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>
This commit is contained in:
2026-07-13 09:54:58 +00:00
parent 8bb18838ff
commit cc158d3819
230 changed files with 7899 additions and 1386 deletions
+167 -126
View File
@@ -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>
)
}
+10 -5
View File
@@ -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>
)
}
+10 -5
View File
@@ -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>
)
}
+10 -5
View File
@@ -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>
)
}
+29 -15
View File
@@ -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>
)
}
+10 -5
View File
@@ -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>
)
}
+10 -5
View File
@@ -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>
)
}
+10 -5
View File
@@ -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>
)
}
+10 -5
View File
@@ -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>
)
}
+10 -5
View File
@@ -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>
</>
)
}
+7 -4
View File
@@ -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>
)
}
+43 -38
View File
@@ -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>
)
}
+47 -38
View File
@@ -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>
)
}
+42 -41
View File
@@ -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>
)
}
+7 -4
View File
@@ -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>
)
}
+25 -3
View File
@@ -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>
+64 -43
View File
@@ -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>
</>
)
}
+22 -4
View File
@@ -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>
)
}
+134 -88
View File
@@ -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 -17
View File
@@ -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>
</>
)
}
+11 -4
View File
@@ -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>
)
}
+20 -18
View File
@@ -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>
)
}
+57 -20
View File
@@ -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>
</>
)
}
+13 -4
View File
@@ -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>
)
}
+7 -4
View File
@@ -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>
)
}
+21 -16
View File
@@ -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>
)
}
+14 -10
View File
@@ -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>
)
}
+7 -4
View File
@@ -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>
)
}
+7 -4
View File
@@ -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>
)
}
+10 -5
View File
@@ -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>
)
}
+63 -8
View File
@@ -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; }