feat(skills): add clickable questions with detail pages and browse view

- Create /api/skills/questions endpoint for listing questions with pagination
- Create /api/skills/questions/[id] endpoint for individual question details
- Add questions list page with filtering by skill
- Add question detail page with full answer, related tools, and similar questions
- Make activity feed cards clickable links to question detail
- Add "View all" link in SkillsSection
This commit is contained in:
Ajax Davis 2026-01-25 04:22:05 +10:00
parent 9995d73052
commit b5731cf1a1
8 changed files with 1247 additions and 45 deletions

View file

@ -0,0 +1,335 @@
'use client';
import { Badge } from '@tpmjs/ui/Badge/Badge';
import { Button } from '@tpmjs/ui/Button/Button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
import { EmptyState } from '@tpmjs/ui/EmptyState/EmptyState';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import { Skeleton } from '@tpmjs/ui/Skeleton/Skeleton';
import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
import { AppHeader } from '~/components/AppHeader';
function formatRelativeTime(date: Date): string {
const now = Date.now();
const diff = now - date.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'just now';
}
interface SkillQuestion {
id: string;
question: string;
answer: string;
confidence: number;
similarCount: number;
tags: string[];
createdAt: string;
skillNodes: Array<{
relevance: number;
skill: {
id: string;
name: string;
slug: string;
};
}>;
toolNodes: Array<{
relevance: number;
tool: {
id: string;
name: string;
package: {
npmPackageName: string;
};
};
}>;
}
interface QuestionsListClientProps {
collection: {
id: string;
name: string;
slug: string;
username: string;
};
initialSkillFilter?: string;
}
export function QuestionsListClient({
collection,
initialSkillFilter,
}: QuestionsListClientProps): React.ReactElement {
const [questions, setQuestions] = useState<SkillQuestion[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const [skillFilter, setSkillFilter] = useState(initialSkillFilter);
const limit = 20;
const fetchQuestions = useCallback(
async (currentOffset: number, append: boolean = false) => {
try {
if (append) {
setLoadingMore(true);
} else {
setLoading(true);
}
const params = new URLSearchParams({
collectionId: collection.id,
limit: String(limit),
offset: String(currentOffset),
});
if (skillFilter) {
params.set('skill', skillFilter);
}
const response = await fetch(`/api/skills/questions?${params}`);
if (!response.ok) {
throw new Error('Failed to fetch questions');
}
const data = await response.json();
if (append) {
setQuestions((prev) => [...prev, ...data.data]);
} else {
setQuestions(data.data);
}
setHasMore(data.pagination.hasMore);
setOffset(currentOffset + data.data.length);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load');
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[collection.id, skillFilter]
);
useEffect(() => {
setOffset(0);
fetchQuestions(0, false);
}, [fetchQuestions]);
const handleLoadMore = () => {
fetchQuestions(offset, true);
};
const clearSkillFilter = () => {
setSkillFilter(undefined);
window.history.replaceState(null, '', `/${collection.username}/collections/${collection.slug}/skills/questions`);
};
const basePath = `/${collection.username}/collections/${collection.slug}`;
return (
<div className="min-h-screen bg-background">
<AppHeader />
<main className="max-w-4xl mx-auto px-4 py-8">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-foreground-secondary mb-6">
<Link href={basePath} className="hover:text-foreground">
{collection.name}
</Link>
<Icon icon="chevronRight" className="w-4 h-4" />
<Link href={`${basePath}/skills/questions`} className="hover:text-foreground">
Skills
</Link>
<Icon icon="chevronRight" className="w-4 h-4" />
<span className="text-foreground">Questions</span>
</nav>
{/* Header */}
<div className="flex items-start justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-foreground">Questions</h1>
<p className="text-foreground-secondary mt-1">
Browse all questions asked about {collection.name}
</p>
</div>
<Link href={basePath}>
<Button variant="secondary" size="sm">
<Icon icon="arrowLeft" className="w-4 h-4 mr-1.5" />
Back to Collection
</Button>
</Link>
</div>
{/* Skill Filter */}
{skillFilter && (
<div className="mb-6 flex items-center gap-2">
<span className="text-sm text-foreground-secondary">Filtered by skill:</span>
<Badge variant="default" size="md">
{skillFilter}
<button
type="button"
onClick={clearSkillFilter}
className="ml-1.5 hover:text-foreground-secondary"
>
<Icon icon="x" className="w-3 h-3" />
</button>
</Badge>
</div>
)}
{/* Loading State */}
{loading && (
<div className="space-y-4">
{[1, 2, 3, 4, 5].map((i) => (
<Card key={i} variant="default">
<CardContent padding="md">
<Skeleton className="h-5 w-3/4 mb-3" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-2/3" />
</CardContent>
</Card>
))}
</div>
)}
{/* Error State */}
{error && !loading && (
<Card variant="default" className="border-error/20 bg-error/5">
<CardContent padding="lg">
<div className="flex items-center gap-2">
<Icon icon="alertCircle" size="md" className="text-error" />
<p className="text-error">{error}</p>
</div>
</CardContent>
</Card>
)}
{/* Empty State */}
{!loading && !error && questions.length === 0 && (
<Card variant="default" className="border-dashed">
<CardContent padding="lg">
<EmptyState
icon="message"
title="No questions yet"
description={
skillFilter
? `No questions found for skill "${skillFilter}"`
: 'Be the first to ask a question about this collection\'s tools.'
}
size="md"
/>
{skillFilter && (
<div className="mt-4 text-center">
<Button variant="secondary" size="sm" onClick={clearSkillFilter}>
Clear filter
</Button>
</div>
)}
</CardContent>
</Card>
)}
{/* Questions List */}
{!loading && !error && questions.length > 0 && (
<div className="space-y-4">
{questions.map((q) => (
<Link
key={q.id}
href={`${basePath}/skills/questions/${q.id}`}
className="block"
>
<Card
variant="default"
className="hover:border-primary/30 hover:bg-muted/30 transition-all cursor-pointer"
>
<CardHeader padding="md" className="pb-2">
<div className="flex items-start justify-between gap-3">
<CardTitle as="h3" className="text-base font-medium">
{q.question}
</CardTitle>
<Badge
variant={q.confidence >= 0.7 ? 'success' : 'secondary'}
size="sm"
className="flex-shrink-0"
>
{Math.round(q.confidence * 100)}%
</Badge>
</div>
</CardHeader>
<CardContent padding="md" className="pt-0">
<CardDescription className="line-clamp-3 text-sm mb-4">
{q.answer.slice(0, 250)}
{q.answer.length > 250 ? '...' : ''}
</CardDescription>
<div className="flex items-center justify-between">
<div className="flex gap-1.5 flex-wrap">
{q.skillNodes.slice(0, 3).map((sn) => (
<Badge key={sn.skill.id} variant="outline" size="sm">
{sn.skill.name}
</Badge>
))}
{q.skillNodes.length > 3 && (
<Badge variant="outline" size="sm">
+{q.skillNodes.length - 3}
</Badge>
)}
</div>
<div className="flex items-center gap-4 text-xs text-foreground-tertiary">
{q.toolNodes.length > 0 && (
<span className="flex items-center gap-1">
<Icon icon="puzzle" className="w-3.5 h-3.5" />
{q.toolNodes.length} tool{q.toolNodes.length !== 1 ? 's' : ''}
</span>
)}
{q.similarCount > 0 && (
<span className="flex items-center gap-1">
<Icon icon="user" className="w-3.5 h-3.5" />
{q.similarCount}
</span>
)}
<span>{formatRelativeTime(new Date(q.createdAt))}</span>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
{/* Load More */}
{hasMore && (
<div className="text-center pt-4">
<Button
variant="secondary"
onClick={handleLoadMore}
disabled={loadingMore}
>
{loadingMore ? (
<>
<Icon icon="loader" className="w-4 h-4 mr-2 animate-spin" />
Loading...
</>
) : (
'Load more questions'
)}
</Button>
</div>
)}
</div>
)}
</main>
</div>
);
}

View file

@ -0,0 +1,319 @@
'use client';
import { Badge } from '@tpmjs/ui/Badge/Badge';
import { Button } from '@tpmjs/ui/Button/Button';
import { Card, CardContent, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link';
import { useState } from 'react';
import { AppHeader } from '~/components/AppHeader';
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatRelativeTime(date: Date): string {
const now = Date.now();
const diff = now - date.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'just now';
}
interface QuestionDetailClientProps {
question: {
id: string;
question: string;
answer: string;
confidence: number;
similarCount: number;
tags: string[];
answerTokens: number;
createdAt: string;
updatedAt: string;
skillNodes: Array<{
relevance: number;
skill: {
id: string;
name: string;
slug: string;
description: string;
questionCount: number;
};
}>;
toolNodes: Array<{
relevance: number;
tool: {
id: string;
name: string;
description: string;
package: {
npmPackageName: string;
category: string;
};
};
}>;
};
collection: {
id: string;
name: string;
slug: string;
username: string;
};
similarQuestions: Array<{
id: string;
question: string;
confidence: number;
createdAt: string;
}>;
}
export function QuestionDetailClient({
question,
collection,
similarQuestions,
}: QuestionDetailClientProps): React.ReactElement {
const [copied, setCopied] = useState(false);
const basePath = `/${collection.username}/collections/${collection.slug}`;
const questionUrl = typeof window !== 'undefined'
? window.location.href
: `https://tpmjs.com${basePath}/skills/questions/${question.id}`;
const copyLink = async () => {
await navigator.clipboard.writeText(questionUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="min-h-screen bg-background">
<AppHeader />
<main className="max-w-4xl mx-auto px-4 py-8">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-foreground-secondary mb-6">
<Link href={basePath} className="hover:text-foreground">
{collection.name}
</Link>
<Icon icon="chevronRight" className="w-4 h-4" />
<Link href={`${basePath}/skills/questions`} className="hover:text-foreground">
Questions
</Link>
<Icon icon="chevronRight" className="w-4 h-4" />
<span className="text-foreground truncate max-w-[200px]">
{question.question.slice(0, 40)}...
</span>
</nav>
<div className="grid lg:grid-cols-3 gap-8">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Question Header */}
<div>
<div className="flex items-start justify-between gap-4 mb-4">
<h1 className="text-xl font-bold text-foreground leading-tight">
{question.question}
</h1>
<Button variant="ghost" size="sm" onClick={copyLink} className="flex-shrink-0">
<Icon icon={copied ? 'check' : 'link'} className="w-4 h-4 mr-1.5" />
{copied ? 'Copied!' : 'Copy link'}
</Button>
</div>
<div className="flex items-center gap-4 text-sm text-foreground-secondary">
<span className="flex items-center gap-1.5">
<Icon icon="clock" className="w-4 h-4" />
{formatDate(question.createdAt)}
</span>
<Badge
variant={question.confidence >= 0.7 ? 'success' : 'secondary'}
size="md"
>
{Math.round(question.confidence * 100)}% confidence
</Badge>
{question.similarCount > 0 && (
<span className="flex items-center gap-1.5">
<Icon icon="user" className="w-4 h-4" />
Asked {question.similarCount} time{question.similarCount !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
{/* Answer */}
<Card variant="default">
<CardHeader padding="md">
<CardTitle as="h2" className="text-base font-semibold flex items-center gap-2">
<Icon icon="message" className="w-5 h-5 text-primary" />
Answer
</CardTitle>
</CardHeader>
<CardContent padding="md" className="pt-0">
<div className="prose prose-sm max-w-none text-foreground">
<p className="whitespace-pre-wrap leading-relaxed">{question.answer}</p>
</div>
{question.answerTokens > 0 && (
<p className="mt-4 text-xs text-foreground-tertiary">
Response: {question.answerTokens.toLocaleString()} tokens
</p>
)}
</CardContent>
</Card>
{/* Related Tools */}
{question.toolNodes.length > 0 && (
<Card variant="default">
<CardHeader padding="md">
<CardTitle as="h2" className="text-base font-semibold flex items-center gap-2">
<Icon icon="puzzle" className="w-5 h-5 text-primary" />
Related Tools
</CardTitle>
</CardHeader>
<CardContent padding="md" className="pt-0">
<div className="space-y-3">
{question.toolNodes.map((tn) => (
<Link
key={tn.tool.id}
href={`/tool/${tn.tool.package.npmPackageName}/${tn.tool.name}`}
className="block p-3 bg-muted/50 border border-border rounded-lg hover:border-primary/30 transition-colors"
>
<div className="flex items-start justify-between">
<div>
<p className="font-medium text-foreground">{tn.tool.name}</p>
<p className="text-sm text-foreground-secondary line-clamp-2 mt-1">
{tn.tool.description}
</p>
</div>
<Badge variant="secondary" size="sm">
{tn.tool.package.category}
</Badge>
</div>
<p className="text-xs text-foreground-tertiary mt-2">
{tn.tool.package.npmPackageName}
</p>
</Link>
))}
</div>
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Skills */}
{question.skillNodes.length > 0 && (
<Card variant="default">
<CardHeader padding="sm">
<CardTitle as="h3" className="text-sm font-semibold">
Skills Identified
</CardTitle>
</CardHeader>
<CardContent padding="sm" className="pt-0">
<div className="space-y-2">
{question.skillNodes.map((sn) => (
<Link
key={sn.skill.id}
href={`${basePath}/skills/questions?skill=${encodeURIComponent(sn.skill.slug)}`}
className="block p-2 bg-muted/50 border border-border rounded hover:border-primary/30 transition-colors"
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm text-foreground">{sn.skill.name}</span>
<Badge variant="outline" size="sm">
{sn.skill.questionCount} Q
</Badge>
</div>
{sn.skill.description && (
<p className="text-xs text-foreground-secondary mt-1 line-clamp-2">
{sn.skill.description}
</p>
)}
</Link>
))}
</div>
</CardContent>
</Card>
)}
{/* Tags */}
{question.tags.length > 0 && (
<Card variant="default">
<CardHeader padding="sm">
<CardTitle as="h3" className="text-sm font-semibold">
Tags
</CardTitle>
</CardHeader>
<CardContent padding="sm" className="pt-0">
<div className="flex flex-wrap gap-1.5">
{question.tags.map((tag) => (
<Badge key={tag} variant="secondary" size="sm">
{tag}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Similar Questions */}
{similarQuestions.length > 0 && (
<Card variant="default">
<CardHeader padding="sm">
<CardTitle as="h3" className="text-sm font-semibold">
Related Questions
</CardTitle>
</CardHeader>
<CardContent padding="sm" className="pt-0">
<div className="space-y-2">
{similarQuestions.map((sq) => (
<Link
key={sq.id}
href={`${basePath}/skills/questions/${sq.id}`}
className="block p-2 bg-muted/50 border border-border rounded hover:border-primary/30 transition-colors"
>
<p className="text-sm text-foreground line-clamp-2">{sq.question}</p>
<div className="flex items-center justify-between mt-1.5">
<Badge
variant={sq.confidence >= 0.7 ? 'success' : 'secondary'}
size="sm"
>
{Math.round(sq.confidence * 100)}%
</Badge>
<span className="text-xs text-foreground-tertiary">
{formatRelativeTime(new Date(sq.createdAt))}
</span>
</div>
</Link>
))}
</div>
</CardContent>
</Card>
)}
{/* Back Link */}
<Link href={`${basePath}/skills/questions`}>
<Button variant="secondary" className="w-full">
<Icon icon="arrowLeft" className="w-4 h-4 mr-1.5" />
All Questions
</Button>
</Link>
</div>
</div>
</main>
</div>
);
}

View file

@ -0,0 +1,202 @@
import { prisma } from '@tpmjs/db';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { QuestionDetailClient } from './QuestionDetailClient';
export const dynamic = 'force-dynamic';
interface QuestionPageProps {
params: Promise<{ username: string; slug: string; questionId: string }>;
}
async function getQuestion(questionId: string) {
const question = await prisma.skillQuestion.findUnique({
where: { id: questionId },
select: {
id: true,
question: true,
answer: true,
confidence: true,
similarCount: true,
tags: true,
answerTokens: true,
createdAt: true,
updatedAt: true,
collection: {
select: {
id: true,
name: true,
slug: true,
isPublic: true,
user: {
select: {
username: true,
},
},
},
},
skillNodes: {
select: {
relevance: true,
skill: {
select: {
id: true,
name: true,
slug: true,
description: true,
questionCount: true,
},
},
},
orderBy: { relevance: 'desc' },
},
toolNodes: {
select: {
relevance: true,
tool: {
select: {
id: true,
name: true,
description: true,
package: {
select: {
npmPackageName: true,
category: true,
},
},
},
},
},
orderBy: { relevance: 'desc' },
},
},
});
return question;
}
async function getSimilarQuestions(questionId: string, collectionId: string, skillIds: string[]) {
if (skillIds.length === 0) return [];
return prisma.skillQuestion.findMany({
where: {
id: { not: questionId },
collectionId,
skillNodes: {
some: {
skillId: { in: skillIds },
},
},
},
take: 5,
orderBy: { createdAt: 'desc' },
select: {
id: true,
question: true,
confidence: true,
createdAt: true,
},
});
}
export async function generateMetadata({ params }: QuestionPageProps): Promise<Metadata> {
const { questionId } = await params;
const question = await getQuestion(questionId);
if (!question || !question.collection.isPublic) {
return {
title: 'Question Not Found | TPMJS',
};
}
const truncatedQuestion =
question.question.length > 60
? question.question.slice(0, 60) + '...'
: question.question;
return {
title: `${truncatedQuestion} | TPMJS Skills`,
description: question.answer.slice(0, 160),
openGraph: {
title: truncatedQuestion,
description: question.answer.slice(0, 160),
type: 'article',
},
};
}
export default async function QuestionPage({ params }: QuestionPageProps) {
const { username, slug, questionId } = await params;
const cleanUsername = username.startsWith('@') ? username.slice(1) : username;
const question = await getQuestion(questionId);
if (!question) {
notFound();
}
if (!question.collection.isPublic) {
notFound();
}
// Verify the URL matches the actual collection
const collectionUsername = question.collection.user.username || '';
const collectionSlug = question.collection.slug || '';
if (collectionUsername !== cleanUsername || collectionSlug !== slug) {
notFound();
}
const skillIds = question.skillNodes.map((sn) => sn.skill.id);
const similarQuestions = await getSimilarQuestions(questionId, question.collection.id, skillIds);
return (
<QuestionDetailClient
question={{
id: question.id,
question: question.question,
answer: question.answer,
confidence: question.confidence,
similarCount: question.similarCount,
tags: question.tags,
answerTokens: question.answerTokens,
createdAt: question.createdAt.toISOString(),
updatedAt: question.updatedAt.toISOString(),
skillNodes: question.skillNodes.map((sn) => ({
relevance: sn.relevance,
skill: {
id: sn.skill.id,
name: sn.skill.name,
slug: sn.skill.slug,
description: sn.skill.description,
questionCount: sn.skill.questionCount,
},
})),
toolNodes: question.toolNodes.map((tn) => ({
relevance: tn.relevance,
tool: {
id: tn.tool.id,
name: tn.tool.name,
description: tn.tool.description,
package: {
npmPackageName: tn.tool.package.npmPackageName,
category: tn.tool.package.category,
},
},
})),
}}
collection={{
id: question.collection.id,
name: question.collection.name,
slug: collectionSlug,
username: collectionUsername,
}}
similarQuestions={similarQuestions.map((sq) => ({
id: sq.id,
question: sq.question,
confidence: sq.confidence,
createdAt: sq.createdAt.toISOString(),
}))}
/>
);
}

View file

@ -0,0 +1,69 @@
import { prisma } from '@tpmjs/db';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { QuestionsListClient } from './QuestionsListClient';
export const dynamic = 'force-dynamic';
interface QuestionsPageProps {
params: Promise<{ username: string; slug: string }>;
searchParams: Promise<{ skill?: string }>;
}
async function getCollection(username: string, slug: string) {
const cleanUsername = username.startsWith('@') ? username.slice(1) : username;
const collection = await prisma.collection.findFirst({
where: {
slug,
user: { username: cleanUsername },
isPublic: true,
},
select: {
id: true,
name: true,
slug: true,
user: { select: { username: true } },
},
});
return collection;
}
export async function generateMetadata({ params }: QuestionsPageProps): Promise<Metadata> {
const { username, slug } = await params;
const collection = await getCollection(username, slug);
if (!collection) {
return {
title: 'Questions Not Found | TPMJS',
};
}
return {
title: `Questions - ${collection.name} | TPMJS Skills`,
description: `Browse questions and answers about ${collection.name} tools`,
};
}
export default async function QuestionsPage({ params, searchParams }: QuestionsPageProps) {
const { username, slug } = await params;
const { skill } = await searchParams;
const collection = await getCollection(username, slug);
if (!collection) {
notFound();
}
return (
<QuestionsListClient
collection={{
id: collection.id,
name: collection.name,
slug: collection.slug || '',
username: collection.user.username || '',
}}
initialSkillFilter={skill}
/>
);
}

View file

@ -0,0 +1,128 @@
/**
* GET /api/skills/questions/[id]
*
* Fetch a single skill question with full details
*/
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
interface RouteContext {
params: Promise<{ id: string }>;
}
export async function GET(_request: NextRequest, context: RouteContext) {
const { id } = await context.params;
try {
// Fetch the question with all related data
const question = await prisma.skillQuestion.findUnique({
where: { id },
select: {
id: true,
question: true,
answer: true,
confidence: true,
similarCount: true,
tags: true,
answerTokens: true,
createdAt: true,
updatedAt: true,
collection: {
select: {
id: true,
name: true,
slug: true,
isPublic: true,
user: {
select: {
username: true,
},
},
},
},
skillNodes: {
select: {
relevance: true,
skill: {
select: {
id: true,
name: true,
slug: true,
description: true,
questionCount: true,
},
},
},
orderBy: { relevance: 'desc' },
},
toolNodes: {
select: {
relevance: true,
tool: {
select: {
id: true,
name: true,
description: true,
package: {
select: {
npmPackageName: true,
category: true,
},
},
},
},
},
orderBy: { relevance: 'desc' },
},
},
});
if (!question) {
return NextResponse.json({ error: 'Question not found' }, { status: 404 });
}
// Check if collection is public
if (!question.collection.isPublic) {
return NextResponse.json({ error: 'Question belongs to a private collection' }, { status: 403 });
}
// Fetch similar questions (based on same skills)
const skillIds = question.skillNodes.map((sn) => sn.skill.id);
const similarQuestions = skillIds.length > 0
? await prisma.skillQuestion.findMany({
where: {
id: { not: id },
collectionId: question.collection.id,
skillNodes: {
some: {
skillId: { in: skillIds },
},
},
},
take: 5,
orderBy: { createdAt: 'desc' },
select: {
id: true,
question: true,
confidence: true,
createdAt: true,
},
})
: [];
return NextResponse.json({
success: true,
data: {
...question,
similarQuestions,
},
});
} catch (error) {
console.error('[Skills Question Detail Error]:', error);
return NextResponse.json({ error: 'Failed to fetch question' }, { status: 500 });
}
}

View file

@ -0,0 +1,130 @@
/**
* GET /api/skills/questions
*
* List all skill questions for a collection with pagination
*/
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const collectionId = searchParams.get('collectionId');
const limitParam = searchParams.get('limit');
const offsetParam = searchParams.get('offset');
const skillSlug = searchParams.get('skill');
const limit = Math.min(50, Math.max(1, parseInt(limitParam || '20', 10)));
const offset = Math.max(0, parseInt(offsetParam || '0', 10));
if (!collectionId) {
return NextResponse.json({ error: 'collectionId is required' }, { status: 400 });
}
try {
// Verify collection exists and is public
const collection = await prisma.collection.findUnique({
where: { id: collectionId },
select: {
id: true,
isPublic: true,
name: true,
slug: true,
user: { select: { username: true } },
},
});
if (!collection) {
return NextResponse.json({ error: 'Collection not found' }, { status: 404 });
}
if (!collection.isPublic) {
return NextResponse.json({ error: 'Collection is not public' }, { status: 403 });
}
// Build where clause
const where: {
collectionId: string;
skillNodes?: { some: { skill: { slug: string } } };
} = { collectionId };
// Filter by skill if provided
if (skillSlug) {
where.skillNodes = {
some: {
skill: { slug: skillSlug },
},
};
}
// Fetch questions with pagination
const questions = await prisma.skillQuestion.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit + 1, // Fetch one extra to check hasMore
skip: offset,
select: {
id: true,
question: true,
answer: true,
confidence: true,
similarCount: true,
tags: true,
createdAt: true,
skillNodes: {
select: {
relevance: true,
skill: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
toolNodes: {
select: {
relevance: true,
tool: {
select: {
id: true,
name: true,
package: {
select: {
npmPackageName: true,
},
},
},
},
},
},
},
});
const hasMore = questions.length > limit;
const data = hasMore ? questions.slice(0, limit) : questions;
return NextResponse.json({
success: true,
data,
collection: {
id: collection.id,
name: collection.name,
slug: collection.slug,
username: collection.user.username,
},
pagination: {
limit,
offset,
hasMore,
},
});
} catch (error) {
console.error('[Skills Questions List Error]:', error);
return NextResponse.json({ error: 'Failed to fetch questions' }, { status: 500 });
}
}

View file

@ -5,6 +5,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmj
import { EmptyState } from '@tpmjs/ui/EmptyState/EmptyState';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import { Skeleton } from '@tpmjs/ui/Skeleton/Skeleton';
import Link from 'next/link';
import { useEffect, useState } from 'react';
// Simple relative time formatter
function formatRelativeTime(date: Date): string {
@ -21,8 +23,6 @@ function formatRelativeTime(date: Date): string {
return 'just now';
}
import { useEffect, useState } from 'react';
interface SkillQuestion {
id: string;
question: string;
@ -40,13 +40,18 @@ interface SkillQuestion {
interface SkillsActivityFeedProps {
collectionId: string;
username: string;
slug: string;
limit?: number;
}
export function SkillsActivityFeed({
collectionId,
username,
slug,
limit = 10,
}: SkillsActivityFeedProps): React.ReactElement {
const basePath = `/${username}/collections/${slug}`;
const [questions, setQuestions] = useState<SkillQuestion[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -118,51 +123,53 @@ export function SkillsActivityFeed({
return (
<div className="space-y-3">
{questions.map((q) => (
<Card key={q.id} variant="default" className="hover:border-primary/20 transition-colors">
<CardHeader padding="sm" className="pb-2">
<div className="flex items-start justify-between gap-2">
<CardTitle as="h4" className="text-sm font-medium line-clamp-2">
{q.question}
</CardTitle>
<div className="flex items-center gap-1 flex-shrink-0">
<Badge variant={q.confidence >= 0.7 ? 'success' : 'secondary'} size="sm">
{Math.round(q.confidence * 100)}%
</Badge>
</div>
</div>
</CardHeader>
<CardContent padding="sm" className="pt-0">
<CardDescription className="line-clamp-2 text-xs mb-3">
{q.answer.slice(0, 150)}
{q.answer.length > 150 ? '...' : ''}
</CardDescription>
<div className="flex items-center justify-between">
<div className="flex gap-1.5 flex-wrap">
{q.skillNodes.slice(0, 2).map((sn, i) => (
<Badge key={i} variant="outline" size="sm">
{sn.skill.name}
<Link key={q.id} href={`${basePath}/skills/questions/${q.id}`} className="block">
<Card variant="default" className="hover:border-primary/20 hover:bg-muted/30 transition-all cursor-pointer">
<CardHeader padding="sm" className="pb-2">
<div className="flex items-start justify-between gap-2">
<CardTitle as="h4" className="text-sm font-medium line-clamp-2">
{q.question}
</CardTitle>
<div className="flex items-center gap-1 flex-shrink-0">
<Badge variant={q.confidence >= 0.7 ? 'success' : 'secondary'} size="sm">
{Math.round(q.confidence * 100)}%
</Badge>
))}
{q.skillNodes.length > 2 && (
<Badge variant="outline" size="sm">
+{q.skillNodes.length - 2}
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-3 text-xs text-foreground-tertiary">
{q.similarCount > 0 && (
<span className="flex items-center gap-1">
<Icon icon="user" size="sm" />
{q.similarCount}
</span>
)}
<span>{formatRelativeTime(new Date(q.createdAt))}</span>
</CardHeader>
<CardContent padding="sm" className="pt-0">
<CardDescription className="line-clamp-2 text-xs mb-3">
{q.answer.slice(0, 150)}
{q.answer.length > 150 ? '...' : ''}
</CardDescription>
<div className="flex items-center justify-between">
<div className="flex gap-1.5 flex-wrap">
{q.skillNodes.slice(0, 2).map((sn, i) => (
<Badge key={i} variant="outline" size="sm">
{sn.skill.name}
</Badge>
))}
{q.skillNodes.length > 2 && (
<Badge variant="outline" size="sm">
+{q.skillNodes.length - 2}
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-xs text-foreground-tertiary">
{q.similarCount > 0 && (
<span className="flex items-center gap-1">
<Icon icon="user" size="sm" />
{q.similarCount}
</span>
)}
<span>{formatRelativeTime(new Date(q.createdAt))}</span>
</div>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
</Link>
))}
</div>
);

View file

@ -122,8 +122,20 @@ curl -X POST "${skillsUrl}" \\
<div className="md:col-span-2">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-foreground">Recent Questions</h3>
<Link
href={`/${username}/collections/${slug}/skills/questions`}
className="text-sm text-primary hover:underline flex items-center gap-1"
>
View all
<Icon icon="arrowRight" className="w-3.5 h-3.5" />
</Link>
</div>
<SkillsActivityFeed collectionId={collectionId} limit={5} />
<SkillsActivityFeed
collectionId={collectionId}
username={username}
slug={slug}
limit={5}
/>
</div>
</div>