Compare commits

...
10 Commits
91 changed files with 7969 additions and 99 deletions
+21
View File
@@ -0,0 +1,21 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev"
},
{"name": "Launch Chrome against localhost",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
}
]
}
+107
View File
@@ -0,0 +1,107 @@
#!/bin/zsh
echo "🔎 Running Next.js Dynamic Route Health Check..."
echo ""
DYNAMIC_PAGES=$(find src/app -type f -path "*/\[*\]/page.js")
RED=$(tput setaf 1)
YELLOW=$(tput setaf 3)
GREEN=$(tput setaf 2)
RESET=$(tput sgr0)
check_pass() {
echo "${GREEN}✔ PASS${RESET} $1"
}
check_warn() {
echo "${YELLOW}⚠ WARN${RESET} $1"
}
check_fail() {
echo "${RED}✖ FAIL${RESET} $1"
}
for FILE in $DYNAMIC_PAGES; do
echo "=========================================="
echo "📄 Checking: $FILE"
FOLDER=$(basename $(dirname "$FILE"))
PARAM_NAME=${FOLDER//[\[\]]/}
CONTENT=$(cat "$FILE")
echo "➡ Route param is: ${PARAM_NAME}"
# 1. Check for export default async
if echo "$CONTENT" | grep -q "export default async function"; then
check_pass "Has async default export"
else
check_fail "Missing 'export default async function'"
fi
# 2. Check for await params
if echo "$CONTENT" | grep -q "await params"; then
check_pass "'await params' found"
else
check_fail "Missing 'await params' — required in Next.js 15+"
fi
# 3. Check if extracted param name is correct
if echo "$CONTENT" | grep -q "const { $PARAM_NAME } = await params"; then
check_pass "Correct param extraction: { $PARAM_NAME }"
else
check_warn "Param '$PARAM_NAME' may not be extracted with 'await params'"
fi
# 4. Check for leftover bad usage: params.paramName
if echo "$CONTENT" | grep -q "params\.$PARAM_NAME"; then
check_fail "Found old syntax 'params.$PARAM_NAME' — must be removed!"
else
check_pass "No old 'params.$PARAM_NAME' references"
fi
# 5. Check that param is used somewhere
if echo "$CONTENT" | grep -q "$PARAM_NAME"; then
check_pass "Param is referenced in file"
else
check_warn "Param '$PARAM_NAME' isn't used — this may be incorrect"
fi
# 6. Validate imports
IMPORT_ERRORS=0
while IFS= read -r line; do
if [[ "$line" == import* ]]; then
PATH=$(echo "$line" | sed -n 's/.*from "\(.*\)".*/\1/p')
if [[ "$PATH" == @/* ]]; then
# Attempt resolution
RESOLVED="src${PATH#@}"
RESOLVED="${RESOLVED%.js}.js"
if [ ! -f "$RESOLVED" ] && [ ! -f "${RESOLVED%.js}.jsx" ] && [ ! -f "${RESOLVED%.js}.ts" ] && [ ! -f "${RESOLVED%.js}.tsx" ]; then
check_warn "Import may be broken: $line"
IMPORT_ERRORS=1
fi
fi
fi
done <<< "$(grep "^import" "$FILE")"
if [[ $IMPORT_ERRORS -eq 0 ]]; then
check_pass "All imports appear valid"
fi
# 7. Check param folder structure
DIR=$(dirname "$FILE")
if [ ! -d "$DIR" ]; then
check_fail "Missing directory: $DIR (route broken)"
else
check_pass "Route directory exists"
fi
echo ""
done
echo "=========================================="
echo "🏁 Dynamic Route Health Check Complete!"
+73
View File
@@ -0,0 +1,73 @@
#!/bin/zsh
echo "🛠 Starting API Dynamic Route Autofix (Next.js 15+)..."
echo ""
# Find all dynamic API routes like src/app/api/**/[something]/route.js
DYNAMIC_API=$(find src/app/api -type f -path "*/\[*\]/route.js")
RED=$(tput setaf 1)
YELLOW=$(tput setaf 3)
GREEN=$(tput setaf 2)
RESET=$(tput sgr0)
for FILE in $DYNAMIC_API; do
echo "=========================================="
echo "📄 Fixing API route: $FILE"
# Extract folder name: [id], [modelId], [partId], etc.
FOLDER=$(basename $(dirname "$FILE"))
PARAM_NAME=${FOLDER//[\[\]]/} # remove brackets
echo "➡ Dynamic API param detected: ${GREEN}${PARAM_NAME}${RESET}"
# Make backup
cp "$FILE" "$FILE.bak"
CONTENT=$(cat "$FILE")
############################################################
# 1. Fix function signature
############################################################
if echo "$CONTENT" | grep -q "export async function GET(request, { params })"; then
echo " 🔧 Fixing signature..."
sed -i '' "s/export async function GET(request, { params })/export async function GET(request, context) {\n const { $PARAM_NAME } = await context.params;\n/" "$FILE"
elif ! echo "$CONTENT" | grep -q "await context.params"; then
echo " 🔧 Inserting param extraction at top of GET function..."
sed -i '' "s/export async function GET([^)]*) {/export async function GET(request, context) {\n const { $PARAM_NAME } = await context.params;/" "$FILE"
else
echo " ✔ Signature already patched"
fi
############################################################
# 2. Fix incorrect usage of params.PARAM_NAME
############################################################
if echo "$CONTENT" | grep -q "params.$PARAM_NAME"; then
echo " 🔧 Removing old params.$PARAM_NAME usage..."
sed -i '' "s/params\.$PARAM_NAME/$PARAM_NAME/g" "$FILE"
else
echo " ✔ No old params.$PARAM_NAME found"
fi
############################################################
# 3. Validate presence of correct variable usage
############################################################
if grep -q "$PARAM_NAME" "$FILE"; then
echo " ✔ Param $PARAM_NAME is being used correctly"
else
echo " ${YELLOW}⚠ WARN:${RESET} Param '$PARAM_NAME' not found in file. You may need manual review."
fi
############################################################
echo " ${GREEN}✔ API route successfully patched${RESET}"
echo ""
done
echo "=========================================="
echo "🏁 API Dynamic Route Autofix Complete!"
+36
View File
@@ -0,0 +1,36 @@
#!/bin/zsh
echo "🔧 Fixing incorrect '@/src/...' imports..."
# Search for JS, JSX, TS, TSX files under src/ only
FILES=$(find src -type f \( -name "*.js" -o -name "*.jsx" -o -name "*.ts" -o -name "*.tsx" \))
for FILE in $FILES; do
echo "📄 Checking $FILE"
# Fix @/src/lib → @/lib
if grep -q '@/src/lib' "$FILE"; then
echo " ➜ Fixing '@/src/lib' → '@/lib'"
sed -i '' 's#@/src/lib#@/lib#g' "$FILE"
fi
# Fix @/src/app → @/app
if grep -q '@/src/app' "$FILE"; then
echo " ➜ Fixing '@/src/app' → '@/app'"
sed -i '' 's#@/src/app#@/app#g' "$FILE"
fi
# Fix @/src/components → @/components
if grep -q '@/src/components' "$FILE"; then
echo " ➜ Fixing '@/src/components' → '@/components'"
sed -i '' 's#@/src/components#@/components#g' "$FILE"
fi
# Fix @/src/styles → @/styles
if grep -q '@/src/styles' "$FILE"; then
echo " ➜ Fixing '@/src/styles' → '@/styles'"
sed -i '' 's#@/src/styles#@/styles#g' "$FILE"
fi
done
echo "🎉 Import alias fixes complete!"
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env bash
echo "🚀 Generating Admin UI (Next.js App Router, JS, Tailwind)..."
### Root paths
APP_DIR="src/app/admin"
COMP_DIR="src/components/admin"
LIB_DIR="lib"
# Create directories
mkdir -p "$APP_DIR"
mkdir -p "$APP_DIR/models/new"
mkdir -p "$APP_DIR/models/[id]"
mkdir -p "$APP_DIR/parts/new"
mkdir -p "$APP_DIR/parts/[id]"
mkdir -p "$COMP_DIR"
mkdir -p "$LIB_DIR"
############################################
# 1. ADMIN LAYOUT
############################################
cat <<'EOF' > "$APP_DIR/layout.js"
import React from "react";
import Link from "next/link";
import "../globals.css";
export default function AdminLayout({ children }) {
return (
<div className="min-h-screen flex bg-gray-100">
<aside className="w-64 bg-white shadow-md p-6">
<h2 className="text-2xl font-bold mb-6">Admin</h2>
<nav className="space-y-4">
<Link href="/admin" className="block hover:text-black">
Dashboard
</Link>
<Link href="/admin/models" className="block hover:text-black">
Models
</Link>
<Link href="/admin/parts" className="block hover:text-black">
Parts
</Link>
</nav>
</aside>
<main className="flex-1 p-10">{children}</main>
</div>
);
}
EOF
############################################
# 2. ADMIN HOME PAGE
############################################
cat <<'EOF' > "$APP_DIR/page.js"
import { supabaseServer } from "@/lib/supabaseServer";
export default async function AdminHome() {
const { data: models } = await supabaseServer.from("models").select("*");
const { data: parts } = await supabaseServer.from("parts").select("*");
const { data: compat } = await supabaseServer.from("parts_compatible_models").select("*");
return (
<div>
<h1 className="text-3xl font-bold mb-6">Admin Dashboard</h1>
<div className="grid grid-cols-3 gap-6">
<div className="p-6 bg-white shadow rounded-lg">
<h2 className="text-xl font-semibold">Models</h2>
<p className="text-3xl mt-2">{models?.length ?? 0}</p>
</div>
<div className="p-6 bg-white shadow rounded-lg">
<h2 className="text-xl font-semibold">Parts</h2>
<p className="text-3xl mt-2">{parts?.length ?? 0}</p>
</div>
<div className="p-6 bg-white shadow rounded-lg">
<h2 className="text-xl font-semibold">Compatibility</h2>
<p className="text-3xl mt-2">{compat?.length ?? 0}</p>
</div>
</div>
</div>
);
}
EOF
############################################
# 3. MODELS LIST PAGE
############################################
cat <<'EOF' > "$APP_DIR/models/page.js"
import Link from "next/link";
import { supabaseServer } from "@/lib/supabaseServer";
export default async function ModelsPage() {
const { data: models } = await supabaseServer
.from("models")
.select("*")
.order("name");
return (
<div>
<div className="flex justify-between mb-6">
<h1 className="text-3xl font-bold">Models</h1>
<Link href="/admin/models/new" className="px-4 py-2 bg-black text-white rounded">
+ Add Model
</Link>
</div>
<div className="bg-white shadow rounded-lg divide-y">
{models?.map((m) => (
<Link
key={m.id}
href={`/admin/models/${m.id}`}
className="block p-4 hover:bg-gray-50"
>
<div className="font-semibold">{m.name}</div>
<div className="text-sm text-gray-500">{m.brand}</div>
</Link>
))}
</div>
</div>
);
}
EOF
############################################
# 4. NEW MODEL PAGE
############################################
cat <<'EOF' > "$APP_DIR/models/new/page.js"
"use client";
import { useState } from "react";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { useRouter } from "next/navigation";
export default function NewModelPage() {
const [form, setForm] = useState({
name: "",
brand: "",
scale: "",
chassis_type: "",
notes: "",
});
const router = useRouter();
async function save() {
const { error } = await supabaseBrowser.from("models").insert(form);
if (!error) router.push("/admin/models");
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">Add New Model</h1>
<div className="space-y-4 max-w-lg">
{Object.entries(form).map(([key, value]) => (
<div key={key}>
<label className="block mb-1 capitalize">{key.replace("_", " ")}</label>
<input
className="w-full p-2 border rounded"
value={value}
onChange={(e) => setForm({ ...form, [key]: e.target.value })}
/>
</div>
))}
<button
onClick={save}
className="px-4 py-2 bg-black text-white rounded"
>
Save
</button>
</div>
</div>
);
}
EOF
############################################
# 5. MODEL DETAIL PAGE
############################################
cat <<'EOF' > "$APP_DIR/models/[id]/page.js"
import Link from "next/link";
import { supabaseServer } from "@/lib/supabaseServer";
export default async function ModelDetail({ params }) {
const { id } = params;
const { data: model } = await supabaseServer
.from("models")
.select("*")
.eq("id", id)
.single();
const { data: compat } = await supabaseServer
.from("parts_compatible_models")
.select("*, parts(*)")
.eq("model_id", id);
return (
<div>
<h1 className="text-3xl font-bold mb-4">{model?.name}</h1>
<div className="bg-white p-4 shadow rounded mb-6">
<div className="text-gray-700">Brand: {model?.brand}</div>
<div className="text-gray-700">Scale: {model?.scale}</div>
<div className="text-gray-700">Chassis: {model?.chassis_type}</div>
</div>
<h2 className="text-2xl font-bold mb-2">Compatible Parts</h2>
<Link
href={`/admin/models/${id}/add-part`}
className="inline-block mb-4 px-4 py-2 bg-black text-white rounded"
>
+ Add Compatible Part
</Link>
<div className="bg-white shadow rounded divide-y">
{compat?.map((c) => (
<div key={c.id} className="p-4 flex justify-between">
<div>
<div className="font-semibold">{c.parts.name}</div>
<div className="text-sm text-gray-500">{c.fit_type}</div>
</div>
<Link
href={`/admin/parts/${c.parts.id}`}
className="text-blue-600 underline"
>
View Part
</Link>
</div>
))}
</div>
</div>
);
}
EOF
############################################
# PARTS PAGES (STUBS)
############################################
cat <<'EOF' > "$APP_DIR/parts/page.js"
export default function PartsPage() {
return (
<div>
<h1 className="text-3xl font-bold">Parts (UI Coming Next)</h1>
<p className="text-gray-600 mt-2">List, edit, and link parts here.</p>
</div>
);
}
EOF
cat <<'EOF' > "$APP_DIR/parts/new/page.js"
export default function NewPartPage() {
return (
<div>
<h1 className="text-2xl font-bold">Add Part (UI Coming Next)</h1>
</div>
);
}
EOF
############################################
# SUPABASE CLIENTS
############################################
cat <<'EOF' > "$LIB_DIR/supabaseClient.js"
import { createClient } from "@supabase/supabase-js";
export const supabaseBrowser = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
EOF
cat <<'EOF' > "$LIB_DIR/supabaseServer.js"
import { createClient } from "@supabase/supabase-js";
export const supabaseServer = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY
);
EOF
echo "🎉 Admin UI scaffolding complete!"
+39
View File
@@ -0,0 +1,39 @@
#!/bin/zsh
echo "🛠 Patching all dynamic Next.js route pages to use 'await params'..."
# Find all dynamic folders: anything like /[paramName]/
DYNAMIC_PAGES=$(find src/app -type f -path "*/\[*\]/page.js")
for FILE in $DYNAMIC_PAGES; do
echo "📄 Patching: $FILE"
# Extract folder name, e.g. [partId]
FOLDER=$(basename $(dirname "$FILE"))
# Remove brackets -> partId
PARAM_NAME=${FOLDER//[\[\]]/}
# Make backup
cp "$FILE" "$FILE.bak"
# Replace any direct use of params.PARAM_NAME
# Ensure the file declares: const { paramName } = await params;
# Only insert if not already patched
if ! grep -q "await params" "$FILE"; then
echo " Inserting param extraction: const { $PARAM_NAME } = await params;"
# Insert param extraction after function signature
sed -i '' "s/export default async function \(.*\)({ params }) {/export default async function \1({ params }) {\n const { $PARAM_NAME } = await params;/" "$FILE"
else
echo " ✔ Already patched."
fi
echo " 🔧 Updating references to params.$PARAM_NAME..."
# Replace params.PARAM_NAME → PARAM_NAME
sed -i '' "s/params\.$PARAM_NAME/$PARAM_NAME/g" "$FILE"
done
echo "🎉 All dynamic route pages patched successfully!"
+42
View File
@@ -0,0 +1,42 @@
#!/bin/zsh
echo "🎨 Depth-aware SCSS variables import patcher starting..."
VARIABLES_FILE="src/app/styles/variables.scss"
if [ ! -f "$VARIABLES_FILE" ]; then
echo "❌ variables.scss not found at: $VARIABLES_FILE"
exit 1
fi
# Normalize absolute path for comparison
VARIABLES_PATH=$(realpath "$VARIABLES_FILE")
# Find all SCSS files under src/app (excluding the variables file itself)
SCSS_FILES=$(find src/app -type f -name "*.scss" ! -path "*variables.scss")
for FILE in $SCSS_FILES; do
echo "🔍 Checking: $FILE"
# Skip if already importing variables
if grep -q 'variables' "$FILE"; then
echo " ✔ Already has variables import"
continue
fi
# Compute relative path from current SCSS file directory to variables.scss
FILE_DIR=$(dirname "$FILE")
REL_PATH=$(realpath --relative-to="$FILE_DIR" "$VARIABLES_PATH")
# Convert absolute to Sass import (remove leading ./ if present)
IMPORT_PATH="${REL_PATH#./}"
echo " Adding import: @import \"$IMPORT_PATH\";"
# Prepend import to file
echo "@import \"$IMPORT_PATH\";" | cat - "$FILE" > "$FILE.tmp"
mv "$FILE.tmp" "$FILE"
done
echo "🎉 Depth-aware SCSS import patching complete!"
+22
View File
@@ -0,0 +1,22 @@
#!/bin/zsh
echo "🩹 Auto-patching SCSS files with @reference \"tailwindcss\"..."
SCSS_FILES=$(find src -type f -name "*.scss")
for FILE in $SCSS_FILES; do
echo "Checking: $FILE"
# Skip if already has @reference
if grep -q '@reference "tailwindcss"' "$FILE"; then
echo " ✔ Already patched"
continue
fi
echo " Adding @reference to $FILE"
# Prepend @reference at top of file
echo '@reference "tailwindcss";' | cat - "$FILE" > "$FILE.tmp" && mv "$FILE.tmp" "$FILE"
done
echo "🎉 All SCSS files have been patched for Tailwind v4!"
+110
View File
@@ -0,0 +1,110 @@
#!/bin/zsh
echo "📡 Creating API Layer for RC Compatibility App..."
BASE="src/app/api"
#############################################
# CREATE DIRECTORY STRUCTURE
#############################################
mkdir -p "$BASE/models"
mkdir -p "$BASE/models/[id]"
mkdir -p "$BASE/parts"
mkdir -p "$BASE/parts/[id]"
mkdir -p "$BASE/compatibility/model/[id]"
mkdir -p "$BASE/compatibility/part/[id]"
#############################################
# MODELS → /api/models
#############################################
cat > "$BASE/models/route.js" << 'EOF'
import { MODELS } from "@/src/lib/data/models";
export async function GET() {
return Response.json(MODELS);
}
EOF
#############################################
# MODEL BY ID → /api/models/[id]
#############################################
cat > "$BASE/models/[id]/route.js" << 'EOF'
import { MODELS } from "@/src/lib/data/models";
export async function GET(request, { params }) {
const model = MODELS.find(m => m.id === params.id);
if (!model) {
return Response.json({ error: "Model not found" }, { status: 404 });
}
return Response.json(model);
}
EOF
#############################################
# PARTS → /api/parts
#############################################
cat > "$BASE/parts/route.js" << 'EOF'
import { PARTS } from "@/src/lib/data/parts";
export async function GET() {
return Response.json(PARTS);
}
EOF
#############################################
# PART BY ID → /api/parts/[id]
#############################################
cat > "$BASE/parts/[id]/route.js" << 'EOF'
import { PARTS } from "@/src/lib/data/parts";
export async function GET(request, { params }) {
const part = PARTS.find(p => p.id === params.id);
if (!part) {
return Response.json({ error: "Part not found" }, { status: 404 });
}
return Response.json(part);
}
EOF
#############################################
# MODEL COMPATIBILITY → /api/compatibility/model/[id]
#############################################
cat > "$BASE/compatibility/model/[id]/route.js" << 'EOF'
import { getCompatibleParts } from "@/src/lib/compatibility/engine";
export async function GET(request, { params }) {
const results = getCompatibleParts(params.id);
if (!results.length) {
return Response.json({ error: "No compatible parts or model not found" }, { status: 404 });
}
return Response.json(results);
}
EOF
#############################################
# PART COMPATIBILITY → /api/compatibility/part/[id]
#############################################
cat > "$BASE/compatibility/part/[id]/route.js" << 'EOF'
import { getCrossCompatibleModels } from "@/src/lib/compatibility/engine";
export async function GET(request, { params }) {
const results = getCrossCompatibleModels(params.id);
if (!results.length) {
return Response.json({ error: "No compatible models or part not found" }, { status: 404 });
}
return Response.json(results);
}
EOF
echo "🎉 API Layer created successfully!"
+259
View File
@@ -0,0 +1,259 @@
#!/bin/zsh
echo "🛠 Setting up Compatibility Explorer (Option A)..."
COMPAT_DIR="src/app/compatibility"
mkdir -p "$COMPAT_DIR"
cat > "$COMPAT_DIR/page.js" << 'EOF'
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { CATEGORIES } from "@/lib/data/categories";
function ModeCard({ title, emoji, description, children }) {
return (
<div className="border rounded-xl p-6 shadow-sm bg-white flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="text-2xl">{emoji}</div>
<div>
<h2 className="font-semibold text-lg">{title}</h2>
<p className="text-sm text-gray-500">{description}</p>
</div>
</div>
<div className="mt-2 flex flex-col gap-3">{children}</div>
</div>
);
}
export default function CompatibilityPage() {
const [models, setModels] = useState([]);
const [parts, setParts] = useState([]);
const [loading, setLoading] = useState(true);
const [modelForParts, setModelForParts] = useState("");
const [partForModels, setPartForModels] = useState("");
const [modelA, setModelA] = useState("");
const [modelB, setModelB] = useState("");
const [categoryModel, setCategoryModel] = useState("");
const [category, setCategory] = useState("");
const router = useRouter();
useEffect(() => {
async function load() {
try {
const [modelsRes, partsRes] = await Promise.all([
fetch("/api/models"),
fetch("/api/parts"),
]);
const modelsJson = modelsRes.ok ? await modelsRes.json() : [];
const partsJson = partsRes.ok ? await partsRes.json() : [];
setModels(modelsJson || []);
setParts(partsJson || []);
} catch (e) {
console.error("Failed to load models/parts", e);
} finally {
setLoading(false);
}
}
load();
}, []);
function handleGoToModelCompatibility() {
if (!modelForParts) return;
router.push(`/model/${modelForParts}`);
}
function handleGoToPartCompatibility() {
if (!partForModels) return;
router.push(`/part/${partForModels}`);
}
function handleExploreCategory() {
if (!categoryModel || !category) return;
router.push(`/category/${categoryModel}/${category}`);
}
function handleCompareModels() {
if (!modelA || !modelB) return;
// Stubbed for now to avoid broken routes
alert("Model comparison view coming soon! (Hotshot vs Supershot, etc.)");
}
return (
<div className="space-y-10">
{/* Breadcrumb-ish header */}
<nav className="text-sm text-gray-500">
<Link href="/" className="hover:underline text-blue-600">
Home
</Link>{" "}
<span></span>{" "}
<span className="font-semibold text-gray-700">Compatibility Explorer</span>
</nav>
{/* Page Title */}
<div>
<h1 className="text-4xl font-bold mb-2">Compatibility Explorer</h1>
<p className="text-gray-600 max-w-2xl">
Discover cross-compatibility between RC models and parts. Use one of the
tools below to explore fitment, upgrades, and shared components.
</p>
</div>
{loading && (
<p className="text-gray-500">Loading models and parts…</p>
)}
{/* Mode Selector Grid */}
{!loading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Mode 1: Model → Compatible Parts */}
<ModeCard
title="Model → Compatible Parts"
emoji="🔧"
description="Find every part that fits a specific RC model."
>
<select
className="border rounded-lg px-3 py-2"
value={modelForParts}
onChange={(e) => setModelForParts(e.target.value)}
>
<option value="">Select a model…</option>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.brand})
</option>
))}
</select>
<button
onClick={handleGoToModelCompatibility}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
disabled={!modelForParts}
>
Show Compatible Parts
</button>
</ModeCard>
{/* Mode 2: Part → Compatible Models */}
<ModeCard
title="Part → Compatible Models"
emoji="🧩"
description="See all RC models that this part works with."
>
<select
className="border rounded-lg px-3 py-2"
value={partForModels}
onChange={(e) => setPartForModels(e.target.value)}
>
<option value="">Select a part…</option>
{parts.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.id})
</option>
))}
</select>
<button
onClick={handleGoToPartCompatibility}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
disabled={!partForModels}
>
Show Compatible Models
</button>
</ModeCard>
{/* Mode 3: Compare Two Models (stubbed) */}
<ModeCard
title="Compare Two Models"
emoji="🔍"
description="See shared and unique parts between two models. (Coming soon)"
>
<div className="flex flex-col gap-2">
<select
className="border rounded-lg px-3 py-2"
value={modelA}
onChange={(e) => setModelA(e.target.value)}
>
<option value="">Select Model A…</option>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.brand})
</option>
))}
</select>
<select
className="border rounded-lg px-3 py-2"
value={modelB}
onChange={(e) => setModelB(e.target.value)}
>
<option value="">Select Model B…</option>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.brand})
</option>
))}
</select>
</div>
<button
onClick={handleCompareModels}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg cursor-not-allowed"
disabled
>
Compare (Coming Soon)
</button>
</ModeCard>
{/* Mode 4: Explore Categories */}
<ModeCard
title="Explore by Category"
emoji="📂"
description="Browse shocks, drivetrain, chassis, and more for a model."
>
<div className="flex flex-col gap-2">
<select
className="border rounded-lg px-3 py-2"
value={categoryModel}
onChange={(e) => setCategoryModel(e.target.value)}
>
<option value="">Select a model…</option>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.brand})
</option>
))}
</select>
<select
className="border rounded-lg px-3 py-2"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="">Select a category…</option>
{CATEGORIES.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
</div>
<button
onClick={handleExploreCategory}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
disabled={!categoryModel || !category}
>
Explore Category
</button>
</ModeCard>
</div>
)}
</div>
);
}
EOF
echo "🎉 Compatibility Explorer (Option A) page created at src/app/compatibility/page.js"
+239
View File
@@ -0,0 +1,239 @@
#!/bin/zsh
echo "📦 Creating RC Compatibility Data Layer + Engine..."
BASE="src/lib"
#############################################
# CREATE DIRECTORY STRUCTURE
#############################################
mkdir -p "$BASE/data"
mkdir -p "$BASE/compatibility"
mkdir -p "$BASE/search"
#############################################
# CREATE DATA FILES
#############################################
# categories.js
cat > "$BASE/data/categories.js" << 'EOF'
export const CATEGORIES = [
"drivetrain",
"suspension",
"chassis",
"shocks",
"electronics",
"steering",
"wheels",
"tires",
"body"
];
EOF
# models.js
cat > "$BASE/data/models.js" << 'EOF'
export const MODELS = [
{
id: "tamiya-hotshot",
brand: "Tamiya",
name: "Hotshot",
scale: "1/10",
year: 1985,
categories: ["drivetrain", "suspension", "chassis", "shocks"],
generation: 1,
},
{
id: "tamiya-supershot",
brand: "Tamiya",
name: "Super Shot",
scale: "1/10",
year: 1986,
categories: ["drivetrain", "suspension", "chassis", "shocks"],
generation: 1,
},
{
id: "traxxas-rustler-4x4",
brand: "Traxxas",
name: "Rustler 4x4",
scale: "1/10",
year: 2018,
categories: ["drivetrain", "suspension", "electronics", "steering"],
generation: 2,
}
];
EOF
# parts.js
cat > "$BASE/data/parts.js" << 'EOF'
export const PARTS = [
{
id: "TAM-198055",
name: "Hotshot Front Gearbox",
category: "drivetrain",
fitsModels: ["tamiya-hotshot", "tamiya-supershot"],
notes: "Original vintage fit.",
},
{
id: "TAM-430512",
name: "Super Shot Steering Knuckle",
category: "steering",
fitsModels: ["tamiya-supershot"],
},
{
id: "TRA-6755X",
name: "Traxxas Steel Driveshaft Upgrade",
category: "drivetrain",
fitsModels: ["traxxas-rustler-4x4"],
upgrade: true,
},
{
id: "GEN-UNIV-55MM",
name: "Generic 55mm Shock Set",
category: "shocks",
fitsModels: ["tamiya-hotshot", "tamiya-supershot"],
universalFit: true,
}
];
EOF
#############################################
# COMPATIBILITY UTILITIES
#############################################
cat > "$BASE/compatibility/utils.js" << 'EOF'
export function getModel(modelId, models) {
return models.find(m => m.id === modelId) || null;
}
export function getPart(partId, parts) {
return parts.find(p => p.id === partId) || null;
}
EOF
#############################################
# COMPATIBILITY SCORING SYSTEM
#############################################
cat > "$BASE/compatibility/scoring.js" << 'EOF'
export function scoreCompatibility({ directFit, sameBrand, sameCategory, universal, generationMatch }) {
let score = 0;
if (directFit) score += 100;
if (sameBrand) score += 20;
if (sameCategory) score += 15;
if (generationMatch) score += 10;
if (universal) score += 5;
return Math.min(score, 100);
}
EOF
#############################################
# MAIN COMPATIBILITY ENGINE
#############################################
cat > "$BASE/compatibility/engine.js" << 'EOF'
import { MODELS } from "../data/models";
import { PARTS } from "../data/parts";
import { getModel, getPart } from "./utils";
import { scoreCompatibility } from "./scoring";
// Check model-to-part compatibility
export function isCompatible(modelId, partId) {
const model = getModel(modelId, MODELS);
const part = getPart(partId, PARTS);
if (!model || !part) {
return { compatible: false, reason: "Unknown model or part" };
}
const directFit = part.fitsModels.includes(modelId);
const sameBrand = model.brand && part.brand && part.brand === model.brand;
const sameCategory = model.categories.includes(part.category);
const universal = !!part.universalFit;
const generationMatch = part.generation
? part.generation === model.generation
: false;
const score = scoreCompatibility({
directFit,
sameBrand,
sameCategory,
universal,
generationMatch
});
return {
compatible: score > 0,
score,
details: {
directFit,
sameBrand,
sameCategory,
universal,
generationMatch
}
};
}
// Get all compatible parts for a model
export function getCompatibleParts(modelId) {
return PARTS
.map(part => ({
part,
result: isCompatible(modelId, part.id)
}))
.filter(item => item.result.compatible)
.sort((a, b) => b.result.score - a.result.score);
}
// Get models that fit a part
export function getCrossCompatibleModels(partId) {
return MODELS
.map(model => ({
model,
result: isCompatible(model.id, partId)
}))
.filter(item => item.result.compatible)
.sort((a, b) => b.result.score - a.result.score);
}
// Suggest alternative parts in same category
export function suggestAlternatives(partId) {
const part = getPart(partId, PARTS);
if (!part) return [];
return PARTS.filter(p =>
p.category === part.category && p.id !== partId
);
}
EOF
#############################################
# SEARCH ENGINE
#############################################
cat > "$BASE/search/search.js" << 'EOF'
import { MODELS } from "../data/models";
import { PARTS } from "../data/parts";
export function searchModels(query) {
const q = query.toLowerCase();
return MODELS.filter(m =>
m.name.toLowerCase().includes(q) ||
m.brand.toLowerCase().includes(q)
);
}
export function searchParts(query) {
const q = query.toLowerCase();
return PARTS.filter(p =>
p.name.toLowerCase().includes(q) ||
p.id.toLowerCase().includes(q)
);
}
EOF
echo "🎉 Data layer + compatibility engine generated successfully!"
+104
View File
@@ -0,0 +1,104 @@
#!/bin/zsh
echo "📘 Creating Models Browser Page..."
PAGE="src/app/models"
COMP="src/app/components"
LIB="src/lib/ui"
mkdir -p "$PAGE"
#############################################
# Create models/page.js
#############################################
cat > "$PAGE/page.js" << 'EOF'
"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>
);
}
EOF
echo "🎉 Models Browser Page created!"
+300
View File
@@ -0,0 +1,300 @@
#!/bin/zsh
echo "🎨 Setting up UI integration (Next.js App Router)..."
BASE="src/app"
COMP="src/app/components"
UTIL="src/lib/ui"
mkdir -p "$COMP"
mkdir -p "$UTIL"
#############################################
# API CLIENT (simple fetch helpers)
#############################################
cat > "$UTIL/api.js" << 'EOF'
export async function fetchJSON(url) {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return null;
return res.json();
}
export function getModel(id) {
return fetchJSON(`/api/models/${id}`);
}
export function getPart(id) {
return fetchJSON(`/api/parts/${id}`);
}
export function getCompatibleParts(modelId) {
return fetchJSON(`/api/compatibility/model/${modelId}`);
}
export function getCompatibleModels(partId) {
return fetchJSON(`/api/compatibility/part/${partId}`);
}
EOF
#############################################
# COMPONENTS
#############################################
# ModelCard.js
cat > "$COMP/ModelCard.js" << 'EOF'
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>
);
}
EOF
# PartCard.js
cat > "$COMP/PartCard.js" << 'EOF'
export default function PartCard({ part }) {
return (
<div className="border rounded p-4 shadow">
<h2 className="font-bold text-lg">{part.name}</h2>
<p className="opacity-70">ID: {part.id}</p>
</div>
);
}
EOF
# SearchBar.js
cat > "$COMP/SearchBar.js" << 'EOF'
"use client";
import { useState } from "react";
export default function SearchBar({ onSearch }) {
const [text, setText] = useState("");
return (
<div className="flex gap-2">
<input
className="border p-2 rounded w-full"
placeholder="Search models or parts..."
value={text}
onChange={e => setText(e.target.value)}
/>
<button
className="px-4 py-2 bg-primary text-white rounded"
onClick={() => onSearch(text)}
>
Go
</button>
</div>
);
}
EOF
#############################################
# UI: Model Page Integration
#############################################
cat > "$BASE/model/[modelId]/page.js" << 'EOF'
import { getModel, getCompatibleParts } from "@/src/lib/ui/api";
import PartCard from "@/src/app/components/PartCard";
export default async function ModelPage({ params }) {
const model = await getModel(params.modelId);
const parts = await getCompatibleParts(params.modelId);
if (!model) return <p>Model not found</p>;
return (
<div className="space-y-4">
<h1 className="text-3xl font-bold">{model.name}</h1>
<p className="opacity-70">{model.brand} • {model.scale}</p>
<h2 className="text-xl font-bold mt-6">Compatible Parts</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Array.isArray(parts) && parts.length > 0 ? (
parts.map(item => <PartCard key={item.part.id} part={item.part} />)
) : (
<p>No compatible parts.</p>
)}
</div>
</div>
);
}
EOF
#############################################
# UI: Part Page Integration
#############################################
cat > "$BASE/part/[partId]/page.js" << 'EOF'
import { getPart, getCompatibleModels } from "@/src/lib/ui/api";
import ModelCard from "@/src/app/components/ModelCard";
export default async function PartDetailPage({ params }) {
const part = await getPart(params.partId);
const models = await getCompatibleModels(params.partId);
if (!part) return <p>Part not found</p>;
return (
<div className="space-y-4">
<h1 className="text-3xl font-bold">{part.name}</h1>
<p className="opacity-70">ID: {part.id}</p>
<h2 className="text-xl font-bold mt-6">Compatible Models</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Array.isArray(models) && models.length > 0 ? (
models.map(item => (
<ModelCard key={item.model.id} model={item.model} />
))
) : (
<p>No compatible models.</p>
)}
</div>
</div>
);
}
EOF
#############################################
# UI: Category Page Integration
#############################################
cat > "$BASE/category/[modelId]/[categoryName]/page.js" << 'EOF'
import { getModel, getCompatibleParts } from "@/src/lib/ui/api";
import PartCard from "@/src/app/components/PartCard";
export default async function CategoryPage({ params }) {
const model = await getModel(params.modelId);
const allParts = await getCompatibleParts(params.modelId);
if (!model) return <p>Model not found</p>;
const parts = allParts
? allParts.filter(p => p.part.category === params.categoryName)
: [];
return (
<div>
<h1 className="text-2xl font-bold">
{params.categoryName} for {model.name}
</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
{parts.length ? (
parts.map(p => (
<PartCard key={p.part.id} part={p.part} />
))
) : (
<p>No compatible parts in this category.</p>
)}
</div>
</div>
);
}
EOF
#############################################
# UI: Compatibility Explorer Page
#############################################
cat > "$BASE/compatibility/page.js" << 'EOF'
"use client";
import { useState } from "react";
import { fetchJSON } from "@/src/lib/ui/api";
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
className="px-4 py-2 bg-primary text-white rounded"
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>
);
}
EOF
#############################################
# UI: Home Page Search Integration
#############################################
cat > "$BASE/page.js" << 'EOF'
"use client";
import { useState } from "react";
import SearchBar from "./components/SearchBar";
import { searchModels, searchParts } from "@/src/lib/search/search";
export default function HomePage() {
const [results, setResults] = useState([]);
async 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 =>
item.categories ? (
<div key={item.id} className="border p-4 rounded">
<strong>{item.name}</strong> — Model
</div>
) : (
<div key={item.id} className="border p-4 rounded">
<strong>{item.name}</strong> — Part
</div>
)
)}
</div>
</div>
);
}
EOF
echo "🎉 UI integration created successfully!"
+156
View File
@@ -0,0 +1,156 @@
#!/bin/zsh
echo "🎨 Applying UI polish across the app..."
BASE="src/app"
COMP="src/app/components"
mkdir -p "$COMP"
#############################################
# CategoryCard component
#############################################
cat > "$COMP/CategoryCard.js" << 'EOF'
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>
);
}
EOF
#############################################
# Patch ModelPage
#############################################
cat > "$BASE/model/[modelId]/page.js" << 'EOF'
import { getModel, getCompatibleParts } from "@/src/lib/ui/api";
import CategoryCard from "@/src/app/components/CategoryCard";
import PartCard from "@/src/app/components/PartCard";
export default async function ModelPage({ params }) {
const model = await getModel(params.modelId);
const parts = await getCompatibleParts(params.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>
);
}
EOF
#############################################
# Patch PartPage
#############################################
cat > "$BASE/part/[partId]/page.js" << 'EOF'
import { getPart, getCompatibleModels } from "@/src/lib/ui/api";
import ModelCard from "@/src/app/components/ModelCard";
export default async function PartDetailPage({ params }) {
const part = await getPart(params.partId);
const models = await getCompatibleModels(params.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 => <ModelCard key={item.model.id} model={item.model} />)
) : (
<p>No compatible models found.</p>
)}
</div>
</div>
);
}
EOF
#############################################
# Patch CategoryPage
#############################################
cat > "$BASE/category/[modelId]/[categoryName]/page.js" << 'EOF'
import { getModel, getCompatibleParts } from "@/src/lib/ui/api";
import PartCard from "@/src/app/components/PartCard";
export default async function CategoryPage({ params }) {
const model = await getModel(params.modelId);
const allParts = await getCompatibleParts(params.modelId);
if (!model) return <p className="text-red-500">Model not found</p>;
const parts = allParts.filter(
p => p.part.category === params.categoryName
);
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold capitalize">
{params.categoryName} for {model.name}
</h1>
<a href={`/model/${model.id}`} className="text-blue-600 underline">
← Back to model
</a>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{parts.length ? (
parts.map(p => (
<PartCard key={p.part.id} part={p.part} />
))
) : (
<p>No compatible parts in this category.</p>
)}
</div>
</div>
);
}
EOF
echo "🎉 UI polish applied!"
+265
View File
@@ -0,0 +1,265 @@
#!/bin/zsh
echo "🚀 Setting up RC Compatibility App (Next.js + Tailwind v4 + Sass)..."
BASE="src/app"
#############################################
# 1️⃣ CREATE DIRECTORY STRUCTURE
#############################################
mkdir -p "$BASE"
mkdir -p "$BASE/model/[modelId]"
mkdir -p "$BASE/category/[modelId]/[categoryName]"
mkdir -p "$BASE/part/[partId]"
mkdir -p "$BASE/compatibility"
mkdir -p "$BASE/garage"
mkdir -p "$BASE/components"
mkdir -p "$BASE/styles/components"
#############################################
# 2️⃣ WRITE ROOT LAYOUT + MAIN PAGES
#############################################
cat > "$BASE/layout.js" << 'EOF'
import "./styles/globals.scss";
export const metadata = {
title: "RC Compatibility Explorer",
description: "Find compatibility across RC models and parts",
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
{children}
</body>
</html>
);
}
EOF
cat > "$BASE/page.js" << 'EOF'
export default function HomePage() {
return (
<div>
<h1 className="text-4xl font-bold mb-4 text-primary">RC Compatibility Explorer</h1>
<p>Search for RC models, parts, and compare compatibility.</p>
</div>
);
}
EOF
#############################################
# 3️⃣ MODEL PAGE
#############################################
cat > "$BASE/model/[modelId]/page.js" << 'EOF'
export default function ModelPage({ params }) {
const { modelId } = params;
return (
<div>
<h1 className="text-2xl font-bold mb-2 text-primary">Model: {modelId}</h1>
<p>Details and component categories for this RC model.</p>
</div>
);
}
EOF
#############################################
# 4️⃣ CATEGORY PAGE
#############################################
cat > "$BASE/category/[modelId]/[categoryName]/page.js" << 'EOF'
export default function CategoryPage({ params }) {
const { modelId, categoryName } = params;
return (
<div>
<h1 className="text-2xl font-bold text-primary">{categoryName} for {modelId}</h1>
<p>List of compatible parts for this category.</p>
</div>
);
}
EOF
#############################################
# 5️⃣ PART DETAIL PAGE
#############################################
cat > "$BASE/part/[partId]/page.js" << 'EOF'
export default function PartDetailPage({ params }) {
const { partId } = params;
return (
<div>
<h1 className="text-2xl font-bold text-primary">Part Detail: {partId}</h1>
<p>Specifications and compatibility info.</p>
</div>
);
}
EOF
#############################################
# 6️⃣ COMPATIBILITY EXPLORER
#############################################
cat > "$BASE/compatibility/page.js" << 'EOF'
export default function CompatibilityPage() {
return (
<div>
<h1 className="text-3xl font-bold text-primary mb-4">Compatibility Explorer</h1>
<p>Compare RC models and parts side-by-side.</p>
</div>
);
}
EOF
#############################################
# 7️⃣ GARAGE
#############################################
cat > "$BASE/garage/page.js" << 'EOF'
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>
);
}
EOF
#############################################
# 8️⃣ COMPONENTS
#############################################
cat > "$BASE/components/SearchBar.js" << 'EOF'
export default function SearchBar() {
return (
<input
type="text"
placeholder="Search..."
className="border p-2 rounded w-full"
/>
);
}
EOF
cat > "$BASE/components/ModelCard.js" << 'EOF'
export default function ModelCard({ model }) {
return (
<div className="model-card">
<h2 className="font-bold">{model.name}</h2>
<p>{model.scale} Scale</p>
</div>
);
}
EOF
cat > "$BASE/styles/components/model-card.scss" << 'EOF'
.model-card {
@apply border rounded p-4 shadow;
background: $card-bg;
}
EOF
cat > "$BASE/components/PartCard.js" << 'EOF'
export default function PartCard({ part }) {
return (
<div className="part-card">
<h2 className="font-bold">{part.name}</h2>
<p>ID: {part.id}</p>
</div>
);
}
EOF
cat > "$BASE/styles/components/part-card.scss" << 'EOF'
.part-card {
@apply border rounded p-4 shadow;
background: $card-bg;
}
EOF
cat > "$BASE/components/NavBar.js" << 'EOF'
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>
);
}
EOF
cat > "$BASE/styles/components/navbar.scss" << 'EOF'
.navbar {
@apply flex gap-4 p-4 border-b;
background: $nav-bg;
}
EOF
#############################################
# 9️⃣ INSTALL TAILWIND v4 + SASS
#############################################
echo "📦 Installing Tailwind v4 & Sass..."
npm install -D tailwindcss@latest sass
#############################################
# 🔟 GLOBAL STYLES + VARIABLES
#############################################
cat > "$BASE/styles/variables.scss" << 'EOF'
$background: #ffffff;
$text-color: #111111;
$primary: #0070f3;
$secondary: #7928ca;
$card-bg: #f8f8f8;
$nav-bg: #fafafa;
:root {
--color-primary: #0070f3;
--color-secondary: #7928ca;
}
EOF
cat > "$BASE/styles/globals.scss" << 'EOF'
@import "tailwindcss";
@import "./variables";
body {
margin: 0;
padding: 0;
font-family: sans-serif;
background: $background;
color: $text-color;
}
EOF
#############################################
# 1️⃣1️⃣ OPTIONAL: TAILWIND CONFIG (THEME COLORS)
#############################################
cat > tailwind.config.js << 'EOF'
/** @type {import('tailwindcss').Config} */
module.exports = {
theme: {
extend: {
colors: {
primary: "var(--color-primary)",
secondary: "var(--color-secondary)"
}
}
}
};
EOF
echo "🎉 RC Compatibility App fully set up with Next.js + Tailwind v4 + Sass!"
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": false,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+1671 -4
View File
File diff suppressed because it is too large Load Diff
+26 -3
View File
@@ -9,14 +9,37 @@
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@supabase/supabase-js": "^2.84.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"fs": "^0.0.1-security",
"lucide-react": "^0.554.0",
"next": "16.0.3",
"path": "^0.12.7",
"react": "19.2.0",
"react-dom": "19.2.0"
"react-dom": "19.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"autoprefixer": "^10.4.22",
"eslint": "^9",
"eslint-config-next": "16.0.3",
"tailwindcss": "^4"
}
"postcss": "^8.5.6",
"sass": "^1.94.2",
"tailwindcss": "^4.1.17"
},
"description": "This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).",
"main": "tailwind.config.js",
"keywords": [],
"author": "",
"license": "ISC"
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { usePathname } from "next/navigation";
export default function AdminHeader() {
const pathname = usePathname();
// Convert pathname into readable title:
const title =
pathname.replace("/admin", "").replace("/", " ").trim() || "Dashboard";
return (
<header className="h-16 bg-white border-b flex items-center px-6">
<h1 className="text-xl font-semibold capitalize">
{title === "" ? "Dashboard" : title}
</h1>
</header>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
export default function AdminSidebar() {
const pathname = usePathname();
const nav = [
{ href: "/admin", label: "Dashboard" },
{ href: "/admin/models", label: "Models" },
{ href: "/admin/parts", label: "Parts" },
];
return (
<aside className="w-64 bg-white border-r min-h-screen p-4 flex flex-col">
<div className="text-2xl font-bold mb-8 pl-2">RC Admin</div>
<nav className="space-y-2">
{nav.map((item) => {
const active = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"block px-3 py-2 rounded text-sm font-medium",
active
? "bg-blue-600 text-white"
: "text-gray-700 hover:bg-gray-100"
)}
>
{item.label}
</Link>
);
})}
</nav>
<div className="flex-1" />
<div className="text-xs text-gray-400 pl-3 pb-4">
RC Compatibility Admin
</div>
</aside>
);
}
+208
View File
@@ -0,0 +1,208 @@
"use client";
import { useState } from "react";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
// Optional helper: guess manufacturer from part ID prefix
function guessManufacturerFromSku(id = "") {
if (id.startsWith("TAM-")) return "Tamiya";
if (id.startsWith("TRA-")) return "Traxxas";
return null;
}
export default function ImportPage() {
const [jsonText, setJsonText] = useState("");
const [parsed, setParsed] = useState(null);
const [status, setStatus] = useState("");
const [importing, setImporting] = useState(false);
function handleFile(e) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setJsonText(reader.result);
reader.readAsText(file);
}
function preview() {
try {
const obj = JSON.parse(jsonText);
setParsed(obj);
setStatus("✅ JSON parsed successfully.");
} catch (err) {
setStatus("❌ Invalid JSON: " + err.message);
}
}
async function runImport() {
if (!parsed) {
setStatus("❌ No parsed JSON to import.");
return;
}
const { models = [], parts = [] } = parsed;
if (!Array.isArray(models) || !Array.isArray(parts)) {
setStatus("❌ JSON must contain `models` and `parts` arrays.");
return;
}
setImporting(true);
setStatus("Importing data…");
const modelSlugToId = {}; // map: slug (id in stub) -> DB uuid
// STEP 1: Insert models
for (const m of models) {
try {
const metadata = {
scale: m.scale || null,
categories: m.categories || [],
generation: m.generation ?? null,
slug: m.id, // keep the original slug
};
const { data, error } = await supabaseBrowser
.from("models")
.insert({
name: m.name,
manufacturer: m.brand || null,
category:
Array.isArray(m.categories) && m.categories.length > 0
? m.categories[0]
: null,
year: m.year || null,
description: null,
metadata,
})
.select()
.single();
if (error) {
setStatus(`❌ Error inserting model "${m.name}": ${error.message}`);
setImporting(false);
return;
}
modelSlugToId[m.id] = data.id;
} catch (err) {
setStatus(`❌ Exception inserting model "${m.name}": ${String(err)}`);
setImporting(false);
return;
}
}
// STEP 2: Insert parts and compatibility
for (const p of parts) {
try {
const metadata = {
upgrade: p.upgrade ?? false,
universalFit: p.universalFit ?? false,
};
const { data: partData, error: partErr } = await supabaseBrowser
.from("parts")
.insert({
name: p.name,
manufacturer: guessManufacturerFromSku(p.id),
category: p.category || null,
sku: p.id,
description: p.notes || null,
metadata,
})
.select()
.single();
if (partErr) {
setStatus(`❌ Error inserting part "${p.name}": ${partErr.message}`);
setImporting(false);
return;
}
// Compatibility from fitsModels
if (Array.isArray(p.fitsModels)) {
for (const slug of p.fitsModels) {
const model_id = modelSlugToId[slug];
if (!model_id) {
// model not found just warn, don't abort
console.warn("Unknown model slug in fitsModels:", slug);
continue;
}
const fit_type = "direct"; // default; could be expanded later
await supabaseBrowser.from("parts_compatible_models").insert({
part_id: partData.id,
model_id,
fit_type,
notes: p.notes || "",
});
}
}
} catch (err) {
setStatus(`❌ Exception inserting part "${p.name}": ${String(err)}`);
setImporting(false);
return;
}
}
setStatus("✅ Import complete!");
setImporting(false);
}
return (
<Card className="max-w-3xl mx-auto">
<CardHeader>
<CardTitle className="text-2xl">Bulk JSON Import (Stub Data)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* FILE UPLOAD */}
<div>
<Label>Upload JSON File</Label>
<Input type="file" accept=".json" onChange={handleFile} />
</div>
{/* TEXTAREA */}
<div>
<Label>Or paste JSON</Label>
<Textarea
value={jsonText}
onChange={(e) => setJsonText(e.target.value)}
className="min-h-[200px] font-mono text-sm"
/>
</div>
{/* PREVIEW BUTTON */}
<Button onClick={preview} variant="secondary">
Preview JSON
</Button>
{/* PARSED PREVIEW */}
{parsed && (
<pre className="bg-gray-900 text-gray-200 p-4 rounded text-xs overflow-x-auto max-h-64">
{JSON.stringify(parsed, null, 2)}
</pre>
)}
{/* IMPORT BUTTON */}
<Button onClick={runImport} disabled={importing}>
{importing ? "Importing…" : "Import Data"}
</Button>
{/* STATUS */}
{status && (
<div className="p-3 bg-gray-100 border rounded text-sm whitespace-pre-wrap">
{status}
</div>
)}
</CardContent>
</Card>
);
}
+20
View File
@@ -0,0 +1,20 @@
import AdminSidebar from "./components/AdminSidebar";
import AdminHeader from "./components/AdminHeader";
export default function AdminLayout({ children }) {
return (
<div className="min-h-screen flex bg-gray-100">
{/* SIDEBAR */}
<AdminSidebar />
{/* MAIN COLUMN */}
<div className="flex-1 flex flex-col">
{/* TOP HEADER */}
<AdminHeader />
{/* PAGE CONTENT */}
<main className="p-6">{children}</main>
</div>
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
"use client";
import { use, useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
export default function AddPartToModelPage({ params }) {
const { id: modelId } = use(params); // React 19 unwrap
const router = useRouter();
const [search, setSearch] = useState("");
const [results, setResults] = useState([]);
const [selectedPart, setSelectedPart] = useState(null);
const [fitType, setFitType] = useState("direct");
const [notes, setNotes] = useState("");
// Search parts live
useEffect(() => {
async function load() {
if (!search.trim()) {
setResults([]);
return;
}
const { data } = await supabaseBrowser
.from("parts")
.select("*")
.ilike("name", `%${search}%`)
.limit(20);
setResults(data || []);
}
load();
}, [search]);
async function save() {
if (!selectedPart) return;
await supabaseBrowser.from("parts_compatible_models").insert({
part_id: selectedPart.id,
model_id: modelId,
fit_type: fitType,
notes: notes || null,
});
router.push(`/admin/models/${modelId}`);
}
return (
<Card className="max-w-2xl mx-auto p-4">
<CardHeader>
<CardTitle className="text-2xl">Add Compatible Part</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* SEARCH */}
<div className="space-y-2">
<Label>Search Parts</Label>
<Input
placeholder="Enter part name…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* SEARCH RESULTS */}
{!selectedPart && results.length > 0 && (
<div className="border rounded divide-y">
{results.map((p) => (
<button
key={p.id}
onClick={() => setSelectedPart(p)}
className="w-full text-left p-3 hover:bg-gray-50"
>
<div className="font-medium">{p.name}</div>
<div className="text-sm text-gray-500">
{p.manufacturer || "-"} {p.category || "-"}
</div>
</button>
))}
</div>
)}
{/* SELECTED PART CARD */}
{selectedPart && (
<div className="p-4 border rounded bg-gray-50">
<div className="font-semibold">{selectedPart.name}</div>
<div className="text-sm text-gray-500">
{selectedPart.manufacturer || "-"} {" "}
{selectedPart.category || "-"}
</div>
<Button
variant="secondary"
size="sm"
className="mt-2"
onClick={() => setSelectedPart(null)}
>
Change
</Button>
</div>
)}
{/* FIT TYPE + NOTES */}
{selectedPart && (
<>
<div className="space-y-2">
<Label>Fit Type</Label>
<Select value={fitType} onValueChange={setFitType}>
<SelectTrigger>
<SelectValue placeholder="Choose Fit Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="direct">Direct Fit</SelectItem>
<SelectItem value="mod_required">
Modification Required
</SelectItem>
<SelectItem value="not_recommended">
Not Recommended
</SelectItem>
<SelectItem value="unknown">Unknown</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Notes (optional)</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="min-h-[100px]"
/>
</div>
{/* SAVE */}
<Button onClick={save} className="mt-4">
Save Compatibility
</Button>
</>
)}
<Button
variant="secondary"
className="mt-4"
onClick={() => router.back()}
>
Cancel
</Button>
</CardContent>
</Card>
);
}
+299
View File
@@ -0,0 +1,299 @@
"use client";
import { use, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export default function ModelDetailPage({ params }) {
const { id } = use(params); // React 19: unwrap params
const router = useRouter();
const [model, setModel] = useState(null);
const [compat, setCompat] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
// Load model + compatibility
useEffect(() => {
async function load() {
setLoading(true);
const { data: modelData } = await supabaseBrowser
.from("models")
.select("*")
.eq("id", id)
.single();
setModel(modelData || null);
const { data: compatData } = await supabaseBrowser
.from("parts_compatible_models")
.select("*, parts(*)")
.eq("model_id", id);
setCompat(compatData || []);
setLoading(false);
}
load();
}, [id]);
// Save model changes
async function saveModel() {
if (!model) return;
setSaving(true);
let metadataObj = null;
if (typeof model.metadata === "string" && model.metadata.trim() !== "") {
try {
metadataObj = JSON.parse(model.metadata);
} catch {
alert("Metadata must be valid JSON.");
setSaving(false);
return;
}
}
const updatePayload = {
name: model.name,
category: model.category || null,
manufacturer: model.manufacturer || null,
year: model.year || null,
description: model.description || null,
metadata: metadataObj,
};
await supabaseBrowser.from("models").update(updatePayload).eq("id", id);
setSaving(false);
}
async function deleteModel() {
await supabaseBrowser.from("models").delete().eq("id", id);
router.push("/admin/models");
}
async function removeCompatibility(compatId) {
await supabaseBrowser
.from("parts_compatible_models")
.delete()
.eq("id", compatId);
setCompat((prev) => prev.filter((c) => c.id !== compatId));
}
// PREVENT NULL CRASHES
if (loading) return <p className="p-6">Loading model</p>;
if (!model) return <p className="p-6 text-red-600">Model not found.</p>;
return (
<div className="space-y-10">
{/* MODEL EDIT CARD */}
<Card>
<CardHeader>
<CardTitle className="text-3xl">Edit Model</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* NAME */}
<div className="space-y-2">
<Label>Name</Label>
<Input
value={model?.name || ""}
onChange={(e) => setModel({ ...model, name: e.target.value })}
/>
</div>
{/* GRID FIELDS */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Manufacturer</Label>
<Input
value={model?.manufacturer || ""}
onChange={(e) =>
setModel({ ...model, manufacturer: e.target.value })
}
/>
</div>
<div>
<Label>Category</Label>
<Input
value={model?.category || ""}
onChange={(e) =>
setModel({ ...model, category: e.target.value })
}
/>
</div>
<div>
<Label>Year</Label>
<Input
type="number"
value={model?.year || ""}
onChange={(e) => setModel({ ...model, year: e.target.value })}
/>
</div>
</div>
{/* DESCRIPTION */}
<div>
<Label>Description</Label>
<Textarea
className="min-h-[100px]"
value={model?.description || ""}
onChange={(e) =>
setModel({ ...model, description: e.target.value })
}
/>
</div>
{/* METADATA */}
<div>
<Label>Metadata (JSON)</Label>
<Textarea
className="font-mono text-sm min-h-[120px]"
value={
typeof model.metadata === "object"
? JSON.stringify(model.metadata, null, 2)
: model?.metadata || ""
}
onChange={(e) => setModel({ ...model, metadata: e.target.value })}
/>
</div>
{/* ACTION BUTTONS */}
<div className="flex items-center justify-between pt-4">
<Button onClick={saveModel} disabled={saving}>
{saving ? "Saving…" : "Save Changes"}
</Button>
{/* DELETE WITH DIALOG */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogTrigger asChild>
<Button variant="destructive">Delete Model</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm deletion</DialogTitle>
</DialogHeader>
<p className="text-gray-600 mb-4">
This action cannot be undone. This will permanently delete the
model and all compatibility links.
</p>
<DialogFooter>
<Button
variant="secondary"
onClick={() => setDeleteOpen(false)}
>
Cancel
</Button>
<Button variant="destructive" onClick={deleteModel}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
{/* COMPATIBILITY TABLE */}
<Card>
<CardHeader className="flex justify-between items-center">
<CardTitle className="text-2xl">Compatible Parts</CardTitle>
<Button asChild>
<Link href={`/admin/models/${id}/add-part`}>
+ Add Compatible Part
</Link>
</Button>
</CardHeader>
<Separator />
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Part</TableHead>
<TableHead>Fit Type</TableHead>
<TableHead>Notes</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{compat.map((c) => (
<TableRow key={c.id}>
<TableCell>{c.parts?.name}</TableCell>
<TableCell>
<Badge variant="secondary">
{c.fit_type?.replace("_", " ")}
</Badge>
</TableCell>
<TableCell>{c.notes || "-"}</TableCell>
<TableCell className="text-right">
<Button
variant="destructive"
size="sm"
onClick={() => removeCompatibility(c.id)}
>
Remove
</Button>
</TableCell>
</TableRow>
))}
{compat.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-gray-500 py-6"
>
No compatible parts found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
export default function CreateModelPage() {
const router = useRouter();
const [form, setForm] = useState({
name: "",
manufacturer: "",
category: "",
year: "",
description: "",
metadata: "",
});
async function save() {
let metadataObj = null;
if (form.metadata.trim()) {
try {
metadataObj = JSON.parse(form.metadata);
} catch {
alert("Metadata must be valid JSON");
return;
}
}
const { error } = await supabaseBrowser.from("models").insert({
name: form.name,
manufacturer: form.manufacturer || null,
category: form.category || null,
year: form.year || null,
description: form.description || null,
metadata: metadataObj,
});
if (!error) {
router.push("/admin/models");
} else {
alert(error.message);
}
}
return (
<Card className="max-w-2xl mx-auto p-6">
<CardHeader>
<CardTitle className="text-2xl">Create New Model</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Manufacturer</Label>
<Input
value={form.manufacturer}
onChange={(e) =>
setForm({ ...form, manufacturer: e.target.value })
}
/>
</div>
<div>
<Label>Category</Label>
<Input
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
/>
</div>
<div>
<Label>Year</Label>
<Input
type="number"
value={form.year}
onChange={(e) => setForm({ ...form, year: e.target.value })}
/>
</div>
</div>
<div>
<Label>Description</Label>
<Textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</div>
<div>
<Label>Metadata (JSON)</Label>
<Textarea
className="font-mono text-sm min-h-[100px]"
value={form.metadata}
onChange={(e) => setForm({ ...form, metadata: e.target.value })}
/>
</div>
<div className="flex justify-between">
<Button onClick={save}>Create Model</Button>
<Button
variant="secondary"
onClick={() => router.push("/admin/models")}
>
Cancel
</Button>
</div>
</CardContent>
</Card>
);
}
+211
View File
@@ -0,0 +1,211 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import Link from "next/link";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import {
Table,
TableHead,
TableRow,
TableHeader,
TableBody,
TableCell,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
export default function ModelsListPage() {
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(true);
// Search / Filter / Sort states
const [search, setSearch] = useState("");
const [manufacturerFilter, setManufacturerFilter] = useState("all");
const [sort, setSort] = useState("asc"); // asc | desc | newest | oldest
useEffect(() => {
async function load() {
setLoading(true);
const { data } = await supabaseBrowser
.from("models")
.select("*")
.order("name", { ascending: true });
setModels(data || []);
setLoading(false);
}
load();
}, []);
// ----------------------------------------------
// 🎯 FILTERING + SEARCH + SORTING (client-side)
// ----------------------------------------------
const filteredModels = useMemo(() => {
let list = [...models];
// SEARCH
if (search.trim()) {
list = list.filter((m) =>
m.name.toLowerCase().includes(search.toLowerCase())
);
}
// MANUFACTURER FILTER
if (manufacturerFilter !== "all") {
list = list.filter((m) => m.manufacturer === manufacturerFilter);
}
// SORTING
switch (sort) {
case "asc":
list.sort((a, b) => a.name.localeCompare(b.name));
break;
case "desc":
list.sort((a, b) => b.name.localeCompare(a.name));
break;
case "newest":
list.sort((a, b) => (b.year || 0) - (a.year || 0));
break;
case "oldest":
list.sort((a, b) => (a.year || 0) - (b.year || 0));
break;
}
return list;
}, [models, search, manufacturerFilter, sort]);
// SKELETON
if (loading) {
return (
<Card>
<CardHeader className="flex justify-between items-center">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-32" />
</CardHeader>
<CardContent>
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full mb-2" />
))}
</CardContent>
</Card>
);
}
// Collect manufacturers for filter dropdown
const manufacturers = Array.from(
new Set(models.map((m) => m.manufacturer).filter(Boolean))
);
return (
<Card>
<CardHeader className="flex justify-between items-center">
<CardTitle className="text-2xl">Models</CardTitle>
<Button asChild>
<Link href="/admin/models/create">+ New Model</Link>
</Button>
</CardHeader>
<CardContent className="space-y-4">
{/* SEARCH + FILTERS + SORT BAR */}
<div className="flex flex-col md:flex-row gap-4 items-center">
{/* SEARCH */}
<Input
placeholder="Search models…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="md:w-1/3"
/>
{/* MANUFACTURER FILTER */}
<Select
value={manufacturerFilter}
onValueChange={setManufacturerFilter}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="Manufacturer" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Manufacturers</SelectItem>
{manufacturers.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
{/* SORTING */}
<Select value={sort} onValueChange={setSort}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Sort" />
</SelectTrigger>
<SelectContent>
<SelectItem value="asc">Name (AZ)</SelectItem>
<SelectItem value="desc">Name (ZA)</SelectItem>
<SelectItem value="newest">Year (Newest)</SelectItem>
<SelectItem value="oldest">Year (Oldest)</SelectItem>
</SelectContent>
</Select>
</div>
{/* TABLE */}
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Manufacturer</TableHead>
<TableHead>Category</TableHead>
<TableHead>Year</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredModels.map((m) => (
<TableRow key={m.id}>
<TableCell>{m.name}</TableCell>
<TableCell>{m.manufacturer || "-"}</TableCell>
<TableCell>{m.category || "-"}</TableCell>
<TableCell>{m.year || "-"}</TableCell>
<TableCell className="text-right">
<Button asChild variant="secondary" size="sm">
<Link href={`/admin/models/${m.id}`}>View</Link>
</Button>
</TableCell>
</TableRow>
))}
{filteredModels.length === 0 && (
<TableRow>
<TableCell
colSpan={5}
className="text-center py-10 text-gray-500"
>
No models found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { supabaseServer } from "@/lib/supabaseServer";
export default async function AdminHome() {
const { data: models } = await supabaseServer.from("models").select("*");
const { data: parts } = await supabaseServer.from("parts").select("*");
const { data: compat } = await supabaseServer
.from("parts_compatible_models")
.select("*");
return (
<div>
<h1 className="text-3xl font-bold mb-6">Admin Dashboard</h1>
<div className="grid grid-cols-3 gap-6">
<div className="p-6 bg-white shadow rounded-lg">
<h2 className="text-xl font-semibold">Models</h2>
<p className="text-3xl mt-2">{models?.length ?? 0}</p>
</div>
<div className="p-6 bg-white shadow rounded-lg">
<h2 className="text-xl font-semibold">Parts</h2>
<p className="text-3xl mt-2">{parts?.length ?? 0}</p>
</div>
<div className="p-6 bg-white shadow rounded-lg">
<h2 className="text-xl font-semibold">Compatibility</h2>
<p className="text-3xl mt-2">{compat?.length ?? 0}</p>
</div>
</div>
</div>
);
}
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { use, useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
export default function AddModelToPartPage({ params }) {
const { id: partId } = use(params); // React 19 unwrapping
const router = useRouter();
const [search, setSearch] = useState("");
const [results, setResults] = useState([]);
const [selectedModel, setSelectedModel] = useState(null);
const [fitType, setFitType] = useState("direct");
const [notes, setNotes] = useState("");
// 🔍 Live search for models
useEffect(() => {
async function load() {
if (!search.trim()) {
setResults([]);
return;
}
const { data } = await supabaseBrowser
.from("models")
.select("*")
.ilike("name", `%${search}%`)
.limit(20);
setResults(data || []);
}
load();
}, [search]);
// 💾 Save compatibility entry
async function save() {
if (!selectedModel) return;
await supabaseBrowser.from("parts_compatible_models").insert({
part_id: partId,
model_id: selectedModel.id,
fit_type: fitType,
notes: notes || null,
});
router.push(`/admin/parts/${partId}`);
}
return (
<Card className="max-w-2xl mx-auto p-4">
<CardHeader>
<CardTitle className="text-2xl">Add Compatible Model</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* SEARCH FIELD */}
<div className="space-y-2">
<Label>Search Models</Label>
<Input
placeholder="Enter model name…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* SEARCH RESULTS */}
{!selectedModel && results.length > 0 && (
<div className="border rounded divide-y">
{results.map((m) => (
<button
key={m.id}
onClick={() => setSelectedModel(m)}
className="w-full text-left p-3 hover:bg-gray-50"
>
<div className="font-medium">{m.name}</div>
<div className="text-sm text-gray-500">
{m.manufacturer || "-"} {m.category || "-"}
</div>
</button>
))}
</div>
)}
{/* SELECTED MODEL CARD */}
{selectedModel && (
<div className="p-4 border rounded bg-gray-50">
<div className="font-semibold">{selectedModel.name}</div>
<div className="text-sm text-gray-500">
{selectedModel.manufacturer || "-"} {" "}
{selectedModel.category || "-"}
</div>
<Button
variant="secondary"
size="sm"
className="mt-2"
onClick={() => setSelectedModel(null)}
>
Change
</Button>
</div>
)}
{/* FIT-TYPE + NOTES */}
{selectedModel && (
<>
<div className="space-y-2">
<Label>Fit Type</Label>
<Select value={fitType} onValueChange={setFitType}>
<SelectTrigger>
<SelectValue placeholder="Choose fit type…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="direct">Direct Fit</SelectItem>
<SelectItem value="mod_required">
Modification Required
</SelectItem>
<SelectItem value="not_recommended">
Not Recommended
</SelectItem>
<SelectItem value="unknown">Unknown</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Notes (optional)</Label>
<Textarea
className="min-h-[100px]"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
{/* SAVE BUTTON */}
<Button onClick={save} className="mt-4">
Save Compatibility
</Button>
</>
)}
{/* CANCEL BUTTON */}
<Button
variant="secondary"
className="mt-4"
onClick={() => router.back()}
>
Cancel
</Button>
</CardContent>
</Card>
);
}
+292
View File
@@ -0,0 +1,292 @@
"use client";
import { use, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export default function PartDetailPage({ params }) {
const { id } = use(params); // React 19 unwrap
const router = useRouter();
const [part, setPart] = useState(null);
const [compat, setCompat] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
// Load part data + compatibility
useEffect(() => {
async function load() {
setLoading(true);
const { data: partData } = await supabaseBrowser
.from("parts")
.select("*")
.eq("id", id)
.single();
setPart(partData || null);
const { data: compatData } = await supabaseBrowser
.from("parts_compatible_models")
.select("*, models(*)")
.eq("part_id", id);
setCompat(compatData || []);
setLoading(false);
}
load();
}, [id]);
// Save part edits
async function savePart() {
if (!part) return;
setSaving(true);
let metadataObj = null;
if (typeof part.metadata === "string" && part.metadata.trim() !== "") {
try {
metadataObj = JSON.parse(part.metadata);
} catch {
alert("Metadata must be valid JSON.");
setSaving(false);
return;
}
}
const updatePayload = {
name: part.name,
manufacturer: part.manufacturer || null,
sku: part.sku || null,
category: part.category || null,
description: part.description || null,
metadata: metadataObj,
};
await supabaseBrowser.from("parts").update(updatePayload).eq("id", id);
setSaving(false);
}
async function deletePart() {
await supabaseBrowser.from("parts").delete().eq("id", id);
router.push("/admin/parts");
}
async function removeCompatibility(compatId) {
await supabaseBrowser
.from("parts_compatible_models")
.delete()
.eq("id", compatId);
setCompat((prev) => prev.filter((c) => c.id !== compatId));
}
// Prevent render crashes
if (loading) return <p className="p-6">Loading part</p>;
if (!part) return <p className="p-6 text-red-600">Part not found.</p>;
return (
<div className="space-y-10">
{/* PART EDIT FORM */}
<Card>
<CardHeader>
<CardTitle className="text-3xl">Edit Part</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={part?.name || ""}
onChange={(e) => setPart({ ...part, name: e.target.value })}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Manufacturer</Label>
<Input
value={part?.manufacturer || ""}
onChange={(e) =>
setPart({ ...part, manufacturer: e.target.value })
}
/>
</div>
<div>
<Label>Category</Label>
<Input
value={part?.category || ""}
onChange={(e) => setPart({ ...part, category: e.target.value })}
/>
</div>
<div>
<Label>SKU</Label>
<Input
value={part?.sku || ""}
onChange={(e) => setPart({ ...part, sku: e.target.value })}
/>
</div>
</div>
<div>
<Label>Description</Label>
<Textarea
className="min-h-[100px]"
value={part?.description || ""}
onChange={(e) =>
setPart({ ...part, description: e.target.value })
}
/>
</div>
<div>
<Label>Metadata (JSON)</Label>
<Textarea
className="font-mono text-sm min-h-[100px]"
value={
typeof part.metadata === "object"
? JSON.stringify(part.metadata, null, 2)
: part?.metadata || ""
}
onChange={(e) => setPart({ ...part, metadata: e.target.value })}
/>
</div>
{/* SAVE + DELETE */}
<div className="flex items-center justify-between pt-4">
<Button onClick={savePart} disabled={saving}>
{saving ? "Saving…" : "Save Changes"}
</Button>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogTrigger asChild>
<Button variant="destructive">Delete Part</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm deletion</DialogTitle>
</DialogHeader>
<p className="text-gray-600 mb-4">
This will permanently delete the part and all compatibility
links.
</p>
<DialogFooter>
<Button
variant="secondary"
onClick={() => setDeleteOpen(false)}
>
Cancel
</Button>
<Button variant="destructive" onClick={deletePart}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
{/* COMPATIBLE MODELS TABLE */}
<Card>
<CardHeader className="flex justify-between items-center">
<CardTitle className="text-2xl">Compatible Models</CardTitle>
<Button asChild>
<Link href={`/admin/parts/${id}/add-model`}>
+ Add Compatible Model
</Link>
</Button>
</CardHeader>
<Separator />
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead>Fit Type</TableHead>
<TableHead>Notes</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{compat.map((c) => (
<TableRow key={c.id}>
<TableCell>{c.models?.name}</TableCell>
<TableCell>
<Badge variant="secondary">
{c.fit_type?.replace("_", " ")}
</Badge>
</TableCell>
<TableCell>{c.notes || "-"}</TableCell>
<TableCell className="text-right">
<Button
variant="destructive"
size="sm"
onClick={() => removeCompatibility(c.id)}
>
Remove
</Button>
</TableCell>
</TableRow>
))}
{compat.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-gray-500 py-6"
>
No models linked to this part.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
export default function CreatePartPage() {
const router = useRouter();
const [form, setForm] = useState({
name: "",
manufacturer: "",
category: "",
sku: "",
description: "",
metadata: "",
});
const [saving, setSaving] = useState(false);
async function save() {
setSaving(true);
let metadataObj = null;
// Parse metadata if provided
if (form.metadata.trim()) {
try {
metadataObj = JSON.parse(form.metadata);
} catch {
alert("Metadata must be valid JSON.");
setSaving(false);
return;
}
}
// Insert into Supabase
const { error } = await supabaseBrowser.from("parts").insert({
name: form.name,
manufacturer: form.manufacturer || null,
category: form.category || null,
sku: form.sku || null,
description: form.description || null,
metadata: metadataObj,
});
setSaving(false);
if (!error) {
router.push("/admin/parts");
} else {
alert(error.message);
}
}
return (
<Card className="max-w-2xl mx-auto p-6">
<CardHeader>
<CardTitle className="text-2xl">Create New Part</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* NAME */}
<div className="space-y-2">
<Label>Name</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="e.g., Tamiya Hotshot Gearbox"
/>
</div>
{/* GRID FIELDS */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Manufacturer</Label>
<Input
value={form.manufacturer}
onChange={(e) =>
setForm({ ...form, manufacturer: e.target.value })
}
placeholder="e.g., Tamiya"
/>
</div>
<div>
<Label>Category</Label>
<Input
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
placeholder="e.g., drivetrain"
/>
</div>
<div>
<Label>SKU</Label>
<Input
value={form.sku}
onChange={(e) => setForm({ ...form, sku: e.target.value })}
placeholder="e.g., TAM-198055"
/>
</div>
</div>
{/* DESCRIPTION */}
<div>
<Label>Description</Label>
<Textarea
className="min-h-[100px]"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Optional part description..."
/>
</div>
{/* METADATA */}
<div>
<Label>Metadata (JSON)</Label>
<Textarea
className="font-mono text-sm min-h-[100px]"
value={form.metadata}
onChange={(e) => setForm({ ...form, metadata: e.target.value })}
placeholder='{ "color": "red", "material": "plastic" }'
/>
</div>
{/* ACTION BUTTONS */}
<div className="flex justify-between pt-4">
<Button onClick={save} disabled={saving}>
{saving ? "Creating…" : "Create Part"}
</Button>
<Button
variant="secondary"
onClick={() => router.push("/admin/parts")}
>
Cancel
</Button>
</div>
</CardContent>
</Card>
);
}
+236
View File
@@ -0,0 +1,236 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import Link from "next/link";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import {
Table,
TableHead,
TableRow,
TableHeader,
TableBody,
TableCell,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
export default function PartsListPage() {
const [parts, setParts] = useState([]);
const [loading, setLoading] = useState(true);
// UI state: Search, filters, sorting
const [search, setSearch] = useState("");
const [manufacturerFilter, setManufacturerFilter] = useState("all");
const [categoryFilter, setCategoryFilter] = useState("all");
const [sort, setSort] = useState("asc"); // asc | desc
useEffect(() => {
async function load() {
setLoading(true);
const { data } = await supabaseBrowser
.from("parts")
.select("*")
.order("name", { ascending: true });
setParts(data || []);
setLoading(false);
}
load();
}, []);
// --------------------------------------------------------
// 🎯 FILTER + SEARCH + SORT (client-side, fast)
// --------------------------------------------------------
const filteredParts = useMemo(() => {
let list = [...parts];
// SEARCH
if (search.trim()) {
list = list.filter(
(p) =>
p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.sku || "").toLowerCase().includes(search.toLowerCase())
);
}
// MANUFACTURER FILTER
if (manufacturerFilter !== "all") {
list = list.filter((p) => p.manufacturer === manufacturerFilter);
}
// CATEGORY FILTER
if (categoryFilter !== "all") {
list = list.filter((p) => p.category === categoryFilter);
}
// SORTING
if (sort === "asc") {
list.sort((a, b) => a.name.localeCompare(b.name));
} else if (sort === "desc") {
list.sort((a, b) => b.name.localeCompare(a.name));
}
return list;
}, [parts, search, manufacturerFilter, categoryFilter, sort]);
// --------------------------------------------------------
// 🦴 SKELETON LOADING
// --------------------------------------------------------
if (loading) {
return (
<Card>
<CardHeader className="flex justify-between items-center">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-32" />
</CardHeader>
<CardContent>
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full mb-2" />
))}
</CardContent>
</Card>
);
}
// Collect filter options
const manufacturers = Array.from(
new Set(parts.map((p) => p.manufacturer).filter(Boolean))
);
const categories = Array.from(
new Set(parts.map((p) => p.category).filter(Boolean))
);
// --------------------------------------------------------
// 🌟 RENDER UI
// --------------------------------------------------------
return (
<Card>
<CardHeader className="flex justify-between items-center">
<CardTitle className="text-2xl">Parts</CardTitle>
<Button asChild>
<Link href="/admin/parts/create">+ New Part</Link>
</Button>
</CardHeader>
<CardContent className="space-y-4">
{/* SEARCH + FILTERS + SORT BAR */}
<div className="flex flex-col md:flex-row gap-4 items-center">
{/* SEARCH */}
<Input
placeholder="Search parts…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="md:w-1/3"
/>
{/* MANUFACTURER FILTER */}
<Select
value={manufacturerFilter}
onValueChange={setManufacturerFilter}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="Manufacturer" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Manufacturers</SelectItem>
{manufacturers.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
{/* CATEGORY FILTER */}
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{categories.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
{/* SORTING */}
<Select value={sort} onValueChange={setSort}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Sort" />
</SelectTrigger>
<SelectContent>
<SelectItem value="asc">Name (AZ)</SelectItem>
<SelectItem value="desc">Name (ZA)</SelectItem>
</SelectContent>
</Select>
</div>
{/* TABLE */}
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Manufacturer</TableHead>
<TableHead>Category</TableHead>
<TableHead>SKU</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredParts.map((p) => (
<TableRow key={p.id}>
<TableCell>{p.name}</TableCell>
<TableCell>{p.manufacturer || "-"}</TableCell>
<TableCell>{p.category || "-"}</TableCell>
<TableCell>{p.sku || "-"}</TableCell>
<TableCell className="text-right">
<Button asChild variant="secondary" size="sm">
<Link href={`/admin/parts/${p.id}`}>View</Link>
</Button>
</TableCell>
</TableRow>
))}
{filteredParts.length === 0 && (
<TableRow>
<TableCell
colSpan={5}
className="text-center py-10 text-gray-500"
>
No parts match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -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>
);
}
+248
View File
@@ -0,0 +1,248 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { CATEGORIES } from "@/lib/data/categories";
function ModeCard({ title, emoji, description, children }) {
return (
<div className="border rounded-xl p-6 shadow-sm bg-white flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="text-2xl">{emoji}</div>
<div>
<h2 className="font-semibold text-lg">{title}</h2>
<p className="text-sm text-gray-500">{description}</p>
</div>
</div>
<div className="mt-2 flex flex-col gap-3">{children}</div>
</div>
);
}
export default function CompatibilityPage() {
const [models, setModels] = useState([]);
const [parts, setParts] = useState([]);
const [loading, setLoading] = useState(true);
const [modelForParts, setModelForParts] = useState("");
const [partForModels, setPartForModels] = useState("");
const [modelA, setModelA] = useState("");
const [modelB, setModelB] = useState("");
const [categoryModel, setCategoryModel] = useState("");
const [category, setCategory] = useState("");
const router = useRouter();
useEffect(() => {
async function load() {
try {
const [modelsRes, partsRes] = await Promise.all([
fetch("/api/models"),
fetch("/api/parts"),
]);
const modelsJson = modelsRes.ok ? await modelsRes.json() : [];
const partsJson = partsRes.ok ? await partsRes.json() : [];
setModels(modelsJson || []);
setParts(partsJson || []);
} catch (e) {
console.error("Failed to load models/parts", e);
} finally {
setLoading(false);
}
}
load();
}, []);
function handleGoToModelCompatibility() {
if (!modelForParts) return;
router.push(`/model/${modelForParts}`);
}
function handleGoToPartCompatibility() {
if (!partForModels) return;
router.push(`/part/${partForModels}`);
}
function handleExploreCategory() {
if (!categoryModel || !category) return;
router.push(`/category/${categoryModel}/${category}`);
}
function handleCompareModels() {
if (!modelA || !modelB) return;
// Stubbed for now to avoid broken routes
alert("Model comparison view coming soon! (Hotshot vs Supershot, etc.)");
}
return (
<div className="space-y-10">
{/* Breadcrumb-ish header */}
<nav className="text-sm text-gray-500">
<Link href="/" className="hover:underline text-blue-600">
Home
</Link>{" "}
<span></span>{" "}
<span className="font-semibold text-gray-700">Compatibility Explorer</span>
</nav>
{/* Page Title */}
<div>
<h1 className="text-4xl font-bold mb-2">Compatibility Explorer</h1>
<p className="text-gray-600 max-w-2xl">
Discover cross-compatibility between RC models and parts. Use one of the
tools below to explore fitment, upgrades, and shared components.
</p>
</div>
{loading && (
<p className="text-gray-500">Loading models and parts</p>
)}
{/* Mode Selector Grid */}
{!loading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Mode 1: Model → Compatible Parts */}
<ModeCard
title="Model → Compatible Parts"
emoji="🔧"
description="Find every part that fits a specific RC model."
>
<select
className="border rounded-lg px-3 py-2"
value={modelForParts}
onChange={(e) => setModelForParts(e.target.value)}
>
<option value="">Select a model</option>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.brand})
</option>
))}
</select>
<button
onClick={handleGoToModelCompatibility}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
disabled={!modelForParts}
>
Show Compatible Parts
</button>
</ModeCard>
{/* Mode 2: Part → Compatible Models */}
<ModeCard
title="Part → Compatible Models"
emoji="🧩"
description="See all RC models that this part works with."
>
<select
className="border rounded-lg px-3 py-2"
value={partForModels}
onChange={(e) => setPartForModels(e.target.value)}
>
<option value="">Select a part</option>
{parts.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.id})
</option>
))}
</select>
<button
onClick={handleGoToPartCompatibility}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
disabled={!partForModels}
>
Show Compatible Models
</button>
</ModeCard>
{/* Mode 3: Compare Two Models (stubbed) */}
<ModeCard
title="Compare Two Models"
emoji="🔍"
description="See shared and unique parts between two models. (Coming soon)"
>
<div className="flex flex-col gap-2">
<select
className="border rounded-lg px-3 py-2"
value={modelA}
onChange={(e) => setModelA(e.target.value)}
>
<option value="">Select Model A</option>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.brand})
</option>
))}
</select>
<select
className="border rounded-lg px-3 py-2"
value={modelB}
onChange={(e) => setModelB(e.target.value)}
>
<option value="">Select Model B</option>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.brand})
</option>
))}
</select>
</div>
<button
onClick={handleCompareModels}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg cursor-not-allowed"
disabled
>
Compare (Coming Soon)
</button>
</ModeCard>
{/* Mode 4: Explore Categories */}
<ModeCard
title="Explore by Category"
emoji="📂"
description="Browse shocks, drivetrain, chassis, and more for a model."
>
<div className="flex flex-col gap-2">
<select
className="border rounded-lg px-3 py-2"
value={categoryModel}
onChange={(e) => setCategoryModel(e.target.value)}
>
<option value="">Select a model</option>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.brand})
</option>
))}
</select>
<select
className="border rounded-lg px-3 py-2"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="">Select a category</option>
{CATEGORIES.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
</div>
<button
onClick={handleExploreCategory}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
disabled={!categoryModel || !category}
>
Explore Category
</button>
</ModeCard>
</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>
);
}
+90
View File
@@ -0,0 +1,90 @@
"use client";
import { useState, useEffect, useRef } from "react";
export default function SearchBar({ onSearch }) {
const [query, setQuery] = useState("");
const debounceTimer = useRef(null);
const inputRef = useRef(null);
/** Fire search immediately */
function fireSearch(value) {
onSearch(value.trim());
}
/** Debounced search while typing */
useEffect(() => {
const value = query.trim();
// If empty, clear results
if (value === "") {
fireSearch("");
return;
}
clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
fireSearch(value);
}, 300);
return () => clearTimeout(debounceTimer.current);
}, [query]);
/** Enter key triggers immediate search */
function handleKeyDown(e) {
if (e.key === "Enter") {
e.preventDefault();
clearTimeout(debounceTimer.current);
fireSearch(query);
inputRef.current?.blur(); // optional UX
}
}
/** Click search button */
function handleSearchClick() {
clearTimeout(debounceTimer.current);
fireSearch(query);
}
/** Clear input */
function clearInput() {
setQuery("");
fireSearch("");
inputRef.current?.focus();
}
return (
<div className="flex gap-3 w-full max-w-xl relative">
<input
ref={inputRef}
suppressHydrationWarning
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search models or parts…"
className="flex-1 border rounded-xl px-4 py-2 shadow-sm focus:ring-2 focus:ring-blue-500"
/>
{/* Clear button (only visible when typing) */}
{query && (
<button
suppressHydrationWarning
type="button"
onClick={clearInput}
className="absolute right-24 top-1/2 -translate-y-1/2 text-gray-500 hover:text-black"
>
</button>
)}
<button
suppressHydrationWarning
onClick={handleSearchClick}
className="px-5 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700 transition"
>
Search
</button>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import Link from "next/link";
export default function SearchResultCard({ item, bestScore }) {
const isModel = item.type === "model";
const href = isModel
? `/model/${item.slug ?? item.id}`
: `/part/${item.sku ?? item.id}`;
// Determine if this is the “best match”
const isBestMatch =
bestScore != null && item.score != null && item.score >= bestScore - 0.02; // 2% threshold
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"
>
{/* Best Match Badge */}
{isBestMatch && (
<div className="inline-block mb-2 px-3 py-1 text-xs font-semibold bg-green-100 text-green-700 rounded-full">
Best Match
</div>
)}
<img
src={placeholderImg}
alt={item.name}
className="rounded mb-4 w-full object-cover"
/>
<h3 className="text-lg font-bold mb-1">{item.name}</h3>
<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>
<p className="mt-3 text-sm text-gray-500 font-mono">
{isModel ? item.slug ?? item.id : item.sku ?? item.id}
</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>
);
}
+112 -14
View File
@@ -1,26 +1,124 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@plugin "tailwindcss-animate";
@use "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
+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>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import { useEffect, useState } from "react";
import { createClient } from "@supabase/supabase-js";
import { Skeleton } from "@/components/ui/skeleton";
import ModelCard from "../components/ModelCard";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
export default function ModelsPage() {
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [brand, setBrand] = useState("all");
const [scale, setScale] = useState("all");
const filtered = models.filter((m) => {
const matchesSearch =
m.name.toLowerCase().includes(search.toLowerCase()) ||
m.manufacturer.toLowerCase().includes(search.toLowerCase());
const matchesBrand = brand === "all" || m.manufacturer === brand;
const matchesScale = scale === "all" || m.scale === scale;
return matchesSearch && matchesBrand && matchesScale;
});
useEffect(() => {
async function load() {
setLoading(true);
const { data, error } = await supabase
.from("models")
.select("*")
.order("manufacturer", { ascending: true })
.order("name", { ascending: true });
if (error) console.error(error);
setModels(data || []);
setLoading(false);
}
load();
}, []);
const brands = [...new Set(models.map((m) => m.manufacturer))];
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 flex-wrap">
<div>
<label className="block mb-1 font-semibold">Search</label>
<input
className="border p-2 rounded w-64"
placeholder="Search models..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<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>
</div>
{/* Loading skeleton */}
{loading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-40 w-full rounded-xl" />
))}
</div>
)}
{/* Model Grid */}
{!loading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filtered.map((model) => (
<a
key={model.slug}
href={`/models/${model.slug}`}
className="block"
>
<ModelCard model={model} />
</a>
))}
</div>
)}
</div>
);
}
+88 -59
View File
@@ -1,65 +1,94 @@
import Image from "next/image";
"use client";
import { useState } from "react";
import { createClient } from "@supabase/supabase-js";
import SearchBar from "./components/SearchBar";
import SearchResultCard from "./components/SearchResultCard";
import SearchSkeletonCard from "./components/SearchSkeletonCard";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
export default function HomePage() {
const [results, setResults] = useState(null);
const [loading, setLoading] = useState(false);
async function handleSearch(query) {
if (!query || query.trim() === "") {
setResults([]);
return;
}
setLoading(true);
setResults(null);
const q = query.trim();
// Fuzzy RPC search
const { data: modelData } = await supabase.rpc("search_models", { q });
const { data: partData } = await supabase.rpc("search_parts", { q });
const models = (modelData ?? []).map((m) => ({
...m,
type: "model",
slug: m.metadata?.slug ?? null,
}));
const parts = (partData ?? []).map((p) => ({
...p,
type: "part",
}));
// Sort by score
const combined = [...models, ...parts].sort(
(a, b) => (b.score ?? 0) - (a.score ?? 0)
);
setResults({
list: combined,
bestScore: combined.length > 0 ? combined[0].score : null,
});
setLoading(false);
}
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">
<h1 className="text-4xl font-bold">RC Compatibility Explorer</h1>
<SearchBar onSearch={handleSearch} />
{/* Skeleton */}
{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?.list?.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.list.map((item) => (
<SearchResultCard
key={item.id}
item={item}
bestScore={results.bestScore}
/>
))}
</div>
</>
)}
{/* No Results */}
{!loading && results?.list?.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;
}
+8
View File
@@ -0,0 +1,8 @@
@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;
}
+15
View File
@@ -0,0 +1,15 @@
$background: #ffffff;
$text-color: #111111;
$foreground: #111111;
$primary: #0070f3;
$secondary: #7928ca;
$card-bg: #f8f8f8;
$nav-bg: #fafafa;
:root {
--color-primary: #0070f3;
--color-secondary: #7928ca;
}
+34
View File
@@ -0,0 +1,34 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
...props
}) {
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
}
export { Badge, badgeVariants }
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp suppressHydrationWarning
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props} />
);
})
Button.displayName = "Button"
export { Button, buttonVariants }
+50
View File
@@ -0,0 +1,50 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props} />
))
Card.displayName = "Card"
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} />
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props} />
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props} />
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+96
View File
@@ -0,0 +1,96 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props} />
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}>
{children}
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props} />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+158
View File
@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props} />
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props} />
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props} />
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props} />
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props} />
);
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
<input suppressHydrationWarning
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props} />
);
})
Input.displayName = "Input"
export { Input }
+18
View File
@@ -0,0 +1,18 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+29
View File
@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props} />
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+121
View File
@@ -0,0 +1,121 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn("p-1", position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+25
View File
@@ -0,0 +1,25 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef((
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props} />
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+14
View File
@@ -0,0 +1,14 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props} />
);
}
export { Skeleton }
+86
View File
@@ -0,0 +1,86 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props} />
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props} />
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props} />
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props} />
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} />
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
return (
<textarea suppressHydrationWarning
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props} />
);
})
Textarea.displayName = "Textarea"
export { Textarea }
+75
View File
@@ -0,0 +1,75 @@
import { MODELS } from "../data/models";
import { PARTS } from "../data/parts";
import { getModel, getPart } from "./utils";
import { scoreCompatibility } from "./scoring";
// Check model-to-part compatibility
export function isCompatible(modelId, partId) {
const model = getModel(modelId, MODELS);
const part = getPart(partId, PARTS);
if (!model || !part) {
return { compatible: false, reason: "Unknown model or part" };
}
const directFit = part.fitsModels.includes(modelId);
const sameBrand = model.brand && part.brand && part.brand === model.brand;
const sameCategory = model.categories.includes(part.category);
const universal = !!part.universalFit;
const generationMatch = part.generation
? part.generation === model.generation
: false;
const score = scoreCompatibility({
directFit,
sameBrand,
sameCategory,
universal,
generationMatch
});
return {
compatible: score > 0,
score,
details: {
directFit,
sameBrand,
sameCategory,
universal,
generationMatch
}
};
}
// Get all compatible parts for a model
export function getCompatibleParts(modelId) {
return PARTS
.map(part => ({
part,
result: isCompatible(modelId, part.id)
}))
.filter(item => item.result.compatible)
.sort((a, b) => b.result.score - a.result.score);
}
// Get models that fit a part
export function getCrossCompatibleModels(partId) {
return MODELS
.map(model => ({
model,
result: isCompatible(model.id, partId)
}))
.filter(item => item.result.compatible)
.sort((a, b) => b.result.score - a.result.score);
}
// Suggest alternative parts in same category
export function suggestAlternatives(partId) {
const part = getPart(partId, PARTS);
if (!part) return [];
return PARTS.filter(p =>
p.category === part.category && p.id !== partId
);
}
+11
View File
@@ -0,0 +1,11 @@
export function scoreCompatibility({ directFit, sameBrand, sameCategory, universal, generationMatch }) {
let score = 0;
if (directFit) score += 100;
if (sameBrand) score += 20;
if (sameCategory) score += 15;
if (generationMatch) score += 10;
if (universal) score += 5;
return Math.min(score, 100);
}
+7
View File
@@ -0,0 +1,7 @@
export function getModel(modelId, models) {
return models.find(m => m.id === modelId) || null;
}
export function getPart(partId, parts) {
return parts.find(p => p.id === partId) || null;
}
+85
View File
@@ -0,0 +1,85 @@
{
"models": [
{
"id": "tamiya-hotshot",
"brand": "Tamiya",
"name": "Hotshot",
"scale": "1/10",
"year": 1985,
"categories": [
"drivetrain",
"suspension",
"chassis",
"shocks"
],
"generation": 1
},
{
"id": "tamiya-supershot",
"brand": "Tamiya",
"name": "Super Shot",
"scale": "1/10",
"year": 1986,
"categories": [
"drivetrain",
"suspension",
"chassis",
"shocks"
],
"generation": 1
},
{
"id": "traxxas-rustler-4x4",
"brand": "Traxxas",
"name": "Rustler 4x4",
"scale": "1/10",
"year": 2018,
"categories": [
"drivetrain",
"suspension",
"electronics",
"steering"
],
"generation": 2
}
],
"parts": [
{
"id": "TAM-198055",
"name": "Hotshot Front Gearbox",
"category": "drivetrain",
"fitsModels": [
"tamiya-hotshot",
"tamiya-supershot"
],
"notes": "Original vintage fit."
},
{
"id": "TAM-430512",
"name": "Super Shot Steering Knuckle",
"category": "steering",
"fitsModels": [
"tamiya-supershot"
]
},
{
"id": "TRA-6755X",
"name": "Traxxas Steel Driveshaft Upgrade",
"category": "drivetrain",
"fitsModels": [
"traxxas-rustler-4x4"
],
"upgrade": true
},
{
"id": "GEN-UNIV-55MM",
"name": "Generic 55mm Shock Set",
"category": "shocks",
"fitsModels": [
"tamiya-hotshot",
"tamiya-supershot"
],
"universalFit": true
}
]
}
+11
View File
@@ -0,0 +1,11 @@
export const CATEGORIES = [
"drivetrain",
"suspension",
"chassis",
"shocks",
"electronics",
"steering",
"wheels",
"tires",
"body"
];
+29
View File
@@ -0,0 +1,29 @@
export const MODELS = [
{
id: "tamiya-hotshot",
brand: "Tamiya",
name: "Hotshot",
scale: "1/10",
year: 1985,
categories: ["drivetrain", "suspension", "chassis", "shocks"],
generation: 1,
},
{
id: "tamiya-supershot",
brand: "Tamiya",
name: "Super Shot",
scale: "1/10",
year: 1986,
categories: ["drivetrain", "suspension", "chassis", "shocks"],
generation: 1,
},
{
id: "traxxas-rustler-4x4",
brand: "Traxxas",
name: "Rustler 4x4",
scale: "1/10",
year: 2018,
categories: ["drivetrain", "suspension", "electronics", "steering"],
generation: 2,
}
];
+29
View File
@@ -0,0 +1,29 @@
export const PARTS = [
{
id: "TAM-198055",
name: "Hotshot Front Gearbox",
category: "drivetrain",
fitsModels: ["tamiya-hotshot", "tamiya-supershot"],
notes: "Original vintage fit.",
},
{
id: "TAM-430512",
name: "Super Shot Steering Knuckle",
category: "steering",
fitsModels: ["tamiya-supershot"],
},
{
id: "TRA-6755X",
name: "Traxxas Steel Driveshaft Upgrade",
category: "drivetrain",
fitsModels: ["traxxas-rustler-4x4"],
upgrade: true,
},
{
id: "GEN-UNIV-55MM",
name: "Generic 55mm Shock Set",
category: "shocks",
fitsModels: ["tamiya-hotshot", "tamiya-supershot"],
universalFit: true,
},
];
+21
View File
@@ -0,0 +1,21 @@
export function validateModel(model) {
if (!model.id) throw new Error("Model missing id");
if (!model.name) console.warn(`Model ${model.id} missing name`);
if (!Array.isArray(model.categories))
throw new Error(`Model ${model.id} categories must be an array`);
}
export function validatePart(part) {
if (!part.id) throw new Error("Part missing id");
if (!part.name) throw new Error(`Part ${part.id} missing name`);
if (typeof part.category !== "string")
throw new Error(`Part ${part.id} must have a category`);
if (
!Array.isArray(part.compatibleModels) &&
!Array.isArray(part.fitsModels)
) {
throw new Error(
`Part ${part.id} must include 'compatibleModels' or 'fitsModels'`
);
}
}
+18
View File
@@ -0,0 +1,18 @@
import { MODELS } from "../data/models";
import { PARTS } from "../data/parts";
export function searchModels(query) {
const q = query.toLowerCase();
return MODELS.filter(m =>
m.name.toLowerCase().includes(q) ||
m.brand.toLowerCase().includes(q)
);
}
export function searchParts(query) {
const q = query.toLowerCase();
return PARTS.filter(p =>
p.name.toLowerCase().includes(q) ||
p.id.toLowerCase().includes(q)
);
}
+6
View File
@@ -0,0 +1,6 @@
import { createClient } from "@supabase/supabase-js";
export const supabaseBrowser = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
+6
View File
@@ -0,0 +1,6 @@
import { createClient } from "@supabase/supabase-js";
export const supabaseServer = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY
);
+27
View File
@@ -0,0 +1,27 @@
import { fetchJSON, BASE_URL } from "./fetch";
export function getModel(id) {
return fetchJSON(`/api/models/${id}`);
}
export function getPart(id) {
return fetchJSON(`/api/parts/${id}`);
}
export function getCompatibleParts(modelId) {
return fetchJSON(`/api/compatibility/model/${modelId}`);
}
export function getCompatibleModels(partId) {
return fetchJSON(`/api/compatibility/part/${partId}`);
}
export async function getPartsByModel(modelId) {
return fetchJSON(`/api/compatibility/model/${modelId}`);
}
export async function getPartsByCategory(modelId, categoryName) {
return fetchJSON(
`/api/compatibility/model/${modelId}?category=${categoryName}`
);
}
+18
View File
@@ -0,0 +1,18 @@
const port = process.env.PORT || 3000;
// BASE_URL resolved for server & browser
export const BASE_URL =
typeof window === "undefined"
? process.env.API_ROOT || `http://localhost:${port}`
: "";
// Universal JSON fetcher
export async function fetchJSON(path) {
const url = BASE_URL + path;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return null;
return res.json();
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
theme: {
extend: {
colors: {
primary: "var(--color-primary)",
secondary: "var(--color-secondary)"
}
}
}
};