Add leaderboard route with participants, and configure Shell brand colors in Tailwind

This commit is contained in:
ut-masekela
2026-03-25 01:27:18 +02:00
parent a88504357d
commit a63ea60860
10 changed files with 584 additions and 3 deletions

View File

@@ -43,7 +43,7 @@ services:
image: mysql:8.0 image: mysql:8.0
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${DB_PORT:-3306}:3306" - "${DB_PORT:-3307}:3306"
environment: environment:
MYSQL_DATABASE: ${DB_DATABASE:-laravel} MYSQL_DATABASE: ${DB_DATABASE:-laravel}
MYSQL_USER: ${DB_USERNAME:-laravel} MYSQL_USER: ${DB_USERNAME:-laravel}

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ParticipantResource\Pages;
use App\Models\Participant;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class ParticipantResource extends Resource
{
protected static ?string $model = Participant::class;
protected static ?string $navigationIcon = 'heroicon-o-flag';
protected static ?string $navigationGroup = 'Leaderboard';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('Participant Information')
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255)
->placeholder('Enter participant name'),
Forms\Components\TextInput::make('car_number')
->label('Car Number')
->numeric()
->minValue(1)
->maxValue(999)
->placeholder('e.g., 44'),
])->columns(2),
Forms\Components\Section::make('Timing')
->schema([
Forms\Components\Grid::make(4)
->schema([
Forms\Components\TextInput::make('time_minutes')
->label('Minutes')
->numeric()
->minValue(0)
->maxValue(59)
->default(0)
->placeholder('00')
->dehydrated(false)
->afterStateHydrated(function ($component, $state, $record) {
if ($record && $record->time_ms) {
$component->state(floor($record->time_ms / 60000));
}
}),
Forms\Components\TextInput::make('time_seconds')
->label('Seconds')
->numeric()
->minValue(0)
->maxValue(59)
->default(0)
->placeholder('00')
->dehydrated(false)
->afterStateHydrated(function ($component, $state, $record) {
if ($record && $record->time_ms) {
$component->state(floor(($record->time_ms % 60000) / 1000));
}
}),
Forms\Components\TextInput::make('time_milliseconds')
->label('Milliseconds')
->numeric()
->minValue(0)
->maxValue(999)
->default(0)
->placeholder('000')
->dehydrated(false)
->afterStateHydrated(function ($component, $state, $record) {
if ($record && $record->time_ms) {
$component->state($record->time_ms % 1000);
}
}),
Forms\Components\Select::make('status')
->options([
'pending' => 'Pending',
'completed' => 'Completed',
'dnf' => 'DNF',
])
->default('pending')
->required(),
]),
Forms\Components\Hidden::make('time_ms'),
])
->description('Enter time as MM:SS.ms (e.g., 01:23.456)'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('position')
->label('P')
->state(function ($record, $rowLoop) {
return $rowLoop->iteration;
})
->alignCenter()
->weight('bold'),
Tables\Columns\TextColumn::make('car_number')
->label('#')
->alignCenter()
->placeholder('-')
->sortable(),
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable()
->weight('bold'),
Tables\Columns\TextColumn::make('formatted_time')
->label('Time')
->placeholder('No time')
->fontFamily('mono')
->alignCenter(),
Tables\Columns\BadgeColumn::make('status')
->colors([
'warning' => 'pending',
'success' => 'completed',
'danger' => 'dnf',
]),
Tables\Columns\TextColumn::make('updated_at')
->label('Last Update')
->dateTime('M j, H:i')
->sortable(),
])
->defaultSort('time_ms', 'asc')
->filters([
Tables\Filters\SelectFilter::make('status')
->options([
'pending' => 'Pending',
'completed' => 'Completed',
'dnf' => 'DNF',
]),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\ListParticipants::route('/'),
'create' => Pages\CreateParticipant::route('/create'),
'edit' => Pages\EditParticipant::route('/{record}/edit'),
];
}
public static function mutateFormDataBeforeCreate(array $data): array
{
return static::calculateTimeMs($data);
}
public static function mutateFormDataBeforeSave(array $data): array
{
return static::calculateTimeMs($data);
}
protected static function calculateTimeMs(array $data): array
{
$minutes = (int) ($data['time_minutes'] ?? 0);
$seconds = (int) ($data['time_seconds'] ?? 0);
$milliseconds = (int) ($data['time_milliseconds'] ?? 0);
if ($minutes > 0 || $seconds > 0 || $milliseconds > 0) {
$data['time_ms'] = ($minutes * 60000) + ($seconds * 1000) + $milliseconds;
} else {
$data['time_ms'] = null;
}
return $data;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Filament\Resources\ParticipantResource\Pages;
use App\Filament\Resources\ParticipantResource;
use Filament\Resources\Pages\CreateRecord;
class CreateParticipant extends CreateRecord
{
protected static string $resource = ParticipantResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$minutes = (int) ($data['time_minutes'] ?? 0);
$seconds = (int) ($data['time_seconds'] ?? 0);
$milliseconds = (int) ($data['time_milliseconds'] ?? 0);
if ($minutes > 0 || $seconds > 0 || $milliseconds > 0) {
$data['time_ms'] = ($minutes * 60000) + ($seconds * 1000) + $milliseconds;
} else {
$data['time_ms'] = null;
}
unset($data['time_minutes'], $data['time_seconds'], $data['time_milliseconds']);
return $data;
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Filament\Resources\ParticipantResource\Pages;
use App\Filament\Resources\ParticipantResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditParticipant extends EditRecord
{
protected static string $resource = ParticipantResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
$minutes = (int) ($data['time_minutes'] ?? 0);
$seconds = (int) ($data['time_seconds'] ?? 0);
$milliseconds = (int) ($data['time_milliseconds'] ?? 0);
if ($minutes > 0 || $seconds > 0 || $milliseconds > 0) {
$data['time_ms'] = ($minutes * 60000) + ($seconds * 1000) + $milliseconds;
} else {
$data['time_ms'] = null;
}
unset($data['time_minutes'], $data['time_seconds'], $data['time_milliseconds']);
return $data;
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\ParticipantResource\Pages;
use App\Filament\Resources\ParticipantResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListParticipants extends ListRecords
{
protected static string $resource = ParticipantResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Participant extends Model
{
use HasFactory;
protected $fillable = [
'name',
'car_number',
'time_ms',
'status',
];
protected $casts = [
'car_number' => 'integer',
'time_ms' => 'integer',
];
/**
* Format time_ms as mm:ss.ms (e.g., 01:23.456)
*/
public function getFormattedTimeAttribute(): ?string
{
if ($this->time_ms === null) {
return null;
}
$totalMs = $this->time_ms;
$minutes = floor($totalMs / 60000);
$seconds = floor(($totalMs % 60000) / 1000);
$milliseconds = $totalMs % 1000;
return sprintf('%02d:%02d.%03d', $minutes, $seconds, $milliseconds);
}
/**
* Scope to get ranked participants (fastest first, null times last)
*/
public function scopeRanked($query)
{
return $query->orderByRaw('CASE WHEN time_ms IS NULL THEN 1 ELSE 0 END')
->orderBy('time_ms', 'asc');
}
/**
* Scope to get only completed participants
*/
public function scopeCompleted($query)
{
return $query->where('status', 'completed');
}
}

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('participants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->integer('car_number')->nullable();
$table->integer('time_ms')->nullable(); // Time in milliseconds for accurate sorting
$table->string('status')->default('pending'); // pending, completed, dnf
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('participants');
}
};

View File

@@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="10">
<title>Shell Leaderboard</title>
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700,800,900&display=swap" rel="stylesheet" />
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
shell: {
red: '#E60000',
yellow: '#FFD100',
dark: '#1A1A1A',
}
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
}
}
}
}
</script>
<style>
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 20px rgba(230, 0, 0, 0.3); }
50% { box-shadow: 0 0 40px rgba(230, 0, 0, 0.6); }
}
.leader-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
@keyframes slide-in {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
.animate-row {
animation: slide-in 0.3s ease-out forwards;
}
</style>
</head>
<body class="bg-shell-dark min-h-screen font-sans antialiased">
<!-- Header -->
<header class="bg-gradient-to-r from-shell-red to-red-700 py-6 px-8 shadow-2xl">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<div class="flex items-center gap-6">
<div class="w-16 h-16 bg-shell-yellow rounded-lg flex items-center justify-center shadow-lg">
<svg class="w-10 h-10 text-shell-dark" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2L1 12h3v9h6v-6h4v6h6v-9h3L12 2z"/>
</svg>
</div>
<div>
<h1 class="text-4xl md:text-5xl lg:text-6xl font-black text-white tracking-tight">SHELL LEADERBOARD</h1>
<p class="text-shell-yellow text-lg md:text-xl font-semibold tracking-widest uppercase mt-1">Race Results</p>
</div>
</div>
<div class="text-right hidden md:block">
<div class="text-white/80 text-sm uppercase tracking-wider">Live Standings</div>
<div class="text-shell-yellow text-2xl font-bold tabular-nums" id="clock">{{ now()->format('H:i:s') }}</div>
</div>
</div>
</header>
<!-- Leaderboard -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
@if($participants->isEmpty())
<div class="text-center py-20">
<div class="text-shell-yellow text-6xl mb-4">🏁</div>
<h2 class="text-3xl font-bold text-white mb-2">No Participants Yet</h2>
<p class="text-gray-400 text-lg">Waiting for race data...</p>
</div>
@else
<!-- Table Header -->
<div class="bg-shell-dark/50 border-b-2 border-shell-yellow mb-2 rounded-t-xl overflow-hidden">
<div class="grid grid-cols-12 gap-4 px-6 py-4 text-shell-yellow font-bold uppercase tracking-wider text-sm md:text-base">
<div class="col-span-1 text-center">POS</div>
<div class="col-span-1 text-center">#</div>
<div class="col-span-6">DRIVER</div>
<div class="col-span-2 text-center">TIME</div>
<div class="col-span-2 text-center">GAP</div>
</div>
</div>
<!-- Participants -->
<div class="space-y-2">
@php $leaderTime = $participants->first()?->time_ms; @endphp
@foreach($participants as $index => $participant)
@php
$position = $index + 1;
$isLeader = $position === 1;
$isPodium = $position <= 3;
$gap = null;
if ($leaderTime && $participant->time_ms && !$isLeader) {
$gapMs = $participant->time_ms - $leaderTime;
$gapSec = $gapMs / 1000;
$gap = sprintf('+%.3f', $gapSec);
}
@endphp
<div class="animate-row {{ $isLeader ? 'leader-glow' : '' }}" style="animation-delay: {{ $index * 0.05 }}s">
<div class="grid grid-cols-12 gap-4 px-6 py-4 md:py-5 rounded-xl items-center transition-all duration-300 hover:scale-[1.01]
@if($isLeader)
bg-gradient-to-r from-shell-red to-red-800 text-white
@elseif($position === 2)
bg-gradient-to-r from-gray-600 to-gray-700 text-white
@elseif($position === 3)
bg-gradient-to-r from-amber-700 to-amber-800 text-white
@else
bg-gray-800/80 text-white hover:bg-gray-700/80
@endif
">
<!-- Position -->
<div class="col-span-1 text-center">
<span class="inline-flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full font-black text-xl md:text-2xl
@if($isLeader) bg-shell-yellow text-shell-dark
@elseif($position === 2) bg-gray-300 text-gray-800
@elseif($position === 3) bg-amber-500 text-amber-900
@else bg-gray-600 text-white
@endif
">
{{ $position }}
</span>
</div>
<!-- Car Number -->
<div class="col-span-1 text-center">
<span class="text-xl md:text-2xl font-bold {{ $isLeader ? 'text-shell-yellow' : 'text-gray-300' }}">
{{ $participant->car_number ?? '-' }}
</span>
</div>
<!-- Name -->
<div class="col-span-6">
<span class="text-xl md:text-2xl lg:text-3xl font-bold tracking-wide uppercase">
{{ $participant->name }}
</span>
@if($participant->status === 'dnf')
<span class="ml-3 px-2 py-1 bg-red-600 text-white text-xs font-bold rounded uppercase">DNF</span>
@elseif($participant->status === 'pending')
<span class="ml-3 px-2 py-1 bg-yellow-600 text-white text-xs font-bold rounded uppercase">In Progress</span>
@endif
</div>
<!-- Time -->
<div class="col-span-2 text-center">
<span class="text-xl md:text-2xl lg:text-3xl font-mono font-bold {{ $isLeader ? 'text-shell-yellow' : '' }}">
@if($participant->formatted_time)
{{ $participant->formatted_time }}
@else
<span class="text-gray-500">--:--.---</span>
@endif
</span>
</div>
<!-- Gap -->
<div class="col-span-2 text-center">
<span class="text-lg md:text-xl font-mono {{ $isLeader ? 'text-shell-yellow font-bold' : 'text-gray-400' }}">
@if($isLeader && $participant->time_ms)
LEADER
@elseif($gap)
{{ $gap }}
@else
-
@endif
</span>
</div>
</div>
</div>
@endforeach
</div>
@endif
</main>
<!-- Footer -->
<footer class="fixed bottom-0 left-0 right-0 bg-gradient-to-r from-shell-dark via-gray-900 to-shell-dark py-4 border-t border-gray-800">
<div class="max-w-7xl mx-auto px-8 flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-8 h-8 bg-shell-yellow rounded flex items-center justify-center">
<span class="text-shell-dark font-black text-sm">S</span>
</div>
<span class="text-gray-400 text-sm">Shell Racing Event</span>
</div>
<div class="flex items-center gap-2">
<span class="inline-block w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
<span class="text-gray-400 text-sm">Auto-refresh every 10s</span>
</div>
</div>
</footer>
<script>
function updateClock() {
const clock = document.getElementById('clock');
if (clock) {
const now = new Date();
clock.textContent = now.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
}
setInterval(updateClock, 1000);
</script>
</body>
</html>

View File

@@ -1,11 +1,13 @@
<?php <?php
use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileController;
use App\Models\Participant;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', function () { Route::get('/', function () {
return redirect()->route('login'); $participants = Participant::ranked()->get();
}); return view('leaderboard', compact('participants'));
})->name('leaderboard');
Route::get('/dashboard', function () { Route::get('/dashboard', function () {
return view('dashboard'); return view('dashboard');

View File

@@ -14,6 +14,13 @@ export default {
fontFamily: { fontFamily: {
sans: ['Figtree', ...defaultTheme.fontFamily.sans], sans: ['Figtree', ...defaultTheme.fontFamily.sans],
}, },
colors: {
shell: {
red: '#E60000',
yellow: '#FFD100',
dark: '#1A1A1A',
},
},
}, },
}, },