Merge pull request #2 from afiqzudinhadi/drizzle-supabase

drizzle supabase
This commit is contained in:
afiqzudinhadi 2025-07-10 14:49:08 +08:00 committed by GitHub
commit f5a12d3dd1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 4021 additions and 5588 deletions

View file

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

View file

@ -15,6 +15,7 @@
"preset": "@repo/jest-presets/node"
},
"dependencies": {
"@repo/db": "*",
"@repo/logger": "*",
"body-parser": "^1.20.3",
"cors": "^2.8.5",

View file

@ -0,0 +1,43 @@
import { Router } from "express";
import { createProduct, getProducts } from "@repo/db/index";
const router = Router();
// GET /api/products - Get all products
router.get("/", async (req, res) => {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
// Validate pagination parameters
if (page < 1 || limit < 1 || limit > 100) {
return res.status(400).json({
error: "Invalid pagination parameters. Page must be >= 1, limit must be 1-100",
});
}
const result = await getProducts(page, limit);
res.status(200).json(result);
} catch (e: unknown) {
console.log("Error fetching products:", e);
res.status(500).json({
error: e instanceof Error ? e.message : "Unknown error",
});
}
});
// POST /api/products - Create new product
router.post("/", async (req, res) => {
try {
const { name, price, description } = req.body;
await createProduct({ name, price, description });
res.status(201).json({ ok: true });
} catch (e: unknown) {
console.error("Error creating product:", e);
res.status(500).json({
error: e instanceof Error ? e.message : "Unknown error",
});
}
});
export default router;

View file

@ -0,0 +1,82 @@
import { Router } from "express";
import { getUsers, createUser } from "@repo/db/index";
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: unknown) {
console.error("Error fetching users:", error);
res.status(500).json({
error: "Failed to fetch users",
message: error instanceof Error ? error.message : "Unknown error",
});
}
});
// POST /api/users - Create new user
router.post("/", async (req, res) => {
try {
const { name, email, password, role } = 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: unknown) {
console.error("Error creating user:", error);
// Handle unique constraint violation (duplicate email)
if (
error instanceof Error &&
(error.message?.includes("duplicate") ||
(error as any).code === "23505")
) {
return res.status(409).json({ error: "Email already exists" });
}
res.status(500).json({
error: "Failed to create user",
message: error instanceof Error ? error.message : "Unknown error",
});
}
});
export default router;

View file

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

View file

@ -1,9 +1,12 @@
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"lib": ["ES2015"],
"outDir": "./dist"
},
"exclude": ["node_modules"],
"include": ["."]
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"lib": ["ES2015"],
"outDir": "./dist",
"paths": {
"@repo/db/*": ["../../packages/db/src/*"]
}
},
"exclude": ["node_modules"],
"include": ["."]
}

View file

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

View file

@ -14,6 +14,7 @@
"dependencies": {
"@clerk/nextjs": "^6.22.0",
"@repo/logger": "*",
"@repo/db": "*",
"@repo/ui": "*",
"next": "^15.3.0",
"react": "^18.3.1",

View file

@ -0,0 +1,162 @@
import { NextRequest, NextResponse } from "next/server";
import { getProducts, createProduct } from "@repo/db/index";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1");
const limit = parseInt(searchParams.get("limit") || "10");
// Validate pagination parameters
if (page < 1 || limit < 1 || limit > 100) {
return NextResponse.json(
{
error: "Invalid pagination parameters. Page must be >= 1, limit must be 1-100",
},
{ status: 400 }
);
}
const result = await getProducts(page, limit);
return NextResponse.json(result);
} catch (error: any) {
console.error("API Error:", error);
return NextResponse.json(
{ error: "Failed to fetch products", message: error.message },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, description, price, imageUrl, isPublished } = body;
// Enhanced validation
if (!name || typeof name !== "string" || !name.trim()) {
return NextResponse.json(
{
error: "Product name is required and must be a non-empty string",
},
{ status: 400 }
);
}
if (price === undefined || price === null || isNaN(Number(price))) {
return NextResponse.json(
{ error: "Price is required and must be a valid number" },
{ status: 400 }
);
}
const priceValue = Number(price);
if (priceValue < 0) {
return NextResponse.json(
{ error: "Price must be non-negative" },
{ status: 400 }
);
}
// Validate optional fields
if (description && typeof description !== "string") {
return NextResponse.json(
{ error: "Description must be a string" },
{ status: 400 }
);
}
if (imageUrl && typeof imageUrl !== "string") {
return NextResponse.json(
{ error: "Image URL must be a string" },
{ status: 400 }
);
}
// Validate URL format if provided
if (imageUrl && imageUrl.trim()) {
try {
new URL(imageUrl.trim());
} catch {
return NextResponse.json(
{ error: "Image URL must be a valid URL" },
{ status: 400 }
);
}
}
// Prepare product data
const productData = {
name: name.trim(),
description: description?.trim() || null,
price: priceValue,
imageUrl: imageUrl?.trim() || null,
isPublished: Boolean(isPublished),
};
// Create product in database
const newProduct = await createProduct(productData);
// Return success response with created product
return NextResponse.json(
{
success: true,
message: "Product created successfully",
data: newProduct,
},
{ status: 201 }
);
} catch (error: any) {
console.error("API Error:", error);
// Handle specific database errors
if (error.message?.includes("duplicate") || error.code === "23505") {
return NextResponse.json(
{
success: false,
error: "Product already exists",
message: "A product with this name already exists",
},
{ status: 409 }
);
}
// Handle database connection errors
if (error.code === "ENOTFOUND" || error.code === "ECONNREFUSED") {
return NextResponse.json(
{
success: false,
error: "Database connection failed",
message: "Unable to connect to database",
},
{ status: 503 }
);
}
// Handle validation errors from database
if (error.code === "23502") {
// NOT NULL violation
return NextResponse.json(
{
success: false,
error: "Missing required field",
message: "A required field is missing",
},
{ status: 400 }
);
}
// Generic error response
return NextResponse.json(
{
success: false,
error: "Failed to create product",
message: error.message || "An unexpected error occurred",
...(process.env.NODE_ENV === "development" && {
stack: error.stack,
}),
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,67 @@
"use client";
import React, { useState } from "react";
import {
ProductCreate,
ProductCreateData,
} from "@repo/ui/components/products/create";
import { Button } from "@repo/ui/components/button";
interface CreateFormProps {
onProductCreated?: () => void;
className?: string;
}
const CreateForm: React.FC<CreateFormProps> = ({
onProductCreated,
className,
}) => {
const [showForm, setShowForm] = useState(false);
const handleProductCreated = (product: ProductCreateData) => {
console.log("Product created successfully:", product);
// Hide the form after successful creation
setShowForm(false);
// Trigger parent to refresh products list
if (onProductCreated) {
onProductCreated();
}
};
const handleCancel = () => {
setShowForm(false);
};
const toggleForm = () => {
setShowForm(!showForm);
};
return (
<div className={className}>
{/* Toggle Button */}
<div className="mb-6">
<Button
onClick={toggleForm}
variant={showForm ? "outline" : "default"}
>
{showForm ? "Cancel" : "Add New Product"}
</Button>
</div>
{/* Create Form */}
{showForm && (
<div className="mb-6 max-w-md">
<ProductCreate
onSuccess={handleProductCreated}
onCancel={handleCancel}
apiEndpoint="/api/products"
className="bg-white border rounded-lg shadow-sm"
/>
</div>
)}
</div>
);
};
export default CreateForm;

View file

@ -0,0 +1,166 @@
"use client";
import React, { useState, useCallback } from "react";
import { ProductGrid } from "@repo/ui/components/products/index";
import { PaginatedResponse } from "@repo/ui/components/template/table-show";
import { Button } from "@repo/ui/components/button";
import CreateForm from "./create-form";
interface Product {
id: string;
name: string;
description: string;
price: number;
imageUrl?: string;
isPublished?: boolean;
created_at?: string;
updated_at?: string;
}
interface PageClientProps {
initialPaginatedProducts: PaginatedResponse<Product>;
initialError: string | null;
}
const fetchProducts = async (
page: number = 1,
limit: number = 10
): Promise<PaginatedResponse<Product>> => {
const response = await fetch(`/api/products?page=${page}&limit=${limit}`);
if (!response.ok) {
throw new Error("Failed to fetch products");
}
return response.json();
};
const PageClient: React.FC<PageClientProps> = ({
initialPaginatedProducts,
initialError,
}) => {
const [paginatedProducts, setPaginatedProducts] = useState<
PaginatedResponse<Product>
>(initialPaginatedProducts);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(initialError);
const loadProducts = useCallback(async (page: number = 1) => {
try {
setLoading(true);
setError(null);
const result = await fetchProducts(page);
setPaginatedProducts(result);
} catch (err: any) {
console.error("Failed to load products:", err);
setError(err.message || "Failed to load products");
} finally {
setLoading(false);
}
}, []);
const handlePageChange = (page: number) => {
loadProducts(page);
};
const handleEdit = (product: Product) => {
// TODO: Implement edit functionality
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(`/api/products/${productId}`, {
method: "DELETE",
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to delete product");
}
// Refresh current page after deletion
await loadProducts(paginatedProducts.pagination.page);
} catch (err: any) {
console.error("Delete error:", err);
alert(`Failed to delete product: ${err.message}`);
}
};
const handleProductCreated = useCallback(() => {
// Refresh products list and go to first page to see the new product
loadProducts(1);
}, [loadProducts]);
// Show loading state when no data and loading
if (loading && paginatedProducts.data.length === 0 && !error) {
return (
<div className="flex justify-center items-center min-h-64">
<div className="text-lg text-gray-600">Loading products...</div>
</div>
);
}
// Show error state
if (error && paginatedProducts.data.length === 0) {
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)} variant="outline">
Try Again
</Button>
</div>
);
}
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header Section */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Products</h1>
<p className="text-gray-600 mt-1">
{paginatedProducts.pagination.totalCount} product
{paginatedProducts.pagination.totalCount !== 1
? "s"
: ""}{" "}
found
</p>
</div>
</div>
{/* Create Product Form */}
<CreateForm
onProductCreated={handleProductCreated}
className="mb-6"
/>
{/* Error Banner (when products exist but there's an error) */}
{error && paginatedProducts.data.length > 0 && (
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-md">
<p>
<strong>Warning:</strong> {error}
</p>
</div>
)}
{/* Products Grid */}
<ProductGrid
paginatedProducts={paginatedProducts}
onEdit={handleEdit}
onDelete={handleDelete}
onPageChange={handlePageChange}
loading={loading}
className="mt-6"
/>
</div>
);
};
export default PageClient;

View file

@ -0,0 +1,53 @@
import React from "react";
import { getProducts } from "@repo/db/index";
import PageClient from "./page-client";
import { Product } from "@repo/ui/components/products";
import { PaginatedResponse } from "@repo/ui/components/template/table-show";
export default async function ProductPage() {
// Fetch initial data on the server (first page)
let initialPaginatedProducts: PaginatedResponse<Product> = {
data: [],
pagination: {
page: 1,
limit: 10,
totalCount: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
},
};
let initialError = null;
try {
const result = await getProducts(1, 10); // Get first page with 10 items
initialPaginatedProducts = {
data: result.data.map((product: any) => ({
...product,
id: String(product.id), // Convert to string for consistency
description: product.description ?? "",
imageUrl: product.imageUrl ?? undefined,
isPublished: product.isPublished ?? false,
created_at: product.createdAt
? product.createdAt.toISOString()
: undefined,
updated_at: product.updatedAt
? product.updatedAt.toISOString()
: undefined,
})),
pagination: result.pagination,
};
} catch (error: any) {
console.error("Failed to fetch initial products:", error);
initialError = error.message;
}
return (
<div className="container">
<PageClient
initialPaginatedProducts={initialPaginatedProducts}
initialError={initialError}
/>
</div>
);
}

View file

@ -10,7 +10,8 @@
],
"paths": {
"~/*": ["./src/*"],
"@repo/ui/*": ["../../packages/ui/src/*"]
"@repo/ui/*": ["../../packages/ui/src/*"],
"@repo/db/*": ["../../packages/db/src/*"]
}
},
"include": ["src", "next.config.ts", "next-env.d.ts", ".next/types/**/*.ts"]

View file

@ -0,0 +1,124 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { getProducts, createProduct } from "@repo/db/index";
export async function loader({ request }: LoaderFunctionArgs) {
try {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get("page") || "1");
const limit = parseInt(url.searchParams.get("limit") || "10");
if (page < 1 || limit < 1 || limit > 100) {
return new Response(
JSON.stringify({
error: "Invalid pagination parameters. Page must be >= 1, limit must be 1-100",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const result = await getProducts(page, limit);
return new Response(JSON.stringify(result), {
headers: { "Content-Type": "application/json" },
});
} catch (error: any) {
console.error("API Error:", error);
return new Response(
JSON.stringify({
error: "Failed to fetch products",
message: error.message,
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
}
}
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json" },
});
}
try {
const body = await request.json();
const { name, description, price, imageUrl, isPublished } = body;
// Validation
if (!name || typeof name !== "string" || !name.trim()) {
return new Response(
JSON.stringify({
error: "Product name is required and must be a non-empty string",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
if (price === undefined || price === null || isNaN(Number(price))) {
return new Response(
JSON.stringify({
error: "Price is required and must be a valid number",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const priceValue = Number(price);
if (priceValue < 0) {
return new Response(
JSON.stringify({ error: "Price must be non-negative" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const productData = {
name: name.trim(),
description: description?.trim() || null,
price: priceValue.toString(),
imageUrl: imageUrl?.trim() || null,
isPublished: Boolean(isPublished),
};
const newProduct = await createProduct(productData);
return new Response(
JSON.stringify({
success: true,
message: "Product created successfully",
data: newProduct,
}),
{
status: 201,
headers: { "Content-Type": "application/json" },
}
);
} catch (error: any) {
console.error("API Error:", error);
return new Response(
JSON.stringify({
success: false,
error: "Failed to create product",
message: error.message || "An unexpected error occurred",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
}
}

View file

@ -0,0 +1,209 @@
import React, { useState, useCallback } from "react";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useRevalidator } from "@remix-run/react";
import { getProducts } from "@repo/db/index";
import { ProductGrid } from "@repo/ui/components/products/index";
import { ProductCreate } from "@repo/ui/components/products/create";
import { PaginatedResponse } from "@repo/ui/components/template/table-show";
import { Button } from "@repo/ui/components/button";
interface Product {
id: string;
name: string;
description: string;
price: number;
imageUrl?: string;
isPublished?: boolean;
created_at?: string;
updated_at?: string;
}
interface LoaderData {
products: PaginatedResponse<Product>;
error: string | null;
}
export async function loader({
request,
}: LoaderFunctionArgs): Promise<LoaderData> {
try {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get("page") || "1");
const limit = parseInt(url.searchParams.get("limit") || "10");
const result = await getProducts(page, limit);
const products: PaginatedResponse<Product> = {
data: result.data.map((product: any) => ({
...product,
id: String(product.id),
description: product.description ?? "",
imageUrl: product.imageUrl ?? undefined,
isPublished: product.isPublished ?? false,
created_at: product.createdAt
? product.createdAt.toISOString()
: undefined,
updated_at: product.updatedAt
? product.updatedAt.toISOString()
: undefined,
})),
pagination: result.pagination,
};
// Return raw object with Single Fetch
return { products, error: null };
} catch (error: any) {
console.error("Failed to fetch products:", error);
// Return raw object with Single Fetch
return {
products: {
data: [],
pagination: {
page: 1,
limit: 10,
totalCount: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
},
},
error: error.message || "Failed to load products",
};
}
}
export default function ProductPage() {
const { products, error } = useLoaderData<LoaderData>();
const revalidator = useRevalidator();
const [loading, setLoading] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
const handlePageChange = (page: number) => {
// Use Remix navigation for better UX
const url = new URL(window.location.href);
url.searchParams.set("page", page.toString());
window.history.pushState({}, "", url);
revalidator.revalidate();
};
const handleEdit = (product: Product) => {
// TODO: Implement edit functionality
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(`/api/products/${productId}`, {
method: "DELETE",
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to delete product");
}
// Refresh products list
revalidator.revalidate();
} catch (err: any) {
console.error("Delete error:", err);
alert(`Failed to delete product: ${err.message}`);
}
};
const handleProductCreated = useCallback(() => {
// Refresh the entire page data using Remix revalidator
revalidator.revalidate();
setShowCreateForm(false);
}, [revalidator]);
const handleFormCancel = () => {
setShowCreateForm(false);
};
// Show loading state when revalidating
if (revalidator.state === "loading" && products.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>
);
}
// Show error state
if (error && products.data.length === 0) {
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={() => revalidator.revalidate()}
variant="outline"
>
Try Again
</Button>
</div>
);
}
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header Section */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Products</h1>
<p className="text-gray-600 mt-1">
{products.pagination.totalCount} product
{products.pagination.totalCount !== 1 ? "s" : ""} found
</p>
</div>
<Button
onClick={() => setShowCreateForm(!showCreateForm)}
variant={showCreateForm ? "outline" : "default"}
>
{showCreateForm ? "Cancel" : "Add Product"}
</Button>
</div>
{/* Create Product Form */}
{showCreateForm && (
<div className="mb-6 max-w-md">
<ProductCreate
onSuccess={handleProductCreated}
onCancel={handleFormCancel}
apiEndpoint="/api/products"
className="bg-white border rounded-lg shadow-sm"
/>
</div>
)}
{/* Error Banner */}
{error && products.data.length > 0 && (
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-md">
<p>
<strong>Warning:</strong> {error}
</p>
</div>
)}
{/* Products Grid */}
<ProductGrid
paginatedProducts={products}
onEdit={handleEdit}
onDelete={handleDelete}
onPageChange={handlePageChange}
loading={revalidator.state === "loading"}
className="mt-6"
/>
</div>
);
}

View file

@ -16,7 +16,7 @@
"@remix-run/node": "^2.15.2",
"@remix-run/react": "^2.15.2",
"@remix-run/server-runtime": "^2.15.2",
"@repo/auth": "*",
"@repo/db": "*",
"@repo/ui": "*",
"@vercel/analytics": "^1.5.0",
"@vercel/remix": "2.16.6",

View file

@ -7,7 +7,8 @@
"skipLibCheck": true,
"paths": {
"~/*": ["./app/*"],
"@repo/ui/*": ["../../packages/ui/src/*"]
"@repo/ui/*": ["../../packages/ui/src/*"],
"@repo/db/*": ["../../packages/db/src/*"]
},
// Vite takes care of building everything, not tsc.

View file

@ -7,5 +7,13 @@ import tsconfigPaths from "vite-tsconfig-paths";
installGlobals();
export default defineConfig({
plugins: [remix({ presets: [vercelPreset()] }), tsconfigPaths()],
plugins: [
remix({
presets: [vercelPreset()],
future: {
v3_singleFetch: true,
},
}),
tsconfigPaths(),
],
});

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 { CounterButton } from "@repo/ui/counter-button";
import { Link } from "@repo/ui/link";
import ProductForm from "./ProductForm";
import ProductIndex from "./ProductIndex";
import { Button } from "@repo/ui/components/button";
import {
@ -69,6 +71,9 @@ function App() {
Vite
</Link>
</p>
<ProductForm />
<ProductIndex />
</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,155 @@
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: unknown) {
setError(err instanceof Error ? err.message : "An error occurred");
} 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: unknown) {
const errorMessage =
err instanceof Error ? err.message : "An error occurred";
console.error("Delete error:", errorMessage);
alert("Failed to delete product: " + errorMessage);
}
};
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,5 +1,6 @@
{
"compilerOptions": {
"types": ["vite/client"],
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],

View file

@ -1,10 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

6203
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,7 @@
"name": "ecommerce-template",
"private": true,
"scripts": {
"reinstall": "npm run clean:node_modules && npm install",
"build": "turbo run build",
"clean": "turbo run clean",
"clean:node_modules": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +",
@ -9,7 +10,20 @@
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"lint": "turbo run lint",
"test": "turbo run test",
"check-types": "turbo run check-types"
"check-types": "turbo run check-types",
"db:check": "turbo run db:check",
"db:generate": "turbo run db:generate",
"db:migrate": "turbo run db:migrate",
"db:pull": "turbo run db:pull",
"db:push": "turbo run db:push",
"db:init": "turbo run db:init",
"db:login": "turbo run db:login",
"db:link": "turbo run db:link",
"db:start": "turbo run db:start",
"db:stop": "turbo run db:stop",
"db:reset": "turbo run db:reset",
"db:reset-local": "turbo run db:reset-local",
"db:seed": "turbo run db:seed"
},
"devDependencies": {
"prettier": "^3.5.3",

5
packages/db/.env.example Normal file
View file

@ -0,0 +1,5 @@
# Drizzle ORM configuration for Supabase PostgreSQL database
DATABASE_URL=
# Required to run supabase cli commands
SUPABASE_DB_PASSWORD=

85
packages/db/README.md Normal file
View file

@ -0,0 +1,85 @@
# @repo/db
Shared database package for the monorepo using `Drizzle ORM` and `Supabase (Postgres)` as the database. This package provides a centralized way to manage database connections, models, migrations, and seeders across all applications in the monorepo.
WIP - Current implementation has no authorization and application logic yet. It is a basic setup to get started with `Drizzle ORM` and `Supabase`.
TODO:
- Configure to work with `Clerk` for authentication and authorization
- Implement application logic for the database models
## What's inside?
This repo uses `Drizzle ORM` for database interactions and includes the following:
### Apps and Packages
- `drizzle-orm`: ORM for TypeScript and JavaScript
- `@drizzle-orm/postgres`: Postgres driver for Drizzle ORM
- `drizzle-kit`: Tool for generating migrations and managing the database schema
- `@faker-js/faker`: Library for generating fake data for seeding the database
### Structure
The database package is structured as follows:
```
src/
├── queries/ # Database queries
│ ├── products.ts # Example product query
│ └── users.ts # Example user query
├── schema/ # Database models
│ ├── column.helpers.ts # Helper functions for column definitions
│ ├── products.ts # Example product models
│ └── user.ts # Example user model
├── seeders/ # Database migration files
│ ├── products.ts # Example product seeder
│ ├── seed.ts # Main seeder file
│ └── user.ts # Example user seeder
├── database.ts # Database connection and initialization
└── index.ts # Database schema exports
supabase/ # Supabase configuration and migrations
├── migrations/ # Database migration files
│ └── *.sql # Example migration file
├── seed.sql # SQL file for seeding the database
└── config.toml # Supabase configuration file
```
### Local Supabase Instance
To run a local Supabase instance, you need to have the Supabase CLI installed
https://supabase.com/docs/guides/local-development/cli/getting-started
Some commands to manage the local Supabase instance:
- `npx supabase init`: Initialize Supabase in the monorepo
- `npx supabase login`: Log in to your Supabase account
- `npx supabase link`: Link the Supabase project to the monorepo
- `npx supbase start`: Start the Supabase local development server using Docker. Docker must be installed and running.
- `npx supabase stop`: Stop the Supabase local development server
### Development Commands
- `turbo run db:check`: Check the database schema against the models
- `turbo run db:generate`: Generate database schema and types
- `turbo run db:migrate`: Run database migrations
- `turbo run db:push`: Push the current schema to the database
- `turbo run db:pull`: Pull the current schema from the database
- `turbo run db:reset`: Reset the online database. Connection to the online Supabase DB with Supabase CLI is required; Database password is required in `.env` file; `turbo run db:generate` must be run before this command to ensure the database schema is up-to-date, as the CLI will look for the migrations sql in the supabase directory.
- `turbo run db:seed`: Seed the database with initial data
### Usage
WIP
To use the database package in your applications, you can import the necessary modules and functions from the `@repo/db` package. For example:
### Known Issues
_Please install latest version of "drizzle-orm"_
- For `npm` users, run `npm install --force drizzle-kit drizzle-orm` in root of monorepo
- remove the entries in `package.json` file and run `npm install` again
- https://github.com/drizzle-team/drizzle-orm/issues/2699#issuecomment-2660850530

View file

@ -0,0 +1,15 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/schema",
out: "./supabase/migrations",
dialect: "postgresql",
entities: {
roles: {
provider: "supabase",
},
},
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View file

@ -0,0 +1,4 @@
import { config } from "@repo/eslint-config/react";
/** @type {import("eslint").Linter.Config} */
export default config;

39
packages/db/package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "@repo/db",
"version": "0.0.0",
"author": "Afiq Zudin Hadi",
"private": true,
"main": "index.ts",
"license": "MIT",
"type": "module",
"exports": {
"./index.ts": "./src/index.ts"
},
"scripts": {
"lint": "eslint . --ext .ts,.tsx",
"db:check": "npx drizzle-kit check",
"db:generate": "npx drizzle-kit generate",
"db:migrate": "npx drizzle-kit migrate",
"db:pull": "npx drizzle-kit pull",
"db:push": "npx drizzle-kit push",
"db:init": "npx supabase init",
"db:login": "npx supabase login",
"db:link": "npx supabase link",
"db:start": "npx supabase start",
"db:stop": "npx supabase stop",
"db:reset": "npx supabase db reset --linked",
"db:reset-local": "npx supabase db reset",
"db:seed": "tsx --env-file=.env ./src/seeders/seed.tsx"
},
"devDependencies": {
"@faker-js/faker": "^9.8.0",
"@types/react": ">=18",
"drizzle-kit": "^0.31.2",
"tsx": "^4.20.3"
},
"dependencies": {
"drizzle-orm": "^0.44.2",
"postgres": "^3.4.7",
"typescript": "^5.8.2"
}
}

View file

@ -0,0 +1,21 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import dotenv from "dotenv";
dotenv.config({ path: ".env" });
let db_url = process.env.DATABASE_URL!;
if (!db_url) {
throw new Error("DATABASE_URL is not set in environment variables.");
}
try {
db_url = decodeURI(db_url);
} catch {
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);

2
packages/db/src/index.ts Normal file
View file

@ -0,0 +1,2 @@
export * from "./queries/users";
export * from "./queries/products";

View file

@ -0,0 +1,33 @@
import { count, desc } from "drizzle-orm";
import { db } from "../database";
import { InsertProduct, products } from "../schema/products";
export async function createProduct(data: InsertProduct) {
await db.insert(products).values(data);
}
export async function getProducts(page: number = 1, limit: number = 10) {
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

@ -0,0 +1,20 @@
import { db } from "../database";
import { InsertUser, users } from "../schema/users";
import { eq } from "drizzle-orm";
export async function createUser(data: InsertUser) {
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,11 @@
import { timestamp } from "drizzle-orm/pg-core";
export const timestamps = {
updatedAt: timestamp("updated_at", { withTimezone: true })
.$onUpdate(() => new Date())
.notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
};

View file

@ -0,0 +1,15 @@
import { pgTable, serial, text, numeric, boolean } from "drizzle-orm/pg-core";
import { timestamps } from "./column.helpers";
export const products = pgTable("products", {
id: serial("id").primaryKey(),
name: text("name").notNull(),
description: text("description"),
price: numeric("price", { precision: 10, scale: 2 }).notNull(),
imageUrl: text("image_url"),
isPublished: boolean("is_published").default(false),
...timestamps,
});
export type InsertProduct = typeof products.$inferInsert;
export type SelectProduct = typeof products.$inferSelect;

View file

@ -0,0 +1,13 @@
import { pgTable, text } from "drizzle-orm/pg-core";
import { timestamps } from "./column.helpers";
export const users = pgTable("users", {
id: text("id").primaryKey(), // Clerk user ID
email: text("email").notNull(),
name: text("name"),
role: text("role").default("user").notNull(),
...timestamps,
});
export type InsertUser = typeof users.$inferInsert;
export type SelectUser = typeof users.$inferSelect;

View file

@ -0,0 +1,29 @@
import { faker } from "@faker-js/faker";
import { db } from "../database";
import { products } from "../schema/products";
import type { SeedFunction } from "./seed";
type ProductRow = {
id: number;
name: string;
description: string | null;
price: string;
imageUrl: string | null;
isPublished: boolean;
};
export const seedProducts: SeedFunction = async () => {
// generate random data for 10 people to stick into the chefs table
const data: ProductRow[] = Array.from({ length: 10 }, () => ({
id: faker.number.int({ min: 1, max: 10000 }),
name: faker.commerce.productName(),
description: faker.commerce.productDescription(),
price: faker.commerce.price({ min: 1, max: 1000, dec: 2 }),
imageUrl: faker.image.url(),
isPublished: faker.datatype.boolean(),
}));
await db.insert(products).values(data);
return `${data.length} Products seeded successfully`;
};

View file

@ -0,0 +1,52 @@
import { seedProducts } from "./products";
import { seedUsers } from "./users";
const seedDb = async () => {
// note: this function assumes we're starting with an empty database
console.log("Seeding database...");
console.log("Adding independent data...");
const res = await Promise.allSettled([
seedUsers(),
seedProducts(),
// add more independent seeding functions here
// these will run in parallel
]);
res.forEach((result) => {
if (result.status === "rejected") {
console.log("Error seeding database:", result.reason);
} else {
console.log(result.value);
}
});
// console.log("Adding related data...");
// const dependentTasks = [
// seedRecipes,
// // add more dependent tasks here, they will run in this order
// ];
// for (const task of dependentTasks) {
// try {
// const result = await task();
// console.log(result);
// } catch e {
// console.log("Error seeding database:", e);
// }
// }
console.log("Seeding complete!");
};
seedDb()
.then(() => {
console.log("Seeding complete!");
process.exit(0);
})
.catch((err) => {
console.error("Error seeding database:", err);
process.exit(1);
});
export type SeedFunction = () => Promise<string>;

View file

@ -0,0 +1,23 @@
import { faker } from "@faker-js/faker";
import { db } from "../database";
import { users } from "../schema/users";
import type { SeedFunction } from "./seed";
type UserRow = {
id: string;
name: string;
email: string;
};
export const seedUsers: SeedFunction = async () => {
// generate random data for 10 people to stick into the chefs table
const data: UserRow[] = Array.from({ length: 10 }, () => ({
id: faker.number.int({ min: 1, max: 10000 }).toString(),
name: faker.person.fullName(),
email: faker.internet.email(),
}));
await db.insert(users).values(data);
return `${data.length} Users seeded successfully`;
};

8
packages/db/supabase/.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local

View file

@ -0,0 +1,317 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "db"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth redirectUrl.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
[edge_runtime]
enabled = true
# Configure one of the supported request policies: `oneshot`, `per_worker`.
# Use `oneshot` for hot reload, or `per_worker` for load testing.
policy = "oneshot"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 1
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"

View file

@ -0,0 +1,21 @@
CREATE TABLE "products" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"description" text,
"price" numeric(10, 2) NOT NULL,
"image_url" text,
"is_published" boolean DEFAULT false,
"updated_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" text PRIMARY KEY NOT NULL,
"email" text NOT NULL,
"name" text,
"role" text DEFAULT 'user' NOT NULL,
"updated_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);

View file

@ -0,0 +1,145 @@
{
"id": "b00718fb-5a84-4440-92fd-92d7ebaca1b1",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.products": {
"name": "products",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"price": {
"name": "price",
"type": "numeric(10, 2)",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_published": {
"name": "is_published",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'user'"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1751101339243,
"tag": "0000_awesome_prowler",
"breakpoints": true
}
]
}

14
packages/db/tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"extends": "@repo/typescript-config/react-library.json",
"compilerOptions": {
"lib": ["dom", "ES2015"],
"sourceMap": true,
"types": ["jest", "node"],
"baseUrl": ".",
"paths": {
"@repo/db/index.ts": ["./src/index.ts"]
}
},
"include": [".", "src"],
"exclude": ["dist", "build", "node_modules"]
}

View file

@ -0,0 +1,129 @@
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;
imageUrl: string;
isPublished: boolean;
}
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,
imageUrl: data.imageUrl as string,
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,359 @@
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]: string | number | boolean;
}
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: string | number | boolean
): 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: string | number | boolean
) => {
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: unknown) {
setErrors({
submit:
error instanceof Error
? 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={String(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={String(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={Boolean(formData[field.key])}
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={
typeof formData[field.key] === "number"
? String(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={String(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"
/>
);
}

View file

@ -1,6 +1,7 @@
{
"$schema": "https://turborepo.com/schema.json",
"ui": "tui",
"globalEnv": ["DATABASE_URL"],
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", ".env*"],
@ -30,6 +31,52 @@
},
"clean": {
"cache": false
},
"db:check": {
"cache": false
},
"db:generate": {
"cache": false,
"persistent": true
},
"db:migrate": {
"cache": false
},
"db:push": {
"cache": false,
"persistent": true
},
"db:pull": {
"cache": false
},
"db:init": {
"cache": false,
"persistent": true
},
"db:login": {
"cache": false,
"persistent": true
},
"db:link": {
"cache": false,
"persistent": true
},
"db:start": {
"cache": false,
"persistent": true
},
"db:stop": {
"cache": false
},
"db:reset": {
"cache": false,
"persistent": true
},
"db:reset-local": {
"cache": false
},
"db:seed": {
"cache": false
}
}
}