diff --git a/web-next/app/[locale]/admin/page.tsx b/web-next/app/[locale]/admin/page.tsx
index 9923b4b..de0bfdc 100644
--- a/web-next/app/[locale]/admin/page.tsx
+++ b/web-next/app/[locale]/admin/page.tsx
@@ -27,6 +27,11 @@ export default async function AdminPage({ params }: { params: Promise<{ locale:
{t('managePrices')}
+
+
+ {t('manageVotes')}
+
+
)
diff --git a/web-next/app/[locale]/admin/votes/page.tsx b/web-next/app/[locale]/admin/votes/page.tsx
new file mode 100644
index 0000000..3cdcdcb
--- /dev/null
+++ b/web-next/app/[locale]/admin/votes/page.tsx
@@ -0,0 +1,24 @@
+import { getTranslations, setRequestLocale } from 'next-intl/server'
+import { redirect } from '@/i18n/navigation'
+import { getSession } from '@/lib/session'
+import { isAdmin } from '@/lib/admin'
+import { listVoteSites } from '@/lib/admin-votes'
+import { AdminVotesManager } from '@/components/AdminVotesManager'
+
+export const dynamic = 'force-dynamic'
+
+export default async function AdminVotesPage({ params }: { params: Promise<{ locale: string }> }) {
+ const { locale } = await params
+ setRequestLocale(locale)
+ const t = await getTranslations('Admin')
+ const session = await getSession()
+ if (!session.bnetId) redirect({ href: '/login', locale })
+ if (!(await isAdmin(session))) redirect({ href: '/', locale })
+ const sites = await listVoteSites()
+ return (
+
+ {t('votes')}
+
+
+ )
+}
diff --git a/web-next/app/api/admin/votes/[id]/route.ts b/web-next/app/api/admin/votes/[id]/route.ts
new file mode 100644
index 0000000..1c05b55
--- /dev/null
+++ b/web-next/app/api/admin/votes/[id]/route.ts
@@ -0,0 +1,11 @@
+import { getSession } from '@/lib/session'
+import { isAdmin } from '@/lib/admin'
+import { deleteVoteSite } from '@/lib/admin-votes'
+
+export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
+ const session = await getSession()
+ if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
+ const { id } = await params
+ await deleteVoteSite(Number(id))
+ return Response.json({ success: true })
+}
diff --git a/web-next/app/api/admin/votes/route.ts b/web-next/app/api/admin/votes/route.ts
new file mode 100644
index 0000000..16d385a
--- /dev/null
+++ b/web-next/app/api/admin/votes/route.ts
@@ -0,0 +1,17 @@
+import { getSession } from '@/lib/session'
+import { isAdmin } from '@/lib/admin'
+import { createVoteSite } from '@/lib/admin-votes'
+
+export async function POST(request: Request) {
+ const session = await getSession()
+ if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
+ let b: Record = {}
+ try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) }
+ const name = String(b.name ?? '').trim()
+ const url = String(b.url ?? '').trim()
+ const imageUrl = String(b.imageUrl ?? '').trim()
+ const points = Number(b.points)
+ if (!name || !url || !(points >= 0)) return Response.json({ success: false, error: 'emptyError' })
+ const id = await createVoteSite(name, url, imageUrl, points)
+ return Response.json({ success: true, id })
+}
diff --git a/web-next/components/AdminVotesManager.tsx b/web-next/components/AdminVotesManager.tsx
new file mode 100644
index 0000000..0139da7
--- /dev/null
+++ b/web-next/components/AdminVotesManager.tsx
@@ -0,0 +1,80 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import type { VoteSite } from '@/lib/admin-votes'
+
+export function AdminVotesManager({ initial }: { initial: VoteSite[] }) {
+ const t = useTranslations('Admin')
+ const [sites, setSites] = useState(initial)
+ const [form, setForm] = useState({ name: '', url: '', imageUrl: '', points: '1' })
+ const [busy, setBusy] = useState(false)
+
+ function upd(k: string, v: string) {
+ setForm((f) => ({ ...f, [k]: v }))
+ }
+
+ async function create(e: React.FormEvent) {
+ e.preventDefault()
+ if (busy || !form.name.trim() || !form.url.trim()) return
+ setBusy(true)
+ try {
+ const res = await fetch('/api/admin/votes', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'same-origin',
+ body: JSON.stringify({ ...form, points: Number(form.points) }),
+ })
+ const data: { success?: boolean; id?: number } = await res.json()
+ if (data.success && data.id) {
+ setSites([
+ ...sites,
+ { id: data.id, name: form.name, url: form.url, image_url: form.imageUrl, points: Number(form.points) },
+ ])
+ setForm({ name: '', url: '', imageUrl: '', points: '1' })
+ }
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ async function remove(id: number) {
+ if (!confirm(t('confirmDelete'))) return
+ const res = await fetch(`/api/admin/votes/${id}`, { method: 'DELETE', credentials: 'same-origin' })
+ if ((await res.json()).success) setSites(sites.filter((s) => s.id !== id))
+ }
+
+ const field = 'w-full rounded border border-amber-900/60 bg-[#2c1e14] px-3 py-2'
+ return (
+
+
+ {sites.length === 0 ? (
+
{t('noVotes')}
+ ) : (
+
+ {sites.map((s) => (
+ -
+
+
{s.name}
+
+ {s.points} PV · {s.url}
+
+
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/web-next/lib/admin-votes.ts b/web-next/lib/admin-votes.ts
new file mode 100644
index 0000000..f2be350
--- /dev/null
+++ b/web-next/lib/admin-votes.ts
@@ -0,0 +1,29 @@
+import type { RowDataPacket, ResultSetHeader } from 'mysql2'
+import { db, DB } from './db'
+
+export interface VoteSite {
+ id: number
+ name: string
+ url: string
+ image_url: string
+ points: number
+}
+
+export async function listVoteSites(): Promise {
+ const [rows] = await db(DB.default).query(
+ 'SELECT id, name, url, image_url, points FROM home_votesite ORDER BY id',
+ )
+ return rows.map((r) => ({ id: r.id, name: r.name, url: r.url, image_url: r.image_url, points: r.points }))
+}
+
+export async function createVoteSite(name: string, url: string, imageUrl: string, points: number): Promise {
+ const [res] = await db(DB.default).query(
+ 'INSERT INTO home_votesite (name, url, image_url, points, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
+ [name, url, imageUrl, points],
+ )
+ return res.insertId
+}
+
+export async function deleteVoteSite(id: number): Promise {
+ await db(DB.default).query('DELETE FROM home_votesite WHERE id = ?', [id])
+}
diff --git a/web-next/messages/en.json b/web-next/messages/en.json
index ce17bb7..ce7e4ce 100644
--- a/web-next/messages/en.json
+++ b/web-next/messages/en.json
@@ -292,6 +292,13 @@
"svc_change-race": "Change race",
"svc_change-faction": "Change faction",
"svc_level-up": "Level up to 80",
- "svc_transfer": "Transfer character"
+ "svc_transfer": "Transfer character",
+ "manageVotes": "Manage vote sites",
+ "votes": "Vote sites",
+ "voteName": "Name",
+ "voteUrl": "URL",
+ "voteImage": "Image URL",
+ "votePoints": "VP per vote",
+ "noVotes": "No vote sites."
}
}
diff --git a/web-next/messages/es.json b/web-next/messages/es.json
index e5d1042..840edc4 100644
--- a/web-next/messages/es.json
+++ b/web-next/messages/es.json
@@ -292,6 +292,13 @@
"svc_change-race": "Cambiar raza",
"svc_change-faction": "Cambiar facción",
"svc_level-up": "Subir a nivel 80",
- "svc_transfer": "Transferir personaje"
+ "svc_transfer": "Transferir personaje",
+ "manageVotes": "Gestionar sitios de voto",
+ "votes": "Sitios de voto",
+ "voteName": "Nombre",
+ "voteUrl": "URL",
+ "voteImage": "URL de la imagen",
+ "votePoints": "PV por voto",
+ "noVotes": "No hay sitios de voto."
}
}