we ball
This commit is contained in:
parent
bc05b45d00
commit
de527073dc
7 changed files with 60 additions and 205 deletions
33
README.md
33
README.md
|
|
@ -22,10 +22,10 @@ A plataforma sincroniza automaticamente com o LiftingCast, portanto os árbitros
|
|||
### Para os Organizadores
|
||||
|
||||
- Painel de administração em http://seu-dominio.pt/admin
|
||||
- Edição de configuração em tempo real (Meet ID, Auth String, etc)
|
||||
- Upload de CSV e logo sem recompilar a aplicação
|
||||
- Visualização de estatísticas (quantos atletas estão no ficheiro, etc)
|
||||
- Suporta múltiplas competições apenas trocando ficheiro CSV e configuração
|
||||
- Configuração lida do `.env` (Meet ID, Auth String, etc)
|
||||
- Suporta múltiplas competições apenas trocando ficheiro CSV e valores no `.env`
|
||||
|
||||
### Técnico
|
||||
|
||||
|
|
@ -46,16 +46,14 @@ npm install
|
|||
|
||||
### 2. Configurar
|
||||
|
||||
Edita `src/config/config.json`:
|
||||
Copia `.env.example` para `.env` e preenche:
|
||||
|
||||
```json
|
||||
{
|
||||
"meetId": "seu-meet-id-do-liftingcast",
|
||||
"csvFileName": "seu-ficheiro.csv",
|
||||
"authString": "palavra-passe-segura",
|
||||
"adminPassword": "password-admin",
|
||||
"competitionName": "Nome da sua Competição"
|
||||
}
|
||||
```
|
||||
MEET_ID=seu-meet-id-do-liftingcast
|
||||
CSV_FILE_NAME=seu-ficheiro.csv
|
||||
AUTH_STRING=palavra-passe-segura
|
||||
ADMIN_PASSWORD=password-admin
|
||||
COMPETITION_NAME=Nome da sua Competição
|
||||
```
|
||||
|
||||
Copia o teu CSV para `src/data/seu-ficheiro.csv` (o formato esperado está documentado em baixo).
|
||||
|
|
@ -81,11 +79,10 @@ src/
|
|||
auth/ # Valida o auth param
|
||||
find-lifter/ # Busca atleta no CSV
|
||||
submit/ # Submete ao LiftingCast
|
||||
admin/ # Config e upload (admin)
|
||||
admin/ # Upload e stats
|
||||
layout.tsx
|
||||
page.tsx # Página principal
|
||||
components/ # UI reutilizável
|
||||
config/ # config.json (não commitar!)
|
||||
data/ # Ficheiros CSV (não commitar!)
|
||||
services/ # Lógica de server (stats, etc)
|
||||
types/ # TypeScript types
|
||||
|
|
@ -108,12 +105,11 @@ public/
|
|||
Acede a `/admin` com a tua password.
|
||||
|
||||
Daqui podes:
|
||||
- Ver quantos atletas estão carregados
|
||||
- Ver quantos atletas estão carregados (e valores atuais de configuração)
|
||||
- Upload de novo CSV (troca automática)
|
||||
- Upload de logo
|
||||
- Editar todas as configurações (Meet ID, Auth String, etc) sem tocar em ficheiros
|
||||
|
||||
As mudanças levam efeito imediatamente.
|
||||
Nota: a configuração (Meet ID, Auth String, etc.) é lida do `.env` e não pode ser editada via UI.
|
||||
|
||||
## Ficheiro CSV
|
||||
|
||||
|
|
@ -143,13 +139,14 @@ O matching de nomes é flexible:
|
|||
Se usas Docker/Coolify:
|
||||
|
||||
- Mount `src/data/` e `public/branding/` como volumes, assim podes trocar ficheiros sem rebuild
|
||||
- Variáveis de ambiente: a app lê de `src/config/config.json`, portanto não precisa .env (mas podes adicionar se quiseres)
|
||||
- Define as variáveis de ambiente (MEET_ID, CSV_FILE_NAME, AUTH_STRING, ADMIN_PASSWORD, COMPETITION_NAME)
|
||||
- Build: `npm run build`, run: `npm start`
|
||||
|
||||
Se usas Vercel:
|
||||
|
||||
- Coloca o CSV em `src/data/` antes de fazer push
|
||||
- A logo em `public/branding/logo.png`
|
||||
- Define as variáveis de ambiente no painel da Vercel
|
||||
- Deploy automático no push
|
||||
|
||||
## Desenvolvimento
|
||||
|
|
@ -166,7 +163,7 @@ Adiciona `"use client"` no topo de ficheiros que usem useState, useEffect, event
|
|||
|
||||
**"Não conseguo fazer upload do CSV"**: Verifica se o ficheiro está bem formatado (CSV simples, não Excel). A password de admin está correcta?
|
||||
|
||||
**"Ninguém consegue aceder à form"**: Verifica se o auth param na URL é igual ao `authString` em config.json. Gera um QR code e testa.
|
||||
**"Ninguém consegue aceder à form"**: Verifica se o auth param na URL é igual ao `AUTH_STRING` no `.env`. Gera um QR code e testa.
|
||||
|
||||
**"A logo não aparece"**: Coloca o ficheiro em `public/branding/logo.png` (ou outro nome) e actualiza o código se o nome for diferente.
|
||||
|
||||
|
|
|
|||
|
|
@ -8,30 +8,20 @@ type Stats = {
|
|||
csvFiles: string[]
|
||||
logoFiles: string[]
|
||||
meetId: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
type Config = {
|
||||
meetId: string
|
||||
csvFileName: string
|
||||
authString: string
|
||||
adminPassword: string
|
||||
competitionName: string
|
||||
authString: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const [password, setPassword] = useState('')
|
||||
const [authenticated, setAuthenticated] = useState(false)
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [config, setConfig] = useState<Config | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null)
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [editingConfig, setEditingConfig] = useState(false)
|
||||
const [configForm, setConfigForm] = useState<Config | null>(null)
|
||||
const [savingConfig, setSavingConfig] = useState(false)
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
|
|
@ -46,8 +36,6 @@ export default function AdminPage() {
|
|||
}
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
setConfig(data)
|
||||
setConfigForm(data)
|
||||
setAuthenticated(true)
|
||||
} catch {
|
||||
setMessage('❌ Erro ao conectar')
|
||||
|
|
@ -207,132 +195,23 @@ export default function AdminPage() {
|
|||
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-lg font-semibold text-black">Configuração</h2>
|
||||
{editingConfig && configForm ? (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault()
|
||||
setSavingConfig(true)
|
||||
setMessage('')
|
||||
try {
|
||||
const res = await fetch('/api/admin/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-admin-password': password },
|
||||
body: JSON.stringify(configForm)
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
setMessage(data.message || '✓ Configuração atualizada')
|
||||
setConfig(configForm)
|
||||
setEditingConfig(false)
|
||||
} else {
|
||||
setMessage(`❌ ${data.error}`)
|
||||
}
|
||||
} catch {
|
||||
setMessage('❌ Erro ao salvar configuração')
|
||||
} finally {
|
||||
setSavingConfig(false)
|
||||
}
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Meet ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={configForm.meetId}
|
||||
onChange={(e) => setConfigForm({ ...configForm, meetId: e.target.value })}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2"
|
||||
disabled={savingConfig}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">CSV Filename</label>
|
||||
<input
|
||||
type="text"
|
||||
value={configForm.csvFileName}
|
||||
onChange={(e) => setConfigForm({ ...configForm, csvFileName: e.target.value })}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2"
|
||||
disabled={savingConfig}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Auth String</label>
|
||||
<input
|
||||
type="text"
|
||||
value={configForm.authString}
|
||||
onChange={(e) => setConfigForm({ ...configForm, authString: e.target.value })}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2"
|
||||
disabled={savingConfig}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Admin Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={configForm.adminPassword}
|
||||
onChange={(e) => setConfigForm({ ...configForm, adminPassword: e.target.value })}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2"
|
||||
disabled={savingConfig}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Competition Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={configForm.competitionName}
|
||||
onChange={(e) => setConfigForm({ ...configForm, competitionName: e.target.value })}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2"
|
||||
disabled={savingConfig}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 rounded-md bg-brand-600 px-4 py-2 text-white hover:bg-brand-700 disabled:opacity-60"
|
||||
disabled={savingConfig}
|
||||
>
|
||||
{savingConfig ? 'A guardar...' : 'Guardar'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingConfig(false)
|
||||
setConfigForm(config)
|
||||
}}
|
||||
className="flex-1 rounded-md border border-gray-300 px-4 py-2 hover:bg-gray-50"
|
||||
disabled={savingConfig}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
{message && (
|
||||
<p className={`text-sm ${message.includes('❌') ? 'text-red-600' : 'text-green-600'}`}>{message}</p>
|
||||
)}
|
||||
</form>
|
||||
) : (
|
||||
<div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>
|
||||
<span className="font-medium">Meet ID:</span> {config?.meetId}
|
||||
<span className="font-medium">Competition:</span> {stats?.competitionName || '—'}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">CSV:</span> {config?.csvFileName}
|
||||
<span className="font-medium">Meet ID:</span> {stats?.meetId || '—'}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Auth String:</span> {config?.authString}
|
||||
<span className="font-medium">CSV:</span> {stats?.csvFileName || '—'}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Competition:</span> {config?.competitionName}
|
||||
<span className="font-medium">Auth String:</span> {stats?.authString || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditingConfig(true)}
|
||||
className="mt-4 rounded-md bg-brand-600 px-4 py-2 text-white hover:bg-brand-700"
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-3 text-xs text-gray-500">
|
||||
Estes valores agora são definidos no ficheiro .env e não podem ser alterados na UI.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { CONFIG_PATH, readConfig } from '@/utils/config'
|
||||
import { readConfig } from '@/utils/config'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
|
|
@ -22,30 +21,8 @@ export async function GET(req: NextRequest) {
|
|||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const password = req.headers.get('x-admin-password') || ''
|
||||
const config = await readConfig()
|
||||
if (password !== config.adminPassword) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await req.json()) as Partial<ConfigData>
|
||||
|
||||
if (!body.meetId || !body.csvFileName || !body.authString || !body.adminPassword || !body.competitionName) {
|
||||
return NextResponse.json({ error: 'All fields are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const newConfig: ConfigData = {
|
||||
meetId: body.meetId,
|
||||
csvFileName: body.csvFileName,
|
||||
authString: body.authString,
|
||||
adminPassword: body.adminPassword,
|
||||
competitionName: body.competitionName
|
||||
}
|
||||
|
||||
await writeFile(CONFIG_PATH, JSON.stringify(newConfig, null, 2))
|
||||
return NextResponse.json({ success: true, message: '✓ Configuração atualizada com sucesso!' })
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: `Failed to save config: ${e.message}` }, { status: 500 })
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Config is read-only. Update values via environment variables.' },
|
||||
{ status: 405 }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { readConfig } from '@/utils/config'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const configPath = path.join(process.cwd(), 'src', 'config', 'config.json')
|
||||
const content = await readFile(configPath, 'utf8')
|
||||
const data = JSON.parse(content)
|
||||
const data = await readConfig()
|
||||
return NextResponse.json({ competitionName: data.competitionName })
|
||||
} catch {
|
||||
return NextResponse.json({ competitionName: 'Altarra' })
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"meetId": "LIFTINGCAST_MEET_ID",
|
||||
"csvFileName": "CSV_FILE_NAME",
|
||||
"authString": "AUTH_STRING",
|
||||
"adminPassword": "ADMIN_PASSWORD",
|
||||
"competitionName": "COMPETITION_NAME"
|
||||
}
|
||||
|
|
@ -23,7 +23,9 @@ export async function getLifterStats() {
|
|||
csvFileName: config.csvFileName,
|
||||
csvFiles,
|
||||
logoFiles,
|
||||
meetId: config.meetId
|
||||
meetId: config.meetId,
|
||||
competitionName: config.competitionName,
|
||||
authString: config.authString
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
|
|
@ -32,6 +34,8 @@ export async function getLifterStats() {
|
|||
csvFiles: [],
|
||||
logoFiles: [],
|
||||
meetId: config.meetId,
|
||||
competitionName: config.competitionName,
|
||||
authString: config.authString,
|
||||
error: 'Failed to load stats'
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
export type AppConfig = {
|
||||
meetId: string
|
||||
csvFileName: string
|
||||
|
|
@ -9,9 +6,20 @@ export type AppConfig = {
|
|||
competitionName: string
|
||||
}
|
||||
|
||||
export const CONFIG_PATH = path.join(process.cwd(), 'src', 'config', 'config.json')
|
||||
function getEnv(name: string) {
|
||||
const value = process.env[name]
|
||||
if (!value) {
|
||||
throw new Error(`Missing required env: ${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export async function readConfig(): Promise<AppConfig> {
|
||||
const content = await readFile(CONFIG_PATH, 'utf8')
|
||||
return JSON.parse(content) as AppConfig
|
||||
return {
|
||||
meetId: getEnv('MEET_ID'),
|
||||
csvFileName: getEnv('CSV_FILE_NAME'),
|
||||
authString: getEnv('AUTH_STRING'),
|
||||
adminPassword: getEnv('ADMIN_PASSWORD'),
|
||||
competitionName: getEnv('COMPETITION_NAME')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue