Merge remote-tracking branch 'origin/main'

This commit is contained in:
Umar Adilov 2026-01-12 15:45:09 +05:00
commit 853c434d75
25 changed files with 1594 additions and 446 deletions

View File

@ -1,94 +1,125 @@
import { Moon, Sun } from "lucide-react";
import { Moon, Sun, Database, Palette } from "lucide-react";
import { useEffect, useState } from "react";
import { NavLink, Outlet } from "react-router-dom";
import { Outlet } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const navItems = [
{ to: "/", label: "Home" },
{ to: "/about", label: "About" },
];
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;
const getInitialTheme = (): "light" | "dark" => {
if (typeof window === "undefined") {
return "light";
}
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") {
return stored;
}
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<"light" | "dark">(getInitialTheme);
const [theme, setTheme] = useState<ThemeId>(getInitialTheme);
const [mode, setMode] = useState<Mode>(getInitialMode);
useEffect(() => {
const root = document.documentElement;
root.classList.toggle("dark", theme === "dark");
localStorage.setItem("theme", theme);
}, [theme]);
// 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="border-b">
<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">
<span className="text-base font-semibold">TaylorDB Starter</span>
<nav className="flex items-center gap-1">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
"rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition hover:text-foreground",
isActive && "bg-accent text-foreground"
)
}
end={item.to === "/"}
>
{item.label}
</NavLink>
))}
</nav>
<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"
aria-label="Toggle theme"
onClick={() =>
setTheme((prev) => (prev === "dark" ? "light" : "dark"))
}
className="rounded-full"
aria-label="Choose theme"
>
{theme === "dark" ? (
<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>
<Button variant="outline" asChild>
<a href="https://ui.shadcn.com/" target="_blank" rel="noreferrer">
shadcn/ui
</a>
</Button>
<Button asChild>
<a
href="https://reactrouter.com/6.30.2"
target="_blank"
rel="noreferrer"
>
React Router V6
</a>
</Button>
</div>
</div>
</header>
<main className="container py-8">
<main>
<Outlet />
</main>
</div>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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]}`} />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -0,0 +1,3 @@
export { HelloExample } from "./HelloExample";
export { UsersExample } from "./UsersExample";
export { PostsExample } from "./PostsExample";

View 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";

View 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,
};

View File

@ -29,49 +29,295 @@
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
/* ========================================================================
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: 222.2 84% 4.9%;
--card-foreground: 270 50% 10%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.75rem;
--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 {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
.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%;
}
* {
@ -93,20 +339,127 @@
}
}
/* 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);
}
from { height: 0; }
to { height: var(--radix-accordion-content-height); }
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
from { height: var(--radix-accordion-content-height); }
to { height: 0; }
}

View File

@ -14,10 +14,10 @@ export const trpc: CreateTRPCReact<AppRouter, unknown> =
function getBaseUrl() {
if (typeof window !== "undefined") {
// Browser: use relative URL or environment variable
return import.meta.env.VITE_TRPC_URL || "http://localhost:3001";
return import.meta.env.VITE_TRPC_URL || "http://localhost:3001/api";
}
// SSR: assume localhost
return "http://localhost:3001";
return "http://localhost:3001/api";
}
/**

View File

@ -5,10 +5,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./index.css";
import AboutPage from "./pages/AboutPage";
import HomePage from "./pages/HomePage";
import NotFoundPage from "./pages/NotFoundPage";
import TRPCDemoPage from "./pages/TRPCDemoPage";
import NotFoundPage from "./pages/NotFoundPage";
import { trpc, trpcClient } from "./lib/trpc";
// Create a React Query client
@ -24,11 +22,7 @@ const router = createBrowserRouter([
{
path: "/",
element: <App />,
children: [
{ index: true, element: <HomePage /> },
{ path: "about", element: <AboutPage /> },
{ path: "trpc-demo", element: <TRPCDemoPage /> },
],
children: [{ index: true, element: <TRPCDemoPage /> }],
},
{ path: "*", element: <NotFoundPage /> },
]);

View File

@ -1,88 +0,0 @@
import { ExternalLink } from "lucide-react";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
const AboutPage = () => {
return (
<section className="space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-semibold">About This Template</h1>
<p className="text-muted-foreground">
This is a full-stack template for building modern web applications
with TaylorDB. It includes React + Vite frontend, Node.js + tRPC
backend, and shadcn/ui components.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border bg-card p-4 shadow-sm">
<h2 className="text-lg font-medium mb-2">Type-Safe APIs</h2>
<p className="text-sm text-muted-foreground mb-3">
Full end-to-end type safety from database to UI using tRPC and
TypeScript. Auto-generated types from your TaylorDB schema.
</p>
<Button variant="outline" size="sm" asChild>
<Link to="/trpc-demo">View Demo</Link>
</Button>
</div>
<div className="rounded-lg border bg-card p-4 shadow-sm">
<h2 className="text-lg font-medium mb-2">Modern UI</h2>
<p className="text-sm text-muted-foreground mb-3">
Built with shadcn/ui and Tailwind CSS. Responsive, accessible, and
customizable components with dark mode support.
</p>
<Button variant="outline" size="sm" asChild>
<a
href="https://ui.shadcn.com/docs"
target="_blank"
rel="noreferrer"
>
View Components <ExternalLink className="ml-1 h-3 w-3" />
</a>
</Button>
</div>
</div>
<div className="space-y-3">
<h2 className="text-lg font-medium">Documentation</h2>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
📘 <code className="font-mono">AGENTS.md</code> - AI agent
instructions and development workflow
</li>
<li>
📗{" "}
<code className="font-mono">docs/TAYLORDB_QUERY_REFERENCE.md</code>{" "}
- Complete query builder examples
</li>
<li>
📙{" "}
<code className="font-mono">docs/SHADCN_COMPONENTS_GUIDE.md</code> -
UI component patterns for dashboards
</li>
</ul>
</div>
<div className="flex gap-3">
<Button variant="outline" asChild>
<a
href="https://www.npmjs.com/package/@taylordb/query-builder"
target="_blank"
rel="noreferrer"
>
TaylorDB Query Builder <ExternalLink className="ml-1 h-4 w-4" />
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://trpc.io" target="_blank" rel="noreferrer">
tRPC Docs <ExternalLink className="ml-1 h-4 w-4" />
</a>
</Button>
</div>
</section>
);
};
export default AboutPage;

View File

@ -1,58 +0,0 @@
import { ArrowRight } from "lucide-react";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
const HomePage = () => {
return (
<section className="grid gap-6">
<div className="space-y-3">
<p className="text-sm uppercase tracking-wide text-muted-foreground">
Welcome
</p>
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
Build your TaylorDB UI with React Router + shadcn/ui
</h1>
<p className="text-lg text-muted-foreground max-w-2xl">
Routing, Tailwind, and shadcn/ui are wired up. Start connecting
components to your TaylorDB data using the generated client and types.
</p>
</div>
<div className="rounded-lg border bg-card p-4 shadow-sm animate-in fade-in">
<p className="text-sm font-medium text-foreground">
Animation check: this card fades in using{" "}
<code>animate-in fade-in</code> from <code>tw-animate-css</code>.
</p>
<p className="text-sm text-muted-foreground">
If you see a smooth fade on load, the plugin is wired correctly.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button asChild>
<Link to="/about">
Learn more
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
<Button variant="outline" asChild>
<a href="https://ui.shadcn.com/docs" target="_blank" rel="noreferrer">
shadcn/ui docs
</a>
</Button>
<Button variant="ghost" asChild>
<a
href="https://reactrouter.com/en/main"
target="_blank"
rel="noreferrer"
>
React Router docs
</a>
</Button>
</div>
</section>
);
};
export default HomePage;

View File

@ -24,4 +24,3 @@ const NotFoundPage = () => {
};
export default NotFoundPage;

View File

@ -1,128 +1,41 @@
import { useState } from "react";
import { Sparkles } from "lucide-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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { InfoIcon, Loader2 } from "lucide-react";
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">
<div className="mb-8">
<h1 className="text-4xl font-bold mb-2">tRPC + TaylorDB Demo</h1>
<p className="text-muted-foreground">
Example of type-safe API calls with tRPC
{/* 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>
</div>
</header>
<div className="grid gap-6">
{/* Info Alert */}
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle>Template Example</AlertTitle>
<AlertDescription>
This page demonstrates a simple tRPC query. Replace this with your
own queries based on your TaylorDB schema. See{" "}
<code className="font-mono text-sm">apps/server/router.ts</code> to
add more procedures.
</AlertDescription>
</Alert>
{/* Example: Hello Query */}
{/* 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 &amp; TaylorDB</p>
</footer>
</div>
</div>
);
}
// ============================================================================
// Example Component: Simple Query
// ============================================================================
function HelloExample() {
const [name, setName] = useState("");
const { data, isLoading, refetch } = trpc.hello.useQuery(
{ name: name || undefined },
{ enabled: false } // Only run when user clicks button
);
const handleQuery = () => {
refetch();
};
return (
<Card>
<CardHeader>
<CardTitle>Example: Hello Query</CardTitle>
<CardDescription>
A simple tRPC query to test the connection
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="name">Name (optional)</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name..."
/>
</div>
<Button onClick={handleQuery} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
"Send Query"
)}
</Button>
{data && (
<div className="mt-4 p-4 border rounded-lg bg-muted/50">
<p className="font-medium text-sm mb-2">Response:</p>
<pre className="text-sm">{JSON.stringify(data, null, 2)}</pre>
</div>
)}
</CardContent>
</Card>
);
}
/**
* ============================================================================
* Add Your Own Components Here
* ============================================================================
*
* Follow this pattern to create your own tRPC queries and mutations:
*
* 1. Create procedures in apps/server/router.ts
* 2. Use trpc.<procedure>.useQuery() for queries (reading data)
* 3. Use trpc.<procedure>.useMutation() for mutations (writing data)
* 4. Handle loading and error states
* 5. Refetch queries after mutations to update the UI
*
* Example Query:
* const { data, isLoading, error } = trpc.items.getAll.useQuery();
*
* Example Mutation:
* const createMutation = trpc.items.create.useMutation({
* onSuccess: () => {
* // Refetch or invalidate queries
* },
* });
*
* For comprehensive examples, see:
* - docs/SHADCN_COMPONENTS_GUIDE.md - UI component patterns
* - AGENTS.md - Complete development workflow
*/

View File

@ -1,34 +1,29 @@
import { z } from "zod";
import { router, publicProcedure } from "./trpc";
// import * as db from "./taylordb/query-builder";
import { usersRouter, postsRouter } from "./routers";
/**
* Main tRPC Router
*
* This is your main API router. Define all your procedures here.
* Group related procedures together for better organization.
* This router merges all sub-routers from the routers/ directory.
* Each domain (users, posts, etc.) has its own file for better organization.
*
* Example structure:
*
* export const appRouter = router({
* users: {
* getAll: publicProcedure.query(async () => { ... }),
* getById: publicProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { ... }),
* create: publicProcedure.input(z.object({ ... })).mutation(async ({ input }) => { ... }),
* update: publicProcedure.input(z.object({ ... })).mutation(async ({ input }) => { ... }),
* delete: publicProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => { ... }),
* },
* posts: {
* // ...
* },
* });
* 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({
// ============================================================================
// Example / Test Procedures
// Sub-Routers (organized by domain)
// ============================================================================
users: usersRouter,
posts: postsRouter,
// ============================================================================
// Global / Utility Procedures
// ============================================================================
hello: publicProcedure
.input(
z
@ -44,50 +39,6 @@ export const appRouter = router({
uptime: process.uptime(),
};
}),
// ============================================================================
// Your API Procedures
// ============================================================================
//
// Add your procedures here following this pattern:
//
// tableName: {
// getAll: publicProcedure.query(async () => {
// return await db.getAllRecords();
// }),
//
// getById: publicProcedure
// .input(z.object({ id: z.number() }))
// .query(async ({ input }) => {
// return await db.getRecordById(input.id);
// }),
//
// create: publicProcedure
// .input(z.object({
// name: z.string().min(1),
// status: z.string()
// }))
// .mutation(async ({ input }) => {
// return await db.createRecord(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.updateRecord(id, data);
// }),
//
// delete: publicProcedure
// .input(z.object({ id: z.number() }))
// .mutation(async ({ input }) => {
// return await db.deleteRecord(input.id);
// }),
// },
});
// Export type definition of API

View 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";

View 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;
}),
});

View 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;
}),
});