diff --git a/web-next/app/[locale]/forum/page.tsx b/web-next/app/[locale]/forum/page.tsx
index 5ec7bc5..f566db5 100644
--- a/web-next/app/[locale]/forum/page.tsx
+++ b/web-next/app/[locale]/forum/page.tsx
@@ -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 (
{t('title')}
+
{categories.length === 0 ? (
{t('noForums')}
) : (
diff --git a/web-next/app/[locale]/forum/search/page.tsx b/web-next/app/[locale]/forum/search/page.tsx
new file mode 100644
index 0000000..f2cd5aa
--- /dev/null
+++ b/web-next/app/[locale]/forum/search/page.tsx
@@ -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 (
+
+
+
+ ← {t('backToForum')}
+
+
+ {t('search')}
+
+
+ {!valid ? (
+ {t('searchHint')}
+ ) : results.length === 0 ? (
+ {t('noResults', { query })}
+ ) : (
+ <>
+ {t('resultsCount', { count: total, query })}
+
+ {results.map((r) => (
+ -
+
+
+ {r.name}
+
+
+ {t('in')}{' '}
+
+ {r.forum_name}
+ {' '}
+ · {t('by')} {r.poster}
+
+
+ {r.updated_at && (
+
+ {new Date(r.updated_at).toLocaleDateString(locale)}
+
+ )}
+
+ ))}
+
+ >
+ )}
+
+ )
+}
diff --git a/web-next/app/[locale]/forum/topic/[topicId]/page.tsx b/web-next/app/[locale]/forum/topic/[topicId]/page.tsx
index 8da0af3..5db70ea 100644
--- a/web-next/app/[locale]/forum/topic/[topicId]/page.tsx
+++ b/web-next/app/[locale]/forum/topic/[topicId]/page.tsx
@@ -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 (
@@ -30,24 +34,33 @@ export default async function TopicPage({
← {t('backToForum')}
-
+
+ {topic!.sticky && [{t('pinned')}]}
{topic!.locked && [{t('locked')}]}
{topic!.name}
+ {isMod && }
+
{posts.map((post) => (
{post.poster}
{post.time && (
- {new Date(post.time).toLocaleString(locale)}
+
+ {new Date(post.time).toLocaleString(locale)}
+ {post.edited && · {t('editedMark')}}
+
)}
+ {canEditPost(session, isMod, post.poster_id) && (
+
+ )}
))}
diff --git a/web-next/app/api/forum/moderate/route.ts b/web-next/app/api/forum/moderate/route.ts
new file mode 100644
index 0000000..f742eee
--- /dev/null
+++ b/web-next/app/api/forum/moderate/route.ts
@@ -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 = {}
+ 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 })
+}
diff --git a/web-next/app/api/forum/post/route.ts b/web-next/app/api/forum/post/route.ts
new file mode 100644
index 0000000..a160520
--- /dev/null
+++ b/web-next/app/api/forum/post/route.ts
@@ -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 = {}
+ 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 = {}
+ 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 })
+}
diff --git a/web-next/components/ForumSearchBox.tsx b/web-next/components/ForumSearchBox.tsx
new file mode 100644
index 0000000..17a53cb
--- /dev/null
+++ b/web-next/components/ForumSearchBox.tsx
@@ -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 (
+
+ )
+}
diff --git a/web-next/components/PostActions.tsx b/web-next/components/PostActions.tsx
new file mode 100644
index 0000000..0971e9b
--- /dev/null
+++ b/web-next/components/PostActions.tsx
@@ -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(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 (
+
+ )
+ }
+
+ return (
+
+
+
+ {error && {error}}
+
+ )
+}
diff --git a/web-next/components/TopicModBar.tsx b/web-next/components/TopicModBar.tsx
new file mode 100644
index 0000000..eb155af
--- /dev/null
+++ b/web-next/components/TopicModBar.tsx
@@ -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 (
+
+ {t('moderation')}:
+
+
+
+
+ )
+}
diff --git a/web-next/lib/forum-perm.ts b/web-next/lib/forum-perm.ts
new file mode 100644
index 0000000..c50f91f
--- /dev/null
+++ b/web-next/lib/forum-perm.ts
@@ -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 {
+ if (!accountId) return 0
+ try {
+ const [rows] = await db(DB.auth).query(
+ '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 {
+ 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
+}
diff --git a/web-next/lib/forum-sanitize.ts b/web-next/lib/forum-sanitize.ts
index 68ccdb3..b2d822c 100644
--- a/web-next/lib/forum-sanitize.ts
+++ b/web-next/lib/forum-sanitize.ts
@@ -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
+}
diff --git a/web-next/lib/forum-write.ts b/web-next/lib/forum-write.ts
index 0d4bb89..6440154 100644
--- a/web-next/lib/forum-write.ts
+++ b/web-next/lib/forum-write.ts
@@ -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(
+ '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 {
+ 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 {
+ 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 {
+ 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 {
const [rows] = await db(DB.web).query(
diff --git a/web-next/lib/forum.ts b/web-next/lib/forum.ts
index 7c30135..6d831b5 100644
--- a/web-next/lib/forum.ts
+++ b/web-next/lib/forum.ts
@@ -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 {
}
}
-export async function getTopic(id: number): Promise<{ name: string; locked: boolean } | null> {
+export async function getTopic(id: number): Promise {
try {
const [rows] = await db(DB.web).query(
- '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 {
try {
const [rows] = await db(DB.web).query(
- '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 {
+ const like = `%${query}%`
+ try {
+ const [rows] = await db(DB.web).query(
+ `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 {
+ const like = `%${query}%`
+ try {
+ const [rows] = await db(DB.web).query(
+ `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 []
diff --git a/web-next/messages/en.json b/web-next/messages/en.json
index 60a5d42..476819c 100644
--- a/web-next/messages/en.json
+++ b/web-next/messages/en.json
@@ -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",
diff --git a/web-next/messages/es.json b/web-next/messages/es.json
index ecc8830..1b38832 100644
--- a/web-next/messages/es.json
+++ b/web-next/messages/es.json
@@ -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",