Compare commits
10
Commits
b064d458c4
...
6681e5ccd1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6681e5ccd1 | ||
|
|
a4621875e4 | ||
|
|
195ee7ff92 | ||
|
|
6ccd2bd442 | ||
|
|
04212a47e2 | ||
|
|
0a938d5a6c | ||
|
|
5412abe0fe | ||
|
|
2682308015 | ||
|
|
f8bb195159 | ||
|
|
ffe7ea5d75 |
Vendored
+21
@@ -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
@@ -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!"
|
||||
Executable
+73
@@ -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!"
|
||||
Executable
+36
@@ -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!"
|
||||
Executable
+289
@@ -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!"
|
||||
Executable
+39
@@ -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!"
|
||||
Executable
+42
@@ -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
@@ -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!"
|
||||
Executable
+110
@@ -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
@@ -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"
|
||||
Executable
+239
@@ -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!"
|
||||
Executable
+104
@@ -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!"
|
||||
Executable
+300
@@ -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!"
|
||||
Executable
+156
@@ -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!"
|
||||
Executable
+265
@@ -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!"
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
Generated
+1671
-4
File diff suppressed because it is too large
Load Diff
+26
-3
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (A–Z)</SelectItem>
|
||||
<SelectItem value="desc">Name (Z–A)</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (A–Z)</SelectItem>
|
||||
<SelectItem value="desc">Name (Z–A)</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);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MODELS } from "@/lib/data/models";
|
||||
|
||||
export async function GET(request, context) {
|
||||
const { id } = await context.params; // ⬅ MUST AWAIT PARAMS
|
||||
|
||||
const model = MODELS.find((m) => m.id === id);
|
||||
if (!model) {
|
||||
return Response.json({ error: "Model not found" }, { status: 404 });
|
||||
}
|
||||
return Response.json(model);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { MODELS } from "@/lib/data/models";
|
||||
|
||||
export async function GET() {
|
||||
return Response.json(MODELS);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PARTS } from "@/lib/data/parts";
|
||||
|
||||
export async function GET(request, context) {
|
||||
const { id } = await context.params; // ⬅ MUST AWAIT PARAMS
|
||||
|
||||
const part = PARTS.find((p) => p.id === id);
|
||||
if (!part) {
|
||||
return Response.json({ error: "Part not found" }, { status: 404 });
|
||||
}
|
||||
return Response.json(part);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PARTS } from "@/lib/data/parts";
|
||||
|
||||
export async function GET() {
|
||||
return Response.json(PARTS);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getPartsByModel } from "@/lib/ui/api";
|
||||
import SkeletonCard from "@/app/components/SkeletonCard";
|
||||
import PartCard from "@/app/components/PartCard";
|
||||
|
||||
export default function PartGrid({ modelId, categoryName }) {
|
||||
const [parts, setParts] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const allParts = await getPartsByModel(modelId);
|
||||
|
||||
if (!Array.isArray(allParts)) {
|
||||
console.error("❌ getPartsByModel() returned:", allParts);
|
||||
setParts([]); // Prevents filter crash
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = allParts.filter(
|
||||
(p) => p.part.category.toLowerCase() === categoryName.toLowerCase()
|
||||
);
|
||||
|
||||
setParts(filtered);
|
||||
}
|
||||
load();
|
||||
}, [modelId, categoryName]);
|
||||
|
||||
// Skeleton loading
|
||||
if (parts === null) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No parts found
|
||||
if (parts.length === 0) {
|
||||
return <p>No parts found in this category.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{parts.map((part) => (
|
||||
<PartCard key={part.part.id} part={part} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Link from "next/link";
|
||||
import PartGrid from "./PartGrid";
|
||||
|
||||
export default async function CategoryPage({ params }) {
|
||||
const { modelId, categoryName } = await params;
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="text-sm text-gray-500 flex gap-2 items-center">
|
||||
<Link href="/" className="hover:underline text-blue-600">
|
||||
Home
|
||||
</Link>
|
||||
<span>›</span>
|
||||
|
||||
<Link
|
||||
href={`/model/${modelId}`}
|
||||
className="hover:underline text-blue-600 capitalize"
|
||||
>
|
||||
{modelId.replace(/-/g, " ")}
|
||||
</Link>
|
||||
<span>›</span>
|
||||
|
||||
<span className="capitalize text-gray-700 font-semibold">
|
||||
{categoryName}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2 capitalize">
|
||||
{categoryName} Parts
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Explore compatible parts for{" "}
|
||||
<span className="font-semibold">{modelId.replace(/-/g, " ")}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Parts Grid with Skeleton Loading */}
|
||||
<PartGrid modelId={modelId} categoryName={categoryName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function CategoryCard({ category }) {
|
||||
return (
|
||||
<div className="border rounded p-4 shadow hover:shadow-lg transition cursor-pointer bg-white">
|
||||
<h3 className="font-bold text-lg capitalize">{category}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function CompatibilityStatus({ status }) {
|
||||
return (
|
||||
<p className="font-bold">
|
||||
Compatibility: {status ? "Compatible" : "Not Compatible"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function ModelCard({ model }) {
|
||||
return (
|
||||
<div className="border rounded p-4 shadow">
|
||||
<h2 className="font-bold text-lg">{model.name}</h2>
|
||||
<p>{model.brand} • {model.scale}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export default function ModelSummary({ model }) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="font-bold text-lg">Summary</h2>
|
||||
<p>Name: {model.name}</p>
|
||||
<p>Scale: {model.scale}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import "../styles/components/navbar.scss";
|
||||
|
||||
export default function NavBar() {
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<Link href="/">Home</Link>
|
||||
<Link href="/compatibility">Compatibility</Link>
|
||||
<Link href="/garage">Garage</Link>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function PartCard({ part }) {
|
||||
part = part.hasOwnProperty("result") ? part.part : part;
|
||||
const placeholderImg = `https://placehold.co/40x25?text=${encodeURIComponent(
|
||||
part.name
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/part/${part.id}`}
|
||||
className="block border rounded-xl p-5 shadow-sm hover:shadow-md hover:-translate-y-1 transition bg-white"
|
||||
>
|
||||
<div className="flex flex-col h-full justify-between">
|
||||
{/* Image */}
|
||||
<img
|
||||
src={placeholderImg}
|
||||
alt={part.name}
|
||||
className="rounded mb-4 w-full object-cover"
|
||||
/>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-1">{part.name}</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">{part.id}</p>
|
||||
|
||||
{/* Small Category Tag */}
|
||||
<span className="inline-block px-3 py-1 text-xs rounded-full bg-blue-100 text-blue-700 capitalize">
|
||||
{part.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Model Count */}
|
||||
<p className="mt-4 text-sm text-gray-500">
|
||||
Compatible with <strong>{part.fitsModels?.length || 0}</strong>{" "}
|
||||
model(s)
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export default function SearchSkeletonCard() {
|
||||
return (
|
||||
<div className="animate-pulse border rounded-xl p-5 bg-gray-100/50 shadow-sm">
|
||||
<div className="h-32 bg-gray-300 rounded mb-4"></div>
|
||||
<div className="h-4 bg-gray-300 rounded w-2/3 mb-2"></div>
|
||||
<div className="h-4 bg-gray-300 rounded w-1/3"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export default function SkeletonCard() {
|
||||
return (
|
||||
<div className="animate-pulse border rounded-xl p-5 bg-gray-100/50 shadow-sm">
|
||||
<div className="h-32 bg-gray-300 rounded mb-4"></div>
|
||||
<div className="h-4 bg-gray-300 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-4 bg-gray-300 rounded w-1/2 mb-4"></div>
|
||||
<div className="h-6 bg-gray-300 rounded w-1/3"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function GaragePage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-primary mb-4">Your Garage</h1>
|
||||
<p>Save your RC models and parts here.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+112
-14
@@ -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
@@ -1,28 +1,17 @@
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import "./styles/globals.scss";
|
||||
import NavBar from "./components/NavBar";
|
||||
|
||||
export const metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "RC Compatibility Explorer",
|
||||
description: "Find compatibility across RC models and parts",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body suppressHydrationWarning>
|
||||
<NavBar />
|
||||
<main className="p-4">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { getModel, getCompatibleParts } from "@/lib/ui/api";
|
||||
import CategoryCard from "@/app/components/CategoryCard";
|
||||
import PartCard from "@/app/components/PartCard";
|
||||
|
||||
export default async function ModelPage({ params }) {
|
||||
const { modelId } = await params;
|
||||
|
||||
const model = await getModel(modelId);
|
||||
const parts = await getCompatibleParts(modelId);
|
||||
|
||||
if (!model) return <p className="text-red-500">Model not found</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold">{model.name}</h1>
|
||||
<p className="opacity-75">
|
||||
{model.brand} • {model.scale}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold">Categories</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{model.categories.map((cat) => (
|
||||
<a key={cat} href={`/category/${model.id}/${cat}`}>
|
||||
<CategoryCard category={cat} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold">Compatible Parts</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Array.isArray(parts) && parts.length ? (
|
||||
parts.map((item) => <PartCard key={item.part.id} part={item.part} />)
|
||||
) : (
|
||||
<p>No compatible parts found.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { getPart, getCompatibleModels } from "@/lib/ui/api";
|
||||
|
||||
export default async function PartDetailPage({ params }) {
|
||||
const { partId } = await params;
|
||||
|
||||
const part = await getPart(partId);
|
||||
const models = await getCompatibleModels(partId);
|
||||
|
||||
if (!part) return <p className="text-red-500">Part not found</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold">{part.name}</h1>
|
||||
<p className="opacity-75">Part ID: {part.id}</p>
|
||||
|
||||
{part.universalFit && (
|
||||
<span className="inline-block mt-2 px-3 py-1 bg-green-200 text-green-800 text-sm rounded">
|
||||
Universal Fit
|
||||
</span>
|
||||
)}
|
||||
|
||||
{part.upgrade && (
|
||||
<span className="inline-block mt-2 px-3 py-1 bg-blue-200 text-blue-800 text-sm rounded">
|
||||
Upgrade Part
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold">Compatible Models</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Array.isArray(models) && models.length ? (
|
||||
models.map((item) => (
|
||||
<div key={item.model.id}>
|
||||
<p className="font-bold">{item.model.name}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p>No compatible models found.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@use "../variables" as *;
|
||||
|
||||
.model-card {
|
||||
@apply border rounded p-4 shadow;
|
||||
background: $card-bg;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@use "../variables" as *;
|
||||
@reference "tailwindcss";
|
||||
|
||||
.navbar {
|
||||
@apply flex gap-4 p-4 border-b;
|
||||
background: $nav-bg;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@use "../variables" as *;
|
||||
|
||||
.part-card {
|
||||
@apply border rounded p-4 shadow;
|
||||
background: $card-bg;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
@use "./variables" as *;
|
||||
@use "tailwindcss";
|
||||
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: sans-serif;
|
||||
background: $background;
|
||||
color: $text-color;
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const CATEGORIES = [
|
||||
"drivetrain",
|
||||
"suspension",
|
||||
"chassis",
|
||||
"shocks",
|
||||
"electronics",
|
||||
"steering",
|
||||
"wheels",
|
||||
"tires",
|
||||
"body"
|
||||
];
|
||||
@@ -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,
|
||||
}
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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'`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: "var(--color-primary)",
|
||||
secondary: "var(--color-secondary)"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user