Foro: edición/borrado de posts, moderación de temas y búsqueda
Portado desde forum/db.py + forum/permissions.py de Django: - lib/forum-perm.ts: forumIsModerator (gmlevel >= FORUM_MOD_GMLEVEL) y canEditPost (autor o moderador). - Edición y borrado lógico de posts (autor o mod), respetando tema bloqueado, con validación de longitud mínima (plainLength). - Moderación de temas (solo mod): bloquear/desbloquear, fijar/no fijar, borrar. - Búsqueda de temas por título/contenido (searchTopics/countSearchTopics) con página /forum/search y buscador en el índice. - API: PATCH/DELETE /api/forum/post, POST /api/forum/moderate (401/403 correctos). - UI: PostActions, TopicModBar, ForumSearchBox; marca "editado" y "[Fijado]". - i18n: 22 claves nuevas en es/en. Verificado: build OK, /forum y /forum/search 200, APIs 401 sin sesión. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { getForumIndex } from '@/lib/forum'
|
||||
import { ForumSearchBox } from '@/components/ForumSearchBox'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -13,6 +14,7 @@ export default async function ForumIndexPage({ params }: { params: Promise<{ loc
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
<ForumSearchBox />
|
||||
{categories.length === 0 ? (
|
||||
<p className="text-amber-200/70">{t('noForums')}</p>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { searchTopics, countSearchTopics } from '@/lib/forum'
|
||||
import { ForumSearchBox } from '@/components/ForumSearchBox'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function ForumSearchPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
searchParams: Promise<{ q?: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const { q } = await searchParams
|
||||
const query = (q ?? '').trim()
|
||||
const t = await getTranslations('Forum')
|
||||
|
||||
const valid = query.length >= 2
|
||||
const results = valid ? await searchTopics(query, 0, 30) : []
|
||||
const total = valid ? await countSearchTopics(query) : 0
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||
<p className="mb-2 text-sm">
|
||||
<Link href="/forum" className="text-sky-400 hover:underline">
|
||||
← {t('backToForum')}
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-500">{t('search')}</h1>
|
||||
<ForumSearchBox initial={query} />
|
||||
|
||||
{!valid ? (
|
||||
<p className="text-amber-200/70">{t('searchHint')}</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-amber-200/70">{t('noResults', { query })}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-amber-200/60">{t('resultsCount', { count: total, query })}</p>
|
||||
<ul className="divide-y divide-amber-900/40">
|
||||
{results.map((r) => (
|
||||
<li key={r.id} className="flex items-center justify-between gap-4 py-3">
|
||||
<div>
|
||||
<Link href={`/forum/topic/${r.id}`} className="font-semibold text-amber-300 hover:text-amber-400">
|
||||
{r.name}
|
||||
</Link>
|
||||
<p className="text-xs text-amber-200/60">
|
||||
{t('in')}{' '}
|
||||
<Link href={`/forum/${r.forum_id}`} className="text-sky-400 hover:underline">
|
||||
{r.forum_name}
|
||||
</Link>{' '}
|
||||
· {t('by')} {r.poster}
|
||||
</p>
|
||||
</div>
|
||||
{r.updated_at && (
|
||||
<span className="whitespace-nowrap text-xs text-amber-200/50">
|
||||
{new Date(r.updated_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { getTopic, getPosts } from '@/lib/forum'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { forumIsModerator, canEditPost } from '@/lib/forum-perm'
|
||||
import { ReplyForm } from '@/components/ReplyForm'
|
||||
import { PostActions } from '@/components/PostActions'
|
||||
import { TopicModBar } from '@/components/TopicModBar'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -21,7 +24,8 @@ export default async function TopicPage({
|
||||
if (!topic) notFound()
|
||||
const posts = await getPosts(id)
|
||||
const session = await getSession()
|
||||
const canReply = Boolean(session.username) && !topic!.locked
|
||||
const isMod = await forumIsModerator(session)
|
||||
const canReply = Boolean(session.username) && (!topic!.locked || isMod)
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||
@@ -30,24 +34,33 @@ export default async function TopicPage({
|
||||
← {t('backToForum')}
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-500">
|
||||
<h1 className="mb-4 text-2xl font-bold text-amber-500">
|
||||
{topic!.sticky && <span className="mr-2 text-sm text-nw-gold-light">[{t('pinned')}]</span>}
|
||||
{topic!.locked && <span className="mr-2 text-sm text-red-400">[{t('locked')}]</span>}
|
||||
{topic!.name}
|
||||
</h1>
|
||||
|
||||
{isMod && <TopicModBar topicId={id} locked={topic!.locked} sticky={topic!.sticky} />}
|
||||
|
||||
<div className="space-y-4">
|
||||
{posts.map((post) => (
|
||||
<article key={post.id} className="nw-card">
|
||||
<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}</span>
|
||||
{post.time && (
|
||||
<span className="text-amber-200/50">{new Date(post.time).toLocaleString(locale)}</span>
|
||||
<span className="text-amber-200/50">
|
||||
{new Date(post.time).toLocaleString(locale)}
|
||||
{post.edited && <span className="ml-2 italic text-amber-200/40">· {t('editedMark')}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: post.text }}
|
||||
/>
|
||||
{canEditPost(session, isMod, post.poster_id) && (
|
||||
<PostActions postId={post.id} initialText={post.text} />
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getTopic } from '@/lib/forum'
|
||||
import { setTopicFlag } from '@/lib/forum-write'
|
||||
import { forumIsModerator } from '@/lib/forum-perm'
|
||||
|
||||
type Action = 'lock' | 'unlock' | 'sticky' | 'unsticky' | 'delete'
|
||||
|
||||
/** Moderación de temas: bloquear, fijar, borrar. Solo moderadores. */
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession()
|
||||
if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
|
||||
if (!(await forumIsModerator(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
|
||||
|
||||
let b: Record<string, string> = {}
|
||||
try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) }
|
||||
const topicId = Number(b.topicId)
|
||||
const action = String(b.action ?? '') as Action
|
||||
if (!topicId) return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
|
||||
|
||||
const topic = await getTopic(topicId)
|
||||
if (!topic) return Response.json({ success: false, error: 'topicNotFound' }, { status: 404 })
|
||||
|
||||
switch (action) {
|
||||
case 'lock':
|
||||
await setTopicFlag(topicId, 'locked', true)
|
||||
break
|
||||
case 'unlock':
|
||||
await setTopicFlag(topicId, 'locked', false)
|
||||
break
|
||||
case 'sticky':
|
||||
await setTopicFlag(topicId, 'sticky', true)
|
||||
break
|
||||
case 'unsticky':
|
||||
await setTopicFlag(topicId, 'sticky', false)
|
||||
break
|
||||
case 'delete':
|
||||
await setTopicFlag(topicId, 'deleted', true)
|
||||
return Response.json({ success: true, forumId: topic.forum_id })
|
||||
default:
|
||||
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
|
||||
}
|
||||
return Response.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getPost, updatePost, setPostDeleted } from '@/lib/forum-write'
|
||||
import { getTopic } from '@/lib/forum'
|
||||
import { forumIsModerator, canEditPost } from '@/lib/forum-perm'
|
||||
import { plainLength } from '@/lib/forum-sanitize'
|
||||
|
||||
const MIN_TEXT_LEN = 5
|
||||
|
||||
/** Editar el texto de un post propio (o cualquiera si es moderador). */
|
||||
export async function PATCH(request: Request) {
|
||||
const session = await getSession()
|
||||
if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
|
||||
|
||||
let b: Record<string, string> = {}
|
||||
try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) }
|
||||
const postId = Number(b.postId)
|
||||
const text = String(b.text ?? '').trim()
|
||||
if (!postId) return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
|
||||
|
||||
const post = await getPost(postId)
|
||||
if (!post) return Response.json({ success: false, error: 'postNotFound' }, { status: 404 })
|
||||
|
||||
const isMod = await forumIsModerator(session)
|
||||
if (!canEditPost(session, isMod, post.poster_id)) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
|
||||
|
||||
const topic = await getTopic(post.topic_id)
|
||||
if (topic?.locked && !isMod) return Response.json({ success: false, error: 'topicLocked' })
|
||||
if (plainLength(text) < MIN_TEXT_LEN) return Response.json({ success: false, error: 'tooShort' })
|
||||
|
||||
await updatePost(postId, text)
|
||||
return Response.json({ success: true })
|
||||
}
|
||||
|
||||
/** Borrar (lógico) un post propio (o cualquiera si es moderador). */
|
||||
export async function DELETE(request: Request) {
|
||||
const session = await getSession()
|
||||
if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
|
||||
|
||||
let b: Record<string, string> = {}
|
||||
try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) }
|
||||
const postId = Number(b.postId)
|
||||
if (!postId) return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
|
||||
|
||||
const post = await getPost(postId)
|
||||
if (!post) return Response.json({ success: false, error: 'postNotFound' }, { status: 404 })
|
||||
|
||||
const isMod = await forumIsModerator(session)
|
||||
if (!canEditPost(session, isMod, post.poster_id)) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
|
||||
|
||||
const topic = await getTopic(post.topic_id)
|
||||
if (topic?.locked && !isMod) return Response.json({ success: false, error: 'topicLocked' })
|
||||
|
||||
await setPostDeleted(postId, true)
|
||||
return Response.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter } from '@/i18n/navigation'
|
||||
|
||||
export function ForumSearchBox({ initial = '' }: { initial?: string }) {
|
||||
const t = useTranslations('Forum')
|
||||
const router = useRouter()
|
||||
const [q, setQ] = useState(initial)
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const query = q.trim()
|
||||
if (query.length < 2) return
|
||||
router.push(`/forum/search?q=${encodeURIComponent(query)}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="mb-6 flex gap-2">
|
||||
<input
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
className="nw-input flex-1"
|
||||
/>
|
||||
<button type="submit" className="nw-btn whitespace-nowrap">
|
||||
{t('search')}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter } from '@/i18n/navigation'
|
||||
|
||||
export function PostActions({ postId, initialText }: { postId: number; initialText: string }) {
|
||||
const t = useTranslations('Forum')
|
||||
const router = useRouter()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [text, setText] = useState(initialText)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function save() {
|
||||
if (busy || !text.trim()) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/forum/post', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ postId, text }),
|
||||
})
|
||||
const data: { success?: boolean; error?: string } = await res.json()
|
||||
if (data.success) {
|
||||
setEditing(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
setError(t(data.error === 'tooShort' ? 'tooShort' : data.error === 'topicLocked' ? 'topicLocked' : 'genericError'))
|
||||
}
|
||||
} catch {
|
||||
setError(t('genericError'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (busy || !confirm(t('confirmDeletePost'))) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/forum/post', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ postId }),
|
||||
})
|
||||
const data: { success?: boolean } = await res.json()
|
||||
if (data.success) router.refresh()
|
||||
else setError(t('genericError'))
|
||||
} catch {
|
||||
setError(t('genericError'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="mt-3 space-y-2">
|
||||
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={4} className="nw-input" />
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={save} disabled={busy} className="nw-btn text-sm disabled:opacity-60">
|
||||
{busy ? t('sending') : t('save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditing(false); setText(initialText); setError(null) }}
|
||||
className="nw-btn-ghost text-sm"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-3 text-xs">
|
||||
<button type="button" onClick={() => setEditing(true)} className="text-nw-muted hover:text-nw-gold-light">
|
||||
{t('edit')}
|
||||
</button>
|
||||
<button type="button" onClick={remove} disabled={busy} className="text-nw-muted hover:text-red-400 disabled:opacity-60">
|
||||
{t('delete')}
|
||||
</button>
|
||||
{error && <span className="text-red-400">{error}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter } from '@/i18n/navigation'
|
||||
|
||||
export function TopicModBar({
|
||||
topicId,
|
||||
locked,
|
||||
sticky,
|
||||
}: {
|
||||
topicId: number
|
||||
locked: boolean
|
||||
sticky: boolean
|
||||
}) {
|
||||
const t = useTranslations('Forum')
|
||||
const router = useRouter()
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function act(action: string, confirmMsg?: string) {
|
||||
if (busy) return
|
||||
if (confirmMsg && !confirm(confirmMsg)) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await fetch('/api/forum/moderate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ topicId, action }),
|
||||
})
|
||||
const data: { success?: boolean; forumId?: number } = await res.json()
|
||||
if (data.success && action === 'delete' && data.forumId) {
|
||||
router.push(`/forum/${data.forumId}`)
|
||||
} else {
|
||||
router.refresh()
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap items-center gap-2 rounded-lg border border-nw-border/60 bg-nw-panel-2/40 px-3 py-2 text-sm">
|
||||
<span className="mr-1 font-semibold text-nw-gold-light">{t('moderation')}:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => act(locked ? 'unlock' : 'lock')}
|
||||
disabled={busy}
|
||||
className="nw-btn-ghost text-xs disabled:opacity-60"
|
||||
>
|
||||
{locked ? t('unlock') : t('lock')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => act(sticky ? 'unsticky' : 'sticky')}
|
||||
disabled={busy}
|
||||
className="nw-btn-ghost text-xs disabled:opacity-60"
|
||||
>
|
||||
{sticky ? t('unpin') : t('pin')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => act('delete', t('confirmDeleteTopic'))}
|
||||
disabled={busy}
|
||||
className="text-xs text-red-400 hover:text-red-300 disabled:opacity-60"
|
||||
>
|
||||
{t('deleteTopic')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { RowDataPacket } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
import type { SessionData } from './session'
|
||||
|
||||
/** Nivel GM mínimo para moderar el foro (equivale a FORUM_MOD_GMLEVEL en Django). */
|
||||
const MOD_GMLEVEL = Number(process.env.FORUM_MOD_GMLEVEL || 2)
|
||||
|
||||
/** Nivel GM máximo de una cuenta (acore_auth.account_access). 0 si no tiene. */
|
||||
export async function getGmlevel(accountId: number | undefined): Promise<number> {
|
||||
if (!accountId) return 0
|
||||
try {
|
||||
const [rows] = await db(DB.auth).query<RowDataPacket[]>(
|
||||
'SELECT MAX(gmlevel) AS lvl FROM account_access WHERE id = ?',
|
||||
[accountId],
|
||||
)
|
||||
return rows[0] && rows[0].lvl != null ? Number(rows[0].lvl) : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** ¿El usuario puede moderar el foro? */
|
||||
export async function forumIsModerator(session: SessionData): Promise<boolean> {
|
||||
if (!session.accountId) return false
|
||||
return (await getGmlevel(session.accountId)) >= MOD_GMLEVEL
|
||||
}
|
||||
|
||||
/** Puede editar/borrar un post: su autor o un moderador. */
|
||||
export function canEditPost(
|
||||
session: SessionData,
|
||||
isMod: boolean,
|
||||
posterId: number | null | undefined,
|
||||
): boolean {
|
||||
if (!session.accountId) return false
|
||||
if (posterId != null && Number(posterId) === Number(session.accountId)) return true
|
||||
return isMod
|
||||
}
|
||||
@@ -26,3 +26,11 @@ export function cleanPostHtml(html: string): string {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Longitud del texto plano (sin etiquetas) — para validar longitud mínima. */
|
||||
export function plainLength(html: string): number {
|
||||
return sanitizeHtml(html || '', { allowedTags: [], allowedAttributes: {} })
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim().length
|
||||
}
|
||||
|
||||
@@ -38,6 +38,39 @@ export async function createPost(
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
/** Datos mínimos de un post para comprobar permisos (autor + tema). */
|
||||
export async function getPost(
|
||||
postId: number,
|
||||
): Promise<{ id: number; topic_id: number; poster_id: number | null; text: string } | null> {
|
||||
const [rows] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT id, topic_id, poster_id, text FROM forum_posts WHERE id = ? AND deleted = 0',
|
||||
[postId],
|
||||
)
|
||||
const r = rows[0]
|
||||
return r ? { id: r.id, topic_id: r.topic_id, poster_id: r.poster_id ?? null, text: r.text } : null
|
||||
}
|
||||
|
||||
/** Edita el texto de un post (ya saneado por el llamador). */
|
||||
export async function updatePost(postId: number, text: string): Promise<void> {
|
||||
const clean = cleanPostHtml(text)
|
||||
await db(DB.web).query('UPDATE forum_posts SET text = ?, updated_at = NOW() WHERE id = ?', [clean, postId])
|
||||
}
|
||||
|
||||
/** Marca/desmarca un post como borrado (borrado lógico). */
|
||||
export async function setPostDeleted(postId: number, deleted: boolean): Promise<void> {
|
||||
await db(DB.web).query('UPDATE forum_posts SET deleted = ? WHERE id = ?', [deleted ? 1 : 0, postId])
|
||||
}
|
||||
|
||||
/** Cambia una bandera del tema (sticky/locked/deleted). Solo moderación. */
|
||||
export async function setTopicFlag(
|
||||
topicId: number,
|
||||
field: 'sticky' | 'locked' | 'deleted',
|
||||
value: boolean,
|
||||
): Promise<void> {
|
||||
if (!['sticky', 'locked', 'deleted'].includes(field)) throw new Error('campo no permitido')
|
||||
await db(DB.web).query(`UPDATE forum_topics SET ${field} = ? WHERE id = ?`, [value ? 1 : 0, topicId])
|
||||
}
|
||||
|
||||
/** Comprueba que un foro existe y es visible (para crear tema). */
|
||||
export async function forumIsPostable(forumId: number): Promise<boolean> {
|
||||
const [rows] = await db(DB.web).query<RowDataPacket[]>(
|
||||
|
||||
+84
-4
@@ -33,8 +33,28 @@ export interface Topic {
|
||||
export interface Post {
|
||||
id: number
|
||||
poster: string
|
||||
poster_id: number | null
|
||||
text: string
|
||||
time: string | null
|
||||
edited: boolean
|
||||
}
|
||||
|
||||
export interface TopicFull {
|
||||
id: number
|
||||
name: string
|
||||
forum_id: number
|
||||
poster_id: number | null
|
||||
locked: boolean
|
||||
sticky: boolean
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: number
|
||||
name: string
|
||||
poster: string
|
||||
forum_id: number
|
||||
forum_name: string
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
/** Índice del foro: categorías con sus foros visibles (+ contadores y último tema). */
|
||||
@@ -104,13 +124,23 @@ export async function getTopics(forumId: number): Promise<Topic[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTopic(id: number): Promise<{ name: string; locked: boolean } | null> {
|
||||
export async function getTopic(id: number): Promise<TopicFull | null> {
|
||||
try {
|
||||
const [rows] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT name, locked FROM forum_topics WHERE id = ? AND deleted = 0',
|
||||
'SELECT id, name, forum_id, poster_id, locked, sticky FROM forum_topics WHERE id = ? AND deleted = 0',
|
||||
[id],
|
||||
)
|
||||
return rows[0] ? { name: rows[0].name, locked: rows[0].locked === 1 } : null
|
||||
const r = rows[0]
|
||||
return r
|
||||
? {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
forum_id: r.forum_id,
|
||||
poster_id: r.poster_id ?? null,
|
||||
locked: r.locked === 1,
|
||||
sticky: r.sticky === 1,
|
||||
}
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -119,14 +149,64 @@ export async function getTopic(id: number): Promise<{ name: string; locked: bool
|
||||
export async function getPosts(topicId: number): Promise<Post[]> {
|
||||
try {
|
||||
const [rows] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT id, poster, text, time FROM forum_posts WHERE topic_id = ? AND deleted = 0 ORDER BY time ASC, id ASC',
|
||||
'SELECT id, poster, poster_id, text, time, created_at, updated_at FROM forum_posts WHERE topic_id = ? AND deleted = 0 ORDER BY time ASC, id ASC',
|
||||
[topicId],
|
||||
)
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
poster: r.poster,
|
||||
poster_id: r.poster_id ?? null,
|
||||
text: r.text,
|
||||
time: r.time ? new Date(r.time).toISOString() : null,
|
||||
// editado si updated_at es posterior (más de 2s) al alta del post
|
||||
edited: Boolean(
|
||||
r.updated_at && r.created_at && new Date(r.updated_at).getTime() - new Date(r.created_at).getTime() > 2000,
|
||||
),
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Nº de temas que coinciden con la búsqueda (foros visibles). */
|
||||
export async function countSearchTopics(query: string): Promise<number> {
|
||||
const like = `%${query}%`
|
||||
try {
|
||||
const [rows] = await db(DB.web).query<RowDataPacket[]>(
|
||||
`SELECT COUNT(DISTINCT t.id) AS n
|
||||
FROM forum_topics t
|
||||
JOIN forums f ON f.id = t.forum_id AND f.visibility = 1 AND f.deleted = 0
|
||||
LEFT JOIN forum_posts p ON p.topic_id = t.id AND p.deleted = 0
|
||||
WHERE t.deleted = 0 AND (t.name LIKE ? OR p.text LIKE ?)`,
|
||||
[like, like],
|
||||
)
|
||||
return Number(rows[0]?.n ?? 0)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Busca temas por título o contenido de sus posts (solo foros visibles). */
|
||||
export async function searchTopics(query: string, offset = 0, limit = 20): Promise<SearchResult[]> {
|
||||
const like = `%${query}%`
|
||||
try {
|
||||
const [rows] = await db(DB.web).query<RowDataPacket[]>(
|
||||
`SELECT DISTINCT t.id, t.name, t.poster, t.forum_id, t.updated_at, f.name AS forum_name
|
||||
FROM forum_topics t
|
||||
JOIN forums f ON f.id = t.forum_id AND f.visibility = 1 AND f.deleted = 0
|
||||
LEFT JOIN forum_posts p ON p.topic_id = t.id AND p.deleted = 0
|
||||
WHERE t.deleted = 0 AND (t.name LIKE ? OR p.text LIKE ?)
|
||||
ORDER BY t.updated_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[like, like, limit, offset],
|
||||
)
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
poster: r.poster,
|
||||
forum_id: r.forum_id,
|
||||
forum_name: r.forum_name,
|
||||
updated_at: r.updated_at ? new Date(r.updated_at).toISOString() : null,
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
|
||||
@@ -268,7 +268,29 @@
|
||||
"sending": "Sending…",
|
||||
"loginToPost": "Sign in to participate.",
|
||||
"emptyError": "Fill in all fields.",
|
||||
"genericError": "Something went wrong. Please try again later."
|
||||
"genericError": "Something went wrong. Please try again later.",
|
||||
"moderation": "Moderation",
|
||||
"lock": "Lock",
|
||||
"unlock": "Unlock",
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
"pinned": "Pinned",
|
||||
"deleteTopic": "Delete topic",
|
||||
"confirmDeleteTopic": "Are you sure you want to delete this topic?",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"confirmDeletePost": "Are you sure you want to delete this post?",
|
||||
"tooShort": "The message is too short (minimum 5 characters).",
|
||||
"topicLocked": "This topic is locked.",
|
||||
"editedMark": "edited",
|
||||
"search": "Search",
|
||||
"searchPlaceholder": "Search the forum…",
|
||||
"searchHint": "Type at least 2 characters to search.",
|
||||
"noResults": "No results for “{query}”.",
|
||||
"resultsCount": "{count} result(s) for “{query}”.",
|
||||
"in": "in"
|
||||
},
|
||||
"Admin": {
|
||||
"title": "Admin panel",
|
||||
|
||||
@@ -268,7 +268,29 @@
|
||||
"sending": "Enviando…",
|
||||
"loginToPost": "Inicia sesión para participar.",
|
||||
"emptyError": "Completa todos los campos.",
|
||||
"genericError": "Algo ha salido mal. Inténtalo más tarde."
|
||||
"genericError": "Algo ha salido mal. Inténtalo más tarde.",
|
||||
"moderation": "Moderación",
|
||||
"lock": "Bloquear",
|
||||
"unlock": "Desbloquear",
|
||||
"pin": "Fijar",
|
||||
"unpin": "No fijar",
|
||||
"pinned": "Fijado",
|
||||
"deleteTopic": "Borrar tema",
|
||||
"confirmDeleteTopic": "¿Seguro que quieres borrar este tema?",
|
||||
"edit": "Editar",
|
||||
"delete": "Borrar",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"confirmDeletePost": "¿Seguro que quieres borrar este mensaje?",
|
||||
"tooShort": "El mensaje es demasiado corto (mínimo 5 caracteres).",
|
||||
"topicLocked": "El tema está bloqueado.",
|
||||
"editedMark": "editado",
|
||||
"search": "Buscar",
|
||||
"searchPlaceholder": "Buscar en el foro…",
|
||||
"searchHint": "Escribe al menos 2 caracteres para buscar.",
|
||||
"noResults": "No hay resultados para «{query}».",
|
||||
"resultsCount": "{count} resultado(s) para «{query}».",
|
||||
"in": "en"
|
||||
},
|
||||
"Admin": {
|
||||
"title": "Panel de administración",
|
||||
|
||||
Reference in New Issue
Block a user