basic seach small json data set models and parts
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { getCompatibleParts } from "@/lib/compatibility/engine";
|
||||
|
||||
export async function GET(request, context) {
|
||||
const { id } = await context.params; // ⬅ MUST AWAIT PARAMS
|
||||
|
||||
const results = getCompatibleParts(id);
|
||||
|
||||
if (!results.length) {
|
||||
return Response.json(
|
||||
{ error: "No compatible parts or model not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(results);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getCrossCompatibleModels } from "@/lib/compatibility/engine";
|
||||
|
||||
export async function GET(request, context) {
|
||||
const { id } = await context.params; // ⬅ MUST AWAIT PARAMS
|
||||
|
||||
const results = getCrossCompatibleModels(id);
|
||||
|
||||
if (!results.length) {
|
||||
return Response.json(
|
||||
{ error: "No compatible models or part not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(results);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MODELS } from "@/lib/data/models";
|
||||
|
||||
export async function GET(request, context) {
|
||||
const { id } = await context.params; // ⬅ MUST AWAIT PARAMS
|
||||
|
||||
const model = MODELS.find((m) => m.id === id);
|
||||
if (!model) {
|
||||
return Response.json({ error: "Model not found" }, { status: 404 });
|
||||
}
|
||||
return Response.json(model);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { MODELS } from "@/lib/data/models";
|
||||
|
||||
export async function GET() {
|
||||
return Response.json(MODELS);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PARTS } from "@/lib/data/parts";
|
||||
|
||||
export async function GET(request, context) {
|
||||
const { id } = await context.params; // ⬅ MUST AWAIT PARAMS
|
||||
|
||||
const part = PARTS.find((p) => p.id === id);
|
||||
if (!part) {
|
||||
return Response.json({ error: "Part not found" }, { status: 404 });
|
||||
}
|
||||
return Response.json(part);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PARTS } from "@/lib/data/parts";
|
||||
|
||||
export async function GET() {
|
||||
return Response.json(PARTS);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getPartsByModel } from "@/lib/ui/api";
|
||||
import SkeletonCard from "@/app/components/SkeletonCard";
|
||||
import PartCard from "@/app/components/PartCard";
|
||||
|
||||
export default function PartGrid({ modelId, categoryName }) {
|
||||
const [parts, setParts] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const allParts = await getPartsByModel(modelId);
|
||||
|
||||
if (!Array.isArray(allParts)) {
|
||||
console.error("❌ getPartsByModel() returned:", allParts);
|
||||
setParts([]); // Prevents filter crash
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = allParts.filter(
|
||||
(p) => p.part.category.toLowerCase() === categoryName.toLowerCase()
|
||||
);
|
||||
|
||||
setParts(filtered);
|
||||
}
|
||||
load();
|
||||
}, [modelId, categoryName]);
|
||||
|
||||
// Skeleton loading
|
||||
if (parts === null) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No parts found
|
||||
if (parts.length === 0) {
|
||||
return <p>No parts found in this category.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{parts.map((part) => (
|
||||
<PartCard key={part.part.id} part={part} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Link from "next/link";
|
||||
import PartGrid from "./PartGrid";
|
||||
|
||||
export default async function CategoryPage({ params }) {
|
||||
const { modelId, categoryName } = await params;
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="text-sm text-gray-500 flex gap-2 items-center">
|
||||
<Link href="/" className="hover:underline text-blue-600">
|
||||
Home
|
||||
</Link>
|
||||
<span>›</span>
|
||||
|
||||
<Link
|
||||
href={`/model/${modelId}`}
|
||||
className="hover:underline text-blue-600 capitalize"
|
||||
>
|
||||
{modelId.replace(/-/g, " ")}
|
||||
</Link>
|
||||
<span>›</span>
|
||||
|
||||
<span className="capitalize text-gray-700 font-semibold">
|
||||
{categoryName}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2 capitalize">
|
||||
{categoryName} Parts
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Explore compatible parts for{" "}
|
||||
<span className="font-semibold">{modelId.replace(/-/g, " ")}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Parts Grid with Skeleton Loading */}
|
||||
<PartGrid modelId={modelId} categoryName={categoryName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { fetchJSON } from "@/lib/ui/fetch";
|
||||
import ModelCard from "../components/ModelCard";
|
||||
import PartCard from "../components/PartCard";
|
||||
|
||||
export default function CompatibilityPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [result, setResult] = useState([]);
|
||||
|
||||
async function compare() {
|
||||
const modelData = await fetchJSON(`/api/compatibility/model/${query}`);
|
||||
const partData = await fetchJSON(`/api/compatibility/part/${query}`);
|
||||
|
||||
setResult(modelData || partData || []);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold">Compatibility Explorer</h1>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="border p-2 rounded w-full"
|
||||
placeholder="Enter modelId or partId"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 text-white rounded"
|
||||
style={{ backgroundColor: "var(--color-primary, #0070f3)" }}
|
||||
onClick={compare}
|
||||
>
|
||||
Compare
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
{result.map((item) =>
|
||||
item.model ? (
|
||||
<ModelCard key={item.model.id} model={item.model} />
|
||||
) : (
|
||||
<PartCard key={item.part.id} part={item.part} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function CategoryCard({ category }) {
|
||||
return (
|
||||
<div className="border rounded p-4 shadow hover:shadow-lg transition cursor-pointer bg-white">
|
||||
<h3 className="font-bold text-lg capitalize">{category}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function CompatibilityStatus({ status }) {
|
||||
return (
|
||||
<p className="font-bold">
|
||||
Compatibility: {status ? "Compatible" : "Not Compatible"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function ModelCard({ model }) {
|
||||
return (
|
||||
<div className="border rounded p-4 shadow">
|
||||
<h2 className="font-bold text-lg">{model.name}</h2>
|
||||
<p>{model.brand} • {model.scale}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export default function ModelSummary({ model }) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="font-bold text-lg">Summary</h2>
|
||||
<p>Name: {model.name}</p>
|
||||
<p>Scale: {model.scale}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import "../styles/components/navbar.scss";
|
||||
|
||||
export default function NavBar() {
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<Link href="/">Home</Link>
|
||||
<Link href="/compatibility">Compatibility</Link>
|
||||
<Link href="/garage">Garage</Link>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function PartCard({ part }) {
|
||||
part = part.hasOwnProperty("result") ? part.part : part;
|
||||
const placeholderImg = `https://placehold.co/40x25?text=${encodeURIComponent(
|
||||
part.name
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/part/${part.id}`}
|
||||
className="block border rounded-xl p-5 shadow-sm hover:shadow-md hover:-translate-y-1 transition bg-white"
|
||||
>
|
||||
<div className="flex flex-col h-full justify-between">
|
||||
{/* Image */}
|
||||
<img
|
||||
src={placeholderImg}
|
||||
alt={part.name}
|
||||
className="rounded mb-4 w-full object-cover"
|
||||
/>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-1">{part.name}</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">{part.id}</p>
|
||||
|
||||
{/* Small Category Tag */}
|
||||
<span className="inline-block px-3 py-1 text-xs rounded-full bg-blue-100 text-blue-700 capitalize">
|
||||
{part.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Model Count */}
|
||||
<p className="mt-4 text-sm text-gray-500">
|
||||
Compatible with <strong>{part.fitsModels?.length || 0}</strong>{" "}
|
||||
model(s)
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export default function SearchBar({ onSearch }) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); // Prevents form submission / reloads
|
||||
onSearch(query.trim()); // Fire search
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchClick() {
|
||||
onSearch(query.trim());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 w-full max-w-xl">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search for a model or part…"
|
||||
className="flex-1 border rounded-xl px-4 py-2 shadow-sm focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearchClick}
|
||||
className="px-5 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700 transition"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function SearchResultCard({ item }) {
|
||||
const isModel = !!item.categories; // Models have .categories
|
||||
const href = isModel ? `/model/${item.id}` : `/part/${item.id}`;
|
||||
|
||||
const placeholderImg = `https://placehold.co/400x250?text=${encodeURIComponent(
|
||||
item.name
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="block border rounded-xl p-5 shadow-sm hover:shadow-md hover:-translate-y-1 transition bg-white"
|
||||
>
|
||||
{/* Image */}
|
||||
<img
|
||||
src={placeholderImg}
|
||||
alt={item.name}
|
||||
className="rounded mb-4 w-full object-cover"
|
||||
/>
|
||||
|
||||
{/* Name */}
|
||||
<h3 className="text-lg font-bold mb-1">{item.name}</h3>
|
||||
|
||||
{/* Type Tag */}
|
||||
<span
|
||||
className={`inline-block px-3 py-1 text-xs rounded-full capitalize ${
|
||||
isModel
|
||||
? "bg-purple-100 text-purple-700"
|
||||
: "bg-blue-100 text-blue-700"
|
||||
}`}
|
||||
>
|
||||
{isModel ? "Model" : "Part"}
|
||||
</span>
|
||||
|
||||
{/* Extra meta */}
|
||||
<p className="mt-3 text-sm text-gray-500">
|
||||
ID: <span className="font-mono">{item.id}</span>
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export default function SearchSkeletonCard() {
|
||||
return (
|
||||
<div className="animate-pulse border rounded-xl p-5 bg-gray-100/50 shadow-sm">
|
||||
<div className="h-32 bg-gray-300 rounded mb-4"></div>
|
||||
<div className="h-4 bg-gray-300 rounded w-2/3 mb-2"></div>
|
||||
<div className="h-4 bg-gray-300 rounded w-1/3"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export default function SkeletonCard() {
|
||||
return (
|
||||
<div className="animate-pulse border rounded-xl p-5 bg-gray-100/50 shadow-sm">
|
||||
<div className="h-32 bg-gray-300 rounded mb-4"></div>
|
||||
<div className="h-4 bg-gray-300 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-4 bg-gray-300 rounded w-1/2 mb-4"></div>
|
||||
<div className="h-6 bg-gray-300 rounded w-1/3"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function GaragePage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-primary mb-4">Your Garage</h1>
|
||||
<p>Save your RC models and parts here.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+8
-19
@@ -1,28 +1,17 @@
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import "./styles/globals.scss";
|
||||
import NavBar from "./components/NavBar";
|
||||
|
||||
export const metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "RC Compatibility Explorer",
|
||||
description: "Find compatibility across RC models and parts",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body suppressHydrationWarning>
|
||||
<NavBar />
|
||||
<main className="p-4">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { getModel, getCompatibleParts } from "@/lib/ui/api";
|
||||
import CategoryCard from "@/app/components/CategoryCard";
|
||||
import PartCard from "@/app/components/PartCard";
|
||||
|
||||
export default async function ModelPage({ params }) {
|
||||
const { modelId } = await params;
|
||||
|
||||
const model = await getModel(modelId);
|
||||
const parts = await getCompatibleParts(modelId);
|
||||
|
||||
if (!model) return <p className="text-red-500">Model not found</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold">{model.name}</h1>
|
||||
<p className="opacity-75">
|
||||
{model.brand} • {model.scale}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold">Categories</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{model.categories.map((cat) => (
|
||||
<a key={cat} href={`/category/${model.id}/${cat}`}>
|
||||
<CategoryCard category={cat} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold">Compatible Parts</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Array.isArray(parts) && parts.length ? (
|
||||
parts.map((item) => <PartCard key={item.part.id} part={item.part} />)
|
||||
) : (
|
||||
<p>No compatible parts found.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import ModelCard from "../components/ModelCard";
|
||||
|
||||
export default function ModelsPage() {
|
||||
const [models, setModels] = useState([]);
|
||||
const [filtered, setFiltered] = useState([]);
|
||||
const [brand, setBrand] = useState("all");
|
||||
const [scale, setScale] = useState("all");
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const res = await fetch("/api/models", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
setModels(data);
|
||||
setFiltered(data);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
function filter() {
|
||||
let result = [...models];
|
||||
|
||||
if (brand !== "all") {
|
||||
result = result.filter(m => m.brand === brand);
|
||||
}
|
||||
if (scale !== "all") {
|
||||
result = result.filter(m => m.scale === scale);
|
||||
}
|
||||
|
||||
setFiltered(result);
|
||||
}
|
||||
|
||||
const brands = [...new Set(models.map(m => m.brand))];
|
||||
const scales = [...new Set(models.map(m => m.scale))];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-4xl font-bold">All Models</h1>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-4 items-end">
|
||||
<div>
|
||||
<label className="block mb-1 font-semibold">Brand</label>
|
||||
<select
|
||||
className="border p-2 rounded"
|
||||
value={brand}
|
||||
onChange={e => setBrand(e.target.value)}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
{brands.map(b => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block mb-1 font-semibold">Scale</label>
|
||||
<select
|
||||
className="border p-2 rounded"
|
||||
value={scale}
|
||||
onChange={e => setScale(e.target.value)}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
{scales.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={filter}
|
||||
className="px-4 py-2 bg-primary text-white rounded"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Model Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{filtered.map(model => (
|
||||
<a key={model.id} href={`/model/${model.id}`} className="block">
|
||||
<ModelCard model={model} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+102
-59
@@ -1,65 +1,108 @@
|
||||
import Image from "next/image";
|
||||
// "use client";
|
||||
|
||||
// import { useState } from "react";
|
||||
// import SearchBar from "./components/SearchBar";
|
||||
// import { searchModels, searchParts } from "../lib/search/search";
|
||||
|
||||
// export default function HomePage() {
|
||||
// const [results, setResults] = useState([]);
|
||||
|
||||
// function handleSearch(query) {
|
||||
// const models = searchModels(query);
|
||||
// const parts = searchParts(query);
|
||||
// setResults([...models, ...parts]);
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <div className="space-y-6">
|
||||
// <h1 className="text-4xl font-bold">RC Compatibility Explorer</h1>
|
||||
|
||||
// <SearchBar onSearch={handleSearch} />
|
||||
|
||||
// <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
// {results.map((item) => {
|
||||
// const isModel = !!item.categories;
|
||||
// const link = isModel ? `/model/${item.id}` : `/part/${item.id}`;
|
||||
|
||||
// return (
|
||||
// <a
|
||||
// key={item.id}
|
||||
// href={link}
|
||||
// className="block border p-4 rounded hover:bg-gray-50"
|
||||
// >
|
||||
// <strong>{item.name}</strong>
|
||||
// <div className="opacity-60">{isModel ? "Model" : "Part"}</div>
|
||||
// </a>
|
||||
// );
|
||||
// })}
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import SearchBar from "./components/SearchBar";
|
||||
import SearchResultCard from "./components/SearchResultCard";
|
||||
import SearchSkeletonCard from "./components/SearchSkeletonCard";
|
||||
import { searchModels, searchParts } from "@/lib/search/search";
|
||||
|
||||
export default function HomePage() {
|
||||
const [results, setResults] = useState(null); // null = loading phase
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSearch(query) {
|
||||
if (!query) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setResults(null);
|
||||
|
||||
// Simulate real network delay for proper skeleton visuals
|
||||
setTimeout(() => {
|
||||
const models = searchModels(query);
|
||||
const parts = searchParts(query);
|
||||
setResults([...models, ...parts]);
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.js file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
<div className="space-y-10">
|
||||
{/* Title */}
|
||||
<h1 className="text-4xl font-bold">RC Compatibility Explorer</h1>
|
||||
|
||||
{/* Search Field */}
|
||||
<SearchBar onSearch={handleSearch} />
|
||||
|
||||
{/* Skeleton Loader */}
|
||||
{loading && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<SearchSkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{!loading && results?.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-2xl font-semibold">Search Results</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{results.map((item) => (
|
||||
<SearchResultCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* No Results */}
|
||||
{!loading && results?.length === 0 && (
|
||||
<p>No matching models or parts found.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { getPart, getCompatibleModels } from "@/lib/ui/api";
|
||||
|
||||
export default async function PartDetailPage({ params }) {
|
||||
const { partId } = await params;
|
||||
|
||||
const part = await getPart(partId);
|
||||
const models = await getCompatibleModels(partId);
|
||||
|
||||
if (!part) return <p className="text-red-500">Part not found</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold">{part.name}</h1>
|
||||
<p className="opacity-75">Part ID: {part.id}</p>
|
||||
|
||||
{part.universalFit && (
|
||||
<span className="inline-block mt-2 px-3 py-1 bg-green-200 text-green-800 text-sm rounded">
|
||||
Universal Fit
|
||||
</span>
|
||||
)}
|
||||
|
||||
{part.upgrade && (
|
||||
<span className="inline-block mt-2 px-3 py-1 bg-blue-200 text-blue-800 text-sm rounded">
|
||||
Upgrade Part
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold">Compatible Models</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Array.isArray(models) && models.length ? (
|
||||
models.map((item) => (
|
||||
<div key={item.model.id}>
|
||||
<p className="font-bold">{item.model.name}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p>No compatible models found.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@use "../variables" as *;
|
||||
|
||||
.model-card {
|
||||
@apply border rounded p-4 shadow;
|
||||
background: $card-bg;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@use "../variables" as *;
|
||||
@reference "tailwindcss";
|
||||
|
||||
.navbar {
|
||||
@apply flex gap-4 p-4 border-b;
|
||||
background: $nav-bg;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@use "../variables" as *;
|
||||
|
||||
.part-card {
|
||||
@apply border rounded p-4 shadow;
|
||||
background: $card-bg;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
@use "./variables" as *;
|
||||
@use "tailwindcss";
|
||||
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: sans-serif;
|
||||
background: $background;
|
||||
color: $text-color;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
$background: #ffffff;
|
||||
$text-color: #111111;
|
||||
|
||||
$primary: #0070f3;
|
||||
$secondary: #7928ca;
|
||||
|
||||
$card-bg: #f8f8f8;
|
||||
$nav-bg: #fafafa;
|
||||
|
||||
:root {
|
||||
--color-primary: #0070f3;
|
||||
--color-secondary: #7928ca;
|
||||
}
|
||||
Reference in New Issue
Block a user