feat: remix integration
This commit is contained in:
parent
7045e582c7
commit
082bd78bf7
5 changed files with 348 additions and 3 deletions
124
apps/remix-template/app/routes/api.products.tsx
Normal file
124
apps/remix-template/app/routes/api.products.tsx
Normal 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" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
212
apps/remix-template/app/routes/product.tsx
Normal file
212
apps/remix-template/app/routes/product.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
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: initialProducts, error: initialError } =
|
||||||
|
useLoaderData<LoaderData>();
|
||||||
|
const revalidator = useRevalidator();
|
||||||
|
|
||||||
|
const [products, setProducts] = useState(initialProducts);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(initialError);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
"@remix-run/node": "^2.15.2",
|
"@remix-run/node": "^2.15.2",
|
||||||
"@remix-run/react": "^2.15.2",
|
"@remix-run/react": "^2.15.2",
|
||||||
"@remix-run/server-runtime": "^2.15.2",
|
"@remix-run/server-runtime": "^2.15.2",
|
||||||
"@repo/auth": "*",
|
"@repo/db": "*",
|
||||||
"@repo/ui": "*",
|
"@repo/ui": "*",
|
||||||
"@vercel/analytics": "^1.5.0",
|
"@vercel/analytics": "^1.5.0",
|
||||||
"@vercel/remix": "2.16.6",
|
"@vercel/remix": "2.16.6",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/*": ["./app/*"],
|
"~/*": ["./app/*"],
|
||||||
"@repo/ui/*": ["../../packages/ui/src/*"]
|
"@repo/ui/*": ["../../packages/ui/src/*"],
|
||||||
|
"@repo/db/*": ["../../packages/db/src/*"]
|
||||||
},
|
},
|
||||||
|
|
||||||
// Vite takes care of building everything, not tsc.
|
// Vite takes care of building everything, not tsc.
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,13 @@ import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
installGlobals();
|
installGlobals();
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [remix({ presets: [vercelPreset()] }), tsconfigPaths()],
|
plugins: [
|
||||||
|
remix({
|
||||||
|
presets: [vercelPreset()],
|
||||||
|
future: {
|
||||||
|
v3_singleFetch: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tsconfigPaths(),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue