diff --git a/apps/web/src/app/(profile)/[username]/collections/[slug]/skills/questions/QuestionsListClient.tsx b/apps/web/src/app/(profile)/[username]/collections/[slug]/skills/questions/QuestionsListClient.tsx new file mode 100644 index 0000000..8ebc337 --- /dev/null +++ b/apps/web/src/app/(profile)/[username]/collections/[slug]/skills/questions/QuestionsListClient.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(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 ( +
+ + +
+ {/* Breadcrumb */} + + + {/* Header */} +
+
+

Questions

+

+ Browse all questions asked about {collection.name} +

+
+ + + +
+ + {/* Skill Filter */} + {skillFilter && ( +
+ Filtered by skill: + + {skillFilter} + + +
+ )} + + {/* Loading State */} + {loading && ( +
+ {[1, 2, 3, 4, 5].map((i) => ( + + + + + + + + ))} +
+ )} + + {/* Error State */} + {error && !loading && ( + + +
+ +

{error}

+
+
+
+ )} + + {/* Empty State */} + {!loading && !error && questions.length === 0 && ( + + + + {skillFilter && ( +
+ +
+ )} +
+
+ )} + + {/* Questions List */} + {!loading && !error && questions.length > 0 && ( +
+ {questions.map((q) => ( + + + +
+ + {q.question} + + = 0.7 ? 'success' : 'secondary'} + size="sm" + className="flex-shrink-0" + > + {Math.round(q.confidence * 100)}% + +
+
+ + + + {q.answer.slice(0, 250)} + {q.answer.length > 250 ? '...' : ''} + + +
+
+ {q.skillNodes.slice(0, 3).map((sn) => ( + + {sn.skill.name} + + ))} + {q.skillNodes.length > 3 && ( + + +{q.skillNodes.length - 3} + + )} +
+ +
+ {q.toolNodes.length > 0 && ( + + + {q.toolNodes.length} tool{q.toolNodes.length !== 1 ? 's' : ''} + + )} + {q.similarCount > 0 && ( + + + {q.similarCount} + + )} + {formatRelativeTime(new Date(q.createdAt))} +
+
+
+
+ + ))} + + {/* Load More */} + {hasMore && ( +
+ +
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/(profile)/[username]/collections/[slug]/skills/questions/[questionId]/QuestionDetailClient.tsx b/apps/web/src/app/(profile)/[username]/collections/[slug]/skills/questions/[questionId]/QuestionDetailClient.tsx new file mode 100644 index 0000000..8e18087 --- /dev/null +++ b/apps/web/src/app/(profile)/[username]/collections/[slug]/skills/questions/[questionId]/QuestionDetailClient.tsx @@ -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 ( +
+ + +
+ {/* Breadcrumb */} + + +
+ {/* Main Content */} +
+ {/* Question Header */} +
+
+

+ {question.question} +

+ +
+ +
+ + + {formatDate(question.createdAt)} + + = 0.7 ? 'success' : 'secondary'} + size="md" + > + {Math.round(question.confidence * 100)}% confidence + + {question.similarCount > 0 && ( + + + Asked {question.similarCount} time{question.similarCount !== 1 ? 's' : ''} + + )} +
+
+ + {/* Answer */} + + + + + Answer + + + +
+

{question.answer}

+
+ {question.answerTokens > 0 && ( +

+ Response: {question.answerTokens.toLocaleString()} tokens +

+ )} +
+
+ + {/* Related Tools */} + {question.toolNodes.length > 0 && ( + + + + + Related Tools + + + +
+ {question.toolNodes.map((tn) => ( + +
+
+

{tn.tool.name}

+

+ {tn.tool.description} +

+
+ + {tn.tool.package.category} + +
+

+ {tn.tool.package.npmPackageName} +

+ + ))} +
+
+
+ )} +
+ + {/* Sidebar */} +
+ {/* Skills */} + {question.skillNodes.length > 0 && ( + + + + Skills Identified + + + +
+ {question.skillNodes.map((sn) => ( + +
+ {sn.skill.name} + + {sn.skill.questionCount} Q + +
+ {sn.skill.description && ( +

+ {sn.skill.description} +

+ )} + + ))} +
+
+
+ )} + + {/* Tags */} + {question.tags.length > 0 && ( + + + + Tags + + + +
+ {question.tags.map((tag) => ( + + {tag} + + ))} +
+
+
+ )} + + {/* Similar Questions */} + {similarQuestions.length > 0 && ( + + + + Related Questions + + + +
+ {similarQuestions.map((sq) => ( + +

{sq.question}

+
+ = 0.7 ? 'success' : 'secondary'} + size="sm" + > + {Math.round(sq.confidence * 100)}% + + + {formatRelativeTime(new Date(sq.createdAt))} + +
+ + ))} +
+
+
+ )} + + {/* Back Link */} + + + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(profile)/[username]/collections/[slug]/skills/questions/[questionId]/page.tsx b/apps/web/src/app/(profile)/[username]/collections/[slug]/skills/questions/[questionId]/page.tsx new file mode 100644 index 0000000..7402864 --- /dev/null +++ b/apps/web/src/app/(profile)/[username]/collections/[slug]/skills/questions/[questionId]/page.tsx @@ -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 { + 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 ( + ({ + 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(), + }))} + /> + ); +} diff --git a/apps/web/src/app/(profile)/[username]/collections/[slug]/skills/questions/page.tsx b/apps/web/src/app/(profile)/[username]/collections/[slug]/skills/questions/page.tsx new file mode 100644 index 0000000..d85c94b --- /dev/null +++ b/apps/web/src/app/(profile)/[username]/collections/[slug]/skills/questions/page.tsx @@ -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 { + 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 ( + + ); +} diff --git a/apps/web/src/app/api/skills/questions/[id]/route.ts b/apps/web/src/app/api/skills/questions/[id]/route.ts new file mode 100644 index 0000000..14c9caf --- /dev/null +++ b/apps/web/src/app/api/skills/questions/[id]/route.ts @@ -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 }); + } +} diff --git a/apps/web/src/app/api/skills/questions/route.ts b/apps/web/src/app/api/skills/questions/route.ts new file mode 100644 index 0000000..03f3d2b --- /dev/null +++ b/apps/web/src/app/api/skills/questions/route.ts @@ -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 }); + } +} diff --git a/apps/web/src/components/skills/SkillsActivityFeed.tsx b/apps/web/src/components/skills/SkillsActivityFeed.tsx index a4a2eff..7d9f873 100644 --- a/apps/web/src/components/skills/SkillsActivityFeed.tsx +++ b/apps/web/src/components/skills/SkillsActivityFeed.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -118,51 +123,53 @@ export function SkillsActivityFeed({ return (
{questions.map((q) => ( - - -
- - {q.question} - -
- = 0.7 ? 'success' : 'secondary'} size="sm"> - {Math.round(q.confidence * 100)}% - -
-
-
- - - - {q.answer.slice(0, 150)} - {q.answer.length > 150 ? '...' : ''} - - -
-
- {q.skillNodes.slice(0, 2).map((sn, i) => ( - - {sn.skill.name} + + + +
+ + {q.question} + +
+ = 0.7 ? 'success' : 'secondary'} size="sm"> + {Math.round(q.confidence * 100)}% - ))} - {q.skillNodes.length > 2 && ( - - +{q.skillNodes.length - 2} - - )} +
-
- {q.similarCount > 0 && ( - - - {q.similarCount} - - )} - {formatRelativeTime(new Date(q.createdAt))} + + + + + {q.answer.slice(0, 150)} + {q.answer.length > 150 ? '...' : ''} + + +
+
+ {q.skillNodes.slice(0, 2).map((sn, i) => ( + + {sn.skill.name} + + ))} + {q.skillNodes.length > 2 && ( + + +{q.skillNodes.length - 2} + + )} +
+
+ {q.similarCount > 0 && ( + + + {q.similarCount} + + )} + {formatRelativeTime(new Date(q.createdAt))} +
-
- -
+ + + ))}
); diff --git a/apps/web/src/components/skills/SkillsSection.tsx b/apps/web/src/components/skills/SkillsSection.tsx index e36bb1c..81fee44 100644 --- a/apps/web/src/components/skills/SkillsSection.tsx +++ b/apps/web/src/components/skills/SkillsSection.tsx @@ -122,8 +122,20 @@ curl -X POST "${skillsUrl}" \\

Recent Questions

+ + View all + +
- +