basic seach small json data set models and parts

This commit is contained in:
2025-11-24 12:51:14 +00:00
parent b064d458c4
commit ffe7ea5d75
58 changed files with 3006 additions and 81 deletions
@@ -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);
}
+11
View File
@@ -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);
}
+5
View File
@@ -0,0 +1,5 @@
import { MODELS } from "@/lib/data/models";
export async function GET() {
return Response.json(MODELS);
}
+11
View File
@@ -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);
}
+5
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+7
View File
@@ -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>
);
}
+8
View File
@@ -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>
);
}
+9
View File
@@ -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>
);
}
+12
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+37
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+9
View File
@@ -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>
);
}
+10
View File
@@ -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>
);
}
+8
View File
@@ -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
View File
@@ -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>
);
+41
View File
@@ -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>
);
}
+86
View File
@@ -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
View File
@@ -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>
);
}
+44
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
@use "../variables" as *;
@reference "tailwindcss";
.navbar {
@apply flex gap-4 p-4 border-b;
background: $nav-bg;
}
+6
View File
@@ -0,0 +1,6 @@
@use "../variables" as *;
.part-card {
@apply border rounded p-4 shadow;
background: $card-bg;
}
+7
View File
@@ -0,0 +1,7 @@
@import "tailwindcss";
body {
margin: 0;
padding: 0;
font-family: sans-serif;
}
+11
View File
@@ -0,0 +1,11 @@
@use "./variables" as *;
@use "tailwindcss";
body {
margin: 0;
padding: 0;
font-family: sans-serif;
background: $background;
color: $text-color;
}
+13
View File
@@ -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;
}