Foro: reemplazar el foro anterior por el port del foro estilo FusionCMS

Se sustituye por completo el foro anterior de Next (que estaba vacío: 0 temas, 0
posts) por una adaptación del foro PHP de FusionCMS que tenía el usuario, con su
estructura, sus imágenes y su estética, llevadas a Next.

BASE DE DATOS (acore_web), esquema idéntico al original (sql/forum_fusion.sql):
  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)
Se hizo DROP de las tablas antiguas (forums, forum_categories, forum_topics,
forum_posts) y CREATE de estas, con el seed original (News/Reports/General,
foros con icono, clases con color, subforos por idioma con bandera). Única
diferencia con el dump: forum_topics.created, que faltaba y usan las vistas, y
el recorte del salto de línea final en los nombres de clase.

Los temas no guardan autor ni última actividad: se derivan del primer/último post,
como en el original. `poster` es el AccountID de acore_auth; el nombre se resuelve
al render (username sin el sufijo #N de Battle.net). El texto se guarda como HTML
saneado con nh3 (no BBCode) para encajar con el editor.

EDITOR: TinyMCE community self-hosted (licenseKey gpl), servido desde /tinymce
(scripts/copy-tinymce.mjs en postinstall; public/tinymce en .gitignore por ser
artefacto). El HTML se sanea SIEMPRE en el servidor.

PÁGINAS (app/[locale]/forum): índice con los 3 tipos de foro (fila normal, tarjeta
de clase tipo 1, tarjeta de idioma tipo 2), subforo, tema, crear y editar. La
lógica de forum.js (crear/responder/editar/moderar) se portó a fetch + los avisos
del sitio, sin jQuery/SweetAlert, y la moderación pasa por POST (no GET).

CSS: forum.css adaptado al tema (app/forum.css), rutas de imagen a /forum, dorado
alineado al acento del sitio (#d79602), y se aportan .nice_button/.main-wide/
.pagination que no estaban en theme.css.

ADMIN: lib/admin-forum.ts, sus rutas y AdminForumManager reescritos al esquema
nuevo (categoría, icono, color, tipo, orden; sin _en/visibility).

Se eliminan /forum/search y /forum/user (no existen en el foro nuevo) y los
componentes que quedaban huérfanos (ForumSearchBox, PostActions, TopicModBar,
NewTopicForm). i18n ES/EN completado (next-intl).

Verificado en producción: el índice renderiza en es/en con categorías, clases,
banderas e iconos; los assets y TinyMCE sirven 200; crear tema y responder guardan
las filas con el esquema correcto y las páginas las muestran (autor resuelto, HTML
saneado). Datos de prueba borrados: el foro arranca vacío.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 12:22:08 +00:00
parent 0f98b754d4
commit af707ad98c
190 changed files with 3142 additions and 1104 deletions
+3
View File
@@ -39,3 +39,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# TinyMCE self-hosted (copiado en postinstall desde node_modules)
/public/tinymce/
+82 -45
View File
@@ -1,17 +1,19 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { getTranslations, setRequestLocale } from 'next-intl/server' import { getTranslations, setRequestLocale } from 'next-intl/server'
import { Link } from '@/i18n/navigation' 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 { getSession } from '@/lib/session'
import { forumIsModerator } from '@/lib/forum-perm'
import { PageShell } from '@/components/PageShell' import { PageShell } from '@/components/PageShell'
import { NewTopicForm } from '@/components/NewTopicForm' import { ForumBreadcrumb } from '@/components/ForumBreadcrumb'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
const PER_PAGE = 20 const PER_PAGE = 10
export default async function ForumPage({ export default async function SubforumPage({
params, params,
searchParams, searchParams,
}: { }: {
@@ -23,58 +25,93 @@ export default async function ForumPage({
const t = await getTranslations('Forum') const t = await getTranslations('Forum')
const id = Number(forumId) const id = Number(forumId)
const forum = await getForum(id, locale) const forum = await getForum(id)
if (!forum) notFound() if (!forum) notFound()
const session = await getSession()
const isMod = await forumIsModerator(session)
const canCreate = Boolean(session.username)
const total = await countTopics(id) const total = await countTopics(id)
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)) const totalPages = Math.max(1, Math.ceil(total / PER_PAGE))
const page = Math.min(Math.max(1, Number((await searchParams).page) || 1), totalPages) 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 topics = await getTopics(id, (page - 1) * PER_PAGE, PER_PAGE, isMod)
const session = await getSession()
const canPost = Boolean(session.username) 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 ( return (
<PageShell title={forum!.name}> <PageShell title={forum.name}>
<div className="box-content"> <div className="main-wide">
<div className="body-box-content"> {path && <ForumBreadcrumb items={[{ label: t('title'), href: '/forum' }, { label: path.forum }]} />}
<p> <div className="forum-padding">
<Link href="/forum"> {t('backToForum')}</Link> <div className="forum_header">
</p> <div className="forum_title">
{forum!.description && <p className="second-brown">{forum!.description}</p>} <h1>{forum.name}</h1>
<br /> {forum.description && <h3>{forum.description}</h3>}
</div>
<h4>
<b>{total}</b> {t('topics')}
</h4>
</div>
<div className="actions_c">
{canCreate ? (
<Link href={`/forum/create/${id}`} className="nice_button">
{t('createTopic')}
</Link>
) : (
<span />
)}
<Pagination page={page} totalPages={totalPages} hrefFor={hrefFor} />
</div>
{topics.length === 0 ? ( {topics.length === 0 ? (
<p className="second-brown centered">{t('noTopics')}</p> <h2 className="forum-empty">{t('noTopics')}</h2>
) : ( ) : (
<table className="max-center-table"> topics.map((tp) => (
<tbody> <ul className="topic_row" key={tp.id}>
{topics.map((topic) => ( <li className="icon">
<tr key={topic.id} className="team-center-table-tr"> <img
<td className="lefted separate"> src={`/forum/forum_icons/topic_${tp.locked ? 'read_locked' : tp.deleted ? 'read_mine' : 'unread'}.png`}
<Link href={`/forum/topic/${topic.id}`} className="yellow-info"> height={39}
{topic.sticky && <span className="second-yellow small-font">[{t('sticky')}] </span>} width={55}
{topic.locked && <span className="red-info2 small-font">[{t('locked')}] </span>} alt=""
{topic.name} />
</Link> </li>
<p className="third-brown small-font"> <li className="topic_title_by_date">
{t('by')} {topic.poster} <h1>
</p> <Link href={`/forum/topic/${tp.id}`}>
</td> {tp.deleted && `[${t('deleted')}] `}
<td className="real-info-box no-wrap-td second-brown small-font"> {tp.sticky && `[${t('sticky')}] `}
{topic.views} {t('views')} {tp.locked && `[${t('locked')}] `}
</td> {tp.name}
</tr> </Link>
))} </h1>
</tbody> <p>
</table> {t('createdBy')} <span className="username">{name(tp.poster)}</span>,{' '}
{formatForumDate(tp.created, locale)}
</p>
</li>
<li className="lastpost">
<h4>
{t('by')} {name(tp.last_poster)}
</h4>
<h5>{formatForumDate(tp.time_updated, locale)}</h5>
</li>
</ul>
))
)} )}
<Pagination page={page} totalPages={totalPages} hrefFor={(p) => `/forum/${id}?page=${p}`} /> <div className="actions_c" style={{ marginTop: 20 }}>
<span />
{canPost ? ( <Pagination page={page} totalPages={totalPages} hrefFor={hrefFor} />
<NewTopicForm forumId={id} /> </div>
) : (
<p className="second-brown centered separate">{t('loginToPost')}</p>
)}
</div> </div>
</div> </div>
</PageShell> </PageShell>
@@ -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 (
<PageShell title={t('createTopic')}>
<div className="main-wide">
{path && (
<ForumBreadcrumb
items={[
{ label: t('title'), href: '/forum' },
{ label: path.forum, href: `/forum/${id}` },
{ label: t('createTopic') },
]}
/>
)}
<div className="forum_header create_header">
<div className="forum_title">
<h1>{forum.name}</h1>
{forum.description && <h3>{forum.description}</h3>}
</div>
</div>
<CreateTopicForm forumId={id} />
</div>
</PageShell>
)
}
@@ -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 (
<PageShell title={t('editPost')}>
<div className="main-wide">
{path && (
<ForumBreadcrumb
items={[
{ label: t('title'), href: '/forum' },
{ label: path.forum, href: `/forum/${path.forum_id}` },
{ label: path.topic, href: `/forum/topic/${post.topic}` },
{ label: t('editPost') },
]}
/>
)}
<div className="forum_header create_header">
<div className="forum_title">
<h1>{topic?.name}</h1>
{path && <h3>{path.forum}</h3>}
</div>
</div>
<EditPostPageForm postId={id} topicId={post.topic} initialText={post.text} />
</div>
</PageShell>
)
}
+6
View File
@@ -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
}
+112 -44
View File
@@ -1,8 +1,8 @@
import { getTranslations, setRequestLocale } from 'next-intl/server' import { getTranslations, setRequestLocale } from 'next-intl/server'
import { Link } from '@/i18n/navigation' 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 { PageShell } from '@/components/PageShell'
import { ForumSearchBox } from '@/components/ForumSearchBox'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -10,53 +10,121 @@ export default async function ForumIndexPage({ params }: { params: Promise<{ loc
const { locale } = await params const { locale } = await params
setRequestLocale(locale) setRequestLocale(locale)
const t = await getTranslations('Forum') 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 (
<div className="forum-row">
<div className="icon">
<Link href={`/forum/${f.id}`}>
<img src={iconSrc} height={53} width={56} alt={f.name} />
</Link>
</div>
<div className="forum_title_desc">
<Link href={`/forum/${f.id}`}>
<h1 style={f.colortitle && f.colortitle !== '0' ? { color: f.colortitle } : undefined}>{f.name}</h1>
{f.description && <h2>{f.description}</h2>}
</Link>
</div>
<div className="post" title={t('posts')}>
<p>{f.post_count}</p>
</div>
<div className="topics" title={t('topics')}>
<p>{f.topic_count}</p>
</div>
{f.last_topic_id && (
<div className="lastpost">
<p className="topic_title">
<Link href={`/forum/topic/${f.last_topic_id}`}>{f.last_topic_name}</Link>
</p>
{f.last_poster != null && (
<p className="by">{posters.get(f.last_poster)?.name ?? `#${f.last_poster}`}</p>
)}
<p className="postdate">{formatForumDate(f.last_time, locale)}</p>
</div>
)}
</div>
)
}
function ClassTile({ f }: { f: ForumRow }) {
return (
<div className={`class-row ${f.icon ?? ''}`}>
<div className="icon">
<Link href={`/forum/${f.id}`}>
<div className="image_icon" />
</Link>
</div>
<div className="info">
<Link href={`/forum/${f.id}`}>
<h1 style={f.colortitle && f.colortitle !== '0' ? { color: f.colortitle } : undefined}>{f.name}</h1>
<h2>{topicsLabel(f.topic_count)}</h2>
</Link>
</div>
</div>
)
}
function FlagTile({ f }: { f: ForumRow }) {
return (
<div className="class-row">
<div className="icon">
<Link href={`/forum/${f.id}`}>
<img className="image_icon" src={f.icon || ''} height={36} width={36} alt={f.name} />
</Link>
</div>
<div className="info">
<Link href={`/forum/${f.id}`}>
<h1>{f.name}</h1>
<h2>{topicsLabel(f.topic_count)}</h2>
</Link>
</div>
</div>
)
}
return ( return (
<PageShell title={t('title')}> <PageShell title={t('title')}>
<div className="box-content"> <div className="forum-container">
<div className="body-box-content"> {categories.length === 0 ? (
<ForumSearchBox /> <p className="forum-empty">{t('noForums')}</p>
{categories.length === 0 ? ( ) : (
<p className="second-brown centered">{t('noForums')}</p> categories.map((cat) => {
) : ( const tiles = cat.forums.filter((f) => f.type === 1 || f.type === 2)
categories.map((cat) => ( const rows = cat.forums.filter((f) => f.type !== 1 && f.type !== 2)
<div key={cat.id}> return (
<div className="title-content-h3"> <div key={cat.id} className="forum-category" style={{ marginBottom: 40 }}>
<h3 className="big-font">{cat.name}</h3> <div className="category-title">{cat.name}</div>
{tiles.length > 0 && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: 12,
marginBottom: rows.length ? 12 : 0,
}}
>
{tiles.map((f) => (f.type === 1 ? <ClassTile key={f.id} f={f} /> : <FlagTile key={f.id} f={f} />))}
</div>
)}
<div style={{ display: 'grid', gap: 8 }}>
{rows.map((f) => (
<NormalRow key={f.id} f={f} />
))}
</div> </div>
<table className="max-center-table">
<tbody>
{cat.forums.map((f) => (
<tr key={f.id} className="team-center-table-tr">
<td className="lefted separate">
<Link href={`/forum/${f.id}`} className="yellow-info">
{f.name}
</Link>
{f.description && <p className="second-brown small-font">{f.description}</p>}
</td>
<td className="real-info-box no-wrap-td">
<span className="second-brown small-font">
{f.topic_count} {t('topics')} · {f.post_count} {t('posts')}
</span>
{f.last_topic_id && (
<p className="small-font">
<Link href={`/forum/topic/${f.last_topic_id}`}>{f.last_topic_name}</Link>{' '}
<span className="third-brown">
{t('by')} {f.last_topic_poster}
</span>
</p>
)}
</td>
</tr>
))}
</tbody>
</table>
<br />
</div> </div>
)) )
)} })
</div> )}
</div> </div>
</PageShell> </PageShell>
) )
@@ -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 (
<PageShell title={t('search')}>
<div className="box-content">
<div className="body-box-content">
<p>
<Link href="/forum"> {t('backToForum')}</Link>
</p>
<br />
<ForumSearchBox initial={query} />
{!valid ? (
<p className="second-brown centered">{t('searchHint')}</p>
) : results.length === 0 ? (
<p className="second-brown centered">{t('noResults', { query })}</p>
) : (
<>
<p className="second-brown">{t('resultsCount', { count: total, query })}</p>
<table className="max-center-table">
<tbody>
{results.map((r) => (
<tr key={r.id} className="team-center-table-tr">
<td className="lefted separate">
<Link href={`/forum/topic/${r.id}`} className="yellow-info">
{r.name}
</Link>
<p className="third-brown small-font">
{t('in')} <Link href={`/forum/${r.forum_id}`}>{r.forum_name}</Link> · {t('by')} {r.poster}
</p>
</td>
{r.updated_at && (
<td className="real-info-box no-wrap-td third-brown small-font">
{new Date(r.updated_at).toLocaleDateString(locale)}
</td>
)}
</tr>
))}
</tbody>
</table>
</>
)}
</div>
</div>
</PageShell>
)
}
@@ -1,18 +1,26 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { getTranslations, setRequestLocale } from 'next-intl/server' import { getTranslations, setRequestLocale } from 'next-intl/server'
import { Link } from '@/i18n/navigation' import {
import { getTopic, getPosts, countPosts, listForumsForMove } from '@/lib/forum' getTopic,
getTopicPath,
getPosts,
countPosts,
resolvePosters,
getUserPostCount,
} from '@/lib/forum'
import { formatForumDate } from '@/lib/forum-format'
import { getSession } from '@/lib/session' import { getSession } from '@/lib/session'
import { forumIsModerator, canEditPost } from '@/lib/forum-perm' import { forumIsModerator, canEditPost } from '@/lib/forum-perm'
import { PageShell } from '@/components/PageShell' import { PageShell } from '@/components/PageShell'
import { ReplyForm } from '@/components/ReplyForm' import { ForumBreadcrumb } from '@/components/ForumBreadcrumb'
import { PostActions } from '@/components/PostActions'
import { TopicModBar } from '@/components/TopicModBar'
import { Pagination } from '@/components/Pagination' 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' export const dynamic = 'force-dynamic'
const PER_PAGE = 15 const PER_PAGE = 5
export default async function TopicPage({ export default async function TopicPage({
params, params,
@@ -30,77 +38,136 @@ export default async function TopicPage({
const isMod = await forumIsModerator(session) const isMod = await forumIsModerator(session)
const topic = await getTopic(id, isMod) const topic = await getTopic(id, isMod)
if (!topic) notFound() if (!topic) notFound()
const total = await countPosts(id, isMod) const total = await countPosts(id, isMod)
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)) const totalPages = Math.max(1, Math.ceil(total / PER_PAGE))
const page = Math.min(Math.max(1, Number((await searchParams).page) || 1), totalPages) 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 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 = ( const posters = await resolvePosters(posts.map((p) => p.poster))
<> // Nº de posts por autor visible (para el panel lateral), en paralelo.
{topic!.sticky && <span className="second-yellow">[{t('pinned')}] </span>} const uniquePosters = [...new Set(posts.map((p) => p.poster))]
{topic!.locked && <span className="red-info2">[{t('locked')}] </span>} const counts = new Map<number, number>(
{topic!.deleted && <span className="red-info2">[{t('deletedMark')}] </span>} await Promise.all(uniquePosters.map(async (pid) => [pid, await getUserPostCount(pid)] as const)),
{topic!.name}
</>
) )
const path = await getTopicPath(id)
const canReply = Boolean(session.username) && (!topic.locked || isMod)
return ( return (
<PageShell title={title}> <PageShell
<div className="box-content"> title={
<div className="body-box-content"> <>
<p> {topic.sticky && `[${t('sticky')}] `}
<Link href="/forum"> {t('backToForum')}</Link> {topic.locked && `[${t('locked')}] `}
</p> {topic.deleted && `[${t('deleted')}] `}
<br /> {topic.name}
</>
}
>
<div className="main-wide">
{path && (
<ForumBreadcrumb
items={[
{ label: t('title'), href: '/forum' },
{ label: path.forum, href: `/forum/${path.forum_id}` },
{ label: path.topic },
]}
/>
)}
{isMod && ( <div className="topic_header">
<TopicModBar <div className="topic_title">
topicId={id} <h1>{topic.name}</h1>
locked={topic!.locked} <h3>{formatForumDate(topic.created, locale)}</h3>
sticky={topic!.sticky} </div>
deleted={topic!.deleted} <h4>
moveForums={moveForums} <b>{total}</b> {t('posts')}
/> </h4>
)}
{posts.map((post) => (
<div key={post.id} className="inline-div" style={{ display: 'block', opacity: post.deleted ? 0.5 : 1 }}>
<div className="lefted">
<span className="yellow-info">
<Link href={`/forum/user/${encodeURIComponent(post.poster)}`}>{post.poster}</Link>
{post.deleted && <span className="red-info2 small-font"> [{t('deletedMark')}]</span>}
</span>
{post.time && (
<span className="date third-brown small-font">
{new Date(post.time).toLocaleString(locale)}
{post.edited && <span className="grey-info2"> · {t('editedMark')}</span>}
</span>
)}
</div>
<hr />
<div className="post-content" dangerouslySetInnerHTML={{ __html: post.text }} />
{canEditPost(session, isMod, post.poster_id) && (
<PostActions postId={post.id} initialText={post.text} deleted={post.deleted} />
)}
</div>
))}
<Pagination page={page} totalPages={totalPages} hrefFor={(p) => `/forum/topic/${id}?page=${p}`} />
{canReply ? (
page === totalPages ? (
<ReplyForm topicId={id} />
) : (
<p className="separate">
<Link href={`/forum/topic/${id}?page=${totalPages}`}>{t('replyOnLastPage')}</Link>
</p>
)
) : (
!session.username && <p className="second-brown centered separate">{t('loginToPost')}</p>
)}
</div> </div>
<div className="actions_c">
<span />
<Pagination page={page} totalPages={totalPages} hrefFor={(p) => `/forum/topic/${id}?page=${p}`} />
</div>
{posts.map((post) => {
const author = posters.get(post.poster)
const isStaff = author?.isStaff ?? false
return (
<div key={post.id} className={`topic_post${isStaff ? ' isStaff' : ''}${post.deleted ? ' post_deleted' : ''}`}>
<div className="left_side">
<div className="username_container">
<span className="username">{author?.name ?? `#${post.poster}`}</span>
</div>
<div className={`user_avatar${isStaff ? ' isStaff' : ''}`}>
<span />
</div>
<div className="user_info">
<h3 className="post_field">
<dt>
<i className="fa-solid fa-user-shield fa-fw" />
</dt>
<dd className={isStaff ? 'badge_staff' : undefined}>{isStaff ? t('staff') : t('member')}</dd>
</h3>
<h3 className="post_field">
<dt>
<i className="fa-solid fa-calendar fa-fw" />
</dt>
<dd>{formatForumDate(author?.joindate ?? null, locale) || '—'}</dd>
</h3>
<h3 className="post_field">
<dt>
<i className="fa-solid fa-comments fa-fw" />
</dt>
<dd>
{t('posts')}: {counts.get(post.poster) ?? 0}
</dd>
</h3>
</div>
</div>
<div className="right_side">
<div className="post_container" dangerouslySetInnerHTML={{ __html: post.text }} />
<ul className="post_controls">
<li className="post_date">{formatForumDate(post.time, locale)}</li>
{canEditPost(session, isMod, post.poster) && (
<li>
<EditPostForm postId={post.id} initialText={post.text} />
</li>
)}
{isMod && (
<li>
<ForumModActions kind="post" id={post.id} deleted={post.deleted} />
</li>
)}
</ul>
</div>
</div>
)
})}
<div className="actions_c">
<span />
<Pagination page={page} totalPages={totalPages} hrefFor={(p) => `/forum/topic/${id}?page=${p}`} />
</div>
{isMod && (
<div className="forum-form-actions" style={{ marginBottom: 20 }}>
<ForumModActions kind="topic" id={id} deleted={topic.deleted} locked={topic.locked} />
</div>
)}
{canReply ? (
page === totalPages ? (
<ReplyForm topicId={id} />
) : (
<p className="forum-empty">
<a href={`/forum/topic/${id}?page=${totalPages}`}>{t('replyOnLastPage')}</a>
</p>
)
) : (
!session.username && <p className="forum-empty">{t('loginToPost')}</p>
)}
</div> </div>
</PageShell> </PageShell>
) )
@@ -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 && (
<span className="second-yellow small-font"> [{p.status === 'admin' ? t('roleAdmin') : t('roleGm')}]</span>
)}
</>
)
return (
<PageShell title={title}>
<div className="box-content">
<div className="body-box-content">
<p>
<Link href="/forum"> {t('backToForum')}</Link>
</p>
<br />
<table className="max-center-table info-box-light">
<tbody>
<tr>
<td className="centered separate">
<span className="second-brown">{t('posts')}</span>
<br />
<span className="yellow-info big-font">{p.postCount}</span>
</td>
<td className="centered separate">
<span className="second-brown">{t('topics')}</span>
<br />
<span className="yellow-info big-font">{p.topicCount}</span>
</td>
<td className="centered separate">
<span className="second-brown">{t('memberSince')}</span>
<br />
<span className="yellow-info">
{p.joindate ? new Date(p.joindate).toLocaleDateString(locale) : '—'}
</span>
</td>
</tr>
</tbody>
</table>
{p.recentTopics.length > 0 && (
<>
<div className="title-content-h3">
<h3 className="big-font">{t('recentTopics')}</h3>
</div>
<table className="max-center-table">
<tbody>
{p.recentTopics.map((topic) => (
<tr key={topic.id} className="team-center-table-tr">
<td className="lefted separate">
<Link href={`/forum/topic/${topic.id}`} className="yellow-info">
{topic.name}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
</div>
</PageShell>
)
}
+19 -22
View File
@@ -1,23 +1,13 @@
import { getSession } from '@/lib/session' import { getSession } from '@/lib/session'
import { isAdmin } from '@/lib/admin' 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 }> }) { export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession() const session = await getSession()
if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 }) if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
const { id } = await params const { id } = await params
await deleteForum(Number(id)) const ok = await deleteForum(Number(id))
return Response.json({ success: true }) return Response.json({ success: ok, error: ok ? undefined : 'forumNotEmpty' })
}
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<string, unknown> = {}
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 })
} }
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { 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 }) if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
const { id } = await params const { id } = await params
let b: Record<string, unknown> = {} let b: Record<string, unknown> = {}
try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) } try {
const name = String(b.name ?? '').trim() b = await request.json()
const nameEn = String(b.nameEn ?? '').trim() || null } catch {
const description = String(b.description ?? '').trim() return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
const descriptionEn = String(b.descriptionEn ?? '').trim() || null }
const order = Number(b.order) || 0 const f = {
const visibility = b.visibility === false ? 0 : 1 category: Number(b.category),
if (!name) return Response.json({ success: false, error: 'emptyError' }) name: String(b.name ?? '').trim(),
await updateForum(Number(id), name, nameEn, description, descriptionEn, order, visibility) 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 }) return Response.json({ success: true })
} }
@@ -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 }) if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
const { id } = await params const { id } = await params
let b: Record<string, unknown> = {} let b: Record<string, unknown> = {}
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 name = String(b.name ?? '').trim()
const nameEn = String(b.nameEn ?? '').trim() || null
const order = Number(b.order) || 0 const order = Number(b.order) || 0
if (!name) return Response.json({ success: false, error: 'emptyError' }) 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 }) return Response.json({ success: true })
} }
@@ -6,11 +6,14 @@ export async function POST(request: Request) {
const session = await getSession() const session = await getSession()
if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 }) if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
let b: Record<string, unknown> = {} let b: Record<string, unknown> = {}
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 name = String(b.name ?? '').trim()
const nameEn = String(b.nameEn ?? '').trim() || null
const order = Number(b.order) || 0 const order = Number(b.order) || 0
if (!name) return Response.json({ success: false, error: 'emptyError' }) 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 }) return Response.json({ success: true, id })
} }
+16 -10
View File
@@ -6,15 +6,21 @@ export async function POST(request: Request) {
const session = await getSession() const session = await getSession()
if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 }) if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
let b: Record<string, unknown> = {} let b: Record<string, unknown> = {}
try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) } try {
const categoryId = Number(b.categoryId) b = await request.json()
const name = String(b.name ?? '').trim() } catch {
const nameEn = String(b.nameEn ?? '').trim() || null return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
const description = String(b.description ?? '').trim() }
const descriptionEn = String(b.descriptionEn ?? '').trim() || null const f = {
const order = Number(b.order) || 0 category: Number(b.category),
const visibility = b.visibility === false ? 0 : 1 name: String(b.name ?? '').trim(),
if (!categoryId || !name) return Response.json({ success: false, error: 'emptyError' }) description: String(b.description ?? '').trim(),
const id = await createForum(categoryId, name, nameEn, description, descriptionEn, order, visibility) 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 }) return Response.json({ success: true, id })
} }
+35 -36
View File
@@ -1,55 +1,54 @@
import { getSession } from '@/lib/session' import { getSession } from '@/lib/session'
import { getTopic, getForum } from '@/lib/forum' import { getTopic } from '@/lib/forum'
import { setTopicFlag, moveTopic } from '@/lib/forum-write' import { getPost, setTopicFlag, setPostDeleted } from '@/lib/forum-write'
import { forumIsModerator } from '@/lib/forum-perm' import { forumIsModerator } from '@/lib/forum-perm'
type Action = 'lock' | 'unlock' | 'sticky' | 'unsticky' | 'delete' | 'restore' | 'move' /**
* Moderación del foro (solo moderadores), por POST para no mutar con GET.
/** Moderación de temas: bloquear, fijar, borrar. Solo moderadores. */ * kind='post' → action: delete | undelete
* kind='topic' → action: delete | undelete | lock | unlock
*/
export async function POST(request: Request) { export async function POST(request: Request) {
const session = await getSession() const session = await getSession()
if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 }) 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 }) if (!(await forumIsModerator(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
let b: Record<string, string> = {} let b: Record<string, string> = {}
try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) } try {
const topicId = Number(b.topicId) b = await request.json()
const action = String(b.action ?? '') as Action } catch {
if (!topicId) return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) 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 }) if (!topic) return Response.json({ success: false, error: 'topicNotFound' }, { status: 404 })
switch (action) { switch (action) {
case 'delete':
await setTopicFlag(id, 'deleted', true)
break
case 'undelete':
await setTopicFlag(id, 'deleted', false)
break
case 'lock': case 'lock':
await setTopicFlag(topicId, 'locked', true) await setTopicFlag(id, 'locked', true)
break break
case 'unlock': case 'unlock':
await setTopicFlag(topicId, 'locked', false) await setTopicFlag(id, 'locked', false)
break 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: default:
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
} }
+11 -44
View File
@@ -1,25 +1,10 @@
import { getSession } from '@/lib/session' 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 { getTopic } from '@/lib/forum'
import { forumIsModerator, canEditPost } from '@/lib/forum-perm' import { forumIsModerator, canEditPost } from '@/lib/forum-perm'
import { plainLength } from '@/lib/forum-sanitize' import { plainLength } from '@/lib/forum-sanitize'
const MIN_TEXT_LEN = 5 const MIN_TEXT_LEN = 3
/** 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<string, string> = {}
try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) }
const postId = Number(b.postId)
if (!postId) return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
await setPostDeleted(postId, false)
return Response.json({ success: true })
}
/** Editar el texto de un post propio (o cualquiera si es moderador). */ /** Editar el texto de un post propio (o cualquiera si es moderador). */
export async function PATCH(request: Request) { 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 }) if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
let b: Record<string, string> = {} let b: Record<string, string> = {}
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 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 }) if (!postId) return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
const post = await getPost(postId) const post = await getPost(postId)
if (!post) return Response.json({ success: false, error: 'postNotFound' }, { status: 404 }) if (!post) return Response.json({ success: false, error: 'postNotFound' }, { status: 404 })
const isMod = await forumIsModerator(session) 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 (topic?.locked && !isMod) return Response.json({ success: false, error: 'topicLocked' })
if (plainLength(text) < MIN_TEXT_LEN) return Response.json({ success: false, error: 'tooShort' }) if (plainLength(text) < MIN_TEXT_LEN) return Response.json({ success: false, error: 'tooShort' })
await updatePost(postId, text) await updatePost(postId, text)
return Response.json({ success: true }) return Response.json({ success: true })
} }
/** Borrar (lógico) un post propio (o cualquiera si es moderador). */
export async function DELETE(request: Request) {
const session = await getSession()
if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
let b: Record<string, string> = {}
try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) }
const postId = Number(b.postId)
if (!postId) return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
const post = await getPost(postId)
if (!post) return Response.json({ success: false, error: 'postNotFound' }, { status: 404 })
const isMod = await forumIsModerator(session)
if (!canEditPost(session, isMod, post.poster_id)) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
const topic = await getTopic(post.topic_id)
if (topic?.locked && !isMod) return Response.json({ success: false, error: 'topicLocked' })
await setPostDeleted(postId, true)
return Response.json({ success: true })
}
+11 -5
View File
@@ -1,15 +1,21 @@
import { getSession } from '@/lib/session' import { getSession } from '@/lib/session'
import { createPost, topicIsReplyable } from '@/lib/forum-write' import { createPost, topicIsReplyable } from '@/lib/forum-write'
import { plainLength } from '@/lib/forum-sanitize'
export async function POST(request: Request) { export async function POST(request: Request) {
const session = await getSession() 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<string, string> = {} let b: Record<string, string> = {}
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 topicId = Number(b.topicId)
const text = String(b.text ?? '').trim() const text = String(b.text ?? '')
if (!topicId || !text) return Response.json({ success: false, error: 'emptyError' }) if (!topicId || plainLength(text) < 3) return Response.json({ success: false, error: 'emptyError' })
if (!(await topicIsReplyable(topicId))) return Response.json({ success: false, error: 'topicLocked' }) 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 }) return Response.json({ success: true })
} }
+16 -8
View File
@@ -1,16 +1,24 @@
import { getSession } from '@/lib/session' 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) { export async function POST(request: Request) {
const session = await getSession() 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<string, string> = {} let b: Record<string, string> = {}
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 forumId = Number(b.forumId)
const name = String(b.name ?? '').trim() const title = String(b.title ?? '').trim()
const text = String(b.text ?? '').trim() const text = String(b.text ?? '')
if (!forumId || !name || !text) return Response.json({ success: false, error: 'emptyError' }) if (!forumId || title.length < 3) return Response.json({ success: false, error: 'errTitle' })
if (!(await forumIsPostable(forumId))) return Response.json({ success: false, error: 'invalidForum' }) if (plainLength(text) < 3) return Response.json({ success: false, error: 'emptyError' })
const topicId = await createTopic(forumId, name, session.username, session.accountId, text) 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 }) return Response.json({ success: true, topicId })
} }
+242
View File
@@ -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; }
}
+77 -56
View File
@@ -6,22 +6,29 @@ import { useRouter } from '@/i18n/navigation'
import type { AdminCategory, AdminForum } from '@/lib/admin-forum' import type { AdminCategory, AdminForum } from '@/lib/admin-forum'
type Group = { category: AdminCategory; forums: AdminForum[] } 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[] }) { export function AdminForumManager({ initial }: { initial: Group[] }) {
const t = useTranslations('Admin') const t = useTranslations('Admin')
const router = useRouter() const router = useRouter()
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [catForm, setCatForm] = useState({ name: '', nameEn: '', order: '0' }) const [catForm, setCatForm] = useState({ name: '', order: '0' })
const [forumForm, setForumForm] = useState<Record<number, { name: string; nameEn: string; description: string; descriptionEn: string; order: string }>>({}) const [forumForm, setForumForm] = useState<Record<number, ForumFields>>({})
const [editCat, setEditCat] = useState<{ id: number; name: string; nameEn: string; order: string } | null>(null) const [editCat, setEditCat] = useState<{ id: number; name: 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 [editForum, setEditForum] = useState<(ForumFields & { id: number }) | null>(null)
function ff(catId: number) { const ff = (catId: number) => forumForm[catId] ?? EMPTY_FORUM
return forumForm[catId] ?? { name: '', nameEn: '', description: '', descriptionEn: '', order: '0' } const setFF = (catId: number, patch: Partial<ForumFields>) =>
}
function setFF(catId: number, patch: Partial<{ name: string; nameEn: string; description: string; descriptionEn: string; order: string }>) {
setForumForm((s) => ({ ...s, [catId]: { ...ff(catId), ...patch } })) setForumForm((s) => ({ ...s, [catId]: { ...ff(catId), ...patch } }))
}
async function call(url: string, method: string, body?: unknown): Promise<boolean> { async function call(url: string, method: string, body?: unknown): Promise<boolean> {
setBusy(true) setBusy(true)
@@ -38,12 +45,46 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
return true return true
} }
if (d.error === 'categoryNotEmpty') alert(t('categoryNotEmpty')) if (d.error === 'categoryNotEmpty') alert(t('categoryNotEmpty'))
else if (d.error === 'forumNotEmpty') alert(t('forumNotEmpty'))
return false return false
} finally { } finally {
setBusy(false) 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<ForumFields>) => void
}) {
return (
<>
<input value={v.name} onChange={(e) => onChange({ name: e.target.value })} placeholder={t('forumName')} maxLength={45} />
<input value={v.description} onChange={(e) => onChange({ description: e.target.value })} placeholder={t('forumDescription')} />
<select value={v.type} onChange={(e) => onChange({ type: e.target.value })}>
<option value="0">{t('forumTypeNormal')}</option>
<option value="1">{t('forumTypeClass')}</option>
<option value="2">{t('forumTypeFlag')}</option>
</select>
<input value={v.icon} onChange={(e) => onChange({ icon: e.target.value })} placeholder={t('forumIcon')} />
<input value={v.colortitle} onChange={(e) => onChange({ colortitle: e.target.value })} placeholder={t('forumColor')} />
<input type="number" value={v.order} onChange={(e) => onChange({ order: e.target.value })} placeholder={t('order')} />
</>
)
}
return ( return (
<div> <div>
{/* Alta de categoría */} {/* Alta de categoría */}
@@ -51,18 +92,16 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault() e.preventDefault()
if (catForm.name.trim()) if (catForm.name.trim())
call('/api/admin/forum/category', 'POST', { name: catForm.name, nameEn: catForm.nameEn, order: Number(catForm.order) }).then( call('/api/admin/forum/category', 'POST', { name: catForm.name, order: Number(catForm.order) }).then(
(ok) => ok && setCatForm({ name: '', nameEn: '', order: '0' }), (ok) => ok && setCatForm({ name: '', order: '0' }),
) )
}} }}
className="admin-form info-box-light separate2" className="admin-form info-box-light separate2"
> >
<span className="second-brown">{t('newCategory')}</span> <span className="second-brown">{t('newCategory')}</span>
<input value={catForm.name} onChange={(e) => setCatForm({ ...catForm, name: e.target.value })} placeholder={t('categoryName')} /> <input value={catForm.name} onChange={(e) => setCatForm({ ...catForm, name: e.target.value })} placeholder={t('categoryName')} maxLength={45} />
<input value={catForm.nameEn} onChange={(e) => setCatForm({ ...catForm, nameEn: e.target.value })} placeholder={t('categoryNameEn')} />
<span className="second-brown">{t('order')}</span> <span className="second-brown">{t('order')}</span>
<input type="number" value={catForm.order} onChange={(e) => setCatForm({ ...catForm, order: e.target.value })} /> <input type="number" value={catForm.order} onChange={(e) => setCatForm({ ...catForm, order: e.target.value })} />
<p className="third-brown small-font">{t('forumEnHint')}</p>
<button type="submit" disabled={busy}>{t('create')}</button> <button type="submit" disabled={busy}>{t('create')}</button>
</form> </form>
<br /> <br />
@@ -73,13 +112,12 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
<div key={category.id} className="inline-div" style={{ display: 'block' }}> <div key={category.id} className="inline-div" style={{ display: 'block' }}>
{editCat?.id === category.id ? ( {editCat?.id === category.id ? (
<div className="admin-form separate2"> <div className="admin-form separate2">
<input value={editCat.name} onChange={(e) => setEditCat({ ...editCat, name: e.target.value })} placeholder={t('categoryName')} /> <input value={editCat.name} onChange={(e) => setEditCat({ ...editCat, name: e.target.value })} placeholder={t('categoryName')} maxLength={45} />
<input value={editCat.nameEn} onChange={(e) => setEditCat({ ...editCat, nameEn: e.target.value })} placeholder={t('categoryNameEn')} />
<input type="number" value={editCat.order} onChange={(e) => setEditCat({ ...editCat, order: e.target.value })} /> <input type="number" value={editCat.order} onChange={(e) => setEditCat({ ...editCat, order: e.target.value })} />
<button <button
onClick={() => onClick={() =>
editCat.name.trim() && editCat.name.trim() &&
call(`/api/admin/forum/category/${category.id}`, 'PUT', { name: editCat.name, nameEn: editCat.nameEn, order: Number(editCat.order) }).then((ok) => ok && setEditCat(null)) call(`/api/admin/forum/category/${category.id}`, 'PUT', { name: editCat.name, order: Number(editCat.order) }).then((ok) => ok && setEditCat(null))
} }
disabled={busy} disabled={busy}
className="ns-tool-btn" className="ns-tool-btn"
@@ -91,11 +129,9 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
) : ( ) : (
<div className="title-content-h3"> <div className="title-content-h3">
<h3 className="big-font"> <h3 className="big-font">
{category.name} {category.name} <span className="third-brown small-font">#{category.order}</span>{' '}
{category.name_en && <span className="third-brown small-font"> · EN: {category.name_en}</span>}{' '}
<span className="third-brown small-font">#{category.order}</span>{' '}
<button <button
onClick={() => setEditCat({ id: category.id, name: category.name, nameEn: category.name_en, order: String(category.order) })} onClick={() => setEditCat({ id: category.id, name: category.name, order: String(category.order) })}
disabled={busy} disabled={busy}
className="ns-tool-btn" className="ns-tool-btn"
> >
@@ -112,7 +148,6 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
</div> </div>
)} )}
{/* Foros de la categoría */}
<table className="max-center-table"> <table className="max-center-table">
<tbody> <tbody>
{forums.length === 0 && ( {forums.length === 0 && (
@@ -124,21 +159,11 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
editForum?.id === f.id ? ( editForum?.id === f.id ? (
<tr key={f.id} className="team-center-table-tr"> <tr key={f.id} className="team-center-table-tr">
<td className="lefted separate" colSpan={2}> <td className="lefted separate" colSpan={2}>
<input value={editForum.name} onChange={(e) => setEditForum({ ...editForum, name: e.target.value })} placeholder={t('forumName')} maxLength={45} /> <ForumEditFields v={editForum} onChange={(patch) => setEditForum({ ...editForum, ...patch })} />
<input value={editForum.nameEn} onChange={(e) => setEditForum({ ...editForum, nameEn: e.target.value })} placeholder={t('forumNameEn')} maxLength={45} />
<input value={editForum.description} onChange={(e) => setEditForum({ ...editForum, description: e.target.value })} placeholder={t('forumDescription')} />
<input value={editForum.descriptionEn} onChange={(e) => setEditForum({ ...editForum, descriptionEn: e.target.value })} placeholder={t('forumDescriptionEn')} />
<button <button
onClick={() => onClick={() =>
editForum.name.trim() && editForum.name.trim() &&
call(`/api/admin/forum/${f.id}`, 'PUT', { call(`/api/admin/forum/${f.id}`, 'PUT', forumBody(editForum, category.id)).then((ok) => ok && setEditForum(null))
name: editForum.name,
nameEn: editForum.nameEn,
description: editForum.description,
descriptionEn: editForum.descriptionEn,
order: Number(editForum.order),
visibility: editForum.visibility !== 0,
}).then((ok) => ok && setEditForum(null))
} }
disabled={busy} disabled={busy}
className="ns-tool-btn" className="ns-tool-btn"
@@ -152,25 +177,29 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
<tr key={f.id} className="team-center-table-tr"> <tr key={f.id} className="team-center-table-tr">
<td className="lefted separate"> <td className="lefted separate">
<span className="yellow-info">{f.name}</span>{' '} <span className="yellow-info">{f.name}</span>{' '}
{f.name_en && <span className="third-brown small-font">· EN: {f.name_en}</span>}{' '} <span className="third-brown small-font">
{f.visibility === 0 && <span className="red-info2 small-font">({t('hidden')})</span>} · {t('forumType')}: {f.type} · #{f.order}
</span>
{f.description && <p className="third-brown small-font">{f.description}</p>} {f.description && <p className="third-brown small-font">{f.description}</p>}
</td> </td>
<td className="real-info-box no-wrap-td"> <td className="real-info-box no-wrap-td">
<button <button
onClick={() => setEditForum({ id: f.id, name: f.name, nameEn: f.name_en, description: f.description, descriptionEn: f.description_en, order: String(f.order), visibility: f.visibility })} onClick={() =>
setEditForum({
id: f.id,
name: f.name,
description: f.description,
icon: f.icon ?? '',
colortitle: f.colortitle ?? '',
type: String(f.type),
order: String(f.order),
})
}
disabled={busy} disabled={busy}
className="ns-tool-btn" className="ns-tool-btn"
> >
{t('edit')} {t('edit')}
</button>{' '} </button>{' '}
<button
onClick={() => call(`/api/admin/forum/${f.id}`, 'PATCH', { visibility: f.visibility === 0 })}
disabled={busy}
className="ns-tool-btn"
>
{f.visibility === 0 ? t('show') : t('hide')}
</button>{' '}
<button <button
onClick={() => confirm(t('confirmDelete')) && call(`/api/admin/forum/${f.id}`, 'DELETE')} onClick={() => confirm(t('confirmDelete')) && call(`/api/admin/forum/${f.id}`, 'DELETE')}
disabled={busy} disabled={busy}
@@ -191,21 +220,13 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
e.preventDefault() e.preventDefault()
const v = ff(category.id) const v = ff(category.id)
if (v.name.trim()) if (v.name.trim())
call('/api/admin/forum', 'POST', { call('/api/admin/forum', 'POST', forumBody(v, category.id)).then(
categoryId: category.id, (ok) => ok && setFF(category.id, EMPTY_FORUM),
name: v.name, )
nameEn: v.nameEn,
description: v.description,
descriptionEn: v.descriptionEn,
order: Number(v.order),
}).then((ok) => ok && setFF(category.id, { name: '', nameEn: '', description: '', descriptionEn: '', order: '0' }))
}} }}
className="admin-form separate" className="admin-form separate"
> >
<input value={ff(category.id).name} onChange={(e) => setFF(category.id, { name: e.target.value })} placeholder={t('forumName')} maxLength={45} /> <ForumEditFields v={ff(category.id)} onChange={(patch) => setFF(category.id, patch)} />
<input value={ff(category.id).nameEn} onChange={(e) => setFF(category.id, { nameEn: e.target.value })} placeholder={t('forumNameEn')} maxLength={45} />
<input value={ff(category.id).description} onChange={(e) => setFF(category.id, { description: e.target.value })} placeholder={t('forumDescription')} />
<input value={ff(category.id).descriptionEn} onChange={(e) => setFF(category.id, { descriptionEn: e.target.value })} placeholder={t('forumDescriptionEn')} />
<button type="submit" disabled={busy}>{t('addForum')}</button> <button type="submit" disabled={busy}>{t('addForum')}</button>
</form> </form>
</div> </div>
+59
View File
@@ -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<string | null>(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 (
<form onSubmit={submit}>
<input
className="forum-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
type="text"
placeholder={t('topicTitle')}
maxLength={45}
/>
<ForumEditor value={text} onChange={setText} />
<div className="forum-form-actions">
<button type="submit" className="nice_button" disabled={busy}>
{busy ? t('sending') : t('postTopic')}
</button>
{error && <span className="red-form-response">{error}</span>}
</div>
</form>
)
}
+14
View File
@@ -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 (
<Link className="edit" href={`/forum/edit/${postId}`}>
{t('edit')}
</Link>
)
}
+60
View File
@@ -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<string | null>(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 (
<form onSubmit={submit}>
<ForumEditor value={text} onChange={setText} />
<div className="forum-form-actions">
<button type="submit" className="nice_button" disabled={busy}>
{busy ? t('sending') : t('editPost')}
</button>
<Link className="nice_button" href={`/forum/topic/${topicId}`}>
{t('cancel')}
</Link>
{error && <span className="red-form-response">{error}</span>}
</div>
</form>
)
}
+19
View File
@@ -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 (
<div className="forum-path">
{items.map((it, i) => (
<span key={i}>
{i > 0 && <span className="sep"></span>}
{it.href ? <Link href={it.href}>{it.label}</Link> : <span>{it.label}</span>}
</span>
))}
</div>
)
}
+48
View File
@@ -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 (
<Editor
tinymceScriptSrc="/tinymce/tinymce.min.js"
licenseKey="gpl"
value={value}
onEditorChange={(content) => 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,
}}
/>
)
}
+68
View File
@@ -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 (
<>
<a
className="delete"
role="button"
tabIndex={0}
onClick={() => act(deleted ? 'undelete' : 'delete')}
style={busy ? { opacity: 0.5 } : undefined}
>
{deleted ? t('undelete') : t('delete')}
</a>
{kind === 'topic' && (
<a
className="lock"
role="button"
tabIndex={0}
onClick={() => act(locked ? 'unlock' : 'lock')}
style={busy ? { opacity: 0.5 } : undefined}
>
{locked ? t('unlock') : t('lock')}
</a>
)}
</>
)
}
-32
View File
@@ -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 (
<form onSubmit={submit} className="centered separate">
<input
type="text"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t('searchPlaceholder')}
/>{' '}
<button type="submit" className="search-items-button">
{t('search')}
</button>
</form>
)
}
-59
View File
@@ -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<string | null>(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 (
<form onSubmit={handleSubmit} className="info-box-light separate2">
<div className="title-content-h3">
<h3 className="big-font">{t('newTopic')}</h3>
</div>
<br />
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={t('newTopicName')} maxLength={200} style={{ width: '100%' }} />
<br />
<br />
<RichTextArea value={text} onChange={setText} placeholder={t('newTopicText')} rows={4} />
<br />
<button type="submit" disabled={busy}>
{busy ? t('publishing') : t('publish')}
</button>
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
{error && <span className="red-form-response">{error}</span>}
</div>
</form>
)
}
-133
View File
@@ -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<string | null>(null)
async function save() {
if (busy || !text.trim()) return
setBusy(true)
setError(null)
try {
const res = await fetch('/api/forum/post', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ postId, text }),
})
const data: { success?: boolean; error?: string } = await res.json()
if (data.success) {
setEditing(false)
router.refresh()
} else {
setError(t(data.error === 'tooShort' ? 'tooShort' : data.error === 'topicLocked' ? 'topicLocked' : 'genericError'))
}
} catch {
setError(t('genericError'))
} finally {
setBusy(false)
}
}
async function 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 (
<div className="separate">
<RichTextArea value={text} onChange={setText} rows={4} />
<br />
<button type="button" onClick={save} disabled={busy} className="ns-tool-btn">
{busy ? t('sending') : t('save')}
</button>{' '}
<button
type="button"
onClick={() => { setEditing(false); setText(initialText); setError(null) }}
className="ns-tool-btn"
>
{t('cancel')}
</button>
{error && <p className="red-info2 small-font">{error}</p>}
</div>
)
}
if (deleted) {
return (
<div className="separate small-font">
<button type="button" onClick={restore} disabled={busy} className="ns-tool-btn green-info">
{t('restore')}
</button>
{error && <span className="red-info2"> {error}</span>}
</div>
)
}
return (
<div className="separate small-font">
<a role="button" tabIndex={0} onClick={() => setEditing(true)} className="second-brown" style={{ cursor: 'pointer' }}>
{t('edit')}
</a>
{' '}
<a role="button" tabIndex={0} onClick={() => { if (!busy) remove() }} className="second-brown" style={{ cursor: 'pointer' }}>
{t('delete')}
</a>
{error && <span className="red-info2"> {error}</span>}
</div>
)
}
+18 -15
View File
@@ -3,8 +3,9 @@
import { useState } from 'react' import { useState } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useRouter } from '@/i18n/navigation' 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 }) { export function ReplyForm({ topicId }: { topicId: number }) {
const t = useTranslations('Forum') const t = useTranslations('Forum')
const router = useRouter() const router = useRouter()
@@ -12,9 +13,9 @@ export function ReplyForm({ topicId }: { topicId: number }) {
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) { async function submit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (busy || !text.trim()) return if (busy) return
setBusy(true) setBusy(true)
setError(null) setError(null)
try { try {
@@ -24,12 +25,12 @@ export function ReplyForm({ topicId }: { topicId: number }) {
credentials: 'same-origin', credentials: 'same-origin',
body: JSON.stringify({ topicId, text }), body: JSON.stringify({ topicId, text }),
}) })
const data: { success?: boolean } = await res.json() const data: { success?: boolean; error?: string } = await res.json()
if (data.success) { if (data.success) {
setText('') setText('')
router.refresh() router.refresh()
} else { } else {
setError(t('genericError')) setError(data.error || t('genericError'))
} }
} catch { } catch {
setError(t('genericError')) setError(t('genericError'))
@@ -39,15 +40,17 @@ export function ReplyForm({ topicId }: { topicId: number }) {
} }
return ( return (
<form onSubmit={handleSubmit} className="separate2"> <div className="quick_reply">
<RichTextArea value={text} onChange={setText} placeholder={t('replyText')} rows={3} /> <h2>{t('reply')}</h2>
<br /> <form onSubmit={submit}>
<button type="submit" disabled={busy}> <ForumEditor value={text} onChange={setText} height={260} />
{busy ? t('sending') : t('sendReply')} <div className="forum-form-actions">
</button> <button type="submit" className="nice_button" disabled={busy}>
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}> {busy ? t('sending') : t('post')}
{error && <span className="red-form-response">{error}</span>} </button>
</div> {error && <span className="red-form-response">{error}</span>}
</form> </div>
</form>
</div>
) )
} }
-95
View File
@@ -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<string, unknown>, 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 (
<div className="info-box-light separate">
<span className="red-info2">{t('deletedMark')}</span>{' '}
<button type="button" onClick={() => act('restore')} disabled={busy} className="ns-tool-btn green-info">
{t('restoreTopic')}
</button>
</div>
)
}
return (
<div className="info-box-light separate">
<span className="yellow-info">{t('moderation')}:</span>{' '}
<button type="button" onClick={() => act(locked ? 'unlock' : 'lock')} disabled={busy} className="ns-tool-btn">
{locked ? t('unlock') : t('lock')}
</button>{' '}
<button type="button" onClick={() => act(sticky ? 'unsticky' : 'sticky')} disabled={busy} className="ns-tool-btn">
{sticky ? t('unpin') : t('pin')}
</button>{' '}
{moveForums.length > 0 && (
<select
defaultValue=""
disabled={busy}
onChange={(e) => {
const forumId = Number(e.target.value)
e.target.value = ''
if (forumId) act('move', { forumId })
}}
style={{ width: 'auto' }}
aria-label={t('moveTopic')}
>
<option value="" disabled>
{t('moveTopic')}
</option>
{moveForums.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
)}{' '}
<button type="button" onClick={() => act('delete', undefined, t('confirmDeleteTopic'))} disabled={busy} className="ns-tool-btn red-info2">
{t('deleteTopic')}
</button>
</div>
)
}
+48 -54
View File
@@ -1,72 +1,75 @@
import type { RowDataPacket, ResultSetHeader } from 'mysql2' import type { RowDataPacket, ResultSetHeader } from 'mysql2'
import { db, DB } from './db' 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 { export interface AdminCategory {
id: number id: number
name: string name: string
name_en: string
order: number order: number
} }
export interface AdminForum { export interface AdminForum {
id: number id: number
category_id: number category: number
name: string name: string
name_en: string
description: string description: string
description_en: string icon: string | null
colortitle: string | null
type: number
order: number order: number
visibility: number
} }
/** Categorías con sus foros (incluye ocultos y sin borrar), para el admin. */
export async function listCategoriesWithForums(): Promise< export async function listCategoriesWithForums(): Promise<
{ category: AdminCategory; forums: AdminForum[] }[] { category: AdminCategory; forums: AdminForum[] }[]
> { > {
const [cats] = await db(DB.web).query<RowDataPacket[]>( const [cats] = await db(DB.web).query<RowDataPacket[]>(
'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<RowDataPacket[]>( const [forums] = await db(DB.web).query<RowDataPacket[]>(
'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) => ({ 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 forums: forums
.filter((f) => f.category_id === c.id) .filter((f) => f.category === c.id)
.map((f) => ({ .map((f) => ({
id: f.id, id: f.id,
category_id: f.category_id, category: f.category,
name: f.name, name: f.name,
name_en: f.name_en || '',
description: f.description, description: f.description,
description_en: f.description_en || '', icon: f.icon,
colortitle: f.colortitle,
type: f.type,
order: f.order, order: f.order,
visibility: f.visibility,
})), })),
})) }))
} }
export async function createCategory(name: string, nameEn: string | null, order: number): Promise<number> { export async function createCategory(name: string, order: number): Promise<number> {
const [res] = await db(DB.web).query<ResultSetHeader>( const [res] = await db(DB.web).query<ResultSetHeader>(
'INSERT INTO forum_categories (name, name_en, `order`, created_at) VALUES (?, ?, ?, NOW())', 'INSERT INTO forum_categories (name, `order`) VALUES (?, ?)',
[name, nameEn || null, order], [name.slice(0, 45), order],
) )
return res.insertId return res.insertId
} }
export async function updateCategory(id: number, name: string, nameEn: string | null, order: number): Promise<void> { export async function updateCategory(id: number, name: string, order: number): Promise<void> {
await db(DB.web).query('UPDATE forum_categories SET name = ?, name_en = ?, `order` = ? WHERE id = ?', [ await db(DB.web).query('UPDATE forum_categories SET name = ?, `order` = ? WHERE id = ?', [
name, name.slice(0, 45),
nameEn || null,
order, order,
id, 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<boolean> { export async function deleteCategory(id: number): Promise<boolean> {
const [f] = await db(DB.web).query<RowDataPacket[]>( const [f] = await db(DB.web).query<RowDataPacket[]>(
'SELECT COUNT(*) AS n FROM forums WHERE category_id = ? AND deleted = 0', 'SELECT COUNT(*) AS n FROM forum_forums WHERE category = ?',
[id], [id],
) )
if (Number(f[0]?.n ?? 0) > 0) return false if (Number(f[0]?.n ?? 0) > 0) return false
@@ -74,44 +77,35 @@ export async function deleteCategory(id: number): Promise<boolean> {
return true return true
} }
export async function createForum( export interface ForumInput {
categoryId: number, category: number
name: string, name: string
nameEn: string | null, description: string
description: string, icon: string | null
descriptionEn: string | null, colortitle: string | null
order: number, type: number
visibility: number, order: number
): Promise<number> { }
export async function createForum(f: ForumInput): Promise<number> {
const [res] = await db(DB.web).query<ResultSetHeader>( const [res] = await db(DB.web).query<ResultSetHeader>(
'INSERT INTO forums (category_id, name, name_en, description, description_en, `order`, type, visibility, deleted, created_at, updated_at) ' + 'INSERT INTO forum_forums (category, name, description, icon, colortitle, type, `order`) VALUES (?, ?, ?, ?, ?, ?, ?)',
'VALUES (?, ?, ?, ?, ?, ?, 0, ?, 0, NOW(), NOW())', [f.category, f.name.slice(0, 45), f.description, f.icon || null, f.colortitle || null, f.type, f.order],
[categoryId, name, nameEn || null, description, descriptionEn || null, order, visibility ? 1 : 0],
) )
return res.insertId return res.insertId
} }
export async function updateForum( export async function updateForum(id: number, f: ForumInput): Promise<void> {
id: number,
name: string,
nameEn: string | null,
description: string,
descriptionEn: string | null,
order: number,
visibility: number,
): Promise<void> {
await db(DB.web).query( await db(DB.web).query(
'UPDATE forums SET name = ?, name_en = ?, description = ?, description_en = ?, `order` = ?, visibility = ?, updated_at = NOW() WHERE id = ?', 'UPDATE forum_forums SET category = ?, name = ?, description = ?, icon = ?, colortitle = ?, type = ?, `order` = ? WHERE id = ?',
[name, nameEn || null, description, descriptionEn || null, order, visibility ? 1 : 0, 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). */ /** Borra un foro solo si no tiene temas. */
export async function deleteForum(id: number): Promise<void> { export async function deleteForum(id: number): Promise<boolean> {
await db(DB.web).query('UPDATE forums SET deleted = 1 WHERE id = ?', [id]) const [t] = await db(DB.web).query<RowDataPacket[]>('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])
/** Cambia la visibilidad de un foro. */ return true
export async function setForumVisibility(id: number, visible: boolean): Promise<void> {
await db(DB.web).query('UPDATE forums SET visibility = ? WHERE id = ?', [visible ? 1 : 0, id])
} }
+14
View File
@@ -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)
}
+21 -36
View File
@@ -2,61 +2,60 @@ import type { ResultSetHeader, RowDataPacket } from 'mysql2'
import { db, DB } from './db' import { db, DB } from './db'
import { cleanPostHtml } from './forum-sanitize' 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( export async function createTopic(
forumId: number, forumId: number,
name: string, name: string,
poster: string,
posterId: number, posterId: number,
text: string, text: string,
): Promise<number> { ): Promise<number> {
const clean = cleanPostHtml(text) const clean = cleanPostHtml(text)
const [res] = await db(DB.web).query<ResultSetHeader>( const [res] = await db(DB.web).query<ResultSetHeader>(
'INSERT INTO forum_topics (forum_id, name, poster, poster_id, created, created_at, updated_at, locked, sticky, deleted) ' + 'INSERT INTO forum_topics (forum, name, sticky, locked, deleted, created) VALUES (?, ?, 0, 0, 0, NOW())',
'VALUES (?, ?, ?, ?, NOW(), NOW(), NOW(), 0, 0, 0)', [forumId, name.slice(0, TITLE_MAX)],
[forumId, name, poster, posterId],
) )
const topicId = res.insertId const topicId = res.insertId
await db(DB.web).query( await db(DB.web).query(
'INSERT INTO forum_posts (topic_id, poster, poster_id, text, time, created_at, deleted) VALUES (?, ?, ?, ?, NOW(), NOW(), 0)', 'INSERT INTO forum_posts (topic, poster, text, time, deleted) VALUES (?, ?, ?, NOW(), 0)',
[topicId, poster, posterId, clean], [topicId, posterId, clean],
) )
return topicId return topicId
} }
export async function createPost( export async function createPost(topicId: number, posterId: number, text: string): Promise<number> {
topicId: number,
poster: string,
posterId: number,
text: string,
): Promise<number> {
const clean = cleanPostHtml(text) const clean = cleanPostHtml(text)
const [res] = await db(DB.web).query<ResultSetHeader>( const [res] = await db(DB.web).query<ResultSetHeader>(
'INSERT INTO forum_posts (topic_id, poster, poster_id, text, time, created_at, deleted) VALUES (?, ?, ?, ?, NOW(), NOW(), 0)', 'INSERT INTO forum_posts (topic, poster, text, time, deleted) VALUES (?, ?, ?, NOW(), 0)',
[topicId, poster, posterId, clean], [topicId, posterId, clean],
) )
await db(DB.web).query('UPDATE forum_topics SET updated_at = NOW() WHERE id = ?', [topicId])
return res.insertId return res.insertId
} }
/** Datos mínimos de un post para comprobar permisos (autor + tema). */ /** Datos mínimos de un post para comprobar permisos (autor + tema). */
export async function getPost( export async function getPost(
postId: number, 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<RowDataPacket[]>( const [rows] = await db(DB.web).query<RowDataPacket[]>(
'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], [postId],
) )
const r = rows[0] 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<void> { export async function updatePost(postId: number, text: string): Promise<void> {
const clean = cleanPostHtml(text) 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<void> { export async function setPostDeleted(postId: number, deleted: boolean): Promise<void> {
await db(DB.web).query('UPDATE forum_posts SET deleted = ? WHERE id = ?', [deleted ? 1 : 0, postId]) 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]) 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. */ /** ¿El tema existe, no está borrado y admite respuestas (no cerrado)? */
export async function moveTopic(topicId: number, newForumId: number): Promise<void> {
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<boolean> {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
'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). */
export async function topicIsReplyable(topicId: number): Promise<boolean> { export async function topicIsReplyable(topicId: number): Promise<boolean> {
const [rows] = await db(DB.web).query<RowDataPacket[]>( const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT locked FROM forum_topics WHERE id = ? AND deleted = 0', 'SELECT locked FROM forum_topics WHERE id = ? AND deleted = 0',
+242 -170
View File
@@ -1,126 +1,221 @@
import type { RowDataPacket } from 'mysql2' import type { RowDataPacket } from 'mysql2'
import { db, DB } from './db' 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 id: number
category_id: number category: number
order: number
name: string name: string
description: string description: string
icon: string | null icon: string | null
colortitle: string | null
type: number
topic_count: number topic_count: number
post_count: number post_count: number
last_topic_id: number | null last_topic_id: number | null
last_topic_name: string | null last_topic_name: string | null
last_topic_poster: string | null last_poster: number | null
last_time: string | null
} }
export interface Category { export interface Category {
id: number id: number
name: string name: string
forums: ForumInfo[] forums: ForumRow[]
} }
export interface Topic { export interface TopicRow {
id: number id: number
name: string name: string
poster: string
created: string | null
sticky: boolean sticky: boolean
locked: 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 id: number
poster: string poster: number
poster_id: number | null
text: string text: string
time: string | null time: string | null
edited: boolean
deleted: boolean deleted: boolean
} }
export interface TopicFull { export interface ForumMeta {
id: number id: number
name: string name: string
forum_id: number description: string
poster_id: number | null icon: string | null
locked: boolean colortitle: string | null
sticky: boolean type: number
deleted: boolean
} }
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 id: number
name: string name: string
poster: string gmlevel: number
forum_id: number isStaff: boolean
forum_name: string joindate: string | null
updated_at: string | null postCount: number
} }
/** Índice del foro: categorías con sus foros visibles (+ contadores y último tema). */ /** Nivel GM mínimo para considerarse «staff» en el foro (badge y color). */
export async function getForumIndex(locale = 'es'): Promise<Category[]> { const STAFF_GMLEVEL = Number(process.env.FORUM_MOD_GMLEVEL || 2)
const en = locale === 'en'
/** Índice del foro: categorías ordenadas, cada una con sus foros y agregados. */
export async function getForumIndex(): Promise<Category[]> {
try { try {
const [cats] = await db(DB.web).query<RowDataPacket[]>( const [cats] = await db(DB.web).query<RowDataPacket[]>(
'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<RowDataPacket[]>( const [forums] = await db(DB.web).query<RowDataPacket[]>(
`SELECT f.id, f.category_id, f.name, f.name_en, f.description, f.description_en, f.icon, `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_id = f.id AND t.deleted = 0) AS topic_count, (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_id = t.id (SELECT COUNT(*) FROM forum_posts p JOIN forum_topics t ON p.topic = t.id
WHERE t.forum_id = f.id AND p.deleted = 0 AND t.deleted = 0) AS post_count, WHERE t.forum = 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 (SELECT t.id FROM forum_posts p JOIN forum_topics t ON p.topic = t.id
ORDER BY t2.updated_at DESC, t2.id DESC LIMIT 1) AS last_topic_id, WHERE t.forum = f.id AND p.deleted = 0 AND t.deleted = 0
(SELECT t3.name FROM forum_topics t3 WHERE t3.forum_id = f.id AND t3.deleted = 0 ORDER BY p.time DESC, p.id DESC LIMIT 1) AS last_topic_id,
ORDER BY t3.updated_at DESC, t3.id DESC LIMIT 1) AS last_topic_name, (SELECT t.name FROM forum_posts p JOIN forum_topics t ON p.topic = t.id
(SELECT t4.poster FROM forum_topics t4 WHERE t4.forum_id = f.id AND t4.deleted = 0 WHERE t.forum = f.id AND p.deleted = 0 AND t.deleted = 0
ORDER BY t4.updated_at DESC, t4.id DESC LIMIT 1) AS last_topic_poster ORDER BY p.time DESC, p.id DESC LIMIT 1) AS last_topic_name,
FROM forums f (SELECT p.poster FROM forum_posts p JOIN forum_topics t ON p.topic = t.id
WHERE f.visibility = 1 AND f.deleted = 0 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`, ORDER BY f.\`order\`, f.id`,
) )
const byCat = new Map<number, ForumInfo[]>() const byCat = new Map<number, ForumRow[]>()
for (const f of forums) { for (const f of forums) {
const info = f as unknown as ForumInfo & { name_en?: string | null; description_en?: string | null } const row: ForumRow = {
info.name = en && info.name_en ? info.name_en : info.name id: f.id,
info.description = en && info.description_en ? info.description_en : info.description category: f.category,
if (!byCat.has(info.category_id)) byCat.set(info.category_id, []) order: f.order,
byCat.get(info.category_id)!.push(info) 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 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) .filter((c) => c.forums.length > 0)
} catch { } catch {
return [] return []
} }
} }
/** Lista de foros visibles para el desplegable de «mover tema». */ export async function getForum(id: number): Promise<ForumMeta | null> {
export async function listForumsForMove(): Promise<{ id: number; name: string }[]> {
try { try {
const [rows] = await db(DB.web).query<RowDataPacket[]>( const [rows] = await db(DB.web).query<RowDataPacket[]>(
'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 { } catch {
return [] return null
} }
} }
export async function getForum(id: number, locale = 'es'): Promise<{ name: string; description: string } | null> { export async function forumExists(id: number): Promise<boolean> {
const en = locale === 'en' if (!id) return false
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>('SELECT id FROM forum_forums WHERE id = ?', [id])
return Boolean(rows[0])
} catch {
return false
}
}
export async function getForumPath(forumId: number): Promise<ForumPath | null> {
try { try {
const [rows] = await db(DB.web).query<RowDataPacket[]>( const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT name, name_en, description, description_en FROM forums WHERE id = ? AND visibility = 1 AND deleted = 0', `SELECT c.id AS category_id, c.name AS category, f.id AS forum_id, f.name AS forum, f.description AS forum_description
[id], FROM forum_categories c JOIN forum_forums f ON f.category = c.id
WHERE f.id = ? LIMIT 1`,
[forumId],
) )
if (!rows[0]) return null const r = rows[0]
return { return r
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, 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<TopicPath | null> {
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
`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 { } catch {
return null return null
} }
@@ -129,7 +224,7 @@ export async function getForum(id: number, locale = 'es'): Promise<{ name: strin
export async function countTopics(forumId: number): Promise<number> { export async function countTopics(forumId: number): Promise<number> {
try { try {
const [rows] = await db(DB.web).query<RowDataPacket[]>( const [rows] = await db(DB.web).query<RowDataPacket[]>(
'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], [forumId],
) )
return Number(rows[0]?.n ?? 0) return Number(rows[0]?.n ?? 0)
@@ -138,43 +233,67 @@ export async function countTopics(forumId: number): Promise<number> {
} }
} }
export async function getTopics(forumId: number, offset = 0, limit = 20): Promise<Topic[]> { /** 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<TopicRow[]> {
try { try {
const del = includeDeleted ? '' : 'AND t.deleted = 0'
const [rows] = await db(DB.web).query<RowDataPacket[]>( const [rows] = await db(DB.web).query<RowDataPacket[]>(
'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], [forumId, limit, offset],
) )
return rows.map((r) => ({ return rows.map((r) => ({
id: r.id, id: r.id,
name: r.name, name: r.name,
poster: r.poster,
created: r.created ? new Date(r.created).toISOString() : null,
sticky: r.sticky === 1, sticky: r.sticky === 1,
locked: r.locked === 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 { } catch {
return [] return []
} }
} }
export async function getTopic(id: number, includeDeleted = false): Promise<TopicFull | null> { 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 { try {
const where = includeDeleted ? 'id = ?' : 'id = ? AND deleted = 0' const where = includeDeleted ? 'id = ?' : 'id = ? AND deleted = 0'
const [rows] = await db(DB.web).query<RowDataPacket[]>( const [rows] = await db(DB.web).query<RowDataPacket[]>(
`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], [id],
) )
const r = rows[0] const r = rows[0]
return r return r
? { ? {
id: r.id, id: r.id,
forum: r.forum,
name: r.name, name: r.name,
forum_id: r.forum_id,
poster_id: r.poster_id ?? null,
locked: r.locked === 1,
sticky: r.sticky === 1, sticky: r.sticky === 1,
locked: r.locked === 1,
deleted: r.deleted === 1, deleted: r.deleted === 1,
created: r.created ? new Date(r.created).toISOString() : null,
} }
: null : null
} catch { } catch {
@@ -186,7 +305,7 @@ export async function countPosts(topicId: number, includeDeleted = false): Promi
try { try {
const del = includeDeleted ? '' : 'AND deleted = 0' const del = includeDeleted ? '' : 'AND deleted = 0'
const [rows] = await db(DB.web).query<RowDataPacket[]>( const [rows] = await db(DB.web).query<RowDataPacket[]>(
`SELECT COUNT(*) AS n FROM forum_posts WHERE topic_id = ? ${del}`, `SELECT COUNT(*) AS n FROM forum_posts WHERE topic = ? ${del}`,
[topicId], [topicId],
) )
return Number(rows[0]?.n ?? 0) 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<Post[]> { export async function getPosts(
topicId: number,
offset = 0,
limit = 5,
includeDeleted = false,
): Promise<PostRow[]> {
try { try {
const del = includeDeleted ? '' : 'AND deleted = 0' const del = includeDeleted ? '' : 'AND deleted = 0'
const [rows] = await db(DB.web).query<RowDataPacket[]>( const [rows] = await db(DB.web).query<RowDataPacket[]>(
`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], [topicId, limit, offset],
) )
return rows.map((r) => ({ return rows.map((r) => ({
id: r.id, id: r.id,
poster: r.poster, poster: r.poster,
poster_id: r.poster_id ?? null,
text: r.text, text: r.text,
time: r.time ? new Date(r.time).toISOString() : null, 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, deleted: r.deleted === 1,
})) }))
} catch { } catch {
@@ -219,78 +338,12 @@ export async function getPosts(topicId: number, offset = 0, limit = 15, includeD
} }
} }
export interface ForumProfile { /** Nº de posts (no borrados) de un usuario, para el «Posts: N» del perfil lateral. */
username: string export async function getUserPostCount(posterId: number): Promise<number> {
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<ForumProfile> {
const profile: ForumProfile = {
username,
postCount: 0,
topicCount: 0,
joindate: null,
gmlevel: 0,
status: null,
recentTopics: [],
}
try {
const [pc] = await db(DB.web).query<RowDataPacket[]>(
'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<RowDataPacket[]>(
'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<RowDataPacket[]>(
'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<RowDataPacket[]>(
'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<RowDataPacket[]>(
'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<number> {
const like = `%${query}%`
try { try {
const [rows] = await db(DB.web).query<RowDataPacket[]>( const [rows] = await db(DB.web).query<RowDataPacket[]>(
`SELECT COUNT(DISTINCT t.id) AS n 'SELECT COUNT(*) AS n FROM forum_posts WHERE poster = ? AND deleted = 0',
FROM forum_topics t [posterId],
JOIN forums f ON f.id = t.forum_id AND f.visibility = 1 AND f.deleted = 0
LEFT JOIN forum_posts p ON p.topic_id = t.id AND p.deleted = 0
WHERE t.deleted = 0 AND (t.name LIKE ? OR p.text LIKE ?)`,
[like, like],
) )
return Number(rows[0]?.n ?? 0) return Number(rows[0]?.n ?? 0)
} catch { } catch {
@@ -298,29 +351,48 @@ export async function countSearchTopics(query: string): Promise<number> {
} }
} }
/** Busca temas por título o contenido de sus posts (solo foros visibles). */ /**
export async function searchTopics(query: string, offset = 0, limit = 20): Promise<SearchResult[]> { * Resuelve un conjunto de AccountID a nombre + rango + antigüedad en una sola pasada.
const like = `%${query}%` * 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<Map<number, PosterInfo>> {
const map = new Map<number, PosterInfo>()
const unique = [...new Set(ids.filter((n) => Number.isFinite(n) && n > 0))]
if (unique.length === 0) return map
const placeholders = unique.map(() => '?').join(',')
try { try {
const [rows] = await db(DB.web).query<RowDataPacket[]>( const [accs] = await db(DB.auth).query<RowDataPacket[]>(
`SELECT DISTINCT t.id, t.name, t.poster, t.forum_id, t.updated_at, f.name AS forum_name `SELECT id, username, joindate FROM account WHERE id IN (${placeholders})`,
FROM forum_topics t unique,
JOIN forums f ON f.id = t.forum_id AND f.visibility = 1 AND f.deleted = 0
LEFT JOIN forum_posts p ON p.topic_id = t.id AND p.deleted = 0
WHERE t.deleted = 0 AND (t.name LIKE ? OR p.text LIKE ?)
ORDER BY t.updated_at DESC
LIMIT ? OFFSET ?`,
[like, like, limit, offset],
) )
return rows.map((r) => ({ const [access] = await db(DB.auth).query<RowDataPacket[]>(
id: r.id, `SELECT AccountID, MAX(SecurityLevel) AS lvl FROM account_access WHERE AccountID IN (${placeholders}) GROUP BY AccountID`,
name: r.name, unique,
poster: r.poster, )
forum_id: r.forum_id, const gm = new Map<number, number>()
forum_name: r.forum_name, for (const a of access) gm.set(Number(a.AccountID), Number(a.lvl ?? 0))
updated_at: r.updated_at ? new Date(r.updated_at).toISOString() : null,
})) 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 { } 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
} }
+21 -2
View File
@@ -502,7 +502,19 @@
"memberSince": "Member since", "memberSince": "Member since",
"recentTopics": "Recent topics", "recentTopics": "Recent topics",
"roleAdmin": "Administrator", "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": { "Admin": {
"title": "Admin panel", "title": "Admin panel",
@@ -607,7 +619,14 @@
"categoryNameEn": "Name (English, optional)", "categoryNameEn": "Name (English, optional)",
"forumNameEn": "Forum name (English, optional)", "forumNameEn": "Forum name (English, optional)",
"forumDescriptionEn": "Description (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": { "Vote": {
"title": "Vote for us", "title": "Vote for us",
+21 -2
View File
@@ -502,7 +502,19 @@
"memberSince": "Miembro desde", "memberSince": "Miembro desde",
"recentTopics": "Temas recientes", "recentTopics": "Temas recientes",
"roleAdmin": "Administrador", "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": { "Admin": {
"title": "Panel de administración", "title": "Panel de administración",
@@ -607,7 +619,14 @@
"categoryNameEn": "Nombre (inglés, opcional)", "categoryNameEn": "Nombre (inglés, opcional)",
"forumNameEn": "Nombre del foro (inglés, opcional)", "forumNameEn": "Nombre del foro (inglés, opcional)",
"forumDescriptionEn": "Descripción (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": { "Vote": {
"title": "Votar por nosotros", "title": "Votar por nosotros",
+28 -8
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@next/third-parties": "^16.2.10", "@next/third-parties": "^16.2.10",
"@tinymce/tinymce-react": "^6.3.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"iron-session": "^8.0.4", "iron-session": "^8.0.4",
"mysql2": "^3.22.6", "mysql2": "^3.22.6",
@@ -19,7 +20,8 @@
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"sanitize-html": "^2.17.6", "sanitize-html": "^2.17.6",
"stripe": "^22.3.1" "stripe": "^22.3.1",
"tinymce": "^8.8.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.3.2", "@tailwindcss/postcss": "^4.3.2",
@@ -2420,6 +2422,24 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/@tybys/wasm-util": {
"version": "0.10.3", "version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -5656,8 +5676,7 @@
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
"dev": true
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.3.0", "version": "4.3.0",
@@ -6065,7 +6084,6 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
}, },
@@ -6421,7 +6439,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -6723,7 +6740,6 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
@@ -6797,8 +6813,7 @@
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
"dev": true
}, },
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
@@ -7547,6 +7562,11 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+5 -2
View File
@@ -6,10 +6,12 @@
"dev": "next dev -p 3001", "dev": "next dev -p 3001",
"build": "next build", "build": "next build",
"start": "next start -p 3001", "start": "next start -p 3001",
"lint": "eslint" "lint": "eslint",
"postinstall": "node scripts/copy-tinymce.mjs"
}, },
"dependencies": { "dependencies": {
"@next/third-parties": "^16.2.10", "@next/third-parties": "^16.2.10",
"@tinymce/tinymce-react": "^6.3.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"iron-session": "^8.0.4", "iron-session": "^8.0.4",
"mysql2": "^3.22.6", "mysql2": "^3.22.6",
@@ -20,7 +22,8 @@
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"sanitize-html": "^2.17.6", "sanitize-html": "^2.17.6",
"stripe": "^22.3.1" "stripe": "^22.3.1",
"tinymce": "^8.8.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.3.2", "@tailwindcss/postcss": "^4.3.2",
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Sodipodi ("http://www.sodipodi.com/") -->
<svg id="svg626" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="512" width="512" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata id="metadata3008">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="flag" fill-rule="evenodd" transform="scale(5.6889 8.5333)" stroke-width="1pt">
<rect id="rect629" height="20" width="90" y="40" x="0" fill="#de2110"/>
<rect id="rect631" height="20" width="90" y="0" x="0" fill="#fff"/>
<rect id="rect632" height="20" width="90" y="20" x="0" fill="#319400"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 885 B

+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Sodipodi ("http://www.sodipodi.com/") -->
<!-- /Creative Commons Public Domain -->
<!--
<rdf:RDF xmlns="http://web.resource.org/cc/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<Work rdf:about="">
<dc:title>SVG graphic of German flags</dc:title>
<dc:rights><Agent>
<dc:title>Philipp Sadleder</dc:title>
</Agent></dc:rights>
<license rdf:resource="http://web.resource.org/cc/PublicDomain" />
</Work>
<License rdf:about="http://web.resource.org/cc/PublicDomain">
<permits rdf:resource="http://web.resource.org/cc/Reproduction" />
<permits rdf:resource="http://web.resource.org/cc/Distribution" />
<permits rdf:resource="http://web.resource.org/cc/DerivativeWorks" />
</License>
</rdf:RDF>
-->
<svg id="svg378" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="512" width="512" version="1.0" y="0" x="0" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata id="metadata3074">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="flag" fill-rule="evenodd" stroke-width="1pt" transform="matrix(.48166 0 0 .80277 0 -.000027678)">
<rect id="rect171" height="212.6" width="1063" y="425.2" x="0" fill="#ffe600"/>
<rect id="rect256" height="212.6" width="1063" y="0.000038" x="0"/>
<rect id="rect255" height="212.6" width="1063" y="212.6" x="0" fill="#f00"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Sodipodi ("http://www.sodipodi.com/") -->
<!-- /Creative Commons Public Domain -->
<!--
<rdf:RDF xmlns="http://web.resource.org/cc/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<Work rdf:about="">
<dc:title>New Zealand, Australia, United Kingdom, United States,
Bosnia and Herzegovina, Azerbaijan, Armenia, Bahamas, Belgium, Benin,
Bulgaria, Estonia, Finland, Gabon, Gambia, Germany, Greece, Greenland,
Guinea, Honduras, Israel, Jamaica, Jordan, and Romania Flags</dc:title>
<dc:rights><Agent>
<dc:title>Daniel McRae</dc:title>
</Agent></dc:rights>
<license rdf:resource="http://web.resource.org/cc/PublicDomain" />
</Work>
<License rdf:about="http://web.resource.org/cc/PublicDomain">
<permits rdf:resource="http://web.resource.org/cc/Reproduction" />
<permits rdf:resource="http://web.resource.org/cc/Distribution" />
<permits rdf:resource="http://web.resource.org/cc/DerivativeWorks" />
</License>
</rdf:RDF>
-->
<svg id="svg1" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="512" width="512" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata id="metadata2995">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
</cc:Work>
</rdf:RDF>
</metadata>
<defs id="defs3">
<clipPath id="clipPath3224" clipPathUnits="userSpaceOnUse">
<rect id="rect3226" fill-opacity="0.67" height="500" width="500" y=".0000023437" x="250"/>
</clipPath>
</defs>
<g id="flag" clip-path="url(#clipPath3224)" transform="matrix(1.024 0 0 1.024 -256 -0.0000024)">
<g id="g578" stroke-width="1pt" transform="scale(16.667)">
<rect id="rect124" height="30" width="60" y="0" x="0" fill="#006"/>
<g id="g584">
<path id="path146" d="m0 0v3.3541l53.292 26.646h6.708v-3.354l-53.292-26.646h-6.708zm60 0v3.354l-53.292 26.646h-6.708v-3.354l53.292-26.646h6.708z" fill="#fff"/>
<path id="path136" d="m25 0v30h10v-30h-10zm-25 10v10h60v-10h-60z" fill="#fff"/>
<path id="path141" d="m0 12v6h60v-6h-60zm27-12v30h6v-30h-6z" fill="#c00"/>
<path id="path150" d="m0 30 20-10h4.472l-20 10h-4.472zm0-30 20 10h-4.472l-15.528-7.7639v-2.2361zm35.528 10 20-10h4.472l-20 10h-4.472zm24.472 20-20-10h4.472l15.528 7.764v2.236z" fill="#c00"/>
</g>
</g>
</g>
</svg>
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 201 KiB

+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Sodipodi ("http://www.sodipodi.com/") -->
<svg id="svg378" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="512" width="512" version="1" y="0" x="0" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata id="metadata3176">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="flag" fill-rule="evenodd" stroke-width="1pt" transform="matrix(.48166 0 0 .72249 0 7.4219e-7)">
<rect id="rect171" height="708.66" width="1063" y="0" x="0" fill="#fff"/>
<rect id="rect403" height="708.66" width="354.33" y="0" x="0" fill="#00267f"/>
<rect id="rect135" height="708.66" width="354.33" y="0" x="708.66" fill="#f31830"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 937 B

+522
View File
@@ -0,0 +1,522 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Sodipodi ("http://www.sodipodi.com/") -->
<svg id="svg734" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="512" width="512" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata id="metadata4587">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
</cc:Work>
</rdf:RDF>
</metadata>
<defs id="defs736">
<clipPath id="clipPath7329" clipPathUnits="userSpaceOnUse">
<rect id="rect7331" fill-opacity="0.67" height="496.06" width="496.06" y="-.000017760" x="185.98"/>
</clipPath>
</defs>
<g id="flag" clip-path="url(#clipPath7329)" fill-rule="evenodd" transform="matrix(1.0321 0 0 1.0321 -191.96 .000018331)">
<rect id="rect1052" height="496.06" width="868.11" y="-.000004" x="0" stroke-width="1pt" fill="#fff"/>
<rect id="rect1927" height="162.93" width="868.11" y="333.13" x="0" stroke-width="1pt" fill="#da0000"/>
<g id="g772" transform="matrix(1.4724 0 0 1.4724 117.81 35.66)" stroke-width="1pt" fill="#fff">
<rect id="rect750" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect751" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect753" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect755" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect756" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect757" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect758" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect759" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect760" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect763" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect764" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect765" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect766" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect767" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect768" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect769" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect770" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect771" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g791" transform="matrix(1.4724 0 0 1.4724 39.725 35.66)" stroke-width="1pt" fill="#fff">
<rect id="rect792" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect793" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect794" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect795" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect796" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect797" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect798" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect799" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect800" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect801" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect802" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect803" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect804" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect805" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect806" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect807" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect808" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect809" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g812" transform="matrix(1.4724 0 0 1.4724 195.25 35.66)" stroke-width="1pt" fill="#fff">
<rect id="rect813" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect814" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect815" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect816" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect817" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect818" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect819" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect820" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect821" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect822" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect823" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect824" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect825" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect826" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect827" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect828" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect829" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect830" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g840" transform="matrix(1.4724 0 0 1.4724 742.06 35.66)" stroke-width="1pt" fill="#fff">
<rect id="rect841" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect842" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect843" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect844" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect845" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect846" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect847" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect848" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect849" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect850" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect851" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect852" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect853" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect854" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect855" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect856" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect857" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect858" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g859" transform="matrix(1.4724 0 0 1.4724 273.19 35.66)" stroke-width="1pt" fill="#fff">
<rect id="rect860" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect861" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect862" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect863" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect864" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect865" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect866" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect867" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect868" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect869" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect870" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect871" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect872" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect873" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect874" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect875" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect876" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect877" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g878" transform="matrix(1.4724 0 0 1.4724 351.41 35.66)" stroke-width="1pt" fill="#fff">
<rect id="rect879" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect880" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect881" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect882" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect883" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect884" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect885" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect886" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect887" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect888" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect889" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect890" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect891" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect892" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect893" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect894" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect895" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect896" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<rect id="rect1926" height="162.93" width="868.11" y="-.000011" x="0" stroke-width="1pt" fill="#239f40"/>
<g id="g897" transform="matrix(1.4724 0 0 1.4724 430.09 35.66)" stroke-width="1pt" fill="#fff">
<rect id="rect898" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect899" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect900" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect901" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect902" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect903" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect904" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect905" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect906" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect907" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect908" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect909" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect910" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect911" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect912" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect913" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect914" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect915" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g916" transform="matrix(1.4724 0 0 1.4724 508.32 35.66)" stroke-width="1pt" fill="#fff">
<rect id="rect917" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect918" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect919" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect920" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect921" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect922" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect923" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect924" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect925" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect926" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect927" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect928" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect929" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect930" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect931" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect932" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect933" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect934" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g935" transform="matrix(1.4724 0 0 1.4724 586.54 35.66)" stroke-width="1pt" fill="#fff">
<rect id="rect936" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect937" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect938" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect939" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect940" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect941" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect942" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect943" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect944" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect945" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect946" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect947" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect948" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect949" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect950" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect951" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect952" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect953" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g954" transform="matrix(1.4724 0 0 1.4724 665.22 35.66)" stroke-width="1pt" fill="#fff">
<rect id="rect955" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect956" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect957" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect958" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect959" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect960" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect961" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect962" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect963" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect964" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect965" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect966" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect967" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect968" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect969" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect970" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect971" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect972" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g992" transform="matrix(1.4724 0 0 1.4724 -39.296 35.66)" stroke-width="1pt" fill="#fff">
<rect id="rect993" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect994" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect995" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect996" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect997" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect998" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect999" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1000" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect1001" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect1002" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect1003" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1004" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1005" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1006" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect1007" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect1008" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect1009" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect1010" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g1225" transform="matrix(1.4724 0 0 1.4724 117.81 -165.4)" stroke-width="1pt" fill="#fff">
<rect id="rect1226" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect1227" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect1228" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect1229" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1230" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect1231" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1232" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1233" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect1234" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect1235" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect1236" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1237" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1238" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1239" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect1240" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect1241" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect1242" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect1243" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g1244" transform="matrix(1.4724 0 0 1.4724 39.725 -165.4)" stroke-width="1pt" fill="#fff">
<rect id="rect1245" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect1246" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect1247" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect1248" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1249" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect1250" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1251" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1252" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect1253" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect1254" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect1255" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1256" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1257" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1258" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect1259" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect1260" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect1261" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect1262" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g1263" transform="matrix(1.4724 0 0 1.4724 195.25 -165.4)" stroke-width="1pt" fill="#fff">
<rect id="rect1264" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect1265" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect1266" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect1267" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1268" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect1269" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1270" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1271" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect1272" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect1273" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect1274" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1275" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1276" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1277" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect1278" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect1279" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect1280" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect1281" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g1282" transform="matrix(1.4724 0 0 1.4724 742.06 -165.4)" stroke-width="1pt" fill="#fff">
<rect id="rect1283" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect1284" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect1285" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect1286" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1287" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect1288" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1289" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1290" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect1291" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect1292" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect1293" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1294" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1295" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1296" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect1297" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect1298" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect1299" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect1300" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g1301" transform="matrix(1.4724 0 0 1.4724 273.19 -165.4)" stroke-width="1pt" fill="#fff">
<rect id="rect1302" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect1303" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect1304" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect1305" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1306" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect1307" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1308" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1309" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect1310" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect1311" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect1312" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1313" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1314" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1315" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect1316" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect1317" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect1318" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect1319" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g1320" transform="matrix(1.4724 0 0 1.4724 351.41 -165.4)" stroke-width="1pt" fill="#fff">
<rect id="rect1321" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect1322" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect1323" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect1324" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1325" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect1326" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1327" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1328" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect1329" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect1330" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect1331" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1332" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1333" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1334" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect1335" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect1336" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect1337" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect1338" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g1339" transform="matrix(1.4724 0 0 1.4724 430.09 -165.4)" stroke-width="1pt" fill="#fff">
<rect id="rect1340" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect1341" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect1342" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect1343" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1344" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect1345" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1346" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1347" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect1348" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect1349" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect1350" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1351" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1352" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1353" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect1354" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect1355" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect1356" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect1357" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g1358" transform="matrix(1.4724 0 0 1.4724 508.32 -165.4)" stroke-width="1pt" fill="#fff">
<rect id="rect1359" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect1360" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect1361" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect1362" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1363" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect1364" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1365" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1366" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect1367" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect1368" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect1369" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1370" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1371" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1372" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect1373" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect1374" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect1375" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect1376" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g1377" transform="matrix(1.4724 0 0 1.4724 586.54 -165.4)" stroke-width="1pt" fill="#fff">
<rect id="rect1378" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect1379" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect1380" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect1381" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1382" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect1383" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1384" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1385" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect1386" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect1387" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect1388" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1389" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1390" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1391" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect1392" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect1393" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect1394" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect1395" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g1396" transform="matrix(1.4724 0 0 1.4724 665.22 -165.4)" stroke-width="1pt" fill="#fff">
<rect id="rect1397" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect1398" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect1399" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect1400" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1401" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect1402" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1403" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1404" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect1405" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect1406" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect1407" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1408" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1409" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1410" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect1411" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect1412" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect1413" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect1414" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g1415" transform="matrix(1.4724 0 0 1.4724 -39.296 -165.4)" stroke-width="1pt" fill="#fff">
<rect id="rect1416" height="2.2217" width="32.289" y="206.72" x="32.067"/>
<rect id="rect1417" height="2.1576" width="2.2217" y="217.83" x="36.881"/>
<rect id="rect1418" transform="rotate(90)" height="6.453" width="2.2217" y="-64.44" x="217.76"/>
<rect id="rect1419" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1420" transform="rotate(90)" height="12.229" width="2.2217" y="-80.765" x="217.76"/>
<rect id="rect1421" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1422" transform="translate(11.035 -.000015259)" height="13.266" width="2.2217" y="206.72" x="67.911"/>
<rect id="rect1423" height="13.266" width="2.2217" y="206.72" x="73.391"/>
<rect id="rect1424" transform="matrix(0 1 -1 0 .0092220 -.000015259)" height="11.785" width="2.2217" y="-43.903" x="212"/>
<rect id="rect1425" transform="rotate(90)" height="11.785" width="2.2217" y="-69.772" x="212"/>
<rect id="rect1426" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1427" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1428" height="7.4157" width="2.2217" y="212.57" x="57.987"/>
<rect id="rect1429" transform="translate(-.17213 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="32.299"/>
<rect id="rect1430" height="7.4157" width="2.2217" y="212.57" x="51.082"/>
<rect id="rect1431" transform="translate(0 -.000015259)" height="7.4157" width="2.2217" y="212.57" x="41.691"/>
<rect id="rect1432" transform="rotate(90)" height="9.9621" width="2.2217" y="-52.641" x="217.76"/>
<rect id="rect1433" transform="rotate(90)" height="6.3161" width="2.2217" y="-53.304" x="212"/>
</g>
<g id="g1668" transform="translate(0 171.39)" stroke-width="1pt" fill="#d90000">
<rect id="rect1224" height="10.178" width="5.8601" y="157.55" x="119.3"/>
<rect id="rect1645" height="10.178" width="5.8601" y="157.55" x="274.84"/>
<rect id="rect1646" height="10.178" width="5.8601" y="157.55" x="-.042406"/>
<rect id="rect1647" transform="translate(39.591 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1648" transform="translate(117.97 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1649" transform="translate(156.75 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1650" transform="translate(196.34 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1651" height="10.178" width="5.8601" y="157.55" x="313.62"/>
<rect id="rect1652" height="10.178" width="5.8601" y="157.55" x="510.36"/>
<rect id="rect1653" transform="translate(313.9 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1654" transform="translate(352.69 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1655" transform="translate(391.87 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1656" transform="translate(431.06 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1657" height="10.178" width="5.8601" y="157.55" x="783.46"/>
<rect id="rect1658" transform="translate(509.03 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1659" transform="translate(548.62 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1660" transform="translate(588.62 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1661" transform="translate(626.19 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1662" transform="translate(666.18 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1663" transform="translate(705.37 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1664" height="10.178" width="5.8601" y="157.55" x="860.63"/>
<rect id="rect1665" transform="translate(783.75 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1667" height="10.178" width="5.8601" y="157.55" x="39.308"/>
</g>
<g id="g1692" transform="translate(0 -.000015259)" stroke-width="1pt" fill="#239e3f">
<rect id="rect1693" height="10.178" width="5.8601" y="157.55" x="119.3"/>
<rect id="rect1694" height="10.178" width="5.8601" y="157.55" x="274.84"/>
<rect id="rect1695" height="10.178" width="5.8601" y="157.55" x="-.042406"/>
<rect id="rect1696" transform="translate(39.591 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1697" transform="translate(117.97 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1698" transform="translate(156.75 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1699" transform="translate(196.34 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1700" height="10.178" width="5.8601" y="157.55" x="313.62"/>
<rect id="rect1701" height="10.178" width="5.8601" y="157.55" x="510.36"/>
<rect id="rect1702" transform="translate(313.9 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1703" transform="translate(352.69 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1704" transform="translate(391.87 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1705" transform="translate(431.06 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1706" height="10.178" width="5.8601" y="157.55" x="783.46"/>
<rect id="rect1707" transform="translate(509.03 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1708" transform="translate(548.62 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1709" transform="translate(588.62 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1710" transform="translate(626.19 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1711" transform="translate(666.18 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1712" transform="translate(705.37 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1713" height="10.178" width="5.8601" y="157.55" x="860.63"/>
<rect id="rect1714" transform="translate(783.75 -.000015259)" height="10.178" width="5.8601" y="157.55" x="39.308"/>
<rect id="rect1715" height="10.178" width="5.8601" y="157.55" x="39.308"/>
</g>
<g id="g1043" fill="#da0000" transform="translate(11.919 -515.45)">
<path id="path1928" d="m445.16 706.81c8.19 10.039 33.438 65.548-15.241 101.91-22.884 17.223-8.714 18.093-8.037 20.988 36.785-19.539 48.799-46.013 48.52-69.727-0.28-23.715-12.853-44.669-25.242-53.167z"/>
<path id="path1929" d="m450.03 704.23c18.166 9.301 57.701 56.079 15.149 108.85 26.439-5.868 60.053-83.729-15.149-108.85z"/>
<path id="path1930" d="m394.24 704.23c-18.166 9.301-57.701 56.079-15.149 108.85-26.439-5.868-60.053-83.729 15.149-108.85z"/>
<path id="path1931" d="m399.04 706.81c-8.19 10.039-33.438 65.548 15.241 101.91 22.884 17.223 8.714 18.093 8.037 20.988-36.785-19.539-48.799-46.013-48.52-69.727 0.28-23.715 12.853-44.669 25.242-53.167z"/>
<path id="path1932" d="m468.8 824.65c-14.398 0.228-32.549-1.984-46.01-8.979 2.217 4.311 4.059 7.026 6.276 11.337 12.838 1.184 30.554 2.649 39.734-2.358z"/>
<path id="path1933" d="m376.78 824.65c14.398 0.228 32.549-1.984 46.01-8.979-2.217 4.311-4.059 7.026-6.276 11.337-12.838 1.184-30.554 2.649-39.734-2.358z"/>
<path id="path1934" d="m403.19 690.25c2.917 7.753 10.57 8.88 18.762 4.315 5.97 3.582 15.202 3.809 18.386-3.94 2.421 19.169-17.736 14.634-18.487 10.882-7.504 7.254-21.475 3.064-18.661-11.257z"/>
<path id="path1042" d="m422.49 836.72 7.57-8.652 1.082-116.44-9.012-7.931-9.012 7.57 1.802 117.16 7.57 8.291z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 46 KiB

+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Sodipodi ("http://www.sodipodi.com/") -->
<svg id="svg378" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="512" width="512" version="1" y="0" x="0" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata id="metadata4604">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="flag" fill-rule="evenodd" stroke-width="1pt" transform="matrix(.48166 0 0 .72249 0 .0000024116)">
<rect id="rect171" height="708.66" width="1063" y="0" x="0" fill="#fff"/>
<rect id="rect403" height="708.66" width="354.33" y="0" x="0" fill="#005700"/>
<rect id="rect135" height="708.66" width="354.33" y="0" x="708.66" fill="#fc0000"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 939 B

+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Sodipodi ("http://www.sodipodi.com/") -->
<svg id="svg378" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="512" width="512" version="1" y="0" x="0" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata id="metadata3683">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="flag" fill-rule="evenodd">
<rect id="rect149" transform="scale(-1)" height="512" width="512" y="-512" x="-512" fill="#fff"/>
<rect id="rect148" transform="scale(-1)" height="256" width="512" y="-512" x="-512" stroke-width="1pt" fill="#df0000"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 847 B

+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Sodipodi ("http://www.sodipodi.com/") -->
<!-- /Creative Commons Public Domain -->
<!--
<rdf:RDF xmlns="http://web.resource.org/cc/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<Work rdf:about="">
<dc:title>New Zealand, Australia, United Kingdom, United States,
Bosnia and Herzegovina, Azerbaijan, Armenia, Bahamas, Belgium, Benin,
Bulgaria, Estonia, Finland, Gabon, Gambia, Germany, Greece, Greenland,
Guinea, Honduras, Israel, Jamaica, Jordan, and Romania Flags</dc:title>
<dc:rights><Agent>
<dc:title>Daniel McRae</dc:title>
</Agent></dc:rights>
<license rdf:resource="http://web.resource.org/cc/PublicDomain" />
</Work>
<License rdf:about="http://web.resource.org/cc/PublicDomain">
<permits rdf:resource="http://web.resource.org/cc/Reproduction" />
<permits rdf:resource="http://web.resource.org/cc/Distribution" />
<permits rdf:resource="http://web.resource.org/cc/DerivativeWorks" />
</License>
</rdf:RDF>
-->
<svg id="svg548" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="512" width="512" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata id="metadata4337">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="flag" transform="matrix(.51251 0 0 .76877 0 -8.2e-7)">
<g id="g555" fill-rule="evenodd" stroke-width="1pt" transform="scale(8.325)">
<rect id="rect551" height="80" width="40" y="0" x="0" fill="#00319c"/>
<rect id="rect552" height="80" width="40" y="0" x="40" fill="#ffde00"/>
<rect id="rect553" height="80" width="40" y="0" x="80" fill="#de2110"/>
</g>
</g>
</svg>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Sodipodi ("http://www.sodipodi.com/") -->
<svg id="svg378" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="512" width="512" version="1" y="0" x="0" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata id="metadata4346">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="flag" fill-rule="evenodd" stroke-width="1pt" transform="matrix(.48166 0 0 .72249 0 .0000024116)">
<rect id="rect171" height="708.66" width="1063" y="0" x="0" fill="#fff"/>
<rect id="rect403" height="472.44" width="1063" y="236.22" x="0" fill="#01017e"/>
<rect id="rect135" height="236.22" width="1063" y="472.44" x="0" fill="#fe0101"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 940 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,4 @@
<?php
header('HTTP/1.0 404 not found');
exit;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Some files were not shown because too many files have changed in this diff Show More