feat: better component abstractions

This commit is contained in:
Thet Aung 2026-01-09 12:06:01 +03:00
parent 962f1d798f
commit 455a55448a
14 changed files with 662 additions and 422 deletions

View File

@ -0,0 +1,11 @@
interface AvatarProps {
name: string;
}
export function Avatar({ name }: AvatarProps) {
return (
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-primary to-accent flex items-center justify-center text-white text-sm font-medium">
{name.charAt(0).toUpperCase()}
</div>
);
}

View File

@ -0,0 +1,14 @@
interface CodePreviewProps {
data: unknown;
}
export function CodePreview({ data }: CodePreviewProps) {
return (
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-r from-primary/5 via-secondary/5 to-accent/5 rounded-lg" />
<pre className="relative p-4 rounded-lg text-sm font-mono overflow-x-auto">
{JSON.stringify(data, null, 2)}
</pre>
</div>
);
}

View File

@ -0,0 +1,43 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { LucideIcon } from "lucide-react";
interface DemoCardProps {
title: string;
description: string;
icon: LucideIcon;
iconColorClass?: string;
glowClass?: string;
children: React.ReactNode;
}
export function DemoCard({
title,
description,
icon: Icon,
iconColorClass = "bg-primary/10 text-primary",
glowClass = "glow-primary",
children,
}: DemoCardProps) {
return (
<Card className="card-hover glass-card overflow-hidden">
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${iconColorClass} ${glowClass}`}>
<Icon className="h-5 w-5" />
</div>
<div>
<CardTitle className="text-xl">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">{children}</CardContent>
</Card>
);
}

View File

@ -0,0 +1,7 @@
interface EmptyStateProps {
message: string;
}
export function EmptyState({ message }: EmptyStateProps) {
return <p className="text-center text-muted-foreground py-4">{message}</p>;
}

View File

@ -0,0 +1,14 @@
interface ItemRowProps {
children: React.ReactNode;
className?: string;
}
export function ItemRow({ children, className = "" }: ItemRowProps) {
return (
<div
className={`item-row flex items-center gap-3 p-4 rounded-lg bg-muted/30 border border-border/50 ${className}`}
>
{children}
</div>
);
}

View File

@ -0,0 +1,31 @@
import { Loader2 } from "lucide-react";
interface LoadingSpinnerProps {
size?: "sm" | "md" | "lg";
colorClass?: string;
className?: string;
}
const sizeClasses = {
sm: "h-4 w-4",
md: "h-6 w-6",
lg: "h-8 w-8",
};
export function LoadingSpinner({
size = "lg",
colorClass = "text-primary",
className = "",
}: LoadingSpinnerProps) {
return (
<div className={`flex justify-center py-8 ${className}`}>
<Loader2
className={`animate-spin pulse-glow ${sizeClasses[size]} ${colorClass}`}
/>
</div>
);
}
export function InlineSpinner({ size = "sm" }: { size?: "sm" | "md" }) {
return <Loader2 className={`animate-spin ${sizeClasses[size]}`} />;
}

View File

@ -0,0 +1,20 @@
interface StatusBadgeProps {
status: "success" | "warning" | "info";
children: React.ReactNode;
}
const badgeClasses = {
success: "badge-success",
warning: "badge-warning",
info: "bg-blue-500/10 text-blue-500",
};
export function StatusBadge({ status, children }: StatusBadgeProps) {
return (
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${badgeClasses[status]}`}
>
{children}
</span>
);
}

View File

@ -0,0 +1,48 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { trpc } from "@/lib/trpc";
import { Sparkles, Zap } from "lucide-react";
import { DemoCard, InlineSpinner, CodePreview } from "@/components/demo";
export function HelloExample() {
const [name, setName] = useState("");
const { data, isLoading, refetch } = trpc.hello.useQuery(
{ name: name || undefined },
{ enabled: false }
);
return (
<DemoCard
title="Hello Query"
description="Simple query to test the connection"
icon={Zap}
iconColorClass="bg-primary/10 text-primary"
glowClass="glow-primary"
>
<div className="flex gap-3">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name..."
className="flex-1"
/>
<Button
onClick={() => refetch()}
disabled={isLoading}
className="min-w-[100px]"
>
{isLoading ? (
<InlineSpinner />
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
Send
</>
)}
</Button>
</div>
{data && <CodePreview data={data} />}
</DemoCard>
);
}

View File

@ -0,0 +1,209 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { trpc } from "@/lib/trpc";
import { FileText, Plus, Sparkles, Trash2 } from "lucide-react";
import {
DemoCard,
LoadingSpinner,
InlineSpinner,
EmptyState,
StatusBadge,
} from "@/components/demo";
interface Post {
id: number;
title: string;
content: string | null;
published: boolean;
}
export function PostsExample() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [authorId, setAuthorId] = useState("1");
const utils = trpc.useUtils();
const { data: posts, isLoading } = trpc.posts.getAll.useQuery();
const createMutation = trpc.posts.create.useMutation({
onSuccess: () => {
utils.posts.getAll.invalidate();
setTitle("");
setContent("");
},
});
const publishMutation = trpc.posts.publish.useMutation({
onSuccess: () => utils.posts.getAll.invalidate(),
});
const deleteMutation = trpc.posts.delete.useMutation({
onSuccess: () => utils.posts.getAll.invalidate(),
});
const handleCreate = () => {
createMutation.mutate({
title,
content,
authorId: parseInt(authorId),
published: false,
});
};
return (
<DemoCard
title="Posts"
description="With publish action and filtering"
icon={FileText}
iconColorClass="bg-accent/10 text-accent"
glowClass="glow-accent"
>
{/* Create Form */}
<CreatePostForm
title={title}
content={content}
authorId={authorId}
onTitleChange={setTitle}
onContentChange={setContent}
onAuthorIdChange={setAuthorId}
onSubmit={handleCreate}
isLoading={createMutation.isPending}
/>
{/* Posts List */}
{isLoading ? (
<LoadingSpinner colorClass="text-accent" />
) : (
<div className="space-y-3">
{posts?.length === 0 && (
<EmptyState message="No posts yet. Create your first post!" />
)}
{posts?.map((post) => (
<PostCard
key={post.id}
post={post}
onPublish={() => publishMutation.mutate({ id: post.id })}
onDelete={() => deleteMutation.mutate({ id: post.id })}
/>
))}
</div>
)}
</DemoCard>
);
}
// ============================================================================
// Sub-components
// ============================================================================
interface CreatePostFormProps {
title: string;
content: string;
authorId: string;
onTitleChange: (value: string) => void;
onContentChange: (value: string) => void;
onAuthorIdChange: (value: string) => void;
onSubmit: () => void;
isLoading: boolean;
}
function CreatePostForm({
title,
content,
authorId,
onTitleChange,
onContentChange,
onAuthorIdChange,
onSubmit,
isLoading,
}: CreatePostFormProps) {
return (
<div className="space-y-3">
<div className="flex gap-3">
<Input
value={title}
onChange={(e) => onTitleChange(e.target.value)}
placeholder="Post title..."
className="flex-1"
/>
<div className="w-28">
<Label className="text-xs text-muted-foreground mb-1 block">
Author ID
</Label>
<Input
value={authorId}
onChange={(e) => onAuthorIdChange(e.target.value)}
type="number"
/>
</div>
</div>
<div className="flex gap-3">
<Input
value={content}
onChange={(e) => onContentChange(e.target.value)}
placeholder="Write something amazing..."
className="flex-1"
/>
<Button onClick={onSubmit} disabled={!title || !content || isLoading}>
{isLoading ? (
<InlineSpinner />
) : (
<>
<Plus className="h-4 w-4 mr-2" />
Create
</>
)}
</Button>
</div>
</div>
);
}
interface PostCardProps {
post: Post;
onPublish: () => void;
onDelete: () => void;
}
function PostCard({ post, onPublish, onDelete }: PostCardProps) {
return (
<div className="item-row p-4 rounded-lg bg-muted/30 border border-border/50">
<div className="flex items-start gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold truncate">{post.title}</h3>
<StatusBadge status={post.published ? "success" : "warning"}>
{post.published ? "Published" : "Draft"}
</StatusBadge>
</div>
<p className="text-sm text-muted-foreground line-clamp-2">
{post.content}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{!post.published && (
<Button
size="sm"
variant="outline"
className="text-secondary border-secondary/30 hover:bg-secondary/10"
onClick={onPublish}
>
<Sparkles className="h-3 w-3 mr-1" />
Publish
</Button>
)}
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,241 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { trpc } from "@/lib/trpc";
import { Users, Plus, Edit2, Check, X, Trash2 } from "lucide-react";
import {
DemoCard,
LoadingSpinner,
InlineSpinner,
EmptyState,
ItemRow,
Avatar,
} from "@/components/demo";
interface User {
id: number;
name: string;
email: string;
}
export function UsersExample() {
const [newName, setNewName] = useState("");
const [newEmail, setNewEmail] = useState("");
const [editingId, setEditingId] = useState<number | null>(null);
const [editName, setEditName] = useState("");
const [editEmail, setEditEmail] = useState("");
const utils = trpc.useUtils();
const { data: users, isLoading } = trpc.users.getAll.useQuery();
const createMutation = trpc.users.create.useMutation({
onSuccess: () => {
utils.users.getAll.invalidate();
setNewName("");
setNewEmail("");
},
});
const updateMutation = trpc.users.update.useMutation({
onSuccess: () => {
utils.users.getAll.invalidate();
setEditingId(null);
},
});
const deleteMutation = trpc.users.delete.useMutation({
onSuccess: () => utils.users.getAll.invalidate(),
});
const startEditing = (user: User) => {
setEditingId(user.id);
setEditName(user.name);
setEditEmail(user.email);
};
const handleCreate = () => {
createMutation.mutate({ name: newName, email: newEmail });
};
const handleUpdate = (id: number) => {
updateMutation.mutate({ id, name: editName, email: editEmail });
};
return (
<DemoCard
title="Users"
description="Full CRUD operations example"
icon={Users}
iconColorClass="bg-secondary/10 text-secondary"
glowClass="glow-secondary"
>
{/* Create Form */}
<CreateUserForm
name={newName}
email={newEmail}
onNameChange={setNewName}
onEmailChange={setNewEmail}
onSubmit={handleCreate}
isLoading={createMutation.isPending}
/>
{/* Users List */}
{isLoading ? (
<LoadingSpinner colorClass="text-secondary" />
) : (
<div className="space-y-2">
{users?.length === 0 && (
<EmptyState message="No users yet. Create one above!" />
)}
{users?.map((user) => (
<UserRow
key={user.id}
user={user}
isEditing={editingId === user.id}
editName={editName}
editEmail={editEmail}
onEditNameChange={setEditName}
onEditEmailChange={setEditEmail}
onStartEdit={() => startEditing(user)}
onCancelEdit={() => setEditingId(null)}
onSaveEdit={() => handleUpdate(user.id)}
onDelete={() => deleteMutation.mutate({ id: user.id })}
/>
))}
</div>
)}
</DemoCard>
);
}
// ============================================================================
// Sub-components
// ============================================================================
interface CreateUserFormProps {
name: string;
email: string;
onNameChange: (value: string) => void;
onEmailChange: (value: string) => void;
onSubmit: () => void;
isLoading: boolean;
}
function CreateUserForm({
name,
email,
onNameChange,
onEmailChange,
onSubmit,
isLoading,
}: CreateUserFormProps) {
return (
<div className="flex gap-3">
<Input
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="Name"
className="flex-1"
/>
<Input
value={email}
onChange={(e) => onEmailChange(e.target.value)}
placeholder="Email"
className="flex-1"
/>
<Button
onClick={onSubmit}
disabled={!name || !email || isLoading}
size="icon"
className="shrink-0"
>
{isLoading ? <InlineSpinner /> : <Plus className="h-4 w-4" />}
</Button>
</div>
);
}
interface UserRowProps {
user: User;
isEditing: boolean;
editName: string;
editEmail: string;
onEditNameChange: (value: string) => void;
onEditEmailChange: (value: string) => void;
onStartEdit: () => void;
onCancelEdit: () => void;
onSaveEdit: () => void;
onDelete: () => void;
}
function UserRow({
user,
isEditing,
editName,
editEmail,
onEditNameChange,
onEditEmailChange,
onStartEdit,
onCancelEdit,
onSaveEdit,
onDelete,
}: UserRowProps) {
if (isEditing) {
return (
<ItemRow>
<Input
value={editName}
onChange={(e) => onEditNameChange(e.target.value)}
className="flex-1"
/>
<Input
value={editEmail}
onChange={(e) => onEditEmailChange(e.target.value)}
className="flex-1"
/>
<Button
size="icon"
variant="ghost"
className="text-green-500 hover:text-green-600 hover:bg-green-500/10"
onClick={onSaveEdit}
>
<Check className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={onCancelEdit}
>
<X className="h-4 w-4" />
</Button>
</ItemRow>
);
}
return (
<ItemRow>
<Avatar name={user.name} />
<div className="flex-1">
<div className="font-medium">{user.name}</div>
<div className="text-sm text-muted-foreground">{user.email}</div>
</div>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-primary"
onClick={onStartEdit}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />
</Button>
</ItemRow>
);
}

View File

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

View File

@ -0,0 +1,7 @@
export { DemoCard } from "./DemoCard";
export { LoadingSpinner, InlineSpinner } from "./LoadingSpinner";
export { EmptyState } from "./EmptyState";
export { ItemRow } from "./ItemRow";
export { Avatar } from "./Avatar";
export { StatusBadge } from "./StatusBadge";
export { CodePreview } from "./CodePreview";

View File

@ -362,7 +362,7 @@
} }
.card-hover:hover { .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 */ /* Glassmorphism card */

View File

@ -1,34 +1,16 @@
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 {
Loader2,
Plus,
Trash2,
Edit2,
Check,
X,
Sparkles,
Users,
FileText,
Zap,
} from "lucide-react";
export default function TRPCDemoPage() { export default function TRPCDemoPage() {
return ( return (
<div className="min-h-screen gradient-bg"> <div className="min-h-screen gradient-bg">
<div className="container mx-auto p-8 max-w-4xl"> <div className="container mx-auto p-8 max-w-4xl">
{/* Hero Header */} {/* Hero Header */}
<div className="mb-12 text-center"> <header className="mb-12 text-center">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium mb-4"> <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium mb-4">
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
Type-safe API Demo Type-safe API Demo
@ -40,410 +22,20 @@ export default function TRPCDemoPage() {
Experience the power of end-to-end type safety with a beautiful, Experience the power of end-to-end type safety with a beautiful,
modern interface modern interface
</p> </p>
</div> </header>
<div className="grid gap-8"> {/* Demo Examples */}
<main className="grid gap-8">
<HelloExample /> <HelloExample />
<UsersExample /> <UsersExample />
<PostsExample /> <PostsExample />
</div> </main>
{/* Footer */} {/* Footer */}
<div className="mt-12 text-center text-sm text-muted-foreground"> <footer className="mt-12 text-center text-sm text-muted-foreground">
<p>Built with 💜 using tRPC, React Query & TaylorDB</p> <p>Built with 💜 using tRPC, React Query &amp; TaylorDB</p>
</div> </footer>
</div> </div>
</div> </div>
); );
} }
// ============================================================================
// Hello Query Example
// ============================================================================
function HelloExample() {
const [name, setName] = useState("");
const { data, isLoading, refetch } = trpc.hello.useQuery(
{ name: name || undefined },
{ enabled: false }
);
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 bg-primary/10 glow-primary">
<Zap className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-xl">Hello Query</CardTitle>
<CardDescription>
Simple query to test the connection
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<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 ? (
<Loader2 className="h-4 w-4 animate-spin pulse-glow" />
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
Send
</>
)}
</Button>
</div>
{data && (
<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>
)}
</CardContent>
</Card>
);
}
// ============================================================================
// Users CRUD Example
// ============================================================================
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: { id: number; name: string; email: string }) => {
setEditingId(user.id);
setEditName(user.name);
setEditEmail(user.email);
};
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 bg-secondary/10 glow-secondary">
<Users className="h-5 w-5 text-secondary" />
</div>
<div>
<CardTitle className="text-xl">Users</CardTitle>
<CardDescription>Full CRUD operations example</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Create Form */}
<div className="flex gap-3">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Name"
className="flex-1"
/>
<Input
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder="Email"
className="flex-1"
/>
<Button
onClick={() =>
createMutation.mutate({ name: newName, email: newEmail })
}
disabled={!newName || !newEmail || createMutation.isPending}
size="icon"
className="shrink-0"
>
{createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
</Button>
</div>
{/* Users List */}
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary pulse-glow" />
</div>
) : (
<div className="space-y-2">
{users?.length === 0 && (
<p className="text-center text-muted-foreground py-4">
No users yet. Create one above!
</p>
)}
{users?.map((user) => (
<div
key={user.id}
className="item-row flex items-center gap-3 p-4 rounded-lg bg-muted/30 border border-border/50"
>
{editingId === user.id ? (
<>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="flex-1"
/>
<Input
value={editEmail}
onChange={(e) => setEditEmail(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={() =>
updateMutation.mutate({
id: user.id,
name: editName,
email: editEmail,
})
}
>
<Check className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={() => setEditingId(null)}
>
<X className="h-4 w-4" />
</Button>
</>
) : (
<>
<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">
{user.name.charAt(0).toUpperCase()}
</div>
<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={() => startEditing(user)}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
onClick={() => deleteMutation.mutate({ id: user.id })}
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
// ============================================================================
// 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 (
<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 bg-accent/10 glow-accent">
<FileText className="h-5 w-5 text-accent" />
</div>
<div>
<CardTitle className="text-xl">Posts</CardTitle>
<CardDescription>With publish action and filtering</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Create Form */}
<div className="space-y-3">
<div className="flex gap-3">
<Input
value={title}
onChange={(e) => setTitle(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) => setAuthorId(e.target.value)}
type="number"
/>
</div>
</div>
<div className="flex gap-3">
<Input
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write something amazing..."
className="flex-1"
/>
<Button
onClick={() =>
createMutation.mutate({
title,
content,
authorId: parseInt(authorId),
published: false,
})
}
disabled={!title || !content || createMutation.isPending}
>
{createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Plus className="h-4 w-4 mr-2" />
Create
</>
)}
</Button>
</div>
</div>
{/* Posts List */}
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-accent pulse-glow" />
</div>
) : (
<div className="space-y-3">
{posts?.length === 0 && (
<p className="text-center text-muted-foreground py-4">
No posts yet. Create your first post!
</p>
)}
{posts?.map((post) => (
<div
key={post.id}
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>
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
post.published ? "badge-success" : "badge-warning"
}`}
>
{post.published ? "Published" : "Draft"}
</span>
</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={() => publishMutation.mutate({ id: post.id })}
>
<Sparkles className="h-3 w-3 mr-1" />
Publish
</Button>
)}
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
onClick={() => deleteMutation.mutate({ id: post.id })}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}