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 ( +
+ setQ(e.target.value)} + placeholder={t('searchPlaceholder')} + className="nw-input flex-1" + /> + +
+ ) +} 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 ( +
+