Initialize repo and add session changes (2025-09-08)

This commit is contained in:
Auto Commit
2025-09-08 14:03:43 +02:00
commit 44ab0a534b
327 changed files with 40952 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Console\Commands;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Illuminate\Console\Attributes\AsCommand;
#[AsCommand(name: 'tenant:add-dummy')]
class AddDummyTenantUser extends Command
{
protected $signature = 'tenant:add-dummy
{--email=demo@example.com}
{--password=secret123!}
{--tenant="Demo Tenant"}
{--name="Demo Admin"}
{--update-password : Overwrite password if user already exists}
';
protected $description = 'Create a demo tenant and a tenant user with given credentials.';
public function handle(): int
{
$email = (string) $this->option('email');
$password = (string) $this->option('password');
$tenantName = (string) $this->option('tenant');
$userName = (string) $this->option('name');
// Pre-flight checks for common failures
if (! Schema::hasTable('users')) {
$this->error("Table 'users' does not exist. Run: php artisan migrate");
return self::FAILURE;
}
if (! Schema::hasTable('tenants')) {
$this->error("Table 'tenants' does not exist. Run: php artisan migrate");
return self::FAILURE;
}
DB::beginTransaction();
try {
// Create or fetch tenant
$slug = Str::slug($tenantName ?: 'demo-tenant');
/** @var Tenant $tenant */
$tenant = Tenant::query()->where('slug', $slug)->first();
if (! $tenant) {
$tenant = new Tenant();
$tenant->name = $tenantName;
$tenant->slug = $slug;
$tenant->domain = null;
$tenant->contact_name = $userName;
$tenant->contact_email = $email;
$tenant->contact_phone = null;
$tenant->event_credits_balance = 1;
$tenant->max_photos_per_event = 500;
$tenant->max_storage_mb = 1024;
$tenant->features = ['custom_branding' => false];
$tenant->save();
}
// Create or fetch user
/** @var User $user */
$user = User::query()->where('email', $email)->first();
$updatePassword = (bool) $this->option('update-password');
if (! $user) {
$user = new User();
if (Schema::hasColumn($user->getTable(), 'name')) $user->name = $userName;
$user->email = $email;
$user->password = Hash::make($password);
} else if ($updatePassword) {
$user->password = Hash::make($password);
}
if (Schema::hasColumn($user->getTable(), 'tenant_id')) {
$user->tenant_id = $tenant->id;
}
if (Schema::hasColumn($user->getTable(), 'role')) {
$user->role = 'tenant_admin';
}
$user->save();
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
$this->error('Failed: '.$e->getMessage());
return self::FAILURE;
}
$this->info('Dummy tenant user created/updated.');
$this->line('Tenant: '.$tenant->name.' (#'.$tenant->id.')');
$this->line('Email: '.$email);
$this->line('Password: '.$password);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Console\Commands;
use App\Models\Event;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[AsCommand(name: 'tenant:attach-demo-event')]
class AttachDemoEvent extends Command
{
protected $signature = 'tenant:attach-demo-event
{--tenant-email=demo@example.com : Email of tenant admin user to locate tenant}
{--tenant-slug= : Tenant slug (overrides tenant-email lookup)}
{--event-id= : Event ID}
{--event-slug= : Event slug}
';
protected $description = 'Attach an existing demo event to a tenant (by email or slug). Safe and idempotent.';
public function handle(): int
{
if (! \Illuminate\Support\Facades\Schema::hasTable('events')) {
$this->error("Table 'events' does not exist. Run: php artisan migrate");
return self::FAILURE;
}
if (! \Illuminate\Support\Facades\Schema::hasColumn('events', 'tenant_id')) {
$this->error("Column 'events.tenant_id' does not exist. Add it and rerun. Suggested: create a migration to add a nullable foreignId to tenants.");
return self::FAILURE;
}
$tenant = null;
if ($slug = $this->option('tenant-slug')) {
$tenant = Tenant::where('slug', $slug)->first();
}
if (! $tenant) {
$email = (string) $this->option('tenant-email');
/** @var User|null $user */
$user = User::where('email', $email)->first();
if ($user && $user->tenant_id) {
$tenant = Tenant::find($user->tenant_id);
}
}
if (! $tenant) {
$this->error('Tenant not found. Provide --tenant-slug or a user with tenant_id via --tenant-email.');
return self::FAILURE;
}
$event = null;
if ($id = $this->option('event-id')) {
$event = Event::find($id);
} elseif ($slug = $this->option('event-slug')) {
$event = Event::where('slug', $slug)->first();
} else {
// Heuristics: first event without tenant, or a demo wedding by slug/name
$event = Event::whereNull('tenant_id')->first();
if (! $event) {
$event = Event::where('slug', 'like', '%demo%')->where('slug', 'like', '%wedding%')->first();
}
if (! $event) {
// Try JSON name contains "Demo" or "Wedding"
$event = Event::where('name', 'like', '%Demo%')->orWhere('name', 'like', '%Wedding%')->first();
}
}
if (! $event) {
$this->error('Event not found. Provide --event-id or --event-slug.');
return self::FAILURE;
}
// Idempotent update
if ((int) $event->tenant_id === (int) $tenant->id) {
$this->info("Event #{$event->id} already attached to tenant #{$tenant->id} ({$tenant->slug}).");
return self::SUCCESS;
}
$event->tenant_id = $tenant->id;
$event->save();
$this->info("Attached event #{$event->id} ({$event->slug}) to tenant #{$tenant->id} ({$tenant->slug}).");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Console\Commands;
use App\Support\ImageHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class BackfillThumbnails extends Command
{
protected $signature = 'media:backfill-thumbnails {--limit=500}';
protected $description = 'Generate thumbnails for photos missing thumbnail_path or where thumbnail equals original.';
public function handle(): int
{
$limit = (int) $this->option('limit');
$rows = DB::table('photos')
->select(['id','event_id','file_path','thumbnail_path'])
->orderBy('id')
->limit($limit)
->get();
$count = 0;
foreach ($rows as $r) {
$orig = $this->relativeFromUrl((string)$r->file_path);
$thumb = (string)($r->thumbnail_path ?? '');
if ($thumb && $thumb !== $r->file_path) continue; // already set to different thumb
if (! $orig) continue;
$baseName = pathinfo($orig, PATHINFO_FILENAME);
$destRel = "events/{$r->event_id}/photos/thumbs/{$baseName}_thumb.jpg";
$made = ImageHelper::makeThumbnailOnDisk('public', $orig, $destRel, 640, 82);
if ($made) {
$url = Storage::url($made);
DB::table('photos')->where('id', $r->id)->update(['thumbnail_path' => $url, 'updated_at' => now()]);
$count++;
$this->line("Photo {$r->id}: thumb created");
}
}
$this->info("Done. Thumbnails generated: {$count}");
return self::SUCCESS;
}
private function relativeFromUrl(string $url): ?string
{
// Assume Storage::url maps to /storage/*
$p = parse_url($url, PHP_URL_PATH) ?? '';
if (str_starts_with($p, '/storage/')) {
return substr($p, strlen('/storage/'));
}
return null;
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\EmotionResource\Pages;
use App\Models\Emotion;
use Filament\Schemas\Schema as Schema;
use Filament\Schemas\Components as SC;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class EmotionResource extends Resource
{
protected static ?string $model = Emotion::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-face-smile';
protected static string|\UnitEnum|null $navigationGroup = 'Library';
protected static ?int $navigationSort = 10;
public static function form(Schema $schema): Schema
{
return $schema->components([
SC\KeyValue::make('name')->label('Name (de/en)')->keyLabel('locale')->valueLabel('value')->default(['de' => '', 'en' => ''])->required(),
SC\TextInput::make('icon')->label('Icon/Emoji')->maxLength(50),
SC\TextInput::make('color')->maxLength(7)->helperText('#RRGGBB'),
SC\KeyValue::make('description')->label('Description (de/en)')->keyLabel('locale')->valueLabel('value'),
SC\TextInput::make('sort_order')->numeric()->default(0),
SC\Toggle::make('is_active')->default(true),
SC\Select::make('eventTypes')
->label('Event Types')
->multiple()
->searchable()
->preload()
->relationship('eventTypes', 'name'),
])->columns(2);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('name')->searchable(),
Tables\Columns\TextColumn::make('icon'),
Tables\Columns\TextColumn::make('color'),
Tables\Columns\IconColumn::make('is_active')->boolean(),
Tables\Columns\TextColumn::make('sort_order')->sortable(),
])
->filters([])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ManageEmotions::route('/'),
'import' => Pages\ImportEmotions::route('/import'),
];
}
}
namespace App\Filament\Resources\EmotionResource\Pages;
use App\Filament\Resources\EmotionResource;
use Filament\Resources\Pages\ManageRecords;
use Filament\Actions;
use Filament\Resources\Pages\Page;
use Filament\Forms;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
class ManageEmotions extends ManageRecords
{
protected static string $resource = EmotionResource::class;
protected function getHeaderActions(): array
{
return [
Actions\Action::make('import')
->label('Import CSV')
->icon('heroicon-o-arrow-up-tray')
->url(EmotionResource::getUrl('import')),
Actions\Action::make('template')
->label('Download CSV Template')
->icon('heroicon-o-document-arrow-down')
->url(url('/super-admin/templates/emotions.csv')),
];
}
}
class ImportEmotions extends Page
{
protected static string $resource = EmotionResource::class;
protected string $view = 'filament.pages.blank';
protected ?string $heading = 'Import Emotions (CSV)';
public ?string $file = null;
protected function getFormSchema(): array
{
return [
Forms\Components\FileUpload::make('file')
->label('CSV file')
->acceptedFileTypes(['text/csv', 'text/plain'])
->directory('imports')
->required(),
];
}
protected function getFormActions(): array
{
return [
Forms\Components\Actions\Action::make('import')
->label('Import')
->action('doImport')
->color('primary')
];
}
public function doImport(): void
{
$state = $this->form->getState();
$path = $state['file'] ?? null;
if (! $path || ! Storage::disk('public')->exists($path)) {
Notification::make()->danger()->title('File not found')->send();
return;
}
$full = Storage::disk('public')->path($path);
[$ok, $fail] = $this->importEmotionsCsv($full);
Notification::make()->success()->title("Imported {$ok} rows")->body($fail ? "{$fail} failed" : null)->send();
}
private function importEmotionsCsv(string $file): array
{
$h = fopen($file, 'r');
if (! $h) return [0,0];
$ok = 0; $fail = 0;
// Expected headers: name_de,name_en,icon,color,description_de,description_en,sort_order,is_active,event_types
$headers = fgetcsv($h, 0, ',');
if (! $headers) return [0,0];
$map = array_flip($headers);
while (($row = fgetcsv($h, 0, ',')) !== false) {
try {
$nameDe = trim($row[$map['name_de']] ?? '');
$nameEn = trim($row[$map['name_en']] ?? '');
$name = $nameDe ?: $nameEn;
if ($name === '') { $fail++; continue; }
$data = [
'name' => ['de' => $nameDe, 'en' => $nameEn],
'icon' => $row[$map['icon']] ?? null,
'color' => $row[$map['color']] ?? null,
'description' => [
'de' => $row[$map['description_de']] ?? null,
'en' => $row[$map['description_en']] ?? null,
],
'sort_order' => (int)($row[$map['sort_order']] ?? 0),
'is_active' => (int)($row[$map['is_active']] ?? 1) ? 1 : 0,
];
$id = DB::table('emotions')->insertGetId(array_merge($data, [
'created_at' => now(), 'updated_at' => now(),
]));
// Attach event types if provided (by slug list separated by '|')
$et = $row[$map['event_types']] ?? '';
if ($et) {
$slugs = array_filter(array_map('trim', explode('|', $et)));
if ($slugs) {
$ids = DB::table('event_types')->whereIn('slug', $slugs)->pluck('id')->all();
foreach ($ids as $eid) {
DB::table('emotion_event_type')->insertOrIgnore([
'emotion_id' => $id,
'event_type_id' => $eid,
]);
}
}
}
$ok++;
} catch (\Throwable $e) {
$fail++;
}
}
fclose($h);
return [$ok, $fail];
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\EventResource\Pages;
use App\Models\Event;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class EventResource extends Resource
{
protected static ?string $model = Event::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-calendar';
protected static string|\UnitEnum|null $navigationGroup = 'Platform';
protected static ?int $navigationSort = 20;
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('tenant_id')->label('Tenant')->sortable(),
Tables\Columns\TextColumn::make('name')->limit(30),
Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('date')->date(),
Tables\Columns\IconColumn::make('is_active')->boolean(),
Tables\Columns\TextColumn::make('default_locale'),
Tables\Columns\TextColumn::make('join')->label('Join')
->getStateUsing(fn($record) => url("/e/{$record->slug}"))
->copyable()
->copyMessage('Join link copied'),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->filters([])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
Tables\Actions\Action::make('toggle')
->label('Toggle Active')
->icon('heroicon-o-power')
->action(fn($record) => $record->update(['is_active' => ! (bool)$record->is_active])),
Tables\Actions\Action::make('join_link')
->label('Join Link / QR')
->icon('heroicon-o-qr-code')
->modalHeading('Event Join Link')
->modalSubmitActionLabel('Close')
->modalContent(fn($record) => view('filament.events.join-link', [
'link' => url("/e/{$record->slug}"),
])),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListEvents::route('/'),
'view' => Pages\ViewEvent::route('/{record}'),
'edit' => Pages\EditEvent::route('/{record}/edit'),
];
}
}
namespace App\Filament\Resources\EventResource\Pages;
use App\Filament\Resources\EventResource;
use Filament\Resources\Pages\ListRecords;
use Filament\Resources\Pages\ViewRecord;
use Filament\Resources\Pages\EditRecord;
class ListEvents extends ListRecords
{
protected static string $resource = EventResource::class;
}
class ViewEvent extends ViewRecord
{
protected static string $resource = EventResource::class;
}
class EditEvent extends EditRecord
{
protected static string $resource = EventResource::class;
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\EventTypeResource\Pages;
use App\Models\EventType;
use Filament\Schemas\Schema as Schema;
use Filament\Schemas\Components as SC;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class EventTypeResource extends Resource
{
protected static ?string $model = EventType::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-swatch';
protected static string|\UnitEnum|null $navigationGroup = 'Library';
protected static ?int $navigationSort = 20;
public static function form(Schema $schema): Schema
{
return $schema->components([
SC\KeyValue::make('name')->label('Name (de/en)')->default(['de' => '', 'en' => ''])->required(),
SC\TextInput::make('slug')->required()->unique(ignoreRecord: true),
SC\TextInput::make('icon')->maxLength(64),
SC\KeyValue::make('settings')->label('Settings')->keyLabel('key')->valueLabel('value'),
SC\Select::make('emotions')
->label('Emotions')
->multiple()
->searchable()
->preload()
->relationship('emotions', 'name'),
])->columns(2);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('name')->searchable(),
Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('icon'),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->filters([])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ManageEventTypes::route('/'),
];
}
}
namespace App\Filament\Resources\EventTypeResource\Pages;
use App\Filament\Resources\EventTypeResource;
use Filament\Resources\Pages\ManageRecords;
class ManageEventTypes extends ManageRecords
{
protected static string $resource = EventTypeResource::class;
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\LegalPageResource\Pages;
use App\Models\LegalPage;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class LegalPageResource extends Resource
{
protected static ?string $model = LegalPage::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-scale';
protected static string|\UnitEnum|null $navigationGroup = 'Platform';
protected static ?int $navigationSort = 40;
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('slug')->badge(),
Tables\Columns\TextColumn::make('version')->badge(),
Tables\Columns\IconColumn::make('is_published')->boolean(),
Tables\Columns\TextColumn::make('effective_from')->date(),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->filters([])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListLegalPages::route('/'),
'view' => Pages\ViewLegalPage::route('/{record}'),
'edit' => Pages\EditLegalPage::route('/{record}/edit'),
];
}
}
namespace App\Filament\Resources\LegalPageResource\Pages;
use App\Filament\Resources\LegalPageResource;
use Filament\Resources\Pages\ListRecords;
use Filament\Resources\Pages\ViewRecord;
use Filament\Resources\Pages\EditRecord;
class ListLegalPages extends ListRecords
{
protected static string $resource = LegalPageResource::class;
}
class ViewLegalPage extends ViewRecord
{
protected static string $resource = LegalPageResource::class;
}
class EditLegalPage extends EditRecord
{
protected static string $resource = LegalPageResource::class;
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\PhotoResource\Pages;
use App\Models\Photo;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class PhotoResource extends Resource
{
protected static ?string $model = Photo::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-photo';
protected static string|\UnitEnum|null $navigationGroup = 'Content';
protected static ?int $navigationSort = 30;
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\ImageColumn::make('thumbnail_path')->label('Thumb')->circular(),
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('event_id')->label('Event'),
Tables\Columns\TextColumn::make('likes_count')->label('Likes'),
Tables\Columns\IconColumn::make('is_featured')->boolean(),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->filters([])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
Tables\Actions\Action::make('feature')
->label('Feature')
->visible(fn($record) => ! (bool)$record->is_featured)
->action(fn($record) => $record->update(['is_featured' => 1]))
->icon('heroicon-o-star'),
Tables\Actions\Action::make('unfeature')
->label('Unfeature')
->visible(fn($record) => (bool)$record->is_featured)
->action(fn($record) => $record->update(['is_featured' => 0]))
->icon('heroicon-o-star'),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\BulkAction::make('feature')
->label('Feature selected')
->icon('heroicon-o-star')
->action(fn($records) => $records->each->update(['is_featured' => 1])),
Tables\Actions\BulkAction::make('unfeature')
->label('Unfeature selected')
->icon('heroicon-o-star')
->action(fn($records) => $records->each->update(['is_featured' => 0])),
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListPhotos::route('/'),
'view' => Pages\ViewPhoto::route('/{record}'),
'edit' => Pages\EditPhoto::route('/{record}/edit'),
];
}
}
namespace App\Filament\Resources\PhotoResource\Pages;
use App\Filament\Resources\PhotoResource;
use Filament\Resources\Pages\ListRecords;
use Filament\Resources\Pages\ViewRecord;
use Filament\Resources\Pages\EditRecord;
class ListPhotos extends ListRecords
{
protected static string $resource = PhotoResource::class;
}
class ViewPhoto extends ViewRecord
{
protected static string $resource = PhotoResource::class;
}
class EditPhoto extends EditRecord
{
protected static string $resource = PhotoResource::class;
}

View File

@@ -0,0 +1,203 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\TaskResource\Pages;
use App\Models\Task;
use Filament\Schemas\Schema as Schema;
use Filament\Schemas\Components as SC;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class TaskResource extends Resource
{
protected static ?string $model = Task::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-check';
protected static string|\UnitEnum|null $navigationGroup = 'Library';
protected static ?int $navigationSort = 30;
public static function form(Schema $schema): Schema
{
return $schema->components([
SC\Select::make('emotion_id')->relationship('emotion', 'name')->required()->searchable()->preload(),
SC\Select::make('event_type_id')->relationship('eventType', 'name')->searchable()->preload()->label('Event Type (optional)'),
SC\KeyValue::make('title')->label('Title (de/en)')->default(['de' => '', 'en' => ''])->required(),
SC\KeyValue::make('description')->label('Description (de/en)'),
SC\Select::make('difficulty')->options([
'easy' => 'Easy',
'medium' => 'Medium',
'hard' => 'Hard',
])->default('easy'),
SC\KeyValue::make('example_text')->label('Example (de/en)'),
SC\TextInput::make('sort_order')->numeric()->default(0),
SC\Toggle::make('is_active')->default(true),
])->columns(2);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('emotion.name')->label('Emotion')->sortable()->searchable(),
Tables\Columns\TextColumn::make('eventType.name')->label('Event Type')->toggleable(),
Tables\Columns\TextColumn::make('title')->searchable()->limit(40),
Tables\Columns\TextColumn::make('difficulty')->badge(),
Tables\Columns\IconColumn::make('is_active')->boolean(),
Tables\Columns\TextColumn::make('sort_order')->sortable(),
])
->filters([])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ManageTasks::route('/'),
'import' => Pages\ImportTasks::route('/import'),
];
}
}
namespace App\Filament\Resources\TaskResource\Pages;
use App\Filament\Resources\TaskResource;
use Filament\Resources\Pages\ManageRecords;
use Filament\Actions;
use Filament\Resources\Pages\Page;
use Filament\Forms;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
class ManageTasks extends ManageRecords
{
protected static string $resource = TaskResource::class;
protected function getHeaderActions(): array
{
return [
Actions\Action::make('import')
->label('Import CSV')
->icon('heroicon-o-arrow-up-tray')
->url(TaskResource::getUrl('import')),
Actions\Action::make('template')
->label('Download CSV Template')
->icon('heroicon-o-document-arrow-down')
->url(url('/super-admin/templates/tasks.csv')),
];
}
}
class ImportTasks extends Page
{
protected static string $resource = TaskResource::class;
protected string $view = 'filament.pages.blank';
protected ?string $heading = 'Import Tasks (CSV)';
public ?string $file = null;
protected function getFormSchema(): array
{
return [
Forms\Components\FileUpload::make('file')
->label('CSV file')
->acceptedFileTypes(['text/csv', 'text/plain'])
->directory('imports')
->required(),
];
}
protected function getFormActions(): array
{
return [
Forms\Components\Actions\Action::make('import')
->label('Import')
->action('doImport')
->color('primary')
];
}
public function doImport(): void
{
$state = $this->form->getState();
$path = $state['file'] ?? null;
if (! $path || ! Storage::disk('public')->exists($path)) {
Notification::make()->danger()->title('File not found')->send();
return;
}
$full = Storage::disk('public')->path($path);
[$ok, $fail] = $this->importTasksCsv($full);
Notification::make()->success()->title("Imported {$ok} rows")->body($fail ? "${fail} failed" : null)->send();
}
private function importTasksCsv(string $file): array
{
$h = fopen($file, 'r');
if (! $h) return [0,0];
$ok = 0; $fail = 0;
// Expected headers: emotion_name,emotion_name_de,emotion_name_en,event_type_slug,title_de,title_en,description_de,description_en,difficulty,example_text_de,example_text_en,sort_order,is_active
$headers = fgetcsv($h, 0, ',');
if (! $headers) return [0,0];
$map = array_flip($headers);
while (($row = fgetcsv($h, 0, ',')) !== false) {
try {
$emotionName = trim($row[$map['emotion_name']] ?? '');
$emotionNameDe = trim($row[$map['emotion_name_de']] ?? '');
$emotionNameEn = trim($row[$map['emotion_name_en']] ?? '');
$emotionId = null;
if ($emotionName !== '') {
$emotionId = DB::table('emotions')->where('name', $emotionName)->value('id');
}
if (! $emotionId && $emotionNameDe !== '') {
$emotionId = DB::table('emotions')->where('name', 'like', '%"de":"'.str_replace('"','""',$emotionNameDe).'"%')->value('id');
}
if (! $emotionId && $emotionNameEn !== '') {
$emotionId = DB::table('emotions')->where('name', 'like', '%"en":"'.str_replace('"','""',$emotionNameEn).'"%')->value('id');
}
if (! $emotionId) { $fail++; continue; }
$eventTypeSlug = trim($row[$map['event_type_slug']] ?? '');
$eventTypeId = null;
if ($eventTypeSlug !== '') {
$eventTypeId = DB::table('event_types')->where('slug', $eventTypeSlug)->value('id');
}
$data = [
'emotion_id' => $emotionId,
'event_type_id' => $eventTypeId,
'title' => [
'de' => $row[$map['title_de']] ?? null,
'en' => $row[$map['title_en']] ?? null,
],
'description' => [
'de' => $row[$map['description_de']] ?? null,
'en' => $row[$map['description_en']] ?? null,
],
'difficulty' => $row[$map['difficulty']] ?? 'easy',
'example_text' => [
'de' => $row[$map['example_text_de']] ?? null,
'en' => $row[$map['example_text_en']] ?? null,
],
'sort_order' => (int)($row[$map['sort_order']] ?? 0),
'is_active' => (int)($row[$map['is_active']] ?? 1) ? 1 : 0,
'created_at' => now(),
'updated_at' => now(),
];
DB::table('tasks')->insert($data);
$ok++;
} catch (\Throwable $e) {
$fail++;
}
}
fclose($h);
return [$ok, $fail];
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\TenantResource\Pages;
use App\Models\Tenant;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class TenantResource extends Resource
{
protected static ?string $model = Tenant::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-building-office';
protected static string|\UnitEnum|null $navigationGroup = 'Platform';
protected static ?int $navigationSort = 10;
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('contact_email'),
Tables\Columns\TextColumn::make('event_credits_balance')->label('Credits'),
Tables\Columns\TextColumn::make('last_activity_at')->since()->label('Last activity'),
Tables\Columns\TextColumn::make('created_at')->dateTime()->toggleable(isToggledHiddenByDefault: true),
])
->filters([])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListTenants::route('/'),
'view' => Pages\ViewTenant::route('/{record}'),
'edit' => Pages\EditTenant::route('/{record}/edit'),
];
}
}
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource;
use Filament\Resources\Pages\ListRecords;
use Filament\Resources\Pages\ViewRecord;
use Filament\Resources\Pages\EditRecord;
class ListTenants extends ListRecords
{
protected static string $resource = TenantResource::class;
}
class ViewTenant extends ViewRecord
{
protected static string $resource = TenantResource::class;
}
class EditTenant extends EditRecord
{
protected static string $resource = TenantResource::class;
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Filament\Widgets;
use Filament\Tables;
use Filament\Widgets\TableWidget as BaseWidget;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon;
class EventsActiveToday extends BaseWidget
{
protected static ?string $heading = 'Events active today';
protected ?string $pollingInterval = '60s';
public function table(Tables\Table $table): Tables\Table
{
$today = Carbon::today()->toDateString();
$query = DB::table('events as e')
->leftJoin('photos as p', function ($join) use ($today) {
$join->on('p.event_id', '=', 'e.id')
->whereRaw("date(p.created_at) = ?", [$today]);
})
->where('e.is_active', 1)
->whereDate('e.date', '<=', $today)
->selectRaw('e.id, e.slug, e.name, e.date, COUNT(p.id) as uploads_today')
->groupBy('e.id', 'e.slug', 'e.name', 'e.date')
->orderBy('e.date', 'desc')
->limit(10);
return $table
->query($query)
->columns([
Tables\Columns\TextColumn::make('id')->label('#')->width('60px'),
Tables\Columns\TextColumn::make('slug')->label('Slug')->searchable(),
Tables\Columns\TextColumn::make('date')->date(),
Tables\Columns\TextColumn::make('uploads_today')->label('Uploads today')->numeric(),
])
->paginated(false);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Filament\Widgets;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon;
class PlatformStatsWidget extends BaseWidget
{
protected ?string $pollingInterval = '30s';
protected function getStats(): array
{
$now = Carbon::now();
$dayAgo = $now->copy()->subDay();
$tenants = (int) DB::table('tenants')->count();
$events = (int) DB::table('events')->count();
$photos = (int) DB::table('photos')->count();
$photos24h = (int) DB::table('photos')->where('created_at', '>=', $dayAgo)->count();
return [
Stat::make('Tenants', number_format($tenants)),
Stat::make('Events', number_format($events)),
Stat::make('Photos', number_format($photos))
->description("+{$photos24h} in last 24h"),
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Filament\Widgets;
use Filament\Tables;
use Filament\Widgets\TableWidget as BaseWidget;
use Illuminate\Support\Facades\DB;
class RecentPhotosTable extends BaseWidget
{
protected static ?string $heading = 'Recent uploads';
protected int|string|array $columnSpan = 'full';
public function table(Tables\Table $table): Tables\Table
{
$query = DB::table('photos')->orderByDesc('created_at')->limit(10);
return $table
->query($query)
->columns([
Tables\Columns\ImageColumn::make('thumbnail_path')->label('Thumb')->circular(),
Tables\Columns\TextColumn::make('id')->label('#'),
Tables\Columns\TextColumn::make('event_id')->label('Event'),
Tables\Columns\TextColumn::make('likes_count')->label('Likes'),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->actions([
Tables\Actions\Action::make('feature')
->label('Feature')
->visible(fn($record) => ! (bool)($record->is_featured ?? 0))
->action(fn($record) => DB::table('photos')->where('id', $record->id)->update(['is_featured' => 1, 'updated_at' => now()])),
Tables\Actions\Action::make('unfeature')
->label('Unfeature')
->visible(fn($record) => (bool)($record->is_featured ?? 0))
->action(fn($record) => DB::table('photos')->where('id', $record->id)->update(['is_featured' => 0, 'updated_at' => now()])),
])
->paginated(false);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Filament\Widgets;
use Filament\Tables;
use Filament\Widgets\TableWidget as BaseWidget;
use Illuminate\Support\Facades\DB;
class TopTenantsByUploads extends BaseWidget
{
protected static ?string $heading = 'Top tenants by uploads';
protected ?string $pollingInterval = '60s';
public function table(Tables\Table $table): Tables\Table
{
$query = DB::table('photos as p')
->join('events as e', 'e.id', '=', 'p.event_id')
->join('tenants as t', 't.id', '=', 'e.tenant_id')
->selectRaw('t.id as tenant_id, t.name as tenant_name, COUNT(p.id) as uploads')
->groupBy('t.id', 't.name')
->orderByDesc('uploads')
->limit(5);
return $table
->query($query)
->columns([
Tables\Columns\TextColumn::make('tenant_id')->label('#')->width('60px'),
Tables\Columns\TextColumn::make('tenant_name')->label('Tenant')->searchable(),
Tables\Columns\TextColumn::make('uploads')->label('Uploads')->numeric(),
])
->paginated(false);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Filament\Widgets;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon;
class UploadsPerDayChart extends ChartWidget
{
protected ?string $heading = 'Uploads (14 days)';
protected ?string $maxHeight = '220px';
protected ?string $pollingInterval = '60s';
protected function getData(): array
{
// Build last 14 days labels
$labels = [];
$start = Carbon::now()->startOfDay()->subDays(13);
for ($i = 0; $i < 14; $i++) {
$labels[] = $start->copy()->addDays($i)->format('Y-m-d');
}
// SQLite-friendly group by date
$rows = DB::table('photos')
->selectRaw("strftime('%Y-%m-%d', created_at) as d, count(*) as c")
->where('created_at', '>=', $start)
->groupBy('d')
->orderBy('d')
->get();
$map = collect($rows)->keyBy('d');
$data = array_map(fn ($d) => (int) ($map[$d]->c ?? 0), $labels);
return [
'labels' => $labels,
'datasets' => [
[
'label' => 'Uploads',
'data' => $data,
'borderColor' => '#f59e0b',
'backgroundColor' => 'rgba(245, 158, 11, 0.2)',
'tension' => 0.3,
],
],
];
}
protected function getType(): string
{
return 'line';
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Admin;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Http\Request;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
class QrController extends BaseController
{
public function png(Request $request)
{
$data = (string) $request->query('data', '');
if ($data === '') {
return response('missing data', 400);
}
$png = QrCode::format('png')->size(300)->generate($data);
return response($png, 200, ['Content-Type' => 'image/png']);
}
}

View File

@@ -0,0 +1,218 @@
<?php
namespace App\Http\Controllers\Api;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use App\Support\ImageHelper;
class EventPublicController extends BaseController
{
public function event(string $slug)
{
$event = DB::table('events')->where('slug', $slug)->first([
'id', 'slug', 'name', 'default_locale', 'created_at', 'updated_at'
]);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found']], 404);
}
return response()->json([
'id' => $event->id,
'slug' => $event->slug,
'name' => $event->name,
'default_locale' => $event->default_locale,
'created_at' => $event->created_at,
'updated_at' => $event->updated_at,
])->header('Cache-Control', 'no-store');
}
public function stats(string $slug)
{
$event = DB::table('events')->where('slug', $slug)->first(['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found']], 404);
}
$eventId = $event->id;
// Approximate online guests as distinct recent uploaders in last 10 minutes.
$tenMinutesAgo = CarbonImmutable::now()->subMinutes(10);
$onlineGuests = DB::table('photos')
->where('event_id', $eventId)
->where('created_at', '>=', $tenMinutesAgo)
->distinct('guest_name')
->count('guest_name');
// Tasks solved as number of photos linked to a task (proxy metric).
$tasksSolved = DB::table('photos')->where('event_id', $eventId)->whereNotNull('task_id')->count();
$latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at');
$payload = [
'online_guests' => $onlineGuests,
'tasks_solved' => $tasksSolved,
'latest_photo_at' => $latestPhotoAt,
];
$etag = sha1(json_encode($payload));
$reqEtag = request()->headers->get('If-None-Match');
if ($reqEtag && $reqEtag === $etag) {
return response('', 304);
}
return response()->json($payload)
->header('Cache-Control', 'no-store')
->header('ETag', $etag);
}
public function photos(Request $request, string $slug)
{
$event = DB::table('events')->where('slug', $slug)->first(['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found']], 404);
}
$eventId = $event->id;
$since = $request->query('since');
$query = DB::table('photos')
->select(['id', 'file_path', 'thumbnail_path', 'likes_count', 'emotion_id', 'task_id', 'created_at'])
->where('event_id', $eventId)
->orderByDesc('created_at')
->limit(60);
if ($since) {
$query->where('created_at', '>', $since);
}
$rows = $query->get();
$latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at');
$payload = [
'data' => $rows,
'latest_photo_at' => $latestPhotoAt,
];
$etag = sha1(json_encode([$since, $latestPhotoAt]));
$reqEtag = request()->headers->get('If-None-Match');
if ($reqEtag && $reqEtag === $etag) {
return response('', 304);
}
return response()->json($payload)
->header('Cache-Control', 'no-store')
->header('ETag', $etag)
->header('Last-Modified', (string)$latestPhotoAt);
}
public function photo(int $id)
{
$row = DB::table('photos')
->select(['id', 'event_id', 'file_path', 'thumbnail_path', 'likes_count', 'emotion_id', 'task_id', 'created_at'])
->where('id', $id)
->first();
if (! $row) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Photo not found']], 404);
}
return response()->json($row)->header('Cache-Control', 'no-store');
}
public function like(Request $request, int $id)
{
$deviceId = (string) $request->header('X-Device-Id', 'anon');
$deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64);
if ($deviceId === '') {
$deviceId = 'anon';
}
$photo = DB::table('photos')->where('id', $id)->first(['id', 'event_id']);
if (! $photo) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Photo not found']], 404);
}
// Idempotent like per device
$exists = DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->exists();
if ($exists) {
$count = (int) DB::table('photos')->where('id', $id)->value('likes_count');
return response()->json(['liked' => true, 'likes_count' => $count]);
}
DB::beginTransaction();
try {
DB::table('photo_likes')->insert([
'photo_id' => $id,
'guest_name' => $deviceId,
'ip_address' => 'device',
'created_at' => now(),
]);
DB::table('photos')->where('id', $id)->update([
'likes_count' => DB::raw('likes_count + 1'),
'updated_at' => now(),
]);
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
Log::warning('like failed', ['error' => $e->getMessage()]);
}
$count = (int) DB::table('photos')->where('id', $id)->value('likes_count');
return response()->json(['liked' => true, 'likes_count' => $count]);
}
public function upload(Request $request, string $slug)
{
$event = DB::table('events')->where('slug', $slug)->first(['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found']], 404);
}
$deviceId = (string) $request->header('X-Device-Id', 'anon');
$deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64) ?: 'anon';
// Per-device cap per event (MVP: 50)
$deviceCount = DB::table('photos')->where('event_id', $event->id)->where('guest_name', $deviceId)->count();
if ($deviceCount >= 50) {
return response()->json(['error' => ['code' => 'limit_reached', 'message' => 'Upload-Limit erreicht']], 429);
}
$validated = $request->validate([
'photo' => ['required', 'image', 'max:6144'], // 6 MB
'emotion_id' => ['nullable', 'integer'],
'task_id' => ['nullable', 'integer'],
'guest_name' => ['nullable', 'string', 'max:255'],
]);
$file = $validated['photo'];
$path = Storage::disk('public')->putFile("events/{$event->id}/photos", $file);
$url = Storage::url($path);
// Generate thumbnail (JPEG) under photos/thumbs
$baseName = pathinfo($path, PATHINFO_FILENAME);
$thumbRel = "events/{$event->id}/photos/thumbs/{$baseName}_thumb.jpg";
$thumbPath = ImageHelper::makeThumbnailOnDisk('public', $path, $thumbRel, 640, 82);
$thumbUrl = $thumbPath ? Storage::url($thumbPath) : $url;
$id = DB::table('photos')->insertGetId([
'event_id' => $event->id,
'emotion_id' => $validated['emotion_id'] ?? null,
'task_id' => $validated['task_id'] ?? null,
'guest_name' => $validated['guest_name'] ?? $deviceId,
'file_path' => $url,
'thumbnail_path' => $thumbUrl,
'likes_count' => 0,
'is_featured' => 0,
'metadata' => null,
'created_at' => now(),
'updated_at' => now(),
]);
return response()->json([
'id' => $id,
'file_path' => $url,
'thumbnail_path' => $thumbUrl,
], 201);
}
}

View File

@@ -0,0 +1,207 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Event;
use App\Models\Photo;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class TenantController extends BaseController
{
public function login(Request $request)
{
$creds = $request->validate([
'email' => ['required','email'],
'password' => ['required','string'],
]);
if (! Auth::attempt($creds)) {
return response()->json(['error' => ['code' => 'invalid_credentials']], 401);
}
/** @var User $user */
$user = Auth::user();
// naive token (cache-based), expires in 8 hours
$token = Str::random(80);
Cache::put('api_token:'.$token, $user->id, now()->addHours(8));
return response()->json([
'token' => $token,
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'tenant_id' => $user->tenant_id ?? null,
],
]);
}
public function me(Request $request)
{
$u = Auth::user();
return response()->json([
'id' => $u->id,
'name' => $u->name,
'email' => $u->email,
'tenant_id' => $u->tenant_id ?? null,
]);
}
public function events()
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$q = Event::query();
if ($tenantId) {
$q->where('tenant_id', $tenantId);
}
return response()->json(['data' => $q->orderByDesc('created_at')->limit(100)->get(['id','name','slug','date','is_active'])]);
}
public function showEvent(int $id)
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$ev = Event::findOrFail($id);
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
}
return response()->json($ev->only(['id','name','slug','date','is_active','default_locale']));
}
public function storeEvent(Request $request)
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$data = $request->validate([
'name' => ['required','string','max:255'],
'slug' => ['required','string','max:255'],
'date' => ['nullable','date'],
'is_active' => ['boolean'],
]);
$ev = new Event();
$ev->tenant_id = $tenantId ?? $ev->tenant_id;
$ev->name = ['de' => $data['name'], 'en' => $data['name']];
$ev->slug = $data['slug'];
$ev->date = $data['date'] ?? null;
$ev->is_active = (bool)($data['is_active'] ?? true);
$ev->default_locale = 'de';
$ev->save();
return response()->json(['id' => $ev->id]);
}
public function updateEvent(Request $request, int $id)
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$ev = Event::findOrFail($id);
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
}
$data = $request->validate([
'name' => ['nullable','string','max:255'],
'slug' => ['nullable','string','max:255'],
'date' => ['nullable','date'],
'is_active' => ['nullable','boolean'],
]);
if (isset($data['name'])) $ev->name = ['de' => $data['name'], 'en' => $data['name']];
if (isset($data['slug'])) $ev->slug = $data['slug'];
if (array_key_exists('date', $data)) $ev->date = $data['date'];
if (array_key_exists('is_active', $data)) $ev->is_active = (bool)$data['is_active'];
$ev->save();
return response()->json(['ok' => true]);
}
public function toggleEvent(int $id)
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$ev = Event::findOrFail($id);
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
}
$ev->is_active = ! (bool) $ev->is_active;
$ev->save();
return response()->json(['is_active' => (bool)$ev->is_active]);
}
public function eventStats(int $id)
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$ev = Event::findOrFail($id);
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
}
$total = Photo::where('event_id', $id)->count();
$featured = Photo::where('event_id', $id)->where('is_featured', 1)->count();
$likes = Photo::where('event_id', $id)->sum('likes_count');
return response()->json([
'total' => (int)$total,
'featured' => (int)$featured,
'likes' => (int)$likes,
]);
}
public function createInvite(int $id)
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$ev = Event::findOrFail($id);
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
}
$token = Str::random(32);
Cache::put('invite:'.$token, $ev->slug, now()->addDays(2));
$link = url('/e/'.$ev->slug).'?t='.$token;
return response()->json(['link' => $link]);
}
public function eventPhotos(int $id)
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$ev = Event::findOrFail($id);
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
}
$rows = Photo::where('event_id', $id)->orderByDesc('created_at')->limit(100)->get(['id','thumbnail_path','file_path','likes_count','is_featured','created_at']);
return response()->json(['data' => $rows]);
}
public function featurePhoto(int $photoId)
{
$p = Photo::findOrFail($photoId);
$this->authorizePhoto($p);
$p->is_featured = 1; $p->save();
return response()->json(['ok' => true]);
}
public function unfeaturePhoto(int $photoId)
{
$p = Photo::findOrFail($photoId);
$this->authorizePhoto($p);
$p->is_featured = 0; $p->save();
return response()->json(['ok' => true]);
}
public function deletePhoto(int $photoId)
{
$p = Photo::findOrFail($photoId);
$this->authorizePhoto($p);
$p->delete();
return response()->json(['ok' => true]);
}
protected function authorizePhoto(Photo $p): void
{
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$event = Event::find($p->event_id);
if ($tenantId && $event && $event->tenant_id !== $tenantId) {
abort(403);
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Inertia\Response;
class AuthenticatedSessionController extends Controller
{
/**
* Show the login page.
*/
public function create(Request $request): Response
{
return Inertia::render('auth/login', [
'canResetPassword' => Route::has('password.request'),
'status' => $request->session()->get('status'),
]);
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password page.
*/
public function show(): Response
{
return Inertia::render('auth/confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class EmailVerificationPromptController extends Controller
{
/**
* Show the email verification prompt page.
*/
public function __invoke(Request $request): Response|RedirectResponse
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: Inertia::render('auth/verify-email', ['status' => $request->session()->get('status')]);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class NewPasswordController extends Controller
{
/**
* Show the password reset page.
*/
public function create(Request $request): Response
{
return Inertia::render('auth/reset-password', [
'email' => $request->email,
'token' => $request->route('token'),
]);
}
/**
* Handle an incoming new password request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
if ($status == Password::PasswordReset) {
return to_route('login')->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => [__($status)],
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Inertia\Inertia;
use Inertia\Response;
class PasswordResetLinkController extends Controller
{
/**
* Show the password reset link request page.
*/
public function create(Request $request): Response
{
return Inertia::render('auth/forgot-password', [
'status' => $request->session()->get('status'),
]);
}
/**
* Handle an incoming password reset link request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => 'required|email',
]);
Password::sendResetLink(
$request->only('email')
);
return back()->with('status', __('A reset link will be sent if the account exists.'));
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Inertia\Inertia;
use Inertia\Response;
class RegisteredUserController extends Controller
{
/**
* Show the registration page.
*/
public function create(): Response
{
return Inertia::render('auth/register');
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect()->intended(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
$request->fulfill();
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
class PasswordController extends Controller
{
/**
* Show the user's password settings page.
*/
public function edit(): Response
{
return Inertia::render('settings/password');
}
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back();
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ProfileUpdateRequest;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Show the user's profile settings page.
*/
public function edit(Request $request): Response
{
return Inertia::render('settings/profile', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => $request->session()->get('status'),
]);
}
/**
* Update the user's profile settings.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return to_route('profile.edit');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validate([
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Auth;
use App\Models\User;
class ApiTokenAuth
{
public function handle(Request $request, Closure $next)
{
$header = $request->header('Authorization', '');
if (! str_starts_with($header, 'Bearer ')) {
return response()->json(['error' => ['code' => 'unauthorized']], 401);
}
$token = substr($header, 7);
$userId = Cache::get('api_token:'.$token);
if (! $userId) {
return response()->json(['error' => ['code' => 'unauthorized']], 401);
}
$user = User::find($userId);
if (! $user) {
return response()->json(['error' => ['code' => 'unauthorized']], 401);
}
Auth::login($user); // for policies if needed
return $next($request);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class HandleAppearance
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
View::share('appearance', $request->cookie('appearance') ?? 'system');
return $next($request);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Inspiring;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that's loaded on the first page visit.
*
* @see https://inertiajs.com/server-side-setup#root-template
*
* @var string
*/
protected $rootView = 'app';
/**
* Determines the current asset version.
*
* @see https://inertiajs.com/asset-versioning
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @see https://inertiajs.com/shared-data
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
[$message, $author] = str(Inspiring::quotes()->random())->explode('-');
return [
...parent::share($request),
'name' => config('app.name'),
'quote' => ['message' => trim($message), 'author' => trim($author)],
'auth' => [
'user' => $request->user(),
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return $this->string('email')
->lower()
->append('|'.$this->ip())
->transliterate()
->value();
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Settings;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}

27
app/Models/Emotion.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Emotion extends Model
{
protected $table = 'emotions';
protected $guarded = [];
protected $casts = [
'name' => 'array',
'description' => 'array',
];
public function eventTypes(): BelongsToMany
{
return $this->belongsToMany(EventType::class, 'emotion_event_type', 'emotion_id', 'event_type_id');
}
public function tasks(): HasMany
{
return $this->hasMany(Task::class);
}
}

29
app/Models/Event.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Event extends Model
{
protected $table = 'events';
protected $guarded = [];
protected $casts = [
'date' => 'date',
'settings' => 'array',
'is_active' => 'boolean',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function photos(): HasMany
{
return $this->hasMany(Photo::class);
}
}

27
app/Models/EventType.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class EventType extends Model
{
protected $table = 'event_types';
protected $guarded = [];
protected $casts = [
'name' => 'array',
'settings' => 'array',
];
public function emotions(): BelongsToMany
{
return $this->belongsToMany(Emotion::class, 'emotion_event_type', 'event_type_id', 'emotion_id');
}
public function events(): HasMany
{
return $this->hasMany(Event::class);
}
}

18
app/Models/LegalPage.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LegalPage extends Model
{
protected $table = 'legal_pages';
protected $guarded = [];
protected $casts = [
'title' => 'array',
'body_markdown' => 'array',
'is_published' => 'boolean',
'effective_from' => 'datetime',
];
}

22
app/Models/Photo.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Photo extends Model
{
protected $table = 'photos';
protected $guarded = [];
protected $casts = [
'is_featured' => 'boolean',
'metadata' => 'array',
];
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
}

27
app/Models/Task.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Task extends Model
{
protected $table = 'tasks';
protected $guarded = [];
protected $casts = [
'title' => 'array',
'description' => 'array',
'example_text' => 'array',
];
public function emotion(): BelongsTo
{
return $this->belongsTo(Emotion::class);
}
public function eventType(): BelongsTo
{
return $this->belongsTo(EventType::class, 'event_type_id');
}
}

21
app/Models/Tenant.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Tenant extends Model
{
protected $table = 'tenants';
protected $guarded = [];
protected $casts = [
'features' => 'array',
'last_activity_at' => 'datetime',
];
public function events(): HasMany
{
return $this->hasMany(Event::class);
}
}

54
app/Models/User.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Providers\Filament;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\Widgets\AccountWidget;
use Filament\Widgets\FilamentInfoWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class SuperAdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('super-admin')
->path('super-admin')
->colors([
'primary' => Color::Amber,
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->pages([
Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
->widgets([
AccountWidget::class,
FilamentInfoWidget::class,
\App\Filament\Widgets\PlatformStatsWidget::class,
\App\Filament\Widgets\UploadsPerDayChart::class,
\App\Filament\Widgets\RecentPhotosTable::class,
\App\Filament\Widgets\TopTenantsByUploads::class,
\App\Filament\Widgets\EventsActiveToday::class,
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\Storage;
class ImageHelper
{
/**
* Create a JPEG thumbnail for a file stored on a given disk.
* Returns the relative path (on the same disk) or null on failure.
*/
public static function makeThumbnailOnDisk(string $disk, string $sourcePath, string $destPath, int $maxEdge = 600, int $quality = 82): ?string
{
try {
$fullSrc = Storage::disk($disk)->path($sourcePath);
if (! file_exists($fullSrc)) {
return null;
}
$data = @file_get_contents($fullSrc);
if ($data === false) {
return null;
}
// Prefer robust decode via GD from string (handles jpeg/png/webp if compiled)
$src = @imagecreatefromstring($data);
if (! $src) {
return null;
}
$w = imagesx($src);
$h = imagesy($src);
if ($w === 0 || $h === 0) {
imagedestroy($src);
return null;
}
$scale = min(1.0, $maxEdge / max($w, $h));
$tw = (int) max(1, round($w * $scale));
$th = (int) max(1, round($h * $scale));
$dst = imagecreatetruecolor($tw, $th);
imagecopyresampled($dst, $src, 0, 0, 0, 0, $tw, $th, $w, $h);
// Ensure destination directory exists
$destDir = dirname($destPath);
Storage::disk($disk)->makeDirectory($destDir);
$fullDest = Storage::disk($disk)->path($destPath);
// Encode JPEG
@imagejpeg($dst, $fullDest, $quality);
imagedestroy($dst);
imagedestroy($src);
// Confirm file written
if (! file_exists($fullDest)) {
return null;
}
return $destPath;
} catch (\Throwable $e) {
// Silent failure; caller can fall back to original
return null;
}
}
}