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
*.tsbuildinfo
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 { getTranslations, setRequestLocale } from 'next-intl/server'
import { Link } from '@/i18n/navigation'
import { getForum, getTopics, countTopics } from '@/lib/forum'
import { getForum, getForumPath, countTopics, getTopics, resolvePosters } from '@/lib/forum'
import { formatForumDate } from '@/lib/forum-format'
import { getSession } from '@/lib/session'
import { forumIsModerator } from '@/lib/forum-perm'
import { PageShell } from '@/components/PageShell'
import { NewTopicForm } from '@/components/NewTopicForm'
import { ForumBreadcrumb } from '@/components/ForumBreadcrumb'
import { Pagination } from '@/components/Pagination'
export const dynamic = 'force-dynamic'
const PER_PAGE = 20
const PER_PAGE = 10
export default async function ForumPage({
export default async function SubforumPage({
params,
searchParams,
}: {
@@ -23,58 +25,93 @@ export default async function ForumPage({
const t = await getTranslations('Forum')
const id = Number(forumId)
const forum = await getForum(id, locale)
const forum = await getForum(id)
if (!forum) notFound()
const session = await getSession()
const isMod = await forumIsModerator(session)
const canCreate = Boolean(session.username)
const total = await countTopics(id)
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE))
const page = Math.min(Math.max(1, Number((await searchParams).page) || 1), totalPages)
const topics = await getTopics(id, (page - 1) * PER_PAGE, PER_PAGE)
const session = await getSession()
const canPost = Boolean(session.username)
const topics = await getTopics(id, (page - 1) * PER_PAGE, PER_PAGE, isMod)
const posters = await resolvePosters(
topics.flatMap((tp) => [tp.poster, tp.last_poster].filter(Boolean) as number[]),
)
const name = (pid: number | null) => (pid != null ? posters.get(pid)?.name ?? `#${pid}` : '')
const path = await getForumPath(id)
const hrefFor = (p: number) => `/forum/${id}?page=${p}`
return (
<PageShell title={forum!.name}>
<div className="box-content">
<div className="body-box-content">
<p>
<Link href="/forum"> {t('backToForum')}</Link>
</p>
{forum!.description && <p className="second-brown">{forum!.description}</p>}
<br />
<PageShell title={forum.name}>
<div className="main-wide">
{path && <ForumBreadcrumb items={[{ label: t('title'), href: '/forum' }, { label: path.forum }]} />}
<div className="forum-padding">
<div className="forum_header">
<div className="forum_title">
<h1>{forum.name}</h1>
{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 ? (
<p className="second-brown centered">{t('noTopics')}</p>
<h2 className="forum-empty">{t('noTopics')}</h2>
) : (
<table className="max-center-table">
<tbody>
{topics.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.sticky && <span className="second-yellow small-font">[{t('sticky')}] </span>}
{topic.locked && <span className="red-info2 small-font">[{t('locked')}] </span>}
{topic.name}
</Link>
<p className="third-brown small-font">
{t('by')} {topic.poster}
</p>
</td>
<td className="real-info-box no-wrap-td second-brown small-font">
{topic.views} {t('views')}
</td>
</tr>
))}
</tbody>
</table>
topics.map((tp) => (
<ul className="topic_row" key={tp.id}>
<li className="icon">
<img
src={`/forum/forum_icons/topic_${tp.locked ? 'read_locked' : tp.deleted ? 'read_mine' : 'unread'}.png`}
height={39}
width={55}
alt=""
/>
</li>
<li className="topic_title_by_date">
<h1>
<Link href={`/forum/topic/${tp.id}`}>
{tp.deleted && `[${t('deleted')}] `}
{tp.sticky && `[${t('sticky')}] `}
{tp.locked && `[${t('locked')}] `}
{tp.name}
</Link>
</h1>
<p>
{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}`} />
{canPost ? (
<NewTopicForm forumId={id} />
) : (
<p className="second-brown centered separate">{t('loginToPost')}</p>
)}
<div className="actions_c" style={{ marginTop: 20 }}>
<span />
<Pagination page={page} totalPages={totalPages} hrefFor={hrefFor} />
</div>
</div>
</div>
</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 { Link } from '@/i18n/navigation'
import { getForumIndex } from '@/lib/forum'
import { getForumIndex, resolvePosters, type ForumRow } from '@/lib/forum'
import { formatForumDate } from '@/lib/forum-format'
import { PageShell } from '@/components/PageShell'
import { ForumSearchBox } from '@/components/ForumSearchBox'
export const dynamic = 'force-dynamic'
@@ -10,53 +10,121 @@ export default async function ForumIndexPage({ params }: { params: Promise<{ loc
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations('Forum')
const categories = await getForumIndex(locale)
const categories = await getForumIndex()
// Nombres de los últimos posteadores de todos los foros, en una sola consulta.
const posterIds = categories.flatMap((c) => c.forums.map((f) => f.last_poster).filter(Boolean) as number[])
const posters = await resolvePosters(posterIds)
function topicsLabel(n: number) {
return `${n} ${n === 1 ? t('topic') : t('topics')}`
}
function NormalRow({ f }: { f: ForumRow }) {
const iconSrc = f.type === 3 ? f.icon || '' : `/forum/forum_icons/${f.icon || 'forum_read'}.png`
return (
<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 (
<PageShell title={t('title')}>
<div className="box-content">
<div className="body-box-content">
<ForumSearchBox />
{categories.length === 0 ? (
<p className="second-brown centered">{t('noForums')}</p>
) : (
categories.map((cat) => (
<div key={cat.id}>
<div className="title-content-h3">
<h3 className="big-font">{cat.name}</h3>
<div className="forum-container">
{categories.length === 0 ? (
<p className="forum-empty">{t('noForums')}</p>
) : (
categories.map((cat) => {
const tiles = cat.forums.filter((f) => f.type === 1 || f.type === 2)
const rows = cat.forums.filter((f) => f.type !== 1 && f.type !== 2)
return (
<div key={cat.id} className="forum-category" style={{ marginBottom: 40 }}>
<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>
<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>
</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 { getTranslations, setRequestLocale } from 'next-intl/server'
import { Link } from '@/i18n/navigation'
import { getTopic, getPosts, countPosts, listForumsForMove } from '@/lib/forum'
import {
getTopic,
getTopicPath,
getPosts,
countPosts,
resolvePosters,
getUserPostCount,
} from '@/lib/forum'
import { formatForumDate } from '@/lib/forum-format'
import { getSession } from '@/lib/session'
import { forumIsModerator, canEditPost } from '@/lib/forum-perm'
import { PageShell } from '@/components/PageShell'
import { ReplyForm } from '@/components/ReplyForm'
import { PostActions } from '@/components/PostActions'
import { TopicModBar } from '@/components/TopicModBar'
import { ForumBreadcrumb } from '@/components/ForumBreadcrumb'
import { Pagination } from '@/components/Pagination'
import { ReplyForm } from '@/components/ReplyForm'
import { EditPostForm } from '@/components/EditPostForm'
import { ForumModActions } from '@/components/ForumModActions'
export const dynamic = 'force-dynamic'
const PER_PAGE = 15
const PER_PAGE = 5
export default async function TopicPage({
params,
@@ -30,77 +38,136 @@ export default async function TopicPage({
const isMod = await forumIsModerator(session)
const topic = await getTopic(id, isMod)
if (!topic) notFound()
const total = await countPosts(id, isMod)
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE))
const page = Math.min(Math.max(1, Number((await searchParams).page) || 1), totalPages)
const posts = await getPosts(id, (page - 1) * PER_PAGE, PER_PAGE, isMod)
const moveForums = isMod ? (await listForumsForMove()).filter((f) => f.id !== topic!.forum_id) : []
const canReply = Boolean(session.username) && (!topic!.locked || isMod)
const title = (
<>
{topic!.sticky && <span className="second-yellow">[{t('pinned')}] </span>}
{topic!.locked && <span className="red-info2">[{t('locked')}] </span>}
{topic!.deleted && <span className="red-info2">[{t('deletedMark')}] </span>}
{topic!.name}
</>
const posters = await resolvePosters(posts.map((p) => p.poster))
// Nº de posts por autor visible (para el panel lateral), en paralelo.
const uniquePosters = [...new Set(posts.map((p) => p.poster))]
const counts = new Map<number, number>(
await Promise.all(uniquePosters.map(async (pid) => [pid, await getUserPostCount(pid)] as const)),
)
const path = await getTopicPath(id)
const canReply = Boolean(session.username) && (!topic.locked || isMod)
return (
<PageShell title={title}>
<div className="box-content">
<div className="body-box-content">
<p>
<Link href="/forum"> {t('backToForum')}</Link>
</p>
<br />
<PageShell
title={
<>
{topic.sticky && `[${t('sticky')}] `}
{topic.locked && `[${t('locked')}] `}
{topic.deleted && `[${t('deleted')}] `}
{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 && (
<TopicModBar
topicId={id}
locked={topic!.locked}
sticky={topic!.sticky}
deleted={topic!.deleted}
moveForums={moveForums}
/>
)}
{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 className="topic_header">
<div className="topic_title">
<h1>{topic.name}</h1>
<h3>{formatForumDate(topic.created, locale)}</h3>
</div>
<h4>
<b>{total}</b> {t('posts')}
</h4>
</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>
</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 { isAdmin } from '@/lib/admin'
import { deleteForum, setForumVisibility, updateForum } from '@/lib/admin-forum'
import { deleteForum, updateForum } from '@/lib/admin-forum'
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
const { id } = await params
await deleteForum(Number(id))
return Response.json({ success: true })
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
const { id } = await params
let b: Record<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 })
const ok = await deleteForum(Number(id))
return Response.json({ success: ok, error: ok ? undefined : 'forumNotEmpty' })
}
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
@@ -25,14 +15,21 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
const { id } = await params
let b: Record<string, unknown> = {}
try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) }
const name = String(b.name ?? '').trim()
const nameEn = String(b.nameEn ?? '').trim() || null
const description = String(b.description ?? '').trim()
const descriptionEn = String(b.descriptionEn ?? '').trim() || null
const order = Number(b.order) || 0
const visibility = b.visibility === false ? 0 : 1
if (!name) return Response.json({ success: false, error: 'emptyError' })
await updateForum(Number(id), name, nameEn, description, descriptionEn, order, visibility)
try {
b = await request.json()
} catch {
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
}
const f = {
category: Number(b.category),
name: String(b.name ?? '').trim(),
description: String(b.description ?? '').trim(),
icon: String(b.icon ?? '').trim() || null,
colortitle: String(b.colortitle ?? '').trim() || null,
type: Number(b.type) || 0,
order: Number(b.order) || 0,
}
if (!f.category || !f.name) return Response.json({ success: false, error: 'emptyError' })
await updateForum(Number(id), f)
return Response.json({ success: true })
}
@@ -15,11 +15,14 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
const { id } = await params
let b: Record<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 nameEn = String(b.nameEn ?? '').trim() || null
const order = Number(b.order) || 0
if (!name) return Response.json({ success: false, error: 'emptyError' })
await updateCategory(Number(id), name, nameEn, order)
await updateCategory(Number(id), name, order)
return Response.json({ success: true })
}
@@ -6,11 +6,14 @@ export async function POST(request: Request) {
const session = await getSession()
if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
let b: Record<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 nameEn = String(b.nameEn ?? '').trim() || null
const order = Number(b.order) || 0
if (!name) return Response.json({ success: false, error: 'emptyError' })
const id = await createCategory(name, nameEn, order)
const id = await createCategory(name, order)
return Response.json({ success: true, id })
}
+16 -10
View File
@@ -6,15 +6,21 @@ export async function POST(request: Request) {
const session = await getSession()
if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
let b: Record<string, unknown> = {}
try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) }
const categoryId = Number(b.categoryId)
const name = String(b.name ?? '').trim()
const nameEn = String(b.nameEn ?? '').trim() || null
const description = String(b.description ?? '').trim()
const descriptionEn = String(b.descriptionEn ?? '').trim() || null
const order = Number(b.order) || 0
const visibility = b.visibility === false ? 0 : 1
if (!categoryId || !name) return Response.json({ success: false, error: 'emptyError' })
const id = await createForum(categoryId, name, nameEn, description, descriptionEn, order, visibility)
try {
b = await request.json()
} catch {
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
}
const f = {
category: Number(b.category),
name: String(b.name ?? '').trim(),
description: String(b.description ?? '').trim(),
icon: String(b.icon ?? '').trim() || null,
colortitle: String(b.colortitle ?? '').trim() || null,
type: Number(b.type) || 0,
order: Number(b.order) || 0,
}
if (!f.category || !f.name) return Response.json({ success: false, error: 'emptyError' })
const id = await createForum(f)
return Response.json({ success: true, id })
}
+35 -36
View File
@@ -1,55 +1,54 @@
import { getSession } from '@/lib/session'
import { getTopic, getForum } from '@/lib/forum'
import { setTopicFlag, moveTopic } from '@/lib/forum-write'
import { getTopic } from '@/lib/forum'
import { getPost, setTopicFlag, setPostDeleted } from '@/lib/forum-write'
import { forumIsModerator } from '@/lib/forum-perm'
type Action = 'lock' | 'unlock' | 'sticky' | 'unsticky' | 'delete' | 'restore' | 'move'
/** Moderación de temas: bloquear, fijar, borrar. Solo moderadores. */
/**
* Moderación del foro (solo moderadores), por POST para no mutar con GET.
* kind='post' → action: delete | undelete
* kind='topic' → action: delete | undelete | lock | unlock
*/
export async function POST(request: Request) {
const session = await getSession()
if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
if (!(await forumIsModerator(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
let b: Record<string, string> = {}
try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) }
const topicId = Number(b.topicId)
const action = String(b.action ?? '') as Action
if (!topicId) return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
try {
b = await request.json()
} catch {
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
}
const kind = String(b.kind ?? '')
const id = Number(b.id)
const action = String(b.action ?? '')
if (!id || (kind !== 'post' && kind !== 'topic'))
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
const topic = await getTopic(topicId, true) // incluye borrados (ya verificado que es mod)
if (kind === 'post') {
const post = await getPost(id)
if (!post) return Response.json({ success: false, error: 'postNotFound' }, { status: 404 })
if (action === 'delete') await setPostDeleted(id, true)
else if (action === 'undelete') await setPostDeleted(id, false)
else return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
return Response.json({ success: true })
}
const topic = await getTopic(id, true)
if (!topic) return Response.json({ success: false, error: 'topicNotFound' }, { status: 404 })
switch (action) {
case 'delete':
await setTopicFlag(id, 'deleted', true)
break
case 'undelete':
await setTopicFlag(id, 'deleted', false)
break
case 'lock':
await setTopicFlag(topicId, 'locked', true)
await setTopicFlag(id, 'locked', true)
break
case 'unlock':
await setTopicFlag(topicId, 'locked', false)
await setTopicFlag(id, 'locked', false)
break
case 'sticky':
await setTopicFlag(topicId, 'sticky', true)
break
case 'unsticky':
await setTopicFlag(topicId, 'sticky', false)
break
case 'delete':
await setTopicFlag(topicId, 'deleted', true)
return Response.json({ success: true, forumId: topic.forum_id })
case 'restore':
await setTopicFlag(topicId, 'deleted', false)
break
case 'move': {
const newForumId = Number(b.forumId)
if (!newForumId || newForumId === topic.forum_id) {
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
}
if (!(await getForum(newForumId))) {
return Response.json({ success: false, error: 'invalidForum' }, { status: 400 })
}
await moveTopic(topicId, newForumId)
return Response.json({ success: true })
}
default:
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
}
+11 -44
View File
@@ -1,25 +1,10 @@
import { getSession } from '@/lib/session'
import { getPost, updatePost, setPostDeleted } from '@/lib/forum-write'
import { getPost, updatePost } from '@/lib/forum-write'
import { getTopic } from '@/lib/forum'
import { forumIsModerator, canEditPost } from '@/lib/forum-perm'
import { plainLength } from '@/lib/forum-sanitize'
const MIN_TEXT_LEN = 5
/** Restaurar un post borrado. Solo moderadores. */
export async function PUT(request: Request) {
const session = await getSession()
if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
if (!(await forumIsModerator(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
let b: Record<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 })
}
const MIN_TEXT_LEN = 3
/** Editar el texto de un post propio (o cualquiera si es moderador). */
export async function PATCH(request: Request) {
@@ -27,44 +12,26 @@ export async function PATCH(request: Request) {
if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
let b: Record<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 text = String(b.text ?? '').trim()
const text = String(b.text ?? '')
if (!postId) return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
const post = await getPost(postId)
if (!post) return Response.json({ success: false, error: 'postNotFound' }, { status: 404 })
const isMod = await forumIsModerator(session)
if (!canEditPost(session, isMod, post.poster_id)) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
if (!canEditPost(session, isMod, post.poster))
return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
const topic = await getTopic(post.topic_id)
const topic = await getTopic(post.topic)
if (topic?.locked && !isMod) return Response.json({ success: false, error: 'topicLocked' })
if (plainLength(text) < MIN_TEXT_LEN) return Response.json({ success: false, error: 'tooShort' })
await updatePost(postId, text)
return Response.json({ success: true })
}
/** Borrar (lógico) un post propio (o cualquiera si es moderador). */
export async function DELETE(request: Request) {
const session = await getSession()
if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
let b: Record<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 { createPost, topicIsReplyable } from '@/lib/forum-write'
import { plainLength } from '@/lib/forum-sanitize'
export async function POST(request: Request) {
const session = await getSession()
if (!session.accountId || !session.username) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
if (!session.accountId || !session.username)
return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
let b: Record<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 text = String(b.text ?? '').trim()
if (!topicId || !text) return Response.json({ success: false, error: 'emptyError' })
const text = String(b.text ?? '')
if (!topicId || plainLength(text) < 3) return Response.json({ success: false, error: 'emptyError' })
if (!(await topicIsReplyable(topicId))) return Response.json({ success: false, error: 'topicLocked' })
await createPost(topicId, session.username, session.accountId, text)
await createPost(topicId, session.accountId, text)
return Response.json({ success: true })
}
+16 -8
View File
@@ -1,16 +1,24 @@
import { getSession } from '@/lib/session'
import { createTopic, forumIsPostable } from '@/lib/forum-write'
import { createTopic } from '@/lib/forum-write'
import { forumExists } from '@/lib/forum'
import { plainLength } from '@/lib/forum-sanitize'
export async function POST(request: Request) {
const session = await getSession()
if (!session.accountId || !session.username) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
if (!session.accountId || !session.username)
return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
let b: Record<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 name = String(b.name ?? '').trim()
const text = String(b.text ?? '').trim()
if (!forumId || !name || !text) return Response.json({ success: false, error: 'emptyError' })
if (!(await forumIsPostable(forumId))) return Response.json({ success: false, error: 'invalidForum' })
const topicId = await createTopic(forumId, name, session.username, session.accountId, text)
const title = String(b.title ?? '').trim()
const text = String(b.text ?? '')
if (!forumId || title.length < 3) return Response.json({ success: false, error: 'errTitle' })
if (plainLength(text) < 3) return Response.json({ success: false, error: 'emptyError' })
if (!(await forumExists(forumId))) return Response.json({ success: false, error: 'invalidForum' })
const topicId = await createTopic(forumId, title, session.accountId, text)
return Response.json({ success: true, topicId })
}
+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'
type Group = { category: AdminCategory; forums: AdminForum[] }
type ForumFields = {
name: string
description: string
icon: string
colortitle: string
type: string
order: string
}
const EMPTY_FORUM: ForumFields = { name: '', description: '', icon: '', colortitle: '', type: '0', order: '0' }
export function AdminForumManager({ initial }: { initial: Group[] }) {
const t = useTranslations('Admin')
const router = useRouter()
const [busy, setBusy] = useState(false)
const [catForm, setCatForm] = useState({ name: '', nameEn: '', order: '0' })
const [forumForm, setForumForm] = useState<Record<number, { name: string; nameEn: string; description: string; descriptionEn: string; order: string }>>({})
const [editCat, setEditCat] = useState<{ id: number; name: string; nameEn: string; order: string } | null>(null)
const [editForum, setEditForum] = useState<{ id: number; name: string; nameEn: string; description: string; descriptionEn: string; order: string; visibility: number } | null>(null)
const [catForm, setCatForm] = useState({ name: '', order: '0' })
const [forumForm, setForumForm] = useState<Record<number, ForumFields>>({})
const [editCat, setEditCat] = useState<{ id: number; name: string; order: string } | null>(null)
const [editForum, setEditForum] = useState<(ForumFields & { id: number }) | null>(null)
function ff(catId: number) {
return forumForm[catId] ?? { name: '', nameEn: '', description: '', descriptionEn: '', order: '0' }
}
function setFF(catId: number, patch: Partial<{ name: string; nameEn: string; description: string; descriptionEn: string; order: string }>) {
const ff = (catId: number) => forumForm[catId] ?? EMPTY_FORUM
const setFF = (catId: number, patch: Partial<ForumFields>) =>
setForumForm((s) => ({ ...s, [catId]: { ...ff(catId), ...patch } }))
}
async function call(url: string, method: string, body?: unknown): Promise<boolean> {
setBusy(true)
@@ -38,12 +45,46 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
return true
}
if (d.error === 'categoryNotEmpty') alert(t('categoryNotEmpty'))
else if (d.error === 'forumNotEmpty') alert(t('forumNotEmpty'))
return false
} finally {
setBusy(false)
}
}
const forumBody = (v: ForumFields, category: number) => ({
category,
name: v.name,
description: v.description,
icon: v.icon,
colortitle: v.colortitle,
type: Number(v.type),
order: Number(v.order),
})
function ForumEditFields({
v,
onChange,
}: {
v: ForumFields
onChange: (patch: Partial<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 (
<div>
{/* Alta de categoría */}
@@ -51,18 +92,16 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
onSubmit={(e) => {
e.preventDefault()
if (catForm.name.trim())
call('/api/admin/forum/category', 'POST', { name: catForm.name, nameEn: catForm.nameEn, order: Number(catForm.order) }).then(
(ok) => ok && setCatForm({ name: '', nameEn: '', order: '0' }),
call('/api/admin/forum/category', 'POST', { name: catForm.name, order: Number(catForm.order) }).then(
(ok) => ok && setCatForm({ name: '', order: '0' }),
)
}}
className="admin-form info-box-light separate2"
>
<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.nameEn} onChange={(e) => setCatForm({ ...catForm, nameEn: e.target.value })} placeholder={t('categoryNameEn')} />
<input value={catForm.name} onChange={(e) => setCatForm({ ...catForm, name: e.target.value })} placeholder={t('categoryName')} maxLength={45} />
<span className="second-brown">{t('order')}</span>
<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>
</form>
<br />
@@ -73,13 +112,12 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
<div key={category.id} className="inline-div" style={{ display: 'block' }}>
{editCat?.id === category.id ? (
<div className="admin-form separate2">
<input value={editCat.name} onChange={(e) => setEditCat({ ...editCat, name: e.target.value })} placeholder={t('categoryName')} />
<input value={editCat.nameEn} onChange={(e) => setEditCat({ ...editCat, nameEn: e.target.value })} placeholder={t('categoryNameEn')} />
<input value={editCat.name} onChange={(e) => setEditCat({ ...editCat, name: e.target.value })} placeholder={t('categoryName')} maxLength={45} />
<input type="number" value={editCat.order} onChange={(e) => setEditCat({ ...editCat, order: e.target.value })} />
<button
onClick={() =>
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}
className="ns-tool-btn"
@@ -91,11 +129,9 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
) : (
<div className="title-content-h3">
<h3 className="big-font">
{category.name}
{category.name_en && <span className="third-brown small-font"> · EN: {category.name_en}</span>}{' '}
<span className="third-brown small-font">#{category.order}</span>{' '}
{category.name} <span className="third-brown small-font">#{category.order}</span>{' '}
<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}
className="ns-tool-btn"
>
@@ -112,7 +148,6 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
</div>
)}
{/* Foros de la categoría */}
<table className="max-center-table">
<tbody>
{forums.length === 0 && (
@@ -124,21 +159,11 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
editForum?.id === f.id ? (
<tr key={f.id} className="team-center-table-tr">
<td className="lefted separate" colSpan={2}>
<input value={editForum.name} onChange={(e) => setEditForum({ ...editForum, name: e.target.value })} placeholder={t('forumName')} maxLength={45} />
<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')} />
<ForumEditFields v={editForum} onChange={(patch) => setEditForum({ ...editForum, ...patch })} />
<button
onClick={() =>
editForum.name.trim() &&
call(`/api/admin/forum/${f.id}`, 'PUT', {
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))
call(`/api/admin/forum/${f.id}`, 'PUT', forumBody(editForum, category.id)).then((ok) => ok && setEditForum(null))
}
disabled={busy}
className="ns-tool-btn"
@@ -152,25 +177,29 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
<tr key={f.id} className="team-center-table-tr">
<td className="lefted separate">
<span className="yellow-info">{f.name}</span>{' '}
{f.name_en && <span className="third-brown small-font">· EN: {f.name_en}</span>}{' '}
{f.visibility === 0 && <span className="red-info2 small-font">({t('hidden')})</span>}
<span className="third-brown small-font">
· {t('forumType')}: {f.type} · #{f.order}
</span>
{f.description && <p className="third-brown small-font">{f.description}</p>}
</td>
<td className="real-info-box no-wrap-td">
<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}
className="ns-tool-btn"
>
{t('edit')}
</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
onClick={() => confirm(t('confirmDelete')) && call(`/api/admin/forum/${f.id}`, 'DELETE')}
disabled={busy}
@@ -191,21 +220,13 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
e.preventDefault()
const v = ff(category.id)
if (v.name.trim())
call('/api/admin/forum', 'POST', {
categoryId: category.id,
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' }))
call('/api/admin/forum', 'POST', forumBody(v, category.id)).then(
(ok) => ok && setFF(category.id, EMPTY_FORUM),
)
}}
className="admin-form separate"
>
<input value={ff(category.id).name} onChange={(e) => setFF(category.id, { name: e.target.value })} placeholder={t('forumName')} maxLength={45} />
<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')} />
<ForumEditFields v={ff(category.id)} onChange={(patch) => setFF(category.id, patch)} />
<button type="submit" disabled={busy}>{t('addForum')}</button>
</form>
</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 { useTranslations } from 'next-intl'
import { useRouter } from '@/i18n/navigation'
import { RichTextArea } from './RichTextArea'
import { ForumEditor } from './ForumEditor'
/** Respuesta rápida a un tema: editor TinyMCE, enviado por fetch. */
export function ReplyForm({ topicId }: { topicId: number }) {
const t = useTranslations('Forum')
const router = useRouter()
@@ -12,9 +13,9 @@ export function ReplyForm({ topicId }: { topicId: number }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
async function submit(e: React.FormEvent) {
e.preventDefault()
if (busy || !text.trim()) return
if (busy) return
setBusy(true)
setError(null)
try {
@@ -24,12 +25,12 @@ export function ReplyForm({ topicId }: { topicId: number }) {
credentials: 'same-origin',
body: JSON.stringify({ topicId, text }),
})
const data: { success?: boolean } = await res.json()
const data: { success?: boolean; error?: string } = await res.json()
if (data.success) {
setText('')
router.refresh()
} else {
setError(t('genericError'))
setError(data.error || t('genericError'))
}
} catch {
setError(t('genericError'))
@@ -39,15 +40,17 @@ export function ReplyForm({ topicId }: { topicId: number }) {
}
return (
<form onSubmit={handleSubmit} className="separate2">
<RichTextArea value={text} onChange={setText} placeholder={t('replyText')} rows={3} />
<br />
<button type="submit" disabled={busy}>
{busy ? t('sending') : t('sendReply')}
</button>
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
{error && <span className="red-form-response">{error}</span>}
</div>
</form>
<div className="quick_reply">
<h2>{t('reply')}</h2>
<form onSubmit={submit}>
<ForumEditor value={text} onChange={setText} height={260} />
<div className="forum-form-actions">
<button type="submit" className="nice_button" disabled={busy}>
{busy ? t('sending') : t('post')}
</button>
{error && <span className="red-form-response">{error}</span>}
</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 { db, DB } from './db'
// Gestión del foro para el admin, sobre el esquema FusionCMS (ver sql/forum_fusion.sql):
// forum_categories(id, order, name)
// forum_forums(id, category, order, name, description, icon, colortitle, type)
// No hay columnas _en / visibility / deleted: las categorías y foros son datos del
// seed, editables aquí. Borrar un foro es un DELETE real (se rechaza si tiene temas).
export interface AdminCategory {
id: number
name: string
name_en: string
order: number
}
export interface AdminForum {
id: number
category_id: number
category: number
name: string
name_en: string
description: string
description_en: string
icon: string | null
colortitle: string | null
type: number
order: number
visibility: number
}
/** Categorías con sus foros (incluye ocultos y sin borrar), para el admin. */
export async function listCategoriesWithForums(): Promise<
{ category: AdminCategory; forums: AdminForum[] }[]
> {
const [cats] = await db(DB.web).query<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[]>(
'SELECT id, category_id, name, name_en, description, description_en, `order`, visibility FROM forums WHERE deleted = 0 ORDER BY `order`, id',
'SELECT id, category, name, description, icon, colortitle, type, `order` FROM forum_forums ORDER BY `order`, id',
)
return cats.map((c) => ({
category: { id: c.id, name: c.name, name_en: c.name_en || '', order: c.order },
category: { id: c.id, name: c.name, order: c.order },
forums: forums
.filter((f) => f.category_id === c.id)
.filter((f) => f.category === c.id)
.map((f) => ({
id: f.id,
category_id: f.category_id,
category: f.category,
name: f.name,
name_en: f.name_en || '',
description: f.description,
description_en: f.description_en || '',
icon: f.icon,
colortitle: f.colortitle,
type: f.type,
order: f.order,
visibility: f.visibility,
})),
}))
}
export async function createCategory(name: string, nameEn: string | null, order: number): Promise<number> {
export async function createCategory(name: string, order: number): Promise<number> {
const [res] = await db(DB.web).query<ResultSetHeader>(
'INSERT INTO forum_categories (name, name_en, `order`, created_at) VALUES (?, ?, ?, NOW())',
[name, nameEn || null, order],
'INSERT INTO forum_categories (name, `order`) VALUES (?, ?)',
[name.slice(0, 45), order],
)
return res.insertId
}
export async function updateCategory(id: number, name: string, nameEn: string | null, order: number): Promise<void> {
await db(DB.web).query('UPDATE forum_categories SET name = ?, name_en = ?, `order` = ? WHERE id = ?', [
name,
nameEn || null,
export async function updateCategory(id: number, name: string, order: number): Promise<void> {
await db(DB.web).query('UPDATE forum_categories SET name = ?, `order` = ? WHERE id = ?', [
name.slice(0, 45),
order,
id,
])
}
/** Borra una categoría solo si no tiene foros (no borrados). */
/** Borra una categoría solo si no tiene foros. */
export async function deleteCategory(id: number): Promise<boolean> {
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],
)
if (Number(f[0]?.n ?? 0) > 0) return false
@@ -74,44 +77,35 @@ export async function deleteCategory(id: number): Promise<boolean> {
return true
}
export async function createForum(
categoryId: number,
name: string,
nameEn: string | null,
description: string,
descriptionEn: string | null,
order: number,
visibility: number,
): Promise<number> {
export interface ForumInput {
category: number
name: string
description: string
icon: string | null
colortitle: string | null
type: number
order: number
}
export async function createForum(f: ForumInput): Promise<number> {
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) ' +
'VALUES (?, ?, ?, ?, ?, ?, 0, ?, 0, NOW(), NOW())',
[categoryId, name, nameEn || null, description, descriptionEn || null, order, visibility ? 1 : 0],
'INSERT INTO forum_forums (category, name, description, icon, colortitle, type, `order`) VALUES (?, ?, ?, ?, ?, ?, ?)',
[f.category, f.name.slice(0, 45), f.description, f.icon || null, f.colortitle || null, f.type, f.order],
)
return res.insertId
}
export async function updateForum(
id: number,
name: string,
nameEn: string | null,
description: string,
descriptionEn: string | null,
order: number,
visibility: number,
): Promise<void> {
export async function updateForum(id: number, f: ForumInput): Promise<void> {
await db(DB.web).query(
'UPDATE forums SET name = ?, name_en = ?, description = ?, description_en = ?, `order` = ?, visibility = ?, updated_at = NOW() WHERE id = ?',
[name, nameEn || null, description, descriptionEn || null, order, visibility ? 1 : 0, id],
'UPDATE forum_forums SET category = ?, name = ?, description = ?, icon = ?, colortitle = ?, type = ?, `order` = ? WHERE id = ?',
[f.category, f.name.slice(0, 45), f.description, f.icon || null, f.colortitle || null, f.type, f.order, id],
)
}
/** Borrado lógico de un foro (deleted = 1). */
export async function deleteForum(id: number): Promise<void> {
await db(DB.web).query('UPDATE forums SET deleted = 1 WHERE id = ?', [id])
}
/** Cambia la visibilidad de un foro. */
export async function setForumVisibility(id: number, visible: boolean): Promise<void> {
await db(DB.web).query('UPDATE forums SET visibility = ? WHERE id = ?', [visible ? 1 : 0, id])
/** Borra un foro solo si no tiene temas. */
export async function deleteForum(id: number): Promise<boolean> {
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])
return true
}
+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 { cleanPostHtml } from './forum-sanitize'
// Escritura del foro (esquema FusionCMS, ver lib/forum.ts). `poster` es el AccountID.
// El texto se guarda como HTML ya saneado con nh3 (cleanPostHtml), no como BBCode:
// es la adaptación a nuestro editor TinyMCE. El esquema no cambia (columna `text`).
// El título vive en forum_topics.name, un varchar(45) del esquema original. Se recorta
// a 45 para no chocar con el modo estricto de MySQL; la validación de longitud real la
// hace la API antes de llegar aquí.
const TITLE_MAX = 45
export async function createTopic(
forumId: number,
name: string,
poster: string,
posterId: number,
text: string,
): Promise<number> {
const clean = cleanPostHtml(text)
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) ' +
'VALUES (?, ?, ?, ?, NOW(), NOW(), NOW(), 0, 0, 0)',
[forumId, name, poster, posterId],
'INSERT INTO forum_topics (forum, name, sticky, locked, deleted, created) VALUES (?, ?, 0, 0, 0, NOW())',
[forumId, name.slice(0, TITLE_MAX)],
)
const topicId = res.insertId
await db(DB.web).query(
'INSERT INTO forum_posts (topic_id, poster, poster_id, text, time, created_at, deleted) VALUES (?, ?, ?, ?, NOW(), NOW(), 0)',
[topicId, poster, posterId, clean],
'INSERT INTO forum_posts (topic, poster, text, time, deleted) VALUES (?, ?, ?, NOW(), 0)',
[topicId, posterId, clean],
)
return topicId
}
export async function createPost(
topicId: number,
poster: string,
posterId: number,
text: string,
): Promise<number> {
export async function createPost(topicId: number, posterId: number, text: string): Promise<number> {
const clean = cleanPostHtml(text)
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)',
[topicId, poster, posterId, clean],
'INSERT INTO forum_posts (topic, poster, text, time, deleted) VALUES (?, ?, ?, NOW(), 0)',
[topicId, posterId, clean],
)
await db(DB.web).query('UPDATE forum_topics SET updated_at = NOW() WHERE id = ?', [topicId])
return res.insertId
}
/** Datos mínimos de un post para comprobar permisos (autor + tema). */
export async function getPost(
postId: number,
): Promise<{ id: number; topic_id: number; poster_id: number | null; text: string } | null> {
): Promise<{ id: number; topic: number; poster: number; text: string; deleted: boolean } | null> {
const [rows] = await db(DB.web).query<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],
)
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> {
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> {
await db(DB.web).query('UPDATE forum_posts SET deleted = ? WHERE id = ?', [deleted ? 1 : 0, postId])
}
@@ -71,21 +70,7 @@ export async function setTopicFlag(
await db(DB.web).query(`UPDATE forum_topics SET ${field} = ? WHERE id = ?`, [value ? 1 : 0, topicId])
}
/** Mueve un tema a otro foro (marca moved=1). Solo moderación. */
export async function moveTopic(topicId: number, newForumId: number): Promise<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). */
/** ¿El tema existe, no está borrado y admite respuestas (no cerrado)? */
export async function topicIsReplyable(topicId: number): Promise<boolean> {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT locked FROM forum_topics WHERE id = ? AND deleted = 0',
+242 -170
View File
@@ -1,126 +1,221 @@
import type { RowDataPacket } from 'mysql2'
import { db, DB } from './db'
export interface ForumInfo {
// Foro estilo FusionCMS (ver sql/forum_fusion.sql). Esquema:
// forum_categories(id, order, name)
// forum_forums(id, category, order, name, description, icon, colortitle, type)
// forum_topics(id, forum, name, sticky, locked, deleted, created)
// forum_posts(id, topic, poster, text, time, deleted)
// `poster` es el AccountID de acore_auth (entero); el nombre se resuelve aparte
// con resolvePosters(). Los temas no guardan autor: se deriva del primer/último post.
// Tipos de foro (columna `type`):
// 0 = fila normal (icono de forum_icons)
// 1 = disciplina de clase (icon = clase CSS, colortitle = color)
// 2 = subforo por idioma (icon = ruta a una bandera)
export interface ForumRow {
id: number
category_id: number
category: number
order: number
name: string
description: string
icon: string | null
colortitle: string | null
type: number
topic_count: number
post_count: number
last_topic_id: number | null
last_topic_name: string | null
last_topic_poster: string | null
last_poster: number | null
last_time: string | null
}
export interface Category {
id: number
name: string
forums: ForumInfo[]
forums: ForumRow[]
}
export interface Topic {
export interface TopicRow {
id: number
name: string
poster: string
created: string | null
sticky: boolean
locked: boolean
views: number
deleted: boolean
created: string | null
poster: number | null
last_poster: number | null
time_updated: string | null
}
export interface Post {
export interface PostRow {
id: number
poster: string
poster_id: number | null
poster: number
text: string
time: string | null
edited: boolean
deleted: boolean
}
export interface TopicFull {
export interface ForumMeta {
id: number
name: string
forum_id: number
poster_id: number | null
locked: boolean
sticky: boolean
deleted: boolean
description: string
icon: string | null
colortitle: string | null
type: number
}
export interface SearchResult {
export interface ForumPath {
category_id: number
category: string
forum_id: number
forum: string
forum_description: string
}
export interface TopicPath extends ForumPath {
topic_id: number
topic: string
}
export interface PosterInfo {
id: number
name: string
poster: string
forum_id: number
forum_name: string
updated_at: string | null
gmlevel: number
isStaff: boolean
joindate: string | null
postCount: number
}
/** Índice del foro: categorías con sus foros visibles (+ contadores y último tema). */
export async function getForumIndex(locale = 'es'): Promise<Category[]> {
const en = locale === 'en'
/** Nivel GM mínimo para considerarse «staff» en el foro (badge y color). */
const STAFF_GMLEVEL = Number(process.env.FORUM_MOD_GMLEVEL || 2)
/** Índice del foro: categorías ordenadas, cada una con sus foros y agregados. */
export async function getForumIndex(): Promise<Category[]> {
try {
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[]>(
`SELECT f.id, f.category_id, f.name, f.name_en, f.description, f.description_en, f.icon,
(SELECT COUNT(*) FROM forum_topics t WHERE t.forum_id = f.id AND t.deleted = 0) AS topic_count,
(SELECT COUNT(*) FROM forum_posts p JOIN forum_topics t ON p.topic_id = t.id
WHERE t.forum_id = f.id AND p.deleted = 0 AND t.deleted = 0) AS post_count,
(SELECT t2.id FROM forum_topics t2 WHERE t2.forum_id = f.id AND t2.deleted = 0
ORDER BY t2.updated_at DESC, t2.id DESC LIMIT 1) AS last_topic_id,
(SELECT t3.name FROM forum_topics t3 WHERE t3.forum_id = f.id AND t3.deleted = 0
ORDER BY t3.updated_at DESC, t3.id DESC LIMIT 1) AS last_topic_name,
(SELECT t4.poster FROM forum_topics t4 WHERE t4.forum_id = f.id AND t4.deleted = 0
ORDER BY t4.updated_at DESC, t4.id DESC LIMIT 1) AS last_topic_poster
FROM forums f
WHERE f.visibility = 1 AND f.deleted = 0
`SELECT f.id, f.category, f.\`order\`, f.name, f.description, f.icon, f.colortitle, f.type,
(SELECT COUNT(*) FROM forum_topics t WHERE t.forum = f.id AND t.deleted = 0) AS topic_count,
(SELECT COUNT(*) FROM forum_posts p JOIN forum_topics t ON p.topic = t.id
WHERE t.forum = f.id AND p.deleted = 0 AND t.deleted = 0) AS post_count,
(SELECT t.id FROM forum_posts p JOIN forum_topics t ON p.topic = t.id
WHERE t.forum = f.id AND p.deleted = 0 AND t.deleted = 0
ORDER BY p.time DESC, p.id DESC LIMIT 1) AS last_topic_id,
(SELECT t.name FROM forum_posts p JOIN forum_topics t ON p.topic = t.id
WHERE t.forum = f.id AND p.deleted = 0 AND t.deleted = 0
ORDER BY p.time DESC, p.id DESC LIMIT 1) AS last_topic_name,
(SELECT p.poster FROM forum_posts p JOIN forum_topics t ON p.topic = t.id
WHERE t.forum = f.id AND p.deleted = 0 AND t.deleted = 0
ORDER BY p.time DESC, p.id DESC LIMIT 1) AS last_poster,
(SELECT p.time FROM forum_posts p JOIN forum_topics t ON p.topic = t.id
WHERE t.forum = f.id AND p.deleted = 0 AND t.deleted = 0
ORDER BY p.time DESC, p.id DESC LIMIT 1) AS last_time
FROM forum_forums f
ORDER BY f.\`order\`, f.id`,
)
const byCat = new Map<number, ForumInfo[]>()
const byCat = new Map<number, ForumRow[]>()
for (const f of forums) {
const info = f as unknown as ForumInfo & { name_en?: string | null; description_en?: string | null }
info.name = en && info.name_en ? info.name_en : info.name
info.description = en && info.description_en ? info.description_en : info.description
if (!byCat.has(info.category_id)) byCat.set(info.category_id, [])
byCat.get(info.category_id)!.push(info)
const row: ForumRow = {
id: f.id,
category: f.category,
order: f.order,
name: f.name,
description: f.description,
icon: f.icon,
colortitle: f.colortitle,
type: f.type,
topic_count: Number(f.topic_count ?? 0),
post_count: Number(f.post_count ?? 0),
last_topic_id: f.last_topic_id ?? null,
last_topic_name: f.last_topic_name ?? null,
last_poster: f.last_poster ?? null,
last_time: f.last_time ? new Date(f.last_time).toISOString() : null,
}
if (!byCat.has(row.category)) byCat.set(row.category, [])
byCat.get(row.category)!.push(row)
}
return cats
.map((c) => ({ id: c.id, name: en && c.name_en ? c.name_en : c.name, forums: byCat.get(c.id) ?? [] }))
.map((c) => ({ id: c.id, name: c.name, forums: byCat.get(c.id) ?? [] }))
.filter((c) => c.forums.length > 0)
} catch {
return []
}
}
/** Lista de foros visibles para el desplegable de «mover tema». */
export async function listForumsForMove(): Promise<{ id: number; name: string }[]> {
export async function getForum(id: number): Promise<ForumMeta | null> {
try {
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 {
return []
return null
}
}
export async function getForum(id: number, locale = 'es'): Promise<{ name: string; description: string } | null> {
const en = locale === 'en'
export async function forumExists(id: number): Promise<boolean> {
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 {
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',
[id],
`SELECT c.id AS category_id, c.name AS category, f.id AS forum_id, f.name AS forum, f.description AS forum_description
FROM forum_categories c JOIN forum_forums f ON f.category = c.id
WHERE f.id = ? LIMIT 1`,
[forumId],
)
if (!rows[0]) return null
return {
name: en && rows[0].name_en ? rows[0].name_en : rows[0].name,
description: en && rows[0].description_en ? rows[0].description_en : rows[0].description,
}
const r = rows[0]
return r
? {
category_id: r.category_id,
category: r.category,
forum_id: r.forum_id,
forum: r.forum,
forum_description: r.forum_description,
}
: null
} catch {
return null
}
}
export async function getTopicPath(topicId: number): Promise<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 {
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> {
try {
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],
)
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 {
const del = includeDeleted ? '' : 'AND t.deleted = 0'
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],
)
return rows.map((r) => ({
id: r.id,
name: r.name,
poster: r.poster,
created: r.created ? new Date(r.created).toISOString() : null,
sticky: r.sticky === 1,
locked: r.locked === 1,
views: r.views ?? 0,
deleted: r.deleted === 1,
created: r.created ? new Date(r.created).toISOString() : null,
poster: r.poster ?? null,
last_poster: r.last_poster ?? null,
time_updated: r.time_updated ? new Date(r.time_updated).toISOString() : null,
}))
} catch {
return []
}
}
export async function getTopic(id: number, includeDeleted = false): Promise<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 {
const where = includeDeleted ? 'id = ?' : 'id = ? AND deleted = 0'
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],
)
const r = rows[0]
return r
? {
id: r.id,
forum: r.forum,
name: r.name,
forum_id: r.forum_id,
poster_id: r.poster_id ?? null,
locked: r.locked === 1,
sticky: r.sticky === 1,
locked: r.locked === 1,
deleted: r.deleted === 1,
created: r.created ? new Date(r.created).toISOString() : null,
}
: null
} catch {
@@ -186,7 +305,7 @@ export async function countPosts(topicId: number, includeDeleted = false): Promi
try {
const del = includeDeleted ? '' : 'AND deleted = 0'
const [rows] = await db(DB.web).query<RowDataPacket[]>(
`SELECT COUNT(*) AS n FROM forum_posts WHERE topic_id = ? ${del}`,
`SELECT COUNT(*) AS n FROM forum_posts WHERE topic = ? ${del}`,
[topicId],
)
return Number(rows[0]?.n ?? 0)
@@ -195,23 +314,23 @@ export async function countPosts(topicId: number, includeDeleted = false): Promi
}
}
export async function getPosts(topicId: number, offset = 0, limit = 15, includeDeleted = false): Promise<Post[]> {
export async function getPosts(
topicId: number,
offset = 0,
limit = 5,
includeDeleted = false,
): Promise<PostRow[]> {
try {
const del = includeDeleted ? '' : 'AND deleted = 0'
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],
)
return rows.map((r) => ({
id: r.id,
poster: r.poster,
poster_id: r.poster_id ?? null,
text: r.text,
time: r.time ? new Date(r.time).toISOString() : null,
// editado si updated_at es posterior (más de 2s) al alta del post
edited: Boolean(
r.updated_at && r.created_at && new Date(r.updated_at).getTime() - new Date(r.created_at).getTime() > 2000,
),
deleted: r.deleted === 1,
}))
} catch {
@@ -219,78 +338,12 @@ export async function getPosts(topicId: number, offset = 0, limit = 15, includeD
}
}
export interface ForumProfile {
username: string
postCount: number
topicCount: number
joindate: string | null
gmlevel: number
status: 'admin' | 'gm' | null
recentTopics: { id: number; name: string; forum_id: number }[]
}
/** Perfil público de un usuario del foro (contadores + antigüedad + rango). */
export async function getForumProfile(username: string): Promise<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}%`
/** Nº de posts (no borrados) de un usuario, para el «Posts: N» del perfil lateral. */
export async function getUserPostCount(posterId: number): Promise<number> {
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
`SELECT COUNT(DISTINCT t.id) AS n
FROM forum_topics t
JOIN forums f ON f.id = t.forum_id AND f.visibility = 1 AND f.deleted = 0
LEFT JOIN forum_posts p ON p.topic_id = t.id AND p.deleted = 0
WHERE t.deleted = 0 AND (t.name LIKE ? OR p.text LIKE ?)`,
[like, like],
'SELECT COUNT(*) AS n FROM forum_posts WHERE poster = ? AND deleted = 0',
[posterId],
)
return Number(rows[0]?.n ?? 0)
} catch {
@@ -298,29 +351,48 @@ export async function countSearchTopics(query: string): Promise<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[]> {
const like = `%${query}%`
/**
* Resuelve un conjunto de AccountID a nombre + rango + antigüedad en una sola pasada.
* El nombre es el username de la cuenta de juego (acore_auth.account), sin el sufijo
* «#N» de Battle.net para que se lea mejor. Devuelve un Map por id.
*/
export async function resolvePosters(ids: number[]): Promise<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 {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
`SELECT DISTINCT t.id, t.name, t.poster, t.forum_id, t.updated_at, f.name AS forum_name
FROM forum_topics t
JOIN forums f ON f.id = t.forum_id AND f.visibility = 1 AND f.deleted = 0
LEFT JOIN forum_posts p ON p.topic_id = t.id AND p.deleted = 0
WHERE t.deleted = 0 AND (t.name LIKE ? OR p.text LIKE ?)
ORDER BY t.updated_at DESC
LIMIT ? OFFSET ?`,
[like, like, limit, offset],
const [accs] = await db(DB.auth).query<RowDataPacket[]>(
`SELECT id, username, joindate FROM account WHERE id IN (${placeholders})`,
unique,
)
return rows.map((r) => ({
id: r.id,
name: r.name,
poster: r.poster,
forum_id: r.forum_id,
forum_name: r.forum_name,
updated_at: r.updated_at ? new Date(r.updated_at).toISOString() : null,
}))
const [access] = await db(DB.auth).query<RowDataPacket[]>(
`SELECT AccountID, MAX(SecurityLevel) AS lvl FROM account_access WHERE AccountID IN (${placeholders}) GROUP BY AccountID`,
unique,
)
const gm = new Map<number, number>()
for (const a of access) gm.set(Number(a.AccountID), Number(a.lvl ?? 0))
for (const a of accs) {
const gmlevel = gm.get(Number(a.id)) ?? 0
map.set(Number(a.id), {
id: Number(a.id),
name: String(a.username || '').replace(/#\d+$/, '') || `#${a.id}`,
gmlevel,
isStaff: gmlevel >= STAFF_GMLEVEL,
joindate: a.joindate ? new Date(a.joindate).toISOString() : null,
postCount: 0,
})
}
} catch {
return []
/* acore_auth no disponible: se devuelven placeholders abajo */
}
// Cualquier id no resuelto (cuenta borrada) recibe un placeholder.
for (const id of unique) {
if (!map.has(id)) {
map.set(id, { id, name: `#${id}`, gmlevel: 0, isStaff: false, joindate: null, postCount: 0 })
}
}
return map
}
+21 -2
View File
@@ -502,7 +502,19 @@
"memberSince": "Member since",
"recentTopics": "Recent topics",
"roleAdmin": "Administrator",
"roleGm": "GM"
"roleGm": "GM",
"topic": "topic",
"createdBy": "Created by",
"deleted": "Deleted",
"createTopic": "Create topic",
"topicTitle": "Topic title",
"postTopic": "Post topic",
"errTitle": "The title must be between 3 and 45 characters.",
"post": "Post",
"staff": "Staff",
"member": "Member",
"editPost": "Edit post",
"undelete": "Undelete"
},
"Admin": {
"title": "Admin panel",
@@ -607,7 +619,14 @@
"categoryNameEn": "Name (English, optional)",
"forumNameEn": "Forum name (English, optional)",
"forumDescriptionEn": "Description (English, optional)",
"forumEnHint": "Leave English empty to fall back to Spanish on the English site."
"forumEnHint": "Leave English empty to fall back to Spanish on the English site.",
"forumType": "Type",
"forumTypeNormal": "Normal",
"forumTypeClass": "Class",
"forumTypeFlag": "Language (flag)",
"forumIcon": "Icon (name or path)",
"forumColor": "Title color (#hex)",
"forumNotEmpty": "Cannot delete a forum that has topics."
},
"Vote": {
"title": "Vote for us",
+21 -2
View File
@@ -502,7 +502,19 @@
"memberSince": "Miembro desde",
"recentTopics": "Temas recientes",
"roleAdmin": "Administrador",
"roleGm": "GM"
"roleGm": "GM",
"topic": "tema",
"createdBy": "Creado por",
"deleted": "Borrado",
"createTopic": "Crear tema",
"topicTitle": "Título del tema",
"postTopic": "Publicar tema",
"errTitle": "El título debe tener entre 3 y 45 caracteres.",
"post": "Publicar",
"staff": "Staff",
"member": "Miembro",
"editPost": "Editar mensaje",
"undelete": "Restaurar"
},
"Admin": {
"title": "Panel de administración",
@@ -607,7 +619,14 @@
"categoryNameEn": "Nombre (inglés, opcional)",
"forumNameEn": "Nombre del foro (inglés, opcional)",
"forumDescriptionEn": "Descripción (inglés, opcional)",
"forumEnHint": "Deja el inglés vacío para usar el español en la web en inglés."
"forumEnHint": "Deja el inglés vacío para usar el español en la web en inglés.",
"forumType": "Tipo",
"forumTypeNormal": "Normal",
"forumTypeClass": "Clase",
"forumTypeFlag": "Idioma (bandera)",
"forumIcon": "Icono (nombre o ruta)",
"forumColor": "Color del título (#hex)",
"forumNotEmpty": "No se puede borrar un foro con temas."
},
"Vote": {
"title": "Votar por nosotros",
+28 -8
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@next/third-parties": "^16.2.10",
"@tinymce/tinymce-react": "^6.3.0",
"@types/qrcode": "^1.5.6",
"iron-session": "^8.0.4",
"mysql2": "^3.22.6",
@@ -19,7 +20,8 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"sanitize-html": "^2.17.6",
"stripe": "^22.3.1"
"stripe": "^22.3.1",
"tinymce": "^8.8.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.2",
@@ -2420,6 +2422,24 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/@tinymce/tinymce-react": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@tinymce/tinymce-react/-/tinymce-react-6.3.0.tgz",
"integrity": "sha512-E++xnn0XzDzpKr40jno2Kj7umfAE6XfINZULEBBeNjTMvbACWzA6CjiR6V8eTDc9yVmdVhIPqVzV4PqD5TZ/4g==",
"dependencies": {
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0",
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0",
"tinymce": "^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.1"
},
"peerDependenciesMeta": {
"tinymce": {
"optional": true
}
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -5656,8 +5676,7 @@
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/js-yaml": {
"version": "4.3.0",
@@ -6065,7 +6084,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
@@ -6421,7 +6439,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6723,7 +6740,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
@@ -6797,8 +6813,7 @@
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
@@ -7547,6 +7562,11 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinymce": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-8.8.0.tgz",
"integrity": "sha512-rdhwXLHDjUTJ42Cvq1tKRDvS4h8oeQSiWeNGx82AKNdOm8FGAqKObeEPa6amFGobTXHcoFtPZRtyLHO5zV5mZA=="
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+5 -2
View File
@@ -6,10 +6,12 @@
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start -p 3001",
"lint": "eslint"
"lint": "eslint",
"postinstall": "node scripts/copy-tinymce.mjs"
},
"dependencies": {
"@next/third-parties": "^16.2.10",
"@tinymce/tinymce-react": "^6.3.0",
"@types/qrcode": "^1.5.6",
"iron-session": "^8.0.4",
"mysql2": "^3.22.6",
@@ -20,7 +22,8 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"sanitize-html": "^2.17.6",
"stripe": "^22.3.1"
"stripe": "^22.3.1",
"tinymce": "^8.8.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.2",
+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