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
|
### Para os Organizadores
|
||||||
|
|
||||||
- Painel de administração em http://seu-dominio.pt/admin
|
- 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
|
- Upload de CSV e logo sem recompilar a aplicação
|
||||||
- Visualização de estatísticas (quantos atletas estão no ficheiro, etc)
|
- 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
|
### Técnico
|
||||||
|
|
||||||
|
|
@ -46,16 +46,14 @@ npm install
|
||||||
|
|
||||||
### 2. Configurar
|
### 2. Configurar
|
||||||
|
|
||||||
Edita `src/config/config.json`:
|
Copia `.env.example` para `.env` e preenche:
|
||||||
|
|
||||||
```json
|
```
|
||||||
{
|
MEET_ID=seu-meet-id-do-liftingcast
|
||||||
"meetId": "seu-meet-id-do-liftingcast",
|
CSV_FILE_NAME=seu-ficheiro.csv
|
||||||
"csvFileName": "seu-ficheiro.csv",
|
AUTH_STRING=palavra-passe-segura
|
||||||
"authString": "palavra-passe-segura",
|
ADMIN_PASSWORD=password-admin
|
||||||
"adminPassword": "password-admin",
|
COMPETITION_NAME=Nome da sua Competição
|
||||||
"competitionName": "Nome da sua Competição"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Copia o teu CSV para `src/data/seu-ficheiro.csv` (o formato esperado está documentado em baixo).
|
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
|
auth/ # Valida o auth param
|
||||||
find-lifter/ # Busca atleta no CSV
|
find-lifter/ # Busca atleta no CSV
|
||||||
submit/ # Submete ao LiftingCast
|
submit/ # Submete ao LiftingCast
|
||||||
admin/ # Config e upload (admin)
|
admin/ # Upload e stats
|
||||||
layout.tsx
|
layout.tsx
|
||||||
page.tsx # Página principal
|
page.tsx # Página principal
|
||||||
components/ # UI reutilizável
|
components/ # UI reutilizável
|
||||||
config/ # config.json (não commitar!)
|
|
||||||
data/ # Ficheiros CSV (não commitar!)
|
data/ # Ficheiros CSV (não commitar!)
|
||||||
services/ # Lógica de server (stats, etc)
|
services/ # Lógica de server (stats, etc)
|
||||||
types/ # TypeScript types
|
types/ # TypeScript types
|
||||||
|
|
@ -108,12 +105,11 @@ public/
|
||||||
Acede a `/admin` com a tua password.
|
Acede a `/admin` com a tua password.
|
||||||
|
|
||||||
Daqui podes:
|
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 novo CSV (troca automática)
|
||||||
- Upload de logo
|
- 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
|
## Ficheiro CSV
|
||||||
|
|
||||||
|
|
@ -143,13 +139,14 @@ O matching de nomes é flexible:
|
||||||
Se usas Docker/Coolify:
|
Se usas Docker/Coolify:
|
||||||
|
|
||||||
- Mount `src/data/` e `public/branding/` como volumes, assim podes trocar ficheiros sem rebuild
|
- 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`
|
- Build: `npm run build`, run: `npm start`
|
||||||
|
|
||||||
Se usas Vercel:
|
Se usas Vercel:
|
||||||
|
|
||||||
- Coloca o CSV em `src/data/` antes de fazer push
|
- Coloca o CSV em `src/data/` antes de fazer push
|
||||||
- A logo em `public/branding/logo.png`
|
- A logo em `public/branding/logo.png`
|
||||||
|
- Define as variáveis de ambiente no painel da Vercel
|
||||||
- Deploy automático no push
|
- Deploy automático no push
|
||||||
|
|
||||||
## Desenvolvimento
|
## 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?
|
**"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.
|
**"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[]
|
csvFiles: string[]
|
||||||
logoFiles: string[]
|
logoFiles: string[]
|
||||||
meetId: string
|
meetId: string
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Config = {
|
|
||||||
meetId: string
|
|
||||||
csvFileName: string
|
|
||||||
authString: string
|
|
||||||
adminPassword: string
|
|
||||||
competitionName: string
|
competitionName: string
|
||||||
|
authString: string
|
||||||
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [authenticated, setAuthenticated] = useState(false)
|
const [authenticated, setAuthenticated] = useState(false)
|
||||||
const [stats, setStats] = useState<Stats | null>(null)
|
const [stats, setStats] = useState<Stats | null>(null)
|
||||||
const [config, setConfig] = useState<Config | null>(null)
|
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const [csvFile, setCsvFile] = useState<File | null>(null)
|
const [csvFile, setCsvFile] = useState<File | null>(null)
|
||||||
const [logoFile, setLogoFile] = useState<File | null>(null)
|
const [logoFile, setLogoFile] = useState<File | null>(null)
|
||||||
const [uploading, setUploading] = useState(false)
|
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) {
|
async function handleLogin(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -46,8 +36,6 @@ export default function AdminPage() {
|
||||||
}
|
}
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setStats(data)
|
setStats(data)
|
||||||
setConfig(data)
|
|
||||||
setConfigForm(data)
|
|
||||||
setAuthenticated(true)
|
setAuthenticated(true)
|
||||||
} catch {
|
} catch {
|
||||||
setMessage('❌ Erro ao conectar')
|
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">
|
<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>
|
<h2 className="mb-4 text-lg font-semibold text-black">Configuração</h2>
|
||||||
{editingConfig && configForm ? (
|
<div className="space-y-2 text-sm">
|
||||||
<form
|
<p>
|
||||||
onSubmit={async (e) => {
|
<span className="font-medium">Competition:</span> {stats?.competitionName || '—'}
|
||||||
e.preventDefault()
|
</p>
|
||||||
setSavingConfig(true)
|
<p>
|
||||||
setMessage('')
|
<span className="font-medium">Meet ID:</span> {stats?.meetId || '—'}
|
||||||
try {
|
</p>
|
||||||
const res = await fetch('/api/admin/config', {
|
<p>
|
||||||
method: 'POST',
|
<span className="font-medium">CSV:</span> {stats?.csvFileName || '—'}
|
||||||
headers: { 'Content-Type': 'application/json', 'x-admin-password': password },
|
</p>
|
||||||
body: JSON.stringify(configForm)
|
<p>
|
||||||
})
|
<span className="font-medium">Auth String:</span> {stats?.authString || '—'}
|
||||||
const data = await res.json()
|
</p>
|
||||||
if (res.ok) {
|
</div>
|
||||||
setMessage(data.message || '✓ Configuração atualizada')
|
<p className="mt-3 text-xs text-gray-500">
|
||||||
setConfig(configForm)
|
Estes valores agora são definidos no ficheiro .env e não podem ser alterados na UI.
|
||||||
setEditingConfig(false)
|
</p>
|
||||||
} 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>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { writeFile } from 'node:fs/promises'
|
import { readConfig } from '@/utils/config'
|
||||||
import { CONFIG_PATH, readConfig } from '@/utils/config'
|
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
|
@ -22,30 +21,8 @@ export async function GET(req: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const password = req.headers.get('x-admin-password') || ''
|
return NextResponse.json(
|
||||||
const config = await readConfig()
|
{ error: 'Config is read-only. Update values via environment variables.' },
|
||||||
if (password !== config.adminPassword) {
|
{ status: 405 }
|
||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { readFile } from 'node:fs/promises'
|
import { readConfig } from '@/utils/config'
|
||||||
import path from 'node:path'
|
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const configPath = path.join(process.cwd(), 'src', 'config', 'config.json')
|
const data = await readConfig()
|
||||||
const content = await readFile(configPath, 'utf8')
|
|
||||||
const data = JSON.parse(content)
|
|
||||||
return NextResponse.json({ competitionName: data.competitionName })
|
return NextResponse.json({ competitionName: data.competitionName })
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ competitionName: 'Altarra' })
|
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,
|
csvFileName: config.csvFileName,
|
||||||
csvFiles,
|
csvFiles,
|
||||||
logoFiles,
|
logoFiles,
|
||||||
meetId: config.meetId
|
meetId: config.meetId,
|
||||||
|
competitionName: config.competitionName,
|
||||||
|
authString: config.authString
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -32,6 +34,8 @@ export async function getLifterStats() {
|
||||||
csvFiles: [],
|
csvFiles: [],
|
||||||
logoFiles: [],
|
logoFiles: [],
|
||||||
meetId: config.meetId,
|
meetId: config.meetId,
|
||||||
|
competitionName: config.competitionName,
|
||||||
|
authString: config.authString,
|
||||||
error: 'Failed to load stats'
|
error: 'Failed to load stats'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
import { readFile } from 'node:fs/promises'
|
|
||||||
import path from 'node:path'
|
|
||||||
|
|
||||||
export type AppConfig = {
|
export type AppConfig = {
|
||||||
meetId: string
|
meetId: string
|
||||||
csvFileName: string
|
csvFileName: string
|
||||||
|
|
@ -9,9 +6,20 @@ export type AppConfig = {
|
||||||
competitionName: string
|
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> {
|
export async function readConfig(): Promise<AppConfig> {
|
||||||
const content = await readFile(CONFIG_PATH, 'utf8')
|
return {
|
||||||
return JSON.parse(content) as AppConfig
|
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