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:
parent
9995d73052
commit
b5731cf1a1
8 changed files with 1247 additions and 45 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
128
apps/web/src/app/api/skills/questions/[id]/route.ts
Normal file
128
apps/web/src/app/api/skills/questions/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
130
apps/web/src/app/api/skills/questions/route.ts
Normal file
130
apps/web/src/app/api/skills/questions/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue