Initialize repo and add session changes (2025-09-08)
This commit is contained in:
97
app/Console/Commands/AddDummyTenantUser.php
Normal file
97
app/Console/Commands/AddDummyTenantUser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
85
app/Console/Commands/AttachDemoEvent.php
Normal file
85
app/Console/Commands/AttachDemoEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
53
app/Console/Commands/BackfillThumbnails.php
Normal file
53
app/Console/Commands/BackfillThumbnails.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
192
app/Filament/Resources/EmotionResource.php
Normal file
192
app/Filament/Resources/EmotionResource.php
Normal 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];
|
||||
}
|
||||
}
|
||||
89
app/Filament/Resources/EventResource.php
Normal file
89
app/Filament/Resources/EventResource.php
Normal 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;
|
||||
}
|
||||
73
app/Filament/Resources/EventTypeResource.php
Normal file
73
app/Filament/Resources/EventTypeResource.php
Normal 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;
|
||||
}
|
||||
71
app/Filament/Resources/LegalPageResource.php
Normal file
71
app/Filament/Resources/LegalPageResource.php
Normal 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;
|
||||
}
|
||||
90
app/Filament/Resources/PhotoResource.php
Normal file
90
app/Filament/Resources/PhotoResource.php
Normal 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;
|
||||
}
|
||||
203
app/Filament/Resources/TaskResource.php
Normal file
203
app/Filament/Resources/TaskResource.php
Normal 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];
|
||||
}
|
||||
}
|
||||
72
app/Filament/Resources/TenantResource.php
Normal file
72
app/Filament/Resources/TenantResource.php
Normal 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;
|
||||
}
|
||||
40
app/Filament/Widgets/EventsActiveToday.php
Normal file
40
app/Filament/Widgets/EventsActiveToday.php
Normal 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);
|
||||
}
|
||||
}
|
||||
31
app/Filament/Widgets/PlatformStatsWidget.php
Normal file
31
app/Filament/Widgets/PlatformStatsWidget.php
Normal 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"),
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Filament/Widgets/RecentPhotosTable.php
Normal file
39
app/Filament/Widgets/RecentPhotosTable.php
Normal 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);
|
||||
}
|
||||
}
|
||||
33
app/Filament/Widgets/TopTenantsByUploads.php
Normal file
33
app/Filament/Widgets/TopTenantsByUploads.php
Normal 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);
|
||||
}
|
||||
}
|
||||
52
app/Filament/Widgets/UploadsPerDayChart.php
Normal file
52
app/Filament/Widgets/UploadsPerDayChart.php
Normal 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';
|
||||
}
|
||||
}
|
||||
21
app/Http/Controllers/Admin/QrController.php
Normal file
21
app/Http/Controllers/Admin/QrController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
|
||||
218
app/Http/Controllers/Api/EventPublicController.php
Normal file
218
app/Http/Controllers/Api/EventPublicController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
207
app/Http/Controllers/Api/TenantController.php
Normal file
207
app/Http/Controllers/Api/TenantController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
51
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal 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('/');
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal file
41
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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')]);
|
||||
}
|
||||
}
|
||||
70
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
70
app/Http/Controllers/Auth/NewPasswordController.php
Normal 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)],
|
||||
]);
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
41
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal 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.'));
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
51
app/Http/Controllers/Auth/RegisteredUserController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
24
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
24
app/Http/Controllers/Auth/VerifyEmailController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
39
app/Http/Controllers/Settings/PasswordController.php
Normal file
39
app/Http/Controllers/Settings/PasswordController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/Settings/ProfileController.php
Normal file
63
app/Http/Controllers/Settings/ProfileController.php
Normal 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('/');
|
||||
}
|
||||
}
|
||||
32
app/Http/Middleware/ApiTokenAuth.php
Normal file
32
app/Http/Middleware/ApiTokenAuth.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
23
app/Http/Middleware/HandleAppearance.php
Normal file
23
app/Http/Middleware/HandleAppearance.php
Normal 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);
|
||||
}
|
||||
}
|
||||
51
app/Http/Middleware/HandleInertiaRequests.php
Normal file
51
app/Http/Middleware/HandleInertiaRequests.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
88
app/Http/Requests/Auth/LoginRequest.php
Normal file
88
app/Http/Requests/Auth/LoginRequest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/Settings/ProfileUpdateRequest.php
Normal file
32
app/Http/Requests/Settings/ProfileUpdateRequest.php
Normal 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
27
app/Models/Emotion.php
Normal 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
29
app/Models/Event.php
Normal 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
27
app/Models/EventType.php
Normal 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
18
app/Models/LegalPage.php
Normal 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
22
app/Models/Photo.php
Normal 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
27
app/Models/Task.php
Normal 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
21
app/Models/Tenant.php
Normal 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
54
app/Models/User.php
Normal 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);
|
||||
}
|
||||
}
|
||||
24
app/Providers/AppServiceProvider.php
Normal file
24
app/Providers/AppServiceProvider.php
Normal 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
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
63
app/Providers/Filament/SuperAdminPanelProvider.php
Normal file
63
app/Providers/Filament/SuperAdminPanelProvider.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
69
app/Support/ImageHelper.php
Normal file
69
app/Support/ImageHelper.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user