diff --git a/web-next/.gitignore b/web-next/.gitignore index 5ef6a52..bde9500 100644 --- a/web-next/.gitignore +++ b/web-next/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# TinyMCE self-hosted (copiado en postinstall desde node_modules) +/public/tinymce/ diff --git a/web-next/app/[locale]/forum/[forumId]/page.tsx b/web-next/app/[locale]/forum/[forumId]/page.tsx index fdb06af..1727a41 100644 --- a/web-next/app/[locale]/forum/[forumId]/page.tsx +++ b/web-next/app/[locale]/forum/[forumId]/page.tsx @@ -1,17 +1,19 @@ import { notFound } from 'next/navigation' import { getTranslations, setRequestLocale } from 'next-intl/server' import { Link } from '@/i18n/navigation' -import { getForum, getTopics, countTopics } from '@/lib/forum' +import { getForum, getForumPath, countTopics, getTopics, resolvePosters } from '@/lib/forum' +import { formatForumDate } from '@/lib/forum-format' import { getSession } from '@/lib/session' +import { forumIsModerator } from '@/lib/forum-perm' import { PageShell } from '@/components/PageShell' -import { NewTopicForm } from '@/components/NewTopicForm' +import { ForumBreadcrumb } from '@/components/ForumBreadcrumb' import { Pagination } from '@/components/Pagination' export const dynamic = 'force-dynamic' -const PER_PAGE = 20 +const PER_PAGE = 10 -export default async function ForumPage({ +export default async function SubforumPage({ params, searchParams, }: { @@ -23,58 +25,93 @@ export default async function ForumPage({ const t = await getTranslations('Forum') const id = Number(forumId) - const forum = await getForum(id, locale) + const forum = await getForum(id) if (!forum) notFound() + + const session = await getSession() + const isMod = await forumIsModerator(session) + const canCreate = Boolean(session.username) + const total = await countTopics(id) const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)) const page = Math.min(Math.max(1, Number((await searchParams).page) || 1), totalPages) - const topics = await getTopics(id, (page - 1) * PER_PAGE, PER_PAGE) - const session = await getSession() - const canPost = Boolean(session.username) + const topics = await getTopics(id, (page - 1) * PER_PAGE, PER_PAGE, isMod) + + const posters = await resolvePosters( + topics.flatMap((tp) => [tp.poster, tp.last_poster].filter(Boolean) as number[]), + ) + const name = (pid: number | null) => (pid != null ? posters.get(pid)?.name ?? `#${pid}` : '') + + const path = await getForumPath(id) + const hrefFor = (p: number) => `/forum/${id}?page=${p}` return ( - -
-
-

- ← {t('backToForum')} -

- {forum!.description &&

{forum!.description}

} -
+ +
+ {path && } +
+
+
+

{forum.name}

+ {forum.description &&

{forum.description}

} +
+

+ {total} {t('topics')} +

+
+ +
+ {canCreate ? ( + + {t('createTopic')} + + ) : ( + + )} + +
{topics.length === 0 ? ( -

{t('noTopics')}

+

{t('noTopics')}

) : ( - - - {topics.map((topic) => ( - - - - - ))} - -
- - {topic.sticky && [{t('sticky')}] } - {topic.locked && [{t('locked')}] } - {topic.name} - -

- {t('by')} {topic.poster} -

-
- {topic.views} {t('views')} -
+ topics.map((tp) => ( +
    +
  • + +
  • +
  • +

    + + {tp.deleted && `[${t('deleted')}] `} + {tp.sticky && `[${t('sticky')}] `} + {tp.locked && `[${t('locked')}] `} + {tp.name} + +

    +

    + {t('createdBy')} {name(tp.poster)},{' '} + {formatForumDate(tp.created, locale)} +

    +
  • +
  • +

    + {t('by')} {name(tp.last_poster)} +

    +
    {formatForumDate(tp.time_updated, locale)}
    +
  • +
+ )) )} - `/forum/${id}?page=${p}`} /> - - {canPost ? ( - - ) : ( -

{t('loginToPost')}

- )} +
+ + +
diff --git a/web-next/app/[locale]/forum/create/[forumId]/page.tsx b/web-next/app/[locale]/forum/create/[forumId]/page.tsx new file mode 100644 index 0000000..1534a5e --- /dev/null +++ b/web-next/app/[locale]/forum/create/[forumId]/page.tsx @@ -0,0 +1,51 @@ +import { notFound, redirect } from 'next/navigation' +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { getForum, getForumPath } from '@/lib/forum' +import { getSession } from '@/lib/session' +import { PageShell } from '@/components/PageShell' +import { ForumBreadcrumb } from '@/components/ForumBreadcrumb' +import { CreateTopicForm } from '@/components/CreateTopicForm' + +export const dynamic = 'force-dynamic' + +export default async function CreateTopicPage({ + params, +}: { + params: Promise<{ locale: string; forumId: string }> +}) { + const { locale, forumId } = await params + setRequestLocale(locale) + const t = await getTranslations('Forum') + + const id = Number(forumId) + const forum = await getForum(id) + if (!forum) notFound() + + const session = await getSession() + if (!session.username) redirect(`/${locale}/forum/${id}`) + + const path = await getForumPath(id) + + return ( + +
+ {path && ( + + )} +
+
+

{forum.name}

+ {forum.description &&

{forum.description}

} +
+
+ +
+
+ ) +} diff --git a/web-next/app/[locale]/forum/edit/[postId]/page.tsx b/web-next/app/[locale]/forum/edit/[postId]/page.tsx new file mode 100644 index 0000000..930fd14 --- /dev/null +++ b/web-next/app/[locale]/forum/edit/[postId]/page.tsx @@ -0,0 +1,58 @@ +import { notFound, redirect } from 'next/navigation' +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { getPost } from '@/lib/forum-write' +import { getTopic, getTopicPath } from '@/lib/forum' +import { getSession } from '@/lib/session' +import { forumIsModerator, canEditPost } from '@/lib/forum-perm' +import { PageShell } from '@/components/PageShell' +import { ForumBreadcrumb } from '@/components/ForumBreadcrumb' +import { EditPostPageForm } from '@/components/EditPostPageForm' + +export const dynamic = 'force-dynamic' + +export default async function EditPostPage({ + params, +}: { + params: Promise<{ locale: string; postId: string }> +}) { + const { locale, postId } = await params + setRequestLocale(locale) + const t = await getTranslations('Forum') + + const id = Number(postId) + const post = await getPost(id) + if (!post) notFound() + + const session = await getSession() + if (!session.username) redirect(`/${locale}/forum/topic/${post.topic}`) + + const isMod = await forumIsModerator(session) + if (!canEditPost(session, isMod, post.poster)) redirect(`/${locale}/forum/topic/${post.topic}`) + + const topic = await getTopic(post.topic, true) + const path = await getTopicPath(post.topic) + + return ( + +
+ {path && ( + + )} +
+
+

{topic?.name}

+ {path &&

{path.forum}

} +
+
+ +
+
+ ) +} diff --git a/web-next/app/[locale]/forum/layout.tsx b/web-next/app/[locale]/forum/layout.tsx new file mode 100644 index 0000000..c168496 --- /dev/null +++ b/web-next/app/[locale]/forum/layout.tsx @@ -0,0 +1,6 @@ +import '@/app/forum.css' + +// Carga el CSS del foro solo bajo /forum (todas sus subrutas heredan este layout). +export default function ForumLayout({ children }: { children: React.ReactNode }) { + return children +} diff --git a/web-next/app/[locale]/forum/page.tsx b/web-next/app/[locale]/forum/page.tsx index 16da9d5..9c07086 100644 --- a/web-next/app/[locale]/forum/page.tsx +++ b/web-next/app/[locale]/forum/page.tsx @@ -1,8 +1,8 @@ import { getTranslations, setRequestLocale } from 'next-intl/server' import { Link } from '@/i18n/navigation' -import { getForumIndex } from '@/lib/forum' +import { getForumIndex, resolvePosters, type ForumRow } from '@/lib/forum' +import { formatForumDate } from '@/lib/forum-format' import { PageShell } from '@/components/PageShell' -import { ForumSearchBox } from '@/components/ForumSearchBox' export const dynamic = 'force-dynamic' @@ -10,53 +10,121 @@ export default async function ForumIndexPage({ params }: { params: Promise<{ loc const { locale } = await params setRequestLocale(locale) const t = await getTranslations('Forum') - const categories = await getForumIndex(locale) + const categories = await getForumIndex() + + // Nombres de los últimos posteadores de todos los foros, en una sola consulta. + const posterIds = categories.flatMap((c) => c.forums.map((f) => f.last_poster).filter(Boolean) as number[]) + const posters = await resolvePosters(posterIds) + + function topicsLabel(n: number) { + return `${n} ${n === 1 ? t('topic') : t('topics')}` + } + + function NormalRow({ f }: { f: ForumRow }) { + const iconSrc = f.type === 3 ? f.icon || '' : `/forum/forum_icons/${f.icon || 'forum_read'}.png` + return ( +
+
+ + {f.name} + +
+
+ +

{f.name}

+ {f.description &&

{f.description}

} + +
+
+

{f.post_count}

+
+
+

{f.topic_count}

+
+ {f.last_topic_id && ( +
+

+ {f.last_topic_name} +

+ {f.last_poster != null && ( +

{posters.get(f.last_poster)?.name ?? `#${f.last_poster}`}

+ )} +

{formatForumDate(f.last_time, locale)}

+
+ )} +
+ ) + } + + function ClassTile({ f }: { f: ForumRow }) { + return ( +
+
+ +
+ +
+
+ +

{f.name}

+

{topicsLabel(f.topic_count)}

+ +
+
+ ) + } + + function FlagTile({ f }: { f: ForumRow }) { + return ( +
+
+ + {f.name} + +
+
+ +

{f.name}

+

{topicsLabel(f.topic_count)}

+ +
+
+ ) + } return ( -
-
- - {categories.length === 0 ? ( -

{t('noForums')}

- ) : ( - categories.map((cat) => ( -
-
-

{cat.name}

+
+ {categories.length === 0 ? ( +

{t('noForums')}

+ ) : ( + categories.map((cat) => { + const tiles = cat.forums.filter((f) => f.type === 1 || f.type === 2) + const rows = cat.forums.filter((f) => f.type !== 1 && f.type !== 2) + return ( +
+
{cat.name}
+ {tiles.length > 0 && ( +
+ {tiles.map((f) => (f.type === 1 ? : ))} +
+ )} +
+ {rows.map((f) => ( + + ))}
- - - {cat.forums.map((f) => ( - - - - - ))} - -
- - {f.name} - - {f.description &&

{f.description}

} -
- - {f.topic_count} {t('topics')} · {f.post_count} {t('posts')} - - {f.last_topic_id && ( -

- {f.last_topic_name}{' '} - - {t('by')} {f.last_topic_poster} - -

- )} -
-
- )) - )} -
+ ) + }) + )}
) diff --git a/web-next/app/[locale]/forum/search/page.tsx b/web-next/app/[locale]/forum/search/page.tsx deleted file mode 100644 index fde6e6e..0000000 --- a/web-next/app/[locale]/forum/search/page.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { getTranslations, setRequestLocale } from 'next-intl/server' -import { Link } from '@/i18n/navigation' -import { searchTopics, countSearchTopics } from '@/lib/forum' -import { PageShell } from '@/components/PageShell' -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')} -

-
- - - {!valid ? ( -

{t('searchHint')}

- ) : results.length === 0 ? ( -

{t('noResults', { query })}

- ) : ( - <> -

{t('resultsCount', { count: total, query })}

- - - {results.map((r) => ( - - - {r.updated_at && ( - - )} - - ))} - -
- - {r.name} - -

- {t('in')} {r.forum_name} · {t('by')} {r.poster} -

-
- {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 7fd4181..827c18b 100644 --- a/web-next/app/[locale]/forum/topic/[topicId]/page.tsx +++ b/web-next/app/[locale]/forum/topic/[topicId]/page.tsx @@ -1,18 +1,26 @@ import { notFound } from 'next/navigation' import { getTranslations, setRequestLocale } from 'next-intl/server' -import { Link } from '@/i18n/navigation' -import { getTopic, getPosts, countPosts, listForumsForMove } from '@/lib/forum' +import { + getTopic, + getTopicPath, + getPosts, + countPosts, + resolvePosters, + getUserPostCount, +} from '@/lib/forum' +import { formatForumDate } from '@/lib/forum-format' import { getSession } from '@/lib/session' import { forumIsModerator, canEditPost } from '@/lib/forum-perm' import { PageShell } from '@/components/PageShell' -import { ReplyForm } from '@/components/ReplyForm' -import { PostActions } from '@/components/PostActions' -import { TopicModBar } from '@/components/TopicModBar' +import { ForumBreadcrumb } from '@/components/ForumBreadcrumb' import { Pagination } from '@/components/Pagination' +import { ReplyForm } from '@/components/ReplyForm' +import { EditPostForm } from '@/components/EditPostForm' +import { ForumModActions } from '@/components/ForumModActions' export const dynamic = 'force-dynamic' -const PER_PAGE = 15 +const PER_PAGE = 5 export default async function TopicPage({ params, @@ -30,77 +38,136 @@ export default async function TopicPage({ const isMod = await forumIsModerator(session) const topic = await getTopic(id, isMod) if (!topic) notFound() + const total = await countPosts(id, isMod) const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)) const page = Math.min(Math.max(1, Number((await searchParams).page) || 1), totalPages) const posts = await getPosts(id, (page - 1) * PER_PAGE, PER_PAGE, isMod) - const moveForums = isMod ? (await listForumsForMove()).filter((f) => f.id !== topic!.forum_id) : [] - const canReply = Boolean(session.username) && (!topic!.locked || isMod) - const title = ( - <> - {topic!.sticky && [{t('pinned')}] } - {topic!.locked && [{t('locked')}] } - {topic!.deleted && [{t('deletedMark')}] } - {topic!.name} - + const posters = await resolvePosters(posts.map((p) => p.poster)) + // Nº de posts por autor visible (para el panel lateral), en paralelo. + const uniquePosters = [...new Set(posts.map((p) => p.poster))] + const counts = new Map( + await Promise.all(uniquePosters.map(async (pid) => [pid, await getUserPostCount(pid)] as const)), ) + const path = await getTopicPath(id) + const canReply = Boolean(session.username) && (!topic.locked || isMod) + return ( - -
-
-

- ← {t('backToForum')} -

-
+ + {topic.sticky && `[${t('sticky')}] `} + {topic.locked && `[${t('locked')}] `} + {topic.deleted && `[${t('deleted')}] `} + {topic.name} + + } + > +
+ {path && ( + + )} - {isMod && ( - - )} - - {posts.map((post) => ( -
-
- - {post.poster} - {post.deleted && [{t('deletedMark')}]} - - {post.time && ( - - {new Date(post.time).toLocaleString(locale)} - {post.edited && · {t('editedMark')}} - - )} -
-
-
- {canEditPost(session, isMod, post.poster_id) && ( - - )} -
- ))} - - `/forum/topic/${id}?page=${p}`} /> - - {canReply ? ( - page === totalPages ? ( - - ) : ( -

- {t('replyOnLastPage')} -

- ) - ) : ( - !session.username &&

{t('loginToPost')}

- )} +
+
+

{topic.name}

+

{formatForumDate(topic.created, locale)}

+
+

+ {total} {t('posts')} +

+ +
+ + `/forum/topic/${id}?page=${p}`} /> +
+ + {posts.map((post) => { + const author = posters.get(post.poster) + const isStaff = author?.isStaff ?? false + return ( +
+
+
+ {author?.name ?? `#${post.poster}`} +
+
+ +
+
+

+
+ +
+
{isStaff ? t('staff') : t('member')}
+

+

+
+ +
+
{formatForumDate(author?.joindate ?? null, locale) || '—'}
+

+

+
+ +
+
+ {t('posts')}: {counts.get(post.poster) ?? 0} +
+

+
+
+
+
+
    +
  • {formatForumDate(post.time, locale)}
  • + {canEditPost(session, isMod, post.poster) && ( +
  • + +
  • + )} + {isMod && ( +
  • + +
  • + )} +
+
+
+ ) + })} + +
+ + `/forum/topic/${id}?page=${p}`} /> +
+ + {isMod && ( +
+ +
+ )} + + {canReply ? ( + page === totalPages ? ( + + ) : ( +

+ {t('replyOnLastPage')} +

+ ) + ) : ( + !session.username &&

{t('loginToPost')}

+ )}
) diff --git a/web-next/app/[locale]/forum/user/[username]/page.tsx b/web-next/app/[locale]/forum/user/[username]/page.tsx deleted file mode 100644 index 8f4111f..0000000 --- a/web-next/app/[locale]/forum/user/[username]/page.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { getTranslations, setRequestLocale } from 'next-intl/server' -import { Link } from '@/i18n/navigation' -import { getForumProfile } from '@/lib/forum' -import { PageShell } from '@/components/PageShell' - -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)) - - const title = ( - <> - {p.username} - {p.status && ( - [{p.status === 'admin' ? t('roleAdmin') : t('roleGm')}] - )} - - ) - - return ( - -
-
-

- ← {t('backToForum')} -

-
- - - - - - - - -
- {t('posts')} -
- {p.postCount} -
- {t('topics')} -
- {p.topicCount} -
- {t('memberSince')} -
- - {p.joindate ? new Date(p.joindate).toLocaleDateString(locale) : '—'} - -
- - {p.recentTopics.length > 0 && ( - <> -
-

{t('recentTopics')}

-
- - - {p.recentTopics.map((topic) => ( - - - - ))} - -
- - {topic.name} - -
- - )} -
-
-
- ) -} diff --git a/web-next/app/api/admin/forum/[id]/route.ts b/web-next/app/api/admin/forum/[id]/route.ts index 86afb72..62f8bf7 100644 --- a/web-next/app/api/admin/forum/[id]/route.ts +++ b/web-next/app/api/admin/forum/[id]/route.ts @@ -1,23 +1,13 @@ import { getSession } from '@/lib/session' import { isAdmin } from '@/lib/admin' -import { deleteForum, setForumVisibility, updateForum } from '@/lib/admin-forum' +import { deleteForum, updateForum } from '@/lib/admin-forum' export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { const session = await getSession() if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 }) const { id } = await params - await deleteForum(Number(id)) - return Response.json({ success: true }) -} - -export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { - const session = await getSession() - if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 }) - const { id } = await params - let b: Record = {} - try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) } - await setForumVisibility(Number(id), b.visibility !== false) - return Response.json({ success: true }) + const ok = await deleteForum(Number(id)) + return Response.json({ success: ok, error: ok ? undefined : 'forumNotEmpty' }) } export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { @@ -25,14 +15,21 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 }) const { id } = await params let b: Record = {} - try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) } - const name = String(b.name ?? '').trim() - const nameEn = String(b.nameEn ?? '').trim() || null - const description = String(b.description ?? '').trim() - const descriptionEn = String(b.descriptionEn ?? '').trim() || null - const order = Number(b.order) || 0 - const visibility = b.visibility === false ? 0 : 1 - if (!name) return Response.json({ success: false, error: 'emptyError' }) - await updateForum(Number(id), name, nameEn, description, descriptionEn, order, visibility) + try { + b = await request.json() + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + const f = { + category: Number(b.category), + name: String(b.name ?? '').trim(), + description: String(b.description ?? '').trim(), + icon: String(b.icon ?? '').trim() || null, + colortitle: String(b.colortitle ?? '').trim() || null, + type: Number(b.type) || 0, + order: Number(b.order) || 0, + } + if (!f.category || !f.name) return Response.json({ success: false, error: 'emptyError' }) + await updateForum(Number(id), f) return Response.json({ success: true }) } diff --git a/web-next/app/api/admin/forum/category/[id]/route.ts b/web-next/app/api/admin/forum/category/[id]/route.ts index 0835306..9fe6b79 100644 --- a/web-next/app/api/admin/forum/category/[id]/route.ts +++ b/web-next/app/api/admin/forum/category/[id]/route.ts @@ -15,11 +15,14 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 }) const { id } = await params let b: Record = {} - try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) } + try { + b = await request.json() + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } const name = String(b.name ?? '').trim() - const nameEn = String(b.nameEn ?? '').trim() || null const order = Number(b.order) || 0 if (!name) return Response.json({ success: false, error: 'emptyError' }) - await updateCategory(Number(id), name, nameEn, order) + await updateCategory(Number(id), name, order) return Response.json({ success: true }) } diff --git a/web-next/app/api/admin/forum/category/route.ts b/web-next/app/api/admin/forum/category/route.ts index 167868f..40ef054 100644 --- a/web-next/app/api/admin/forum/category/route.ts +++ b/web-next/app/api/admin/forum/category/route.ts @@ -6,11 +6,14 @@ export async function POST(request: Request) { const session = await getSession() if (!(await isAdmin(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 }) } + try { + b = await request.json() + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } const name = String(b.name ?? '').trim() - const nameEn = String(b.nameEn ?? '').trim() || null const order = Number(b.order) || 0 if (!name) return Response.json({ success: false, error: 'emptyError' }) - const id = await createCategory(name, nameEn, order) + const id = await createCategory(name, order) return Response.json({ success: true, id }) } diff --git a/web-next/app/api/admin/forum/route.ts b/web-next/app/api/admin/forum/route.ts index 02dd1e2..98c2592 100644 --- a/web-next/app/api/admin/forum/route.ts +++ b/web-next/app/api/admin/forum/route.ts @@ -6,15 +6,21 @@ export async function POST(request: Request) { const session = await getSession() if (!(await isAdmin(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 categoryId = Number(b.categoryId) - const name = String(b.name ?? '').trim() - const nameEn = String(b.nameEn ?? '').trim() || null - const description = String(b.description ?? '').trim() - const descriptionEn = String(b.descriptionEn ?? '').trim() || null - const order = Number(b.order) || 0 - const visibility = b.visibility === false ? 0 : 1 - if (!categoryId || !name) return Response.json({ success: false, error: 'emptyError' }) - const id = await createForum(categoryId, name, nameEn, description, descriptionEn, order, visibility) + try { + b = await request.json() + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + const f = { + category: Number(b.category), + name: String(b.name ?? '').trim(), + description: String(b.description ?? '').trim(), + icon: String(b.icon ?? '').trim() || null, + colortitle: String(b.colortitle ?? '').trim() || null, + type: Number(b.type) || 0, + order: Number(b.order) || 0, + } + if (!f.category || !f.name) return Response.json({ success: false, error: 'emptyError' }) + const id = await createForum(f) return Response.json({ success: true, id }) } diff --git a/web-next/app/api/forum/moderate/route.ts b/web-next/app/api/forum/moderate/route.ts index 8b4a7dd..f34f7c4 100644 --- a/web-next/app/api/forum/moderate/route.ts +++ b/web-next/app/api/forum/moderate/route.ts @@ -1,55 +1,54 @@ import { getSession } from '@/lib/session' -import { getTopic, getForum } from '@/lib/forum' -import { setTopicFlag, moveTopic } from '@/lib/forum-write' +import { getTopic } from '@/lib/forum' +import { getPost, setTopicFlag, setPostDeleted } from '@/lib/forum-write' import { forumIsModerator } from '@/lib/forum-perm' -type Action = 'lock' | 'unlock' | 'sticky' | 'unsticky' | 'delete' | 'restore' | 'move' - -/** Moderación de temas: bloquear, fijar, borrar. Solo moderadores. */ +/** + * Moderación del foro (solo moderadores), por POST para no mutar con GET. + * kind='post' → action: delete | undelete + * kind='topic' → action: delete | undelete | lock | unlock + */ 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 }) + try { + b = await request.json() + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + const kind = String(b.kind ?? '') + const id = Number(b.id) + const action = String(b.action ?? '') + if (!id || (kind !== 'post' && kind !== 'topic')) + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) - const topic = await getTopic(topicId, true) // incluye borrados (ya verificado que es mod) + if (kind === 'post') { + const post = await getPost(id) + if (!post) return Response.json({ success: false, error: 'postNotFound' }, { status: 404 }) + if (action === 'delete') await setPostDeleted(id, true) + else if (action === 'undelete') await setPostDeleted(id, false) + else return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + return Response.json({ success: true }) + } + + const topic = await getTopic(id, true) if (!topic) return Response.json({ success: false, error: 'topicNotFound' }, { status: 404 }) - switch (action) { + case 'delete': + await setTopicFlag(id, 'deleted', true) + break + case 'undelete': + await setTopicFlag(id, 'deleted', false) + break case 'lock': - await setTopicFlag(topicId, 'locked', true) + await setTopicFlag(id, 'locked', true) break case 'unlock': - await setTopicFlag(topicId, 'locked', false) + await setTopicFlag(id, '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 }) - case 'restore': - await setTopicFlag(topicId, 'deleted', false) - break - case 'move': { - const newForumId = Number(b.forumId) - if (!newForumId || newForumId === topic.forum_id) { - return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) - } - if (!(await getForum(newForumId))) { - return Response.json({ success: false, error: 'invalidForum' }, { status: 400 }) - } - await moveTopic(topicId, newForumId) - return Response.json({ success: true }) - } default: return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) } diff --git a/web-next/app/api/forum/post/route.ts b/web-next/app/api/forum/post/route.ts index 49e1311..f57bbdd 100644 --- a/web-next/app/api/forum/post/route.ts +++ b/web-next/app/api/forum/post/route.ts @@ -1,25 +1,10 @@ import { getSession } from '@/lib/session' -import { getPost, updatePost, setPostDeleted } from '@/lib/forum-write' +import { getPost, updatePost } 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 - -/** Restaurar un post borrado. Solo moderadores. */ -export async function PUT(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 postId = Number(b.postId) - if (!postId) return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) - - await setPostDeleted(postId, false) - return Response.json({ success: true }) -} +const MIN_TEXT_LEN = 3 /** Editar el texto de un post propio (o cualquiera si es moderador). */ export async function PATCH(request: Request) { @@ -27,44 +12,26 @@ export async function PATCH(request: Request) { 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 }) } + 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() + const text = String(b.text ?? '') 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 }) + if (!canEditPost(session, isMod, post.poster)) + return Response.json({ success: false, error: 'forbidden' }, { status: 403 }) - const topic = await getTopic(post.topic_id) + const topic = await getTopic(post.topic) 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/app/api/forum/reply/route.ts b/web-next/app/api/forum/reply/route.ts index 04aef94..e747dd4 100644 --- a/web-next/app/api/forum/reply/route.ts +++ b/web-next/app/api/forum/reply/route.ts @@ -1,15 +1,21 @@ import { getSession } from '@/lib/session' import { createPost, topicIsReplyable } from '@/lib/forum-write' +import { plainLength } from '@/lib/forum-sanitize' export async function POST(request: Request) { const session = await getSession() - if (!session.accountId || !session.username) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 }) + if (!session.accountId || !session.username) + 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 }) } + try { + b = await request.json() + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } const topicId = Number(b.topicId) - const text = String(b.text ?? '').trim() - if (!topicId || !text) return Response.json({ success: false, error: 'emptyError' }) + const text = String(b.text ?? '') + if (!topicId || plainLength(text) < 3) return Response.json({ success: false, error: 'emptyError' }) if (!(await topicIsReplyable(topicId))) return Response.json({ success: false, error: 'topicLocked' }) - await createPost(topicId, session.username, session.accountId, text) + await createPost(topicId, session.accountId, text) return Response.json({ success: true }) } diff --git a/web-next/app/api/forum/topic/route.ts b/web-next/app/api/forum/topic/route.ts index 38b9353..8393021 100644 --- a/web-next/app/api/forum/topic/route.ts +++ b/web-next/app/api/forum/topic/route.ts @@ -1,16 +1,24 @@ import { getSession } from '@/lib/session' -import { createTopic, forumIsPostable } from '@/lib/forum-write' +import { createTopic } from '@/lib/forum-write' +import { forumExists } from '@/lib/forum' +import { plainLength } from '@/lib/forum-sanitize' export async function POST(request: Request) { const session = await getSession() - if (!session.accountId || !session.username) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 }) + if (!session.accountId || !session.username) + 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 }) } + try { + b = await request.json() + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } const forumId = Number(b.forumId) - const name = String(b.name ?? '').trim() - const text = String(b.text ?? '').trim() - if (!forumId || !name || !text) return Response.json({ success: false, error: 'emptyError' }) - if (!(await forumIsPostable(forumId))) return Response.json({ success: false, error: 'invalidForum' }) - const topicId = await createTopic(forumId, name, session.username, session.accountId, text) + const title = String(b.title ?? '').trim() + const text = String(b.text ?? '') + if (!forumId || title.length < 3) return Response.json({ success: false, error: 'errTitle' }) + if (plainLength(text) < 3) return Response.json({ success: false, error: 'emptyError' }) + if (!(await forumExists(forumId))) return Response.json({ success: false, error: 'invalidForum' }) + const topicId = await createTopic(forumId, title, session.accountId, text) return Response.json({ success: true, topicId }) } diff --git a/web-next/app/forum.css b/web-next/app/forum.css new file mode 100644 index 0000000..1bca9cd --- /dev/null +++ b/web-next/app/forum.css @@ -0,0 +1,242 @@ +/* + * Estilos del foro (adaptado del forum.css original de FusionCMS). + * Cambios respecto al original: + * - rutas de imagen ../images/ -> /forum/ (assets en public/forum, ver copia) + * - el dorado del foro se alinea al acento del sitio (#d79602) + * - se aportan .nice_button, .main-wide y .pagination (no están en theme.css) + * theme.css se importa DESPUÉS y podría pisar reglas: por eso el foro cuelga de + * contenedores propios (.forum-container, .forum-padding, .topic_post…). + */ + +:root { + --forum-gold: #d79602; + --forum-gold-soft: #c9a33c; +} + +/* ===================== index ===================== */ +.forum-container { margin: 0 40px; } + +.category-title { + display: block; + color: #8a8272; + font-size: 15px; + font-weight: bold; + text-shadow: 0 0 8px #000, 0 1px 1px #000; + text-transform: uppercase; + text-align: left; +} + +.forum-row { + width: 100%; + min-height: 75px; + display: flex; + align-items: center; + flex-wrap: wrap; + text-align: left; + border-radius: 3px; + overflow: hidden; + background: rgba(0, 0, 0, .18); + box-shadow: 0 0 11px rgba(0, 0, 0, .3), 0 1px 3px rgba(0, 0, 0, .5), inset 0 0 0 1px rgba(255, 255, 255, .03); + transition: all 200ms; +} +.forum-row:hover { box-shadow: 0 0 11px rgba(0, 0, 0, .3), 0 1px 3px rgba(0, 0, 0, .5), inset 0 0 0 1px rgba(255, 255, 255, .05); } + +.forum-row .icon img { margin: 6px 0 0 6px; } +.forum-row .forum_title_desc { text-align: left; flex: 1 1 220px; } +.forum-row .forum_title_desc a { display: block; } +.forum-row .forum_title_desc a h1 { + font-size: 14px; color: var(--forum-gold); font-weight: bold; margin: 8px 0 0 20px; transition: all 200ms; +} +.forum-row .forum_title_desc a:hover h1 { color: #f0b53a; } +.forum-row .forum_title_desc a h2 { font-size: 12px; color: #6d6a5e; font-weight: bold; margin: 4px 0 8px 20px; } + +.forum-row .post, .forum-row .topics { min-width: 78px; text-align: center; } +.forum-row .post p, .forum-row .topics p { font-size: 15px; color: #8a8578; font-weight: bold; margin: 24px 0; } + +.forum-row .lastpost { flex: 1 1 200px; padding-left: 12px; } +.forum-row .lastpost p.topic_title a { font-size: 12px; color: #8d8370; font-weight: bold; } +.forum-row .lastpost p.topic_title a:hover { color: var(--forum-gold); } +.forum-row .lastpost p.by { margin: 0 0 0 15px; } +.forum-row .lastpost p.by a { font-size: 12px; font-weight: bold; color: var(--forum-gold-soft); } +.forum-row .lastpost p.postdate { font-size: 11px; color: #6d6a5e; margin: 0 0 0 15px; } + +/* class tiles (type 1) & flag tiles (type 2) */ +.class-row { + display: flex; + align-items: center; + width: 100%; + min-height: 53px; + overflow: hidden; + background: rgba(0, 0, 0, .18); + box-shadow: 0 0 11px rgba(0, 0, 0, .3), 0 1px 3px rgba(0, 0, 0, .5), inset 0 0 0 1px rgba(255, 255, 255, .03); + border-radius: 4px; + transition: all 200ms; +} +.class-row:hover { box-shadow: 0 0 11px rgba(0, 0, 0, .3), 0 1px 3px rgba(0, 0, 0, .5), inset 0 0 0 1px rgba(255, 255, 255, .05); } +.class-row .icon { width: 36px; height: 36px; margin: 8px; flex: 0 0 auto; } +.class-row .icon img.image_icon { width: 36px; height: 36px; border-radius: 3px; box-shadow: 0 0 6px rgba(0, 0, 0, .4); } +.class-row .icon div.image_icon { + width: 36px; height: 36px; + background-image: url(/forum/forum_icons/icons.jpg); + background-repeat: no-repeat; + border-radius: 3px; + box-shadow: 0 0 6px rgba(0, 0, 0, .4); +} +.deathknight .icon div.image_icon { background-position: 0 0; } +.druid .icon div.image_icon { background-position: -36px 0; } +.hunter .icon div.image_icon { background-position: -72px 0; } +.mage .icon div.image_icon { background-position: -108px 0; } +.paladin .icon div.image_icon { background-position: -144px 0; } +.priest .icon div.image_icon { background-position: -180px 0; } +.rogue .icon div.image_icon { background-position: -216px 0; } +.shaman .icon div.image_icon { background-position: -252px 0; } +.warlock .icon div.image_icon { background-position: -288px 0; } +.warrior .icon div.image_icon { background-position: -324px 0; } +.monk .icon div.image_icon { background-position: -360px 0; } +.demonhunter .icon div.image_icon { background-position: -396px 0; } +.evoker .icon div.image_icon { background-position: -432px 0; } +.class-row .info { margin: 0 0 0 4px; text-align: left; } +.class-row .info a { display: block; } +.class-row .info a h1 { font-size: 13px; color: var(--forum-gold); } +.class-row .info a h2 { font-size: 11px; color: #6d6a5e; } + +.forum-path { margin-bottom: 10px; display: block; color: var(--forum-gold); font-weight: bold; } +.forum-path a { color: var(--forum-gold); opacity: .7; } +.forum-path a:hover { opacity: 1; } +.forum-path .sep { opacity: .4; margin: 0 6px; } + +/* ===================== subforum ===================== */ +.main-wide { max-width: 1100px; margin: 0 auto; padding: 0 15px; } +.forum-padding { margin: 0 30px; } + +.forum_header, .topic_header { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + margin: 20px 0; + padding-bottom: 12px; + box-shadow: 0 2px 0 rgba(0, 0, 0, .25); +} +.forum_title h1 { font-size: 24px; color: var(--forum-gold); } +.forum_title h3 { font-weight: bold; font-size: 13px; color: #6d6a5e; } +.forum_header h4, .topic_header h4 { font-size: 20px; color: #6d6a5e; font-weight: normal; } +.forum_header h4 b, .topic_header h4 b { color: var(--forum-gold-soft); } + +.actions_c { display: flex; justify-content: space-between; align-items: center; margin: 0 0 24px; flex-wrap: wrap; gap: 10px; } + +ul.pagination { display: flex; list-style: none; padding: 0; margin: 0; gap: 3px; flex-wrap: wrap; } +ul.pagination li a, ul.pagination li p { + display: block; min-width: 24px; height: 24px; line-height: 24px; padding: 0 6px; + background: rgba(241, 221, 196, .07); border-radius: 3px; text-align: center; color: #8a8578; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .02), 0 1px 1px rgba(0, 0, 0, .4); +} +ul.pagination li.active p { color: var(--forum-gold); font-weight: bold; } +ul.pagination li a:hover { color: var(--forum-gold); } + +ul.topic_row { + display: flex; flex-wrap: nowrap; align-items: center; width: 100%; list-style: none; margin: 0 0 6px; padding: 0; + background: rgba(0, 0, 0, .18); + box-shadow: 0 0 11px rgba(0, 0, 0, .3), 0 1px 3px rgba(0, 0, 0, .5), inset 0 0 0 1px rgba(255, 255, 255, .03); + border-radius: 3px; +} +ul.topic_row li { text-shadow: 0 0 6px rgba(0, 0, 0, .5), 0 1px 1px rgba(0, 0, 0, .6); } +ul.topic_row .icon { width: 60px; flex: 0 0 auto; } +ul.topic_row .icon img { margin: 8px 0 0 4px; } +ul.topic_row li.topic_title_by_date { flex: 1 1 auto; text-align: left; } +ul.topic_row li.topic_title_by_date h1 { margin: 11px 0 0 14px; } +ul.topic_row li.topic_title_by_date h1 a { font-size: 13px; font-weight: bold; color: var(--forum-gold); } +ul.topic_row li.topic_title_by_date h1 a:hover { color: #f0b53a; } +ul.topic_row li.topic_title_by_date p { margin: 3px 0 8px 14px; font-weight: bold; font-size: 11px; color: #6d6a5e; } +ul.topic_row li.topic_title_by_date p a.username { color: var(--forum-gold-soft); } +ul.topic_row .lastpost { flex: 0 0 220px; padding-left: 14px; color: #6d6a5e; text-align: left; } +ul.topic_row .lastpost h4 { margin: 13px 0 0 0; font-size: 11px; } +ul.topic_row .lastpost h4 a { color: var(--forum-gold-soft); } +ul.topic_row .lastpost h5 { margin: 2px 0 8px; font-size: 11px; color: #6d6a5e; } + +.forum-empty { text-align: center; color: #6d6a5e; padding: 30px 0; } + +/* ===================== posts ===================== */ +.topic_title h1 { font-size: 22px; color: var(--forum-gold); margin: 0 0 5px; font-weight: bold; } +.topic_title h3 { font-weight: bold; font-size: 13px; color: #6d6a5e; } + +.topic_post { + display: flex; flex-wrap: wrap; width: 100%; + background: rgba(0, 0, 0, .18); + box-shadow: 0 0 11px rgba(0, 0, 0, .3), 0 1px 3px rgba(0, 0, 0, .5), inset 0 0 0 1px rgba(255, 255, 255, .03); + border-radius: 3px; margin: 0 0 30px; +} +.topic_post.isStaff { + box-shadow: inset 0 0 1px rgba(96, 76, 65, .3), 0 0 0 1px #251d19, 0 2px 2px rgba(0, 0, 0, .3), inset 0 0 46px rgba(255, 68, 0, .12); +} +.topic_post .left_side { + flex: 0 0 200px; padding: 10px; background: rgba(0, 0, 0, .25); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .03); +} +.topic_post .right_side { flex: 1 1 320px; display: flex; flex-direction: column; } +.username_container { display: block; margin: 6px 0 0; text-align: center; } +.username_container a.username { font-size: 14px; color: var(--forum-gold-soft); font-weight: bold; } +.topic_post .left_side .user_avatar { + width: 86px; height: 86px; border-radius: 3px; margin: 16px auto; overflow: hidden; + background: rgba(69, 67, 62, .5); + box-shadow: inset 0 0 1px 1px rgba(255, 255, 255, .05), inset 0 0 28px rgba(0, 0, 0, .8), 0 0 16px rgba(0, 0, 0, .6); +} +.topic_post .left_side .user_avatar.isStaff { border: 1px solid #813c26; box-shadow: 0 0 21px rgba(240, 106, 59, .45); } +.topic_post .left_side .user_avatar span { display: block; width: 100%; height: 100%; } +.topic_post .left_side .user_info { text-align: center; } +h3.post_field { + border: 1px solid #212322; border-radius: 5px; margin: 8px 4px; display: flex; align-items: center; gap: .5rem; padding: 8px; + font-size: 12px; font-weight: bold; color: #8a8578; +} +h3.post_field dt { min-width: auto; } +h3.post_field dd { margin: 0; } +.badge_staff { color: #f06a3b; } + +.topic_post .right_side .post_container { + padding: 14px; font-size: 13px; color: #b5b1a3; min-height: 120px; + text-shadow: 0 1px 1px rgba(0, 0, 0, .6); +} +.topic_post .right_side .post_container img { display: block; max-width: 100%; height: auto; } +.topic_post .right_side .post_container a { color: var(--forum-gold-soft); text-decoration: underline; } +.post_deleted { opacity: .5; } + +ul.post_controls { display: flex; justify-content: flex-end; align-items: center; gap: 8px; list-style: none; padding: 15px; margin: auto 0 0; } +ul.post_controls li { height: 20px; } +ul.post_controls li a { + display: block; border-radius: 2px; padding: 4px 8px; background: rgba(255, 255, 255, .04); font-weight: bold; color: #8a8578; + box-shadow: 0 1px 1px rgba(0, 0, 0, .13), inset 0 1px 1px rgba(255, 255, 255, .04); +} +ul.post_controls li a:hover { background: rgba(255, 255, 255, .08); color: var(--forum-gold); } +ul.post_controls .post_date { color: #6d6a5e; font-weight: bold; font-size: 11px; margin-right: auto; } + +/* quick reply / editor */ +.quick_reply { padding: 0 20px 20px; background: rgba(0, 0, 0, .18); border-radius: 3px; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .03); } +.quick_reply h2 { font-size: 14px; color: #8a8578; padding: 18px 0; text-transform: uppercase; text-align: left; } +.forum-form-actions { display: flex; align-items: center; gap: 10px; margin-top: 16px; flex-wrap: wrap; } +.forum-form-actions .right-0 { margin-left: auto; } + +.forum-input { + width: 100%; padding: 12px; margin: 12px 0; border: none; border-radius: 3px; color: #d4cdbb; font-size: 14px; + background: rgba(0, 0, 0, .25); box-shadow: inset 1px 1px 2px rgba(0, 0, 0, .25), inset 0 0 10px rgba(0, 0, 0, .1); +} +.forum-input::placeholder { color: #6d6a5e; } + +/* nice_button (no está en theme.css) */ +.nice_button { + display: inline-block; padding: 9px 18px; border-radius: 5px; cursor: pointer; font-weight: bold; text-transform: uppercase; + color: #1c1305; border: 1px solid #b08f1d; background: linear-gradient(#e3ab2d, #b08f1d); transition: all 150ms; +} +.nice_button:hover { background: linear-gradient(#f0b53a, #c9a836); } +.nice_button:disabled { opacity: .5; cursor: default; } + +/* ===================== responsive ===================== */ +@media (max-width: 991px) { + .forum-container { margin: 0; } + .forum-padding { margin: 0; } +} +@media (max-width: 767px) { + .forum-row .post, .forum-row .topics { display: none; } + ul.topic_row .lastpost { display: none; } + .topic_post .left_side { flex-basis: 100%; } + .topic_post .left_side .user_avatar { width: 56px; height: 56px; } +} diff --git a/web-next/components/AdminForumManager.tsx b/web-next/components/AdminForumManager.tsx index 06e20f2..2f4b81f 100644 --- a/web-next/components/AdminForumManager.tsx +++ b/web-next/components/AdminForumManager.tsx @@ -6,22 +6,29 @@ import { useRouter } from '@/i18n/navigation' import type { AdminCategory, AdminForum } from '@/lib/admin-forum' type Group = { category: AdminCategory; forums: AdminForum[] } +type ForumFields = { + name: string + description: string + icon: string + colortitle: string + type: string + order: string +} + +const EMPTY_FORUM: ForumFields = { name: '', description: '', icon: '', colortitle: '', type: '0', order: '0' } export function AdminForumManager({ initial }: { initial: Group[] }) { const t = useTranslations('Admin') const router = useRouter() const [busy, setBusy] = useState(false) - const [catForm, setCatForm] = useState({ name: '', nameEn: '', order: '0' }) - const [forumForm, setForumForm] = useState>({}) - const [editCat, setEditCat] = useState<{ id: number; name: string; nameEn: string; order: string } | null>(null) - const [editForum, setEditForum] = useState<{ id: number; name: string; nameEn: string; description: string; descriptionEn: string; order: string; visibility: number } | null>(null) + const [catForm, setCatForm] = useState({ name: '', order: '0' }) + const [forumForm, setForumForm] = useState>({}) + const [editCat, setEditCat] = useState<{ id: number; name: string; order: string } | null>(null) + const [editForum, setEditForum] = useState<(ForumFields & { id: number }) | null>(null) - function ff(catId: number) { - return forumForm[catId] ?? { name: '', nameEn: '', description: '', descriptionEn: '', order: '0' } - } - function setFF(catId: number, patch: Partial<{ name: string; nameEn: string; description: string; descriptionEn: string; order: string }>) { + const ff = (catId: number) => forumForm[catId] ?? EMPTY_FORUM + const setFF = (catId: number, patch: Partial) => setForumForm((s) => ({ ...s, [catId]: { ...ff(catId), ...patch } })) - } async function call(url: string, method: string, body?: unknown): Promise { setBusy(true) @@ -38,12 +45,46 @@ export function AdminForumManager({ initial }: { initial: Group[] }) { return true } if (d.error === 'categoryNotEmpty') alert(t('categoryNotEmpty')) + else if (d.error === 'forumNotEmpty') alert(t('forumNotEmpty')) return false } finally { setBusy(false) } } + const forumBody = (v: ForumFields, category: number) => ({ + category, + name: v.name, + description: v.description, + icon: v.icon, + colortitle: v.colortitle, + type: Number(v.type), + order: Number(v.order), + }) + + function ForumEditFields({ + v, + onChange, + }: { + v: ForumFields + onChange: (patch: Partial) => void + }) { + return ( + <> + onChange({ name: e.target.value })} placeholder={t('forumName')} maxLength={45} /> + onChange({ description: e.target.value })} placeholder={t('forumDescription')} /> + + onChange({ icon: e.target.value })} placeholder={t('forumIcon')} /> + onChange({ colortitle: e.target.value })} placeholder={t('forumColor')} /> + onChange({ order: e.target.value })} placeholder={t('order')} /> + + ) + } + return (
{/* Alta de categoría */} @@ -51,18 +92,16 @@ export function AdminForumManager({ initial }: { initial: Group[] }) { onSubmit={(e) => { e.preventDefault() if (catForm.name.trim()) - call('/api/admin/forum/category', 'POST', { name: catForm.name, nameEn: catForm.nameEn, order: Number(catForm.order) }).then( - (ok) => ok && setCatForm({ name: '', nameEn: '', order: '0' }), + call('/api/admin/forum/category', 'POST', { name: catForm.name, order: Number(catForm.order) }).then( + (ok) => ok && setCatForm({ name: '', order: '0' }), ) }} className="admin-form info-box-light separate2" > {t('newCategory')} - setCatForm({ ...catForm, name: e.target.value })} placeholder={t('categoryName')} /> - setCatForm({ ...catForm, nameEn: e.target.value })} placeholder={t('categoryNameEn')} /> + setCatForm({ ...catForm, name: e.target.value })} placeholder={t('categoryName')} maxLength={45} /> {t('order')} setCatForm({ ...catForm, order: e.target.value })} /> -

{t('forumEnHint')}


@@ -73,13 +112,12 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
{editCat?.id === category.id ? (
- setEditCat({ ...editCat, name: e.target.value })} placeholder={t('categoryName')} /> - setEditCat({ ...editCat, nameEn: e.target.value })} placeholder={t('categoryNameEn')} /> + setEditCat({ ...editCat, name: e.target.value })} placeholder={t('categoryName')} maxLength={45} /> setEditCat({ ...editCat, order: e.target.value })} />
)} - {/* Foros de la categoría */} {forums.length === 0 && ( @@ -124,21 +159,11 @@ export function AdminForumManager({ initial }: { initial: Group[] }) { editForum?.id === f.id ? (
- setEditForum({ ...editForum, name: e.target.value })} placeholder={t('forumName')} maxLength={45} /> - setEditForum({ ...editForum, nameEn: e.target.value })} placeholder={t('forumNameEn')} maxLength={45} /> - setEditForum({ ...editForum, description: e.target.value })} placeholder={t('forumDescription')} /> - setEditForum({ ...editForum, descriptionEn: e.target.value })} placeholder={t('forumDescriptionEn')} /> + setEditForum({ ...editForum, ...patch })} />
{f.name}{' '} - {f.name_en && · EN: {f.name_en}}{' '} - {f.visibility === 0 && ({t('hidden')})} + + · {t('forumType')}: {f.type} · #{f.order} + {f.description &&

{f.description}

}
{' '} - {' '} diff --git a/web-next/components/CreateTopicForm.tsx b/web-next/components/CreateTopicForm.tsx new file mode 100644 index 0000000..d8f1172 --- /dev/null +++ b/web-next/components/CreateTopicForm.tsx @@ -0,0 +1,59 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { useRouter } from '@/i18n/navigation' +import { ForumEditor } from './ForumEditor' + +/** Formulario de nuevo tema: título + editor TinyMCE, enviado por fetch. */ +export function CreateTopicForm({ forumId }: { forumId: number }) { + const t = useTranslations('Forum') + const router = useRouter() + const [title, setTitle] = useState('') + const [text, setText] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + async function submit(e: React.FormEvent) { + e.preventDefault() + if (busy) return + if (title.trim().length < 3) return setError(t('errTitle')) + setBusy(true) + setError(null) + try { + const res = await fetch('/api/forum/topic', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ forumId, title, text }), + }) + const data: { success?: boolean; topicId?: number; error?: string } = await res.json() + if (data.success && data.topicId) router.push(`/forum/topic/${data.topicId}`) + else setError(data.error || t('genericError')) + } catch { + setError(t('genericError')) + } finally { + setBusy(false) + } + } + + return ( +
+ setTitle(e.target.value)} + type="text" + placeholder={t('topicTitle')} + maxLength={45} + /> + +
+ + {error && {error}} +
+ + ) +} diff --git a/web-next/components/EditPostForm.tsx b/web-next/components/EditPostForm.tsx new file mode 100644 index 0000000..518a3a0 --- /dev/null +++ b/web-next/components/EditPostForm.tsx @@ -0,0 +1,14 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { Link } from '@/i18n/navigation' + +/** Enlace «Editar» de un post, hacia la página de edición a pantalla completa. */ +export function EditPostForm({ postId }: { postId: number; initialText?: string }) { + const t = useTranslations('Forum') + return ( + + {t('edit')} + + ) +} diff --git a/web-next/components/EditPostPageForm.tsx b/web-next/components/EditPostPageForm.tsx new file mode 100644 index 0000000..7e408df --- /dev/null +++ b/web-next/components/EditPostPageForm.tsx @@ -0,0 +1,60 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { useRouter, Link } from '@/i18n/navigation' +import { ForumEditor } from './ForumEditor' + +/** Edición de un post a página completa: editor TinyMCE precargado, PATCH por fetch. */ +export function EditPostPageForm({ + postId, + topicId, + initialText, +}: { + postId: number + topicId: number + initialText: string +}) { + const t = useTranslations('Forum') + const router = useRouter() + const [text, setText] = useState(initialText) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + async function submit(e: React.FormEvent) { + e.preventDefault() + if (busy) 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) router.push(`/forum/topic/${topicId}`) + else setError(data.error || t('genericError')) + } catch { + setError(t('genericError')) + } finally { + setBusy(false) + } + } + + return ( +
+ +
+ + + {t('cancel')} + + {error && {error}} +
+ + ) +} diff --git a/web-next/components/ForumBreadcrumb.tsx b/web-next/components/ForumBreadcrumb.tsx new file mode 100644 index 0000000..3f3dd91 --- /dev/null +++ b/web-next/components/ForumBreadcrumb.tsx @@ -0,0 +1,19 @@ +import { Link } from '@/i18n/navigation' + +/** Migas del foro (Foro › Subforo › Tema). El último item va sin enlace. */ +export function ForumBreadcrumb({ + items, +}: { + items: { label: string; href?: string }[] +}) { + return ( +
+ {items.map((it, i) => ( + + {i > 0 && } + {it.href ? {it.label} : {it.label}} + + ))} +
+ ) +} diff --git a/web-next/components/ForumEditor.tsx b/web-next/components/ForumEditor.tsx new file mode 100644 index 0000000..c276afb --- /dev/null +++ b/web-next/components/ForumEditor.tsx @@ -0,0 +1,48 @@ +'use client' + +import { Editor } from '@tinymce/tinymce-react' + +/** + * Editor TinyMCE del foro (community, self-hosted desde /tinymce — ver + * scripts/copy-tinymce.mjs). Mismo editor que el foro original de FusionCMS, con su + * skin oscura y su lista de plugins, adaptado a React como componente controlado: + * el HTML vive en el estado del padre (value/onChange) y se envía por fetch, en vez + * del `tinyMCE.triggerSave()` + jQuery del forum.js original. + * + * `licenseKey: 'gpl'` deja claro que es la edición community (GPL), no la de nube. + * El HTML que produce se sanea SIEMPRE en el servidor con nh3 (lib/forum-sanitize), + * nunca se confía en el cliente. + */ +export function ForumEditor({ + value, + onChange, + height = 400, +}: { + value: string + onChange: (html: string) => void + height?: number +}) { + return ( + onChange(content)} + init={{ + height, + menubar: false, + statusbar: false, + skin: 'oxide-dark', + content_css: 'dark', + branding: false, + promotion: false, + mobile: { menubar: true }, + plugins: + 'preview searchreplace autolink directionality visualblocks visualchars fullscreen image link media codesample table charmap pagebreak nonbreaking anchor insertdatetime advlist lists wordcount emoticons', + toolbar: + 'bold italic strikethrough forecolor backcolor emoticons | link image media | alignleft aligncenter alignright alignjustify | numlist bullist outdent indent | removeformat', + image_advtab: true, + }} + /> + ) +} diff --git a/web-next/components/ForumModActions.tsx b/web-next/components/ForumModActions.tsx new file mode 100644 index 0000000..baaca4c --- /dev/null +++ b/web-next/components/ForumModActions.tsx @@ -0,0 +1,68 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { useRouter } from '@/i18n/navigation' + +/** + * Acciones de moderación de un post o tema (borrar/restaurar y, en temas, + * cerrar/abrir). Reemplaza los enlaces GET del forum.js original por POST a + * /api/forum/moderate, para no mutar estado con peticiones GET. + */ +export function ForumModActions({ + kind, + id, + deleted = false, + locked = false, +}: { + kind: 'post' | 'topic' + id: number + deleted?: boolean + locked?: boolean +}) { + const t = useTranslations('Forum') + const router = useRouter() + const [busy, setBusy] = useState(false) + + async function act(action: string) { + if (busy) return + setBusy(true) + try { + const res = await fetch('/api/forum/moderate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ kind, id, action }), + }) + const data: { success?: boolean } = await res.json() + if (data.success) router.refresh() + } finally { + setBusy(false) + } + } + + return ( + <> + act(deleted ? 'undelete' : 'delete')} + style={busy ? { opacity: 0.5 } : undefined} + > + {deleted ? t('undelete') : t('delete')} + + {kind === 'topic' && ( + act(locked ? 'unlock' : 'lock')} + style={busy ? { opacity: 0.5 } : undefined} + > + {locked ? t('unlock') : t('lock')} + + )} + + ) +} diff --git a/web-next/components/ForumSearchBox.tsx b/web-next/components/ForumSearchBox.tsx deleted file mode 100644 index e539d55..0000000 --- a/web-next/components/ForumSearchBox.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'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')} - />{' '} - -
- ) -} diff --git a/web-next/components/NewTopicForm.tsx b/web-next/components/NewTopicForm.tsx deleted file mode 100644 index 92cdcb7..0000000 --- a/web-next/components/NewTopicForm.tsx +++ /dev/null @@ -1,59 +0,0 @@ -'use client' - -import { useState } from 'react' -import { useTranslations } from 'next-intl' -import { useRouter } from '@/i18n/navigation' -import { RichTextArea } from './RichTextArea' - -export function NewTopicForm({ forumId }: { forumId: number }) { - const t = useTranslations('Forum') - const router = useRouter() - const [name, setName] = useState('') - const [text, setText] = useState('') - const [busy, setBusy] = useState(false) - const [error, setError] = useState(null) - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault() - if (busy || !name.trim() || !text.trim()) return - setBusy(true) - setError(null) - try { - const res = await fetch('/api/forum/topic', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'same-origin', - body: JSON.stringify({ forumId, name, text }), - }) - const data: { success?: boolean; topicId?: number } = await res.json() - if (data.success && data.topicId) router.push(`/forum/topic/${data.topicId}`) - else { - setError(t('genericError')) - setBusy(false) - } - } catch { - setError(t('genericError')) - setBusy(false) - } - } - - return ( -
-
-

{t('newTopic')}

-
-
- setName(e.target.value)} placeholder={t('newTopicName')} maxLength={200} style={{ width: '100%' }} /> -
-
- -
- -
- {error && {error}} -
- - ) -} diff --git a/web-next/components/PostActions.tsx b/web-next/components/PostActions.tsx deleted file mode 100644 index 6e5b377..0000000 --- a/web-next/components/PostActions.tsx +++ /dev/null @@ -1,133 +0,0 @@ -'use client' - -import { useState } from 'react' -import { useTranslations } from 'next-intl' -import { useRouter } from '@/i18n/navigation' -import { RichTextArea } from './RichTextArea' - -export function PostActions({ - postId, - initialText, - deleted = false, -}: { - postId: number - initialText: string - deleted?: boolean -}) { - 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 restore() { - if (busy) return - setBusy(true) - setError(null) - try { - const res = await fetch('/api/forum/post', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - credentials: 'same-origin', - body: JSON.stringify({ postId }), - }) - if ((await res.json()).success) router.refresh() - else setError(t('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 ( -
- -
- {' '} - - {error &&

{error}

} -
- ) - } - - if (deleted) { - return ( -
- - {error && {error}} -
- ) - } - - return ( - - ) -} diff --git a/web-next/components/ReplyForm.tsx b/web-next/components/ReplyForm.tsx index eb81323..f51e7a7 100644 --- a/web-next/components/ReplyForm.tsx +++ b/web-next/components/ReplyForm.tsx @@ -3,8 +3,9 @@ import { useState } from 'react' import { useTranslations } from 'next-intl' import { useRouter } from '@/i18n/navigation' -import { RichTextArea } from './RichTextArea' +import { ForumEditor } from './ForumEditor' +/** Respuesta rápida a un tema: editor TinyMCE, enviado por fetch. */ export function ReplyForm({ topicId }: { topicId: number }) { const t = useTranslations('Forum') const router = useRouter() @@ -12,9 +13,9 @@ export function ReplyForm({ topicId }: { topicId: number }) { const [busy, setBusy] = useState(false) const [error, setError] = useState(null) - async function handleSubmit(e: React.FormEvent) { + async function submit(e: React.FormEvent) { e.preventDefault() - if (busy || !text.trim()) return + if (busy) return setBusy(true) setError(null) try { @@ -24,12 +25,12 @@ export function ReplyForm({ topicId }: { topicId: number }) { credentials: 'same-origin', body: JSON.stringify({ topicId, text }), }) - const data: { success?: boolean } = await res.json() + const data: { success?: boolean; error?: string } = await res.json() if (data.success) { setText('') router.refresh() } else { - setError(t('genericError')) + setError(data.error || t('genericError')) } } catch { setError(t('genericError')) @@ -39,15 +40,17 @@ export function ReplyForm({ topicId }: { topicId: number }) { } return ( -
- -
- -
- {error && {error}} -
- +
+

{t('reply')}

+
+ +
+ + {error && {error}} +
+ +
) } diff --git a/web-next/components/TopicModBar.tsx b/web-next/components/TopicModBar.tsx deleted file mode 100644 index 4a44f6f..0000000 --- a/web-next/components/TopicModBar.tsx +++ /dev/null @@ -1,95 +0,0 @@ -'use client' - -import { useState } from 'react' -import { useTranslations } from 'next-intl' -import { useRouter } from '@/i18n/navigation' - -export function TopicModBar({ - topicId, - locked, - sticky, - deleted = false, - moveForums = [], -}: { - topicId: number - locked: boolean - sticky: boolean - deleted?: boolean - moveForums?: { id: number; name: string }[] -}) { - const t = useTranslations('Forum') - const router = useRouter() - const [busy, setBusy] = useState(false) - - async function act(action: string, extra?: Record, 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, ...extra }), - }) - const data: { success?: boolean; forumId?: number } = await res.json() - if (data.success && action === 'delete' && data.forumId) { - router.push(`/forum/${data.forumId}`) - } else if (data.success && action === 'move') { - router.push(`/forum/${extra?.forumId}`) - } else { - router.refresh() - } - } finally { - setBusy(false) - } - } - - if (deleted) { - return ( -
- {t('deletedMark')}{' '} - -
- ) - } - - return ( -
- {t('moderation')}:{' '} - {' '} - {' '} - {moveForums.length > 0 && ( - - )}{' '} - -
- ) -} diff --git a/web-next/lib/admin-forum.ts b/web-next/lib/admin-forum.ts index cec2d12..c4faa23 100644 --- a/web-next/lib/admin-forum.ts +++ b/web-next/lib/admin-forum.ts @@ -1,72 +1,75 @@ import type { RowDataPacket, ResultSetHeader } from 'mysql2' import { db, DB } from './db' +// Gestión del foro para el admin, sobre el esquema FusionCMS (ver sql/forum_fusion.sql): +// forum_categories(id, order, name) +// forum_forums(id, category, order, name, description, icon, colortitle, type) +// No hay columnas _en / visibility / deleted: las categorías y foros son datos del +// seed, editables aquí. Borrar un foro es un DELETE real (se rechaza si tiene temas). + export interface AdminCategory { id: number name: string - name_en: string order: number } export interface AdminForum { id: number - category_id: number + category: number name: string - name_en: string description: string - description_en: string + icon: string | null + colortitle: string | null + type: number order: number - visibility: number } -/** Categorías con sus foros (incluye ocultos y sin borrar), para el admin. */ export async function listCategoriesWithForums(): Promise< { category: AdminCategory; forums: AdminForum[] }[] > { const [cats] = await db(DB.web).query( - 'SELECT id, name, name_en, `order` FROM forum_categories ORDER BY `order`, id', + 'SELECT id, name, `order` FROM forum_categories ORDER BY `order`, id', ) const [forums] = await db(DB.web).query( - 'SELECT id, category_id, name, name_en, description, description_en, `order`, visibility FROM forums WHERE deleted = 0 ORDER BY `order`, id', + 'SELECT id, category, name, description, icon, colortitle, type, `order` FROM forum_forums ORDER BY `order`, id', ) return cats.map((c) => ({ - category: { id: c.id, name: c.name, name_en: c.name_en || '', order: c.order }, + category: { id: c.id, name: c.name, order: c.order }, forums: forums - .filter((f) => f.category_id === c.id) + .filter((f) => f.category === c.id) .map((f) => ({ id: f.id, - category_id: f.category_id, + category: f.category, name: f.name, - name_en: f.name_en || '', description: f.description, - description_en: f.description_en || '', + icon: f.icon, + colortitle: f.colortitle, + type: f.type, order: f.order, - visibility: f.visibility, })), })) } -export async function createCategory(name: string, nameEn: string | null, order: number): Promise { +export async function createCategory(name: string, order: number): Promise { const [res] = await db(DB.web).query( - 'INSERT INTO forum_categories (name, name_en, `order`, created_at) VALUES (?, ?, ?, NOW())', - [name, nameEn || null, order], + 'INSERT INTO forum_categories (name, `order`) VALUES (?, ?)', + [name.slice(0, 45), order], ) return res.insertId } -export async function updateCategory(id: number, name: string, nameEn: string | null, order: number): Promise { - await db(DB.web).query('UPDATE forum_categories SET name = ?, name_en = ?, `order` = ? WHERE id = ?', [ - name, - nameEn || null, +export async function updateCategory(id: number, name: string, order: number): Promise { + await db(DB.web).query('UPDATE forum_categories SET name = ?, `order` = ? WHERE id = ?', [ + name.slice(0, 45), order, id, ]) } -/** Borra una categoría solo si no tiene foros (no borrados). */ +/** Borra una categoría solo si no tiene foros. */ export async function deleteCategory(id: number): Promise { const [f] = await db(DB.web).query( - 'SELECT COUNT(*) AS n FROM forums WHERE category_id = ? AND deleted = 0', + 'SELECT COUNT(*) AS n FROM forum_forums WHERE category = ?', [id], ) if (Number(f[0]?.n ?? 0) > 0) return false @@ -74,44 +77,35 @@ export async function deleteCategory(id: number): Promise { return true } -export async function createForum( - categoryId: number, - name: string, - nameEn: string | null, - description: string, - descriptionEn: string | null, - order: number, - visibility: number, -): Promise { +export interface ForumInput { + category: number + name: string + description: string + icon: string | null + colortitle: string | null + type: number + order: number +} + +export async function createForum(f: ForumInput): Promise { const [res] = await db(DB.web).query( - 'INSERT INTO forums (category_id, name, name_en, description, description_en, `order`, type, visibility, deleted, created_at, updated_at) ' + - 'VALUES (?, ?, ?, ?, ?, ?, 0, ?, 0, NOW(), NOW())', - [categoryId, name, nameEn || null, description, descriptionEn || null, order, visibility ? 1 : 0], + 'INSERT INTO forum_forums (category, name, description, icon, colortitle, type, `order`) VALUES (?, ?, ?, ?, ?, ?, ?)', + [f.category, f.name.slice(0, 45), f.description, f.icon || null, f.colortitle || null, f.type, f.order], ) return res.insertId } -export async function updateForum( - id: number, - name: string, - nameEn: string | null, - description: string, - descriptionEn: string | null, - order: number, - visibility: number, -): Promise { +export async function updateForum(id: number, f: ForumInput): Promise { await db(DB.web).query( - 'UPDATE forums SET name = ?, name_en = ?, description = ?, description_en = ?, `order` = ?, visibility = ?, updated_at = NOW() WHERE id = ?', - [name, nameEn || null, description, descriptionEn || null, order, visibility ? 1 : 0, id], + 'UPDATE forum_forums SET category = ?, name = ?, description = ?, icon = ?, colortitle = ?, type = ?, `order` = ? WHERE id = ?', + [f.category, f.name.slice(0, 45), f.description, f.icon || null, f.colortitle || null, f.type, f.order, id], ) } -/** Borrado lógico de un foro (deleted = 1). */ -export async function deleteForum(id: number): Promise { - await db(DB.web).query('UPDATE forums SET deleted = 1 WHERE id = ?', [id]) -} - -/** Cambia la visibilidad de un foro. */ -export async function setForumVisibility(id: number, visible: boolean): Promise { - await db(DB.web).query('UPDATE forums SET visibility = ? WHERE id = ?', [visible ? 1 : 0, id]) +/** Borra un foro solo si no tiene temas. */ +export async function deleteForum(id: number): Promise { + const [t] = await db(DB.web).query('SELECT COUNT(*) AS n FROM forum_topics WHERE forum = ?', [id]) + if (Number(t[0]?.n ?? 0) > 0) return false + await db(DB.web).query('DELETE FROM forum_forums WHERE id = ?', [id]) + return true } diff --git a/web-next/lib/forum-format.ts b/web-next/lib/forum-format.ts new file mode 100644 index 0000000..59d4afe --- /dev/null +++ b/web-next/lib/forum-format.ts @@ -0,0 +1,14 @@ +// Formato de fechas del foro, sensible al idioma (es/en). Reemplaza el date() +// de PHP del original por Intl, coherente con el locale de la página. +export function formatForumDate(iso: string | null, locale: string): string { + if (!iso) return '' + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '' + return new Intl.DateTimeFormat(locale === 'en' ? 'en-US' : 'es-ES', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(d) +} diff --git a/web-next/lib/forum-write.ts b/web-next/lib/forum-write.ts index 7266387..6ded21c 100644 --- a/web-next/lib/forum-write.ts +++ b/web-next/lib/forum-write.ts @@ -2,61 +2,60 @@ import type { ResultSetHeader, RowDataPacket } from 'mysql2' import { db, DB } from './db' import { cleanPostHtml } from './forum-sanitize' +// Escritura del foro (esquema FusionCMS, ver lib/forum.ts). `poster` es el AccountID. +// El texto se guarda como HTML ya saneado con nh3 (cleanPostHtml), no como BBCode: +// es la adaptación a nuestro editor TinyMCE. El esquema no cambia (columna `text`). + +// El título vive en forum_topics.name, un varchar(45) del esquema original. Se recorta +// a 45 para no chocar con el modo estricto de MySQL; la validación de longitud real la +// hace la API antes de llegar aquí. +const TITLE_MAX = 45 + export async function createTopic( forumId: number, name: string, - poster: string, posterId: number, text: string, ): Promise { const clean = cleanPostHtml(text) const [res] = await db(DB.web).query( - 'INSERT INTO forum_topics (forum_id, name, poster, poster_id, created, created_at, updated_at, locked, sticky, deleted) ' + - 'VALUES (?, ?, ?, ?, NOW(), NOW(), NOW(), 0, 0, 0)', - [forumId, name, poster, posterId], + 'INSERT INTO forum_topics (forum, name, sticky, locked, deleted, created) VALUES (?, ?, 0, 0, 0, NOW())', + [forumId, name.slice(0, TITLE_MAX)], ) const topicId = res.insertId await db(DB.web).query( - 'INSERT INTO forum_posts (topic_id, poster, poster_id, text, time, created_at, deleted) VALUES (?, ?, ?, ?, NOW(), NOW(), 0)', - [topicId, poster, posterId, clean], + 'INSERT INTO forum_posts (topic, poster, text, time, deleted) VALUES (?, ?, ?, NOW(), 0)', + [topicId, posterId, clean], ) return topicId } -export async function createPost( - topicId: number, - poster: string, - posterId: number, - text: string, -): Promise { +export async function createPost(topicId: number, posterId: number, text: string): Promise { const clean = cleanPostHtml(text) const [res] = await db(DB.web).query( - 'INSERT INTO forum_posts (topic_id, poster, poster_id, text, time, created_at, deleted) VALUES (?, ?, ?, ?, NOW(), NOW(), 0)', - [topicId, poster, posterId, clean], + 'INSERT INTO forum_posts (topic, poster, text, time, deleted) VALUES (?, ?, ?, NOW(), 0)', + [topicId, posterId, clean], ) - await db(DB.web).query('UPDATE forum_topics SET updated_at = NOW() WHERE id = ?', [topicId]) 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> { +): Promise<{ id: number; topic: number; poster: number; text: string; deleted: boolean } | null> { const [rows] = await db(DB.web).query( - 'SELECT id, topic_id, poster_id, text FROM forum_posts WHERE id = ? AND deleted = 0', + 'SELECT id, topic, poster, text, deleted FROM forum_posts WHERE id = ?', [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 + return r ? { id: r.id, topic: r.topic, poster: r.poster, text: r.text, deleted: r.deleted === 1 } : 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]) + await db(DB.web).query('UPDATE forum_posts SET text = ? 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]) } @@ -71,21 +70,7 @@ export async function setTopicFlag( await db(DB.web).query(`UPDATE forum_topics SET ${field} = ? WHERE id = ?`, [value ? 1 : 0, topicId]) } -/** Mueve un tema a otro foro (marca moved=1). Solo moderación. */ -export async function moveTopic(topicId: number, newForumId: number): Promise { - await db(DB.web).query('UPDATE forum_topics SET forum_id = ?, moved = 1 WHERE id = ?', [newForumId, 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( - 'SELECT id FROM forums WHERE id = ? AND visibility = 1 AND deleted = 0', - [forumId], - ) - return Boolean(rows[0]) -} - -/** Comprueba que un tema existe y no está cerrado (para responder). */ +/** ¿El tema existe, no está borrado y admite respuestas (no cerrado)? */ export async function topicIsReplyable(topicId: number): Promise { const [rows] = await db(DB.web).query( 'SELECT locked FROM forum_topics WHERE id = ? AND deleted = 0', diff --git a/web-next/lib/forum.ts b/web-next/lib/forum.ts index f29a86e..e8d1913 100644 --- a/web-next/lib/forum.ts +++ b/web-next/lib/forum.ts @@ -1,126 +1,221 @@ import type { RowDataPacket } from 'mysql2' import { db, DB } from './db' -export interface ForumInfo { +// Foro estilo FusionCMS (ver sql/forum_fusion.sql). Esquema: +// forum_categories(id, order, name) +// forum_forums(id, category, order, name, description, icon, colortitle, type) +// forum_topics(id, forum, name, sticky, locked, deleted, created) +// forum_posts(id, topic, poster, text, time, deleted) +// `poster` es el AccountID de acore_auth (entero); el nombre se resuelve aparte +// con resolvePosters(). Los temas no guardan autor: se deriva del primer/último post. + +// Tipos de foro (columna `type`): +// 0 = fila normal (icono de forum_icons) +// 1 = disciplina de clase (icon = clase CSS, colortitle = color) +// 2 = subforo por idioma (icon = ruta a una bandera) +export interface ForumRow { id: number - category_id: number + category: number + order: number name: string description: string icon: string | null + colortitle: string | null + type: number topic_count: number post_count: number last_topic_id: number | null last_topic_name: string | null - last_topic_poster: string | null + last_poster: number | null + last_time: string | null } export interface Category { id: number name: string - forums: ForumInfo[] + forums: ForumRow[] } -export interface Topic { +export interface TopicRow { id: number name: string - poster: string - created: string | null sticky: boolean locked: boolean - views: number + deleted: boolean + created: string | null + poster: number | null + last_poster: number | null + time_updated: string | null } -export interface Post { +export interface PostRow { id: number - poster: string - poster_id: number | null + poster: number text: string time: string | null - edited: boolean deleted: boolean } -export interface TopicFull { +export interface ForumMeta { id: number name: string - forum_id: number - poster_id: number | null - locked: boolean - sticky: boolean - deleted: boolean + description: string + icon: string | null + colortitle: string | null + type: number } -export interface SearchResult { +export interface ForumPath { + category_id: number + category: string + forum_id: number + forum: string + forum_description: string +} + +export interface TopicPath extends ForumPath { + topic_id: number + topic: string +} + +export interface PosterInfo { id: number name: string - poster: string - forum_id: number - forum_name: string - updated_at: string | null + gmlevel: number + isStaff: boolean + joindate: string | null + postCount: number } -/** Índice del foro: categorías con sus foros visibles (+ contadores y último tema). */ -export async function getForumIndex(locale = 'es'): Promise { - const en = locale === 'en' +/** Nivel GM mínimo para considerarse «staff» en el foro (badge y color). */ +const STAFF_GMLEVEL = Number(process.env.FORUM_MOD_GMLEVEL || 2) + +/** Índice del foro: categorías ordenadas, cada una con sus foros y agregados. */ +export async function getForumIndex(): Promise { try { const [cats] = await db(DB.web).query( - 'SELECT id, name, name_en FROM forum_categories ORDER BY `order`, id', + 'SELECT id, name FROM forum_categories ORDER BY `order`, name', ) const [forums] = await db(DB.web).query( - `SELECT f.id, f.category_id, f.name, f.name_en, f.description, f.description_en, f.icon, - (SELECT COUNT(*) FROM forum_topics t WHERE t.forum_id = f.id AND t.deleted = 0) AS topic_count, - (SELECT COUNT(*) FROM forum_posts p JOIN forum_topics t ON p.topic_id = t.id - WHERE t.forum_id = f.id AND p.deleted = 0 AND t.deleted = 0) AS post_count, - (SELECT t2.id FROM forum_topics t2 WHERE t2.forum_id = f.id AND t2.deleted = 0 - ORDER BY t2.updated_at DESC, t2.id DESC LIMIT 1) AS last_topic_id, - (SELECT t3.name FROM forum_topics t3 WHERE t3.forum_id = f.id AND t3.deleted = 0 - ORDER BY t3.updated_at DESC, t3.id DESC LIMIT 1) AS last_topic_name, - (SELECT t4.poster FROM forum_topics t4 WHERE t4.forum_id = f.id AND t4.deleted = 0 - ORDER BY t4.updated_at DESC, t4.id DESC LIMIT 1) AS last_topic_poster - FROM forums f - WHERE f.visibility = 1 AND f.deleted = 0 + `SELECT f.id, f.category, f.\`order\`, f.name, f.description, f.icon, f.colortitle, f.type, + (SELECT COUNT(*) FROM forum_topics t WHERE t.forum = f.id AND t.deleted = 0) AS topic_count, + (SELECT COUNT(*) FROM forum_posts p JOIN forum_topics t ON p.topic = t.id + WHERE t.forum = f.id AND p.deleted = 0 AND t.deleted = 0) AS post_count, + (SELECT t.id FROM forum_posts p JOIN forum_topics t ON p.topic = t.id + WHERE t.forum = f.id AND p.deleted = 0 AND t.deleted = 0 + ORDER BY p.time DESC, p.id DESC LIMIT 1) AS last_topic_id, + (SELECT t.name FROM forum_posts p JOIN forum_topics t ON p.topic = t.id + WHERE t.forum = f.id AND p.deleted = 0 AND t.deleted = 0 + ORDER BY p.time DESC, p.id DESC LIMIT 1) AS last_topic_name, + (SELECT p.poster FROM forum_posts p JOIN forum_topics t ON p.topic = t.id + WHERE t.forum = f.id AND p.deleted = 0 AND t.deleted = 0 + ORDER BY p.time DESC, p.id DESC LIMIT 1) AS last_poster, + (SELECT p.time FROM forum_posts p JOIN forum_topics t ON p.topic = t.id + WHERE t.forum = f.id AND p.deleted = 0 AND t.deleted = 0 + ORDER BY p.time DESC, p.id DESC LIMIT 1) AS last_time + FROM forum_forums f ORDER BY f.\`order\`, f.id`, ) - const byCat = new Map() + const byCat = new Map() for (const f of forums) { - const info = f as unknown as ForumInfo & { name_en?: string | null; description_en?: string | null } - info.name = en && info.name_en ? info.name_en : info.name - info.description = en && info.description_en ? info.description_en : info.description - if (!byCat.has(info.category_id)) byCat.set(info.category_id, []) - byCat.get(info.category_id)!.push(info) + const row: ForumRow = { + id: f.id, + category: f.category, + order: f.order, + name: f.name, + description: f.description, + icon: f.icon, + colortitle: f.colortitle, + type: f.type, + topic_count: Number(f.topic_count ?? 0), + post_count: Number(f.post_count ?? 0), + last_topic_id: f.last_topic_id ?? null, + last_topic_name: f.last_topic_name ?? null, + last_poster: f.last_poster ?? null, + last_time: f.last_time ? new Date(f.last_time).toISOString() : null, + } + if (!byCat.has(row.category)) byCat.set(row.category, []) + byCat.get(row.category)!.push(row) } return cats - .map((c) => ({ id: c.id, name: en && c.name_en ? c.name_en : c.name, forums: byCat.get(c.id) ?? [] })) + .map((c) => ({ id: c.id, name: c.name, forums: byCat.get(c.id) ?? [] })) .filter((c) => c.forums.length > 0) } catch { return [] } } -/** Lista de foros visibles para el desplegable de «mover tema». */ -export async function listForumsForMove(): Promise<{ id: number; name: string }[]> { +export async function getForum(id: number): Promise { try { const [rows] = await db(DB.web).query( - 'SELECT id, name FROM forums WHERE visibility = 1 AND deleted = 0 ORDER BY `order`, id', + 'SELECT id, name, description, icon, colortitle, type FROM forum_forums WHERE id = ?', + [id], ) - return rows.map((r) => ({ id: r.id, name: r.name })) + const r = rows[0] + return r + ? { id: r.id, name: r.name, description: r.description, icon: r.icon, colortitle: r.colortitle, type: r.type } + : null } catch { - return [] + return null } } -export async function getForum(id: number, locale = 'es'): Promise<{ name: string; description: string } | null> { - const en = locale === 'en' +export async function forumExists(id: number): Promise { + if (!id) return false + try { + const [rows] = await db(DB.web).query('SELECT id FROM forum_forums WHERE id = ?', [id]) + return Boolean(rows[0]) + } catch { + return false + } +} + +export async function getForumPath(forumId: number): Promise { try { const [rows] = await db(DB.web).query( - 'SELECT name, name_en, description, description_en FROM forums WHERE id = ? AND visibility = 1 AND deleted = 0', - [id], + `SELECT c.id AS category_id, c.name AS category, f.id AS forum_id, f.name AS forum, f.description AS forum_description + FROM forum_categories c JOIN forum_forums f ON f.category = c.id + WHERE f.id = ? LIMIT 1`, + [forumId], ) - if (!rows[0]) return null - return { - name: en && rows[0].name_en ? rows[0].name_en : rows[0].name, - description: en && rows[0].description_en ? rows[0].description_en : rows[0].description, - } + const r = rows[0] + return r + ? { + category_id: r.category_id, + category: r.category, + forum_id: r.forum_id, + forum: r.forum, + forum_description: r.forum_description, + } + : null + } catch { + return null + } +} + +export async function getTopicPath(topicId: number): Promise { + try { + const [rows] = await db(DB.web).query( + `SELECT c.id AS category_id, c.name AS category, f.id AS forum_id, f.name AS forum, + f.description AS forum_description, t.id AS topic_id, t.name AS topic + FROM forum_categories c + JOIN forum_forums f ON f.category = c.id + JOIN forum_topics t ON t.forum = f.id + WHERE t.id = ? LIMIT 1`, + [topicId], + ) + const r = rows[0] + return r + ? { + category_id: r.category_id, + category: r.category, + forum_id: r.forum_id, + forum: r.forum, + forum_description: r.forum_description, + topic_id: r.topic_id, + topic: r.topic, + } + : null } catch { return null } @@ -129,7 +224,7 @@ export async function getForum(id: number, locale = 'es'): Promise<{ name: strin export async function countTopics(forumId: number): Promise { try { const [rows] = await db(DB.web).query( - 'SELECT COUNT(*) AS n FROM forum_topics WHERE forum_id = ? AND deleted = 0', + 'SELECT COUNT(*) AS n FROM forum_topics WHERE forum = ? AND deleted = 0', [forumId], ) return Number(rows[0]?.n ?? 0) @@ -138,43 +233,67 @@ export async function countTopics(forumId: number): Promise { } } -export async function getTopics(forumId: number, offset = 0, limit = 20): Promise { +/** Temas de un foro. Autor = primer post; última actividad = último post no borrado. */ +export async function getTopics( + forumId: number, + offset = 0, + limit = 10, + includeDeleted = false, +): Promise { try { + const del = includeDeleted ? '' : 'AND t.deleted = 0' const [rows] = await db(DB.web).query( - 'SELECT id, name, poster, created, sticky, locked, views FROM forum_topics WHERE forum_id = ? AND deleted = 0 ORDER BY sticky DESC, updated_at DESC, id DESC LIMIT ? OFFSET ?', + `SELECT t.id, t.name, t.sticky, t.locked, t.deleted, t.created, + (SELECT p.poster FROM forum_posts p WHERE p.topic = t.id ORDER BY p.time ASC, p.id ASC LIMIT 1) AS poster, + (SELECT p.poster FROM forum_posts p WHERE p.topic = t.id AND p.deleted = 0 ORDER BY p.time DESC, p.id DESC LIMIT 1) AS last_poster, + (SELECT p.time FROM forum_posts p WHERE p.topic = t.id AND p.deleted = 0 ORDER BY p.time DESC, p.id DESC LIMIT 1) AS time_updated + FROM forum_topics t + WHERE t.forum = ? ${del} + ORDER BY t.sticky DESC, COALESCE((SELECT MAX(p.time) FROM forum_posts p WHERE p.topic = t.id AND p.deleted = 0), t.created) DESC, t.id DESC + LIMIT ? OFFSET ?`, [forumId, limit, offset], ) return rows.map((r) => ({ id: r.id, name: r.name, - poster: r.poster, - created: r.created ? new Date(r.created).toISOString() : null, sticky: r.sticky === 1, locked: r.locked === 1, - views: r.views ?? 0, + deleted: r.deleted === 1, + created: r.created ? new Date(r.created).toISOString() : null, + poster: r.poster ?? null, + last_poster: r.last_poster ?? null, + time_updated: r.time_updated ? new Date(r.time_updated).toISOString() : null, })) } catch { return [] } } -export async function getTopic(id: number, includeDeleted = false): Promise { +export async function getTopic(id: number, includeDeleted = false): Promise<{ + id: number + forum: number + name: string + sticky: boolean + locked: boolean + deleted: boolean + created: string | null +} | null> { try { const where = includeDeleted ? 'id = ?' : 'id = ? AND deleted = 0' const [rows] = await db(DB.web).query( - `SELECT id, name, forum_id, poster_id, locked, sticky, deleted FROM forum_topics WHERE ${where}`, + `SELECT id, forum, name, sticky, locked, deleted, created FROM forum_topics WHERE ${where}`, [id], ) const r = rows[0] return r ? { id: r.id, + forum: r.forum, name: r.name, - forum_id: r.forum_id, - poster_id: r.poster_id ?? null, - locked: r.locked === 1, sticky: r.sticky === 1, + locked: r.locked === 1, deleted: r.deleted === 1, + created: r.created ? new Date(r.created).toISOString() : null, } : null } catch { @@ -186,7 +305,7 @@ export async function countPosts(topicId: number, includeDeleted = false): Promi try { const del = includeDeleted ? '' : 'AND deleted = 0' const [rows] = await db(DB.web).query( - `SELECT COUNT(*) AS n FROM forum_posts WHERE topic_id = ? ${del}`, + `SELECT COUNT(*) AS n FROM forum_posts WHERE topic = ? ${del}`, [topicId], ) return Number(rows[0]?.n ?? 0) @@ -195,23 +314,23 @@ export async function countPosts(topicId: number, includeDeleted = false): Promi } } -export async function getPosts(topicId: number, offset = 0, limit = 15, includeDeleted = false): Promise { +export async function getPosts( + topicId: number, + offset = 0, + limit = 5, + includeDeleted = false, +): Promise { try { const del = includeDeleted ? '' : 'AND deleted = 0' const [rows] = await db(DB.web).query( - `SELECT id, poster, poster_id, text, time, created_at, updated_at, deleted FROM forum_posts WHERE topic_id = ? ${del} ORDER BY time ASC, id ASC LIMIT ? OFFSET ?`, + `SELECT id, poster, text, time, deleted FROM forum_posts WHERE topic = ? ${del} ORDER BY time ASC, id ASC LIMIT ? OFFSET ?`, [topicId, limit, offset], ) 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, - ), deleted: r.deleted === 1, })) } catch { @@ -219,78 +338,12 @@ 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 { - const profile: ForumProfile = { - username, - postCount: 0, - topicCount: 0, - joindate: null, - gmlevel: 0, - status: null, - recentTopics: [], - } - try { - const [pc] = await db(DB.web).query( - '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( - '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( - '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( - '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( - 'SELECT MAX(SecurityLevel) AS lvl FROM account_access WHERE AccountID = ?', - [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 { - const like = `%${query}%` +/** Nº de posts (no borrados) de un usuario, para el «Posts: N» del perfil lateral. */ +export async function getUserPostCount(posterId: number): Promise { 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], + 'SELECT COUNT(*) AS n FROM forum_posts WHERE poster = ? AND deleted = 0', + [posterId], ) return Number(rows[0]?.n ?? 0) } catch { @@ -298,29 +351,48 @@ export async function countSearchTopics(query: string): Promise { } } -/** 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}%` +/** + * Resuelve un conjunto de AccountID a nombre + rango + antigüedad en una sola pasada. + * El nombre es el username de la cuenta de juego (acore_auth.account), sin el sufijo + * «#N» de Battle.net para que se lea mejor. Devuelve un Map por id. + */ +export async function resolvePosters(ids: number[]): Promise> { + const map = new Map() + const unique = [...new Set(ids.filter((n) => Number.isFinite(n) && n > 0))] + if (unique.length === 0) return map + + const placeholders = unique.map(() => '?').join(',') 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], + const [accs] = await db(DB.auth).query( + `SELECT id, username, joindate FROM account WHERE id IN (${placeholders})`, + unique, ) - 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, - })) + const [access] = await db(DB.auth).query( + `SELECT AccountID, MAX(SecurityLevel) AS lvl FROM account_access WHERE AccountID IN (${placeholders}) GROUP BY AccountID`, + unique, + ) + const gm = new Map() + for (const a of access) gm.set(Number(a.AccountID), Number(a.lvl ?? 0)) + + for (const a of accs) { + const gmlevel = gm.get(Number(a.id)) ?? 0 + map.set(Number(a.id), { + id: Number(a.id), + name: String(a.username || '').replace(/#\d+$/, '') || `#${a.id}`, + gmlevel, + isStaff: gmlevel >= STAFF_GMLEVEL, + joindate: a.joindate ? new Date(a.joindate).toISOString() : null, + postCount: 0, + }) + } } catch { - return [] + /* acore_auth no disponible: se devuelven placeholders abajo */ } + // Cualquier id no resuelto (cuenta borrada) recibe un placeholder. + for (const id of unique) { + if (!map.has(id)) { + map.set(id, { id, name: `#${id}`, gmlevel: 0, isStaff: false, joindate: null, postCount: 0 }) + } + } + return map } diff --git a/web-next/messages/en.json b/web-next/messages/en.json index c06aae4..5cbcb58 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -502,7 +502,19 @@ "memberSince": "Member since", "recentTopics": "Recent topics", "roleAdmin": "Administrator", - "roleGm": "GM" + "roleGm": "GM", + "topic": "topic", + "createdBy": "Created by", + "deleted": "Deleted", + "createTopic": "Create topic", + "topicTitle": "Topic title", + "postTopic": "Post topic", + "errTitle": "The title must be between 3 and 45 characters.", + "post": "Post", + "staff": "Staff", + "member": "Member", + "editPost": "Edit post", + "undelete": "Undelete" }, "Admin": { "title": "Admin panel", @@ -607,7 +619,14 @@ "categoryNameEn": "Name (English, optional)", "forumNameEn": "Forum name (English, optional)", "forumDescriptionEn": "Description (English, optional)", - "forumEnHint": "Leave English empty to fall back to Spanish on the English site." + "forumEnHint": "Leave English empty to fall back to Spanish on the English site.", + "forumType": "Type", + "forumTypeNormal": "Normal", + "forumTypeClass": "Class", + "forumTypeFlag": "Language (flag)", + "forumIcon": "Icon (name or path)", + "forumColor": "Title color (#hex)", + "forumNotEmpty": "Cannot delete a forum that has topics." }, "Vote": { "title": "Vote for us", diff --git a/web-next/messages/es.json b/web-next/messages/es.json index c0cedca..578ad2b 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -502,7 +502,19 @@ "memberSince": "Miembro desde", "recentTopics": "Temas recientes", "roleAdmin": "Administrador", - "roleGm": "GM" + "roleGm": "GM", + "topic": "tema", + "createdBy": "Creado por", + "deleted": "Borrado", + "createTopic": "Crear tema", + "topicTitle": "Título del tema", + "postTopic": "Publicar tema", + "errTitle": "El título debe tener entre 3 y 45 caracteres.", + "post": "Publicar", + "staff": "Staff", + "member": "Miembro", + "editPost": "Editar mensaje", + "undelete": "Restaurar" }, "Admin": { "title": "Panel de administración", @@ -607,7 +619,14 @@ "categoryNameEn": "Nombre (inglés, opcional)", "forumNameEn": "Nombre del foro (inglés, opcional)", "forumDescriptionEn": "Descripción (inglés, opcional)", - "forumEnHint": "Deja el inglés vacío para usar el español en la web en inglés." + "forumEnHint": "Deja el inglés vacío para usar el español en la web en inglés.", + "forumType": "Tipo", + "forumTypeNormal": "Normal", + "forumTypeClass": "Clase", + "forumTypeFlag": "Idioma (bandera)", + "forumIcon": "Icono (nombre o ruta)", + "forumColor": "Color del título (#hex)", + "forumNotEmpty": "No se puede borrar un foro con temas." }, "Vote": { "title": "Votar por nosotros", diff --git a/web-next/package-lock.json b/web-next/package-lock.json index 4661503..01819cb 100644 --- a/web-next/package-lock.json +++ b/web-next/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@next/third-parties": "^16.2.10", + "@tinymce/tinymce-react": "^6.3.0", "@types/qrcode": "^1.5.6", "iron-session": "^8.0.4", "mysql2": "^3.22.6", @@ -19,7 +20,8 @@ "react": "19.2.4", "react-dom": "19.2.4", "sanitize-html": "^2.17.6", - "stripe": "^22.3.1" + "stripe": "^22.3.1", + "tinymce": "^8.8.0" }, "devDependencies": { "@tailwindcss/postcss": "^4.3.2", @@ -2420,6 +2422,24 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/@tinymce/tinymce-react": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@tinymce/tinymce-react/-/tinymce-react-6.3.0.tgz", + "integrity": "sha512-E++xnn0XzDzpKr40jno2Kj7umfAE6XfINZULEBBeNjTMvbACWzA6CjiR6V8eTDc9yVmdVhIPqVzV4PqD5TZ/4g==", + "dependencies": { + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0", + "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0", + "tinymce": "^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.1" + }, + "peerDependenciesMeta": { + "tinymce": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -5656,8 +5676,7 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { "version": "4.3.0", @@ -6065,7 +6084,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -6421,7 +6439,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -6723,7 +6740,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -6797,8 +6813,7 @@ "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -7547,6 +7562,11 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinymce": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-8.8.0.tgz", + "integrity": "sha512-rdhwXLHDjUTJ42Cvq1tKRDvS4h8oeQSiWeNGx82AKNdOm8FGAqKObeEPa6amFGobTXHcoFtPZRtyLHO5zV5mZA==" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/web-next/package.json b/web-next/package.json index 47d5e90..43a66ce 100644 --- a/web-next/package.json +++ b/web-next/package.json @@ -6,10 +6,12 @@ "dev": "next dev -p 3001", "build": "next build", "start": "next start -p 3001", - "lint": "eslint" + "lint": "eslint", + "postinstall": "node scripts/copy-tinymce.mjs" }, "dependencies": { "@next/third-parties": "^16.2.10", + "@tinymce/tinymce-react": "^6.3.0", "@types/qrcode": "^1.5.6", "iron-session": "^8.0.4", "mysql2": "^3.22.6", @@ -20,7 +22,8 @@ "react": "19.2.4", "react-dom": "19.2.4", "sanitize-html": "^2.17.6", - "stripe": "^22.3.1" + "stripe": "^22.3.1", + "tinymce": "^8.8.0" }, "devDependencies": { "@tailwindcss/postcss": "^4.3.2", diff --git a/web-next/public/forum/flags2/bg.svg b/web-next/public/forum/flags2/bg.svg new file mode 100644 index 0000000..b69b738 --- /dev/null +++ b/web-next/public/forum/flags2/bg.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/web-next/public/forum/flags2/de.svg b/web-next/public/forum/flags2/de.svg new file mode 100644 index 0000000..737e329 --- /dev/null +++ b/web-next/public/forum/flags2/de.svg @@ -0,0 +1,37 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/web-next/public/forum/flags2/en.svg b/web-next/public/forum/flags2/en.svg new file mode 100644 index 0000000..5cc3fb1 --- /dev/null +++ b/web-next/public/forum/flags2/en.svg @@ -0,0 +1,52 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/web-next/public/forum/flags2/es.svg b/web-next/public/forum/flags2/es.svg new file mode 100644 index 0000000..57950a5 --- /dev/null +++ b/web-next/public/forum/flags2/es.svg @@ -0,0 +1,703 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web-next/public/forum/flags2/fr.svg b/web-next/public/forum/flags2/fr.svg new file mode 100644 index 0000000..7cc4ef8 --- /dev/null +++ b/web-next/public/forum/flags2/fr.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/web-next/public/forum/flags2/ir.svg b/web-next/public/forum/flags2/ir.svg new file mode 100644 index 0000000..a3e6bb7 --- /dev/null +++ b/web-next/public/forum/flags2/ir.svg @@ -0,0 +1,522 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web-next/public/forum/flags2/it.svg b/web-next/public/forum/flags2/it.svg new file mode 100644 index 0000000..46db837 --- /dev/null +++ b/web-next/public/forum/flags2/it.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/web-next/public/forum/flags2/pl.svg b/web-next/public/forum/flags2/pl.svg new file mode 100644 index 0000000..4e15e1d --- /dev/null +++ b/web-next/public/forum/flags2/pl.svg @@ -0,0 +1,16 @@ + + + + + + + image/svg+xml + + + + + + + + + diff --git a/web-next/public/forum/flags2/ro.svg b/web-next/public/forum/flags2/ro.svg new file mode 100644 index 0000000..eed5f95 --- /dev/null +++ b/web-next/public/forum/flags2/ro.svg @@ -0,0 +1,42 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/web-next/public/forum/flags2/ru.svg b/web-next/public/forum/flags2/ru.svg new file mode 100644 index 0000000..b73efdb --- /dev/null +++ b/web-next/public/forum/flags2/ru.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/web-next/public/forum/forum-seperators.png b/web-next/public/forum/forum-seperators.png new file mode 100644 index 0000000..bcb4c87 Binary files /dev/null and b/web-next/public/forum/forum-seperators.png differ diff --git a/web-next/public/forum/forum_bg_image.png b/web-next/public/forum/forum_bg_image.png new file mode 100644 index 0000000..1a4aa95 Binary files /dev/null and b/web-next/public/forum/forum_bg_image.png differ diff --git a/web-next/public/forum/forum_icons/blizz/battlegroup-large.gif b/web-next/public/forum/forum_icons/blizz/battlegroup-large.gif new file mode 100644 index 0000000..f829943 Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/battlegroup-large.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/battlegroup.gif b/web-next/public/forum/forum_icons/blizz/battlegroup.gif new file mode 100644 index 0000000..77c8dd0 Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/battlegroup.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/beer.gif b/web-next/public/forum/forum_icons/blizz/beer.gif new file mode 100644 index 0000000..1128b1c Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/beer.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/blizzard.gif b/web-next/public/forum/forum_icons/blizz/blizzard.gif new file mode 100644 index 0000000..2c088e6 Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/blizzard.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/blizzcon.gif b/web-next/public/forum/forum_icons/blizz/blizzcon.gif new file mode 100644 index 0000000..25a81a2 Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/blizzcon.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/blood.gif b/web-next/public/forum/forum_icons/blizz/blood.gif new file mode 100644 index 0000000..046f845 Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/blood.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/bloodelf.gif b/web-next/public/forum/forum_icons/blizz/bloodelf.gif new file mode 100644 index 0000000..a19c5ba Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/bloodelf.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/bnet.gif b/web-next/public/forum/forum_icons/blizz/bnet.gif new file mode 100644 index 0000000..2b1ab0c Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/bnet.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/bomb.gif b/web-next/public/forum/forum_icons/blizz/bomb.gif new file mode 100644 index 0000000..3cc5d1e Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/bomb.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/bugs.gif b/web-next/public/forum/forum_icons/blizz/bugs.gif new file mode 100644 index 0000000..85316a6 Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/bugs.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/bullet.gif b/web-next/public/forum/forum_icons/blizz/bullet.gif new file mode 100644 index 0000000..7007689 Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/bullet.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/cataclysm.gif b/web-next/public/forum/forum_icons/blizz/cataclysm.gif new file mode 100644 index 0000000..eb8ad2a Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/cataclysm.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/china-guild.gif b/web-next/public/forum/forum_icons/blizz/china-guild.gif new file mode 100644 index 0000000..9edcdb4 Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/china-guild.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/china-modify.gif b/web-next/public/forum/forum_icons/blizz/china-modify.gif new file mode 100644 index 0000000..6978463 Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/china-modify.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/cs.gif b/web-next/public/forum/forum_icons/blizz/cs.gif new file mode 100644 index 0000000..46f802a Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/cs.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/deathknight.gif b/web-next/public/forum/forum_icons/blizz/deathknight.gif new file mode 100644 index 0000000..cbe24c0 Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/deathknight.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/diablo2.gif b/web-next/public/forum/forum_icons/blizz/diablo2.gif new file mode 100644 index 0000000..343341b Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/diablo2.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/diablo3.gif b/web-next/public/forum/forum_icons/blizz/diablo3.gif new file mode 100644 index 0000000..1f791f5 Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/diablo3.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/dps.gif b/web-next/public/forum/forum_icons/blizz/dps.gif new file mode 100644 index 0000000..0880f76 Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/dps.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/dreanai.gif b/web-next/public/forum/forum_icons/blizz/dreanai.gif new file mode 100644 index 0000000..5a65c9f Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/dreanai.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/druid.gif b/web-next/public/forum/forum_icons/blizz/druid.gif new file mode 100644 index 0000000..4e047ae Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/druid.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/dungeons.gif b/web-next/public/forum/forum_icons/blizz/dungeons.gif new file mode 100644 index 0000000..e7c4f36 Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/dungeons.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/france.gif b/web-next/public/forum/forum_icons/blizz/france.gif new file mode 100644 index 0000000..103ef59 Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/france.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/general.gif b/web-next/public/forum/forum_icons/blizz/general.gif new file mode 100644 index 0000000..014d3cd Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/general.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/germany.gif b/web-next/public/forum/forum_icons/blizz/germany.gif new file mode 100644 index 0000000..5fa64ac Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/germany.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/guides.gif b/web-next/public/forum/forum_icons/blizz/guides.gif new file mode 100644 index 0000000..5108e09 Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/guides.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/guildrelations.gif b/web-next/public/forum/forum_icons/blizz/guildrelations.gif new file mode 100644 index 0000000..839cbfe Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/guildrelations.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/guilds.gif b/web-next/public/forum/forum_icons/blizz/guilds.gif new file mode 100644 index 0000000..6bb078a Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/guilds.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/heal.gif b/web-next/public/forum/forum_icons/blizz/heal.gif new file mode 100644 index 0000000..e044d4c Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/heal.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/horde-icon.gif b/web-next/public/forum/forum_icons/blizz/horde-icon.gif new file mode 100644 index 0000000..ce35d71 Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/horde-icon.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/hunter.gif b/web-next/public/forum/forum_icons/blizz/hunter.gif new file mode 100644 index 0000000..982818a Binary files /dev/null and b/web-next/public/forum/forum_icons/blizz/hunter.gif differ diff --git a/web-next/public/forum/forum_icons/blizz/index.php b/web-next/public/forum/forum_icons/blizz/index.php new file mode 100644 index 0000000..5abb376 --- /dev/null +++ b/web-next/public/forum/forum_icons/blizz/index.php @@ -0,0 +1,4 @@ + 'Druid') + +SET FOREIGN_KEY_CHECKS = 0; + +DROP TABLE IF EXISTS `forums`; +DROP TABLE IF EXISTS `forum_categories`; +DROP TABLE IF EXISTS `forum_forums`; +DROP TABLE IF EXISTS `forum_topics`; +DROP TABLE IF EXISTS `forum_posts`; + +CREATE TABLE `forum_categories` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `order` int(11) NOT NULL DEFAULT 0, + `name` varchar(45) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; + +CREATE TABLE `forum_forums` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `category` int(11) NOT NULL, + `order` int(11) NOT NULL DEFAULT 0, + `name` varchar(45) NOT NULL, + `description` text NOT NULL, + `icon` varchar(45) DEFAULT NULL, + `colortitle` varchar(45) DEFAULT NULL, + `type` int(11) NOT NULL DEFAULT 1, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; + +CREATE TABLE `forum_topics` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `forum` int(11) NOT NULL, + `name` varchar(45) NOT NULL, + `sticky` tinyint(4) NOT NULL DEFAULT 0, + `locked` tinyint(4) NOT NULL DEFAULT 0, + `deleted` tinyint(4) NOT NULL DEFAULT 0, + `created` timestamp NOT NULL DEFAULT (now()), + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; + +CREATE TABLE `forum_posts` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `topic` int(11) NOT NULL, + `poster` int(11) NOT NULL DEFAULT 0, + `text` text NOT NULL, + `time` timestamp NOT NULL DEFAULT current_timestamp(), + `deleted` tinyint(4) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +INSERT INTO `forum_categories` (`id`, `order`, `name`) VALUES + (1, 1, 'News'), + (2, 2, 'Reports'), + (3, 3, 'General'), + (4, 4, 'Community Forums'), + (5, 5, 'Localized Forums'), + (6, 6, 'Class Discussions'); + +INSERT INTO `forum_forums` (`id`, `category`, `order`, `name`, `description`, `icon`, `colortitle`, `type`) VALUES + (1, 1, 0, 'Latest News', 'Latest news for the community.', 'forum_read', '0', 0), + (2, 1, 0, 'Rules and Regulations', 'Laws to abide by to keep your game experience intact.', 'forum_read', '0', 0), + (3, 2, 0, 'Appeal a Ban', 'Appeal a ban if you think you where wrongly banned.', 'forum_read', '0', 0), + (4, 2, 0, 'Report a Player', 'Report a player/s for breaking rules in-game.', 'forum_read', '0', 0), + (5, 2, 0, 'Report a Gm/Dev', 'Report a Gm/Dev.', 'forum_read', '0', 0), + (6, 3, 0, 'General Discussion', 'For anything and everything this server related.', 'forum_read', '0', 0), + (7, 3, 0, 'Suggestions', 'Give us your thoughts on anything to do with the server.', 'forum_read', '0', 0), + (8, 3, 0, 'Guides & Tutorials', 'Create guides to help the community.', 'forum_read', '0', 0), + (9, 3, 0, 'Support & Q/A', 'Having trouble with something or just want to help players out?', 'forum_read', '0', 0), + (10, 3, 0, 'Addons', 'Helpful and interesting Addons.', 'forum_read', '0', 0), + (11, 3, 0, 'Guild Recruitment', '', 'forum_read', '0', 0), + (12, 4, 0, 'Introduce Yourself', 'New player? Tell us a bit about yourself.', 'forum_read', '0', 0), + (13, 4, 0, 'Dungeons and Raids', 'Everything about raiding, organization and shared experience.', 'forum_read', '0', 0), + (14, 4, 0, 'World PvP, Arenas & Battlegrounds', 'Discuss builds, city raids, arenas and battlegrounds.', 'forum_read', '0', 0), + (15, 4, 0, 'Specific Discussions', 'Discussion about your favorite Faction, Roleplay and ...', 'forum_read', '0', 0), + (16, 5, 0, 'English', '', '/forum/flags2/en.svg', '0', 2), + (17, 5, 0, 'Persian', '', '/forum/flags2/ir.svg', '0', 2), + (18, 5, 0, 'Romania', '', '/forum/flags2/ro.svg', '0', 2), + (19, 5, 0, 'Russian', '', '/forum/flags2/ru.svg', '0', 2), + (20, 5, 0, 'Bulgaria', '', '/forum/flags2/bg.svg', '0', 2), + (21, 5, 0, 'Spanish', '', '/forum/flags2/es.svg', '0', 2), + (22, 5, 0, 'French', '', '/forum/flags2/fr.svg', '0', 2), + (23, 5, 0, 'German', '', '/forum/flags2/de.svg', '0', 2), + (24, 5, 0, 'Italian', '', '/forum/flags2/it.svg', '0', 2), + (25, 5, 0, 'Polish', '', '/forum/flags2/pl.svg', '0', 2), + (26, 6, 0, 'Death Knight', '', 'deathknight', '#C41E3A', 1), + (27, 6, 0, 'Demon Hunter', '', 'demonhunter', '#A330C9', 1), + (28, 6, 0, 'Druid', '', 'druid', '#FF7C0A', 1), + (29, 6, 0, 'Evoker', '', 'evoker', '#33937F', 1), + (30, 6, 0, 'Hunter', '', 'hunter', '#AAD372', 1), + (31, 6, 0, 'Mage', '', 'mage', '#3FC7EB', 1), + (32, 6, 0, 'Monk', '', 'monk', '#00FF98', 1), + (33, 6, 0, 'Paladin', '', 'paladin', '#F48CBA', 1), + (34, 6, 0, 'Priest', '', 'priest', '#FFFFFF', 1), + (35, 6, 0, 'Rogue', '', 'rogue', '#FFF468', 1), + (36, 6, 0, 'Shaman', '', 'shaman', '#0070DD', 1), + (37, 6, 0, 'Warlock', '', 'warlock', '#8788EE', 1), + (38, 6, 0, 'Warrior', '', 'warrior', '#C69B6D', 1); + +SET FOREIGN_KEY_CHECKS = 1;