import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Header } from '@/components/Header'; import { Footer } from '@/components/Footer'; import { SkillCard } from '@/components/SkillCard'; import { Pagination } from '@/components/BrowseFilters'; import { createDb, skillQueries } from '@skillhub/db'; import { formatCompactNumber, toPersianNumber } from '@/lib/format-number'; import { ExternalLink, Download, Package, Eye, GitFork, ArrowUpDown, FolderGit2 } from 'lucide-react'; import Link from 'next/link'; import type { Metadata } from 'next'; import { getPageAlternates } from '@/lib/seo'; import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache'; import { auth } from '@/lib/auth'; export const dynamic = 'force-dynamic'; export async function generateMetadata({ params, }: { params: Promise<{ locale: string; username: string }>; }): Promise { const { locale, username } = await params; return { title: `${decodeURIComponent(username)} | SkillHub`, alternates: getPageAlternates(locale, `/owner/${username}`), }; } const ITEMS_PER_PAGE = 24; type SortOption = 'popularity' | 'downloads' | 'stars'; interface OwnerPageProps { params: Promise<{ locale: string; username: string }>; searchParams: Promise<{ page?: string; sort?: string; repo?: string }>; } export default async function OwnerPage({ params, searchParams }: OwnerPageProps) { const { locale, username: rawUsername } = await params; const searchParamsResolved = await searchParams; setRequestLocale(locale); const t = await getTranslations('owner'); const tBrowse = await getTranslations('browse'); const username = decodeURIComponent(rawUsername); const page = Math.max(1, parseInt(searchParamsResolved.page || '1')); const sort = (['popularity', 'downloads', 'stars'].includes(searchParamsResolved.sort || '') ? searchParamsResolved.sort : 'popularity') as SortOption; const activeRepo = searchParamsResolved.repo || ''; const db = createDb(); const session = await auth(); const isOwner = session?.user?.username?.toLowerCase() === username.toLowerCase(); // Fetch stats and repo list with caching (30 min TTL), count is dynamic per filter const [stats, totalSkills, ownerRepos] = await Promise.all([ getOrSetCache(cacheKeys.ownerStats(username), cacheTTL.owner, () => skillQueries.getOwnerStats(db, username) ), skillQueries.countByOwner(db, username, activeRepo || undefined), getOrSetCache(cacheKeys.ownerRepos(username), cacheTTL.owner, () => skillQueries.getOwnerRepos(db, username) ), ]); if (stats.totalSkills === 0) { return (

{t('notFound')}

{t('notFoundDescription', { username })}

{t('browseAll')}
); } const offset = (page - 1) * ITEMS_PER_PAGE; const totalPages = Math.ceil(totalSkills / ITEMS_PER_PAGE); const skills = await skillQueries.getByOwner(db, username, { limit: ITEMS_PER_PAGE, offset, sortBy: sort, repo: activeRepo || undefined, }); // Group fetched skills by repo for display const repoMap = new Map(); for (const skill of skills) { const repo = skill.githubRepo; if (!repoMap.has(repo)) { repoMap.set(repo, { name: repo, stars: skill.githubStars ?? 0, skills: [], }); } repoMap.get(repo)!.skills.push(skill); } const repos = Array.from(repoMap.values()); const formatNum = (n: number) => locale === 'fa' ? toPersianNumber(formatCompactNumber(n, locale)) : formatCompactNumber(n, locale); const startItem = offset + 1; const endItem = Math.min(offset + ITEMS_PER_PAGE, totalSkills); const paginationTranslations = { previous: tBrowse('pagination.previous'), next: tBrowse('pagination.next'), page: tBrowse('pagination.page'), of: tBrowse('pagination.of'), }; const sortOptions: { value: SortOption; label: string }[] = [ { value: 'popularity', label: t('sort.popularity') }, { value: 'downloads', label: t('sort.downloads') }, { value: 'stars', label: t('sort.stars') }, ]; // Build URL helper preserving sort/repo params const buildUrl = (overrides: { sort?: string; repo?: string; page?: number }) => { const params = new URLSearchParams(); const s = overrides.sort ?? sort; const r = overrides.repo ?? activeRepo; const p = overrides.page ?? 1; if (s && s !== 'popularity') params.set('sort', s); if (r) params.set('repo', r); if (p > 1) params.set('page', String(p)); const qs = params.toString(); return `/${locale}/owner/${rawUsername}${qs ? `?${qs}` : ''}`; }; return (
{/* Owner Header */}
{username}

{t('title', { username })}

{/* Stats */}
{t('stats.skills', { count: stats.totalSkills })} {formatNum(stats.totalDownloads)} {t('stats.downloads')} {formatNum(stats.totalViews)} {t('stats.views')} {t('stats.repos', { count: stats.totalRepos })}
{/* Repo filter chips (only if > 1 repo) */} {ownerRepos.length > 1 && (
{t('repo.filter')}: {t('repo.all')} ({locale === 'fa' ? toPersianNumber(stats.totalSkills) : stats.totalSkills}) {ownerRepos.map((r) => ( {r.repo} ({locale === 'fa' ? toPersianNumber(r.skillCount) : r.skillCount}) ))}
)} {/* Controls: Sort + Results count */}
{/* Results count */}

{t('pagination.showing', { start: locale === 'fa' ? toPersianNumber(startItem) : startItem, end: locale === 'fa' ? toPersianNumber(endItem) : endItem, total: locale === 'fa' ? toPersianNumber(totalSkills) : totalSkills, })}

{/* Sort selector */}
{t('sort.label')}:
{sortOptions.map((opt) => ( {opt.label} ))}
{/* Claim CTA */} {isOwner ? (
{t('ownerManageCta')}
{t('ownerManageButton')}
) : (
{t('claimCta')} {t('claimButton')}
)} {/* Repos + Skills */} {repos.map((repo) => (

{repo.name}

{t('repo.skills', { count: repo.skills.length })} {t('repo.viewOnGithub')}
{repo.skills.map((skill) => ( ))}
))} {/* Pagination */} {/* Browse CTA - link to owner page and CLI for owners with long enough usernames */}

{t('installCta')}

{skills.length > 0 && ( npx skillhub install {skills[0].id} )}
); }