Compare commits
12 Commits
a0d233437d
...
0d74f211a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d74f211a9 | ||
|
|
507b345fab | ||
|
|
6ac7efd3ae | ||
|
|
b5458ccab5 | ||
| 03cb497565 | |||
|
|
64275cb90b | ||
|
|
ae532de920 | ||
|
|
fe89259ef8 | ||
|
|
369b201cfa | ||
|
|
853c434d75 | ||
| 455a55448a | |||
| 962f1d798f |
|
|
@ -1,9 +1 @@
|
||||||
{
|
{}
|
||||||
"dependencies": {
|
|
||||||
"@opencode-ai/plugin": "1.0.191",
|
|
||||||
"@types/micromatch": "^4.0.10",
|
|
||||||
"axios": "^1.13.2",
|
|
||||||
"micromatch": "^4.0.8",
|
|
||||||
"zod": "^4.1.13"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
|
||||||
import type { Plugin } from "@opencode-ai/plugin";
|
|
||||||
import { Axios } from "axios";
|
|
||||||
import { promises as fs } from "fs";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const { vmOrchestrationStatusUpdateUrl } = z
|
|
||||||
.object({
|
|
||||||
vmOrchestrationStatusUpdateUrl: z.string(),
|
|
||||||
})
|
|
||||||
.parse({
|
|
||||||
vmOrchestrationStatusUpdateUrl:
|
|
||||||
process.env.TAYLORDB_VM_ORCHESTRATION_STATUS_UPDATE_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
const axios = new Axios({
|
|
||||||
baseURL: vmOrchestrationStatusUpdateUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateAppStatus = async (status: "Errored" | "Active" | "Pending") => {
|
|
||||||
await axios.put(
|
|
||||||
"/",
|
|
||||||
JSON.stringify({
|
|
||||||
status,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sessionRetries: Record<string, number> = {};
|
|
||||||
|
|
||||||
export const BuildPlugin: Plugin = async ({ client, $ }) => {
|
|
||||||
return {
|
|
||||||
event: async ({ event }) => {
|
|
||||||
const isMessagedDone =
|
|
||||||
event.type === "message.updated" &&
|
|
||||||
// @ts-ignore
|
|
||||||
event.properties.info["finish"] === "stop";
|
|
||||||
|
|
||||||
if (!isMessagedDone) return;
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const error = event.properties.info["error"];
|
|
||||||
|
|
||||||
const isAbortionError = error && error.name === "MessageAbortedError";
|
|
||||||
|
|
||||||
const isAnyChange =
|
|
||||||
(await $`git status --porcelain`.quiet()).stdout.toString().trim() !==
|
|
||||||
"";
|
|
||||||
|
|
||||||
console.log({ isAnyChange });
|
|
||||||
|
|
||||||
if (!isAnyChange || isAbortionError) {
|
|
||||||
await updateAppStatus("Active");
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Building...");
|
|
||||||
|
|
||||||
const result = await $`pnpm build`.quiet().catch((error) => error);
|
|
||||||
|
|
||||||
if (result.exitCode !== 0) {
|
|
||||||
if (!sessionRetries[event.properties.info.sessionID]) {
|
|
||||||
sessionRetries[event.properties.info.sessionID] = 1;
|
|
||||||
} else {
|
|
||||||
sessionRetries[event.properties.info.sessionID]++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionRetries[event.properties.info.sessionID] > 3) {
|
|
||||||
await updateAppStatus("Errored");
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Retrying... ${sessionRetries[event.properties.info.sessionID]}`
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.session.promptAsync({
|
|
||||||
path: { id: event.properties.info.sessionID },
|
|
||||||
body: {
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `While building the project, the following error occurred:\n\n${result.stdout.toString()}\n\nPlease fix the error and try again.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionRetries[event.properties.info.sessionID] = 1;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const packageJson = JSON.parse(
|
|
||||||
await fs.readFile("package.json", "utf-8")
|
|
||||||
);
|
|
||||||
const [major, minor, patch] = packageJson.version
|
|
||||||
.split(".")
|
|
||||||
.map(Number);
|
|
||||||
const newVersion = `${major}.${minor}.${patch + 1}`;
|
|
||||||
|
|
||||||
const messages = await client.session.messages({
|
|
||||||
path: { id: event.properties.info.sessionID },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!messages.data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentMessage = messages.data
|
|
||||||
.reverse()
|
|
||||||
.find(
|
|
||||||
(message) =>
|
|
||||||
message.info.role === "user" &&
|
|
||||||
message.info.summary &&
|
|
||||||
message.info.summary.title
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!currentMessage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const commitMessage =
|
|
||||||
// @ts-ignore
|
|
||||||
currentMessage.info.summary?.["title"] ??
|
|
||||||
`feat: release version v${newVersion}`;
|
|
||||||
|
|
||||||
packageJson.version = newVersion;
|
|
||||||
|
|
||||||
await fs.writeFile(
|
|
||||||
"package.json",
|
|
||||||
JSON.stringify(packageJson, null, 2)
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("Pushing");
|
|
||||||
await $`git add .`.quiet();
|
|
||||||
await $`GIT_AUTHOR_NAME="Taylor AI" GIT_AUTHOR_EMAIL="ai@taylordb.io" GIT_COMMITTER_NAME="Taylor AI" GIT_COMMITTER_EMAIL="ai@taylordb.io" git commit -m ${commitMessage}`.quiet();
|
|
||||||
await $`git tag v${newVersion}`.quiet();
|
|
||||||
await $`git push origin main --tags`.quiet();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to push to git", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateAppStatus("Active");
|
|
||||||
},
|
|
||||||
|
|
||||||
"chat.message": async () => {
|
|
||||||
await updateAppStatus("Pending");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import type { Plugin } from "@opencode-ai/plugin";
|
|
||||||
import micromatch from "micromatch";
|
|
||||||
|
|
||||||
const uneditableFiles = [
|
|
||||||
".env",
|
|
||||||
".env.local",
|
|
||||||
".env.development",
|
|
||||||
".env.production",
|
|
||||||
"**/src/lib/**.ts",
|
|
||||||
"opencode.json",
|
|
||||||
"**/.opencode/**",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const FileProtectionPlugin: Plugin = async () => {
|
|
||||||
return {
|
|
||||||
"tool.execute.before": async (input, output) => {
|
|
||||||
if (
|
|
||||||
input.tool === "edit" &&
|
|
||||||
uneditableFiles.some((pattern) =>
|
|
||||||
micromatch.isMatch(output.args.filePath, pattern)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error(`Do not edit ${output.args.filePath} files`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -1,94 +1,125 @@
|
||||||
import { Moon, Sun } from "lucide-react";
|
import { Moon, Sun, Database, Palette } from "lucide-react";
|
||||||
import { useEffect, useState } from "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 { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
const navItems = [
|
const themes = [
|
||||||
{ to: "/", label: "Home" },
|
{ id: "purple", name: "Purple", color: "hsl(270 80% 60%)" },
|
||||||
{ to: "/about", label: "About" },
|
{ 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" => {
|
type ThemeId = (typeof themes)[number]["id"];
|
||||||
if (typeof window === "undefined") {
|
type Mode = "light" | "dark";
|
||||||
return "light";
|
|
||||||
}
|
const getInitialTheme = (): ThemeId => {
|
||||||
const stored = localStorage.getItem("theme");
|
if (typeof window === "undefined") return "purple";
|
||||||
if (stored === "light" || stored === "dark") {
|
const stored = localStorage.getItem("color-theme");
|
||||||
return stored;
|
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
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
? "dark"
|
? "dark"
|
||||||
: "light";
|
: "light";
|
||||||
};
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [theme, setTheme] = useState<"light" | "dark">(getInitialTheme);
|
const [theme, setTheme] = useState<ThemeId>(getInitialTheme);
|
||||||
|
const [mode, setMode] = useState<Mode>(getInitialMode);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.toggle("dark", theme === "dark");
|
|
||||||
localStorage.setItem("theme", theme);
|
// Remove existing theme classes
|
||||||
}, [theme]);
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<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="container flex h-16 items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-base font-semibold">TaylorDB Starter</span>
|
<div className="p-2 rounded-lg bg-gradient-to-br from-primary to-accent">
|
||||||
<nav className="flex items-center gap-1">
|
<Database className="h-5 w-5 text-white" />
|
||||||
{navItems.map((item) => (
|
</div>
|
||||||
<NavLink
|
<span className="text-lg font-bold gradient-text">TaylorDB</span>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label="Toggle theme"
|
aria-label="Toggle dark mode"
|
||||||
onClick={() =>
|
className="rounded-full"
|
||||||
setTheme((prev) => (prev === "dark" ? "light" : "dark"))
|
onClick={() => setMode((m) => (m === "dark" ? "light" : "dark"))}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{theme === "dark" ? (
|
{mode === "dark" ? (
|
||||||
<Sun className="h-4 w-4" />
|
<Sun className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Moon className="h-4 w-4" />
|
<Moon className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="container py-8">
|
<main>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
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";
|
||||||
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,
|
||||||
|
};
|
||||||
|
|
@ -29,49 +29,295 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
/* ========================================================================
|
||||||
--background: 0 0% 100%;
|
THEME: Purple (Default) - Vibrant purple with teal & pink accents
|
||||||
--foreground: 222.2 84% 4.9%;
|
======================================================================== */
|
||||||
|
:root,
|
||||||
|
.theme-purple {
|
||||||
|
--background: 270 50% 98%;
|
||||||
|
--foreground: 270 50% 10%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 270 50% 10%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 270 50% 10%;
|
||||||
--primary: 221.2 83.2% 53.3%;
|
--primary: 270 80% 60%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 175 60% 45%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 0 0% 100%;
|
||||||
--muted: 210 40% 96.1%;
|
--muted: 270 30% 94%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted-foreground: 270 20% 45%;
|
||||||
--accent: 210 40% 96.1%;
|
--accent: 330 80% 65%;
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
--accent-foreground: 0 0% 100%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 75% 55%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 270 30% 88%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 270 30% 88%;
|
||||||
--ring: 221.2 83.2% 53.3%;
|
--ring: 270 80% 60%;
|
||||||
--radius: 0.75rem;
|
--radius: 1rem;
|
||||||
|
--theme-gradient: linear-gradient(135deg, hsl(270 80% 60%) 0%, hsl(330 85% 60%) 50%, hsl(175 70% 50%) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark.theme-purple,
|
||||||
--background: 222.2 84% 4.9%;
|
.dark:not([class*="theme-"]) {
|
||||||
--foreground: 210 40% 98%;
|
--background: 270 40% 8%;
|
||||||
--card: 222.2 84% 4.9%;
|
--foreground: 270 20% 95%;
|
||||||
--card-foreground: 210 40% 98%;
|
--card: 270 40% 12%;
|
||||||
--popover: 222.2 84% 4.9%;
|
--card-foreground: 270 20% 95%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover: 270 40% 12%;
|
||||||
--primary: 217.2 91.2% 59.8%;
|
--popover-foreground: 270 20% 95%;
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary: 270 80% 65%;
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--primary-foreground: 270 40% 8%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary: 175 70% 50%;
|
||||||
--muted: 217.2 32.6% 17.5%;
|
--secondary-foreground: 270 40% 8%;
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--muted: 270 30% 18%;
|
||||||
--accent: 217.2 32.6% 17.5%;
|
--muted-foreground: 270 15% 65%;
|
||||||
--accent-foreground: 210 40% 98%;
|
--accent: 330 85% 60%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--accent-foreground: 0 0% 100%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive: 0 70% 50%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--border: 270 30% 22%;
|
||||||
--ring: 224.3 76.3% 48%;
|
--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 {
|
@keyframes accordion-down {
|
||||||
from {
|
from { height: 0; }
|
||||||
height: 0;
|
to { height: var(--radix-accordion-content-height); }
|
||||||
}
|
|
||||||
to {
|
|
||||||
height: var(--radix-accordion-content-height);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes accordion-up {
|
@keyframes accordion-up {
|
||||||
from {
|
from { height: var(--radix-accordion-content-height); }
|
||||||
height: var(--radix-accordion-content-height);
|
to { height: 0; }
|
||||||
}
|
|
||||||
to {
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -14,10 +14,10 @@ export const trpc: CreateTRPCReact<AppRouter, unknown> =
|
||||||
function getBaseUrl() {
|
function getBaseUrl() {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
// Browser: use relative URL or environment variable
|
// 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
|
// SSR: assume localhost
|
||||||
return "http://localhost:3001";
|
return "http://localhost:3001/api";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import AboutPage from "./pages/AboutPage";
|
|
||||||
import HomePage from "./pages/HomePage";
|
|
||||||
import NotFoundPage from "./pages/NotFoundPage";
|
|
||||||
import TRPCDemoPage from "./pages/TRPCDemoPage";
|
import TRPCDemoPage from "./pages/TRPCDemoPage";
|
||||||
|
import NotFoundPage from "./pages/NotFoundPage";
|
||||||
import { trpc, trpcClient } from "./lib/trpc";
|
import { trpc, trpcClient } from "./lib/trpc";
|
||||||
|
|
||||||
// Create a React Query client
|
// Create a React Query client
|
||||||
|
|
@ -24,11 +22,7 @@ const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
element: <App />,
|
element: <App />,
|
||||||
children: [
|
children: [{ index: true, element: <TRPCDemoPage /> }],
|
||||||
{ index: true, element: <HomePage /> },
|
|
||||||
{ path: "about", element: <AboutPage /> },
|
|
||||||
{ path: "trpc-demo", element: <TRPCDemoPage /> },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{ path: "*", element: <NotFoundPage /> },
|
{ path: "*", element: <NotFoundPage /> },
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -24,4 +24,3 @@ const NotFoundPage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default NotFoundPage;
|
export default NotFoundPage;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,128 +1,41 @@
|
||||||
import { useState } from "react";
|
import { Sparkles } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
HelloExample,
|
||||||
CardContent,
|
UsersExample,
|
||||||
CardDescription,
|
PostsExample,
|
||||||
CardHeader,
|
} from "@/components/demo/examples";
|
||||||
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";
|
|
||||||
|
|
||||||
export default function TRPCDemoPage() {
|
export default function TRPCDemoPage() {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-8 max-w-4xl">
|
<div className="min-h-screen gradient-bg">
|
||||||
<div className="mb-8">
|
<div className="container mx-auto p-8 max-w-4xl">
|
||||||
<h1 className="text-4xl font-bold mb-2">tRPC + TaylorDB Demo</h1>
|
{/* Hero Header */}
|
||||||
<p className="text-muted-foreground">
|
<header className="mb-12 text-center">
|
||||||
Example of type-safe API calls with tRPC
|
<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">
|
||||||
</p>
|
<Sparkles className="h-4 w-4" />
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
<div className="grid gap-6">
|
{/* Demo Examples */}
|
||||||
{/* Info Alert */}
|
<main className="grid gap-8">
|
||||||
<Alert>
|
<HelloExample />
|
||||||
<InfoIcon className="h-4 w-4" />
|
<UsersExample />
|
||||||
<AlertTitle>Template Example</AlertTitle>
|
<PostsExample />
|
||||||
<AlertDescription>
|
</main>
|
||||||
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 */}
|
{/* Footer */}
|
||||||
<HelloExample />
|
<footer className="mt-12 text-center text-sm text-muted-foreground">
|
||||||
|
<p>Built with 💜 using tRPC, React Query & TaylorDB</p>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</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
|
|
||||||
*/
|
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ export default defineConfig({
|
||||||
host: true,
|
host: true,
|
||||||
port: 5173,
|
port: 5173,
|
||||||
hmr: {
|
hmr: {
|
||||||
|
host: "",
|
||||||
protocol: "wss",
|
protocol: "wss",
|
||||||
clientPort: 443,
|
clientPort: 443,
|
||||||
path: "/__vite_hmr",
|
path: "/__vite_hmr",
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch index.ts",
|
"dev": "tsx watch index.ts",
|
||||||
"build": "tsc",
|
"build": "pnpm exec esbuild index.ts --bundle --outfile=dist/index.js --format=esm --platform=node --packages=external",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
|
"esbuild": "^0.27.2",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "~5.9.3"
|
"typescript": "~5.9.3"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,29 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { router, publicProcedure } from "./trpc";
|
import { router, publicProcedure } from "./trpc";
|
||||||
// import * as db from "./taylordb/query-builder";
|
import { usersRouter, postsRouter } from "./routers";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main tRPC Router
|
* Main tRPC Router
|
||||||
*
|
*
|
||||||
* This is your main API router. Define all your procedures here.
|
* This router merges all sub-routers from the routers/ directory.
|
||||||
* Group related procedures together for better organization.
|
* Each domain (users, posts, etc.) has its own file for better organization.
|
||||||
*
|
*
|
||||||
* Example structure:
|
* To add a new domain:
|
||||||
*
|
* 1. Create a new file in routers/ (e.g., routers/comments.ts)
|
||||||
* export const appRouter = router({
|
* 2. Export the router from routers/index.ts
|
||||||
* users: {
|
* 3. Import and add it below
|
||||||
* 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: {
|
|
||||||
* // ...
|
|
||||||
* },
|
|
||||||
* });
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Example / Test Procedures
|
// Sub-Routers (organized by domain)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
users: usersRouter,
|
||||||
|
posts: postsRouter,
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Global / Utility Procedures
|
||||||
|
// ============================================================================
|
||||||
hello: publicProcedure
|
hello: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
z
|
z
|
||||||
|
|
@ -44,50 +39,6 @@ export const appRouter = router({
|
||||||
uptime: process.uptime(),
|
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
|
// Export type definition of API
|
||||||
|
|
|
||||||
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;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
@ -166,6 +166,9 @@ importers:
|
||||||
concurrently:
|
concurrently:
|
||||||
specifier: ^9.2.1
|
specifier: ^9.2.1
|
||||||
version: 9.2.1
|
version: 9.2.1
|
||||||
|
esbuild:
|
||||||
|
specifier: ^0.27.2
|
||||||
|
version: 0.27.2
|
||||||
tsx:
|
tsx:
|
||||||
specifier: ^4.21.0
|
specifier: ^4.21.0
|
||||||
version: 4.21.0
|
version: 4.21.0
|
||||||
|
|
|
||||||
33
taylordb.yml
33
taylordb.yml
|
|
@ -24,6 +24,26 @@ services:
|
||||||
TAYLORDB_API_TOKEN: secrets.TAYLORDB_API_TOKEN
|
TAYLORDB_API_TOKEN: secrets.TAYLORDB_API_TOKEN
|
||||||
TAYLORDB_SERVER_ID: vars.TAYLORDB_SERVER_ID
|
TAYLORDB_SERVER_ID: vars.TAYLORDB_SERVER_ID
|
||||||
FRONTEND_URL: routing.client.url
|
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:
|
client:
|
||||||
workDir: apps/client
|
workDir: apps/client
|
||||||
|
|
@ -36,6 +56,19 @@ services:
|
||||||
env:
|
env:
|
||||||
vars:
|
vars:
|
||||||
VITE_TRPC_URL: routing.server.url
|
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:
|
taylordb:
|
||||||
types:
|
types:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user