feat: init drizzle implementation for backend (express) and frontend (vite-react)

This commit is contained in:
afiqzudinhadi 2025-07-08 16:38:55 +08:00
parent fc3a21263a
commit aca6a4da98
17 changed files with 1608 additions and 35 deletions

View file

@ -0,0 +1,2 @@
# Drizzle ORM configuration for Supabase PostgreSQL database
DATABASE_URL=

View file

@ -0,0 +1,78 @@
import { Router } from "express";
import { getUsers, createUser } from "@repo/db/index.ts";
import { randomUUID } from "crypto";
const router = Router();
// GET /api/users - Get all users
router.get("/", async (req, res) => {
try {
const users = await getUsers();
res.json(users);
} catch (error: any) {
console.error("Error fetching users:", error);
res.status(500).json({
error: "Failed to fetch users",
message: error.message,
});
}
});
// POST /api/users - Create new user
router.post("/", async (req, res) => {
try {
const { name, email, password, role, isActive } = req.body;
// Validation
if (!name || !email || !password) {
return res.status(400).json({
error: "Missing required fields: name, email, and password are required",
});
}
// Email format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({ error: "Invalid email format" });
}
// Password length validation
if (password.length < 6) {
return res
.status(400)
.json({ error: "Password must be at least 6 characters long" });
}
// Role validation (based on schema enum)
const validRoles = ["admin", "user"];
if (role && !validRoles.includes(role)) {
return res.status(400).json({
error: "Invalid role. Must be 'admin' or 'user'",
});
}
const userData = {
id: randomUUID(),
name: name.trim(),
email: email.toLowerCase().trim(),
role: role || "user", // Default role
};
const newUser = await createUser(userData);
res.status(201).json(newUser);
} catch (error: any) {
console.error("Error creating user:", error);
// Handle unique constraint violation (duplicate email)
if (error.message?.includes("duplicate") || error.code === "23505") {
return res.status(409).json({ error: "Email already exists" });
}
res.status(500).json({
error: "Failed to create user",
message: error.message,
});
}
});
export default router;

View file

@ -2,21 +2,25 @@ import { json, urlencoded } from "body-parser";
import express, { type Express } from "express"; import express, { type Express } from "express";
import morgan from "morgan"; import morgan from "morgan";
import cors from "cors"; import cors from "cors";
import productRoutes from "./routes/products";
import userRoutes from "./routes/users";
export const createServer = (): Express => { export const createServer = (): Express => {
const app = express(); const app = express();
app app.disable("x-powered-by")
.disable("x-powered-by")
.use(morgan("dev")) .use(morgan("dev"))
.use(urlencoded({ extended: true })) .use(urlencoded({ extended: true }))
.use(json()) .use(json())
.use(cors()) .use(cors())
.get("/message/:name", (req, res) => { .get("/message/:name", (req, res) => {
return res.json({ message: `hello ${req.params.name}` }); return res.json({ message: `hello ${req.params.name}` });
}) })
.get("/status", (_, res) => { .get("/status", (_, res) => {
return res.json({ ok: true }); return res.json({ ok: true });
}); })
.use("/api/products", productRoutes)
.use("/api/users", userRoutes);
return app; return app;
}; };

View file

@ -1 +1,3 @@
VITE_CLERK_PUBLISHABLE_KEY= VITE_CLERK_PUBLISHABLE_KEY=
VITE_API_BASE_URL=http://localhost:5001

View file

@ -2,6 +2,8 @@ import "@repo/ui/styles/styles.css";
import "@repo/ui/styles/global.css"; import "@repo/ui/styles/global.css";
import { CounterButton } from "@repo/ui/counter-button"; import { CounterButton } from "@repo/ui/counter-button";
import { Link } from "@repo/ui/link"; import { Link } from "@repo/ui/link";
import ProductForm from "./ProductForm";
import ProductIndex from "./ProductIndex";
import { Button } from "@repo/ui/components/button"; import { Button } from "@repo/ui/components/button";
import { import {
@ -69,6 +71,9 @@ function App() {
Vite Vite
</Link> </Link>
</p> </p>
<ProductForm />
<ProductIndex />
</div> </div>
</div> </div>
); );

View file

@ -0,0 +1,76 @@
import React, { useState } from "react";
import {
ProductCreate,
ProductCreateData,
} from "@repo/ui/components/products/create";
interface ProductFormProps {
onSuccess?: (product: ProductCreateData) => void;
onCancel?: () => void;
className?: string;
}
const ProductForm: React.FC<ProductFormProps> = ({
onSuccess,
onCancel,
className,
}) => {
const [isVisible, setIsVisible] = useState(true);
const apiBaseUrl =
import.meta.env.VITE_API_BASE_URL || "http://localhost:5001";
const handleProductCreated = (product: ProductCreateData) => {
console.log("Product created successfully:", product);
// Call the success callback if provided
if (onSuccess) {
onSuccess(product);
}
// Optionally hide the form after successful creation
// setIsVisible(false);
};
const handleCancel = () => {
console.log("Product creation cancelled");
if (onCancel) {
onCancel();
} else {
// Default behavior: hide the form
setIsVisible(false);
}
};
const handleToggleForm = () => {
setIsVisible(!isVisible);
};
return (
<div className={className}>
{/* Toggle Button */}
<div className="mb-4">
<button
onClick={handleToggleForm}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
{isVisible ? "Hide Form" : "Add New Product"}
</button>
</div>
{/* Product Create Form */}
{isVisible && (
<div className="max-w-2xl">
<ProductCreate
onSuccess={handleProductCreated}
onCancel={handleCancel}
apiEndpoint={`${apiBaseUrl}/api/products`}
className="shadow-lg"
/>
</div>
)}
</div>
);
};
export default ProductForm;

View file

@ -0,0 +1,153 @@
import React, { useEffect, useState, useCallback } from "react";
import { ProductGrid } from "@repo/ui/components/products/index";
import { PaginatedResponse } from "@repo/ui/components/template/table-show";
interface Product {
id: string;
name: string;
description: string;
price: number;
imageUrl?: string;
isPublished?: boolean;
created_at?: string;
updated_at?: string;
}
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:5001";
const fetchProducts = async (
page: number = 1,
limit: number = 10
): Promise<PaginatedResponse<Product>> => {
const response = await fetch(
`${apiBaseUrl}/api/products?page=${page}&limit=${limit}`
);
if (!response.ok) {
throw new Error("Failed to fetch products");
}
return response.json();
};
const ProductIndex: React.FC = () => {
const [paginatedProducts, setPaginatedProducts] = useState<
PaginatedResponse<Product>
>({
data: [],
pagination: {
page: 1,
limit: 10,
totalCount: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
},
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadProducts = useCallback(async (page: number = 1) => {
try {
setLoading(true);
const result = await fetchProducts(page);
setPaginatedProducts(result);
setError(null);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadProducts(1);
}, [loadProducts]);
const handlePageChange = (page: number) => {
loadProducts(page);
};
const handleEdit = (product: Product) => {
// TODO: Implement edit functionality for Vite app
console.log("Edit product:", product);
};
const handleDelete = async (productId: string) => {
if (!confirm("Are you sure you want to delete this product?")) {
return;
}
try {
const response = await fetch(
`${apiBaseUrl}/api/products/${productId}`,
{
method: "DELETE",
}
);
if (!response.ok) {
throw new Error("Failed to delete product");
}
// Refresh current page
loadProducts(paginatedProducts.pagination.page);
} catch (err: any) {
console.error("Delete error:", err.message);
alert("Failed to delete product: " + err.message);
}
};
if (loading && paginatedProducts.data.length === 0) {
return (
<div className="flex justify-center items-center min-h-64">
<div className="text-lg text-gray-600">Loading products...</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col justify-center items-center min-h-64 space-y-4">
<div className="text-red-600 bg-red-50 p-4 rounded-md max-w-md text-center">
<h3 className="font-semibold mb-2">
Error Loading Products
</h3>
<p>{error}</p>
</div>
<button
onClick={() => loadProducts(1)}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Try Again
</button>
</div>
);
}
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold">Products</h2>
<p className="text-gray-600 mt-1">
{paginatedProducts.pagination.totalCount} product
{paginatedProducts.pagination.totalCount !== 1
? "s"
: ""}{" "}
found
</p>
</div>
</div>
<ProductGrid
paginatedProducts={paginatedProducts}
onEdit={handleEdit}
onDelete={handleDelete}
onPageChange={handlePageChange}
loading={loading}
className="mt-6"
/>
</div>
);
};
export default ProductIndex;

View file

@ -1,6 +1,17 @@
import { drizzle } from "drizzle-orm/postgres-js"; import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres"; import postgres from "postgres";
import dotenv from "dotenv";
dotenv.config({ path: ".env" });
const client = postgres(process.env.DATABASE_URL!); let db_url = process.env.DATABASE_URL!;
try {
db_url = decodeURI(db_url);
} catch (e) {
console.warn("Failed to decode DATABASE_URL, using as is.");
}
console.log("Connecting to database URL:", db_url);
const client = postgres(db_url!);
export const db = drizzle(client); export const db = drizzle(client);

View file

@ -1,3 +1,4 @@
import { count, desc } from "drizzle-orm";
import { db } from "../database"; import { db } from "../database";
import { InsertProduct, products } from "../schema/products"; import { InsertProduct, products } from "../schema/products";
@ -5,6 +6,28 @@ export async function createProduct(data: InsertProduct) {
await db.insert(products).values(data); await db.insert(products).values(data);
} }
export async function getProducts() { export async function getProducts(page: number = 1, limit: number = 10) {
return db.select().from(products); const offset = (page - 1) * limit;
const totalCountResult = await db.select({ count: count() }).from(products);
const totalCount = totalCountResult[0].count;
const productsList = await db
.select()
.from(products)
.orderBy(desc(products.createdAt))
.limit(limit)
.offset(offset);
return {
data: productsList,
pagination: {
page,
limit,
totalCount,
totalPages: Math.ceil(totalCount / limit),
hasNextPage: page < Math.ceil(totalCount / limit),
hasPreviousPage: page > 1,
},
};
} }

View file

@ -1,6 +1,20 @@
import { db } from "../database"; import { db } from "../database";
import { InsertUser, user } from "../schema/users"; import { InsertUser, users } from "../schema/users";
import { eq } from "drizzle-orm";
export async function createUser(data: InsertUser) { export async function createUser(data: InsertUser) {
await db.insert(user).values(data); return await db.insert(users).values(data);
}
export async function getUsers() {
return await db.select().from(users);
}
export async function getUserById(id: string) {
const result = await db
.select()
.from(users)
.where(eq(users.id, id))
.limit(1);
return result[0] || null;
} }

View file

@ -0,0 +1,130 @@
import React from "react";
import { CreateForm, FormField, FormData } from "../template/create-form";
// Product-specific form fields configuration based on database schema
const productFields: FormField[] = [
{
key: "name",
label: "Product Name",
type: "text",
required: true,
placeholder: "Enter product name",
validation: {
minLength: 2,
maxLength: 255,
},
helpText: "A clear, descriptive name for your product",
},
{
key: "price",
label: "Price",
type: "number",
required: true,
placeholder: "0.00",
validation: {
min: 0,
max: 99999999.99, // Based on numeric(10, 2) from schema
},
helpText: "Price in USD (up to 8 digits before decimal, 2 after)",
},
{
key: "description",
label: "Description",
type: "textarea",
placeholder: "Enter detailed product description",
validation: {
maxLength: 2000,
},
helpText: "Detailed description of the product features and benefits",
},
{
key: "imageUrl",
label: "Image URL",
type: "url",
placeholder: "https://example.com/product-image.jpg",
helpText: "URL to the main product image",
},
{
key: "isPublished",
label: "Published Status",
type: "checkbox",
placeholder: "Make this product visible to customers",
helpText: "Uncheck to save as draft (defaults to false)",
},
];
export interface ProductCreateData extends FormData {
name: string;
price: number;
description?: string | null;
imageUrl?: string | null;
isPublished?: boolean | null;
}
interface ProductCreateProps {
onSuccess?: (product: ProductCreateData) => void;
onCancel?: () => void;
loading?: boolean;
className?: string;
apiEndpoint?: string;
}
export function ProductCreate({
onSuccess,
onCancel,
loading = false,
className,
apiEndpoint = "/api/products",
}: ProductCreateProps) {
const handleSubmit = async (data: FormData): Promise<void> => {
// Transform and validate the data according to database schema
const productData: ProductCreateData = {
name: data.name as string,
price: Number(data.price),
description: (data.description as string) || null,
imageUrl: (data.imageUrl as string) || null,
isPublished: data.isPublished ? Boolean(data.isPublished) : false,
};
// Make API call
const response = await fetch(apiEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(productData),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message ||
`Failed to create product (${response.status})`
);
}
const createdProduct = await response.json();
if (onSuccess) {
onSuccess(createdProduct);
}
};
return (
<CreateForm
title="Create New Product"
fields={productFields}
onSubmit={handleSubmit}
onCancel={onCancel}
loading={loading}
submitText="Create Product"
cancelText="Cancel"
className={className}
/>
);
}
// Export individual field configurations for reuse
export const productFormFields = productFields;
export type { ProductCreateProps };

View file

@ -0,0 +1,169 @@
import {
TableShow,
TableGrid,
SimpleTableGrid,
FieldConfig,
ActionConfig,
BaseRecord,
PaginatedResponse,
} from "../template/table-show";
export interface Product extends BaseRecord {
name: string;
description: string;
price: number;
imageUrl?: string;
isPublished?: boolean;
}
const productFields: FieldConfig<Product>[] = [
{ key: "description", label: "Description", type: "text" },
{ key: "price", label: "Price", type: "currency" },
];
interface ProductShowProps {
product: Product;
onEdit?: (product: Product) => void;
onDelete?: (productId: string) => void;
className?: string;
}
export function ProductShow({
product,
onEdit,
onDelete,
className,
}: ProductShowProps) {
const actions: ActionConfig<Product>[] = [
...(onEdit
? [
{
label: "Edit",
variant: "outline" as const,
onClick: onEdit,
},
]
: []),
...(onDelete
? [
{
label: "Delete",
variant: "destructive" as const,
onClick: (p: Product) => onDelete(p.id),
},
]
: []),
];
return (
<TableShow
record={product}
title={(p) => p.name}
fields={productFields}
actions={actions}
className={className}
statusField="isPublished"
imageField="imageUrl"
/>
);
}
// Updated paginated ProductGrid
export function ProductGrid({
paginatedProducts,
onEdit,
onDelete,
onPageChange,
loading,
className,
}: {
paginatedProducts: PaginatedResponse<Product>;
onEdit?: (product: Product) => void;
onDelete?: (productId: string) => void;
onPageChange: (page: number) => void;
loading?: boolean;
className?: string;
}) {
const actions: ActionConfig<Product>[] = [
...(onEdit
? [
{
label: "Edit",
variant: "outline" as const,
onClick: onEdit,
},
]
: []),
...(onDelete
? [
{
label: "Delete",
variant: "destructive" as const,
onClick: (p: Product) => onDelete(p.id),
},
]
: []),
];
return (
<TableGrid
paginatedData={paginatedProducts}
title={(p) => p.name}
fields={productFields}
actions={actions}
onPageChange={onPageChange}
loading={loading}
className={className}
statusField="isPublished"
imageField="imageUrl"
emptyMessage="No products found."
/>
);
}
// Simple non-paginated version for backward compatibility
export function SimpleProductGrid({
products,
onEdit,
onDelete,
className,
}: {
products: Product[];
onEdit?: (product: Product) => void;
onDelete?: (productId: string) => void;
className?: string;
}) {
const actions: ActionConfig<Product>[] = [
...(onEdit
? [
{
label: "Edit",
variant: "outline" as const,
onClick: onEdit,
},
]
: []),
...(onDelete
? [
{
label: "Delete",
variant: "destructive" as const,
onClick: (p: Product) => onDelete(p.id),
},
]
: []),
];
return (
<SimpleTableGrid
records={products}
title={(p) => p.name}
fields={productFields}
actions={actions}
className={className}
statusField="isPublished"
imageField="imageUrl"
emptyMessage="No products found."
/>
);
}

View file

@ -0,0 +1,344 @@
import React, { useState } from "react";
import { Button } from "../button";
import { Card, CardContent, CardHeader, CardTitle } from "../card";
import { cn } from "@repo/ui/lib/utils";
// Base interface for form fields
export interface FormField {
key: string;
label: string;
type:
| "text"
| "number"
| "email"
| "password"
| "textarea"
| "url"
| "tel"
| "date"
| "select"
| "checkbox";
required?: boolean;
placeholder?: string;
options?: { value: string; label: string }[]; // For select fields
validation?: {
min?: number;
max?: number;
pattern?: string;
minLength?: number;
maxLength?: number;
};
className?: string;
disabled?: boolean;
helpText?: string;
}
// Generic form data interface
export interface FormData {
[key: string]: any;
}
interface CreateFormProps {
title: string;
fields: FormField[];
onSubmit: (data: FormData) => Promise<void>;
onCancel?: () => void;
loading?: boolean;
submitText?: string;
cancelText?: string;
className?: string;
initialData?: FormData;
}
export function CreateForm({
title,
fields,
onSubmit,
onCancel,
loading = false,
submitText = "Create",
cancelText = "Cancel",
className,
initialData = {},
}: CreateFormProps) {
const [formData, setFormData] = useState<FormData>(() => {
const initial: FormData = {};
fields.forEach((field) => {
initial[field.key] =
initialData[field.key] ||
(field.type === "number"
? 0
: field.type === "checkbox"
? false
: "");
});
return initial;
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const validateField = (field: FormField, value: any): string | null => {
if (
field.required &&
(!value || (typeof value === "string" && !value.trim()))
) {
return `${field.label} is required`;
}
if (field.validation) {
const { min, max, pattern, minLength, maxLength } =
field.validation;
if (field.type === "number" && typeof value === "number") {
if (min !== undefined && value < min) {
return `${field.label} must be at least ${min}`;
}
if (max !== undefined && value > max) {
return `${field.label} must be at most ${max}`;
}
}
if (typeof value === "string") {
if (minLength !== undefined && value.length < minLength) {
return `${field.label} must be at least ${minLength} characters`;
}
if (maxLength !== undefined && value.length > maxLength) {
return `${field.label} must be at most ${maxLength} characters`;
}
if (pattern && !new RegExp(pattern).test(value)) {
return `${field.label} format is invalid`;
}
}
}
return null;
};
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
fields.forEach((field) => {
const error = validateField(field, formData[field.key]);
if (error) {
newErrors[field.key] = error;
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleInputChange = (key: string, value: any) => {
setFormData((prev) => ({ ...prev, [key]: value }));
// Clear error when user starts typing
if (errors[key]) {
setErrors((prev) => ({ ...prev, [key]: "" }));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setIsSubmitting(true);
try {
await onSubmit(formData);
// Reset form on success
const resetData: FormData = {};
fields.forEach((field) => {
resetData[field.key] =
field.type === "number"
? 0
: field.type === "checkbox"
? false
: "";
});
setFormData(resetData);
setErrors({});
} catch (error: any) {
setErrors({ submit: error.message || "An error occurred" });
} finally {
setIsSubmitting(false);
}
};
const renderField = (field: FormField) => {
const isFieldDisabled = loading || isSubmitting || field.disabled;
const fieldError = errors[field.key];
const baseInputClasses = cn(
"w-full mt-1 p-2 border rounded-md transition-colors",
"focus:ring-2 focus:ring-blue-500 focus:border-transparent",
fieldError ? "border-red-500" : "border-gray-300",
isFieldDisabled && "opacity-50 cursor-not-allowed",
field.className
);
switch (field.type) {
case "textarea":
return (
<textarea
className={baseInputClasses}
value={formData[field.key] || ""}
onChange={(e) =>
handleInputChange(field.key, e.target.value)
}
disabled={isFieldDisabled}
placeholder={field.placeholder}
rows={3}
/>
);
case "select":
return (
<select
className={baseInputClasses}
value={formData[field.key] || ""}
onChange={(e) =>
handleInputChange(field.key, e.target.value)
}
disabled={isFieldDisabled}
>
<option value="">
{field.placeholder || `Select ${field.label}`}
</option>
{field.options?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
case "checkbox":
return (
<div className="flex items-center">
<input
type="checkbox"
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
checked={formData[field.key] || false}
onChange={(e) =>
handleInputChange(field.key, e.target.checked)
}
disabled={isFieldDisabled}
/>
<label className="ml-2 text-sm text-gray-700">
{field.placeholder || field.label}
</label>
</div>
);
case "number":
return (
<input
type="number"
className={baseInputClasses}
value={formData[field.key] || ""}
onChange={(e) =>
handleInputChange(
field.key,
parseFloat(e.target.value) || 0
)
}
disabled={isFieldDisabled}
placeholder={field.placeholder}
min={field.validation?.min}
max={field.validation?.max}
step={field.type === "number" ? "any" : undefined}
/>
);
default:
return (
<input
type={field.type}
className={baseInputClasses}
value={formData[field.key] || ""}
onChange={(e) =>
handleInputChange(field.key, e.target.value)
}
disabled={isFieldDisabled}
placeholder={field.placeholder}
pattern={field.validation?.pattern}
minLength={field.validation?.minLength}
maxLength={field.validation?.maxLength}
/>
);
}
};
return (
<Card className={className}>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{fields.map((field) => (
<div key={field.key}>
{field.type !== "checkbox" && (
<label className="block text-sm font-medium text-gray-700">
{field.label}
{field.required && (
<span className="text-red-500 ml-1">
*
</span>
)}
</label>
)}
{renderField(field)}
{errors[field.key] && (
<p className="mt-1 text-sm text-red-600">
{errors[field.key]}
</p>
)}
{field.helpText && (
<p className="mt-1 text-sm text-gray-500">
{field.helpText}
</p>
)}
</div>
))}
{errors.submit && (
<div className="text-red-600 text-sm p-2 bg-red-50 rounded">
{errors.submit}
</div>
)}
<div className="flex gap-2 pt-4">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
className="flex-1"
>
{cancelText}
</Button>
)}
<Button
type="submit"
disabled={isSubmitting}
className="flex-1"
>
{isSubmitting ? "Saving..." : submitText}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
export type { CreateFormProps };

View file

@ -0,0 +1,493 @@
import { Card, CardContent, CardHeader, CardTitle } from "../card";
import { Button } from "../button";
import { cn } from "@repo/ui/lib/utils";
// Base interface that all table records should extend
export interface BaseRecord {
id: string;
created_at?: string;
updated_at?: string;
}
// Pagination information interface
export interface PaginationInfo {
page: number;
limit: number;
totalCount: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
}
// Paginated response interface
export interface PaginatedResponse<T> {
data: T[];
pagination: PaginationInfo;
}
// Configuration for how to display fields
export interface FieldConfig<T = any> {
key: keyof T;
label: string;
type?: "text" | "currency" | "date" | "boolean" | "image" | "status";
format?: (value: any) => string;
className?: string;
hide?: boolean;
}
// Configuration for actions
export interface ActionConfig<T = any> {
label: string;
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
size?: "default" | "sm" | "lg" | "icon";
onClick: (record: T) => void;
className?: string;
show?: (record: T) => boolean;
}
// Pagination controls component
interface PaginationControlsProps {
pagination: PaginationInfo;
onPageChange: (page: number) => void;
loading?: boolean;
className?: string;
}
export function PaginationControls({
pagination,
onPageChange,
loading = false,
className,
}: PaginationControlsProps) {
const {
page,
totalPages,
hasNextPage,
hasPreviousPage,
totalCount,
limit,
} = pagination;
// Calculate visible page numbers
const getVisiblePages = () => {
const delta = 2; // Number of pages to show on each side of current page
const range = [];
const rangeWithDots = [];
for (
let i = Math.max(2, page - delta);
i <= Math.min(totalPages - 1, page + delta);
i++
) {
range.push(i);
}
if (page - delta > 2) {
rangeWithDots.push(1, "...");
} else {
rangeWithDots.push(1);
}
rangeWithDots.push(...range);
if (page + delta < totalPages - 1) {
rangeWithDots.push("...", totalPages);
} else if (totalPages > 1) {
rangeWithDots.push(totalPages);
}
return rangeWithDots;
};
const startItem = (page - 1) * limit + 1;
const endItem = Math.min(page * limit, totalCount);
return (
<div
className={cn(
"flex flex-col sm:flex-row items-center justify-between gap-4",
className
)}
>
{/* Results info */}
<div className="text-sm text-gray-700">
Showing {startItem} to {endItem} of {totalCount} results
</div>
{/* Pagination controls */}
<div className="flex items-center gap-2">
{/* Previous button */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page - 1)}
disabled={!hasPreviousPage || loading}
>
Previous
</Button>
{/* Page numbers */}
<div className="flex items-center gap-1">
{getVisiblePages().map((pageNum, index) => (
<span key={index}>
{pageNum === "..." ? (
<span className="px-2 py-1 text-gray-500">
...
</span>
) : (
<Button
variant={
pageNum === page ? "default" : "outline"
}
size="sm"
onClick={() =>
onPageChange(pageNum as number)
}
disabled={loading}
className="min-w-[2.5rem]"
>
{pageNum}
</Button>
)}
</span>
))}
</div>
{/* Next button */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page + 1)}
disabled={!hasNextPage || loading}
>
Next
</Button>
</div>
</div>
);
}
interface TableShowProps<T extends BaseRecord> {
record: T;
title: string | ((record: T) => string);
fields: FieldConfig<T>[];
actions?: ActionConfig<T>[];
className?: string;
showId?: boolean;
statusField?: keyof T;
imageField?: keyof T;
}
export function TableShow<T extends BaseRecord>({
record,
title,
fields,
actions = [],
className,
showId = true,
statusField,
imageField,
}: TableShowProps<T>) {
const getTitle = () => {
return typeof title === "function" ? title(record) : title;
};
const formatValue = (value: any, field: FieldConfig<T>) => {
if (value === null || value === undefined) return "N/A";
if (field.format) {
return field.format(value);
}
switch (field.type) {
case "currency":
return `$${Number(value).toFixed(2)}`;
case "date":
return new Date(value).toLocaleDateString();
case "boolean":
return value ? "Yes" : "No";
default:
return String(value);
}
};
const getStatusComponent = () => {
if (!statusField) return null;
const status = record[statusField];
const isPublished =
status === true || status === "published" || status === "active";
if (isPublished) return null;
return (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded-full">
{status === false || status === "draft"
? "Draft"
: String(status)}
</span>
);
};
const getImageComponent = () => {
if (!imageField) return null;
const imageUrl = record[imageField] as string;
if (!imageUrl) return null;
return (
<div className="w-full h-48 bg-gray-100 rounded-lg overflow-hidden">
<img
src={imageUrl}
alt={getTitle()}
className="w-full h-full object-cover"
/>
</div>
);
};
const visibleActions = actions.filter(
(action) => !action.show || action.show(record)
);
return (
<Card className={cn("w-full max-w-md", className)}>
<CardHeader>
<CardTitle className="text-xl font-bold">
{getTitle()}
</CardTitle>
{getStatusComponent()}
</CardHeader>
<CardContent className="space-y-4">
{getImageComponent()}
<div className="space-y-2">
{fields
.filter((field) => !field.hide)
.map((field) => {
const value = record[field.key];
if (field.type === "image") {
return value ? (
<div
key={String(field.key)}
className="w-full"
>
<img
src={String(value)}
alt={field.label}
className="w-full h-32 object-cover rounded"
/>
</div>
) : null;
}
return (
<div
key={String(field.key)}
className={cn(
"flex justify-between items-start",
field.className
)}
>
<span className="text-sm font-medium text-gray-600">
{field.label}:
</span>
<span
className={cn(
"text-sm",
field.type === "currency" &&
"font-bold text-green-600"
)}
>
{formatValue(value, field)}
</span>
</div>
);
})}
{showId && (
<div className="flex justify-between items-center pt-2 border-t">
<span className="text-xs text-gray-500">ID:</span>
<span className="text-xs text-gray-500 font-mono">
{record.id}
</span>
</div>
)}
</div>
{visibleActions.length > 0 && (
<div className="flex gap-2 pt-4">
{visibleActions.map((action, index) => (
<Button
key={index}
variant={action.variant || "outline"}
size={action.size || "sm"}
onClick={() => action.onClick(record)}
className={cn("flex-1", action.className)}
>
{action.label}
</Button>
))}
</div>
)}
</CardContent>
</Card>
);
}
// Updated Grid version for paginated records
interface TableGridProps<T extends BaseRecord> {
paginatedData: PaginatedResponse<T>;
title: string | ((record: T) => string);
fields: FieldConfig<T>[];
actions?: ActionConfig<T>[];
onPageChange: (page: number) => void;
loading?: boolean;
className?: string;
showId?: boolean;
statusField?: keyof T;
imageField?: keyof T;
emptyMessage?: string;
}
export function TableGrid<T extends BaseRecord>({
paginatedData,
title,
fields,
actions,
onPageChange,
loading = false,
className,
showId,
statusField,
imageField,
emptyMessage = "No records found.",
}: TableGridProps<T>) {
const { data: records, pagination } = paginatedData;
if (records.length === 0 && !loading) {
return (
<div className="space-y-6">
<div className="text-center py-8 text-gray-500">
{emptyMessage}
</div>
{pagination.totalCount > 0 && (
<PaginationControls
pagination={pagination}
onPageChange={onPageChange}
loading={loading}
/>
)}
</div>
);
}
return (
<div className="space-y-6">
{/* Loading state overlay */}
{loading && (
<div className="text-center py-4">
<div className="text-gray-500">Loading...</div>
</div>
)}
{/* Grid of records */}
<div
className={cn(
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6",
loading && "opacity-50 pointer-events-none",
className
)}
>
{records.map((record) => (
<TableShow
key={record.id}
record={record}
title={title}
fields={fields}
actions={actions}
showId={showId}
statusField={statusField}
imageField={imageField}
/>
))}
</div>
{/* Pagination controls */}
{pagination.totalPages > 1 && (
<PaginationControls
pagination={pagination}
onPageChange={onPageChange}
loading={loading}
/>
)}
</div>
);
}
// Simple non-paginated grid for backward compatibility
interface SimpleTableGridProps<T extends BaseRecord> {
records: T[];
title: string | ((record: T) => string);
fields: FieldConfig<T>[];
actions?: ActionConfig<T>[];
className?: string;
showId?: boolean;
statusField?: keyof T;
imageField?: keyof T;
emptyMessage?: string;
}
export function SimpleTableGrid<T extends BaseRecord>({
records,
title,
fields,
actions,
className,
showId,
statusField,
imageField,
emptyMessage = "No records found.",
}: SimpleTableGridProps<T>) {
if (records.length === 0) {
return (
<div className="text-center py-8 text-gray-500">{emptyMessage}</div>
);
}
return (
<div
className={cn(
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6",
className
)}
>
{records.map((record) => (
<TableShow
key={record.id}
record={record}
title={title}
fields={fields}
actions={actions}
showId={showId}
statusField={statusField}
imageField={imageField}
/>
))}
</div>
);
}
export type {
TableShowProps,
TableGridProps,
SimpleTableGridProps,
PaginationControlsProps,
};

View file

@ -0,0 +1,69 @@
import React from "react";
import {
TableShow,
TableGrid,
FieldConfig,
ActionConfig,
BaseRecord,
} from "../template/table-show";
export interface User extends BaseRecord {
name: string;
email: string;
role: string;
isActive?: boolean;
lastLogin?: string;
}
const userFields: FieldConfig<User>[] = [
{ key: "email", label: "Email", type: "text" },
{ key: "role", label: "Role", type: "text" },
{ key: "lastLogin", label: "Last Login", type: "date" },
];
interface UserShowProps {
user: User;
onEdit?: (user: User) => void;
onDeactivate?: (userId: string) => void;
className?: string;
}
export function UserShow({
user,
onEdit,
onDeactivate,
className,
}: UserShowProps) {
const actions: ActionConfig<User>[] = [
...(onEdit
? [
{
label: "Edit",
variant: "outline" as const,
onClick: onEdit,
},
]
: []),
...(onDeactivate
? [
{
label: "Deactivate",
variant: "destructive" as const,
onClick: (u: User) => onDeactivate(u.id),
show: (u: User) => u.isActive !== false,
},
]
: []),
];
return (
<TableShow
record={user}
title={(u) => u.name}
fields={userFields}
actions={actions}
className={className}
statusField="isActive"
/>
);
}