This commit is contained in:
headpatsyou 2026-01-08 19:48:13 +00:00
parent bc05b45d00
commit de527073dc
7 changed files with 60 additions and 205 deletions

View file

@ -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.

View file

@ -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}
</p>
<p>
<span className="font-medium">CSV:</span> {config?.csvFileName}
</p>
<p>
<span className="font-medium">Auth String:</span> {config?.authString}
</p>
<p>
<span className="font-medium">Competition:</span> {config?.competitionName}
</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>
)}
<div className="space-y-2 text-sm">
<p>
<span className="font-medium">Competition:</span> {stats?.competitionName || '—'}
</p>
<p>
<span className="font-medium">Meet ID:</span> {stats?.meetId || '—'}
</p>
<p>
<span className="font-medium">CSV:</span> {stats?.csvFileName || '—'}
</p>
<p>
<span className="font-medium">Auth String:</span> {stats?.authString || '—'}
</p>
</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">

View file

@ -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 }
)
}

View file

@ -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' })

View file

@ -1,7 +0,0 @@
{
"meetId": "LIFTINGCAST_MEET_ID",
"csvFileName": "CSV_FILE_NAME",
"authString": "AUTH_STRING",
"adminPassword": "ADMIN_PASSWORD",
"competitionName": "COMPETITION_NAME"
}

View file

@ -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'
}
}

View file

@ -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')
}
}