diff --git a/app/Filament/Enums/ImageUploadType.php b/app/Filament/Enums/ImageUploadType.php index dc2363a464..9e322acfb1 100644 --- a/app/Filament/Enums/ImageUploadType.php +++ b/app/Filament/Enums/ImageUploadType.php @@ -8,4 +8,5 @@ enum ImageUploadType { case News; case HubBadge; + case GameBadge; } diff --git a/app/Filament/Resources/AchievementResource.php b/app/Filament/Resources/AchievementResource.php index a21f4e0043..4e615bbf33 100644 --- a/app/Filament/Resources/AchievementResource.php +++ b/app/Filament/Resources/AchievementResource.php @@ -147,6 +147,7 @@ public static function infolist(Infolist $infolist): Infolist Infolists\Components\TextEntry::make('DisplayOrder'), ])->grow(false), ])->from('md'), + Infolists\Components\Section::make('Event Association') ->schema([ Infolists\Components\TextEntry::make('eventData.source_achievement_id') @@ -246,42 +247,6 @@ public static function form(Form $form): Form ->disabled(!$user->can('updateField', [$form->model, 'DisplayOrder'])), ]), ])->from('md'), - - Forms\Components\Section::make('Event Association') - ->relationship('eventData') - ->columns(['xl' => 4, 'md' => 2]) - ->schema([ - Forms\Components\Select::make('source_achievement_id') - ->label('Source Achievement') - ->columnSpan(2) - ->searchable() - ->getSearchResultsUsing(function (string $search): array { - return Achievement::where('Title', 'like', "%{$search}%") - ->orWhere('ID', 'like', "%{$search}%") - ->limit(50) - ->get() - ->mapWithKeys(function ($achievement) { - return [$achievement->id => "[{$achievement->id}] {$achievement->title}"]; - }) - ->toArray(); - }) - ->getOptionLabelUsing(function (int $value): string { - $achievement = Achievement::find($value); - - return "[{$achievement->id}] {$achievement->title}"; - }), - - Forms\Components\DatePicker::make('active_from') - ->label('Active From') - ->native(false) - ->date(), - - Forms\Components\DatePicker::make('active_through') - ->label('Active Through') - ->native(false) - ->date(), - ]) - ->hidden(fn ($record) => $record && $record->game->system->id !== System::Events), ]); } diff --git a/app/Filament/Resources/EventAchievementResource.php b/app/Filament/Resources/EventAchievementResource.php new file mode 100644 index 0000000000..01bb21d099 --- /dev/null +++ b/app/Filament/Resources/EventAchievementResource.php @@ -0,0 +1,151 @@ +columns(1) + ->schema([ + Infolists\Components\Section::make() + ->schema([ + Infolists\Components\TextEntry::make('source_achievement_id') + ->label('Source Achievement') + ->formatStateUsing(function (int $state): string { + $achievement = Achievement::find($state); + + return "[{$achievement->id}] {$achievement->title}"; + }), + Infolists\Components\TextEntry::make('active_from') + ->label('Active From') + ->date(), + Infolists\Components\TextEntry::make('active_through') + ->label('Active Through') + ->date(), + ]) + ->columns(['xl' => 4, 'md' => 2]), + + Infolists\Components\Section::make('Source Achievement') + ->relationship('sourceAchievement') + ->columns(['xl' => 2, '2xl' => 3]) + ->schema([ + Infolists\Components\Group::make() + ->schema([ + Infolists\Components\ImageEntry::make('badge_url') + ->label('Badge') + ->size(config('media.icon.lg.width')), + Infolists\Components\ImageEntry::make('badge_locked_url') + ->label('Badge (locked)') + ->size(config('media.icon.lg.width')), + ]), + + Infolists\Components\Group::make() + ->schema([ + Infolists\Components\TextEntry::make('Title'), + + Infolists\Components\TextEntry::make('Description'), + + Infolists\Components\TextEntry::make('game') + ->label('Game') + ->formatStateUsing(fn (Game $state) => '[' . $state->id . '] ' . $state->title) + ->url(fn (EventAchievement $record): string => $record->sourceAchievement->game->getCanonicalUrlAttribute()), + + Infolists\Components\TextEntry::make('developer') + ->label('Author') + ->formatStateUsing(fn (User $state) => $state->display_name), + ]), + + Infolists\Components\Group::make() + ->schema([ + Infolists\Components\TextEntry::make('canonical_url') + ->label('Canonical URL') + ->url(fn (EventAchievement $record): string => $record->sourceAchievement->getCanonicalUrlAttribute()), + Infolists\Components\TextEntry::make('permalink') + ->url(fn (EventAchievement $record): string => $record->sourceAchievement->getPermalinkAttribute()), + ]), + ]) + ->hidden(fn ($record) => !$record->sourceAchievement), + ]); + } + + public static function form(Form $form): Form + { + return $form + ->columns(1) + ->schema([ + Forms\Components\Section::make() + ->columns(['xl' => 4, 'md' => 2]) + ->schema([ + Forms\Components\Select::make('source_achievement_id') + ->label('Source Achievement') + ->columnSpan(2) + ->searchable() + ->getSearchResultsUsing(function (string $search): array { + return Achievement::where('Title', 'like', "%{$search}%") + ->orWhere('ID', 'like', "%{$search}%") + ->limit(50) + ->get() + ->mapWithKeys(function ($achievement) { + return [$achievement->id => "[{$achievement->id}] {$achievement->title}"]; + }) + ->toArray(); + }) + ->getOptionLabelUsing(function (int $value): string { + $achievement = Achievement::find($value); + + return "[{$achievement->id}] {$achievement->title}"; + }), + + Forms\Components\DatePicker::make('active_from') + ->label('Active From') + ->native(false) + ->date(), + + Forms\Components\DatePicker::make('active_through') + ->label('Active Through') + ->native(false) + ->date(), + ]), + ]); + } + + public static function getRecordSubNavigation(Page $page): array + { + return $page->generateNavigationItems([ + Pages\Details::class, + Pages\AuditLog::class, + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\Index::route('/'), + 'view' => Pages\Details::route('/{record}'), + 'edit' => Pages\Edit::route('/{record}/edit'), + 'audit-log' => Pages\AuditLog::route('/{record}/audit-log'), + ]; + } +} diff --git a/app/Filament/Resources/EventAchievementResource/Pages/AuditLog.php b/app/Filament/Resources/EventAchievementResource/Pages/AuditLog.php new file mode 100644 index 0000000000..009286a26c --- /dev/null +++ b/app/Filament/Resources/EventAchievementResource/Pages/AuditLog.php @@ -0,0 +1,37 @@ +record; + $game = $eventAchievement->achievement->game; + $event = $game->event; + + return [ + route('filament.admin.resources.events.index') => 'Events', + route('filament.admin.resources.events.view', $event) => $game->title, + route('filament.admin.resources.event-achievements.view', $eventAchievement) => $eventAchievement->achievement->title, + 'Audit Log', + ]; + } +} diff --git a/app/Filament/Resources/EventAchievementResource/Pages/Details.php b/app/Filament/Resources/EventAchievementResource/Pages/Details.php new file mode 100644 index 0000000000..d9547dbe49 --- /dev/null +++ b/app/Filament/Resources/EventAchievementResource/Pages/Details.php @@ -0,0 +1,37 @@ +record; + $game = $eventAchievement->achievement->game; + $event = $game->event; + + return [ + route('filament.admin.resources.events.index') => 'Events', + route('filament.admin.resources.events.view', $event) => $game->title, + route('filament.admin.resources.event-achievements.view', $eventAchievement) => $eventAchievement->achievement->title, + 'View', + ]; + } + + protected function getHeaderActions(): array + { + return [ + Actions\EditAction::make(), + ]; + } +} diff --git a/app/Filament/Resources/EventAchievementResource/Pages/Edit.php b/app/Filament/Resources/EventAchievementResource/Pages/Edit.php new file mode 100644 index 0000000000..ec7acd4037 --- /dev/null +++ b/app/Filament/Resources/EventAchievementResource/Pages/Edit.php @@ -0,0 +1,32 @@ +record; + $game = $eventAchievement->achievement->game; + $event = $game->event; + + return [ + route('filament.admin.resources.events.index') => 'Events', + route('filament.admin.resources.events.view', $event) => $game->title, + route('filament.admin.resources.event-achievements.view', $eventAchievement) => $eventAchievement->achievement->title, + 'Edit', + ]; + } +} diff --git a/app/Filament/Resources/EventAchievementResource/Pages/Index.php b/app/Filament/Resources/EventAchievementResource/Pages/Index.php new file mode 100644 index 0000000000..ab5150d7f3 --- /dev/null +++ b/app/Filament/Resources/EventAchievementResource/Pages/Index.php @@ -0,0 +1,19 @@ +schema([ + Infolists\Components\ImageEntry::make('badge_url') + ->label('') + ->size(config('media.icon.lg.width')), + + Infolists\Components\Section::make('Primary Details') + ->icon('heroicon-m-key') + ->columns(['md' => 2, 'xl' => 3, '2xl' => 4]) + ->schema([ + Infolists\Components\TextEntry::make('game.title') + ->label('Title'), + + Infolists\Components\TextEntry::make('game.sort_title') + ->label('Sort Title'), + + Infolists\Components\TextEntry::make('slug') + ->label('URL alias'), + + Infolists\Components\TextEntry::make('id') + ->label('ID'), + + Infolists\Components\TextEntry::make('permalink') + ->url(fn (Event $record): string => $record->getPermalinkAttribute()) + ->extraAttributes(['class' => 'underline']) + ->openUrlInNewTab(), + + Infolists\Components\TextEntry::make('game.forumTopic.id') + ->label('Forum Topic ID') + ->url(fn (?int $state) => url("viewtopic.php?t={$state}")) + ->extraAttributes(['class' => 'underline']), + + Infolists\Components\TextEntry::make('active_from') + ->label('Active From') + ->date(), + + Infolists\Components\TextEntry::make('active_through') + ->label('Active Through') + ->date(), + ]), + + Infolists\Components\Section::make('Metrics') + ->icon('heroicon-s-arrow-trending-up') + ->description(" + Statistics regarding the game's players and achievements can be found here. + ") + ->columns(['md' => 2, 'xl' => 3, '2xl' => 4]) + ->schema([ + Infolists\Components\TextEntry::make('game.players_total') + ->label('Players') + ->numeric(), + + Infolists\Components\TextEntry::make('game.achievements_published') + ->label('Achievements') + ->numeric(), + ]), + ]); + } + + public static function form(Form $form): Form + { + return $form + ->schema([ + Forms\Components\Section::make() + ->relationship('game') + ->columns(['md' => 2, 'xl' => 3, '2xl' => 4]) + ->schema([ + Forms\Components\TextInput::make('Title') + ->required() + ->label('Title') + ->minLength(2) + ->maxLength(80), + + Forms\Components\TextInput::make('sort_title') + ->required() + ->label('Sort Title') + ->minLength(2) + ->helperText('Normalized title for sorting purposes. For example, "The Goonies II" would sort as "goonies 02". DON\'T CHANGE THIS UNLESS YOU KNOW WHAT YOU\'RE DOING.') + ->reactive() + ->afterStateHydrated(function (callable $set, callable $get, ?string $state) { + $set('original_sort_title', $state ?? ''); + }), + + Forms\Components\TextInput::make('ForumTopicID') + ->label('Forum Topic ID') + ->numeric() + ->rules([new ExistsInForumTopics()]), + ]), + + Forms\Components\Section::make() + ->columns(['md' => 2, 'xl' => 3, '2xl' => 4]) + ->schema([ + Forms\Components\TextInput::make('slug') + ->label('URL alias') + ->helperText('Provides an alias for accessing the event via a URL: /events/[URL alias]') + ->rules(['alpha_dash']) + ->minLength(4) + ->maxLength(20) + ->columnSpan(2), + + Forms\Components\DatePicker::make('active_from') + ->label('Active From') + ->native(false) + ->date(), + + Forms\Components\DatePicker::make('active_through') + ->label('Active Through') + ->native(false) + ->date(), + ]), + + Forms\Components\Section::make('Media') + ->icon('heroicon-s-photo') + ->schema([ + // Store a temporary file on disk until the user submits. + // When the user submits, put in storage. + Forms\Components\FileUpload::make('image_asset_path') + ->label('Badge') + ->disk('livewire-tmp') // Use Livewire's self-cleaning temporary disk + ->image() + ->rules([ + 'dimensions:width=96,height=96', + ]) + ->acceptedFileTypes(['image/jpeg', 'image/png', 'image/gif']) + ->maxSize(1024) + ->maxFiles(1) + ->previewable(true), + ]) + ->columns(2), + + ]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('active_until', 'desc') + ->columns([ + Tables\Columns\ImageColumn::make('badge_url') + ->label('') + ->size(config('media.icon.sm.width')), + + Tables\Columns\TextColumn::make('id') + ->label('ID') + ->sortable() + ->searchable(), + + Tables\Columns\TextColumn::make('game.title') + ->sortable() + ->searchable(), + + Tables\Columns\TextColumn::make('active_from') + ->date() + ->sortable() + ->toggleable(), + + Tables\Columns\TextColumn::make('active_through') + ->date() + ->sortable(['active_until']) + ->toggleable(), + + Tables\Columns\TextColumn::make('game.forumTopic.id') + ->label('Forum Topic') + ->url(fn (?int $state) => url("viewtopic.php?t={$state}")) + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('game.players_hardcore') + ->label('Players') + ->numeric() + ->sortable() + ->alignEnd() + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('game.achievements_published') + ->label('Achievements') + ->numeric() + ->sortable() + ->alignEnd(), + ]) + ->filters([ + + ]) + ->actions([ + Tables\Actions\ActionGroup::make([ + Tables\Actions\ActionGroup::make([ + Tables\Actions\ViewAction::make(), + Tables\Actions\EditAction::make(), + ])->dropdown(false), + ]), + ]) + ->bulkActions([ + + ]); + } + + public static function getRelations(): array + { + return [ + AchievementsRelationManager::class, + HubsRelationManager::class, + ]; + } + + public static function getRecordSubNavigation(Page $page): array + { + return $page->generateNavigationItems([ + Pages\Details::class, + Pages\AuditLog::class, + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\Index::route('/'), + 'create' => Pages\Create::route('/create'), + 'view' => Pages\Details::route('/{record}'), + 'edit' => Pages\Edit::route('/{record}/edit'), + 'audit-log' => Pages\AuditLog::route('/{record}/audit-log'), + ]; + } +} diff --git a/app/Filament/Resources/EventResource/Pages/AuditLog.php b/app/Filament/Resources/EventResource/Pages/AuditLog.php new file mode 100644 index 0000000000..a2d6138769 --- /dev/null +++ b/app/Filament/Resources/EventResource/Pages/AuditLog.php @@ -0,0 +1,11 @@ +record; + + $existingImage = $record->image_asset_path ?? '/Images/000001.png'; + + if (isset($data['image_asset_path'])) { + $data['image_asset_path'] = (new ProcessUploadedImageAction())->execute( + $data['image_asset_path'], + ImageUploadType::GameBadge, + ); + } else { + // If no new image was uploaded, retain the existing image. + $data['image_asset_path'] = $existingImage; + } + + return $data; + } +} diff --git a/app/Filament/Resources/EventResource/Pages/Index.php b/app/Filament/Resources/EventResource/Pages/Index.php new file mode 100644 index 0000000000..ed4ec3d9ca --- /dev/null +++ b/app/Filament/Resources/EventResource/Pages/Index.php @@ -0,0 +1,21 @@ +schema([ + Forms\Components\TextInput::make('title') + ->required() + ->maxLength(255), + ]); + } + + public function table(Table $table): Table + { + /** @var User $user */ + $user = Auth::user(); + + return $table + ->recordTitleAttribute('title') + ->columns([ + Tables\Columns\ImageColumn::make('achievement.badge_url') + ->label('') + ->size(config('media.icon.md.width')), + + Tables\Columns\TextColumn::make('title') + ->description(fn (EventAchievement $record): string => $record->achievement->description) + ->wrap(), + + Tables\Columns\TextColumn::make('active_from') + ->date() + ->toggleable(), + + Tables\Columns\TextColumn::make('active_through') + ->date() + ->toggleable(), + + Tables\Columns\TextColumn::make('DateCreated') + ->date() + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('DateModified') + ->date() + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('achievement.DisplayOrder') + ->label('Display Order') + ->toggleable(), + ]) + ->filters([ + + ]) + ->headerActions([ + + ]) + ->actions([ + + ]) + ->bulkActions([ + + ]) + ->recordUrl(function (EventAchievement $record): string { + /** @var User $user */ + $user = Auth::user(); + + if ($user->can('update', $record)) { + return route('filament.admin.resources.event-achievements.edit', ['record' => $record]); + } + + return route('filament.admin.resources.event-achievements.view', ['record' => $record]); + }) + ->paginated([50, 100, 150]) + ->defaultPaginationPageOption(50) + ->defaultSort(function (Builder $query): Builder { + return $query + ->orderBy('DisplayOrder') + ->orderBy('DateCreated', 'asc'); + }); + } +} diff --git a/app/Filament/Resources/EventResource/RelationManagers/HubsRelationManager.php b/app/Filament/Resources/EventResource/RelationManagers/HubsRelationManager.php new file mode 100644 index 0000000000..9b78235cd1 --- /dev/null +++ b/app/Filament/Resources/EventResource/RelationManagers/HubsRelationManager.php @@ -0,0 +1,149 @@ +hubs->count(); + + return $count > 0 ? "{$count}" : null; + } + + public function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\ImageColumn::make('badge_url') + ->label('') + ->size(config('media.icon.sm.width')) + ->url(function (GameSet $record) { + if (request()->user()->can('manage', GameSet::class)) { + return HubResource::getUrl('view', ['record' => $record]); + } + }), + + Tables\Columns\TextColumn::make('game_set_id') + ->label('Hub ID') + ->sortable() + ->searchable() + ->url(function (GameSet $record) { + if (request()->user()->can('manage', GameSet::class)) { + return HubResource::getUrl('view', ['record' => $record]); + } + }), + + Tables\Columns\TextColumn::make('title') + ->label('Title') + ->sortable() + ->searchable() + ->url(function (GameSet $record) { + if (request()->user()->can('manage', GameSet::class)) { + return HubResource::getUrl('view', ['record' => $record]); + } + }), + ]) + ->filters([ + + ]) + ->headerActions([ + Tables\Actions\Action::make('add') + ->label('Add related hubs') + ->form([ + Forms\Components\Select::make('hub_ids') + ->label('Hubs') + ->multiple() + ->options(function () { + return GameSet::whereType(GameSetType::Hub) + ->whereNotIn('id', $this->getOwnerRecord()->hubs->pluck('id')) + ->limit(20) + ->get() + ->mapWithKeys(fn ($gameSet) => [$gameSet->id => "[{$gameSet->id} {$gameSet->title}]"]); + }) + ->searchable() + ->getSearchResultsUsing(function (string $search) { + return GameSet::whereType(GameSetType::Hub) + ->whereNotIn('id', $this->getOwnerRecord()->hubs->pluck('id')) + ->where(function ($query) use ($search) { + $query->where('id', 'LIKE', "%{$search}%") + ->orWhere('title', 'LIKE', "%{$search}%"); + }) + ->limit(20) + ->get() + ->mapWithKeys(fn ($gameSet) => [$gameSet->id => "[{$gameSet->id} {$gameSet->title}]"]); + }) + ->required(), + ]) + ->modalHeading('Add event to related hub') + ->action(function (array $data): void { + /** @var Event $event */ + $event = $this->getOwnerRecord(); + foreach ($data['hub_ids'] as $hubId) { + $gameSet = GameSet::find($hubId); + (new AttachGamesToGameSetAction())->execute($gameSet, [$event->game->id]); + } + }), + ]) + ->actions([ + Tables\Actions\Action::make('remove') + ->tooltip('Remove') + ->icon('heroicon-o-trash') + ->iconButton() + ->requiresConfirmation() + ->color('danger') + ->modalHeading('Remove event from related hub') + ->action(function (GameSet $gameSetToDetach): void { + /** @var Event $event */ + $event = $this->getOwnerRecord(); + + (new DetachGamesFromGameSetAction())->execute($gameSetToDetach, [$event->game->id]); + }), + + Tables\Actions\Action::make('visit') + ->tooltip('View on Site') + ->icon('heroicon-m-arrow-top-right-on-square') + ->iconButton() + ->url(fn (GameSet $record): string => route('hub.show', $record)) + ->openUrlInNewTab(), + ]) + ->bulkActions([ + Tables\Actions\BulkAction::make('remove') + ->label('Remove selected') + ->modalHeading('Remove selected events from hub') + ->modalDescription('Are you sure you would like to do this?') + ->requiresConfirmation() + ->color('danger') + ->action(function (Collection $gameSets): void { + /** @var Event $event */ + $event = $this->getOwnerRecord(); + + foreach ($gameSets as $gameSet) { + (new DetachGamesFromGameSetAction())->execute($gameSet, [$event->game->id]); + } + + $this->deselectAllTableRecords(); + }), + ]) + ->paginated([50, 100, 150]); + } +} diff --git a/app/Filament/Resources/NewsResource/Actions/ProcessUploadedImageAction.php b/app/Filament/Resources/NewsResource/Actions/ProcessUploadedImageAction.php index 69a49d53d8..787a3183fc 100644 --- a/app/Filament/Resources/NewsResource/Actions/ProcessUploadedImageAction.php +++ b/app/Filament/Resources/NewsResource/Actions/ProcessUploadedImageAction.php @@ -32,11 +32,17 @@ public function execute(string $tempImagePath, ImageUploadType $imageUploadType) // Upload the image and get the final path. $imagePath = null; - if ($imageUploadType === ImageUploadType::News) { - $imagePath = UploadNewsImage($dataUrl); - } elseif ($imageUploadType === ImageUploadType::HubBadge) { - $file = createFileArrayFromDataUrl($dataUrl); - $imagePath = UploadGameImage($file, ImageType::GameIcon); + switch ($imageUploadType) { + case ImageUploadType::News: + $imagePath = UploadNewsImage($dataUrl); + break; + case ImageUploadType::HubBadge: + case ImageUploadType::GameBadge: + $file = createFileArrayFromDataUrl($dataUrl); + $imagePath = UploadGameImage($file, ImageType::GameIcon); + break; + default: + throw new Exception("Unknown ImageUploadType: {$imageUploadType->name}"); } // Livewire auto-deletes these temp files after 24 hours, however diff --git a/app/Models/Event.php b/app/Models/Event.php new file mode 100644 index 0000000000..39f8cfacf2 --- /dev/null +++ b/app/Models/Event.php @@ -0,0 +1,129 @@ + 'date', + 'active_until' => 'date', + ]; + + protected $appends = [ + 'active_through', + ]; + + // == logging + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logOnly([ + 'image_asset_path', + 'slug', + 'active_from', + 'active_until', + ]) + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + + // == accessors + + public function getTitleAttribute(): string + { + return $this->game->title; + } + + public function getActiveThroughAttribute(): ?Carbon + { + return $this->active_until ? $this->active_until->clone()->subDays(1) : null; + } + + public function getBadgeUrlAttribute(): string + { + return media_asset($this->image_asset_path); + } + + public function getPermalinkAttribute(): string + { + // TODO: use slug (implies slug is immutable) + return $this->game->getPermalinkAttribute(); + } + + // == mutators + + public function setTitleAttribute(string $value): void + { + $this->game->title = $value; + } + + public function setActiveThroughAttribute(Carbon|string|null $value): void + { + if (is_string($value)) { + $value = Carbon::parse($value); + } + + $this->active_until = $value ? $value->clone()->addDays(1) : null; + } + + // == relations + + /** + * @return BelongsTo + */ + public function game(): BelongsTo + { + return $this->belongsTo(Game::class, 'legacy_game_id', 'ID'); + } + + /** + * @return HasManyThrough + */ + public function achievements(): HasManyThrough + { + return $this->game->hasManyThrough( + EventAchievement::class, + Achievement::class, + 'GameID', // Achievements.GameID + 'achievement_id', // event_achievements.achievement_id + 'ID', // Game.ID + 'ID', // Achievement.ID + )->with('achievement.game'); + } + + /** + * @return BelongsToMany + */ + public function hubs(): BelongsToMany + { + return $this->game->gameSets(); + } + + // == scopes +} diff --git a/app/Models/EventAchievement.php b/app/Models/EventAchievement.php index 98f7543e08..3ab3930f96 100644 --- a/app/Models/EventAchievement.php +++ b/app/Models/EventAchievement.php @@ -8,9 +8,15 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Spatie\Activitylog\LogOptions; +use Spatie\Activitylog\Traits\LogsActivity; class EventAchievement extends BaseModel { + use LogsActivity { + LogsActivity::activities as auditLog; + } + protected $table = 'event_achievements'; protected $fillable = [ @@ -33,8 +39,27 @@ class EventAchievement extends BaseModel public const RAEVENTS_USER_ID = 279854; public const DEVQUEST_USER_ID = 240336; + // == logging + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logOnly([ + 'source_achievement_id', + 'active_from', + 'active_until', + ]) + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + // == accessors + public function getTitleAttribute(): string + { + return $this->achievement->title; + } + public function getActiveThroughAttribute(): ?Carbon { return $this->active_until ? $this->active_until->clone()->subDays(1) : null; diff --git a/app/Models/Game.php b/app/Models/Game.php index 9d76109a48..b78970e8f6 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -619,7 +619,6 @@ public function gameAchievementSets(): HasMany public function gameSets(): BelongsToMany { return $this->belongsToMany(GameSet::class, 'game_set_games', 'game_id', 'game_set_id') - ->withTimestamps() ->withPivot('created_at', 'updated_at', 'deleted_at'); } @@ -671,6 +670,14 @@ public function unresolvedTickets(): HasManyThrough return $this->tickets()->unresolved(); } + /** + * @return HasOne + */ + public function event(): HasOne + { + return $this->hasOne(Event::class, 'legacy_game_id'); + } + // == scopes /** diff --git a/app/Platform/Actions/CreateAchievementOfTheWeek.php b/app/Platform/Actions/CreateAchievementOfTheWeek.php index ddb26e57df..a358de89b5 100644 --- a/app/Platform/Actions/CreateAchievementOfTheWeek.php +++ b/app/Platform/Actions/CreateAchievementOfTheWeek.php @@ -5,6 +5,7 @@ namespace App\Platform\Actions; use App\Models\Achievement; +use App\Models\Event; use App\Models\EventAchievement; use App\Models\Game; use App\Models\System; @@ -24,17 +25,29 @@ public function execute(Carbon $startDate, ?array $achievementIds = null): Game $eventTitle = "Achievement of the Week $year"; - $event = Game::firstWhere('Title', '=', $eventTitle); - if (!$event) { - $event = Game::create([ + $eventGame = Game::firstWhere('Title', '=', $eventTitle); + if (!$eventGame) { + $eventGame = Game::create([ 'Title' => $eventTitle, 'sort_title' => (new ComputeGameSortTitleAction())->execute($eventTitle), 'Publisher' => 'RetroAchievements', 'ConsoleID' => System::Events, ]); + + $nextDate = $startDate->clone()->addWeeks(52); + while ($nextDate->year === $date->year) { + $nextDate = $nextDate->addDays(7); + } + + Event::create([ + 'legacy_game_id' => $eventGame->ID, + 'slug' => "aotw-$year", + 'active_from' => $startDate, + 'active_until' => $nextDate, + ]); } - $achievementCount = $event->achievements()->count(); + $achievementCount = $eventGame->achievements()->count(); while ($achievementCount < 52) { $achievementCount++; $achievement = Achievement::create([ @@ -42,14 +55,14 @@ public function execute(Carbon $startDate, ?array $achievementIds = null): Game 'Description' => 'TBD', 'MemAddr' => '0=1', 'Flags' => AchievementFlag::OfficialCore->value, - 'GameID' => $event->id, + 'GameID' => $eventGame->id, 'user_id' => EventAchievement::RAEVENTS_USER_ID, 'BadgeName' => '00000', 'DisplayOrder' => $achievementCount, ]); } - $achievements = $event->achievements()->orderBy('DisplayOrder')->get(); + $achievements = $eventGame->achievements()->orderBy('DisplayOrder')->get(); $index = 0; foreach ($achievementIds as $achievementId) { @@ -73,7 +86,7 @@ public function execute(Carbon $startDate, ?array $achievementIds = null): Game 'Description' => 'TBD', 'MemAddr' => '0=1', 'Flags' => AchievementFlag::OfficialCore->value, - 'GameID' => $event->id, + 'GameID' => $eventGame->id, 'user_id' => EventAchievement::RAEVENTS_USER_ID, 'BadgeName' => '00000', 'DisplayOrder' => $achievementCount, @@ -97,9 +110,9 @@ public function execute(Carbon $startDate, ?array $achievementIds = null): Game } // update metrics and sync to game_achievement_set - dispatch(new UpdateGameMetricsJob($event->id))->onQueue('game-metrics'); + dispatch(new UpdateGameMetricsJob($eventGame->id))->onQueue('game-metrics'); - return $event; + return $eventGame; } /** diff --git a/app/Policies/AchievementPolicy.php b/app/Policies/AchievementPolicy.php index 562cb9bdbe..9aa124c2ad 100644 --- a/app/Policies/AchievementPolicy.php +++ b/app/Policies/AchievementPolicy.php @@ -6,6 +6,7 @@ use App\Models\Achievement; use App\Models\Role; +use App\Models\System; use App\Models\User; use Illuminate\Auth\Access\HandlesAuthorization; @@ -151,6 +152,16 @@ public function updateField(User $user, ?Achievement $achievement, string $field // If any of the user's roles allow updating the specified field, return true. // Otherwise, they can't edit the field. - return in_array($fieldName, $allowedFieldsForUser, true); + if (in_array($fieldName, $allowedFieldsForUser, true)) { + return true; + } + + if ($user->hasRole(Role::EVENT_MANAGER)) { + if ($achievement->game->ConsoleID === System::Events) { + return true; + } + } + + return false; } } diff --git a/app/Policies/EventAchievementPolicy.php b/app/Policies/EventAchievementPolicy.php index a7b386a821..e8cfcf9f55 100644 --- a/app/Policies/EventAchievementPolicy.php +++ b/app/Policies/EventAchievementPolicy.php @@ -4,7 +4,6 @@ namespace App\Policies; -use App\Models\EventAchievement; use App\Models\Role; use App\Models\User; use Illuminate\Auth\Access\HandlesAuthorization; @@ -15,7 +14,10 @@ class EventAchievementPolicy public function manage(User $user): bool { - return $user->hasRole(Role::EVENT_MANAGER); + return $user->hasAnyRole([ + Role::EVENT_MANAGER, + Role::ADMINISTRATOR, + ]); } public function viewAny(?User $user): bool @@ -23,23 +25,32 @@ public function viewAny(?User $user): bool return true; } - public function view(?User $user, EventAchievement $eventAchievement): bool + public function view(?User $user): bool { return true; } public function create(User $user): bool { - return $user->hasRole(Role::EVENT_MANAGER); + return $user->hasAnyRole([ + Role::EVENT_MANAGER, + Role::ADMINISTRATOR, + ]); } - public function update(User $user, EventAchievement $eventAchievement): bool + public function update(User $user): bool { - return $user->hasRole(Role::EVENT_MANAGER); + return $user->hasAnyRole([ + Role::EVENT_MANAGER, + Role::ADMINISTRATOR, + ]); } - public function delete(User $user, EventAchievement $eventAchievement): bool + public function delete(User $user): bool { - return $user->hasRole(Role::EVENT_MANAGER); + return $user->hasAnyRole([ + Role::EVENT_MANAGER, + Role::ADMINISTRATOR, + ]); } } diff --git a/app/Policies/EventPolicy.php b/app/Policies/EventPolicy.php new file mode 100644 index 0000000000..ea18a0afa4 --- /dev/null +++ b/app/Policies/EventPolicy.php @@ -0,0 +1,56 @@ +hasAnyRole([ + Role::EVENT_MANAGER, + Role::ADMINISTRATOR, + ]); + } + + public function viewAny(?User $user): bool + { + return true; + } + + public function view(?User $user): bool + { + return true; + } + + public function create(User $user): bool + { + return $user->hasAnyRole([ + Role::EVENT_MANAGER, + Role::ADMINISTRATOR, + ]); + } + + public function update(User $user): bool + { + return $user->hasAnyRole([ + Role::EVENT_MANAGER, + Role::ADMINISTRATOR, + ]); + } + + public function delete(User $user): bool + { + return $user->hasAnyRole([ + Role::EVENT_MANAGER, + Role::ADMINISTRATOR, + ]); + } +} diff --git a/database/migrations/2025_01_06_000000_create_events_table.php b/database/migrations/2025_01_06_000000_create_events_table.php new file mode 100644 index 0000000000..b447cd96ea --- /dev/null +++ b/database/migrations/2025_01_06_000000_create_events_table.php @@ -0,0 +1,34 @@ +bigIncrements('id'); + $table->unsignedBigInteger('legacy_game_id'); + $table->string('image_asset_path', 50)->default('/Images/000001.png'); + $table->string('slug', 20)->unique(); + $table->date('active_from')->nullable(); + $table->date('active_until')->nullable(); + $table->timestamps(); + }); + + Schema::table('events', function (Blueprint $table) { + $table->foreign('legacy_game_id') + ->references('ID') + ->on('GameData') + ->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('events'); + } +}; diff --git a/resources/views/livewire/administrative-tools/create-event.blade.php b/resources/views/livewire/administrative-tools/create-event.blade.php index 2448c6036e..7e34895c61 100644 --- a/resources/views/livewire/administrative-tools/create-event.blade.php +++ b/resources/views/livewire/administrative-tools/create-event.blade.php @@ -2,6 +2,7 @@ use App\Models\Achievement; use App\Models\EventAchievement; +use App\Models\Event; use App\Models\Game; use App\Models\System; use App\Models\User; @@ -35,6 +36,7 @@ public function submit(): void 'Publisher' => 'RetroAchievements', 'ConsoleID' => System::Events, ]); + Event::create(['legacy_game_id' => $event->ID]); for ($i = 0; $i < $this->numberOfAchievements; $i++) { $achievement = Achievement::create([ diff --git a/resources/views/pages-legacy/achievementInfo.blade.php b/resources/views/pages-legacy/achievementInfo.blade.php index 3a5fa8e57c..df9cf9d1e4 100644 --- a/resources/views/pages-legacy/achievementInfo.blade.php +++ b/resources/views/pages-legacy/achievementInfo.blade.php @@ -59,6 +59,7 @@ $game = Game::find($dataOut['GameID']); $parentGame = $game->getParentGame() ?? null; +$isEventGame = $game->ConsoleID === System::Events; sanitize_outputs( $achievementTitle, @@ -209,7 +210,7 @@ function updateAchievementFlag(newFlag) { - +