generated from theradcoza/Laravel-Docker-Dev-Template
Compare commits
5 Commits
a88504357d
...
52d2af0721
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52d2af0721 | ||
|
|
3533bfc024 | ||
|
|
6520926351 | ||
|
|
adc85b2b88 | ||
|
|
a63ea60860 |
@@ -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}
|
||||||
|
|||||||
237
src/app/Filament/Resources/ParticipantResource.php
Normal file
237
src/app/Filament/Resources/ParticipantResource.php
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<?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('Driver name'),
|
||||||
|
Forms\Components\TextInput::make('number')
|
||||||
|
->label('Number')
|
||||||
|
->numeric()
|
||||||
|
->minValue(1)
|
||||||
|
->maxValue(999)
|
||||||
|
->placeholder('e.g., 44'),
|
||||||
|
Forms\Components\Select::make('status')
|
||||||
|
->options(Participant::statuses())
|
||||||
|
->default('ready')
|
||||||
|
->required(),
|
||||||
|
])->columns(3),
|
||||||
|
|
||||||
|
Forms\Components\Section::make('Best Time')
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Grid::make(3)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('time_minutes')
|
||||||
|
->label('Minutes')
|
||||||
|
->numeric()
|
||||||
|
->minValue(0)
|
||||||
|
->maxValue(59)
|
||||||
|
->default(null)
|
||||||
|
->placeholder('MM')
|
||||||
|
->dehydrated(false)
|
||||||
|
->afterStateHydrated(function ($component, $state, $record) {
|
||||||
|
if ($record && $record->best_time_ms) {
|
||||||
|
$component->state(floor($record->best_time_ms / 60000));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Forms\Components\TextInput::make('time_seconds')
|
||||||
|
->label('Seconds')
|
||||||
|
->numeric()
|
||||||
|
->minValue(0)
|
||||||
|
->maxValue(59)
|
||||||
|
->default(null)
|
||||||
|
->placeholder('SS')
|
||||||
|
->dehydrated(false)
|
||||||
|
->afterStateHydrated(function ($component, $state, $record) {
|
||||||
|
if ($record && $record->best_time_ms) {
|
||||||
|
$component->state(floor(($record->best_time_ms % 60000) / 1000));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Forms\Components\TextInput::make('time_milliseconds')
|
||||||
|
->label('Milliseconds')
|
||||||
|
->numeric()
|
||||||
|
->minValue(0)
|
||||||
|
->maxValue(999)
|
||||||
|
->default(null)
|
||||||
|
->placeholder('ms')
|
||||||
|
->dehydrated(false)
|
||||||
|
->afterStateHydrated(function ($component, $state, $record) {
|
||||||
|
if ($record && $record->best_time_ms) {
|
||||||
|
$component->state($record->best_time_ms % 1000);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
Forms\Components\Hidden::make('best_time_ms'),
|
||||||
|
])
|
||||||
|
->description('Enter best lap time as MM:SS.ms (leave blank if no time set)'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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('number')
|
||||||
|
->label('#')
|
||||||
|
->alignCenter()
|
||||||
|
->placeholder('-')
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('name')
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->weight('bold'),
|
||||||
|
Tables\Columns\TextColumn::make('formatted_time')
|
||||||
|
->label('Best Time')
|
||||||
|
->placeholder('—')
|
||||||
|
->fontFamily('mono')
|
||||||
|
->alignCenter(),
|
||||||
|
Tables\Columns\SelectColumn::make('status')
|
||||||
|
->options(Participant::statuses())
|
||||||
|
->selectablePlaceholder(false),
|
||||||
|
Tables\Columns\TextColumn::make('updated_at')
|
||||||
|
->label('Updated')
|
||||||
|
->dateTime('H:i:s')
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->defaultSort('best_time_ms', 'asc')
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('status')
|
||||||
|
->options(Participant::statuses()),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Tables\Actions\Action::make('quickEdit')
|
||||||
|
->label('Quick Edit')
|
||||||
|
->icon('heroicon-o-bolt')
|
||||||
|
->color('warning')
|
||||||
|
->modalHeading(fn ($record) => 'Quick Edit: ' . $record->name)
|
||||||
|
->modalWidth('md')
|
||||||
|
->form([
|
||||||
|
Forms\Components\Select::make('status')
|
||||||
|
->options(Participant::statuses())
|
||||||
|
->required(),
|
||||||
|
Forms\Components\Grid::make(3)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('time_minutes')
|
||||||
|
->label('Min')
|
||||||
|
->numeric()
|
||||||
|
->minValue(0)
|
||||||
|
->maxValue(59)
|
||||||
|
->placeholder('MM'),
|
||||||
|
Forms\Components\TextInput::make('time_seconds')
|
||||||
|
->label('Sec')
|
||||||
|
->numeric()
|
||||||
|
->minValue(0)
|
||||||
|
->maxValue(59)
|
||||||
|
->placeholder('SS'),
|
||||||
|
Forms\Components\TextInput::make('time_milliseconds')
|
||||||
|
->label('Ms')
|
||||||
|
->numeric()
|
||||||
|
->minValue(0)
|
||||||
|
->maxValue(999)
|
||||||
|
->placeholder('ms'),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->fillForm(fn ($record) => [
|
||||||
|
'status' => $record->status,
|
||||||
|
'time_minutes' => $record->best_time_ms ? floor($record->best_time_ms / 60000) : null,
|
||||||
|
'time_seconds' => $record->best_time_ms ? floor(($record->best_time_ms % 60000) / 1000) : null,
|
||||||
|
'time_milliseconds' => $record->best_time_ms ? $record->best_time_ms % 1000 : null,
|
||||||
|
])
|
||||||
|
->action(function ($record, array $data) {
|
||||||
|
$minutes = $data['time_minutes'] ?? null;
|
||||||
|
$seconds = $data['time_seconds'] ?? null;
|
||||||
|
$milliseconds = $data['time_milliseconds'] ?? null;
|
||||||
|
|
||||||
|
$bestTimeMs = null;
|
||||||
|
if ($minutes !== null || $seconds !== null || $milliseconds !== null) {
|
||||||
|
$bestTimeMs = ((int)$minutes * 60000) + ((int)$seconds * 1000) + (int)$milliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->update([
|
||||||
|
'status' => $data['status'],
|
||||||
|
'best_time_ms' => $bestTimeMs,
|
||||||
|
]);
|
||||||
|
}),
|
||||||
|
Tables\Actions\EditAction::make()
|
||||||
|
->label('Full Edit'),
|
||||||
|
Tables\Actions\DeleteAction::make(),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
Tables\Actions\BulkActionGroup::make([
|
||||||
|
Tables\Actions\DeleteBulkAction::make(),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->poll('5s');
|
||||||
|
}
|
||||||
|
|
||||||
|
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::calculateBestTimeMs($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function mutateFormDataBeforeSave(array $data): array
|
||||||
|
{
|
||||||
|
return static::calculateBestTimeMs($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function calculateBestTimeMs(array $data): array
|
||||||
|
{
|
||||||
|
$minutes = $data['time_minutes'] ?? null;
|
||||||
|
$seconds = $data['time_seconds'] ?? null;
|
||||||
|
$milliseconds = $data['time_milliseconds'] ?? null;
|
||||||
|
|
||||||
|
if ($minutes !== null || $seconds !== null || $milliseconds !== null) {
|
||||||
|
$data['best_time_ms'] = ((int)$minutes * 60000) + ((int)$seconds * 1000) + (int)$milliseconds;
|
||||||
|
} else {
|
||||||
|
$data['best_time_ms'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = $data['time_minutes'] ?? null;
|
||||||
|
$seconds = $data['time_seconds'] ?? null;
|
||||||
|
$milliseconds = $data['time_milliseconds'] ?? null;
|
||||||
|
|
||||||
|
if ($minutes !== null || $seconds !== null || $milliseconds !== null) {
|
||||||
|
$data['best_time_ms'] = ((int)$minutes * 60000) + ((int)$seconds * 1000) + (int)$milliseconds;
|
||||||
|
} else {
|
||||||
|
$data['best_time_ms'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($data['time_minutes'], $data['time_seconds'], $data['time_milliseconds']);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getRedirectUrl(): string
|
||||||
|
{
|
||||||
|
return $this->getResource()::getUrl('index');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = $data['time_minutes'] ?? null;
|
||||||
|
$seconds = $data['time_seconds'] ?? null;
|
||||||
|
$milliseconds = $data['time_milliseconds'] ?? null;
|
||||||
|
|
||||||
|
if ($minutes !== null || $seconds !== null || $milliseconds !== null) {
|
||||||
|
$data['best_time_ms'] = ((int)$minutes * 60000) + ((int)$seconds * 1000) + (int)$milliseconds;
|
||||||
|
} else {
|
||||||
|
$data['best_time_ms'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($data['time_minutes'], $data['time_seconds'], $data['time_milliseconds']);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getRedirectUrl(): string
|
||||||
|
{
|
||||||
|
return $this->getResource()::getUrl('index');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/app/Models/Participant.php
Normal file
65
src/app/Models/Participant.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Participant extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'number',
|
||||||
|
'best_time_ms',
|
||||||
|
'status',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'number' => 'integer',
|
||||||
|
'best_time_ms' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_READY = 'ready';
|
||||||
|
const STATUS_RUNNING = 'running';
|
||||||
|
const STATUS_FINISHED = 'finished';
|
||||||
|
const STATUS_PIT = 'pit';
|
||||||
|
const STATUS_DNF = 'dnf';
|
||||||
|
|
||||||
|
public static function statuses(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::STATUS_READY => 'READY',
|
||||||
|
self::STATUS_RUNNING => 'RUNNING',
|
||||||
|
self::STATUS_FINISHED => 'FINISHED',
|
||||||
|
self::STATUS_PIT => 'IN PIT',
|
||||||
|
self::STATUS_DNF => 'DNF',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFormattedTimeAttribute(): ?string
|
||||||
|
{
|
||||||
|
if ($this->best_time_ms === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalMs = $this->best_time_ms;
|
||||||
|
$minutes = floor($totalMs / 60000);
|
||||||
|
$seconds = floor(($totalMs % 60000) / 1000);
|
||||||
|
$milliseconds = $totalMs % 1000;
|
||||||
|
|
||||||
|
return sprintf('%02d:%02d.%03d', $minutes, $seconds, $milliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusLabelAttribute(): string
|
||||||
|
{
|
||||||
|
return self::statuses()[$this->status] ?? strtoupper($this->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeRanked($query)
|
||||||
|
{
|
||||||
|
return $query->orderByRaw('CASE WHEN best_time_ms IS NULL THEN 1 ELSE 0 END')
|
||||||
|
->orderBy('best_time_ms', 'asc');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('number')->nullable();
|
||||||
|
$table->integer('best_time_ms')->nullable(); // Best lap time in milliseconds
|
||||||
|
$table->string('status')->default('ready'); // ready, running, finished, pit, dnf
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('participants');
|
||||||
|
}
|
||||||
|
};
|
||||||
250
src/resources/views/leaderboard.blade.php
Normal file
250
src/resources/views/leaderboard.blade.php
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
<!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 Race Standings</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
|
<link href="https://fonts.bunny.net/css?family=russo-one:400&display=swap" rel="stylesheet" />
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
shell: {
|
||||||
|
red: '#E60000',
|
||||||
|
yellow: '#FFD100',
|
||||||
|
},
|
||||||
|
broadcast: {
|
||||||
|
bg: '#0E0E0E',
|
||||||
|
bar: '#F5F5F5',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
display: ['Russo One', 'sans-serif'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: #0E0E0E;
|
||||||
|
font-family: 'Russo One', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-light {
|
||||||
|
background: #F5F5F5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-gold {
|
||||||
|
background: linear-gradient(180deg, #FFD700 0%, #B8860B 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-silver {
|
||||||
|
background: linear-gradient(180deg, #C0C0C0 0%, #808080 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-bronze {
|
||||||
|
background: linear-gradient(180deg, #CD7F32 0%, #8B4513 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-default {
|
||||||
|
background: linear-gradient(180deg, #E60000 0%, #990000 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabular {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-pulse {
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.6; transform: scale(0.95); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-shadow {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen antialiased text-white">
|
||||||
|
|
||||||
|
<!-- Header Bar -->
|
||||||
|
<header class="bg-shell-red">
|
||||||
|
<div class="w-full px-4 sm:px-6 lg:px-8 py-3 lg:py-4 flex items-center justify-between">
|
||||||
|
<!-- Left: Logo + Title -->
|
||||||
|
<div class="flex items-center gap-3 lg:gap-5">
|
||||||
|
<div class="w-10 h-10 lg:w-12 lg:h-12 bg-shell-yellow rounded flex items-center justify-center flex-shrink-0">
|
||||||
|
<span class="text-black font-bold text-lg lg:text-xl">S</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl sm:text-3xl lg:text-4xl font-bold text-white tracking-wide uppercase">
|
||||||
|
Race Standings
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Live + Clock -->
|
||||||
|
<div class="flex items-center gap-4 lg:gap-6">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="live-pulse w-3 h-3 lg:w-4 lg:h-4 bg-white rounded-full"></span>
|
||||||
|
<span class="text-white text-base lg:text-xl font-semibold uppercase tracking-wider">Live</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-white text-xl lg:text-2xl font-medium tabular" id="clock">{{ now()->format('H:i') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Column Header -->
|
||||||
|
<div class="bg-[#111111] border-b border-gray-800">
|
||||||
|
<div class="w-full px-2 sm:px-4 lg:px-6 flex items-center h-8 sm:h-9 lg:h-10">
|
||||||
|
<!-- POS column -->
|
||||||
|
<div class="w-12 sm:w-14 lg:w-20 flex-shrink-0 flex items-center justify-center">
|
||||||
|
<span class="text-xs sm:text-sm lg:text-base text-white/60 uppercase tracking-wider">POS</span>
|
||||||
|
</div>
|
||||||
|
<!-- DRIVER column -->
|
||||||
|
<div class="flex-1 flex items-center px-2 sm:px-4 lg:px-6">
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="text-xs sm:text-sm lg:text-base text-white/60 uppercase tracking-wider">DRIVER</span>
|
||||||
|
</div>
|
||||||
|
<!-- STATUS column -->
|
||||||
|
<div class="w-14 sm:w-20 md:w-24 text-center flex-shrink-0">
|
||||||
|
<span class="text-[10px] sm:text-xs md:text-sm text-white/60 uppercase tracking-wider">STATUS</span>
|
||||||
|
</div>
|
||||||
|
<!-- TIME column -->
|
||||||
|
<div class="w-16 sm:w-20 lg:w-28 text-right flex-shrink-0">
|
||||||
|
<span class="text-[10px] sm:text-xs md:text-sm text-white/60 uppercase tracking-wider">TIME</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Leaderboard -->
|
||||||
|
<main class="w-full px-2 sm:px-4 lg:px-6 py-2 sm:py-3 lg:py-4">
|
||||||
|
|
||||||
|
@if($participants->isEmpty())
|
||||||
|
<div class="text-center py-20 lg:py-32">
|
||||||
|
<div class="text-6xl lg:text-8xl mb-6">🏁</div>
|
||||||
|
<h2 class="text-3xl lg:text-5xl font-bold text-white mb-4">WAITING FOR DRIVERS</h2>
|
||||||
|
<p class="text-gray-500 text-xl lg:text-2xl">Race data will appear here</p>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<!-- Standings -->
|
||||||
|
<div class="space-y-1.5 lg:space-y-2">
|
||||||
|
@php
|
||||||
|
$leaderTime = $participants->first()?->best_time_ms;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@foreach($participants as $index => $participant)
|
||||||
|
@php
|
||||||
|
$position = $index + 1;
|
||||||
|
$isLeader = $position === 1;
|
||||||
|
$timeDisplay = null;
|
||||||
|
|
||||||
|
if ($isLeader && $participant->best_time_ms) {
|
||||||
|
$timeDisplay = $participant->formatted_time;
|
||||||
|
} elseif ($leaderTime && $participant->best_time_ms) {
|
||||||
|
$gapMs = $participant->best_time_ms - $leaderTime;
|
||||||
|
$timeDisplay = '+' . number_format($gapMs / 1000, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$posClass = match($position) {
|
||||||
|
1 => 'pos-gold',
|
||||||
|
2 => 'pos-silver',
|
||||||
|
3 => 'pos-bronze',
|
||||||
|
default => 'pos-default'
|
||||||
|
};
|
||||||
|
|
||||||
|
$statusBadge = match($participant->status) {
|
||||||
|
'running' => ['bg-blue-500 text-white', 'RUNNING'],
|
||||||
|
'pit' => ['bg-shell-yellow text-black', 'IN PIT'],
|
||||||
|
'dnf' => ['bg-red-600 text-white', 'DNF'],
|
||||||
|
'finished' => ['bg-green-600 text-white', 'FINISHED'],
|
||||||
|
default => null
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="row-shadow flex items-stretch h-12 sm:h-14 lg:h-18">
|
||||||
|
|
||||||
|
<!-- Position Cube -->
|
||||||
|
<div class="{{ $posClass }} w-12 sm:w-14 lg:w-20 flex-shrink-0 flex items-center justify-center">
|
||||||
|
<span class="tabular text-xl sm:text-2xl lg:text-4xl text-white drop-shadow-md">
|
||||||
|
{{ $position }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Bar (Light) -->
|
||||||
|
<div class="bar-light flex-1 flex items-center px-2 sm:px-4 lg:px-6 overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Driver (Number + Name) -->
|
||||||
|
<div class="flex-1 min-w-0 flex items-center gap-1.5 sm:gap-2 lg:gap-4">
|
||||||
|
@if($participant->number)
|
||||||
|
<span class="tabular text-xs sm:text-sm lg:text-xl text-gray-500 flex-shrink-0">
|
||||||
|
#{{ $participant->number }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
<span class="text-sm sm:text-lg lg:text-2xl text-gray-900 uppercase tracking-wide truncate">
|
||||||
|
{{ $participant->name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="w-14 sm:w-20 md:w-24 flex items-center justify-center flex-shrink-0">
|
||||||
|
@if($statusBadge)
|
||||||
|
<span class="px-1 py-0.5 sm:px-2 {{ $statusBadge[0] }} text-[8px] sm:text-[10px] md:text-xs rounded">
|
||||||
|
{{ $statusBadge[1] }}
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="text-[8px] sm:text-[10px] md:text-xs text-gray-400">READY</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time -->
|
||||||
|
<div class="w-16 sm:w-20 lg:w-28 flex-shrink-0 text-right">
|
||||||
|
@if($timeDisplay)
|
||||||
|
<span class="tabular text-[10px] sm:text-xs lg:text-lg text-gray-900">
|
||||||
|
{{ $timeDisplay }}
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="text-[10px] sm:text-xs lg:text-lg text-gray-400">—</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="fixed bottom-0 left-0 right-0 bg-broadcast-bg border-t border-gray-800">
|
||||||
|
<div class="w-full px-4 sm:px-6 lg:px-8 py-2 lg:py-3 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2 lg:gap-3">
|
||||||
|
<span class="text-shell-red font-bold text-sm lg:text-lg uppercase">Shell</span>
|
||||||
|
<span class="text-gray-600 text-xs lg:text-sm">Racing Event</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
||||||
|
<span class="text-gray-600 text-xs lg:text-sm">Auto-refresh</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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setInterval(updateClock, 1000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user