feat: better component abstractions
This commit is contained in:
parent
962f1d798f
commit
455a55448a
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";
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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 & 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user