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:
2026-07-13 00:28:39 +00:00
parent 13636d097a
commit 1de1002938
14 changed files with 590 additions and 9 deletions
+2
View File
@@ -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>
+43
View File
@@ -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 })
}
+55
View File
@@ -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 })
}
+33
View File
@@ -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>
)
}
+93
View File
@@ -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>
)
}
+71
View File
@@ -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>
)
}
+37
View File
@@ -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
}
+8
View File
@@ -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(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim().length
}
+33
View File
@@ -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
View File
@@ -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 []
+23 -1
View File
@@ -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",
+23 -1
View File
@@ -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",