Initial commit
This commit is contained in:
commit
f67c9141fc
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
server/dist
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
.aider*
|
||||||
|
|
||||||
|
src/lib/taylordb.types.ts
|
||||||
762
AGENTS.md
Normal file
762
AGENTS.md
Normal file
|
|
@ -0,0 +1,762 @@
|
||||||
|
# AI Agent Instructions for TaylorDB Full-Stack Template
|
||||||
|
|
||||||
|
This template is designed for AI agents to build **modern, type-safe, full-stack applications** using TaylorDB as the data source. This template provides a complete monorepo setup with a React frontend (Vite + shadcn/ui) and a Node.js backend (tRPC + TaylorDB query builder).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Your Mission
|
||||||
|
|
||||||
|
Build production-ready, modern web applications (primarily dashboards and CRUD interfaces) that:
|
||||||
|
|
||||||
|
- Are **fully type-safe** from database to UI
|
||||||
|
- Use TaylorDB as the single source of truth for data
|
||||||
|
- Have **stunning, modern UI/UX** that wows users
|
||||||
|
- Follow best practices in architecture and code organization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Development Workflow
|
||||||
|
|
||||||
|
### **Phase 1: Understand Requirements & Design**
|
||||||
|
|
||||||
|
#### Step 1: Gather Requirements
|
||||||
|
|
||||||
|
Ask the user clarifying questions about:
|
||||||
|
|
||||||
|
- What data they want to work with (understand the domain)
|
||||||
|
- Key features and user workflows
|
||||||
|
- Target users and use cases
|
||||||
|
- Any specific UI/UX preferences
|
||||||
|
|
||||||
|
#### Step 2: Understand the Database Schema
|
||||||
|
|
||||||
|
**CRITICAL**: Always start by reading these files to understand the data model:
|
||||||
|
|
||||||
|
- `apps/server/taylordb/types.ts` - TypeScript types generated from TaylorDB schema
|
||||||
|
- `apps/server/taylordb/query-builder.ts` - Query builder patterns (review for examples)
|
||||||
|
|
||||||
|
> ⚠️ **IMPORTANT**: If these files don't exist, STOP and ask the user to generate them first. Never proceed with mock data.
|
||||||
|
|
||||||
|
#### Step 3: Design the Color Scheme & Visual Identity
|
||||||
|
|
||||||
|
Based on the requirements, decide on:
|
||||||
|
|
||||||
|
- **Primary color scheme** (use HSL values for flexibility)
|
||||||
|
- **Design aesthetic** (modern glassmorphism, gradients, minimalism, etc.)
|
||||||
|
- **Typography** (Google Fonts like Inter, Outfit, or Manrope)
|
||||||
|
- **Animation style** (subtle micro-interactions vs. bold animations)
|
||||||
|
|
||||||
|
Document your design decisions briefly before implementing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **Phase 2: Build the Foundation**
|
||||||
|
|
||||||
|
#### Step 1: Set Up Server-Side Data Layer
|
||||||
|
|
||||||
|
**File: `apps/server/taylordb/query-builder.ts`**
|
||||||
|
|
||||||
|
This file contains all database operations. Create type-safe CRUD functions for each table:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createQueryBuilder } from "@taylordb/query-builder";
|
||||||
|
import type { TaylorDatabase } from "./types.js";
|
||||||
|
|
||||||
|
export const queryBuilder = createQueryBuilder<TaylorDatabase>({
|
||||||
|
baseUrl: process.env.TAYLORDB_BASE_URL!,
|
||||||
|
baseId: process.env.TAYLORDB_SERVER_ID!,
|
||||||
|
apiKey: process.env.TAYLORDB_API_TOKEN!,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// READ Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all records from a table
|
||||||
|
*/
|
||||||
|
export async function getAllItems() {
|
||||||
|
return await queryBuilder
|
||||||
|
.selectFrom("items")
|
||||||
|
.select(["id", "name", "status", "createdAt"])
|
||||||
|
.orderBy("createdAt", "desc")
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single record by ID
|
||||||
|
*/
|
||||||
|
export async function getItemById(id: number) {
|
||||||
|
return await queryBuilder
|
||||||
|
.selectFrom("items")
|
||||||
|
.where("id", "=", id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CREATE Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function createItem(data: { name: string; status: string }) {
|
||||||
|
return await queryBuilder.insertInto("items").values(data).executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UPDATE Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function updateItem(
|
||||||
|
id: number,
|
||||||
|
data: { name?: string; status?: string },
|
||||||
|
) {
|
||||||
|
return await queryBuilder
|
||||||
|
.update("items")
|
||||||
|
.set(data)
|
||||||
|
.where("id", "=", id)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DELETE Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function deleteItem(id: number) {
|
||||||
|
return await queryBuilder.deleteFrom("items").where("id", "=", id).execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Builder Patterns:**
|
||||||
|
|
||||||
|
- **Filtering**: `.where("field", "=", value)`, `.where("date", ">=", ["exactDay", "2024-01-01"])`
|
||||||
|
- **Select specific fields**: `.select(["id", "name", "status"])`
|
||||||
|
- **Ordering**: `.orderBy("createdAt", "desc")`
|
||||||
|
- **Single record**: `.executeTakeFirst()`
|
||||||
|
- **Multiple records**: `.execute()`
|
||||||
|
- **Array fields**: Use `[value]` for single-select enums
|
||||||
|
- **Date filters**: Use `["exactDay", date]` format
|
||||||
|
|
||||||
|
Organize functions by domain (e.g., all user-related functions together).
|
||||||
|
|
||||||
|
#### Step 2: Create tRPC API Router
|
||||||
|
|
||||||
|
**File: `apps/server/router.ts`**
|
||||||
|
|
||||||
|
Expose your database functions as type-safe tRPC procedures:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { z } from "zod";
|
||||||
|
import { router, publicProcedure } from "./trpc";
|
||||||
|
import * as db from "./taylordb/query-builder";
|
||||||
|
|
||||||
|
export const appRouter = router({
|
||||||
|
// Group by domain/feature
|
||||||
|
items: {
|
||||||
|
getAll: publicProcedure.query(async () => {
|
||||||
|
return await db.getAllItems();
|
||||||
|
}),
|
||||||
|
|
||||||
|
getById: publicProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
return await db.getItemById(input.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
create: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
status: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
return await db.createItem(input);
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const { id, ...data } = input;
|
||||||
|
return await db.updateItem(id, data);
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: publicProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
return await db.deleteItem(input.id);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AppRouter = typeof appRouter;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Organization:**
|
||||||
|
|
||||||
|
- Group related procedures (e.g., `items`, `users`, `projects`)
|
||||||
|
- Use Zod for input validation
|
||||||
|
- Queries for reads, mutations for writes
|
||||||
|
- Export `AppRouter` type for frontend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **Phase 3: Build the Frontend**
|
||||||
|
|
||||||
|
#### Step 1: Update Design System
|
||||||
|
|
||||||
|
**File: `apps/client/src/index.css`**
|
||||||
|
|
||||||
|
Update the design tokens based on your chosen color scheme:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
/* Update these HSL values for your color scheme */
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 262 83% 58%; /* Example: Purple */
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--accent: 262 90% 95%;
|
||||||
|
--accent-foreground: 262 83% 58%;
|
||||||
|
/* ... customize all tokens ... */
|
||||||
|
--radius: 0.75rem; /* Border radius */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
/* Dark mode colors */
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--primary: 263 70% 65%;
|
||||||
|
/* ... */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: Create shadcn/ui Components
|
||||||
|
|
||||||
|
**Always use shadcn/ui components**. Install components as needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dlx shadcn@latest add <component-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available by default:**
|
||||||
|
|
||||||
|
- `button`, `card`, `input`, `label`, `textarea`, `select`, `tabs`, `alert`
|
||||||
|
|
||||||
|
**Common additions for dashboards:**
|
||||||
|
|
||||||
|
- `table`, `dialog`, `dropdown-menu`, `toast`, `sheet`, `form`, `badge`, `avatar`, `skeleton`
|
||||||
|
|
||||||
|
Install with: `pnpm dlx shadcn@latest add table dialog dropdown-menu toast sheet form badge avatar skeleton`
|
||||||
|
|
||||||
|
#### Step 3: Build Page Components
|
||||||
|
|
||||||
|
**File: `apps/client/src/pages/DashboardPage.tsx`**
|
||||||
|
|
||||||
|
Create feature-rich, modern pages:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { PlusIcon, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
|
const { data: items, isLoading, refetch } = trpc.items.getAll.useQuery();
|
||||||
|
|
||||||
|
const createMutation = trpc.items.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
refetch();
|
||||||
|
setName("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = trpc.items.delete.useMutation({
|
||||||
|
onSuccess: () => refetch(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (name) {
|
||||||
|
createMutation.mutate({ name, status: "active" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-8 max-w-6xl">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold mb-2">Dashboard</h1>
|
||||||
|
<p className="text-muted-foreground">Manage your items</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{/* Create Form */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Add New Item</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create a new item in your database
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="flex gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="name">Item Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Enter item name..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="mt-auto"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Adding...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PlusIcon className="mr-2 h-4 w-4" />
|
||||||
|
Add Item
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Items List */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Your Items</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items && items.length === 0 && (
|
||||||
|
<p className="text-center text-muted-foreground py-8">
|
||||||
|
No items yet. Create your first item above.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items?.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-center justify-between p-4 border rounded-lg hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{item.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{item.status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
item.id && deleteMutation.mutate({ id: item.id })
|
||||||
|
}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 4: Update Routing
|
||||||
|
|
||||||
|
**File: `apps/client/src/main.tsx`**
|
||||||
|
|
||||||
|
Add your new pages to the router:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
import HomePage from "./pages/HomePage";
|
||||||
|
import DashboardPage from "./pages/DashboardPage";
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <App />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <HomePage /> },
|
||||||
|
{ path: "dashboard", element: <DashboardPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**File: `apps/client/src/App.tsx`**
|
||||||
|
|
||||||
|
Update navigation:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const navItems = [
|
||||||
|
{ to: "/", label: "Home" },
|
||||||
|
{ to: "/dashboard", label: "Dashboard" },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **Phase 4: Polish & Validate**
|
||||||
|
|
||||||
|
#### Step 1: Run Type Checking
|
||||||
|
|
||||||
|
**ALWAYS run this before considering your work complete:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix all TypeScript errors. Never use `any` unless absolutely necessary.
|
||||||
|
|
||||||
|
#### Step 2: Run Linter
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix all linting errors.
|
||||||
|
|
||||||
|
#### Step 3: Test in Browser
|
||||||
|
|
||||||
|
The dev server should be running. Test:
|
||||||
|
|
||||||
|
- All CRUD operations work correctly
|
||||||
|
- Data updates in real-time
|
||||||
|
- Error states display properly
|
||||||
|
- Loading states show appropriate feedback
|
||||||
|
- UI is responsive and looks great
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Design Guidelines
|
||||||
|
|
||||||
|
### Visual Excellence Principles
|
||||||
|
|
||||||
|
1. **No Generic Colors**: Never use plain red/blue/green. Use curated HSL palettes.
|
||||||
|
- ✅ `hsl(262 83% 58%)` (vibrant purple)
|
||||||
|
- ❌ `#0000ff` (plain blue)
|
||||||
|
|
||||||
|
2. **Premium Aesthetics**: Make it feel high-end
|
||||||
|
- Use subtle gradients, shadows, and glassmorphism
|
||||||
|
- Add smooth transitions (`transition-all duration-200`)
|
||||||
|
- Implement hover states on interactive elements
|
||||||
|
- Use proper spacing and visual hierarchy
|
||||||
|
|
||||||
|
3. **Modern Typography**:
|
||||||
|
- Import Google Fonts (e.g., Inter, Outfit, Manrope)
|
||||||
|
- Use varied font weights (400, 500, 600, 700)
|
||||||
|
- Proper text sizing hierarchy
|
||||||
|
|
||||||
|
4. **Micro-Animations**:
|
||||||
|
- Loading spinners with `lucide-react` icons + `animate-spin`
|
||||||
|
- Fade-ins on data load
|
||||||
|
- Smooth transitions on hover
|
||||||
|
- Button press feedback
|
||||||
|
|
||||||
|
5. **Dashboard-First Design**:
|
||||||
|
- Card-based layouts
|
||||||
|
- Clear visual grouping
|
||||||
|
- Stats/metrics prominently displayed
|
||||||
|
- Intuitive navigation
|
||||||
|
|
||||||
|
### Component Structure
|
||||||
|
|
||||||
|
**Always follow this pattern:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Imports (external, then internal)
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
|
||||||
|
// 2. Type definitions (if needed)
|
||||||
|
interface ItemFormProps {
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Component (arrow function)
|
||||||
|
export default function ItemForm({ onSuccess }: ItemFormProps) {
|
||||||
|
// 4. State & hooks
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const createMutation = trpc.items.create.useMutation({ onSuccess });
|
||||||
|
|
||||||
|
// 5. Event handlers
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
createMutation.mutate({ name });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 6. Render
|
||||||
|
return <form onSubmit={handleSubmit}>{/* ... */}</form>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 File Organization Best Practices
|
||||||
|
|
||||||
|
### Backend Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/server/
|
||||||
|
├── taylordb/
|
||||||
|
│ ├── types.ts # Generated types (DO NOT EDIT)
|
||||||
|
│ └── query-builder.ts # All database operations
|
||||||
|
├── router.ts # tRPC API routes
|
||||||
|
├── trpc.ts # tRPC configuration
|
||||||
|
└── index.ts # Server entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/client/src/
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # shadcn/ui components (auto-generated)
|
||||||
|
│ └── [custom]/ # Your custom components
|
||||||
|
├── pages/
|
||||||
|
│ └── [PageName].tsx # Route pages
|
||||||
|
├── lib/
|
||||||
|
│ ├── trpc.ts # tRPC client setup
|
||||||
|
│ └── utils.ts # Utilities (cn helper, etc.)
|
||||||
|
├── App.tsx # Layout + navigation
|
||||||
|
├── main.tsx # Router + app initialization
|
||||||
|
└── index.css # Global styles + design tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
### Where to Put What
|
||||||
|
|
||||||
|
| What | Where |
|
||||||
|
| ---------------------- | -------------------------------------------- |
|
||||||
|
| Database queries | `apps/server/taylordb/query-builder.ts` |
|
||||||
|
| API endpoints | `apps/server/router.ts` |
|
||||||
|
| Route pages | `apps/client/src/pages/` |
|
||||||
|
| Reusable UI components | `apps/client/src/components/` |
|
||||||
|
| shadcn/ui components | `apps/client/src/components/ui/` (auto) |
|
||||||
|
| Design tokens | `apps/client/src/index.css` |
|
||||||
|
| TypeScript types | Use generated types from `taylordb/types.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 TaylorDB Query Builder Reference
|
||||||
|
|
||||||
|
### Common Query Patterns
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// SELECT with specific fields
|
||||||
|
queryBuilder
|
||||||
|
.selectFrom("tableName")
|
||||||
|
.select(["id", "name", "status"])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// WHERE conditions
|
||||||
|
.where("status", "=", "active")
|
||||||
|
.where("createdAt", ">=", ["exactDay", "2024-01-01"])
|
||||||
|
.where("tags", "hasAnyOf", ["tag1", "tag2"])
|
||||||
|
|
||||||
|
// ORDER BY
|
||||||
|
.orderBy("createdAt", "desc")
|
||||||
|
.orderBy("name", "asc")
|
||||||
|
|
||||||
|
// INSERT
|
||||||
|
queryBuilder
|
||||||
|
.insertInto("tableName")
|
||||||
|
.values({ name: "John", status: "active" })
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
// UPDATE
|
||||||
|
queryBuilder
|
||||||
|
.update("tableName")
|
||||||
|
.set({ status: "inactive" })
|
||||||
|
.where("id", "=", 123)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
queryBuilder
|
||||||
|
.deleteFrom("tableName")
|
||||||
|
.where("id", "=", 123)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// DELETE multiple
|
||||||
|
queryBuilder
|
||||||
|
.deleteFrom("tableName")
|
||||||
|
.where("id", "hasAnyOf", [1, 2, 3])
|
||||||
|
.execute();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Type Handling
|
||||||
|
|
||||||
|
| TaylorDB Field Type | TypeScript Type | Query Value Format |
|
||||||
|
| ------------------- | --------------- | ---------------------------- |
|
||||||
|
| Text | `string` | `"value"` |
|
||||||
|
| Number | `number` | `42` |
|
||||||
|
| Date | `string` | `["exactDay", "2024-01-01"]` |
|
||||||
|
| Single Select | `string[]` | `["option"]` |
|
||||||
|
| Multi Select | `string[]` | `["opt1", "opt2"]` |
|
||||||
|
| Checkbox | `boolean` | `true` / `false` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Code Style Guidelines
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
|
||||||
|
- **Never use `any`**. Use proper types from `taylordb/types.ts`
|
||||||
|
- Strict null checks: handle `null` and `undefined` explicitly
|
||||||
|
- Use type inference where obvious, explicit types for function params/returns
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
- **Variables/Functions**: `camelCase` (e.g., `getUserData`)
|
||||||
|
- **Components**: `PascalCase` (e.g., `DashboardPage`)
|
||||||
|
- **Constants**: `UPPER_CASE` (e.g., `MAX_ITEMS`)
|
||||||
|
- **Files**: Match component name (e.g., `DashboardPage.tsx`)
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
|
||||||
|
- Group external imports first, then internal
|
||||||
|
- Use path aliases: `@/components/...` not `../../components/...`
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
- Use arrow functions: `const MyComponent = () => { ... }`
|
||||||
|
- Props typing: `interface MyComponentProps { ... }`
|
||||||
|
- Keep components focused (single responsibility)
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Display error states in UI
|
||||||
|
- Use tRPC's built-in error handling
|
||||||
|
- Show user-friendly messages
|
||||||
|
|
||||||
|
### Comments
|
||||||
|
|
||||||
|
- Use JSDoc for functions: `/** Description */`
|
||||||
|
- Explain "why", not "what"
|
||||||
|
- Remove commented-out code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎓 Example: Building a Task Manager Dashboard
|
||||||
|
|
||||||
|
**User Request**: "Build a task manager with projects and tasks"
|
||||||
|
|
||||||
|
### 1. Analyze Schema
|
||||||
|
|
||||||
|
Assume TaylorDB has:
|
||||||
|
|
||||||
|
- `projects` table: `id`, `name`, `description`, `status`
|
||||||
|
- `tasks` table: `id`, `title`, `projectId`, `status`, `dueDate`
|
||||||
|
|
||||||
|
### 2. Design Decision
|
||||||
|
|
||||||
|
- **Color**: Gradient purple/blue theme
|
||||||
|
- **Style**: Modern with glassmorphism cards
|
||||||
|
- **Layout**: Projects on left sidebar, tasks on right
|
||||||
|
|
||||||
|
### 3. Backend (`apps/server/taylordb/query-builder.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function getAllProjects() {
|
||||||
|
return await queryBuilder
|
||||||
|
.selectFrom("projects")
|
||||||
|
.select(["id", "name", "description", "status"])
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTasksByProject(projectId: number) {
|
||||||
|
return await queryBuilder
|
||||||
|
.selectFrom("tasks")
|
||||||
|
.where("projectId", "=", projectId)
|
||||||
|
.orderBy("dueDate", "asc")
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. API (`apps/server/router.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const appRouter = router({
|
||||||
|
projects: {
|
||||||
|
getAll: publicProcedure.query(() => db.getAllProjects()),
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
getByProject: publicProcedure
|
||||||
|
.input(z.object({ projectId: z.number() }))
|
||||||
|
.query(({ input }) => db.getTasksByProject(input.projectId)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Frontend (`apps/client/src/pages/TasksPage.tsx`)
|
||||||
|
|
||||||
|
Build the UI with cards, proper loading states, and type-safe tRPC calls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Critical Rules
|
||||||
|
|
||||||
|
1. **NEVER use mock data.** Always connect to real TaylorDB.
|
||||||
|
2. **NEVER ignore TypeScript errors.** Fix them before moving on.
|
||||||
|
3. **ALWAYS use shadcn/ui components** instead of hand-rolling UI.
|
||||||
|
4. **NEVER modify generated types** in `taylordb/types.ts`.
|
||||||
|
5. **ALWAYS run `pnpm build`** to validate your work.
|
||||||
|
6. **Design must be modern and premium**, not basic MVP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Success Criteria
|
||||||
|
|
||||||
|
Your implementation is successful when:
|
||||||
|
|
||||||
|
- ✅ All TypeScript errors are resolved (`pnpm build` passes)
|
||||||
|
- ✅ All lint errors are fixed (`pnpm lint` passes)
|
||||||
|
- ✅ UI looks modern and premium (not basic/generic)
|
||||||
|
- ✅ All CRUD operations work correctly with TaylorDB
|
||||||
|
- ✅ Loading and error states are handled gracefully
|
||||||
|
- ✅ Code is well-organized and follows best practices
|
||||||
|
- ✅ Type safety is maintained from database to UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Remember**: You're building production-quality applications that should impress users from the first glance. Focus on visual excellence, type safety, and solid architecture.
|
||||||
224
README.md
Normal file
224
README.md
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
# TaylorDB Full-Stack Template
|
||||||
|
|
||||||
|
A production-ready template for building modern, type-safe web applications with TaylorDB. Designed for AI-assisted development with comprehensive documentation and best practices built-in.
|
||||||
|
|
||||||
|
## 🎯 What This Template Provides
|
||||||
|
|
||||||
|
- **Full-Stack Setup**: React frontend + Node.js backend in a monorepo
|
||||||
|
- **Type Safety**: End-to-end TypeScript from database to UI
|
||||||
|
- **Modern UI**: shadcn/ui components with Tailwind CSS
|
||||||
|
- **Type-Safe API**: tRPC for seamless client-server communication
|
||||||
|
- **TaylorDB Integration**: Query builder with generated types
|
||||||
|
- **AI-Ready**: Comprehensive documentation for AI-assisted development
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
taylordb-fullstack-template/
|
||||||
|
├── apps/
|
||||||
|
│ ├── client/ # React frontend (Vite)
|
||||||
|
│ │ ├── src/
|
||||||
|
│ │ │ ├── components/ui/ # shadcn/ui components
|
||||||
|
│ │ │ ├── pages/ # Route pages
|
||||||
|
│ │ │ ├── lib/ # Utilities & tRPC client
|
||||||
|
│ │ │ └── index.css # Design tokens
|
||||||
|
│ │ └── package.json
|
||||||
|
│ │
|
||||||
|
│ └── server/ # Node.js backend
|
||||||
|
│ ├── taylordb/
|
||||||
|
│ │ ├── types.ts # Generated schema types
|
||||||
|
│ │ └── query-builder.ts # Database operations
|
||||||
|
│ ├── router.ts # tRPC API routes
|
||||||
|
│ └── package.json
|
||||||
|
│
|
||||||
|
├── docs/ # Comprehensive guides
|
||||||
|
│ ├── TAYLORDB_QUERY_REFERENCE.md
|
||||||
|
│ └── SHADCN_COMPONENTS_GUIDE.md
|
||||||
|
│
|
||||||
|
├── AGENTS.md # AI agent instructions
|
||||||
|
└── taylordb.yml # Deployment config
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
This template includes comprehensive documentation for both human and AI developers:
|
||||||
|
|
||||||
|
### For AI Agents
|
||||||
|
|
||||||
|
- **[AGENTS.md](./AGENTS.md)**: Complete AI agent instructions
|
||||||
|
- Development workflow (Planning → Execution → Verification)
|
||||||
|
- Design guidelines for modern UIs
|
||||||
|
- Code organization best practices
|
||||||
|
- Type safety patterns
|
||||||
|
- Example implementations
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
|
||||||
|
- **[docs/TAYLORDB_QUERY_REFERENCE.md](./docs/TAYLORDB_QUERY_REFERENCE.md)**: Query builder reference
|
||||||
|
|
||||||
|
- All CRUD operations with examples
|
||||||
|
- Field type handling
|
||||||
|
- Advanced patterns (aggregations, pagination)
|
||||||
|
- Common pitfalls and solutions
|
||||||
|
|
||||||
|
- **[docs/SHADCN_COMPONENTS_GUIDE.md](./docs/SHADCN_COMPONENTS_GUIDE.md)**: UI component guide
|
||||||
|
- Dashboard patterns
|
||||||
|
- Form examples
|
||||||
|
- Data tables
|
||||||
|
- Responsive design tips
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Tech Stack
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- **React 19** with TypeScript
|
||||||
|
- **Vite 7** for fast builds
|
||||||
|
- **TailwindCSS 4** for styling
|
||||||
|
- **shadcn/ui** for UI components
|
||||||
|
- **React Router v6** for routing
|
||||||
|
- **tRPC React Query** for API calls
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- **Node.js** with TypeScript
|
||||||
|
- **Express 5** web server
|
||||||
|
- **tRPC 11** for type-safe APIs
|
||||||
|
- **Zod** for validation
|
||||||
|
- **TaylorDB Query Builder** for database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Key Features
|
||||||
|
|
||||||
|
### ✅ Full Type Safety
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Backend defines the API
|
||||||
|
export const appRouter = router({
|
||||||
|
users: {
|
||||||
|
getById: publicProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.query(async ({ input }) => { ... }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Frontend gets full autocomplete
|
||||||
|
const { data: user } = trpc.users.getById.useQuery({ id: 1 });
|
||||||
|
// ^? User | undefined (fully typed!)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ Modern UI Components
|
||||||
|
|
||||||
|
All components from shadcn/ui with:
|
||||||
|
|
||||||
|
- Full dark mode support
|
||||||
|
- Responsive design
|
||||||
|
- Accessible by default
|
||||||
|
- Customizable with Tailwind
|
||||||
|
|
||||||
|
### ✅ Database Integration
|
||||||
|
|
||||||
|
Type-safe queries with TaylorDB:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Auto-generated types from your schema
|
||||||
|
export async function getAllUsers() {
|
||||||
|
return await queryBuilder
|
||||||
|
.selectFrom("users")
|
||||||
|
.select(["id", "name", "email"])
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤖 AI-Assisted Development
|
||||||
|
|
||||||
|
This template is optimized for AI-assisted development:
|
||||||
|
|
||||||
|
1. **Read AGENTS.md**: Comprehensive instructions for AI agents
|
||||||
|
2. **Follow the workflow**: Planning → Execution → Verification
|
||||||
|
3. **Use type safety**: All examples use strict TypeScript
|
||||||
|
4. **Reference docs**: Query patterns, component examples, best practices
|
||||||
|
|
||||||
|
The AI agent will:
|
||||||
|
|
||||||
|
- Understand your TaylorDB schema
|
||||||
|
- Design appropriate color schemes
|
||||||
|
- Build type-safe CRUD operations
|
||||||
|
- Create modern, responsive UIs
|
||||||
|
- Follow best practices automatically
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚢 Deployment
|
||||||
|
|
||||||
|
This template is designed to deploy to TaylorDB's platform using the included `taylordb.yml` configuration.
|
||||||
|
|
||||||
|
**Environment Variables Required:**
|
||||||
|
|
||||||
|
- `TAYLORDB_BASE_URL`
|
||||||
|
- `TAYLORDB_API_TOKEN`
|
||||||
|
- `TAYLORDB_SERVER_ID`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 Usage Examples
|
||||||
|
|
||||||
|
### 1. Add a New Feature
|
||||||
|
|
||||||
|
1. Create database functions in `apps/server/taylordb/query-builder.ts`
|
||||||
|
2. Expose via tRPC in `apps/server/router.ts`
|
||||||
|
3. Build UI in `apps/client/src/pages/`
|
||||||
|
4. Add route in `apps/client/src/main.tsx`
|
||||||
|
|
||||||
|
### 2. Add shadcn/ui Component
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dlx shadcn@latest add <component-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dlx shadcn@latest add table dialog toast
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Customize Design
|
||||||
|
|
||||||
|
Edit `apps/client/src/index.css` to change colors, fonts, and spacing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Resources
|
||||||
|
|
||||||
|
- **shadcn/ui**: https://ui.shadcn.com/
|
||||||
|
- **tRPC**: https://trpc.io/
|
||||||
|
- **TaylorDB**: https://taylordb.ai/
|
||||||
|
- **Tailwind CSS**: https://tailwindcss.com/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT - Use freely for any project!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Built for modern, type-safe full-stack development with AI assistance** ✨
|
||||||
17
apps/client/components.json
Normal file
17
apps/client/components.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "src/components",
|
||||||
|
"utils": "@/lib/utils"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
13
apps/client/index.html
Normal file
13
apps/client/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>blank</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
54
apps/client/package.json
Normal file
54
apps/client/package.json
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
{
|
||||||
|
"name": "@repo/client",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.10",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@tanstack/react-query": "^5.90.16",
|
||||||
|
"@trpc/client": "^11.8.1",
|
||||||
|
"@trpc/react-query": "^11.8.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"lucide-react": "^0.561.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^6.28.1",
|
||||||
|
"recharts": "^3.5.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zod": "^4.3.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@repo/server": "workspace:*",
|
||||||
|
"@taylordb/query-builder": "^0.10.3",
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.2",
|
||||||
|
"@types/react-dom": "^19.2.2",
|
||||||
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.3",
|
||||||
|
"vite": "^7.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/client/public/vite.svg
Normal file
1
apps/client/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
129
apps/client/src/App.tsx
Normal file
129
apps/client/src/App.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { Moon, Sun, Database, Palette } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
const themes = [
|
||||||
|
{ id: "purple", name: "Purple", color: "hsl(270 80% 60%)" },
|
||||||
|
{ id: "ocean", name: "Ocean", color: "hsl(210 90% 55%)" },
|
||||||
|
{ id: "sunset", name: "Sunset", color: "hsl(25 95% 55%)" },
|
||||||
|
{ id: "forest", name: "Forest", color: "hsl(145 70% 40%)" },
|
||||||
|
{ id: "rose", name: "Rose", color: "hsl(350 80% 60%)" },
|
||||||
|
{ id: "slate", name: "Slate", color: "hsl(220 15% 35%)" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type ThemeId = (typeof themes)[number]["id"];
|
||||||
|
type Mode = "light" | "dark";
|
||||||
|
|
||||||
|
const getInitialTheme = (): ThemeId => {
|
||||||
|
if (typeof window === "undefined") return "purple";
|
||||||
|
const stored = localStorage.getItem("color-theme");
|
||||||
|
if (themes.some((t) => t.id === stored)) return stored as ThemeId;
|
||||||
|
return "purple";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitialMode = (): Mode => {
|
||||||
|
if (typeof window === "undefined") return "light";
|
||||||
|
const stored = localStorage.getItem("mode");
|
||||||
|
if (stored === "light" || stored === "dark") return stored;
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
};
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [theme, setTheme] = useState<ThemeId>(getInitialTheme);
|
||||||
|
const [mode, setMode] = useState<Mode>(getInitialMode);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
// Remove existing theme classes
|
||||||
|
themes.forEach((t) => root.classList.remove(`theme-${t.id}`));
|
||||||
|
|
||||||
|
// Add current theme class
|
||||||
|
root.classList.add(`theme-${theme}`);
|
||||||
|
root.classList.toggle("dark", mode === "dark");
|
||||||
|
|
||||||
|
localStorage.setItem("color-theme", theme);
|
||||||
|
localStorage.setItem("mode", mode);
|
||||||
|
}, [theme, mode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
<header className="sticky top-0 z-50 glass-card border-b border-border/50">
|
||||||
|
<div className="container flex h-16 items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-gradient-to-br from-primary to-accent">
|
||||||
|
<Database className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold gradient-text">TaylorDB</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Theme Picker */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full"
|
||||||
|
aria-label="Choose theme"
|
||||||
|
>
|
||||||
|
<Palette className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
|
{themes.map((t) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setTheme(t.id)}
|
||||||
|
className="flex items-center gap-3 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: t.color }}
|
||||||
|
/>
|
||||||
|
<span className={theme === t.id ? "font-medium" : ""}>
|
||||||
|
{t.name}
|
||||||
|
</span>
|
||||||
|
{theme === t.id && (
|
||||||
|
<span className="ml-auto text-xs text-primary">✓</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Dark/Light Mode Toggle */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle dark mode"
|
||||||
|
className="rounded-full"
|
||||||
|
onClick={() => setMode((m) => (m === "dark" ? "light" : "dark"))}
|
||||||
|
>
|
||||||
|
{mode === "dark" ? (
|
||||||
|
<Sun className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Moon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
1
apps/client/src/assets/react.svg
Normal file
1
apps/client/src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
11
apps/client/src/components/demo/Avatar.tsx
Normal file
11
apps/client/src/components/demo/Avatar.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
interface AvatarProps {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({ name }: AvatarProps) {
|
||||||
|
return (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-primary to-accent flex items-center justify-center text-white text-sm font-medium">
|
||||||
|
{name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
apps/client/src/components/demo/CodePreview.tsx
Normal file
14
apps/client/src/components/demo/CodePreview.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
interface CodePreviewProps {
|
||||||
|
data: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodePreview({ data }: CodePreviewProps) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-primary/5 via-secondary/5 to-accent/5 rounded-lg" />
|
||||||
|
<pre className="relative p-4 rounded-lg text-sm font-mono overflow-x-auto">
|
||||||
|
{JSON.stringify(data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/client/src/components/demo/DemoCard.tsx
Normal file
43
apps/client/src/components/demo/DemoCard.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface DemoCardProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
iconColorClass?: string;
|
||||||
|
glowClass?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DemoCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
iconColorClass = "bg-primary/10 text-primary",
|
||||||
|
glowClass = "glow-primary",
|
||||||
|
children,
|
||||||
|
}: DemoCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className="card-hover glass-card overflow-hidden">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${iconColorClass} ${glowClass}`}>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl">{title}</CardTitle>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">{children}</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
apps/client/src/components/demo/EmptyState.tsx
Normal file
7
apps/client/src/components/demo/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
interface EmptyStateProps {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ message }: EmptyStateProps) {
|
||||||
|
return <p className="text-center text-muted-foreground py-4">{message}</p>;
|
||||||
|
}
|
||||||
14
apps/client/src/components/demo/ItemRow.tsx
Normal file
14
apps/client/src/components/demo/ItemRow.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
interface ItemRowProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemRow({ children, className = "" }: ItemRowProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`item-row flex items-center gap-3 p-4 rounded-lg bg-muted/30 border border-border/50 ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/client/src/components/demo/LoadingSpinner.tsx
Normal file
31
apps/client/src/components/demo/LoadingSpinner.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
colorClass?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: "h-4 w-4",
|
||||||
|
md: "h-6 w-6",
|
||||||
|
lg: "h-8 w-8",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoadingSpinner({
|
||||||
|
size = "lg",
|
||||||
|
colorClass = "text-primary",
|
||||||
|
className = "",
|
||||||
|
}: LoadingSpinnerProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex justify-center py-8 ${className}`}>
|
||||||
|
<Loader2
|
||||||
|
className={`animate-spin pulse-glow ${sizeClasses[size]} ${colorClass}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineSpinner({ size = "sm" }: { size?: "sm" | "md" }) {
|
||||||
|
return <Loader2 className={`animate-spin ${sizeClasses[size]}`} />;
|
||||||
|
}
|
||||||
20
apps/client/src/components/demo/StatusBadge.tsx
Normal file
20
apps/client/src/components/demo/StatusBadge.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: "success" | "warning" | "info";
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeClasses = {
|
||||||
|
success: "badge-success",
|
||||||
|
warning: "badge-warning",
|
||||||
|
info: "bg-blue-500/10 text-blue-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ status, children }: StatusBadgeProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full font-medium ${badgeClasses[status]}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/client/src/components/demo/examples/HelloExample.tsx
Normal file
48
apps/client/src/components/demo/examples/HelloExample.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { Sparkles, Zap } from "lucide-react";
|
||||||
|
import { DemoCard, InlineSpinner, CodePreview } from "@/components/demo";
|
||||||
|
|
||||||
|
export function HelloExample() {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const { data, isLoading, refetch } = trpc.hello.useQuery(
|
||||||
|
{ name: name || undefined },
|
||||||
|
{ enabled: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DemoCard
|
||||||
|
title="Hello Query"
|
||||||
|
description="Simple query to test the connection"
|
||||||
|
icon={Zap}
|
||||||
|
iconColorClass="bg-primary/10 text-primary"
|
||||||
|
glowClass="glow-primary"
|
||||||
|
>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Enter your name..."
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="min-w-[100px]"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<InlineSpinner />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles className="h-4 w-4 mr-2" />
|
||||||
|
Send
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{data && <CodePreview data={data} />}
|
||||||
|
</DemoCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
apps/client/src/components/demo/examples/PostsExample.tsx
Normal file
209
apps/client/src/components/demo/examples/PostsExample.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { FileText, Plus, Sparkles, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DemoCard,
|
||||||
|
LoadingSpinner,
|
||||||
|
InlineSpinner,
|
||||||
|
EmptyState,
|
||||||
|
StatusBadge,
|
||||||
|
} from "@/components/demo";
|
||||||
|
|
||||||
|
interface Post {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
content: string | null;
|
||||||
|
published: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostsExample() {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [authorId, setAuthorId] = useState("1");
|
||||||
|
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const { data: posts, isLoading } = trpc.posts.getAll.useQuery();
|
||||||
|
|
||||||
|
const createMutation = trpc.posts.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.posts.getAll.invalidate();
|
||||||
|
setTitle("");
|
||||||
|
setContent("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const publishMutation = trpc.posts.publish.useMutation({
|
||||||
|
onSuccess: () => utils.posts.getAll.invalidate(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = trpc.posts.delete.useMutation({
|
||||||
|
onSuccess: () => utils.posts.getAll.invalidate(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
createMutation.mutate({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
authorId: parseInt(authorId),
|
||||||
|
published: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DemoCard
|
||||||
|
title="Posts"
|
||||||
|
description="With publish action and filtering"
|
||||||
|
icon={FileText}
|
||||||
|
iconColorClass="bg-accent/10 text-accent"
|
||||||
|
glowClass="glow-accent"
|
||||||
|
>
|
||||||
|
{/* Create Form */}
|
||||||
|
<CreatePostForm
|
||||||
|
title={title}
|
||||||
|
content={content}
|
||||||
|
authorId={authorId}
|
||||||
|
onTitleChange={setTitle}
|
||||||
|
onContentChange={setContent}
|
||||||
|
onAuthorIdChange={setAuthorId}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
isLoading={createMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Posts List */}
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingSpinner colorClass="text-accent" />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{posts?.length === 0 && (
|
||||||
|
<EmptyState message="No posts yet. Create your first post!" />
|
||||||
|
)}
|
||||||
|
{posts?.map((post) => (
|
||||||
|
<PostCard
|
||||||
|
key={post.id}
|
||||||
|
post={post}
|
||||||
|
onPublish={() => publishMutation.mutate({ id: post.id })}
|
||||||
|
onDelete={() => deleteMutation.mutate({ id: post.id })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DemoCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sub-components
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface CreatePostFormProps {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
authorId: string;
|
||||||
|
onTitleChange: (value: string) => void;
|
||||||
|
onContentChange: (value: string) => void;
|
||||||
|
onAuthorIdChange: (value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreatePostForm({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
authorId,
|
||||||
|
onTitleChange,
|
||||||
|
onContentChange,
|
||||||
|
onAuthorIdChange,
|
||||||
|
onSubmit,
|
||||||
|
isLoading,
|
||||||
|
}: CreatePostFormProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => onTitleChange(e.target.value)}
|
||||||
|
placeholder="Post title..."
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<div className="w-28">
|
||||||
|
<Label className="text-xs text-muted-foreground mb-1 block">
|
||||||
|
Author ID
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={authorId}
|
||||||
|
onChange={(e) => onAuthorIdChange(e.target.value)}
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Input
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => onContentChange(e.target.value)}
|
||||||
|
placeholder="Write something amazing..."
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button onClick={onSubmit} disabled={!title || !content || isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<InlineSpinner />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Create
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PostCardProps {
|
||||||
|
post: Post;
|
||||||
|
onPublish: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostCard({ post, onPublish, onDelete }: PostCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="item-row p-4 rounded-lg bg-muted/30 border border-border/50">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-semibold truncate">{post.title}</h3>
|
||||||
|
<StatusBadge status={post.published ? "success" : "warning"}>
|
||||||
|
{post.published ? "Published" : "Draft"}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{post.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{!post.published && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-secondary border-secondary/30 hover:bg-secondary/10"
|
||||||
|
onClick={onPublish}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3 w-3 mr-1" />
|
||||||
|
Publish
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
apps/client/src/components/demo/examples/UsersExample.tsx
Normal file
241
apps/client/src/components/demo/examples/UsersExample.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { Users, Plus, Edit2, Check, X, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DemoCard,
|
||||||
|
LoadingSpinner,
|
||||||
|
InlineSpinner,
|
||||||
|
EmptyState,
|
||||||
|
ItemRow,
|
||||||
|
Avatar,
|
||||||
|
} from "@/components/demo";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersExample() {
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [newEmail, setNewEmail] = useState("");
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editName, setEditName] = useState("");
|
||||||
|
const [editEmail, setEditEmail] = useState("");
|
||||||
|
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const { data: users, isLoading } = trpc.users.getAll.useQuery();
|
||||||
|
|
||||||
|
const createMutation = trpc.users.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.users.getAll.invalidate();
|
||||||
|
setNewName("");
|
||||||
|
setNewEmail("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = trpc.users.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.users.getAll.invalidate();
|
||||||
|
setEditingId(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = trpc.users.delete.useMutation({
|
||||||
|
onSuccess: () => utils.users.getAll.invalidate(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const startEditing = (user: User) => {
|
||||||
|
setEditingId(user.id);
|
||||||
|
setEditName(user.name);
|
||||||
|
setEditEmail(user.email);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
createMutation.mutate({ name: newName, email: newEmail });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = (id: number) => {
|
||||||
|
updateMutation.mutate({ id, name: editName, email: editEmail });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DemoCard
|
||||||
|
title="Users"
|
||||||
|
description="Full CRUD operations example"
|
||||||
|
icon={Users}
|
||||||
|
iconColorClass="bg-secondary/10 text-secondary"
|
||||||
|
glowClass="glow-secondary"
|
||||||
|
>
|
||||||
|
{/* Create Form */}
|
||||||
|
<CreateUserForm
|
||||||
|
name={newName}
|
||||||
|
email={newEmail}
|
||||||
|
onNameChange={setNewName}
|
||||||
|
onEmailChange={setNewEmail}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
isLoading={createMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Users List */}
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingSpinner colorClass="text-secondary" />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{users?.length === 0 && (
|
||||||
|
<EmptyState message="No users yet. Create one above!" />
|
||||||
|
)}
|
||||||
|
{users?.map((user) => (
|
||||||
|
<UserRow
|
||||||
|
key={user.id}
|
||||||
|
user={user}
|
||||||
|
isEditing={editingId === user.id}
|
||||||
|
editName={editName}
|
||||||
|
editEmail={editEmail}
|
||||||
|
onEditNameChange={setEditName}
|
||||||
|
onEditEmailChange={setEditEmail}
|
||||||
|
onStartEdit={() => startEditing(user)}
|
||||||
|
onCancelEdit={() => setEditingId(null)}
|
||||||
|
onSaveEdit={() => handleUpdate(user.id)}
|
||||||
|
onDelete={() => deleteMutation.mutate({ id: user.id })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DemoCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sub-components
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface CreateUserFormProps {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
onNameChange: (value: string) => void;
|
||||||
|
onEmailChange: (value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateUserForm({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
onNameChange,
|
||||||
|
onEmailChange,
|
||||||
|
onSubmit,
|
||||||
|
isLoading,
|
||||||
|
}: CreateUserFormProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => onNameChange(e.target.value)}
|
||||||
|
placeholder="Name"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => onEmailChange(e.target.value)}
|
||||||
|
placeholder="Email"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={!name || !email || isLoading}
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{isLoading ? <InlineSpinner /> : <Plus className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserRowProps {
|
||||||
|
user: User;
|
||||||
|
isEditing: boolean;
|
||||||
|
editName: string;
|
||||||
|
editEmail: string;
|
||||||
|
onEditNameChange: (value: string) => void;
|
||||||
|
onEditEmailChange: (value: string) => void;
|
||||||
|
onStartEdit: () => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onSaveEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserRow({
|
||||||
|
user,
|
||||||
|
isEditing,
|
||||||
|
editName,
|
||||||
|
editEmail,
|
||||||
|
onEditNameChange,
|
||||||
|
onEditEmailChange,
|
||||||
|
onStartEdit,
|
||||||
|
onCancelEdit,
|
||||||
|
onSaveEdit,
|
||||||
|
onDelete,
|
||||||
|
}: UserRowProps) {
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<ItemRow>
|
||||||
|
<Input
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => onEditNameChange(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={editEmail}
|
||||||
|
onChange={(e) => onEditEmailChange(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-green-500 hover:text-green-600 hover:bg-green-500/10"
|
||||||
|
onClick={onSaveEdit}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={onCancelEdit}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</ItemRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemRow>
|
||||||
|
<Avatar name={user.name} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{user.name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-muted-foreground hover:text-primary"
|
||||||
|
onClick={onStartEdit}
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</ItemRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
apps/client/src/components/demo/examples/index.ts
Normal file
3
apps/client/src/components/demo/examples/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { HelloExample } from "./HelloExample";
|
||||||
|
export { UsersExample } from "./UsersExample";
|
||||||
|
export { PostsExample } from "./PostsExample";
|
||||||
7
apps/client/src/components/demo/index.ts
Normal file
7
apps/client/src/components/demo/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export { DemoCard } from "./DemoCard";
|
||||||
|
export { LoadingSpinner, InlineSpinner } from "./LoadingSpinner";
|
||||||
|
export { EmptyState } from "./EmptyState";
|
||||||
|
export { ItemRow } from "./ItemRow";
|
||||||
|
export { Avatar } from "./Avatar";
|
||||||
|
export { StatusBadge } from "./StatusBadge";
|
||||||
|
export { CodePreview } from "./CodePreview";
|
||||||
59
apps/client/src/components/ui/alert.tsx
Normal file
59
apps/client/src/components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Alert.displayName = "Alert"
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertTitle.displayName = "AlertTitle"
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDescription.displayName = "AlertDescription"
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
57
apps/client/src/components/ui/button.tsx
Normal file
57
apps/client/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
79
apps/client/src/components/ui/card.tsx
Normal file
79
apps/client/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
198
apps/client/src/components/ui/dropdown-menu.tsx
Normal file
198
apps/client/src/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
};
|
||||||
22
apps/client/src/components/ui/input.tsx
Normal file
22
apps/client/src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
24
apps/client/src/components/ui/label.tsx
Normal file
24
apps/client/src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
158
apps/client/src/components/ui/select.tsx
Normal file
158
apps/client/src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
||||||
55
apps/client/src/components/ui/tabs.tsx
Normal file
55
apps/client/src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
22
apps/client/src/components/ui/textarea.tsx
Normal file
22
apps/client/src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.ComponentProps<"textarea">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
465
apps/client/src/index.css
Normal file
465
apps/client/src/index.css
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-background: hsl(var(--background));
|
||||||
|
--color-foreground: hsl(var(--foreground));
|
||||||
|
--color-card: hsl(var(--card));
|
||||||
|
--color-card-foreground: hsl(var(--card-foreground));
|
||||||
|
--color-popover: hsl(var(--popover));
|
||||||
|
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||||
|
--color-primary: hsl(var(--primary));
|
||||||
|
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||||
|
--color-secondary: hsl(var(--secondary));
|
||||||
|
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||||
|
--color-muted: hsl(var(--muted));
|
||||||
|
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||||
|
--color-accent: hsl(var(--accent));
|
||||||
|
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||||
|
--color-destructive: hsl(var(--destructive));
|
||||||
|
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||||
|
--color-border: hsl(var(--border));
|
||||||
|
--color-input: hsl(var(--input));
|
||||||
|
--color-ring: hsl(var(--ring));
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||||
|
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
/* ========================================================================
|
||||||
|
THEME: Purple (Default) - Vibrant purple with teal & pink accents
|
||||||
|
======================================================================== */
|
||||||
|
:root,
|
||||||
|
.theme-purple {
|
||||||
|
--background: 270 50% 98%;
|
||||||
|
--foreground: 270 50% 10%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 270 50% 10%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 270 50% 10%;
|
||||||
|
--primary: 270 80% 60%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 175 60% 45%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--muted: 270 30% 94%;
|
||||||
|
--muted-foreground: 270 20% 45%;
|
||||||
|
--accent: 330 80% 65%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 75% 55%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 270 30% 88%;
|
||||||
|
--input: 270 30% 88%;
|
||||||
|
--ring: 270 80% 60%;
|
||||||
|
--radius: 1rem;
|
||||||
|
--theme-gradient: linear-gradient(135deg, hsl(270 80% 60%) 0%, hsl(330 85% 60%) 50%, hsl(175 70% 50%) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-purple,
|
||||||
|
.dark:not([class*="theme-"]) {
|
||||||
|
--background: 270 40% 8%;
|
||||||
|
--foreground: 270 20% 95%;
|
||||||
|
--card: 270 40% 12%;
|
||||||
|
--card-foreground: 270 20% 95%;
|
||||||
|
--popover: 270 40% 12%;
|
||||||
|
--popover-foreground: 270 20% 95%;
|
||||||
|
--primary: 270 80% 65%;
|
||||||
|
--primary-foreground: 270 40% 8%;
|
||||||
|
--secondary: 175 70% 50%;
|
||||||
|
--secondary-foreground: 270 40% 8%;
|
||||||
|
--muted: 270 30% 18%;
|
||||||
|
--muted-foreground: 270 15% 65%;
|
||||||
|
--accent: 330 85% 60%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 70% 50%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 270 30% 22%;
|
||||||
|
--input: 270 30% 22%;
|
||||||
|
--ring: 270 80% 65%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================================
|
||||||
|
THEME: Ocean - Deep blues with cyan accents
|
||||||
|
======================================================================== */
|
||||||
|
.theme-ocean {
|
||||||
|
--background: 210 50% 98%;
|
||||||
|
--foreground: 210 50% 10%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 210 50% 10%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 210 50% 10%;
|
||||||
|
--primary: 210 90% 55%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 185 80% 45%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--muted: 210 30% 94%;
|
||||||
|
--muted-foreground: 210 20% 45%;
|
||||||
|
--accent: 195 85% 50%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 75% 55%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 210 30% 88%;
|
||||||
|
--input: 210 30% 88%;
|
||||||
|
--ring: 210 90% 55%;
|
||||||
|
--theme-gradient: linear-gradient(135deg, hsl(210 90% 55%) 0%, hsl(185 80% 45%) 50%, hsl(195 85% 50%) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-ocean {
|
||||||
|
--background: 210 50% 6%;
|
||||||
|
--foreground: 210 20% 95%;
|
||||||
|
--card: 210 45% 10%;
|
||||||
|
--card-foreground: 210 20% 95%;
|
||||||
|
--popover: 210 45% 10%;
|
||||||
|
--popover-foreground: 210 20% 95%;
|
||||||
|
--primary: 210 85% 60%;
|
||||||
|
--primary-foreground: 210 50% 6%;
|
||||||
|
--secondary: 185 75% 50%;
|
||||||
|
--secondary-foreground: 210 50% 6%;
|
||||||
|
--muted: 210 35% 16%;
|
||||||
|
--muted-foreground: 210 15% 65%;
|
||||||
|
--accent: 195 80% 55%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 70% 50%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 210 35% 20%;
|
||||||
|
--input: 210 35% 20%;
|
||||||
|
--ring: 210 85% 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================================
|
||||||
|
THEME: Sunset - Warm oranges and reds
|
||||||
|
======================================================================== */
|
||||||
|
.theme-sunset {
|
||||||
|
--background: 30 50% 98%;
|
||||||
|
--foreground: 20 50% 10%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 20 50% 10%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 20 50% 10%;
|
||||||
|
--primary: 25 95% 55%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 350 80% 55%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--muted: 30 30% 94%;
|
||||||
|
--muted-foreground: 20 20% 45%;
|
||||||
|
--accent: 45 90% 55%;
|
||||||
|
--accent-foreground: 20 50% 10%;
|
||||||
|
--destructive: 0 75% 55%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 30 30% 88%;
|
||||||
|
--input: 30 30% 88%;
|
||||||
|
--ring: 25 95% 55%;
|
||||||
|
--theme-gradient: linear-gradient(135deg, hsl(25 95% 55%) 0%, hsl(350 80% 55%) 50%, hsl(45 90% 55%) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-sunset {
|
||||||
|
--background: 20 40% 7%;
|
||||||
|
--foreground: 30 20% 95%;
|
||||||
|
--card: 20 40% 11%;
|
||||||
|
--card-foreground: 30 20% 95%;
|
||||||
|
--popover: 20 40% 11%;
|
||||||
|
--popover-foreground: 30 20% 95%;
|
||||||
|
--primary: 25 90% 58%;
|
||||||
|
--primary-foreground: 20 40% 7%;
|
||||||
|
--secondary: 350 75% 58%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--muted: 20 30% 16%;
|
||||||
|
--muted-foreground: 30 15% 65%;
|
||||||
|
--accent: 45 85% 58%;
|
||||||
|
--accent-foreground: 20 50% 10%;
|
||||||
|
--destructive: 0 70% 50%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 20 30% 20%;
|
||||||
|
--input: 20 30% 20%;
|
||||||
|
--ring: 25 90% 58%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================================
|
||||||
|
THEME: Forest - Earthy greens with warm accents
|
||||||
|
======================================================================== */
|
||||||
|
.theme-forest {
|
||||||
|
--background: 140 30% 97%;
|
||||||
|
--foreground: 140 40% 10%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 140 40% 10%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 140 40% 10%;
|
||||||
|
--primary: 145 70% 40%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 85 55% 45%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--muted: 140 20% 93%;
|
||||||
|
--muted-foreground: 140 15% 40%;
|
||||||
|
--accent: 35 80% 50%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 75% 55%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 140 20% 87%;
|
||||||
|
--input: 140 20% 87%;
|
||||||
|
--ring: 145 70% 40%;
|
||||||
|
--theme-gradient: linear-gradient(135deg, hsl(145 70% 40%) 0%, hsl(85 55% 45%) 50%, hsl(35 80% 50%) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-forest {
|
||||||
|
--background: 140 35% 7%;
|
||||||
|
--foreground: 140 15% 95%;
|
||||||
|
--card: 140 35% 11%;
|
||||||
|
--card-foreground: 140 15% 95%;
|
||||||
|
--popover: 140 35% 11%;
|
||||||
|
--popover-foreground: 140 15% 95%;
|
||||||
|
--primary: 145 65% 45%;
|
||||||
|
--primary-foreground: 140 35% 7%;
|
||||||
|
--secondary: 85 50% 50%;
|
||||||
|
--secondary-foreground: 140 35% 7%;
|
||||||
|
--muted: 140 25% 16%;
|
||||||
|
--muted-foreground: 140 10% 65%;
|
||||||
|
--accent: 35 75% 55%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 70% 50%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 140 25% 20%;
|
||||||
|
--input: 140 25% 20%;
|
||||||
|
--ring: 145 65% 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================================
|
||||||
|
THEME: Rose - Soft pinks with elegant accents
|
||||||
|
======================================================================== */
|
||||||
|
.theme-rose {
|
||||||
|
--background: 350 50% 98%;
|
||||||
|
--foreground: 350 40% 10%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 350 40% 10%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 350 40% 10%;
|
||||||
|
--primary: 350 80% 60%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 320 70% 55%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--muted: 350 25% 94%;
|
||||||
|
--muted-foreground: 350 20% 45%;
|
||||||
|
--accent: 15 85% 60%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 75% 55%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 350 25% 88%;
|
||||||
|
--input: 350 25% 88%;
|
||||||
|
--ring: 350 80% 60%;
|
||||||
|
--theme-gradient: linear-gradient(135deg, hsl(350 80% 60%) 0%, hsl(320 70% 55%) 50%, hsl(15 85% 60%) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-rose {
|
||||||
|
--background: 350 35% 7%;
|
||||||
|
--foreground: 350 15% 95%;
|
||||||
|
--card: 350 35% 11%;
|
||||||
|
--card-foreground: 350 15% 95%;
|
||||||
|
--popover: 350 35% 11%;
|
||||||
|
--popover-foreground: 350 15% 95%;
|
||||||
|
--primary: 350 75% 62%;
|
||||||
|
--primary-foreground: 350 35% 7%;
|
||||||
|
--secondary: 320 65% 58%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--muted: 350 25% 16%;
|
||||||
|
--muted-foreground: 350 12% 65%;
|
||||||
|
--accent: 15 80% 62%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 70% 50%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 350 25% 20%;
|
||||||
|
--input: 350 25% 20%;
|
||||||
|
--ring: 350 75% 62%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================================
|
||||||
|
THEME: Slate - Minimal and professional
|
||||||
|
======================================================================== */
|
||||||
|
.theme-slate {
|
||||||
|
--background: 0 0% 98%;
|
||||||
|
--foreground: 220 15% 15%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 220 15% 15%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 220 15% 15%;
|
||||||
|
--primary: 220 15% 25%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 220 10% 50%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--muted: 220 10% 94%;
|
||||||
|
--muted-foreground: 220 10% 45%;
|
||||||
|
--accent: 220 15% 35%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 75% 55%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 220 10% 88%;
|
||||||
|
--input: 220 10% 88%;
|
||||||
|
--ring: 220 15% 25%;
|
||||||
|
--theme-gradient: linear-gradient(135deg, hsl(220 15% 25%) 0%, hsl(220 10% 50%) 50%, hsl(220 15% 35%) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-slate {
|
||||||
|
--background: 220 20% 6%;
|
||||||
|
--foreground: 220 10% 95%;
|
||||||
|
--card: 220 18% 10%;
|
||||||
|
--card-foreground: 220 10% 95%;
|
||||||
|
--popover: 220 18% 10%;
|
||||||
|
--popover-foreground: 220 10% 95%;
|
||||||
|
--primary: 220 12% 70%;
|
||||||
|
--primary-foreground: 220 20% 6%;
|
||||||
|
--secondary: 220 10% 55%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--muted: 220 15% 15%;
|
||||||
|
--muted-foreground: 220 8% 60%;
|
||||||
|
--accent: 220 12% 45%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 70% 50%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 220 15% 18%;
|
||||||
|
--input: 220 15% 18%;
|
||||||
|
--ring: 220 12% 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated gradient background - uses theme gradient */
|
||||||
|
.gradient-bg {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
hsl(var(--primary) / 0.08) 0%,
|
||||||
|
hsl(var(--secondary) / 0.08) 50%,
|
||||||
|
hsl(var(--accent) / 0.08) 100%
|
||||||
|
);
|
||||||
|
animation: gradient-shift 8s ease infinite;
|
||||||
|
background-size: 200% 200%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradient-shift {
|
||||||
|
0%, 100% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card hover effects */
|
||||||
|
.card-hover {
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover:hover {
|
||||||
|
box-shadow: 0 10px 20px -12px hsl(var(--primary) / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glassmorphism card */
|
||||||
|
.glass-card {
|
||||||
|
background: hsl(var(--card) / 0.8);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid hsl(var(--border) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient text - uses theme colors */
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--accent)) 50%, hsl(var(--secondary)) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow effects - use theme primary */
|
||||||
|
.glow-primary {
|
||||||
|
box-shadow: 0 0 20px hsl(var(--primary) / 0.3), 0 0 40px hsl(var(--primary) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-secondary {
|
||||||
|
box-shadow: 0 0 20px hsl(var(--secondary) / 0.3), 0 0 40px hsl(var(--secondary) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-accent {
|
||||||
|
box-shadow: 0 0 20px hsl(var(--accent) / 0.3), 0 0 40px hsl(var(--accent) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item row hover */
|
||||||
|
.item-row {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row:hover {
|
||||||
|
background: hsl(var(--muted) / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badges */
|
||||||
|
.badge-success {
|
||||||
|
background: linear-gradient(135deg, hsl(160 80% 40% / 0.2) 0%, hsl(175 70% 50% / 0.2) 100%);
|
||||||
|
color: hsl(160 80% 40%);
|
||||||
|
border: 1px solid hsl(160 80% 40% / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .badge-success {
|
||||||
|
background: linear-gradient(135deg, hsl(160 70% 45% / 0.2) 0%, hsl(175 70% 50% / 0.2) 100%);
|
||||||
|
color: hsl(160 70% 65%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background: linear-gradient(135deg, hsl(45 90% 50% / 0.2) 0%, hsl(35 90% 55% / 0.2) 100%);
|
||||||
|
color: hsl(35 90% 40%);
|
||||||
|
border: 1px solid hsl(45 90% 50% / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .badge-warning {
|
||||||
|
background: linear-gradient(135deg, hsl(45 90% 50% / 0.2) 0%, hsl(35 90% 55% / 0.2) 100%);
|
||||||
|
color: hsl(45 90% 65%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse animation for loader */
|
||||||
|
.pulse-glow {
|
||||||
|
animation: pulse-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%, 100% { opacity: 1; filter: drop-shadow(0 0 8px hsl(var(--primary) / 0.5)); }
|
||||||
|
50% { opacity: 0.7; filter: drop-shadow(0 0 16px hsl(var(--primary) / 0.8)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme picker swatch */
|
||||||
|
.theme-swatch {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-swatch:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-swatch.active {
|
||||||
|
border-color: hsl(var(--foreground));
|
||||||
|
box-shadow: 0 0 0 2px hsl(var(--background)), 0 0 0 4px hsl(var(--foreground) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes accordion-down {
|
||||||
|
from { height: 0; }
|
||||||
|
to { height: var(--radix-accordion-content-height); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes accordion-up {
|
||||||
|
from { height: var(--radix-accordion-content-height); }
|
||||||
|
to { height: 0; }
|
||||||
|
}
|
||||||
35
apps/client/src/lib/trpc.ts
Normal file
35
apps/client/src/lib/trpc.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { createTRPCReact, type CreateTRPCReact } from "@trpc/react-query";
|
||||||
|
import { httpBatchLink, type TRPCClient } from "@trpc/client";
|
||||||
|
import type { AppRouter } from "@repo/server/router";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create tRPC React hooks
|
||||||
|
*/
|
||||||
|
export const trpc: CreateTRPCReact<AppRouter, unknown> =
|
||||||
|
createTRPCReact<AppRouter>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base URL for tRPC requests
|
||||||
|
*/
|
||||||
|
const BASE_URL =
|
||||||
|
(typeof globalThis.process !== "undefined" &&
|
||||||
|
globalThis.process.env?.VITE_TRPC_URL) ||
|
||||||
|
import.meta.env.VITE_TRPC_URL ||
|
||||||
|
"http://localhost:3001/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create tRPC client
|
||||||
|
*/
|
||||||
|
export const trpcClient: TRPCClient<AppRouter> = trpc.createClient({
|
||||||
|
links: [
|
||||||
|
httpBatchLink({
|
||||||
|
url: `${BASE_URL}/trpc`,
|
||||||
|
// Optional: add headers, credentials, etc.
|
||||||
|
// headers() {
|
||||||
|
// return {
|
||||||
|
// authorization: getAuthToken(),
|
||||||
|
// };
|
||||||
|
// },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
7
apps/client/src/lib/utils.ts
Normal file
7
apps/client/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
38
apps/client/src/main.tsx
Normal file
38
apps/client/src/main.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
import TRPCDemoPage from "./pages/TRPCDemoPage";
|
||||||
|
import NotFoundPage from "./pages/NotFoundPage";
|
||||||
|
import { trpc, trpcClient } from "./lib/trpc";
|
||||||
|
|
||||||
|
// Create a React Query client
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 5 * 1000, // 5 seconds
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <App />,
|
||||||
|
children: [{ index: true, element: <TRPCDemoPage /> }],
|
||||||
|
},
|
||||||
|
{ path: "*", element: <NotFoundPage /> },
|
||||||
|
]);
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</trpc.Provider>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
26
apps/client/src/pages/NotFoundPage.tsx
Normal file
26
apps/client/src/pages/NotFoundPage.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const NotFoundPage = () => {
|
||||||
|
return (
|
||||||
|
<section className="space-y-4 text-center">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm uppercase tracking-wide text-muted-foreground">
|
||||||
|
404
|
||||||
|
</p>
|
||||||
|
<h1 className="text-2xl font-semibold">Page not found</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
The page you are looking for does not exist or has moved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/">Go back home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotFoundPage;
|
||||||
41
apps/client/src/pages/TRPCDemoPage.tsx
Normal file
41
apps/client/src/pages/TRPCDemoPage.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { Sparkles } from "lucide-react";
|
||||||
|
import {
|
||||||
|
HelloExample,
|
||||||
|
UsersExample,
|
||||||
|
PostsExample,
|
||||||
|
} from "@/components/demo/examples";
|
||||||
|
|
||||||
|
export default function TRPCDemoPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen gradient-bg">
|
||||||
|
<div className="container mx-auto p-8 max-w-4xl">
|
||||||
|
{/* Hero Header */}
|
||||||
|
<header className="mb-12 text-center">
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium mb-4">
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
Type-safe API Demo
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl font-bold mb-4 gradient-text">
|
||||||
|
tRPC + TaylorDB
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-muted-foreground max-w-md mx-auto">
|
||||||
|
Experience the power of end-to-end type safety with a beautiful,
|
||||||
|
modern interface
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Demo Examples */}
|
||||||
|
<main className="grid gap-8">
|
||||||
|
<HelloExample />
|
||||||
|
<UsersExample />
|
||||||
|
<PostsExample />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="mt-12 text-center text-sm text-muted-foreground">
|
||||||
|
<p>Built with 💜 using tRPC, React Query & TaylorDB</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/client/tsconfig.app.json
Normal file
33
apps/client/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client", "node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": []
|
||||||
|
}
|
||||||
9
apps/client/tsconfig.json
Normal file
9
apps/client/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.app.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
26
apps/client/tsconfig.node.json
Normal file
26
apps/client/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
83
apps/client/vite.config.ts
Normal file
83
apps/client/vite.config.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "node:path";
|
||||||
|
import { PassThrough } from "stream";
|
||||||
|
import { defineConfig, type ViteDevServer } from "vite";
|
||||||
|
|
||||||
|
// A simple connection manager for SSE
|
||||||
|
const clients = new Set<PassThrough>();
|
||||||
|
|
||||||
|
type ConsoleLevel = "log" | "warn" | "error" | "info";
|
||||||
|
|
||||||
|
function sendLog(message: { level: ConsoleLevel; message: string }) {
|
||||||
|
clients.forEach((client) =>
|
||||||
|
client.write(`data: ${JSON.stringify(message)}\n\n`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercept console messages
|
||||||
|
const originalConsole = {
|
||||||
|
log: console.log,
|
||||||
|
warn: console.warn,
|
||||||
|
error: console.error,
|
||||||
|
info: console.info,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.keys(originalConsole).forEach((level) => {
|
||||||
|
console[level as ConsoleLevel] = (
|
||||||
|
...args: Parameters<Console[ConsoleLevel]>
|
||||||
|
) => {
|
||||||
|
sendLog({ level: level as ConsoleLevel, message: args.join(" ") });
|
||||||
|
originalConsole[level as ConsoleLevel](...args);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
tailwindcss(),
|
||||||
|
react(),
|
||||||
|
{
|
||||||
|
name: "sse-plugin",
|
||||||
|
apply: "serve" as const,
|
||||||
|
configureServer(server: ViteDevServer) {
|
||||||
|
server.middlewares.use("/sse-logs", (req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = new PassThrough();
|
||||||
|
clients.add(stream);
|
||||||
|
sendLog({ level: "info", message: "Client connected to SSE logs" });
|
||||||
|
|
||||||
|
stream.pipe(res);
|
||||||
|
|
||||||
|
req.on("close", () => {
|
||||||
|
clients.delete(stream);
|
||||||
|
sendLog({
|
||||||
|
level: "info",
|
||||||
|
message: "Client disconnected from SSE logs",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
allowedHosts: [".develop.taylordb.ai", "localhost", "127.0.0.1"],
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
hmr: {
|
||||||
|
host: "",
|
||||||
|
protocol: "wss",
|
||||||
|
clientPort: 443,
|
||||||
|
path: "/__vite_hmr",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
3
apps/server/.env.example
Normal file
3
apps/server/.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
TAYLORDB_BASE_ID=
|
||||||
|
TAYLORDB_BASE_URL=
|
||||||
|
TAYLORDB_API_KEY=
|
||||||
50
apps/server/index.ts
Normal file
50
apps/server/index.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import * as trpcExpress from "@trpc/server/adapters/express";
|
||||||
|
import { appRouter } from "./router.js";
|
||||||
|
import { createContext } from "./trpc.js";
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// Enable CORS for frontend (adjust origin in production)
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: [
|
||||||
|
process.env.FRONTEND_URL || "http://localhost:5173",
|
||||||
|
"http://localhost:5174",
|
||||||
|
],
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get("/api/health", (_req, res) => {
|
||||||
|
res.json({ status: "ok", timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// tRPC middleware
|
||||||
|
app.use(
|
||||||
|
"/api/trpc",
|
||||||
|
(req, res, next) => {
|
||||||
|
// Handle root path access with a friendly message instead of a tRPC error
|
||||||
|
if (req.path === "/" || req.path === "") {
|
||||||
|
return res.json({
|
||||||
|
message: "TaylorDB tRPC server is running!",
|
||||||
|
health: `http://${req.headers.host}/api/trpc/health`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
trpcExpress.createExpressMiddleware({
|
||||||
|
router: appRouter,
|
||||||
|
createContext,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`🚀 Server running on http://localhost:${PORT}`);
|
||||||
|
console.log(`📡 tRPC endpoint: http://localhost:${PORT}/api/trpc`);
|
||||||
|
});
|
||||||
32
apps/server/package.json
Normal file
32
apps/server/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"name": "@repo/server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"description": "tRPC backend server with Express",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"exports": {
|
||||||
|
"./router": "./router.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch index.ts",
|
||||||
|
"build": "pnpm exec esbuild index.ts --bundle --outfile=dist/index.js --format=esm --platform=node --packages=external",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@taylordb/query-builder": "^0.10.3",
|
||||||
|
"@trpc/server": "^11.8.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"zod": "^4.3.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"esbuild": "^0.27.2",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "~5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
apps/server/router.ts
Normal file
77
apps/server/router.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { router, publicProcedure } from "./trpc";
|
||||||
|
import { usersRouter, postsRouter } from "./routers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main tRPC Router
|
||||||
|
*
|
||||||
|
* This router merges all sub-routers from the routers/ directory.
|
||||||
|
* Each domain (users, posts, etc.) has its own file for better organization.
|
||||||
|
*
|
||||||
|
* To add a new domain:
|
||||||
|
* 1. Create a new file in routers/ (e.g., routers/comments.ts)
|
||||||
|
* 2. Export the router from routers/index.ts
|
||||||
|
* 3. Import and add it below
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const appRouter = router({
|
||||||
|
// ============================================================================
|
||||||
|
// Sub-Routers (organized by domain)
|
||||||
|
// ============================================================================
|
||||||
|
users: usersRouter,
|
||||||
|
posts: postsRouter,
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Global / Utility Procedures
|
||||||
|
// ============================================================================
|
||||||
|
hello: publicProcedure
|
||||||
|
.input(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
)
|
||||||
|
.query(({ input }) => {
|
||||||
|
return {
|
||||||
|
message: `Hello ${input?.name ?? "World"}!`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export type definition of API
|
||||||
|
export type AppRouter = typeof appRouter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* tRPC Quick Reference
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* QUERIES (for reading data):
|
||||||
|
* - Use .query() for operations that don't modify data
|
||||||
|
* - Example: getAll, getById, search, etc.
|
||||||
|
*
|
||||||
|
* MUTATIONS (for writing data):
|
||||||
|
* - Use .mutation() for operations that create, update, or delete data
|
||||||
|
* - Example: create, update, delete
|
||||||
|
*
|
||||||
|
* INPUT VALIDATION:
|
||||||
|
* - Use .input() with Zod schemas to validate input
|
||||||
|
* - Example: .input(z.object({ id: z.number(), name: z.string() }))
|
||||||
|
*
|
||||||
|
* ACCESSING INPUT:
|
||||||
|
* - Access validated input via { input } destructuring
|
||||||
|
* - Example: .query(async ({ input }) => { ... })
|
||||||
|
*
|
||||||
|
* ORGANIZATION:
|
||||||
|
* - Group related procedures under a namespace
|
||||||
|
* - Example: users.getAll, users.create, posts.getAll, etc.
|
||||||
|
*
|
||||||
|
* ERROR HANDLING:
|
||||||
|
* - Throw errors from procedures, tRPC will handle them
|
||||||
|
* - Example: throw new Error("User not found");
|
||||||
|
*
|
||||||
|
* For comprehensive examples, see the example implementation or docs.
|
||||||
|
*/
|
||||||
9
apps/server/routers/index.ts
Normal file
9
apps/server/routers/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/**
|
||||||
|
* Routers Index
|
||||||
|
*
|
||||||
|
* Re-export all sub-routers from a single entry point.
|
||||||
|
* This keeps imports clean in the main router.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { usersRouter } from "./users";
|
||||||
|
export { postsRouter } from "./posts";
|
||||||
123
apps/server/routers/posts.ts
Normal file
123
apps/server/routers/posts.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { router, publicProcedure } from "../trpc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts Router
|
||||||
|
*
|
||||||
|
* Another example sub-router showing a different domain.
|
||||||
|
* Demonstrates relationships (author references users).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// In-memory store for demonstration
|
||||||
|
const posts: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
authorId: number;
|
||||||
|
published: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "Hello World",
|
||||||
|
content: "This is my first post!",
|
||||||
|
authorId: 1,
|
||||||
|
published: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let nextId = 2;
|
||||||
|
|
||||||
|
export const postsRouter = router({
|
||||||
|
getAll: publicProcedure
|
||||||
|
.input(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
published: z.boolean().optional(),
|
||||||
|
authorId: z.number().optional(),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
)
|
||||||
|
.query(({ input }) => {
|
||||||
|
let result = posts;
|
||||||
|
|
||||||
|
if (input?.published !== undefined) {
|
||||||
|
result = result.filter((p) => p.published === input.published);
|
||||||
|
}
|
||||||
|
if (input?.authorId !== undefined) {
|
||||||
|
result = result.filter((p) => p.authorId === input.authorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getById: publicProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.query(({ input }) => {
|
||||||
|
const post = posts.find((p) => p.id === input.id);
|
||||||
|
if (!post) throw new Error("Post not found");
|
||||||
|
return post;
|
||||||
|
}),
|
||||||
|
|
||||||
|
create: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
title: z.string().min(1).max(200),
|
||||||
|
content: z.string().min(1),
|
||||||
|
authorId: z.number(),
|
||||||
|
published: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
const newPost = {
|
||||||
|
id: nextId++,
|
||||||
|
title: input.title,
|
||||||
|
content: input.content,
|
||||||
|
authorId: input.authorId,
|
||||||
|
published: input.published,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
posts.push(newPost);
|
||||||
|
return newPost;
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.number(),
|
||||||
|
title: z.string().min(1).max(200).optional(),
|
||||||
|
content: z.string().min(1).optional(),
|
||||||
|
published: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
const post = posts.find((p) => p.id === input.id);
|
||||||
|
if (!post) throw new Error("Post not found");
|
||||||
|
|
||||||
|
if (input.title) post.title = input.title;
|
||||||
|
if (input.content) post.content = input.content;
|
||||||
|
if (input.published !== undefined) post.published = input.published;
|
||||||
|
return post;
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: publicProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
const index = posts.findIndex((p) => p.id === input.id);
|
||||||
|
if (index === -1) throw new Error("Post not found");
|
||||||
|
|
||||||
|
const [deleted] = posts.splice(index, 1);
|
||||||
|
return deleted;
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Example of a more specific procedure
|
||||||
|
publish: publicProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
const post = posts.find((p) => p.id === input.id);
|
||||||
|
if (!post) throw new Error("Post not found");
|
||||||
|
|
||||||
|
post.published = true;
|
||||||
|
return post;
|
||||||
|
}),
|
||||||
|
});
|
||||||
75
apps/server/routers/users.ts
Normal file
75
apps/server/routers/users.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { router, publicProcedure } from "../trpc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Users Router
|
||||||
|
*
|
||||||
|
* Example sub-router demonstrating CRUD operations.
|
||||||
|
* Replace with your actual taylordb implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// In-memory store for demonstration
|
||||||
|
const users: { id: number; name: string; email: string; createdAt: Date }[] = [
|
||||||
|
{ id: 1, name: "Alice", email: "alice@example.com", createdAt: new Date() },
|
||||||
|
{ id: 2, name: "Bob", email: "bob@example.com", createdAt: new Date() },
|
||||||
|
];
|
||||||
|
let nextId = 3;
|
||||||
|
|
||||||
|
export const usersRouter = router({
|
||||||
|
getAll: publicProcedure.query(() => {
|
||||||
|
return users;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getById: publicProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.query(({ input }) => {
|
||||||
|
const user = users.find((u) => u.id === input.id);
|
||||||
|
if (!user) throw new Error("User not found");
|
||||||
|
return user;
|
||||||
|
}),
|
||||||
|
|
||||||
|
create: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
email: z.string().email(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
const newUser = {
|
||||||
|
id: nextId++,
|
||||||
|
name: input.name,
|
||||||
|
email: input.email,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
users.push(newUser);
|
||||||
|
return newUser;
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
const user = users.find((u) => u.id === input.id);
|
||||||
|
if (!user) throw new Error("User not found");
|
||||||
|
|
||||||
|
if (input.name) user.name = input.name;
|
||||||
|
if (input.email) user.email = input.email;
|
||||||
|
return user;
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: publicProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
const index = users.findIndex((u) => u.id === input.id);
|
||||||
|
if (index === -1) throw new Error("User not found");
|
||||||
|
|
||||||
|
const [deleted] = users.splice(index, 1);
|
||||||
|
return deleted;
|
||||||
|
}),
|
||||||
|
});
|
||||||
319
apps/server/taylordb/query-builder.ts
Normal file
319
apps/server/taylordb/query-builder.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
import { createQueryBuilder } from "@taylordb/query-builder";
|
||||||
|
import type { TaylorDatabase } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TaylorDB Query Builder Instance
|
||||||
|
*
|
||||||
|
* This is the main query builder instance configured with your TaylorDB credentials.
|
||||||
|
* Use this to perform all database operations in a type-safe manner.
|
||||||
|
*/
|
||||||
|
export const queryBuilder = createQueryBuilder<TaylorDatabase>({
|
||||||
|
baseUrl: process.env.TAYLORDB_BASE_URL!,
|
||||||
|
baseId: process.env.TAYLORDB_SERVER_ID!,
|
||||||
|
apiKey: process.env.TAYLORDB_API_TOKEN!,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* Example Query Functions
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Below are example patterns for common database operations.
|
||||||
|
* Replace these with your own functions based on your actual schema.
|
||||||
|
*
|
||||||
|
* For comprehensive examples, see: /docs/TAYLORDB_QUERY_REFERENCE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// READ Operations (Queries)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Get all records from a table
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* export async function getAllUsers() {
|
||||||
|
* return await queryBuilder
|
||||||
|
* .selectFrom("users")
|
||||||
|
* .select(["id", "name", "email", "createdAt"])
|
||||||
|
* .orderBy("createdAt", "desc")
|
||||||
|
* .execute();
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Get a single record by ID
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* export async function getUserById(id: number) {
|
||||||
|
* return await queryBuilder
|
||||||
|
* .selectFrom("users")
|
||||||
|
* .where("id", "=", id)
|
||||||
|
* .executeTakeFirst();
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Get records with filtering
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* export async function getActiveUsers() {
|
||||||
|
* return await queryBuilder
|
||||||
|
* .selectFrom("users")
|
||||||
|
* .where("status", "=", "active")
|
||||||
|
* .orderBy("name", "asc")
|
||||||
|
* .execute();
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Get records with date range filtering
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* export async function getRecordsInDateRange(startDate: string, endDate: string) {
|
||||||
|
* return await queryBuilder
|
||||||
|
* .selectFrom("records")
|
||||||
|
* .where("date", ">=", ["exactDay", startDate])
|
||||||
|
* .where("date", "<=", ["exactDay", endDate])
|
||||||
|
* .orderBy("date", "asc")
|
||||||
|
* .execute();
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CREATE Operations (Insert)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Insert a new record
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* export async function createUser(data: { name: string; email: string }) {
|
||||||
|
* return await queryBuilder
|
||||||
|
* .insertInto("users")
|
||||||
|
* .values({
|
||||||
|
* name: data.name,
|
||||||
|
* email: data.email,
|
||||||
|
* status: "active",
|
||||||
|
* })
|
||||||
|
* .executeTakeFirst();
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Insert with single-select field
|
||||||
|
*
|
||||||
|
* Note: Single-select fields must be wrapped in an array
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* export async function createTask(data: { title: string; priority: "low" | "medium" | "high" }) {
|
||||||
|
* return await queryBuilder
|
||||||
|
* .insertInto("tasks")
|
||||||
|
* .values({
|
||||||
|
* title: data.title,
|
||||||
|
* priority: [data.priority], // Wrap in array for single-select
|
||||||
|
* })
|
||||||
|
* .executeTakeFirst();
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Insert with computed fields
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* export async function createOrder(data: { quantity: number; pricePerUnit: number }) {
|
||||||
|
* const totalPrice = data.quantity * data.pricePerUnit;
|
||||||
|
*
|
||||||
|
* return await queryBuilder
|
||||||
|
* .insertInto("orders")
|
||||||
|
* .values({
|
||||||
|
* quantity: data.quantity,
|
||||||
|
* pricePerUnit: data.pricePerUnit,
|
||||||
|
* totalPrice: totalPrice,
|
||||||
|
* })
|
||||||
|
* .executeTakeFirst();
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UPDATE Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Update a record
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* export async function updateUser(id: number, data: { name?: string; email?: string }) {
|
||||||
|
* return await queryBuilder
|
||||||
|
* .update("users")
|
||||||
|
* .set(data)
|
||||||
|
* .where("id", "=", id)
|
||||||
|
* .execute();
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Update with conditional recalculation
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* export async function updateOrder(id: number, data: { quantity?: number; pricePerUnit?: number }) {
|
||||||
|
* // Fetch current record to compute total
|
||||||
|
* const currentOrder = await queryBuilder
|
||||||
|
* .selectFrom("orders")
|
||||||
|
* .select(["quantity", "pricePerUnit"])
|
||||||
|
* .where("id", "=", id)
|
||||||
|
* .executeTakeFirst();
|
||||||
|
*
|
||||||
|
* if (!currentOrder) {
|
||||||
|
* throw new Error("Order not found");
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* const newQuantity = data.quantity ?? currentOrder.quantity ?? 0;
|
||||||
|
* const newPrice = data.pricePerUnit ?? currentOrder.pricePerUnit ?? 0;
|
||||||
|
* const totalPrice = newQuantity * newPrice;
|
||||||
|
*
|
||||||
|
* return await queryBuilder
|
||||||
|
* .update("orders")
|
||||||
|
* .set({
|
||||||
|
* ...data,
|
||||||
|
* totalPrice,
|
||||||
|
* })
|
||||||
|
* .where("id", "=", id)
|
||||||
|
* .execute();
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DELETE Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Delete a single record
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* export async function deleteUser(id: number) {
|
||||||
|
* return await queryBuilder
|
||||||
|
* .deleteFrom("users")
|
||||||
|
* .where("id", "=", id)
|
||||||
|
* .execute();
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Delete multiple records by IDs
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* export async function deleteUsers(ids: number[]) {
|
||||||
|
* return await queryBuilder
|
||||||
|
* .deleteFrom("users")
|
||||||
|
* .where("id", "hasAnyOf", ids)
|
||||||
|
* .execute();
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Delete with condition
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* export async function deleteInactiveUsers() {
|
||||||
|
* return await queryBuilder
|
||||||
|
* .deleteFrom("users")
|
||||||
|
* .where("status", "=", "inactive")
|
||||||
|
* .execute();
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AGGREGATION Operations (Manual)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Calculate statistics
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* export async function getUserStats() {
|
||||||
|
* const users = await queryBuilder
|
||||||
|
* .selectFrom("users")
|
||||||
|
* .select(["age"])
|
||||||
|
* .execute();
|
||||||
|
*
|
||||||
|
* if (users.length === 0) {
|
||||||
|
* return { count: 0, average: null, min: null, max: null };
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* const ages = users.map(u => u.age).filter((a): a is number => a !== undefined);
|
||||||
|
*
|
||||||
|
* return {
|
||||||
|
* count: ages.length,
|
||||||
|
* average: ages.reduce((a, b) => a + b, 0) / ages.length,
|
||||||
|
* min: Math.min(...ages),
|
||||||
|
* max: Math.max(...ages),
|
||||||
|
* };
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Sum totals for a date
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* export async function getTotalSalesForDate(date: string) {
|
||||||
|
* const sales = await queryBuilder
|
||||||
|
* .selectFrom("sales")
|
||||||
|
* .select(["amount", "quantity"])
|
||||||
|
* .where("date", "=", ["exactDay", date])
|
||||||
|
* .execute();
|
||||||
|
*
|
||||||
|
* return {
|
||||||
|
* totalAmount: sales.reduce((sum, s) => sum + (s.amount ?? 0), 0),
|
||||||
|
* totalQuantity: sales.reduce((sum, s) => sum + (s.quantity ?? 0), 0),
|
||||||
|
* };
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* Query Builder Quick Reference
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* SELECT:
|
||||||
|
* - .selectFrom("tableName")
|
||||||
|
* - .select(["field1", "field2"])
|
||||||
|
* - .execute() // Returns array
|
||||||
|
* - .executeTakeFirst() // Returns single record or undefined
|
||||||
|
*
|
||||||
|
* WHERE:
|
||||||
|
* - .where("field", "=", value)
|
||||||
|
* - .where("field", ">", value)
|
||||||
|
* - .where("field", "hasAnyOf", [value1, value2])
|
||||||
|
* - .where("date", ">=", ["exactDay", "2024-01-01"])
|
||||||
|
*
|
||||||
|
* ORDER BY:
|
||||||
|
* - .orderBy("field", "asc")
|
||||||
|
* - .orderBy("field", "desc")
|
||||||
|
*
|
||||||
|
* INSERT:
|
||||||
|
* - .insertInto("tableName")
|
||||||
|
* - .values({ field1: value1, field2: value2 })
|
||||||
|
* - .executeTakeFirst()
|
||||||
|
*
|
||||||
|
* UPDATE:
|
||||||
|
* - .update("tableName")
|
||||||
|
* - .set({ field1: value1 })
|
||||||
|
* - .where("id", "=", id)
|
||||||
|
* - .execute()
|
||||||
|
*
|
||||||
|
* DELETE:
|
||||||
|
* - .deleteFrom("tableName")
|
||||||
|
* - .where("id", "=", id)
|
||||||
|
* - .execute()
|
||||||
|
*
|
||||||
|
* Field Types:
|
||||||
|
* - Text: string
|
||||||
|
* - Number: number
|
||||||
|
* - Date: ["exactDay", "YYYY-MM-DD"]
|
||||||
|
* - Single Select: ["option"]
|
||||||
|
* - Multi Select: ["opt1", "opt2"]
|
||||||
|
* - Boolean: true/false
|
||||||
|
*
|
||||||
|
* For comprehensive examples, see: /docs/TAYLORDB_QUERY_REFERENCE.md
|
||||||
|
*/
|
||||||
465
apps/server/taylordb/types.ts
Normal file
465
apps/server/taylordb/types.ts
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2025 TaylorDB
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface FileInformation {
|
||||||
|
fieldname: string;
|
||||||
|
originalname: string;
|
||||||
|
encoding: string;
|
||||||
|
mimetype: string;
|
||||||
|
destination: string;
|
||||||
|
filename: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
format: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadResponse {
|
||||||
|
collectionName: string;
|
||||||
|
fileInformation: FileInformation;
|
||||||
|
metadata: {
|
||||||
|
thumbnails: any[];
|
||||||
|
clips: any[];
|
||||||
|
};
|
||||||
|
baseId: string;
|
||||||
|
storageAdaptor: string;
|
||||||
|
_id: string;
|
||||||
|
__v: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentColumnValue {
|
||||||
|
url: string;
|
||||||
|
fileType: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Attachment {
|
||||||
|
public readonly collectionName: string;
|
||||||
|
public readonly fileInformation: FileInformation;
|
||||||
|
public readonly metadata: { thumbnails: any[]; clips: any[] };
|
||||||
|
public readonly baseId: string;
|
||||||
|
public readonly storageAdaptor: string;
|
||||||
|
public readonly _id: string;
|
||||||
|
|
||||||
|
constructor(data: UploadResponse) {
|
||||||
|
this.collectionName = data.collectionName;
|
||||||
|
this.fileInformation = data.fileInformation;
|
||||||
|
this.metadata = data.metadata;
|
||||||
|
this.baseId = data.baseId;
|
||||||
|
this.storageAdaptor = data.storageAdaptor;
|
||||||
|
this._id = data._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
toColumnValue(): AttachmentColumnValue {
|
||||||
|
return {
|
||||||
|
url: this.fileInformation.path,
|
||||||
|
fileType: this.fileInformation.mimetype,
|
||||||
|
size: this.fileInformation.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type IsWithinOperatorValue =
|
||||||
|
| 'pastWeek'
|
||||||
|
| 'pastMonth'
|
||||||
|
| 'pastYear'
|
||||||
|
| 'nextWeek'
|
||||||
|
| 'nextMonth'
|
||||||
|
| 'nextYear'
|
||||||
|
| 'daysFromNow'
|
||||||
|
| 'daysAgo'
|
||||||
|
| 'currentWeek'
|
||||||
|
| 'currentMonth'
|
||||||
|
| 'currentYear';
|
||||||
|
|
||||||
|
type DefaultDateFilterValue =
|
||||||
|
| (
|
||||||
|
| 'today'
|
||||||
|
| 'tomorrow'
|
||||||
|
| 'yesterday'
|
||||||
|
| 'oneWeekAgo'
|
||||||
|
| 'oneWeekFromNow'
|
||||||
|
| 'oneMonthAgo'
|
||||||
|
| 'oneMonthFromNow'
|
||||||
|
)
|
||||||
|
| ['exactDay' | 'exactTimestamp', string]
|
||||||
|
| ['daysAgo' | 'daysFromNow', number];
|
||||||
|
|
||||||
|
type DateFilters = {
|
||||||
|
'=': DefaultDateFilterValue;
|
||||||
|
'!=': DefaultDateFilterValue;
|
||||||
|
'<': DefaultDateFilterValue;
|
||||||
|
'>': DefaultDateFilterValue;
|
||||||
|
'<=': DefaultDateFilterValue;
|
||||||
|
'>=': DefaultDateFilterValue;
|
||||||
|
isWithIn:
|
||||||
|
| IsWithinOperatorValue
|
||||||
|
| { value: 'daysAgo' | 'daysFromNow'; date: number };
|
||||||
|
isEmpty: boolean;
|
||||||
|
isNotEmpty: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DateAggregations = {
|
||||||
|
empty: number;
|
||||||
|
filled: number;
|
||||||
|
unique: number;
|
||||||
|
percentEmpty: number;
|
||||||
|
percentFilled: number;
|
||||||
|
percentUnique: number;
|
||||||
|
min: number | null;
|
||||||
|
max: number | null;
|
||||||
|
daysRange: number | null;
|
||||||
|
monthRange: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TextFilters = {
|
||||||
|
'=': string;
|
||||||
|
'!=': string;
|
||||||
|
caseEqual: string;
|
||||||
|
hasAnyOf: string[];
|
||||||
|
contains: string;
|
||||||
|
startsWith: string;
|
||||||
|
endsWith: string;
|
||||||
|
doesNotContain: string;
|
||||||
|
isEmpty: never;
|
||||||
|
isNotEmpty: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LinkFilters = {
|
||||||
|
hasAnyOf: number[];
|
||||||
|
hasAllOf: number[];
|
||||||
|
isExactly: number[];
|
||||||
|
'=': number;
|
||||||
|
hasNoneOf: number[];
|
||||||
|
contains: string;
|
||||||
|
doesNotContain: string;
|
||||||
|
isEmpty: never;
|
||||||
|
isNotEmpty: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SelectFilters<O extends readonly string[]> = {
|
||||||
|
hasAnyOf: O[number][];
|
||||||
|
hasAllOf: O[number][];
|
||||||
|
isExactly: O[number][];
|
||||||
|
'=': O[number];
|
||||||
|
hasNoneOf: O[number][];
|
||||||
|
contains: string;
|
||||||
|
doesNotContain: string;
|
||||||
|
isEmpty: never;
|
||||||
|
isNotEmpty: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LinkAggregations = {
|
||||||
|
empty: number;
|
||||||
|
filled: number;
|
||||||
|
percentEmpty: number;
|
||||||
|
percentFilled: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NumberFilters = {
|
||||||
|
'=': number;
|
||||||
|
'!=': number;
|
||||||
|
'>': number;
|
||||||
|
'>=': number;
|
||||||
|
'<': number;
|
||||||
|
'<=': number;
|
||||||
|
hasAnyOf: number[];
|
||||||
|
hasNoneOf: number[];
|
||||||
|
isEmpty: never;
|
||||||
|
isNotEmpty: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NumberAggregations = {
|
||||||
|
sum: number;
|
||||||
|
average: number;
|
||||||
|
median: number;
|
||||||
|
min: number | null;
|
||||||
|
max: number | null;
|
||||||
|
range: number;
|
||||||
|
standardDeviation: number;
|
||||||
|
histogram: Record<string, number>;
|
||||||
|
empty: number;
|
||||||
|
filled: number;
|
||||||
|
unique: number;
|
||||||
|
percentEmpty: number;
|
||||||
|
percentFilled: number;
|
||||||
|
percentUnique: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CheckboxFilters = {
|
||||||
|
'=': number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* Column types
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type ColumnType<
|
||||||
|
S,
|
||||||
|
U,
|
||||||
|
I,
|
||||||
|
R extends boolean,
|
||||||
|
F extends { [key: string]: any } = object,
|
||||||
|
A extends { [key: string]: any } = object,
|
||||||
|
> = {
|
||||||
|
raw: S;
|
||||||
|
insert: I;
|
||||||
|
update: U;
|
||||||
|
filters: F;
|
||||||
|
aggregations: A;
|
||||||
|
isRequired: R;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DateColumnType<R extends boolean> = ColumnType<
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
R,
|
||||||
|
DateFilters,
|
||||||
|
DateAggregations
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type TextColumnType<R extends boolean> = ColumnType<
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
R,
|
||||||
|
TextFilters
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ALinkColumnType<
|
||||||
|
T extends string,
|
||||||
|
S,
|
||||||
|
U,
|
||||||
|
I,
|
||||||
|
R extends boolean,
|
||||||
|
F extends { [key: string]: any } = LinkFilters,
|
||||||
|
A extends LinkAggregations = LinkAggregations,
|
||||||
|
> = ColumnType<S, U, I, R, F, A> & {
|
||||||
|
linkedTo: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LinkColumnType<
|
||||||
|
T extends string,
|
||||||
|
R extends boolean,
|
||||||
|
> = ALinkColumnType<
|
||||||
|
T,
|
||||||
|
object,
|
||||||
|
number[] | { newIds: number[]; deletedIds: number[] },
|
||||||
|
number[],
|
||||||
|
R
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type AttachmentColumnType<R extends boolean> = ALinkColumnType<
|
||||||
|
'attachmentTable',
|
||||||
|
Attachment[],
|
||||||
|
Attachment[] | { newIds: number[]; deletedIds: number[] } | number[],
|
||||||
|
Attachment[] | number[],
|
||||||
|
R
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type SingleSelectColumnType<
|
||||||
|
O extends readonly string[],
|
||||||
|
R extends boolean,
|
||||||
|
> = ColumnType<O[number][], O[number][], O[number][], R, SelectFilters<O>>;
|
||||||
|
|
||||||
|
export type NumberColumnType<R extends boolean> = ColumnType<
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
R,
|
||||||
|
NumberFilters,
|
||||||
|
NumberAggregations
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type CheckboxColumnType<R extends boolean> = ColumnType<
|
||||||
|
boolean,
|
||||||
|
boolean,
|
||||||
|
boolean,
|
||||||
|
R,
|
||||||
|
CheckboxFilters
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type AutoGeneratedNumberColumnType = ColumnType<
|
||||||
|
number,
|
||||||
|
never,
|
||||||
|
never,
|
||||||
|
false,
|
||||||
|
NumberFilters,
|
||||||
|
NumberAggregations
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type AutoGeneratedDateColumnType = ColumnType<
|
||||||
|
string,
|
||||||
|
never,
|
||||||
|
never,
|
||||||
|
false,
|
||||||
|
DateFilters,
|
||||||
|
DateAggregations
|
||||||
|
>;
|
||||||
|
|
||||||
|
|
||||||
|
export type TableRaws<T extends keyof TaylorDatabase> = {
|
||||||
|
[K in keyof TaylorDatabase[T]]: TaylorDatabase[T][K] extends ColumnType<
|
||||||
|
infer S,
|
||||||
|
any,
|
||||||
|
any,
|
||||||
|
infer R,
|
||||||
|
any,
|
||||||
|
any
|
||||||
|
>
|
||||||
|
? R extends true
|
||||||
|
? S
|
||||||
|
: S | undefined
|
||||||
|
: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TableInserts<T extends keyof TaylorDatabase> = {
|
||||||
|
[K in keyof TaylorDatabase[T]]: TaylorDatabase[T][K] extends ColumnType<
|
||||||
|
any,
|
||||||
|
infer I,
|
||||||
|
any,
|
||||||
|
infer R,
|
||||||
|
any,
|
||||||
|
any
|
||||||
|
>
|
||||||
|
? R extends true
|
||||||
|
? I
|
||||||
|
: I | undefined
|
||||||
|
: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TableUpdates<T extends keyof TaylorDatabase> = {
|
||||||
|
[K in keyof TaylorDatabase[T]]: TaylorDatabase[T][K] extends ColumnType<
|
||||||
|
any,
|
||||||
|
any,
|
||||||
|
infer U,
|
||||||
|
any,
|
||||||
|
any,
|
||||||
|
any
|
||||||
|
>
|
||||||
|
? U
|
||||||
|
: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SelectTable = {
|
||||||
|
id: AutoGeneratedNumberColumnType;
|
||||||
|
name: TextColumnType<true>;
|
||||||
|
color: TextColumnType<true>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AttachmentTable = {
|
||||||
|
id: AutoGeneratedNumberColumnType;
|
||||||
|
name: TextColumnType<true>;
|
||||||
|
metadata: TextColumnType<true>;
|
||||||
|
size: NumberColumnType<true>;
|
||||||
|
fileType: TextColumnType<true>;
|
||||||
|
url: TextColumnType<true>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CollaboratorsTable = {
|
||||||
|
id: AutoGeneratedNumberColumnType;
|
||||||
|
name: TextColumnType<true>;
|
||||||
|
emailAddress: TextColumnType<true>;
|
||||||
|
avatar: TextColumnType<true>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaylorDatabase = {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Internal tables, these tables can not be queried directly.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
selectTable: SelectTable;
|
||||||
|
attachmentTable: AttachmentTable;
|
||||||
|
collaboratorsTable: CollaboratorsTable;
|
||||||
|
calories: CaloriesTable;
|
||||||
|
strength: StrengthTable;
|
||||||
|
cardio: CardioTable;
|
||||||
|
weight: WeightTable;
|
||||||
|
goals: GoalsTable;
|
||||||
|
settings: SettingsTable;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CaloriesTimeOfDayOptions = ['Breakfast', 'Lunch', 'Dinner', 'Supper', 'Snack'] as const;
|
||||||
|
export const CaloriesUnitOptions = ['1 tsp = 5 mL ≈ 0.17 fl oz', '1 tbsp = 15 mL ≈ 0.5 fl oz', '1 cup = 240 mL = 8 fl oz', '1 fl oz ≈ 29.57 mL', '1 mL ≈ 0.034 fl oz', '1 L ≈ 33.814 fl oz', '1 g ≈ 0.035 oz', '1 oz ≈ 28.35 g', '1 kg ≈ 2.205 lb', '1 lb ≈ 453.59 g'] as const;
|
||||||
|
|
||||||
|
type CaloriesTable = {
|
||||||
|
id: NumberColumnType<false>;
|
||||||
|
createdAt: AutoGeneratedDateColumnType;
|
||||||
|
updatedAt: AutoGeneratedDateColumnType;
|
||||||
|
date: DateColumnType<false>;
|
||||||
|
timeOfDay: SingleSelectColumnType<typeof CaloriesTimeOfDayOptions, false>;
|
||||||
|
proteinPer100G: NumberColumnType<false>;
|
||||||
|
carbsPer100G: NumberColumnType<false>;
|
||||||
|
fatsPer100G: NumberColumnType<false>;
|
||||||
|
totalCalories: NumberColumnType<false>;
|
||||||
|
totalCarbs: NumberColumnType<false>;
|
||||||
|
totalFats: NumberColumnType<false>;
|
||||||
|
totalProtein: NumberColumnType<false>;
|
||||||
|
mealName: TextColumnType<false>;
|
||||||
|
name: TextColumnType<false>;
|
||||||
|
mealIngredient: TextColumnType<false>;
|
||||||
|
quantity: NumberColumnType<false>;
|
||||||
|
unit: SingleSelectColumnType<typeof CaloriesUnitOptions, false>;
|
||||||
|
quantityInGramsmL: NumberColumnType<false>;
|
||||||
|
quantityInFlOzozlb: NumberColumnType<false>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StrengthExerciseOptions = ['Push-ups', 'Pull-ups', 'Pistol Squat', 'Deadlifts', 'Bench Press', 'Sit-ups', 'Lunges', 'Squats', 'Cable Pull-downs', 'Diamond Push-ups', 'Biceps Curls'] as const;
|
||||||
|
|
||||||
|
type StrengthTable = {
|
||||||
|
id: NumberColumnType<false>;
|
||||||
|
createdAt: AutoGeneratedDateColumnType;
|
||||||
|
updatedAt: AutoGeneratedDateColumnType;
|
||||||
|
reps: NumberColumnType<false>;
|
||||||
|
weight: NumberColumnType<false>;
|
||||||
|
date: DateColumnType<false>;
|
||||||
|
name: TextColumnType<false>;
|
||||||
|
exercise: SingleSelectColumnType<typeof StrengthExerciseOptions, false>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CardioExerciseOptions = ['Running', 'Cycling', 'Swimming'] as const;
|
||||||
|
|
||||||
|
type CardioTable = {
|
||||||
|
id: NumberColumnType<false>;
|
||||||
|
createdAt: AutoGeneratedDateColumnType;
|
||||||
|
updatedAt: AutoGeneratedDateColumnType;
|
||||||
|
date: DateColumnType<false>;
|
||||||
|
duration: NumberColumnType<false>;
|
||||||
|
distance: NumberColumnType<false>;
|
||||||
|
exercise: SingleSelectColumnType<typeof CardioExerciseOptions, false>;
|
||||||
|
name: TextColumnType<false>;
|
||||||
|
speed: NumberColumnType<false>;
|
||||||
|
};
|
||||||
|
type WeightTable = {
|
||||||
|
id: NumberColumnType<false>;
|
||||||
|
createdAt: AutoGeneratedDateColumnType;
|
||||||
|
updatedAt: AutoGeneratedDateColumnType;
|
||||||
|
date: DateColumnType<false>;
|
||||||
|
weight: NumberColumnType<false>;
|
||||||
|
name: TextColumnType<false>;
|
||||||
|
};
|
||||||
|
type GoalsTable = {
|
||||||
|
id: NumberColumnType<false>;
|
||||||
|
createdAt: AutoGeneratedDateColumnType;
|
||||||
|
updatedAt: AutoGeneratedDateColumnType;
|
||||||
|
name: TextColumnType<false>;
|
||||||
|
value: TextColumnType<false>;
|
||||||
|
description: TextColumnType<false>;
|
||||||
|
};
|
||||||
|
type SettingsTable = {
|
||||||
|
id: NumberColumnType<false>;
|
||||||
|
createdAt: AutoGeneratedDateColumnType;
|
||||||
|
updatedAt: AutoGeneratedDateColumnType;
|
||||||
|
name: TextColumnType<false>;
|
||||||
|
value: TextColumnType<false>;
|
||||||
|
description: TextColumnType<false>;
|
||||||
|
};
|
||||||
27
apps/server/trpc.ts
Normal file
27
apps/server/trpc.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { initTRPC } from "@trpc/server";
|
||||||
|
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create context for each tRPC request
|
||||||
|
* This is where you can add user session, database clients, etc.
|
||||||
|
*/
|
||||||
|
export const createContext = ({ req, res }: CreateExpressContextOptions) => {
|
||||||
|
return {
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
// Add any shared context here (e.g., database client, user session)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Context = Awaited<ReturnType<typeof createContext>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize tRPC instance
|
||||||
|
*/
|
||||||
|
const t = initTRPC.context<Context>().create();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export reusable router and procedure helpers
|
||||||
|
*/
|
||||||
|
export const router = t.router;
|
||||||
|
export const publicProcedure = t.procedure;
|
||||||
20
apps/server/tsconfig.json
Normal file
20
apps/server/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": false,
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": ".",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["./**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
651
docs/SHADCN_COMPONENTS_GUIDE.md
Normal file
651
docs/SHADCN_COMPONENTS_GUIDE.md
Normal file
|
|
@ -0,0 +1,651 @@
|
||||||
|
# shadcn/ui Dashboard Components Guide
|
||||||
|
|
||||||
|
This guide provides examples of using shadcn/ui components to build modern dashboard interfaces for your TaylorDB applications.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Installation
|
||||||
|
|
||||||
|
### Install Individual Components
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Core components (already included)
|
||||||
|
pnpm dlx shadcn@latest add button card input label textarea select tabs alert
|
||||||
|
|
||||||
|
# Dashboard-specific components
|
||||||
|
pnpm dlx shadcn@latest add table dialog dropdown-menu toast sheet form badge avatar skeleton
|
||||||
|
|
||||||
|
# Data display components
|
||||||
|
pnpm dlx shadcn@latest add separator progress scroll-area tooltip
|
||||||
|
|
||||||
|
# Advanced components
|
||||||
|
pnpm dlx shadcn@latest add command popover calendar date-picker
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Common Dashboard Patterns
|
||||||
|
|
||||||
|
### 1. Dashboard Layout with Stats Cards
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Total Users
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold">1,234</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
+20% from last month
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Revenue
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold">$45,231</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
+12% from last month
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* More stats cards... */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Data Table with Actions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { MoreHorizontal, Pencil, Trash } from "lucide-react";
|
||||||
|
|
||||||
|
export default function UsersTable({ users }: { users: User[] }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Users</CardTitle>
|
||||||
|
<CardDescription>Manage your user accounts</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-medium">{user.name}</TableCell>
|
||||||
|
<TableCell>{user.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={user.status === "active" ? "default" : "secondary"}
|
||||||
|
>
|
||||||
|
{user.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="text-destructive">
|
||||||
|
<Trash className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create/Edit Form in Dialog
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { PlusIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export default function CreateUserDialog() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
|
||||||
|
const createMutation = trpc.users.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setOpen(false);
|
||||||
|
setName("");
|
||||||
|
setEmail("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<PlusIcon className="mr-2 h-4 w-4" />
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New User</DialogTitle>
|
||||||
|
<DialogDescription>Add a new user to your database</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="John Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="john@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => createMutation.mutate({ name, email })}
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
>
|
||||||
|
Create User
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Loading States with Skeleton
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-8">
|
||||||
|
<Skeleton className="h-10 w-48 mb-6" />
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-8 w-20 mb-2" />
|
||||||
|
<Skeleton className="h-3 w-32" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage in main component
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { data: stats, isLoading } = trpc.dashboard.getStats.useQuery();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <DashboardSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>{/* Actual dashboard */}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Tabs for Different Views
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
|
export default function DataExplorer() {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Data Explorer</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="overview">
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value="analytics">Analytics</TabsTrigger>
|
||||||
|
<TabsTrigger value="reports">Reports</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="overview" className="space-y-4">
|
||||||
|
{/* Overview content */}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="analytics" className="space-y-4">
|
||||||
|
{/* Analytics content */}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="reports" className="space-y-4">
|
||||||
|
{/* Reports content */}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Status Badges
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const variants = {
|
||||||
|
active: "default",
|
||||||
|
pending: "secondary",
|
||||||
|
inactive: "outline",
|
||||||
|
error: "destructive",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant={variants[status as keyof typeof variants] || "outline"}>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
<StatusBadge status="active" />;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Toast Notifications
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function ActionsPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const deleteMutation = trpc.items.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "Success!",
|
||||||
|
description: "Item deleted successfully",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button onClick={() => deleteMutation.mutate({ id: 1 })}>
|
||||||
|
Delete Item
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't forget to add <Toaster /> in your App.tsx or layout
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Form with Validation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
age: z.number().min(0).max(120),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function UserForm() {
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
age: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = trpc.users.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: z.infer<typeof formSchema>) => {
|
||||||
|
createMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="John Doe" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Your full name as it appears on documents.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="email" placeholder="john@example.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? "Submitting..." : "Submit"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Side Sheet for Details
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function UserDetailsSheet({ userId }: { userId: number }) {
|
||||||
|
const { data: user } = trpc.users.getById.useQuery({ id: userId });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="outline">View Details</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>User Details</SheetTitle>
|
||||||
|
<SheetDescription>Information about this user</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">
|
||||||
|
Name
|
||||||
|
</h4>
|
||||||
|
<p className="text-base">{user.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">
|
||||||
|
Email
|
||||||
|
</h4>
|
||||||
|
<p className="text-base">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
{/* More fields... */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Command Palette (Search)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export default function GlobalSearch() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const down = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((open) => !open);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", down);
|
||||||
|
return () => document.removeEventListener("keydown", down);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<CommandInput placeholder="Search..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
<CommandGroup heading="Users">
|
||||||
|
<CommandItem>John Doe</CommandItem>
|
||||||
|
<CommandItem>Jane Smith</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandGroup heading="Projects">
|
||||||
|
<CommandItem>Project Alpha</CommandItem>
|
||||||
|
<CommandItem>Project Beta</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Design Tips
|
||||||
|
|
||||||
|
### Color Schemes
|
||||||
|
|
||||||
|
Use semantic color tokens:
|
||||||
|
|
||||||
|
- `bg-background` / `text-foreground` - Main background and text
|
||||||
|
- `bg-card` / `text-card-foreground` - Card surfaces
|
||||||
|
- `bg-primary` / `text-primary-foreground` - Primary actions
|
||||||
|
- `bg-destructive` - Destructive actions (delete, etc.)
|
||||||
|
- `bg-muted` / `text-muted-foreground` - Subtle UI elements
|
||||||
|
|
||||||
|
### Spacing
|
||||||
|
|
||||||
|
Use consistent spacing:
|
||||||
|
|
||||||
|
- `space-y-4` / `gap-4` - Between related items
|
||||||
|
- `space-y-6` / `gap-6` - Between sections
|
||||||
|
- `p-4` / `p-6` - Card padding
|
||||||
|
- `p-8` - Page padding
|
||||||
|
|
||||||
|
### Icons
|
||||||
|
|
||||||
|
Use `lucide-react` for consistent icons:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Home, Users, Settings, Plus, Edit, Trash, Search } from "lucide-react";
|
||||||
|
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Item
|
||||||
|
</Button>;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 Responsive Design
|
||||||
|
|
||||||
|
### Grid Layouts
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1 column on mobile, 2 on tablet, 4 on desktop
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{/* cards */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// 1 column on mobile, 3 on desktop
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* cards */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hide on Mobile
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Hide text on mobile, show on desktop
|
||||||
|
<span className="hidden sm:inline">Dashboard</span>
|
||||||
|
|
||||||
|
// Different layout on mobile
|
||||||
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
|
{/* content */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Performance Tips
|
||||||
|
|
||||||
|
1. **Lazy load dialogs**: Only render dialog content when open
|
||||||
|
2. **Virtualize long lists**: Use libraries like `react-window`
|
||||||
|
3. **Skeleton loaders**: Always show loading states
|
||||||
|
4. **Optimistic updates**: Update UI before server confirms
|
||||||
|
5. **Debounce search**: Don't query on every keystroke
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Resources
|
||||||
|
|
||||||
|
- **shadcn/ui**: https://ui.shadcn.com/
|
||||||
|
- **Tailwind CSS**: https://tailwindcss.com/
|
||||||
|
- **Lucide Icons**: https://lucide.dev/
|
||||||
|
- **React Hook Form**: https://react-hook-form.com/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Remember**: Always test your components in both light and dark mode, and on different screen sizes!
|
||||||
700
docs/TAYLORDB_QUERY_REFERENCE.md
Normal file
700
docs/TAYLORDB_QUERY_REFERENCE.md
Normal file
|
|
@ -0,0 +1,700 @@
|
||||||
|
# TaylorDB Query Builder Reference
|
||||||
|
|
||||||
|
This document provides comprehensive examples of how to use the TaylorDB query builder for all common database operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Table of Contents
|
||||||
|
|
||||||
|
1. [Setup & Configuration](#setup--configuration)
|
||||||
|
2. [Basic Queries](#basic-queries)
|
||||||
|
3. [Filtering & Conditions](#filtering--conditions)
|
||||||
|
4. [Inserting Data](#inserting-data)
|
||||||
|
5. [Updating Data](#updating-data)
|
||||||
|
6. [Deleting Data](#deleting-data)
|
||||||
|
7. [Advanced Patterns](#advanced-patterns)
|
||||||
|
8. [Field Type Handling](#field-type-handling)
|
||||||
|
9. [Common Pitfalls](#common-pitfalls)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup & Configuration
|
||||||
|
|
||||||
|
### Initialize Query Builder
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createQueryBuilder } from "@taylordb/query-builder";
|
||||||
|
import type { TaylorDatabase } from "./types.js";
|
||||||
|
|
||||||
|
export const queryBuilder = createQueryBuilder<TaylorDatabase>({
|
||||||
|
baseUrl: process.env.TAYLORDB_BASE_URL!,
|
||||||
|
baseId: process.env.TAYLORDB_SERVER_ID!,
|
||||||
|
apiKey: process.env.TAYLORDB_API_TOKEN!,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The type parameter `<TaylorDatabase>` provides full type safety based on your generated schema.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Basic Queries
|
||||||
|
|
||||||
|
### Get All Records
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function getAllUsers() {
|
||||||
|
return await queryBuilder
|
||||||
|
.selectFrom("users")
|
||||||
|
.select(["id", "name", "email", "createdAt"])
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get All Records (All Fields)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function getAllUsers() {
|
||||||
|
return await queryBuilder.selectFrom("users").execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Single Record by ID
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function getUserById(id: number) {
|
||||||
|
return await queryBuilder
|
||||||
|
.selectFrom("users")
|
||||||
|
.where("id", "=", id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: `.executeTakeFirst()` returns a single record or `undefined`.
|
||||||
|
|
||||||
|
### Get Records with Ordering
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Descending order (newest first)
|
||||||
|
export async function getRecentUsers() {
|
||||||
|
return await queryBuilder
|
||||||
|
.selectFrom("users")
|
||||||
|
.orderBy("createdAt", "desc")
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ascending order (oldest first)
|
||||||
|
export async function getOldestUsers() {
|
||||||
|
return await queryBuilder
|
||||||
|
.selectFrom("users")
|
||||||
|
.orderBy("createdAt", "asc")
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filtering & Conditions
|
||||||
|
|
||||||
|
### Basic Where Clauses
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Exact match
|
||||||
|
.where("status", "=", "active")
|
||||||
|
|
||||||
|
// Not equal
|
||||||
|
.where("status", "!=", "deleted")
|
||||||
|
|
||||||
|
// Greater than / Less than
|
||||||
|
.where("age", ">", 18)
|
||||||
|
.where("age", ">=", 18)
|
||||||
|
.where("age", "<", 65)
|
||||||
|
.where("age", "<=", 65)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Conditions (AND logic)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function getActiveAdults() {
|
||||||
|
return await queryBuilder
|
||||||
|
.selectFrom("users")
|
||||||
|
.where("status", "=", "active")
|
||||||
|
.where("age", ">=", 18)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Date Filtering
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Exact date
|
||||||
|
export async function getUsersForDate(date: string) {
|
||||||
|
return await queryBuilder
|
||||||
|
.selectFrom("users")
|
||||||
|
.where("createdAt", "=", ["exactDay", date])
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date range
|
||||||
|
export async function getUsersInRange(startDate: string, endDate: string) {
|
||||||
|
return await queryBuilder
|
||||||
|
.selectFrom("users")
|
||||||
|
.where("createdAt", ">=", ["exactDay", startDate])
|
||||||
|
.where("createdAt", "<=", ["exactDay", endDate])
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before/After a date
|
||||||
|
.where("dueDate", "<", ["exactDay", "2024-01-01"])
|
||||||
|
.where("startDate", ">", ["exactDay", "2024-12-31"])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Array/Multi-Select Filtering
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Check if array contains any of the values
|
||||||
|
export async function getUsersByTags(tags: string[]) {
|
||||||
|
return await queryBuilder
|
||||||
|
.selectFrom("users")
|
||||||
|
.where("tags", "hasAnyOf", tags)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example: Get users tagged with "admin" OR "moderator"
|
||||||
|
const adminUsers = await getUsersByTags(["admin", "moderator"]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Single Select Filtering
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// For single-select fields (stored as arrays in TaylorDB)
|
||||||
|
export async function getUsersByRole(role: string) {
|
||||||
|
return await queryBuilder
|
||||||
|
.selectFrom("users")
|
||||||
|
.where("role", "=", role)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Text Search (Contains)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function searchUsersByName(query: string) {
|
||||||
|
return await queryBuilder
|
||||||
|
.selectFrom("users")
|
||||||
|
.where("name", "contains", query)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inserting Data
|
||||||
|
|
||||||
|
### Insert Single Record
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function createUser(data: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
age: number;
|
||||||
|
}) {
|
||||||
|
return await queryBuilder
|
||||||
|
.insertInto("users")
|
||||||
|
.values({
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
age: data.age,
|
||||||
|
status: "active", // Default value
|
||||||
|
})
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Returns**: The created record with its generated `id`.
|
||||||
|
|
||||||
|
### Insert with Single-Select Field
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function createTask(data: {
|
||||||
|
title: string;
|
||||||
|
priority: "low" | "medium" | "high";
|
||||||
|
}) {
|
||||||
|
return await queryBuilder
|
||||||
|
.insertInto("tasks")
|
||||||
|
.values({
|
||||||
|
title: data.title,
|
||||||
|
priority: [data.priority], // Wrap in array for single-select
|
||||||
|
})
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Insert with Multi-Select Field
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function createProject(data: { name: string; tags: string[] }) {
|
||||||
|
return await queryBuilder
|
||||||
|
.insertInto("projects")
|
||||||
|
.values({
|
||||||
|
name: data.name,
|
||||||
|
tags: data.tags, // Already an array
|
||||||
|
})
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Insert with Computed Fields
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function createCardioSession(data: {
|
||||||
|
distance: number;
|
||||||
|
duration: number; // in minutes
|
||||||
|
}) {
|
||||||
|
const speed = data.distance / (data.duration / 60); // km/h
|
||||||
|
|
||||||
|
return await queryBuilder
|
||||||
|
.insertInto("cardio")
|
||||||
|
.values({
|
||||||
|
distance: data.distance,
|
||||||
|
duration: data.duration,
|
||||||
|
speed: speed, // Computed field
|
||||||
|
})
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Insert with Optional Fields
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function createPost(data: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
tags?: string[];
|
||||||
|
}) {
|
||||||
|
return await queryBuilder
|
||||||
|
.insertInto("posts")
|
||||||
|
.values({
|
||||||
|
title: data.title,
|
||||||
|
content: data.content,
|
||||||
|
tags: data.tags || [], // Default to empty array
|
||||||
|
})
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Updating Data
|
||||||
|
|
||||||
|
### Update Single Field
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function updateUserName(id: number, name: string) {
|
||||||
|
return await queryBuilder
|
||||||
|
.update("users")
|
||||||
|
.set({ name })
|
||||||
|
.where("id", "=", id)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Multiple Fields
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function updateUser(
|
||||||
|
id: number,
|
||||||
|
data: {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
age?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return await queryBuilder
|
||||||
|
.update("users")
|
||||||
|
.set(data)
|
||||||
|
.where("id", "=", id)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Only provided fields will be updated.
|
||||||
|
|
||||||
|
### Update with Single-Select Field
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function updateTaskPriority(
|
||||||
|
id: number,
|
||||||
|
priority: "low" | "medium" | "high",
|
||||||
|
) {
|
||||||
|
return await queryBuilder
|
||||||
|
.update("tasks")
|
||||||
|
.set({ priority: [priority] }) // Wrap in array
|
||||||
|
.where("id", "=", id)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update with Conditional Logic
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function updateCardioSession(
|
||||||
|
id: number,
|
||||||
|
data: {
|
||||||
|
distance?: number;
|
||||||
|
duration?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
// Fetch current record to compute speed
|
||||||
|
const currentRecord = await queryBuilder
|
||||||
|
.selectFrom("cardio")
|
||||||
|
.select(["distance", "duration"])
|
||||||
|
.where("id", "=", id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!currentRecord) {
|
||||||
|
throw new Error("Record not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDistance = data.distance ?? currentRecord.distance ?? 0;
|
||||||
|
const newDuration = data.duration ?? currentRecord.duration ?? 0;
|
||||||
|
const speed = newDistance / (newDuration / 60);
|
||||||
|
|
||||||
|
return await queryBuilder
|
||||||
|
.update("cardio")
|
||||||
|
.set({
|
||||||
|
...data,
|
||||||
|
speed,
|
||||||
|
})
|
||||||
|
.where("id", "=", id)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Multiple Records
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function activateAllUsers() {
|
||||||
|
return await queryBuilder.update("users").set({ status: "active" }).execute(); // No where clause = update all
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update with condition
|
||||||
|
export async function activateInactiveUsers() {
|
||||||
|
return await queryBuilder
|
||||||
|
.update("users")
|
||||||
|
.set({ status: "active" })
|
||||||
|
.where("status", "=", "inactive")
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deleting Data
|
||||||
|
|
||||||
|
### Delete Single Record
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function deleteUser(id: number) {
|
||||||
|
return await queryBuilder.deleteFrom("users").where("id", "=", id).execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Multiple Records by IDs
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function deleteUsers(ids: number[]) {
|
||||||
|
return await queryBuilder
|
||||||
|
.deleteFrom("users")
|
||||||
|
.where("id", "hasAnyOf", ids)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete with Condition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function deleteInactiveUsers() {
|
||||||
|
return await queryBuilder
|
||||||
|
.deleteFrom("users")
|
||||||
|
.where("status", "=", "inactive")
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Old Records
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function deleteOldLogs(beforeDate: string) {
|
||||||
|
return await queryBuilder
|
||||||
|
.deleteFrom("logs")
|
||||||
|
.where("createdAt", "<", ["exactDay", beforeDate])
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced Patterns
|
||||||
|
|
||||||
|
### Aggregations (Manual)
|
||||||
|
|
||||||
|
Since TaylorDB query builder might not have built-in aggregations, compute manually:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function getUserStats() {
|
||||||
|
const users = await queryBuilder
|
||||||
|
.selectFrom("users")
|
||||||
|
.select(["age"])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return { count: 0, average: null, min: null, max: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ages = users
|
||||||
|
.map((u) => u.age)
|
||||||
|
.filter((a): a is number => a !== undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: ages.length,
|
||||||
|
average: ages.reduce((a, b) => a + b, 0) / ages.length,
|
||||||
|
min: Math.min(...ages),
|
||||||
|
max: Math.max(...ages),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sum Totals
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function getTotalCaloriesForDate(date: string) {
|
||||||
|
const entries = await queryBuilder
|
||||||
|
.selectFrom("meals")
|
||||||
|
.select(["calories", "protein", "carbs", "fats"])
|
||||||
|
.where("date", "=", ["exactDay", date])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCalories: entries.reduce((sum, e) => sum + (e.calories ?? 0), 0),
|
||||||
|
totalProtein: entries.reduce((sum, e) => sum + (e.protein ?? 0), 0),
|
||||||
|
totalCarbs: entries.reduce((sum, e) => sum + (e.carbs ?? 0), 0),
|
||||||
|
totalFats: entries.reduce((sum, e) => sum + (e.fats ?? 0), 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Queries
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function searchTasks(filters: {
|
||||||
|
projectId?: number;
|
||||||
|
status?: string;
|
||||||
|
dueAfter?: string;
|
||||||
|
}) {
|
||||||
|
let query = queryBuilder
|
||||||
|
.selectFrom("tasks")
|
||||||
|
.select(["id", "title", "status", "dueDate"]);
|
||||||
|
|
||||||
|
if (filters.projectId) {
|
||||||
|
query = query.where("projectId", "=", filters.projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.status) {
|
||||||
|
query = query.where("status", "=", filters.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.dueAfter) {
|
||||||
|
query = query.where("dueDate", ">=", ["exactDay", filters.dueAfter]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query.execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pagination
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function getPaginatedUsers(page: number, pageSize: number) {
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
return await queryBuilder
|
||||||
|
.selectFrom("users")
|
||||||
|
.select(["id", "name", "email"])
|
||||||
|
.orderBy("createdAt", "desc")
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Field Type Handling
|
||||||
|
|
||||||
|
### Field Type Reference
|
||||||
|
|
||||||
|
| TaylorDB Field Type | TypeScript Type | Insert Value | Query Value |
|
||||||
|
| ------------------- | --------------- | -------------------- | ---------------------------- |
|
||||||
|
| **Text** | `string` | `"Hello"` | `"Hello"` |
|
||||||
|
| **Number** | `number` | `42` | `42` |
|
||||||
|
| **Date** | `string` (ISO) | `"2024-01-15"` | `["exactDay", "2024-01-15"]` |
|
||||||
|
| **Checkbox** | `boolean` | `true` | `true` |
|
||||||
|
| **Single Select** | `string[]` | `["option"]` | `"option"` |
|
||||||
|
| **Multi Select** | `string[]` | `["opt1", "opt2"]` | `tags: ["opt1", "opt2"]` |
|
||||||
|
| **Email** | `string` | `"user@example.com"` | `"user@example.com"` |
|
||||||
|
|
||||||
|
### Handling Nullable Fields
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function createUserSafe(data: {
|
||||||
|
name: string;
|
||||||
|
email?: string | null;
|
||||||
|
age?: number | null;
|
||||||
|
}) {
|
||||||
|
return await queryBuilder
|
||||||
|
.insertInto("users")
|
||||||
|
.values({
|
||||||
|
name: data.name,
|
||||||
|
email: data.email ?? "", // Default to empty string
|
||||||
|
age: data.age ?? 0, // Default to 0
|
||||||
|
})
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Working with Enums
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Import from generated types
|
||||||
|
import type { TaskStatusOptions } from "./types";
|
||||||
|
|
||||||
|
export async function createTask(data: {
|
||||||
|
title: string;
|
||||||
|
status: (typeof TaskStatusOptions)[number]; // e.g., "todo" | "in-progress" | "done"
|
||||||
|
}) {
|
||||||
|
return await queryBuilder
|
||||||
|
.insertInto("tasks")
|
||||||
|
.values({
|
||||||
|
title: data.title,
|
||||||
|
status: [data.status], // Single select as array
|
||||||
|
})
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTasksByStatus(
|
||||||
|
status: (typeof TaskStatusOptions)[number],
|
||||||
|
) {
|
||||||
|
return await queryBuilder
|
||||||
|
.selectFrom("tasks")
|
||||||
|
.where("status", "=", status)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### ❌ Pitfall 1: Not Wrapping Single-Select in Array
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ WRONG
|
||||||
|
.values({ priority: "high" })
|
||||||
|
|
||||||
|
// ✅ CORRECT
|
||||||
|
.values({ priority: ["high"] })
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Pitfall 2: Not Using exactDay for Dates
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ WRONG
|
||||||
|
.where("date", "=", "2024-01-15")
|
||||||
|
|
||||||
|
// ✅ CORRECT
|
||||||
|
.where("date", "=", ["exactDay", "2024-01-15"])
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Pitfall 3: Ignoring Nullable Fields
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ WRONG (assumes field is always present)
|
||||||
|
const user = await queryBuilder
|
||||||
|
.selectFrom("users")
|
||||||
|
.where("id", "=", 1)
|
||||||
|
.executeTakeFirst();
|
||||||
|
console.log(user.email); // Could be undefined!
|
||||||
|
|
||||||
|
// ✅ CORRECT
|
||||||
|
const user = await queryBuilder
|
||||||
|
.selectFrom("users")
|
||||||
|
.where("id", "=", 1)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (user && user.email) {
|
||||||
|
console.log(user.email);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Pitfall 4: Using execute() for Single Record
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ WRONG (returns array)
|
||||||
|
const user = await queryBuilder
|
||||||
|
.selectFrom("users")
|
||||||
|
.where("id", "=", 1)
|
||||||
|
.execute();
|
||||||
|
console.log(user.name); // Error: user is an array!
|
||||||
|
|
||||||
|
// ✅ CORRECT
|
||||||
|
const user = await queryBuilder
|
||||||
|
.selectFrom("users")
|
||||||
|
.where("id", "=", 1)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (user) {
|
||||||
|
console.log(user.name);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Pitfall 5: Not Handling Empty Arrays
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ WRONG (fails if users is empty)
|
||||||
|
const ages = users.map((u) => u.age);
|
||||||
|
const avg = ages.reduce((a, b) => a + b) / ages.length; // Division by zero!
|
||||||
|
|
||||||
|
// ✅ CORRECT
|
||||||
|
if (users.length === 0) {
|
||||||
|
return { average: null };
|
||||||
|
}
|
||||||
|
const ages = users
|
||||||
|
.map((u) => u.age)
|
||||||
|
.filter((a): a is number => a !== undefined);
|
||||||
|
const avg = ages.reduce((a, b) => a + b, 0) / ages.length;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always handle `undefined` and `null`** when working with query results
|
||||||
|
2. **Use TypeScript types** from `taylordb/types.ts` for type safety
|
||||||
|
3. **Wrap single-select values** in arrays when inserting/updating
|
||||||
|
4. **Use `executeTakeFirst()`** when you expect a single record
|
||||||
|
5. **Filter nullish values** before aggregations
|
||||||
|
6. **Provide defaults** for optional fields
|
||||||
|
7. **Use `exactDay`** format for date comparisons
|
||||||
|
8. **Group related queries** in the same function file
|
||||||
|
9. **Export functions**, not raw queries
|
||||||
|
10. **Document complex queries** with JSDoc comments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- **Generated Types**: Check `apps/server/taylordb/types.ts` for your schema
|
||||||
|
- **Example Queries**: See `apps/server/taylordb/query-builder.ts`
|
||||||
|
- **tRPC Integration**: See `apps/server/router.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Note**: This reference is based on the TaylorDB query builder patterns used in this template. Always refer to the official TaylorDB documentation for the most up-to-date API details.
|
||||||
24
eslint.config.js
Normal file
24
eslint.config.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import js from '@eslint/js'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
import globals from 'globals'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
ignores: ['dist', './src/lib/*.ts'],
|
||||||
|
},
|
||||||
|
])
|
||||||
3
opencode.json
Normal file
3
opencode.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
}
|
||||||
20
package.json
Normal file
20
package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"name": "taylordb-monorepo",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm --filter @repo/client dev",
|
||||||
|
"dev:server": "pnpm --filter @repo/server dev",
|
||||||
|
"dev:full": "concurrently \"pnpm dev\" \"pnpm dev:server\" --names \"client,server\" --prefix-colors \"cyan,magenta\"",
|
||||||
|
"build": "pnpm --filter @repo/client build",
|
||||||
|
"build:server": "pnpm --filter @repo/server build",
|
||||||
|
"build:all": "pnpm build && pnpm build:server",
|
||||||
|
"lint": "eslint apps",
|
||||||
|
"generate:schema": "pnpx @taylordb/cli generate-schema"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"typescript": "~5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
5050
pnpm-lock.yaml
Normal file
5050
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
87
taylordb.yml
Normal file
87
taylordb.yml
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# yaml-language-server: $schema=https://server-vms.develop.taylordb.ai/apps/config-schema/v1
|
||||||
|
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
project: blank
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
type: node
|
||||||
|
version: 20
|
||||||
|
packageManager: pnpm
|
||||||
|
|
||||||
|
services:
|
||||||
|
server:
|
||||||
|
workDir: apps/server
|
||||||
|
install:
|
||||||
|
commands:
|
||||||
|
- pnpm install
|
||||||
|
dev:
|
||||||
|
command: pnpm dev
|
||||||
|
port: 3000
|
||||||
|
env:
|
||||||
|
vars:
|
||||||
|
TAYLORDB_BASE_URL: vars.TAYLORDB_INTERNAL_BASE_URL
|
||||||
|
TAYLORDB_API_TOKEN: secrets.TAYLORDB_API_TOKEN
|
||||||
|
TAYLORDB_SERVER_ID: vars.TAYLORDB_SERVER_ID
|
||||||
|
FRONTEND_URL: routing.client.url
|
||||||
|
|
||||||
|
start:
|
||||||
|
command: pnpm start --port 3000
|
||||||
|
port: 3000
|
||||||
|
env:
|
||||||
|
vars:
|
||||||
|
TAYLORDB_BASE_URL: vars.TAYLORDB_INTERNAL_BASE_URL
|
||||||
|
TAYLORDB_API_TOKEN: secrets.TAYLORDB_API_TOKEN
|
||||||
|
TAYLORDB_SERVER_ID: vars.TAYLORDB_SERVER_ID
|
||||||
|
FRONTEND_URL: routing.client.url
|
||||||
|
|
||||||
|
build:
|
||||||
|
commands:
|
||||||
|
- pnpm build
|
||||||
|
env:
|
||||||
|
vars:
|
||||||
|
TAYLORDB_BASE_URL: vars.TAYLORDB_INTERNAL_BASE_URL
|
||||||
|
TAYLORDB_API_TOKEN: secrets.TAYLORDB_API_TOKEN
|
||||||
|
TAYLORDB_SERVER_ID: vars.TAYLORDB_SERVER_ID
|
||||||
|
FRONTEND_URL: routing.client.url
|
||||||
|
|
||||||
|
client:
|
||||||
|
workDir: apps/client
|
||||||
|
install:
|
||||||
|
commands:
|
||||||
|
- pnpm install
|
||||||
|
dev:
|
||||||
|
command: pnpm dev
|
||||||
|
port: 5173
|
||||||
|
env:
|
||||||
|
vars:
|
||||||
|
VITE_TRPC_URL: routing.server.url
|
||||||
|
start:
|
||||||
|
command: pnpm preview --port 5173
|
||||||
|
port: 5173
|
||||||
|
env:
|
||||||
|
vars:
|
||||||
|
VITE_TRPC_URL: routing.server.url
|
||||||
|
|
||||||
|
build:
|
||||||
|
commands:
|
||||||
|
- pnpm build
|
||||||
|
env:
|
||||||
|
vars:
|
||||||
|
VITE_TRPC_URL: routing.server.url
|
||||||
|
|
||||||
|
taylordb:
|
||||||
|
types:
|
||||||
|
- output: apps/server/taylordb/types.ts
|
||||||
|
format: typescript
|
||||||
|
onChange:
|
||||||
|
- pnpm lint
|
||||||
|
|
||||||
|
preview:
|
||||||
|
service: client
|
||||||
|
|
||||||
|
routing:
|
||||||
|
- path: /api
|
||||||
|
service: server
|
||||||
|
- path: /
|
||||||
|
service: client
|
||||||
4
tsconfig.json
Normal file
4
tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./apps/client" }, { "path": "./apps/server" }]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user