Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ function ownerHandleFrom(req: NextRequest): string | null {
return normalizeOwnerHandle(candidate);
}

export async function DELETE(req: NextRequest, context: { params: Promise<{ id: string; itemId: string }> }) {
export async function DELETE(req: NextRequest, context: { params: Promise<{ slug: string; itemId: string }> }) {
const ip = getClientIp(req);
const rl = checkRateLimit(`collections:items:delete:${ip}`, { windowMs: 60_000, max: 60 });
if (!rl.ok) return rateLimitResponse(rl.retryAfterSec);
Expand All @@ -30,7 +30,8 @@ export async function DELETE(req: NextRequest, context: { params: Promise<{ id:
const ownerHandle = ownerHandleFrom(req);
if (!ownerHandle) return NextResponse.json({ error: "ownerHandle is required" }, { status: 401 });

const { id, itemId } = await context.params;
const { slug, itemId } = await context.params;
const id = slug;

const { data: collection } = await supabase
.from("collections")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function ownerHandleFrom(req: NextRequest): string | null {
return normalizeOwnerHandle(candidate);
}

export async function POST(req: NextRequest, context: { params: Promise<{ id: string }> }) {
export async function POST(req: NextRequest, context: { params: Promise<{ slug: string }> }) {
const supabase = getSupabase();
if (!supabase) return NextResponse.json({ error: "Database not configured" }, { status: 500 });

Expand All @@ -22,7 +22,8 @@ export async function POST(req: NextRequest, context: { params: Promise<{ id: st
const ownerHandle = ownerHandleFrom(req);
if (!ownerHandle) return NextResponse.json({ error: "ownerHandle is required" }, { status: 401 });

const { id } = await context.params;
const { slug } = await context.params;
const id = slug;

const { data: collection } = await supabase
.from("collections")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ async function assertOwner(opts: {
return { collection: data };
}

export async function GET(req: NextRequest, context: { params: Promise<{ id: string }> }) {
export async function GET(req: NextRequest, context: { params: Promise<{ slug: string }> }) {
const ownerHandle = ownerHandleFrom(req);
const { id } = await context.params;
const { slug } = await context.params;

// If no ownerHandle is provided, treat this as a curated skill-bundle lookup by slug.
if (!ownerHandle) {
const curated = await getSkillCollectionBySlug(id);
const curated = await getSkillCollectionBySlug(slug);
if (!curated) return NextResponse.json({ error: "Not found" }, { status: 404 });

return NextResponse.json({
Expand All @@ -56,6 +56,8 @@ export async function GET(req: NextRequest, context: { params: Promise<{ id: str
});
}

const id = slug;

const supabase = getSupabase();
if (!supabase) return NextResponse.json({ error: "Database not configured" }, { status: 500 });

Expand Down Expand Up @@ -131,7 +133,7 @@ export async function GET(req: NextRequest, context: { params: Promise<{ id: str
});
}

export async function PATCH(req: NextRequest, context: { params: Promise<{ id: string }> }) {
export async function PATCH(req: NextRequest, context: { params: Promise<{ slug: string }> }) {
const ip = getClientIp(req);
const rl = checkRateLimit(`collections_patch:${ip}`, { windowMs: 60_000, max: 30 });
if (!rl.ok) return rateLimitResponse(rl.retryAfterSec);
Expand All @@ -142,7 +144,8 @@ export async function PATCH(req: NextRequest, context: { params: Promise<{ id: s
const ownerHandle = ownerHandleFrom(req);
if (!ownerHandle) return NextResponse.json({ error: "ownerHandle is required" }, { status: 401 });

const { id } = await context.params;
const { slug } = await context.params;
const id = slug;
const owned = await assertOwner({ supabase, id, ownerHandle });
if ("error" in owned) return NextResponse.json({ error: owned.error }, { status: owned.status });

Expand Down Expand Up @@ -221,7 +224,7 @@ export async function PATCH(req: NextRequest, context: { params: Promise<{ id: s
});
}

export async function DELETE(req: NextRequest, context: { params: Promise<{ id: string }> }) {
export async function DELETE(req: NextRequest, context: { params: Promise<{ slug: string }> }) {
const ip = getClientIp(req);
const rl = checkRateLimit(`collections_delete:${ip}`, { windowMs: 60_000, max: 20 });
if (!rl.ok) return rateLimitResponse(rl.retryAfterSec);
Expand All @@ -237,7 +240,8 @@ export async function DELETE(req: NextRequest, context: { params: Promise<{ id:
const ownerHandle = ownerHandleFrom(req);
if (!ownerHandle) return NextResponse.json({ error: "ownerHandle is required" }, { status: 401 });

const { id } = await context.params;
const { slug } = await context.params;
const id = slug;
const owned = await assertOwner({ supabase, id, ownerHandle });
if ("error" in owned) return NextResponse.json({ error: owned.error }, { status: owned.status });

Expand Down
84 changes: 74 additions & 10 deletions src/app/api/collections/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import { NextRequest, NextResponse } from "next/server";
import { checkRateLimit, getClientIp, rateLimitResponse, readJsonWithLimit } from "@/lib/requestLimits";
import { getSupabase } from "@/lib/supabase";
import { ensureUniqueSlug, normalizeOwnerHandle } from "@/lib/collections";
import { getSkillCollections } from "@/lib/skillCollections";
import {
getSkillCollections,
readCollectionsFile,
searchSkillCollections,
type SkillCollection,
writeCollectionsFile,
} from "@/lib/skillCollections";
import { getSkills } from "@/lib/data";

function getOwnerHandleFromRequest(req: NextRequest): string | null {
const header = req.headers.get("x-owner-handle") || req.headers.get("x-agent-handle");
Expand All @@ -15,9 +22,11 @@ function getOwnerHandleFromRequest(req: NextRequest): string | null {
export async function GET(req: NextRequest) {
const ownerHandle = getOwnerHandleFromRequest(req);

// If no ownerHandle is provided, return the curated skill-bundle collections.
// If no ownerHandle is provided, return curated collections from JSON.
if (!ownerHandle) {
const collections = await getSkillCollections();
const search = req.nextUrl.searchParams.get("search") || "";
const collections = search ? await searchSkillCollections(search) : await getSkillCollections();

return NextResponse.json({
collections: collections.map((c) => ({
slug: c.slug,
Expand All @@ -27,6 +36,7 @@ export async function GET(req: NextRequest) {
skillCount: c.skillCount,
totalInstalls: c.totalInstalls,
})),
total: collections.length,
});
}

Expand Down Expand Up @@ -74,15 +84,11 @@ export async function GET(req: NextRequest) {
updatedAt: c.updated_at,
itemCount: counts[c.id] || 0,
})),
total: (data || []).length,
});
}

export async function POST(req: NextRequest) {
const supabase = getSupabase();
if (!supabase) {
return NextResponse.json({ error: "Database not configured" }, { status: 500 });
}

const ip = getClientIp(req);
const rl = checkRateLimit(`collections:post:${ip}`, { windowMs: 60_000, max: 20 });
if (!rl.ok) return rateLimitResponse(rl.retryAfterSec);
Expand All @@ -91,6 +97,7 @@ export async function POST(req: NextRequest) {
ownerHandle?: string;
name?: string;
description?: string;
skills?: unknown;
} = null;

try {
Expand All @@ -103,13 +110,70 @@ export async function POST(req: NextRequest) {
}

const ownerHandle = normalizeOwnerHandle(body?.ownerHandle || "");

// If no ownerHandle is provided, create/editable custom collection in JSON data file.
if (!ownerHandle) {
const name = (body?.name || "").trim();
const description = typeof body?.description === "string" ? body.description.trim() : "";

if (!name) {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}

const incomingSkills = Array.isArray(body?.skills)
? body.skills.filter((s): s is string => typeof s === "string").map((s) => s.trim())
: null;

if (!incomingSkills) {
return NextResponse.json({ error: "skills is required and must be an array of skill slugs" }, { status: 400 });
}

const skills = [...new Set(incomingSkills.filter(Boolean))];
const knownSkillSlugs = new Set(getSkills().map((s) => s.slug));
const invalidSkills = skills.filter((s) => !knownSkillSlugs.has(s));

if (invalidSkills.length > 0) {
return NextResponse.json(
{
error: "Unknown skill slugs in skills[]",
invalidSkills,
},
{ status: 400 }
);
}

const existing = await readCollectionsFile().catch(() => []);
const slug = await ensureUniqueSlug({
desired: name,
exists: async (candidate) => existing.some((c) => c.slug === candidate),
});

const created: SkillCollection = {
slug,
name,
description,
skills,
};

await writeCollectionsFile([...existing, created]);

return NextResponse.json(
{ error: "ownerHandle is required (format: @name@domain)" },
{ status: 401 }
{
collection: {
...created,
skillCount: created.skills.length,
totalInstalls: 0,
},
},
{ status: 201 }
);
}

const supabase = getSupabase();
if (!supabase) {
return NextResponse.json({ error: "Database not configured" }, { status: 500 });
}

const name = (body?.name || "").trim();
const description = typeof body?.description === "string" ? body.description.trim() : null;

Expand Down
59 changes: 50 additions & 9 deletions src/app/collections/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,59 @@
import Link from "next/link";
import type { Metadata } from "next";
import { headers } from "next/headers";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { VerifiedSkillBadge } from "@/components/verified-badge";
import { getSkillCollectionBySlug, getSkillCollections } from "@/lib/skillCollections";
import { CollectionDetailClient } from "@/app/collections/[slug]/collection-detail-client";
import { RunInReflecttButton } from "@/components/RunInReflecttButton";

type CollectionSkill = {
slug: string;
name: string;
author: string;
description: string;
installs: number;
verification?: unknown;
};

type CollectionDetailResponse = {
collection?: {
slug: string;
name: string;
description: string;
skills: string[];
skillCount: number;
totalInstalls: number;
};
skills?: CollectionSkill[];
};

export const revalidate = 300;

async function getBaseUrl(): Promise<string> {
const hdrs = await headers();
const host = hdrs.get("x-forwarded-host") || hdrs.get("host");
const protocol = hdrs.get("x-forwarded-proto") || "https";
const fallbackBase = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
return host ? `${protocol}://${host}` : fallbackBase;
}

async function fetchCollectionBySlug(slug: string): Promise<CollectionDetailResponse | null> {
const base = await getBaseUrl();
const res = await fetch(`${base}/api/collections/${encodeURIComponent(slug)}`, {
next: { revalidate: 300 },
});

if (!res.ok) return null;
return (await res.json()) as CollectionDetailResponse;
}

export async function generateMetadata(props: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await props.params;
const col = await getSkillCollectionBySlug(slug);
const detail = await fetchCollectionBySlug(slug);
const col = detail?.collection;

// Personal collections (UUID) fall back to layout metadata.
if (!col) {
Expand Down Expand Up @@ -47,14 +87,16 @@ export default async function CollectionRoute(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;

const curated = await getSkillCollectionBySlug(slug);
const detail = await fetchCollectionBySlug(slug);

// If this isn't one of our curated skill bundles, render the existing user-collections UI.
if (!curated) {
if (!detail?.collection) {
return <CollectionDetailClient />;
}

const curated = detail.collection;
const resolvedSkills = Array.isArray(detail.skills) ? detail.skills : [];

return (
<div className="min-h-screen">
<section className="max-w-5xl mx-auto px-4 py-10">
Expand Down Expand Up @@ -87,7 +129,7 @@ export default async function CollectionRoute(props: {
<Separator className="opacity-10 my-8" />

<div className="grid gap-3">
{curated.resolvedSkills.map((s) => (
{resolvedSkills.map((s) => (
<Link
key={s.slug}
href={`/skills/${s.slug}`}
Expand All @@ -112,7 +154,7 @@ export default async function CollectionRoute(props: {
))}
</div>

{curated.resolvedSkills.length !== curated.skillCount ? (
{resolvedSkills.length !== curated.skillCount ? (
<div className="mt-6 text-xs text-slate-500">
Note: some skills in this collection are not yet in the directory.
</div>
Expand All @@ -127,6 +169,5 @@ export default async function CollectionRoute(props: {
}

export async function generateStaticParams() {
const cols = await getSkillCollections();
return cols.map((c) => ({ slug: c.slug }));
return [];
}
Loading
Loading