Initial commit

This commit is contained in:
2026-05-20 18:25:15 +02:00
commit 95f0b4ff01
288 changed files with 90909 additions and 0 deletions

77
app/Auth/XenForoGuard.php Normal file
View File

@@ -0,0 +1,77 @@
<?php
namespace App\Auth;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Http\Request;
class XenForoGuard implements Guard
{
private ?XenForoUser $user = null;
public function __construct(private readonly Request $request) {}
public function check(): bool
{
return $this->user() !== null;
}
public function guest(): bool
{
return ! $this->check();
}
public function id(): mixed
{
return $this->user()?->getAuthIdentifier();
}
public function hasUser(): bool
{
return $this->user !== null;
}
public function user(): ?XenForoUser
{
if ($this->hasUser())
return $this->user;
$sessionId = $this->request->cookie('xf_session');
if(!$sessionId)
return null;
$xfSession = \DB::connection('xenforo')
->table('session')
->where('session_id', $sessionId)
->value('session_data');
if(!$xfSession)
return null;
$sessionData = unserialize($xfSession);
if (!$sessionData || !isset($sessionData['userId']) || !$sessionData['userId'])
return null;
$xfUser = \DB::connection('xenforo')
->table('user')
->where('user_id', $sessionData['userId'])
->first();
if(!$xfUser)
return null;
return $this->user = new XenForoUser($xfUser);
}
public function validate(array $credentials = []): bool
{
return false;
}
public function setUser(mixed $user): void
{
$this->user = $user;
}
}

84
app/Auth/XenForoUser.php Normal file
View File

@@ -0,0 +1,84 @@
<?php
namespace App\Auth;
use App\Services\XenforoService;
use Illuminate\Contracts\Auth\Authenticatable;
class XenForoUser implements Authenticatable {
public ?array $permissions = null;
private XenforoService $services;
public function __construct(public readonly object $data) {
$this->services = app(XenforoService::class);
}
public function __get(string $name): mixed {
return $this->data->$name ?? null;
}
public function getAuthIdentifierName(): string
{
return 'user_id';
}
public function getAuthIdentifier(): mixed
{
return $this->data->user_id;
}
public function getAuthPasswordName(): string
{
return 'password';
}
public function getAuthPassword(): string
{
return '';
}
public function getRememberToken(): string
{
return '';
}
public function setRememberToken($value): void
{
return;
}
public function getRememberTokenName(): string
{
return '';
}
/**
* Get XenForo avatar if it exist.
*
* @param string $xfSize
*
* @return string|null
*/
public function getAvatarUrl( string $xfSize = 'm' ): ?string
{
$userId = $this->data->user_id;
$avatarDate = $this->data->avatar_date;
if( $avatarDate ){
$group = floor($userId / 1000);
return config('app.forum_url') . "/data/avatars/{$xfSize}/{$group}/{$userId}.jpg?{$avatarDate}";
}
return null;
}
public function can(string $permissionGroup, string $permissionName): bool
{
if( !$this->permissions ){
$this->permissions = $this->services->getPermissions($this->data->user_id, $this->data->permission_combination_id);
}
return ($this->permissions[$permissionGroup][$permissionName] ?? 0) === true;
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Exceptions;
use Exception;
class SubmissionException extends Exception
{
//
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\Authors;
use App\Filament\Resources\Authors\Pages\CreateAuthor;
use App\Filament\Resources\Authors\Pages\EditAuthor;
use App\Filament\Resources\Authors\Pages\ListAuthors;
use App\Filament\Resources\Authors\Schemas\AuthorForm;
use App\Filament\Resources\Authors\Tables\AuthorsTable;
use App\Models\Author;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class AuthorResource extends Resource
{
protected static ?string $model = Author::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::UserGroup;
protected static ?string $recordTitleAttribute = 'name';
public static function form(Schema $schema): Schema
{
return AuthorForm::configure($schema);
}
public static function table(Table $table): Table
{
return AuthorsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListAuthors::route('/'),
'create' => CreateAuthor::route('/create'),
'edit' => EditAuthor::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Authors\Pages;
use App\Filament\Resources\Authors\AuthorResource;
use Filament\Resources\Pages\CreateRecord;
class CreateAuthor extends CreateRecord
{
protected static string $resource = AuthorResource::class;
}

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Filament\Resources\Authors\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class AuthorForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->maxLength(255)
->required(),
TextInput::make('slug')
->maxLength(255)
->unique(ignoreRecord: true)
->required(),
TextInput::make('website')
->url()
->maxLength(500)
->default(null),
TextInput::make('user_id')
->numeric()
->default(null),
]);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Filament\Resources\Authors\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class AuthorsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('slug')
->searchable(),
TextColumn::make('website')
->searchable(),
TextColumn::make('user_id')
->numeric()
->sortable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\Entries;
use App\Filament\Resources\Entries\Pages\CreateEntry;
use App\Filament\Resources\Entries\Pages\EditEntry;
use App\Filament\Resources\Entries\Pages\ListEntries;
use App\Filament\Resources\Entries\Schemas\EntryForm;
use App\Filament\Resources\Entries\Tables\EntriesTable;
use App\Models\Entry;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class EntryResource extends Resource
{
protected static ?string $model = Entry::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
protected static ?string $recordTitleAttribute = 'title';
public static function form(Schema $schema): Schema
{
return EntryForm::configure($schema);
}
public static function table(Table $table): Table
{
return EntriesTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListEntries::route('/'),
'create' => CreateEntry::route('/create'),
'edit' => EditEntry::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Entries\Pages;
use App\Filament\Resources\Entries\EntryResource;
use Filament\Resources\Pages\CreateRecord;
class CreateEntry extends CreateRecord
{
protected static string $resource = EntryResource::class;
}

View File

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

View File

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

View File

@@ -0,0 +1,139 @@
<?php
namespace App\Filament\Resources\Entries\Schemas;
use App\Models\Author;
use App\Models\Game;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
class EntryForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Select::make('type')
->options([
'translations' => 'Translations',
'romhacks' => 'Romhacks',
'homebrew' => 'Homebrew',
'utilities' => 'Utilities',
'documents' => 'Documents',
'lua-scripts' => 'Lua scripts',
'tutorials' => 'Tutorials',
])
->required()
->live(),
TextInput::make('title')
->maxLength(255)
->required(),
TextInput::make('slug')
->maxLength(255)
->unique(ignoreRecord: true)
->required(),
Textarea::make('description')
->required()
->columnSpanFull(),
FileUpload::make('main_image')
->image(),
Select::make('state')
->options([
'draft' => 'Draft',
'pending' => 'Pending',
'published' => 'Published',
'locked' => 'Locked',
'hidden' => 'Hidden',
])
->default('draft')
->required(),
Toggle::make('featured')
->required(),
Select::make('game_id')
->relationship('game', 'name')
->searchable()
->default(null)
->hidden(fn(Get $get) => $get('type') === 'homebrew')
->createOptionForm([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('slug')
->required()
->maxLength(255),
Select::make('platform_id')
->relationship('platform', 'name')
->required(),
Select::make('genre_id')
->relationship('genre', 'name')
->required(),
])
->createOptionUsing(function (array $data){
return Game::create( $data )->id;
})
,
Select::make('platform_id')
->relationship('platform', 'name')
->default(null)
->hidden(fn(Get $get) => in_array( $get('type'), [ 'translations', 'romhacks' ] ) ),
Select::make('status_id')
->relationship('status', 'id')
->default(null),
Select::make('authors')
->relationship('authors', 'name')
->searchable()
->multiple()
->createOptionForm([
TextInput::make('name')
->maxLength(255)
->required(),
TextInput::make('slug')
->maxLength(255)
->unique(ignoreRecord: true)
->required(),
TextInput::make('website')
->url()
->maxLength(500)
->default(null),
TextInput::make('user_id')
->numeric()
->default(null),
])
->createOptionUsing(function (array $data){
return Author::create( $data )->id;
})
,
Select::make('languages')
->relationship('languages', 'name')
->multiple()
->preload(),
Select::make('modifications')
->relationship('modifications', 'name')
->multiple()
->preload()
->hidden(fn(Get $get) => $get('type') !== "romhacks"),
TextInput::make('version')
->default(null),
DatePicker::make('release_date'),
Textarea::make('staff_credits')
->default(null)
->columnSpanFull(),
TextInput::make('relevant_link')
->default(null),
TextInput::make('youtube_link')
->default(null),
TextInput::make('user_id')
->required()
->numeric(),
TextInput::make('comments_thread_id')
->numeric()
->default(null),
]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Filament\Resources\Entries\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class EntriesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('type')
->badge(),
TextColumn::make('title')
->searchable(),
TextColumn::make('slug')
->searchable(),
TextColumn::make('state')
->badge(),
IconColumn::make('featured')
->boolean(),
TextColumn::make('version')
->searchable(),
TextColumn::make('user_id')
->numeric()
->sortable(),
TextColumn::make('comments_thread_id')
->numeric()
->sortable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\Games;
use App\Filament\Resources\Games\Pages\CreateGame;
use App\Filament\Resources\Games\Pages\EditGame;
use App\Filament\Resources\Games\Pages\ListGames;
use App\Filament\Resources\Games\Schemas\GameForm;
use App\Filament\Resources\Games\Tables\GamesTable;
use App\Models\Game;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class GameResource extends Resource
{
protected static ?string $model = Game::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::PlayCircle;
protected static ?string $recordTitleAttribute = 'name';
public static function form(Schema $schema): Schema
{
return GameForm::configure($schema);
}
public static function table(Table $table): Table
{
return GamesTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListGames::route('/'),
'create' => CreateGame::route('/create'),
'edit' => EditGame::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Games\Pages;
use App\Filament\Resources\Games\GameResource;
use Filament\Resources\Pages\CreateRecord;
class CreateGame extends CreateRecord
{
protected static string $resource = GameResource::class;
}

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Filament\Resources\Games\Schemas;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class GameForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('slug')
->required()
->maxLength(255),
Select::make('platform_id')
->relationship('platform', 'name')
->required(),
Select::make('genre_id')
->relationship('genre', 'name')
->required(),
]);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Filament\Resources\Games\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class GamesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('slug')
->searchable(),
TextColumn::make('platform.name')
->searchable(),
TextColumn::make('genre.name')
->searchable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\Genres;
use App\Filament\Resources\Genres\Pages\CreateGenre;
use App\Filament\Resources\Genres\Pages\EditGenre;
use App\Filament\Resources\Genres\Pages\ListGenres;
use App\Filament\Resources\Genres\Schemas\GenreForm;
use App\Filament\Resources\Genres\Tables\GenresTable;
use App\Models\Genre;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class GenreResource extends Resource
{
protected static ?string $model = Genre::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::FingerPrint;
protected static ?string $recordTitleAttribute = 'name';
public static function form(Schema $schema): Schema
{
return GenreForm::configure($schema);
}
public static function table(Table $table): Table
{
return GenresTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListGenres::route('/'),
'create' => CreateGenre::route('/create'),
'edit' => EditGenre::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Genres\Pages;
use App\Filament\Resources\Genres\GenreResource;
use Filament\Resources\Pages\CreateRecord;
class CreateGenre extends CreateRecord
{
protected static string $resource = GenreResource::class;
}

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Filament\Resources\Genres\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class GenreForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('slug')
->required()
->maxLength(255),
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\Genres\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class GenresTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('slug')
->searchable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\Languages;
use App\Filament\Resources\Languages\Pages\CreateLanguage;
use App\Filament\Resources\Languages\Pages\EditLanguage;
use App\Filament\Resources\Languages\Pages\ListLanguages;
use App\Filament\Resources\Languages\Schemas\LanguageForm;
use App\Filament\Resources\Languages\Tables\LanguagesTable;
use App\Models\Language;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class LanguageResource extends Resource
{
protected static ?string $model = Language::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::Language;
protected static ?string $recordTitleAttribute = 'name';
public static function form(Schema $schema): Schema
{
return LanguageForm::configure($schema);
}
public static function table(Table $table): Table
{
return LanguagesTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListLanguages::route('/'),
'create' => CreateLanguage::route('/create'),
'edit' => EditLanguage::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Languages\Pages;
use App\Filament\Resources\Languages\LanguageResource;
use Filament\Resources\Pages\CreateRecord;
class CreateLanguage extends CreateRecord
{
protected static string $resource = LanguageResource::class;
}

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Filament\Resources\Languages\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class LanguageForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->maxLength(255)
->required(),
TextInput::make('slug')
->maxLength(255)
->unique(ignoreRecord: true)
->required(),
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\Languages\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class LanguagesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('slug')
->searchable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\Modifications;
use App\Filament\Resources\Modifications\Pages\CreateModification;
use App\Filament\Resources\Modifications\Pages\EditModification;
use App\Filament\Resources\Modifications\Pages\ListModifications;
use App\Filament\Resources\Modifications\Schemas\ModificationForm;
use App\Filament\Resources\Modifications\Tables\ModificationsTable;
use App\Models\Modification;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class ModificationResource extends Resource
{
protected static ?string $model = Modification::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::ArchiveBox;
protected static ?string $recordTitleAttribute = 'name';
public static function form(Schema $schema): Schema
{
return ModificationForm::configure($schema);
}
public static function table(Table $table): Table
{
return ModificationsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListModifications::route('/'),
'create' => CreateModification::route('/create'),
'edit' => EditModification::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Modifications\Pages;
use App\Filament\Resources\Modifications\ModificationResource;
use Filament\Resources\Pages\CreateRecord;
class CreateModification extends CreateRecord
{
protected static string $resource = ModificationResource::class;
}

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Filament\Resources\Modifications\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class ModificationForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->maxLength(255)
->required(),
TextInput::make('slug')
->maxLength(255)
->unique(ignoreRecord: true)
->required(),
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\Modifications\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ModificationsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('slug')
->searchable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Platforms\Pages;
use App\Filament\Resources\Platforms\PlatformResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePlatform extends CreateRecord
{
protected static string $resource = PlatformResource::class;
}

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\Platforms;
use App\Filament\Resources\Platforms\Pages\CreatePlatform;
use App\Filament\Resources\Platforms\Pages\EditPlatform;
use App\Filament\Resources\Platforms\Pages\ListPlatforms;
use App\Filament\Resources\Platforms\Schemas\PlatformForm;
use App\Filament\Resources\Platforms\Tables\PlatformsTable;
use App\Models\Platform;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class PlatformResource extends Resource
{
protected static ?string $model = Platform::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::Cube;
protected static ?string $recordTitleAttribute = 'name';
public static function form(Schema $schema): Schema
{
return PlatformForm::configure($schema);
}
public static function table(Table $table): Table
{
return PlatformsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListPlatforms::route('/'),
'create' => CreatePlatform::route('/create'),
'edit' => EditPlatform::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Filament\Resources\Platforms\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class PlatformForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->required()
->maxLength(100),
TextInput::make('slug')
->required()
->unique( ignoreRecord: true )
->maxLength(100),
TextInput::make('short_name')
->default(null)
->maxLength(30),
]);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Filament\Resources\Platforms\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class PlatformsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable()
->sortable(),
TextColumn::make('slug')
->searchable(),
TextColumn::make('short_name')
->searchable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Statuses\Pages;
use App\Filament\Resources\Statuses\StatusResource;
use Filament\Resources\Pages\CreateRecord;
class CreateStatus extends CreateRecord
{
protected static string $resource = StatusResource::class;
}

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Filament\Resources\Statuses\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class StatusForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->maxLength(255)
->required(),
TextInput::make('slug')
->maxLength(255)
->unique(ignoreRecord: true)
->required(),
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\Statuses;
use App\Filament\Resources\Statuses\Pages\CreateStatus;
use App\Filament\Resources\Statuses\Pages\EditStatus;
use App\Filament\Resources\Statuses\Pages\ListStatuses;
use App\Filament\Resources\Statuses\Schemas\StatusForm;
use App\Filament\Resources\Statuses\Tables\StatusesTable;
use App\Models\Status;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class StatusResource extends Resource
{
protected static ?string $model = Status::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::ChartBar;
protected static ?string $recordTitleAttribute = 'name';
public static function form(Schema $schema): Schema
{
return StatusForm::configure($schema);
}
public static function table(Table $table): Table
{
return StatusesTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListStatuses::route('/'),
'create' => CreateStatus::route('/create'),
'edit' => EditStatus::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\Statuses\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class StatusesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('slug')
->searchable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Helpers;
use Illuminate\Support\Str;
class EntryHelpers {
/**
* Create a unique slug.
*
* @param string $title
* @param class-string $model The model with a 'slug' field.
* @param int|null $ignoreId Ignore specific ID, like when edition
*
* @return string The original slug if no duplicates, otherwise with a number at the end.
*/
public static function uniqueSlug(string $title, string $model, ?int $ignoreId = null ): string {
$slug = Str::slug($title);
$baseSlug = $slug;
$i = 1;
while(
$model::where( 'slug', $slug )
->when($ignoreId, fn($q) => $q->where('id', '!=', $ignoreId))
->exists() && $i < 100
){
$slug = $baseSlug . '-' . $i++;
}
if( $i >= 100 ){
$slug = Str::uuid(); // Fallback...
}
return $slug;
}
/**
* Build complete title.
*
* @param string $section
* @param array $fields
*
* @return string
*/
public static function buildCompleteTitle( string $section, array $fields = [] ){
return match ($section) {
'translations' => sprintf('%s (%s Translation) %s', $fields['entry_title'] ?? $fields['game_name'], $fields['languages_string'], $fields['platform_name']),
'romhacks' => sprintf('%s (%s) Romhack', $fields['entry_title'], $fields['platform_name']),
'homebrew' => sprintf('%s (%s) Homebrew', $fields['game_name'], $fields['platform_name']),
'utilities' => sprintf('%s - Utility', $fields['entry_title']),
'documents' => sprintf('%s - Document', $fields['entry_title']),
'lua-scripts' => sprintf('%s (%s) LUA Script', $fields['entry_title'], $fields['platform_name']),
'tutorials' => sprintf('%s - Tutorial', $fields['entry_title']),
default => $fields['entry_title'],
};
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Helpers;
class FormHelpers {
private static array $formWords = [
'translations' => [
'page_title' => "Submit a translation",
'about_the' => "About the translation",
'entry_title' => "Custom title",
'entry_title_helper' => "If the translation have a custom title. If not, leave it blank and the game title will be taken.",
'version' => "Patch version",
'status' => "Status",
'release_date' => "Release date",
'release_date_helper' => "If only initial release exist, the release date.",
'description' => "Description",
'about_game' => "Game Information",
'attachments' => "Attachments",
'authors' => "Team members",
'related_links' => "Related links",
'release_site' => "Release site",
'release_site_helper' => "Project entry on site/blog/forum/Github.",
'youtube_video' => "YouTube video",
],
'romhacks' => [
'page_title' => "Submit a romhack",
'about_the' => "About the romhack",
'entry_title' => "Hack title",
'type_of_hack' => "Type of hack",
'version' => "Patch version",
'status' => "Status",
'release_date' => "Release date",
'release_date_helper' => "If only initial release exist, the release date.",
'description' => "Description",
'about_game' => "Game Information",
'attachments' => "Attachments",
'authors' => "Team members",
'related_links' => "Related links",
'release_site' => "Release site",
'release_site_helper' => "Project entry on site/blog/forum/Github.",
'youtube_video' => "YouTube video",
],
];
public static function getEntryFormWords( string $section ){
return self::$formWords[$section] ?? [];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Helpers;
use App\Auth\XenForoUser;
class XenForoHelpers {
const array XF_AVATAR_COLORS = ['#FF6B6B','#FF9F43','#48DBFB','#1DD1A1','#5F27CD','#341f97','#EE5A24','#009432'];
/**
* Have XenForo default profile picture letter.
*
* @param ?XenForoUser $user If null, default : actual user.
*
* @return string
*/
public static function getAvatarLetter( ?XenForoUser $user = null ): string {
if( $user === null ) {
$user = \Auth::user();
if( $user === null ) {
return '';
}
}
return strtoupper(mb_substr($user->data->username, 0, 1));
}
public static function getAvatarColor( ?XenForoUser $user = null ): string {
if( $user === null ) {
$user = \Auth::user();
if( $user === null ) {
return '';
}
}
return self::XF_AVATAR_COLORS[ crc32( $user->data->username ) % count( self::XF_AVATAR_COLORS ) ];
}
}

View File

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

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers;
use App\Models\Entry;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EntryController extends Controller
{
private const SECTION_TYPES = [ 'translations', 'romhacks', 'homebrew', 'utilities', 'documents', 'lua-scripts', 'tutorials' ];
public function index(): View
{
$entries = Entry::published()
->with(['game.platform', 'platform'])
->latest('published_at')
->paginate(30);
return view('entries.index', compact('entries'));
}
public function show(string $section, Entry $entry): View
{
if( ! in_array($section, self::SECTION_TYPES) )
abort(404);
if( $entry->type !== $section )
abort(404);
return view('entries.show', compact('entry', 'section'));
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers;
use App\Models\EntryFile;
use App\Services\FileServersService;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class FileServerController extends Controller {
public function __construct(private FileServersService $fs) {}
/**
* @throws ConnectionException
*/
public function uploadChunk(Request $request, string $section): JsonResponse {
$request->validate([
'file' => 'required|file',
'file_uuid' => 'required|string|max:128',
'current_chunk' => 'required|integer|min:0',
'total_chunks' => 'required|integer|min:1',
'filename' => 'required|string|max:255',
]);
$type = $section;
$fileUuid = $request->input('file_uuid');
$currentChunk = (int) $request->input('current_chunk');
$totalChunks = (int) $request->input('total_chunks');
$filename = $request->input('filename');
$data = $this->fs->uploadChunk(
$request->file('file'),
$fileUuid,
$currentChunk,
$totalChunks,
$filename,
$type
);
if( !isset( $data['file'] ) || $data['file'] === false ){
$data['finished'] = false;
return response()->json($data);
}
\Cache::put("uploaded_file_{$fileUuid}", [
'uuid' => $fileUuid,
'type' => $type,
'filename' => $filename,
'filepath' => $data['file_path'],
'filesize' => $data['file']['size'],
'favorite_server' => $data['favorite_server'],
'favorite_at' => time()
], now()->addHours(2) );
$data['finished'] = true;
return response()->json($data);
}
public function download(Request $request, int $entry_id, EntryFile $file ) {
if( $file->entry_id != $entry_id ) {
abort(404);
}
// TODO: DL Count.
return redirect( $this->fs->getDownloadFileUrl( $file) );
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\View\View;
class HomeController extends Controller
{
public function index(): View {
return view('home');
}
}

View File

@@ -0,0 +1,265 @@
<?php
namespace App\Http\Controllers;
use App\Exceptions\SubmissionException;
use App\Helpers\FormHelpers;
use App\Http\Requests\StoreEntryRequest;
use App\Models\Author;
use App\Models\Entry;
use App\Models\EntryFile;
use App\Models\EntryGallery;
use App\Models\EntryHash;
use App\Models\Game;
use App\Models\Genre;
use App\Models\Language;
use App\Models\Modification;
use App\Models\Platform;
use App\Models\Status;
use App\Services\SubmissionsService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
class SubmissionController extends Controller
{
public function __construct(private SubmissionsService $services){}
public function create(Request $request, string $section)
{
$data = [
'entry' => new Entry(),
'section' => $section,
'words' => FormHelpers::getEntryFormWords($section),
'isEdit' => false,
'oldModifications' => old( 'modifications', [] ),
'oldLanguages' => old( 'languages', [] ),
'oldFilesArray' => $this->services->prepareOldFiles( null )
];
if( $data['words'] === [] )
abort(500);
if( section_must_be( 'romhacks', $section ) ){
$data['modifications'] = Modification::orderBy('name')->get();
}
if( section_must_be( [ 'romhacks', 'translations' ], $section ) ){
$data['statuses'] = Status::orderBy('id')->get();
}
return view('submissions.create', $data);
}
public function edit(Request $request, string $section, Entry $entry){
if( $entry->type !== $section )
abort(404);
$data = [
'entry' => $entry,
'section' => $section,
'words' => FormHelpers::getEntryFormWords($section),
'isEdit' => true,
'oldModifications' => old('modifications', $entry->modifications->pluck('id')->toArray() ?? [] ),
'oldLanguages' => old('languages', $entry->languages->pluck('id')->toArray() ?? [] ),
'oldFilesArray' => $this->services->prepareOldFiles( $entry )
];
if( $data['words'] === [] )
abort(500);
if( section_must_be( 'romhacks', $section ) ){
$data['modifications'] = Modification::orderBy('name')->get();
}
if( section_must_be( [ 'romhacks', 'translations' ], $section ) ){
$data['statuses'] = Status::orderBy('id')->get();
}
return view('submissions.edit', $data);
}
public function store(StoreEntryRequest $request, string $section){
try {
$entry = $this->services->storeEntry($request, $section);
return match ($entry->state) {
'published' => redirect()->route('entries.show', ['section' => $section, 'entry' => $entry->slug])->with('success', "Your entry has been published."),
'pending' => redirect()->route('home')->with('success', "Your entry has been submitted and is pending review."),
default => redirect()->route('home')->with('success', "Your entry has been saved as a draft.")
};
} catch ( SubmissionException $e ) {
return back()->withInput()->withErrors(['error' => $e->getMessage()]);
} catch ( \Exception $e ) {
return back()->withInput()->withErrors(['error' => 'Unknown error: '.$e->getMessage()]);
}
}
public function update(StoreEntryRequest $request, string $section, Entry $entry)
{
if( $entry->type !== $section ) {
abort(404);
}
$gameId = null;
if( !$request->input('game_id') ){
if( $request->input('new-game-title') && $request->input('new-game-platform') && $request->input('new-game-genre') ){
$platform = Platform::find($request->input('new-game-platform'));
$genre = Genre::find($request->input('new-game-genre'));
$game = Game::create([
'name' => $request->input('new-game-title'),
'slug' => Str::slug($request->input('new-game-title')),
'platform_id' => $platform->id,
'genre_id' => $genre->id,
]);
$gameId = $game->id;
}
} else {
$gameId = $request->input('game_id');
}
$mainImage = $entry->main_image;
if ( $request->hasFile('main-image') ) {
if ( $mainImage ) {
Storage::disk('public')->delete($mainImage);
}
$mainImage = $request->file('main-image')->store('entries/main_images', 'public');
} elseif ( $request->input('remove_main_image') === '1' ) {
if ( $mainImage ) {
Storage::disk('public')->delete($mainImage);
}
$mainImage = null;
}
$staffCredits = collect($request->input('credits', []))
->filter(fn($item) => isset($item['name']) || isset($item['description']))
->map(function ($item) {
$name = trim($item['name'] ?? '');
$description = trim($item['description'] ?? '');
if ($name === '' && $description === '') {
return null;
}
return trim($name . ($name !== '' && $description !== '' ? ' — ' : '') . $description);
})
->filter()
->implode("\n");
$fields = [
'type' => $section,
'title' => $request->input('entry_title'),
'slug' => $request->input('slug') ?? Str::slug($request->input('entry_title', '')),
'description' => $request->input('description'),
'main_image' => $mainImage,
'state' => $request->input('submit-state', 'draft'),
'game_id' => $gameId,
'status_id' => $request->input('status'),
'version' => $request->input('version'),
'release_date' => $request->input('release-date'),
'staff_credits' => $staffCredits ?: null,
'relevant_link' => $request->input('release_site'),
'youtube_link' => $request->input('youtube_video'),
];
$entry->update($fields);
$entry->hashes()->delete();
foreach ( $request->input('hashes', []) as $hash ) {
if( !isset($hash['filename'], $hash['crc32'], $hash['sha1'], $hash['verified']) ) {
continue;
}
EntryHash::create([
'entry_id' => $entry->id,
'filename' => $hash['filename'],
'hash_crc32' => $hash['crc32'],
'hash_sha1' => $hash['sha1'],
'verified' => $hash['verified'],
]);
}
$authorIds = [];
foreach ( $request->input('authors', []) as $authorId ) {
$author = Author::find($authorId);
if( $author ) {
$authorIds[] = $author->id;
}
}
foreach( $request->input('new-authors', []) as $authorName ) {
$authorName = trim($authorName);
if ($authorName === '') continue;
$author = Author::firstOrCreate(
['slug' => Str::slug($authorName)],
['name' => $authorName],
);
$authorIds[] = $author->id;
}
$entry->authors()->sync(array_values(array_unique($authorIds)));
if( section_must_be( 'romhacks', $section ) ){
$entry->modifications()->sync($request->input('modifications', []));
} else {
$entry->modifications()->sync([]);
}
$entry->languages()->sync($request->input('languages', []));
$existingFileUuids = $request->input('existing_file_ids', []);
if (!is_array($existingFileUuids)) {
$existingFileUuids = [];
}
$entry->files()->whereNotIn('file_uuid', $existingFileUuids)->delete();
foreach ( $request->input('file_ids', []) as $file_uuid ) {
$fileData = Cache::pull("uploaded_file_{$file_uuid}");
if( ! $fileData ) {
continue;
}
EntryFile::create([
'entry_id' => $entry->id,
'file_uuid' => $fileData['uuid'],
'filename' => $fileData['filename'],
'filepath' => $fileData['filepath'],
'favorite_server' => $fileData['favorite_server'],
'favorite_at' => \DateTimeImmutable::createFromTimestamp( $fileData['favorite_at'] ),
'filesize' => $fileData['filesize'],
'state' => 'public'
]);
}
$existingGalleryIds = $request->input('existing_gallery_ids', []);
if (!is_array($existingGalleryIds)) {
$existingGalleryIds = [];
}
$entry->gallery()->whereNotIn('id', $existingGalleryIds)->get()->each(function ($gallery) {
if ($gallery->image) {
Storage::disk('public')->delete($gallery->image);
}
$gallery->delete();
});
foreach ( $request->file('gallery', [] ) as $galleryFile ){
if( !$galleryFile->isValid() ){
continue;
}
$path = $galleryFile->store('entries/gallery/' . $entry->id, 'public');
EntryGallery::create([
'entry_id' => $entry->id,
'image' => $path
]);
}
return match( $entry->state ){
'published' => redirect()->route('entries.show', [ 'section' => $section, 'entry' => $entry->slug ])->with('success', "Your entry has been published."),
'pending' => redirect()->route('home')->with('success', "Your entry has been submitted and is pending review."),
default => redirect()->route('home')->with('success', "Your entry has been saved as a draft.")
};
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\TemporaryFileUploadRequest;
use App\Services\TemporaryFileService;
use Illuminate\Http\Request;
class TemporaryFileController extends Controller
{
public function __construct(private TemporaryFileService $services) {}
public function upload(TemporaryFileUploadRequest $request){
$file = $request->file('file');
if( !$file || !$file->isValid()){
response()->json( ['path' => null], 400);
}
$path = $this->services->uploadFile( $file );
if( !$path ){
response()->json( ['path' => null], 500);
}
return response()->json(['path' => $path]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckXenForoPermissions
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next, string ...$permissions ): Response
{
if( !\Auth::check() )
return redirect()->to(config('app.forum_url') . '/login' );
if( empty($permissions) ) // No permissions needed.
return $next($request);
foreach ($permissions as $permissionStr) {
[$group, $permission] = explode('.', $permissionStr);
if( !\Auth::user()->can($group, $permission) )
return $this->deny($request, $permission);
}
return $next($request);
}
private function deny(Request $request, string $permission): Response
{
if($request->expectsJson())
return \response()->json(['error' => 'forbidden'], 403);
return response()->view('pages.forbidden', [
'permission' => $permission,
], 403 );
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Http\Requests;
use App\Rules\PublicFileExists;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class StoreEntryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
// TODO: Change it by role.
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function prepareForValidation(): void
{
$newGameTitle = trim((string) $this->input('new-game-title', ''));
$newGamePlatform = $this->input('new-game-platform');
$newGameGenre = $this->input('new-game-genre');
$this->merge([
'game_id' => $this->input('game_id') !== '' ? $this->input('game_id') : null,
'new-game-title' => $newGameTitle !== '' ? $newGameTitle : null,
'new-game-platform' => $newGamePlatform !== '' ? $newGamePlatform : null,
'new-game-genre' => $newGameGenre !== '' ? $newGameGenre : null,
'gallery' => $this->input('gallery') !== '' ? $this->input('gallery') : null,
]);
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$rules = [];
$section = $this->route('section');
$rules['files_uuid'] = 'array|required|min:1';
$rules['files_uuid.*'] = 'string';
if( section_must_not_be( 'translations', $section ) ){
$rules['entry_title'] = "required|string|max:255";
} else {
$rules['entry_title'] = "nullable|string|max:255";
}
if( section_must_be( 'romhacks', $section ) ){
$rules['modifications'] = 'array|required|min:1';
$rules['modifications.*'] = 'integer|exists:modifications,id';
}
$rules['version'] = 'required|string|max:50';
$rules['release-date'] = 'required|date';
$rules['status'] = 'required|integer|exists:statuses,id';
$rules['description'] = 'required|string';
if( section_must_be( ['romhacks', 'translations' ], $section ) ){
$rules['game_id'] = 'required_without:new-game-title|nullable|integer|exists:games,id';
$rules['new-game-title'] = 'required_without:game_id|nullable|string|max:255';
$rules['new-game-platform'] = 'required_with:new-game-title|nullable|integer|exists:platforms,id';
$rules['new-game-genre'] = 'required_with:new-game-title|integer|nullable|exists:genres,id';
}
$rules['hashes'] = 'array|required|min:1';
$rules['hashes.*.filename'] = 'required|string|max:512';
$rules['hashes.*.hash_crc32'] = 'required|string|max:512';
$rules['hashes.*.hash_sha1'] = 'required|string|max:512';
$rules['hashes.*.verified'] = 'required|string|max:512';
$rules['languages'] = 'array|required|min:1';
$rules['languages.*'] = 'integer|exists:languages,id';
$rules['main-image'] = [ 'required', 'string', new PublicFileExists ];
$rules['gallery'] = 'array|required|min:1';
$rules['gallery.*'] = [ 'string', new PublicFileExists ];
$rules['authors'] = 'array|required_without:new-authors|min:1';
$rules['authors.*'] = 'integer|exists:authors,id';
$rules['new-authors'] = 'array|required_without:authors|min:1';
$rules['new-authors.*'] = 'string|max:255';
$rules['staff_credits'] = 'nullable|json';
$rules['release_site'] = 'nullable|url|max:500';
$rules['youtube_video'] = 'nullable|url|max:500';
$rules['submit-state'] = 'required|string|in:draft,pending,published';
return $rules;
}
public function messages(): array
{
return [
'entry_title.required' => 'Please provide an entry title.',
'slug.unique' => 'An entry with this title already exists. Please choose another title.',
'file_ids.required' => 'Please upload at least one file.',
'file_ids.min' => 'Please upload at least one file.',
'hashes.required' => 'Please add at least one hash.',
'hashes.min' => 'Please add at least one hash.',
'gallery.required' => 'Please add at least one screenshot.',
'gallery.min' => 'Please add at least one screenshot.',
'new-game-platform.required_without' => 'Please choose a platform for the new game.',
'new-game-genre.required_without' => 'Please choose a genre for the new game.',
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class TemporaryFileUploadRequest 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, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'file' => 'required|file|max:100000'
];
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Livewire;
use App\Models\Author;
use App\Models\Game;
use App\Models\Genre;
use App\Models\Platform;
use Illuminate\View\View;
use Livewire\Component;
class AuthorsSelector extends Component
{
public string $search = '';
public bool $newAuthor = false;
public string $newAuthorName = '';
public array $selectedAuthors = []; // 'id', 'name'
public array $newAuthors = [];
public bool $dropdown = false;
public function mount( array $oldAuthors = [], array $oldNewAuthors = [] ): void
{
$selectedAuthors = [];
if ( is_string($oldAuthors) || is_int($oldAuthors) ) {
$authors = [ $oldAuthors ];
}
if ( is_array($oldAuthors) && $oldAuthors !== [] ) {
$firstItem = reset($oldAuthors);
if ( is_array($firstItem) && array_key_exists('id', $firstItem) ) {
$selectedAuthors = $oldAuthors;
} else {
$authorRecords = Author::whereIn('id', array_filter($oldAuthors, fn($id) => $id !== null))->get()->keyBy('id');
foreach ($oldAuthors as $authorId) {
if ( isset($authorRecords[$authorId]) ) {
$selectedAuthors[] = [
'id' => $authorRecords[$authorId]->id,
'name' => $authorRecords[$authorId]->name,
];
}
}
}
}
if ( is_string($oldNewAuthors) || is_int($oldNewAuthors) ) {
$newAuthors = [ $oldNewAuthors ];
}
foreach ( (array) $oldNewAuthors as $name ) {
if ( trim((string) $name) === '' ) {
continue;
}
$selectedAuthors[] = [
'id' => null,
'name' => trim((string) $name),
];
}
$this->selectedAuthors = $selectedAuthors;
$this->newAuthors = $oldNewAuthors;
}
public function updatedSearch(): void
{
$this->dropdown = strlen($this->search) > 2;
}
public function selectAuthor( int $id, string $name ): void
{
foreach ( $this->selectedAuthors as $author ) {
if ( $author['id'] === $id ) {
$this->search = '';
$this->dropdown = false;
return;
}
}
$this->selectedAuthors[] = [
'id' => $id,
'name' => $name,
];
$this->search = '';
$this->dropdown = false;
}
public function removeAuthor( int $i ): void
{
array_splice( $this->selectedAuthors, $i, 1 );
}
public function addNewAuthor(): void
{
if( empty( trim( $this->newAuthorName ) ) )
return;
foreach ( $this->selectedAuthors as $author ) {
if( strtolower($author['name']) === strtolower( $this->newAuthorName ) ){
$this->newAuthor = false;
$this->newAuthorName = '';
return;
}
}
$this->selectedAuthors[] = [
'id' => null,
'name' => trim( $this->newAuthorName ),
];
$this->newAuthor = false;
$this->newAuthorName = '';
}
public function switchNewAuthor(): void
{
$this->newAuthor = !$this->newAuthor;
$this->newAuthorName = '';
$this->search = '';
$this->dropdown = false;
}
public function render(): View
{
$authors = collect();
$data = [];
if( $this->dropdown && strlen( $this->search ) > 2 && !$this->newAuthor ){
$ids = array_filter(array_column( $this->selectedAuthors, 'id' ), function ( $id ) {
return !is_null( $id );
});
$authors = Author::where( 'name', 'like', '%' . $this->search . '%' )
->when( !empty( $ids ), function ( $query ) use ( $ids ) { $query->whereNotIn( 'id', $ids ); } )
->orderBy( 'name' )
->limit( 20 )
->get();
$data['authors'] = $authors;
}
return view('livewire.authors-selector', $data );
}
}

View File

@@ -0,0 +1,184 @@
<?php
namespace App\Livewire;
use App\Models\Game;
use App\Models\Genre;
use App\Models\Platform;
use Illuminate\View\View;
use Livewire\Component;
/**
* Game Selector for existing games and new games.
*/
class GameSelector extends Component
{
public const int REQUIRED_CHARS = 3;
/**
* If we are in new game mode.
* @var bool
*/
public bool $newGame = false;
/**
* Current user game search.
* @var string
*/
public string $search = '';
/**
* Existing game ID selected.
* @var int|null
*/
public ?int $gameId = null;
/**
* Existing game name selected or new game name.
* @var string|null
*/
public ?string $gameName = null;
/**
* New game platform ID.
* @var int|null
*/
public ?int $gamePlatformId = null;
/**
* New game genre ID.
* @var int|null
*/
public ?int $gameGenreId = null;
/**
* Existing game platform name or new game platform name.
* @var string|null
*/
public ?string $platformName = null;
/**
* Existing game genre name or new game genre name.
* @var string|null
*/
public ?string $genreName = null;
/**
* If dropdown must be rendered or not.
* @var bool
*/
public bool $dropdown = false;
public function mount( ?int $gameId = null, ?string $newGameTitle = null, ?int $newGamePlatform = null, ?int $newGameGenre = null ): void
{
// If we selected an existent game.
if( $gameId ){
$game = Game::with(['platform','genre'])->find($gameId);
if( $game ){
$this->gameId = $game->id;
$this->gameName = $game->name;
$this->platformName = $game->platform->name;
$this->genreName = $game->genre->name;
$this->search = $game->name;
$this->newGame = false;
return;
}
}
if( $newGameTitle || $newGamePlatform || $newGameGenre ){
$this->newGame = true;
$this->gameName = $newGameTitle;
$this->gamePlatformId = is_numeric($newGamePlatform) ? (int) $newGamePlatform : null;
$this->gameGenreId = is_numeric($newGameGenre) ? (int) $newGameGenre : null;
}
}
/**
* If we update search bar.
* @return void
*/
public function updatedSearch(): void
{
if( $this->gameId ){
$this->gameId = null;
$this->gameName = null;
$this->platformName = null;
$this->genreName = null;
}
$this->dropdown = strlen($this->search) >= self::REQUIRED_CHARS;
}
/**
* Select an existent game.
*
* @param int $id
* @param string $name
*
* @return void
*/
public function selectGame( int $id, string $name ): void
{
$game = Game::with(['platform','genre'])->find($id);
if( $game ){
$this->gameId = $game->id;
$this->gameName = $game->name;
$this->platformName = $game->platform->name;
$this->genreName = $game->genre->name;
$this->search = $game->name;
$this->dropdown = false;
$this->dispatch( 'game-selected', id: $id ); // Send an event to the JS part.
}
}
/**
* Clear existent game selection.
* @return void
*/
public function clearGame(): void
{
$this->gameId = null;
$this->gameName = null;
$this->platformName = null;
$this->genreName = null;
$this->search = '';
$this->dispatch( 'game-selected', id: null ); // Send an event to the JS part.
}
/**
* Switch mode.
* @return void
*/
public function switchNewGame(): void
{
$this->clearGame();
$this->newGame = !$this->newGame;
}
public function render(): View
{
$games = collect();
// Need to search games for dropdown.
if( $this->dropdown && strlen($this->search) >= self::REQUIRED_CHARS && $this->newGame === false ){
$games = Game::with(['platform','genre'])
->where('name', 'like', '%'.$this->search.'%')
->orderBy('name')
->limit(20)
->get();
}
$data = [ 'games' => $games, 'required_chars' => self::REQUIRED_CHARS ];
if( $this->newGame === true ){ // If we want a new game, get platforms and genres.
$data['platforms'] = Platform::orderBy('name')->get();
$data['genres'] = Genre::orderBy('name')->get();
}
$data['hasOldNewGame'] = old('new-game-title') || old('new-game-platform') || old('new-game-genre');
return view('livewire.game-selector', $data );
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Livewire;
use App\Models\EntryHash;
use App\Models\Game;
use App\Models\Genre;
use App\Models\Platform;
use Illuminate\View\View;
use Livewire\Component;
/**
* Hashes uploader in submission form.
* Upload hash and create fields.
*
* @phpstan-import-type HashObject from \App\Types\SubmissionTypes
*/
class HashesUpload extends Component
{
/**
* List of hashes.
* @var list<HashObject> $hashes
*/
public array $hashes = [];
/**
* Prepare old hashes.
*
* @param list<HashObject> $oldHashes
* @return void
*/
public function mount( array $oldHashes = [] ): void
{
foreach ($oldHashes as $hash) {
$this->addHash( $hash['filename'], $hash['hash_crc32'], $hash['hash_sha1'], $hash['verified'] );
}
}
/**
* Add an hash to the list.
*
* @param string $filename
* @param string $crc32
* @param string $sha1
* @param string|null $verified
*
* @return void
*/
public function addHash(string $filename, string $crc32, string $sha1, ?string $verified = null): void
{
$this->hashes[] = [
'filename' => $filename,
'hash_crc32' => $crc32,
'hash_sha1' => $sha1,
'verified' => $verified ?? "No-Intro" // TODO: Change it.
];
}
/**
* Remove a specific hash.
*
* @param int $index
* @return void
*/
public function removeHash(int $index): void
{
array_splice($this->hashes, $index, 1);
}
public function render(): View
{
return view('livewire.hashes-upload');
}
}

12
app/Models/Author.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Author extends Model
{
protected $fillable = [
'name', 'slug', 'user_id', 'website'
];
}

93
app/Models/Entry.php Normal file
View File

@@ -0,0 +1,93 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Entry extends Model
{
/**
* @var string[]
*/
protected $fillable = [
'type',
'title',
'slug',
'description',
'main_image',
'state',
'featured',
'game_id',
'platform_id',
'status_id',
'version',
'release_date',
'staff_credits',
'relevant_link',
'youtube_link',
'user_id',
'complete_title',
];
/**
* @var string[]
*/
protected $casts = [
'featured' => 'boolean',
'release_date' => 'date',
];
public function scopePublished( Builder $query ): Builder {
return $query->where( 'state', 'published' );
}
/**
* Return game link.
* @return BelongsTo
*/
public function game(): BelongsTo {
return $this->belongsTo(Game::class);
}
public function platform(): BelongsTo {
return $this->belongsTo(Platform::class);
}
public function getRealPlatform(): ?Platform {
return $this->game?->platform ?? $this->platform;
}
public function status(): BelongsTo {
return $this->belongsTo(Status::class );
}
public function authors(): BelongsToMany {
return $this->belongsToMany(Author::class, 'entry_authors');
}
public function languages(): BelongsToMany {
return $this->belongsToMany(Language::class, 'entry_languages');
}
public function modifications(): BelongsToMany {
return $this->belongsToMany( Modification::class, 'entry_modifications');
}
public function files(): HasMany {
return $this->hasMany(EntryFile::class)->orderBy('filename');
}
public function gallery(): HasMany {
return $this->hasMany(EntryGallery::class)->orderBy('id');
}
public function hashes(): HasMany {
return $this->hasMany(EntryHash::class);
}
}

23
app/Models/EntryFile.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EntryFile extends Model
{
protected $fillable = [
'entry_id', 'filename', 'filepath', 'favorite_server', 'favorite_at', 'filesize', 'state', 'file_uuid'
];
protected $casts = [
'favorite_at' => 'timestamp'
];
public function entry(): BelongsTo
{
return $this->belongsTo(Entry::class);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class EntryGallery extends Model
{
protected $fillable = ['entry_id','image'];
}

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

@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EntryHash extends Model
{
protected $fillable = [
'entry_id', 'filename', 'hash_crc32', 'hash_sha1', 'verified'
];
public function entry(): BelongsTo
{
return $this->belongsTo(Entry::class);
}
}

24
app/Models/Game.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Game extends Model
{
/**
* @var string[]
*/
protected $fillable = ['name', 'slug', 'platform_id', 'genre_id' ];
public function platform(): BelongsTo
{
return $this->belongsTo(Platform::class);
}
public function genre(): BelongsTo
{
return $this->belongsTo(Genre::class);
}
}

10
app/Models/Genre.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Genre extends Model
{
protected $fillable = [ 'name', 'slug' ];
}

10
app/Models/Language.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Language extends Model
{
protected $fillable = [ 'name', 'slug' ];
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Modification extends Model
{
protected $fillable = [ 'name', 'slug' ];
}

17
app/Models/Platform.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Platform extends Model
{
/**
* @var string[]
*/
protected $fillable = [
'name', 'slug', 'short_name'
];
}

10
app/Models/Status.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Status extends Model
{
protected $fillable = ['name', 'slug'];
}

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

@@ -0,0 +1,32 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Providers;
use App\Auth\XenForoGuard;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
\Auth::extend('xenforo', function ($app, $name, array $config) {
return new XenForoGuard($app['request']);
});
}
}

View File

@@ -0,0 +1,59 @@
<?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\PreventRequestForgery;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class ManagePanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('manage')
->path('manage')
->login()
->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,
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
PreventRequestForgery::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
]);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Storage;
use Illuminate\Translation\PotentiallyTranslatedString;
class PublicFileExists implements ValidationRule
{
/**
* Run the validation rule.
*
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if(!Storage::disk('public')->exists($value) ) {
$fail("The file {$value} does not exist.");
}
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Services;
use App\Models\EntryFile;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
class FileServersService {
public const FAVORITE_SERVER_TIME = 3600; // In seconds.
private const ZEUS_TOKEN_EXPIRATION = 600; // In seconds.
private string $apiKey;
private array $servers;
public function __construct()
{
$this->apiKey = config('fileservers.secret_key');
$this->servers = config('fileservers.servers');
}
public function generateZeusToken( int $user_id, string $to, string $action ){
$info = [
'user_id' => $user_id,
'to' => $to,
'action' => $action,
'generated_at' => time(),
'expires_at' => time() + self::ZEUS_TOKEN_EXPIRATION,
'romhackplaza' => bin2hex(random_bytes(16)),
];
$json = json_encode( $info );
$sig = hash_hmac( 'sha256', $json, $this->apiKey );
$end = base64_encode( $json ) . "|" . $sig;
return $end;
}
public function getRandomServerKey(): string
{
$keys = array_keys($this->servers);
return $keys[array_rand($keys)];
}
public function getEntryFileServerKey( EntryFile $file ): string
{
$serverKey = null;
if( $file->favorite_server !== null && $file->favorite_at !== null ) {
if( time() < $file->favorite_at + static::FAVORITE_SERVER_TIME ) {
$serverKey = $file->favorite_server;
}
}
if( $serverKey === null || !isset( $this->servers[$serverKey] ) ) {
$serverKey = $this->getRandomServerKey();
}
return $serverKey;
}
/**
* Download URL requires 'filepath' and 'filename'.
*
* @param EntryFile $file
* @return string
*/
public function getDownloadFileUrl( EntryFile $file ): string
{
$serverKey = $this->getEntryFileServerKey( $file );
$url = $this->servers[$serverKey]['download'] ?? "#";
if( $url === "#" )
return $url;
return $url . "&" . http_build_query( [ 'filename' => $file->filename, 'filepath' => $file->filepath ] );
}
/**
* @throws ConnectionException
*/
public function uploadChunk(
UploadedFile $chunk,
string $fileUUID,
int $currentChunk,
int $totalChunks,
string $filename,
string $type
){
// Define or get favorite server.
if( $currentChunk === 0 ){
$serverKey = $this->getRandomServerKey();
Cache::put("favorite_server_{$fileUUID}", $serverKey, now()->addHours(2) );
} else {
$serverKey = Cache::get( "favorite_server_{$fileUUID}" );
abort_if( !$serverKey, 422, "File upload expired, please retry." );
}
$server = $this->servers[$serverKey];
$filepath = $type . '/' . $fileUUID;
$response = Http::withHeaders([])
->attach( 'file', file_get_contents( $chunk->getRealPath() ), $fileUUID )
->post( $server['upload_chunk'], [
'filepath' => $filepath,
'filename' => $filename,
'current_chunk' => $currentChunk,
'total_chunks' => $totalChunks,
// TODO : Must replace User ID
'zeus' => $this->generateZeusToken( 0, $server['base_url'], "Uploadchunk" ),
]);
if (!$response->successful()) {
throw new \RuntimeException( $response->body() );
}
$data = $response->json();
if( isset( $data['file'] ) && $data['file'] !== false ){
Cache::forget( "favorite_server_{$fileUUID}" );
}
$data['favorite_server'] = $serverKey;
$data['file_uuid'] = $fileUUID;
$data['file_path'] = $filepath;
return $data;
}
}

View File

@@ -0,0 +1,388 @@
<?php
namespace App\Services;
use App\Exceptions\SubmissionException;
use App\Helpers\EntryHelpers;
use App\Http\Requests\StoreEntryRequest;
use App\Models\Author;
use App\Models\Entry;
use App\Models\EntryFile;
use App\Models\EntryGallery;
use App\Models\EntryHash;
use App\Models\Game;
use App\Models\Genre;
use App\Models\Language;
use App\Models\Modification;
use App\Models\Platform;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* @phpstan-import-type FSFileData from \App\Types\FSTypes
*/
class SubmissionsService {
/**
* Request for store/edit.
* @var StoreEntryRequest|null
*/
private ?StoreEntryRequest $request = null;
/**
* Section for store/edit.
* @var string|null
*/
private ?string $section = null;
/**
* @return list<FSFileData>
*/
public function prepareOldFiles( ?Entry $entry = null ): array
{
if( $entry === null ){
$files = old( 'files_uuid', [] );
} else {
$files = old( 'files_uuid', $entry->files->pluck('file_uuid')->toArray() );
}
if( $files === [] )
return [];
return array_map(
function( string $uuid ) {
$file = EntryFile::where('file_uuid', $uuid)->first();
if( $file )
return [
'name' => $file->filename,
'totalChunks' => 0, // Already uploaded.
'rawFile' => null,
'progressValue' => 0,
'currentChunk' => 0,
'done' => true,
'error' => null,
'uuid' => $uuid
];
$file = Cache::get("uploaded_file_{$uuid}");
if( $file )
return [
'name' => $file['filename'],
'totalChunks' => 0, // Already uploaded.
'rawFile' => null,
'progressValue' => 0,
'currentChunk' => 0,
'done' => true,
'error' => null,
'uuid' => $uuid
];
return null;
},
$files );
}
/**
* @param StoreEntryRequest $request
* @param string $section
*
* @return Entry
* @throws SubmissionException
* @throws \Throwable
*/
public function storeEntry( StoreEntryRequest $request, string $section ){
// STEP 1 : Prepare basic fields.
$this->request = $request;
$this->section = $section;
$user_id = 0; // TODO: Replace that.
$entry = DB::transaction(function () use ( $user_id ) {
// STEP 2 : Create game.
$gameId = null;
if( section_must_be( ['romhacks', 'translations'], $this->section ) ){
$gameId = $this->Step2_CreateAndReturnGameId();
}
// STEP 3 : Create Complete title.
$completeTitle = $this->Step3_BuildCompleteTitle( $gameId );
// STEP 4 : Generate slug and entry title.
$entrySlug = EntryHelpers::uniqueSlug( $completeTitle, Entry::class );
if( section_must_be( 'translations', $this->section ) &&
!$this->request->input('entry_title') ){
$entryTitle = Game::find($gameId)->name;
} else {
$entryTitle = $this->request->input('entry_title');
}
// STEP 5 : Removed / Delayed.
// $mainImage = $this->Step5_MoveMainImage();
// STEP 6 : Prepare entry fields and save entry.
$fields = [
'type' => $this->section,
'title' => $entryTitle,
'slug' => $entrySlug,
'description' => $this->request->input('description'),
'main_image' => $this->request->input('main-image'),
'state' => $this->request->input('submit-state'),
'game_id' => $gameId,
'status_id' => $this->request->input('status'),
'version' => $this->request->input('version'),
'release_date' => $this->request->input('release-date'),
'staff_credits' => $this->request->input('staff_credits'),
'relevant_link' => $this->request->input('release_site'),
'youtube_link' => $this->request->input('youtube_video'),
'user_id' => $user_id,
'complete_title' => $completeTitle,
];
$entry = Entry::create( $fields );
// STEP 7 : Save entry fields.
$this->Step7_SaveEntryFiles( $entry->id );
// STEP 8 : Save hashes.
$this->Step8_SaveHashes( $entry->id );
// STEP 9 : Save Authors.
$this->Step9_SaveAuthors( $entry );
// STEP 10 : Save Modifications.
if( section_must_be( 'romhacks', $this->section ) ){
$this->Step10_SaveRomhacksModifications( $entry );
}
// STEP 11 : Save Languages
$this->Step11_SaveLanguages( $entry );
// STEP 12 : Prepare Gallery images.
$this->Step12a_PrepareGalleryImages( $entry );
return $entry;
});
// Step 12, Move main image and gallery.
$this->Step12b_MoveMainImage( $entry );
$this->Step12c_SaveGalleryImages( $entry );
return $entry;
}
/**
* @return int
*
* @throws SubmissionException
*/
private function Step2_CreateAndReturnGameId(): int {
// Already existing game.
if( $this->request->input('game_id') )
return $this->request->input('game_id');
// Need to create a game.
if( !$this->request->input('new-game-title') || !$this->request->input('new-game-platform') || !$this->request->input('new-game-genre') )
throw new SubmissionException( "New game informations is missing" );
$platform = Platform::find( $this->request->input('new-game-platform') );
$genre = Genre::find( $this->request->input('new-game-genre') );
if( !$platform || !$genre )
throw new SubmissionException( "Incorrect game platform id" );
$gameSlug = EntryHelpers::uniqueSlug( $this->request->input('new-game-title'), Game::class );
$game = Game::create([
'name' => trim( $this->request->input('new-game-title') ),
'slug' => $gameSlug,
'platform_id' => $platform->id,
'genre_id' => $genre->id,
]);
return $game->id;
}
/**
* Prepare and build complete title.
*
* @param int|null $gameId
*
* @return string
*/
private function Step3_BuildCompleteTitle( ?int $gameId = null ): string {
$fields = [];
$fields['entry_title'] = $this->request->input('entry_title') ?? null;
if( section_must_be( [ 'homebrew', 'translations' ], $this->section ) && $gameId ){
$fields['game_name'] = Game::find( $gameId )->name;
}
if( section_must_be( 'translations', $this->section ) ) {
$fields['languages_string'] = Language::whereIn('id', $this->request->input('languages', []))->pluck('name')->implode(', ');
}
if( section_must_be(['romhacks', 'homebrew', 'lua-scripts', 'tutorials'], $this->section ) ) {
// TODO: Add single platform ID compatibility.
$fields['platform_name'] = Game::find( $gameId )->platform->name;
}
return EntryHelpers::buildCompleteTitle( $this->section, $fields );
}
/**
* @param int $entryId
*
* @return void
* @throws SubmissionException
*/
private function Step7_SaveEntryFiles( int $entryId ): void
{
foreach ( $this->request->input('files_uuid', [] ) as $uuid ) {
$fileData = Cache::pull("uploaded_file_{$uuid}");
if( !$fileData )
throw new SubmissionException( "File {$uuid} has expired. Please delete all your files and retry." );
EntryFile::create([
'entry_id' => $entryId,
'file_uuid' => $uuid,
'filename' => $fileData['filename'],
'filepath' => $fileData['filepath'],
'favorite_server' => $fileData['favorite_server'],
'favorite_at' => \DateTimeImmutable::createFromTimestamp( $fileData['favorite_at'] ),
'filesize' => $fileData['filesize'],
'state' => 'public'
]);
}
}
/**
* @param int $entryId
*
* @return void
*/
private function Step8_SaveHashes( int $entryId ): void
{
foreach ( $this->request->input('hashes', [] ) as $hash ) {
if( !isset($hash['filename'], $hash['hash_crc32'], $hash['hash_sha1'], $hash['verified']) ) {
continue;
}
EntryHash::create([
'entry_id' => $entryId,
'filename' => $hash['filename'],
'hash_crc32' => $hash['hash_crc32'],
'hash_sha1' => $hash['hash_sha1'],
'verified' => $hash['verified'],
]);
}
}
/**
* @param Entry $entry
*
* @return void
* @throws SubmissionException
*/
private function Step9_SaveAuthors( Entry $entry ): void
{
// Existing authors.
foreach ( $this->request->input('authors', [] ) as $authorId ) {
$author = Author::find( $authorId );
if( !$author )
throw new SubmissionException( "Author {$authorId} does not exist." );
$entry->authors()->attach( $author->id );
}
// New Authors
foreach ( $this->request->input('new-authors', [] ) as $authorName ) {
$authorName = trim( $authorName );
if( $authorName === '' )
continue;
$author = Author::firstOrCreate(
['slug' => EntryHelpers::uniqueSlug( $authorName, Author::class )],
['name' => $authorName]
);
$entry->authors()->attach( $author->id );
}
}
/**
* @param Entry $entry
*
* @return void
* @throws SubmissionException
*/
private function Step10_SaveRomhacksModifications( Entry $entry ): void
{
foreach ( $this->request->input('modifications', [] ) as $modificationId ) {
$modification = Modification::find( $modificationId );
if( !$modification )
throw new SubmissionException( "Modification {$modificationId} does not exist." );
$entry->modifications()->attach( $modification->id );
}
}
/**
* @param Entry $entry
*
* @return void
* @throws SubmissionException
*/
private function Step11_SaveLanguages( Entry $entry ): void
{
foreach ( $this->request->input('languages', [] ) as $languageId ) {
$language = Language::find( $languageId );
if( !$language )
throw new SubmissionException( "Language {$languageId} does not exist." );
$entry->languages()->attach( $language->id );
}
}
private function Step12a_PrepareGalleryImages( Entry $entry ): void
{
foreach ( $this->request->input('gallery', [] ) as $imagePath ) {
EntryGallery::create([
'entry_id' => $entry->id,
'image' => $imagePath,
]);
}
}
/**
* @param Entry $entry
*
* @return void
*/
private function Step12b_MoveMainImage( Entry $entry ): void {
$mainImage = $entry->main_image;
$newPath = 'entries/main-images/' . basename($mainImage);
if( !Storage::disk('public')->move($mainImage, $newPath) )
return;
$entry->update(['main_image' => $newPath]);
}
private function Step12c_SaveGalleryImages( Entry $entry ): void
{
foreach ( $entry->gallery as $galleryItem ) {
$newPath = 'entries/gallery-images/' . $entry->id . '/' . basename($galleryItem->image);
if( !Storage::disk('public')->move($galleryItem->image, $newPath) )
continue;
$galleryItem->update(['image' => $newPath]);
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
class TemporaryFileService {
public const int NB_HOURS_FILES_KEPT = 6;
/**
* Upload a file in the temporary path.
*
* @param UploadedFile|null $file
*
* @return string|bool
*/
public function uploadFile(?UploadedFile $file ): string|bool {
if( !$file )
return false;
return $file->store( 'temp', 'public' );
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
class XenforoService {
private const array PERMISSIONS_KEPT = [ 'general', 'romhackplaza' ];
private const int TTL_PERMISSIONS = 300;
public function getPermissions(int $userId, int $permissionCombinationId): array {
return Cache::remember("xf_permissions_{$userId}", self::TTL_PERMISSIONS, function() use($permissionCombinationId) {
$row = \DB::connection('xenforo')
->table('permission_combination')
->where('permission_combination_id', $permissionCombinationId)
->value('cache_value');
if( !$row )
return [];
$data = json_decode($row, true);
$data = array_intersect_key($data, array_flip(self::PERMISSIONS_KEPT));
return $data ?: [];
});
}
public function clearUserData(int $userId): void
{
Cache::forget("xf_permissions_{$userId}");
}
}

19
app/Types/FSTypes.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
namespace App\Types;
/**
* @phpstan-type FSFileData array{
* name:string,
* totalChunks:int,
* rawFile?:mixed, // Must be used only in JS.
* progressValue:int,
* currentChunk:int,
* done:bool,
* error:mixed,
* uuid:string
* }
*/
interface FSTypes
{
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Types;
/**
* @phpstan-type HashObject array{
* filename:string,
* hash_crc32:string,
* hash_sha1:string,
* verified:string
* }
*/
interface SubmissionTypes
{}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class EntryMetaItem extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public string $label,
public string $value,
public string $route = "#"
)
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.entry-meta-item');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class EntrySectionTitle extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public string $label,
public string $icon = ''
)
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.entry-section-title');
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class ErrorBlock extends Component
{
private const array ERROR_TYPES = [
'custom' => [
'icon' => 'ban',
'message' => '%s'
],
'page-not-allowed' => [
'icon' => 'shield-ban',
'message' => "You do not have permission to access this page.\nRequired permission: %s"
]
];
public array $errorArray;
/**
* Create a new component instance.
*/
public function __construct(
public string $errorType,
public string $message = ""
)
{
$this->errorArray = self::ERROR_TYPES[$errorType] ?? self::ERROR_TYPES['custom'];
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.error-block');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class FormErrorText extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public string $message,
public bool $icon = true
)
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.form-error-text');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class FormFieldTitle extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public string $name,
public string $helper = "",
public bool $required = false,
)
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.form-field-title');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class FormGroupTitle extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public string $label,
public string $icon = ""
)
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.form-group-title');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class GalleryField extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public array $oldPaths = [],
public bool $required = true,
)
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.gallery-field');
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\View\Components;
use App\Models\Language;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class LanguagesSelector extends Component
{
public $languages;
/**
* Create a new component instance.
*/
public function __construct(
public array $selected = [],
public bool $required = true
)
{
$this->languages = Language::orderBy('name')->get();
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.languages-selector');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class MainImageField extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public string $oldPath = "",
public bool $required = true
)
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.main-image-field');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class MarkdownTextarea extends Component
{
public array $toolbar;
/**
* Create a new component instance.
*/
public function __construct(
public string $name,
public string $value = "",
public string $minHeight = "200px",
public bool $required = true
)
{
$this->toolbar = [
'bold', 'italic', 'strikethrough', 'heading', '|', 'quote', 'unordered-list', 'ordered-list', 'check-list', '|', 'link', 'image', '|', 'preview', 'guide'
];
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.markdown-textarea');
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class StaffCreditsField extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public ?string $oldStaffCredits = null // JSON.
)
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.staff-credits-field');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class SubmitEntryStatus extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public string $section
)
{
//
}
public function availableStates(): array
{
// TODO: Change for permissions.
return [
'draft' => "Save into my drafts",
'pending' => "Add to submissions queue",
'published' => "Publish it now"
];
}
public function defaultState(): string
{
$availableStates = array_keys( $this->availableStates() );
return in_array('published', $availableStates) ? 'published' : ( in_array('pending', $availableStates) ? 'pending' : 'draft' );
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.submit-entry-status', [ 'states' => $this->availableStates(), 'defaultState' => $this->defaultState(), 'section' => $this->section ]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class SuccessBlock extends Component
{
private const array SUCCESS_TYPES = [
'custom' => [
'icon' => 'check',
'message' => '%s'
]
];
public array $successArray;
/**
* Create a new component instance.
*/
public function __construct(
public string $successType,
public string $message = "",
)
{
$this->successArray = self::SUCCESS_TYPES[$this->successType] ?? self::SUCCESS_TYPES['custom'];
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.success-block');
}
}

Some files were not shown because too many files have changed in this diff Show More