Foro: perfil de usuario

- lib/forum.ts: getForumProfile (post_count, topic_count, joindate, gmlevel->status
  admin/gm, y últimos 5 temas), portado de forum/views.py profile().
- Página /forum/user/[username] con contadores, antigüedad, rango y temas recientes.
- Los nombres de autor en la vista del tema enlazan al perfil.
- i18n es/en: Forum.memberSince, recentTopics, roleAdmin, roleGm.

Verificado: build OK, /forum/user 200.

Foro COMPLETO: índice, foro/tema, crear, responder, editar/borrar, moderación
(lock/pin/mover/borrar/restaurar), búsqueda, paginación, editor rico y perfiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 01:08:49 +00:00
parent 8a3ba8ff40
commit 00c03d84e1
5 changed files with 142 additions and 3 deletions
@@ -65,7 +65,9 @@ export default async function TopicPage({
<article key={post.id} className={`nw-card ${post.deleted ? 'opacity-50 ring-1 ring-red-900/50' : ''}`}>
<div className="mb-2 flex items-center justify-between border-b border-amber-900/40 pb-2 text-sm">
<span className="font-semibold text-amber-400">
{post.poster}
<Link href={`/forum/user/${encodeURIComponent(post.poster)}`} className="hover:text-amber-300 hover:underline">
{post.poster}
</Link>
{post.deleted && <span className="ml-2 text-xs text-red-400">[{t('deletedMark')}]</span>}
</span>
{post.time && (
@@ -0,0 +1,68 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { Link } from '@/i18n/navigation'
import { getForumProfile } from '@/lib/forum'
export const dynamic = 'force-dynamic'
export default async function ForumProfilePage({
params,
}: {
params: Promise<{ locale: string; username: string }>
}) {
const { locale, username } = await params
setRequestLocale(locale)
const t = await getTranslations('Forum')
const p = await getForumProfile(decodeURIComponent(username))
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<p className="mb-2 text-sm">
<Link href="/forum" className="text-sky-400 hover:underline">
{t('backToForum')}
</Link>
</p>
<div className="nw-card">
<h1 className="flex items-center gap-2 text-2xl font-bold text-amber-500">
{p.username}
{p.status && (
<span className="rounded bg-nw-gold/15 px-2 py-0.5 text-xs text-nw-gold-light">
{p.status === 'admin' ? t('roleAdmin') : t('roleGm')}
</span>
)}
</h1>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm sm:grid-cols-3">
<div>
<p className="text-nw-muted">{t('posts')}</p>
<p className="text-lg font-semibold text-amber-300">{p.postCount}</p>
</div>
<div>
<p className="text-nw-muted">{t('topics')}</p>
<p className="text-lg font-semibold text-amber-300">{p.topicCount}</p>
</div>
<div>
<p className="text-nw-muted">{t('memberSince')}</p>
<p className="font-semibold text-amber-300">
{p.joindate ? new Date(p.joindate).toLocaleDateString(locale) : '—'}
</p>
</div>
</div>
</div>
{p.recentTopics.length > 0 && (
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold text-nw-gold-light">{t('recentTopics')}</h2>
<ul className="divide-y divide-amber-900/40">
{p.recentTopics.map((topic) => (
<li key={topic.id} className="py-2">
<Link href={`/forum/topic/${topic.id}`} className="text-amber-300 hover:text-amber-400">
{topic.name}
</Link>
</li>
))}
</ul>
</section>
)}
</main>
)
}
+61
View File
@@ -211,6 +211,67 @@ export async function getPosts(topicId: number, offset = 0, limit = 15, includeD
}
}
export interface ForumProfile {
username: string
postCount: number
topicCount: number
joindate: string | null
gmlevel: number
status: 'admin' | 'gm' | null
recentTopics: { id: number; name: string; forum_id: number }[]
}
/** Perfil público de un usuario del foro (contadores + antigüedad + rango). */
export async function getForumProfile(username: string): Promise<ForumProfile> {
const profile: ForumProfile = {
username,
postCount: 0,
topicCount: 0,
joindate: null,
gmlevel: 0,
status: null,
recentTopics: [],
}
try {
const [pc] = await db(DB.web).query<RowDataPacket[]>(
'SELECT COUNT(*) AS n FROM forum_posts WHERE poster = ? AND deleted = 0',
[username],
)
profile.postCount = Number(pc[0]?.n ?? 0)
const [tc] = await db(DB.web).query<RowDataPacket[]>(
'SELECT COUNT(*) AS n FROM forum_topics WHERE poster = ? AND deleted = 0',
[username],
)
profile.topicCount = Number(tc[0]?.n ?? 0)
const [rt] = await db(DB.web).query<RowDataPacket[]>(
'SELECT id, name, forum_id FROM forum_topics WHERE poster = ? AND deleted = 0 ORDER BY updated_at DESC, id DESC LIMIT 5',
[username],
)
profile.recentTopics = rt.map((r) => ({ id: r.id, name: r.name, forum_id: r.forum_id }))
} catch {
/* tablas de foro no pobladas */
}
try {
const [acc] = await db(DB.auth).query<RowDataPacket[]>(
'SELECT id, joindate FROM account WHERE username = ?',
[username],
)
if (acc[0]) {
profile.joindate = acc[0].joindate ? new Date(acc[0].joindate).toISOString() : null
const [ga] = await db(DB.auth).query<RowDataPacket[]>(
'SELECT MAX(gmlevel) AS lvl FROM account_access WHERE id = ?',
[acc[0].id],
)
profile.gmlevel = ga[0]?.lvl != null ? Number(ga[0].lvl) : 0
}
} catch {
/* account_access puede no existir */
}
if (profile.gmlevel >= 16) profile.status = 'admin'
else if (profile.gmlevel >= 1) profile.status = 'gm'
return profile
}
/** Nº de temas que coinciden con la búsqueda (foros visibles). */
export async function countSearchTopics(query: string): Promise<number> {
const like = `%${query}%`
+5 -1
View File
@@ -297,7 +297,11 @@
"moveTopic": "Move to",
"restore": "Restore",
"restoreTopic": "Restore topic",
"deletedMark": "Deleted"
"deletedMark": "Deleted",
"memberSince": "Member since",
"recentTopics": "Recent topics",
"roleAdmin": "Administrator",
"roleGm": "GM"
},
"Admin": {
"title": "Admin panel",
+5 -1
View File
@@ -297,7 +297,11 @@
"moveTopic": "Mover a",
"restore": "Restaurar",
"restoreTopic": "Restaurar tema",
"deletedMark": "Borrado"
"deletedMark": "Borrado",
"memberSince": "Miembro desde",
"recentTopics": "Temas recientes",
"roleAdmin": "Administrador",
"roleGm": "GM"
},
"Admin": {
"title": "Panel de administración",