diff --git a/.gitignore b/.gitignore index 205e3ee..0e5699a 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ _ide_helper.php Homestead.json Homestead.yaml Thumbs.db - +avancee.ods +.~lock.avancee.ods# diff --git a/app/Auth/XenForoGuard.php b/app/Auth/XenForoGuard.php index 83b691b..4a61359 100644 --- a/app/Auth/XenForoGuard.php +++ b/app/Auth/XenForoGuard.php @@ -74,4 +74,9 @@ class XenForoGuard implements Guard $this->user = $user; } + public function logout(): void + { + redirect('/'); + } + } diff --git a/app/Auth/XenForoUser.php b/app/Auth/XenForoUser.php index dc7aec2..def6d9e 100644 --- a/app/Auth/XenForoUser.php +++ b/app/Auth/XenForoUser.php @@ -3,21 +3,18 @@ namespace App\Auth; use App\Services\XenforoService; +use App\XenForoDataTypes\XenForoData; +use Filament\Models\Contracts\FilamentUser; +use Filament\Models\Contracts\HasName; +use Filament\Panel; +use Illuminate\Contracts\Auth\Access\Authorizable; use Illuminate\Contracts\Auth\Authenticatable; -class XenForoUser implements Authenticatable { +class XenForoUser extends XenForoData implements Authenticatable, Authorizable, FilamentUser, HasName { + + use \Illuminate\Foundation\Auth\Access\Authorizable; 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'; @@ -73,7 +70,7 @@ class XenForoUser implements Authenticatable { return null; } - public function can(string $permissionGroup, string $permissionName): bool + 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); @@ -81,4 +78,31 @@ class XenForoUser implements Authenticatable { return ($this->permissions[$permissionGroup][$permissionName] ?? 0) === true; } + + /* FILAMENT COMPATIBILITY */ + + public function canAccessPanel(Panel $panel): bool + { + return $this->is_admin === 1; + } + + public function getFilamentName(): string + { + return $this->username ?? "XF"; + } + + public function getAttributeValue($key) + { + return $this->{$key} ?? null; + } + + public function getKey() + { + return $this->data->user_id; + } + + public function getKeyName() + { + return 'user_id'; + } } diff --git a/app/Console/Commands/DeleteRejectedEntries.php b/app/Console/Commands/DeleteRejectedEntries.php new file mode 100644 index 0000000..c65582f --- /dev/null +++ b/app/Console/Commands/DeleteRejectedEntries.php @@ -0,0 +1,26 @@ +option('days'); + $count = Entry::where('state', 'rejected') + ->where('rejected_at', '<', now()->subDays($days)) + ->delete(); + + $this->info("Deleted {$count} entries"); + } +} diff --git a/app/Console/Commands/ImportDatFile.php b/app/Console/Commands/ImportDatFile.php new file mode 100644 index 0000000..dc7a32c --- /dev/null +++ b/app/Console/Commands/ImportDatFile.php @@ -0,0 +1,75 @@ +argument('file'); + + if(!file_exists($file)) { + $this->error('File not found'); + return; + } + + $reader = new \XMLReader(); + $reader->open($file); + + $this->info("Importing..."); + + $count = 0; + $insertBuffer = []; + $datReferenceId = null; + + while ($reader->read()) { + if($reader->nodeType == \XMLReader::ELEMENT) { + if( $reader->name == 'header' ){ + $node = new \SimpleXMLElement($reader->readOuterXml()); + $name = (string) ($node->name . ' v.' . $node->version . ' (' . $node->homepage . ')'); + DB::connection('hashes')->table('dat_reference')->insert([['name' => $name ]]); + $datReferenceId = DB::connection('hashes')->table('dat_reference')->where('name', $name)->value('id'); + } + + if( $reader->name == 'game' ){ + if( !$datReferenceId ){ + $this->error("No dat reference found"); + return; + } + $node = new \SimpleXMLElement($reader->readOuterXml()); + foreach ($node->rom as $rom) { + $insertBuffer[] = [ + 'filename' => $rom['name'], + 'crc32' => $rom['crc'], + 'sha1' => $rom['sha1'], + 'dat_reference_id' => $datReferenceId, + ]; + $count++; + + if( count($insertBuffer) >= 1000 ){ + DB::connection('hashes')->table('hashes')->insert($insertBuffer); + $insertBuffer = []; + } + } + } + } + } + + if( count($insertBuffer) >= 0 ){ + DB::connection('hashes')->table('hashes')->insert($insertBuffer); + } + + $reader->close(); + $this->info("{$count} ROMs hashes imported."); + } +} diff --git a/app/Helpers/EntryHelpers.php b/app/Helpers/EntryHelpers.php index efdf4c9..0d50789 100644 --- a/app/Helpers/EntryHelpers.php +++ b/app/Helpers/EntryHelpers.php @@ -2,6 +2,9 @@ namespace App\Helpers; +use App\Models\Entry; +use App\Services\XenforoApiService; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; class EntryHelpers { @@ -45,6 +48,8 @@ class EntryHelpers { */ public static function buildCompleteTitle( string $section, array $fields = [] ){ + $fields = array_merge( ['entry_title' => 'Untitled', 'game_name' => '', 'languages_string' => '', 'platform_name' => ''], $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']), @@ -56,4 +61,33 @@ class EntryHelpers { default => $fields['entry_title'], }; } + + public static function getLatestComments(Entry $entry, int $limit = 20): array { + + if( !$entry->comments_thread_id ){ + return []; + } + + $cacheKey = "entry_comments_{$entry->id}"; + return Cache::remember($cacheKey, now()->addDays(1), function () use ($entry, $limit) { + + $service = app(XenforoApiService::class); + + // Get thread infos and pagination. + $paginationInfos = $service->getThreadPosts($entry->comments_thread_id, 1); + $lastPage = $paginationInfos['pagination']['last_page'] ?? 1; + + // Get last threads + $lastPageData = $lastPage > 1 ? $service->getThreadPosts($entry->comments_thread_id, $lastPage) : $paginationInfos; + $posts = $lastPageData['posts'] ?? []; + + if( count( $posts ) < $limit && $lastPage > 1 ){ + $previousPageData = $service->getThreadPosts($entry->comments_thread_id, $lastPage - 1 ); + $posts = array_merge( $posts, $previousPageData['posts'] ?? [] ); + } + + return collect( $posts )->slice(-$limit)->reverse()->values()->toArray(); + + }); + } } diff --git a/app/Helpers/HashesHelpers.php b/app/Helpers/HashesHelpers.php new file mode 100644 index 0000000..7e67ccd --- /dev/null +++ b/app/Helpers/HashesHelpers.php @@ -0,0 +1,21 @@ +table('hashes')->where('sha1', $sha1)->first(); + } + + public static function getReferenceName( int $id ): ?string + { + return DB::connection('hashes')->table('dat_reference')->where('id', $id)->first()?->name ?? null; + } + +} diff --git a/app/Helpers/XenForoHelpers.php b/app/Helpers/XenForoHelpers.php index efbcd66..a07e71b 100644 --- a/app/Helpers/XenForoHelpers.php +++ b/app/Helpers/XenForoHelpers.php @@ -3,6 +3,8 @@ namespace App\Helpers; use App\Auth\XenForoUser; +use App\Models\Entry; +use App\Services\XenforoApiService; class XenForoHelpers { @@ -34,4 +36,28 @@ class XenForoHelpers { } return self::XF_AVATAR_COLORS[ crc32( $user->data->username ) % count( self::XF_AVATAR_COLORS ) ]; } + + public static function updateEntriesCount( int $userId ): void + { + $count = Entry::where('user_id', $userId)->where('state','published')->count(); + $service = app(XenforoApiService::class); + $service->updateEntriesCount( $count, $userId ); + } + + public static function entryApproved( Entry $entry ): void + { + // 1. Update XF Entry count. + self::updateEntriesCount( $entry->user_id ); + + // 2. Send a private message + if( \Auth::user()->user_id === $entry->user_id ) { + return; + } + + $moderator = \Auth::user()->username; + $title = "Entry approved : {$entry->title}"; + $message = "Your entry {$entry->title} has been approved by {$moderator}."; + + $service->createConversation([ $entry->user_id ], $title, $message, false, false); + } } diff --git a/app/Http/Controllers/DynamicLoadController.php b/app/Http/Controllers/DynamicLoadController.php new file mode 100644 index 0000000..bbf847a --- /dev/null +++ b/app/Http/Controllers/DynamicLoadController.php @@ -0,0 +1,66 @@ +getXfUser( $user_id ); + + if( !$user ){ + return response()->json(['error' => 'User not found'], 404); + } + + return [ + 'user_id' => $user_id, + 'username' => $user->username, + 'avatar_url' => $user->getAvatarUrl(), + 'avatar_color' => XenForoHelpers::getAvatarColor( $user ), + 'avatar_letter' => XenForoHelpers::getAvatarLetter( $user ), + 'group_name' => $service->getXfUserGroup( $user?->user_group_id ?? 0 )?->title ?? 'Guest', + 'joined' => \DateTimeImmutable::createFromTimestamp( $user->register_date ?? 0 )->format('Y-m-d'), + 'last_seen' => \DateTimeImmutable::createFromTimestamp( $user->last_activity ?? 0 )->format('Y-m-d'), + 'message_count' => $user->message_count, + 'reaction_score' => $user->reaction_score, + 'trophy_points' => $user->trophy_points, + 'entries_count' => $user->rhpz_entry_count, + ]; + + }); + + + return response()->json( ['user' => $data] ); + } + + public function getNotifications( Request $request ){ + + $service = app(XenforoApiService::class); + $data = $service->getUserAlerts(\Auth::user()->user_id); + + return response()->json( $data ); + } + + public function markAllRead( Request $request ){ + $service = app(XenforoApiService::class); + $service->markAllNotificationsRead(\Auth::user()->user_id); + + return response()->json( ['success' => true] ); + } + + public function getConversations( Request $request ){ + $service = app(XenforoApiService::class); + $data = $service->getConversations(\Auth::user()->user_id); + + return response()->json( $data ); + } +} diff --git a/app/Http/Controllers/EntryController.php b/app/Http/Controllers/EntryController.php index 3b12399..07b9b71 100644 --- a/app/Http/Controllers/EntryController.php +++ b/app/Http/Controllers/EntryController.php @@ -2,35 +2,66 @@ namespace App\Http\Controllers; +use App\Helpers\EntryHelpers; use App\Models\Entry; +use Illuminate\Support\Facades\Gate; use Illuminate\Http\Request; use Illuminate\View\View; class EntryController extends Controller { - private const SECTION_TYPES = [ 'translations', 'romhacks', 'homebrew', 'utilities', 'documents', 'lua-scripts', 'tutorials' ]; + 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'); + } - return view('entries.index', compact('entries')); + public function section_redirect(string $section) + { + return redirect( databaseRoute( ['types' => [ $section ] ] ) ); } public function show(string $section, Entry $entry): View { - if( ! in_array($section, self::SECTION_TYPES) ) + if (!in_array($section, self::SECTION_TYPES)) abort(404); - if( $entry->type !== $section ) + if ($entry->type !== $section) abort(404); - return view('entries.show', compact('entry', 'section')); + Gate::authorize('viewAny', $entry); + + // Permissions. + $entryPolicy = match ($entry->state) { + 'pending' => 'viewPending', + 'draft' => 'viewDraft', + 'rejected' => 'viewRejected', + 'hidden' => 'viewHidden', + 'locked' => 'viewLocked', + 'published' => null, + 'default' => null + }; + + if ($entryPolicy) + Gate::authorize($entryPolicy, $entry); + + $comments = EntryHelpers::getLatestComments($entry); + + return view('entries.show', compact('entry', 'section', 'comments')); } + public function drafts(): View + { + $drafts = Entry::where('user_id', \Auth::user()->user_id ) + ->where('state', 'draft') + ->with('game.platform', 'status') + ->orderBy('updated_at', 'desc') + ->paginate(20); + + return view('entries.drafts', compact('drafts')); + } + } diff --git a/app/Http/Controllers/FileServerController.php b/app/Http/Controllers/FileServerController.php index 3ef909e..f8c71f3 100644 --- a/app/Http/Controllers/FileServerController.php +++ b/app/Http/Controllers/FileServerController.php @@ -51,7 +51,8 @@ class FileServerController extends Controller { 'filepath' => $data['file_path'], 'filesize' => $data['file']['size'], 'favorite_server' => $data['favorite_server'], - 'favorite_at' => time() + 'favorite_at' => time(), + 'state' => 'public' ], now()->addHours(2) ); $data['finished'] = true; diff --git a/app/Http/Controllers/ModCP/AuthorController.php b/app/Http/Controllers/ModCP/AuthorController.php new file mode 100644 index 0000000..3eac649 --- /dev/null +++ b/app/Http/Controllers/ModCP/AuthorController.php @@ -0,0 +1,72 @@ +orderBy('name') + ->tap(fn($query) => $this->applySearch($query, ['name'])) + ->paginate(30) + ->withQueryString(); + + return view('modcp.authors', [ + 'items' => $items + ]); + } + + public function store(Request $request) + { + $request->validate([ + 'name' => 'required|string|max:255|unique:authors,name', + 'owner_user_id' => [ 'nullable', 'integer', new XfUserExists ], + 'website' => 'nullable|string|max:255', + ]); + + Author::create([ + 'name' => trim( $request->name ), + 'slug' => EntryHelpers::uniqueSlug( $request->name, Author::class ), + 'user_id' => $request->owner_user_id, + 'website' => $request->website, + ]); + + return back()->with('success', 'Author added.'); + } + + public function update(Request $request, Author $author) + { + $request->validate([ + 'name' => 'required|string|max:255|unique:authors,name,' . $author->id, + 'owner_user_id' => [ 'nullable', 'integer', new XfUserExists ], + 'website' => 'nullable|string|max:255', + ]); + + $author->update([ + 'name' => trim($request->name), + 'slug' => EntryHelpers::uniqueSlug( $request->name, Author::class, $author->id ), + 'user_id' => $request->owner_user_id, + 'website' => $request->website, + ]); + + return back()->with('success', 'Author updated.'); + } + + public function destroy(Author $author) + { + $author->delete(); + return back()->with('success', 'Author deleted.'); + } +} diff --git a/app/Http/Controllers/ModCP/GameController.php b/app/Http/Controllers/ModCP/GameController.php new file mode 100644 index 0000000..6c8b9af --- /dev/null +++ b/app/Http/Controllers/ModCP/GameController.php @@ -0,0 +1,75 @@ +orderBy('name') + ->tap(fn($query) => $this->applySearch($query, ['name'])) + ->paginate(30)->withQueryString(); + $platforms = Platform::orderBy('name')->get(); + $genres = Genre::orderBy('name')->get(); + + return view('modcp.games', [ + 'items' => $items, + 'platforms' => $platforms, + 'genres' => $genres, + ]); + + } + + public function store(Request $request) + { + $request->validate([ + 'name' => 'required|string|max:255', + 'platform_id' => 'required|integer|exists:platforms,id', + 'genre_id' => 'required|integer|exists:genres,id', + ]); + + Game::create([ + 'name' => trim($request->name), + 'platform_id' => $request->platform_id, + 'genre_id' => $request->genre_id, + 'slug' => EntryHelpers::uniqueSlug($request->name, Game::class), + ]); + + return back()->with('success', 'Game added.'); + } + + public function update(Request $request, Game $game) + { + $request->validate([ + 'name' => 'required|string|max:255', + 'platform_id' => 'required|integer|exists:platforms,id', + 'genre_id' => 'required|integer|exists:genres,id' + ]); + + $game->update([ + 'name' => trim($request->name), + 'platform_id' => $request->platform_id, + 'genre_id' => $request->genre_id, + 'slug' => EntryHelpers::uniqueSlug($request->name, Game::class, $game->id), + ]); + + return back()->with('success', 'Game updated.'); + } + + public function destroy(Game $game) + { + $game->delete(); + return back()->with('success', 'Game deleted.'); + } +} diff --git a/app/Http/Controllers/ModCP/GenreController.php b/app/Http/Controllers/ModCP/GenreController.php new file mode 100644 index 0000000..3364a63 --- /dev/null +++ b/app/Http/Controllers/ModCP/GenreController.php @@ -0,0 +1,68 @@ +orderBy('name') + ->tap(fn($query) => $this->applySearch($query, ['name'])) + ->paginate(30) + ->withQueryString(); + + return view('modcp.resources', [ + 'items' => $items, + 'title' => 'Genres', + 'singular' => 'Genre', + 'storeRoute' => 'modcp.genres.store', + 'updateRoute' => 'modcp.genres.update', + 'destroyRoute' => 'modcp.genres.destroy' + ]); + } + + public function store(Request $request) + { + $request->validate([ + 'name' => 'required|string|max:255|unique:genres,name', + ]); + + Genre::create([ + 'name' => trim($request->name), + 'slug' => EntryHelpers::uniqueSlug( $request->name, Genre::class ), + ]); + + return back()->with('success', 'Genre added.'); + } + + public function update(Request $request, Genre $genre) + { + $request->validate([ + 'name' => 'required|string|max:255|unique:genres,name,' . $genre->id, + ]); + + $genre->update([ + 'name' => trim($request->name), + 'slug' => EntryHelpers::uniqueSlug( $request->name, Genre::class, $genre->id ), + ]); + + return back()->with('success', 'Genre updated.'); + } + + public function destroy(Genre $genre) + { + $genre->delete(); + return back()->with('success', 'Genre deleted.'); + } +} diff --git a/app/Http/Controllers/ModCP/LanguageController.php b/app/Http/Controllers/ModCP/LanguageController.php new file mode 100644 index 0000000..ca000ee --- /dev/null +++ b/app/Http/Controllers/ModCP/LanguageController.php @@ -0,0 +1,67 @@ +orderBy('name') + ->tap(fn($query) => $this->applySearch($query, ['name'])) + ->paginate(30) + ->withQueryString(); + + return view('modcp.resources', [ + 'items' => $items, + 'title' => 'Languages', + 'singular' => 'Language', + 'storeRoute' => 'modcp.languages.store', + 'updateRoute' => 'modcp.languages.update', + 'destroyRoute' => 'modcp.languages.destroy' + ]); + } + + public function store(Request $request) + { + $request->validate([ + 'name' => 'required|string|max:255|unique:languages,name', + ]); + + Language::create([ + 'name' => trim($request->name), + 'slug' => EntryHelpers::uniqueSlug( $request->name, Language::class ), + ]); + + return back()->with('success', 'Language added.'); + } + + public function update(Request $request, Language $language) + { + $request->validate([ + 'name' => 'required|string|max:255|unique:languages,name,' . $language->id, + ]); + + $language->update([ + 'name' => trim($request->name), + 'slug' => EntryHelpers::uniqueSlug( $request->name, Language::class, $language->id ), + ]); + + return back()->with('success', 'Language updated.'); + } + + public function destroy(Language $language) + { + $language->delete(); + return back()->with('success', 'Language deleted.'); + } +} diff --git a/app/Http/Controllers/ModCP/PlatformController.php b/app/Http/Controllers/ModCP/PlatformController.php new file mode 100644 index 0000000..2cffa06 --- /dev/null +++ b/app/Http/Controllers/ModCP/PlatformController.php @@ -0,0 +1,68 @@ +orderBy('name') + ->tap(fn($query) => $this->applySearch($query, ['name'])) + ->paginate(30) + ->withQueryString(); + + return view('modcp.resources', [ + 'items' => $items, + 'title' => 'Platforms', + 'singular' => 'Platform', + 'storeRoute' => 'modcp.platforms.store', + 'updateRoute' => 'modcp.platforms.update', + 'destroyRoute' => 'modcp.platforms.destroy' + ]); + } + + public function store(Request $request) + { + $request->validate([ + 'name' => 'required|string|max:255|unique:platforms,name', + ]); + + Platform::create([ + 'name' => trim($request->name), + 'slug' => EntryHelpers::uniqueSlug( $request->name, Platform::class ), + ]); + + return back()->with('success', 'Platform added.'); + } + + public function update(Request $request, Platform $platform) + { + $request->validate([ + 'name' => 'required|string|max:255|unique:platforms,name,' . $platform->id, + ]); + + $platform->update([ + 'name' => trim($request->name), + 'slug' => EntryHelpers::uniqueSlug( $request->name, Platform::class, $platform->id ), + ]); + + return back()->with('success', 'Platform updated.'); + } + + public function destroy(Platform $platform) + { + $platform->delete(); + return back()->with('success', 'Platform deleted.'); + } +} diff --git a/app/Http/Controllers/ModCPController.php b/app/Http/Controllers/ModCPController.php new file mode 100644 index 0000000..b0d0d52 --- /dev/null +++ b/app/Http/Controllers/ModCPController.php @@ -0,0 +1,78 @@ + Entry::where('state', 'pending')->count(), + 'locked' => Entry::where('state', 'locked')->count(), + 'total' => Entry::count() + ]; + + if( \Auth::user()->can('is-admin') ){ + $stats['draft'] = Entry::where('state', 'draft')->count(); + $stats['hidden'] = Entry::where('state', 'hidden')->count(); + $stats['deleted'] = Entry::where('state', 'deleted')->count(); + } + + $recentDeleted = Entry::onlyTrashed()->latest('deleted_at')->limit(5)->get(); + + return view('modcp.index', compact('stats', 'recentDeleted')); + } + + public function locked() + { + $entries = Entry::where('state', 'locked') + ->with(['game.platform', 'authors']) + ->latest()->paginate(25); + + return view('modcp.entries', compact('entries'))->with('pageTitle', "Locked Entries")->with('state', 'locked'); + } + + public function draft() + { + $entries = Entry::where('state', 'draft') + ->with(['game.platform', 'authors']) + ->latest()->paginate(25); + + return view('modcp.entries', compact('entries'))->with('pageTitle', "Draft Entries")->with('state', 'draft'); + } + + public function hidden() + { + $entries = Entry::where('state', 'hidden') + ->with(['game.platform', 'authors']) + ->latest()->paginate(25); + + return view('modcp.entries', compact('entries'))->with('pageTitle', "Hidden Entries")->with('state', 'hidden'); + } + + public function deleted() + { + $entries = Entry::onlyTrashed() + ->with(['game.platform', 'authors']) + ->latest('deleted_at')->paginate(25); + return view('modcp.deleted', compact('entries')); + } + + public function restore(Entry $entry) + { + $entry->restore(); + RestoreXenForoCommentsThread::dispatch($entry->comments_thread_id); + $entry->update(['state' => 'draft']); + return back()->with('success', "Entry restored"); + } + + public function destroy(Entry $entry) + { + $entry->forceDelete(); + return back()->with('success', "Entry permanently deleted"); + } +} diff --git a/app/Http/Controllers/QueueController.php b/app/Http/Controllers/QueueController.php new file mode 100644 index 0000000..c9f4e91 --- /dev/null +++ b/app/Http/Controllers/QueueController.php @@ -0,0 +1,49 @@ +with(['authors', 'game.platform']) + ->orderByRaw("CASE WHEN state = 'pending' THEN 1 ELSE 0 END") + ->orderBy('created_at', 'asc') + ->get(); + + return view('queue.index', compact('entries')); + } + + public function updateComment(Request $request, Entry $entry) + { + $request->validate(['comment' => 'nullable|string|max:2000']); + + $entry->update(['staff_comment' => $request->input('comment')]); + + return back()->with('success', 'Comment updated'); + } + + public function approve(Request $request, Entry $entry) + { + // $entry->update(['state' => 'published']); + + XenForoHelpers::entryApproved($entry); + + return back()->with('success', 'Entry approved'); + } + + public function reject(Request $request, Entry $entry) + { + $request->validate(['reason' => 'nullable|string|max:2000']); + + $entry->update(['state' => 'rejected', 'staff_comment' => $request->input('reason'), 'rejected_at' => now() ]); + + return back()->with('success', 'Entry rejected'); + } + +} diff --git a/app/Http/Controllers/RedirectController.php b/app/Http/Controllers/RedirectController.php new file mode 100644 index 0000000..10d9753 --- /dev/null +++ b/app/Http/Controllers/RedirectController.php @@ -0,0 +1,17 @@ +input('id'); + $entry = Entry::findOrFail($id); + + return redirect()->route('entries.show', ['section' => $entry->type, 'entry' => $entry])->with('success', "Your report has been sent."); + } +} diff --git a/app/Http/Controllers/SubmissionController.php b/app/Http/Controllers/SubmissionController.php index 6afe022..a8ee83d 100644 --- a/app/Http/Controllers/SubmissionController.php +++ b/app/Http/Controllers/SubmissionController.php @@ -4,7 +4,9 @@ namespace App\Http\Controllers; use App\Exceptions\SubmissionException; use App\Helpers\FormHelpers; +use App\Http\Requests\StoreDraftRequest; use App\Http\Requests\StoreEntryRequest; +use App\Jobs\DeleteXenForoCommentsThread; use App\Models\Author; use App\Models\Entry; use App\Models\EntryFile; @@ -17,6 +19,7 @@ use App\Models\Modification; use App\Models\Platform; use App\Models\Status; use App\Services\SubmissionsService; +use App\Services\XenforoApiService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Storage; @@ -82,7 +85,22 @@ class SubmissionController extends Controller return view('submissions.edit', $data); } - public function store(StoreEntryRequest $request, string $section){ + public function destroy(Request $request, string $section, Entry $entry) + { + if( $entry->type !== $section ) + abort(404); + + if( $entry->comments_thread_id) + DeleteXenForoCommentsThread::dispatch( $entry->comments_thread_id ); + + $entry->delete(); + return redirect( route('entries.index') )->with('success', "Entry successfully deleted."); + } + + public function store(Request $request, string $section){ + + $request = $request->input('submit-state') === 'draft' ? app(StoreDraftRequest::class) : app(StoreEntryRequest::class); + $request->validateResolved(); try { $entry = $this->services->storeEntry($request, $section); @@ -100,166 +118,24 @@ class SubmissionController extends Controller } - public function update(StoreEntryRequest $request, string $section, Entry $entry) + public function update(Request $request, string $section, Entry $entry) { - if( $entry->type !== $section ) { - abort(404); + $request = $request->input('submit-state') === 'draft' ? app(StoreDraftRequest::class) : app(StoreEntryRequest::class); + $request->validateResolved(); + + try { + $entry = $this->services->editEntry($request, $section, $entry); + + 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()]); } - $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.") - }; } } diff --git a/app/Http/Controllers/ToolsController.php b/app/Http/Controllers/ToolsController.php new file mode 100644 index 0000000..4502744 --- /dev/null +++ b/app/Http/Controllers/ToolsController.php @@ -0,0 +1,22 @@ + 'ZELDA.ips', + 'name' => "Meltin", + 'description' => 'Blablabla', + 'outputName' => 'Game...' + ]; + + return view('tools.patcher', compact('patches')); + } +} diff --git a/app/Http/Controllers/WebhookController.php b/app/Http/Controllers/WebhookController.php new file mode 100644 index 0000000..48eda82 --- /dev/null +++ b/app/Http/Controllers/WebhookController.php @@ -0,0 +1,29 @@ +header('XF-Webhook-Secret') !== env('WEBHOOK_SECRET') ) + abort(403); + + $threadId = $request->input('data.thread_id'); + if( $threadId ){ + $entry = Entry::where('comments_thread_id', $threadId)->first(); + if( $entry ){ + Cache::forget("entry_comments_{$entry->id}"); + } + } + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Middleware/CheckXenForoPermissions.php b/app/Http/Middleware/CheckXenForoPermissions.php index 28cdea9..2738921 100644 --- a/app/Http/Middleware/CheckXenForoPermissions.php +++ b/app/Http/Middleware/CheckXenForoPermissions.php @@ -24,7 +24,7 @@ class CheckXenForoPermissions foreach ($permissions as $permissionStr) { [$group, $permission] = explode('.', $permissionStr); - if( !\Auth::user()->can($group, $permission) ) + if( !\Auth::user()->_can($group, $permission) ) return $this->deny($request, $permission); } diff --git a/app/Http/Requests/StoreDraftRequest.php b/app/Http/Requests/StoreDraftRequest.php new file mode 100644 index 0000000..1cf3387 --- /dev/null +++ b/app/Http/Requests/StoreDraftRequest.php @@ -0,0 +1,26 @@ + $r === 'required' ? 'nullable' : $r, $rule); + } + + return preg_replace( + ['/\brequired_without\S*/', '/required_with\S*/', '/\brequired\b/'], + ['nullable', 'nullable', 'nullable'], + $rule + ); + }, $rules ); + + return $rules; + } +} diff --git a/app/Http/Requests/StoreEntryRequest.php b/app/Http/Requests/StoreEntryRequest.php index d8a1bd4..2652b6f 100644 --- a/app/Http/Requests/StoreEntryRequest.php +++ b/app/Http/Requests/StoreEntryRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests; use App\Rules\PublicFileExists; +use App\Rules\XfUserExists; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Str; @@ -14,8 +15,11 @@ class StoreEntryRequest extends FormRequest */ public function authorize(): bool { - // TODO: Change it by role. - return true; + $entry = $this->route('entry'); + if( $entry ) + return $this->user()->can('update', $entry); + + return $this->user()->can('create', '\App\Models\Entry'); } /** @@ -46,12 +50,20 @@ class StoreEntryRequest extends FormRequest */ public function rules(): array { + + $isEdit = (bool) $this->route('entry'); + $rules = []; $section = $this->route('section'); $rules['files_uuid'] = 'array|required|min:1'; $rules['files_uuid.*'] = 'string'; + if( $isEdit ){ + $rules['files_state'] = 'array|required|min:1'; + $rules['files_state.*'] = 'string|in:public,private,archived'; + } + if( section_must_not_be( 'translations', $section ) ){ $rules['entry_title'] = "required|string|max:255"; } else { @@ -75,7 +87,6 @@ class StoreEntryRequest extends FormRequest $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'; @@ -98,7 +109,27 @@ class StoreEntryRequest extends FormRequest $rules['release_site'] = 'nullable|url|max:500'; $rules['youtube_video'] = 'nullable|url|max:500'; - $rules['submit-state'] = 'required|string|in:draft,pending,published'; + if( $isEdit ){ + $ss = 'draft,pending,published'; + if( \Auth::user()->can('moderate', $this->route('entry')) && \Auth::user()->can('view-hidden', $this->route('entry')) ) + $ss .= ',hidden'; + if(\Auth::user()->can('moderate', $this->route('entry')) && \Auth::user()->can('view-locked', $this->route('entry')) ) + $ss .= ',locked'; + $rules['submit-state'] = 'required|in:' . $ss; + } else { + if( $this->user()->can('skip-queue', '\App\Models\Entry') ){ + $rules['submit-state'] = 'required|string|in:draft,pending,published'; + } else { + $rules['submit-state'] = 'required|string|in:draft,pending'; + } + } + + if( $isEdit && $this->user()->can('moderate', $this->route('entry') ) ){ + $rules['staff_comment'] = 'nullable|string'; + $rules['owner_user_id'] = [ 'required', 'integer', new XfUserExists ]; + $rules['comments_thread_id'] = 'nullable|integer'; + $rules['featured'] = 'nullable|boolean'; + } return $rules; } diff --git a/app/Http/Requests/TemporaryFileUploadRequest.php b/app/Http/Requests/TemporaryFileUploadRequest.php index bbbad96..fea22ac 100644 --- a/app/Http/Requests/TemporaryFileUploadRequest.php +++ b/app/Http/Requests/TemporaryFileUploadRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Services\TemporaryFileService; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; @@ -12,7 +13,7 @@ class TemporaryFileUploadRequest extends FormRequest */ public function authorize(): bool { - return true; + return $this->user()->can('create', TemporaryFileService::class ); } /** diff --git a/app/Jobs/CreateXenForoCommentsThread.php b/app/Jobs/CreateXenForoCommentsThread.php new file mode 100644 index 0000000..0edeb09 --- /dev/null +++ b/app/Jobs/CreateXenForoCommentsThread.php @@ -0,0 +1,34 @@ +createCommentsThread($this->entry); + } +} diff --git a/app/Jobs/DeleteXenForoCommentsThread.php b/app/Jobs/DeleteXenForoCommentsThread.php new file mode 100644 index 0000000..46cc2e2 --- /dev/null +++ b/app/Jobs/DeleteXenForoCommentsThread.php @@ -0,0 +1,34 @@ +deleteThreadWithEntry($this->threadId); + } +} diff --git a/app/Jobs/RestoreXenForoCommentsThread.php b/app/Jobs/RestoreXenForoCommentsThread.php new file mode 100644 index 0000000..e541c3a --- /dev/null +++ b/app/Jobs/RestoreXenForoCommentsThread.php @@ -0,0 +1,34 @@ +restoreThreadWithEntry($this->threadId); + } +} diff --git a/app/Livewire/Database.php b/app/Livewire/Database.php new file mode 100644 index 0000000..fa9d1c9 --- /dev/null +++ b/app/Livewire/Database.php @@ -0,0 +1,272 @@ + 'Date added', + 'release_date' => 'Release date', + 'title' => 'Title' + ]; + + /** + * Translation of entries key. + */ + public const array ENTRY_TYPES = [ + 'translations' => 'Translations', + 'romhacks' => 'Romhacks', + 'homebrew' => 'Homebrew', + 'utilities' => 'Utilities', + 'documents' => 'Documents', + 'lua-scripts' => 'Lua Scripts', + 'tutorials' => 'Tutorials', + ]; + + public const int PAGINATION = 30; + + public function updatedSearch(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedTypes(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedGames(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedPlatforms(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedGenres(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedStatuses(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedAuthors(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedAuthorsMode(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedLanguages(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedLanguagesMode(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedModifications(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedModificationsMode(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + + public function clearFilters(): void + { + $this->reset([ + 'search', 'types', 'platforms', 'genres', 'statuses', 'authors', 'authorsMode', 'languages', 'languagesMode', 'modifications', 'modificationsMode' + ]); + $this->resetPage(); + } + + public function setSort(string $field): void + { + if( $this->sortBy === $field ) { + $this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortBy = $field; + $this->sortDir = 'asc'; + } + $this->resetPage(); + $this->dispatch('filters-updated'); + } + + private function buildQuery() + { + $query = Entry::query()->published()->with([ + 'game.platform', 'game.genre', 'status', 'authors', 'languages' + ]); + + if( $this->search ) { + $query->where(function($q) { + $q->where('title', 'like', '%'.$this->search.'%'); + $q->orWhere('complete_title', 'like', '%'.$this->search.'%'); + }); + } + + if( $this->types ) { + $query->whereIn('type', $this->types); + } + + if( $this->platforms ) { + $query->where(function($q) { + $q->whereIn('platform_id', $this->platforms) + ->orWhereHas('game', fn($q2) => $q2->whereIn('platform_id', $this->platforms) ); + }); + } + + if( $this->genres ) { + $query->where(function($q) { + $q->whereHas('game', fn($q2) => $q2->whereIn('genre_id', $this->genres) ); + }); + } + + if( $this->games ){ + $query->whereIn('game_id', $this->games); + } + + if( $this->statuses ) { + $query->whereIn('status_id', $this->statuses); + } + + if( $this->authors ) { + if( $this->authorsMode === 'and' ) { + foreach ( $this->authors as $authorId ) { + $query->whereHas('authors', fn($q) => $q->where('authors.id', $authorId)); + } + } else { + $query->whereHas('authors', fn($q) => $q->whereIn('authors.id', $this->authors)); + } + } + + if( $this->languages ) { + if( $this->languagesMode === 'and' ) { + foreach ( $this->languages as $langId ) { + $query->whereHas('languages', fn($q) => $q->where('languages.id', $langId)); + } + } else { + $query->whereHas('languages', fn($q) => $q->whereIn('languages.id', $this->languages)); + } + } + + if( $this->modifications ) { + if( $this->modificationsMode === 'and' ) { + foreach ( $this->modifications as $modificationId ) { + $query->whereHas('modifications', fn($q) => $q->where('modifications.id', $modificationId)); + } + } else { + $query->whereHas('modifications', fn($q) => $q->whereIn('modifications.id', $this->modifications)); + } + } + + return $query->orderBy($this->sortBy, $this->sortDir); + } + + public function render() + { + return view('livewire.database', [ + 'entries' => $this->buildQuery()->paginate(self::PAGINATION), + 'allGames' => Game::orderBy('name')->get(), + 'allPlatforms' => Platform::orderBy('name')->get(), + 'allGenres' => Genre::orderBy('name')->get(), + 'allStatuses' => Status::orderBy('name')->get(), + 'allAuthors' => Author::orderBy('name')->get(), + 'allLanguages' => Language::orderBy('name')->get(), + 'allModifications' => Modification::orderBy('name')->get(), + ]); + } +} diff --git a/app/Livewire/EntryFilesModal.php b/app/Livewire/EntryFilesModal.php new file mode 100644 index 0000000..c03e0e1 --- /dev/null +++ b/app/Livewire/EntryFilesModal.php @@ -0,0 +1,37 @@ + 'openModal' + ]; + + public function openModal( int $entryId ): void + { + $this->entryId = $entryId; + $this->open = true; + + $this->dispatch('modal:opened'); + } + + public function close(): void + { + $this->open = false; + $this->entryId = null; + } + + public function render() + { + $files = $this->entryId ? Entry::find($this->entryId)?->files : collect(); + return view('livewire.entry-files-modal', compact('files')); + } +} diff --git a/app/Livewire/HashesUpload.php b/app/Livewire/HashesUpload.php index b0035f7..2aa5a91 100644 --- a/app/Livewire/HashesUpload.php +++ b/app/Livewire/HashesUpload.php @@ -2,6 +2,7 @@ namespace App\Livewire; +use App\Helpers\HashesHelpers; use App\Models\EntryHash; use App\Models\Game; use App\Models\Genre; @@ -50,12 +51,28 @@ class HashesUpload extends Component */ 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. - ]; + + if( $verified !== null && $verified !== "No" ) + $this->hashes[] = [ + 'filename' => $filename, + 'hash_crc32' => $crc32, + 'hash_sha1' => $sha1, + 'verified' => $verified + ]; + else if( ( $hash = HashesHelpers::findHashes( $sha1 ) ) !== null ) + $this->hashes[] = [ + 'filename' => $hash->filename, + 'hash_crc32' => $hash->crc32, + 'hash_sha1' => $hash->sha1, + 'verified' => HashesHelpers::getReferenceName( $hash->dat_reference_id ) + ]; + else + $this->hashes[] = [ + 'filename' => $filename, + 'hash_crc32' => $crc32, + 'hash_sha1' => $sha1, + 'verified' => "No" + ]; } /** diff --git a/app/Livewire/XfUserSelector.php b/app/Livewire/XfUserSelector.php new file mode 100644 index 0000000..8c6fbd5 --- /dev/null +++ b/app/Livewire/XfUserSelector.php @@ -0,0 +1,62 @@ +table('user')->where('user_id', $initialUserId)->first(); + if( $user ){ + $this->selected = $user->user_id; + $this->selectedUsername = $user->username; + $this->search = $user->username; + } + } + } + + public function updatedSearch(): void + { + if( $this->selected && $this->search !== $this->selectedUsername) { + $this->selected = null; + $this->selectedUsername = null; + } + } + + public function selectUser( int $userId, string $username ): void + { + $this->selected = $userId; + $this->selectedUsername = $username; + $this->search = $username; + } + + public function getResultsProperty(): array + { + if( strlen($this->search) < self::MIN_CHARS || $this->selected ) { + return []; + } + + return DB::connection('xenforo') + ->table('user') + ->where('username', 'like', '%' . $this->search . '%') + ->orderBy('username') + ->limit(10) + ->get(['user_id','username']) + ->toArray(); + } + + public function render() + { + return view('livewire.xf-user-selector'); + } +} diff --git a/app/Models/Author.php b/app/Models/Author.php index 3ee4c1d..357367d 100644 --- a/app/Models/Author.php +++ b/app/Models/Author.php @@ -2,11 +2,29 @@ namespace App\Models; +use App\Auth\XenForoUser; +use App\Services\XenforoService; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; class Author extends Model { protected $fillable = [ 'name', 'slug', 'user_id', 'website' ]; + + public function entries(): BelongsToMany + { + return $this->belongsToMany(Entry::class, 'entry_authors'); + } + + public function user(): ?XenForoUser + { + if( !$this->user_id ) + return null; + + return app(XenforoService::class)->getXfUser($this->user_id); + } + } diff --git a/app/Models/Entry.php b/app/Models/Entry.php index 4940786..db044fb 100644 --- a/app/Models/Entry.php +++ b/app/Models/Entry.php @@ -7,10 +7,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; class Entry extends Model { + use SoftDeletes; + /** * @var string[] */ @@ -32,6 +35,9 @@ class Entry extends Model 'youtube_link', 'user_id', 'complete_title', + 'comments_thread_id', + 'staff_comment', + 'rejected_at', ]; /** @@ -40,12 +46,21 @@ class Entry extends Model protected $casts = [ 'featured' => 'boolean', 'release_date' => 'date', + 'rejected_at' => 'datetime', ]; public function scopePublished( Builder $query ): Builder { return $query->where( 'state', 'published' ); } + public function scopeInQueue( Builder $query, int $daysRejected = 7 ): Builder { + return $query->withTrashed()->where(function($q) use($daysRejected) { + $q->where('state', 'pending')->whereNull('deleted_at'); + })->orWhere(function($q) use($daysRejected) { + $q->where('state', 'rejected')->whereNotNull('rejected_at')->where('rejected_at', '>=', now()->subDays($daysRejected) ); + }); + } + /** * Return game link. * @return BelongsTo @@ -90,4 +105,18 @@ class Entry extends Model return $this->hasMany(EntryHash::class); } + public function parseStaffCredits(): array { + return json_decode( $this->staff_credits ?? "", true ); + } + + public function getYoutubeVideoId(): ?string { + if( !$this->youtube_link ) + return null; + + $pattern = '%(?:https?://)?(?:www\.|m\.)?(?:youtu\.be/|youtube(?:-nocookie)?\.com/(?:watch\?.*v=|embed/|v/|shorts/|live/))([\w-]{11})%i'; + + preg_match($pattern, $this->youtube_link, $matches); + return $matches[1] ?? null; + } + } diff --git a/app/Models/EntryFile.php b/app/Models/EntryFile.php index b21fdaf..c738572 100644 --- a/app/Models/EntryFile.php +++ b/app/Models/EntryFile.php @@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class EntryFile extends Model { protected $fillable = [ - 'entry_id', 'filename', 'filepath', 'favorite_server', 'favorite_at', 'filesize', 'state', 'file_uuid' + 'entry_id', 'filename', 'filepath', 'favorite_server', 'favorite_at', 'filesize', 'state', 'file_uuid', 'online_patcher', 'secondary_online_patcher', ]; protected $casts = [ diff --git a/app/Models/Game.php b/app/Models/Game.php index c6d4b1c..db4b115 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -21,4 +21,9 @@ class Game extends Model { return $this->belongsTo(Genre::class); } + + public function entries(): Game|\Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(Entry::class); + } } diff --git a/app/Models/Genre.php b/app/Models/Genre.php index 999da8a..c2d3551 100644 --- a/app/Models/Genre.php +++ b/app/Models/Genre.php @@ -3,8 +3,15 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; class Genre extends Model { protected $fillable = [ 'name', 'slug' ]; + + public function games(): HasMany + { + return $this->hasMany(Game::class); + } } diff --git a/app/Models/Language.php b/app/Models/Language.php index e139057..a2de75b 100644 --- a/app/Models/Language.php +++ b/app/Models/Language.php @@ -3,8 +3,15 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; class Language extends Model { protected $fillable = [ 'name', 'slug' ]; + + public function entries(): BelongsToMany + { + return $this->belongsToMany(Entry::class, 'entry_languages'); + } } diff --git a/app/Models/Platform.php b/app/Models/Platform.php index ce9b92b..cac5bf3 100644 --- a/app/Models/Platform.php +++ b/app/Models/Platform.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; class Platform extends Model { @@ -14,4 +15,13 @@ class Platform extends Model 'name', 'slug', 'short_name' ]; + public function games(): HasMany + { + return $this->hasMany(Game::class); + } + + public function entries(): HasMany + { + return $this->hasMany(Entry::class); + } } diff --git a/app/Policies/EntryPolicy.php b/app/Policies/EntryPolicy.php new file mode 100644 index 0000000..4b82c08 --- /dev/null +++ b/app/Policies/EntryPolicy.php @@ -0,0 +1,167 @@ +_can( 'romhackplaza', 'view' ) ) + return true; + + return false; + } + + public function viewPending(User $user, Entry $entry): bool + { + // Author. + if( $entry->user_id === $user->user_id ) + return true; + + return $user->_can( 'romhackplaza', 'canModerateEntries' ); + } + + public function viewDraft(User $user, Entry $entry): bool + { + // Author. + if( $entry->user_id === $user->user_id ) + return true; + + return $user->_can( 'romhackplaza', 'canSeeOthersDrafts' ); + } + + public function viewRejected(User $user, Entry $entry): bool + { + // Author. + if( $entry->user_id === $user->user_id ) + return true; + + return $user->_can( 'romhackplaza', 'canSeeRejectedEntries' ); + } + + public function viewHidden(User $user, Entry $entry): bool + { + return $user->_can('romhackplaza', 'canSeeHiddenEntries' ); + } + + public function viewLocked(User $user, Entry $entry): bool + { + // Author. + if( $entry->user_id === $user->user_id ) + return true; + + return $user->_can('romhackplaza', 'canSeeLockedEntries' ); + } + + public function create(User $user, ?Entry $entry = null ): bool + { + return $user->_can( 'romhackplaza', 'canSubmitEntry' ); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Entry $entry): bool + { + if( $entry->state === 'published' ){ + + // Staff editors + if( $user->_can('romhackplaza', 'canEditOthersEntries') ) + return true; + + // Author. + if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $entry->user_id === $user->user_id ) + return true; + + return false; + + } else if( $entry->state === 'pending' ){ + + // Staff moderation. + if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can('romhackplaza', 'canModerateEntries') ) + return true; + + // Author. + if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $entry->user_id === $user->user_id ) + return true; + + } else if( $entry->state === 'draft' ){ + + // Staff. + if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeOthersDrafts' ) ) + return true; + + // Author. + if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $entry->user_id === $user->user_id ) + return true; + + } else if( $entry->state === 'rejected' ){ + + // Staff. + if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeRejectedEntries' ) ) + return true; + + // Author. + if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $entry->user_id === $user->user_id ) + return true; + + } else if( $entry->state === 'locked' ){ + + // Staff. + if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeLockedEntries' ) ) + return true; + + return false; + + } else if( $entry->state === 'hidden' ){ + + // Staff. + if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeHiddenEntries' ) ) + return true; + + return false; + + } + + return false; + } + + public function skipQueue(User $user, ?Entry $entry = null): bool + { + return $user->_can( 'romhackplaza', 'canSubmitEntryInPublished' ); + } + + public function updateComment(User $user, Entry $entry): bool + { + return $user->_can('romhackplaza', 'canModerateEntries' ); + } + + public function manageButtonsInQueue(User $user, Entry $entry): bool + { + if( $entry->state === 'rejected' ){ + return $this->viewRejected( $user, $entry ); + } + + return $user->_can('romhackplaza', 'canModerateEntries' ); + } + + public function approve(User $user, Entry $entry): bool + { + return $user->_can('romhackplaza', 'canModerateEntries' ); + } + + public function reject(User $user, Entry $entry): bool + { + return $user->_can('romhackplaza', 'canModerateEntries' ); + } + + public function moderate(User $user, Entry $entry): bool + { + return $user->_can('romhackplaza', 'canModerateEntries' ); + } + +} diff --git a/app/Policies/TempFilePolicy.php b/app/Policies/TempFilePolicy.php new file mode 100644 index 0000000..c1d0433 --- /dev/null +++ b/app/Policies/TempFilePolicy.php @@ -0,0 +1,13 @@ +_can( 'romhackplaza', 'canSubmitTempFile' ); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index c61b956..7a1413e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,10 @@ namespace App\Providers; use App\Auth\XenForoGuard; +use App\Auth\XenForoUser; +use App\Policies\TempFilePolicy; +use App\Services\TemporaryFileService; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -20,8 +24,18 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + \Auth::extend('xenforo', function ($app, $name, array $config) { return new XenForoGuard($app['request']); }); + + \Gate::policy(TemporaryFileService::class, TempFilePolicy::class ); + + Gate::define('is-admin', function (XenForoUser $user) { + return $user->is_admin === 1; + }); + Gate::define('is-mod', function (XenForoUser $user) { + return $user->is_moderator === 1; + }); } } diff --git a/app/Providers/Filament/ManagePanelProvider.php b/app/Providers/Filament/ManagePanelProvider.php index 0135210..1a1185d 100644 --- a/app/Providers/Filament/ManagePanelProvider.php +++ b/app/Providers/Filament/ManagePanelProvider.php @@ -27,7 +27,7 @@ class ManagePanelProvider extends PanelProvider ->default() ->id('manage') ->path('manage') - ->login() + ->authGuard('xenforo') ->colors([ 'primary' => Color::Amber, ]) diff --git a/app/Rules/XfUserExists.php b/app/Rules/XfUserExists.php new file mode 100644 index 0000000..4eb57ef --- /dev/null +++ b/app/Rules/XfUserExists.php @@ -0,0 +1,25 @@ +table('user')->where('user_id', $value)->first(); + if( !$user ){ + $fail("The user ID {$value} does not exist."); + } + } +} diff --git a/app/Services/FileServersService.php b/app/Services/FileServersService.php index 899979f..42d82c0 100644 --- a/app/Services/FileServersService.php +++ b/app/Services/FileServersService.php @@ -111,7 +111,7 @@ class FileServersService { 'current_chunk' => $currentChunk, 'total_chunks' => $totalChunks, // TODO : Must replace User ID - 'zeus' => $this->generateZeusToken( 0, $server['base_url'], "Uploadchunk" ), + 'zeus' => $this->generateZeusToken( \Auth::user()->user_id, $server['base_url'], "Uploadchunk" ), ]); if (!$response->successful()) { diff --git a/app/Services/SubmissionsService.php b/app/Services/SubmissionsService.php index 0f8db9a..11ad10e 100644 --- a/app/Services/SubmissionsService.php +++ b/app/Services/SubmissionsService.php @@ -4,7 +4,9 @@ namespace App\Services; use App\Exceptions\SubmissionException; use App\Helpers\EntryHelpers; +use App\Helpers\XenForoHelpers; use App\Http\Requests\StoreEntryRequest; +use App\Jobs\CreateXenForoCommentsThread; use App\Models\Author; use App\Models\Entry; use App\Models\EntryFile; @@ -15,6 +17,7 @@ use App\Models\Genre; use App\Models\Language; use App\Models\Modification; use App\Models\Platform; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; @@ -26,9 +29,9 @@ class SubmissionsService { /** * Request for store/edit. - * @var StoreEntryRequest|null + * @var Request|null */ - private ?StoreEntryRequest $request = null; + private ?Request $request = null; /** * Section for store/edit. @@ -36,6 +39,12 @@ class SubmissionsService { */ private ?string $section = null; + /** + * Entry for edit. + * @var Entry|null + */ + private ?Entry $entry = null; + /** * @return list */ @@ -63,7 +72,10 @@ class SubmissionsService { 'currentChunk' => 0, 'done' => true, 'error' => null, - 'uuid' => $uuid + 'uuid' => $uuid, + 'state' => $file->state, + 'meta_online_patcher' => $file->online_patcher, + 'meta_secondary_online_patcher' => $file->secondary_online_patcher, ]; $file = Cache::get("uploaded_file_{$uuid}"); @@ -76,7 +88,10 @@ class SubmissionsService { 'currentChunk' => 0, 'done' => true, 'error' => null, - 'uuid' => $uuid + 'uuid' => $uuid, + 'state' => $file['state'], + 'meta_online_patcher' => false, + 'meta_secondary_online_patcher' => false, ]; return null; @@ -92,13 +107,13 @@ class SubmissionsService { * @throws SubmissionException * @throws \Throwable */ - public function storeEntry( StoreEntryRequest $request, string $section ){ + public function storeEntry( Request $request, string $section ){ // STEP 1 : Prepare basic fields. $this->request = $request; $this->section = $section; - $user_id = 0; // TODO: Replace that. + $user_id = \Auth::user()->user_id; $entry = DB::transaction(function () use ( $user_id ) { @@ -174,22 +189,42 @@ class SubmissionsService { $this->Step12b_MoveMainImage( $entry ); $this->Step12c_SaveGalleryImages( $entry ); + // Step 13: Try to create the comments section. + $this->Step13_CreateCommentsThread( $entry ); + + // Step 14: Refresh XF count. + if( $entry->state !== 'draft') + XenForoHelpers::updateEntriesCount( $entry->user_id ); + return $entry; } /** - * @return int + * @return null|int * * @throws SubmissionException */ - private function Step2_CreateAndReturnGameId(): int { + private function Step2_CreateAndReturnGameId(): ?int { // Already existing game. if( $this->request->input('game_id') ) return $this->request->input('game_id'); + // No fields like a draft. + if( !$this->request->input('new-game-title') && + !$this->request->input('new-game-platform') && + !$this->request->input('new-game-genre') ) + return null; + // Need to create a game. + $game = $this->createGameFromFormFields(); + + return $game->id; + } + + private function createGameFromFormFields(): 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" ); @@ -201,14 +236,12 @@ class SubmissionsService { $gameSlug = EntryHelpers::uniqueSlug( $this->request->input('new-game-title'), Game::class ); - $game = Game::create([ + return Game::create([ 'name' => trim( $this->request->input('new-game-title') ), 'slug' => $gameSlug, 'platform_id' => $platform->id, 'genre_id' => $genre->id, ]); - - return $game->id; } /** @@ -223,14 +256,14 @@ class SubmissionsService { $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; + $fields['game_name'] = $gameId ? Game::find( $gameId )->name : null; } 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 ) ) { + if( section_must_be(['romhacks', 'translations', 'homebrew', 'lua-scripts', 'tutorials'], $this->section ) ) { // TODO: Add single platform ID compatibility. - $fields['platform_name'] = Game::find( $gameId )->platform->name; + $fields['platform_name'] = $gameId ? Game::find( $gameId )->platform->name : null; } return EntryHelpers::buildCompleteTitle( $this->section, $fields ); @@ -242,12 +275,15 @@ class SubmissionsService { * @return void * @throws SubmissionException */ - private function Step7_SaveEntryFiles( int $entryId ): void + private function Step7_SaveEntryFiles( int $entryId, ?array $uuidData = null ): void { - foreach ( $this->request->input('files_uuid', [] ) as $uuid ) { + if( !$uuidData ) + $uuidData = $this->request->input('files_uuid', [] ); + + foreach ( $uuidData ?? [] as $uuid ) { $fileData = Cache::pull("uploaded_file_{$uuid}"); if( !$fileData ) - throw new SubmissionException( "File {$uuid} has expired. Please delete all your files and retry." ); + throw new SubmissionException( "File {$uuid} has expired. Please delete all your files and retry. If it's an edition, delete all your new files and retry." ); EntryFile::create([ 'entry_id' => $entryId, @@ -270,7 +306,7 @@ class SubmissionsService { */ private function Step8_SaveHashes( int $entryId ): void { - foreach ( $this->request->input('hashes', [] ) as $hash ) { + foreach ( $this->request->input('hashes', [] ) ?? [] as $hash ) { if( !isset($hash['filename'], $hash['hash_crc32'], $hash['hash_sha1'], $hash['verified']) ) { continue; } @@ -293,8 +329,10 @@ class SubmissionsService { */ private function Step9_SaveAuthors( Entry $entry ): void { + // TODO: Code fragment to be replaced by edit version. + // Existing authors. - foreach ( $this->request->input('authors', [] ) as $authorId ) { + foreach ( $this->request->input('authors', [] ) ?? [] as $authorId ) { $author = Author::find( $authorId ); if( !$author ) throw new SubmissionException( "Author {$authorId} does not exist." ); @@ -302,7 +340,7 @@ class SubmissionsService { } // New Authors - foreach ( $this->request->input('new-authors', [] ) as $authorName ) { + foreach ( $this->request->input('new-authors', [] ) ?? [] as $authorName ) { $authorName = trim( $authorName ); if( $authorName === '' ) continue; @@ -323,7 +361,10 @@ class SubmissionsService { */ private function Step10_SaveRomhacksModifications( Entry $entry ): void { - foreach ( $this->request->input('modifications', [] ) as $modificationId ) { + + // TODO: Replace by edit version + + foreach ( $this->request->input('modifications', [] ) ?? [] as $modificationId ) { $modification = Modification::find( $modificationId ); if( !$modification ) throw new SubmissionException( "Modification {$modificationId} does not exist." ); @@ -339,7 +380,9 @@ class SubmissionsService { */ private function Step11_SaveLanguages( Entry $entry ): void { - foreach ( $this->request->input('languages', [] ) as $languageId ) { + // TODO: Replace by edit version. + + foreach ( $this->request->input('languages', [] ) ?? [] as $languageId ) { $language = Language::find( $languageId ); if( !$language ) throw new SubmissionException( "Language {$languageId} does not exist." ); @@ -350,7 +393,7 @@ class SubmissionsService { private function Step12a_PrepareGalleryImages( Entry $entry ): void { - foreach ( $this->request->input('gallery', [] ) as $imagePath ) { + foreach ( $this->request->input('gallery', [] ) ?? [] as $imagePath ) { EntryGallery::create([ 'entry_id' => $entry->id, 'image' => $imagePath, @@ -365,6 +408,10 @@ class SubmissionsService { */ private function Step12b_MoveMainImage( Entry $entry ): void { $mainImage = $entry->main_image; + + if( !$mainImage ) + return; + $newPath = 'entries/main-images/' . basename($mainImage); if( !Storage::disk('public')->move($mainImage, $newPath) ) @@ -375,7 +422,7 @@ class SubmissionsService { private function Step12c_SaveGalleryImages( Entry $entry ): void { - foreach ( $entry->gallery as $galleryItem ) { + foreach ( $entry->gallery ?? [] as $galleryItem ) { $newPath = 'entries/gallery-images/' . $entry->id . '/' . basename($galleryItem->image); if( !Storage::disk('public')->move($galleryItem->image, $newPath) ) @@ -385,4 +432,367 @@ class SubmissionsService { } } + public function editEntry(Request $request, string $section, Entry $entry ): Entry + { + + // STEP 1: Prepare basic fields and keep in save some others fields. + $this->request = $request; + $this->section = $section; + $this->entry = $entry; + + if( \Auth::user()->can('moderate', $entry) ){ + $user_id = $this->request->input('owner_user_id'); + $oldUserId = $this->entry->user_id; + } else { + $user_id = \Auth::user()->user_id; + $oldUserId = null; + } + + $oldMainImage = $entry->main_image; + $galleryPaths = []; + + $entry = DB::transaction( function() use ( $user_id, &$galleryPaths ){ + + // STEP 2: Create game if different. + $gameId = null; + if( section_must_be( ['romhacks', 'translations' ], $this->section ) ){ + $gameId = $this->eStep2_VerifyCreateAndEditGameId(); + } + + // STEP 3: Recreate complete title and refresh slug if needed. + $completeTitle = $this->Step3_BuildCompleteTitle( $gameId ); + if( $completeTitle !== $this->entry->complete_title ) { + $this->entry->complete_title = $completeTitle; + $this->entry->slug = EntryHelpers::uniqueSlug( $completeTitle, Entry::class, $this->entry->id ); + } + + // STEP 4: Regenerate entry title. + + if( section_must_be( 'translations', $this->section ) && + !$this->request->input('entry_title') ){ + $this->entry->title = Game::find($gameId)->name; + } else { + $this->entry->title = $this->request->input('entry_title'); + } + + // STEP 5: Update entry fields. + + $fields = [ + 'type' => $this->section, + 'title' => $this->entry->title, // Useless, I know. + 'slug' => $this->entry->slug, + '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, + 'comments_thread_id' => $this->request->input('comments_thread_id'), + 'featured' => $this->request->input('featured'), + ]; + + if( \Auth::user()->can('moderate', $this->entry) ){ + $fields['staff_comment'] = $this->request->input('staff_comment'); + } + + $this->entry->update( $fields ); + + // STEP 6: Update entry files. + $this->eStep6_UpdateEntryFiles( $this->entry->id ); + + // STEP 7: Update hashes. + $this->eStep7_UpdateHashes( $this->entry->id ); + + // STEP 8: Update Authors. + $this->eStep8_UpdateAuthors(); + + // STEP 9: Update romhacks modifications. + if( section_must_be( 'romhacks', $this->section ) ) { + $this->eStep9_UpdateRomhacksModifications(); + } + + // STEP 10: Update Languages. + $this->eStep10_UpdateLanguages(); + + // STEP 11: Prepare new gallery images and prepare deletion of others ones. + $galleryPaths = $this->eStep11a_UpdateGalleryImages(); + + return $this->entry; + + }); + + // STEP 11 : Update main image if needed. + $this->eStep11b_UpdateMainImage( $oldMainImage ); + + // STEP 11 : Update gallery storage. + $this->eStep11c_UpdateGalleryImages( $galleryPaths ); + + // STEP 12: Refresh XF count. + if( $entry->state !== 'draft' ) { + if ($oldUserId) + XenForoHelpers::updateEntriesCount($oldUserId); + XenForoHelpers::updateEntriesCount($entry->user_id); + } + + // STEP 13: Try to create comments area if it doesn't exist. + $this->Step13_CreateCommentsThread( $this->entry ); + + return $entry; + } + + /** + * @throws SubmissionException + */ + private function eStep2_VerifyCreateAndEditGameId(): int + { + // Already existing game. + if( $this->request->input('game_id') ){ + + if( $this->entry->game_id == $this->request->input('game_id') ){ + + return $this->entry->game_id; // No changes. + + } else { // Change in game but already exist. + + $game = Game::find( $this->request->input('game_id') ); + if( !$game ) + throw new SubmissionException( "Game {$this->request->input('game_id')} does not exist." ); + $this->entry->game_id = $game->id; + return $this->entry->game_id; + + } + + } + + // In draft. + if( !$this->request->input('new-game-title') && + !$this->request->input('new-game-platform') && + !$this->request->input('new-game-genre') ) + return $this->entry->game_id; + + // Need to create a game. + $game = $this->createGameFromFormFields(); + + $this->entry->game_id = $game->id; + return $this->entry->game_id; + } + + /** + * @throws SubmissionException + */ + private function eStep6_UpdateEntryFiles(int $entryId ): void + { + $requestUuids = $this->request->input('files_uuid', []) ?? []; + $requestStates = $this->request->input('files_state', []) ?? []; + $existingUuids = EntryFile::where( 'entry_id', $entryId )->pluck('file_uuid')->toArray(); + + $needDeletion = array_diff( $existingUuids, $requestUuids ); + if( !empty( $needDeletion ) ){ + EntryFile::where('entry_id', $entryId)->whereIn('file_uuid', $needDeletion)->delete(); + } + + $needAddition = array_diff( $requestUuids, $existingUuids ); + + if( !empty( $needAddition ) ){ + $this->Step7_SaveEntryFiles( $this->entry->id, $needAddition ); // Same code. + } + + $stateMap = array_combine( $requestUuids, $requestStates ); + foreach( $stateMap as $uuid => $state ){ + EntryFile::where('file_uuid', $uuid)->where('entry_id', $entryId)->where('state', '!=', 'archived')->update(['state' => $state]); + } + } + + private function eStep7_UpdateHashes(int $entryId): void + { + $requestHashes = collect( $this->request->input('hashes', [] ) ) + ->filter( fn($h) => isset( $h['filename'], $h['hash_crc32'], $h['hash_sha1'], $h['verified'] ) ) + ->keyBy( 'hash_sha1' ) + ->toArray(); + ; + + $existingHashes = EntryHash::where( 'entry_id', $entryId )->get()->keyBy( 'hash_sha1' ); + + $hashsToDelete = array_diff( $existingHashes->keys()->toArray(), array_keys( $requestHashes ) ); + + if( !empty( $hashsToDelete ) ){ + EntryHash::where( 'entry_id', $entryId )->whereIn('hash_sha1', $hashsToDelete)->delete(); + } + + foreach( $requestHashes as $sha1 => $hash ){ + if( $existingHashes->has( $sha1 ) ){ + $existingHashes->get( $sha1 )->update([ + 'filename' => $hash['filename'], + 'hash_crc32' => $hash['hash_crc32'], + 'hash_sha1' => $hash['hash_sha1'], + 'verified' => $hash['verified'], + ]); + } else { + EntryHash::create([ + 'entry_id' => $entryId, + 'filename' => $hash['filename'], + 'hash_crc32' => $hash['hash_crc32'], + 'hash_sha1' => $hash['hash_sha1'], + 'verified' => $hash['verified'], + ]); + } + } + } + + /** + * @return void + * @throws SubmissionException + */ + private function eStep8_UpdateAuthors(): void + { + $syncAuthorsId = []; + $requestAuthorsId = $this->request->input('authors', [] ) ?? []; + + if( !empty( $requestAuthorsId ) ){ + $valid = Author::whereIn( 'id', $requestAuthorsId )->pluck('id')->toArray(); + + if( count( $valid ) !== count( $requestAuthorsId ) ){ + throw new SubmissionException( "One of the authors doesn't exist." ); + } + + $syncAuthorsId = array_merge( $syncAuthorsId, $requestAuthorsId ); + } + + 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] + ); + + $syncAuthorsId[] = $author->id; + } + + $this->entry->authors()->sync( $syncAuthorsId ); + } + + /** + * @return void + * @throws SubmissionException + */ + private function eStep9_UpdateRomhacksModifications(): void + { + $requestModifications = $this->request->input('modifications', [] ) ?? []; + if( !empty( $requestModifications ) ){ + $valid = Modification::whereIn( 'id', $requestModifications )->pluck('id')->toArray(); + + if( count( $valid ) !== count( $requestModifications ) ){ + throw new SubmissionException( "One of the modifications doesn't exist." ); + } + + + } + + $this->entry->modifications()->sync( $requestModifications ); + + } + + /** + * @return void + * @throws SubmissionException + */ + private function eStep10_UpdateLanguages(): void + { + $requestLanguages = $this->request->input('languages', [] ) ?? []; + if( !empty( $requestLanguages ) ){ + $valid = Language::whereIn( 'id', $requestLanguages )->pluck('id')->toArray(); + if( count( $valid ) !== count( $requestLanguages ) ){ + throw new SubmissionException( "One of the languages doesn't exist." ); + } + + } + + $this->entry->languages()->sync( $requestLanguages ); + } + + private function eStep11a_UpdateGalleryImages(): array + { + $requestGallery = $this->request->input('gallery', [] ) ?? []; + $existingGalleryPaths = $this->entry->gallery->pluck('image')->toArray(); + + $needDeletion = array_diff( $existingGalleryPaths, $requestGallery ); + + if( !empty( $needDeletion ) ){ + EntryGallery::where('entry_id', $this->entry->id)->whereIn('image', $needDeletion )->delete(); + } + + $needAddition = array_diff( $requestGallery, $existingGalleryPaths ); + $images = []; + foreach( $needAddition as $imagePath ){ + $images[] = EntryGallery::create([ + 'entry_id' => $this->entry->id, + 'image' => $imagePath, + ]); + } + + return [ 'addition' => $images, 'deletion' => $needDeletion ]; + } + + private function eStep11b_UpdateMainImage( ?string $oldMainImagePath ): void + { + $currentMainImagePath = $this->entry->main_image; + + if( $currentMainImagePath === $oldMainImagePath ) + return; + + if( !$currentMainImagePath ) { + if( $oldMainImagePath && Storage::disk('public')->exists($oldMainImagePath) ) + Storage::disk('public')->delete($oldMainImagePath); + return; + } + + $newPath = 'entries/main-images/' . basename( $currentMainImagePath ); + + if( !Storage::disk('public')->move( $currentMainImagePath, $newPath ) ){ + $this->entry->update(['main_image' => $oldMainImagePath]); + return; + } + + $this->entry->update(['main_image' => $newPath]); + if( $oldMainImagePath && Storage::disk('public')->exists($oldMainImagePath) ) + Storage::disk('public')->delete($oldMainImagePath); + } + + private function eStep11c_UpdateGalleryImages( array $pathsChanges ): void + { + foreach ( $pathsChanges['deletion'] as $deletePath ){ + if( Storage::disk('public')->exists($deletePath) ) + Storage::disk('public')->delete($deletePath); + } + + foreach ( $pathsChanges['addition'] as $galleryItem ){ + $newPath = 'entries/gallery-images/' . $this->entry->id . '/' . basename( $galleryItem->image ); + + if( !Storage::disk('public')->move( $galleryItem->image, $newPath ) ){ + continue; + } + + $galleryItem->update(['image' => $newPath]); + } + } + + private function Step13_CreateCommentsThread( Entry $entry ): void + { + if( $entry->state !== 'published' ) + return; + + if( !$entry->comments_thread_id ) + CreateXenForoCommentsThread::dispatch( $entry ); + // app(XenforoApiService::class)->createCommentsThread( $entry ); + } + } diff --git a/app/Services/XenforoApiService.php b/app/Services/XenforoApiService.php new file mode 100644 index 0000000..f1ce2ef --- /dev/null +++ b/app/Services/XenforoApiService.php @@ -0,0 +1,147 @@ +apiKey = config('services.xf_api.key'); + $this->superUserId = config('services.xf_api.user'); + $this->apiUrl = config('services.xf_api.url'); + } + + /** + * @throws ConnectionException + */ + private function get(string $endpoint, ?int $customUserId = null ): mixed + { + $response = Http::withHeaders([ + 'XF-Api-Key' => $this->apiKey, + 'XF-Api-User' => $customUserId ?? $this->superUserId, + ])->get("{$this->apiUrl}/{$endpoint}"); + + if( !$response->ok() ) + return null; + + return $response->json(); + } + + private function post(string $endpoint, ?int $customUserId = null, array $data = [] ): mixed + { + $response = Http::withHeaders([ + 'XF-Api-Key' => $this->apiKey, + 'XF-Api-User' => $customUserId ?? $this->superUserId, + ])->post("{$this->apiUrl}/{$endpoint}", $data); + + if( !$response->ok() ) + return null; + + return $response->json(); + } + + private function delete(string $endpoint, ?int $customUserId = null, array $data = [] ): mixed + { + $response = Http::withHeaders([ + 'XF-Api-Key' => $this->apiKey, + 'XF-Api-User' => $customUserId ?? $this->superUserId, + ])->delete("{$this->apiUrl}/{$endpoint}", $data); + + if( !$response->ok() ) + return null; + + return $response->json(); + } + + public function getUserAlerts(int $userId): mixed + { + if( app(XenforoService::class)->getXfUser($userId)?->alerts_unviewed > 0 ) { + Cache::forget("xf_alerts_{$userId}"); + } + + return Cache::remember("xf_alerts_{$userId}", 60, function() use($userId) { + return $this->get("alerts?page=1&cutoff=7days", $userId); + }); + } + + public function markAllNotificationsRead(int $userId): void + { + Cache::forget("xf_alerts_{$userId}"); + $this->post("alerts/marl-all", $userId ); + } + + public function getConversations(int $userId): mixed + { + return Cache::remember("xf_conversations_{$userId}", 60, function() use($userId) { + return $this->get("conversations?page=1&receiver_id={$userId}", $userId); + }); + } + + public function createConversation( array $userIdList, string $title, string $message, bool $conversationOpen, bool $openInvite ): bool + { + $response = $this->post("conversations", data: ['recipient_ids' => $userIdList, 'title' => $title, 'message' => $message, 'open_invite' => $openInvite, 'conversation_open' => $conversationOpen] ); + + return $response['success'] ?? false; + } + + public function createCommentsThread( Entry $entry ): bool + { + if( !$entry->comments_thread_id || $entry->comments_thread_id <= 0 ){ + $data = [ + 'node_id' => config('xenforo.comments_node_id'), + 'title' => $entry->complete_title, + 'message' => $entry->description, + 'prefix_id' => config('xenforo.comments_prefixes')[$entry->type] ?? 1, + 'custom_fields' => [ 'entry_id' => $entry->id ], + 'discussion_open' => true, + ]; + + // TODO: Flag must be removed. + $response = $this->post("threads?api_bypass_permissions=true", config('xenforo.bot_user_id'), $data ); + if( $response['success'] === true ){ + $commentsThreadId = $response['thread']['thread_id']; + $entry->update(['comments_thread_id' => $commentsThreadId]); + return true; + } + } + + return false; + } + + /** + * @throws ConnectionException + */ + public function getThreadPosts(int $threadId, int $page = 1 ): array + { + $response = $this->get("threads/{$threadId}/posts?page=$page"); + if( !isset( $response['posts'] ) || $response['posts'] === [] ) + return [ 'posts' => [], 'pagination' => null ]; + + return $response; + } + + public function updateEntriesCount(int $entryCount, int $userId): bool + { + $response = $this->post("romhackplaza_entry/update_entry_count", data: ['count' => $entryCount, 'user_id' => $userId] ); + return $response['success'] ?? false; + } + + public function deleteThreadWithEntry(int $threadId): bool + { + return (bool) $this->delete( "threads/{$threadId}", data: ['reason' => "Deletion with entry." ] ); + } + + public function restoreThreadWithEntry(int $threadId): bool + { + return (bool) $this->post("threads/{$threadId}/undelete" ); + } + +} diff --git a/app/Services/XenforoService.php b/app/Services/XenforoService.php index 82b7b36..fc8d0d1 100644 --- a/app/Services/XenforoService.php +++ b/app/Services/XenforoService.php @@ -2,13 +2,65 @@ namespace App\Services; +use App\Auth\XenForoUser; +use App\XenForoDataTypes\XenForoUserGroup; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Cookie; +use Illuminate\Support\Str; class XenforoService { private const array PERMISSIONS_KEPT = [ 'general', 'romhackplaza' ]; private const int TTL_PERMISSIONS = 300; + private const int TTL_ROUTES = 86400; + /** + * Get specific XenForo user. + * + * @param int $xfUserId + * + * @return XenForoUser|null + */ + public function getXfUser( int $xfUserId ): ?XenForoUser { + + $xfUser = \DB::connection('xenforo') + ->table('user') + ->where('user_id', $xfUserId) + ->first(); + + if(!$xfUser) + return null; + + return new XenForoUser($xfUser); + } + + /** + * Get specific XenForo user group. + * + * @param int $xfUserGroupId + * + * @return XenForoUserGroup|null + */ + public function getXfUserGroup( int $xfUserGroupId ): ?XenForoUserGroup { + $xfUserGroup = \DB::connection('xenforo') + ->table('user_group') + ->where('user_group_id', $xfUserGroupId) + ->first(); + + if(!$xfUserGroup) + return null; + + return new XenForoUserGroup($xfUserGroup); + } + + /** + * Get permissions for a specific user ID. + * + * @param int $userId + * @param int $permissionCombinationId + * + * @return array + */ public function getPermissions(int $userId, int $permissionCombinationId): array { return Cache::remember("xf_permissions_{$userId}", self::TTL_PERMISSIONS, function() use($permissionCombinationId) { @@ -29,8 +81,132 @@ class XenforoService { } + /** + * Clear user data. + * + * @param int $userId + * + * @return void + */ public function clearUserData(int $userId): void { Cache::forget("xf_permissions_{$userId}"); } + + /** + * + * + * @param string $routeName . + * + * @return string + */ + public function getRoute( string $routeName, array $arguments ): string { + + $routes = Cache::remember("xf_routes", self::TTL_ROUTES, function(){ + return \DB::connection('xenforo') + ->table('route') + ->where('route_type', 'public' ) + ->get(['route_prefix', 'sub_name', 'format']) + ->map(fn($r) => (array) $r ) + ->toArray(); + }); + + $baseUrl = config('app.forum_url'); + + try { + [$prefix, $subName] = explode('.', $routeName, 2); + + $route = collect($routes)->first(function ($r) use ($prefix, $subName) { + return $r['route_prefix'] === $prefix && $r['sub_name'] === $subName; + }); + + if( !$route ) + return $baseUrl . '/' . $prefix; + + $path = $this->buildRoutePath((array)$route, $arguments); + return rtrim($baseUrl, '/') . '/' . $path; + + } catch (\Throwable $th) { + $prefix = $routeName; + + $route = collect($routes)->first(function ($r) use ($prefix) { + return $r['route_prefix'] === $prefix; + }); + + if( !$route ) + return $baseUrl . '/' . $prefix; + + $path = $this->buildRoutePath((array)$route, $arguments); + return rtrim($baseUrl, '/') . '/' . $path; + } + + } + + private function buildRoutePath(array $route, array $arguments): string { + + $prefix = $route['route_prefix']; + $format = $route['format']; + $subName = $route['sub_name']; + + if (!$format) { + return $subName + ? $prefix . '-/' . $subName + : $prefix; + } + + if (str_starts_with($format, '-/')) { + return $prefix . $format; + } + + $built = preg_replace_callback( + '/:\+?(\w+)(?:_\w+)?(?:<([^>]+)>)?/', + function(array $m) use ($arguments): string { + $type = $m[1]; + $keys = isset($m[2]) + ? explode(',', $m[2]) + : []; + + return match(true) { + + $type === 'page' => isset($params['page']) && $params['page'] > 1 + ? 'page-' . $params['page'] + : '', + + $type === 'str_int' && count($keys) >= 2 => implode('.', array_filter([ + $params[$keys[0]] ?? null, + $params[$keys[1]] ?? null, + ])), + + $type === 'int' && count($keys) >= 1 => (string) ($params[$keys[0]] ?? ''), + + in_array($type, ['str', 'any']) && count($keys) >= 1 + => (string) ($params[$keys[0]] ?? ''), + + default => isset($params[$type]) ? (string) $params[$type] : '', + }; + }, + $format + ); + + $built = preg_replace('/\/+/', '/', $prefix . '/' . $built); + $built = rtrim($built, '/'); + + return $built; + + } + + private function hashCSRFToken( string $token ): string + { + return hash_hmac('md5', $token . time(), config('app.xf_salt') ); + } + public function getCSRFToken(): string + { + $token = Cookie::get('xf_csrf'); + if( !$token ){ + $token = Str::random(16); + Cookie::queue('xf_csrf', $token, 0, '/', config('session.domain'), 0, false, false ); + } + + return time() . ',' . $this->hashCSRFToken($token); + } } diff --git a/app/Traits/ModCPSearch.php b/app/Traits/ModCPSearch.php new file mode 100644 index 0000000..a1bc3dd --- /dev/null +++ b/app/Traits/ModCPSearch.php @@ -0,0 +1,23 @@ +where(function ($query) use ($columns, $search) { + foreach ($columns as $i => $column) { + $method = $i === 0 ? 'where' : 'orWhere'; + $query->{$method}($column, 'LIKE', "%{$search}%"); + } + }); + } + +} diff --git a/app/View/Components/DatabaseFilterWithMode.php b/app/View/Components/DatabaseFilterWithMode.php new file mode 100644 index 0000000..60161c5 --- /dev/null +++ b/app/View/Components/DatabaseFilterWithMode.php @@ -0,0 +1,35 @@ + "Trans", + 'romhacks' => 'Hack', + 'homebrew' => 'HBrew', + 'utilities' => 'Util', + 'documents' => 'Doc', + 'lua-scripts' => 'Lua', + 'tutorials' => 'Tuto' + ]; + + /** + * Create a new component instance. + */ + public function __construct( + public Entry $entry, + ) + { + // + } + + /** + * Get the view / contents that represent the component. + */ + public function render(): View|Closure|string + { + return view('components.entry-card'); + } +} diff --git a/app/View/Components/ModCPSearch.php b/app/View/Components/ModCPSearch.php new file mode 100644 index 0000000..5382494 --- /dev/null +++ b/app/View/Components/ModCPSearch.php @@ -0,0 +1,29 @@ +entry === null ) + $this->entry = 'App\Models\Entry'; } public function availableStates(): array { - // TODO: Change for permissions. - return [ - 'draft' => "Save into my drafts", - 'pending' => "Add to submissions queue", - 'published' => "Publish it now" + + $labelPublished = $this->isEdit && $this->currentState === 'published' ? "Keep it published" : "Publish it now"; + $labelPending = $this->isEdit && $this->currentState === 'pending' ? "Keep it to submissions queue" : "Add to submissions queue"; + $labelDraft = $this->isEdit && $this->currentState === 'draft' ? "Keep it into my drafts" : "Save into my drafts"; + $labelHidden = $this->isEdit && $this->currentState === 'hidden' ? "Keep it hidden" : "Hide this entry"; + $labelLocked = $this->isEdit && $this->currentState === 'locked' ? "Keep it locked" : "Lock this entry"; + + $states = [ + 'draft' => $labelDraft, + 'pending' => $labelPending, ]; + + if( $this->isEdit ) { + + $isAuthor = $this->entry->user_id === \Auth::user()->user_id; + if( $isAuthor && ( \Auth::user()->can('skip-queue', $this->entry) || $this->currentState === 'published' ) ) + $states['published'] = $labelPublished; + else if( \Auth::user()->can('moderate', $this->entry) ) + $states['published'] = $labelPublished; + if( \Auth::user()->can('moderate', $this->entry) && \Auth::user()->can('view-hidden', $this->entry) ) + $states['hidden'] = $labelHidden; + if(\Auth::user()->can('moderate', $this->entry) && \Auth::user()->can('view-locked', $this->entry) ) + $states['locked'] = $labelLocked; + + } else { + if( \Auth::user()->can('skip-queue', $this->entry ) ){ + $states['published'] = $labelPublished; + } + } + + return $states; } public function defaultState(): string diff --git a/app/View/Components/XenForoAvatar.php b/app/View/Components/XenForoAvatar.php index 10db62c..b661fc1 100644 --- a/app/View/Components/XenForoAvatar.php +++ b/app/View/Components/XenForoAvatar.php @@ -3,6 +3,7 @@ namespace App\View\Components; use App\Auth\XenForoUser; +use App\Services\XenforoService; use Closure; use Illuminate\Contracts\View\View; use Illuminate\View\Component; @@ -13,11 +14,13 @@ class XenForoAvatar extends Component * Create a new component instance. */ public function __construct( - public ?XenForoUser $user = null, + public null|int|XenForoUser $user = null, ) { if( $this->user === null ) $this->user = \Auth::user(); + else if( is_int( $this->user ) ) + $this->user = app(XenforoService::class)->getXfUser( $this->user ); } /** diff --git a/app/View/Components/XfUsernameLink.php b/app/View/Components/XfUsernameLink.php new file mode 100644 index 0000000..c3d77cd --- /dev/null +++ b/app/View/Components/XfUsernameLink.php @@ -0,0 +1,38 @@ +user === null && $this->userId !== null ){ + $this->user = app(XenforoService::class)->getXfUser($this->userId); + if( $this->user === null ){ + $this->user = app(XenforoService::class)->getXfUser(config('xenforo.bot_user_id')); + } + } + + } + + /** + * Get the view / contents that represent the component. + */ + public function render(): View|Closure|string + { + return view('components.xf-username-link'); + } +} diff --git a/app/XenForoDataTypes/XenForoData.php b/app/XenForoDataTypes/XenForoData.php new file mode 100644 index 0000000..fc03ac5 --- /dev/null +++ b/app/XenForoDataTypes/XenForoData.php @@ -0,0 +1,19 @@ +services = app(XenforoService::class); + } + + public function __get(string $name): mixed { + return $this->data->$name ?? null; + } + +} diff --git a/app/XenForoDataTypes/XenForoUserGroup.php b/app/XenForoDataTypes/XenForoUserGroup.php new file mode 100644 index 0000000..df02d17 --- /dev/null +++ b/app/XenForoDataTypes/XenForoUserGroup.php @@ -0,0 +1,7 @@ + [], + 'platforms' => [], + 'genres' => [], + 'games' => [], + 'statuses' => [], + 'authors' => [], + 'authorsMode' => 'or', + 'languages' => [], + 'languagesMode' => 'or', + 'modifications' => [], + 'modificationsMode' => 'or', + 'sort' => 'created_at', + 'dir' => 'desc', + 's' => '' + ]; + + $query = array_filter( + array_merge($defaults, $params), + fn($v,$k) => match(true){ + is_array($v) => !empty($v), + in_array($k, ['authorsMode', 'languagesMode', 'modificationsMode']) => $v !== 'or', + $k === 'sort' => $v !== 'created_at', + $k === 'dir' => $v !== 'desc', + default => $v !== '', + }, + ARRAY_FILTER_USE_BOTH + ); + + return route('entries.index', $query ); + } +} diff --git a/app/xenforo.php b/app/xenforo.php new file mode 100644 index 0000000..bb1f249 --- /dev/null +++ b/app/xenforo.php @@ -0,0 +1,19 @@ +getRoute( $routeName, $arguments ); + } +} + +if( !function_exists( 'xfCsrfToken') ){ + function xfCsrfToken(): string { + return app(\App\Services\XenforoService::class)->getCSRFToken(); + } +} + +if( !function_exists( 'xfStyleVariationUrl' ) ){ + function xfStyleVariationUrl( string $variation ): string { + return config('app.forum_url') . '/misc/style-variation?variation=' . $variation . "&t=" . xfCsrfToken(); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index df43409..3928513 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,18 +3,24 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Http\Request; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', + api: __DIR__.'/../routes/api.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - $middleware->encryptCookies(except: ['xf_session','xf_user']); + $middleware->encryptCookies(except: ['xf_session','xf_user','xf_csrf','theme','entries_per_page']); $middleware->alias([ 'xf.auth' => \App\Http\Middleware\CheckXenForoPermissions::class, ]); + $middleware->redirectGuestsTo(function(Request $request): void { + if( $request->is('manage*')) + abort(403); + }); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index 7fcd221..dbd334c 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,9 @@ "laravel/framework": "^13.7", "laravel/tinker": "^3.0", "livewire/livewire": "^4.3", - "predis/predis": "^3.4" + "predis/predis": "^3.4", + "ext-xmlreader": "*", + "ext-simplexml": "*" }, "require-dev": { "barryvdh/laravel-debugbar": "^4.2", @@ -28,7 +30,8 @@ }, "autoload": { "files": [ - "app/helpers.php" + "app/helpers.php", + "app/xenforo.php" ], "psr-4": { "App\\": "app/", diff --git a/config/app.php b/config/app.php index 4bab8d2..e534a64 100644 --- a/config/app.php +++ b/config/app.php @@ -106,6 +106,8 @@ return [ ), ], + 'xf_salt' => env('XF_GLOBAL_SALT'), + /* |-------------------------------------------------------------------------- | Maintenance Mode Driver diff --git a/config/auth.php b/config/auth.php index ac014ce..9df6e4f 100644 --- a/config/auth.php +++ b/config/auth.php @@ -45,6 +45,7 @@ return [ ], 'xenforo' => [ 'driver' => 'xenforo', + 'provider' => 'users', ] ], diff --git a/config/database.php b/config/database.php index 443cc2b..c3c8ad2 100644 --- a/config/database.php +++ b/config/database.php @@ -124,6 +124,13 @@ return [ 'prefix' => 'xf_' ], + 'hashes' => [ + 'driver' => 'sqlite', + 'database' => storage_path('hashes.sqlite'), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + ] + ], /* diff --git a/config/menu.php b/config/menu.php new file mode 100644 index 0000000..9941ab2 --- /dev/null +++ b/config/menu.php @@ -0,0 +1,105 @@ + => ['name', 'items'] + * Items: ['name','icon','route' or 'xf_route'] + */ +return [ + 'website' => [ + 'name' => 'Website', + 'items' => [ + [ + 'name' => 'Home', + 'icon' => 'home', + 'route' => 'home', + ], + [ + 'name' => 'Database', + 'icon' => 'database', + 'route' => 'entries.index' + ], + [ + 'name' => "Submissions queue", + 'icon' => 'gavel', + 'route' => 'queue.index' + ], + [ + 'name' => "My Drafts", + 'icon' => 'scissors', + 'route' => 'entries.drafts', + 'condition' => (fn() => !\Auth::guest()) + ] + ] + ], + 'community' => [ + 'name' => 'Community', + 'items' => [ + [ + 'name' => 'Forum', + 'icon' => 'message-circle', + 'xf_route' => '' + ], + [ + 'name' => 'Clubs', + 'icon' => 'balloon', + 'xf_route' => 'clubs' + ], + [ + 'name' => 'Discord', + 'icon' => 'messages-square', + 'route' => 'home' + ], + [ + 'name' => 'Members', + 'icon' => 'users', + 'xf_route' => 'members' + ], + + ] + ], + 'tools' => [ + 'name' => 'Tools', + 'items' => [ + [ + 'name' => 'ROM Patcher', + 'icon' => 'stamp', + 'route' => 'home' + ], + [ + 'name' => 'ROM Hasher', + 'icon' => 'hash', + 'route' => 'home' + ], + [ + 'name' => 'ROM Checker', + 'icon' => 'check', + 'route' => 'home' + ] + ] + ], + 'pages' => [ + 'name' => 'Pages', + 'items' => [ + [ + 'name' => 'Learn Romhacking', + 'icon' => 'graduation-cap', + 'route' => 'home' + ], + [ + 'name' => 'About', + 'icon' => 'info', + 'route' => 'home' + ], + [ + 'name' => 'Contact Us', + 'icon' => 'at-sign', + 'xf_route' => 'misc/contact' + ], + [ + 'name' => 'Legal pages', + 'icon' => 'scale', + 'route' => 'home' + ] + ] + ] +]; diff --git a/config/services.php b/config/services.php index 6a90eb8..7a6199f 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,10 @@ return [ ], ], + 'xf_api' => [ + 'user' => env('XF_API_USER'), + 'key' => env('XF_API_KEY'), + 'url' => env('XF_API_URL'), + ] + ]; diff --git a/config/xenforo.php b/config/xenforo.php new file mode 100644 index 0000000..d644ace --- /dev/null +++ b/config/xenforo.php @@ -0,0 +1,18 @@ + 1, + + 'comments_node_id' => 4, + + 'comments_prefixes' => [ + 'translations' => 1, + 'romhacks' => 2, + 'homebrew' => 3, + 'utilities' => 4, + 'documents' => 5, + 'lua-scripts' => 6, + 'tutorials' => 7, + 'news' => 8 + ], +]; diff --git a/database/migrations/2026_05_10_072747_create_entries_table.php b/database/migrations/2026_05_10_072747_create_entries_table.php index a298cb0..0c6a41f 100644 --- a/database/migrations/2026_05_10_072747_create_entries_table.php +++ b/database/migrations/2026_05_10_072747_create_entries_table.php @@ -24,7 +24,7 @@ return new class extends Migration $table->string( 'main_image' )->nullable(); // TODO: Replace it by state. - $table->enum( 'status', [ 'draft', 'pending', 'published', 'locked', 'hidden' ] )->default('draft'); + $table->enum( 'state', [ 'draft', 'pending', 'published', 'locked', 'hidden' ] )->default('draft'); $table->boolean('featured')->default(false); // FK diff --git a/database/migrations/2026_05_27_192635_add_fields_for_queue_to_entries.php b/database/migrations/2026_05_27_192635_add_fields_for_queue_to_entries.php new file mode 100644 index 0000000..29469c5 --- /dev/null +++ b/database/migrations/2026_05_27_192635_add_fields_for_queue_to_entries.php @@ -0,0 +1,31 @@ +softDeletes(); + $table->text("staff_comment")->nullable()->after("state"); + $table->timestamp("rejected_at")->nullable()->after("staff_comment"); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('entries', function (Blueprint $table) { + $table->dropSoftDeletes(); + $table->dropColumn(['staff_comment', 'rejected_at']); + }); + } +}; diff --git a/database/migrations/2026_05_27_193235_add_rejected_state_to_entries.php b/database/migrations/2026_05_27_193235_add_rejected_state_to_entries.php new file mode 100644 index 0000000..c9ee39c --- /dev/null +++ b/database/migrations/2026_05_27_193235_add_rejected_state_to_entries.php @@ -0,0 +1,26 @@ +string('title')->nullable()->change(); + $table->longText('description')->nullable()->change(); + $table->string('slug')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // + } +}; diff --git a/database/migrations/2026_06_05_163235_add_online_patcher_fields_to_files.php b/database/migrations/2026_06_05_163235_add_online_patcher_fields_to_files.php new file mode 100644 index 0000000..5b18fcc --- /dev/null +++ b/database/migrations/2026_06_05_163235_add_online_patcher_fields_to_files.php @@ -0,0 +1,30 @@ +boolean('online_patcher')->default(false); + $table->boolean('secondary_online_patcher')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('entry_files', function (Blueprint $table) { + $table->dropColumn('online_patcher'); + $table->dropColumn('secondary_online_patcher'); + }); + } +}; diff --git a/database/migrations/hashes/2026_06_01_162748_create_hashes_reference_table.php b/database/migrations/hashes/2026_06_01_162748_create_hashes_reference_table.php new file mode 100644 index 0000000..c03d90b --- /dev/null +++ b/database/migrations/hashes/2026_06_01_162748_create_hashes_reference_table.php @@ -0,0 +1,27 @@ +create('dat_reference', function (Blueprint $table) { + $table->id(); + $table->string('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::connection('hashes')->dropIfExists('dat_reference'); + } +}; diff --git a/database/migrations/hashes/2026_06_01_162839_create_hashes_table.php b/database/migrations/hashes/2026_06_01_162839_create_hashes_table.php new file mode 100644 index 0000000..10b8deb --- /dev/null +++ b/database/migrations/hashes/2026_06_01_162839_create_hashes_table.php @@ -0,0 +1,30 @@ +create('hashes', function (Blueprint $table) { + $table->id(); + $table->string('filename')->nullable(); + $table->string('crc32')->nullable()->index(); + $table->string('sha1')->nullable()->index(); + $table->foreignId('dat_reference_id')->constrained('dat_reference')->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::connection('hashes')->dropIfExists('hashes'); + } +}; diff --git a/extra.less b/extra.less index 1790ddd..ea5932a 100644 --- a/extra.less +++ b/extra.less @@ -37,6 +37,8 @@ ul { list-style-type: square; } +[x-cloak] {display: none !important;} + /* File: resources/css/base/variables.css */ :root { @@ -71,6 +73,32 @@ ul { --menu-user-avatar-bg: #555; } +.\$light-mode { + + /* RHPZ color */ + --rhpz-orange: #ff7300; + --rhpz-orange-hover: #e56700; + + /* Background colors */ + --bg: #f0f0f0; + --bg2: #ffffff; + --bg3: #e8e8e8; + --bg4: #dcdcdc; + + /* Text */ + --text: #454545; + --text2: #737373; + --text3: #111111; + + /* Elements */ + --border: #d0d0d0; + --error: #e57373; + --info: #1976d2; + --success: #81c784; + --success2: #388e3c; + +} + /* File: resources/css/components/cards.css */ /* STAT CARDS */ @@ -98,6 +126,81 @@ ul { height: 32px; } +/* ENTRY CARDS */ + +.\$entry-card { + background-color: var(--bg2); + border: 1px solid var(--border); + display: flex; + flex-direction: column; + transition: transform 0.2s, border-color 0.2s; + cursor: pointer; + + &:hover { + transform: translateY(-3px); + border-color: var(--rhpz-orange); + } + + .\$entry-cover-wrapper { + position: relative; + aspect-ratio: 4/3; + background-color: var(--bg); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + } + + .\$entry-cover-wrapper img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .\$entry-badge { + position: absolute; + top: 10px; + right: 10px; + background-color: rgba(0,0,0,0.7); + backdrop-filter: blur(4px); + border: 1px solid var(--border); + padding: 4px 8px; + font-size: 0.75rem; + color: var(--text); + } + + .\$entry-card-info { + padding: 15px; + flex-grow: 1; + display: flex; + flex-direction: column; + } + + .\$entry-card-title { + font-weight: 600; + color: var(--text); + font-size: 1.1rem; + margin-bottom: 5px; + line-height: 1.3; + } + + .\$entry-card-author { + color: var(--rhpz-orange); + font-size: 0.85rem; + margin-bottom: 10px; + } + + .\$entry-card-meta { + margin-top: auto; + font-size: 0.8rem; + color: var(--text2); + display: flex; + justify-content: space-between; + align-items: center; + } +} + /* File: resources/css/components/common.css */ /* BUTTONS */ @@ -166,6 +269,13 @@ ul { border-bottom: 1px solid var(--border); padding-bottom: 10px; } +.\$block-success { + background-color: var(--success); + border: 1px solid var(--success); + color: var(--text); + padding: 20px; + margin-bottom: 20px; +} .\$block-error { background-color: var(--error); border: 1px solid var(--error); @@ -189,17 +299,50 @@ ul { color: var(--text3); border-color: var(--rhpz-orange); } -.\$badge.blue { +.\$badge.blue, .\$badge.translations { background-color: var(--info); color: var(--text); border-color: var(--info); } -.\$badge.green { +.\$badge.green, .\$badge.romhacks { background-color: var(--success2); color: var(--text); border-color: var(--success2); } +.\$topbar-badge { + position: absolute; + top: 0; + right: 0; + min-width: 18px; + height: 18px; + padding: 0 4px; + border-radius: 9px; + background-color: var(--rhpz-orange); + color: #111; + font-size: 0.65rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + border: 2px solid var(--bg); + animation: badge-pop 0.2s ease; +} + +.\$topbar-badge--overflow { + border-radius: 9px; + padding: 0 5px; + font-size: 0.6rem; +} + +@keyframes badge-pop { + 0% { transform: scale(0.5); opacity: 0; } + 0% { transform: scale(0.5); opacity: 0; } + 70% { transform: scale(1.2); } + 100% { transform: scale(1); opacity: 1; } +} + /* BREADCRUMB */ .\$breadcrumb { @@ -247,6 +390,406 @@ ul { animation: spin 1s infinite linear; } +.\$search-button { + background: none; + border: none; + cursor: pointer; +} + + +/* File: resources/css/components/database.css */ +.\$filter-bar { + display: flex; + gap: 15px; + background-color: var(--bg2); + padding: 15px; + border: 1px solid var(--border); + margin-bottom: 20px; + flex-wrap: wrap; + align-items: center; + + .\$filter-bar-search { + flex: 1; + max-width: 400px; + background-color: var(--bg); + border: 1px solid var(--border); + display: flex; + align-items: center; + padding: 8px 12px; + gap: 8px; + + } +} + +.\$database-wrapper { + display: flex; + gap: 20px; + align-items: flex-start; + + .\$database-filters { + width: 300px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 2px; + background-color: var(--bg2); + border: 1px solid var(--border); + + .\$filter-group { + border-bottom: 1px solid var(--border); + overflow: hidden; + &:last-child { + border-bottom: none; + } + } + + .\$filter-title-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background-color: var(--bg3); + cursor: pointer; + user-select: none; + + .\$filter-title { + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text2); + margin: 0; + } + + } + + .\$filter-mode { + display: flex; + gap: 4px; + } + + .\$filter-btn-mode { + background: none; + border: 1px solid var(--border); + color: var(--text2); + font-size: 0.7rem; + font-weight: 600; + padding: 2px 7px; + cursor: pointer; + font-family: var(--typography); + transition: all 0.15s; + letter-spacing: 0.5px; + &:hover { + border-color: var(--rhpz-orange); + color: var(--rhpz-orange); + } + &.\$active { + background-color: var(--rhpz-orange); + border-color: var(--rhpz-orange); + color: var(--text3); + } + } + + .\$filter-options { + padding: 6px 0; + max-height: 180px; + overflow-y: auto; + &::-webkit-scrollbar { + width: 6px; + } + &::-webkit-scrollbar-track { + background: var(--bg2); + } + &::-webkit-scrollbar-thumb { + background: var(--border); + } + } + + .\$filter-option { + display: flex; + align-items: center; + gap: 9px; + padding: 6px 14px; + font-size: 0.88rem; + color: var(--text); + cursor: pointer; + transition: background-color 0.1s; + &:hover { + background-color: var(--bg4); + } + } + + .\$filter-option input[type="checkbox"] { + accent-color: var(--rhpz-orange); + width: 14px; + height: 14px; + cursor: pointer; + flex-shrink: 0; + } + + } + + .\$database-results { + flex: 1; + min-width: 0; + + .\$database-sort { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 15px; + padding-bottom: 15px; + border-bottom: 1px solid var(--border); + flex-wrap: wrap; + + .\$btn { + font-size: 0.85rem; + padding: 6px 12px; + &.\$active { + border-color: var(--rhpz-orange); + color: var(--rhpz-orange); + } + } + + } + + .\$database-results-count { + margin-left: auto; + font-size: 0.85rem; + color: var(--text2); + } + + .\$database-active-filters { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 15px; + } + + .\$database-active-filter-tag { + display: inline-flex; + align-items: center; + gap: 6px; + background-color: var(--bg3); + border: 1px solid var(--border); + padding: 3px 10px; + font-size: 0.8rem; + color: var(--text); + .\$tag-type { + color: var(--rhpz-orange); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.5px; + } + button { + background: none; + border: none; + color: var(--text2); + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + transition: color 0.15s; + &:hover { + color: var(--text); + } + } + } + + .\$database-empty { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + color: var(--text2); + background-color: var(--bg2); + border: 1px solid var(--border); + gap: 15px; + text-align: center; + + i { + color: var(--border); + } + + p { + font-size: 0.95rem; + } + } + + } + +} + +.\$database-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 4px; + margin-top: 20px; + + .\$btn { + min-width: 36px; + padding: 6px 10px; + font-size: 0.85rem; + display: flex; + align-items: center; + justify-content: center; + } + + .\$active { + background-color: var(--rhpz-orange); + border-color: var(--rhpz-orange); + color: #111; + font-weight: 600; + } +} + +.\$database-search { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 20px; +} + +@media (max-width: 900px) { + .\$database-layout { + flex-direction: column; + } + + .\$database-filters { + width: 100%; + display: grid; + grid-template-columns: repeat(2,1fr); + } + + .\$database-filter-group:last-child { + border-bottom: 1px solid var(--border); + } + + .\$database-results-count { + margin-left: 0; + width: 100%; + } +} + +@media (max-width: 600px) { + .\$database-filters { + grid-template-columns: 1fr; + } + + .\$grid-entries { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 420px) { + .\$grid-entries { + grid-template-columns: 1fr; + } +} + +.\$filter-chevron { + transition: transform 0.2s ease; + color: var(--text2); + flex-shrink: 0; +} + +.\$filter-chevron.rotated { + transform: rotate(-90deg); +} + +.\$internal-filter-search { + display: flex; + align-items: center; + gap: 7px; + padding: 7px 14px; + border-bottom: 1px solid var(--border); + background-color: var(--bg); + + i { + color: var(--text2); + flex-shrink: 0; + } + + input { + background: none; + border: none; + outline: none; + color: var(--text); + font-family: var(--typography); + font-size: 0.85rem; + width: 100%; + + &::placeholder { + color: var(--text2); + } + } +} + +.\$filter-title-left { + display: flex; + align-items: center; + gap: 7px; +} + +.\$filter-title-right { + display: flex; + align-items: center; + gap: 6px; +} + +.\$internal-filter-count { + display: inline-flex; + align-items: center; + justify-content: center; + background-color: var(--rhpz-orange); + color: #111; + font-size: 0.7rem; + font-weight: 700; + min-width: 18px; + height: 18px; + padding: 0 5px; + line-height: 1; +} + +.\$internal-filter-clear { + background: none; + border: 1px solid var(--border); + color: var(--text2); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + transition: all 0.15s; + + &:hover { + border-color: var(--error); + color: var(--error); + } +} + +.\$filter-search-clear { + background: none; + border: none; + color: var(--text2); + cursor: pointer; + display: flex; + align-items: center; + padding: 0; + flex-shrink: 0; + transition: color 0.15s; + &:hover { + color: var(--text); + } +} + + /* File: resources/css/components/easymde.css */ .\$EasyMDEContainer { @@ -831,6 +1374,75 @@ ul { color: var(--text2); white-space: nowrap; } +.\$upload-item-actions { + display: flex; + flex-direction: row; + gap: 15px; +} +.file-state-icon { width: 18px; height: 18px; } +.file-state-icon--public { color: var(--success); } +.file-state-icon--private { color: var(--text2); } +.file-state-icon--archived { color: var(--error); } + +.upload-item-state { display: flex; align-items: center; gap: 8px; } + +.author-search { position: relative; } + +.\$author-search-input { + display: flex; + align-items: center; + gap: 8px; + background-color: var(--bg); + border: 1px solid var(--border); + padding: 8px 12px; +} + +.\$author-search-input .\$form-input { + border: none; + padding: 0; + background: none; + flex: 1; +} + +.\$author-search-selected { + display: flex; + align-items: center; + gap: 5px; + color: var(--success); + font-size: 0.85rem; + white-space: nowrap; +} + +.\$author-search-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background-color: var(--bg2); + border: 1px solid var(--border); + border-top: none; + z-index: 100; + max-height: 200px; + overflow-y: auto; +} + +.\$author-search-item { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + padding: 9px 12px; + background: none; + border: none; + color: var(--text); + font-size: 0.88rem; + cursor: pointer; + text-align: left; + font-family: var(--typography); + transition: background-color 0.1s; +} + +.author-search-item:hover { background-color: var(--bg3); } /* File: resources/css/components/grid.css */ @@ -864,10 +1476,140 @@ ul { gap: 20px; } +.\$grid-entries { + display: grid; + grid-template-columns: repeat(6,1fr); + gap: 20px; + margin-bottom: 20px; +} + + + +/* File: resources/css/components/hovercard.css */ +.\$hovercard-overlay { + position: absolute; + z-index: 2000; + background-color: var(--bg2); + border: 1px solid var(--border); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); +} + +.\$hovercard-overlay-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 30px; + color: var(--text2); +} + +.\$hovercard-overlay-error { + padding: 20px; + text-align: center; + color: var(--text2); + font-size: 0.85rem; +} + +.\$hovercard { + width: 280px; +} + +.\$hovercard-header { + height: 70px; + background-color: var(--bg3); + border-bottom: 1px solid var(--border); + position: relative; +} + +.\$hovercard-avatar { + position: absolute; + bottom: -26px; + left: 16px; + width: 52px; + height: 52px; + border-radius: 50%; + border: 3px solid var(--bg2); + overflow: hidden; + background-color: var(--bg4); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 1.2rem; + color: var(--text); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.\$hovercard-body { + padding: 34px 16px 16px; +} + +.\$hovercard-username { + font-weight: 600; + font-size: 1rem; + color: var(--text); + margin-bottom: 2px; +} + +.\$hovercard-title { + font-size: 0.8rem; + color: var(--rhpz-orange); + margin-bottom: 14px; + min-height: 14px; +} + +.\$hovercard-stats { + display: flex; + border: 1px solid var(--border); + margin-bottom: 14px; +} + +.\$hovercard-stat { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 4px; + border-right: 1px solid var(--border); + + &:last-child { + border-right: none; + } + + .\$stat-value { + font-size: 0.95rem; + font-weight: 600; + color: var(--text); + } + + .\$stat-label { + font-size: 0.68rem; + color: var(--text2); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 2px; + } +} + +.\$hovercard-actions { + display: flex; + gap: 8px; +} + +.\$hovercard-actions .\$btn { + flex: 1; + justify-content: center; + font-size: 0.82rem; +} + /* File: resources/css/components/modal.css */ .\$modal-overlay { - display: none; + display: flex; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.7); @@ -921,6 +1663,497 @@ ul { } +/* File: resources/css/components/notifications.css */ +.\$notifications, .\$conversations { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 340px; + max-height: 480px; + overflow-y: auto; + background-color: var(--bg2); + border: 1px solid var(--border); + box-shadow: 0 8px 24px rgba(0,0,0,0.5); + z-index: 2000; + + &::-webkit-scrollbar { + width: 8px; + } + &::-webkit-scrollbar-thumb { + background-color: var(--border); + } + &::-webkit-scrollbar-track { + background-color: var(--bg2); + } +} + +@keyframes dropdown-enter { + from { opacity: 0; transform: translateY(-6px); } + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +.\$dropdown-enter { + animation: dropdown-enter 0.15s ease; +} + +.\$notifications-header { + + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + background-color: var(--bg3); + z-index: 1; + + .\$notifications-header-title { + font-weight: 600; + font-size: 0.9rem; + color: var(--text); + } + + .\$notifications-header-actions { + display: flex; + gap: 6px; + } +} + +.\$notifications-loading, .\$notifications-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 40px 20px; + color: var(--text2); + font-size: 0.85rem; +} + +.\$notifications-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + text-decoration: none; + color: var(--text); + transition: background-color 0.1s; + position: relative; + + &:last-child { + border-bottom: none; + } + &:hover { + background-color: var(--bg3); + } + .\$unread { + border-left: 2px solid var(--rhpz-orange); + background-color: var(--bg3); + } +} + +.\$notifications-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; + background-color: var(--bg4); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 0.9rem; + color: var(--text); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.\$notifications-content { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; + + .\$notifications-text { + font-size: 0.88rem; + color: var(--text); + line-height: 1.4; + } + + .\$notifications-date { + font-size: 0.75rem; + color: var(--text2); + } +} + +.\$notifications-unread-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--rhpz-orange); + flex-shrink: 0; + margin-top: 4px; +} + + +/* File: resources/css/components/queue.css */ +.\$queue-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 15px; + padding: 80px 20px; + color: var(--text2); + font-size: 0.95rem; +} + +.\$queue-item { + background-color: var(--bg2); + border: 1px solid var(--border); + border-left-width: 4px; + margin-bottom: 20px; + padding: 20px; +} + +.\$queue-item--pending { + border-left-color: var(--rhpz-orange); +} +.\$queue-item--rejected { + position: relative; + overflow: hidden; + border-left-color: var(--error); + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + height: 2px; + background-color: var(--error); + width: var(--reject-progress, 100%); + opacity: 0.5; + transition: width 0.3s; + } +} + +.\$queue-item-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 15px; + margin-bottom: 20px; +} + +.\$queue-item-title { + font-size: 1.15rem; + font-weight: 600; + color: var(--text); + margin-bottom: 6px; +} + +.\$queue-item-meta { + font-size: 0.85rem; + color: var(--text2); + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.\$queue-item-actions-header { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.\$timeline-container { + padding: 15px 20px; + background-color: var(--bg); + border: 1px solid var(--border); + margin-bottom: 20px; +} + +.\$timeline { + display: flex; + justify-content: space-between; + position: relative; + + &::before { + content: ''; + position: absolute; + top: 15px; + left: 30px; + right: 30px; + height: 2px; + background-color: var(--border); + z-index: 0; + } + +} + +.\$timeline-step { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + z-index: 1; + width: 100px; +} + +.\$timeline-dot { + width: 32px; + height: 32px; + border-radius: 50%; + background-color: var(--bg); + border: 2px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + color: var(--text2); + margin-bottom: 10px; + transition: all 0.2s; +} + +.\$timeline-step--active .\$timeline-dot { + border-color: var(--rhpz-orange); + background-color: var(--rhpz-orange); + color: #111; +} + +.\$timeline-step--validated .\$timeline-dot { + border-color: var(--success); + background-color: var(--success); + color: #111; +} + +.\$timeline-step--rejected .\$timeline-dot { + border-color: var(--error); + background-color: var(--error); + color: #fff; +} + +.\$timeline-label { + font-size: 0.78rem; + text-align: center; + color: var(--text2); + font-weight: 500; +} + +.timeline-step--active .timeline-label { color: var(--rhpz-orange); font-weight: 600; } +.timeline-step--validated .timeline-label { color: var(--success); } +.timeline-step--rejected .timeline-label { color: var(--error); } + +.\$queue-reject-reason { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 12px 15px; + background-color: rgba(229, 115, 115, 0.08); + border-left: 2px solid var(--error); + color: var(--text); + font-size: 0.88rem; + margin-bottom: 15px; + line-height: 1.5; +} + +.\$queue-mod-zone { + border-top: 1px solid var(--border); + padding-top: 15px; + margin-top: 5px; + display: flex; + flex-direction: column; + gap: 15px; +} + +.\$queue-mod-separator { + border-top: 1px solid var(--border); +} + +.\$queue-mod-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 10px; +} + +.\$queue-reject-form { + margin-top: 12px; + padding: 15px; + background-color: var(--bg3); + border: 1px solid var(--border); +} + + + +/* File: resources/css/components/settings.css */ +.\$settings-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 240px; + background-color: var(--bg2); + border: 1px solid var(--border); + box-shadow: 0 8px 24px rgba(0,0,0,0.5); + z-index: 2000; +} + +.\$settings-header { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background-color: var(--bg3); + font-weight: 600; + font-size: 0.9rem; + color: var(--text); +} + +.\$settings-section { + padding: 12px 16px; +} + +.\$settings-section-title { + display: flex; + align-items: center; + gap: 7px; + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.7px; + color: var(--text2); + margin-bottom: 10px; +} + +.\$settings-separator { + border-top: 1px solid var(--border); +} + +.\$settings-themes { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.\$settings-theme-btn { + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text); + transition: transform 0.15s, border-color 0.15s; + padding: 0; + + &:hover { + transform: scale(1.15); + } + + .\$active { + border-color: var(--text); + transform: scale(1.1); + } +} + +.\$settings-theme-toggle { + width: 100%; + background-color: var(--bg3); + border: 1px solid var(--border); + color: var(--text); + padding: 8px 12px; + cursor: pointer; + font-family: var(--typography); + font-size: 0.88rem; + transition: background-color 0.1s; + text-align: left; + + &:hover { + background-color: var(--bg4); + } +} + +.\$settings-theme-toggle-inner { + display: flex; + align-items: center; + gap: 8px; +} + +.\$settings-theme-toggle-badge { + margin-left: auto; + background-color: var(--rhpz-orange); + color: #111; + font-size: 0.65rem; + font-weight: 700; + padding: 2px 6px; +} + +.\$settings-perpage { + display: flex; + gap: 6px; +} + +.\$settings-perpage-btn { + flex: 1; + padding: 6px 4px; + background-color: var(--bg3); + border: 1px solid var(--border); + color: var(--text2); + font-size: 0.85rem; + cursor: pointer; + font-family: var(--typography); + transition: all 0.1s; + + &:hover { + background-color: var(--bg4); + color: var(--text); + } + + .\$active { + background-color: var(--rhpz-orange); + border-color: var(--rhpz-orange); + color: var(--text3); + font-weight: 600; + } +} + +.\$settings-link { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 10px; + color: var(--text); + text-decoration: none; + font-size: 0.88rem; + transition: background-color 0.1s; + border: 1px solid transparent; + + &:hover { + background-color: var(--bg3); + border-color: var(--border); + text-decoration: none; + } +} + +.\$settings-link--danger { + color: var(--error); + + &:hover { + background-color: rgba(229, 115, 115, 0.08); + border-color: rgba(229, 115, 115, 0.3); + } +} + + /* File: resources/css/layout/content.css */ #main-wrapper { flex-grow: 1; @@ -965,7 +2198,15 @@ ul { } } + .\$topbar-actions { + display: flex; + gap: 8px; + } + .\$vertical-separator { + align-items: center; + border-left: 1px solid var(--border); + } } @@ -979,7 +2220,7 @@ ul { /* File: resources/css/layout/entry.css */ -#entry-container { +#entry-container, #comments-section, #reviews-section { background-color: var(--bg2); border: 1px solid var(--border); display: flex; @@ -1087,6 +2328,7 @@ ul { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 15px; + margin-bottom: 30px; } .\$entry-gallery-item { @@ -1097,10 +2339,81 @@ ul { align-items: center; justify-content: center; cursor: pointer; + overflow: hidden; transition: border-color 0.2s; &:hover { border-color: var(--rhpz-orange); + + img { + transform: scale(1.05); + } + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s ease; + } + } +} + +.\$gallery-modal { + position: fixed; + z-index: 3000; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + + .\$gallery-modal-content { + max-width: 90%; + max-height: 90%; + position: relative; + + img { + max-width: 100%; + max-height: 90vh; + object-fit: contain; + border: 2px solid var(--border); + background-color: var(--bg); + box-shadow: 0 10px 25px rgba(0,0,0,0.5); + } + } + + .\$gallery-modal-close { + position: absolute; + top: 20px; + right: 30px; + color: #fff; + font-size: 40px; + font-weight: bold; + cursor: pointer; + user-select: none; + + &:hover { + color: var(--rhpz-orange); + } + } + + .\$gallery-modal-video { + width: 90%; + max-width: 960px; + aspect-ratio: 16/9; + box-shadow: 0 10px 30px rgba(0,0,0,0.6); + border: 1px solid var(--border); + background-color: #000; + + iframe { + width: 100%; + height: 100%; + border: none; + display: block; } } } @@ -1110,6 +2423,186 @@ ul { color: var( --text2 ); } +.\$comment-block { + display: flex; + gap: 16px; + padding: 20px 0; + border-bottom: 1px solid var(--border); + + &:last-child { + border-bottom: none; + } + + .\$comment-avatar { + flex-shrink: 0; + width: 48px; + height: 48px; + border-radius: 50%; + overflow: hidden; + background-color: var(--bg4); + border: 1px solid var(--border); + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + } + + .\$comment-content { + flex: 1; + min-width: 0; + + .\$comment-meta { + font-size: 0.88rem; + color: var(--text2); + margin-bottom: 6px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + + .\$comment-author { + font-weight: 600; + color: var(--text); + text-decoration: none; + transition: color 0.2s; + + &:hover { + color: var(--rhpz-orange); + } + } + + .\$comment-date { + color: var(--text2); + } + + .\$comment-separator { + color: var(--border); + user-select: none; + } + } + + .\$comment-body { + font-size: 0.95rem; + color: var(--text); + line-height: 1.5; + word-wrap: break-word; + + p { + margin-bottom: 10px; + &:last-child { margin-bottom: 0; } + } + + a { + color: var(--rhpz-orange); + &:hover { + color: var(--rhpz-orange-hover); + text-decoration: underline; + } + } + + blockquote, .\$bbCodeBlock-blockquote { + background-color: var(--bg); + border-left: 3px solid var(--info); + padding: 12px 16px; + margin: 12px 0; + font-style: italic; + color: var(--text2); + } + + code { + font-family: monospace; + background-color: var(--bg3); + border: 1px solid var(--border); + padding: 2px 5px; + font-size: 0.9rem; + } + } + } +} + +.\$comments-empty { + text-align: center; + padding: 40px 20px; + color: var(--text2); + font-style: italic; + background-color: var(--bg); + border: 1px dashed var(--border); +} + +.\$entry-video-section { + margin-bottom: 30px; +} + +.\$video-thumbnail-wrapper { + position: relative; + width: 100%; + max-width: 500px; + aspect-ratio: 16/9; + background-color: #000; + border: 1px solid var(--border); + cursor: pointer; + overflow: hidden; + border-radius: 4px; + + img { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.8; + transition: transform 0.3s ease, opacity 0.3s ease; + } + + .\$play-trigger { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 65px; + height: 65px; + background-color: rgba(0, 0, 0, 0.7); + border: 2px solid #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 1.5rem; + transition: background-color 0.2s, transform 0.2s ease-out; + + i { margin-left: 4px; } + } + + &:hover { + img { + transform: scale(1.03); + opacity: 1; + } + .\$play-trigger { + background-color: var(--rhpz-orange); + border-color: var(--rhpz-orange); + transform: translate(-50%, -50%) scale(1.1); + box-shadow: 0 0 15px rgba(255, 115, 0, 0.5); + } + } +} + +.\$entry-submission-byline { + font-size: 0.85rem; + color: var(--text2); + margin-bottom: 20px; + display: flex; + align-items: center; + gap: 15px; + + i { + vertical-align: middle; + margin-right: 4px; + } +} + /* File: resources/css/layout/menu.css */ #menu { @@ -1292,3 +2785,10 @@ ul { } } + +/* File: resources/css/xenforo.css */ +.\$xf-menu-user-avatar-fix { + width: 40px !important; + height: 40px !important; +} + diff --git a/package-lock.json b/package-lock.json index c327f1c..4625da6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "easymde": "^2.21.0", + "js-cookie": "^3.0.7", "lucide": "^1.14.0" }, "devDependencies": { @@ -971,6 +972,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-cookie": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz", + "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/laravel-vite-plugin": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.0.tgz", diff --git a/package.json b/package.json index 41aaf9b..b5f4387 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "easymde": "^2.21.0", + "js-cookie": "^3.0.7", "lucide": "^1.14.0" } } diff --git a/public/ZELDA.ips b/public/ZELDA.ips new file mode 100644 index 0000000..53748da Binary files /dev/null and b/public/ZELDA.ips differ diff --git a/public/rom-patcher-js/RomPatcher.js b/public/rom-patcher-js/RomPatcher.js new file mode 100644 index 0000000..6dc2985 --- /dev/null +++ b/public/rom-patcher-js/RomPatcher.js @@ -0,0 +1,412 @@ +/* +* Rom Patcher JS core +* A ROM patcher/builder made in JavaScript, can be implemented as a webapp or a Node.JS CLI tool +* By Marc Robledo https://www.marcrobledo.com +* Sourcecode: https://github.com/marcrobledo/RomPatcher.js +* License: +* +* MIT License +* +* Copyright (c) 2016-2025 Marc Robledo +* +* Permission is hereby granted, free of charge, to any person obtaining a copy +* of this software and associated documentation files (the "Software"), to deal +* in the Software without restriction, including without limitation the rights +* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +* copies of the Software, and to permit persons to whom the Software is +* furnished to do so, subject to the following conditions: +* +* The above copyright notice and this permission notice shall be included in all +* copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +* SOFTWARE. +*/ + +const RomPatcher = (function () { + const TOO_BIG_ROM_SIZE = 67108863; + + const HEADERS_INFO = [ + { extensions: ['nes'], size: 16, romSizeMultiple: 1024, name: 'iNES' }, /* https://www.nesdev.org/wiki/INES */ + { extensions: ['fds'], size: 16, romSizeMultiple: 65500, name: 'fwNES' }, /* https://www.nesdev.org/wiki/FDS_file_format */ + { extensions: ['lnx'], size: 64, romSizeMultiple: 1024, name: 'LNX' }, + { extensions: ['sfc', 'smc', 'swc', 'fig'], size: 512, romSizeMultiple: 262144, name: 'SNES copier' }, + ]; + + const GAME_BOY_NINTENDO_LOGO = [ + 0xce, 0xed, 0x66, 0x66, 0xcc, 0x0d, 0x00, 0x0b, 0x03, 0x73, 0x00, 0x83, 0x00, 0x0c, 0x00, 0x0d, + 0x00, 0x08, 0x11, 0x1f, 0x88, 0x89, 0x00, 0x0e, 0xdc, 0xcc, 0x6e, 0xe6, 0xdd, 0xdd, 0xd9, 0x99 + ]; + + + + const _getRomSystem = function (binFile) { + /* to-do: add more systems */ + const extension = binFile.getExtension().trim(); + if (binFile.fileSize > 0x0200 && binFile.fileSize % 4 === 0) { + if ((extension === 'gb' || extension === 'gbc') && binFile.fileSize % 0x4000 === 0) { + binFile.seek(0x0104); + var valid = true; + for (var i = 0; i < GAME_BOY_NINTENDO_LOGO.length && valid; i++) { + if (GAME_BOY_NINTENDO_LOGO[i] !== binFile.readU8()) + valid = false; + } + if (valid) + return 'gb'; + } else if (extension === 'md' || extension === 'bin') { + binFile.seek(0x0100); + if (/SEGA (GENESIS|MEGA DR)/.test(binFile.readString(12))) + return 'smd'; + } else if (extension === 'z64' && binFile.fileSize >= 0x400000) { + return 'n64' + } + } else if (extension === 'fds' && binFile.fileSize % 65500 === 0) { + return 'fds' + } + return null; + } + const _getRomAdditionalChecksum = function (binFile) { + /* to-do: add more systems */ + const romSystem = _getRomSystem(binFile); + if (romSystem === 'n64') { + binFile.seek(0x3c); + const cartId = binFile.readString(3); + + binFile.seek(0x10); + const crc = binFile.readBytes(8).reduce(function (hex, b) { + if (b < 16) + return hex + '0' + b.toString(16); + else + return hex + b.toString(16); + }, ''); + return cartId + ' (' + crc + ')'; + } + return null; + } + + return { + parsePatchFile: function (patchFile) { + if (!(patchFile instanceof BinFile)) + throw new Error('Patch file is not an instance of BinFile'); + + patchFile.littleEndian = false; + patchFile.seek(0); + + var header = patchFile.readString(8); + var patch = null; + if (header.startsWith(IPS.MAGIC)) { + patch = IPS.fromFile(patchFile); + } else if (header.startsWith(UPS.MAGIC)) { + patch = UPS.fromFile(patchFile); + } else if (header.startsWith(APS.MAGIC)) { + patch = APS.fromFile(patchFile); + } else if (header.startsWith(APSGBA.MAGIC)) { + patch = APSGBA.fromFile(patchFile); + } else if (header.startsWith(BPS.MAGIC)) { + patch = BPS.fromFile(patchFile); + } else if (header.startsWith(RUP.MAGIC)) { + patch = RUP.fromFile(patchFile); + } else if (header.startsWith(PPF.MAGIC)) { + patch = PPF.fromFile(patchFile); + } else if (header.startsWith(BDF.MAGIC)) { + patch = BDF.fromFile(patchFile); + } else if (header.startsWith(PMSR.MAGIC)) { + patch = PMSR.fromFile(patchFile); + } else if (header.startsWith(VCDIFF.MAGIC)) { + patch = VCDIFF.fromFile(patchFile); + } + + if (patch) + patch._originalPatchFile = patchFile; + + return patch; + }, + + validateRom: function (romFile, patch, skipHeaderSize) { + if (!(romFile instanceof BinFile)) + throw new Error('ROM file is not an instance of BinFile'); + else if (typeof patch !== 'object') + throw new Error('Unknown patch format'); + + + if (typeof skipHeaderSize !== 'number' || skipHeaderSize < 0) + skipHeaderSize = 0; + + if ( + typeof patch.validateSource === 'function' && !patch.validateSource(romFile, skipHeaderSize) + ) { + return false; + } + return true; + }, + + applyPatch: function (romFile, patch, optionsParam) { + if (!(romFile instanceof BinFile)) + throw new Error('ROM file is not an instance of BinFile'); + else if (typeof patch !== 'object') + throw new Error('Unknown patch format'); + + + const options = { + requireValidation: false, + removeHeader: false, + addHeader: false, + fixChecksum: false, + outputSuffix: true + }; + if (typeof optionsParam === 'object') { + if (typeof optionsParam.requireValidation !== 'undefined') + options.requireValidation = !!optionsParam.requireValidation; + if (typeof optionsParam.removeHeader !== 'undefined') + options.removeHeader = !!optionsParam.removeHeader; + if (typeof optionsParam.addHeader !== 'undefined') + options.addHeader = !!optionsParam.addHeader; + if (typeof optionsParam.fixChecksum !== 'undefined') + options.fixChecksum = !!optionsParam.fixChecksum; + if (typeof optionsParam.outputSuffix !== 'undefined') + options.outputSuffix = !!optionsParam.outputSuffix; + } + + var extractedHeader = false; + var fakeHeaderSize = 0; + if (options.removeHeader) { + const headerInfo = RomPatcher.isRomHeadered(romFile); + if (headerInfo) { + const splitData = RomPatcher.removeHeader(romFile); + extractedHeader = splitData.header; + romFile = splitData.rom; + } + } else if (options.addHeader) { + const headerInfo = RomPatcher.canRomGetHeader(romFile); + if (headerInfo) { + fakeHeaderSize = headerInfo.fileSize; + romFile = RomPatcher.addFakeHeader(romFile); + } + } + + if (options.requireValidation && !RomPatcher.validateRom(romFile, patch)) { + throw new Error('Invalid input ROM checksum'); + } + + var patchedRom = patch.apply(romFile); + if (extractedHeader) { + /* reinsert header */ + if (options.fixChecksum) + RomPatcher.fixRomHeaderChecksum(patchedRom); + + const patchedRomWithHeader = new BinFile(extractedHeader.fileSize + patchedRom.fileSize); + patchedRomWithHeader.fileName = patchedRom.fileName; + patchedRomWithHeader.fileType = patchedRom.fileType; + extractedHeader.copyTo(patchedRomWithHeader, 0, extractedHeader.fileSize); + patchedRom.copyTo(patchedRomWithHeader, 0, patchedRom.fileSize, extractedHeader.fileSize); + + patchedRom = patchedRomWithHeader; + } else if (fakeHeaderSize) { + /* remove fake header */ + const patchedRomWithoutFakeHeader = patchedRom.slice(fakeHeaderSize); + + if (options.fixChecksum) + RomPatcher.fixRomHeaderChecksum(patchedRomWithoutFakeHeader); + + patchedRom = patchedRomWithoutFakeHeader; + + } else if (options.fixChecksum) { + RomPatcher.fixRomHeaderChecksum(patchedRom); + } + + if (options.outputSuffix) { + patchedRom.fileName = romFile.fileName.replace(/\.([^\.]*?)$/, ' (patched).$1'); + if(patchedRom.unpatched) + patchedRom.fileName = patchedRom.fileName.replace(' (patched)', ' (unpatched)'); + } else if (patch._originalPatchFile) { + patchedRom.fileName = patch._originalPatchFile.fileName.replace(/\.\w+$/i, (/\.\w+$/i.test(romFile.fileName) ? romFile.fileName.match(/\.\w+$/i)[0] : '')); + } else { + patchedRom.fileName = romFile.fileName; + } + + return patchedRom; + }, + + createPatch: function (originalFile, modifiedFile, format, metadata) { + if (!(originalFile instanceof BinFile)) + throw new Error('Original ROM file is not an instance of BinFile'); + else if (!(modifiedFile instanceof BinFile)) + throw new Error('Modified ROM file is not an instance of BinFile'); + + if (typeof format === 'string') + format = format.trim().toLowerCase(); + else if (typeof format === 'undefined') + format = 'ips'; + + var patch; + if (format === 'ips') { + patch = IPS.buildFromRoms(originalFile, modifiedFile); + } else if (format === 'bps') { + patch = BPS.buildFromRoms(originalFile, modifiedFile, (originalFile.fileSize <= 4194304)); + } else if (format === 'ppf') { + patch = PPF.buildFromRoms(originalFile, modifiedFile); + } else if (format === 'ups') { + patch = UPS.buildFromRoms(originalFile, modifiedFile); + } else if (format === 'aps') { + patch = APS.buildFromRoms(originalFile, modifiedFile); + } else if (format === 'rup') { + patch = RUP.buildFromRoms(originalFile, modifiedFile, metadata && metadata.Description? metadata.Description : null); + } else if (format === 'ebp') { + patch = IPS.buildFromRoms(originalFile, modifiedFile, metadata); + } else { + throw new Error('Invalid patch format'); + } + + if ( + !(format === 'ppf' && originalFile.fileSize > modifiedFile.fileSize) && //skip verification if PPF and PPF+modified size>original size + modifiedFile.hashCRC32() !== patch.apply(originalFile).hashCRC32() + ) { + //throw new Error('Unexpected error: verification failed. Patched file and modified file mismatch. Please report this bug.'); + } + return patch; + }, + + + /* check if ROM can inject a fake header (for patches that require a headered ROM) */ + canRomGetHeader: function (romFile) { + if (romFile.fileSize <= 0x600000) { + const compatibleHeader = HEADERS_INFO.find(headerInfo => headerInfo.extensions.indexOf(romFile.getExtension()) !== -1 && romFile.fileSize % headerInfo.romSizeMultiple === 0); + if (compatibleHeader) { + return { + name: compatibleHeader.name, + size: compatibleHeader.size + }; + } + } + return null; + }, + + /* check if ROM has a known header */ + isRomHeadered: function (romFile) { + if (romFile.fileSize <= 0x600200 && romFile.fileSize % 1024 !== 0) { + const compatibleHeader = HEADERS_INFO.find(headerInfo => headerInfo.extensions.indexOf(romFile.getExtension()) !== -1 && (romFile.fileSize - headerInfo.size) % headerInfo.romSizeMultiple === 0); + if (compatibleHeader) { + return { + name: compatibleHeader.name, + size: compatibleHeader.size + }; + } + } + return null; + }, + + /* remove ROM header */ + removeHeader: function (romFile) { + const headerInfo = RomPatcher.isRomHeadered(romFile); + if (headerInfo) { + return { + header: romFile.slice(0, headerInfo.size), + rom: romFile.slice(headerInfo.size) + } + } + return null; + }, + + /* add fake ROM header */ + addFakeHeader: function (romFile) { + const headerInfo = RomPatcher.canRomGetHeader(romFile); + if (headerInfo) { + const romWithFakeHeader = new BinFile(headerInfo.size + romFile.fileSize); + romWithFakeHeader.fileName = romFile.fileName; + romWithFakeHeader.fileType = romFile.fileType; + romFile.copyTo(romWithFakeHeader, 0, romFile.fileSize, headerInfo.size); + + //add a correct FDS header + if (_getRomSystem(romWithFakeHeader) === 'fds') { + romWithFakeHeader.seek(0); + romWithFakeHeader.writeBytes([0x46, 0x44, 0x53, 0x1a, romFile.fileSize / 65500]); + } + + romWithFakeHeader.fakeHeader = true; + + return romWithFakeHeader; + } + return null; + }, + + /* get ROM internal checksum, if possible */ + fixRomHeaderChecksum: function (romFile) { + const romSystem = _getRomSystem(romFile); + + if (romSystem === 'gb') { + /* get current checksum */ + romFile.seek(0x014d); + const currentChecksum = romFile.readU8(); + + /* calculate checksum */ + var newChecksum = 0x00; + romFile.seek(0x0134); + for (var i = 0; i <= 0x18; i++) { + newChecksum = ((newChecksum - romFile.readU8() - 1) >>> 0) & 0xff; + } + + /* fix checksum */ + if (currentChecksum !== newChecksum) { + console.log('fixed Game Boy checksum'); + romFile.seek(0x014d); + romFile.writeU8(newChecksum); + return true; + } + + } else if (romSystem === 'smd') { + /* get current checksum */ + romFile.seek(0x018e); + const currentChecksum = romFile.readU16(); + + /* calculate checksum */ + var newChecksum = 0x0000; + romFile.seek(0x0200); + while (!romFile.isEOF()) { + newChecksum = ((newChecksum + romFile.readU16()) >>> 0) & 0xffff; + } + + /* fix checksum */ + if (currentChecksum !== newChecksum) { + console.log('fixed Megadrive/Genesis checksum'); + romFile.seek(0x018e); + romFile.writeU16(newChecksum); + return true; + } + } + + return false; + }, + + /* get ROM additional checksum info, if possible */ + getRomAdditionalChecksum: function (romFile) { + return _getRomAdditionalChecksum(romFile); + }, + + + /* check if ROM is too big */ + isRomTooBig: function (romFile) { + return romFile && romFile.fileSize > TOO_BIG_ROM_SIZE; + } + } +}()); + + +if (typeof module !== 'undefined' && module.exports) { + module.exports = RomPatcher; + + IPS = require('./modules/RomPatcher.format.ips'); + UPS = require('./modules/RomPatcher.format.ups'); + APS = require('./modules/RomPatcher.format.aps_n64'); + APSGBA = require('./modules/RomPatcher.format.aps_gba'); + BPS = require('./modules/RomPatcher.format.bps'); + RUP = require('./modules/RomPatcher.format.rup'); + PPF = require('./modules/RomPatcher.format.ppf'); + BDF = require('./modules/RomPatcher.format.bdf'); + PMSR = require('./modules/RomPatcher.format.pmsr'); + VCDIFF = require('./modules/RomPatcher.format.vcdiff'); +} \ No newline at end of file diff --git a/public/rom-patcher-js/RomPatcher.webapp.js b/public/rom-patcher-js/RomPatcher.webapp.js new file mode 100644 index 0000000..6e2fedd --- /dev/null +++ b/public/rom-patcher-js/RomPatcher.webapp.js @@ -0,0 +1,2167 @@ +/* +* Rom Patcher JS - Webapp implementation +* A web implementation for Rom Patcher JS +* By Marc Robledo https://www.marcrobledo.com +* Sourcecode: https://github.com/marcrobledo/RomPatcher.js +* License: +* +* MIT License +* +* Copyright (c) 2016-2025 Marc Robledo +* +* Permission is hereby granted, free of charge, to any person obtaining a copy +* of this software and associated documentation files (the "Software"), to deal +* in the Software without restriction, including without limitation the rights +* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +* copies of the Software, and to permit persons to whom the Software is +* furnished to do so, subject to the following conditions: +* +* The above copyright notice and this permission notice shall be included in all +* copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +* SOFTWARE. +*/ + + +/* + to-do list: + - allow multiple instances of RomPatcherWeb? + - switch to ES6 classes and modules? +*/ + +const ROM_PATCHER_JS_PATH = './rom-patcher-js/'; + +const RomPatcherWeb = (function () { + const SCRIPT_DEPENDENCIES = [ + 'modules/BinFile.js', + 'modules/HashCalculator.js', + 'modules/RomPatcher.format.ips.js', + 'modules/RomPatcher.format.ups.js', + 'modules/RomPatcher.format.aps_n64.js', + 'modules/RomPatcher.format.aps_gba.js', + 'modules/RomPatcher.format.bps.js', + 'modules/RomPatcher.format.rup.js', + 'modules/RomPatcher.format.ppf.js', + 'modules/RomPatcher.format.bdf.js', + 'modules/RomPatcher.format.pmsr.js', + 'modules/RomPatcher.format.vcdiff.js', + 'modules/zip.js/zip.min.js', + 'RomPatcher.js' + ]; + + const WEB_CRYPTO_AVAILABLE = window.crypto && window.crypto.subtle && window.crypto.subtle.digest; + const settings = { + language: typeof navigator.language === 'string' ? navigator.language.substring(0, 2) : 'en', + outputSuffix: true, + fixChecksum: false, + requireValidation: false, + + allowDropFiles: false, + + oninitialize: null, + onloadrom: null, + onvalidaterom: null, + onloadpatch: null, + onpatch: null + }; + var romFile, patch; + + const isBrowserSafari = /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent); /* Safari userAgent does not include word Chrome, Chrome includes both! */ + const isBrowserMobile = /Mobile(\/\S+)? /.test(navigator.userAgent); + + /* embeded patches */ + var currentEmbededPatches = null; + const _parseEmbededPatchInfo = function (embededPatchInfo) { + const parsedPatch = { + file: embededPatchInfo.file.trim(), + name: null, + description: null, + outputName: null, + outputExtension: null, + patches: null + }; + + if (typeof embededPatchInfo.name === 'string') { + parsedPatch.name = embededPatchInfo.name.trim(); + } else { + parsedPatch.name = embededPatchInfo.file.replace(/(.*?\/)+/g, ''); + } + + if (typeof embededPatchInfo.description === 'string') { + parsedPatch.description = embededPatchInfo.description; + } + + parsedPatch.optional = !!embededPatchInfo.optional; + + if (typeof embededPatchInfo.outputName === 'string') { + parsedPatch.outputName = embededPatchInfo.outputName; + } + if (typeof embededPatchInfo.outputExtension === 'string') { + parsedPatch.outputExtension = embededPatchInfo.outputExtension; + } + + if (typeof embededPatchInfo.inputMd5 !== 'undefined') { + if (!Array.isArray(embededPatchInfo.inputMd5)) + embededPatchInfo.inputMd5 = [embededPatchInfo.inputMd5]; + + const validMd5s = embededPatchInfo.inputMd5.reduce(function (acc, md5) { + if (typeof md5 === 'string' && /^(0x)?[0-9a-fA-F]{32}$/i.test(md5.trim())) { + acc.push(md5.trim().toLowerCase()); + } else { + console.warn('Rom Patcher JS: inputMd5 must be a string (32 characters long, characters allowed: 0-9, a-f)'); + } + return acc; + }, []); + + if (validMd5s.length) { + parsedPatch.inputValidation = { + 'type': 'MD5', + 'value': validMd5s + }; + } else { + console.warn('Rom Patcher JS: invalid inputMd5 for embeded patch', embededPatchInfo.inputMd5); + } + } + if (typeof embededPatchInfo.inputCrc32 !== 'undefined') { + if (!parsedPatch.inputValidation) { + if (!Array.isArray(embededPatchInfo.inputCrc32)) + embededPatchInfo.inputCrc32 = [embededPatchInfo.inputCrc32]; + + const validCrcs = embededPatchInfo.inputCrc32.reduce(function (acc, crc32) { + if (typeof crc32 === 'string' && /^(0x)?[0-9a-fA-F]{8}$/i.test(crc32.trim())) { + acc.push(parseInt(crc32.trim().replace('0x', ''), 16)); + } else if (typeof crc32 === 'number') { + acc.push((crc32 >>> 0) & 0xffffffff); + } else { + console.warn('Rom Patcher JS: invalid inputCrc32 value'); + } + return acc; + }, []); + + if (validCrcs.length) { + parsedPatch.inputValidation = { + 'type': 'CRC32', + 'value': validCrcs + }; + } else { + console.warn('Rom Patcher JS: invalid inputCrc32 for embeded patch', embededPatchInfo.inputCrc32); + } + } else { + console.warn('Rom Patcher JS: a valid inputMd5 was provided, inputCrc32 will be ignored', embededPatchInfo); + } + } + + return parsedPatch; + } + var isFetching = false; + const _fetchPatchFile = function (embededPatchInfo) { + if (isFetching) + throw new Error('Rom Patcher JS: already fetching another file'); + isFetching = true; + + htmlElements.disableAll(); + + const spinnerHtml = ''; + + htmlElements.hide('select-patch'); + htmlElements.empty('select-patch'); + htmlElements.removeClass('select-patch', 'single'); + htmlElements.removeClass('select-patch', 'multiple'); + htmlElements.setEnabled('select-patch', false); + htmlElements.empty('container-optional-patches'); + htmlElements.setText('span-loading-embeded-patch', _('Downloading...') + ' ' + spinnerHtml); + htmlElements.show('span-loading-embeded-patch'); + + + fetch(decodeURI(embededPatchInfo.file)) + .then(result => result.arrayBuffer()) // Gets the response and returns it as a blob + .then(arrayBuffer => { + const fetchedFile = new BinFile(arrayBuffer); + if (ZIPManager.isZipFile(fetchedFile)) { + if (typeof embededPatchInfo.patches === 'object') { + if (Array.isArray(embededPatchInfo.patches)) { + currentEmbededPatches = embededPatchInfo.patches.map((embededPatchInfo) => _parseEmbededPatchInfo(embededPatchInfo)); + } else { + console.warn('Rom Patcher JS: Invalid patches object for embeded patch', embededPatchInfo); + } + } else { + currentEmbededPatches = [_parseEmbededPatchInfo(embededPatchInfo)]; + } + htmlElements.setText('span-loading-embeded-patch', _('Unzipping...') + ' ' + spinnerHtml); + + ZIPManager.unzipEmbededPatches(arrayBuffer, currentEmbededPatches); + } else { + const parsedPatch = _parseEmbededPatchInfo(embededPatchInfo); + + currentEmbededPatches = [parsedPatch]; + const option = document.createElement('option'); + option.innerHTML = parsedPatch.name; + htmlElements.get('select-patch').appendChild(option); + htmlElements.setEnabled('select-patch', false); + htmlElements.addClass('select-patch', 'single'); + htmlElements.hide('span-loading-embeded-patch'); + htmlElements.show('select-patch'); + + fetchedFile.fileName = embededPatchInfo.file; + RomPatcherWeb.providePatchFile(fetchedFile); + } + isFetching = false; + }) + .catch(function (evt) { + isFetching = false; + _setToastError((_('Error downloading %s') + '
' + evt.message).replace('%s', embededPatchInfo.file.replace(/^.*[\/\\]/g, ''))); + }); + }; + const _getEmbededPatchInfo = function (fileName) { + if (currentEmbededPatches) + return currentEmbededPatches.find((embededPatchInfo) => embededPatchInfo.file === fileName); + return null; + } + + + + const _padZeroes = function (intVal, nBytes) { + var hexString = intVal.toString(16); + while (hexString.length < nBytes * 2) + hexString = '0' + hexString; + return hexString + } + + const _setElementsStatus = function (status, applyButtonStatus) { + htmlElements.setEnabled('input-file-rom', status); + htmlElements.setEnabled('input-file-patch', status); + if (htmlElements.get('select-patch')) { + htmlElements.setEnabled('select-patch', htmlElements.get('select-patch').children.length > 1 ? status : false); + } + htmlElements.setEnabled('checkbox-alter-header', status); + + if (romFile && patch && status) { + if (settings.requireValidation && typeof applyButtonStatus !== 'undefined') + status = !!applyButtonStatus; + htmlElements.setEnabled('button-apply', status); + } else { + htmlElements.setEnabled('button-apply', false); + } + }; + + const _setInputFileSpinner = function (inputFileId, status) { + const elementId = currentEmbededPatches && inputFileId === 'patch' ? ('select-' + inputFileId) : ('input-file-' + inputFileId); + const spinnerId = 'spinner-' + inputFileId; + + htmlElements.removeClass(elementId, 'empty'); + + + if (status) { + const spinner = document.createElement('spinner'); + spinner.id = 'rom-patcher-' + spinnerId; + spinner.className = 'rom-patcher-spinner'; + + const htmlInputFile = htmlElements.get(elementId); + if (htmlInputFile) { + if (elementId === 'select-patch') { + htmlInputFile.parentElement.insertBefore(spinner, htmlElements.get('span-loading-embeded-patch')); + } else { + htmlInputFile.parentElement.appendChild(spinner); + } + } + + htmlElements.addClass(elementId, 'loading'); + htmlElements.removeClass(elementId, 'valid'); + htmlElements.removeClass(elementId, 'invalid'); + + return spinner; + } else { + const spinner = htmlElements.get(spinnerId); + if (spinner) + spinner.parentElement.removeChild(spinner); + htmlElements.removeClass(elementId, 'loading'); + + return spinner; + } + } + const _setRomInputSpinner = function (status) { + return _setInputFileSpinner('rom', status); + } + const _setPatchInputSpinner = function (status) { + return _setInputFileSpinner('patch', status); + } + const _setApplyButtonSpinner = function (status) { + if (status) { + htmlElements.setText('button-apply', ' ' + _('Applying patch...')); + } else { + htmlElements.setText('button-apply', _('Apply patch')); + } + } + const _setToastError = function (errorMessage, className) { + const row = htmlElements.get('row-error-message'); + const span = htmlElements.get('error-message'); + if (row && span) { + if (errorMessage) { + htmlElements.addClass('row-error-message', 'show'); + htmlElements.setText('error-message', errorMessage); + if (className === 'warning') + htmlElements.addClass('error-message', 'warning'); + else + htmlElements.removeClass('error-message', 'warning'); + } else { + htmlElements.removeClass('row-error-message', 'show'); + htmlElements.setText('error-message', ''); + + } + } else { + console.error('Rom Patcher JS: ' + errorMessage); + } + } + + const htmlElements = { + get: function (id) { + return document.getElementById('rom-patcher-' + id); + }, + + enableAll: function () { + _setElementsStatus(true); + }, + disableAll: function () { + _setElementsStatus(false); + }, + + show: function (id) { + if (document.getElementById('rom-patcher-' + id)) + document.getElementById('rom-patcher-' + id).style.display = 'block'; + }, + hide: function (id) { + if (document.getElementById('rom-patcher-' + id)) + document.getElementById('rom-patcher-' + id).style.display = 'none'; + }, + + setValue: function (id, val) { + if (document.getElementById('rom-patcher-' + id)) + document.getElementById('rom-patcher-' + id).value = val; + }, + getValue: function (id, val, fallback) { + if (document.getElementById('rom-patcher-' + id)) + return document.getElementById('rom-patcher-' + id).value; + return fallback || 0; + }, + setFakeFile: function (id, fileName) { + if (!isBrowserSafari && document.getElementById('rom-patcher-input-file-' + id)) { /* safari does not show fake file name: https://pqina.nl/blog/set-value-to-file-input/#but-safari */ + try { + /* add a fake file to the input file, so it shows the chosen file name */ + const fakeFile = new File(new Uint8Array(0), fileName); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(fakeFile); + document.getElementById('rom-patcher-input-file-' + id).files = dataTransfer.files; + } catch (ex) { + console.warning('File API constructor is not supported'); + } + } + }, + + setText: function (id, text) { + if (document.getElementById('rom-patcher-' + id)) + document.getElementById('rom-patcher-' + id).innerHTML = text; + }, + + empty: function (id) { + if (document.getElementById('rom-patcher-' + id)) + document.getElementById('rom-patcher-' + id).innerHTML = ''; + }, + addClass: function (id, className) { + if (document.getElementById('rom-patcher-' + id)) + document.getElementById('rom-patcher-' + id).classList.add(className); + }, + removeClass: function (id, className) { + if (document.getElementById('rom-patcher-' + id)) + document.getElementById('rom-patcher-' + id).classList.remove(className); + }, + setClass: function (id, className) { + if (document.getElementById('rom-patcher-' + id)) + document.getElementById('rom-patcher-' + id).className = className; + }, + + setEnabled: function (id, enabled) { + if (document.getElementById('rom-patcher-' + id)) + document.getElementById('rom-patcher-' + id).disabled = !enabled; + }, + setChecked: function (id, checked) { + if (document.getElementById('rom-patcher-' + id)) + document.getElementById('rom-patcher-' + id).checked = !!checked; + }, + isChecked: function (id) { + if (document.getElementById('rom-patcher-' + id)) + return document.getElementById('rom-patcher-' + id).checked; + return false; + }, + + setSpinner: function (inputFileId, status) { + if (inputFileId !== 'rom' && inputFileId !== 'patch') + throw new Error('RomPatcherWeb.htmlElements.setSpinner: only rom or patch input file ids are allowed'); + + return _setInputFileSpinner(inputFileId, status); + } + } + + + + /* web workers */ + const webWorkerApply = new Worker(ROM_PATCHER_JS_PATH + 'RomPatcher.webworker.apply.js'); + webWorkerApply.onmessage = event => { // listen for events from the worker + //retrieve arraybuffers back from webworker + romFile._u8array = event.data.romFileU8Array; + patch._originalPatchFile._u8array = event.data.patchFileU8Array; + + htmlElements.enableAll(); + _setApplyButtonSpinner(false); + if (event.data.patchedRomU8Array && !event.data.errorMessage) { + var patchedRom = new BinFile(event.data.patchedRomU8Array.buffer); + patchedRom.fileName = event.data.patchedRomFileName; + + if (currentEmbededPatches) { + const optionalPatches = currentEmbededPatches.filter((embededPatchInfo) => embededPatchInfo.optional); + if (optionalPatches.length) { + const originalFileName = patchedRom.fileName; + for (var i = 0; i < optionalPatches.length; i++) { + /* could be improved by using webWorkerApply to apply optional patches */ + if (optionalPatches[i].checkbox.checked) + patchedRom = RomPatcher.applyPatch(patchedRom, optionalPatches[i].parsedPatch, { requireValidation: false, fixChecksum: true }); + } + patchedRom.fileName = originalFileName; + } + } + + if (typeof settings.onpatch === 'function') + settings.onpatch(patchedRom); + + patchedRom.save(); + _setToastError(); + } else { + _setToastError(event.data.errorMessage); + } + }; + webWorkerApply.onerror = event => { // listen for exceptions from the worker + htmlElements.enableAll(); + _setApplyButtonSpinner(false); + _setToastError('webWorkerApply error: ' + event.message); + }; + + const webWorkerCrc = new Worker(ROM_PATCHER_JS_PATH + 'RomPatcher.webworker.crc.js'); + webWorkerCrc.onmessage = event => { // listen for events from the worker + //console.log('received_crc'); + htmlElements.setText('span-crc32', _padZeroes(event.data.crc32, 4)); + htmlElements.setText('span-md5', _padZeroes(event.data.md5, 16)); + romFile._u8array = event.data.u8array; + + if (WEB_CRYPTO_AVAILABLE) { + romFile.hashSHA1().then(function (res) { + htmlElements.setText('span-sha1', res); + }); + } + + if (event.data.rom) { + htmlElements.setText('span-rom-info', event.data.rom); + htmlElements.addClass('row-info-rom', 'show'); + } + + validRom = RomPatcherWeb.validateCurrentRom(event.data.checksumStartOffset); + _setElementsStatus(true, validRom); + }; + webWorkerCrc.onerror = event => { // listen for events from the worker + _setToastError('webWorkerCrc error: ' + event.message); + }; + + const _getChecksumStartOffset = function () { + if (romFile) { + const headerInfo = RomPatcher.isRomHeadered(romFile); + if (headerInfo) { + const htmlCheckboxAlterHeader = htmlElements.get('checkbox-alter-header'); + if (htmlCheckboxAlterHeader && htmlCheckboxAlterHeader.checked) + return headerInfo.size; + } + } + return 0; + } + + + const _getScriptPath = function () { + const currentScripts = document.querySelectorAll('script'); + var scriptPath; + if (document.currentScript) { + scriptPath = document.currentScript.src; + } else { + for (var i = 0; i < currentScripts.length; i++) { + if (currentScripts[i].src.indexOf('RomPatcher.webapp.js') !== -1) { + scriptPath = currentScripts[i].src; + break; + } + } + if (!scriptPath) + scriptPath = './rom-patcher-js/'; + } + return scriptPath.substring(0, scriptPath.lastIndexOf('/') + 1); + } + const _getMissingDependencies = function () { + const currentScripts = document.querySelectorAll('script'); + const scriptPath = _getScriptPath(); + var missingDependencies = []; + for (var i = 0; i < SCRIPT_DEPENDENCIES.length && !isLoaded; i++) { + var isLoaded = false; + for (var j = 0; j < currentScripts.length; j++) { + if (currentScripts[j].src === scriptPath + SCRIPT_DEPENDENCIES[i]) + isLoaded = true; + } + if (!isLoaded) + missingDependencies.push(scriptPath + SCRIPT_DEPENDENCIES[i]); + } + + return missingDependencies; + } + + + + const _checkEmbededPatchParameter = function (embededPatchInfo) { + if (embededPatchInfo) { + if (typeof embededPatchInfo === 'string') + embededPatchInfo = { file: embededPatchInfo }; + + if (typeof embededPatchInfo !== 'object') + throw new Error('Rom Patcher JS: invalid embeded patch parameter'); + else if (typeof embededPatchInfo.file !== 'string') + throw new Error('Rom Patcher JS: embeded patch missing file property'); + + return embededPatchInfo; + } + + return false; + } + + const _dragEventContainsFiles = function (evt) { + if (evt.dataTransfer.types) { + for (var i = 0; i < evt.dataTransfer.types.length; i++) { + if (evt.dataTransfer.types[i] === 'Files') + return true; + } + } + return false; + } + const _initialize = function (newSettings, embededPatchInfo) { + /* embeded patches */ + var validEmbededPatch = _checkEmbededPatchParameter(embededPatchInfo); + if (newSettings && typeof newSettings.file === 'string') { + console.warn('Rom Patcher JS: embeded patch info was provided in settings and will be ignored, must be passed as second parameter'); + } + + + + /* check if Rom Patcher JS core is available */ + if (typeof RomPatcher !== 'object') { + throw new Error('Rom Patcher JS: core not found'); + } + + + + /* check if zip-js web worker is available */ + if (typeof zip !== 'object' || typeof zip.useWebWorkers !== 'boolean') { + console.error('Rom Patcher JS: zip.js web worker not found'); + throw new Error('Rom Patcher JS: zip.js web worker not found'); + } + zip.useWebWorkers = true; + zip.workerScriptsPath = ROM_PATCHER_JS_PATH + 'modules/zip.js/'; + + /* check if all required HTML elements are in DOM */ + const htmlInputFileRom = htmlElements.get('input-file-rom'); + if (htmlInputFileRom && htmlInputFileRom.tagName === 'INPUT' && htmlInputFileRom.type === 'file') { + htmlInputFileRom.addEventListener('change', function (evt) { + if (this.files && this.files.length) { + htmlElements.disableAll(); + new BinFile(this, RomPatcherWeb.provideRomFile); + } else if (romFile) { + /* Webkit browsers trigger the change event when user cancels file selection and resets the input file value */ + /* since we keep a cached copy of ROM file as a BinFile, we do not lose data but the input text, so we try to set it back */ + /* Firefox keeps the previously selected file and does not trigger the change event */ + htmlElements.setFakeFile('rom', romFile.fileName); + } + }); + + if (!isBrowserSafari) + htmlInputFileRom.classList.add('no-file-selector-button'); + } else { + console.error('Rom Patcher JS: input#rom-patcher-input-file-rom[type=file] not found'); + throw new Error('Rom Patcher JS: input#rom-patcher-input-file-rom[type=file] not found'); + } + if (validEmbededPatch) { + const htmlSelectPatch = htmlElements.get('select-patch'); + if (htmlSelectPatch && htmlSelectPatch.tagName === 'SELECT') { + htmlSelectPatch.addEventListener('change', function (evt) { + const zippedEntryIndex = parseInt(this.value); + this._unzipSelectedPatch(zippedEntryIndex); + }); + } else { + console.error('Rom Patcher JS: select#rom-patcher-select-patch not found'); + throw new Error('Rom Patcher JS: select#rom-patcher-select-patch not found'); + } + const loadingSpan = document.createElement('span'); + loadingSpan.id = 'rom-patcher-span-loading-embeded-patch'; + loadingSpan.style.display = 'none'; + htmlSelectPatch.parentElement.appendChild(loadingSpan); + const containerOptionalPatches = document.createElement('div'); + containerOptionalPatches.id = 'rom-patcher-container-optional-patches'; + containerOptionalPatches.style.display = 'none'; + htmlSelectPatch.parentElement.appendChild(containerOptionalPatches); + } else { + const htmlInputFilePatch = htmlElements.get('input-file-patch'); + if (htmlInputFilePatch && htmlInputFilePatch.tagName === 'INPUT' && htmlInputFilePatch.type === 'file') { + htmlInputFilePatch.addEventListener('change', function (evt) { + if (this.files && this.files.length) { + htmlElements.disableAll(); + new BinFile(this, RomPatcherWeb.providePatchFile); + } else if (patch && patch._originalPatchFile) { + /* Webkit browsers trigger the change event when user cancels file selection and resets the input file value */ + /* since we keep a cached copy of patch file as a BinFile, we do not lose data but the input text, so we try to set it back */ + /* Firefox keeps the previously selected file and does not trigger the change event */ + htmlElements.setFakeFile('patch', patch._originalPatchFile.fileName); + } + }); + + if (!isBrowserSafari) + htmlInputFilePatch.classList.add('no-file-selector-button'); + } else { + console.error('Rom Patcher JS: input#rom-patcher-input-file-patch[type=file] not found'); + throw new Error('Rom Patcher JS: input#rom-patcher-input-file-patch[type=file] not found'); + } + + /* dirty fix for iOS Safari, which only supports mimetypes in accept attribute */ + /* accept attribute compatibility: https://caniuse.com/input-file-accept */ + if (isBrowserSafari && isBrowserMobile) { + htmlInputFilePatch.accept = 'application/zip, application/octet-stream, application/x-zip-compressed, multipart/x-zip'; + } + } + const htmlButtonApply = htmlElements.get('button-apply'); + if (htmlButtonApply && htmlButtonApply.tagName === 'BUTTON') { + htmlButtonApply.addEventListener('click', RomPatcherWeb.applyPatch); + } else { + console.error('Rom Patcher JS: button#rom-patcher-button-apply not found'); + throw new Error('Rom Patcher JS: button#rom-patcher-button-apply not found'); + } + const htmlCheckboxAlterHeader = htmlElements.get('checkbox-alter-header'); + if (htmlCheckboxAlterHeader && htmlCheckboxAlterHeader.tagName === 'INPUT' && htmlCheckboxAlterHeader.type === 'checkbox') { + htmlCheckboxAlterHeader.addEventListener('change', function (evt) { + if (!romFile) + return false; + + const headerInfo = RomPatcher.isRomHeadered(romFile); + if (headerInfo) { + htmlElements.disableAll(); + webWorkerCrc.postMessage({ u8array: romFile._u8array, fileName: romFile.fileName, checksumStartOffset: _getChecksumStartOffset() }, [romFile._u8array.buffer]); + } + }); + } + /* set all default input status just in case HTML is wrong */ + htmlElements.disableAll(); + /* reset input files */ + htmlElements.setValue('input-file-rom', ''); + htmlElements.setValue('input-file-patch', ''); + + + /* translatable elements */ + const translatableElements = document.querySelectorAll('*[data-localize="yes"]'); + for (var i = 0; i < translatableElements.length; i++) { + translatableElements[i].setAttribute('data-localize', translatableElements[i].innerHTML); + } + + /* add drag and drop events */ + if (newSettings && newSettings.allowDropFiles) { + window.addEventListener('dragover', function (evt) { + if (_dragEventContainsFiles(evt)) + evt.preventDefault(); /* needed ! */ + }); + window.addEventListener('drop', function (evt) { + evt.preventDefault(); + if (_dragEventContainsFiles(evt)) { + const droppedFiles = evt.dataTransfer.files; + if (droppedFiles && droppedFiles.length === 1) { + new BinFile(droppedFiles[0], function (binFile) { + if (RomPatcherWeb.getEmbededPatches()) { + RomPatcherWeb.provideRomFile(binFile, true); + } else if (ZIPManager.isZipFile(binFile)) { + ZIPManager.unzipAny(binFile._u8array.buffer); + } else if (RomPatcher.parsePatchFile(binFile)) { + RomPatcherWeb.providePatchFile(binFile, null, true); + } else { + RomPatcherWeb.provideRomFile(binFile, true); + } + }); + } + } + }); + htmlInputFileRom.addEventListener('drop', function (evt) { + evt.stopPropagation(); + }); + if (!validEmbededPatch) { + htmlElements.get('input-file-patch').addEventListener('drop', function (evt) { + evt.stopPropagation(); + }); + } + } + + console.log('Rom Patcher JS initialized'); + initialized = true; + + + /* initialize Rom Patcher */ + RomPatcherWeb.setSettings(newSettings); + + /* download embeded patch */ + if (validEmbededPatch) + _fetchPatchFile(validEmbededPatch); + else + htmlElements.enableAll(); + + if (typeof settings.oninitialize === 'function') + settings.oninitialize(this); + } + + + + /* localization */ + const _ = function (str) { return ROM_PATCHER_LOCALE[settings.language] && ROM_PATCHER_LOCALE[settings.language][str] ? ROM_PATCHER_LOCALE[settings.language][str] : str }; + + + var initialized = false; + var loading = 0; + return { + _: function (str) { /* public localization function for external usage purposes */ + return _(str); + }, + getHtmlElements: function () { + return htmlElements; + }, + + isInitialized: function () { + return initialized; + }, + getEmbededPatches: function () { + return currentEmbededPatches; + }, + + provideRomFile: function (binFile, transferFakeFile) { + htmlElements.disableAll(); + + + romFile = binFile; + + + + const canRomGetHeader = RomPatcher.canRomGetHeader(romFile); + const isRomHeadered = RomPatcher.isRomHeadered(romFile); + RomPatcherWeb.getHtmlElements().setChecked('checkbox-alter-header', false); + if (canRomGetHeader) { + RomPatcherWeb.getHtmlElements().setText('span-alter-header', _('Add %s header').replace('%s', _(canRomGetHeader.name))); + RomPatcherWeb.getHtmlElements().addClass('row-alter-header', 'show'); + } else if (isRomHeadered) { + RomPatcherWeb.getHtmlElements().setText('span-alter-header', _('Remove %s header').replace('%s', _(isRomHeadered.name))); + RomPatcherWeb.getHtmlElements().addClass('row-alter-header', 'show'); + } else { + RomPatcherWeb.getHtmlElements().setText('span-alter-header', ''); + RomPatcherWeb.getHtmlElements().removeClass('row-alter-header', 'show'); + } + + + romFile.seek(0); + + + _setRomInputSpinner(false); + if (ZIPManager.isZipFile(romFile)) { + ZIPManager.unzipRoms(romFile._u8array.buffer); + } else { + if (typeof settings.onloadrom === 'function') + settings.onloadrom(romFile); + RomPatcherWeb.calculateCurrentRomChecksums(); + } + + if (transferFakeFile) { + htmlElements.setFakeFile('rom', romFile.fileName); + } + }, + + providePatchFile: function (binFile, transferFakeFile) { + htmlElements.disableAll(); + + patch = null; + if (binFile) { + if (ZIPManager.isZipFile(binFile)) { + ZIPManager.unzipPatches(binFile._u8array.buffer); + } else { + try { + const parsedPatch = RomPatcher.parsePatchFile(binFile); + if (parsedPatch) { + patch = parsedPatch; + _setPatchInputSpinner(false); + + const embededPatchInfo = _getEmbededPatchInfo(binFile.fileName); + if (embededPatchInfo) { + /* custom input validation */ + if (embededPatchInfo.inputValidation) { + if (embededPatchInfo.inputValidation.type === 'CRC32') { + patch.validateSource = function (romFile, headerSize) { + for (var i = 0; i < embededPatchInfo.inputValidation.value.length; i++) { + if (embededPatchInfo.inputValidation.value[i] === romFile.hashCRC32(headerSize)) + return true; + } + return false; + } + } else if (embededPatchInfo.inputValidation.type === 'MD5') { + patch.validateSource = function (romFile, headerSize) { + for (var i = 0; i < embededPatchInfo.inputValidation.value.length; i++) { + if (embededPatchInfo.inputValidation.value[i] === romFile.hashMD5(headerSize)) + return true; + } + return false; + } + } else { + throw new Error('Rom Patcher JS: Invalid inputValidation type'); + } + patch.getValidationInfo = function () { + return embededPatchInfo.inputValidation + }; + } + + /* custom description */ + if (embededPatchInfo.description) { + patch.getDescription = function () { + return embededPatchInfo.description; + } + } + } + + /* toggle ROM requirements */ + if (htmlElements.get('row-patch-requirements') && htmlElements.get('patch-requirements-value')) { + if (typeof patch.getValidationInfo === 'function' && patch.getValidationInfo()) { + var validationInfo = patch.getValidationInfo(); + if (Array.isArray(validationInfo) || !validationInfo.type) { + validationInfo = { + type: 'ROM', + value: validationInfo + } + } + htmlElements.setText('patch-requirements-value', ''); + + htmlElements.setText('patch-requirements-type', validationInfo.type === 'ROM' ? _('Required ROM:') : _('Required %s:').replace('%s', validationInfo.type)); + + if (!Array.isArray(validationInfo.value)) + validationInfo.value = [validationInfo.value]; + + validationInfo.value.forEach(function (value) { + var line = document.createElement('div'); + if (typeof value !== 'string') { + if (validationInfo.type === 'CRC32') { + value = value.toString(16); + while (value.length < 8) + value = '0' + value; + } else { + value = value.toString(); + } + } + /* + var a=document.createElement('a'); + a.href='https://www.google.com/search?q=%22'+value+'%22'; + a.target='_blank'; + a.className='clickable'; + a.innerHTML=value; + line.appendChild(a); + */ + line.innerHTML = value; + htmlElements.get('patch-requirements-value').appendChild(line); + }); + htmlElements.addClass('row-patch-requirements', 'show'); + } else { + htmlElements.setText('patch-requirements-value', ''); + htmlElements.removeClass('row-patch-requirements', 'show'); + } + } + + /* toggle patch description */ + if (typeof patch.getDescription === 'function' && patch.getDescription()) { + htmlElements.setText('patch-description', patch.getDescription()/* .replace(/\n/g, '
') */); + //htmlElements.setTitle('patch-description', patch.getDescription()); + htmlElements.addClass('row-patch-description', 'show'); + } else { + htmlElements.setText('patch-description', ''); + //htmlElements.setTitle('patch-description', ''); + htmlElements.removeClass('row-patch-description', 'show'); + } + + RomPatcherWeb.validateCurrentRom(_getChecksumStartOffset()); + + if (typeof settings.onloadpatch === 'function') { + settings.onloadpatch(binFile, embededPatchInfo, parsedPatch); + } + + if (transferFakeFile) { + htmlElements.setFakeFile('patch', binFile.fileName); + } + } else { + _setToastError(_('Invalid patch file')); + } + } catch (ex) { + _setToastError(ex.message); + } + } + } + + if (patch) { + htmlElements.removeClass('input-file-patch', 'invalid'); + } else { + htmlElements.addClass('input-file-patch', 'invalid'); + } + htmlElements.enableAll(); + }, + + refreshRomFileName: function () { + if (romFile) + htmlElements.setFakeFile('rom', romFile.fileName); + }, + + pickEmbededFile: function (fileName) { + if (!currentEmbededPatches) + throw new Error('No embeded patches available'); + else if (typeof fileName !== 'string') + throw new Error('Invalid embeded patch file name'); + + const selectPatch = htmlElements.get('select-patch'); + for (var i = 0; i < selectPatch.children.length; i++) { + if (selectPatch.children[i].patchFileName === fileName) { + if (selectPatch.value != selectPatch.children[i].value) { + selectPatch.value = selectPatch.children[i].value; + + /* create and dispatch change event */ + const evt = new Event('change'); + selectPatch.dispatchEvent(evt); + } + break; + } + } + }, + + initialize: function (newSettings, embededPatchInfo) { + if (initialized) + throw new Error('Rom Patcher JS was already initialized'); + else if (loading) + throw new Error('Rom Patcher JS is already loading or has failed to load'); + + + /* check incompatible browsers */ + if ( + typeof window === 'undefined' || + typeof Worker !== 'function' || + typeof Array.isArray !== 'function' || + typeof window.addEventListener !== 'function' + // !document.createElement('div').classList instanceof DOMTokenList + ) + throw new Error('Rom Patcher JS: incompatible browser'); + + + /* queue script dependencies */ + const missingDependencies = _getMissingDependencies(); + loading = missingDependencies.length; + const onLoadScript = function () { + loading--; + if (loading === 0) { + try { + _initialize(newSettings, embededPatchInfo); + } catch (ex) { + _setToastError(ex.message); + htmlElements.disableAll(); + } + } + }; + const onErrorScript = function () { + throw new Error('Rom Patcher JS: error loading script ' + script.src); + }; + console.log('Rom Patcher JS: loading ' + missingDependencies.length + ' dependencies'); + missingDependencies.forEach(function (path) { + var script = document.createElement('script'); + script.onload = onLoadScript; + script.onerror = onErrorScript; + script.src = path; + + document.head.appendChild(script); + }); + }, + setEmbededPatches: function (embededPatchInfo) { + if (!currentEmbededPatches) + throw new Error('Rom Patcher JS: not in embeded patch mode'); + + /* embeded patches */ + var validEmbededPatch = _checkEmbededPatchParameter(embededPatchInfo); + if (!validEmbededPatch) + throw new Error('Rom Patcher JS: invalid embeded patch parameter'); + + _fetchPatchFile(validEmbededPatch); + }, + + applyPatch: function () { + if (romFile && patch) { + const romPatcherOptions = { + requireValidation: settings.requireValidation, + removeHeader: RomPatcher.isRomHeadered(romFile) && htmlElements.isChecked('checkbox-alter-header'), + addHeader: RomPatcher.canRomGetHeader(romFile) && htmlElements.isChecked('checkbox-alter-header'), + fixChecksum: settings.fixChecksum, + outputSuffix: settings.outputSuffix + }; + + htmlElements.disableAll(); + _setApplyButtonSpinner(true); + + const embededPatchInfo = _getEmbededPatchInfo(patch._originalPatchFile.fileName); + webWorkerApply.postMessage( + { + romFileU8Array: romFile._u8array, + patchFileU8Array: patch._originalPatchFile._u8array, + romFileName: romFile.fileName, + patchFileName: patch._originalPatchFile.fileName, + patchExtraInfo: embededPatchInfo, + //romFileType:romFile.fileType, + options: romPatcherOptions + }, + [ + romFile._u8array.buffer, + patch._originalPatchFile._u8array.buffer + ] + ); + } else if (!romFile) { + _setToastError(_('No ROM provided')); + } else if (!patch) { + _setToastError(_('No patch file provided')); + } + }, + + calculateCurrentRomChecksums: function (force) { + if (romFile.fileSize > 67108864 && !force) { + htmlElements.setText('span-crc32', _('File is too big.') + ' ' + _('Force calculate checksum') + ''); + htmlElements.setText('span-md5', ''); + htmlElements.setText('span-sha1', ''); + htmlElements.enableAll(); + return false; + } + + htmlElements.setText('span-crc32', _('Calculating...')); + htmlElements.setText('span-md5', _('Calculating...')); + if (WEB_CRYPTO_AVAILABLE) + htmlElements.setText('span-sha1', _('Calculating...')); + + htmlElements.setText('span-rom-info', ''); + htmlElements.removeClass('row-info-rom', 'show'); + + htmlElements.disableAll(); + webWorkerCrc.postMessage({ u8array: romFile._u8array, fileName: romFile.fileName, checksumStartOffset: _getChecksumStartOffset() }, [romFile._u8array.buffer]); + }, + + validateCurrentRom: function (checksumStartOffset) { + if (romFile && patch && typeof patch.validateSource === 'function') { + const validRom = RomPatcher.validateRom(romFile, patch, checksumStartOffset ?? 0); + if (validRom) { + htmlElements.addClass('input-file-rom', 'valid'); + htmlElements.removeClass('input-file-rom', 'invalid'); + _setToastError(); + } else { + htmlElements.addClass('input-file-rom', 'invalid'); + htmlElements.removeClass('input-file-rom', 'valid'); + _setToastError(_('Source ROM checksum mismatch')); + } + + if (typeof settings.onvalidaterom === 'function') + settings.onvalidaterom(romFile, validRom); + + return validRom; + } else { + htmlElements.removeClass('input-file-rom', 'valid'); + htmlElements.removeClass('input-file-rom', 'invalid'); + _setToastError(); + if (romFile && patch && typeof settings.onvalidaterom === 'function') + settings.onvalidaterom(romFile, true); + + return (romFile && patch); + } + }, + + enable: function () { + htmlElements.enableAll(); + }, + disable: function () { + htmlElements.disableAll(); + }, + setErrorMessage: function (message, className) { + _setToastError(message, className); + }, + translateUI: function (newLanguage) { + if (typeof newLanguage === 'object' && typeof newLanguage.language === 'string') + newLanguage = newLanguage.language; + if (typeof newLanguage === 'string') + settings.language = newLanguage; + + const translatableElements = document.querySelectorAll('*[data-localize]'); + for (var i = 0; i < translatableElements.length; i++) { + translatableElements[i].innerHTML = _(translatableElements[i].getAttribute('data-localize')); + } + }, + + getCurrentLanguage: function () { + return settings.language; + }, + setSettings: function (newSettings) { + if (newSettings && typeof newSettings === 'object') { + if (typeof newSettings.language === 'string') + settings.language = newSettings.language; + + if (typeof newSettings.outputSuffix === 'boolean') + settings.outputSuffix = newSettings.outputSuffix; + + if (typeof newSettings.fixChecksum === 'boolean') + settings.fixChecksum = newSettings.fixChecksum; + + if (typeof newSettings.requireValidation === 'boolean') + settings.requireValidation = newSettings.requireValidation; + + if (typeof newSettings.oninitialize === 'function') + settings.oninitialize = newSettings.oninitialize; + else if (typeof newSettings.oninitialize !== 'undefined') + settings.oninitialize = null; + + if (typeof newSettings.onloadrom === 'function') + settings.onloadrom = newSettings.onloadrom; + else if (typeof newSettings.onloadrom !== 'undefined') + settings.onloadrom = null; + + if (typeof newSettings.onvalidaterom === 'function') + settings.onvalidaterom = newSettings.onvalidaterom; + else if (typeof newSettings.onvalidaterom !== 'undefined') + settings.onvalidaterom = null; + + if (typeof newSettings.onloadpatch === 'function') + settings.onloadpatch = newSettings.onloadpatch; + else if (typeof newSettings.onloadpatch !== 'undefined') + settings.onloadpatch = null; + + if (typeof newSettings.onpatch === 'function') + settings.onpatch = newSettings.onpatch; + else if (typeof newSettings.onpatch !== 'undefined') + settings.onpatch = null; + } + RomPatcherWeb.translateUI(); + } + } +}()); + + + + +/* ZIP manager */ +const ZIPManager = (function (romPatcherWeb) { + const _ = romPatcherWeb._; + const htmlElements = romPatcherWeb.getHtmlElements(); + + const _setRomInputSpinner = function (status) { + htmlElements.setSpinner('rom', status); + }; + const _setPatchInputSpinner = function (status) { + htmlElements.setSpinner('patch', status); + + }; + + const ZIP_MAGIC = '\x50\x4b\x03\x04'; + + const FILTER_PATCHES = /\.(ips|ups|bps|aps|rup|ppf|ebp|bdf|bspatch|mod|xdelta|vcdiff)$/i; + //const FILTER_ROMS=/(? 1) { + _showFilePicker(filteredEntries, romPatcherWeb.provideRomFile); + romPatcherWeb.enable(); + } else { + /* no possible patchable files found in zip, treat zip file as ROM file */ + romPatcherWeb.calculateCurrentRomChecksums(); + } + }); + }, + /* failed */ + _unzipError + ); + }, + + unzipPatches: function (arrayBuffer) { + zip.createReader( + new zip.BlobReader(new Blob([arrayBuffer])), + /* success */ + function (zipReader) { + zipReader.getEntries(function (zipEntries) { + const filteredEntries = _filterEntriesPatches(zipEntries); + + if (filteredEntries.length === 1) { + _unzipEntry(filteredEntries[0], romPatcherWeb.providePatchFile); + } else if (filteredEntries.length > 1) { + _showFilePicker(filteredEntries, romPatcherWeb.providePatchFile); + } else { + romPatcherWeb.providePatchFile(null); + } + + }); + }, + /* failed */ + _unzipError + ); + }, + + unzipAny: function (arrayBuffer) { + zip.createReader( + new zip.BlobReader(new Blob([arrayBuffer])), + /* success */ + function (zipReader) { + zipReader.getEntries(function (zipEntries) { + const filteredEntriesRoms = _filterEntriesRoms(zipEntries); + const filteredEntriesPatches = _filterEntriesPatches(zipEntries); + + if (filteredEntriesRoms.length && filteredEntriesPatches.length === 0) { + if (filteredEntriesRoms.length === 1) { + _unzipEntry(filteredEntriesRoms[0], romPatcherWeb.provideRomFile); + } else { + _showFilePicker(filteredEntriesRoms, romPatcherWeb.provideRomFile); + romPatcherWeb.enable(); + } + } else if (filteredEntriesPatches.length && filteredEntriesRoms.length === 0) { + if (filteredEntriesPatches.length === 1) { + _unzipEntry(filteredEntriesPatches[0], romPatcherWeb.providePatchFile); + } else { + _showFilePicker(filteredEntriesPatches, romPatcherWeb.providePatchFile); + } + } else { + console.warn('ZIPManager.unzipAny: zip file contains both ROMs and patches, cannot guess'); + } + }); + }, + /* failed */ + _unzipError + ); + }, + + unzipEmbededPatches: function (arrayBuffer, embededPatchesInfo) { + zip.createReader( + new zip.BlobReader(new Blob([arrayBuffer])), + /* success */ + function (zipReader) { + zipReader.getEntries(function (zipEntries) { + const filteredEntries = _filterEntriesPatches(zipEntries); + + if (filteredEntries.length) { + const selectablePatches = []; + const optionalPatches = []; + for (var i = 0; i < filteredEntries.length; i++) { + const embededPatchInfo = embededPatchesInfo.find((embededPatchInfo) => embededPatchInfo.file === filteredEntries[i].filename); + if (embededPatchInfo && embededPatchInfo.optional) + optionalPatches.push(filteredEntries[i]); + else + selectablePatches.push(filteredEntries[i]); + } + + if (!selectablePatches.length) { + romPatcherWeb.setErrorMessage(_('No valid non-optional patches found in ZIP'), 'error'); + romPatcherWeb.disable(); + throw new Error('No valid non-optional patches found in ZIP'); + } + + if (embededPatchesInfo.length && embededPatchesInfo.length === 1 && selectablePatches.length === 1) + embededPatchesInfo[0].file = selectablePatches[0].filename; + + for (var i = 0; i < selectablePatches.length; i++) { + const embededPatchInfo = embededPatchesInfo.find((embededPatchInfo) => embededPatchInfo.file === selectablePatches[i].filename); + const option = document.createElement('option'); + option.innerHTML = embededPatchInfo && embededPatchInfo.name ? embededPatchInfo.name : selectablePatches[i].filename; + option.value = i; + option.patchFileName = selectablePatches[i].filename; + htmlElements.get('select-patch').appendChild(option); + } + htmlElements.get('select-patch')._unzipSelectedPatch = function (fileIndex) { + _unzipEntry(selectablePatches[fileIndex], romPatcherWeb.providePatchFile); + }; + + for (var i = 0; i < optionalPatches.length; i++) { + const embededPatchInfo = embededPatchesInfo.find((embededPatchInfo) => embededPatchInfo.file === optionalPatches[i].filename); + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = i; + checkbox.checked = false; + checkbox.disabled = true; + embededPatchInfo.checkbox = checkbox; + + const label = document.createElement('label'); + label.className = 'rom-patcher-checkbox-optional-patch'; + label.appendChild(checkbox); + label.appendChild(document.createTextNode(embededPatchInfo.name || embededPatchInfo.file)); + if (embededPatchInfo.description) + label.title = embededPatchInfo.description; + + htmlElements.get('container-optional-patches').appendChild(label); + + optionalPatches[i].getData(new zip.BlobWriter(), function (blob) { + const fileReader = new FileReader(); + fileReader.onload = function () { + const binFile = new BinFile(this.result); + binFile.fileName = 'optional_patch.unk'; + embededPatchInfo.parsedPatch = RomPatcher.parsePatchFile(binFile); + checkbox.disabled = false; + }; + fileReader.readAsArrayBuffer(blob); + }); + } + if (optionalPatches.length === 1) + htmlElements.show('container-optional-patches'); + + + + if (selectablePatches.length === 1) + htmlElements.addClass('select-patch', 'single'); + else + htmlElements.addClass('select-patch', 'multiple'); + + htmlElements.setEnabled('select-patch', false); + htmlElements.hide('span-loading-embeded-patch'); + htmlElements.show('select-patch'); + + _unzipEntry(selectablePatches[0], romPatcherWeb.providePatchFile); + + } else { + romPatcherWeb.setErrorMessage(_('No valid patches found in ZIP'), 'error'); + romPatcherWeb.disable(); + } + }); + }, + /* failed */ + _unzipError + ); + } + } +})(RomPatcherWeb); + + + + + +/* Patch Builder */ +const PatchBuilderWeb = (function (romPatcherWeb) { + var originalRom, modifiedRom; + + const isBrowserSafari = /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent); /* Safari userAgent does not include word Chrome, Chrome includes both! */ + + /* localization */ + const _ = function (str) { + const language = romPatcherWeb.getCurrentLanguage(); + return ROM_PATCHER_LOCALE[language] && ROM_PATCHER_LOCALE[language][str] ? ROM_PATCHER_LOCALE[language][str] : str + }; + + const _setCreateButtonSpinner = function (status) { + if (status) { + document.getElementById('patch-builder-button-create').innerHTML = ' ' + _('Creating patch...'); + } else { + document.getElementById('patch-builder-button-create').innerHTML = _('Create patch'); + } + } + + const _setToastError = function (errorMessage, className) { + const row = document.getElementById('patch-builder-row-error-message'); + const span = document.getElementById('patch-builder-error-message'); + + if (row && span) { + if (errorMessage) { + row.classList.add('show'); + span.innerHTML = errorMessage; + } else { + row.classList.remove('show'); + span.innerHTML = ''; + } + if (className === 'warning') + span.classList.add('warning'); + else + span.classList.remove('warning'); + } else { + if (className === 'warning') + console.warn('Patch Builder JS: ' + errorMessage); + else + console.error('Patch Builder JS: ' + errorMessage); + } + } + + const _setElementsStatus = function (status) { + document.getElementById('patch-builder-input-file-original').disabled = !status; + document.getElementById('patch-builder-input-file-modified').disabled = !status; + document.getElementById('patch-builder-select-patch-type').disabled = !status; + if (originalRom && modifiedRom && status) { + document.getElementById('patch-builder-button-create').disabled = !status; + } else { + document.getElementById('patch-builder-button-create').disabled = true + } + }; + + const _getMetadataFields = function (patchFormat) { + if (patchFormat === 'rup') { + return ['Description']; + } else if (patchFormat === 'ebp') { + return ['Author', 'Title', 'Description']; + } + return []; + }; + const _buildMetadataObject = function (patchFormat) { + return _getMetadataFields(patchFormat).reduce((metadata, field) => { + const input = document.getElementById('patch-builder-input-metadata-' + field.toLowerCase().replace(/\s+/g, '-')); + if (input && input.value.trim()) + metadata[field] = input.value.trim(); + return metadata; + }, {}); + }; + + var webWorkerCreate; + + var initialized = false; + return { + isInitialized: function () { + return initialized; + }, + + initialize: function () { + if (initialized) + throw new Error('Patch Builder JS was already initialized'); + else if (!romPatcherWeb.isInitialized()) + throw new Error('Rom Patcher JS must be initialized before Patch Builder JS'); + + + if (!document.getElementById('patch-builder-button-create') || document.getElementById('patch-builder-button-create').tagName !== 'BUTTON') { + console.error('Patch Builder JS: button#patch-builder-button-create not found'); + throw new Error('Patch Builder JS: button#patch-builder-button-create not found'); + } + if (!document.getElementById('patch-builder-select-patch-type') || document.getElementById('patch-builder-select-patch-type').tagName !== 'SELECT') { + console.error('Patch Builder JS: select#patch-builder-select-patch-type not found'); + throw new Error('Patch Builder JS: select#patch-builder-select-patch-type not found'); + } + if (!document.getElementById('patch-builder-input-file-original') || document.getElementById('patch-builder-input-file-original').tagName !== 'INPUT' || document.getElementById('patch-builder-input-file-original').type !== 'file') { + console.error('Patch Builder JS: input[type=file]#patch-builder-input-file-original not found'); + throw new Error('Patch Builder JS: input[type=file]#patch-builder-input-file-original not found'); + } + if (!document.getElementById('patch-builder-input-file-modified') || document.getElementById('patch-builder-input-file-modified').tagName !== 'INPUT' || document.getElementById('patch-builder-input-file-modified').type !== 'file') { + console.error('Patch Builder JS: input[type=file]#patch-builder-input-file-modified not found'); + throw new Error('Patch Builder JS: input[type=file]#patch-builder-input-file-modified not found'); + } + if (!isBrowserSafari) { + document.getElementById('patch-builder-input-file-original').classList.add('no-file-selector-button'); + document.getElementById('patch-builder-input-file-modified').classList.add('no-file-selector-button'); + } + + webWorkerCreate = new Worker(ROM_PATCHER_JS_PATH + 'RomPatcher.webworker.create.js'); + webWorkerCreate.onmessage = event => { // listen for events from the worker + //retrieve arraybuffers back from webworker + originalRom._u8array = event.data.originalRomU8Array; + modifiedRom._u8array = event.data.modifiedRomU8Array; + + _setElementsStatus(true); + _setCreateButtonSpinner(false); + + const patchFile = new BinFile(event.data.patchFileU8Array.buffer); + patchFile.fileName = modifiedRom.getName() + '.' + document.getElementById('patch-builder-select-patch-type').value; + patchFile.save(); + + _setToastError(); + }; + webWorkerCreate.onerror = event => { // listen for events from the worker + _setElementsStatus(true); + _setCreateButtonSpinner(false); + _setToastError('webWorkerCreate error: ' + event.message); + }; + + document.getElementById('patch-builder-button-create').disabled = true; + + document.getElementById('patch-builder-input-file-original').addEventListener('change', function () { + if (this.files && this.files.length) { + _setElementsStatus(false); + this.classList.remove('empty'); + originalRom = new BinFile(this.files[0], function (evt) { + _setElementsStatus(true); + + if (RomPatcher.isRomTooBig(originalRom)) + _setToastError(_('Using big files is not recommended'), 'warning'); + else if (ZIPManager.isZipFile(originalRom)) + _setToastError(_('Patch creation is not compatible with zipped ROMs'), 'warning'); + }); + } + }); + document.getElementById('patch-builder-input-file-modified').addEventListener('change', function () { + _setElementsStatus(false); + this.classList.remove('empty'); + modifiedRom = new BinFile(this.files[0], function (evt) { + _setElementsStatus(true); + + if (RomPatcher.isRomTooBig(modifiedRom)) + _setToastError(_('Using big files is not recommended'), 'warning'); + else if (ZIPManager.isZipFile(modifiedRom)) + _setToastError(_('Patch creation is not compatible with zipped ROMs'), 'warning'); + }); + }); + document.getElementById('patch-builder-select-patch-type').addEventListener('change', function () { + if (!document.getElementById('patch-builder-container-metadata-inputs')) + return; + + document.getElementById('patch-builder-container-metadata-inputs').innerHTML = ''; + + _getMetadataFields(this.value).forEach(function (field) { + const input = document.createElement('input'); + input.id = 'patch-builder-input-metadata-' + field.toLowerCase().replace(/\s+/g, '-'); + input.className = 'patch-builder-input-metadata'; + input.type = 'text'; + input.placeholder = _(field); + document.getElementById('patch-builder-container-metadata-inputs').appendChild(input); + }); + }); + document.getElementById('patch-builder-button-create').addEventListener('click', function () { + const patchFormat=document.getElementById('patch-builder-select-patch-type').value; + _setElementsStatus(false); + _setCreateButtonSpinner(true); + webWorkerCreate.postMessage( + { + originalRomU8Array: originalRom._u8array, + modifiedRomU8Array: modifiedRom._u8array, + format: patchFormat, + metadata: _buildMetadataObject(patchFormat) + }, [ + originalRom._u8array.buffer, + modifiedRom._u8array.buffer + ] + ); + }); + + console.log('Patch Builder JS initialized'); + initialized = true; + _setElementsStatus(true); + } + } +}(RomPatcherWeb)); + + + + + + + + + + + + + + + + + + + + +const ROM_PATCHER_LOCALE = { + 'fr': { + 'Creator mode': 'Mode créateur', + 'Settings': 'Configurations', + 'Use patch name for output': 'Utiliser le nom du patch pour renommer la ROM une fois patchée', + 'Light theme': 'Thème Clair', + + 'Apply patch': 'Appliquer le patch', + 'ROM file:': 'Fichier ROM:', + 'Patch file:': 'Fichier patch:', + 'Remove %s header': 'Supprimer l\'en-tête %s', + //'Add %s header': 'Add %s header', + 'Compatible formats:': 'Formats compatibles:', + //'Description:': 'Description:', + //'Required ROM:': 'Required ROM:', + //'Required %s:': 'Required %s:', + 'Applying patch...': 'Application du patch...', + 'Downloading...': 'Téléchargement...', + 'Unzipping...': 'Décompresser...', + + 'Create patch': 'Créer le patch', + 'Original ROM:': 'ROM originale:', + 'Modified ROM:': 'ROM modifiée:', + 'Patch type:': 'Type de patch:', + 'Creating patch...': 'Création du patch...', + + 'Source ROM checksum mismatch': 'Non-concordance de la somme de contrôle de la ROM source', + 'Target ROM checksum mismatch': 'Non-concordance de la somme de contrôle de la ROM cible', + 'Patch checksum mismatch': 'Non-concordance de la somme de contrôle du patch', + 'Error downloading %s': 'Erreur lors du téléchargement du patch', + 'Error unzipping file': 'Erreur lors de la décompression du fichier', + 'Invalid patch file': 'Fichier patch invalide', + 'Using big files is not recommended': 'L\'utilisation de gros fichiers n\'est pas recommandée' + }, + 'de': { + 'Creator mode': 'Erstellmodus', + 'Settings': 'Einstellungen', + 'Use patch name for output': 'Output ist Name vom Patch', + 'Fix ROM header checksum': 'Prüfsumme im ROM Header korrigieren', + 'Light theme': 'Helles Design', + + 'Apply patch': 'Patch anwenden', + 'ROM file:': 'ROM-Datei:', + 'Patch file:': 'Patch-Datei:', + 'Remove %s header': 'Header entfernen %s', + //'Add %s header': 'Add %s header', + 'Compatible formats:': 'Unterstützte Formate:', + //'Description:': 'Description:', + //'Required ROM:': 'Required ROM:', + //'Required %s:': 'Required %s:', + 'Applying patch...': 'Patch wird angewandt...', + 'Downloading...': 'Herunterladen...', + 'Unzipping...': 'Entpacken...', + + 'Create patch': 'Patch erstellen', + 'Original ROM:': 'Originale ROM:', + 'Modified ROM:': 'Veränderte ROM:', + 'Patch type:': 'Patch-Format:', + 'Creating patch...': 'Patch wird erstellt...', + + 'Source ROM checksum mismatch': 'Prüfsumme der Input-ROM stimmt nicht überein', + 'Target ROM checksum mismatch': 'Prüfsumme der Output-ROM stimmt nicht überein', + 'Patch checksum mismatch': 'Prüfsumme vom Patch stimmt nicht überein', + 'Error downloading %s': 'Fehler beim Herunterladen vom %s', + 'Error unzipping file': 'Fehler beim Entpacken', + 'Invalid patch file': 'Ungültiger Patch', + 'Using big files is not recommended': 'Große Dateien zu verwenden ist nicht empfohlen' + }, + 'es': { + 'Creator mode': 'Modo creador', + 'Settings': 'Configuración', + 'Use patch name for output': 'Guardar con nombre del parche', + 'Fix ROM header checksum': 'Corregir checksum cabecera ROM', + 'Light theme': 'Tema claro', + + 'Apply patch': 'Aplicar parche', + 'ROM file:': 'Archivo ROM:', + 'Patch file:': 'Archivo parche:', + 'Remove %s header': 'Quitar cabecera %s', + 'Add %s header': 'Añadir cabecera %s', + 'Compatible formats:': 'Formatos compatibles:', + 'Description:': 'Descripción:', + 'Required ROM:': 'ROM requerida:', + 'Required %s:': '%s requerido:', + 'Applying patch...': 'Aplicando parche...', + 'Downloading...': 'Descargando...', + 'Unzipping...': 'Descomprimiendo...', + 'Calculating...': 'Calculando...', + 'Force calculate checksum': 'Forzar cálculo de checksum', + + 'Create patch': 'Crear parche', + 'Original ROM:': 'ROM original:', + 'Modified ROM:': 'ROM modificada:', + 'Patch type:': 'Tipo de parche:', + 'Creating patch...': 'Creando parche...', + 'Author': 'Autor', + 'Title': 'Título', + 'Description': 'Descripción', + + 'Source ROM checksum mismatch': 'Checksum de ROM original no válida', + 'Target ROM checksum mismatch': 'Checksum de ROM creada no válida', + 'Patch checksum mismatch': 'Checksum de parche no válida', + 'Error downloading %s': 'Error descargando %s', + 'Error unzipping file': 'Error descomprimiendo archivo', + 'Invalid patch file': 'Archivo de parche no válido', + 'Using big files is not recommended': 'No es recomendable usar archivos muy grandes', + + 'SNES copier': 'copión SNES' + }, + 'it': { + 'Creator mode': 'Modalità creatore', + 'Settings': 'Impostazioni', + 'Use patch name for output': 'Usa il nome della patch per uscita', + 'Light theme': 'Tema chiaro', + + 'Apply patch': 'Applica patch', + 'ROM file:': 'File ROM:', + 'Patch file:': 'File patch:', + 'Remove %s header': 'Rimuovi header %s', + //'Add %s header': 'Add %s header', + 'Compatible formats:': 'Formati:', + //'Description:': 'Description:', + //'Required ROM:': 'Required ROM:', + //'Required %s:': 'Required %s:', + 'Applying patch...': 'Applica patch...', + 'Downloading...': 'Scaricamento...', + 'Unzipping...': 'Estrazione...', + + 'Create patch': 'Crea patch', + 'Original ROM:': 'ROM originale:', + 'Modified ROM:': 'ROM modificata:', + 'Patch type:': 'Tipologia patch:', + 'Creating patch...': 'Creazione patch...', + + 'Source ROM checksum mismatch': 'Checksum della ROM sorgente non valido', + 'Target ROM checksum mismatch': 'Checksum della ROM destinataria non valido', + 'Patch checksum mismatch': 'Checksum della patch non valido', + 'Error downloading %s': 'Errore di scaricamento %s', + 'Error unzipping file': 'Errore estrazione file', + 'Invalid patch file': 'File della patch non valido', + 'Using big files is not recommended': 'Non è raccomandato usare file di grandi dimensioni' + }, + 'nl': { + 'Creator mode': 'Creator-modus', + 'Settings': 'Settings', + 'Use patch name for output': 'Use patch name for output', + 'Light theme': 'Light theme', + + 'Apply patch': 'Pas patch toe', + 'ROM file:': 'ROM bestand:', + 'Patch file:': 'Patch bestand:', + 'Remove %s header': 'Verwijder rubriek %s', + //'Add %s header': 'Add %s header', + 'Compatible formats:': 'Compatibele formaten:', + //'Description:': 'Description:', + //'Required ROM:': 'Required ROM:', + //'Required %s:': 'Required %s:', + 'Applying patch...': 'Patch toepassen...', + 'Downloading...': 'Downloaden...', + 'Unzipping...': 'Uitpakken...', + + 'Create patch': 'Maak patch', + 'Original ROM:': 'Originale ROM:', + 'Modified ROM:': 'Aangepaste ROM:', + 'Patch type:': 'Type patch:', + 'Creating patch...': 'Patch maken...', + + 'Source ROM checksum mismatch': 'Controlesom van bron-ROM komt niet overeen', + 'Target ROM checksum mismatch': 'Controlesom van doel-ROM komt niet overeen', + 'Patch checksum mismatch': 'Controlesom van patch komt niet overeen', + 'Error downloading %s': 'Fout bij downloaden van patch', + 'Error unzipping file': 'Fout bij uitpakken van bestand', + 'Invalid patch file': 'Ongeldig patchbestand', + 'Using big files is not recommended': 'Het gebruik van grote bestanden wordt niet aanbevolen' + }, + 'sv': { + 'Creator mode': 'Skaparläge', + 'Settings': 'Settings', + 'Use patch name for output': 'Use patch name for output', + 'Light theme': 'Light theme', + + 'Apply patch': 'Tillämpa korrigeringsfil', + 'ROM file:': 'ROM-fil:', + 'Patch file:': 'Korrigeringsfil:', + 'Remove %s header': 'Ta bort rubrik %s', + //'Add %s header': 'Add %s header', + 'Compatible formats:': 'Kompatibla format:', + //'Description:': 'Description:', + //'Required ROM:': 'Required ROM:', + //'Required %s:': 'Required %s:', + 'Applying patch...': 'Tillämpar korrigeringsfil...', + 'Downloading...': 'Ladda ner...', + 'Unzipping...': 'Packa upp...', + + 'Create patch': 'Skapa korrigeringsfil', + 'Original ROM:': 'Original-ROM:', + 'Modified ROM:': 'Modifierad ROM:', + 'Patch type:': 'Korrigeringsfil-typ:', + 'Creating patch...': 'Skapa korrigeringsfil...', + + 'Source ROM checksum mismatch': 'ROM-källans kontrollsumman matchar inte', + 'Target ROM checksum mismatch': 'ROM-målets kontrollsumman matchar inte', + 'Patch checksum mismatch': 'korrigeringsfilens kontrollsumman matchar inte', + 'Error downloading %s': 'Fel vid nedladdning av korrigeringsfilen', + 'Error unzipping file': 'Det gick inte att packa upp filen', + 'Invalid patch file': 'Ogiltig korrigeringsfil', + 'Using big files is not recommended': 'Användning av stora filer rekommenderas inte' + }, + 'ca': { + 'Creator mode': 'Mode creador', + 'Settings': 'Configuració', + 'Use patch name for output': 'Desar amb nom del pedaç', + 'Light theme': 'Tema clar', + + 'Apply patch': 'Aplicar pedaç', + 'ROM file:': 'Arxiu ROM:', + 'Patch file:': 'Arxiu pedaç:', + 'Remove %s header': 'Treure capçalera %s', + 'Add %s header': 'Afegir capçalera %s', + 'Compatible formats:': 'Formats compatibles:', + 'Description:': 'Descripció:', + 'Required ROM:': 'ROM requerida:', + 'Required %s:': '%s requerit:', + 'Applying patch...': 'Aplicant pedaç...', + 'Downloading...': 'Descarregant...', + 'Unzipping...': 'Descomprimint...', + + 'Create patch': 'Crear pedaç', + 'Original ROM:': 'ROM original:', + 'Modified ROM:': 'ROM modificada:', + 'Patch type:': 'Tipus de pedaç:', + 'Creating patch...': 'Creant pedaç...', + 'Author': 'Autor', + 'Title': 'Títol', + 'Description': 'Descripció', + + 'Source ROM checksum mismatch': 'Checksum de ROM original no vàlida', + 'Target ROM checksum mismatch': 'Checksum de ROM creada no vàlida', + 'Patch checksum mismatch': 'Checksum de pedaç no vàlida', + 'Error downloading %s': 'Error descarregant %s', + 'Error unzipping file': 'Error descomprimint arxiu', + 'Invalid patch file': 'Arxiu de pedaç no vàlid', + 'Using big files is not recommended': 'No és recomanable usar arxius molt grans' + }, + 'ca-va': { + 'Creator mode': 'Mode creador', + 'Settings': 'Configuració', + 'Use patch name for output': 'Guardar amb nom del pedaç', + 'Light theme': 'Tema clar', + + 'Apply patch': 'Aplicar pedaç', + 'ROM file:': 'Arxiu ROM:', + 'Patch file:': 'Arxiu pedaç:', + 'Remove %s header': 'Llevar capçalera %s', + 'Add %s header': 'Afegir capçalera %s', + 'Compatible formats:': 'Formats compatibles:', + 'Description:': 'Descripció:', + 'Required ROM:': 'ROM requerida:', + 'Required %s:': '%s requerit:', + 'Applying patch...': 'Aplicant pedaç...', + 'Downloading...': 'Descarregant...', + 'Unzipping...': 'Descomprimint...', + + 'Create patch': 'Crear pedaç', + 'Original ROM:': 'ROM original:', + 'Modified ROM:': 'ROM modificada:', + 'Patch type:': 'Tipus de pedaç:', + 'Creating patch...': 'Creant pedaç...', + + 'Source ROM checksum mismatch': 'Checksum de ROM original incorrecta', + 'Target ROM checksum mismatch': 'Checksum de ROM creada incorrecta', + 'Patch checksum mismatch': 'Checksum de pedaç incorrecte', + 'Error downloading %s': 'Error descarregant %s', + 'Error unzipping file': 'Error descomprimint arxiu', + 'Invalid patch file': 'Arxiu de pedaç incorrecte', + 'Using big files is not recommended': 'No és recomanable utilitzar arxius molt grans' + }, + 'ru': { + 'Creator mode': 'Режим создания', + 'Settings': 'Settings', + 'Use patch name for output': 'Use patch name for output', + 'Light theme': 'Light theme', + + 'Apply patch': 'Применить патч', + 'ROM file:': 'Файл ROM:', + 'Patch file:': 'Файл патча:', + 'Remove %s header': 'Удалить заголовок перед применением', + //'Add %s header': 'Add %s header', + 'Compatible formats:': 'Совместимые форматы:', + //'Description:': 'Description:', + //'Required ROM:': 'Required ROM:', + //'Required %s:': 'Required %s:', + 'Applying patch...': 'Применяется патч...', + 'Downloading...': 'Загрузка...', + 'Unzipping...': 'Unzipping...', + + 'Create patch': 'Создать патч', + 'Original ROM:': 'Оригинальный ROM:', + 'Modified ROM:': 'Изменённый ROM:', + 'Patch type:': 'Тип патча:', + 'Creating patch...': 'Патч создаётся...', + + 'Source ROM checksum mismatch': 'Неправильная контрольная сумма входного ROM', + 'Target ROM checksum mismatch': 'Неправильная контрольная сумма выходного ROM', + 'Patch checksum mismatch': 'Неправильная контрольная сумма патча', + 'Error downloading %s': 'Ошибка при скачивании патча', + 'Error unzipping file': 'Error unzipping file', + 'Invalid patch file': 'Неправильный файл патча', + 'Using big files is not recommended': 'Не рекомендуется использовать большие файлы' + }, + 'pt-br': { + 'Creator mode': 'Modo criador', + 'Settings': 'Configurações', + 'Use patch name for output': 'Usar o nome do patch na saída', + 'Fix ROM header checksum': 'Consertar o checksum do cabeçalho da ROM', + 'Light theme': 'Tema leve', + + 'Apply patch': 'Aplicar patch', + 'ROM file:': 'Arquivo da ROM:', + 'Patch file:': 'Arquivo do patch:', + 'Remove %s header': 'Remover cabeçalho %s', + //'Add %s header': 'Add %s header', + 'Compatible formats:': 'Formatos compatíveis:', + //'Description:': 'Description:', + //'Required ROM:': 'Required ROM:', + //'Required %s:': 'Required %s:', + 'Applying patch...': 'Aplicando patch...', + 'Downloading...': 'Baixando...', + 'Unzipping...': 'Descompactando...', + + 'Create patch': 'Criar patch', + 'Original ROM:': 'ROM original:', + 'Modified ROM:': 'ROM modificada:', + 'Patch type:': 'Tipo de patch:', + 'Creating patch...': 'Criando o patch...', + + 'Source ROM checksum mismatch': 'O checksum da ROM original é inválido', + 'Target ROM checksum mismatch': 'O checksum da ROM alvo é inválido', + 'Patch checksum mismatch': 'O checksum do patch é inválido', + 'Error downloading %s': 'Erro ao baixar o %s', + 'Error unzipping file': 'Erro ao descompactar o arquivo', + 'Invalid patch file': 'Arquivo do patch inválido', + 'Using big files is not recommended': 'O uso de arquivos grandes não é recomendado' + }, + 'ja': { + 'Creator mode': '作成モード', + 'Settings': '設定', + 'Use patch name for output': 'パッチと同じ名前で出力', + 'Light theme': 'ライトテーマ', + + 'Apply patch': 'パッチを当て', + 'ROM file:': 'ROMファィル:', + 'Patch file:': 'パッチファイル:', + 'Remove %s header': 'ヘッダーを削除', + //'Add %s header': 'Add %s header', + 'Compatible formats:': '互換性のあるフォーマット:', + //'Description:': 'Description:', + //'Required ROM:': 'Required ROM:', + //'Required %s:': 'Required %s:', + 'Applying patch...': 'パッチを当ている…', + 'Downloading...': 'ダウンロードしている…', + 'Unzipping...': '解凍している…', + + 'Create patch': 'パッチを作成', + 'Original ROM:': '元のROM:', + 'Modified ROM:': '変更されたROM:', + 'Patch type:': 'パッチのタイプ:', + 'Creating patch...': 'パッチを作成している…', + + 'Source ROM checksum mismatch': 'ソースROMチェックサムの不一致', + 'Target ROM checksum mismatch': 'ターゲットROMチェクサムの不一致', + 'Patch checksum mismatch': 'バッチチェックサムの不一致', + 'Error downloading %s': 'パッチのダウンロードエラー', + 'Error unzipping file': 'パッチの解凍エラー', + 'Invalid patch file': '無効なパッチエラー', + 'Using big files is not recommended': '大きなファイルの使いはおすすめしない。' + }, + 'zh-cn': { + 'Creator mode': '创建模式', + 'Settings': '设置', + 'Use patch name for output': '修改后ROM文件名和补丁保持一致', + 'Light theme': '浅色主题', + + 'Apply patch': '打补丁', + 'ROM file:': 'ROM文件:', + 'Patch file:': '补丁文件:', + 'Remove %s header': '删除文件头', + //'Add %s header': 'Add %s header', + 'Compatible formats:': '兼容补丁格式:', + //'Description:': 'Description:', + //'Required ROM:': 'Required ROM:', + //'Required %s:': 'Required %s:', + 'Applying patch...': '正在打补丁……', + 'Downloading...': '正在下载……', + 'Unzipping...': '正在解压……', + + 'Create patch': '创建补丁', + 'Original ROM:': '原始ROM:', + 'Modified ROM:': '修改后ROM:', + 'Patch type:': '补丁类型:', + 'Creating patch...': '正在创建补丁……', + + 'Source ROM checksum mismatch': '原始ROM校验和不匹配', + 'Target ROM checksum mismatch': '目标ROM校验和不匹配', + 'Patch checksum mismatch': '补丁文件校验和不匹配', + 'Error downloading %s': '下载出错:%s', + 'Error unzipping file': '解压出错', + 'Invalid patch file': '无效补丁', + 'Using big files is not recommended': '不推荐使用大文件。' + }, + 'zh-tw': { + 'Creator mode': '創作者模式', + 'Settings': '設定', + 'Use patch name for output': '修改後ROM檔名和patch保持一致', + 'Fix ROM header checksum': '修正ROM檔頭校驗碼', + 'Light theme': '淺色主題', + + 'Apply patch': '套用patch', + 'ROM file:': 'ROM檔:', + 'Patch file:': 'patch檔:', + 'Remove %s header': '刪除檔頭', + //'Add %s header': 'Add %s header', + 'Compatible formats:': '相容格式:', + //'Description:': 'Description:', + //'Required ROM:': 'Required ROM:', + //'Required %s:': 'Required %s:', + 'Applying patch...': '套用patch中……', + 'Downloading...': '下載中……', + 'Unzipping...': '解壓中……', + + 'Create patch': '創建patch', + 'Original ROM:': '原始ROM:', + 'Modified ROM:': '修改後ROM:', + 'Patch type:': 'patch類型:', + 'Creating patch...': '正在創建patch……', + + 'Source ROM checksum mismatch': '原始ROM校驗碼不匹配', + 'Target ROM checksum mismatch': '目標ROM校驗碼不匹配', + 'Patch checksum mismatch': 'patch檔校驗碼不匹配', + 'Error downloading %s': '下載出錯:%s', + 'Error unzipping file': '解壓出錯', + 'Invalid patch file': '無效的patch檔', + 'Using big files is not recommended': '不建議使用大檔。' + } +}; \ No newline at end of file diff --git a/public/rom-patcher-js/RomPatcher.webworker.apply.js b/public/rom-patcher-js/RomPatcher.webworker.apply.js new file mode 100644 index 0000000..119c401 --- /dev/null +++ b/public/rom-patcher-js/RomPatcher.webworker.apply.js @@ -0,0 +1,83 @@ +/* Rom Patcher JS v20250922 - Marc Robledo 2016-2025 - http://www.marcrobledo.com/license */ + +self.importScripts( + './RomPatcher.js', + './modules/BinFile.js', + './modules/HashCalculator.js', + './modules/RomPatcher.format.ips.js', + './modules/RomPatcher.format.aps_n64.js', + './modules/RomPatcher.format.aps_gba.js', + './modules/RomPatcher.format.ups.js', + './modules/RomPatcher.format.bps.js', + './modules/RomPatcher.format.rup.js', + './modules/RomPatcher.format.ppf.js', + './modules/RomPatcher.format.bdf.js', + './modules/RomPatcher.format.pmsr.js', + './modules/RomPatcher.format.vcdiff.js' +); + + +self.onmessage = event => { // listen for messages from the main thread + const romFile=new BinFile(event.data.romFileU8Array); + romFile.fileName=event.data.romFileName; + //romFile.fileType.event.data.romFileType; + const patchFile=new BinFile(event.data.patchFileU8Array); + patchFile.fileName=event.data.patchFileName; + + const patch=RomPatcher.parsePatchFile(patchFile); + + var errorMessage=false; + + + var patchedRom; + if(patch){ + try{ + patchedRom=RomPatcher.applyPatch(romFile, patch, event.data.options); + }catch(evt){ + errorMessage=evt.message; + } + }else{ + errorMessage='Invalid patch file'; + } + + //console.log('postMessage'); + if(patchedRom){ + /* set custom output name if embeded patch */ + const patchExtraInfo=event.data.patchExtraInfo; + if(patchExtraInfo){ + if(typeof patchExtraInfo.outputName === 'string') + patchedRom.setName(patchExtraInfo.outputName); + if(typeof patchExtraInfo.outputExtension === 'string') + patchedRom.setExtension(patchExtraInfo.outputExtension); + } + + self.postMessage( + { + success: !!errorMessage, + romFileU8Array:event.data.romFileU8Array, + patchFileU8Array:event.data.patchFileU8Array, + patchedRomU8Array:patchedRom._u8array, + patchedRomFileName:patchedRom.fileName, + errorMessage:errorMessage + }, + [ + event.data.romFileU8Array.buffer, + event.data.patchFileU8Array.buffer, + patchedRom._u8array.buffer + ] + ); + }else{ + self.postMessage( + { + success: false, + romFileU8Array:event.data.romFileU8Array, + patchFileU8Array:event.data.patchFileU8Array, + errorMessage:errorMessage + }, + [ + event.data.romFileU8Array.buffer, + event.data.patchFileU8Array.buffer + ] + ); + } +}; \ No newline at end of file diff --git a/public/rom-patcher-js/RomPatcher.webworker.crc.js b/public/rom-patcher-js/RomPatcher.webworker.crc.js new file mode 100644 index 0000000..8d39204 --- /dev/null +++ b/public/rom-patcher-js/RomPatcher.webworker.crc.js @@ -0,0 +1,26 @@ +/* Rom Patcher JS v20240302 - Marc Robledo 2016-2024 - http://www.marcrobledo.com/license */ + +self.importScripts( + './RomPatcher.js', + './modules/BinFile.js', + './modules/HashCalculator.js' +); + + +self.onmessage = event => { // listen for messages from the main thread + const binFile=new BinFile(event.data.u8array); + binFile.fileName=event.data.fileName; + const startOffset=typeof event.data.checksumStartOffset==='number'? event.data.checksumStartOffset : 0; + + self.postMessage( + { + action: event.data.action, + crc32:binFile.hashCRC32(startOffset), + md5:binFile.hashMD5(startOffset), + checksumStartOffset: startOffset, + rom:RomPatcher.getRomAdditionalChecksum(binFile), + u8array:event.data.u8array + }, + [event.data.u8array.buffer] + ); +}; \ No newline at end of file diff --git a/public/rom-patcher-js/RomPatcher.webworker.create.js b/public/rom-patcher-js/RomPatcher.webworker.create.js new file mode 100644 index 0000000..dff6d9e --- /dev/null +++ b/public/rom-patcher-js/RomPatcher.webworker.create.js @@ -0,0 +1,37 @@ +/* Rom Patcher JS v20240302 - Marc Robledo 2016-2024 - http://www.marcrobledo.com/license */ + +self.importScripts( + './RomPatcher.js', + './modules/BinFile.js', + './modules/HashCalculator.js', + './modules/RomPatcher.format.ips.js', + './modules/RomPatcher.format.aps_n64.js', + './modules/RomPatcher.format.ups.js', + './modules/RomPatcher.format.bps.js', + './modules/RomPatcher.format.rup.js', + './modules/RomPatcher.format.ppf.js' +); + + +self.onmessage = event => { // listen for messages from the main thread + const originalFile=new BinFile(event.data.originalRomU8Array); + const modifiedFile=new BinFile(event.data.modifiedRomU8Array); + const format=event.data.format; + const metadata=event.data.metadata; + + const patch=RomPatcher.createPatch(originalFile, modifiedFile, format, metadata); + const patchFile=patch.export('my_patch'); + + self.postMessage( + { + originalRomU8Array:event.data.originalRomU8Array, + modifiedRomU8Array:event.data.modifiedRomU8Array, + patchFileU8Array:patchFile._u8array + }, + [ + event.data.originalRomU8Array.buffer, + event.data.modifiedRomU8Array.buffer, + patchFile._u8array.buffer + ] + ); +}; \ No newline at end of file diff --git a/public/rom-patcher-js/modules/BinFile.js b/public/rom-patcher-js/modules/BinFile.js new file mode 100644 index 0000000..5a8a855 --- /dev/null +++ b/public/rom-patcher-js/modules/BinFile.js @@ -0,0 +1,483 @@ +/* +* BinFile.js (last update: 2024-08-21) +* by Marc Robledo, https://www.marcrobledo.com +* +* a JS class for reading/writing sequentially binary data from/to a file +* that allows much more manipulation than simple DataView +* compatible with both browsers and Node.js +* +* MIT License +* +* Copyright (c) 2014-2024 Marc Robledo +* +* Permission is hereby granted, free of charge, to any person obtaining a copy +* of this software and associated documentation files (the "Software"), to deal +* in the Software without restriction, including without limitation the rights +* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +* copies of the Software, and to permit persons to whom the Software is +* furnished to do so, subject to the following conditions: +* +* The above copyright notice and this permission notice shall be included in all +* copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +* SOFTWARE. +*/ + + + +function BinFile(source, onLoad) { + this.littleEndian = false; + this.offset = 0; + this._lastRead = null; + this._offsetsStack = []; + + + if ( + BinFile.RUNTIME_ENVIROMENT === 'browser' && ( + source instanceof File || + source instanceof FileList || + (source instanceof HTMLElement && source.tagName === 'INPUT' && source.type === 'file') + ) + ) { + if (source instanceof HTMLElement) + source = source.files; + if (source instanceof FileList) + source = source[0]; + + this.fileName = source.name; + this.fileType = source.type; + this.fileSize = source.size; + + if (typeof window.FileReader !== 'function') + throw new Error('Incompatible browser'); + + this._fileReader = new FileReader(); + this._fileReader.addEventListener('load', function () { + this.binFile._u8array = new Uint8Array(this.result); + + if (typeof onLoad === 'function') + onLoad(this.binFile); + }, false); + + + this._fileReader.binFile = this; + + this._fileReader.readAsArrayBuffer(source); + + + + } else if (BinFile.RUNTIME_ENVIROMENT === 'node' && typeof source === 'string') { + if (!nodeFs.existsSync(source)) + throw new Error(source + ' does not exist'); + + const arrayBuffer = nodeFs.readFileSync(source); + + this.fileName = nodePath.basename(source); + this.fileType = nodeFs.statSync(source).type; + this.fileSize = arrayBuffer.byteLength; + + this._u8array = new Uint8Array(arrayBuffer); + + if (typeof onLoad === 'function') + onLoad(this); + + + + } else if (source instanceof BinFile) { /* if source is another BinFile, clone it */ + this.fileName = source.fileName; + this.fileType = source.fileType; + this.fileSize = source.fileSize; + + this._u8array = new Uint8Array(source._u8array.buffer.slice()); + + if (typeof onLoad === 'function') + onLoad(this); + + + + } else if (source instanceof ArrayBuffer) { + this.fileName = 'file.bin'; + this.fileType = 'application/octet-stream'; + this.fileSize = source.byteLength; + + this._u8array = new Uint8Array(source); + + if (typeof onLoad === 'function') + onLoad(this); + + + + } else if (ArrayBuffer.isView(source)) { /* source is TypedArray */ + this.fileName = 'file.bin'; + this.fileType = 'application/octet-stream'; + this.fileSize = source.buffer.byteLength; + + this._u8array = new Uint8Array(source.buffer); + + if (typeof onLoad === 'function') + onLoad(this); + + + + } else if (typeof source === 'number') { /* source is integer, create new empty file */ + this.fileName = 'file.bin'; + this.fileType = 'application/octet-stream'; + this.fileSize = source; + + this._u8array = new Uint8Array(new ArrayBuffer(source)); + + if (typeof onLoad === 'function') + onLoad(this); + + + + } else { + throw new Error('invalid BinFile source'); + } +} +BinFile.RUNTIME_ENVIROMENT = (function () { + if (typeof window === 'object' && typeof window.document === 'object') + return 'browser'; + else if (typeof WorkerGlobalScope === 'function' && self instanceof WorkerGlobalScope) + return 'webworker'; + else if (typeof require === 'function' && typeof process === 'object' && typeof process.versions === 'object' && typeof process.versions.node === 'string') + return 'node'; + else + return null; +}()); +BinFile.DEVICE_LITTLE_ENDIAN = (function () { /* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DataView#Endianness */ + var buffer = new ArrayBuffer(2); + new DataView(buffer).setInt16(0, 256, true /* littleEndian */); + // Int16Array uses the platform's endianness. + return new Int16Array(buffer)[0] === 256; +})(); + + + +BinFile.prototype.push = function () { + this._offsetsStack.push(this.offset); +} +BinFile.prototype.pop = function () { + this.seek(this._offsetsStack.pop()); +} +BinFile.prototype.seek = function (offset) { + this.offset = offset; +} +BinFile.prototype.skip = function (nBytes) { + this.offset += nBytes; +} +BinFile.prototype.isEOF = function () { + return !(this.offset < this.fileSize) +} +BinFile.prototype.slice = function (offset, len, doNotClone) { + if (typeof offset !== 'number' || offset < 0) + offset = 0; + else if (offset >= this.fileSize) + throw new Error('out of bounds slicing'); + else + offset = Math.floor(offset); + + if (typeof len !== 'number' || offset < 0 || (offset + len) >= this.fileSize.length) + len = this.fileSize - offset; + else if (len === 0) + throw new Error('zero length provided for slicing'); + else + len = Math.floor(len); + + if (offset === 0 && len === this.fileSize && doNotClone) + return this; + + + var newFile = new BinFile(this._u8array.buffer.slice(offset, offset + len)); + newFile.fileName = this.fileName; + newFile.fileType = this.fileType; + newFile.littleEndian = this.littleEndian; + return newFile; +} +BinFile.prototype.prependBytes = function (bytes) { + var newFile = new BinFile(this.fileSize + bytes.length); + newFile.seek(0); + newFile.writeBytes(bytes); + this.copyTo(newFile, 0, this.fileSize, bytes.length); + + this.fileSize = newFile.fileSize; + this._u8array = newFile._u8array; + return this; +} +BinFile.prototype.removeLeadingBytes = function (nBytes) { + this.seek(0); + var oldData = this.readBytes(nBytes); + var newFile = this.slice(nBytes.length); + + this.fileSize = newFile.fileSize; + this._u8array = newFile._u8array; + return oldData; +} + + +BinFile.prototype.copyTo = function (target, offsetSource, len, offsetTarget) { + if (!(target instanceof BinFile)) + throw new Error('target is not a BinFile object'); + + if (typeof offsetTarget !== 'number') + offsetTarget = offsetSource; + + len = len || (this.fileSize - offsetSource); + + for (var i = 0; i < len; i++) { + target._u8array[offsetTarget + i] = this._u8array[offsetSource + i]; + } +} + + +BinFile.prototype.save = function () { + if (BinFile.RUNTIME_ENVIROMENT === 'browser') { + var fileBlob = new Blob([this._u8array], { type: this.fileType }); + var blobUrl = URL.createObjectURL(fileBlob); + var a = document.createElement('a'); + a.href = blobUrl; + a.download = this.fileName; + document.body.appendChild(a); + a.dispatchEvent(new MouseEvent('click')); + URL.revokeObjectURL(blobUrl); + document.body.removeChild(a); + } else if (BinFile.RUNTIME_ENVIROMENT === 'node') { + nodeFs.writeFileSync(this.fileName, Buffer.from(this._u8array.buffer)); + } else { + throw new Error('invalid runtime environment, can\'t save file'); + } +} + + +BinFile.prototype.getExtension = function () { + var ext = this.fileName ? this.fileName.toLowerCase().match(/\.(\w+)$/) : ''; + + return ext ? ext[1] : ''; +} +BinFile.prototype.getName = function () { + return this.fileName.replace(new RegExp('\\.' + this.getExtension() + '$', 'i'), ''); +} +BinFile.prototype.setExtension = function (newExtension) { + return (this.fileName = this.getName() + '.' + newExtension); +} +BinFile.prototype.setName = function (newName) { + return (this.fileName = newName + '.' + this.getExtension()); +} + + +BinFile.prototype.readU8 = function () { + this._lastRead = this._u8array[this.offset++]; + + return this._lastRead +} +BinFile.prototype.readU16 = function () { + if (this.littleEndian) + this._lastRead = this._u8array[this.offset] + (this._u8array[this.offset + 1] << 8); + else + this._lastRead = (this._u8array[this.offset] << 8) + this._u8array[this.offset + 1]; + + this.offset += 2; + return this._lastRead >>> 0 +} +BinFile.prototype.readU24 = function () { + if (this.littleEndian) + this._lastRead = this._u8array[this.offset] + (this._u8array[this.offset + 1] << 8) + (this._u8array[this.offset + 2] << 16); + else + this._lastRead = (this._u8array[this.offset] << 16) + (this._u8array[this.offset + 1] << 8) + this._u8array[this.offset + 2]; + + this.offset += 3; + return this._lastRead >>> 0 +} +BinFile.prototype.readU32 = function () { + if (this.littleEndian) + this._lastRead = this._u8array[this.offset] + (this._u8array[this.offset + 1] << 8) + (this._u8array[this.offset + 2] << 16) + (this._u8array[this.offset + 3] << 24); + else + this._lastRead = (this._u8array[this.offset] << 24) + (this._u8array[this.offset + 1] << 16) + (this._u8array[this.offset + 2] << 8) + this._u8array[this.offset + 3]; + + this.offset += 4; + return this._lastRead >>> 0 +} +BinFile.prototype.readU64 = function () { + if (this.littleEndian) + this._lastRead = this._u8array[this.offset] + (this._u8array[this.offset + 1] << 8) + (this._u8array[this.offset + 2] << 16) + (this._u8array[this.offset + 3] << 24) + (this._u8array[this.offset + 4] << 32) + (this._u8array[this.offset + 5] << 40) + (this._u8array[this.offset + 6] << 48) + (this._u8array[this.offset + 7] << 56); + else + this._lastRead = (this._u8array[this.offset] << 56) + (this._u8array[this.offset + 1] << 48) + (this._u8array[this.offset + 2] << 40) + (this._u8array[this.offset + 3] << 32) + (this._u8array[this.offset + 4] << 24) + (this._u8array[this.offset + 5] << 16) + (this._u8array[this.offset + 6] << 8) + this._u8array[this.offset + 7]; + this.offset += 8; + return this._lastRead >>> 0 +} + + + +BinFile.prototype.readBytes = function (len) { + this._lastRead = new Array(len); + for (var i = 0; i < len; i++) { + this._lastRead[i] = this._u8array[this.offset + i]; + } + + this.offset += len; + return this._lastRead +} + +BinFile.prototype.readString = function (len) { + this._lastRead = ''; + for (var i = 0; i < len && (this.offset + i) < this.fileSize && this._u8array[this.offset + i] > 0; i++) + this._lastRead = this._lastRead + String.fromCharCode(this._u8array[this.offset + i]); + + this.offset += len; + return this._lastRead +} + +BinFile.prototype.writeU8 = function (u8) { + this._u8array[this.offset++] = u8; +} +BinFile.prototype.writeU16 = function (u16) { + if (this.littleEndian) { + this._u8array[this.offset] = u16 & 0xff; + this._u8array[this.offset + 1] = u16 >> 8; + } else { + this._u8array[this.offset] = u16 >> 8; + this._u8array[this.offset + 1] = u16 & 0xff; + } + + this.offset += 2; +} +BinFile.prototype.writeU24 = function (u24) { + if (this.littleEndian) { + this._u8array[this.offset] = u24 & 0x0000ff; + this._u8array[this.offset + 1] = (u24 & 0x00ff00) >> 8; + this._u8array[this.offset + 2] = (u24 & 0xff0000) >> 16; + } else { + this._u8array[this.offset] = (u24 & 0xff0000) >> 16; + this._u8array[this.offset + 1] = (u24 & 0x00ff00) >> 8; + this._u8array[this.offset + 2] = u24 & 0x0000ff; + } + + this.offset += 3; +} +BinFile.prototype.writeU32 = function (u32) { + if (this.littleEndian) { + this._u8array[this.offset] = u32 & 0x000000ff; + this._u8array[this.offset + 1] = (u32 & 0x0000ff00) >> 8; + this._u8array[this.offset + 2] = (u32 & 0x00ff0000) >> 16; + this._u8array[this.offset + 3] = (u32 & 0xff000000) >> 24; + } else { + this._u8array[this.offset] = (u32 & 0xff000000) >> 24; + this._u8array[this.offset + 1] = (u32 & 0x00ff0000) >> 16; + this._u8array[this.offset + 2] = (u32 & 0x0000ff00) >> 8; + this._u8array[this.offset + 3] = u32 & 0x000000ff; + } + + this.offset += 4; +} + + +BinFile.prototype.writeBytes = function (a) { + for (var i = 0; i < a.length; i++) + this._u8array[this.offset + i] = a[i] + + this.offset += a.length; +} + +BinFile.prototype.writeString = function (str, len) { + len = len || str.length; + for (var i = 0; i < str.length && i < len; i++) + this._u8array[this.offset + i] = str.charCodeAt(i); + + for (; i < len; i++) + this._u8array[this.offset + i] = 0x00; + + this.offset += len; +} + + +BinFile.prototype.swapBytes = function (swapSize, newFile) { + if (typeof swapSize !== 'number') { + swapSize = 4; + } + + if (this.fileSize % swapSize !== 0) { + throw new Error('file size is not divisible by ' + swapSize); + } + + var swappedFile = new BinFile(this.fileSize); + this.seek(0); + while (!this.isEOF()) { + swappedFile.writeBytes( + this.readBytes(swapSize).reverse() + ); + } + + if (newFile) { + swappedFile.fileName = this.fileName; + swappedFile.fileType = this.fileType; + + return swappedFile; + } else { + this._u8array = swappedFile._u8array; + + return this; + } + +} + + + + + +BinFile.prototype.hashSHA1 = async function (start, len) { + if (typeof HashCalculator !== 'object' || typeof HashCalculator.sha1 !== 'function') + throw new Error('no Hash object found or missing sha1 function'); + + return HashCalculator.sha1(this.slice(start, len, true)._u8array.buffer); +} +BinFile.prototype.hashMD5 = function (start, len) { + if (typeof HashCalculator !== 'object' || typeof HashCalculator.md5 !== 'function') + throw new Error('no Hash object found or missing md5 function'); + + return HashCalculator.md5(this.slice(start, len, true)._u8array.buffer); +} +BinFile.prototype.hashCRC32 = function (start, len) { + if (typeof HashCalculator !== 'object' || typeof HashCalculator.crc32 !== 'function') + throw new Error('no Hash object found or missing crc32 function'); + + return HashCalculator.crc32(this.slice(start, len, true)._u8array.buffer); +} +BinFile.prototype.hashAdler32 = function (start, len) { + if (typeof HashCalculator !== 'object' || typeof HashCalculator.adler32 !== 'function') + throw new Error('no Hash object found or missing adler32 function'); + + return HashCalculator.adler32(this.slice(start, len, true)._u8array.buffer); +} +BinFile.prototype.hashCRC16 = function (start, len) { + if (typeof HashCalculator !== 'object' || typeof HashCalculator.crc16 !== 'function') + throw new Error('no Hash object found or missing crc16 function'); + + return HashCalculator.crc16(this.slice(start, len, true)._u8array.buffer); +} + + + + + + + + + + + + + + + +if (BinFile.RUNTIME_ENVIROMENT === 'node' && typeof module !== 'undefined' && module.exports) { + module.exports = BinFile; + HashCalculator = require('./HashCalculator'); + nodePath = require('path'); + nodeFs = require('fs'); +} diff --git a/public/rom-patcher-js/modules/HashCalculator.js b/public/rom-patcher-js/modules/HashCalculator.js new file mode 100644 index 0000000..1d9d3cb --- /dev/null +++ b/public/rom-patcher-js/modules/HashCalculator.js @@ -0,0 +1,179 @@ +/* +* HashCalculator.js (last update: 2021-08-15) +* by Marc Robledo, https://www.marcrobledo.com +* +* data hash calculator (CRC32, MD5, SHA1, ADLER-32, CRC16) +* +* MIT License +* +* Copyright (c) 2016-2021 Marc Robledo +* +* Permission is hereby granted, free of charge, to any person obtaining a copy +* of this software and associated documentation files (the "Software"), to deal +* in the Software without restriction, including without limitation the rights +* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +* copies of the Software, and to permit persons to whom the Software is +* furnished to do so, subject to the following conditions: +* +* The above copyright notice and this permission notice shall be included in all +* copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +* SOFTWARE. +*/ + + +const HashCalculator = (function () { + const HEX_CHR = '0123456789abcdef'.split(''); + + /* MD5 helpers */ + const _add32 = function (a, b) { return (a + b) & 0xffffffff } + const _md5cycle = function (x, k) { var a = x[0], b = x[1], c = x[2], d = x[3]; a = ff(a, b, c, d, k[0], 7, -680876936); d = ff(d, a, b, c, k[1], 12, -389564586); c = ff(c, d, a, b, k[2], 17, 606105819); b = ff(b, c, d, a, k[3], 22, -1044525330); a = ff(a, b, c, d, k[4], 7, -176418897); d = ff(d, a, b, c, k[5], 12, 1200080426); c = ff(c, d, a, b, k[6], 17, -1473231341); b = ff(b, c, d, a, k[7], 22, -45705983); a = ff(a, b, c, d, k[8], 7, 1770035416); d = ff(d, a, b, c, k[9], 12, -1958414417); c = ff(c, d, a, b, k[10], 17, -42063); b = ff(b, c, d, a, k[11], 22, -1990404162); a = ff(a, b, c, d, k[12], 7, 1804603682); d = ff(d, a, b, c, k[13], 12, -40341101); c = ff(c, d, a, b, k[14], 17, -1502002290); b = ff(b, c, d, a, k[15], 22, 1236535329); a = gg(a, b, c, d, k[1], 5, -165796510); d = gg(d, a, b, c, k[6], 9, -1069501632); c = gg(c, d, a, b, k[11], 14, 643717713); b = gg(b, c, d, a, k[0], 20, -373897302); a = gg(a, b, c, d, k[5], 5, -701558691); d = gg(d, a, b, c, k[10], 9, 38016083); c = gg(c, d, a, b, k[15], 14, -660478335); b = gg(b, c, d, a, k[4], 20, -405537848); a = gg(a, b, c, d, k[9], 5, 568446438); d = gg(d, a, b, c, k[14], 9, -1019803690); c = gg(c, d, a, b, k[3], 14, -187363961); b = gg(b, c, d, a, k[8], 20, 1163531501); a = gg(a, b, c, d, k[13], 5, -1444681467); d = gg(d, a, b, c, k[2], 9, -51403784); c = gg(c, d, a, b, k[7], 14, 1735328473); b = gg(b, c, d, a, k[12], 20, -1926607734); a = hh(a, b, c, d, k[5], 4, -378558); d = hh(d, a, b, c, k[8], 11, -2022574463); c = hh(c, d, a, b, k[11], 16, 1839030562); b = hh(b, c, d, a, k[14], 23, -35309556); a = hh(a, b, c, d, k[1], 4, -1530992060); d = hh(d, a, b, c, k[4], 11, 1272893353); c = hh(c, d, a, b, k[7], 16, -155497632); b = hh(b, c, d, a, k[10], 23, -1094730640); a = hh(a, b, c, d, k[13], 4, 681279174); d = hh(d, a, b, c, k[0], 11, -358537222); c = hh(c, d, a, b, k[3], 16, -722521979); b = hh(b, c, d, a, k[6], 23, 76029189); a = hh(a, b, c, d, k[9], 4, -640364487); d = hh(d, a, b, c, k[12], 11, -421815835); c = hh(c, d, a, b, k[15], 16, 530742520); b = hh(b, c, d, a, k[2], 23, -995338651); a = ii(a, b, c, d, k[0], 6, -198630844); d = ii(d, a, b, c, k[7], 10, 1126891415); c = ii(c, d, a, b, k[14], 15, -1416354905); b = ii(b, c, d, a, k[5], 21, -57434055); a = ii(a, b, c, d, k[12], 6, 1700485571); d = ii(d, a, b, c, k[3], 10, -1894986606); c = ii(c, d, a, b, k[10], 15, -1051523); b = ii(b, c, d, a, k[1], 21, -2054922799); a = ii(a, b, c, d, k[8], 6, 1873313359); d = ii(d, a, b, c, k[15], 10, -30611744); c = ii(c, d, a, b, k[6], 15, -1560198380); b = ii(b, c, d, a, k[13], 21, 1309151649); a = ii(a, b, c, d, k[4], 6, -145523070); d = ii(d, a, b, c, k[11], 10, -1120210379); c = ii(c, d, a, b, k[2], 15, 718787259); b = ii(b, c, d, a, k[9], 21, -343485551); x[0] = _add32(a, x[0]); x[1] = _add32(b, x[1]); x[2] = _add32(c, x[2]); x[3] = _add32(d, x[3]) } + const _md5blk = function (d) { var md5blks = [], i; for (i = 0; i < 64; i += 4)md5blks[i >> 2] = d[i] + (d[i + 1] << 8) + (d[i + 2] << 16) + (d[i + 3] << 24); return md5blks } + const _cmn = function (q, a, b, x, s, t) { a = _add32(_add32(a, q), _add32(x, t)); return _add32((a << s) | (a >>> (32 - s)), b) } + const ff = function (a, b, c, d, x, s, t) { return _cmn((b & c) | ((~b) & d), a, b, x, s, t) } + const gg = function (a, b, c, d, x, s, t) { return _cmn((b & d) | (c & (~d)), a, b, x, s, t) } + const hh = function (a, b, c, d, x, s, t) { return _cmn(b ^ c ^ d, a, b, x, s, t) } + const ii = function (a, b, c, d, x, s, t) { return _cmn(c ^ (b | (~d)), a, b, x, s, t) } + + /* CRC32 helpers */ + const CRC32_TABLE = (function () { + var c, crcTable = []; + for (var n = 0; n < 256; n++) { + c = n; + for (var k = 0; k < 8; k++) + c = ((c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1)); + crcTable[n] = c; + } + return crcTable; + }()); + + /* Adler-32 helpers */ + const ADLER32_MOD = 0xfff1; + + + + + const generateUint8Array = function (arrayBuffer, offset, len) { + if (typeof offset !== 'number' || offset < 0) + offset = 0; + else if (offset < arrayBuffer.byteLength) + offset = Math.floor(offset); + else + throw new Error('out of bounds slicing'); + + if (typeof len !== 'number' || len < 0 || (offset + len) >= arrayBuffer.byteLength.length) + len = arrayBuffer.byteLength - offset; + else if (len > 0) + len = Math.floor(len); + else + throw new Error('zero length provided for slicing'); + + return new Uint8Array(arrayBuffer, offset, len); + } + + + return { + /* SHA-1 using WebCryptoAPI */ + sha1: async function sha1(arrayBuffer, offset, len) { + if(typeof window === 'undefined' || typeof window.crypto === 'undefined') + throw new Error('Web Crypto API is not available'); + + const u8array = generateUint8Array(arrayBuffer, offset, len); + if (u8array.byteLength !== arrayBuffer.byteLength) { + arrayBuffer = arrayBuffer.slice(u8array.byteOffset, u8array.byteOffset + u8array.byteLength); + } + + const hash = await window.crypto.subtle.digest('SHA-1', arrayBuffer); + + const bytes = new Uint8Array(hash); + let hexString = ''; + for (let i = 0; i < bytes.length; i++) + hexString += bytes[i] < 16 ? '0' + bytes[i].toString(16) : bytes[i].toString(16); + return hexString; + }, + + /* MD5 - from Joseph's Myers - http://www.myersdaily.org/joseph/javascript/md5.js */ + md5: function (arrayBuffer, offset, len) { + let u8array = generateUint8Array(arrayBuffer, offset, len); + + var n = u8array.byteLength, state = [1732584193, -271733879, -1732584194, 271733878], i; + for (i = 64; i <= u8array.byteLength; i += 64) + _md5cycle(state, _md5blk(u8array.slice(i - 64, i))); + u8array = u8array.slice(i - 64); + var tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + for (i = 0; i < u8array.byteLength; i++) + tail[i >> 2] |= u8array[i] << ((i % 4) << 3); + tail[i >> 2] |= 0x80 << ((i % 4) << 3); + if (i > 55) { + _md5cycle(state, tail); + for (i = 0; i < 16; i++)tail[i] = 0; + } + tail[14] = n * 8; + tail[15] = Math.floor(n / 536870912) >>> 0; //if file is bigger than 512Mb*8, value is bigger than 32 bits, so it needs two words to store its length + _md5cycle(state, tail); + + for (var i = 0; i < state.length; i++) { + var s = '', j = 0; + for (; j < 4; j++) + s += HEX_CHR[(state[i] >> (j * 8 + 4)) & 0x0f] + HEX_CHR[(state[i] >> (j * 8)) & 0x0f]; + state[i] = s; + } + return state.join('') + }, + + /* CRC32 - from Alex - https://stackoverflow.com/a/18639999 */ + crc32: function (arrayBuffer, offset, len) { + const u8array = generateUint8Array(arrayBuffer, offset, len); + + var crc = 0 ^ (-1); + + for (var i = 0; i < u8array.byteLength; i++) + crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ u8array[i]) & 0xff]; + + return ((crc ^ (-1)) >>> 0); + }, + + /* Adler-32 - https://en.wikipedia.org/wiki/Adler-32#Example_implementation */ + adler32: function (arrayBuffer, offset, len) { + const u8array = generateUint8Array(arrayBuffer, offset, len); + + var a = 1, b = 0; + + for (var i = 0; i < u8array.byteLength; i++) { + a = (a + u8array[i]) % ADLER32_MOD; + b = (b + a) % ADLER32_MOD; + } + + return ((b << 16) | a) >>> 0; + }, + + /* CRC16/CCITT-FALSE */ + crc16: function (arrayBuffer, offset, len) { + const u8array = generateUint8Array(arrayBuffer, offset, len); + + var crc = 0xffff; + + var offset = 0; + + for (var i = 0; i < u8array.byteLength; i++) { + crc ^= u8array[offset++] << 8; + for (j = 0; j < 8; ++j) { + crc = (crc & 0x8000) >>> 0 ? (crc << 1) ^ 0x1021 : crc << 1; + } + } + + return crc & 0xffff; + } + } +}()); + + +if (typeof module !== 'undefined' && module.exports) { + module.exports = HashCalculator; +} diff --git a/public/rom-patcher-js/modules/RomPatcher.format.aps_gba.js b/public/rom-patcher-js/modules/RomPatcher.format.aps_gba.js new file mode 100644 index 0000000..66e258f --- /dev/null +++ b/public/rom-patcher-js/modules/RomPatcher.format.aps_gba.js @@ -0,0 +1,114 @@ +/* APS (GBA) module for Rom Patcher JS v20230331 - Marc Robledo 2017-2023 - http://www.marcrobledo.com/license */ +/* File format specification: https://github.com/btimofeev/UniPatcher/wiki/APS-(GBA) */ + +const APS_GBA_MAGIC='APS1'; +const APS_GBA_BLOCK_SIZE=0x010000; //64Kb +const APS_GBA_RECORD_SIZE=4 + 2 + 2 + APS_GBA_BLOCK_SIZE; +if(typeof module !== "undefined" && module.exports){ + module.exports = APSGBA; +} +function APSGBA(){ + this.sourceSize=0; + this.targetSize=0; + this.records=[]; +} +APSGBA.prototype.addRecord=function(offset, sourceCrc16, targetCrc16, xorBytes){ + this.records.push({ + offset:offset, + sourceCrc16:sourceCrc16, + targetCrc16:targetCrc16, + xorBytes:xorBytes} + ); +} +APSGBA.prototype.toString=function(){ + var s='Total records: '+this.records.length; + s+='\nInput file size: '+this.sourceSize; + s+='\nOutput file size: '+this.targetSize; + return s +} +APSGBA.prototype.validateSource=function(sourceFile){ + if(sourceFile.fileSize!==this.sourceSize) + return false; + + for(var i=0; i2){ + patch.addRLERecord(offset, differentBytes[0], differentBytes.length); + }else{ + patch.addRecord(offset, differentBytes); + } + } + } + + return patch +} \ No newline at end of file diff --git a/public/rom-patcher-js/modules/RomPatcher.format.bdf.js b/public/rom-patcher-js/modules/RomPatcher.format.bdf.js new file mode 100644 index 0000000..0367a08 --- /dev/null +++ b/public/rom-patcher-js/modules/RomPatcher.format.bdf.js @@ -0,0 +1,90 @@ +/* BDF module for Rom Patcher JS v20250922 - Marc Robledo 2025 - http://www.marcrobledo.com/license */ +/* File format specification: https://www.daemonology.net/bsdiff/ */ + +const BDF_MAGIC='BSDIFF40'; + +if(typeof module !== "undefined" && module.exports){ + module.exports = BDF; +} + +function BDF(){ + this.records=[]; + this.patchedSize=0; +} + +BDF.prototype.apply=function(file){ + var tempFile=new BinFile(this.patchedSize); + + for (const record of this.records) { + for (const b of record.diff) { + tempFile.writeU8(file.readU8() + b); + } + tempFile.writeBytes(record.extra); + file.seek(file.offset + record.skip); + } + return tempFile; +} + +BDF.MAGIC = BDF_MAGIC; + +BDF.fromFile=function(file){ + var patch=new BDF(); + + file.seek(8); + file.littleEndian=true; + var controlSize=file.readU64(); + var diffSize=file.readU64(); + patch.patchedSize=file.readU64(); + + var controlCompressed=file.readBytes(controlSize); + var diffCompressed=file.readBytes(diffSize); + var extraCompressed=file.readBytes(file.fileSize-file.offset); + + var controlFile=new BinFile(bz2.decompress(controlCompressed)); + controlFile.littleEndian=true; + var diffFile=new BinFile(bz2.decompress(diffCompressed)); + var extraFile=new BinFile(bz2.decompress(extraCompressed)); + + while(!controlFile.isEOF()){ + var diffLen=controlFile.readU64(); + var extraLen=controlFile.readU64(); + var skip=controlFile.readU64(); + if(skip&(1<<63)) + skip=-(skip&~(1<<63)); + var diff=diffFile.readBytes(diffLen); + var extra=extraFile.readBytes(extraLen); + patch.records.push({diff, extra, skip}); + } + + return patch; +} + + + + +/* +bz2 (C) 2019-present SheetJS LLC + +Copyright 2019 SheetJS LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ +"use strict"; +const bz2=function $(){let x=[0,79764919,159529838,222504665,319059676,398814059,445009330,507990021,638119352,583659535,797628118,726387553,890018660,835552979,1015980042,944750013,1276238704,1221641927,1167319070,1095957929,1595256236,1540665371,1452775106,1381403509,1780037320,1859660671,1671105958,1733955601,2031960084,2111593891,1889500026,1952343757,2552477408,2632100695,2443283854,2506133561,2334638140,2414271883,2191915858,2254759653,3190512472,3135915759,3081330742,3009969537,2905550212,2850959411,2762807018,2691435357,3560074640,3505614887,3719321342,3648080713,3342211916,3287746299,3467911202,3396681109,4063920168,4143685023,4223187782,4286162673,3779000052,3858754371,3904687514,3967668269,881225847,809987520,1023691545,969234094,662832811,591600412,771767749,717299826,311336399,374308984,453813921,533576470,25881363,88864420,134795389,214552010,2023205639,2086057648,1897238633,1976864222,1804852699,1867694188,1645340341,1724971778,1587496639,1516133128,1461550545,1406951526,1302016099,1230646740,1142491917,1087903418,2896545431,2825181984,2770861561,2716262478,3215044683,3143675388,3055782693,3001194130,2326604591,2389456536,2200899649,2280525302,2578013683,2640855108,2418763421,2498394922,3769900519,3832873040,3912640137,3992402750,4088425275,4151408268,4197601365,4277358050,3334271071,3263032808,3476998961,3422541446,3585640067,3514407732,3694837229,3640369242,1762451694,1842216281,1619975040,1682949687,2047383090,2127137669,1938468188,2001449195,1325665622,1271206113,1183200824,1111960463,1543535498,1489069629,1434599652,1363369299,622672798,568075817,748617968,677256519,907627842,853037301,1067152940,995781531,51762726,131386257,177728840,240578815,269590778,349224269,429104020,491947555,4046411278,4126034873,4172115296,4234965207,3794477266,3874110821,3953728444,4016571915,3609705398,3555108353,3735388376,3664026991,3290680682,3236090077,3449943556,3378572211,3174993278,3120533705,3032266256,2961025959,2923101090,2868635157,2813903052,2742672763,2604032198,2683796849,2461293480,2524268063,2284983834,2364738477,2175806836,2238787779,1569362073,1498123566,1409854455,1355396672,1317987909,1246755826,1192025387,1137557660,2072149281,2135122070,1912620623,1992383480,1753615357,1816598090,1627664531,1707420964,295390185,358241886,404320391,483945776,43990325,106832002,186451547,266083308,932423249,861060070,1041341759,986742920,613929101,542559546,756411363,701822548,3316196985,3244833742,3425377559,3370778784,3601682597,3530312978,3744426955,3689838204,3819031489,3881883254,3928223919,4007849240,4037393693,4100235434,4180117107,4259748804,2310601993,2373574846,2151335527,2231098320,2596047829,2659030626,2470359227,2550115596,2947551409,2876312838,2788305887,2733848168,3165939309,3094707162,3040238851,2985771188,],f=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535,131071,262143,524287,1048575,2097151,4194303,8388607,16777215,33554431,67108863,134217727,268435455,536870911,1073741823,-2147483648,];function e($){let x=[];for(let f=0;f<$.length;f+=1)x.push([f,$[f]]);x.push([$.length,-1]);let e=[],d=x[0][0],b=x[0][1];for(let _=0;_$.bits-x.bits||$.code-x.code);let l=0,o=-1,r=[],n;for(let s=0;s=$.length)throw RangeError("Out of bound");let f=$.slice();$.sort(($,x)=>$-x);let e={};for(let d=$.length-1;d>=0;d-=1)e[$[d]]=d;let b=[];for(let _=0;_<$.length;_+=1)b.push(e[f[_]]++);let a,c=$[a=x],t=[];for(let l=1;l<$.length;l+=1){let o=$[a=b[a]];void 0===o?t.push(255):t.push(o)}return t.push(c),t.reverse(),t}let b={decompress:function $(b,_=!1){let a=0,c=0,t=0,l=$=>{if($>=32){let x=$>>1;return l(x)*(1<>t-$&e;return t-=$,c&=~(e<=49&&n<=57)n-=48;else throw Error("Invalid blocksize");let s=new Uint8Array(1.5*b.length),i=0,h=-1;for(;;){let u=l(48),p=0|l(32);if(54156738319193===u){if(l(1))throw Error("do not support randomised");let g=l(24),w=[],m=l(16);for(let v=32768;v>0;v>>=1){if(!(m&v)){for(let y=0;y<16;y+=1)w.push(!1);continue}let k=l(16);for(let I=32768;I>0;I>>=1)w.push(!!(k&I))}let A=l(3);if(A<2||A>6)throw Error("Invalid number of huffman groups");let z=l(15),C=[],O=Array.from({length:A},($,x)=>x);for(let F=0;F=A)throw Error("MTF table out of range");let M=O[H];for(let P=H;P>0;O[P]=O[--P]);C.push(M),O[0]=M}let R=w.reduce(($,x)=>$+x,0)+2,T=[];for(let j=0;j20)throw Error("Huffman group length outside range");for(;l(1);)q-=2*l(1)-1;B.push(q)}T.push(e(B))}let E=[];for(let G=0;G>t-V])){c&=f[t-=V],N=N.code;break}if(N>=0&&N<=1){0===Q&&(S=1),Q+=S<0;Q-=1)U.push(W)}if(N===R-1)break;{let X=E[N-1];for(let Y=N-1;Y>0;E[Y]=E[--Y]);E[0]=X,U.push(X)}}let Z=d(U,g),$$=0;for(;$$=s.length){let $e=s;(s=new Uint8Array(2*$e.length)).set($e)}for(let $d=0;$d<$f;$d+=1)_&&(h=h<<8^x[(h>>24^$x)&255]),s[i]=$x,i+=1}if(_){let $b=-1^h;if($b!==p)throw Error(`CRC mismatch: ${$b} !== ${p}`);h=-1}}else if(25779555029136===u){l(7&t);break}else throw Error("Invalid bz2 blocktype")}return s.subarray(0,i)}};return b}(); \ No newline at end of file diff --git a/public/rom-patcher-js/modules/RomPatcher.format.bps.js b/public/rom-patcher-js/modules/RomPatcher.format.bps.js new file mode 100644 index 0000000..b2e8266 --- /dev/null +++ b/public/rom-patcher-js/modules/RomPatcher.format.bps.js @@ -0,0 +1,466 @@ +/* BPS module for Rom Patcher JS v20240821 - Marc Robledo 2016-2024 - http://www.marcrobledo.com/license */ +/* File format specification: https://www.romhacking.net/documents/746/ */ + +const BPS_MAGIC='BPS1'; +const BPS_ACTION_SOURCE_READ=0; +const BPS_ACTION_TARGET_READ=1; +const BPS_ACTION_SOURCE_COPY=2; +const BPS_ACTION_TARGET_COPY=3; +if(typeof module !== "undefined" && module.exports){ + module.exports = BPS; +} + +function BPS(){ + this.sourceSize=0; + this.targetSize=0; + this.metaData=''; + this.actions=[]; + this.sourceChecksum=0; + this.targetChecksum=0; + this.patchChecksum=0; +} +BPS.prototype.toString=function(){ + var s='Source size: '+this.sourceSize; + s+='\nTarget size: '+this.targetSize; + s+='\nMetadata: '+this.metaData; + s+='\n#Actions: '+this.actions.length; + return s +} +BPS.prototype.calculateFileChecksum = function () { + var patchFile = this.export(); + return patchFile.hashCRC32(0, patchFile.fileSize - 4); +} +BPS.prototype.validateSource=function(romFile,headerSize){return this.sourceChecksum===romFile.hashCRC32(headerSize)} +BPS.prototype.getValidationInfo=function(){ + return { + 'type':'CRC32', + 'value':this.sourceChecksum + } +} +BPS.prototype.apply=function(romFile, validate){ + if(validate && !this.validateSource(romFile)){ + throw new Error('Source ROM checksum mismatch'); + } + + + tempFile=new BinFile(this.targetSize); + + + //patch + var sourceRelativeOffset=0; + var targetRelativeOffset=0; + for(var i=0; i> 2)+1}; + + if(action.type===BPS_ACTION_TARGET_READ){ + action.bytes=file.readBytes(action.length); + + }else if(action.type===BPS_ACTION_SOURCE_COPY || action.type===BPS_ACTION_TARGET_COPY){ + var relativeOffset=file.readVLV(); + action.relativeOffset=(relativeOffset & 1? -1 : +1) * (relativeOffset >> 1) + } + + patch.actions.push(action); + } + + //file.seek(endActionsOffset); + patch.sourceChecksum=file.readU32(); + patch.targetChecksum=file.readU32(); + patch.patchChecksum=file.readU32(); + + if (patch.patchChecksum !== patch.calculateFileChecksum()) { + throw new Error('Patch checksum mismatch'); + } + + + return patch; +} + + + +function BPS_readVLV(){ + var data=0, shift=1; + while(true){ + var x = this.readU8(); + data += (x & 0x7f) * shift; + if(x & 0x80) + break; + shift <<= 7; + data += shift; + } + + this._lastRead=data; + return data; +} +function BPS_writeVLV(data){ + while(true){ + var x = data & 0x7f; + data >>= 7; + if(data === 0){ + this.writeU8(0x80 | x); + break; + } + this.writeU8(x); + data--; + } +} +function BPS_getVLVLen(data){ + var len=0; + while(true){ + var x = data & 0x7f; + data >>= 7; + if(data === 0){ + len++; + break; + } + len++; + data--; + } + return len; +} + + +BPS.prototype.export=function(fileName){ + var patchFileSize=BPS_MAGIC.length; + patchFileSize+=BPS_getVLVLen(this.sourceSize); + patchFileSize+=BPS_getVLVLen(this.targetSize); + patchFileSize+=BPS_getVLVLen(this.metaData.length); + patchFileSize+=this.metaData.length; + for(var i=0; i= 4) { + //write byte to repeat + targetReadLength++; + outputOffset++; + targetReadFlush(); + + //copy starting from repetition byte + //encode(TargetCopy | ((rleLength - 1) << 2)); + var relativeOffset = (outputOffset - 1) - targetRelativeOffset; + //encode(relativeOffset << 1); + patchActions.push({type:BPS_ACTION_TARGET_COPY, length:rleLength, relativeOffset:relativeOffset}); + outputOffset += rleLength; + targetRelativeOffset = outputOffset - 1; + } else if(sourceLength >= 4) { + targetReadFlush(); + //encode(SourceRead | ((sourceLength - 1) << 2)); + patchActions.push({type:BPS_ACTION_SOURCE_READ, length:sourceLength}); + outputOffset += sourceLength; + } else { + targetReadLength += Granularity; + outputOffset += Granularity; + } + } + + targetReadFlush(); + + + + return patchActions; +} + +/* delta implementation from https://github.com/chiya/beat/blob/master/nall/beat/delta.hpp */ +function createBPSFromFilesDelta(original, modified){ + var patchActions=[]; + + + /* references to match original beat code */ + var sourceData=original._u8array; + var targetData=modified._u8array; + var sourceSize=original.fileSize; + var targetSize=modified.fileSize; + var Granularity=1; + + + + var sourceRelativeOffset=0; + var targetRelativeOffset=0; + var outputOffset=0; + + + + var sourceTree=new Array(65536); + var targetTree=new Array(65536); + for(var n=0; n<65536; n++){ + sourceTree[n]=null; + targetTree[n]=null; + } + + + + //source tree creation + for(var offset=0; offset maxLength) maxLength = length, mode = BPS_ACTION_SOURCE_READ; + } + + { //source copy + var node = sourceTree[symbol]; + while(node) { + var length = 0, x = node.offset, y = outputOffset; + while(x < sourceSize && y < targetSize && sourceData[x++] == targetData[y++]) length++; + if(length > maxLength) maxLength = length, maxOffset = node.offset, mode = BPS_ACTION_SOURCE_COPY; + node = node.next; + } + } + + { //target copy + var node = targetTree[symbol]; + while(node) { + var length = 0, x = node.offset, y = outputOffset; + while(y < targetSize && targetData[x++] == targetData[y++]) length++; + if(length > maxLength) maxLength = length, maxOffset = node.offset, mode = BPS_ACTION_TARGET_COPY; + node = node.next; + } + + //target tree append + node = new BPS_Node(); + node.offset = outputOffset; + node.next = targetTree[symbol]; + targetTree[symbol] = node; + } + + { //target read + if(maxLength < 4) { + maxLength = Math.min(Granularity, targetSize - outputOffset); + mode = BPS_ACTION_TARGET_READ; + } + } + + if(mode != BPS_ACTION_TARGET_READ) targetReadFlush(); + + switch(mode) { + case BPS_ACTION_SOURCE_READ: + //encode(BPS_ACTION_SOURCE_READ | ((maxLength - 1) << 2)); + patchActions.push({type:BPS_ACTION_SOURCE_READ, length:maxLength}); + break; + case BPS_ACTION_TARGET_READ: + //delay write to group sequential TargetRead commands into one + targetReadLength += maxLength; + break; + case BPS_ACTION_SOURCE_COPY: + case BPS_ACTION_TARGET_COPY: + //encode(mode | ((maxLength - 1) << 2)); + var relativeOffset; + if(mode == BPS_ACTION_SOURCE_COPY) { + relativeOffset = maxOffset - sourceRelativeOffset; + sourceRelativeOffset = maxOffset + maxLength; + } else { + relativeOffset = maxOffset - targetRelativeOffset; + targetRelativeOffset = maxOffset + maxLength; + } + //encode((relativeOffset < 0) | (abs(relativeOffset) << 1)); + patchActions.push({type:mode, length:maxLength, relativeOffset:relativeOffset}); + break; + } + + outputOffset += maxLength; + } + + targetReadFlush(); + + + return patchActions; +} \ No newline at end of file diff --git a/public/rom-patcher-js/modules/RomPatcher.format.ips.js b/public/rom-patcher-js/modules/RomPatcher.format.ips.js new file mode 100644 index 0000000..a7a4e87 --- /dev/null +++ b/public/rom-patcher-js/modules/RomPatcher.format.ips.js @@ -0,0 +1,282 @@ +/* IPS module for Rom Patcher JS v20250430 - Marc Robledo 2016-2025 - http://www.marcrobledo.com/license */ +/* File format specification: http://www.smwiki.net/wiki/IPS_file_format */ + +/* This file also acts as EBP (EarthBound Patch) module */ +/* EBP is actually just IPS with some JSON metadata stuck on the end (implementation: https://github.com/Lyrositor/EBPatcher) */ + +const IPS_MAGIC='PATCH'; +const IPS_MAX_ROM_SIZE=0x1000000; //16 megabytes +const IPS_RECORD_RLE=0x0000; +const IPS_RECORD_SIMPLE=0x01; + + +if(typeof module !== "undefined" && module.exports){ + module.exports = IPS; +} + + +function IPS(){ + this.records=[]; + this.truncate=false; + this.EBPmetadata=null; +} +IPS.prototype.addSimpleRecord=function(o, d){ + this.records.push({offset:o, type:IPS_RECORD_SIMPLE, length:d.length, data:d}) +} +IPS.prototype.addRLERecord=function(o, l, b){ + this.records.push({offset:o, type:IPS_RECORD_RLE, length:l, byte:b}) +} +IPS.prototype.setEBPMetadata=function(metadataObject){ + if(typeof metadataObject !== 'object') + throw new TypeError('metadataObject must be an object'); + for(var key in metadataObject){ + if(typeof metadataObject[key] !== 'string') + throw new TypeError('metadataObject values must be strings'); + } + + /* EBPatcher (linked above) expects the "patcher" field to be EBPatcher to read the metadata */ + /* CoilSnake (EB modding tool) inserts this manually too */ + /* So we also add it here for compatibility purposes */ + this.EBPmetadata={patcher:'EBPatcher', ...metadataObject}; +} +IPS.prototype.getDescription=function(){ + if(this.EBPmetadata){ + var description=''; + for(var key in this.EBPmetadata){ + if(key==='patcher') + continue; + + const keyPretty=key.charAt(0).toUpperCase() + key.slice(1); + description+=keyPretty+': '+this.EBPmetadata[key]+'\n'; + } + return description.trim(); + } + return null; +} +IPS.prototype.toString=function(){ + nSimpleRecords=0; + nRLERecords=0; + for(var i=0; iromFile.fileSize){ //expand (discussed here: https://github.com/marcrobledo/RomPatcher.js/pull/46) + tempFile=new BinFile(this.truncate); + romFile.copyTo(tempFile, 0, romFile.fileSize, 0); + }else{ //truncate + tempFile=romFile.slice(0, this.truncate); + } + }else{ + //calculate target ROM size, expanding it if any record offset is beyond target ROM size + var newFileSize=romFile.fileSize; + for(var i=0; inewFileSize){ + newFileSize=rec.offset+rec.length; + } + }else{ + if(rec.offset+rec.data.length>newFileSize){ + newFileSize=rec.offset+rec.data.length; + } + } + } + + if(newFileSize===romFile.fileSize){ + tempFile=romFile.slice(0, romFile.fileSize); + }else{ + tempFile=new BinFile(newFileSize); + romFile.copyTo(tempFile,0); + } + } + + + romFile.seek(0); + + for(var i=0; i6){ + // separate a potential RLE record + original.seek(startOffset); + modified.seek(startOffset); + previousRecord={type:0xdeadbeef,startOffset:0,length:0}; + }else{ + // merge both records + while(distance--){ + previousRecord.data.push(modified._u8array[previousRecord.offset+previousRecord.length]); + previousRecord.length++; + } + previousRecord.data=previousRecord.data.concat(differentData); + previousRecord.length=previousRecord.data.length; + } + }else{ + if(startOffset>=IPS_MAX_ROM_SIZE){ + throw new Error(`Files are too big for ${patch.EBPmetadata? 'EBP' : 'IPS'} format`); + return null; + } + + if(RLEmode && differentData.length>2){ + patch.addRLERecord(startOffset, differentData.length, differentData[0]); + }else{ + patch.addSimpleRecord(startOffset, differentData); + } + previousRecord=patch.records[patch.records.length-1]; + } + } + } + + + + + if(modified.fileSize>original.fileSize){ + var lastRecord=patch.records[patch.records.length-1]; + var lastOffset=lastRecord.offset+lastRecord.length; + + if(lastOffsetpatch.targetSize) + patch.targetSize=offset+length; + } + + return patch; +} + + + + + +/* to-do */ +//PMSR.prototype.export=function(fileName){return null} +//PMSR.buildFromRoms=function(original, modified){return null} + + +/* https://github.com/pho/WindViewer/wiki/Yaz0-and-Yay0 */ +PMSR.YAY0_decode=function(file){ + /* to-do */ +} \ No newline at end of file diff --git a/public/rom-patcher-js/modules/RomPatcher.format.ppf.js b/public/rom-patcher-js/modules/RomPatcher.format.ppf.js new file mode 100644 index 0000000..42a2c9b --- /dev/null +++ b/public/rom-patcher-js/modules/RomPatcher.format.ppf.js @@ -0,0 +1,269 @@ +/* PPF module for Rom Patcher JS v20200221 - Marc Robledo 2019-2020 - http://www.marcrobledo.com/license */ +/* File format specification: https://www.romhacking.net/utilities/353/ */ + + +const PPF_MAGIC='PPF'; +const PPF_IMAGETYPE_BIN=0x00; +const PPF_IMAGETYPE_GI=0x01; +const PPF_BEGIN_FILE_ID_DIZ_MAGIC='@BEG';//@BEGIN_FILE_ID.DIZ + +if(typeof module !== "undefined" && module.exports){ + module.exports = PPF; +} +function PPF(){ + this.version=3; + this.imageType=PPF_IMAGETYPE_BIN; + this.blockCheck=false; + this.undoData=false; + this.records=[]; +} +PPF.prototype.addRecord=function(offset, data, undoData){ + if(this.undoData){ + this.records.push({offset:offset, data:data, undoData:undoData}); + }else{ + this.records.push({offset:offset, data:data}); + } +} +PPF.prototype.toString=function(){ + var s=this.description; + s+='\nPPF version: '+this.version; + s+='\n#Records: '+this.records.length; + s+='\nImage type: '+this.imageType; + s+='\nBlock check: '+!!this.blockCheck; + s+='\nUndo data: '+this.undoData; + if(this.fileIdDiz) + s+='\nFILE_ID.DIZ: '+this.fileIdDiz; + return s +} +PPF.prototype.export=function(fileName){ + var patchFileSize=5+1+50; //PPFx0 + for(var i=0; i>>0); + tempFile.writeU32(offset2); + } + tempFile.writeU8(this.records[i].data.length); + tempFile.writeBytes(this.records[i].data); + if(this.undoData) + tempFile.writeBytes(this.records[i].undoData); + } + + if(this.fileIdDiz){ + tempFile.writeString('@BEGIN_FILE_ID.DIZ'); + tempFile.writeString(this.fileIdDiz); + tempFile.writeString('@END_FILE_ID.DIZ'); + tempFile.writeU16(this.fileIdDiz.length); + tempFile.writeU16(0x00); + } + + + + return tempFile +} +PPF.prototype.apply=function(romFile){ + var newFileSize=romFile.fileSize; + for(var i=0; inewFileSize) + newFileSize=this.records[i].offset+this.records[i].data.length; + } + if(newFileSize===romFile.fileSize){ + tempFile=romFile.slice(0, romFile.fileSize); + }else{ + tempFile=new BinFile(newFileSize); + romFile.copyTo(tempFile,0); + } + + //check if undoing + var undoingData=false; + if(this.undoData){ + tempFile.seek(this.records[0].offset); + var originalBytes=tempFile.readBytes(this.records[0].data.length); + var foundDifferences=false; + for(var i=0; i3){ + throw new Error('invalid PPF version'); + } + + patch.version=version1; + patch.description=patchFile.readString(50).replace(/ +$/,''); + + + + + if(patch.version===3){ + patch.imageType=patchFile.readU8(); + if(patchFile.readU8()) + patch.blockCheck=true; + if(patchFile.readU8()) + patch.undoData=true; + + patchFile.skip(1); + }else if(patch.version===2){ + patch.blockCheck=true; + patch.inputFileSize=patchFile.readU32(); + } + + if(patch.blockCheck){ + patch.blockCheck=patchFile.readBytes(1024); + } + + + + patchFile.littleEndian=true; + while(!patchFile.isEOF()){ + + if(patchFile.readString(4)===PPF_BEGIN_FILE_ID_DIZ_MAGIC){ + patchFile.skip(14); + //console.log('found file_id.diz begin'); + patch.fileIdDiz=patchFile.readString(3072); + patch.fileIdDiz=patch.fileIdDiz.substr(0, patch.fileIdDiz.indexOf('@END_FILE_ID.DIZ')); + break; + } + patchFile.skip(-4); + + var offset; + if(patch.version===3){ + var u64_1=patchFile.readU32(); + var u64_2=patchFile.readU32(); + offset=u64_1+(u64_2*0x100000000); + }else + offset=patchFile.readU32(); + + var len=patchFile.readU8(); + var data=patchFile.readBytes(len); + + var undoData=false; + if(patch.undoData){ + undoData=patchFile.readBytes(len); + } + + patch.addRecord(offset, data, undoData); + } + + + + return patch; +} + + +PPF.buildFromRoms=function(original, modified){ + var patch=new PPF(); + + patch.description='Patch description'; + + if(original.fileSize>modified.fileSize){ + var expandedModified=new BinFile(original.fileSize); + modified.copyTo(expandedModified,0); + modified=expandedModified; + } + + original.seek(0); + modified.seek(0); + while(!modified.isEOF()){ + var b1=original.isEOF()?0x00:original.readU8(); + var b2=modified.readU8(); + + if(b1!==b2){ + var differentData=[]; + var offset=modified.offset-1; + + while(b1!==b2 && differentData.length<0xff){ + differentData.push(b2); + + if(modified.isEOF() || differentData.length===0xff) + break; + + b1=original.isEOF()?0x00:original.readU8(); + b2=modified.readU8(); + } + + patch.addRecord(offset, differentData); + } + } + + if(original.fileSize byte ^ 0xff)); + }else if(patch.overflowMode==='M' && undo){ /* minify */ + tempFile.seek(patch.targetFileSize); + tempFile.writeBytes(patch.overflowData.map((byte) => byte ^ 0xff)); + } + + + if( + validate && + ( + (!undo && tempFile.hashMD5()!==patch.targetMD5) || + (undo && tempFile.hashMD5()!==patch.sourceMD5) + ) + ){ + throw new Error('Target ROM checksum mismatch'); + } + + if(undo) + tempFile.unpatched=true; + + return tempFile +} + +RUP.MAGIC=RUP_MAGIC; + +RUP.padZeroes=function(intVal, nBytes){ + var hexString=intVal.toString(16); + while(hexString.lengthtarget) or 'A' (source>=8; + } +} +function RUP_getVLVLen(data){ + var ret=1; + while(data){ + ret++; + data>>=8; + } + return ret; +} + + + + + + +RUP.prototype.export=function(fileName){ + var patchFileSize=2048; + for(var i=0; ifile.targetFileSize?'M':'A'); + patchFile.writeVLV(file.overflowData.length); + patchFile.writeBytes(file.overflowData); + } + + for(var j=0; j byte ^ 0xff); + modified=modified.slice(0, file.sourceFileSize); + }else if(file.sourceFileSize>file.targetFileSize){ + original.seek(file.targetFileSize); + file.overflowMode='M'; + file.overflowData=original.readBytes(file.sourceFileSize-file.targetFileSize).map((byte) => byte ^ 0xff); + original=original.slice(0, file.targetFileSize); + } + + + original.seek(0); + modified.seek(0); + + while(!modified.isEOF()){ + var b1=original.isEOF()?0x00:original.readU8(); + var b2=modified.readU8(); + + if(b1!==b2){ + var originalOffset=modified.offset-1; + var xorDifferences=[]; + + while(b1!==b2){ + xorDifferences.push(b1^b2); + + if(modified.isEOF()) + break; + + b1=original.isEOF()?0x00:original.readU8(); + b2=modified.readU8(); + } + + file.records.push({offset:originalOffset, xor:xorDifferences}); + } + } + + patch.files.push(file); + + return patch +} \ No newline at end of file diff --git a/public/rom-patcher-js/modules/RomPatcher.format.ups.js b/public/rom-patcher-js/modules/RomPatcher.format.ups.js new file mode 100644 index 0000000..56a91e9 --- /dev/null +++ b/public/rom-patcher-js/modules/RomPatcher.format.ups.js @@ -0,0 +1,224 @@ +/* UPS module for Rom Patcher JS v20240721 - Marc Robledo 2017-2024 - http://www.marcrobledo.com/license */ +/* File format specification: http://www.romhacking.net/documents/392/ */ + +const UPS_MAGIC='UPS1'; +if(typeof module !== "undefined" && module.exports){ + module.exports = UPS; +} +function UPS(){ + this.records=[]; + this.sizeInput=0; + this.sizeOutput=0; + this.checksumInput=0; + this.checksumOutput=0; +} +UPS.prototype.addRecord=function(relativeOffset, d){ + this.records.push({offset:relativeOffset, XORdata:d}) +} +UPS.prototype.toString=function(){ + var s='Records: '+this.records.length; + s+='\nInput file size: '+this.sizeInput; + s+='\nOutput file size: '+this.sizeOutput; + s+='\nInput file checksum: '+this.checksumInput.toString(16); + s+='\nOutput file checksum: '+this.checksumOutput.toString(16); + return s +} +UPS.prototype.export=function(fileName){ + var patchFileSize=UPS_MAGIC.length;//UPS1 string + patchFileSize+=UPS_getVLVLength(this.sizeInput); //input file size + patchFileSize+=UPS_getVLVLength(this.sizeOutput); //output file size + for(var i=0; i>7; + if(data===0){ + this.writeU8(0x80 | x); + break; + } + this.writeU8(x); + data=data-1; + } +} +function UPS_readVLV(){ + var data=0; + + var shift=1; + while(1){ + var x=this.readU8(); + + if(x==-1) + throw new Error('Can\'t read UPS VLV at 0x'+(this.offset-1).toString(16)); + + data+=(x&0x7f)*shift; + if((x&0x80)!==0) + break; + shift=shift<<7; + data+=shift; + } + return data +} +function UPS_getVLVLength(data){ + var len=0; + while(1){ + var x=data & 0x7f; + data=data>>7; + len++; + if(data===0){ + break; + } + data=data-1; + } + return len; +} + + +UPS.fromFile=function(file){ + var patch=new UPS(); + file.readVLV=UPS_readVLV; + + file.seek(UPS_MAGIC.length); + + patch.sizeInput=file.readVLV(); + patch.sizeOutput=file.readVLV(); + + + var nextOffset=0; + while(file.offset<(file.fileSize-12)){ + var relativeOffset=file.readVLV(); + + + var XORdifferences=[]; + while(file.readU8()){ + XORdifferences.push(file._lastRead); + } + patch.addRecord(relativeOffset, XORdifferences); + } + + file.littleEndian=true; + patch.checksumInput=file.readU32(); + patch.checksumOutput=file.readU32(); + + if(file.readU32()!==file.hashCRC32(0, file.fileSize - 4)){ + throw new Error('Patch checksum mismatch'); + } + + file.littleEndian=false; + return patch; +} + + + +UPS.buildFromRoms=function(original, modified){ + var patch=new UPS(); + patch.sizeInput=original.fileSize; + patch.sizeOutput=modified.fileSize; + + + var previousSeek=1; + while(!modified.isEOF()){ + var b1=original.isEOF()?0x00:original.readU8(); + var b2=modified.readU8(); + + if(b1!==b2){ + var currentSeek=modified.offset; + var XORdata=[]; + + while(b1!==b2){ + XORdata.push(b1 ^ b2); + + if(modified.isEOF()) + break; + b1=original.isEOF()?0x00:original.readU8(); + b2=modified.readU8(); + } + + patch.addRecord(currentSeek-previousSeek, XORdata); + previousSeek=currentSeek+XORdata.length+1; + } + } + + + patch.checksumInput=original.hashCRC32(); + patch.checksumOutput=modified.hashCRC32(); + return patch +} \ No newline at end of file diff --git a/public/rom-patcher-js/modules/RomPatcher.format.vcdiff.js b/public/rom-patcher-js/modules/RomPatcher.format.vcdiff.js new file mode 100644 index 0000000..ab0f3f9 --- /dev/null +++ b/public/rom-patcher-js/modules/RomPatcher.format.vcdiff.js @@ -0,0 +1,382 @@ +/* VCDIFF module for RomPatcher.js v20181021 - Marc Robledo 2018 - http://www.marcrobledo.com/license */ +/* File format specification: https://tools.ietf.org/html/rfc3284 */ +/* + Mostly based in: + https://github.com/vic-alexiev/TelerikAcademy/tree/master/C%23%20Fundamentals%20II/Homework%20Assignments/3.%20Methods/000.%20MiscUtil/Compression/Vcdiff + some code and ideas borrowed from: + https://hack64.net/jscripts/libpatch.js?6 +*/ +//const VCDIFF_MAGIC=0xd6c3c400; +const VCDIFF_MAGIC='\xd6\xc3\xc4'; +/* +const XDELTA_014_MAGIC='%XDELTA'; +const XDELTA_018_MAGIC='%XDZ000'; +const XDELTA_020_MAGIC='%XDZ001'; +const XDELTA_100_MAGIC='%XDZ002'; +const XDELTA_104_MAGIC='%XDZ003'; +const XDELTA_110_MAGIC='%XDZ004'; +*/ +if(typeof module !== "undefined" && module.exports){ + module.exports = VCDIFF; + BinFile = require("./BinFile"); +} + +function VCDIFF(patchFile){ + this.file=patchFile; +} +VCDIFF.prototype.toString=function(){ + return 'VCDIFF patch' +} + +VCDIFF.prototype.apply=function(romFile, validate){ + //romFile._u8array=new Uint8Array(romFile._dataView.buffer); + + //var t0=performance.now(); + var parser=new VCDIFF_Parser(this.file); + + //read header + parser.seek(4); + var headerIndicator=parser.readU8(); + + if(headerIndicator & VCD_DECOMPRESS){ + //has secondary decompressor, read its id + var secondaryDecompressorId=parser.readU8(); + + if(secondaryDecompressorId!==0) + throw new Error('not implemented: secondary decompressor'); + } + + + if(headerIndicator & VCD_CODETABLE){ + var codeTableDataLength=parser.read7BitEncodedInt(); + + if(codeTableDataLength!==0) + throw new Error('not implemented: custom code table'); // custom code table + } + + if(headerIndicator & VCD_APPHEADER){ + // ignore app header data + var appDataLength=parser.read7BitEncodedInt(); + parser.skip(appDataLength); + } + var headerEndOffset=parser.offset; + + //calculate target file size + var newFileSize=0; + while(!parser.isEOF()){ + var winHeader=parser.decodeWindowHeader(); + newFileSize+=winHeader.targetWindowLength; + parser.skip(winHeader.addRunDataLength + winHeader.addressesLength + winHeader.instructionsLength); + } + tempFile=new BinFile(newFileSize); + + + + + parser.seek(headerEndOffset); + + + + var cache = new VCD_AdressCache(4,3); + var codeTable = VCD_DEFAULT_CODE_TABLE; + + var targetWindowPosition = 0; //renombrar + + while(!parser.isEOF()){ + var winHeader = parser.decodeWindowHeader(); + + var addRunDataStream = new VCDIFF_Parser(this.file, parser.offset); + var instructionsStream = new VCDIFF_Parser(this.file, addRunDataStream.offset + winHeader.addRunDataLength); + var addressesStream = new VCDIFF_Parser(this.file, instructionsStream.offset + winHeader.instructionsLength); + + var addRunDataIndex = 0; + + cache.reset(addressesStream); + + var addressesStreamEndOffset = addressesStream.offset; + while(instructionsStream.offset0){ + this.near[this.nextNearSlot]=address; + this.nextNearSlot=(this.nextNearSlot+1)%this.nearSize; + } + + if(this.sameSize>0){ + this.same[address%(this.sameSize*256)]=address; + } +} \ No newline at end of file diff --git a/public/rom-patcher-js/modules/bz2/LICENSE b/public/rom-patcher-js/modules/bz2/LICENSE new file mode 100644 index 0000000..874dc3f --- /dev/null +++ b/public/rom-patcher-js/modules/bz2/LICENSE @@ -0,0 +1,19 @@ +Copyright 2019 SheetJS LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/public/rom-patcher-js/modules/bz2/bz2.js b/public/rom-patcher-js/modules/bz2/bz2.js new file mode 100644 index 0000000..93bafd3 --- /dev/null +++ b/public/rom-patcher-js/modules/bz2/bz2.js @@ -0,0 +1 @@ +/* code has been moved to RomPatcher.format.bdf.js because of incompatibility between webapp, web worker and CLI */ \ No newline at end of file diff --git a/public/rom-patcher-js/modules/zip.js/LICENSE b/public/rom-patcher-js/modules/zip.js/LICENSE new file mode 100644 index 0000000..d4b12d8 --- /dev/null +++ b/public/rom-patcher-js/modules/zip.js/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2013, Gildas Lormeau + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/public/rom-patcher-js/modules/zip.js/inflate.js b/public/rom-patcher-js/modules/zip.js/inflate.js new file mode 100644 index 0000000..3355821 --- /dev/null +++ b/public/rom-patcher-js/modules/zip.js/inflate.js @@ -0,0 +1,36 @@ +/* + Copyright (c) 2013 Gildas Lormeau. All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the distribution. + + 3. The names of the authors may not be used to endorse or promote products + derived from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JCRAFT, + INC. OR ANY CONTRIBUTORS TO THIS SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, + OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/* + * This program is based on JZlib 1.0.2 ymnk, JCraft,Inc. + * JZlib is based on zlib-1.1.3, so all credit should go authors + * Jean-loup Gailly(jloup@gzip.org) and Mark Adler(madler@alumni.caltech.edu) + * and contributors of zlib. + */ + +!function(i){"use strict";var P=0,q=1,B=-2,C=-3,x=-4,F=-5,G=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535],H=1440,a=[96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,192,80,7,10,0,8,96,0,8,32,0,9,160,0,8,0,0,8,128,0,8,64,0,9,224,80,7,6,0,8,88,0,8,24,0,9,144,83,7,59,0,8,120,0,8,56,0,9,208,81,7,17,0,8,104,0,8,40,0,9,176,0,8,8,0,8,136,0,8,72,0,9,240,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,200,81,7,13,0,8,100,0,8,36,0,9,168,0,8,4,0,8,132,0,8,68,0,9,232,80,7,8,0,8,92,0,8,28,0,9,152,84,7,83,0,8,124,0,8,60,0,9,216,82,7,23,0,8,108,0,8,44,0,9,184,0,8,12,0,8,140,0,8,76,0,9,248,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,196,81,7,11,0,8,98,0,8,34,0,9,164,0,8,2,0,8,130,0,8,66,0,9,228,80,7,7,0,8,90,0,8,26,0,9,148,84,7,67,0,8,122,0,8,58,0,9,212,82,7,19,0,8,106,0,8,42,0,9,180,0,8,10,0,8,138,0,8,74,0,9,244,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,204,81,7,15,0,8,102,0,8,38,0,9,172,0,8,6,0,8,134,0,8,70,0,9,236,80,7,9,0,8,94,0,8,30,0,9,156,84,7,99,0,8,126,0,8,62,0,9,220,82,7,27,0,8,110,0,8,46,0,9,188,0,8,14,0,8,142,0,8,78,0,9,252,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,194,80,7,10,0,8,97,0,8,33,0,9,162,0,8,1,0,8,129,0,8,65,0,9,226,80,7,6,0,8,89,0,8,25,0,9,146,83,7,59,0,8,121,0,8,57,0,9,210,81,7,17,0,8,105,0,8,41,0,9,178,0,8,9,0,8,137,0,8,73,0,9,242,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,202,81,7,13,0,8,101,0,8,37,0,9,170,0,8,5,0,8,133,0,8,69,0,9,234,80,7,8,0,8,93,0,8,29,0,9,154,84,7,83,0,8,125,0,8,61,0,9,218,82,7,23,0,8,109,0,8,45,0,9,186,0,8,13,0,8,141,0,8,77,0,9,250,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,198,81,7,11,0,8,99,0,8,35,0,9,166,0,8,3,0,8,131,0,8,67,0,9,230,80,7,7,0,8,91,0,8,27,0,9,150,84,7,67,0,8,123,0,8,59,0,9,214,82,7,19,0,8,107,0,8,43,0,9,182,0,8,11,0,8,139,0,8,75,0,9,246,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,206,81,7,15,0,8,103,0,8,39,0,9,174,0,8,7,0,8,135,0,8,71,0,9,238,80,7,9,0,8,95,0,8,31,0,9,158,84,7,99,0,8,127,0,8,63,0,9,222,82,7,27,0,8,111,0,8,47,0,9,190,0,8,15,0,8,143,0,8,79,0,9,254,96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,193,80,7,10,0,8,96,0,8,32,0,9,161,0,8,0,0,8,128,0,8,64,0,9,225,80,7,6,0,8,88,0,8,24,0,9,145,83,7,59,0,8,120,0,8,56,0,9,209,81,7,17,0,8,104,0,8,40,0,9,177,0,8,8,0,8,136,0,8,72,0,9,241,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,201,81,7,13,0,8,100,0,8,36,0,9,169,0,8,4,0,8,132,0,8,68,0,9,233,80,7,8,0,8,92,0,8,28,0,9,153,84,7,83,0,8,124,0,8,60,0,9,217,82,7,23,0,8,108,0,8,44,0,9,185,0,8,12,0,8,140,0,8,76,0,9,249,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,197,81,7,11,0,8,98,0,8,34,0,9,165,0,8,2,0,8,130,0,8,66,0,9,229,80,7,7,0,8,90,0,8,26,0,9,149,84,7,67,0,8,122,0,8,58,0,9,213,82,7,19,0,8,106,0,8,42,0,9,181,0,8,10,0,8,138,0,8,74,0,9,245,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,205,81,7,15,0,8,102,0,8,38,0,9,173,0,8,6,0,8,134,0,8,70,0,9,237,80,7,9,0,8,94,0,8,30,0,9,157,84,7,99,0,8,126,0,8,62,0,9,221,82,7,27,0,8,110,0,8,46,0,9,189,0,8,14,0,8,142,0,8,78,0,9,253,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,195,80,7,10,0,8,97,0,8,33,0,9,163,0,8,1,0,8,129,0,8,65,0,9,227,80,7,6,0,8,89,0,8,25,0,9,147,83,7,59,0,8,121,0,8,57,0,9,211,81,7,17,0,8,105,0,8,41,0,9,179,0,8,9,0,8,137,0,8,73,0,9,243,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,203,81,7,13,0,8,101,0,8,37,0,9,171,0,8,5,0,8,133,0,8,69,0,9,235,80,7,8,0,8,93,0,8,29,0,9,155,84,7,83,0,8,125,0,8,61,0,9,219,82,7,23,0,8,109,0,8,45,0,9,187,0,8,13,0,8,141,0,8,77,0,9,251,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,199,81,7,11,0,8,99,0,8,35,0,9,167,0,8,3,0,8,131,0,8,67,0,9,231,80,7,7,0,8,91,0,8,27,0,9,151,84,7,67,0,8,123,0,8,59,0,9,215,82,7,19,0,8,107,0,8,43,0,9,183,0,8,11,0,8,139,0,8,75,0,9,247,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,207,81,7,15,0,8,103,0,8,39,0,9,175,0,8,7,0,8,135,0,8,71,0,9,239,80,7,9,0,8,95,0,8,31,0,9,159,84,7,99,0,8,127,0,8,63,0,9,223,82,7,27,0,8,111,0,8,47,0,9,191,0,8,15,0,8,143,0,8,79,0,9,255],r=[80,5,1,87,5,257,83,5,17,91,5,4097,81,5,5,89,5,1025,85,5,65,93,5,16385,80,5,3,88,5,513,84,5,33,92,5,8193,82,5,9,90,5,2049,86,5,129,192,5,24577,80,5,2,87,5,385,83,5,25,91,5,6145,81,5,7,89,5,1537,85,5,97,93,5,24577,80,5,4,88,5,769,84,5,49,92,5,12289,82,5,13,90,5,3073,86,5,193,192,5,24577],w=[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,0,0],c=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,112,112],v=[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577],h=[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13],D=15;function J(){var f,o,E,S,U,z;function b(i,t,e,n,a,r,_,l,d,s,f){var o,b,u,x,w,c,v,h,k,m,y,g,p,I,A;for(m=0,w=e;E[i[t+m]]++,m++,0!==--w;);if(E[0]==e)return _[0]=-1,l[0]=0,P;for(h=l[0],c=1;c<=D&&0===E[c];c++);for(h<(v=c)&&(h=c),w=D;0!==w&&0===E[w];w--);for((u=w)o+1&&(b-=o+1,p=v,cH)return C;U[x]=y=s[0],s[0]+=A,0!==x?(z[x]=w,S[0]=c,c=w>>>g-(S[1]=h),S[2]=y-U[x-1]-c,d.set(S,3*(U[x-1]+c))):_[0]=y}for(S[1]=v-g,e<=m?S[0]=192:f[m]>>g;c>>=1)w^=c;for(w^=c,k=(1<>=s[p+1],u-=s[p+1],0!=(16&o)){for(o&=15,m=s[p+2]+(b&G[o]),b>>=o,u-=o;u<15;)w--,b|=(255&l.read_byte(x++))<>=s[p+1],u-=s[p+1],0!=(16&o)){for(o&=15;u>=o,u-=o,v-=m,y<=c)0>3<(m=l.avail_in-w)?u>>3:m,x-=m,u-=m<<3,_.bitb=b,_.bitk=u,l.avail_in=w,l.total_in+=x-l.next_in_index,l.next_in_index=x,_.write=c,C;d+=s[p+2],o=s[p=3*(f+(d+=b&G[o]))]}break}if(0!=(64&o))return 0!=(32&o)?(w+=m=u>>3<(m=l.avail_in-w)?u>>3:m,x-=m,u-=m<<3,_.bitb=b,_.bitk=u,l.avail_in=w,l.total_in+=x-l.next_in_index,l.next_in_index=x,_.write=c,q):(l.msg="invalid literal/length code",w+=m=u>>3<(m=l.avail_in-w)?u>>3:m,x-=m,u-=m<<3,_.bitb=b,_.bitk=u,l.avail_in=w,l.total_in+=x-l.next_in_index,l.next_in_index=x,_.write=c,C);if(d+=s[p+2],0===(o=s[p=3*(f+(d+=b&G[o]))])){b>>=s[p+1],u-=s[p+1],_.window[c++]=s[p+2],v--;break}}else b>>=s[p+1],u-=s[p+1],_.window[c++]=s[p+2],v--}while(258<=v&&10<=w);return w+=m=u>>3<(m=l.avail_in-w)?u>>3:m,x-=m,u-=m<<3,_.bitb=b,_.bitk=u,l.avail_in=w,l.total_in+=x-l.next_in_index,l.next_in_index=x,_.write=c,P}this.init=function(i,t,e,n,a,r){u=U,p=i,I=t,w=e,A=n,c=a,E=r,x=null},this.proc=function(i,t,e){var n,a,r,_,l,d,s,f=0,o=0,b=0;for(b=t.next_in_index,_=t.avail_in,f=i.bitb,o=i.bitk,d=(l=i.write)>>=x[a+1],o-=x[a+1],0===(r=x[a])){m=x[a+2],u=N;break}if(0!=(16&r)){y=15&r,v=x[a+2],u=j;break}if(0==(64&r)){k=r,h=a/3+x[a+2];break}if(0==(32&r))return u=R,t.msg="invalid literal/length code",e=C,i.bitb=f,i.bitk=o,t.avail_in=_,t.total_in+=b-t.next_in_index,t.next_in_index=b,i.write=l,i.inflate_flush(t,e);u=O;break;case j:for(n=y;o>=n,o-=n,k=I,x=c,h=E,u=K;case K:for(n=k;o>=x[a+1],o-=x[a+1],0!=(16&(r=x[a]))){y=15&r,g=x[a+2],u=L;break}if(0!=(64&r))return u=R,t.msg="invalid distance code",e=C,i.bitb=f,i.bitk=o,t.avail_in=_,t.total_in+=b-t.next_in_index,t.next_in_index=b,i.write=l,i.inflate_flush(t,e);k=r,h=a/3+x[a+2];break;case L:for(n=y;o>=n,o-=n,u=M;case M:for(s=l-g;s<0;)s+=i.end;for(;0!==v;){if(0===d&&(l==i.end&&0!==i.read&&(d=(l=0)i.avail_out&&(e=i.avail_out),0!==e&&t==F&&(t=P),i.avail_out-=e,i.total_out+=e,i.next_out.set(y.window.subarray(a,a+e),n),n+=e,(a+=e)==y.end&&(a=0,y.write==y.end&&(y.write=0),(e=y.write-a)>i.avail_out&&(e=i.avail_out),0!==e&&t==F&&(t=P),i.avail_out-=e,i.total_out+=e,i.next_out.set(y.window.subarray(a,a+e),n),n+=e,a+=e),i.next_out_index=n,y.read=a,t},y.proc=function(i,t){var e,n,a,r,_,l,d,s;for(r=i.next_in_index,_=i.avail_in,n=y.bitb,a=y.bitk,d=(l=y.write)>>1){case 0:n>>>=3,n>>>=e=7&(a-=3),a-=e,g=W;break;case 1:var f=[],o=[],b=[[]],u=[[]];J.inflate_trees_fixed(f,o,b,u),U.init(f[0],o[0],b[0],0,u[0],0),n>>>=3,a-=3,g=ii;break;case 2:n>>>=3,a-=3,g=Y;break;case 3:return n>>>=3,a-=3,g=ni,i.msg="invalid block type",t=C,y.bitb=n,y.bitk=a,i.avail_in=_,i.total_in+=r-i.next_in_index,i.next_in_index=r,y.write=l,y.inflate_flush(i,t)}break;case W:for(;a<32;){if(0===_)return y.bitb=n,y.bitk=a,i.avail_in=_,i.total_in+=r-i.next_in_index,i.next_in_index=r,y.write=l,y.inflate_flush(i,t);t=P,_--,n|=(255&i.read_byte(r++))<>>16&65535)!=(65535&n))return g=ni,i.msg="invalid stored block lengths",t=C,y.bitb=n,y.bitk=a,i.avail_in=_,i.total_in+=r-i.next_in_index,i.next_in_index=r,y.write=l,y.inflate_flush(i,t);p=65535&n,n=a=0,g=0!==p?X:0!==z?ti:V;break;case X:if(0===_)return y.bitb=n,y.bitk=a,i.avail_in=_,i.total_in+=r-i.next_in_index,i.next_in_index=r,y.write=l,y.inflate_flush(i,t);if(0===d&&(l==y.end&&0!==y.read&&(d=(l=0)>5&31))return g=ni,i.msg="too many length or distance symbols",t=C,y.bitb=n,y.bitk=a,i.avail_in=_,i.total_in+=r-i.next_in_index,i.next_in_index=r,y.write=l,y.inflate_flush(i,t);if(e=258+(31&e)+(e>>5&31),!m||m.length>>=14,a-=14,A=0,g=Z;case Z:for(;A<4+(I>>>10);){for(;a<3;){if(0===_)return y.bitb=n,y.bitk=a,i.avail_in=_,i.total_in+=r-i.next_in_index,i.next_in_index=r,y.write=l,y.inflate_flush(i,t);t=P,_--,n|=(255&i.read_byte(r++))<>>=3,a-=3}for(;A<19;)m[T[A++]]=0;if(E[0]=7,(e=j.inflate_trees_bits(m,E,S,D,i))!=P)return(t=e)==C&&(m=null,g=ni),y.bitb=n,y.bitk=a,i.avail_in=_,i.total_in+=r-i.next_in_index,i.next_in_index=r,y.write=l,y.inflate_flush(i,t);A=0,g=$;case $:for(;!(258+(31&(e=I))+(e>>5&31)<=A);){var x,w;for(e=E[0];a>>=e,a-=e,m[A++]=w;else{for(s=18==w?7:w-14,x=18==w?11:3;a>>=e)&G[s],n>>>=s,a-=s,258+(31&(e=I))+(e>>5&31)<(s=A)+x||16==w&&s<1)return m=null,g=ni,i.msg="invalid bit length repeat",t=C,y.bitb=n,y.bitk=a,i.avail_in=_,i.total_in+=r-i.next_in_index,i.next_in_index=r,y.write=l,y.inflate_flush(i,t);for(w=16==w?m[s-1]:0;m[s++]=w,0!=--x;);A=s}}S[0]=-1;var c=[],v=[],h=[],k=[];if(c[0]=9,v[0]=6,e=I,(e=j.inflate_trees_dynamic(257+(31&e),1+(e>>5&31),m,c,v,h,k,D,i))!=P)return e==C&&(m=null,g=ni),t=e,y.bitb=n,y.bitk=a,i.avail_in=_,i.total_in+=r-i.next_in_index,i.next_in_index=r,y.write=l,y.inflate_flush(i,t);U.init(c[0],v[0],D,h[0],D,k[0]),g=ii;case ii:if(y.bitb=n,y.bitk=a,i.avail_in=_,i.total_in+=r-i.next_in_index,i.next_in_index=r,y.write=l,(t=U.proc(y,i,t))!=q)return y.inflate_flush(i,t);if(t=P,U.free(i),r=i.next_in_index,_=i.avail_in,n=y.bitb,a=y.bitk,d=(l=y.write)>4)>i.istate.wbits){i.istate.mode=13,i.msg="invalid window size",i.istate.marker=5;break}i.istate.mode=1;case 1:if(0===i.avail_in)return e;if(e=t,i.avail_in--,i.total_in++,n=255&i.read_byte(i.next_in_index++),((i.istate.method<<8)+n)%31!=0){i.istate.mode=13,i.msg="incorrect header check",i.istate.marker=5;break}if(0==(32&n)){i.istate.mode=7;break}i.istate.mode=2;case 2:if(0===i.avail_in)return e;e=t,i.avail_in--,i.total_in++,i.istate.need=(255&i.read_byte(i.next_in_index++))<<24&4278190080,i.istate.mode=3;case 3:if(0===i.avail_in)return e;e=t,i.avail_in--,i.total_in++,i.istate.need+=(255&i.read_byte(i.next_in_index++))<<16&16711680,i.istate.mode=4;case 4:if(0===i.avail_in)return e;e=t,i.avail_in--,i.total_in++,i.istate.need+=(255&i.read_byte(i.next_in_index++))<<8&65280,i.istate.mode=5;case 5:return 0===i.avail_in?e:(e=t,i.avail_in--,i.total_in++,i.istate.need+=255&i.read_byte(i.next_in_index++),i.istate.mode=6,2);case 6:return i.istate.mode=13,i.msg="need dictionary",i.istate.marker=0,B;case 7:if((e=i.istate.blocks.proc(i,e))==C){i.istate.mode=13,i.istate.marker=0;break}if(e==P&&(e=t),e!=q)return e;e=t,i.istate.blocks.reset(i,i.istate.was),i.istate.mode=12;case 12:return q;case 13:return C;default:return B}},e.inflateSetDictionary=function(i,t,e){var n=0,a=e;return i&&i.istate&&6==i.istate.mode?(a>=1<>>8^r[255&(e^t[c])];this.crc=e},n.prototype.get=function(){return~this.crc},n.prototype.table=function(){var t,e,r,c=[];for(t=0;t<256;t++){for(r=t,e=0;e<8;e++)1&r?r=r>>>1^3988292384:r>>>=1;c[t]=r}return c}(),(c.NOOP=e).prototype.append=function(t,e){return t},e.prototype.flush=function(){}}(this); \ No newline at end of file diff --git a/public/rom-patcher-js/modules/zip.js/zip.min.js b/public/rom-patcher-js/modules/zip.js/zip.min.js new file mode 100644 index 0000000..620e021 --- /dev/null +++ b/public/rom-patcher-js/modules/zip.js/zip.min.js @@ -0,0 +1,28 @@ +/* + Copyright (c) 2013 Gildas Lormeau. All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the distribution. + + 3. The names of the authors may not be used to endorse or promote products + derived from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JCRAFT, + INC. OR ANY CONTRIBUTORS TO THIS SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, + OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +!function(b){"use strict";var o,k="File format is not recognized.",a="File contains encrypted entry.",s="File is using Zip64 (4gb+ file size).",w="Error while reading zip file.",n="Error while reading file data.",y=524288,c="text/plain";try{o=0===new Blob([new DataView(new ArrayBuffer(0))]).size}catch(e){}function r(){this.crc=-1}function l(){}function A(e,t){var r,n;return r=new ArrayBuffer(e),n=new Uint8Array(r),t&&n.set(t,0),{buffer:r,array:n,view:new DataView(r)}}function e(){}function t(n){var i,o=this;o.size=0,o.init=function(e,t){var r=new Blob([n],{type:c});(i=new f(r)).init(function(){o.size=i.size,e()},t)},o.readUint8Array=function(e,t,r,n){i.readUint8Array(e,t,r,n)}}function i(f){var u,r=this;r.size=0,r.init=function(e){for(var t=f.length;"="==f.charAt(t-1);)t--;u=f.indexOf(",")+1,r.size=Math.floor(.75*(t-u)),e()},r.readUint8Array=function(e,t,r){var n,i=A(t),o=4*Math.floor(e/3),a=4*Math.ceil((e+t)/3),s=b.atob(f.substring(o+u,a+u)),c=e-3*Math.floor(o/4);for(n=c;ne.size)throw new RangeError("offset:"+t+", length:"+r+", size:"+e.size);return e.slice?e.slice(t,t+r):e.webkitSlice?e.webkitSlice(t,t+r):e.mozSlice?e.mozSlice(t,t+r):e.msSlice?e.msSlice(t,t+r):void 0}(o,e,t))}catch(e){n(e)}}}function u(){}function h(n){var i;this.init=function(e){i=new Blob([],{type:c}),e()},this.writeUint8Array=function(e,t){i=new Blob([i,o?e:e.buffer],{type:c}),t()},this.getData=function(t,e){var r=new FileReader;r.onload=function(e){t(e.target.result)},r.onerror=e,r.readAsText(i,n)}}function p(t){var o="",a="";this.init=function(e){o+="data:"+(t||"")+";base64,",e()},this.writeUint8Array=function(e,t){var r,n=a.length,i=a;for(a="",r=0;r<3*Math.floor((n+e.length)/3)-n;r++)i+=String.fromCharCode(e[r]);for(;r>16,r=65535&e;try{return new Date(1980+((65024&t)>>9),((480&t)>>5)-1,31&t,(63488&r)>>11,(2016&r)>>5,2*(31&r),0)}catch(e){}}(e.lastModDateRaw),1!=(1&e.bitFlag)?((n||8!=(8&e.bitFlag))&&(e.crc32=t.view.getUint32(r+10,!0),e.compressedSize=t.view.getUint32(r+14,!0),e.uncompressedSize=t.view.getUint32(r+18,!0)),4294967295!==e.compressedSize&&4294967295!==e.uncompressedSize?(e.filenameLength=t.view.getUint16(r+22,!0),e.extraFieldLength=t.view.getUint16(r+24,!0)):i(s)):i(a)}function m(m,t,U){var z=0;function l(){}l.prototype.getData=function(w,i,h,p){var v=this;function d(e,t){var r,n;p&&(r=t,(n=A(4)).view.setUint32(0,r),v.crc32!=n.view.getUint32(0))?U("CRC failed."):w.getData(function(e){i(e)})}function g(e){U(e||n)}function y(e){U(e||"Error while writing file data.")}m.readUint8Array(v.offset,30,function(e){var l,t=A(e.length,e);1347093252==t.view.getUint32(0)?(M(v,t,4,!1,U),l=v.offset+30+v.filenameLength+v.extraFieldLength,w.init(function(){var e,t,r,n,i,o,a,s,c,f,u;0===v.compressionMethod?D(v._worker,z++,m,w,l,v.compressedSize,p,d,h,g,y):(e=v._worker,t=z++,r=m,n=w,i=l,o=v.compressedSize,a=d,s=h,c=g,f=y,u=p?"output":"none",b.zip.useWebWorkers?S(e,{sn:t,codecClass:"Inflater",crcType:u},r,n,i,o,s,a,c,f):_(new b.zip.Inflater,r,n,i,o,u,s,a,c,f))},y)):U(k)},g)};var r={getEntries:function(f){var u=this._worker;!function(n){var i=22;if(m.size=m.size?U(k):m.readUint8Array(t,m.size-t,function(e){var t,r,n,i,o=0,a=[],s=A(e.length,e);for(t=0;t>>8^r[255&(t^e[n])];this.crc=t},r.prototype.get=function(){return~this.crc},r.prototype.table=function(){var e,t,r,n=[];for(e=0;e<256;e++){for(r=e,t=0;t<8;t++)1&r?r=r>>>1^3988292384:r>>>=1;n[e]=r}return n}(),l.prototype.append=function(e,t){return e},l.prototype.flush=function(){},(t.prototype=new e).constructor=t,(i.prototype=new e).constructor=i,(f.prototype=new e).constructor=f,u.prototype.getData=function(e){e(this.data)},(h.prototype=new u).constructor=h,(p.prototype=new u).constructor=p,(v.prototype=new u).constructor=v;var C={deflater:["z-worker.js","deflate.js"],inflater:["z-worker.js","inflate.js"]};function E(e,n,i){if(null===b.zip.workerScripts||null===b.zip.workerScriptsPath){var t,r,o;if(b.zip.workerScripts){if(t=b.zip.workerScripts[e],!Array.isArray(t))return void i(new Error("zip.workerScripts."+e+" is not an array!"));r=t,o=document.createElement("a"),t=r.map(function(e){return o.href=e,o.href})}else(t=C[e].slice(0))[0]=(b.zip.workerScriptsPath||"")+t[0];var a=new Worker(t[0]);a.codecTime=a.crcTime=0,a.postMessage({type:"importScripts",scripts:t.slice(1)}),a.addEventListener("message",function e(t){var r=t.data;if(r.error)return a.terminate(),void i(r.error);"importScripts"===r.type&&(a.removeEventListener("message",e),a.removeEventListener("error",s),n(a))}),a.addEventListener("error",s)}else i(new Error("Either zip.workerScripts or zip.workerScriptsPath may be set, not both."));function s(e){a.terminate(),i(e)}}function F(e){console.error(e)}b.zip={Reader:e,Writer:u,BlobReader:f,Data64URIReader:i,TextReader:t,BlobWriter:v,Data64URIWriter:p,TextWriter:h,createReader:function(e,t,r){r=r||F,e.init(function(){m(e,t,r)},r)},createWriter:function(e,t,r,n){r=r||F,n=!!n,e.init(function(){U(e,t,r,n)},r)},useWebWorkers:!0,workerScriptsPath:null,workerScripts:null}}(this); diff --git a/resources/css/app.css b/resources/css/app.css index 0a672c9..60437ec 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -12,6 +12,14 @@ @import './components/cards.css'; @import './components/modal.css'; @import './components/files.css'; +@import './components/database.css'; +@import './components/hovercard.css'; +@import './components/notifications.css'; +@import './components/settings.css'; +@import './components/queue.css'; +@import './components/drafts.css'; +@import './components/modcp.css'; +@import './components/tools.css'; @import './components/easymde.css'; diff --git a/resources/css/base/reset.css b/resources/css/base/reset.css index 94d8975..53bf823 100644 --- a/resources/css/base/reset.css +++ b/resources/css/base/reset.css @@ -35,3 +35,5 @@ ul { margin-bottom: 20px; list-style-type: square; } + +[x-cloak] {display: none !important;} diff --git a/resources/css/base/variables.css b/resources/css/base/variables.css index ae56c03..5dd4727 100644 --- a/resources/css/base/variables.css +++ b/resources/css/base/variables.css @@ -29,3 +29,29 @@ --menu-size: 260px; --menu-user-avatar-bg: #555; } + +.light-mode { + + /* RHPZ color */ + --rhpz-orange: #ff7300; + --rhpz-orange-hover: #e56700; + + /* Background colors */ + --bg: #f0f0f0; + --bg2: #ffffff; + --bg3: #e8e8e8; + --bg4: #dcdcdc; + + /* Text */ + --text: #454545; + --text2: #737373; + --text3: #111111; + + /* Elements */ + --border: #d0d0d0; + --error: #e57373; + --info: #1976d2; + --success: #81c784; + --success2: #388e3c; + +} diff --git a/resources/css/components/cards.css b/resources/css/components/cards.css index a7dd084..ba5d9f9 100644 --- a/resources/css/components/cards.css +++ b/resources/css/components/cards.css @@ -22,3 +22,78 @@ width: 32px; height: 32px; } + +/* ENTRY CARDS */ + +.entry-card { + background-color: var(--bg2); + border: 1px solid var(--border); + display: flex; + flex-direction: column; + transition: transform 0.2s, border-color 0.2s; + cursor: pointer; + + &:hover { + transform: translateY(-3px); + border-color: var(--rhpz-orange); + } + + .entry-cover-wrapper { + position: relative; + aspect-ratio: 4/3; + background-color: var(--bg); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + } + + .entry-cover-wrapper img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .entry-badge { + position: absolute; + top: 10px; + right: 10px; + background-color: rgba(0,0,0,0.7); + backdrop-filter: blur(4px); + border: 1px solid var(--border); + padding: 4px 8px; + font-size: 0.75rem; + color: var(--text); + } + + .entry-card-info { + padding: 15px; + flex-grow: 1; + display: flex; + flex-direction: column; + } + + .entry-card-title { + font-weight: 600; + color: var(--text); + font-size: 1.1rem; + margin-bottom: 5px; + line-height: 1.3; + } + + .entry-card-author { + color: var(--rhpz-orange); + font-size: 0.85rem; + margin-bottom: 10px; + } + + .entry-card-meta { + margin-top: auto; + font-size: 0.8rem; + color: var(--text2); + display: flex; + justify-content: space-between; + align-items: center; + } +} diff --git a/resources/css/components/common.css b/resources/css/components/common.css index 34cd862..c74aa62 100644 --- a/resources/css/components/common.css +++ b/resources/css/components/common.css @@ -64,6 +64,13 @@ border-bottom: 1px solid var(--border); padding-bottom: 10px; } +.block-success { + background-color: var(--success); + border: 1px solid var(--success); + color: var(--text); + padding: 20px; + margin-bottom: 20px; +} .block-error { background-color: var(--error); border: 1px solid var(--error); @@ -87,17 +94,49 @@ color: var(--text3); border-color: var(--rhpz-orange); } -.badge.blue { +.badge.blue, .badge.translations { background-color: var(--info); color: var(--text); border-color: var(--info); } -.badge.green { +.badge.green, .badge.romhacks { background-color: var(--success2); color: var(--text); border-color: var(--success2); } +.topbar-badge { + position: absolute; + top: 0; + right: 0; + min-width: 18px; + height: 18px; + padding: 0 4px; + border-radius: 9px; + background-color: var(--rhpz-orange); + color: #111; + font-size: 0.65rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + border: 2px solid var(--bg); + animation: badge-pop 0.2s ease; +} + +.topbar-badge--overflow { + border-radius: 9px; + padding: 0 5px; + font-size: 0.6rem; +} + +@keyframes badge-pop { + 0% { transform: scale(0.5); opacity: 0; } + 70% { transform: scale(1.2); } + 100% { transform: scale(1); opacity: 1; } +} + /* BREADCRUMB */ .breadcrumb { @@ -143,3 +182,9 @@ .spin { animation: spin 1s infinite linear; } + +.search-button { + background: none; + border: none; + cursor: pointer; +} diff --git a/resources/css/components/database.css b/resources/css/components/database.css new file mode 100644 index 0000000..c406ad6 --- /dev/null +++ b/resources/css/components/database.css @@ -0,0 +1,391 @@ +.filter-bar { + display: flex; + gap: 15px; + background-color: var(--bg2); + padding: 15px; + border: 1px solid var(--border); + margin-bottom: 20px; + flex-wrap: wrap; + align-items: center; + + .filter-bar-search { + flex: 1; + max-width: 400px; + background-color: var(--bg); + border: 1px solid var(--border); + display: flex; + align-items: center; + padding: 8px 12px; + gap: 8px; + + } +} + +.database-wrapper { + display: flex; + gap: 20px; + align-items: flex-start; + + .database-filters { + width: 300px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 2px; + background-color: var(--bg2); + border: 1px solid var(--border); + + .filter-group { + border-bottom: 1px solid var(--border); + overflow: hidden; + &:last-child { + border-bottom: none; + } + } + + .filter-title-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background-color: var(--bg3); + cursor: pointer; + user-select: none; + + .filter-title { + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text2); + margin: 0; + } + + } + + .filter-mode { + display: flex; + gap: 4px; + } + + .filter-btn-mode { + background: none; + border: 1px solid var(--border); + color: var(--text2); + font-size: 0.7rem; + font-weight: 600; + padding: 2px 7px; + cursor: pointer; + font-family: var(--typography); + transition: all 0.15s; + letter-spacing: 0.5px; + &:hover { + border-color: var(--rhpz-orange); + color: var(--rhpz-orange); + } + &.active { + background-color: var(--rhpz-orange); + border-color: var(--rhpz-orange); + color: var(--text3); + } + } + + .filter-options { + padding: 6px 0; + max-height: 180px; + overflow-y: auto; + &::-webkit-scrollbar { + width: 6px; + } + &::-webkit-scrollbar-track { + background: var(--bg2); + } + &::-webkit-scrollbar-thumb { + background: var(--border); + } + } + + .filter-option { + display: flex; + align-items: center; + gap: 9px; + padding: 6px 14px; + font-size: 0.88rem; + color: var(--text); + cursor: pointer; + transition: background-color 0.1s; + &:hover { + background-color: var(--bg4); + } + } + + .filter-option input[type="checkbox"] { + accent-color: var(--rhpz-orange); + width: 14px; + height: 14px; + cursor: pointer; + flex-shrink: 0; + } + + } + + .database-results { + flex: 1; + min-width: 0; + + .database-sort { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 15px; + padding-bottom: 15px; + border-bottom: 1px solid var(--border); + flex-wrap: wrap; + + .btn { + font-size: 0.85rem; + padding: 6px 12px; + &.active { + border-color: var(--rhpz-orange); + color: var(--rhpz-orange); + } + } + + } + + .database-results-count { + margin-left: auto; + font-size: 0.85rem; + color: var(--text2); + } + + .database-active-filters { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 15px; + } + + .database-active-filter-tag { + display: inline-flex; + align-items: center; + gap: 6px; + background-color: var(--bg3); + border: 1px solid var(--border); + padding: 3px 10px; + font-size: 0.8rem; + color: var(--text); + .tag-type { + color: var(--rhpz-orange); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.5px; + } + button { + background: none; + border: none; + color: var(--text2); + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + transition: color 0.15s; + &:hover { + color: var(--text); + } + } + } + + .database-empty { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + color: var(--text2); + background-color: var(--bg2); + border: 1px solid var(--border); + gap: 15px; + text-align: center; + + i { + color: var(--border); + } + + p { + font-size: 0.95rem; + } + } + + } + +} + +.database-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 4px; + margin-top: 20px; + + .btn { + min-width: 36px; + padding: 6px 10px; + font-size: 0.85rem; + display: flex; + align-items: center; + justify-content: center; + } + + .active { + background-color: var(--rhpz-orange); + border-color: var(--rhpz-orange); + color: #111; + font-weight: 600; + } +} + +.database-search { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 20px; +} + +@media (max-width: 900px) { + .database-layout { + flex-direction: column; + } + + .database-filters { + width: 100%; + display: grid; + grid-template-columns: repeat(2,1fr); + } + + .database-filter-group:last-child { + border-bottom: 1px solid var(--border); + } + + .database-results-count { + margin-left: 0; + width: 100%; + } +} + +@media (max-width: 600px) { + .database-filters { + grid-template-columns: 1fr; + } + + .grid-entries { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 420px) { + .grid-entries { + grid-template-columns: 1fr; + } +} + +.filter-chevron { + transition: transform 0.2s ease; + color: var(--text2); + flex-shrink: 0; +} + +.filter-chevron.rotated { + transform: rotate(-90deg); +} + +.internal-filter-search { + display: flex; + align-items: center; + gap: 7px; + padding: 7px 14px; + border-bottom: 1px solid var(--border); + background-color: var(--bg); + + i { + color: var(--text2); + flex-shrink: 0; + } + + input { + background: none; + border: none; + outline: none; + color: var(--text); + font-family: var(--typography); + font-size: 0.85rem; + width: 100%; + + &::placeholder { + color: var(--text2); + } + } +} + +.filter-title-left { + display: flex; + align-items: center; + gap: 7px; +} + +.filter-title-right { + display: flex; + align-items: center; + gap: 6px; +} + +.internal-filter-count { + display: inline-flex; + align-items: center; + justify-content: center; + background-color: var(--rhpz-orange); + color: #111; + font-size: 0.7rem; + font-weight: 700; + min-width: 18px; + height: 18px; + padding: 0 5px; + line-height: 1; +} + +.internal-filter-clear { + background: none; + border: 1px solid var(--border); + color: var(--text2); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + transition: all 0.15s; + + &:hover { + border-color: var(--error); + color: var(--error); + } +} + +.filter-search-clear { + background: none; + border: none; + color: var(--text2); + cursor: pointer; + display: flex; + align-items: center; + padding: 0; + flex-shrink: 0; + transition: color 0.15s; + &:hover { + color: var(--text); + } +} + diff --git a/resources/css/components/drafts.css b/resources/css/components/drafts.css new file mode 100644 index 0000000..d2ce20d --- /dev/null +++ b/resources/css/components/drafts.css @@ -0,0 +1,160 @@ +.drafts-count { + font-size: 0.85rem; + color: var(--text2); + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border); +} + +.drafts-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 15px; + padding: 80px 20px; + background-color: var(--bg2); + border: 1px dashed var(--border); + text-align: center; + color: var(--text2); + + h3 { + font-size: 1.1rem; + color: var(--text); + margin: 0; + } + + p { + font-size: 0.9rem; + margin: 0; + } + +} + +.drafts-list { + display: flex; + flex-direction: column; + gap: 15px; +} + +.drafts-item { + display: flex; + gap: 20px; + background-color: var(--bg2); + border: 1px solid var(--border); + border-left: 3px solid var(--rhpz-orange); + padding: 20px; + transition: border-color 0.15s; + + &:hover { + border-color: var(--rhpz-orange); + } +} + +.drafts-cover { + width: 80px; + height: 80px; + flex-shrink: 0; + background-color: var(--bg); + border: 1px solid var(--border); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } +} + +.drafts-cover-placeholder { + color: var(--border); +} + +.drafts-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.drafts-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 15px; +} + +.drafts-title { + font-size: 1rem; + font-weight: 600; + color: var(--text); + margin-bottom: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.drafts-meta { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.drafts-dates { + display: flex; + flex-direction: column; + gap: 4px; + text-align: right; + font-size: 0.78rem; + color: var(--text2); + flex-shrink: 0; + + span { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 5px; + } +} + +.drafts-progress { + display: flex; + align-items: center; + gap: 10px; +} + +.drafts-progress-bar { + flex: 1; + height: 4px; + background-color: var(--bg4); + overflow: hidden; +} + +.drafts-progress-fill { + height: 100%; + background-color: var(--rhpz-orange); + transition: width 0.3s ease; + + .complete { + background-color: var(--success); + } +} +.drafts-progress-label { + font-size: 0.75rem; + color: var(--text2); + white-space: nowrap; +} +.drafts-actions { + display: flex; + flex-direction: column; + gap: 8px; + justify-content: center; + flex-shrink: 0; + .btn { + white-space: nowrap; + } +} diff --git a/resources/css/components/forms.css b/resources/css/components/forms.css index 1c41c68..f1fa4ff 100644 --- a/resources/css/components/forms.css +++ b/resources/css/components/forms.css @@ -459,3 +459,72 @@ color: var(--text2); white-space: nowrap; } +.upload-item-actions { + display: flex; + flex-direction: row; + gap: 15px; +} +.file-state-icon { width: 18px; height: 18px; } +.file-state-icon--public { color: var(--success); } +.file-state-icon--private { color: var(--text2); } +.file-state-icon--archived { color: var(--error); } + +.upload-item-state { display: flex; align-items: center; gap: 8px; } + +.author-search { position: relative; } + +.author-search-input { + display: flex; + align-items: center; + gap: 8px; + background-color: var(--bg); + border: 1px solid var(--border); + padding: 8px 12px; +} + +.author-search-input .form-input { + border: none; + padding: 0; + background: none; + flex: 1; +} + +.author-search-selected { + display: flex; + align-items: center; + gap: 5px; + color: var(--success); + font-size: 0.85rem; + white-space: nowrap; +} + +.author-search-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background-color: var(--bg2); + border: 1px solid var(--border); + border-top: none; + z-index: 100; + max-height: 200px; + overflow-y: auto; +} + +.author-search-item { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + padding: 9px 12px; + background: none; + border: none; + color: var(--text); + font-size: 0.88rem; + cursor: pointer; + text-align: left; + font-family: var(--typography); + transition: background-color 0.1s; +} + +.author-search-item:hover { background-color: var(--bg3); } diff --git a/resources/css/components/grid.css b/resources/css/components/grid.css index 66c7d30..646de42 100644 --- a/resources/css/components/grid.css +++ b/resources/css/components/grid.css @@ -27,3 +27,11 @@ grid-template-columns: 0.5fr 1fr 0.25fr; gap: 20px; } + +.grid-entries { + display: grid; + grid-template-columns: repeat(6,1fr); + gap: 20px; + margin-bottom: 20px; +} + diff --git a/resources/css/components/hovercard.css b/resources/css/components/hovercard.css new file mode 100644 index 0000000..5260bc8 --- /dev/null +++ b/resources/css/components/hovercard.css @@ -0,0 +1,119 @@ +.hovercard-overlay { + position: absolute; + z-index: 2000; + background-color: var(--bg2); + border: 1px solid var(--border); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); +} + +.hovercard-overlay-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 30px; + color: var(--text2); +} + +.hovercard-overlay-error { + padding: 20px; + text-align: center; + color: var(--text2); + font-size: 0.85rem; +} + +.hovercard { + width: 280px; +} + +.hovercard-header { + height: 70px; + background-color: var(--bg3); + border-bottom: 1px solid var(--border); + position: relative; +} + +.hovercard-avatar { + position: absolute; + bottom: -26px; + left: 16px; + width: 52px; + height: 52px; + border-radius: 50%; + border: 3px solid var(--bg2); + overflow: hidden; + background-color: var(--bg4); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 1.2rem; + color: var(--text); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.hovercard-body { + padding: 34px 16px 16px; +} + +.hovercard-username { + font-weight: 600; + font-size: 1rem; + color: var(--text); + margin-bottom: 2px; +} + +.hovercard-title { + font-size: 0.8rem; + color: var(--rhpz-orange); + margin-bottom: 14px; + min-height: 14px; +} + +.hovercard-stats { + display: flex; + border: 1px solid var(--border); + margin-bottom: 14px; +} + +.hovercard-stat { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 4px; + border-right: 1px solid var(--border); + + &:last-child { + border-right: none; + } + + .stat-value { + font-size: 0.95rem; + font-weight: 600; + color: var(--text); + } + + .stat-label { + font-size: 0.68rem; + color: var(--text2); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 2px; + } +} + +.hovercard-actions { + display: flex; + gap: 8px; +} + +.hovercard-actions .btn { + flex: 1; + justify-content: center; + font-size: 0.82rem; +} diff --git a/resources/css/components/modal.css b/resources/css/components/modal.css index f61ae6e..934b109 100644 --- a/resources/css/components/modal.css +++ b/resources/css/components/modal.css @@ -1,5 +1,5 @@ .modal-overlay { - display: none; + display: flex; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.7); diff --git a/resources/css/components/modcp.css b/resources/css/components/modcp.css new file mode 100644 index 0000000..e5cddb9 --- /dev/null +++ b/resources/css/components/modcp.css @@ -0,0 +1,320 @@ +.modcp-wrapper { + display: flex; + gap: 0; + align-items: flex-start; + min-height: calc(100vh - 60px); +} + +.modcp-sidebar { + width: 220px; + flex-shrink: 0; + background-color: var(--bg2); + border: 1px solid var(--border); + position: sticky; + top: 0; + align-self: flex-start; + margin-right: 15px; +} + +.modcp-sidebar-header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 16px; + font-weight: 600; + font-size: 0.88rem; + color: var(--text); + border-bottom: 1px solid var(--border); + background-color: var(--bg3); + text-transform: uppercase; + letter-spacing: 0.5px; +} + + +.modcp-nav { padding: 8px 0; } + +.modcp-nav-group { margin-bottom: 4px; } + +.modcp-nav-label { + display: block; + padding: 8px 16px 4px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text2); +} + +.modcp-nav-item { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 16px; + font-size: 0.88rem; + color: var(--text); + text-decoration: none; + border-left: 3px solid transparent; + transition: background-color 0.1s, border-color 0.1s; + + &:hover { + background-color: var(--bg3); + text-decoration: none; + } + .active { + background-color: var(--bg3); + border-left-color: var(--rhpz-orange); + color: var(--text); + font-weight: 600; + } +} + +.modcp-nav-badge { + margin-left: auto; + background-color: var(--rhpz-orange); + color: #111; + font-size: 0.65rem; + font-weight: 700; + min-width: 18px; + height: 18px; + border-radius: 9px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 5px; +} + +.modcp-content { + flex: 1; + min-width: 0; + background-color: var(--bg2); + border: 1px solid var(--border); + padding: 25px; +} + +.modcp-page-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 1.3rem; + font-weight: 600; + color: var(--text); + margin-bottom: 25px; + padding-bottom: 15px; + border-bottom: 1px solid var(--border); +} + +.modcp-count { + margin-left: auto; + font-size: 0.85rem; + font-weight: normal; + color: var(--text2); +} + +.modcp-section-title { + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.7px; + color: var(--text2); + margin-bottom: 12px; +} + +.modcp-stats { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 12px; + margin-bottom: 25px; +} + +.modcp-stat-card { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + background-color: var(--bg3); + border: 1px solid var(--border); + border-left: 3px solid var(--border); + text-decoration: none; + transition: border-color 0.15s, background-color 0.15s; + color: var(--text); + + &:hover { + background-color: var(--bg4); + text-decoration: none; + } +} + +.modcp-stat-card--orange { border-left-color: var(--rhpz-orange); } +.modcp-stat-card--danger { border-left-color: var(--error); } +.modcp-stat-card--muted { cursor: default; } + +.modcp-stat-icon { color: var(--text2); } +.modcp-stat-card--orange .modcp-stat-icon { color: var(--rhpz-orange); } +.modcp-stat-card--danger .modcp-stat-icon { color: var(--error); } + +.modcp-stat-info { display: flex; flex-direction: column; } +.modcp-stat-value { font-size: 1.4rem; font-weight: 700; color: var(--text); line-height: 1; } +.modcp-stat-label { font-size: 0.75rem; color: var(--text2); margin-top: 3px; } + +.modcp-quick-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 25px; +} + +.modcp-quick-btn { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 14px; + background-color: var(--bg3); + border: 1px solid var(--border); + color: var(--text); + font-size: 0.85rem; + text-decoration: none; + transition: background-color 0.1s, border-color 0.1s; + + &:hover { + background-color: var(--bg4); + border-color: var(--rhpz-orange); + text-decoration: none; + } +} + +.modcp-list { display: flex; flex-direction: column; } + +.modcp-list-item { + display: flex; + align-items: center; + gap: 15px; + padding: 12px 15px; + border-bottom: 1px solid var(--border); + transition: background-color 0.1s; +} + +.modcp-list-item:last-child { border-bottom: none; } +.modcp-list-item:hover { background-color: var(--bg3); } +.modcp-list-item--deleted { opacity: 0.8; } + +.modcp-list-item-cover { + width: 44px; + height: 44px; + flex-shrink: 0; + background-color: var(--bg); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + color: var(--border); +} + +.modcp-list-item-cover img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.modcp-list-item-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.modcp-list-item-title { + font-size: 0.92rem; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.modcp-list-item-meta { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.78rem; + color: var(--text2); +} + +.modcp-list-item-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.modcp-list-item-edit { + display: flex; + gap: 6px; + flex: 1; + align-items: center; +} + +.modcp-list-item-edit .form-input { + flex: 1; + padding: 5px 10px; + font-size: 0.88rem; +} + +.modcp-list-see-all { + display: block; + text-align: center; + padding: 10px; + font-size: 0.85rem; + color: var(--rhpz-orange); + border-top: 1px solid var(--border); + text-decoration: none; +} + +.modcp-add-form { + background-color: var(--bg3); + border: 1px solid var(--border); + padding: 15px; + margin-bottom: 20px; +} + +.modcp-add-form-inner { + display: flex; + gap: 8px; + align-items: center; +} + +.modcp-add-form-inner .form-input { flex: 1; } + +.modcp-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 50px 20px; + color: var(--text2); + text-align: center; +} + +.mod-alert { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 15px; + margin-bottom: 20px; + font-size: 0.88rem; + border: 1px solid; +} + +.mod-alert--success { + background-color: rgba(129, 199, 132, 0.08); + border-color: rgba(129, 199, 132, 0.3); + color: var(--success); +} + +.modcp-list-item-edit--game { + flex-wrap: wrap; + gap: 6px; +} + +.modcp-list-item-edit--game .form-input { min-width: 180px; flex: 2; } +.modcp-list-item-edit--game .form-select { flex: 1; min-width: 120px; } diff --git a/resources/css/components/notifications.css b/resources/css/components/notifications.css new file mode 100644 index 0000000..728141c --- /dev/null +++ b/resources/css/components/notifications.css @@ -0,0 +1,138 @@ +.notifications, .conversations { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 340px; + max-height: 480px; + overflow-y: auto; + background-color: var(--bg2); + border: 1px solid var(--border); + box-shadow: 0 8px 24px rgba(0,0,0,0.5); + z-index: 2000; + + &::-webkit-scrollbar { + width: 8px; + } + &::-webkit-scrollbar-thumb { + background-color: var(--border); + } + &::-webkit-scrollbar-track { + background-color: var(--bg2); + } +} + +@keyframes dropdown-enter { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +.dropdown-enter { + animation: dropdown-enter 0.15s ease; +} + +.notifications-header { + + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + background-color: var(--bg3); + z-index: 1; + + .notifications-header-title { + font-weight: 600; + font-size: 0.9rem; + color: var(--text); + } + + .notifications-header-actions { + display: flex; + gap: 6px; + } +} + +.notifications-loading, .notifications-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 40px 20px; + color: var(--text2); + font-size: 0.85rem; +} + +.notifications-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + text-decoration: none; + color: var(--text); + transition: background-color 0.1s; + position: relative; + + &:last-child { + border-bottom: none; + } + &:hover { + background-color: var(--bg3); + } + .unread { + border-left: 2px solid var(--rhpz-orange); + background-color: var(--bg3); + } +} + +.notifications-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; + background-color: var(--bg4); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 0.9rem; + color: var(--text); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.notifications-content { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; + + .notifications-text { + font-size: 0.88rem; + color: var(--text); + line-height: 1.4; + } + + .notifications-date { + font-size: 0.75rem; + color: var(--text2); + } +} + +.notifications-unread-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--rhpz-orange); + flex-shrink: 0; + margin-top: 4px; +} diff --git a/resources/css/components/queue.css b/resources/css/components/queue.css new file mode 100644 index 0000000..5848bc0 --- /dev/null +++ b/resources/css/components/queue.css @@ -0,0 +1,187 @@ +.queue-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 15px; + padding: 80px 20px; + color: var(--text2); + font-size: 0.95rem; +} + +.queue-item { + background-color: var(--bg2); + border: 1px solid var(--border); + border-left-width: 4px; + margin-bottom: 20px; + padding: 20px; +} + +.queue-item--pending { + border-left-color: var(--rhpz-orange); +} +.queue-item--rejected { + position: relative; + overflow: hidden; + border-left-color: var(--error); + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + height: 2px; + background-color: var(--error); + width: var(--reject-progress, 100%); + opacity: 0.5; + transition: width 0.3s; + } +} + +.queue-item-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 15px; + margin-bottom: 20px; +} + +.queue-item-title { + font-size: 1.15rem; + font-weight: 600; + color: var(--text); + margin-bottom: 6px; +} + +.queue-item-meta { + font-size: 0.85rem; + color: var(--text2); + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.queue-item-actions-header { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.timeline-container { + padding: 15px 20px; + background-color: var(--bg); + border: 1px solid var(--border); + margin-bottom: 20px; +} + +.timeline { + display: flex; + justify-content: space-between; + position: relative; + + &::before { + content: ''; + position: absolute; + top: 15px; + left: 30px; + right: 30px; + height: 2px; + background-color: var(--border); + z-index: 0; + } + +} + +.timeline-step { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + z-index: 1; + width: 100px; +} + +.timeline-dot { + width: 32px; + height: 32px; + border-radius: 50%; + background-color: var(--bg); + border: 2px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + color: var(--text2); + margin-bottom: 10px; + transition: all 0.2s; +} + +.timeline-step--active .timeline-dot { + border-color: var(--rhpz-orange); + background-color: var(--rhpz-orange); + color: #111; +} + +.timeline-step--validated .timeline-dot { + border-color: var(--success); + background-color: var(--success); + color: #111; +} + +.timeline-step--rejected .timeline-dot { + border-color: var(--error); + background-color: var(--error); + color: #fff; +} + +.timeline-label { + font-size: 0.78rem; + text-align: center; + color: var(--text2); + font-weight: 500; +} + +.timeline-step--active .timeline-label { color: var(--rhpz-orange); font-weight: 600; } +.timeline-step--validated .timeline-label { color: var(--success); } +.timeline-step--rejected .timeline-label { color: var(--error); } + +.queue-reject-reason { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 12px 15px; + background-color: rgba(229, 115, 115, 0.08); + border-left: 2px solid var(--error); + color: var(--text); + font-size: 0.88rem; + margin-bottom: 15px; + line-height: 1.5; +} + +.queue-mod-zone { + border-top: 1px solid var(--border); + padding-top: 15px; + margin-top: 5px; + display: flex; + flex-direction: column; + gap: 15px; +} + +.queue-mod-separator { + border-top: 1px solid var(--border); +} + +.queue-mod-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 10px; +} + +.queue-reject-form { + margin-top: 12px; + padding: 15px; + background-color: var(--bg3); + border: 1px solid var(--border); +} + diff --git a/resources/css/components/settings.css b/resources/css/components/settings.css new file mode 100644 index 0000000..19cf473 --- /dev/null +++ b/resources/css/components/settings.css @@ -0,0 +1,156 @@ +.settings-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 240px; + background-color: var(--bg2); + border: 1px solid var(--border); + box-shadow: 0 8px 24px rgba(0,0,0,0.5); + z-index: 2000; +} + +.settings-header { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background-color: var(--bg3); + font-weight: 600; + font-size: 0.9rem; + color: var(--text); +} + +.settings-section { + padding: 12px 16px; +} + +.settings-section-title { + display: flex; + align-items: center; + gap: 7px; + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.7px; + color: var(--text2); + margin-bottom: 10px; +} + +.settings-separator { + border-top: 1px solid var(--border); +} + +.settings-themes { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.settings-theme-btn { + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text); + transition: transform 0.15s, border-color 0.15s; + padding: 0; + + &:hover { + transform: scale(1.15); + } + + .active { + border-color: var(--text); + transform: scale(1.1); + } +} + +.settings-theme-toggle { + width: 100%; + background-color: var(--bg3); + border: 1px solid var(--border); + color: var(--text); + padding: 8px 12px; + cursor: pointer; + font-family: var(--typography); + font-size: 0.88rem; + transition: background-color 0.1s; + text-align: left; + + &:hover { + background-color: var(--bg4); + } +} + +.settings-theme-toggle-inner { + display: flex; + align-items: center; + gap: 8px; +} + +.settings-theme-toggle-badge { + margin-left: auto; + background-color: var(--rhpz-orange); + color: #111; + font-size: 0.65rem; + font-weight: 700; + padding: 2px 6px; +} + +.settings-perpage { + display: flex; + gap: 6px; +} + +.settings-perpage-btn { + flex: 1; + padding: 6px 4px; + background-color: var(--bg3); + border: 1px solid var(--border); + color: var(--text2); + font-size: 0.85rem; + cursor: pointer; + font-family: var(--typography); + transition: all 0.1s; + + &:hover { + background-color: var(--bg4); + color: var(--text); + } + + .active { + background-color: var(--rhpz-orange); + border-color: var(--rhpz-orange); + color: var(--text3); + font-weight: 600; + } +} + +.settings-link { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 10px; + color: var(--text); + text-decoration: none; + font-size: 0.88rem; + transition: background-color 0.1s; + border: 1px solid transparent; + + &:hover { + background-color: var(--bg3); + border-color: var(--border); + text-decoration: none; + } +} + +.settings-link--danger { + color: var(--error); + + &:hover { + background-color: rgba(229, 115, 115, 0.08); + border-color: rgba(229, 115, 115, 0.3); + } +} diff --git a/resources/css/components/tools.css b/resources/css/components/tools.css new file mode 100644 index 0000000..2ad0697 --- /dev/null +++ b/resources/css/components/tools.css @@ -0,0 +1,83 @@ +.patcher-container { + background-color: var(--bg2); + border: 1px solid var(--border); + padding: 25px; + margin-bottom: 20px; +} + +.patcher-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +@media (max-width: 768px) { + .patcher-grid { + grid-template-columns: 1fr; + } +} + +.patcher-dropzone { + border: 2px dashed var(--border); + background-color: var(--bg3); + padding: 55px 20px; + text-align: center; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; +} + +.patcher-dropzone:hover, .patcher-dropzone.dragover { + border-color: var(--rhpz-orange); + background-color: var(--bg4); +} + +.patcher-dropzone.has-file { + border-color: var(--success); + background-color: rgba(129, 199, 132, 0.02); +} + +.patcher-status-box { + margin-top: 20px; + padding: 15px; + border: 1px solid var(--border); + background-color: var(--bg3); + font-size: 0.95rem; + line-height: 1.4; +} + +.btn:disabled { + opacity: 0.4; + cursor: not-allowed; + background-color: var(--bg3); + border-color: var(--border); + color: var(--text2); +} + +.embed-patch-box { + border: 1px solid var(--border); + background-color: var(--bg3); + padding: 25px; + height: 85%; + display: flex; + flex-direction: column; + justify-content: center; + gap: 15px; +} +.embed-patch-box-icon { + display: flex; + align-items: center; + gap: 15px; +} +.embed-patch-box-icon-block { + width: 48px; + height: 48px; + background-color: var(--bg2); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; +} diff --git a/resources/css/layout/content.css b/resources/css/layout/content.css index 07ff7c2..d13c635 100644 --- a/resources/css/layout/content.css +++ b/resources/css/layout/content.css @@ -23,26 +23,34 @@ cursor: pointer; } - .search-bar { + .topbar-actions { display: flex; - align-items: center; - background-color: var(--bg); - border: 1px solid var(--border); - border-radius: 2px; - padding: 5px 10px; - width: 300px; - input { - background: none; - border: none; - color: var(--text); - outline: none; - margin-left: 8px; - width: 100%; - } + gap: 8px; } + .vertical-separator { + align-items: center; + border-left: 1px solid var(--border); + } +} +.search-bar { + display: flex; + align-items: center; + background-color: var(--bg); + border: 1px solid var(--border); + border-radius: 2px; + padding: 5px 10px; + width: 300px; + input { + background: none; + border: none; + color: var(--text); + outline: none; + margin-left: 8px; + width: 100%; + } } #content { diff --git a/resources/css/layout/entry.css b/resources/css/layout/entry.css index 864002a..958262f 100644 --- a/resources/css/layout/entry.css +++ b/resources/css/layout/entry.css @@ -1,4 +1,4 @@ -#entry-container { +#entry-container, #comments-section, #reviews-section { background-color: var(--bg2); border: 1px solid var(--border); display: flex; @@ -14,8 +14,8 @@ gap: 30px; .entry-cover { - width: 200px; - height: 280px; + width: 220px; + height: 220px; background-color: var(--bg); border: 1px solid var(--border); display: flex; @@ -29,7 +29,8 @@ img { width: 100%; height: 100%; - object-fit: cover; + object-fit: contain; + padding: 8px; } } @@ -106,6 +107,7 @@ display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 15px; + margin-bottom: 30px; } .entry-gallery-item { @@ -116,10 +118,81 @@ align-items: center; justify-content: center; cursor: pointer; + overflow: hidden; transition: border-color 0.2s; &:hover { border-color: var(--rhpz-orange); + + img { + transform: scale(1.05); + } + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s ease; + } + } +} + +.gallery-modal { + position: fixed; + z-index: 3000; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + + .gallery-modal-content { + max-width: 90%; + max-height: 90%; + position: relative; + + img { + max-width: 100%; + max-height: 90vh; + object-fit: contain; + border: 2px solid var(--border); + background-color: var(--bg); + box-shadow: 0 10px 25px rgba(0,0,0,0.5); + } + } + + .gallery-modal-close { + position: absolute; + top: 20px; + right: 30px; + color: #fff; + font-size: 40px; + font-weight: bold; + cursor: pointer; + user-select: none; + + &:hover { + color: var(--rhpz-orange); + } + } + + .gallery-modal-video { + width: 90%; + max-width: 960px; + aspect-ratio: 16/9; + box-shadow: 0 10px 30px rgba(0,0,0,0.6); + border: 1px solid var(--border); + background-color: #000; + + iframe { + width: 100%; + height: 100%; + border: none; + display: block; } } } @@ -128,3 +201,183 @@ text-align: center; color: var( --text2 ); } + +.comment-block { + display: flex; + gap: 16px; + padding: 20px 0; + border-bottom: 1px solid var(--border); + + &:last-child { + border-bottom: none; + } + + .comment-avatar { + flex-shrink: 0; + width: 48px; + height: 48px; + border-radius: 50%; + overflow: hidden; + background-color: var(--bg4); + border: 1px solid var(--border); + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + } + + .comment-content { + flex: 1; + min-width: 0; + + .comment-meta { + font-size: 0.88rem; + color: var(--text2); + margin-bottom: 6px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + + .comment-author { + font-weight: 600; + color: var(--text); + text-decoration: none; + transition: color 0.2s; + + &:hover { + color: var(--rhpz-orange); + } + } + + .comment-date { + color: var(--text2); + } + + .comment-separator { + color: var(--border); + user-select: none; + } + } + + .comment-body { + font-size: 0.95rem; + color: var(--text); + line-height: 1.5; + word-wrap: break-word; + + p { + margin-bottom: 10px; + &:last-child { margin-bottom: 0; } + } + + a { + color: var(--rhpz-orange); + &:hover { + color: var(--rhpz-orange-hover); + text-decoration: underline; + } + } + + blockquote, .bbCodeBlock-blockquote { + background-color: var(--bg); + border-left: 3px solid var(--info); + padding: 12px 16px; + margin: 12px 0; + font-style: italic; + color: var(--text2); + } + + code { + font-family: monospace; + background-color: var(--bg3); + border: 1px solid var(--border); + padding: 2px 5px; + font-size: 0.9rem; + } + } + } +} + +.comments-empty { + text-align: center; + padding: 40px 20px; + color: var(--text2); + font-style: italic; + background-color: var(--bg); + border: 1px dashed var(--border); +} + +.entry-video-section { + margin-bottom: 30px; +} + +.video-thumbnail-wrapper { + position: relative; + width: 100%; + max-width: 500px; + aspect-ratio: 16/9; + background-color: #000; + border: 1px solid var(--border); + cursor: pointer; + overflow: hidden; + border-radius: 4px; + + img { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.8; + transition: transform 0.3s ease, opacity 0.3s ease; + } + + .play-trigger { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 65px; + height: 65px; + background-color: rgba(0, 0, 0, 0.7); + border: 2px solid #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 1.5rem; + transition: background-color 0.2s, transform 0.2s ease-out; + + i { margin-left: 4px; } + } + + &:hover { + img { + transform: scale(1.03); + opacity: 1; + } + .play-trigger { + background-color: var(--rhpz-orange); + border-color: var(--rhpz-orange); + transform: translate(-50%, -50%) scale(1.1); + box-shadow: 0 0 15px rgba(255, 115, 0, 0.5); + } + } +} + +.entry-submission-byline { + font-size: 0.85rem; + color: var(--text2); + margin-bottom: 20px; + display: flex; + align-items: center; + gap: 15px; + + i { + vertical-align: middle; + margin-right: 4px; + } +} diff --git a/resources/css/xenforo.css b/resources/css/xenforo.css new file mode 100644 index 0000000..cee9708 --- /dev/null +++ b/resources/css/xenforo.css @@ -0,0 +1,4 @@ +.xf-menu-user-avatar-fix { + width: 40px !important; + height: 40px !important; +} diff --git a/resources/js/RomPatcher.js b/resources/js/RomPatcher.js new file mode 100644 index 0000000..185d0dc --- /dev/null +++ b/resources/js/RomPatcher.js @@ -0,0 +1,114 @@ +export function RomPatcher( initialPatches = {} ) { + + let patchesArray = []; + if (initialPatches) { + patchesArray = Array.isArray(initialPatches) ? initialPatches : [initialPatches]; + } + patchesArray = patchesArray.filter(p => p && p.file); + + return { + + /** + * @type {string} + */ + romFileName: '', + + /** + * @type {string} + */ + patchFileName: '', + + /** + * @type {boolean} + */ + isRomDragOver: false, + + /** + * @type {boolean} + */ + isPatchDragOver: false, + + /** + * @type {boolean} + */ + showStatusBox: false, + + /** + * @type {object} + */ + patchesData: patchesArray, + hasEmbedded: patchesArray.length > 0, + + init() { + + const CONFIG = {language: 'en', requireValidation: false}; + + if (!RomPatcherWeb.isInitialized()){ + if (this.hasEmbedded) { + RomPatcherWeb.initialize(CONFIG, ...this.patchesData); + } else { + RomPatcherWeb.initialize(CONFIG); + } + } + + const STAT_BOX = document.getElementById('patcher-status'); + const OBSERVER = new MutationObserver(() => { + const CRC = document.getElementById('rom-patcher-span-crc32').textContent; + const DESC = document.getElementById('rom-patcher-patch-description').textContent; + const PATCH = document.getElementById('rom-patcher-patch-requirements-value').textContent; + + this.showStatusBox = CRC.trim().length > 0 || DESC.trim().length > 0 || PATCH.trim().length > 0; + }); + + OBSERVER.observe(STAT_BOX, { childList: true, subtree: true, characterData: true }); + }, + + /** + * + * @param {string} id + */ + triggerFileInput(id){ + const I = document.getElementById(id); + if( !I.disabled ) + I.click(); + }, + + /** + * + * @param {Event} e + * @param {string} type + */ + handleInputChange(e, type){ + const file = e.target.files[0]; + if(file){ + if( type === 'rom' ) this.romFileName = file.name; + if( type === 'patch' ) this.patchFileName = file.name; + } + }, + + /** + * + * @param {Event} e + * @param {string} type + */ + handleDrop(e, type ){ + + const file = e.dataTransfer.files[0]; + if( !file ) + return; + + const ID = type === 'rom' ? 'rom-patcher-input-file-rom' : 'rom-patcher-input-file-patch'; + const I = document.getElementById(ID); + + if( I.disabled ) + return; + + I.files = e.dataTransfer.files; + + if( type === 'rom' ) this.romFileName = file.name; + if( type === 'patch' ) this.patchFileName = file.name; + + I.dispatchEvent(new Event('change', { bubbles: true })); + } + } +} diff --git a/resources/js/SubmissionsClass/FSFileData.js b/resources/js/SubmissionsClass/FSFileData.js index 64e5652..a44b3c5 100644 --- a/resources/js/SubmissionsClass/FSFileData.js +++ b/resources/js/SubmissionsClass/FSFileData.js @@ -61,93 +61,108 @@ export function FSFileData(name, totalChunks, rawFile ) { */ uuid: crypto.randomUUID(), - /** - * Look if this file is currently uploading. - * @returns {boolean} - */ - get isUploading() - { - return !this.done && !this.error; - }, - - /** - * Build API url. - * @param {string} section - * @returns {string} The API url. - */ - buildUrl(section) - { - return `/api/fs/upload-chunk/${section}`; - }, - - /** - * Upload the file. - * @param {string} section section of the file. - * @returns {Promise} - */ - async upload(section) - { - - if (!this.rawFile) - return; // Can't upload in that case. + /** + * Current file state + */ + state: 'public', /** - * Get CSRF token for uploading request. - * @type {string} + * If the online patcher is enabled */ - const CSRF = document.querySelector('meta[name=csrf-token]')?.content ?? ''; + meta_online_patcher: false, - for (let i = 0; i < this.totalChunks; i++) { + /** + * If this patch is a secondary patch. + */ + meta_secondary_online_patcher: false, - if (this.error) - return; // Abort the process. + /** + * Look if this file is currently uploading. + * @returns {boolean} + */ + get isUploading() + { + return !this.done && !this.error; + }, - const start = i * CHUNK_SIZE; - const end = Math.min(start + CHUNK_SIZE, this.rawFile.size); - const chunk = this.rawFile.slice(start, end); + /** + * Build API url. + * @param {string} section + * @returns {string} The API url. + */ + buildUrl(section) + { + return `/api/fs/upload-chunk/${section}`; + }, - const formData = new FormData(); + /** + * Upload the file. + * @param {string} section section of the file. + * @returns {Promise} + */ + async upload(section) + { - formData.append('file', chunk); - formData.append('file_uuid', this.uuid); - formData.append('current_chunk', i); - formData.append('total_chunks', this.totalChunks); - formData.append('filename', this.rawFile.name); - formData.append('_token', CSRF); + if (!this.rawFile) + return; // Can't upload in that case. - // ----- - // UPLOAD TIME ! - // ----- + /** + * Get CSRF token for uploading request. + * @type {string} + */ + const CSRF = document.querySelector('meta[name=csrf-token]')?.content ?? ''; - try { - const RESPONSE = await fetch(this.buildUrl(section), {method: 'POST', body: formData}); + for (let i = 0; i < this.totalChunks; i++) { - if (!RESPONSE.ok) // Problem with the request. - throw new Error(`${RESPONSE.status} ${RESPONSE.statusText}`); + if (this.error) + return; // Abort the process. - /** @type {UploadchunkResponse} */ - const DATA = await RESPONSE.json(); + const start = i * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, this.rawFile.size); + const chunk = this.rawFile.slice(start, end); - if (DATA.success !== true || DATA.uploaded !== true) - // The request reached the file server but could not be sent. - throw new Error(`${DATA.error}`); + const formData = new FormData(); - this.currentChunk = i + 1; - this.progressValue = Math.round(((i + 1) / this.totalChunks) * 100); + formData.append('file', chunk); + formData.append('file_uuid', this.uuid); + formData.append('current_chunk', i); + formData.append('total_chunks', this.totalChunks); + formData.append('filename', this.rawFile.name); + formData.append('_token', CSRF); - if (DATA.finished === true) { - this.done = true; + // ----- + // UPLOAD TIME ! + // ----- + + try { + const RESPONSE = await fetch(this.buildUrl(section), {method: 'POST', body: formData}); + + if (!RESPONSE.ok) // Problem with the request. + throw new Error(`${RESPONSE.status} ${RESPONSE.statusText}`); + + /** @type {UploadchunkResponse} */ + const DATA = await RESPONSE.json(); + + if (DATA.success !== true || DATA.uploaded !== true) + // The request reached the file server but could not be sent. + throw new Error(`${DATA.error}`); + + this.currentChunk = i + 1; + this.progressValue = Math.round(((i + 1) / this.totalChunks) * 100); + + if (DATA.finished === true) { + this.done = true; + return; + } + + } catch (err) { + this.error = 'Error on chunk ' + (i + 1) + '. ' + err.message; + this.progressValue = 0; return; } - } catch (err) { - this.error = 'Error on chunk ' + (i + 1) + '. ' + err.message; - this.progressValue = 0; - return; } - } - } } diff --git a/resources/js/SubmissionsClass/FSUploader.js b/resources/js/SubmissionsClass/FSUploader.js index a0ff0c9..96af2ed 100644 --- a/resources/js/SubmissionsClass/FSUploader.js +++ b/resources/js/SubmissionsClass/FSUploader.js @@ -127,6 +127,10 @@ export function FSUploader(){ */ handleRemoveFile( index ){ this.files.splice(index, 1); + }, + + changeFileState( index, newState ){ + this.files[index].state = newState; } } diff --git a/resources/js/SubmissionsClass/GalleryManager.js b/resources/js/SubmissionsClass/GalleryManager.js index 604215d..1f680c2 100644 --- a/resources/js/SubmissionsClass/GalleryManager.js +++ b/resources/js/SubmissionsClass/GalleryManager.js @@ -72,8 +72,9 @@ export function GalleryManager() { break; const IMG = GalleryImage(); - IMG.getOldImage( PATH ); + IMG.serverFilePath = PATH; this.images.push(IMG); + this.images[this.images.length - 1].getOldImage( PATH ); } }, diff --git a/resources/js/SubmissionsClass/MainImageManager.js b/resources/js/SubmissionsClass/MainImageManager.js index 408e93e..43684c4 100644 --- a/resources/js/SubmissionsClass/MainImageManager.js +++ b/resources/js/SubmissionsClass/MainImageManager.js @@ -2,6 +2,12 @@ export function MainImageManager() { return { + /** + * Used for gallery managament and indexation. + * @type {string} + */ + key: crypto.randomUUID(), + /** * If an image has been uploaded or not. * @type {boolean} diff --git a/resources/js/app.js b/resources/js/app.js index e7418b1..068c39a 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -3,7 +3,20 @@ import EasyMDE from "easymde"; import "easymde/dist/easymde.min.css"; import { calculate as calculateHashes } from "./hashes.js"; +import hovercard from "./hovercard.js"; +import notifications from "./notifications.js"; +import conversations from "./conversations.js"; +import settings from "./settings.js"; +import {RomPatcher} from "./RomPatcher.js"; +/** + * Get config defined in meta.blade.php + * @param {string} key + * @return {string|null} + */ +window.getConfig = function( key ){ + return document.querySelector('meta[name="config-' + key + '"]').getAttribute('content') ?? null; +} // Lucide icons. createIcons({ icons }); @@ -20,4 +33,17 @@ window.EasyMDE = EasyMDE; // Hashes. window.calculateHashes = calculateHashes; +// Hover card. +Alpine.store('hovercard', hovercard() ); +// Notifications +Alpine.store('notifications', notifications() ); + +// Conversations +Alpine.store('conversations', conversations() ); + +// Settings +Alpine.store('settings', settings() ); + +// ROMPatcher +window.RomPatcher = RomPatcher; diff --git a/resources/js/conversations.js b/resources/js/conversations.js new file mode 100644 index 0000000..ddfb077 --- /dev/null +++ b/resources/js/conversations.js @@ -0,0 +1,103 @@ +/** @typedef { import('types/ConversationResponseItem.js').ConversationResponseItem} ConversationResponseItem */ + +export default function conversations() { + return { + + /** + * @type {boolean} + */ + start: false, + + /** + * @type {ConversationResponseItem[]} + */ + data: null, + + /** + * @type {boolean} + */ + loading: false, + + /** + * @type {boolean} + */ + error: false, + + /** + * @type {number} + */ + unviewed: 0, + + /** + * Request for getting notifications. + * + * @return {Promise} + */ + async getConversations() { + if( this.loading ) + return; + + this.loading = true; + this.error = false; + + try { + const RESPONSE = await fetch('/api/dynamic/conversations', { credentials: "include", headers: { 'X-Requested-With': 'XMLHttpRequest' } }); + + if( !RESPONSE.ok ) + throw new Error(RESPONSE.status) + + let json = await RESPONSE.json() + if( !json.conversations ) + throw new Error(RESPONSE.status); + + this.data = json.conversations; + + + } catch (error) { + this.error = true; + } finally { + this.loading = false; + } + }, + + /** + * + * @param {HTMLElement} anchorEl + * @return {Promise} + */ + async open( anchorEl ){ + + if( this.start ){ + this.close(); + return; + } + + this.start = !this.start; + if( this.start && !this.data ){ + await this.getConversations(); + } + + if( this.start ){ + Alpine.nextTick(() => window.refreshIcons(document.querySelector('.notifications'))) + } + }, + + /** + * @return {number} + */ + get unread(){ + if( !this.data ){ + return this.unviewed; + } + return this.data.filter(a => a.is_unread === 0 ).length; + }, + + /** + * + */ + close(){ + this.start = false; + } + + } +} diff --git a/resources/js/hovercard.js b/resources/js/hovercard.js new file mode 100644 index 0000000..a66bd8d --- /dev/null +++ b/resources/js/hovercard.js @@ -0,0 +1,115 @@ +/** @typedef { import('types/HovercardResponse.js').HovercardResponse} HovercardResponse */ + +export default function hovercard(){ + return { + + /** + * @type {boolean} + */ + start: false, + + /** + * @type {HovercardResponse} + */ + data: null, + + /** + * @type {boolean} + */ + loading: false, + + /** + * @type {any} + */ + error: false, + + /** + * @type {HTMLElement|null} + */ + anchorEl: null, + + /** + * @type {number} + */ + x: 0, + + /** + * @type {number} + */ + y: 0, + + /** + * + * @param {HTMLElement} anchorEl + * @param {string} fetchUrl + * @return {Promise} + */ + async open(anchorEl, fetchUrl){ + + if( this.start && this.anchorEl === anchorEl ){ + // this.close(); + return; + } + + this.start = true; + this.anchorEl = anchorEl; + this.data = null; + this.loading = true; + this.error = false; + this.updatePosition(anchorEl); + + try { + const RESPONSE = await fetch(fetchUrl); + if( !RESPONSE.ok ) + throw new Error(RESPONSE.status); + + let json = await RESPONSE.json(); + if( !json.user ) + throw new Error(RESPONSE.status); + + this.data = json.user; + + Alpine.nextTick(() => { + const card = document.querySelector('.hovercard'); + if (card) window.refreshIcons(card); + }); + + } catch( error ){ + this.error = true; + } finally { + this.loading = false; + } + }, + + /** + * Update Hovercard position + * @param {HTMLElement} anchorEl + */ + updatePosition(anchorEl){ + const RECT = anchorEl.getBoundingClientRect(); + const SCROLL_X = window.scrollX; + const SCROLL_Y = window.scrollY; + + let x = RECT.left + SCROLL_X; + let y = RECT.bottom + SCROLL_Y + 8; + + const WIDTH = 280; + if( x + WIDTH > window.innerWidth ){ + x = window.innerWidth - WIDTH - 16; + } + + this.x = x; + this.y = y; + }, + + /** + * Close the hovercard. + */ + close(){ + this.start = false; + this.data = null; + this.anchorEl = null; + this.error = false; + } + } +} diff --git a/resources/js/notifications.js b/resources/js/notifications.js new file mode 100644 index 0000000..49d6979 --- /dev/null +++ b/resources/js/notifications.js @@ -0,0 +1,126 @@ +/** @typedef { import('types/AlertsResponseItem.js').AlertsResponseItem} AlertsResponseItem */ + +export default function notifications() { + return { + + /** + * @type {boolean} + */ + start: false, + + /** + * @type {AlertsResponseItem[]} + */ + data: null, + + /** + * @type {boolean} + */ + loading: false, + + /** + * @type {boolean} + */ + error: false, + + /** + * @type {number} + */ + unviewed: 0, + + /** + * Request for getting notifications. + * + * @return {Promise} + */ + async getNotifications() { + if( this.loading ) + return; + + this.loading = true; + this.error = false; + + try { + const RESPONSE = await fetch('/api/dynamic/notifications', { credentials: "include", headers: { 'X-Requested-With': 'XMLHttpRequest' } }); + + if( !RESPONSE.ok ) + throw new Error(RESPONSE.status) + + let json = await RESPONSE.json() + if( !json.alerts ) + throw new Error(RESPONSE.status); + + this.data = json.alerts; + + + } catch (error) { + this.error = true; + } finally { + this.loading = false; + } + }, + + /** + * + * @param {HTMLElement} anchorEl + * @return {Promise} + */ + async open( anchorEl ){ + + if( this.start ){ + this.close(); + return; + } + + this.start = !this.start; + if( this.start && !this.data ){ + await this.getNotifications(); + } + + if( this.start ){ + Alpine.nextTick(() => window.refreshIcons(document.querySelector('.notifications'))) + } + }, + + /** + * + * @return {Promise} + */ + async markAllRead(){ + await fetch('/api/dynamic/notifications/mark-all-read', { + method: 'POST', + credentials: "include", + headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '' } + }); + + if(this.data && this.data.length > 0){ + this.data = this.data.map(a => ({ + ...a, + view_date: Math.floor(Date.now() / 1000) + })); + } + }, + + /** + * @return {number} + */ + get unread(){ + if( !this.data ){ + return this.unviewed; + } + return this.data.filter(a => a.view_date === 0 ).length; + }, + + /** + * + */ + close(){ + + if( this.start && this.unread > 0) + this.markAllRead(); + + this.start = false; + } + + } +} diff --git a/resources/js/settings.js b/resources/js/settings.js new file mode 100644 index 0000000..51194fb --- /dev/null +++ b/resources/js/settings.js @@ -0,0 +1,83 @@ +import Cookies from 'js-cookie'; + +export default function settings() { + return { + + /** + * @type {boolean} + */ + start: false, + + /** + * Two keys, default and alternate. + * @type {Object} + */ + xfUrls: {}, + + /** + * @type {number[]} + */ + entriesPerPage: [ 12, 30, 48 ], + + /** + * @type {string} + */ + currentTheme: Cookies.get("theme") ?? 'default', + + /** + * @type {number} + */ + currentEntriesPerPage: Cookies.get("entries_per_page") ?? 30, + + /** + * + * @param {string} newTheme default|alternate + */ + themeChanged( newTheme ){ + if( newTheme !== "default" && newTheme !== "alternate" ) + return; + + if( newTheme === this.currentTheme ) + return; + + this.currentTheme = newTheme; + document.documentElement.classList.toggle('light-mode', this.currentTheme === 'alternate'); + + Cookies.set('theme', this.currentTheme, { expires: 365, path: '/', domain: window.getConfig('session-domain') } ); + this.syncXF(); + }, + + /** + * + * @return {Promise} + */ + async syncXF(){ + await fetch(this.xfUrls[this.currentTheme ?? 'default'], { method: "GET", credentials: "include", mode: "no-cors" }); + }, + + /** + * + */ + toggleTheme(){ + this.themeChanged(this.currentTheme === 'default' ? 'alternate' : 'default'); + }, + + /** + * + * @param n + */ + entriesPerPageChanged( n ){ + if( !this.entriesPerPage.includes(n) ) + return; + + this.entriesPerPage = n; + Cookies.set('entries_per_page', this.entriesPerPage, { expires: 365, path: '/', domain: window.getConfig('session-domain') } ); + if( window.Livewire ){ + Livewire.dispatch('entriesPerPageChanged', {n}); + } + }, + + open(){ this.start = !this.start; }, + close(){ this.start = false; }, + } +} diff --git a/resources/js/submissions.js b/resources/js/submissions.js index 95a34c5..e9b0a67 100644 --- a/resources/js/submissions.js +++ b/resources/js/submissions.js @@ -372,6 +372,12 @@ window.Submission = function(){ this.errorKey = null; // Reset. this.duringSubmissionProcess = true; + const STATE = document.querySelector('select[name="submit-state"]')?.value; + if( STATE === 'draft' ){ + e.target.submit(); + return; + } + if( !this.verifyForm() ){ this.scrollToError(); diff --git a/resources/js/types/AlertsResponseItem.js b/resources/js/types/AlertsResponseItem.js new file mode 100644 index 0000000..3f86d5f --- /dev/null +++ b/resources/js/types/AlertsResponseItem.js @@ -0,0 +1,21 @@ +/** + * @typedef {Object} AlertsResponseItem + * + * @see app/Http/DynamicLoadController.php + * + * @property {number} alert_id + * @property {number} alerted_user_id + * @property {number} user_id + * @property {string} username + * @property {string} content_type + * @property {number} content_id + * @property {string} action + * @property {number} event_date + * @property {number} view_date + * @property {number} read_date + * @property {boolean} auto_read + * @property {string} alert_text + * @property {string} alert_url + * @property {Object} user See XenForo API Documentation for more details. + */ +export {} diff --git a/resources/js/types/ConversationResponseItem.js b/resources/js/types/ConversationResponseItem.js new file mode 100644 index 0000000..de5ac3d --- /dev/null +++ b/resources/js/types/ConversationResponseItem.js @@ -0,0 +1,29 @@ +/** + * @typedef {Object} ConversationResponseItem + * + * @see app/Http/DynamicLoadController.php + * + * @property {string} username + * @property {object} recipients + * @property {boolean} is_starred + * @property {boolean} is_unread + * @property {boolean} can_edit + * @property {boolean} can_reply + * @property {boolean} can_invite + * @property {boolean} can_upload_attachment + * @property {boolean} view_url + * @property {number} conversation_id + * @property {string} title + * @property {number} user_id + * @property {number} start_date + * @property {boolean} open_invite + * @property {boolean} conversation_open + * @property {number} reply_count + * @property {number} recipient_count + * @property {number} first_message_id + * @property {number} last_message_date + * @property {number} last_message_id + * @property {number} last_message_user_id + * @property {Object} Starter + */ +export {} diff --git a/resources/js/types/HovercardResponse.js b/resources/js/types/HovercardResponse.js new file mode 100644 index 0000000..730a76e --- /dev/null +++ b/resources/js/types/HovercardResponse.js @@ -0,0 +1,18 @@ +/** + * @typedef {Object} HovercardResponse + * + * @see app/Http/DynamicLoadController.php + * + * @property {string} username + * @property {string|null} avatar_url + * @property {string} avatar_color + * @property {string} avatar_letter + * @property {string} group_name + * @property {string} joined + * @property {string} last_seen + * @property {number} message_count + * @property {number} reaction_score + * @property {number} trophy_points + * @property {number} entries_count + */ +export {} diff --git a/resources/views/components/conversations.blade.php b/resources/views/components/conversations.blade.php new file mode 100644 index 0000000..4424c3c --- /dev/null +++ b/resources/views/components/conversations.blade.php @@ -0,0 +1,68 @@ +
+
+ Private Messages + +
+ + + + + + +
diff --git a/resources/views/components/database-filter-with-mode-search.blade.php b/resources/views/components/database-filter-with-mode-search.blade.php new file mode 100644 index 0000000..44c035c --- /dev/null +++ b/resources/views/components/database-filter-with-mode-search.blade.php @@ -0,0 +1,32 @@ +
+
+
+

{{ $title }}

+ +
+
+
+ + +
+ + +
+
+
+ +
+ @foreach($items as $item) + + @endforeach +
+
+
diff --git a/resources/views/components/database-filter-with-mode.blade.php b/resources/views/components/database-filter-with-mode.blade.php new file mode 100644 index 0000000..0c543bc --- /dev/null +++ b/resources/views/components/database-filter-with-mode.blade.php @@ -0,0 +1,26 @@ +
+
+
+

{{ $title }}

+ +
+
+
+ + +
+ + +
+
+
+ @foreach($items as $item) + + @endforeach +
+
diff --git a/resources/views/components/database-filter-without-mode-search.blade.php b/resources/views/components/database-filter-without-mode-search.blade.php new file mode 100644 index 0000000..03b40e6 --- /dev/null +++ b/resources/views/components/database-filter-without-mode-search.blade.php @@ -0,0 +1,28 @@ +
+
+
+

{{ $title }}

+ +
+
+ + +
+
+
+ +
+ @foreach($items as $item) + + @endforeach +
+
+
diff --git a/resources/views/components/database-filter-without-mode.blade.php b/resources/views/components/database-filter-without-mode.blade.php new file mode 100644 index 0000000..1b82759 --- /dev/null +++ b/resources/views/components/database-filter-without-mode.blade.php @@ -0,0 +1,22 @@ +
+
+
+

{{ $title }}

+ +
+
+ + +
+
+
+ @foreach($items as $item) + + @endforeach +
+
diff --git a/resources/views/components/entry-card.blade.php b/resources/views/components/entry-card.blade.php new file mode 100644 index 0000000..f1d991d --- /dev/null +++ b/resources/views/components/entry-card.blade.php @@ -0,0 +1,40 @@ +
+
+ {{ $entry->getRealPlatform()?->name ?? 'Unknown' }} + @if( $entry->main_image ) + + @else + + @endif +
+
+ {{ $entry->title }} +
+ @forelse( $entry->authors as $author) + @if($loop->first)By @endif + {{ $author-> name }} + @if( !$loop->last ), @endif + @empty + No authors + @endforelse +
+
+ {{ \App\View\Components\EntryCard::ENTRY_TYPES_BADGE[$entry->type] ?? $entry->type }} + @if( section_must_be('romhacks', $entry->type ) ) + @foreach( $entry->modifications as $modif ) + {{ $modif->name }} + @endforeach + @endif + @if( $entry->status_id ) + {{ $entry->status->name }} + @endif + @foreach( $entry->languages as $lang ) + {{ $lang->name }} + @endforeach +
+
+ x + Added: {{ $entry->created_at->format('y-m-d') }} +
+
+
diff --git a/resources/views/components/gallery-field.blade.php b/resources/views/components/gallery-field.blade.php index 9dac806..bc8e932 100644 --- a/resources/views/components/gallery-field.blade.php +++ b/resources/views/components/gallery-field.blade.php @@ -14,7 +14,7 @@