From 455a55448a302a95733e13c6fb78b0a2ab9c5f15 Mon Sep 17 00:00:00 2001 From: Thet Aung Date: Fri, 9 Jan 2026 12:06:01 +0300 Subject: [PATCH] feat: better component abstractions --- apps/client/src/components/demo/Avatar.tsx | 11 + .../src/components/demo/CodePreview.tsx | 14 + apps/client/src/components/demo/DemoCard.tsx | 43 ++ .../client/src/components/demo/EmptyState.tsx | 7 + apps/client/src/components/demo/ItemRow.tsx | 14 + .../src/components/demo/LoadingSpinner.tsx | 31 ++ .../src/components/demo/StatusBadge.tsx | 20 + .../components/demo/examples/HelloExample.tsx | 48 ++ .../components/demo/examples/PostsExample.tsx | 209 +++++++++ .../components/demo/examples/UsersExample.tsx | 241 ++++++++++ .../src/components/demo/examples/index.ts | 3 + apps/client/src/components/demo/index.ts | 7 + apps/client/src/index.css | 2 +- apps/client/src/pages/TRPCDemoPage.tsx | 434 +----------------- 14 files changed, 662 insertions(+), 422 deletions(-) create mode 100644 apps/client/src/components/demo/Avatar.tsx create mode 100644 apps/client/src/components/demo/CodePreview.tsx create mode 100644 apps/client/src/components/demo/DemoCard.tsx create mode 100644 apps/client/src/components/demo/EmptyState.tsx create mode 100644 apps/client/src/components/demo/ItemRow.tsx create mode 100644 apps/client/src/components/demo/LoadingSpinner.tsx create mode 100644 apps/client/src/components/demo/StatusBadge.tsx create mode 100644 apps/client/src/components/demo/examples/HelloExample.tsx create mode 100644 apps/client/src/components/demo/examples/PostsExample.tsx create mode 100644 apps/client/src/components/demo/examples/UsersExample.tsx create mode 100644 apps/client/src/components/demo/examples/index.ts create mode 100644 apps/client/src/components/demo/index.ts diff --git a/apps/client/src/components/demo/Avatar.tsx b/apps/client/src/components/demo/Avatar.tsx new file mode 100644 index 0000000..b847ef7 --- /dev/null +++ b/apps/client/src/components/demo/Avatar.tsx @@ -0,0 +1,11 @@ +interface AvatarProps { + name: string; +} + +export function Avatar({ name }: AvatarProps) { + return ( +
+ {name.charAt(0).toUpperCase()} +
+ ); +} diff --git a/apps/client/src/components/demo/CodePreview.tsx b/apps/client/src/components/demo/CodePreview.tsx new file mode 100644 index 0000000..d021781 --- /dev/null +++ b/apps/client/src/components/demo/CodePreview.tsx @@ -0,0 +1,14 @@ +interface CodePreviewProps { + data: unknown; +} + +export function CodePreview({ data }: CodePreviewProps) { + return ( +
+
+
+        {JSON.stringify(data, null, 2)}
+      
+
+ ); +} diff --git a/apps/client/src/components/demo/DemoCard.tsx b/apps/client/src/components/demo/DemoCard.tsx new file mode 100644 index 0000000..87f549f --- /dev/null +++ b/apps/client/src/components/demo/DemoCard.tsx @@ -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 ( + + +
+
+ +
+
+ {title} + {description} +
+
+
+ {children} +
+ ); +} diff --git a/apps/client/src/components/demo/EmptyState.tsx b/apps/client/src/components/demo/EmptyState.tsx new file mode 100644 index 0000000..2144fd2 --- /dev/null +++ b/apps/client/src/components/demo/EmptyState.tsx @@ -0,0 +1,7 @@ +interface EmptyStateProps { + message: string; +} + +export function EmptyState({ message }: EmptyStateProps) { + return

{message}

; +} diff --git a/apps/client/src/components/demo/ItemRow.tsx b/apps/client/src/components/demo/ItemRow.tsx new file mode 100644 index 0000000..7263a22 --- /dev/null +++ b/apps/client/src/components/demo/ItemRow.tsx @@ -0,0 +1,14 @@ +interface ItemRowProps { + children: React.ReactNode; + className?: string; +} + +export function ItemRow({ children, className = "" }: ItemRowProps) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/client/src/components/demo/LoadingSpinner.tsx b/apps/client/src/components/demo/LoadingSpinner.tsx new file mode 100644 index 0000000..a4788f1 --- /dev/null +++ b/apps/client/src/components/demo/LoadingSpinner.tsx @@ -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 ( +
+ +
+ ); +} + +export function InlineSpinner({ size = "sm" }: { size?: "sm" | "md" }) { + return ; +} diff --git a/apps/client/src/components/demo/StatusBadge.tsx b/apps/client/src/components/demo/StatusBadge.tsx new file mode 100644 index 0000000..98d08db --- /dev/null +++ b/apps/client/src/components/demo/StatusBadge.tsx @@ -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 ( + + {children} + + ); +} diff --git a/apps/client/src/components/demo/examples/HelloExample.tsx b/apps/client/src/components/demo/examples/HelloExample.tsx new file mode 100644 index 0000000..f60866d --- /dev/null +++ b/apps/client/src/components/demo/examples/HelloExample.tsx @@ -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 ( + +
+ setName(e.target.value)} + placeholder="Enter your name..." + className="flex-1" + /> + +
+ {data && } +
+ ); +} diff --git a/apps/client/src/components/demo/examples/PostsExample.tsx b/apps/client/src/components/demo/examples/PostsExample.tsx new file mode 100644 index 0000000..d0c9427 --- /dev/null +++ b/apps/client/src/components/demo/examples/PostsExample.tsx @@ -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 ( + + {/* Create Form */} + + + {/* Posts List */} + {isLoading ? ( + + ) : ( +
+ {posts?.length === 0 && ( + + )} + {posts?.map((post) => ( + publishMutation.mutate({ id: post.id })} + onDelete={() => deleteMutation.mutate({ id: post.id })} + /> + ))} +
+ )} +
+ ); +} + +// ============================================================================ +// 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 ( +
+
+ onTitleChange(e.target.value)} + placeholder="Post title..." + className="flex-1" + /> +
+ + onAuthorIdChange(e.target.value)} + type="number" + /> +
+
+
+ onContentChange(e.target.value)} + placeholder="Write something amazing..." + className="flex-1" + /> + +
+
+ ); +} + +interface PostCardProps { + post: Post; + onPublish: () => void; + onDelete: () => void; +} + +function PostCard({ post, onPublish, onDelete }: PostCardProps) { + return ( +
+
+
+
+

{post.title}

+ + {post.published ? "Published" : "Draft"} + +
+

+ {post.content} +

+
+
+ {!post.published && ( + + )} + +
+
+
+ ); +} diff --git a/apps/client/src/components/demo/examples/UsersExample.tsx b/apps/client/src/components/demo/examples/UsersExample.tsx new file mode 100644 index 0000000..30f23a1 --- /dev/null +++ b/apps/client/src/components/demo/examples/UsersExample.tsx @@ -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(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 ( + + {/* Create Form */} + + + {/* Users List */} + {isLoading ? ( + + ) : ( +
+ {users?.length === 0 && ( + + )} + {users?.map((user) => ( + startEditing(user)} + onCancelEdit={() => setEditingId(null)} + onSaveEdit={() => handleUpdate(user.id)} + onDelete={() => deleteMutation.mutate({ id: user.id })} + /> + ))} +
+ )} +
+ ); +} + +// ============================================================================ +// 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 ( +
+ onNameChange(e.target.value)} + placeholder="Name" + className="flex-1" + /> + onEmailChange(e.target.value)} + placeholder="Email" + className="flex-1" + /> + +
+ ); +} + +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 ( + + onEditNameChange(e.target.value)} + className="flex-1" + /> + onEditEmailChange(e.target.value)} + className="flex-1" + /> + + + + ); + } + + return ( + + +
+
{user.name}
+
{user.email}
+
+ + +
+ ); +} diff --git a/apps/client/src/components/demo/examples/index.ts b/apps/client/src/components/demo/examples/index.ts new file mode 100644 index 0000000..7f0b425 --- /dev/null +++ b/apps/client/src/components/demo/examples/index.ts @@ -0,0 +1,3 @@ +export { HelloExample } from "./HelloExample"; +export { UsersExample } from "./UsersExample"; +export { PostsExample } from "./PostsExample"; diff --git a/apps/client/src/components/demo/index.ts b/apps/client/src/components/demo/index.ts new file mode 100644 index 0000000..92c494c --- /dev/null +++ b/apps/client/src/components/demo/index.ts @@ -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"; diff --git a/apps/client/src/index.css b/apps/client/src/index.css index 22bb1ea..599a227 100644 --- a/apps/client/src/index.css +++ b/apps/client/src/index.css @@ -362,7 +362,7 @@ } .card-hover:hover { - box-shadow: 0 20px 40px -12px hsl(var(--primary) / 0.25); + box-shadow: 0 10px 20px -12px hsl(var(--primary) / 0.25); } /* Glassmorphism card */ diff --git a/apps/client/src/pages/TRPCDemoPage.tsx b/apps/client/src/pages/TRPCDemoPage.tsx index 77d3cbf..f864a03 100644 --- a/apps/client/src/pages/TRPCDemoPage.tsx +++ b/apps/client/src/pages/TRPCDemoPage.tsx @@ -1,34 +1,16 @@ -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 { - Loader2, - Plus, - Trash2, - Edit2, - Check, - X, - Sparkles, - Users, - FileText, - Zap, -} from "lucide-react"; + HelloExample, + UsersExample, + PostsExample, +} from "@/components/demo/examples"; export default function TRPCDemoPage() { return (
{/* Hero Header */} -
+
Type-safe API Demo @@ -40,410 +22,20 @@ export default function TRPCDemoPage() { Experience the power of end-to-end type safety with a beautiful, modern interface

-
+
-
+ {/* Demo Examples */} +
-
+ {/* Footer */} -
-

Built with 💜 using tRPC, React Query & TaylorDB

-
+
+

Built with 💜 using tRPC, React Query & TaylorDB

+
); } - -// ============================================================================ -// Hello Query Example -// ============================================================================ - -function HelloExample() { - const [name, setName] = useState(""); - const { data, isLoading, refetch } = trpc.hello.useQuery( - { name: name || undefined }, - { enabled: false } - ); - - return ( - - -
-
- -
-
- Hello Query - - Simple query to test the connection - -
-
-
- -
- setName(e.target.value)} - placeholder="Enter your name..." - className="flex-1" - /> - -
- {data && ( -
-
-
-              {JSON.stringify(data, null, 2)}
-            
-
- )} - - - ); -} - -// ============================================================================ -// Users CRUD Example -// ============================================================================ - -function UsersExample() { - const [newName, setNewName] = useState(""); - const [newEmail, setNewEmail] = useState(""); - const [editingId, setEditingId] = useState(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: { id: number; name: string; email: string }) => { - setEditingId(user.id); - setEditName(user.name); - setEditEmail(user.email); - }; - - return ( - - -
-
- -
-
- Users - Full CRUD operations example -
-
-
- - {/* Create Form */} -
- setNewName(e.target.value)} - placeholder="Name" - className="flex-1" - /> - setNewEmail(e.target.value)} - placeholder="Email" - className="flex-1" - /> - -
- - {/* Users List */} - {isLoading ? ( -
- -
- ) : ( -
- {users?.length === 0 && ( -

- No users yet. Create one above! -

- )} - {users?.map((user) => ( -
- {editingId === user.id ? ( - <> - setEditName(e.target.value)} - className="flex-1" - /> - setEditEmail(e.target.value)} - className="flex-1" - /> - - - - ) : ( - <> -
- {user.name.charAt(0).toUpperCase()} -
-
-
{user.name}
-
- {user.email} -
-
- - - - )} -
- ))} -
- )} -
-
- ); -} - -// ============================================================================ -// Posts Example -// ============================================================================ - -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(), - }); - - return ( - - -
-
- -
-
- Posts - With publish action and filtering -
-
-
- - {/* Create Form */} -
-
- setTitle(e.target.value)} - placeholder="Post title..." - className="flex-1" - /> -
- - setAuthorId(e.target.value)} - type="number" - /> -
-
-
- setContent(e.target.value)} - placeholder="Write something amazing..." - className="flex-1" - /> - -
-
- - {/* Posts List */} - {isLoading ? ( -
- -
- ) : ( -
- {posts?.length === 0 && ( -

- No posts yet. Create your first post! -

- )} - {posts?.map((post) => ( -
-
-
-
-

{post.title}

- - {post.published ? "Published" : "Draft"} - -
-

- {post.content} -

-
-
- {!post.published && ( - - )} - -
-
-
- ))} -
- )} -
-
- ); -}