web-next: migrar toda la UI al tema real nw-ryu
Reescritura completa del frontend Next.js del sistema visual Tailwind "simulado" al tema Django original (nw-ryu), para paridad pixel con la web actual antes del cutover. - Tema real: copia de static/nw-themes/nw-ryu + favicons a public/, el layout carga el novavow-style.css real de ultimo (gana la cascada sobre Tailwind) + Font Awesome. - Shell replicando los partials Django: SiteHeader, Video, Social, Footer, ServerClock; home con estructura real (main-page/middle-content/...). - Helpers reutilizables: PageShell (main-page > middle-content > body-content > title-content) y ServiceBox (title-box-content + back-to-account). - Paginas migradas a clases reales del tema (fieldset/tool-button/char-box/ item-box/info-box-light/max-center-table/alert-message/botones reales), eliminando el markup Tailwind (.nw-btn/.nw-card/.nw-input): auth (login/register/recover/reset/select-account/activate), cuenta + servicios de personaje (revive/unstuck/rename/customize/ change-race/change-faction/level-up/gold/transfer + pago Stripe), ajustes (change-password/change-email/security-token), comunidad (vote-points/recruit/battlepay), foro completo, y admin (indice + 7 secciones + los Admin*Manager). - Se conserva el bilingue (next-intl); claves nuevas en messages/es|en.json. Verificado: typecheck + build OK; rutas protegidas 307->login; sin MISSING_MESSAGE; cero Tailwind residual (solo .nw-tool-btn/.nw-page, clases propias en globals.css). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@@ -3,10 +3,13 @@ import { redirect, Link } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getAccountDashboard } from '@/lib/account'
|
||||
import { isAdmin } from '@/lib/admin'
|
||||
import { LogoutButton } from '@/components/LogoutButton'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Grupos de servicios con su icono del sprite real (acc-icon <clase>).
|
||||
type ToolItem = { href: string; icon: string; label: string; desc: string }
|
||||
|
||||
export default async function AccountPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
@@ -19,148 +22,186 @@ export default async function AccountPage({ params }: { params: Promise<{ locale
|
||||
const data = await getAccountDashboard(session)
|
||||
const admin = await isAdmin(session)
|
||||
|
||||
const card = 'nw-card'
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
<LogoutButton className="rounded border border-amber-900/60 px-3 py-1 text-sm text-amber-200 hover:text-amber-400" />
|
||||
</div>
|
||||
|
||||
{admin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className="mb-6 flex items-center justify-between gap-3 rounded-lg border border-nw-gold/50 bg-nw-gold/10 px-4 py-3 text-sm font-semibold text-nw-gold-light transition hover:bg-nw-gold/20"
|
||||
>
|
||||
<span>🛡️ {t('adminPanel')}</span>
|
||||
<span aria-hidden>→</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<section className={card}>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p>{t('bnetAccount')}: <span className="text-amber-400">{data.bnetEmail}</span></p>
|
||||
<p>{t('gameAccount')}: <span className="text-amber-400">{data.gameAccount}</span></p>
|
||||
<p>
|
||||
{t('securityToken')}:{' '}
|
||||
<span className="text-amber-400">
|
||||
{data.securityTokenDate
|
||||
? `${t('requested')} — ${new Date(data.securityTokenDate).toLocaleString(locale)}`
|
||||
: t('notRequested')}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<h2 className="mb-2 text-lg font-semibold">{t('points')}</h2>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p>{t('dp')}: <span className="text-amber-400">{data.dp}</span></p>
|
||||
<p>{t('vp')}: <span className="text-amber-400">{data.vp}</span></p>
|
||||
<p>{t('battlepayCredits')}: <span className="text-amber-400">{data.battlepayCredits}</span></p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="mb-3 text-lg font-semibold">{t('characters')}</h2>
|
||||
{data.characters.length === 0 ? (
|
||||
<p className="text-amber-200/70">{t('noCharacters')}</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||
{data.characters.map((c) => (
|
||||
<div key={c.name} className={`${card} flex gap-3`}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={c.imageUrl}
|
||||
alt={c.raceName}
|
||||
className="h-16 w-16 shrink-0 rounded-md object-cover ring-1 ring-nw-border/60"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className={`truncate font-semibold class-${c.classCss}`}>{c.name}</p>
|
||||
<p className="text-xs text-nw-muted">
|
||||
{t('level')} {c.level} · {c.raceName} {c.className}
|
||||
</p>
|
||||
<p className="mt-1 flex items-center gap-1 text-xs text-amber-200/70" title={c.zone}>
|
||||
<span aria-hidden>📍</span>
|
||||
<span className="truncate">{c.zone}</span>
|
||||
</p>
|
||||
<p className="text-xs text-amber-200/60">
|
||||
{c.gold}g {c.silver}s {c.copper}c
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ServicesPanel t={t} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function ServicesPanel({ t }: { t: (k: string) => string }) {
|
||||
const groups: { title: string; items: { href: string; label: string }[] }[] = [
|
||||
const groups: { title: string; items: ToolItem[] }[] = [
|
||||
{
|
||||
title: t('accountSettings'),
|
||||
items: [
|
||||
{ href: '/change-password', icon: 'password-icon', label: t('svcChangePassword'), desc: t('svcChangePasswordDesc') },
|
||||
{ href: '/change-email', icon: 'email-icon', label: t('svcChangeEmail'), desc: t('svcChangeEmailDesc') },
|
||||
{ href: '/security-token', icon: 'token-icon', label: t('svcToken'), desc: t('svcTokenDesc') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('servicesTitle'),
|
||||
items: [
|
||||
{ href: '/rename', label: t('svcRename') },
|
||||
{ href: '/customize', label: t('svcCustomize') },
|
||||
{ href: '/change-race', label: t('svcChangeRace') },
|
||||
{ href: '/change-faction', label: t('svcChangeFaction') },
|
||||
{ href: '/level-up', label: t('svcLevelUp') },
|
||||
{ href: '/gold', label: t('svcGold') },
|
||||
{ href: '/transfer', label: t('svcTransfer') },
|
||||
{ href: '/rename', icon: 'rename-icon', label: t('svcRename'), desc: t('svcRenameDesc') },
|
||||
{ href: '/customize', icon: 'customize-icon', label: t('svcCustomize'), desc: t('svcCustomizeDesc') },
|
||||
{ href: '/change-race', icon: 'change-race-icon', label: t('svcChangeRace'), desc: t('svcChangeRaceDesc') },
|
||||
{ href: '/change-faction', icon: 'change-faction-icon', label: t('svcChangeFaction'), desc: t('svcChangeFactionDesc') },
|
||||
{ href: '/level-up', icon: 'level-up-icon', label: t('svcLevelUp'), desc: t('svcLevelUpDesc') },
|
||||
{ href: '/gold', icon: 'gold-icon', label: t('svcGold'), desc: t('svcGoldDesc') },
|
||||
{ href: '/transfer', icon: 'tranfer-char-icon', label: t('svcTransfer'), desc: t('svcTransferDesc') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('utilitiesTitle'),
|
||||
items: [
|
||||
{ href: '/revive', label: t('svcRevive') },
|
||||
{ href: '/unstuck', label: t('svcUnstuck') },
|
||||
{ href: '/security-token', label: t('svcToken') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('accountSettings'),
|
||||
items: [
|
||||
{ href: '/change-password', label: t('svcChangePassword') },
|
||||
{ href: '/change-email', label: t('svcChangeEmail') },
|
||||
{ href: '/revive', icon: 'revive-icon', label: t('svcRevive'), desc: t('svcReviveDesc') },
|
||||
{ href: '/unstuck', icon: 'unstuck-icon', label: t('svcUnstuck'), desc: t('svcUnstuckDesc') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('communityTitle'),
|
||||
items: [
|
||||
{ href: '/vote-points', label: t('svcVote') },
|
||||
{ href: '/recruit', label: t('svcRecruit') },
|
||||
{ href: '/battlepay', label: t('svcStore') },
|
||||
{ href: '/vote-points', icon: 'vote-icon', label: t('svcVote'), desc: t('svcVoteDesc') },
|
||||
{ href: '/recruit', icon: 'recruit-a-friend-icon', label: t('svcRecruit'), desc: t('svcRecruitDesc') },
|
||||
{ href: '/battlepay', icon: 'store-icon', label: t('svcStore'), desc: t('svcStoreDesc') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
{groups.map((g) => (
|
||||
<section key={g.title} className="nw-card">
|
||||
<h2 className="mb-3 text-lg font-semibold text-nw-gold-light">{g.title}</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{g.items.map((it) => (
|
||||
<Link
|
||||
key={it.href}
|
||||
href={it.href}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-nw-border/60 bg-nw-panel-2/40 px-3 py-2 text-sm text-nw-text transition hover:border-nw-gold/60 hover:bg-nw-panel-2/80 hover:text-nw-gold-light"
|
||||
>
|
||||
<span>{it.label}</span>
|
||||
<span className="text-nw-muted" aria-hidden>→</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<PageShell title={t('title')}>
|
||||
{/* Información de la cuenta */}
|
||||
<div className="box-content">
|
||||
<div className="title-box-content centered">
|
||||
<h2>{t('infoTitle')}</h2>
|
||||
</div>
|
||||
<div className="body-box-content">
|
||||
<table className="max-center-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{t('bnetAccount')}</td>
|
||||
<td className="real-info-box yellow-info">{data.bnetEmail}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('gameAccount')}</td>
|
||||
<td className="real-info-box yellow-info">{data.gameAccount}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('dp')}</td>
|
||||
<td className="real-info-box dp-color">{data.dp}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('vp')}</td>
|
||||
<td className="real-info-box vp-color">{data.vp}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('battlepayCredits')}</td>
|
||||
<td className="real-info-box yellow-info">{data.battlepayCredits}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('securityToken')}</td>
|
||||
<td className="real-info-box">
|
||||
{data.securityTokenDate ? (
|
||||
<span className="green-info">
|
||||
{t('requested')} — {new Date(data.securityTokenDate).toLocaleString(locale)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="red-info2">{t('notRequested')}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Personajes */}
|
||||
<div className="box-content">
|
||||
<div className="title-box-content centered">
|
||||
<h2>{t('characters')}</h2>
|
||||
</div>
|
||||
<div className="body-box-content centered">
|
||||
{data.characters.length === 0 ? (
|
||||
<p className="second-brown">{t('noCharacters')}</p>
|
||||
) : (
|
||||
data.characters.map((c) => (
|
||||
<div key={c.name} className={`char-box char-box-${c.classCss}`}>
|
||||
<div className="char-text">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="char-icon img-small-icon" src={c.imageUrl} alt={c.raceName} height={64} loading="lazy" />
|
||||
<span className={`${c.classCss} big-font shadow`}>{c.name}</span>
|
||||
<br />
|
||||
<span className="second-brown shadow">
|
||||
{t('level')} {c.level} · {c.raceName} {c.className}
|
||||
</span>
|
||||
<br />
|
||||
<span className="second-brown shadow">📍 {c.zone}</span>
|
||||
<br />
|
||||
<span className="shadow">
|
||||
{c.gold}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="img-align" src="/nw-themes/nw-ryu/nw-images/nw-icons/money-gold.gif" alt="g" /> {c.silver}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="img-align" src="/nw-themes/nw-ryu/nw-images/nw-icons/money-silver.webp" alt="s" /> {c.copper}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="img-align" src="/nw-themes/nw-ryu/nw-images/nw-icons/money-copper.gif" alt="c" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Utilidades y Herramientas */}
|
||||
<div className="box-content">
|
||||
<div className="title-box-content centered">
|
||||
<h2>{t('toolsTitle')}</h2>
|
||||
</div>
|
||||
<div className="body-box-content-account">
|
||||
{admin && (
|
||||
<Link href="/admin">
|
||||
<div className="tool-button">
|
||||
<div className="acc-icon store-icon"></div>
|
||||
<div className="tool-button-div">
|
||||
<span className="yellow-info shadow">🛡️ {t('adminPanel')}</span>
|
||||
<br />
|
||||
<span className="second-brown shadow">{t('adminPanelDesc')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
{groups.map((g) => (
|
||||
<div key={g.title}>
|
||||
<div className="title-content-h3">
|
||||
<h3 className="big-font">{g.title}</h3>
|
||||
</div>
|
||||
<table className="max-center-table fixed-layout-table">
|
||||
<tbody>
|
||||
{rows(g.items).map((pair, i) => (
|
||||
<tr key={i}>
|
||||
{pair.map((it) => (
|
||||
<td key={it.href}>
|
||||
<Link href={it.href}>
|
||||
<div className="tool-button">
|
||||
<div className={`acc-icon ${it.icon}`}></div>
|
||||
<div className="tool-button-div">
|
||||
<span className="first-brown shadow">{it.label}</span>
|
||||
<br />
|
||||
<span className="second-brown shadow">{it.desc}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
))}
|
||||
{pair.length === 1 && <td />}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
// Agrupa los items de dos en dos (2 columnas, como el my-account de Django).
|
||||
function rows<T>(items: T[]): T[][] {
|
||||
const out: T[][] = []
|
||||
for (let i = 0; i < items.length; i += 2) out.push(items.slice(i, i + 2))
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -37,19 +37,17 @@ export function ActivateClient({ hash }: { hash: string }) {
|
||||
}, [hash])
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="centered">
|
||||
{state === 'loading' && <p>{t('activating')}</p>}
|
||||
{state === 'ok' && (
|
||||
<>
|
||||
<p className="text-green-400">{t('success')}</p>
|
||||
<p className="mt-4">
|
||||
<Link href="/login" className="text-sky-400 underline">
|
||||
{t('goLogin')}
|
||||
</Link>
|
||||
<span className="ok-form-response">{t('success')}</span>
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<Link href="/login">{t('goLogin')}</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{state === 'error' && <p className="text-red-400">{t(errorKey)}</p>}
|
||||
{state === 'error' && <span className="red-form-response">{t(errorKey)}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { setRequestLocale } from 'next-intl/server'
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ActivateClient } from './ActivateClient'
|
||||
|
||||
export default async function ActivatePage({
|
||||
@@ -11,10 +12,15 @@ export default async function ActivatePage({
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const { act } = await searchParams
|
||||
const t = await getTranslations('Activate')
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-lg px-4 py-12">
|
||||
<ActivateClient hash={act ?? ''} />
|
||||
</main>
|
||||
<PageShell title={t('title')}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content centered">
|
||||
<ActivateClient hash={act ?? ''} />
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { redirect, Link } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { isAdmin } from '@/lib/admin'
|
||||
import { listCategoriesWithForums } from '@/lib/admin-forum'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { AdminForumManager } from '@/components/AdminForumManager'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -16,9 +17,13 @@ export default async function AdminForumPage({ params }: { params: Promise<{ loc
|
||||
if (!(await isAdmin(session))) redirect({ href: '/', locale })
|
||||
const groups = await listCategoriesWithForums()
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-500">{t('forum')}</h1>
|
||||
<AdminForumManager initial={groups} />
|
||||
</main>
|
||||
<PageShell title={t('forum')}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content">
|
||||
<Link className="back-to-account" href="/admin">{t('backToPanel')}</Link>
|
||||
<AdminForumManager initial={groups} />
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { redirect, Link } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { isAdmin } from '@/lib/admin'
|
||||
import { listGoldOptions } from '@/lib/admin-gold'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { AdminGoldManager } from '@/components/AdminGoldManager'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -16,9 +17,13 @@ export default async function AdminGoldPage({ params }: { params: Promise<{ loca
|
||||
if (!(await isAdmin(session))) redirect({ href: '/', locale })
|
||||
const options = await listGoldOptions()
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-500">{t('gold')}</h1>
|
||||
<AdminGoldManager initial={options} />
|
||||
</main>
|
||||
<PageShell title={t('gold')}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content">
|
||||
<Link className="back-to-account" href="/admin">{t('backToPanel')}</Link>
|
||||
<AdminGoldManager initial={options} />
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { redirect, Link } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { isAdmin } from '@/lib/admin'
|
||||
import { listNews } from '@/lib/admin-news'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { AdminNewsManager } from '@/components/AdminNewsManager'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -16,9 +17,13 @@ export default async function AdminNewsPage({ params }: { params: Promise<{ loca
|
||||
if (!(await isAdmin(session))) redirect({ href: '/', locale })
|
||||
const news = await listNews()
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-500">{t('manageNews')}</h1>
|
||||
<AdminNewsManager initialNews={news} />
|
||||
</main>
|
||||
<PageShell title={t('manageNews')}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content">
|
||||
<Link className="back-to-account" href="/admin">{t('backToPanel')}</Link>
|
||||
<AdminNewsManager initialNews={news} />
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect, Link } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { isAdmin } from '@/lib/admin'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -23,22 +24,35 @@ export default async function AdminPage({ params }: { params: Promise<{ locale:
|
||||
{ href: '/admin/forum', label: t('manageForum'), icon: '💬' },
|
||||
]
|
||||
|
||||
const rows: typeof items[] = []
|
||||
for (let i = 0; i < items.length; i += 2) rows.push(items.slice(i, i + 2))
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{items.map((it) => (
|
||||
<Link
|
||||
key={it.href}
|
||||
href={it.href}
|
||||
className="flex items-center gap-3 rounded-xl border border-nw-border/70 bg-nw-panel/80 p-4 shadow-lg shadow-black/30 backdrop-blur transition hover:border-nw-gold/60 hover:bg-nw-panel-2/70"
|
||||
>
|
||||
<span className="text-2xl" aria-hidden>{it.icon}</span>
|
||||
<span className="font-semibold text-nw-text">{it.label}</span>
|
||||
<span className="ml-auto text-nw-gold-light" aria-hidden>→</span>
|
||||
</Link>
|
||||
))}
|
||||
<PageShell title={t('title')}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content-account">
|
||||
<table className="max-center-table fixed-layout-table">
|
||||
<tbody>
|
||||
{rows.map((pair, i) => (
|
||||
<tr key={i}>
|
||||
{pair.map((it) => (
|
||||
<td key={it.href}>
|
||||
<Link href={it.href}>
|
||||
<div className="tool-button">
|
||||
<div className="tool-button-div">
|
||||
<span className="first-brown shadow big-font">{it.icon} {it.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
))}
|
||||
{pair.length === 1 && <td />}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { redirect, Link } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { isAdmin } from '@/lib/admin'
|
||||
import { getAllPrices } from '@/lib/admin-prices'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { AdminPricesManager } from '@/components/AdminPricesManager'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -16,9 +17,13 @@ export default async function AdminPricesPage({ params }: { params: Promise<{ lo
|
||||
if (!(await isAdmin(session))) redirect({ href: '/', locale })
|
||||
const prices = await getAllPrices()
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-500">{t('prices')}</h1>
|
||||
<AdminPricesManager prices={prices} />
|
||||
</main>
|
||||
<PageShell title={t('prices')}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content">
|
||||
<Link className="back-to-account" href="/admin">{t('backToPanel')}</Link>
|
||||
<AdminPricesManager prices={prices} />
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { redirect, Link } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { isAdmin } from '@/lib/admin'
|
||||
import { listRecruitRewards } from '@/lib/admin-recruit'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { AdminRecruitManager } from '@/components/AdminRecruitManager'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -16,9 +17,13 @@ export default async function AdminRecruitPage({ params }: { params: Promise<{ l
|
||||
if (!(await isAdmin(session))) redirect({ href: '/', locale })
|
||||
const rewards = await listRecruitRewards()
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-500">{t('recruit')}</h1>
|
||||
<AdminRecruitManager initial={rewards} />
|
||||
</main>
|
||||
<PageShell title={t('recruit')}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content">
|
||||
<Link className="back-to-account" href="/admin">{t('backToPanel')}</Link>
|
||||
<AdminRecruitManager initial={rewards} />
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { redirect, Link } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { isAdmin } from '@/lib/admin'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { AdminUsersManager } from '@/components/AdminUsersManager'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -14,9 +15,13 @@ export default async function AdminUsersPage({ params }: { params: Promise<{ loc
|
||||
if (!session.bnetId) redirect({ href: '/login', locale })
|
||||
if (!(await isAdmin(session))) redirect({ href: '/', locale })
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-500">{t('users')}</h1>
|
||||
<AdminUsersManager />
|
||||
</main>
|
||||
<PageShell title={t('users')}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content">
|
||||
<Link className="back-to-account" href="/admin">{t('backToPanel')}</Link>
|
||||
<AdminUsersManager />
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { redirect, Link } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { isAdmin } from '@/lib/admin'
|
||||
import { listVoteSites } from '@/lib/admin-votes'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { AdminVotesManager } from '@/components/AdminVotesManager'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -16,9 +17,13 @@ export default async function AdminVotesPage({ params }: { params: Promise<{ loc
|
||||
if (!(await isAdmin(session))) redirect({ href: '/', locale })
|
||||
const sites = await listVoteSites()
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-500">{t('votes')}</h1>
|
||||
<AdminVotesManager initial={sites} />
|
||||
</main>
|
||||
<PageShell title={t('votes')}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content">
|
||||
<Link className="back-to-account" href="/admin">{t('backToPanel')}</Link>
|
||||
<AdminVotesManager initial={sites} />
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { fulfillCheckoutSession } from '@/lib/fulfill'
|
||||
import { isSessionPaid } from '@/lib/stripe'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -26,20 +27,23 @@ export default async function BattlepaySuccessPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-lg px-4 py-12 text-center">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
{status === 'paid' ? (
|
||||
<p className="text-green-400">{t('paidOk')}</p>
|
||||
) : status === 'already' ? (
|
||||
<p className="text-green-400">{t('alreadyPaid')}</p>
|
||||
) : (
|
||||
<p className="text-red-400">{t('genericError')}</p>
|
||||
)}
|
||||
<p className="mt-6">
|
||||
<Link href="/battlepay" className="text-sky-400 hover:underline">
|
||||
← {t('backToStore')}
|
||||
</Link>
|
||||
</p>
|
||||
</main>
|
||||
<PageShell title={t('title')}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content centered">
|
||||
{status === 'paid' ? (
|
||||
<span className="ok-form-response">{t('paidOk')}</span>
|
||||
) : status === 'already' ? (
|
||||
<span className="ok-form-response">{t('alreadyPaid')}</span>
|
||||
) : (
|
||||
<span className="red-form-response">{t('genericError')}</span>
|
||||
)}
|
||||
<br />
|
||||
<br />
|
||||
<p>
|
||||
<Link href="/battlepay">← {t('backToStore')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getPendingOrders } from '@/lib/battlepay'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { BattlepayList } from '@/components/BattlepayList'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -17,10 +18,14 @@ export default async function BattlepayPage({ params }: { params: Promise<{ loca
|
||||
const orders = await getPendingOrders(session.accountId ?? 0)
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-8">
|
||||
<h1 className="mb-2 text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
<p className="mb-6 text-nw-muted">{t('intro')}</p>
|
||||
<BattlepayList orders={orders} />
|
||||
</main>
|
||||
<PageShell title={t('title')}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content centered">
|
||||
<p>{t('intro')}</p>
|
||||
<br />
|
||||
<BattlepayList orders={orders} />
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,22 +40,32 @@ export function ChangeEmailForm() {
|
||||
}
|
||||
}
|
||||
|
||||
const field = 'nw-input'
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm">
|
||||
<p className="mb-4 text-sm text-amber-200/70">{t('info')}</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<input type="password" maxLength={16} placeholder={t('currentPassword')} value={form.currentPassword} onChange={(e) => upd('currentPassword', e.target.value)} className={field} />
|
||||
<input type="email" placeholder={t('currentEmail')} value={form.currentEmail} onChange={(e) => upd('currentEmail', e.target.value)} className={field} />
|
||||
<input type="email" placeholder={t('newEmail')} value={form.newEmail} onChange={(e) => upd('newEmail', e.target.value)} className={field} />
|
||||
<input type="email" placeholder={t('confEmail')} value={form.confEmail} onChange={(e) => upd('confEmail', e.target.value)} className={field} />
|
||||
<input type="text" maxLength={6} placeholder={t('token')} value={form.token} onChange={(e) => upd('token', e.target.value)} className={field} />
|
||||
<button type="submit" disabled={busy} className="w-full nw-btn disabled:opacity-60">
|
||||
{busy ? t('sending') : t('submit')}
|
||||
</button>
|
||||
</form>
|
||||
{message && <p className={`mt-3 text-center ${message.ok ? 'text-green-400' : 'text-red-400'}`}>{message.text}</p>}
|
||||
</div>
|
||||
<>
|
||||
<p>{t('info')}</p>
|
||||
<br />
|
||||
<div className="centered">
|
||||
<form onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<table className="middle-center-table">
|
||||
<tbody>
|
||||
<tr><td><input type="password" maxLength={16} placeholder={t('currentPassword')} value={form.currentPassword} onChange={(e) => upd('currentPassword', e.target.value)} /></td></tr>
|
||||
<tr><td><input type="email" maxLength={320} placeholder={t('currentEmail')} value={form.currentEmail} onChange={(e) => upd('currentEmail', e.target.value)} /></td></tr>
|
||||
<tr><td><input type="email" maxLength={320} placeholder={t('newEmail')} value={form.newEmail} onChange={(e) => upd('newEmail', e.target.value)} /></td></tr>
|
||||
<tr><td><input type="email" maxLength={320} placeholder={t('confEmail')} value={form.confEmail} onChange={(e) => upd('confEmail', e.target.value)} /></td></tr>
|
||||
<tr><td><input type="text" maxLength={6} placeholder={t('token')} value={form.token} onChange={(e) => upd('token', e.target.value)} /></td></tr>
|
||||
<tr><td>
|
||||
<button type="submit" className="change-email-button" disabled={busy}>
|
||||
{busy ? t('sending') : t('submit')}
|
||||
</button>
|
||||
</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
<hr />
|
||||
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && <span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ServiceBox } from '@/components/ServiceBox'
|
||||
import { ChangeEmailForm } from './ChangeEmailForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -13,9 +15,10 @@ export default async function ChangeEmailPage({ params }: { params: Promise<{ lo
|
||||
if (!session.bnetId) redirect({ href: '/login', locale })
|
||||
if (!session.username) redirect({ href: '/select-account', locale })
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
<ChangeEmailForm />
|
||||
</main>
|
||||
<PageShell title={t('title')}>
|
||||
<ServiceBox>
|
||||
<ChangeEmailForm />
|
||||
</ServiceBox>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,21 +56,31 @@ export function ChangePasswordForm() {
|
||||
}
|
||||
}
|
||||
|
||||
const field = 'nw-input'
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm">
|
||||
<p className="mb-4 text-sm text-amber-200/70">{t('info')}</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<input type="password" maxLength={16} placeholder={t('currentPassword')} value={form.currentPassword} onChange={(e) => upd('currentPassword', e.target.value)} className={field} />
|
||||
<input type="password" maxLength={16} placeholder={t('newPassword')} value={form.newPassword} onChange={(e) => upd('newPassword', e.target.value)} className={field} />
|
||||
<input type="password" maxLength={16} placeholder={t('confPassword')} value={form.confPassword} onChange={(e) => upd('confPassword', e.target.value)} className={field} />
|
||||
<input type="text" maxLength={6} placeholder={t('token')} value={form.token} onChange={(e) => upd('token', e.target.value)} className={field} />
|
||||
<button type="submit" disabled={busy || done} className="w-full nw-btn disabled:opacity-60">
|
||||
{busy ? t('changing') : t('submit')}
|
||||
</button>
|
||||
</form>
|
||||
{message && <p className={`mt-3 text-center ${message.ok ? 'text-green-400' : 'text-red-400'}`}>{message.text}</p>}
|
||||
</div>
|
||||
<>
|
||||
<p>{t('info')}</p>
|
||||
<br />
|
||||
<div className="centered">
|
||||
<form onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<table className="middle-center-table">
|
||||
<tbody>
|
||||
<tr><td><input type="password" maxLength={16} placeholder={t('currentPassword')} value={form.currentPassword} onChange={(e) => upd('currentPassword', e.target.value)} /></td></tr>
|
||||
<tr><td><input type="password" maxLength={16} placeholder={t('newPassword')} value={form.newPassword} onChange={(e) => upd('newPassword', e.target.value)} /></td></tr>
|
||||
<tr><td><input type="password" maxLength={16} placeholder={t('confPassword')} value={form.confPassword} onChange={(e) => upd('confPassword', e.target.value)} /></td></tr>
|
||||
<tr><td><input type="text" maxLength={6} placeholder={t('token')} value={form.token} onChange={(e) => upd('token', e.target.value)} /></td></tr>
|
||||
<tr><td>
|
||||
<button type="submit" className="change-password-button" disabled={busy || done}>
|
||||
{busy ? t('changing') : t('submit')}
|
||||
</button>
|
||||
</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
<hr />
|
||||
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && <span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ServiceBox } from '@/components/ServiceBox'
|
||||
import { ChangePasswordForm } from './ChangePasswordForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -14,9 +16,10 @@ export default async function ChangePasswordPage({ params }: { params: Promise<{
|
||||
if (!session.username) redirect({ href: '/select-account', locale })
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
<ChangePasswordForm />
|
||||
</main>
|
||||
<PageShell title={t('title')}>
|
||||
<ServiceBox>
|
||||
<ChangePasswordForm />
|
||||
</ServiceBox>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ConfirmClient } from '@/components/ConfirmClient'
|
||||
|
||||
export default async function Page({ params, searchParams }: { params: Promise<{ locale: string }>; searchParams: Promise<{ hash?: string }> }) {
|
||||
@@ -7,9 +8,12 @@ export default async function Page({ params, searchParams }: { params: Promise<{
|
||||
const { hash } = await searchParams
|
||||
const t = await getTranslations('ConfirmNewEmail')
|
||||
return (
|
||||
<main className="mx-auto max-w-lg px-4 py-12">
|
||||
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
<ConfirmClient hash={hash ?? ''} endpoint="/api/account/confirm-new-email" namespace="ConfirmNewEmail" showLogin />
|
||||
</main>
|
||||
<PageShell title={t('title')}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content centered">
|
||||
<ConfirmClient hash={hash ?? ''} endpoint="/api/account/confirm-new-email" namespace="ConfirmNewEmail" showLogin />
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ConfirmClient } from '@/components/ConfirmClient'
|
||||
|
||||
export default async function Page({ params, searchParams }: { params: Promise<{ locale: string }>; searchParams: Promise<{ hash?: string }> }) {
|
||||
@@ -7,9 +8,12 @@ export default async function Page({ params, searchParams }: { params: Promise<{
|
||||
const { hash } = await searchParams
|
||||
const t = await getTranslations('ConfirmOldEmail')
|
||||
return (
|
||||
<main className="mx-auto max-w-lg px-4 py-12">
|
||||
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
<ConfirmClient hash={hash ?? ''} endpoint="/api/account/confirm-old-email" namespace="ConfirmOldEmail" />
|
||||
</main>
|
||||
<PageShell title={t('title')}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content centered">
|
||||
<ConfirmClient hash={hash ?? ''} endpoint="/api/account/confirm-old-email" namespace="ConfirmOldEmail" />
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { getForum, getTopics, countTopics } from '@/lib/forum'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { NewTopicForm } from '@/components/NewTopicForm'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
|
||||
@@ -32,46 +33,50 @@ export default async function ForumPage({
|
||||
const canPost = Boolean(session.username)
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||
<p className="mb-2 text-sm">
|
||||
<Link href="/forum" className="text-sky-400 hover:underline">
|
||||
← {t('backToForum')}
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="mb-1 text-2xl font-bold text-amber-500">{forum!.name}</h1>
|
||||
{forum!.description && <p className="mb-6 text-amber-200/60">{forum!.description}</p>}
|
||||
<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 />
|
||||
|
||||
{topics.length === 0 ? (
|
||||
<p className="text-amber-200/70">{t('noTopics')}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-amber-900/40">
|
||||
{topics.map((topic) => (
|
||||
<li key={topic.id} className="flex items-center justify-between gap-4 py-3">
|
||||
<div>
|
||||
<Link href={`/forum/topic/${topic.id}`} className="font-semibold text-amber-300 hover:text-amber-400">
|
||||
{topic.sticky && <span className="mr-2 text-xs text-amber-500">[{t('sticky')}]</span>}
|
||||
{topic.locked && <span className="mr-2 text-xs text-red-400">[{t('locked')}]</span>}
|
||||
{topic.name}
|
||||
</Link>
|
||||
<p className="text-xs text-amber-200/60">
|
||||
{t('by')} {topic.poster}
|
||||
</p>
|
||||
</div>
|
||||
<div className="whitespace-nowrap text-xs text-amber-200/60">
|
||||
{topic.views} {t('views')}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{topics.length === 0 ? (
|
||||
<p className="second-brown centered">{t('noTopics')}</p>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<Pagination page={page} totalPages={totalPages} hrefFor={(p) => `/forum/${id}?page=${p}`} />
|
||||
<Pagination page={page} totalPages={totalPages} hrefFor={(p) => `/forum/${id}?page=${p}`} />
|
||||
|
||||
{canPost ? (
|
||||
<NewTopicForm forumId={id} />
|
||||
) : (
|
||||
<p className="mt-8 text-sm text-amber-200/60">{t('loginToPost')}</p>
|
||||
)}
|
||||
</main>
|
||||
{canPost ? (
|
||||
<NewTopicForm forumId={id} />
|
||||
) : (
|
||||
<p className="second-brown centered separate">{t('loginToPost')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { getForumIndex } from '@/lib/forum'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ForumSearchBox } from '@/components/ForumSearchBox'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -12,43 +13,51 @@ export default async function ForumIndexPage({ params }: { params: Promise<{ loc
|
||||
const categories = await getForumIndex()
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
<ForumSearchBox />
|
||||
{categories.length === 0 ? (
|
||||
<p className="text-amber-200/70">{t('noForums')}</p>
|
||||
) : (
|
||||
categories.map((cat) => (
|
||||
<section key={cat.id} className="mb-6">
|
||||
<h2 className="mb-2 border-b border-amber-900/60 pb-1 text-lg font-semibold text-amber-400">{cat.name}</h2>
|
||||
<ul className="divide-y divide-amber-900/40">
|
||||
{cat.forums.map((f) => (
|
||||
<li key={f.id} className="flex items-center justify-between gap-4 py-3">
|
||||
<div>
|
||||
<Link href={`/forum/${f.id}`} className="font-semibold text-amber-300 hover:text-amber-400">
|
||||
{f.name}
|
||||
</Link>
|
||||
{f.description && <p className="text-sm text-amber-200/60">{f.description}</p>}
|
||||
</div>
|
||||
<div className="whitespace-nowrap text-right text-xs text-amber-200/60">
|
||||
<div>
|
||||
{f.topic_count} {t('topics')} · {f.post_count} {t('posts')}
|
||||
</div>
|
||||
{f.last_topic_id && (
|
||||
<div className="mt-1">
|
||||
<Link href={`/forum/topic/${f.last_topic_id}`} className="text-sky-400 hover:underline">
|
||||
{f.last_topic_name}
|
||||
</Link>{' '}
|
||||
{t('by')} {f.last_topic_poster}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
<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>
|
||||
<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,6 +1,7 @@
|
||||
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'
|
||||
@@ -23,47 +24,47 @@ export default async function ForumSearchPage({
|
||||
const total = valid ? await countSearchTopics(query) : 0
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||
<p className="mb-2 text-sm">
|
||||
<Link href="/forum" className="text-sky-400 hover:underline">
|
||||
← {t('backToForum')}
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-500">{t('search')}</h1>
|
||||
<ForumSearchBox initial={query} />
|
||||
<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="text-amber-200/70">{t('searchHint')}</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-amber-200/70">{t('noResults', { query })}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-amber-200/60">{t('resultsCount', { count: total, query })}</p>
|
||||
<ul className="divide-y divide-amber-900/40">
|
||||
{results.map((r) => (
|
||||
<li key={r.id} className="flex items-center justify-between gap-4 py-3">
|
||||
<div>
|
||||
<Link href={`/forum/topic/${r.id}`} className="font-semibold text-amber-300 hover:text-amber-400">
|
||||
{r.name}
|
||||
</Link>
|
||||
<p className="text-xs text-amber-200/60">
|
||||
{t('in')}{' '}
|
||||
<Link href={`/forum/${r.forum_id}`} className="text-sky-400 hover:underline">
|
||||
{r.forum_name}
|
||||
</Link>{' '}
|
||||
· {t('by')} {r.poster}
|
||||
</p>
|
||||
</div>
|
||||
{r.updated_at && (
|
||||
<span className="whitespace-nowrap text-xs text-amber-200/50">
|
||||
{new Date(r.updated_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
{!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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Link } from '@/i18n/navigation'
|
||||
import { getTopic, getPosts, countPosts, listForumsForMove } from '@/lib/forum'
|
||||
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'
|
||||
@@ -36,73 +37,71 @@ export default async function TopicPage({
|
||||
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}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||
<p className="mb-2 text-sm">
|
||||
<Link href="/forum" className="text-sky-400 hover:underline">
|
||||
← {t('backToForum')}
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="mb-4 text-2xl font-bold text-amber-500">
|
||||
{topic!.sticky && <span className="mr-2 text-sm text-nw-gold-light">[{t('pinned')}]</span>}
|
||||
{topic!.locked && <span className="mr-2 text-sm text-red-400">[{t('locked')}]</span>}
|
||||
{topic!.deleted && <span className="mr-2 text-sm text-red-400">[{t('deletedMark')}]</span>}
|
||||
{topic!.name}
|
||||
</h1>
|
||||
<PageShell title={title}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content">
|
||||
<p>
|
||||
<Link href="/forum">← {t('backToForum')}</Link>
|
||||
</p>
|
||||
<br />
|
||||
|
||||
{isMod && (
|
||||
<TopicModBar
|
||||
topicId={id}
|
||||
locked={topic!.locked}
|
||||
sticky={topic!.sticky}
|
||||
deleted={topic!.deleted}
|
||||
moveForums={moveForums}
|
||||
/>
|
||||
)}
|
||||
{isMod && (
|
||||
<TopicModBar
|
||||
topicId={id}
|
||||
locked={topic!.locked}
|
||||
sticky={topic!.sticky}
|
||||
deleted={topic!.deleted}
|
||||
moveForums={moveForums}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{posts.map((post) => (
|
||||
<article key={post.id} className={`nw-card ${post.deleted ? 'opacity-50 ring-1 ring-red-900/50' : ''}`}>
|
||||
<div className="mb-2 flex items-center justify-between border-b border-amber-900/40 pb-2 text-sm">
|
||||
<span className="font-semibold text-amber-400">
|
||||
<Link href={`/forum/user/${encodeURIComponent(post.poster)}`} className="hover:text-amber-300 hover:underline">
|
||||
{post.poster}
|
||||
</Link>
|
||||
{post.deleted && <span className="ml-2 text-xs text-red-400">[{t('deletedMark')}]</span>}
|
||||
</span>
|
||||
{post.time && (
|
||||
<span className="text-amber-200/50">
|
||||
{new Date(post.time).toLocaleString(locale)}
|
||||
{post.edited && <span className="ml-2 italic text-amber-200/40">· {t('editedMark')}</span>}
|
||||
{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>
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: post.text }}
|
||||
/>
|
||||
{canEditPost(session, isMod, post.poster_id) && (
|
||||
<PostActions postId={post.id} initialText={post.text} deleted={post.deleted} />
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
))}
|
||||
|
||||
<Pagination page={page} totalPages={totalPages} hrefFor={(p) => `/forum/topic/${id}?page=${p}`} />
|
||||
|
||||
{canReply ? (
|
||||
page === totalPages ? (
|
||||
<ReplyForm topicId={id} />
|
||||
) : (
|
||||
<p className="separate">
|
||||
<Link href={`/forum/topic/${id}?page=${totalPages}`}>{t('replyOnLastPage')}</Link>
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
!session.username && <p className="second-brown centered separate">{t('loginToPost')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination page={page} totalPages={totalPages} hrefFor={(p) => `/forum/topic/${id}?page=${p}`} />
|
||||
|
||||
{canReply ? (
|
||||
page === totalPages ? (
|
||||
<ReplyForm topicId={id} />
|
||||
) : (
|
||||
<p className="mt-6 text-sm">
|
||||
<Link href={`/forum/topic/${id}?page=${totalPages}`} className="text-sky-400 hover:underline">
|
||||
{t('replyOnLastPage')}
|
||||
</Link>
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
!session.username && <p className="mt-6 text-sm text-amber-200/60">{t('loginToPost')}</p>
|
||||
)}
|
||||
</main>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
|
||||
@@ -14,55 +15,69 @@ export default async function ForumProfilePage({
|
||||
const t = await getTranslations('Forum')
|
||||
const p = await getForumProfile(decodeURIComponent(username))
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-8">
|
||||
<p className="mb-2 text-sm">
|
||||
<Link href="/forum" className="text-sky-400 hover:underline">
|
||||
← {t('backToForum')}
|
||||
</Link>
|
||||
</p>
|
||||
const title = (
|
||||
<>
|
||||
{p.username}
|
||||
{p.status && (
|
||||
<span className="second-yellow small-font"> [{p.status === 'admin' ? t('roleAdmin') : t('roleGm')}]</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
<div className="nw-card">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold text-amber-500">
|
||||
{p.username}
|
||||
{p.status && (
|
||||
<span className="rounded bg-nw-gold/15 px-2 py-0.5 text-xs text-nw-gold-light">
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-nw-muted">{t('posts')}</p>
|
||||
<p className="text-lg font-semibold text-amber-300">{p.postCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-nw-muted">{t('topics')}</p>
|
||||
<p className="text-lg font-semibold text-amber-300">{p.topicCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-nw-muted">{t('memberSince')}</p>
|
||||
<p className="font-semibold text-amber-300">
|
||||
{p.joindate ? new Date(p.joindate).toLocaleDateString(locale) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{p.recentTopics.length > 0 && (
|
||||
<section className="mt-6">
|
||||
<h2 className="mb-3 text-lg font-semibold text-nw-gold-light">{t('recentTopics')}</h2>
|
||||
<ul className="divide-y divide-amber-900/40">
|
||||
{p.recentTopics.map((topic) => (
|
||||
<li key={topic.id} className="py-2">
|
||||
<Link href={`/forum/topic/${topic.id}`} className="text-amber-300 hover:text-amber-400">
|
||||
{topic.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getGameCharacters } from '@/lib/characters'
|
||||
import { getGoldOptions } from '@/lib/prices'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ServiceBox } from '@/components/ServiceBox'
|
||||
import { GoldForm } from '@/components/GoldForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -18,9 +20,10 @@ export default async function GoldPage({ params }: { params: Promise<{ locale: s
|
||||
const [chars, options] = await Promise.all([getGameCharacters(session.accountId!), getGoldOptions()])
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t('gold.title')}</h1>
|
||||
<GoldForm characters={chars.map((c) => c.name)} options={options} />
|
||||
</main>
|
||||
<PageShell title={t('gold.title')}>
|
||||
<ServiceBox>
|
||||
<GoldForm characters={chars.map((c) => c.name)} options={options} />
|
||||
</ServiceBox>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { NextIntlClientProvider, hasLocale } from 'next-intl'
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { routing } from '@/i18n/routing'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Video } from '@/components/Video'
|
||||
import { Social } from '@/components/Social'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import '../globals.css'
|
||||
|
||||
@@ -23,7 +25,7 @@ export async function generateMetadata({
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
openGraph: {
|
||||
siteName: 'Nova WoW',
|
||||
siteName: 'NovaWoW',
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
type: 'website',
|
||||
@@ -46,10 +48,30 @@ export default async function LocaleLayout({
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body className="flex min-h-screen flex-col">
|
||||
<head>
|
||||
{/* Iconos del sitio (idénticos a la web Django) */}
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="194x194" href="/favicon-194x194.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/android-chrome-192x192.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#47210a" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
{/* Font Awesome (mismos iconos que la web Django) */}
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css"
|
||||
/>
|
||||
{/* Tema real de NovaWoW (nw-ryu). Va el ÚLTIMO para ganar la cascada. */}
|
||||
<link rel="stylesheet" href="/nw-themes/nw-ryu/nw-css/novawow-style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<NextIntlClientProvider>
|
||||
<Header />
|
||||
<div className="flex-1">{children}</div>
|
||||
<Video />
|
||||
{children}
|
||||
<Social />
|
||||
<Footer />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
|
||||
@@ -50,49 +50,70 @@ export function LoginForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm text-center">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('email')}
|
||||
autoFocus
|
||||
required
|
||||
className="nw-input"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('password')}
|
||||
maxLength={16}
|
||||
required
|
||||
className="nw-input pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPw((v) => !v)}
|
||||
className="absolute inset-y-0 right-2 text-amber-200/60"
|
||||
aria-label="toggle"
|
||||
>
|
||||
{showPw ? '🙈' : '👁'}
|
||||
</button>
|
||||
</div>
|
||||
<Turnstile onVerify={setCaptcha} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || (!!SITE_KEY && !captcha)}
|
||||
className="w-full nw-btn disabled:opacity-60"
|
||||
>
|
||||
{busy ? t('connecting') : t('submit')}
|
||||
</button>
|
||||
<>
|
||||
<form onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<table className="middle-center-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type="email"
|
||||
maxLength={320}
|
||||
name="email"
|
||||
id="username"
|
||||
placeholder={t('email')}
|
||||
autoFocus
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'}
|
||||
maxLength={16}
|
||||
name="password"
|
||||
id="password"
|
||||
placeholder={t('password')}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<span
|
||||
className={`far toggle-password ${showPw ? 'fa-eye-slash' : 'fa-eye'}`}
|
||||
onClick={() => setShowPw((v) => !v)}
|
||||
role="button"
|
||||
aria-label="toggle"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Turnstile onVerify={setCaptcha} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<button
|
||||
type="submit"
|
||||
className="login-button"
|
||||
disabled={busy || (!!SITE_KEY && !captcha)}
|
||||
>
|
||||
{busy ? t('connecting') : t('submit')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
|
||||
{message && (
|
||||
<p className={`mt-3 ${message.ok ? 'text-green-400' : 'text-red-400'}`}>{message.text}</p>
|
||||
)}
|
||||
</div>
|
||||
<hr />
|
||||
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && (
|
||||
<span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { LoginForm } from './LoginForm'
|
||||
|
||||
export default async function LoginPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
@@ -7,9 +9,25 @@ export default async function LoginPage({ params }: { params: Promise<{ locale:
|
||||
const t = await getTranslations('Login')
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
<LoginForm />
|
||||
</main>
|
||||
<PageShell title={t('title')}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content centered">
|
||||
<LoginForm />
|
||||
<br />
|
||||
<p>
|
||||
<Link href="/recover">{t('forgot')}</Link>
|
||||
</p>
|
||||
<p>
|
||||
<Link href="/recover">{t('forgotActivation')}</Link>
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
{t('newHere')} <Link href="/register">{t('createAccount')}</Link>
|
||||
</p>
|
||||
<br />
|
||||
<p className="grey-info2">{t('publicNote')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { setRequestLocale } from 'next-intl/server'
|
||||
import { ServerClock } from '@/components/ServerClock'
|
||||
import { getNoticias, getServerStatus, type Noticia, type ServerStatus } from '@/lib/home'
|
||||
|
||||
const SERVER_NAME = 'NovaWoW'
|
||||
|
||||
// SSR por petición: los datos se leen DIRECTAMENTE de MySQL desde el servidor Next.
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -13,100 +15,144 @@ async function getData(): Promise<{ noticias: Noticia[]; status: ServerStatus |
|
||||
}
|
||||
}
|
||||
|
||||
function formatFecha(iso: string | Date | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return String(iso)
|
||||
return d.toLocaleString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations('Home')
|
||||
const { noticias, status } = await getData()
|
||||
|
||||
const online = status ? status.online_characters : 0
|
||||
const loginStatus = status ? status.status : 'Offline'
|
||||
const loginClass = loginStatus === 'Online' ? 'green-info' : 'red-info'
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero */}
|
||||
<section className="relative overflow-hidden border-b border-nw-border/50">
|
||||
<div className="mx-auto max-w-4xl px-4 py-16 text-center sm:py-24">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/brand/logo-long.webp" alt="Nova WoW" className="mx-auto mb-4 max-h-28 w-auto drop-shadow-[0_0_25px_rgba(215,150,2,0.35)]" />
|
||||
<p className="mb-3 text-sm font-semibold uppercase tracking-[0.3em] text-nw-gold/80">
|
||||
WotLK Classic 3.4.3
|
||||
</p>
|
||||
<h1 className="nw-heading text-4xl sm:text-5xl">Nova WoW</h1>
|
||||
<p className="mx-auto mt-5 max-w-2xl text-nw-muted">{t('tagline')}</p>
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||
<Link href="/register" className="nw-btn">
|
||||
{t('ctaRegister')}
|
||||
</Link>
|
||||
<Link href="/forum" className="nw-btn-ghost">
|
||||
{t('ctaForum')}
|
||||
</Link>
|
||||
<div className="main-page">
|
||||
<div className="middle-content middle-content-index">
|
||||
<div className="body-content">
|
||||
<div className="title-content">
|
||||
<h1>{SERVER_NAME} WOTLK 3.4.3</h1>
|
||||
</div>
|
||||
<div className="box-content">
|
||||
<div className="raf-index-holder">
|
||||
<div className="right-content" id="right-content-min">
|
||||
<div className="right-body">
|
||||
{/* Estado del servidor + reloj en vivo (réplica de la isla React de Django) */}
|
||||
<table className="max-center-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="yellow-info">
|
||||
{status?.server_name ?? '—'}{' '}
|
||||
<span className="blue-info small-font">{status?.expansion ?? ''}</span>
|
||||
</td>
|
||||
<td className="real-info-box green-info">{online}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Login</td>
|
||||
<td className={`real-info-box ${loginClass}`}>{loginStatus}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Hora</td>
|
||||
<td className="real-info-box">
|
||||
<ServerClock />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<hr />
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="centered">
|
||||
<td colSpan={2}>
|
||||
<p>
|
||||
<span>{status?.address ?? ''}</span>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="raf-index-responsive">
|
||||
<a href="/recruit">¡RECLUTA A UN AMIGO!</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="raf-index">
|
||||
<a href="/recruit">¡RECLUTA A UN AMIGO!</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="body-box-content">
|
||||
<div className="body-box-content">
|
||||
<p>
|
||||
{SERVER_NAME} es una comunidad enfocada en usuarios de habla hispana,
|
||||
especialmente dirigida al público español y latino.
|
||||
</p>
|
||||
<p>
|
||||
Atraemos a usuarios apasionados tanto por el PvE como por el PvP, y ofrecemos una
|
||||
amplia variedad de contenido.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
Nuestro objetivo principal es mantener una comunidad de alto nivel, brindando un
|
||||
ambiente agradable para que todos los miembros, nuevos y veteranos, encuentren aquí
|
||||
un lugar donde puedan vivir momentos inigualables.
|
||||
</p>
|
||||
<p>¡Únete y disfruta de una experiencia única con miles de usuarios!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<div className="title-content-h3">
|
||||
<h3 className="big-font">NOTICIAS</h3>
|
||||
</div>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content">
|
||||
{noticias.length === 0 ? (
|
||||
<p>No hay noticias disponibles en este momento.</p>
|
||||
) : (
|
||||
<>
|
||||
{noticias.map((n) => (
|
||||
<div key={n.id} className="noticia-item">
|
||||
<h3>{n.titulo}</h3>
|
||||
<span className="date">{formatFecha(n.fecha)}</span>
|
||||
<div
|
||||
className="noticia-contenido"
|
||||
dangerouslySetInnerHTML={{ __html: n.contenido }}
|
||||
/>
|
||||
{n.enlace && (
|
||||
<p>
|
||||
Enlace:{' '}
|
||||
<a href={n.enlace} target="_blank" rel="noopener noreferrer">
|
||||
aquí
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
</div>
|
||||
))}
|
||||
<p className="centered third-brown">
|
||||
<i>* Mostrando las últimas 50 noticias</i>
|
||||
</p>
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main className="mx-auto max-w-4xl px-4 py-10">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{/* Estado del servidor */}
|
||||
<aside className="nw-card md:col-span-1">
|
||||
<h2 className="mb-3 text-lg font-semibold text-nw-gold-light">{t('serverStatus')}</h2>
|
||||
{status ? (
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex justify-between">
|
||||
<span className="text-nw-muted">{t('server')}</span>
|
||||
<span>
|
||||
{status.server_name ?? '—'} <span className="text-nw-muted">({status.expansion})</span>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-nw-muted">{t('onlineCharacters')}</span>
|
||||
<span className="text-green-400">{status.online_characters}</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-nw-muted">{t('login')}</span>
|
||||
<span className={status.status === 'Online' ? 'text-green-400' : 'text-red-400'}>
|
||||
{status.status}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-nw-muted">{t('address')}</span>
|
||||
<span className="text-nw-gold-light">{status.address ?? '—'}</span>
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-nw-muted">{t('notAvailable')}</p>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Noticias */}
|
||||
<section className="md:col-span-2">
|
||||
<h2 className="mb-4 text-xl font-semibold text-nw-gold-light">{t('news')}</h2>
|
||||
{noticias.length === 0 ? (
|
||||
<p className="text-nw-muted">{t('noNews')}</p>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{noticias.map((n) => (
|
||||
<article key={n.id} className="nw-card">
|
||||
<h3 className="text-lg font-semibold">{n.titulo}</h3>
|
||||
{n.fecha && (
|
||||
<small className="text-nw-muted">{new Date(n.fecha).toLocaleString(locale)}</small>
|
||||
)}
|
||||
<div
|
||||
className="prose prose-invert mt-3 max-w-none text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: n.contenido }}
|
||||
/>
|
||||
{n.enlace && (
|
||||
<p className="mt-3">
|
||||
{t('link')}:{' '}
|
||||
<a href={n.enlace} target="_blank" rel="noopener noreferrer" className="text-sky-400 underline">
|
||||
{t('here')}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,24 +39,46 @@ export function RecoverForm() {
|
||||
}
|
||||
}
|
||||
|
||||
const field = 'nw-input'
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<label className="block text-left text-sm text-amber-200/70">{t('type')}</label>
|
||||
<select value={type} onChange={(e) => setType(e.target.value)} className={field}>
|
||||
<option value="password">{t('typePassword')}</option>
|
||||
<option value="accountname">{t('typeAccountName')}</option>
|
||||
<option value="activation">{t('typeActivation')}</option>
|
||||
</select>
|
||||
<input type="email" placeholder={t('email')} value={email} onChange={(e) => setEmail(e.target.value)} required className={field} />
|
||||
<Turnstile onVerify={setCaptcha} />
|
||||
<button type="submit" disabled={busy || (!!SITE_KEY && !captcha)} className="w-full nw-btn disabled:opacity-60">
|
||||
{busy ? t('sending') : t('submit')}
|
||||
</button>
|
||||
<>
|
||||
<form onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<table className="middle-center-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<select value={type} onChange={(e) => setType(e.target.value)}>
|
||||
<option value="password">{t('typePassword')}</option>
|
||||
<option value="accountname">{t('typeAccountName')}</option>
|
||||
<option value="activation">{t('typeActivation')}</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="email" maxLength={320} placeholder={t('email')} required value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Turnstile onVerify={setCaptcha} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<button type="submit" className="recover-button" disabled={busy || (!!SITE_KEY && !captcha)}>
|
||||
{busy ? t('sending') : t('submit')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
{message && <p className={`mt-3 text-center ${message.ok ? 'text-green-400' : 'text-red-400'}`}>{message.text}</p>}
|
||||
</div>
|
||||
<hr />
|
||||
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && (
|
||||
<span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { RecoverForm } from './RecoverForm'
|
||||
|
||||
export default async function RecoverPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
@@ -6,9 +7,15 @@ export default async function RecoverPage({ params }: { params: Promise<{ locale
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations('Recover')
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
<RecoverForm />
|
||||
</main>
|
||||
<PageShell title={t('title')}>
|
||||
<div className="box-content">
|
||||
<div className="title-box-content">
|
||||
<h2>{t('type')}</h2>
|
||||
</div>
|
||||
<div className="body-box-content centered">
|
||||
<RecoverForm />
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getGameCharacters } from '@/lib/characters'
|
||||
import { getRecruitRewards, getRecruitedLevel80Count } from '@/lib/recruit-claim'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { RecruitClaim } from '@/components/RecruitClaim'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -16,23 +17,24 @@ export default async function RecruitPage({ params }: { params: Promise<{ locale
|
||||
const rewards = await getRecruitRewards(session.accountId ?? null)
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||
<h1 className="mb-2 text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
<p className="mb-6 text-nw-muted">{t('intro')}</p>
|
||||
|
||||
{session.accountId ? (
|
||||
<RecruitClaim
|
||||
rewards={rewards}
|
||||
characters={(await getGameCharacters(session.accountId)).map((c) => c.name)}
|
||||
level80Count={await getRecruitedLevel80Count(session.accountId)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-amber-200/70">
|
||||
<Link href="/login" className="text-sky-400 hover:underline">
|
||||
{t('loginToClaim')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
<PageShell title={t('title')}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content justified">
|
||||
<p>{t('intro')}</p>
|
||||
<br />
|
||||
{session.accountId ? (
|
||||
<RecruitClaim
|
||||
rewards={rewards}
|
||||
characters={(await getGameCharacters(session.accountId)).map((c) => c.name)}
|
||||
level80Count={await getRecruitedLevel80Count(session.accountId)}
|
||||
/>
|
||||
) : (
|
||||
<p className="centered">
|
||||
<Link href="/login">{t('loginToClaim')}</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,27 +53,64 @@ export function RegisterForm() {
|
||||
}
|
||||
}
|
||||
|
||||
const input = 'nw-input'
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm">
|
||||
<p className="mb-4 text-sm text-amber-200/70">{t('info')}</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<input type="password" maxLength={16} placeholder={t('password')} value={form.password} onChange={(e) => upd('password', e.target.value)} className={input} />
|
||||
<input type="password" maxLength={16} placeholder={t('confPassword')} value={form.confPassword} onChange={(e) => upd('confPassword', e.target.value)} className={input} />
|
||||
<input type="email" placeholder={t('email')} value={form.email} onChange={(e) => upd('email', e.target.value)} className={input} />
|
||||
<input type="email" placeholder={t('confEmail')} value={form.confEmail} onChange={(e) => upd('confEmail', e.target.value)} className={input} />
|
||||
<input type="text" maxLength={12} placeholder={t('recruiter')} value={form.recruiter} onChange={(e) => upd('recruiter', e.target.value)} className={input} />
|
||||
<label className="flex items-start gap-2 text-left text-sm">
|
||||
<input type="checkbox" checked={accepted} onChange={(e) => setAccepted(e.target.checked)} className="mt-1" />
|
||||
<span>{t('terms')}</span>
|
||||
</label>
|
||||
<Turnstile onVerify={setCaptcha} />
|
||||
<button type="submit" disabled={busy || !accepted || (!!SITE_KEY && !captcha)} className="w-full nw-btn disabled:opacity-60">
|
||||
{busy ? t('creating') : t('submit')}
|
||||
</button>
|
||||
<>
|
||||
<form onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<table className="middle-center-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="password" maxLength={16} placeholder={t('password')} required value={form.password} onChange={(e) => upd('password', e.target.value)} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="password" maxLength={16} placeholder={t('confPassword')} required value={form.confPassword} onChange={(e) => upd('confPassword', e.target.value)} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="email" maxLength={320} placeholder={t('email')} required value={form.email} onChange={(e) => upd('email', e.target.value)} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="email" maxLength={320} placeholder={t('confEmail')} required value={form.confEmail} onChange={(e) => upd('confEmail', e.target.value)} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="text" maxLength={12} placeholder={t('recruiter')} value={form.recruiter} onChange={(e) => upd('recruiter', e.target.value)} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="lefted separate">
|
||||
<label>
|
||||
<input type="checkbox" checked={accepted} onChange={(e) => setAccepted(e.target.checked)} /> {t('terms')}
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Turnstile onVerify={setCaptcha} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<button type="submit" className="create-button" disabled={busy || !accepted || (!!SITE_KEY && !captcha)}>
|
||||
{busy ? t('creating') : t('submit')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
{message && <p className={`mt-3 text-center ${message.ok ? 'text-green-400' : 'text-red-400'}`}>{message.text}</p>}
|
||||
</div>
|
||||
<hr />
|
||||
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && (
|
||||
<span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { RegisterForm } from './RegisterForm'
|
||||
|
||||
export default async function RegisterPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
@@ -7,9 +8,17 @@ export default async function RegisterPage({ params }: { params: Promise<{ local
|
||||
const t = await getTranslations('Register')
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
<RegisterForm />
|
||||
</main>
|
||||
<PageShell title={t('title')}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content info-box-light justified">
|
||||
<p>{t('info')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content centered">
|
||||
<RegisterForm />
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getGameCharacters } from '@/lib/characters'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ServiceBox } from '@/components/ServiceBox'
|
||||
import { CharacterActionForm } from '@/components/CharacterActionForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -16,9 +18,10 @@ export default async function RevivePage({ params }: { params: Promise<{ locale:
|
||||
const chars = await getGameCharacters(session.accountId!)
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t('reviveTitle')}</h1>
|
||||
<CharacterActionForm characters={chars.map((c) => c.name)} endpoint="/api/character/revive" actionLabel={t('reviveAction')} />
|
||||
</main>
|
||||
<PageShell title={t('reviveTitle')}>
|
||||
<ServiceBox>
|
||||
<CharacterActionForm characters={chars.map((c) => c.name)} endpoint="/api/character/revive" actionLabel={t('reviveAction')} buttonClass="revive-button" />
|
||||
</ServiceBox>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,20 +34,23 @@ export function SecurityTokenForm({ initialDate }: { initialDate: string | null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
<p className="mb-4 text-sm text-amber-200/70">{t('info')}</p>
|
||||
<p className="mb-4">
|
||||
{t('lastRequest')}:{' '}
|
||||
<span className="text-amber-400">{date ? new Date(date).toLocaleString(locale) : t('never')}</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={request}
|
||||
disabled={busy}
|
||||
className="nw-btn disabled:opacity-60"
|
||||
>
|
||||
{busy ? t('requesting') : t('request')}
|
||||
</button>
|
||||
{message && <p className={`mt-3 ${message.ok ? 'text-green-400' : 'text-red-400'}`}>{message.text}</p>}
|
||||
</div>
|
||||
<>
|
||||
<p>{t('info')}</p>
|
||||
<br />
|
||||
<div className="centered">
|
||||
<p>
|
||||
{t('lastRequest')}:{' '}
|
||||
<span className="yellow-info">{date ? new Date(date).toLocaleString(locale) : t('never')}</span>
|
||||
</p>
|
||||
<br />
|
||||
<button onClick={request} className="sec-token-button" disabled={busy}>
|
||||
{busy ? t('requesting') : t('request')}
|
||||
</button>
|
||||
<hr />
|
||||
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && <span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { db, DB } from '@/lib/db'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ServiceBox } from '@/components/ServiceBox'
|
||||
import { SecurityTokenForm } from './SecurityTokenForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -27,9 +29,10 @@ export default async function SecurityTokenPage({ params }: { params: Promise<{
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
<SecurityTokenForm initialDate={tokenDate} />
|
||||
</main>
|
||||
<PageShell title={t('title')}>
|
||||
<ServiceBox>
|
||||
<SecurityTokenForm initialDate={tokenDate} />
|
||||
</ServiceBox>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,18 +39,27 @@ export function SelectAccountForm({ accounts }: { accounts: GameAccount[] }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{accounts.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => select(a.id)}
|
||||
disabled={busy !== null}
|
||||
className="w-full nw-btn disabled:opacity-60"
|
||||
>
|
||||
{busy === a.id ? t('entering') : a.username}
|
||||
</button>
|
||||
))}
|
||||
{error && <p className="text-red-400">{error}</p>}
|
||||
<div className="centered">
|
||||
<table className="middle-center-table">
|
||||
<tbody>
|
||||
{accounts.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => select(a.id)}
|
||||
disabled={busy !== null}
|
||||
className="login-button"
|
||||
>
|
||||
{busy === a.id ? t('entering') : a.username}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
|
||||
{error && <span className="red-form-response">{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getGameAccounts } from '@/lib/auth'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { SelectAccountForm } from './SelectAccountForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -22,21 +23,25 @@ export default async function SelectAccountPage({ params }: { params: Promise<{
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-md px-4 py-8 text-center">
|
||||
<h1 className="mb-4 text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
{session.bnetEmail && (
|
||||
<p className="mb-4 text-sm text-amber-200/70">
|
||||
{t('loggedInAs')} <strong>{session.bnetEmail}</strong>
|
||||
</p>
|
||||
)}
|
||||
{games.length > 0 ? (
|
||||
<>
|
||||
<p className="mb-4">{t('choose')}</p>
|
||||
<SelectAccountForm accounts={games} />
|
||||
</>
|
||||
) : (
|
||||
<p className="text-amber-200/70">{t('noAccounts')}</p>
|
||||
)}
|
||||
</main>
|
||||
<PageShell title={t('title')}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content centered">
|
||||
{session.bnetEmail && (
|
||||
<p>
|
||||
{t('loggedInAs')} <span className="yellow-info">{session.bnetEmail}</span>
|
||||
</p>
|
||||
)}
|
||||
{games.length > 0 ? (
|
||||
<>
|
||||
<p>{t('choose')}</p>
|
||||
<br />
|
||||
<SelectAccountForm accounts={games} />
|
||||
</>
|
||||
) : (
|
||||
<p className="second-brown">{t('noAccounts')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { fulfillCheckoutSession } from '@/lib/fulfill'
|
||||
import { isSessionPaid } from '@/lib/stripe'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -34,15 +35,18 @@ export default async function ServiceSuccessPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-lg px-4 py-12 text-center">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-500">{service ? t(`${service}.title`) : ''}</h1>
|
||||
{status === 'delivered' ? (
|
||||
<p className="text-green-400">{t(`${service}.success`, { name: okName ?? '' })}</p>
|
||||
) : status === 'already' ? (
|
||||
<p className="text-green-400">{t('alreadyProcessed')}</p>
|
||||
) : (
|
||||
<p className="text-red-400">{t('error')}</p>
|
||||
)}
|
||||
</main>
|
||||
<PageShell title={service ? t(`${service}.title`) : ''}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content centered">
|
||||
{status === 'delivered' ? (
|
||||
<span className="ok-form-response">{t(`${service}.success`, { name: okName ?? '' })}</span>
|
||||
) : status === 'already' ? (
|
||||
<span className="ok-form-response">{t('alreadyProcessed')}</span>
|
||||
) : (
|
||||
<span className="red-form-response">{t('error')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getGameCharacters } from '@/lib/characters'
|
||||
import { getTransferPrice } from '@/lib/prices'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ServiceBox } from '@/components/ServiceBox'
|
||||
import { TransferForm } from '@/components/TransferForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -18,9 +20,10 @@ export default async function TransferPage({ params }: { params: Promise<{ local
|
||||
const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), getTransferPrice()])
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t('transfer.title')}</h1>
|
||||
<TransferForm characters={chars.map((c) => c.name)} price={price} />
|
||||
</main>
|
||||
<PageShell title={t('transfer.title')}>
|
||||
<ServiceBox>
|
||||
<TransferForm characters={chars.map((c) => c.name)} price={price} />
|
||||
</ServiceBox>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getGameCharacters } from '@/lib/characters'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ServiceBox } from '@/components/ServiceBox'
|
||||
import { CharacterActionForm } from '@/components/CharacterActionForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -16,9 +18,10 @@ export default async function UnstuckPage({ params }: { params: Promise<{ locale
|
||||
const chars = await getGameCharacters(session.accountId!)
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t('unstuckTitle')}</h1>
|
||||
<CharacterActionForm characters={chars.map((c) => c.name)} endpoint="/api/character/unstuck" actionLabel={t('unstuckAction')} />
|
||||
</main>
|
||||
<PageShell title={t('unstuckTitle')}>
|
||||
<ServiceBox>
|
||||
<CharacterActionForm characters={chars.map((c) => c.name)} endpoint="/api/character/unstuck" actionLabel={t('unstuckAction')} buttonClass="unstuck-button" />
|
||||
</ServiceBox>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getVoteSites } from '@/lib/vote'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { VotePanel } from '@/components/VotePanel'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -12,10 +13,14 @@ export default async function VotePointsPage({ params }: { params: Promise<{ loc
|
||||
const session = await getSession()
|
||||
const sites = await getVoteSites()
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-3 text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
<p className="mb-6 text-sm text-amber-200/70">{t('info', { server: 'Nova WoW' })}</p>
|
||||
<VotePanel sites={sites} canVote={Boolean(session.accountId)} />
|
||||
</main>
|
||||
<PageShell title={t('title')}>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content centered">
|
||||
<p>{t('info', { server: 'NovaWoW' })}</p>
|
||||
<br />
|
||||
<VotePanel sites={sites} canVote={Boolean(session.accountId)} />
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,14 +16,9 @@
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(1100px 520px at 50% -8%, rgba(215, 150, 2, 0.12), transparent 62%),
|
||||
linear-gradient(180deg, #1b120b 0%, #120b07 100%);
|
||||
background-attachment: fixed;
|
||||
color: var(--color-nw-text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
/* El fondo, la tipografía (FuturaEF-Book) y los estilos base del <body>
|
||||
los controla el tema real nw-ryu (novawow-style.css), cargado en el <head>
|
||||
del layout. No los redefinimos aquí para que el tema Django quede idéntico. */
|
||||
|
||||
/* Componentes reutilizables */
|
||||
@layer components {
|
||||
@@ -49,6 +44,66 @@ body {
|
||||
color: #7cc0f7;
|
||||
}
|
||||
|
||||
/* Foro: textarea con el look del tema real (el tema nw-ryu no estiliza textarea base). */
|
||||
textarea {
|
||||
width: 100%;
|
||||
background-color: #000;
|
||||
border: 2px solid #352e2b;
|
||||
color: #fff;
|
||||
font-family: 'FuturaEF-Book';
|
||||
font-size: 17px;
|
||||
padding: 8px;
|
||||
transition: 0.5s;
|
||||
}
|
||||
textarea:focus {
|
||||
border-color: #948474;
|
||||
}
|
||||
|
||||
/* Foro: barra de herramientas del editor y paginación (colores del tema). */
|
||||
.nw-tool-btn {
|
||||
border: 2px solid #352e2b;
|
||||
background: hsla(0, 0%, 100%, 0.05);
|
||||
color: #ebdec2;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
}
|
||||
.nw-tool-btn:hover {
|
||||
background: hsla(0, 0%, 100%, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
.nw-page-link {
|
||||
display: inline-block;
|
||||
min-width: 34px;
|
||||
padding: 4px 8px;
|
||||
margin: 0 2px;
|
||||
border: 2px solid #352e2b;
|
||||
color: #2471a9;
|
||||
}
|
||||
.nw-page-link:hover {
|
||||
color: #fff;
|
||||
}
|
||||
.nw-page-current {
|
||||
border-color: #b17c02;
|
||||
color: #d79602;
|
||||
}
|
||||
/* Contenido de posts del foro */
|
||||
.post-content {
|
||||
line-height: 22px;
|
||||
}
|
||||
.post-content a {
|
||||
color: #2471a9;
|
||||
}
|
||||
|
||||
/* Panel de admin: campos a ancho completo dentro de la caja del formulario. */
|
||||
.admin-form input,
|
||||
.admin-form textarea,
|
||||
.admin-form select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Colores de clase de WoW (nombre de personaje) */
|
||||
.class-warrior { color: #c79c6e; }
|
||||
.class-paladin { color: #f58cba; }
|
||||
|
||||
@@ -42,75 +42,76 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
|
||||
}
|
||||
}
|
||||
|
||||
const field = 'nw-input'
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
{/* Alta de categoría */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (catForm.name.trim()) call('/api/admin/forum/category', 'POST', { name: catForm.name, order: Number(catForm.order) }).then((ok) => ok && setCatForm({ name: '', order: '0' }))
|
||||
}}
|
||||
className="flex flex-wrap items-end gap-2 nw-card"
|
||||
className="admin-form info-box-light separate2"
|
||||
>
|
||||
<label className="flex-1 text-sm">
|
||||
<span className="mb-1 block text-nw-muted">{t('newCategory')}</span>
|
||||
<input value={catForm.name} onChange={(e) => setCatForm({ ...catForm, name: e.target.value })} placeholder={t('categoryName')} className={field} />
|
||||
</label>
|
||||
<label className="w-20 text-sm">
|
||||
<span className="mb-1 block text-nw-muted">{t('order')}</span>
|
||||
<input type="number" value={catForm.order} onChange={(e) => setCatForm({ ...catForm, order: e.target.value })} className={field} />
|
||||
</label>
|
||||
<button type="submit" disabled={busy} className="nw-btn disabled:opacity-60">{t('create')}</button>
|
||||
<span className="second-brown">{t('newCategory')}</span>
|
||||
<input value={catForm.name} onChange={(e) => setCatForm({ ...catForm, name: e.target.value })} placeholder={t('categoryName')} />
|
||||
<span className="second-brown">{t('order')}</span>
|
||||
<input type="number" value={catForm.order} onChange={(e) => setCatForm({ ...catForm, order: e.target.value })} />
|
||||
<button type="submit" disabled={busy}>{t('create')}</button>
|
||||
</form>
|
||||
<br />
|
||||
|
||||
{initial.length === 0 && <p className="text-amber-200/70">{t('noCategories')}</p>}
|
||||
{initial.length === 0 && <p className="second-brown centered">{t('noCategories')}</p>}
|
||||
|
||||
{initial.map(({ category, forums }) => (
|
||||
<section key={category.id} className="nw-card">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold text-nw-gold-light">
|
||||
{category.name} <span className="text-xs text-nw-muted">#{category.order}</span>
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => confirm(t('confirmDelete')) && call(`/api/admin/forum/category/${category.id}`, 'DELETE')}
|
||||
disabled={busy}
|
||||
className="rounded border border-red-900/60 px-2 py-1 text-xs text-red-400 hover:bg-red-950/40 disabled:opacity-60"
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
<div key={category.id} className="inline-div" style={{ display: 'block' }}>
|
||||
<div className="title-content-h3">
|
||||
<h3 className="big-font">
|
||||
{category.name} <span className="third-brown small-font">#{category.order}</span>{' '}
|
||||
<button
|
||||
onClick={() => confirm(t('confirmDelete')) && call(`/api/admin/forum/category/${category.id}`, 'DELETE')}
|
||||
disabled={busy}
|
||||
className="nw-tool-btn red-info2"
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Foros de la categoría */}
|
||||
<ul className="mb-3 divide-y divide-amber-900/40">
|
||||
{forums.length === 0 && <li className="py-2 text-sm text-nw-muted">{t('noForums')}</li>}
|
||||
{forums.map((f) => (
|
||||
<li key={f.id} className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-amber-300">
|
||||
{f.name} {f.visibility === 0 && <span className="text-xs text-red-400">({t('hidden')})</span>}
|
||||
</p>
|
||||
{f.description && <p className="truncate text-xs text-nw-muted">{f.description}</p>}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
onClick={() => call(`/api/admin/forum/${f.id}`, 'PATCH', { visibility: f.visibility === 0 })}
|
||||
disabled={busy}
|
||||
className="nw-btn-ghost text-xs disabled:opacity-60"
|
||||
>
|
||||
{f.visibility === 0 ? t('show') : t('hide')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => confirm(t('confirmDelete')) && call(`/api/admin/forum/${f.id}`, 'DELETE')}
|
||||
disabled={busy}
|
||||
className="rounded border border-red-900/60 px-2 py-1 text-xs text-red-400 hover:bg-red-950/40 disabled:opacity-60"
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<table className="max-center-table">
|
||||
<tbody>
|
||||
{forums.length === 0 && (
|
||||
<tr>
|
||||
<td className="second-brown centered">{t('noForums')}</td>
|
||||
</tr>
|
||||
)}
|
||||
{forums.map((f) => (
|
||||
<tr key={f.id} className="team-center-table-tr">
|
||||
<td className="lefted separate">
|
||||
<span className="yellow-info">{f.name}</span>{' '}
|
||||
{f.visibility === 0 && <span className="red-info2 small-font">({t('hidden')})</span>}
|
||||
{f.description && <p className="third-brown small-font">{f.description}</p>}
|
||||
</td>
|
||||
<td className="real-info-box no-wrap-td">
|
||||
<button
|
||||
onClick={() => call(`/api/admin/forum/${f.id}`, 'PATCH', { visibility: f.visibility === 0 })}
|
||||
disabled={busy}
|
||||
className="nw-tool-btn"
|
||||
>
|
||||
{f.visibility === 0 ? t('show') : t('hide')}
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => confirm(t('confirmDelete')) && call(`/api/admin/forum/${f.id}`, 'DELETE')}
|
||||
disabled={busy}
|
||||
className="nw-tool-btn red-info2"
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Alta de foro en esta categoría */}
|
||||
<form
|
||||
@@ -122,13 +123,13 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
|
||||
(ok) => ok && setFF(category.id, { name: '', description: '', order: '0' }),
|
||||
)
|
||||
}}
|
||||
className="grid gap-2 rounded-lg border border-nw-border/50 bg-nw-panel-2/30 p-3 sm:grid-cols-[1fr_1fr_auto]"
|
||||
className="admin-form separate"
|
||||
>
|
||||
<input value={ff(category.id).name} onChange={(e) => setFF(category.id, { name: e.target.value })} placeholder={t('forumName')} maxLength={45} className={field} />
|
||||
<input value={ff(category.id).description} onChange={(e) => setFF(category.id, { description: e.target.value })} placeholder={t('forumDescription')} className={field} />
|
||||
<button type="submit" disabled={busy} className="nw-btn text-sm disabled:opacity-60">{t('addForum')}</button>
|
||||
<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).description} onChange={(e) => setFF(category.id, { description: e.target.value })} placeholder={t('forumDescription')} />
|
||||
<button type="submit" disabled={busy}>{t('addForum')}</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -45,38 +45,38 @@ export function AdminGoldManager({ initial }: { initial: GoldOption[] }) {
|
||||
if ((await res.json()).success) setOpts(opts.filter((o) => o.id !== id))
|
||||
}
|
||||
|
||||
const field = 'nw-input'
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={create} className="mb-8 grid gap-3 nw-card sm:grid-cols-[1fr_1fr_auto] sm:items-end">
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-nw-muted">{t('goldAmount')}</span>
|
||||
<input type="number" min="1" value={form.goldAmount} onChange={(e) => upd('goldAmount', e.target.value)} placeholder="10000" className={field} />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-nw-muted">{t('goldPrice')}</span>
|
||||
<input type="number" min="0" step="0.01" value={form.price} onChange={(e) => upd('price', e.target.value)} placeholder="4.99" className={field} />
|
||||
</label>
|
||||
<button type="submit" disabled={busy} className="nw-btn disabled:opacity-60">
|
||||
<form onSubmit={create} className="admin-form info-box-light separate2">
|
||||
<span className="second-brown">{t('goldAmount')}</span>
|
||||
<input type="number" min="1" value={form.goldAmount} onChange={(e) => upd('goldAmount', e.target.value)} placeholder="10000" />
|
||||
<span className="second-brown">{t('goldPrice')}</span>
|
||||
<input type="number" min="0" step="0.01" value={form.price} onChange={(e) => upd('price', e.target.value)} placeholder="4.99" />
|
||||
<button type="submit" disabled={busy}>
|
||||
{busy ? t('creating') : t('create')}
|
||||
</button>
|
||||
</form>
|
||||
<br />
|
||||
{opts.length === 0 ? (
|
||||
<p className="text-amber-200/70">{t('noGold')}</p>
|
||||
<p className="second-brown centered">{t('noGold')}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-amber-900/40">
|
||||
{opts.map((o) => (
|
||||
<li key={o.id} className="flex items-center justify-between gap-4 py-3">
|
||||
<div>
|
||||
<p className="font-semibold text-amber-300">{o.gold_amount.toLocaleString()} {t('goldUnit')}</p>
|
||||
<p className="text-xs text-amber-200/50">{o.price.toFixed(2)} €</p>
|
||||
</div>
|
||||
<button onClick={() => remove(o.id)} className="rounded border border-red-900/60 px-3 py-1 text-sm text-red-400 hover:bg-red-950/40">
|
||||
{t('delete')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<table className="max-center-table">
|
||||
<tbody>
|
||||
{opts.map((o) => (
|
||||
<tr key={o.id} className="team-center-table-tr">
|
||||
<td className="lefted separate">
|
||||
<span className="yellow-info">{o.gold_amount.toLocaleString()} {t('goldUnit')}</span>
|
||||
<p className="third-brown small-font">{o.price.toFixed(2)} €</p>
|
||||
</td>
|
||||
<td className="real-info-box">
|
||||
<button onClick={() => remove(o.id)} className="nw-tool-btn red-info2">
|
||||
{t('delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -47,36 +47,41 @@ export function AdminNewsManager({ initialNews }: { initialNews: NewsItem[] }) {
|
||||
if (data.success) setNews(news.filter((n) => n.id !== id))
|
||||
}
|
||||
|
||||
const field = 'nw-input'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={create} className="mb-8 space-y-3 nw-card">
|
||||
<input value={titulo} onChange={(e) => setTitulo(e.target.value)} placeholder={t('newsTitle')} maxLength={200} className={field} />
|
||||
<textarea value={contenido} onChange={(e) => setContenido(e.target.value)} placeholder={t('newsContent')} rows={5} className={field} />
|
||||
<input value={enlace} onChange={(e) => setEnlace(e.target.value)} placeholder={t('newsLink')} className={field} />
|
||||
<button type="submit" disabled={busy} className="nw-btn disabled:opacity-60">
|
||||
<form onSubmit={create} className="admin-form info-box-light separate2">
|
||||
<input value={titulo} onChange={(e) => setTitulo(e.target.value)} placeholder={t('newsTitle')} maxLength={200} />
|
||||
<textarea value={contenido} onChange={(e) => setContenido(e.target.value)} placeholder={t('newsContent')} rows={5} />
|
||||
<input value={enlace} onChange={(e) => setEnlace(e.target.value)} placeholder={t('newsLink')} />
|
||||
<button type="submit" disabled={busy}>
|
||||
{busy ? t('creating') : t('create')}
|
||||
</button>
|
||||
{error && <p className="text-red-400">{error}</p>}
|
||||
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
|
||||
{error && <span className="red-form-response">{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
<br />
|
||||
|
||||
{news.length === 0 ? (
|
||||
<p className="text-amber-200/70">{t('noNews')}</p>
|
||||
<p className="second-brown centered">{t('noNews')}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-amber-900/40">
|
||||
{news.map((n) => (
|
||||
<li key={n.id} className="flex items-center justify-between gap-4 py-3">
|
||||
<div>
|
||||
<p className="font-semibold text-amber-300">{n.titulo}</p>
|
||||
{n.fecha && <p className="text-xs text-amber-200/50">{new Date(n.fecha).toLocaleString(locale)}</p>}
|
||||
</div>
|
||||
<button onClick={() => remove(n.id)} className="rounded border border-red-900/60 px-3 py-1 text-sm text-red-400 hover:bg-red-950/40">
|
||||
{t('delete')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<table className="max-center-table">
|
||||
<tbody>
|
||||
{news.map((n) => (
|
||||
<tr key={n.id} className="team-center-table-tr">
|
||||
<td className="lefted separate">
|
||||
<span className="yellow-info">{n.titulo}</span>
|
||||
{n.fecha && <p className="third-brown small-font">{new Date(n.fecha).toLocaleString(locale)}</p>}
|
||||
</td>
|
||||
<td className="real-info-box">
|
||||
<button onClick={() => remove(n.id)} className="nw-tool-btn red-info2">
|
||||
{t('delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -31,32 +31,36 @@ function Row({ row }: { row: PriceRow }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="flex items-center justify-between gap-4 py-3">
|
||||
<span className="text-amber-300">{t(`svc_${row.service}`)}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<tr className="team-center-table-tr">
|
||||
<td className="lefted separate">
|
||||
<span className="yellow-info">{t(`svc_${row.service}`)}</span>
|
||||
</td>
|
||||
<td className="real-info-box no-wrap-td">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
className="w-24 rounded border border-amber-900/60 bg-[#2c1e14] px-2 py-1 text-right"
|
||||
/>
|
||||
<button onClick={save} disabled={busy} className="rounded bg-amber-600 px-3 py-1 text-sm font-semibold text-[#1b120b] disabled:opacity-60">
|
||||
style={{ width: 90, textAlign: 'right' }}
|
||||
/>{' '}
|
||||
<button onClick={save} disabled={busy} className="nw-tool-btn">
|
||||
{t('save')}
|
||||
</button>
|
||||
{saved && <span className="text-sm text-green-400">{t('saved')}</span>}
|
||||
</div>
|
||||
</li>
|
||||
</button>{' '}
|
||||
{saved && <span className="green-info small-font">{t('saved')}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminPricesManager({ prices }: { prices: PriceRow[] }) {
|
||||
return (
|
||||
<ul className="divide-y divide-amber-900/40">
|
||||
{prices.map((row) => (
|
||||
<Row key={row.service} row={row} />
|
||||
))}
|
||||
</ul>
|
||||
<table className="max-center-table">
|
||||
<tbody>
|
||||
{prices.map((row) => (
|
||||
<Row key={row.service} row={row} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,56 +56,48 @@ export function AdminRecruitManager({ initial }: { initial: RecruitReward[] }) {
|
||||
if ((await res.json()).success) setRewards(rewards.filter((r) => r.id !== id))
|
||||
}
|
||||
|
||||
const field = 'nw-input'
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={create} className="mb-8 grid gap-3 nw-card sm:grid-cols-2">
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-nw-muted">{t('requiredFriends')}</span>
|
||||
<input type="number" min="1" value={form.requiredFriends} onChange={(e) => upd('requiredFriends', e.target.value)} placeholder="1" className={field} />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-nw-muted">{t('rewardName')}</span>
|
||||
<input value={form.rewardName} onChange={(e) => upd('rewardName', e.target.value)} placeholder="Montura épica" className={field} />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-nw-muted">{t('itemId')}</span>
|
||||
<input type="number" min="1" value={form.itemId} onChange={(e) => upd('itemId', e.target.value)} placeholder="49284" className={field} />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-nw-muted">{t('itemQuantity')}</span>
|
||||
<input type="number" min="1" value={form.itemQuantity} onChange={(e) => upd('itemQuantity', e.target.value)} className={field} />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-nw-muted">{t('itemLink')}</span>
|
||||
<input value={form.itemLink} onChange={(e) => upd('itemLink', e.target.value)} placeholder="https://…" className={field} />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-nw-muted">{t('iconClass')}</span>
|
||||
<input value={form.iconClass} onChange={(e) => upd('iconClass', e.target.value)} placeholder="icontinyl q3" className={field} />
|
||||
</label>
|
||||
<button type="submit" disabled={busy} className="nw-btn disabled:opacity-60 sm:col-span-2">
|
||||
<form onSubmit={create} className="admin-form info-box-light separate2">
|
||||
<span className="second-brown">{t('requiredFriends')}</span>
|
||||
<input type="number" min="1" value={form.requiredFriends} onChange={(e) => upd('requiredFriends', e.target.value)} placeholder="1" />
|
||||
<span className="second-brown">{t('rewardName')}</span>
|
||||
<input value={form.rewardName} onChange={(e) => upd('rewardName', e.target.value)} placeholder="Montura épica" />
|
||||
<span className="second-brown">{t('itemId')}</span>
|
||||
<input type="number" min="1" value={form.itemId} onChange={(e) => upd('itemId', e.target.value)} placeholder="49284" />
|
||||
<span className="second-brown">{t('itemQuantity')}</span>
|
||||
<input type="number" min="1" value={form.itemQuantity} onChange={(e) => upd('itemQuantity', e.target.value)} />
|
||||
<span className="second-brown">{t('itemLink')}</span>
|
||||
<input value={form.itemLink} onChange={(e) => upd('itemLink', e.target.value)} placeholder="https://…" />
|
||||
<span className="second-brown">{t('iconClass')}</span>
|
||||
<input value={form.iconClass} onChange={(e) => upd('iconClass', e.target.value)} placeholder="icontinyl q3" />
|
||||
<button type="submit" disabled={busy}>
|
||||
{busy ? t('creating') : t('create')}
|
||||
</button>
|
||||
</form>
|
||||
<br />
|
||||
{rewards.length === 0 ? (
|
||||
<p className="text-amber-200/70">{t('noRewards')}</p>
|
||||
<p className="second-brown centered">{t('noRewards')}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-amber-900/40">
|
||||
{rewards.map((r) => (
|
||||
<li key={r.id} className="flex items-center justify-between gap-4 py-3">
|
||||
<div>
|
||||
<p className="font-semibold text-amber-300">{r.reward_name}</p>
|
||||
<p className="text-xs text-amber-200/50">
|
||||
{t('requiredFriendsShort', { n: r.required_friends })} · item {r.item_id} ×{r.item_quantity}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => remove(r.id)} className="rounded border border-red-900/60 px-3 py-1 text-sm text-red-400 hover:bg-red-950/40">
|
||||
{t('delete')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<table className="max-center-table">
|
||||
<tbody>
|
||||
{rewards.map((r) => (
|
||||
<tr key={r.id} className="team-center-table-tr">
|
||||
<td className="lefted separate">
|
||||
<span className="yellow-info">{r.reward_name}</span>
|
||||
<p className="third-brown small-font">
|
||||
{t('requiredFriendsShort', { n: r.required_friends })} · item {r.item_id} ×{r.item_quantity}
|
||||
</p>
|
||||
</td>
|
||||
<td className="real-info-box">
|
||||
<button onClick={() => remove(r.id)} className="nw-tool-btn red-info2">
|
||||
{t('delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -56,60 +56,53 @@ export function AdminUsersManager() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={search} className="mb-6 flex gap-2">
|
||||
<form onSubmit={search} className="centered separate">
|
||||
<input
|
||||
type="search"
|
||||
type="text"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={t('userSearchPlaceholder')}
|
||||
className="nw-input flex-1"
|
||||
/>
|
||||
<button type="submit" disabled={busy} className="nw-btn whitespace-nowrap disabled:opacity-60">
|
||||
/>{' '}
|
||||
<button type="submit" disabled={busy} className="search-items-button">
|
||||
{busy ? t('searching') : t('search')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{searched && accounts.length === 0 ? (
|
||||
<p className="text-amber-200/70">{t('noUsers')}</p>
|
||||
<p className="second-brown centered">{t('noUsers')}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-amber-900/40">
|
||||
{accounts.map((a) => (
|
||||
<li key={a.id} className="flex flex-wrap items-center justify-between gap-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="flex items-center gap-2 font-semibold text-amber-300">
|
||||
{a.username}
|
||||
{a.gmlevel > 0 && (
|
||||
<span className="rounded bg-nw-gold/15 px-1.5 py-0.5 text-xs text-nw-gold-light">GM {a.gmlevel}</span>
|
||||
<table className="max-center-table">
|
||||
<tbody>
|
||||
{accounts.map((a) => (
|
||||
<tr key={a.id} className="team-center-table-tr">
|
||||
<td className="lefted separate">
|
||||
<span className="yellow-info">{a.username}</span>{' '}
|
||||
{a.gmlevel > 0 && <span className="second-yellow small-font">[GM {a.gmlevel}]</span>}{' '}
|
||||
{a.online && <span className="green-info small-font">● {t('online')}</span>}{' '}
|
||||
{a.banned && <span className="red-info2 small-font">⛔ {t('banned')}</span>}
|
||||
<p className="third-brown small-font">
|
||||
#{a.id}
|
||||
{a.email && ` · ${a.email}`}
|
||||
{a.last_login && ` · ${t('lastLogin')}: ${new Date(a.last_login).toLocaleDateString()}`}
|
||||
{a.banned && a.ban_reason && ` · ${a.ban_reason}`}
|
||||
{a.banned && a.ban_until && ` (${new Date(a.ban_until).toLocaleDateString()})`}
|
||||
</p>
|
||||
</td>
|
||||
<td className="real-info-box">
|
||||
{a.banned ? (
|
||||
<button onClick={() => act(a, 'unban')} className="nw-tool-btn green-info">
|
||||
{t('unban')}
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => act(a, 'ban')} className="nw-tool-btn red-info2">
|
||||
{t('ban')}
|
||||
</button>
|
||||
)}
|
||||
{a.online && <span className="text-xs text-green-400">● {t('online')}</span>}
|
||||
{a.banned && <span className="text-xs text-red-400">⛔ {t('banned')}</span>}
|
||||
</p>
|
||||
<p className="truncate text-xs text-amber-200/50">
|
||||
#{a.id}
|
||||
{a.email && ` · ${a.email}`}
|
||||
{a.last_login && ` · ${t('lastLogin')}: ${new Date(a.last_login).toLocaleDateString()}`}
|
||||
{a.banned && a.ban_reason && ` · ${a.ban_reason}`}
|
||||
{a.banned && a.ban_until && ` (${new Date(a.ban_until).toLocaleDateString()})`}
|
||||
</p>
|
||||
</div>
|
||||
{a.banned ? (
|
||||
<button
|
||||
onClick={() => act(a, 'unban')}
|
||||
className="rounded border border-green-900/60 px-3 py-1 text-sm text-green-400 hover:bg-green-950/40"
|
||||
>
|
||||
{t('unban')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => act(a, 'ban')}
|
||||
className="rounded border border-red-900/60 px-3 py-1 text-sm text-red-400 hover:bg-red-950/40"
|
||||
>
|
||||
{t('ban')}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -44,36 +44,40 @@ export function AdminVotesManager({ initial }: { initial: VoteSite[] }) {
|
||||
if ((await res.json()).success) setSites(sites.filter((s) => s.id !== id))
|
||||
}
|
||||
|
||||
const field = 'nw-input'
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={create} className="mb-8 space-y-3 nw-card">
|
||||
<input value={form.name} onChange={(e) => upd('name', e.target.value)} placeholder={t('voteName')} className={field} />
|
||||
<input value={form.url} onChange={(e) => upd('url', e.target.value)} placeholder={t('voteUrl')} className={field} />
|
||||
<input value={form.imageUrl} onChange={(e) => upd('imageUrl', e.target.value)} placeholder={t('voteImage')} className={field} />
|
||||
<input type="number" min="0" value={form.points} onChange={(e) => upd('points', e.target.value)} placeholder={t('votePoints')} className={field} />
|
||||
<button type="submit" disabled={busy} className="nw-btn disabled:opacity-60">
|
||||
<form onSubmit={create} className="admin-form info-box-light separate2">
|
||||
<input value={form.name} onChange={(e) => upd('name', e.target.value)} placeholder={t('voteName')} />
|
||||
<input value={form.url} onChange={(e) => upd('url', e.target.value)} placeholder={t('voteUrl')} />
|
||||
<input value={form.imageUrl} onChange={(e) => upd('imageUrl', e.target.value)} placeholder={t('voteImage')} />
|
||||
<input type="number" min="0" value={form.points} onChange={(e) => upd('points', e.target.value)} placeholder={t('votePoints')} />
|
||||
<button type="submit" disabled={busy}>
|
||||
{busy ? t('creating') : t('create')}
|
||||
</button>
|
||||
</form>
|
||||
<br />
|
||||
{sites.length === 0 ? (
|
||||
<p className="text-amber-200/70">{t('noVotes')}</p>
|
||||
<p className="second-brown centered">{t('noVotes')}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-amber-900/40">
|
||||
{sites.map((s) => (
|
||||
<li key={s.id} className="flex items-center justify-between gap-4 py-3">
|
||||
<div>
|
||||
<p className="font-semibold text-amber-300">{s.name}</p>
|
||||
<p className="text-xs text-amber-200/50">
|
||||
{s.points} PV · {s.url}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => remove(s.id)} className="rounded border border-red-900/60 px-3 py-1 text-sm text-red-400 hover:bg-red-950/40">
|
||||
{t('delete')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<table className="max-center-table">
|
||||
<tbody>
|
||||
{sites.map((s) => (
|
||||
<tr key={s.id} className="team-center-table-tr">
|
||||
<td className="lefted separate">
|
||||
<span className="yellow-info">{s.name}</span>
|
||||
<p className="third-brown small-font">
|
||||
{s.points} PV · {s.url}
|
||||
</p>
|
||||
</td>
|
||||
<td className="real-info-box">
|
||||
<button onClick={() => remove(s.id)} className="nw-tool-btn red-info2">
|
||||
{t('delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -33,35 +33,37 @@ export function BattlepayList({ orders }: { orders: BattlepayOrder[] }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (orders.length === 0) return <p className="text-amber-200/70">{t('noOrders')}</p>
|
||||
if (orders.length === 0) return <p className="second-brown">{t('noOrders')}</p>
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <p className="mb-4 text-red-400">{error}</p>}
|
||||
<ul className="space-y-3">
|
||||
{orders.map((o) => (
|
||||
<li key={o.id} className="flex flex-wrap items-center justify-between gap-3 nw-card">
|
||||
<div>
|
||||
<p className="font-semibold text-amber-300">{o.product_name}</p>
|
||||
<p className="text-xs text-amber-200/50">
|
||||
{t('reference')}: {o.reference}
|
||||
{o.created_at ? ` · ${new Date(o.created_at * 1000).toLocaleDateString()}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-nw-gold-light">{o.price_eur.toFixed(2)} €</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pay(o.reference)}
|
||||
disabled={busy !== null}
|
||||
className="nw-btn text-sm disabled:opacity-60"
|
||||
>
|
||||
{busy === o.reference ? t('redirecting') : t('pay')}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
|
||||
{error && <span className="red-form-response">{error}</span>}
|
||||
</div>
|
||||
<table className="max-center-table">
|
||||
<tbody>
|
||||
{orders.map((o) => (
|
||||
<tr key={o.id} className="info-box-light">
|
||||
<td className="separate">
|
||||
<span className="yellow-info">{o.product_name}</span>
|
||||
<br />
|
||||
<span className="third-brown small-font">
|
||||
{t('reference')}: {o.reference}
|
||||
{o.created_at ? ` · ${new Date(o.created_at * 1000).toLocaleDateString()}` : ''}
|
||||
</span>
|
||||
</td>
|
||||
<td className="real-info-box">
|
||||
<span className="yellow-info">{o.price_eur.toFixed(2)} €</span>
|
||||
</td>
|
||||
<td className="real-info-box">
|
||||
<button type="button" onClick={() => pay(o.reference)} disabled={busy !== null}>
|
||||
{busy === o.reference ? t('redirecting') : t('pay')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,10 +9,11 @@ interface Props {
|
||||
characters: string[]
|
||||
endpoint: string
|
||||
actionLabel: string
|
||||
buttonClass?: string
|
||||
}
|
||||
|
||||
/** Formulario reutilizable para acciones de personaje por SOAP (revive, unstuck...). */
|
||||
export function CharacterActionForm({ characters, endpoint, actionLabel }: Props) {
|
||||
export function CharacterActionForm({ characters, endpoint, actionLabel, buttonClass = '' }: Props) {
|
||||
const t = useTranslations('Services')
|
||||
const [character, setCharacter] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -44,18 +45,15 @@ export function CharacterActionForm({ characters, endpoint, actionLabel }: Props
|
||||
}
|
||||
|
||||
if (characters.length === 0) {
|
||||
return <p className="text-amber-200/70">{t('noCharactersYet')}</p>
|
||||
return <p className="second-brown">{t('noCharactersYet')}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm text-center">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<select
|
||||
value={character}
|
||||
onChange={(e) => setCharacter(e.target.value)}
|
||||
required
|
||||
className="nw-input"
|
||||
>
|
||||
<div className="centered">
|
||||
<p>{t('choose')}</p>
|
||||
<br />
|
||||
<form onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<select value={character} onChange={(e) => setCharacter(e.target.value)} required>
|
||||
<option value="" disabled>
|
||||
{t('selectCharacter')}
|
||||
</option>
|
||||
@@ -65,15 +63,17 @@ export function CharacterActionForm({ characters, endpoint, actionLabel }: Props
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !character}
|
||||
className="w-full nw-btn disabled:opacity-60"
|
||||
>
|
||||
<br />
|
||||
<button type="submit" className={buttonClass} disabled={busy || !character}>
|
||||
{busy ? t('processing') : actionLabel}
|
||||
</button>
|
||||
</form>
|
||||
{message && <p className={`mt-3 ${message.ok ? 'text-green-400' : 'text-red-400'}`}>{message.text}</p>}
|
||||
<hr />
|
||||
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && (
|
||||
<span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,21 +38,19 @@ export function ConfirmClient({
|
||||
}, [hash, endpoint])
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="centered">
|
||||
{state === 'loading' && <p>{t('activating')}</p>}
|
||||
{state === 'ok' && (
|
||||
<>
|
||||
<p className="text-green-400">{t('success')}</p>
|
||||
<span className="ok-form-response">{t('success')}</span>
|
||||
{showLogin && (
|
||||
<p className="mt-4">
|
||||
<Link href="/login" className="text-sky-400 underline">
|
||||
{t('goLogin')}
|
||||
</Link>
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<Link href="/login">{t('goLogin')}</Link>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{state === 'error' && <p className="text-red-400">{t('error')}</p>}
|
||||
{state === 'error' && <span className="red-form-response">{t('error')}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,76 +1,44 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
const SERVER_NAME = 'NovaWoW'
|
||||
|
||||
const SOCIALS: { name: string; href: string; path: string }[] = [
|
||||
{
|
||||
name: 'Discord',
|
||||
href: 'https://discord.gg/novawow',
|
||||
path: 'M20.317 4.369a19.79 19.79 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.211.375-.445.865-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.6 12.6 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.1 13.1 0 0 1-1.872-.892.077.077 0 0 1-.008-.128c.126-.094.252-.192.372-.291a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.009c.12.099.246.198.373.292a.077.077 0 0 1-.006.127 12.3 12.3 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.84 19.84 0 0 0 6.002-3.03.077.077 0 0 0 .032-.056c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.028ZM8.02 15.331c-1.183 0-2.157-1.086-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.332-.956 2.418-2.157 2.418Zm7.975 0c-1.183 0-2.157-1.086-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.332-.946 2.418-2.157 2.418Z',
|
||||
},
|
||||
{
|
||||
name: 'YouTube',
|
||||
href: 'https://www.youtube.com/channel/UCaHL8BZcho8AkeM9wckQbyg',
|
||||
path: 'M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814ZM9.545 15.568V8.432L15.818 12l-6.273 3.568Z',
|
||||
},
|
||||
{
|
||||
name: 'Facebook',
|
||||
href: 'https://www.facebook.com/NovaWoW/',
|
||||
path: 'M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073Z',
|
||||
},
|
||||
{
|
||||
name: 'Instagram',
|
||||
href: 'https://www.instagram.com/NovaWoW/',
|
||||
path: 'M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069ZM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0Zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324ZM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881Z',
|
||||
},
|
||||
]
|
||||
/**
|
||||
* Pie de página — réplica del partial Django `partials/footer.html`
|
||||
* (enlaces legales + copyright + crédito de diseño) con las clases del tema real.
|
||||
*/
|
||||
export function Footer() {
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
export async function Footer() {
|
||||
const t = await getTranslations('Nav')
|
||||
return (
|
||||
<footer className="mt-16 border-t border-nw-border/60 bg-nw-bg/60">
|
||||
<div className="mx-auto flex max-w-5xl flex-col items-center gap-5 px-4 py-8 text-sm">
|
||||
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between sm:self-stretch">
|
||||
<div className="flex items-center gap-2 text-nw-muted">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/brand/logo.webp" alt="Nova WoW" className="h-7 w-auto opacity-80" />
|
||||
<span>© 2026 Nova WoW · WotLK Classic 3.4.3</span>
|
||||
</div>
|
||||
<nav className="flex flex-wrap items-center justify-center gap-4 text-nw-muted">
|
||||
<Link href="/" className="hover:text-nw-gold-light">
|
||||
{t('home')}
|
||||
</Link>
|
||||
<Link href="/forum" className="hover:text-nw-gold-light">
|
||||
{t('forum')}
|
||||
</Link>
|
||||
<Link href="/vote-points" className="hover:text-nw-gold-light">
|
||||
{t('vote')}
|
||||
</Link>
|
||||
<Link href="/recruit" className="hover:text-nw-gold-light">
|
||||
{t('recruit')}
|
||||
</Link>
|
||||
<Link href="/register" className="hover:text-nw-gold-light">
|
||||
{t('register')}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-5">
|
||||
{SOCIALS.map((s) => (
|
||||
<a
|
||||
key={s.name}
|
||||
href={s.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={s.name}
|
||||
title={s.name}
|
||||
className="text-nw-muted transition-colors hover:text-nw-gold-light"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5" aria-hidden="true">
|
||||
<path d={s.path} />
|
||||
</svg>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<p>
|
||||
<a href="/terms-and-conditions">Términos y Condiciones</a>
|
||||
<span className="third-brown">
|
||||
<i className="fas fa-grip-lines-vertical"></i> {' '}
|
||||
</span>
|
||||
<a href="/privacy-policy">Política de Privacidad</a>
|
||||
<span className="third-brown">
|
||||
<i className="fas fa-grip-lines-vertical"></i> {' '}
|
||||
</span>
|
||||
<a href="/refund-policy">Política de Reembolso</a>
|
||||
<span className="third-brown">
|
||||
<i className="fas fa-grip-lines-vertical"></i> {' '}
|
||||
</span>
|
||||
<a href="/cookies">Declaración de Cookies</a>
|
||||
<span className="third-brown">
|
||||
<i className="fas fa-grip-lines-vertical"></i> {' '}
|
||||
</span>
|
||||
<a href="/legal-notice">Aviso legal</a>
|
||||
<span className="third-brown">
|
||||
<i className="fas fa-grip-lines-vertical"></i> {' '}
|
||||
</span>
|
||||
<a href="/contact-us">Contáctanos</a>
|
||||
</p>
|
||||
<br />
|
||||
<p className="third-brown">
|
||||
© Copyright {SERVER_NAME}™ {year}. Todos los derechos reservados
|
||||
</p>
|
||||
<p className="third-brown">
|
||||
Diseño: "{SERVER_NAME}" Por Inna Hoover Brown <i className="fas fa-cat"></i>
|
||||
</p>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,15 +17,14 @@ export function ForumSearchBox({ initial = '' }: { initial?: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="mb-6 flex gap-2">
|
||||
<form onSubmit={submit} className="centered separate">
|
||||
<input
|
||||
type="search"
|
||||
type="text"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
className="nw-input flex-1"
|
||||
/>
|
||||
<button type="submit" className="nw-btn whitespace-nowrap">
|
||||
/>{' '}
|
||||
<button type="submit" className="search-items-button">
|
||||
{t('search')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -36,17 +36,17 @@ export function GoldForm({ characters, options }: { characters: string[]; option
|
||||
}
|
||||
}
|
||||
|
||||
const field = 'nw-input'
|
||||
if (characters.length === 0) return <p className="text-amber-200/70">{t('noCharactersYet')}</p>
|
||||
if (characters.length === 0) return <p className="second-brown">{t('noCharactersYet')}</p>
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm text-center">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<select value={character} onChange={(e) => setCharacter(e.target.value)} required className={field}>
|
||||
<div className="centered">
|
||||
<form onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<select value={character} onChange={(e) => setCharacter(e.target.value)} required>
|
||||
<option value="" disabled>{t('selectCharacter')}</option>
|
||||
{characters.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<select value={amount} onChange={(e) => setAmount(e.target.value)} required className={field}>
|
||||
<br />
|
||||
<select value={amount} onChange={(e) => setAmount(e.target.value)} required>
|
||||
<option value="" disabled>{tp('gold.selectAmount')}</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.gold_amount} value={o.gold_amount}>
|
||||
@@ -54,11 +54,15 @@ export function GoldForm({ characters, options }: { characters: string[]; option
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" disabled={busy || !character || !amount} className="w-full nw-btn disabled:opacity-60">
|
||||
<br />
|
||||
<button type="submit" className="gold-button" disabled={busy || !character || !amount}>
|
||||
{busy ? t('processing') : tp('gold.buy')}
|
||||
</button>
|
||||
</form>
|
||||
{error && <p className="mt-3 text-red-400">{error}</p>}
|
||||
<hr />
|
||||
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
|
||||
{error && <span className="red-form-response">{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,58 +1,11 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { LanguageSwitcher } from './LanguageSwitcher'
|
||||
import { LogoutButton } from './LogoutButton'
|
||||
import { SiteHeader } from './SiteHeader'
|
||||
|
||||
export async function Header() {
|
||||
const t = await getTranslations('Nav')
|
||||
const session = await getSession()
|
||||
const loggedIn = Boolean(session.username)
|
||||
// Django muestra bnet_email si existe, si no el username.
|
||||
const accountLabel = session.bnetEmail || session.username || ''
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-nw-border/60 bg-nw-bg/80 backdrop-blur supports-[backdrop-filter]:bg-nw-bg/60">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-3">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/brand/logo.webp" alt="Nova WoW" className="h-9 w-auto" />
|
||||
<span className="nw-heading text-xl">Nova WoW</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4 text-sm">
|
||||
<Link href="/" className="text-amber-200 hover:text-amber-400">
|
||||
{t('home')}
|
||||
</Link>
|
||||
<Link href="/forum" className="text-amber-200 hover:text-amber-400">
|
||||
{t('forum')}
|
||||
</Link>
|
||||
<Link href="/vote-points" className="text-amber-200 hover:text-amber-400">
|
||||
{t('vote')}
|
||||
</Link>
|
||||
{loggedIn ? (
|
||||
<>
|
||||
<Link href="/battlepay" className="text-amber-200 hover:text-amber-400">
|
||||
{t('store')}
|
||||
</Link>
|
||||
<Link href="/account" className="text-amber-200 hover:text-amber-400">
|
||||
{t('account')}
|
||||
</Link>
|
||||
<LogoutButton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login" className="text-amber-200 hover:text-amber-400">
|
||||
{t('login')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="rounded bg-amber-600 px-3 py-1 font-semibold text-[#1b120b] hover:bg-amber-500"
|
||||
>
|
||||
{t('register')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<LanguageSwitcher />
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
return <SiteHeader loggedIn={loggedIn} accountLabel={accountLabel} />
|
||||
}
|
||||
|
||||
@@ -37,16 +37,23 @@ export function NewTopicForm({ forumId }: { forumId: number }) {
|
||||
}
|
||||
}
|
||||
|
||||
const field = 'nw-input'
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-3 nw-card">
|
||||
<h3 className="font-semibold text-amber-400">{t('newTopic')}</h3>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={t('newTopicName')} maxLength={200} className={field} />
|
||||
<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} />
|
||||
<button type="submit" disabled={busy} className="nw-btn disabled:opacity-60">
|
||||
<br />
|
||||
<button type="submit" disabled={busy}>
|
||||
{busy ? t('publishing') : t('publish')}
|
||||
</button>
|
||||
{error && <p className="text-red-400">{error}</p>}
|
||||
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
|
||||
{error && <span className="red-form-response">{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Envoltorio de página con la estructura EXACTA del tema Django:
|
||||
* main-page > middle-content > body-content > title-content <h1>.
|
||||
* Los hijos aportan los bloques `box-content` / `body-box-content`.
|
||||
*/
|
||||
export function PageShell({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="main-page">
|
||||
<div className="middle-content">
|
||||
<div className="body-content">
|
||||
<div className="title-content">
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,26 +23,21 @@ export async function Pagination({
|
||||
const pages: number[] = []
|
||||
for (let p = from; p <= to; p++) pages.push(p)
|
||||
|
||||
const cls = (active: boolean) =>
|
||||
`inline-flex min-w-9 items-center justify-center rounded border px-2.5 py-1 text-sm ${
|
||||
active
|
||||
? 'border-nw-gold bg-nw-gold/15 font-semibold text-nw-gold-light'
|
||||
: 'border-nw-border/60 text-nw-muted hover:border-nw-gold/60 hover:text-nw-gold-light'
|
||||
}`
|
||||
const cls = (active: boolean) => `nw-page-link${active ? ' nw-page-current' : ''}`
|
||||
|
||||
return (
|
||||
<nav className="mt-6 flex flex-wrap items-center justify-center gap-1" aria-label="pagination">
|
||||
<nav className="centered separate" aria-label="pagination">
|
||||
{page > 1 && (
|
||||
<Link href={hrefFor(page - 1)} className={cls(false)}>
|
||||
<Link href={hrefFor(page - 1)} className="nw-page-link">
|
||||
← {t('prev')}
|
||||
</Link>
|
||||
)}
|
||||
{from > 1 && (
|
||||
<>
|
||||
<Link href={hrefFor(1)} className={cls(false)}>
|
||||
<Link href={hrefFor(1)} className="nw-page-link">
|
||||
1
|
||||
</Link>
|
||||
{from > 2 && <span className="px-1 text-nw-muted">…</span>}
|
||||
{from > 2 && <span className="third-brown">…</span>}
|
||||
</>
|
||||
)}
|
||||
{pages.map((p) => (
|
||||
@@ -52,14 +47,14 @@ export async function Pagination({
|
||||
))}
|
||||
{to < totalPages && (
|
||||
<>
|
||||
{to < totalPages - 1 && <span className="px-1 text-nw-muted">…</span>}
|
||||
<Link href={hrefFor(totalPages)} className={cls(false)}>
|
||||
{to < totalPages - 1 && <span className="third-brown">…</span>}
|
||||
<Link href={hrefFor(totalPages)} className="nw-page-link">
|
||||
{totalPages}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{page < totalPages && (
|
||||
<Link href={hrefFor(page + 1)} className={cls(false)}>
|
||||
<Link href={hrefFor(page + 1)} className="nw-page-link">
|
||||
{t('next')} →
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -7,10 +7,11 @@ interface Props {
|
||||
characters: string[]
|
||||
checkoutEndpoint: string
|
||||
payLabel: string // ya formateado con el precio
|
||||
buttonClass?: string
|
||||
}
|
||||
|
||||
/** Selector de personaje + botón de pago. Redirige al Checkout de Stripe. */
|
||||
export function PaidServiceForm({ characters, checkoutEndpoint, payLabel }: Props) {
|
||||
export function PaidServiceForm({ characters, checkoutEndpoint, payLabel, buttonClass = '' }: Props) {
|
||||
const t = useTranslations('Services')
|
||||
const [character, setCharacter] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -42,18 +43,15 @@ export function PaidServiceForm({ characters, checkoutEndpoint, payLabel }: Prop
|
||||
}
|
||||
|
||||
if (characters.length === 0) {
|
||||
return <p className="text-amber-200/70">{t('noCharactersYet')}</p>
|
||||
return <p className="second-brown">{t('noCharactersYet')}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm text-center">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<select
|
||||
value={character}
|
||||
onChange={(e) => setCharacter(e.target.value)}
|
||||
required
|
||||
className="nw-input"
|
||||
>
|
||||
<div className="centered">
|
||||
<p>{t('choose')}</p>
|
||||
<br />
|
||||
<form onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<select value={character} onChange={(e) => setCharacter(e.target.value)} required>
|
||||
<option value="" disabled>
|
||||
{t('selectCharacter')}
|
||||
</option>
|
||||
@@ -63,15 +61,15 @@ export function PaidServiceForm({ characters, checkoutEndpoint, payLabel }: Prop
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !character}
|
||||
className="w-full nw-btn disabled:opacity-60"
|
||||
>
|
||||
<br />
|
||||
<button type="submit" className={buttonClass} disabled={busy || !character}>
|
||||
{busy ? t('processing') : payLabel}
|
||||
</button>
|
||||
</form>
|
||||
{error && <p className="mt-3 text-red-400">{error}</p>}
|
||||
<hr />
|
||||
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
|
||||
{error && <span className="red-form-response">{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,45 +89,45 @@ export function PostActions({
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="separate">
|
||||
<RichTextArea value={text} onChange={setText} rows={4} />
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={save} disabled={busy} className="nw-btn text-sm disabled:opacity-60">
|
||||
{busy ? t('sending') : t('save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditing(false); setText(initialText); setError(null) }}
|
||||
className="nw-btn-ghost text-sm"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
<br />
|
||||
<button type="button" onClick={save} disabled={busy} className="nw-tool-btn">
|
||||
{busy ? t('sending') : t('save')}
|
||||
</button>{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditing(false); setText(initialText); setError(null) }}
|
||||
className="nw-tool-btn"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
{error && <p className="red-info2 small-font">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (deleted) {
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-3 text-xs">
|
||||
<button type="button" onClick={restore} disabled={busy} className="text-green-400 hover:text-green-300 disabled:opacity-60">
|
||||
<div className="separate small-font">
|
||||
<button type="button" onClick={restore} disabled={busy} className="nw-tool-btn green-info">
|
||||
{t('restore')}
|
||||
</button>
|
||||
{error && <span className="text-red-400">{error}</span>}
|
||||
{error && <span className="red-info2"> {error}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-3 text-xs">
|
||||
<button type="button" onClick={() => setEditing(true)} className="text-nw-muted hover:text-nw-gold-light">
|
||||
<div className="separate small-font">
|
||||
<a href="javascript:void(0);" onClick={() => setEditing(true)} className="second-brown">
|
||||
{t('edit')}
|
||||
</button>
|
||||
<button type="button" onClick={remove} disabled={busy} className="text-nw-muted hover:text-red-400 disabled:opacity-60">
|
||||
</a>
|
||||
{' '}
|
||||
<a href="javascript:void(0);" onClick={() => { if (!busy) remove() }} className="second-brown">
|
||||
{t('delete')}
|
||||
</button>
|
||||
{error && <span className="text-red-400">{error}</span>}
|
||||
</a>
|
||||
{error && <span className="red-info2"> {error}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,59 +43,59 @@ export function RecruitClaim({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-wrap items-center gap-3 nw-card">
|
||||
<p className="text-sm text-nw-muted">
|
||||
{t('level80Friends')}: <span className="font-semibold text-nw-gold-light">{level80Count}</span>
|
||||
<div className="info-box-light separate">
|
||||
<p className="second-brown">
|
||||
{t('level80Friends')}: <span className="yellow-info">{level80Count}</span>
|
||||
</p>
|
||||
{characters.length > 0 ? (
|
||||
<label className="ml-auto text-sm">
|
||||
<span className="mr-2 text-nw-muted">{t('deliverTo')}</span>
|
||||
<select value={character} onChange={(e) => setCharacter(e.target.value)} className="nw-input inline-block w-auto">
|
||||
<p>
|
||||
<span className="second-brown">{t('deliverTo')} </span>
|
||||
<select value={character} onChange={(e) => setCharacter(e.target.value)}>
|
||||
{characters.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
) : (
|
||||
<p className="ml-auto text-sm text-amber-200/60">{t('noCharacters')}</p>
|
||||
<p className="second-brown">{t('noCharacters')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg && <p className={`mb-4 ${msg.ok ? 'text-green-400' : 'text-red-400'}`}>{msg.text}</p>}
|
||||
<div className="alert-message" style={{ display: msg ? 'block' : 'none' }}>
|
||||
{msg && <span className={msg.ok ? 'ok-form-response' : 'red-form-response'}>{msg.text}</span>}
|
||||
</div>
|
||||
|
||||
{rewards.length === 0 ? (
|
||||
<p className="text-amber-200/70">{t('noRewards')}</p>
|
||||
<p className="second-brown">{t('noRewards')}</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="centered">
|
||||
{rewards.map((r) => {
|
||||
const eligible = level80Count >= r.required_friends
|
||||
return (
|
||||
<div key={r.id} className="flex flex-col justify-between nw-card">
|
||||
<div>
|
||||
<a href={r.item_link} target="_blank" rel="noopener noreferrer" className="font-semibold text-amber-300 hover:text-amber-400">
|
||||
{r.reward_name}
|
||||
</a>
|
||||
<p className="mt-1 text-xs text-amber-200/60">
|
||||
{t('requires', { n: r.required_friends })} · item {r.item_id} ×{r.item_quantity}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
{r.claimed ? (
|
||||
<span className="text-sm text-green-400">✓ {t('claimed')}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => claim(r)}
|
||||
disabled={!eligible || busyId !== null || characters.length === 0}
|
||||
className="nw-btn text-sm disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={!eligible ? t('notEnoughFriends') : undefined}
|
||||
>
|
||||
{busyId === r.id ? t('claiming') : eligible ? t('claim') : t('locked')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div key={r.id} className="raf-box">
|
||||
<a href={r.item_link} target="_blank" rel="noopener noreferrer" className="yellow-info">
|
||||
{r.reward_name}
|
||||
</a>
|
||||
<p className="second-brown small-font">
|
||||
{t('requires', { n: r.required_friends })} · item {r.item_id} ×{r.item_quantity}
|
||||
</p>
|
||||
<br />
|
||||
{r.claimed ? (
|
||||
<span className="green-info">
|
||||
<i className="fas fa-check"></i> {t('claimed')}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => claim(r)}
|
||||
disabled={!eligible || busyId !== null || characters.length === 0}
|
||||
title={!eligible ? t('notEnoughFriends') : undefined}
|
||||
>
|
||||
{busyId === r.id ? t('claiming') : eligible ? t('claim') : t('locked')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -39,12 +39,15 @@ export function ReplyForm({ topicId }: { topicId: number }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-3">
|
||||
<form onSubmit={handleSubmit} className="separate2">
|
||||
<RichTextArea value={text} onChange={setText} placeholder={t('replyText')} rows={3} />
|
||||
<button type="submit" disabled={busy} className="nw-btn disabled:opacity-60">
|
||||
<br />
|
||||
<button type="submit" disabled={busy}>
|
||||
{busy ? t('sending') : t('sendReply')}
|
||||
</button>
|
||||
{error && <p className="text-red-400">{error}</p>}
|
||||
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
|
||||
{error && <span className="red-form-response">{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,44 +43,41 @@ export function RichTextArea({
|
||||
surround(`<a href="${url.replace(/"/g, '"')}" target="_blank">`, '</a>', t('linkText'))
|
||||
}
|
||||
|
||||
const btn =
|
||||
'rounded border border-nw-border/60 px-2 py-1 text-xs text-nw-muted hover:border-nw-gold/60 hover:text-nw-gold-light'
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button type="button" onClick={() => surround('<strong>', '</strong>')} className={`${btn} font-bold`} title={t('bold')}>
|
||||
B
|
||||
</button>
|
||||
<button type="button" onClick={() => surround('<em>', '</em>')} className={`${btn} italic`} title={t('italic')}>
|
||||
I
|
||||
</button>
|
||||
<button type="button" onClick={() => surround('<u>', '</u>')} className={`${btn} underline`} title={t('underline')}>
|
||||
U
|
||||
</button>
|
||||
<button type="button" onClick={() => surround('<s>', '</s>')} className={`${btn} line-through`} title={t('strike')}>
|
||||
S
|
||||
</button>
|
||||
<button type="button" onClick={insertLink} className={btn} title={t('link')}>
|
||||
<div>
|
||||
<div className="lefted">
|
||||
<button type="button" onClick={() => surround('<strong>', '</strong>')} className="nw-tool-btn" title={t('bold')}>
|
||||
<b>B</b>
|
||||
</button>{' '}
|
||||
<button type="button" onClick={() => surround('<em>', '</em>')} className="nw-tool-btn" title={t('italic')}>
|
||||
<i>I</i>
|
||||
</button>{' '}
|
||||
<button type="button" onClick={() => surround('<u>', '</u>')} className="nw-tool-btn" title={t('underline')}>
|
||||
<u>U</u>
|
||||
</button>{' '}
|
||||
<button type="button" onClick={() => surround('<s>', '</s>')} className="nw-tool-btn" title={t('strike')}>
|
||||
<s>S</s>
|
||||
</button>{' '}
|
||||
<button type="button" onClick={insertLink} className="nw-tool-btn" title={t('link')}>
|
||||
🔗
|
||||
</button>
|
||||
<button type="button" onClick={() => surround('<blockquote>', '</blockquote>', t('quoteText'))} className={btn} title={t('quote')}>
|
||||
</button>{' '}
|
||||
<button type="button" onClick={() => surround('<blockquote>', '</blockquote>', t('quoteText'))} className="nw-tool-btn" title={t('quote')}>
|
||||
❝
|
||||
</button>
|
||||
<button type="button" onClick={() => surround('<ul>\n<li>', '</li>\n</ul>', t('listItem'))} className={btn} title={t('list')}>
|
||||
</button>{' '}
|
||||
<button type="button" onClick={() => surround('<ul>\n<li>', '</li>\n</ul>', t('listItem'))} className="nw-tool-btn" title={t('list')}>
|
||||
•
|
||||
</button>
|
||||
<button type="button" onClick={() => surround('<code>', '</code>')} className={`${btn} font-mono`} title={t('code')}>
|
||||
</button>{' '}
|
||||
<button type="button" onClick={() => surround('<code>', '</code>')} className="nw-tool-btn" title={t('code')}>
|
||||
{'</>'}
|
||||
</button>
|
||||
</div>
|
||||
<br />
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className="nw-input"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/** Reloj en vivo del estado del servidor (equivale a startTime()/useClock de Django). */
|
||||
export function ServerClock() {
|
||||
const [time, setTime] = useState('')
|
||||
useEffect(() => {
|
||||
const tick = () => setTime(new Date().toLocaleTimeString('es-ES'))
|
||||
tick()
|
||||
const id = setInterval(tick, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
return (
|
||||
<span id="server-time" suppressHydrationWarning>
|
||||
{time}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
|
||||
/**
|
||||
* Caja de servicio con la estructura EXACTA de Django: title-box-content con
|
||||
* <h2> + enlaces "back-to-account" / "back-to-account-responsive", y el cuerpo
|
||||
* en body-box-content justified. Se usa dentro de <PageShell>.
|
||||
*/
|
||||
export async function ServiceBox({
|
||||
heading,
|
||||
children,
|
||||
}: {
|
||||
heading?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const t = await getTranslations('Services')
|
||||
return (
|
||||
<div className="box-content">
|
||||
<div className="title-box-content">
|
||||
<h2>{heading ?? t('info')}</h2>
|
||||
<Link className="back-to-account" href="/account">
|
||||
{t('back')}
|
||||
</Link>
|
||||
<Link className="back-to-account-responsive" href="/account">
|
||||
{t('backShort')}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="body-box-content justified">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getGameCharacters } from '@/lib/characters'
|
||||
import { getPaidService } from '@/lib/paid-services'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ServiceBox } from '@/components/ServiceBox'
|
||||
import { PaidServiceForm } from '@/components/PaidServiceForm'
|
||||
|
||||
/** Contenido común de una página de servicio de pago (guardas + form). */
|
||||
@@ -20,13 +22,15 @@ export async function ServicePageContent({ service, locale }: { service: string;
|
||||
const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), cfg!.price({})])
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t(`${service}.title`)}</h1>
|
||||
<PaidServiceForm
|
||||
characters={chars.map((c) => c.name)}
|
||||
checkoutEndpoint={`/api/character/${service}/checkout`}
|
||||
payLabel={t(`${service}.pay`, { price })}
|
||||
/>
|
||||
</main>
|
||||
<PageShell title={t(`${service}.title`)}>
|
||||
<ServiceBox>
|
||||
<PaidServiceForm
|
||||
characters={chars.map((c) => c.name)}
|
||||
checkoutEndpoint={`/api/character/${service}/checkout`}
|
||||
payLabel={t(`${service}.pay`, { price })}
|
||||
buttonClass={`${service}-button`}
|
||||
/>
|
||||
</ServiceBox>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { useRouter } from '@/i18n/navigation'
|
||||
|
||||
const SERVER_NAME = 'NovaWoW'
|
||||
|
||||
/**
|
||||
* Cabecera / barra de navegación — réplica EXACTA del partial Django
|
||||
* `home/templates/partials/header.html` con las clases del tema real
|
||||
* (nav-wrapper, nav-bar, nav-dropdown...). Los enlaces internos que ya
|
||||
* existen en web-next usan <Link> (next-intl); el resto se mantiene igual
|
||||
* que en Django. El toggle móvil replica `nwNavBar()` de nw-scripts.js.
|
||||
*/
|
||||
export function SiteHeader({
|
||||
loggedIn,
|
||||
accountLabel,
|
||||
}: {
|
||||
loggedIn: boolean
|
||||
accountLabel: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
async function logout(e: React.MouseEvent) {
|
||||
e.preventDefault()
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' })
|
||||
router.push('/')
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<header>
|
||||
<div className="nav-wrapper">
|
||||
<div className={open ? 'nav-bar responsive' : 'nav-bar'} id="nw-nav-bar">
|
||||
<a
|
||||
href="javascript:void(0);"
|
||||
className={open ? 'icon change' : 'icon'}
|
||||
id="menu-icon"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setOpen((v) => !v)
|
||||
}}
|
||||
>
|
||||
<div className="bar1"></div>
|
||||
<div className="bar2"></div>
|
||||
<div className="bar3"></div>
|
||||
</a>
|
||||
|
||||
{loggedIn ? (
|
||||
<Link href="/">INICIO</Link>
|
||||
) : (
|
||||
<Link href="/register">CREAR CUENTA</Link>
|
||||
)}
|
||||
|
||||
<div className="nav-dropdown">
|
||||
<div className="nav-dropdown-btn">
|
||||
DESCARGAS <i className="fas fa-caret-down"></i>
|
||||
</div>
|
||||
<div className="nav-dropdown-content">
|
||||
<p>
|
||||
<a href="/download-client">CLIENTE</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="/download-addons">ADDONS</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="nav-dropdown">
|
||||
<div className="nav-dropdown-btn">
|
||||
{SERVER_NAME} <i className="fas fa-caret-down"></i>
|
||||
</div>
|
||||
<div className="nav-dropdown-content">
|
||||
<p>
|
||||
<a
|
||||
href="https://foro.novawow.com/topic/26596-changelog-2024/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
CHANGELOG
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="/novawow-realm">REINO</a>
|
||||
</p>
|
||||
{loggedIn && (
|
||||
<p>
|
||||
<a href="/novawow-players">JUGADORES</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="nav-dropdown">
|
||||
<div className="nav-dropdown-btn">
|
||||
COMUNIDAD <i className="fas fa-caret-down"></i>
|
||||
</div>
|
||||
<div className="nav-dropdown-content">
|
||||
<p>
|
||||
<Link href="/forum">FOROS</Link>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://wotlk.novawow.com" target="_blank" rel="noopener noreferrer">
|
||||
WOTLK DB
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="/content-creators">VIDEOS</a>
|
||||
</p>
|
||||
{loggedIn && (
|
||||
<p>
|
||||
<a href="/help">AYUDA</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loggedIn ? (
|
||||
<div className="nav-dropdown">
|
||||
<div className="nav-dropdown-btn">
|
||||
{accountLabel} <i className="fas fa-caret-down"></i>
|
||||
</div>
|
||||
<div className="nav-dropdown-content">
|
||||
<p>
|
||||
<Link href="/account">MI CUENTA</Link>
|
||||
</p>
|
||||
<p>
|
||||
<a href="javascript:void(0);" onClick={logout}>
|
||||
DESCONECTAR
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Link href="/login" className="last">
|
||||
CONECTAR
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* eslint-disable @next/next/no-img-element */}
|
||||
<a href="/">
|
||||
<img
|
||||
className="nw-long-logo"
|
||||
src="/nw-themes/nw-ryu/nw-images/nw-logos/nw-long-logo.webp"
|
||||
alt={SERVER_NAME}
|
||||
title={SERVER_NAME}
|
||||
/>
|
||||
</a>
|
||||
<a href="javascript:void(0);">
|
||||
<img
|
||||
className="nw-logo"
|
||||
src="/nw-themes/nw-ryu/nw-images/nw-logos/nw-logo.webp"
|
||||
alt="NW"
|
||||
title="NW"
|
||||
/>
|
||||
</a>
|
||||
{/* eslint-enable @next/next/no-img-element */}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Barra de redes sociales — réplica del partial Django `partials/social.html`
|
||||
* con las clases del tema real (social-media, social-button + marca).
|
||||
*/
|
||||
export function Social() {
|
||||
return (
|
||||
<div className="social-media">
|
||||
<span className="big-font">SÍGUENOS EN</span>
|
||||
<a
|
||||
href="https://www.facebook.com/NovaWoW/"
|
||||
className="social-button social-button-responsive facebook"
|
||||
target="_blank"
|
||||
title="Facebook NovaWoW"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="social-inline">
|
||||
<i className="fab fa-facebook-f"></i>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.instagram.com/NovaWoW/"
|
||||
className="social-button social-button-responsive instagram"
|
||||
target="_blank"
|
||||
title="Instagram NovaWoW"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="social-inline">
|
||||
<i className="fab fa-instagram"></i>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="https://twitter.com/NovaWoW"
|
||||
className="social-button social-button-responsive twitter"
|
||||
target="_blank"
|
||||
title="Twitter NovaWoW"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="social-inline">
|
||||
<i className="fab fa-x-twitter"></i>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.youtube.com/channel/UCaHL8BZcho8AkeM9wckQbyg"
|
||||
className="social-button social-button-responsive youtube"
|
||||
target="_blank"
|
||||
title="Youtube NovaWoW"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="social-inline">
|
||||
<i className="fab fa-youtube"></i>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.novawow.com"
|
||||
className="social-button social-button-responsive discord"
|
||||
target="_blank"
|
||||
title="Discord NovaWoW"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="social-inline">
|
||||
<i className="fab fa-discord"></i>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -47,14 +47,9 @@ export function TopicModBar({
|
||||
|
||||
if (deleted) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap items-center gap-2 rounded-lg border border-red-900/60 bg-red-950/20 px-3 py-2 text-sm">
|
||||
<span className="mr-1 font-semibold text-red-400">{t('deletedMark')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => act('restore')}
|
||||
disabled={busy}
|
||||
className="text-xs text-green-400 hover:text-green-300 disabled:opacity-60"
|
||||
>
|
||||
<div className="info-box-light separate">
|
||||
<span className="red-info2">{t('deletedMark')}</span>{' '}
|
||||
<button type="button" onClick={() => act('restore')} disabled={busy} className="nw-tool-btn green-info">
|
||||
{t('restoreTopic')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -62,24 +57,14 @@ export function TopicModBar({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap items-center gap-2 rounded-lg border border-nw-border/60 bg-nw-panel-2/40 px-3 py-2 text-sm">
|
||||
<span className="mr-1 font-semibold text-nw-gold-light">{t('moderation')}:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => act(locked ? 'unlock' : 'lock')}
|
||||
disabled={busy}
|
||||
className="nw-btn-ghost text-xs disabled:opacity-60"
|
||||
>
|
||||
<div className="info-box-light separate">
|
||||
<span className="yellow-info">{t('moderation')}:</span>{' '}
|
||||
<button type="button" onClick={() => act(locked ? 'unlock' : 'lock')} disabled={busy} className="nw-tool-btn">
|
||||
{locked ? t('unlock') : t('lock')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => act(sticky ? 'unsticky' : 'sticky')}
|
||||
disabled={busy}
|
||||
className="nw-btn-ghost text-xs disabled:opacity-60"
|
||||
>
|
||||
</button>{' '}
|
||||
<button type="button" onClick={() => act(sticky ? 'unsticky' : 'sticky')} disabled={busy} className="nw-tool-btn">
|
||||
{sticky ? t('unpin') : t('pin')}
|
||||
</button>
|
||||
</button>{' '}
|
||||
{moveForums.length > 0 && (
|
||||
<select
|
||||
defaultValue=""
|
||||
@@ -89,7 +74,7 @@ export function TopicModBar({
|
||||
e.target.value = ''
|
||||
if (forumId) act('move', { forumId })
|
||||
}}
|
||||
className="nw-input inline-block w-auto py-1 text-xs disabled:opacity-60"
|
||||
style={{ width: 'auto' }}
|
||||
aria-label={t('moveTopic')}
|
||||
>
|
||||
<option value="" disabled>
|
||||
@@ -101,13 +86,8 @@ export function TopicModBar({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => act('delete', undefined, t('confirmDeleteTopic'))}
|
||||
disabled={busy}
|
||||
className="text-xs text-red-400 hover:text-red-300 disabled:opacity-60"
|
||||
>
|
||||
)}{' '}
|
||||
<button type="button" onClick={() => act('delete', undefined, t('confirmDeleteTopic'))} disabled={busy} className="nw-tool-btn red-info2">
|
||||
{t('deleteTopic')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -35,22 +35,26 @@ export function TransferForm({ characters, price }: { characters: string[]; pric
|
||||
}
|
||||
}
|
||||
|
||||
const field = 'nw-input'
|
||||
if (characters.length === 0) return <p className="text-amber-200/70">{t('noCharactersYet')}</p>
|
||||
if (characters.length === 0) return <p className="second-brown">{t('noCharactersYet')}</p>
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm text-center">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<select value={character} onChange={(e) => setCharacter(e.target.value)} required className={field}>
|
||||
<div className="centered">
|
||||
<form onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<select value={character} onChange={(e) => setCharacter(e.target.value)} required>
|
||||
<option value="" disabled>{t('selectCharacter')}</option>
|
||||
{characters.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<input type="text" placeholder={tp('transfer.destination')} value={destination} onChange={(e) => setDestination(e.target.value)} required className={field} />
|
||||
<button type="submit" disabled={busy || !character || !destination.trim()} className="w-full nw-btn disabled:opacity-60">
|
||||
<br />
|
||||
<input type="text" placeholder={tp('transfer.destination')} value={destination} onChange={(e) => setDestination(e.target.value)} required />
|
||||
<br />
|
||||
<button type="submit" className="transfer-button" disabled={busy || !character || !destination.trim()}>
|
||||
{busy ? t('processing') : tp('transfer.pay', { price })}
|
||||
</button>
|
||||
</form>
|
||||
{error && <p className="mt-3 text-red-400">{error}</p>}
|
||||
<hr />
|
||||
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
|
||||
{error && <span className="red-form-response">{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const SERVER_NAME = 'NovaWoW'
|
||||
|
||||
/**
|
||||
* Bloque de vídeo de cabecera — réplica del partial Django `partials/video.html`
|
||||
* (logo principal + vídeo de fondo en bucle). Clases del tema real: div-nw-video,
|
||||
* nw-main-logo, nw-video.
|
||||
*/
|
||||
export function Video() {
|
||||
return (
|
||||
<div className="div-nw-video">
|
||||
<div className="nw-main-logo">
|
||||
<a href="/">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className="nw_main_logo_img"
|
||||
src="/nw-themes/nw-ryu/nw-images/nw-logos/novawow-main-logo-transparent.webp"
|
||||
alt={SERVER_NAME}
|
||||
title={SERVER_NAME}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<div id="live-stream-div">
|
||||
<div id="live-stream-announce">
|
||||
<a href="./#live-stream" id="live-stream-a">
|
||||
<i className="fas fa-video"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<video className="nw-video" autoPlay loop muted playsInline>
|
||||
<source src="/nw-themes/nw-ryu/nw-videos/nw-logo.mp4" />
|
||||
</video>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -37,37 +37,36 @@ export function VotePanel({ sites, canVote }: { sites: VoteSite[]; canVote: bool
|
||||
}
|
||||
}
|
||||
|
||||
if (sites.length === 0) return <p className="text-amber-200/70">{t('noSites')}</p>
|
||||
if (sites.length === 0) return <p className="second-brown">{t('noSites')}</p>
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
{sites.map((s) => (
|
||||
<div key={s.id} className="w-40 nw-card p-3 text-center">
|
||||
{s.image_url && <img src={s.image_url} alt={s.name} className="mx-auto mb-2 max-h-16" />}
|
||||
<p className="font-semibold text-amber-300">{s.name}</p>
|
||||
<p className="mb-2 text-xs text-amber-200/60">
|
||||
{s.points} {t('pv')}
|
||||
</p>
|
||||
{canVote ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => vote(s)}
|
||||
disabled={busyId !== null}
|
||||
className="w-full rounded bg-amber-600 px-3 py-1 text-sm font-semibold text-[#1b120b] disabled:opacity-60"
|
||||
>
|
||||
{busyId === s.id ? t('voting') : t('vote')}
|
||||
</button>
|
||||
) : (
|
||||
<a href={s.url} target="_blank" rel="noopener noreferrer" className="text-sm text-sky-400 hover:underline">
|
||||
{t('vote')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="centered">
|
||||
{sites.map((s) => (
|
||||
<div key={s.id} className="item-box">
|
||||
{s.image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="item-img-box" src={s.image_url} alt={s.name} />
|
||||
)}
|
||||
<p className="yellow-info">{s.name}</p>
|
||||
<p className="vp-color small-font">
|
||||
{s.points} {t('pv')}
|
||||
</p>
|
||||
{canVote ? (
|
||||
<button type="button" className="vote-button" onClick={() => vote(s)} disabled={busyId !== null}>
|
||||
{busyId === s.id ? t('voting') : t('vote')}
|
||||
</button>
|
||||
) : (
|
||||
<a href={s.url} target="_blank" rel="noopener noreferrer">
|
||||
{t('vote')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<hr />
|
||||
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && <span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>}
|
||||
</div>
|
||||
{message && <p className={`mt-4 text-center ${message.ok ? 'text-green-400' : 'text-red-400'}`}>{message.text}</p>}
|
||||
{!canVote && <p className="mt-4 text-center text-sm text-amber-200/60">{t('loginToVote')}</p>}
|
||||
</>
|
||||
{!canVote && <p className="second-brown small-font">{t('loginToVote')}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,9 @@
|
||||
"forgot": "I forgot my password/username",
|
||||
"newHere": "New to the community? You can create an account",
|
||||
"createAccount": "here",
|
||||
"captchaFailed": "Anti-bot verification failed. Please retry."
|
||||
"captchaFailed": "Anti-bot verification failed. Please retry.",
|
||||
"forgotActivation": "I did not receive the activation link",
|
||||
"publicNote": "* If you log in from a public place, remember to log out when you are not using the website or forum."
|
||||
},
|
||||
"Account": {
|
||||
"title": "My account",
|
||||
@@ -81,7 +83,25 @@
|
||||
"svcVote": "Vote (points)",
|
||||
"svcRecruit": "Recruit a friend",
|
||||
"svcStore": "Store (Battlepay)",
|
||||
"adminPanel": "Admin panel"
|
||||
"adminPanel": "Admin panel",
|
||||
"infoTitle": "Information",
|
||||
"toolsTitle": "Utilities & Tools",
|
||||
"adminPanelDesc": "Manage the server",
|
||||
"svcChangePasswordDesc": "Manage a new password",
|
||||
"svcChangeEmailDesc": "Manage an active email",
|
||||
"svcTokenDesc": "Extra security for your account",
|
||||
"svcRenameDesc": "Change your character name",
|
||||
"svcCustomizeDesc": "Customize your character appearance",
|
||||
"svcChangeRaceDesc": "Change your character race",
|
||||
"svcChangeFactionDesc": "Change your character faction",
|
||||
"svcLevelUpDesc": "Level up your character",
|
||||
"svcGoldDesc": "Add gold to your character",
|
||||
"svcTransferDesc": "Transfer a character to another account",
|
||||
"svcReviveDesc": "Revive your character",
|
||||
"svcUnstuckDesc": "Unstuck your character",
|
||||
"svcVoteDesc": "Cast your vote on top sites",
|
||||
"svcRecruitDesc": "Bring friends and get great rewards",
|
||||
"svcStoreDesc": "Get items in the store"
|
||||
},
|
||||
"SelectAccount": {
|
||||
"title": "Select your game account",
|
||||
@@ -117,7 +137,8 @@
|
||||
"success": "Account activated! You can now sign in.",
|
||||
"invalidLink": "The link is invalid or has already been used.",
|
||||
"expiredLink": "The link has expired. Please request a new one.",
|
||||
"goLogin": "Go to sign in"
|
||||
"goLogin": "Go to sign in",
|
||||
"title": "Account activation"
|
||||
},
|
||||
"Recover": {
|
||||
"title": "Recover account",
|
||||
@@ -161,7 +182,11 @@
|
||||
"notOfflineOrOwned": "The character must be offline and yours.",
|
||||
"cooldown": "You can only do this once every 12 hours.",
|
||||
"soapError": "Server communication error. Please try again later.",
|
||||
"genericError": "Something went wrong. Please try again later."
|
||||
"genericError": "Something went wrong. Please try again later.",
|
||||
"info": "Information",
|
||||
"back": "Back to my account",
|
||||
"backShort": "Back",
|
||||
"choose": "Choose your character:"
|
||||
},
|
||||
"Paid": {
|
||||
"rename": {
|
||||
@@ -399,7 +424,8 @@
|
||||
"hidden": "hidden",
|
||||
"show": "Show",
|
||||
"hide": "Hide",
|
||||
"categoryNotEmpty": "Category has forums; delete them first."
|
||||
"categoryNotEmpty": "Category has forums; delete them first.",
|
||||
"backToPanel": "← Back to panel"
|
||||
},
|
||||
"Vote": {
|
||||
"title": "Vote for us",
|
||||
|
||||
@@ -45,7 +45,9 @@
|
||||
"forgot": "He olvidado mi contraseña/usuario",
|
||||
"newHere": "¿Nuevo en la comunidad? Puedes crear una cuenta",
|
||||
"createAccount": "aquí",
|
||||
"captchaFailed": "Verificación anti-bots fallida. Reintenta."
|
||||
"captchaFailed": "Verificación anti-bots fallida. Reintenta.",
|
||||
"forgotActivation": "No me ha llegado el enlace de activación",
|
||||
"publicNote": "* Si conectas desde un lugar público recuerda desconectar cuando no estés usando la página web o el foro."
|
||||
},
|
||||
"Account": {
|
||||
"title": "Mi cuenta",
|
||||
@@ -81,7 +83,25 @@
|
||||
"svcVote": "Votar (puntos)",
|
||||
"svcRecruit": "Recluta a un amigo",
|
||||
"svcStore": "Tienda (Battlepay)",
|
||||
"adminPanel": "Panel de administración"
|
||||
"adminPanel": "Panel de administración",
|
||||
"infoTitle": "Información",
|
||||
"toolsTitle": "Utilidades y Herramientas",
|
||||
"adminPanelDesc": "Gestiona el servidor",
|
||||
"svcChangePasswordDesc": "Gestiona una nueva contraseña",
|
||||
"svcChangeEmailDesc": "Gestiona un correo activo",
|
||||
"svcTokenDesc": "Seguridad extra para tu cuenta",
|
||||
"svcRenameDesc": "Cambia el nombre de tu personaje",
|
||||
"svcCustomizeDesc": "Personaliza la apariencia de tu personaje",
|
||||
"svcChangeRaceDesc": "Cambia la raza de tu personaje",
|
||||
"svcChangeFactionDesc": "Cambia la facción de tu personaje",
|
||||
"svcLevelUpDesc": "Sube de nivel a tu personaje",
|
||||
"svcGoldDesc": "Añade oro a tu personaje",
|
||||
"svcTransferDesc": "Transfiere un personaje a otra cuenta",
|
||||
"svcReviveDesc": "Revive a tu personaje",
|
||||
"svcUnstuckDesc": "Desatasca a tu personaje",
|
||||
"svcVoteDesc": "Suma tu voto en páginas top",
|
||||
"svcRecruitDesc": "Trae amigos y recibe grandes premios",
|
||||
"svcStoreDesc": "Consigue objetos en la tienda"
|
||||
},
|
||||
"SelectAccount": {
|
||||
"title": "Selecciona tu cuenta de juego",
|
||||
@@ -117,7 +137,8 @@
|
||||
"success": "¡Cuenta activada! Ya puedes iniciar sesión.",
|
||||
"invalidLink": "El enlace no es válido o ya se ha usado.",
|
||||
"expiredLink": "El enlace ha caducado. Solicita uno nuevo.",
|
||||
"goLogin": "Ir a iniciar sesión"
|
||||
"goLogin": "Ir a iniciar sesión",
|
||||
"title": "Activación de cuenta"
|
||||
},
|
||||
"Recover": {
|
||||
"title": "Recuperar cuenta",
|
||||
@@ -161,7 +182,11 @@
|
||||
"notOfflineOrOwned": "El personaje debe estar desconectado y ser tuyo.",
|
||||
"cooldown": "Solo puedes hacerlo una vez cada 12 horas.",
|
||||
"soapError": "Error de comunicación con el servidor. Inténtalo más tarde.",
|
||||
"genericError": "Algo ha salido mal. Inténtalo más tarde."
|
||||
"genericError": "Algo ha salido mal. Inténtalo más tarde.",
|
||||
"info": "Información",
|
||||
"back": "Regresar a mi cuenta",
|
||||
"backShort": "Regresar",
|
||||
"choose": "Escoge el personaje:"
|
||||
},
|
||||
"Paid": {
|
||||
"rename": {
|
||||
@@ -399,7 +424,8 @@
|
||||
"hidden": "oculto",
|
||||
"show": "Mostrar",
|
||||
"hide": "Ocultar",
|
||||
"categoryNotEmpty": "La categoría tiene foros; bórralos primero."
|
||||
"categoryNotEmpty": "La categoría tiene foros; bórralos primero.",
|
||||
"backToPanel": "← Volver al panel"
|
||||
},
|
||||
"Vote": {
|
||||
"title": "Votar por nosotros",
|
||||
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square70x70logo src="/mstile-70x70.png?v=4"/>
|
||||
<square150x150logo src="/mstile-150x150.png?v=4"/>
|
||||
<square310x310logo src="/mstile-310x310.png?v=4"/>
|
||||
<wide310x150logo src="/mstile-310x150.png?v=4"/>
|
||||
<TileColor>#211917</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
|
After Width: | Height: | Size: 662 B |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 1.1 KiB |