diff --git a/_rhpz_ide_helper.php b/_rhpz_ide_helper.php new file mode 100644 index 0000000..e457369 --- /dev/null +++ b/_rhpz_ide_helper.php @@ -0,0 +1,9 @@ +user() !== null; } + /** + * Check if user is a guest. + * @return bool + */ public function guest(): bool { return ! $this->check(); } + /** + * Get user ID. + * @return mixed + */ public function id(): mixed { return $this->user()?->getAuthIdentifier(); } + /** + * If user is defined. + * @return bool + */ public function hasUser(): bool { return $this->user !== null; } + /** + * Login user. + * @return XenForoUser|null + */ public function user(): ?XenForoUser { if ($this->hasUser()) @@ -64,6 +92,13 @@ class XenForoGuard implements Guard return $this->user = new XenForoUser($xfUser); } + /** + * Unused. + * + * @param array $credentials + * + * @return bool + */ public function validate(array $credentials = []): bool { return false; @@ -74,6 +109,10 @@ class XenForoGuard implements Guard $this->user = $user; } + /** + * Unused. + * @return void + */ public function logout(): void { redirect('/'); diff --git a/app/Auth/XenForoUser.php b/app/Auth/XenForoUser.php index def6d9e..8cf5fcb 100644 --- a/app/Auth/XenForoUser.php +++ b/app/Auth/XenForoUser.php @@ -10,11 +10,67 @@ use Filament\Panel; use Illuminate\Contracts\Auth\Access\Authorizable; use Illuminate\Contracts\Auth\Authenticatable; +/** + * Xenforo user custom model used for authentification. + * + * @property-read int $user_id + * @property-read string $username + * @property-read int $username_date + * @property-read int $username_date_visible + * @property-read string $email + * @property-read string $custom_title + * @property-read int $language_id + * @property-read int $style_id + * @property-read string $style_variation + * @property-read string $timezone + * @property-read int $visible + * @property-read int $activity_visible + * @property-read int $user_group_id + * @property-read array $secondary_group_ids + * @property-read int $display_style_group_id + * @property-read int $permission_combination_id + * @property-read int $message_count + * @property-read int $question_solution_count + * @property-read int $conversations_unread + * @property-read int $register_date + * @property-read int $last_activity + * @property-read int $last_summary_email_date + * @property-read int $trophy_points + * @property-read int $alerts_unviewed + * @property-read int $alerts_unread + * @property-read int $avatar_date + * @property-read int $avatar_width + * @property-read int $avatar_height + * @property-read int $avatar_highdpi + * @property-read int $avatar_optimized + * @property-read string $gravatar + * @property-read string $user_state + * @property-read string $security_lock + * @property-read int $is_moderator + * @property-read int $is_admin + * @property-read int $is_banned + * @property-read int $reaction_score + * @property-read int $vote_score + * @property-read int $warning_points + * @property-read int $is_staff + * @property-read string $secret_key + * @property-read int $privacy_policy_accepted + * @property-read int $terms_accepted + * + * Custom properties. + * + * @property-read int $rhpz_entry_count + */ class XenForoUser extends XenForoData implements Authenticatable, Authorizable, FilamentUser, HasName { use \Illuminate\Foundation\Auth\Access\Authorizable; + /** + * Permissions identifier array. + * @var array|null + */ public ?array $permissions = null; + public function getAuthIdentifierName(): string { return 'user_id'; @@ -51,9 +107,9 @@ class XenForoUser extends XenForoData implements Authenticatable, Authorizable, } /** - * Get XenForo avatar if it exist. + * Get XenForo avatar if it exists. * - * @param string $xfSize + * @param string $xfSize s/m/... * * @return string|null */ @@ -70,6 +126,14 @@ class XenForoUser extends XenForoData implements Authenticatable, Authorizable, return null; } + /** + * Custom can function. Check XF user permissions. + * + * @param string $permissionGroup + * @param string $permissionName + * + * @return bool + */ public function _can(string $permissionGroup, string $permissionName): bool { if( !$this->permissions ){ @@ -105,4 +169,9 @@ class XenForoUser extends XenForoData implements Authenticatable, Authorizable, { return 'user_id'; } + + public function validState(): bool + { + return $this->user_state === 'valid'; + } } diff --git a/app/Console/Commands/DeleteRejectedEntries.php b/app/Console/Commands/DeleteRejectedEntries.php index c65582f..395742b 100644 --- a/app/Console/Commands/DeleteRejectedEntries.php +++ b/app/Console/Commands/DeleteRejectedEntries.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use App\Models\Entry; +use App\Models\News; use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; @@ -20,7 +21,11 @@ class DeleteRejectedEntries extends Command $count = Entry::where('state', 'rejected') ->where('rejected_at', '<', now()->subDays($days)) ->delete(); + $count += News::where('state', 'rejected') + ->where('rejected_at', '<', now()->subDays($days)) + ->delete(); - $this->info("Deleted {$count} entries"); + $this->info("Deleted {$count} entries/news"); + return self::SUCCESS; } } diff --git a/app/Console/Commands/FixEncodedSlugs.php b/app/Console/Commands/FixEncodedSlugs.php new file mode 100644 index 0000000..e2600df --- /dev/null +++ b/app/Console/Commands/FixEncodedSlugs.php @@ -0,0 +1,40 @@ +where('slug', 'like', '%\\%%')->get(['id', 'slug']); + $fixed = 0; + + foreach ($rows as $row) { + $decoded = rawurldecode($row->slug); + if ($decoded === $row->slug) continue; + + try { + DB::table($table)->where('id', $row->id)->update(['slug' => $decoded]); + $fixed++; + } catch (\Throwable $e) { + $this->warn("{$table}#{$row->id} : collision '{$decoded}', ignored ({$e->getMessage()})."); + } + } + + if ($fixed > 0) { + $this->info("{$table}: {$fixed} fixed slugs."); + } + } + } +} diff --git a/app/Console/Commands/FixEntriesDescription.php b/app/Console/Commands/FixEntriesDescription.php new file mode 100644 index 0000000..e0765be --- /dev/null +++ b/app/Console/Commands/FixEntriesDescription.php @@ -0,0 +1,47 @@ + true]); + + $rows = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_posts') + ->where('target_table', 'entries') + ->get(['source_id', 'target_id']); + + $this->info("{$rows->count()} entries touched."); + + $this->withProgressBar($rows, function ($row) use ($converter) { + $rawHtml = DB::connection('old_wp')->table('posts') + ->where('ID', $row->source_id) + ->value('post_content'); + + if ($rawHtml === null) return; + + $markdown = trim($rawHtml) === '' ? $rawHtml : $converter->convert(MigrationHelpers::wpAutoP($rawHtml)); + + DB::table('entries')->where('id', $row->target_id)->update([ + 'description' => $markdown, + 'updated_at' => now(), + ]); + }); + + $this->newLine(); + $this->info('Process finished.'); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MigrateCategoriesConfigure.php b/app/Console/Commands/MigrateCategoriesConfigure.php new file mode 100644 index 0000000..6c9589e --- /dev/null +++ b/app/Console/Commands/MigrateCategoriesConfigure.php @@ -0,0 +1,44 @@ +ask("Write WP taxonomy name. Write nothing if you want to save changes.", "" ); + if( $taxonomy == "" ) + break; + + $section = $this->ask("Write entry section equivalent."); + + $taxonomyMap[$taxonomy] = $section; + } + + DB::table('migration_settings') + ->updateOrInsert([ + 'key' => 'wp_categories_to_entry_sections' + ],[ + 'value' => json_encode($taxonomyMap), 'updated_at' => now() + ]); + + $this->info('WP Categories to entry sections has been configured.'); + } +} diff --git a/app/Console/Commands/MigrateCategoriesExecute.php b/app/Console/Commands/MigrateCategoriesExecute.php new file mode 100644 index 0000000..5959285 --- /dev/null +++ b/app/Console/Commands/MigrateCategoriesExecute.php @@ -0,0 +1,92 @@ +where('key', 'wp_categories_to_entry_sections')->value('value'), true); + + if( !$taxMap ){ + $this->error("No WP taxonomies need to be transferred."); + return self::FAILURE; + } + + if( $this->ask("Are you sure you want launch that migration? Write ok if you want to launch it.", "") !== 'ok' ){ + return self::SUCCESS; + } + + $table = self::TABLE; + + foreach ( $taxMap as $wpTax => $restrictedTo ) + { + $this->info("Migrate: {$wpTax} restricted to {$table}"); + + $terms = DB::connection('old_wp') + ->table('term_taxonomy') + ->join('terms', 'term_taxonomy.term_id', '=', 'terms.term_id') + ->where('term_taxonomy.taxonomy', $wpTax) + ->select('term_taxonomy.term_taxonomy_id', 'terms.name', 'terms.slug') + ->get(); + + $this->withProgressBar($terms, function ($term) use ($table, $restrictedTo) { + + $exists = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_term_taxonomy') + ->where('source_id', $term->term_taxonomy_id ) + ->exists(); + + if( $exists ) + return; + + $existing = DB::table( $table )->where('slug', $term->slug)->first(); + + if( $existing === null) { + $Id = DB::table($table) + ->insertGetId([ + 'name' => $term->name, 'slug' => $term->slug, + 'restricted_to' => json_encode([$restrictedTo]), + 'created_at' => now(), 'updated_at' => now() + ]); + } else { + $Id = $existing->id; + $restrictedToField = json_decode($existing->restricted_to, true) ?? []; + if( !in_array( $restrictedTo, $restrictedToField, true ) ){ + $restrictedToField[] = $restrictedTo; + DB::table($table) + ->where('id', $Id) + ->update(['restricted_to' => json_encode($restrictedToField), 'updated_at' => now()]); + } + } + + DB::table('migrations_logs')->insert([ + 'source_system' => 'wp', + 'source_table' => 'wp_term_taxonomy', + 'source_id' => $term->term_taxonomy_id, + 'target_table' => $table, + 'target_id' => $Id, + 'status' => 'done', + 'migrated_at' => now(), + 'created_at' => now(), + 'updated_at' => now() + ]); + }); + $this->newLine(); + } + + $this->info("Migration done"); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MigrateEntriesComments.php b/app/Console/Commands/MigrateEntriesComments.php new file mode 100644 index 0000000..7b75704 --- /dev/null +++ b/app/Console/Commands/MigrateEntriesComments.php @@ -0,0 +1,80 @@ +option('import-log-table'); + if( !$logTable ){ + $this->error( 'XenForo import log table required' ); + return self::FAILURE; + } + + $this->commentsThread( 'entries', 'wp_posts', $logTable ); + $this->commentsThread( 'news', 'wp_posts__news', $logTable ); + + $this->info( "Done" ); + return self::SUCCESS; + } + + private function commentsThread( string $table, string $sourceTable, string $logTable ): void + { + $query = DB::table('migrations_logs') + ->where('source_system', 'wp' ) + ->where('source_table', $sourceTable ) + ->where('target_table', $table ); + + if( $limit = $this->option('limit') ){ + $query->limit((int)$limit); + } + + $rows = $query->get(['source_id', 'target_id']); + $this->info( "{$rows->count()} need migration logs" ); + + $stats = [ 'updated' => 0, 'no_meta' => 0, 'no_new_thread' => 0 ]; + + $this->withProgressBar( $rows, function ( $row ) use( $table, $logTable, &$stats ) { + + $oldThreadId = DB::connection('old_wp') + ->table('postmeta') + ->where('post_id', $row->source_id ) + ->where('meta_key', 'xf_thread_id') + ->value('meta_value'); + + if( !$oldThreadId ){ + $stats['no_meta']++; + return; + } + + $newThreadId = DB::connection('xenforo') + ->withoutTablePrefix( function( Connection $db ) use( $logTable, $oldThreadId ){ + return $db->table( $logTable ) + ->where('content_type', 'thread') + ->where('old_id', (string) $oldThreadId ) + ->value('new_id'); + }); + + if( !$newThreadId ){ + $stats['no_new_thread']++; + return; + } + + DB::table( $table )->where('id', $row->target_id )->update([ + 'comments_thread_id' => (int) $newThreadId + ]); + + $stats['updated']++; + }); + + $this->newLine(); + $this->info( "Updated: {$stats['updated']}, No new thread: {$stats['no_new_thread']}, No meta: {$stats['no_meta']}" ); + } +} diff --git a/app/Console/Commands/MigrateEntriesExecute.php b/app/Console/Commands/MigrateEntriesExecute.php new file mode 100644 index 0000000..cda0727 --- /dev/null +++ b/app/Console/Commands/MigrateEntriesExecute.php @@ -0,0 +1,295 @@ + 'draft', + 'pending' => 'pending', + 'publish' => 'published', + 'private' => 'hidden', + 'locked' => 'locked', + ]; + + private const array ACF_FIELDS = [ + 'entry_title', 'version_number', 'release_date', 'release_site', + 'youtube_video', 'staff', 'hashes', + ]; + + private const array MULTI_TAXONOMIES = [ + 'language' => 'languages', + 'modifications' => 'modifications', + 'author-name' => 'authors', + 'document-category' => 'categories', + 'utility-category' => 'categories', + 'utility-os' => 'systems', + ]; + + private array $stats = []; + + private function getSingle( ?int $term_id, string $targetTable ): ?int + { + if (!$term_id) { + return null; + } + + return DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('target_table', $targetTable ) + ->where('source_id', $term_id) + ->value('target_id'); + } + + private function uniqueSlug(string $baseSlug, string $table, ?int $ignoreId = null): string + { + $slug = $baseSlug; + $i = 1; + while ( + DB::table($table)->where('slug', $slug) + ->when($ignoreId, fn ($q) => $q->where('id', '!=', $ignoreId)) + ->exists() && $i < 100 + ) { + $slug = $baseSlug . '-' . $i++; + } + if ($i >= 100) { + $slug = (string) \Str::uuid(); + } + return $slug; + } + + private function parseStaffCredits(?string $raw): array + { + if (!$raw || trim($raw) === '') { + return []; + } + + $credits = []; + foreach (preg_split('/\r\n|\r|\n/', $raw) as $line) { + $line = trim($line); + if ($line === '') continue; + + if (preg_match('/^(.+?):\s*(.*)$/', $line, $m)) { + $credits[] = ['name' => trim($m[1]), 'description' => trim($m[2])]; + } elseif (!empty($credits)) { + $last = array_key_last($credits); + $credits[$last]['description'] = trim($credits[$last]['description'] . ' ' . $line); + } else { + $credits[] = ['name' => $line, 'description' => '']; + } + } + + return $credits; + } + + private function parseHashes(?string $raw): array + { + if (!$raw || trim($raw) === '') return []; + + $results = []; + foreach (preg_split('/\n\s*\n/', trim($raw)) as $block) { + $fields = []; + foreach (preg_split('/\r\n|\r|\n/', trim($block)) as $line) { + $line = trim($line); + if ($line === '' || !str_contains($line, ':')) continue; + [$key, $value] = array_map('trim', explode(':', $line, 2)); + $fields[strtolower(preg_replace('/[^a-z0-9]/i', '', $key))] = $value; + } + if (empty($fields)) continue; + + $results[] = [ + 'filename' => $fields['filename'] ?? '', + 'hash_crc32' => $fields['crc32'] ?? '', + 'hash_sha1' => $fields['sha1'] ?? '', + ]; + } + return $results; + } + + private function attachMany(int $entryId, string $pivotTable, string $foreignKey, array $ttids, string $targetTable): void + { + if (empty($ttids)) return; + + $ids = DB::table('migrations_logs') + ->where('source_system', 'wp')->where('target_table', $targetTable) + ->whereIn('source_id', $ttids)->pluck('target_id'); + + foreach ($ids as $id) { + DB::table($pivotTable)->insertOrIgnore(['entry_id' => $entryId, $foreignKey => $id]); + } + } + + /** + * @param \StdClass $post + * @param string $cpt + * + * @return void + */ + private function migratePost( $post, string $cpt ): void + { + $exists = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_posts') + ->where('source_id', $post->ID) + ->exists(); + + if ($exists) + return; + + $meta = DB::connection('old_wp') + ->table('postmeta') + ->where('post_id', $post->ID) + ->whereIn('meta_key', self::ACF_FIELDS) + ->pluck('meta_value', 'meta_key'); + + $terms = DB::connection('old_wp') + ->table('term_relationships as tr') + ->join('term_taxonomy as tt', 'tr.term_taxonomy_id', '=', 'tt.term_taxonomy_id') + ->where('tr.object_id', $post->ID) + ->whereIn('tt.taxonomy', array_merge(['game', 'platform', 'hack-status', 'experience-level'], array_keys(self::MULTI_TAXONOMIES)) ) + ->select('tt.taxonomy', 'tt.term_taxonomy_id') + ->get(); + + $byTax = []; + foreach ($terms as $term) { + $byTax[$term->taxonomy][] = $term->term_taxonomy_id; + } + + $gameId = null; + if( !empty( $byTax['game'][0] ) && !empty( $byTax['platform'][0] ) ) + { + $gameId = DB::table('migration_game_plan') + ->where('wp_game_id', $byTax['game'][0]) + ->where('wp_platform_id', $byTax['platform'][0]) + ->value('game_id'); + + if( !$gameId ) + $this->stats['missing_game_plan']++; + } + + $statusId = $this->getSingle( $byTax['hack-status'][0] ?? null, 'statuses' ); + $levelId = $this->getSingle( $byTax['experience-level'][0] ?? null, 'levels' ); + + $userId = DB::table('migration_user_plan') + ->where('wp_user_id', $post->post_author ) + ->value('user_id'); + + if( !$userId ) { + $this->stats['missing_author']++; + return; + } + + $title = $meta['entry_title'] ?? null; + if( $cpt === 'translations' && !$title && $gameId ) + { + $title = DB::table('games')->where('id', $gameId)->value('name'); + } + + $slug = $this->uniqueSlug( rawurldecode($post->post_name), 'entries' ); + + $entryId = DB::table('entries')->insertGetId([ + 'type' => $cpt, + 'title' => $title, + 'complete_title' => $post->post_title ?? null, + 'slug' => $slug, + 'description' => MigrationHelpers::htmlToMarkdown($post->post_content), + 'state' => self::STATE_MAP[$post->post_status], + 'game_id' => $gameId, + 'platform_id' => null, + 'status_id' => $statusId, + 'level_id' => $levelId, + 'version' => $meta['version_number'] ?? null, + 'release_date' => $meta['release_date'] ?? null, + 'staff_credits' => json_encode( $this->parseStaffCredits($meta['staff'] ?? null)), + 'relevant_link' => $meta['release_site'] ?? null, + 'youtube_link' => $meta['youtube_video'] ?? null, + 'user_id' => $userId, + 'created_at' => $post->post_date, + 'updated_at' => $post->post_modified, + ]); + + $this->attachMany($entryId, 'entry_authors', 'author_id', $byTax['author-name'] ?? [], 'authors'); + $this->attachMany($entryId, 'entry_languages', 'language_id', $byTax['language'] ?? [], 'languages'); + + if( $cpt === 'romhacks') + $this->attachMany($entryId, 'entry_modifications', 'modification_id', $byTax['modifications'] ?? [], 'modifications'); + + if ($cpt === 'utilities') { + $this->attachMany($entryId, 'entry_categories', 'category_id', $byTax['utility-category'] ?? [], 'categories'); + $this->attachMany($entryId, 'entry_systems', 'system_id', $byTax['utility-os'] ?? [], 'systems'); + } + if ($cpt === 'documents') { + $this->attachMany($entryId, 'entry_categories', 'category_id', $byTax['document-category'] ?? [], 'categories'); + } + + if( $cpt === 'translations' || $cpt === 'romhacks' ){ + foreach ( $this->parseHashes( $meta['hashes'] ?? null ) as $hash ) { + DB::table('entry_hashes')->insert([ + 'entry_id' => $entryId, + 'filename' => $hash['filename'], + 'hash_crc32' => $hash['hash_crc32'], + 'hash_sha1' => $hash['hash_sha1'], + 'verified' => 'TBD', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + DB::table('migrations_logs')->insert([ + 'source_system' => 'wp', + 'source_table' => 'wp_posts', + 'source_id' => $post->ID, + 'target_table' => 'entries', + 'target_id' => $entryId, + 'status' => 'done', + 'migrated_at' => now(), + 'updated_at' => now(), + 'created_at' => now(), + ]); + + $this->stats['created']++; + + } + + private function migrateCpt( string $cpt ): void + { + $this->info( "Current migration of : $cpt "); + $this->stats = [ 'missing_author' => 0, 'missing_game_plan' => 0, 'created' => 0 ]; + + $query = DB::connection('old_wp') + ->table('posts') + ->where('post_type', $cpt) + ->whereIn('post_status', array_keys(self::STATE_MAP)) + ; + + if( $limit = $this->option('limit') ) { + $query->limit((int) $limit); + } + + $posts = $query->select('ID', 'post_title', 'post_name', 'post_content', 'post_status', 'post_author', 'post_date', 'post_modified') + ->get(); + + $this->withProgressBar($posts, fn( $post ) => $this->migratePost( $post, $cpt ) ); + + $this->newLine(); + $this->info( "Created {$this->stats['created']} entries'. No authors {$this->stats['missing_author']}. Missing game {$this->stats['missing_game_plan']} entries" ); + } + + public function handle() + { + foreach ( self::WP_CPTS as $cpt ) { + $this->migrateCpt( $cpt ); + } + } +} diff --git a/app/Console/Commands/MigrateEntriesImages.php b/app/Console/Commands/MigrateEntriesImages.php new file mode 100644 index 0000000..944c61b --- /dev/null +++ b/app/Console/Commands/MigrateEntriesImages.php @@ -0,0 +1,146 @@ +table('postmeta') + ->where('post_id', $attachmentId) + ->where('meta_key', '_wp_attached_file') + ->value('meta_value'); + + if( !$relativePath ) + return null; + + $sourcePath = rtrim($wpUploadsPath, '/') . '/' . $relativePath; + if( !is_file($sourcePath) ) + return null; + + $extension = pathinfo($sourcePath, PATHINFO_EXTENSION); + $filename = Str::random(40) . ($extension ? '.' . $extension : ''); + $destinationRelative = $destinationPath . '/' . $filename; + + $stream = fopen($sourcePath, 'r'); + Storage::disk('public')->put($destinationRelative, $stream); + if( is_resource($stream) ) + fclose($stream); + + return $destinationRelative; + } + + private function processEntry( int $wpPostId, int $entryId, string $wpUploadsPath, array &$stats ): void + { + $exists = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_posts__attachments') + ->where('source_id', $wpPostId) + ->exists(); + + if( $exists ) + return; + + $thumbnailId = DB::connection('old_wp') + ->table('postmeta') + ->where('post_id', $wpPostId) + ->where('meta_key', '_thumbnail_id') + ->value('meta_value'); + + if( $thumbnailId ){ + $newPath = $this->copyAttachment( (int) $thumbnailId, $wpUploadsPath, 'entries/main-images' ); + if( $newPath ){ + DB::table('entries') + ->where('id', $entryId) + ->update(['main_image' => $newPath]); + $stats['main_image']++; + } else { + $stats['missing_files']++; + } + } + + $galleryRaw = DB::connection('old_wp') + ->table('postmeta') + ->where('post_id', $wpPostId) + ->where('meta_key', 'my_gallery') + ->value('meta_value'); + + $attachmentIds = $galleryRaw ? (@unserialize($galleryRaw) ?: []) : []; + + foreach ( array_values( $attachmentIds ) as $order => $attachmentId ) { + $newPath = $this->copyAttachment( (int) $attachmentId, $wpUploadsPath, "entries/gallery-images/{$entryId}" ); + if( !$newPath ){ + $stats['missing_files']++; + continue; + } + + DB::table('galleries') + ->insert([ + 'galleryable_type' => self::GALLERYABLE_TYPE, + 'galleryable_id' => $entryId, + 'image' => $newPath, + 'order' => $order, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $stats['gallery_images']++; + } + + DB::table('migrations_logs') + ->insert([ + 'source_system' => 'wp', + 'source_table' => 'wp_posts__attachments', + 'source_id' => $wpPostId, + 'target_table' => 'entries', + 'target_id' => $entryId, + 'status' => 'done', + 'migrated_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function handle() + { + $wpUploadsPath = $this->option('wp-uploads-path'); + if( !$wpUploadsPath || !is_dir($wpUploadsPath) ){ + $this->error('Missing WP Uploads Path'); + return self::FAILURE; + } + + $query = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_posts') + ->where('target_table', 'entries') + ; + + if( $limit = $this->option('limit') ){ + $query->limit((int)$limit); + } + + $rows = $query->get(['source_id', 'target_id']); + $this->info("{$rows->count()} entries need to be migrated"); + + $stats = ['main_image' => 0, 'gallery_images' => 0, 'missing_files' => 0 ]; + + $this->withProgressBar($rows, function($row) use($wpUploadsPath, &$stats) { + $this->processEntry( $row->source_id, $row->target_id, $wpUploadsPath, $stats ); + }); + + $this->newLine(); + $this->info("Migrated attachments. Main images: {$stats['main_image']}, Galleries: {$stats['gallery_images']}, Missing files: {$stats['missing_files']}"); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MigrateGamesExecute.php b/app/Console/Commands/MigrateGamesExecute.php new file mode 100644 index 0000000..fc37c23 --- /dev/null +++ b/app/Console/Commands/MigrateGamesExecute.php @@ -0,0 +1,169 @@ +table('term_relationships as tr') + ->join('term_taxonomy as tt', 'tr.term_taxonomy_id', '=', 'tt.term_taxonomy_id') + ->join('posts as p', 'tr.object_id', '=', 'p.ID') + ->whereIn('tt.taxonomy', self::RELATED_TAXS ) + ->whereIn('p.post_type', self::WP_CPTS ) + ->where('p.post_status', '!=', 'trash') + ->select('p.ID as post_id', 'tt.taxonomy', 'tt.term_taxonomy_id' ) + ->get(); + + $byPost = []; + $ignored = 0; + foreach( $rows as $row ){ + if( isset( $byPost[ $row->post_id ][$row->taxonomy] ) ){ + $ignored++; + continue; + } + $byPost[ $row->post_id ][$row->taxonomy] = $row->term_taxonomy_id; + } + if( $ignored ){ + $this->warn("$ignored posts with multiple taxs will be ignored."); + } + + $structure = []; + $ignoredNoPlatform = 0; + + foreach ( $byPost as $data ) + { + if( empty( $data['game'] ) ) + continue; + + if( empty( $data['platform'] ) ){ + $ignoredNoPlatform++; + continue; + } + + $gameId = $data['game']; + $platformId = $data['platform']; + $genreId = $data['genre'] ?? self::NO_GENRE_SENTINEL; + + $structure[$gameId][$platformId]['count'] = ( $structure[$gameId][$platformId]['count'] ?? 0 ) + 1; + $structure[$gameId][$platformId]['genres'][$genreId] = ( $structure[$gameId][$platformId]['genres'][$genreId] ?? 0 ) + 1; + } + if( $ignoredNoPlatform ){ + $this->warn("$ignoredNoPlatform posts with no platforms will be ignored."); + } + + $games = DB::connection('old_wp') + ->table('term_taxonomy') + ->join('terms', 'term_taxonomy.term_id', '=', 'terms.term_id') + ->where('term_taxonomy.taxonomy', 'game' ) + ->select('term_taxonomy.term_taxonomy_id', 'terms.name', 'terms.slug' ) + ->get() + ->keyBy('term_taxonomy_id'); + + $platformMap = DB::table('migrations_logs') + ->where('source_system', 'wp' ) + ->where('target_table', 'platforms') + ->pluck('target_id', 'source_id'); + + $genreMap = DB::table('migrations_logs') + ->where('source_system', 'wp' ) + ->where('target_table', 'genres') + ->pluck('target_id', 'source_id'); + + if( $this->ask("Are you sure you want launch that migration? Write ok if you want to launch it.", "") !== 'ok' ){ + return self::SUCCESS; + } + + $created = 0; + $genreConflicts = 0; + + foreach( $structure as $gameId => $platforms ) + { + $game = $games->get( $gameId ); + if( !$game ) + continue; + + foreach( $platforms as $platformId => $info ) + { + $alreadyExists = DB::table('migration_game_plan') + ->where('wp_game_id', $gameId ) + ->where('wp_platform_id', $platformId ) + ->exists(); + if( $alreadyExists ) + continue; + + $newPlatformId = $platformMap[$platformId] ?? null; + if( !$newPlatformId ){ + $this->warn("$gameId ignored because platform $platformId does not exist in Laravel."); + continue; + } + + $genres = $info['genres']; + $newGenreId = null; + $topId = null; + $genreConflict = false; + + arsort( $genres ); + $topId = array_key_first( $genres ); + $topCount = $genres[$topId]; + $tied = count(array_filter( + $genres, fn( $c ) => $c === $topCount + )); + if( $tied > 1 ){ + $genreConflict = true; + $genreConflicts++; + } + $newGenreId = $topId === self::NO_GENRE_SENTINEL + ? self::DEFAULT_GENRE_ID + : ($genreMap[$topId] ?? self::DEFAULT_GENRE_ID); + + $gameSlug = $game->slug; + if (count($platforms) > 1) { + $platformSlug = DB::table('platforms')->where('id', $newPlatformId)->value('slug'); + $gameSlug = $game->slug . '-' . $platformSlug; + } + + $newGameId = DB::table('games') + ->insertGetId([ + 'name' => $game->name, + 'slug' => $gameSlug, + 'platform_id' => $newPlatformId, + 'genre_id' => $newGenreId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('migration_game_plan')->insert([ + 'wp_game_id' => $gameId, + 'wp_platform_id' => $platformId, + 'game_id' => $newGameId, + 'wp_genre_id' => $topId === self::NO_GENRE_SENTINEL ? null : $topId, + 'post_count' => $info['count'], + 'genre_conflict' => $genreConflict, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $created++; + + } + } + + $this->newLine(); + $this->info("$created games created. $genreConflicts genre conflicts."); + } +} diff --git a/app/Console/Commands/MigrateNewsExecute.php b/app/Console/Commands/MigrateNewsExecute.php new file mode 100644 index 0000000..0141f10 --- /dev/null +++ b/app/Console/Commands/MigrateNewsExecute.php @@ -0,0 +1,149 @@ + 'draft', + 'pending' => 'pending', + 'publish' => 'published', + 'private' => 'hidden', + 'locked' => 'locked', + ]; + + private array $stats = []; + + private function uniqueSlug(string $baseSlug, string $table, ?int $ignoreId = null): string + { + $slug = $baseSlug; + $i = 1; + while ( + DB::table($table)->where('slug', $slug) + ->when($ignoreId, fn ($q) => $q->where('id', '!=', $ignoreId)) + ->exists() && $i < 100 + ) { + $slug = $baseSlug . '-' . $i++; + } + if ($i >= 100) { + $slug = (string) Str::uuid(); + } + return $slug; + } + + private function migrateNews($post): void + { + $exists = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_posts__news') + ->where('source_id', $post->ID) + ->exists(); + if ($exists) + return; + + $meta = DB::connection('old_wp') + ->table('postmeta') + ->where('post_id', $post->ID) + ->whereIn('meta_key', ['release_site', 'romhacks_page', 'youtube_video' ]) + ->pluck('meta_value', 'meta_key'); + + $categoryTtId = DB::connection('old_wp') + ->table('term_relationships as tr') + ->join('term_taxonomy as tt', 'tr.term_taxonomy_id', '=', 'tt.term_taxonomy_id') + ->where('tr.object_id', $post->ID) + ->where('tt.taxonomy', 'news-category') + ->value('tt.term_taxonomy_id'); + + $categoryId = null; + if( $categoryTtId ){ + $categoryId = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('target_table', 'categories' ) + ->where('source_id', $categoryTtId) + ->value('target_id'); + if( !$categoryId ){ + $this->stats['missing_category']++; + } + } + + $userId = DB::table('migration_user_plan') + ->where('wp_user_id', $post->post_author ) + ->value('user_id'); + + if( !$userId ){ + $this->stats['missing_author']++; + return; + } + + $slug = $this->uniqueSlug(rawurldecode($post->post_name), 'news'); + $description = trim($post->post_content) === '' ? '' : MigrationHelpers::htmlToMarkdown($post->post_content); + + if( isset( $meta['romhacks_page'] ) && $meta['romhacks_page'] !== null && $meta['romhacks_page'] !== '' ){ + $description .= "\n\nLink to: " . $meta['romhacks_page']; + } + + $newsId = DB::table('news') + ->insertGetId([ + 'title' => $post->post_title, + 'slug' => $slug, + 'category_id' => $categoryId, + 'description' => $description, + 'state' => self::STATE_MAP[$post->post_status], + 'entry_id' => null, + 'relevant_link' => $meta['release_site'] ?? null, + 'youtube_link' => $meta['youtube_video'] ?? null, + 'user_id' => $userId, + 'created_at' => $post->post_date, + 'updated_at' => $post->post_modified, + ]); + + DB::table('migrations_logs')->insert([ + 'source_system' => 'wp', + 'source_table' => 'wp_posts__news', + 'source_id' => $post->ID, + 'target_table' => 'news', + 'target_id' => $newsId, + 'status' => 'done', + 'migrated_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->stats['created']++; + } + + public function handle() + { + $this->stats = [ 'created' => 0, 'missing_author' => 0, 'missing_category' => 0 ]; + + $query = DB::connection('old_wp') + ->table('posts') + ->where('post_type', 'news') + ->whereIn('post_status', array_keys(self::STATE_MAP)) + ; + + if( $limit = $this->option('limit') ) { + $query->limit((int)$limit); + } + + $posts = $query->select('ID', 'post_title', 'post_name', 'post_content', 'post_author', 'post_status', 'post_date', 'post_modified')->get(); + + $this->info( "{$posts->count()} posts found" ); + + $this->withProgressBar($posts, fn($post) => $this->migrateNews($post) ); + + $this->newLine(); + $this->info( "Created {$this->stats['created']} posts. Missing authors: {$this->stats['missing_author']}. Missing category: {$this->stats['missing_category']}" ); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MigrateNewsImages.php b/app/Console/Commands/MigrateNewsImages.php new file mode 100644 index 0000000..b2a637e --- /dev/null +++ b/app/Console/Commands/MigrateNewsImages.php @@ -0,0 +1,128 @@ +table('postmeta') + ->where('post_id', $attachmentId) + ->where('meta_key', '_wp_attached_file') + ->value('meta_value'); + + if( !$relativePath ) + return null; + + $sourcePath = rtrim($wpUploadsPath, '/') . '/' . $relativePath; + if( !is_file($sourcePath) ) + return null; + + $extension = pathinfo($sourcePath, PATHINFO_EXTENSION); + $filename = Str::random(40) . ($extension ? '.' . $extension : ''); + $destinationRelative = $destinationPath . '/' . $filename; + + $stream = fopen($sourcePath, 'r'); + Storage::disk('public')->put($destinationRelative, $stream); + if( is_resource($stream) ) + fclose($stream); + + return $destinationRelative; + } + + private function processEntry( int $wpPostId, int $entryId, string $wpUploadsPath, array &$stats ): void + { + $exists = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_posts__attachments') + ->where('source_id', $wpPostId) + ->exists(); + + if( $exists ) + return; + + $galleryRaw = DB::connection('old_wp') + ->table('postmeta') + ->where('post_id', $wpPostId) + ->where('meta_key', 'my_gallery') + ->value('meta_value'); + + $attachmentIds = $galleryRaw ? (@unserialize($galleryRaw) ?: []) : []; + + foreach ( array_values( $attachmentIds ) as $order => $attachmentId ) { + $newPath = $this->copyAttachment( (int) $attachmentId, $wpUploadsPath, "news/gallery-images/{$entryId}" ); + if( !$newPath ){ + $stats['missing_files']++; + continue; + } + + DB::table('galleries') + ->insert([ + 'galleryable_type' => self::GALLERYABLE_TYPE, + 'galleryable_id' => $entryId, + 'image' => $newPath, + 'order' => $order, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $stats['gallery_images']++; + } + + DB::table('migrations_logs') + ->insert([ + 'source_system' => 'wp', + 'source_table' => 'wp_posts__attachments', + 'source_id' => $wpPostId, + 'target_table' => 'news', + 'target_id' => $entryId, + 'status' => 'done', + 'migrated_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function handle() + { + $wpUploadsPath = $this->option('wp-uploads-path'); + if( !$wpUploadsPath || !is_dir($wpUploadsPath) ){ + $this->error('Missing WP Uploads Path'); + return self::FAILURE; + } + + $query = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_posts__news') + ->where('target_table', 'news') + ; + + if( $limit = $this->option('limit') ){ + $query->limit((int)$limit); + } + + $rows = $query->get(['source_id', 'target_id']); + $this->info("{$rows->count()} news need to be migrated"); + + $stats = ['gallery_images' => 0, 'missing_files' => 0 ]; + + $this->withProgressBar($rows, function($row) use($wpUploadsPath, &$stats) { + $this->processEntry( $row->source_id, $row->target_id, $wpUploadsPath, $stats ); + }); + + $this->newLine(); + $this->info("Migrated attachments Galleries: {$stats['gallery_images']}, Missing files: {$stats['missing_files']}"); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MigrateReviewsExecute.php b/app/Console/Commands/MigrateReviewsExecute.php new file mode 100644 index 0000000..97bf8a1 --- /dev/null +++ b/app/Console/Commands/MigrateReviewsExecute.php @@ -0,0 +1,117 @@ +where('source_system', 'wp') + ->where('source_table', 'wp_posts__reviews') + ->where('source_id', $post->ID ) + ->exists(); + + if( $exists ) + return; + + $meta = DB::connection('old_wp') + ->table('postmeta') + ->where('post_id', $post->ID) + ->whereIn('meta_key', ['reviews_post_link', 'review_rating']) + ->pluck('meta_value', 'meta_key') + ; + + $linkedWpPostId = $meta['reviews_post_link'] ?? null; + if( !$linkedWpPostId ){ + $stats['missing_entry']++; + return; + } + + $entryId = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_posts') + ->where('source_id', (int) $linkedWpPostId ) + ->where('target_table', 'entries') + ->value('target_id'); + + if( !$entryId ){ + $stats['missing_entry']++; + return; + } + + $userId = DB::table('migration_user_plan') + ->where('wp_user_id', $post->post_author ) + ->value('user_id'); + + if( !$userId ){ + $stats['missing_author']++; + return; + } + + $rating = $meta['review_rating'] ?? null; + if( $rating === null || $rating === '' ){ + $stats['missing_rating']++; + return; + } + + $description = trim($post->post_content) === '' ? '' : MigrationHelpers::htmlToMarkdown($post->post_content); + + $reviewId = DB::table('entry_reviews')->insertGetId([ + 'entry_id' => $entryId, + 'title' => $post->post_title, + 'rating' => (int) $rating, + 'description' => $description, + 'user_id' => $userId, + 'created_at' => $post->post_date, + 'updated_at' => $post->post_modified, + ]); + + DB::table('migrations_logs')->insert([ + 'source_system' => 'wp', + 'source_table' => 'wp_posts__reviews', + 'source_id' => $post->ID, + 'target_table' => 'entry_reviews', + 'target_id' => $reviewId, + 'status' => 'done', + 'migrated_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $stats['created']++; + } + + public function handle(): int + { + $query = DB::connection('old_wp') + ->table('posts') + ->where('post_type', 'reviews') + ->where('post_status', 'publish') + ; + + if( $limit = $this->option('limit') ) { + $query->limit((int) $limit); + } + + $posts = $query->select('ID', 'post_title', 'post_content', 'post_author', 'post_date', 'post_modified' )->get(); + $this->info("{$posts->count()} reviews found."); + + $stats = ['created' => 0, 'missing_entry' => 0, 'missing_author' => 0, 'missing_rating' => 0 ]; + + $this->withProgressBar($posts, function($post) use (&$stats){ + $this->migrateReview($post, $stats); + }); + + $this->newLine(); + $this->info("{$stats['created']} reviews created. {$stats['missing_entry']} missing entry. {$stats['missing_author']} missing author. {$stats['missing_rating']} missing rating."); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MigrateTaxonomiesConfigure.php b/app/Console/Commands/MigrateTaxonomiesConfigure.php new file mode 100644 index 0000000..50e302f --- /dev/null +++ b/app/Console/Commands/MigrateTaxonomiesConfigure.php @@ -0,0 +1,44 @@ +ask("Write WP taxonomy name. Write nothing if you want to save changes.", "" ); + if( $taxonomy == "" ) + break; + + $tableName = $this->ask("Write equivalent Laravel table name."); + + $taxonomyMap[$taxonomy] = $tableName; + } + + DB::table('migration_settings') + ->updateOrInsert([ + 'key' => 'wp_taxonomies_to_laravel_tables' + ],[ + 'value' => json_encode($taxonomyMap), 'updated_at' => now() + ]); + + $this->info('WP Taxonomies to Laravel tables have been configured.'); + } +} diff --git a/app/Console/Commands/MigrateTaxonomiesExecute.php b/app/Console/Commands/MigrateTaxonomiesExecute.php new file mode 100644 index 0000000..d32cc6e --- /dev/null +++ b/app/Console/Commands/MigrateTaxonomiesExecute.php @@ -0,0 +1,77 @@ +where('key', 'wp_taxonomies_to_laravel_tables')->value('value'), true); + + if( !$taxMap ){ + $this->error("No WP taxonomies need to be transferred."); + return self::FAILURE; + } + + if( $this->ask("Are you sure you want launch that migration? Write ok if you want to launch it.", "") !== 'ok' ){ + return self::SUCCESS; + } + + foreach ( $taxMap as $wpTax => $table ) + { + $this->info("Migrate: {$wpTax} => {$table}"); + + $terms = DB::connection('old_wp') + ->table('term_taxonomy') + ->join('terms', 'term_taxonomy.term_id', '=', 'terms.term_id') + ->where('term_taxonomy.taxonomy', $wpTax) + ->select('term_taxonomy.term_taxonomy_id', 'terms.name', 'terms.slug') + ->get(); + + $this->withProgressBar($terms, function ($term) use ($table) { + + $exists = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_term_taxonomy') + ->where('source_id', $term->term_taxonomy_id ) + ->exists(); + + if( $exists ) + return; + + $Id = DB::table( $table )->where('slug', $term->slug)->value('id'); + + if( $Id === null) { + $Id = DB::table($table) + ->insertGetId([ + 'name' => $term->name, 'slug' => $term->slug, + 'created_at' => now(), 'updated_at' => now() + ]); + } + + DB::table('migrations_logs')->insert([ + 'source_system' => 'wp', + 'source_table' => 'wp_term_taxonomy', + 'source_id' => $term->term_taxonomy_id, + 'target_table' => $table, + 'target_id' => $Id, + 'status' => 'done', + 'migrated_at' => now(), + 'created_at' => now(), + 'updated_at' => now() + ]); + }); + $this->newLine(); + } + + $this->info("Migration done"); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MigrateUsersConfigure.php b/app/Console/Commands/MigrateUsersConfigure.php new file mode 100644 index 0000000..98d25d0 --- /dev/null +++ b/app/Console/Commands/MigrateUsersConfigure.php @@ -0,0 +1,57 @@ +table('user_group')->pluck('title', 'user_group_id'); + } + + public function handle() + { + $wpRoles = $this->getWpRoles(); + $roleMap = []; + foreach ($wpRoles as $role) { + $roleMap[$role] = (int) $this->ask("XenForo group ID linked to role {$role}"); + } + DB::table('migration_settings') + ->updateOrInsert([ + 'key' => 'wp_role_to_xf_group' + ], [ + 'value' => json_encode($roleMap), 'updated_at' => now() + ]); + + $oldXfGroups = $this->getOldXfGroups(); + $groupMap = []; + foreach ($oldXfGroups as $id => $title) { + $groupMap[$id] = (int) $this->ask("Xenforo group ID linked to old XF group {$title}|{$id}"); + } + DB::table('migration_settings') + ->updateOrInsert([ + 'key' => 'old_xf_group_to_xf_group' + ],[ + 'value' => json_encode($groupMap), 'updated_at' => now() + ]); + + $this->info('XF groups updated.'); + } +} diff --git a/app/Console/Commands/MigrateUsersExecute.php b/app/Console/Commands/MigrateUsersExecute.php new file mode 100644 index 0000000..6160e1d --- /dev/null +++ b/app/Console/Commands/MigrateUsersExecute.php @@ -0,0 +1,229 @@ +table('postmeta') + ->where('post_id', $mediaId) + ->where('meta_key', '_wp_attached_file') + ->value('meta_value'); + + if( !$relative ) + return null; + + $path = rtrim($wpUploadsPath, '/') . '/' . $relative; + return is_file($path) ? $path : null; + } + + private function extractXfAvatarPath(int $userId, string $xfDataPath, string $size = 'o' ): ?string + { + $path = sprintf('%s/avatars/%s/%d/%d.jpg', + rtrim($xfDataPath, '/'), $size, (int) floor($userId / 1000), $userId + ); + return is_file($path) ? $path : null; + } + + private function extractXfBannerPath(int $userId, string $xfDataPath, string $size = 'l' ): ?string + { + $path = sprintf('%s/profile_banners/%s/%d/%d.jpg', + rtrim($xfDataPath, '/'), $size, (int) floor($userId / 1000), $userId + ); + return is_file($path) ? $path : null; + } + + private function buildUserInfos( MigrationUserPlan $plan, array $roleMap, array $groupMap, string $wpUploadsPath, string $xfDataPath ): array + { + $infos = [ + 'username' => $plan->xf_username ?: $plan->wp_username, + 'email' => $plan->email, + 'user_group_id' => null, + 'password' => Str::uuid(), // Used for API verifications. + 'xf_user_id' => 0, + 'avatar_path' => null, + 'banner_path' => null, + 'register_date' => null, + 'profile' => [ + 'about' => null, + 'website' => null, + ], + 'source_real_password' => null, + 'wp_password' => null, + 'xf_scheme_class' => null, + 'xf_password_data' => null, + ]; + + if( $plan->wp_user_id ) + { + $wp = DB::connection('old_wp') + ->table('users') + ->leftJoin('usermeta as m1', fn( $j ) => $j->on('users.ID', '=', 'm1.user_id')->where('m1.meta_key', '=', 'description') ) + ->leftJoin('usermeta as m2', fn( $j ) => $j->on('users.ID', '=', 'm2.user_id')->where('m2.meta_key', '=', 'wp_capabilities') ) + ->leftJoin('usermeta as m3', fn( $j ) => $j->on('users.ID', '=', 'm3.user_id')->where('m3.meta_key', '=', 'simple_local_avatar') ) + ->where('users.ID', '=', $plan->wp_user_id ) + ->select('m1.meta_value as description', 'm2.meta_value as capabilities', 'm3.meta_value as avatar_meta', 'users.user_url as website', 'users.user_pass as password', 'users.user_registred' ) + ->first(); + + $infos['register_date'] = $wp->user_registred ? strtotime($wp->user_registred) : null; + $infos['profile']['about'] = $wp->description; + $infos['profile']['website'] = $wp->website; + $role = $this->extractWpRole($wp->capabilities); + $infos['user_group_id'] = $roleMap[$role] ?? $roleMap['contributor']; + + if( $url = $this->extractWpAvatarPath($wp->avatar_meta, $wpUploadsPath)){ + $infos['avatar_path'] = $url; + } + + $infos['source_real_password'] = 'wp'; + $infos['wp_password'] = $wp->password; + } + + if( $plan->xf_user_id ) + { + $xf = DB::connection('old_xf') + ->table('user') + ->leftJoin('user_profile', 'user.user_id', '=', 'user_profile.user_id') + ->leftJoin('user_authenticate', 'user.user_id', '=', 'user_authenticate.user_id') + ->where('user.user_id', '=', $plan->xf_user_id) + ->select('user.avatar_date', 'user.user_group_id', 'user.register_date', 'user_profile.about', 'user_profile.website', 'user_profile.banner_date', 'user_authenticate.scheme_class', 'user_authenticate.data') + ->first(); + + if( !$infos['register_date'] && $xf ) + $infos['register_date'] = $xf->register_date ?: null; + if( !$infos['profile']['about'] && $xf ) + $infos['profile']['about'] = $xf->about ?: null; + if( !$infos['profile']['website'] && $xf ) + $infos['profile']['website'] = $xf->website ?: null; + + if( !$plan->wp_user_id ){ + $infos['user_group_id'] = $groupMap[$xf->user_group_id] ?? reset($groupMap); + } + $infos['xf_user_id'] = $plan->xf_user_id; + + if( $infos['avatar_path'] === null && (int) $xf->avatar_date > 0){ + if( $path = $this->extractXfAvatarPath($plan->xf_user_id, $xfDataPath)){ + $infos['avatar_path'] = $path; + } + } + + if( $infos['banner_path'] === null && (int) $xf->banner_date > 0 ){ + if( $path = $this->extractXfBannerPath($plan->xf_user_id, $xfDataPath)){ + $infos['banner_path'] = $path; + } + } + + if( $infos['source_real_password'] === null && $xf->scheme_class && $xf->data ){ + $infos['source_real_password'] = 'xf'; + $infos['xf_scheme_class'] = $xf->scheme_class; + $infos['xf_password_data'] = $xf->data; + } + } + + return $infos; + } + + private function logMap( string $sourceSystem, string $sourceTable, int $sourceId, int $targetId ) + { + DB::table('migrations_logs')->insert([ + 'source_system' => $sourceSystem, + 'source_table' => $sourceTable, + 'source_id' => $sourceId, + 'target_table' => 'xf_user', + 'target_id' => $targetId, + 'status' => 'done', + 'migrated_at' => now(), + 'created_at' => now(), + 'updated_at' => now() + ]); + } + + public function handle() + { + $wpUploadsPath = $this->option('wp-uploads-path'); + $xfDataPath = $this->option('xf-data-path'); + + if( !$wpUploadsPath || !is_dir($wpUploadsPath) ){ + $this->error('Missing WP Uploads Path'); + return self::FAILURE; + } + + if( !$xfDataPath || !is_dir($xfDataPath) ){ + $this->error('Missing XF Data Path'); + return self::FAILURE; + } + + $roleMap = json_decode(DB::table('migration_settings')->where('key', 'wp_role_to_xf_group')->value('value'), true); + $groupMap = json_decode(DB::table('migration_settings')->where('key', 'old_xf_group_to_xf_group')->value('value'), true); + + if( !$roleMap || !$groupMap ) { + $this->error('Role map and group map are required.'); + return self::FAILURE; + } + + $query = MigrationUserPlan::where('status', 'approved')->whereNull('user_id'); + if( $limit = $this->option('limit') ) { + $query->limit((int) $limit); + } + + $rows = $query->get(); + $this->info("{$rows->count()} accounts will be created on XenForo database !!!."); + $ok = $this->ask("Write 'ok' if you want to start the migration. Everything else to quit the migration."); + if( $ok !== 'ok' ) { + return self::SUCCESS; + } + + $service = app(XenforoApiService::class); + + $this->withProgressBar($rows, function($plan) use( $roleMap, $groupMap, $wpUploadsPath, $xfDataPath, $service ) { + try { + $infos = $this->buildUserInfos( $plan, $roleMap, $groupMap, $wpUploadsPath, $xfDataPath ); + + [ $userId, $passwordSet ] = $service->_migrateUser( $infos ); + if( !$userId ){ + throw new \RuntimeException("Error when user creation."); + } + + MigrationUserPlan::where('id', $plan->id )->update([ 'user_id' => $userId ]); + + $this->logMap( $plan->wp_user_id ? 'wp' : 'xf', $plan->wp_user_id ? 'wp_users' : 'xf_user', $plan->wp_user_id ?? $plan->xf_user_id, $userId ); + if( $plan->wp_user_id && $plan->xf_user_id ){ + $this->logMap('xf', 'xf_user', $plan->xf_user_id, $userId ); + } + + + } catch ( \Throwable $e ) { + Log::error("Unable to create Plan#{$plan->id} user : {$e->getMessage()}"); + } + }); + + $this->newLine(2); + $this->info("Process finished."); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MigrateUsersPlan.php b/app/Console/Commands/MigrateUsersPlan.php new file mode 100644 index 0000000..28b9336 --- /dev/null +++ b/app/Console/Commands/MigrateUsersPlan.php @@ -0,0 +1,143 @@ +option('fresh') ) { + MigrationUserPlan::truncate(); + } + + $this->info("Loading old XF accounts..."); + + $xfUsers = DB::connection('old_xf')->table('user') + ->select('user_id','username','email') + ->get() + ->keyBy('user_id') + ; + + $xfUsersByEmail = $xfUsers->groupBy(fn($u) => strtolower($u->email)); + + $this->info("Loading old WP accounts..."); + + $wpUsers = DB::connection('old_wp')->table('users') + ->leftJoin('usermeta', function ($join) { + $join->on('users.ID', '=', 'usermeta.user_id')->where('usermeta.meta_key', '=', 'xf_user_id'); + }) + ->select('users.ID as wp_id', 'users.user_email as email', 'users.user_login as username', 'usermeta.meta_value as linked_xf_id') + ->get(); + + $linkedXfIds = []; + + $this->withProgressBar( $wpUsers, function ($wp) use ($xfUsers, $xfUsersByEmail, &$linkedXfIds) { + $email = strtolower($wp->email); + $linkedId = $wp->linked_xf_id ? (int) $wp->linked_xf_id : null; + + if( $linkedId && $xfUsers->has($linkedId) ) { + + $xf = $xfUsers->get($linkedId); + $matchType = strtolower($xf->email) === $email ? 'explicit' : 'conflict'; + + MigrationUserPlan::updateOrCreate( + ['wp_user_id' => $wp->wp_id], + [ + 'xf_user_id' => $linkedId, + 'match_type' => $matchType, + 'email' => $email, + 'wp_username' => $wp->username, + 'xf_username' => $xf->username, + 'note' => $matchType === 'conflict' ? "E-Mail différent: {$xf->email}" : null, + 'status' => $matchType === 'explicit' ? 'approved' : 'pending', + ] + ); + $linkedXfIds[$linkedId] = true; + return; + } + + if( $linkedId && !$xfUsers->has($linkedId) ) { + MigrationUserPlan::updateOrCreate( + ['wp_user_id' => $wp->wp_id ], + [ + 'match_type' => 'conflict', + 'email' => $email, + 'wp_username' => $wp->username, + 'note' => "xf_user_id={$linkedId} introuvable", + 'status' => 'approved' + ]); + return; + } + + $candidates = $xfUsersByEmail->get($email,collect()); + + if( $candidates->count() === 1 ){ + $xf = $candidates->first(); + MigrationUserPlan::updateOrCreate( + ['wp_user_id' => $wp->wp_id], + [ + 'xf_user_id' => $xf->user_id, + 'match_type' => 'email', + 'email' => $email, + 'wp_username' => $wp->username, + 'xf_username' => $xf->username, + 'status' => 'pending' + ] + ); + $linkedXfIds[$xf->user_id] = true; + return; + } + + if( $candidates->count() > 1 ){ + MigrationUserPlan::updateOrCreate( + ['wp_user_id' => $wp->wp_id ], + [ + 'match_type' => 'conflict', + 'email' => $email, + 'wp_username' => $wp->username, + 'note' => "E-mail identique sur plusieurs comptes XF.", + ] + ); + return; + } + + MigrationUserPlan::updateOrCreate( + ['wp_user_id' => $wp->wp_id ], + [ + 'match_type' => 'wp_only', + 'email' => $email, + 'wp_username' => $wp->username, + 'status' => 'approved' + ] + ); + }); + + $this->info("Listing old XF accounts..."); + foreach( $xfUsers as $id => $xf ) { + if( isset($linkedXfIds[$id]) ) { + continue; + } + MigrationUserPlan::updateOrCreate( + ['xf_user_id' => $xf->user_id, 'wp_user_id' => null], + [ + 'match_type' => 'xf_only', + 'email' => strtolower($xf->email), + 'xf_username' => $xf->username, + 'status' => 'approved' + ] + ); + } + + $pending = MigrationUserPlan::where('status', 'pending')->count(); + $this->newLine(2); + $this->info("Plan generated. {$pending} pending cases."); + } +} diff --git a/app/Console/Commands/MigrateXFConfigure.php b/app/Console/Commands/MigrateXFConfigure.php new file mode 100644 index 0000000..bac1581 --- /dev/null +++ b/app/Console/Commands/MigrateXFConfigure.php @@ -0,0 +1,41 @@ +table('node') + ->where('node_type_id', 'Forum') + ->select('node_id', 'title') + ->get() + ; + + $this->info("{$forums->count()} forums found."); + + $forumMap = []; + foreach ($forums as $forum) { + $newId = $this->ask("New ID for {$forum->title}"); + if( $newId !== null && $newId !== '' ){ + $map[$forum->node_id] = (int) $newId; + } + } + + DB::table('migration_settings') + ->updateOrInsert( + [ 'key' => 'old_xf_node_to_new_xf_node'], + [ 'value' => json_encode($map), 'updated_at' => now() ], + ); + + $this->info("Config saved."); + } +} diff --git a/app/Console/Commands/PurgeFeaturedEntries.php b/app/Console/Commands/PurgeFeaturedEntries.php new file mode 100644 index 0000000..e487ce2 --- /dev/null +++ b/app/Console/Commands/PurgeFeaturedEntries.php @@ -0,0 +1,31 @@ +option('days'); + + $cutoff = now()->subDays($days); + + $count = Entry::query() + ->where('featured', true) + ->where('featured_at', '<=', $cutoff) + ->update([ + 'featured' => false, + 'featured_at' => null, + ]); + + $this->info("$count entr" . ($count > 1 ? 'ies' : 'y') . " unfeatured."); + return self::SUCCESS; + } +} diff --git a/app/Filament/Resources/Categories/CategoryResource.php b/app/Filament/Resources/Categories/CategoryResource.php new file mode 100644 index 0000000..2b6fa11 --- /dev/null +++ b/app/Filament/Resources/Categories/CategoryResource.php @@ -0,0 +1,48 @@ + ListCategories::route('/'), + 'create' => CreateCategory::route('/create'), + 'edit' => EditCategory::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/Categories/Pages/CreateCategory.php b/app/Filament/Resources/Categories/Pages/CreateCategory.php new file mode 100644 index 0000000..a842af9 --- /dev/null +++ b/app/Filament/Resources/Categories/Pages/CreateCategory.php @@ -0,0 +1,11 @@ +components([ + TextInput::make('name') + ->required(), + TextInput::make('slug') + ->required(), + Textarea::make('restricted_to') + ->default(null) + ->columnSpanFull(), + ]); + } +} diff --git a/app/Filament/Resources/Categories/Tables/CategoriesTable.php b/app/Filament/Resources/Categories/Tables/CategoriesTable.php new file mode 100644 index 0000000..346d3a9 --- /dev/null +++ b/app/Filament/Resources/Categories/Tables/CategoriesTable.php @@ -0,0 +1,42 @@ +columns([ + TextColumn::make('name') + ->searchable(), + TextColumn::make('slug') + ->searchable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/Levels/LevelResource.php b/app/Filament/Resources/Levels/LevelResource.php new file mode 100644 index 0000000..c2bb67d --- /dev/null +++ b/app/Filament/Resources/Levels/LevelResource.php @@ -0,0 +1,48 @@ + ListLevels::route('/'), + 'create' => CreateLevel::route('/create'), + 'edit' => EditLevel::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/Levels/Pages/CreateLevel.php b/app/Filament/Resources/Levels/Pages/CreateLevel.php new file mode 100644 index 0000000..e8cfbde --- /dev/null +++ b/app/Filament/Resources/Levels/Pages/CreateLevel.php @@ -0,0 +1,11 @@ +components([ + TextInput::make('name') + ->required(), + TextInput::make('slug') + ->required(), + ]); + } +} diff --git a/app/Filament/Resources/Levels/Tables/LevelsTable.php b/app/Filament/Resources/Levels/Tables/LevelsTable.php new file mode 100644 index 0000000..d64fd20 --- /dev/null +++ b/app/Filament/Resources/Levels/Tables/LevelsTable.php @@ -0,0 +1,42 @@ +columns([ + TextColumn::make('name') + ->searchable(), + TextColumn::make('slug') + ->searchable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/Systems/Pages/CreateSystem.php b/app/Filament/Resources/Systems/Pages/CreateSystem.php new file mode 100644 index 0000000..c03c9fd --- /dev/null +++ b/app/Filament/Resources/Systems/Pages/CreateSystem.php @@ -0,0 +1,11 @@ +components([ + TextInput::make('name') + ->required(), + TextInput::make('slug') + ->required(), + ]); + } +} diff --git a/app/Filament/Resources/Systems/SystemResource.php b/app/Filament/Resources/Systems/SystemResource.php new file mode 100644 index 0000000..14aead4 --- /dev/null +++ b/app/Filament/Resources/Systems/SystemResource.php @@ -0,0 +1,48 @@ + ListSystems::route('/'), + 'create' => CreateSystem::route('/create'), + 'edit' => EditSystem::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/Systems/Tables/SystemsTable.php b/app/Filament/Resources/Systems/Tables/SystemsTable.php new file mode 100644 index 0000000..822e32e --- /dev/null +++ b/app/Filament/Resources/Systems/Tables/SystemsTable.php @@ -0,0 +1,42 @@ +columns([ + TextColumn::make('name') + ->searchable(), + TextColumn::make('slug') + ->searchable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Helpers/EntryHelpers.php b/app/Helpers/EntryHelpers.php index 603673a..25228a3 100644 --- a/app/Helpers/EntryHelpers.php +++ b/app/Helpers/EntryHelpers.php @@ -3,6 +3,8 @@ namespace App\Helpers; use App\Models\Entry; +use App\Models\EntryFile; +use App\Models\News; use App\Services\XenforoApiService; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; @@ -62,13 +64,16 @@ class EntryHelpers { }; } - public static function getLatestComments(Entry $entry, int $limit = 20): array { + public static function getLatestComments(Entry|News $entry, int $limit = 20): array { if( !$entry->comments_thread_id ){ return []; } - $cacheKey = "entry_comments_{$entry->id}"; + if( is_a( $entry, News::class ) ) + $cacheKey = "news_comments_{$entry->id}"; + else + $cacheKey = "entry_comments_{$entry->id}"; return Cache::remember($cacheKey, now()->addDays(1), function () use ($entry, $limit) { $service = app(XenforoApiService::class); @@ -93,6 +98,25 @@ class EntryHelpers { public static function enableOnlinePatcherBasedOnExtension(string $filename): bool { - return Str::endsWith(Str::lower($filename), ['.ips', '.bps', '.xdelta', '.zip' ]); + return Str::endsWith(Str::lower($filename), ['.ips', '.bps', '.xdelta', '.ups', '.aps', '.ppf', '.zip' ]); + } + + public static function getYoutubeVideoId(string $url): ?string + { + $pattern = '%(?:https?://)?(?:www\.|m\.)?(?:youtu\.be/|youtube(?:-nocookie)?\.com/(?:watch\?.*v=|embed/|v/|shorts/|live/))([\w-]{11})%i'; + + preg_match($pattern, $url, $matches); + return $matches[1] ?? null; + } + + public static function fileAlreadyDownloaded(EntryFile $entryFile): bool + { + return session("downloaded_file_{$entryFile->file_uuid}", null ) !== null; + } + + public static function markFileAsDownloaded(EntryFile $entryFile): bool + { + session(["downloaded_file_{$entryFile->file_uuid}" => 1]); + return true; } } diff --git a/app/Helpers/FormHelpers.php b/app/Helpers/FormHelpers.php index b8aac16..efba8c8 100644 --- a/app/Helpers/FormHelpers.php +++ b/app/Helpers/FormHelpers.php @@ -44,7 +44,64 @@ class FormHelpers { 'homebrew' => [ 'page_title' => "Submit an homebrew", 'about_the' => "About the homebrew", - 'version' => "Patch version", + 'version' => "Version", + 'status' => "Status", + 'release_date' => "Release date", + 'release_date_helper' => "If only initial release exist, the release date.", + 'description' => "Description", + 'about_game' => "Game Information", + 'attachments' => "Attachments", + 'authors' => "Team members", + 'related_links' => "Related links", + 'release_site' => "Release site", + 'release_site_helper' => "Project entry on site/blog/forum/Github.", + 'youtube_video' => "YouTube video", + ], + 'utilities' => [ + 'page_title' => "Submit a utility", + 'entry_title' => "Title", + 'about_the' => "About the utility", + 'version' => "Version", + 'status' => "Status", + 'system' => "OS", + 'categories' => "Categories", + 'level' => "Experience Level", + 'release_date' => "Release date", + 'release_date_helper' => "If only initial release exist, the release date.", + 'description' => "Description", + 'about_game' => "Game Information", + 'attachments' => "Attachments", + 'authors' => "Team members", + 'related_links' => "Related links", + 'release_site' => "Release site", + 'release_site_helper' => "Project entry on site/blog/forum/Github.", + 'youtube_video' => "YouTube video", + ], + 'documents' => [ + 'page_title' => "Submit a document", + 'entry_title' => "Title", + 'about_the' => "About the document", + 'version' => "Version", + 'status' => "Status", + 'categories' => "Categories", + 'level' => "Experience Level", + 'release_date' => "Release date", + 'release_date_helper' => "If only initial release exist, the release date.", + 'description' => "Description", + 'about_game' => "Game Information", + 'attachments' => "Attachments", + 'authors' => "Team members", + 'related_links' => "Related links", + 'release_site' => "Release site", + 'release_site_helper' => "Project entry on site/blog/forum/Github.", + 'youtube_video' => "YouTube video", + ], + 'lua-scripts' => [ + 'page_title' => "Submit a LUA Script", + 'about_the' => "About the script", + 'entry_title' => "Title", + 'type_of_hack' => "Modifications", + 'version' => "Version", 'status' => "Status", 'release_date' => "Release date", 'release_date_helper' => "If only initial release exist, the release date.", diff --git a/app/Helpers/MigrationHelpers.php b/app/Helpers/MigrationHelpers.php new file mode 100644 index 0000000..74a24d4 --- /dev/null +++ b/app/Helpers/MigrationHelpers.php @@ -0,0 +1,47 @@ +]/i', $block)) { + return $block; + } + return '

' . nl2br($block, false) . '

'; + }, $blocks); + + return implode("\n\n", array_filter($blocks, fn ($b) => $b !== '')); + } + + public static function htmlToMarkdown( ?string $html ): string + { + if (!$html || trim($html) === '') { + return $html; + } + + static $converter = null; + if ($converter === null) { + $converter = new HtmlConverter(['hard_break' => true]); + } + + return $converter->convert(self::wpAutoP($html)); + } +} diff --git a/app/Helpers/PlayOnlineHelpers.php b/app/Helpers/PlayOnlineHelpers.php new file mode 100644 index 0000000..f7377b8 --- /dev/null +++ b/app/Helpers/PlayOnlineHelpers.php @@ -0,0 +1,57 @@ +updateEntriesCount( $count, $userId ); } - public static function entryApproved( Entry $entry ): void + public static function entryApproved( Entry|News $entry ): void { // 1. Update XF Entry count. self::updateEntriesCount( $entry->user_id ); @@ -58,6 +59,29 @@ class XenForoHelpers { $title = "Entry approved : {$entry->title}"; $message = "Your entry {$entry->title} has been approved by {$moderator}."; + $service = app(XenForoApiService::class); + + $service->createConversation([ $entry->user_id ], $title, $message, false, false); + } + + public static function entryRejected( Entry|News $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 rejected : {$entry->title}"; + $message = "Your entry {$entry->title} has been rejected by {$moderator}.\nReason: {$entry->staff_comment}\n\nYou have 7 days to edit your entry before it is permanently deleted."; + + $service = app(XenForoApiService::class); + $service->createConversation([ $entry->user_id ], $title, $message, false, false); } } diff --git a/app/Http/Controllers/DynamicLoadController.php b/app/Http/Controllers/DynamicLoadController.php index bbf847a..5cde67a 100644 --- a/app/Http/Controllers/DynamicLoadController.php +++ b/app/Http/Controllers/DynamicLoadController.php @@ -3,8 +3,10 @@ namespace App\Http\Controllers; use App\Helpers\XenForoHelpers; +use App\Services\ActivityService; use App\Services\XenforoApiService; use App\Services\XenforoService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; @@ -63,4 +65,24 @@ class DynamicLoadController extends Controller return response()->json( $data ); } + + public function activityFeed(Request $request): JsonResponse + { + $availableFilters = ['entries', 'news', 'messages', 'threads', 'clubs', 'reviews']; + + $requested = $request->query('filters') + ? explode(',', $request->query('filters')) + : []; + + $activeFilters = !empty($requested) + ? array_intersect($requested, $availableFilters) + : $availableFilters; + + $service = app(ActivityService::class); + $items = $service->getActivities(array_values($activeFilters)); + + return response()->json([ + 'html' => view('activity.timeline', compact('items'))->render(), + ]); + } } diff --git a/app/Http/Controllers/EntryController.php b/app/Http/Controllers/EntryController.php index 07b9b71..dcb5c84 100644 --- a/app/Http/Controllers/EntryController.php +++ b/app/Http/Controllers/EntryController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Helpers\EntryHelpers; use App\Models\Entry; +use App\Models\News; use Illuminate\Support\Facades\Gate; use Illuminate\Http\Request; use Illuminate\View\View; @@ -11,7 +12,7 @@ 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']; public function index(): View { @@ -31,7 +32,8 @@ class EntryController extends Controller if ($entry->type !== $section) abort(404); - Gate::authorize('viewAny', $entry); + if( !\Auth::guest() ) + Gate::authorize('viewAny', $entry); // Permissions. $entryPolicy = match ($entry->state) { @@ -48,8 +50,9 @@ class EntryController extends Controller Gate::authorize($entryPolicy, $entry); $comments = EntryHelpers::getLatestComments($entry); + $reviews = $entry->reviews()->orderBy('created_at', 'desc')->limit(10)->get(); - return view('entries.show', compact('entry', 'section', 'comments')); + return view('entries.show', compact('entry', 'section', 'comments', 'reviews')); } @@ -61,7 +64,12 @@ class EntryController extends Controller ->orderBy('updated_at', 'desc') ->paginate(20); - return view('entries.drafts', compact('drafts')); + $newsDrafts = News::where('user_id', \Auth::user()->user_id ) + ->where('state', 'draft') + ->orderBy('updated_at', 'desc') + ->paginate(20); + + return view('entries.drafts', compact('drafts', 'newsDrafts')); } } diff --git a/app/Http/Controllers/EntryFeaturedRequestController.php b/app/Http/Controllers/EntryFeaturedRequestController.php new file mode 100644 index 0000000..6a158e2 --- /dev/null +++ b/app/Http/Controllers/EntryFeaturedRequestController.php @@ -0,0 +1,23 @@ +featuredRequest($entry); + return response()->json([ + 'success' => $response, + ]); + } + +} diff --git a/app/Http/Controllers/FileServerController.php b/app/Http/Controllers/FileServerController.php index c1bea53..2054018 100644 --- a/app/Http/Controllers/FileServerController.php +++ b/app/Http/Controllers/FileServerController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Helpers\EntryHelpers; use App\Models\EntryFile; +use App\Models\LogXfUser; use App\Services\FileServersService; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\JsonResponse; @@ -45,7 +46,7 @@ class FileServerController extends Controller { return response()->json($data); } - \Cache::put("uploaded_file_{$fileUuid}", [ + $fileData = [ 'uuid' => $fileUuid, 'type' => $type, 'filename' => $filename, @@ -54,7 +55,16 @@ class FileServerController extends Controller { 'favorite_server' => $data['favorite_server'], 'favorite_at' => time(), 'state' => 'public', - ], now()->addHours(2) ); + ]; + + activity('entry-file') + ->causedBy(LogXfUser::find(\Auth::user()->getAuthIdentifier())) + ->withProperties($fileData) + ->event('file_upload') + ->log("File uploaded") + ; + + \Cache::put("uploaded_file_{$fileUuid}", $fileData, now()->addHours(2) ); $data['finished'] = true; return response()->json($data); @@ -67,7 +77,13 @@ class FileServerController extends Controller { abort(404); } - // TODO: DL Count. + if($request->input('count_download', true)) { + if (!EntryHelpers::fileAlreadyDownloaded($file)) { + EntryHelpers::markFileAsDownloaded($file); + $file->increaseDownloadCount(); + } + } + return redirect( $this->fs->getDownloadFileUrl( $file) ); } } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index e6054db..eafa41e 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -2,14 +2,43 @@ namespace App\Http\Controllers; +use App\Models\Entry; +use App\Services\ActivityService; use Illuminate\Http\Request; use Illuminate\View\View; +use App\Models\News; class HomeController extends Controller { - public function index(): View { - return view('home'); + public function __construct( private ActivityService $service ) {} + + public function index( Request $request ): View { + + $filters = [ 'entries', 'news', 'messages', 'threads', 'clubs', 'reviews' ]; + + $cookie = $request->cookie('activity_filters'); + $activeFilters = $cookie ? array_intersect( json_decode( $cookie, true ) ?? [], $filters ) : $filters; + + if( empty( $activeFilters ) ) { + $activeFilters = $filters; + } + + $items = $this->service->getActivities( array_values( $activeFilters ) ); + + $viewFilters = [ + 'entries' => ['label' => 'Entries', 'icon' => 'database'], + 'news' => ['label' => 'News', 'icon' => 'newspaper'], + 'messages' => ['label' => 'Posts', 'icon' => 'message-square'], + 'threads' => ['label' => 'Threads', 'icon' => 'messages-square'], + 'clubs' => ['label' => 'Clubs', 'icon' => 'balloon'], + 'reviews' => ['label' => 'Reviews', 'icon' => 'star'], + ]; + + $latestNews = News::published()->latest('created_at')->limit(5)->get(); + $featuredEntries = Entry::published()->where('featured', true)->latest('featured_at')->get(); + + return view('home', compact('items', 'activeFilters', 'viewFilters', 'latestNews', 'featuredEntries')); } } diff --git a/app/Http/Controllers/ModCPController.php b/app/Http/Controllers/ModCPController.php index b0d0d52..bf75df0 100644 --- a/app/Http/Controllers/ModCPController.php +++ b/app/Http/Controllers/ModCPController.php @@ -75,4 +75,9 @@ class ModCPController extends Controller $entry->forceDelete(); return back()->with('success', "Entry permanently deleted"); } + + public function logs() + { + return view('modcp.logs'); + } } diff --git a/app/Http/Controllers/NewsController.php b/app/Http/Controllers/NewsController.php new file mode 100644 index 0000000..23bb429 --- /dev/null +++ b/app/Http/Controllers/NewsController.php @@ -0,0 +1,124 @@ +state) { + 'pending' => 'viewPending', + 'draft' => 'viewDraft', + 'rejected' => 'viewRejected', + 'hidden' => 'viewHidden', + 'locked' => 'viewLocked', + 'published' => null, + 'default' => null + }; + + if ($entryPolicy) + Gate::authorize($entryPolicy, $news); + + $comments = EntryHelpers::getLatestComments($news); + + return view('news.show', compact('news', 'comments')); + } + + public function create(Request $request) + { + $data = [ + 'news' => new News(), + 'isEdit' => false, + 'oldCategory' => old('category') ? [ old('category') ] : [] + ]; + + return view ('news.create', $data); + } + + public function edit(Request $request, News $news) + { + $data = [ + 'news' => $news, + 'isEdit' => true, + 'oldCategory' => old('category', $news->category_id) ? [ old('category', $news->category_id) ] : [] + ]; + + return view ('news.edit', $data); + } + + public function store(Request $request) + { + $request = $request->input('submit-state') === 'draft' ? app(StoreNewsDraftRequest::class) : app(StoreNewsRequest::class); + $request->validateResolved(); + + $service = app(NewsService::class); + + try { + $entry = $service->storeNews($request); + + return match ($entry->state) { + 'published' => redirect()->route('news.show', ['news' => $entry->slug])->with('success', "Your entry has been published."), + 'pending' => redirect()->route('home')->with('success', "Your entry has been submitted and is pending review."), + default => redirect()->route('home')->with('success', "Your entry has been saved as a draft.") + }; + } catch ( SubmissionException $e ) { + return back()->withInput()->withErrors(['error' => $e->getMessage()]); + } catch ( \Exception $e ) { + return back()->withInput()->withErrors(['error' => 'Unknown error: '.$e->getMessage()]); + } + } + + public function update(Request $request, News $news) + { + $request = $request->input('submit-state') === 'draft' ? app(StoreNewsDraftRequest::class) : app(StoreNewsRequest::class); + $request->validateResolved(); + + $service = app(NewsService::class); + + try { + $news = $service->editNews($request, $news); + + return match ($news->state) { + 'published' => redirect()->route('news.show', ['news' => $news->slug])->with('success', "Your entry has been published."), + 'pending' => redirect()->route('home')->with('success', "Your entry has been submitted and is pending review."), + default => redirect()->route('home')->with('success', "Your entry has been saved as a draft.") + }; + } catch ( SubmissionException $e ) { + return back()->withInput()->withErrors(['error' => $e->getMessage()]); + } catch ( \Exception $e ) { + return back()->withInput()->withErrors(['error' => 'Unknown error: '.$e->getMessage()]); + } + + } + + public function destroy(Request $request, News $news) + { + if( $news->comments_thread_id ) + DeleteXenForoCommentsThread::dispatch( $news->comments_thread_id ); + + $news->delete(); + return redirect( route('news.index') )->with('success', "Entry successfully deleted."); + } +} diff --git a/app/Http/Controllers/QueueController.php b/app/Http/Controllers/QueueController.php index c9f4e91..7b5d037 100644 --- a/app/Http/Controllers/QueueController.php +++ b/app/Http/Controllers/QueueController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Helpers\XenForoHelpers; use App\Models\Entry; +use App\Models\News; use App\Services\XenforoService; use Illuminate\Http\Request; class QueueController extends Controller @@ -14,7 +15,25 @@ class QueueController extends Controller ->with(['authors', 'game.platform']) ->orderByRaw("CASE WHEN state = 'pending' THEN 1 ELSE 0 END") ->orderBy('created_at', 'asc') - ->get(); + ->get() + ->map(fn($item) => $item->setAttribute('queue_type', 'entry')); + + $news = News::inQueue() + ->orderByRaw("CASE WHEN state = 'pending' THEN 1 ELSE 0 END") + ->orderBy('created_at', 'asc') + ->get() + ->map(fn($item) => $item->setAttribute('queue_type', 'news')); + + $entries = $entries->concat($news)->sort(function($a, $b) { + $aPending = $a->state === 'pending' ? 0 : 1; + $bPending = $b->state === 'pending' ? 0 : 1; + + if($aPending !== $bPending) { + return $aPending <=> $bPending; + } + + return $a->created_at <=> $b->created_at; + })->values(); return view('queue.index', compact('entries')); } @@ -25,24 +44,53 @@ class QueueController extends Controller $entry->update(['staff_comment' => $request->input('comment')]); + return back()->with('success', 'Comment supdated'); + } + + public function updateComment_news(Request $request, News $news) + { + $request->validate(['comment' => 'nullable|string|max:2000']); + + $news->update(['staff_comment' => $request->input('comment')]); + return back()->with('success', 'Comment updated'); } public function approve(Request $request, Entry $entry) { - // $entry->update(['state' => 'published']); + $entry->update(['state' => 'published', 'created_at' => now()]); XenForoHelpers::entryApproved($entry); return back()->with('success', 'Entry approved'); } + public function approve_news(Request $request, News $news) + { + $news->update(['state' => 'published', 'created_at' => now()]); + + XenForoHelpers::entryApproved($news); + + 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() ]); + XenForoHelpers::entryRejected($entry); + return back()->with('success', 'Entry rejected'); + } + + public function reject_news(Request $request, News $news) + { + $request->validate(['reason' => 'nullable|string|max:2000']); + + $news->update(['state' => 'rejected', 'staff_comment' => $request->input('reason'), 'rejected_at' => now() ]); + + XenForoHelpers::entryRejected($news); return back()->with('success', 'Entry rejected'); } diff --git a/app/Http/Controllers/RedirectController.php b/app/Http/Controllers/RedirectController.php index 10d9753..61254e2 100644 --- a/app/Http/Controllers/RedirectController.php +++ b/app/Http/Controllers/RedirectController.php @@ -14,4 +14,13 @@ class RedirectController extends Controller return redirect()->route('entries.show', ['section' => $entry->type, 'entry' => $entry])->with('success', "Your report has been sent."); } + + public function newsReportRedirect( Request $request ) + { + $id = $request->input('id'); + $entry = News::findOrFail($id); + + return redirect()->route('news.show', ['news' => $entry])->with('success', "Your report has been sent."); + } + } diff --git a/app/Http/Controllers/ReviewController.php b/app/Http/Controllers/ReviewController.php new file mode 100644 index 0000000..7ee1898 --- /dev/null +++ b/app/Http/Controllers/ReviewController.php @@ -0,0 +1,33 @@ +services->storeReview( $request, $entry ); + return redirect()->route('entries.show', [ 'section' => $entry->type, 'entry' => $entry ])->with('success', "Your review has been published."); + } catch ( SubmissionException $e ) { + return back()->withInput()->withErrors(['error' => $e->getMessage()]); + } catch ( \Exception $e ) { + return back()->withInput()->withErrors(['error' => 'Unknown error: '.$e->getMessage()]); + } + } +} diff --git a/app/Http/Controllers/SubmissionController.php b/app/Http/Controllers/SubmissionController.php index 2f1f7f1..ebc7c4b 100644 --- a/app/Http/Controllers/SubmissionController.php +++ b/app/Http/Controllers/SubmissionController.php @@ -10,14 +10,16 @@ use App\Jobs\DeleteXenForoCommentsThread; use App\Models\Author; use App\Models\Entry; use App\Models\EntryFile; -use App\Models\EntryGallery; +use App\Models\Gallery; use App\Models\EntryHash; use App\Models\Game; use App\Models\Genre; use App\Models\Language; +use App\Models\Level; use App\Models\Modification; use App\Models\Platform; use App\Models\Status; +use App\Models\System; use App\Services\SubmissionsService; use App\Services\XenforoApiService; use Illuminate\Http\Request; @@ -31,6 +33,62 @@ class SubmissionController extends Controller public function __construct(private SubmissionsService $services){} + public function index(Request $request) + { + + $entryTypes = [ + 'romhack' => [ + 'slug' => 'romhacks', + 'label' => 'Romhack', + 'icon' => 'gamepad-2', + 'color' => '#4a6fc2', + 'bg' => '#d5e1fc1a', + 'border' => '#d5e1fc40', + ], + 'translation' => [ + 'slug' => 'translations', + 'label' => 'Translation', + 'icon' => 'languages', + 'color' => '#4a8a2a', + 'bg' => '#e7f4d91a', + 'border' => '#e7f4d940', + ], + 'homebrew' => [ + 'slug' => 'homebrew', + 'label' => 'Homebrew', + 'icon' => 'cpu', + 'color' => '#c23060', + 'bg' => '#ffeaf01a', + 'border' => '#ffeaf040', + ], + 'utility' => [ + 'slug' => 'utilities', + 'label' => 'Utility', + 'icon' => 'wrench', + 'color' => '#8a6600', + 'bg' => '#fff8d51a', + 'border' => '#fff8d540', + ], + 'document' => [ + 'slug' => 'documents', + 'label' => 'Document', + 'icon' => 'file-text', + 'color' => '#7a35c2', + 'bg' => '#f3eaff1a', + 'border' => '#f3eaff40', + ], + 'lua-script' => [ + 'slug' => 'lua-script', + 'label' => 'Lua script', + 'icon' => 'terminal', + 'color' => '#a04515', + 'bg' => '#eed6c51a', + 'border' => '#eed6c540', + ] ]; + + return view('submissions.index', compact('entryTypes')); + } + public function create(Request $request, string $section) { $data = [ @@ -39,19 +97,27 @@ class SubmissionController extends Controller 'words' => FormHelpers::getEntryFormWords($section), 'isEdit' => false, 'oldModifications' => old( 'modifications', [] ), + 'oldSystems' => old( 'systems', [] ), 'oldLanguages' => old( 'languages', [] ), + 'oldCategories' => old( 'categories', [] ), 'oldFilesArray' => $this->services->prepareOldFiles( null ) ]; if( $data['words'] === [] ) abort(500); - if( section_must_be( 'romhacks', $section ) ){ + if( section_must_be( ['romhacks', 'lua-scripts'], $section ) ){ $data['modifications'] = Modification::orderBy('name')->get(); } - if( section_must_be( [ 'romhacks', 'translations', 'homebrew' ], $section ) ){ + if( section_must_be( [ 'romhacks', 'translations', 'homebrew', 'lua-scripts' ], $section ) ){ $data['statuses'] = Status::orderBy('id')->get(); } + if( section_must_be( 'utilities' , $section ) ){ + $data['systems'] = System::orderBy('name')->get(); + } + if( section_must_be( [ 'utilities', 'documents' ], $section ) ) { + $data['levels'] = Level::orderBy('id')->get(); + } return view('submissions.create', $data); } @@ -68,19 +134,27 @@ class SubmissionController extends Controller 'words' => FormHelpers::getEntryFormWords($section), 'isEdit' => true, 'oldModifications' => old('modifications', $entry->modifications->pluck('id')->toArray() ?? [] ), + 'oldSystems' => old( 'systems', $entry->systems->pluck('id')->toArray() ?? [] ), 'oldLanguages' => old('languages', $entry->languages->pluck('id')->toArray() ?? [] ), + 'oldCategories' => old('categories', $entry->categories->pluck('id')->toArray() ?? [] ), 'oldFilesArray' => $this->services->prepareOldFiles( $entry ) ]; if( $data['words'] === [] ) abort(500); - if( section_must_be( 'romhacks', $section ) ){ + if( section_must_be( [ 'romhacks', 'lua-scripts' ], $section ) ){ $data['modifications'] = Modification::orderBy('name')->get(); } - if( section_must_be( [ 'romhacks', 'translations' ], $section ) ){ + if( section_must_be( [ 'romhacks', 'translations', 'homebrew', 'lua-scripts' ], $section ) ){ $data['statuses'] = Status::orderBy('id')->get(); } + if( section_must_be( 'utilities' , $section ) ){ + $data['systems'] = System::orderBy('name')->get(); + } + if( section_must_be( [ 'utilities', 'documents' ], $section ) ) { + $data['levels'] = Level::orderBy('id')->get(); + } return view('submissions.edit', $data); } @@ -90,7 +164,7 @@ class SubmissionController extends Controller if( $entry->type !== $section ) abort(404); - if( $entry->comments_thread_id) + if( $entry->comments_thread_id ) DeleteXenForoCommentsThread::dispatch( $entry->comments_thread_id ); $entry->delete(); diff --git a/app/Http/Controllers/ToolsController.php b/app/Http/Controllers/ToolsController.php index ef2180f..f1b5e9b 100644 --- a/app/Http/Controllers/ToolsController.php +++ b/app/Http/Controllers/ToolsController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Models\Entry; use App\Models\EntryFile; use App\Services\FileServersService; use Illuminate\Http\Request; @@ -11,24 +12,59 @@ class ToolsController extends Controller public function patcher() { - return view('tools.patcher'); } - public function directPatch( Request $request, int $entry_id, EntryFile $file ) + public function directPatch( Request $request, int $entryId, EntryFile $file ) { - if( $file->entry_id != $entry_id ) { + if( $file->entry_id != $entryId || $file->state === 'private' ) { abort(404); } $service = app(FileServersService::class); + $patches = [ 'file' => $service->getDownloadFileUrl( $file ), 'name' => $file->entry->title, 'outputName' => $file->filename ]; + + return view('tools.patcher', compact('patches')); } + + public function play( Request $request, int $entryId, EntryFile $file ) + { + if( $file->entry_id != $entryId || $file->state === 'private' ) { + abort(404); + } + + $service = app(FileServersService::class); + + $emuConfig = [ + 'core' => $file->playOnlineSetting?->core, + 'threads' => $file->playOnlineSetting?->threads, + ]; + + if( $file->entry->type === 'homebrew' ){ + $filePath = $service->getDownloadFileUrl( $file ); + + return view('tools.play-homebrew', compact('filePath', 'emuConfig')); + } + $patches = [ + 'file' => $service->getDownloadFileUrl( $file ), + 'name' => $file->entry->title, + 'outputName' => $file->filename + ]; + + return view('tools.play', compact('patches', 'emuConfig')); + } + + public function hasher( Request $request ) + { + return view('tools.hasher'); + } + } diff --git a/app/Http/Middleware/CheckXenForoUserState.php b/app/Http/Middleware/CheckXenForoUserState.php new file mode 100644 index 0000000..3d36c34 --- /dev/null +++ b/app/Http/Middleware/CheckXenForoUserState.php @@ -0,0 +1,50 @@ +security_lock === 'change' ) + return $this->deny( $request, "Password must be changed." ); + else if( \Auth::user()->security_lock === 'reset' ) + return $this->deny( $request, "Password must be reset."); + + if( \Auth::user()->user_state === 'valid' ) + return $next($request); + + else if( \Auth::user()->user_state === 'email_confirm' || \Auth::user()->user_state === 'email_confirm_edit' ) + return $this->deny( $request, "You must verify your email address." ); + else if( \Auth::user()->user_state === 'email_bounce' ) + return $this->deny( $request, "Invalid email address." ); + else if( \Auth::user()->user_state === 'rejected' ) + return $this->deny( $request, "Your account is currently rejected." ); + else if( \Auth::user()->user_state === 'disabled' ) + return $this->deny( $request, "Your account is currently disabled." ); + + return $this->deny($request, "Invalid user state."); + } + + private function deny(Request $request, string $reason): Response + { + if($request->expectsJson()) + return \response()->json(['error' => 'forbidden', 'reason' => $reason], 403); + + return response()->view('pages.user_state', [ + 'reason' => $reason, + ], 403 ); + } +} diff --git a/app/Http/Requests/StoreEntryRequest.php b/app/Http/Requests/StoreEntryRequest.php index 10b73ef..276f472 100644 --- a/app/Http/Requests/StoreEntryRequest.php +++ b/app/Http/Requests/StoreEntryRequest.php @@ -70,28 +70,49 @@ class StoreEntryRequest extends FormRequest $rules['entry_title'] = "nullable|string|max:255"; } - if( section_must_be( 'romhacks', $section ) ){ + if( section_must_be( ['romhacks', 'lua-scripts'], $section ) ){ $rules['modifications'] = 'array|required|min:1'; $rules['modifications.*'] = 'integer|exists:modifications,id'; + } else if( section_must_be( ['utilities','documents'], $section ) ){ + $rules['categories'] = 'array|required|min:1'; + $rules['categories.*'] = 'integer|exists:categories,id'; + } + + if( section_must_be( 'utilities', $section ) ){ + $rules['systems'] = 'array|required|min:1'; + $rules['systems.*'] = 'integer|exists:systems,id'; } $rules['version'] = 'required|string|max:50'; $rules['release-date'] = 'required|date'; - $rules['status'] = 'required|integer|exists:statuses,id'; + if( section_must_not_be( ['utilities', 'documents'], $section ) ){ + $rules['status'] = 'required|integer|exists:statuses,id'; + } else { + $rules['level'] = 'required|integer|exists:levels,id'; + } $rules['description'] = 'required|string'; - if( section_must_be( ['romhacks', 'translations' ], $section ) ){ + $rules['game_selection_mode'] = 'required|string|in:game,platform,none'; + $gameSelectionMode = $this->input('game_selection_mode') !== '' ? $this->input('game_selection_mode') : 'game'; + + if( $gameSelectionMode === 'none' ){ + // ... + } else if( $gameSelectionMode === 'platform' ){ + $rules['platform_only_id'] = 'required|integer|exists:platforms,id'; + } else { $rules['game_id'] = 'required_without:new-game-title|nullable|integer|exists:games,id'; $rules['new-game-title'] = 'required_without:game_id|nullable|string|max:255'; $rules['new-game-platform'] = 'required_with:new-game-title|nullable|integer|exists:platforms,id'; $rules['new-game-genre'] = 'required_with:new-game-title|integer|nullable|exists:genres,id'; } - $rules['hashes'] = 'array|required|min:1'; - $rules['hashes.*.filename'] = 'required|string|max:512'; - $rules['hashes.*.hash_crc32'] = 'required|string|max:512'; - $rules['hashes.*.hash_sha1'] = 'required|string|max:512'; - $rules['hashes.*.verified'] = 'required|string|max:512'; + if( section_must_be( ['translations', 'romhacks'], $section ) ){ + $rules['hashes'] = 'array|required|min:1'; + $rules['hashes.*.filename'] = 'required|string|max:512'; + $rules['hashes.*.hash_crc32'] = 'required|string|max:512'; + $rules['hashes.*.hash_sha1'] = 'required|string|max:512'; + $rules['hashes.*.verified'] = 'required|string|max:512'; + } $rules['languages'] = 'array|required|min:1'; $rules['languages.*'] = 'integer|exists:languages,id'; @@ -128,6 +149,9 @@ class StoreEntryRequest extends FormRequest $rules['files_metadata'] = 'array|nullable'; $rules['files_metadata.*.online_patcher'] = 'nullable|boolean'; $rules['files_metadata.*.secondary_online_patcher'] = 'nullable|boolean|required_with:files_metadata.*.online_patcher'; + $rules['files_metadata.*.play_online'] = 'nullable|boolean'; + $rules['files_metadata.*.play_online_core'] = 'nullable|string'; + $rules['files_metadata.*.play_online_threads'] = 'nullable|boolean'; } if( $isEdit && $this->user()->can('moderate', $this->route('entry') ) ){ @@ -135,6 +159,7 @@ class StoreEntryRequest extends FormRequest $rules['owner_user_id'] = [ 'required', 'integer', new XfUserExists ]; $rules['comments_thread_id'] = 'nullable|integer'; $rules['featured'] = 'nullable|boolean'; + $rules['refresh_created_at'] = 'nullable|boolean'; } return $rules; @@ -143,16 +168,31 @@ class StoreEntryRequest extends FormRequest public function messages(): array { return [ - 'entry_title.required' => 'Please provide an entry title.', - 'slug.unique' => 'An entry with this title already exists. Please choose another title.', - 'file_ids.required' => 'Please upload at least one file.', - 'file_ids.min' => 'Please upload at least one file.', - 'hashes.required' => 'Please add at least one hash.', - 'hashes.min' => 'Please add at least one hash.', - 'gallery.required' => 'Please add at least one screenshot.', - 'gallery.min' => 'Please add at least one screenshot.', - 'new-game-platform.required_without' => 'Please choose a platform for the new game.', - 'new-game-genre.required_without' => 'Please choose a genre for the new game.', + 'files_uuid.required' => 'Please upload at least a file.', + 'files_state.required' => 'A file may be corrupted, please reupload it.', + 'files_state.*.in' => 'A file state doesn\'t have a standard value.', + 'entry_title.required' => 'Please enter a title.', + 'modifications.required' => 'Please select at least one modification.', + 'categories.required' => 'Please select at least one category.', + 'system.required' => 'Please select at least one system.', + 'version.required' => 'Please enter a version number.', + 'release-date.required' => 'Please enter a valid release date.', + 'status.required' => 'Please select a status.', + 'level.required' => 'Please select an experience level.', + 'description.required' => 'Please enter a description.', + 'game_selection_mode.required' => 'Please select a game selection mode.', + 'platform_only_id.required' => 'Please select a platform.', + 'game_id.required' => 'Please select a game.', + 'new-game-title.required' => 'Please enter a game title.', + 'new-game-platform.required' => 'Please select a game platform.', + 'new-game-genre.required' => 'Please select a game genre.', + 'hashes.required' => 'Please send at least one hash.', + 'languages.required' => 'Please select at least one language.', + 'main-image.required' => 'Please upload a main image.', + 'gallery.required' => 'Please upload at least one image for the gallery.', + 'authors.required' => 'Please select at least one author.', + 'new-authors.required' => 'Please select at least one author.', + 'submit-state.required' => 'Please select a submit state.', ]; } } diff --git a/app/Http/Requests/StoreNewsDraftRequest.php b/app/Http/Requests/StoreNewsDraftRequest.php new file mode 100644 index 0000000..87d806c --- /dev/null +++ b/app/Http/Requests/StoreNewsDraftRequest.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/StoreNewsRequest.php b/app/Http/Requests/StoreNewsRequest.php new file mode 100644 index 0000000..822abc3 --- /dev/null +++ b/app/Http/Requests/StoreNewsRequest.php @@ -0,0 +1,78 @@ +route('news'); + if( $news ) + return $this->user()->can('update', $news); + + return $this->user()->can('create', News::class); + } + + public function prepareForValidation(): void + { + $this->merge([ + 'gallery' => $this->input('gallery') !== '' ? $this->input('gallery') : null, + ]); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + $isEdit = (bool) $this->route('news'); + + $rules = []; + + $rules['title'] = 'required|string|max:255'; + $rules['category'] = 'required|integer|exists:categories,id'; + + $rules['description'] = 'required|string'; + $rules['gallery'] = 'array|required|min:1'; + $rules['gallery.*'] = [ 'string', new PublicFileExists ]; + + $rules['entry_id'] = 'nullable|integer|exists:entries,id'; + $rules['release_site'] = 'nullable|url|max:500'; + $rules['youtube_video'] = 'nullable|url|max:500'; + + if( $isEdit ){ + $ss = 'draft,pending,published'; + if( \Auth::user()->can('moderate', $this->route('news')) && \Auth::user()->can('view-hidden', $this->route('news')) ) + $ss .= ',hidden'; + if(\Auth::user()->can('moderate', $this->route('news')) && \Auth::user()->can('view-locked', $this->route('news')) ) + $ss .= ',locked'; + $rules['submit-state'] = 'required|in:' . $ss; + } else { + if( $this->user()->can('skip-queue', '\App\Models\News') ){ + $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('news') ) ){ + $rules['staff_comment'] = 'nullable|string'; + $rules['owner_user_id'] = [ 'required', 'integer', new XfUserExists ]; + $rules['comments_thread_id'] = 'nullable|integer'; + } + + return $rules; + + } +} diff --git a/app/Http/Requests/StoreReviewRequest.php b/app/Http/Requests/StoreReviewRequest.php new file mode 100644 index 0000000..8a3040c --- /dev/null +++ b/app/Http/Requests/StoreReviewRequest.php @@ -0,0 +1,41 @@ +route('review'); + if( $review ) + return $this->user()->can('update', $review); + + return $this->user()->can('create', '\App\Models\EntryReview'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + + $isEdit = (bool) $this->route('review'); + + $rules = []; + + $rules['rating'] = 'required|numeric|min:1|max:5'; + $rules['title'] = 'required|string|max:255'; + $rules['description'] = 'required|string'; + + return $rules; + + } +} diff --git a/app/Jobs/CreateXenForoCommentsThread.php b/app/Jobs/CreateXenForoCommentsThread.php index 0edeb09..ce532c5 100644 --- a/app/Jobs/CreateXenForoCommentsThread.php +++ b/app/Jobs/CreateXenForoCommentsThread.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Models\Entry; +use App\Models\News; use App\Services\XenforoApiService; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; @@ -18,7 +19,7 @@ class CreateXenForoCommentsThread implements ShouldQueue * Create a new job instance. */ public function __construct( - protected Entry $entry + protected Entry|News $entry ) { // diff --git a/app/Jobs/DeleteFile.php b/app/Jobs/DeleteFile.php new file mode 100644 index 0000000..52369dd --- /dev/null +++ b/app/Jobs/DeleteFile.php @@ -0,0 +1,36 @@ +deleteFile($this->filePath, $this->fileName, $this->userId); + } +} diff --git a/app/Jobs/DeleteXenForoCommentsThread.php b/app/Jobs/DeleteXenForoCommentsThread.php index 46cc2e2..80b0eff 100644 --- a/app/Jobs/DeleteXenForoCommentsThread.php +++ b/app/Jobs/DeleteXenForoCommentsThread.php @@ -18,7 +18,7 @@ class DeleteXenForoCommentsThread implements ShouldQueue * Create a new job instance. */ public function __construct( - protected int $threadId, + protected ?int $threadId, ) { // @@ -29,6 +29,7 @@ class DeleteXenForoCommentsThread implements ShouldQueue */ public function handle(XenforoApiService $service): void { - $service->deleteThreadWithEntry($this->threadId); + if( $this->threadId ) + $service->deleteThreadWithEntry($this->threadId); } } diff --git a/app/Jobs/RestoreXenForoCommentsThread.php b/app/Jobs/RestoreXenForoCommentsThread.php index e541c3a..ea5ba4b 100644 --- a/app/Jobs/RestoreXenForoCommentsThread.php +++ b/app/Jobs/RestoreXenForoCommentsThread.php @@ -18,7 +18,7 @@ class RestoreXenForoCommentsThread implements ShouldQueue * Create a new job instance. */ public function __construct( - protected int $threadId, + protected ?int $threadId, ) { // @@ -29,6 +29,7 @@ class RestoreXenForoCommentsThread implements ShouldQueue */ public function handle(XenforoApiService $service): void { - $service->restoreThreadWithEntry($this->threadId); + if( $this->threadId ) + $service->restoreThreadWithEntry($this->threadId); } } diff --git a/app/Livewire/ActivityLogs.php b/app/Livewire/ActivityLogs.php new file mode 100644 index 0000000..a80b40d --- /dev/null +++ b/app/Livewire/ActivityLogs.php @@ -0,0 +1,64 @@ +resetPage(); } + + public function clearFilters(): void + { + $this->reset('search', 'logName', 'event', 'dateFrom', 'dateTo', 'causerId'); + $this->resetPage(); + } + + public function render() + { + $logs = Activity::query() + ->with(['causer']) + ->when($this->search, fn($q) => $q + ->where('description', 'like', "%{$this->search}%") + ->orWhere('subject_type', 'like', "%{$this->search}%") + ->orWhere('log_name', 'like', "%{$this->search}%") + ) + ->when($this->logName, fn($q) => $q->where('log_name', $this->logName)) + ->when($this->event, fn($q) => $q->where('event', $this->event)) + ->when($this->causerId, fn($q) => $q->where('causer_id', $this->causerId)) + ->when($this->dateFrom, fn($q) => $q->whereDate('created_at', '>=', $this->dateFrom)) + ->when($this->dateTo, fn($q) => $q->whereDate('created_at', '<=', $this->dateTo)) + ->latest() + ->paginate(50); + + $logNames = Activity::distinct()->orderBy('log_name')->pluck('log_name')->filter()->values(); + $events = Activity::distinct()->orderBy('event')->pluck('event')->filter()->values(); + + $hasFilters = $this->logName || $this->event || $this->dateFrom || $this->dateTo || $this->causerId; + + return view('livewire.activity-logs', compact('logs', 'logNames', 'events', 'hasFilters')); + } +} diff --git a/app/Livewire/Database.php b/app/Livewire/Database.php index fa9d1c9..4f44940 100644 --- a/app/Livewire/Database.php +++ b/app/Livewire/Database.php @@ -3,13 +3,16 @@ namespace App\Livewire; use App\Models\Author; +use App\Models\Category; use App\Models\Entry; use App\Models\Game; use App\Models\Genre; use App\Models\Language; +use App\Models\Level; use App\Models\Modification; use App\Models\Platform; use App\Models\Status; +use App\Models\System; use Livewire\Attributes\Url; use Livewire\Component; use Livewire\WithPagination; @@ -114,6 +117,44 @@ class Database extends Component #[Url(except:'or')] public string $modificationsMode = 'or'; + /** + * Categories IDs filter. + * @var array + */ + #[Url(except:[])] + public array $categories = []; + + /** + * Categories mode and/or + * @var string + */ + #[Url(except:'or')] + public string $categoriesMode = 'or'; + + /** + * Systems IDs filter. + * @var array + */ + #[Url(except:[])] + public array $systems = []; + + /** + * Systems mode and/or + * @var string + */ + #[Url(except:'or')] + public string $systemsMode = 'or'; + + /** + * Levels IDs filter. + * @var array + */ + #[Url(except:[])] + public array $levels = []; + + #[Url(except:null)] + public ?int $userId = null; + /** * Sort by field. * @var string @@ -147,7 +188,6 @@ class Database extends Component 'utilities' => 'Utilities', 'documents' => 'Documents', 'lua-scripts' => 'Lua Scripts', - 'tutorials' => 'Tutorials', ]; public const int PAGINATION = 30; @@ -164,12 +204,19 @@ class Database extends Component 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 updatedCategories(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedCategoriesMode(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedSystems(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedSystemsMode(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedLevels(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedUserId(): void { $this->resetPage(); $this->dispatch('filters-updated'); } public function clearFilters(): void { $this->reset([ - 'search', 'types', 'platforms', 'genres', 'statuses', 'authors', 'authorsMode', 'languages', 'languagesMode', 'modifications', 'modificationsMode' + 'search', 'types', 'platforms', 'genres', 'statuses', 'authors', 'authorsMode', 'languages', 'languagesMode', 'modifications', 'modificationsMode', 'categories', 'categoriesMode', 'systems', 'systemsMode', 'levels', 'userId' ]); + $this->dispatch('filters-updated'); $this->resetPage(); } @@ -188,7 +235,7 @@ class Database extends Component private function buildQuery() { $query = Entry::query()->published()->with([ - 'game.platform', 'game.genre', 'status', 'authors', 'languages' + 'game.platform', 'game.genre', 'status', 'authors', 'languages', 'level', 'systems', 'categories', 'modifications' ]); if( $this->search ) { @@ -253,20 +300,88 @@ class Database extends Component } } + if( $this->levels ) { + $query->whereIn('level_id', $this->levels); + } + + if( $this->categories ) { + if( $this->categoriesMode === 'and' ) { + foreach ( $this->categories as $categoryId ) { + $query->whereHas('categories', fn($q) => $q->where('categories.id', $categoryId)); + } + } else { + $query->whereHas('categories', fn($q) => $q->whereIn('categories.id', $this->categories)); + } + } + + if( $this->systems ) { + if( $this->systemsMode === 'and' ) { + foreach ( $this->systems as $systemId ) { + $query->whereHas('systems', fn($q) => $q->where('systems.id', $systemId)); + } + } else { + $query->whereHas('systems', fn($q) => $q->whereIn('systems.id', $this->systems)); + } + } + + if( $this->userId ){ + $query->where('user_id', $this->userId); + } + return $query->orderBy($this->sortBy, $this->sortDir); } + private function searchFilter( string $modelClass, string $search ) + { + + $this->dispatch('filters-updated'); + + if( mb_strlen( $search ) < 3 ) { + return collect(); + } + + return $modelClass::where('name', 'like', "%{$search}%") + ->orderBy('name') + ->limit(50) + ->get(); + } + + private function searchGameFilter() + { + + $search = $this->gameSearch; + + $this->dispatch('filters-updated'); + + if( mb_strlen( $search ) < 3 ) { + return collect(); + } + + $collect = Game::where('name', 'like', "%{$search}%") + ->orderBy('name') + ->limit(50) + ->get(); + + return $collect->map(function($item){ + $item->name = $item->name . ' (' . ($item->platform?->short_name ?? $item->platform->name) . ')'; + return $item; + } ); + } + public function render() { return view('livewire.database', [ 'entries' => $this->buildQuery()->paginate(self::PAGINATION), - 'allGames' => Game::orderBy('name')->get(), + 'allGames' => $this->searchGameFilter(), 'allPlatforms' => Platform::orderBy('name')->get(), 'allGenres' => Genre::orderBy('name')->get(), 'allStatuses' => Status::orderBy('name')->get(), - 'allAuthors' => Author::orderBy('name')->get(), + 'allAuthors' => $this->searchFilter(Author::class, $this->authorSearch), 'allLanguages' => Language::orderBy('name')->get(), 'allModifications' => Modification::orderBy('name')->get(), + 'allCategories' => Category::orderBy('name')->get(), + 'allLevels' => Level::orderBy('name')->get(), + 'allSystems' => System::orderBy('name')->get(), ]); } } diff --git a/app/Livewire/EntrySelector.php b/app/Livewire/EntrySelector.php new file mode 100644 index 0000000..e78b74c --- /dev/null +++ b/app/Livewire/EntrySelector.php @@ -0,0 +1,72 @@ +selectedEntryId = $oldEntryId; + $this->entryName = $entry->complete_title ?? $entry->title; + } + } + } + + public function updatedSearch(): void + { + if( $this->selectedEntryId ) { + $this->selectedEntryId = null; + $this->entryName = null; + } + $this->dropdown = strlen($this->search) > 2; + } + + public function selectEntry( int $id ){ + $entry = Entry::find($id); + if( $entry ) { + $this->selectedEntryId = $id; + $this->entryName = $entry->complete_title ?? $entry->title; + $this->search = $entry->complete_title ?? $entry->title; + $this->dropdown = false; + + } + } + + public function clearEntry(): void + { + $this->selectedEntryId = null; + $this->entryName = null; + $this->search = ''; + } + + public function render() + { + $entries = collect(); + + if( $this->dropdown && strlen($this->search) > 2 ) { + $entries = Entry::where('complete_title', 'like', '%' . $this->search . '%') + ->orWhere('title', 'like', '%' . $this->search . '%') + ->orderBy('complete_title', 'asc') + ->orderBy('title', 'asc') + ->limit(20) + ->get(); + } + + $data = [ 'entries' => $entries, 'required_chars' => 3 ]; + + return view('livewire.entry-selector', $data); + } +} diff --git a/app/Livewire/GameSelector.php b/app/Livewire/GameSelector.php index 84b0b5b..2446d77 100644 --- a/app/Livewire/GameSelector.php +++ b/app/Livewire/GameSelector.php @@ -16,6 +16,17 @@ class GameSelector extends Component public const int REQUIRED_CHARS = 3; + /** + * Which section we can change selection mode. + */ + public const array CHANGE_SECTION_MODE = [ 'utilities', 'documents' ]; + + /** + * Selection mode between game|platform|none. + * @var string + */ + public string $selectionMode = 'game'; + /** * If we are in new game mode. * @var bool @@ -70,9 +81,25 @@ class GameSelector extends Component */ public bool $dropdown = false; - public function mount( ?int $gameId = null, ?string $newGameTitle = null, ?int $newGamePlatform = null, ?int $newGameGenre = null ): void + /** + * In platform mode. + * @var int|null + */ + public ?int $platformModeId = null; + + /** + * In platform mode. + * @var string|null + */ + public ?string $platformModeName = null; + + public ?string $section = null; + + public function mount( ?int $gameId = null, ?string $newGameTitle = null, ?int $newGamePlatform = null, ?int $newGameGenre = null, ?string $section, ?int $platformOnlyId ): void { + $this->section = $section; + // If we selected an existent game. if( $gameId ){ $game = Game::with(['platform','genre'])->find($gameId); @@ -93,6 +120,36 @@ class GameSelector extends Component $this->gamePlatformId = is_numeric($newGamePlatform) ? (int) $newGamePlatform : null; $this->gameGenreId = is_numeric($newGameGenre) ? (int) $newGameGenre : null; } + + if( in_array( $section, self::CHANGE_SECTION_MODE ) ) { + if ($platformOnlyId) { + $this->selectionMode = 'platform'; + $this->platformModeId = $platformOnlyId; + $platform = Platform::find($platformOnlyId); + if ($platform) { + $this->platformModeName = $platform->name; + } else { + $this->platformModeId = null; + } + } + } + } + + public function setSelectionMode(string $mode): void + { + if( !in_array( $this->section, self::CHANGE_SECTION_MODE ) ) + return; + + $this->selectionMode = $mode; + + if( $mode !== 'game' ){ + $this->clearGame(); + $this->newGame = false; + } + if( $mode !== 'platform' ){ + $this->platformModeId = null; + $this->platformModeName = null; + } } /** @@ -134,6 +191,15 @@ class GameSelector extends Component } + public function selectPlatformOnly( int $id ): void + { + $platform = Platform::find($id); + if( $platform ){ + $this->platformModeId = $platform->id; + $this->platformModeName = $platform->name; + } + } + /** * Clear existent game selection. * @return void @@ -179,6 +245,10 @@ class GameSelector extends Component } $data['hasOldNewGame'] = old('new-game-title') || old('new-game-platform') || old('new-game-genre'); + $data['canChangeSelection'] = in_array( $this->section, self::CHANGE_SECTION_MODE ); + if( in_array( $this->section, self::CHANGE_SECTION_MODE ) ) + $data['platforms'] = Platform::orderBy('name')->get(); + return view('livewire.game-selector', $data ); } } diff --git a/app/Livewire/HashesChecker.php b/app/Livewire/HashesChecker.php new file mode 100644 index 0000000..a36723f --- /dev/null +++ b/app/Livewire/HashesChecker.php @@ -0,0 +1,81 @@ +hash = [ + 'filename' => $filename, + 'hash_crc32' => $crc32, + 'hash_sha1' => $sha1, + 'verified' => $verified + ]; + else if( ( $hash = HashesHelpers::findHashes( $sha1 ) ) !== null ) + $this->hash = [ + 'filename' => $hash->filename, + 'hash_crc32' => $hash->crc32, + 'hash_sha1' => $hash->sha1, + 'verified' => HashesHelpers::getReferenceName( $hash->dat_reference_id ) + ]; + else + $this->hash = [ + 'filename' => $filename, + 'hash_crc32' => $crc32, + 'hash_sha1' => $sha1, + 'verified' => "No" + ]; + } + + /** + * Remove the hash. + * + * @return void + */ + public function removeHash(): void + { + $this->hash = null; + $this->dispatch('refresh'); + } + + + public function render(): View + { + $entries = collect(); + if( $this->hash !== null ){ + $entries = EntryHash::where('hash_sha1', $this->hash['hash_sha1'] )->get()->pluck('entry')->filter(); + } + return view('livewire.hashes-checker', compact('entries')); + } +} diff --git a/app/Livewire/NewsDatabase.php b/app/Livewire/NewsDatabase.php new file mode 100644 index 0000000..3c64c58 --- /dev/null +++ b/app/Livewire/NewsDatabase.php @@ -0,0 +1,104 @@ + 'Date added', + 'title' => 'Title' + ]; + + public const int PAGINATION = 30; + + public function updatedSearch(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedCategories(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedCategoriesMode(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + + public function clearFilters(): void + { + $this->reset([ + 'search', 'categories', + ]); + $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 = News::query()->published(); + + if( $this->search ){ + $query->where(function($q){ + $q->where('title', 'like', '%'.$this->search.'%'); + }); + } + + if( $this->categories ) { + $query->whereIn('category_id', $this->categories); + } + + return $query->orderBy($this->sortBy, $this->sortDir); + } + + public function render() + { + return view('livewire.news-database', [ + 'news' => $this->buildQuery()->paginate(self::PAGINATION), + 'allCategories' => Category::where(function ($query) { + $query->whereJsonContains('restricted_to', "news") + ->orWhereNull('restricted_to'); + })->orderBy('name')->get() + ]); + } +} diff --git a/app/Livewire/Reviews.php b/app/Livewire/Reviews.php new file mode 100644 index 0000000..01e60d1 --- /dev/null +++ b/app/Livewire/Reviews.php @@ -0,0 +1,92 @@ + 'Date added', + 'rating' => 'Rating', + 'title' => 'Title' + ]; + + public const int PAGINATION = 30; + + public function updatedEntryId(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedRating(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + + public function clearFilters(): void + { + $this->reset([ + 'entryId', 'rating' + ]); + $this->dispatch('filters-updated'); + $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 = EntryReview::query()->with([ + 'entry' + ]); + + if( $this->entryId ) { + $query->where('entry_id', $this->entryId); + } + if( $this->rating ){ + $query->where('rating', $this->rating); + } + + return $query->orderBy($this->sortBy, $this->sortDir); + } + + public function render() + { + return view('livewire.reviews', [ + 'reviews' => $this->buildQuery()->paginate(self::PAGINATION), + ]); + } +} diff --git a/app/Models/Author.php b/app/Models/Author.php index 357367d..56eacce 100644 --- a/app/Models/Author.php +++ b/app/Models/Author.php @@ -8,6 +8,28 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +/** + * @property int $id + * @property string $name + * @property string $slug + * @property string|null $website + * @property int|null $user_id + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \Illuminate\Database\Eloquent\Collection $entries + * @property-read int|null $entries_count + * @method static \Illuminate\Database\Eloquent\Builder|Author newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Author newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Author query() + * @method static \Illuminate\Database\Eloquent\Builder|Author whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Author whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Author whereName($value) + * @method static \Illuminate\Database\Eloquent\Builder|Author whereSlug($value) + * @method static \Illuminate\Database\Eloquent\Builder|Author whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Author whereUserId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Author whereWebsite($value) + * @mixin \Eloquent + */ class Author extends Model { protected $fillable = [ diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 0000000..69e3e41 --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,33 @@ +|null $restricted_to + * @method static \Illuminate\Database\Eloquent\Builder|Category newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Category newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Category query() + * @method static \Illuminate\Database\Eloquent\Builder|Category whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Category whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Category whereName($value) + * @method static \Illuminate\Database\Eloquent\Builder|Category whereRestrictedTo($value) + * @method static \Illuminate\Database\Eloquent\Builder|Category whereSlug($value) + * @method static \Illuminate\Database\Eloquent\Builder|Category whereUpdatedAt($value) + * @mixin \Eloquent + */ +class Category extends Model +{ + protected $fillable = ['name', 'slug', 'restricted_to']; + + protected $casts = [ + 'restricted_to' => 'array', + ]; + +} diff --git a/app/Models/Entry.php b/app/Models/Entry.php index db044fb..5fcb025 100644 --- a/app/Models/Entry.php +++ b/app/Models/Entry.php @@ -2,17 +2,114 @@ namespace App\Models; +use App\Helpers\EntryHelpers; +use App\Traits\HasGallery; +use App\Traits\HasXenforoUserId; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use League\CommonMark\GithubFlavoredMarkdownConverter; +use Monolog\Level; +use Spatie\Activitylog\Models\Concerns\LogsActivity; +use Spatie\Activitylog\Support\LogOptions; +/** + * @property int $id + * @property string $type + * @property string|null $title + * @property string|null $slug + * @property string|null $description + * @property string|null $main_image + * @property string $state + * @property string|null $staff_comment + * @property \Illuminate\Support\Carbon|null $rejected_at + * @property bool $featured + * @property \Illuminate\Support\Carbon|null $featured_at + * @property int|null $game_id + * @property int|null $platform_id + * @property int|null $status_id + * @property string|null $version + * @property \Illuminate\Support\Carbon|null $release_date + * @property string|null $staff_credits + * @property string|null $relevant_link + * @property string|null $youtube_link + * @property int $user_id + * @property int|null $comments_thread_id + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property string|null $complete_title + * @property \Illuminate\Support\Carbon|null $deleted_at + * @property int|null $level_id + * @property-read \Illuminate\Database\Eloquent\Collection $authors + * @property-read int|null $authors_count + * @property-read \Illuminate\Database\Eloquent\Collection $categories + * @property-read int|null $categories_count + * @property-read \Illuminate\Database\Eloquent\Collection $files + * @property-read int|null $files_count + * @property-read \Illuminate\Database\Eloquent\Collection $gallery + * @property-read int|null $gallery_count + * @property-read \App\Models\Game|null $game + * @property-read \Illuminate\Database\Eloquent\Collection $hashes + * @property-read int|null $hashes_count + * @property-read \Illuminate\Database\Eloquent\Collection $languages + * @property-read int|null $languages_count + * @property-read \App\Models\Level|null $level + * @property-read \Illuminate\Database\Eloquent\Collection $modifications + * @property-read int|null $modifications_count + * @property-read \App\Models\Platform|null $platform + * @property-read \App\Models\Status|null $status + * @property-read \Illuminate\Database\Eloquent\Collection $systems + * @property-read int|null $systems_count + * @method static Builder|Entry inQueue(int $daysRejected = 7) + * @method static Builder|Entry newModelQuery() + * @method static Builder|Entry newQuery() + * @method static Builder|Entry onlyTrashed() + * @method static Builder|Entry published() + * @method static Builder|Entry query() + * @method static Builder|Entry whereCommentsThreadId($value) + * @method static Builder|Entry whereCompleteTitle($value) + * @method static Builder|Entry whereCreatedAt($value) + * @method static Builder|Entry whereDeletedAt($value) + * @method static Builder|Entry whereDescription($value) + * @method static Builder|Entry whereFeatured($value) + * @method static Builder|Entry whereFeaturedAt($value) + * @method static Builder|Entry whereGameId($value) + * @method static Builder|Entry whereId($value) + * @method static Builder|Entry whereLevelId($value) + * @method static Builder|Entry whereMainImage($value) + * @method static Builder|Entry wherePlatformId($value) + * @method static Builder|Entry whereRejectedAt($value) + * @method static Builder|Entry whereReleaseDate($value) + * @method static Builder|Entry whereRelevantLink($value) + * @method static Builder|Entry whereSlug($value) + * @method static Builder|Entry whereStaffComment($value) + * @method static Builder|Entry whereStaffCredits($value) + * @method static Builder|Entry whereState($value) + * @method static Builder|Entry whereStatusId($value) + * @method static Builder|Entry whereTitle($value) + * @method static Builder|Entry whereType($value) + * @method static Builder|Entry whereUpdatedAt($value) + * @method static Builder|Entry whereUserId($value) + * @method static Builder|Entry whereVersion($value) + * @method static Builder|Entry whereYoutubeLink($value) + * @method static Builder|Entry withTrashed(bool $withTrashed = true) + * @method static Builder|Entry withoutTrashed() + * @property-read \Illuminate\Database\Eloquent\Collection $activitiesAsSubject + * @property-read int|null $activities_as_subject_count + * @property-read \Illuminate\Database\Eloquent\Collection $reviews + * @property-read int|null $reviews_count + * @property-read float $average_rating + * @property-read int $reviews_count_cached + * @property-read string $description_html + * @mixin \Eloquent + */ class Entry extends Model { - use SoftDeletes; + use SoftDeletes, HasGallery, LogsActivity, HasXenforoUserId; /** * @var string[] @@ -25,6 +122,7 @@ class Entry extends Model 'main_image', 'state', 'featured', + 'featured_at', 'game_id', 'platform_id', 'status_id', @@ -38,6 +136,8 @@ class Entry extends Model 'comments_thread_id', 'staff_comment', 'rejected_at', + 'level_id', + 'created_at' ]; /** @@ -47,8 +147,18 @@ class Entry extends Model 'featured' => 'boolean', 'release_date' => 'date', 'rejected_at' => 'datetime', + 'featured_at' => 'datetime', ]; + protected static function booted(): void { + static::saving( function( $entry ) { + if( $entry->isDirty('version') ) { + $entry->created_at = now(); + } + }); + } + + public function scopePublished( Builder $query ): Builder { return $query->where( 'state', 'published' ); } @@ -81,6 +191,10 @@ class Entry extends Model return $this->belongsTo(Status::class ); } + public function level(): BelongsTo { + return $this->belongsTo(\App\Models\Level::class); + } + public function authors(): BelongsToMany { return $this->belongsToMany(Author::class, 'entry_authors'); } @@ -93,19 +207,47 @@ class Entry extends Model return $this->belongsToMany( Modification::class, 'entry_modifications'); } - public function files(): HasMany { - return $this->hasMany(EntryFile::class)->orderBy('filename'); + public function categories(): BelongsToMany { + return $this->belongsToMany(Category::class, 'entry_categories'); } - public function gallery(): HasMany { - return $this->hasMany(EntryGallery::class)->orderBy('id'); + public function systems(): BelongsToMany { + return $this->belongsToMany(System::class, 'entry_systems'); + } + + public function files(): HasMany { + return $this->hasMany(EntryFile::class)->orderBy('filename'); } public function hashes(): HasMany { return $this->hasMany(EntryHash::class); } - public function parseStaffCredits(): array { + public function reviews(): HasMany { + return $this->hasMany(EntryReview::class); + } + + public function getAverageRatingAttribute(): float + { + return round( $this->reviews->avg('rating') ?? 0, 1 ); + } + + public function getReviewsCountCachedAttribute(): int + { + return $this->reviews->count(); + } + + public function getDescriptionHtmlAttribute(): string + { + $converter = new GithubFlavoredMarkdownConverter([ + 'html_input' => 'strip', + 'allow_unsafe_links' => false, + ]); + + return $converter->convert($this->description)->getContent(); + } + + public function parseStaffCredits(): ?array { return json_decode( $this->staff_credits ?? "", true ); } @@ -113,10 +255,17 @@ class Entry extends Model if( !$this->youtube_link ) return null; - $pattern = '%(?:https?://)?(?:www\.|m\.)?(?:youtu\.be/|youtube(?:-nocookie)?\.com/(?:watch\?.*v=|embed/|v/|shorts/|live/))([\w-]{11})%i'; + return EntryHelpers::getYoutubeVideoId( $this->youtube_link ); + } - preg_match($pattern, $this->youtube_link, $matches); - return $matches[1] ?? null; + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->useLogName('entry') + ->logAll() + ->logOnlyDirty() + ->dontLogEmptyChanges() + ->setDescriptionForEvent(fn(string $eventName) => "Entry {$eventName}"); } } diff --git a/app/Models/EntryFile.php b/app/Models/EntryFile.php index c738572..69df869 100644 --- a/app/Models/EntryFile.php +++ b/app/Models/EntryFile.php @@ -4,7 +4,44 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; +/** + * @property int $id + * @property int $entry_id + * @property string $filename + * @property string $filepath + * @property string $favorite_server + * @property int $favorite_at + * @property int|null $filesize + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property string $file_uuid + * @property string $state + * @property int $online_patcher + * @property int $secondary_online_patcher + * @property-read \App\Models\Entry|null $entry + * @method static \Illuminate\Database\Eloquent\Builder|EntryFile newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|EntryFile newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|EntryFile query() + * @method static \Illuminate\Database\Eloquent\Builder|EntryFile whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryFile whereEntryId($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryFile whereFavoriteAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryFile whereFavoriteServer($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryFile whereFileUuid($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryFile whereFilename($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryFile whereFilepath($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryFile whereFilesize($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryFile whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryFile whereOnlinePatcher($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryFile whereSecondaryOnlinePatcher($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryFile whereState($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryFile whereUpdatedAt($value) + * @property-read \App\Models\PlayOnlineSetting|null $playOnlineSetting + * @property int $download_count + * @method static \Illuminate\Database\Eloquent\Builder|EntryFile whereDownloadCount($value) + * @mixin \Eloquent + */ class EntryFile extends Model { protected $fillable = [ @@ -20,4 +57,32 @@ class EntryFile extends Model return $this->belongsTo(Entry::class); } + public function playOnlineSetting(): HasOne + { + return $this->hasOne(PlayOnlineSetting::class,'file_id'); + } + + public function prettyFileSize(): string + { + $bytes = $this->filesize; + + if ($bytes >= 1073741824) { + $bytes = number_format($bytes / 1073741824, 2) . ' GB'; + } elseif ($bytes >= 1048576) { + $bytes = number_format($bytes / 1048576, 2) . ' MB'; + } elseif ($bytes >= 1024) { + $bytes = number_format($bytes / 1024, 2) . ' KB'; + } else { + $bytes = ($bytes == 0) ? '0 bytes' : $bytes . ' bytes'; + } + + return $bytes; + } + + public function increaseDownloadCount(): void + { + $this->download_count++; + $this->save(); + } + } diff --git a/app/Models/EntryGallery.php b/app/Models/EntryGallery.php index 0b478ec..e8a9014 100644 --- a/app/Models/EntryGallery.php +++ b/app/Models/EntryGallery.php @@ -2,10 +2,26 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Model; - -class EntryGallery extends Model -{ - protected $fillable = ['entry_id','image']; - -} +/** + * @deprecated Use Gallery instead. + * @property int $id + * @property string $galleryable_type + * @property int $galleryable_id + * @property string $image + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \Illuminate\Database\Eloquent\Model|\Eloquent $galleryable + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery query() + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery whereGalleryableId($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery whereGalleryableType($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery whereImage($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery whereUpdatedAt($value) + * @property int $order + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery whereOrder($value) + * @mixin \Eloquent + */ +class EntryGallery extends Gallery {} diff --git a/app/Models/EntryHash.php b/app/Models/EntryHash.php index 35ef829..bdeb050 100644 --- a/app/Models/EntryHash.php +++ b/app/Models/EntryHash.php @@ -5,6 +5,29 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +/** + * @property int $id + * @property int $entry_id + * @property string $filename + * @property string $hash_crc32 + * @property string $hash_sha1 + * @property string $verified + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \App\Models\Entry|null $entry + * @method static \Illuminate\Database\Eloquent\Builder|EntryHash newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|EntryHash newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|EntryHash query() + * @method static \Illuminate\Database\Eloquent\Builder|EntryHash whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryHash whereEntryId($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryHash whereFilename($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryHash whereHashCrc32($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryHash whereHashSha1($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryHash whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryHash whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryHash whereVerified($value) + * @mixin \Eloquent + */ class EntryHash extends Model { protected $fillable = [ diff --git a/app/Models/EntryReview.php b/app/Models/EntryReview.php new file mode 100644 index 0000000..861e48e --- /dev/null +++ b/app/Models/EntryReview.php @@ -0,0 +1,63 @@ +|EntryReview newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|EntryReview newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|EntryReview query() + * @method static \Illuminate\Database\Eloquent\Builder|EntryReview whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryReview whereDeletedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryReview whereDescription($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryReview whereEntryId($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryReview whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryReview whereRating($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryReview whereTitle($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryReview whereUpdatedAt($value) + * @property int $user_id + * @method static \Illuminate\Database\Eloquent\Builder|EntryReview whereUserId($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryReview onlyTrashed() + * @method static \Illuminate\Database\Eloquent\Builder|EntryReview withTrashed(bool $withTrashed = true) + * @method static \Illuminate\Database\Eloquent\Builder|EntryReview withoutTrashed() + * @property-read string $description_html + * @mixin \Eloquent + */ +class EntryReview extends Model +{ + + use HasXenforoUserId, SoftDeletes; + + protected $fillable = [ 'entry_id', 'title', 'rating', 'description', 'user_id' ]; + + public function entry(): BelongsTo + { + return $this->belongsTo(Entry::class); + } + + public function getDescriptionHtmlAttribute(): string + { + $converter = new GithubFlavoredMarkdownConverter([ + 'html_input' => 'strip', + 'allow_unsafe_links' => false, + ]); + + return $converter->convert($this->description)->getContent(); + } + +} diff --git a/app/Models/Gallery.php b/app/Models/Gallery.php new file mode 100644 index 0000000..288650e --- /dev/null +++ b/app/Models/Gallery.php @@ -0,0 +1,41 @@ +|Gallery newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Gallery newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Gallery query() + * @method static \Illuminate\Database\Eloquent\Builder|Gallery whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Gallery whereEntryId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Gallery whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Gallery whereImage($value) + * @method static \Illuminate\Database\Eloquent\Builder|Gallery whereUpdatedAt($value) + * @property string $galleryable_type + * @property int $galleryable_id + * @property-read Model|\Eloquent $galleryable + * @method static \Illuminate\Database\Eloquent\Builder|Gallery whereGalleryableId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Gallery whereGalleryableType($value) + * @property int $order + * @method static \Illuminate\Database\Eloquent\Builder|Gallery whereOrder($value) + * @mixin \Eloquent + */ +class Gallery extends Model +{ + protected $table = 'galleries'; + protected $fillable = ['image', 'galleryable_id', 'galleryable_type']; + + public function galleryable(): MorphTo + { + return $this->morphTo(); + } + +} diff --git a/app/Models/Game.php b/app/Models/Game.php index db4b115..0878b20 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -5,6 +5,30 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +/** + * @property int $id + * @property string $name + * @property string $slug + * @property int $platform_id + * @property int $genre_id + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \Illuminate\Database\Eloquent\Collection $entries + * @property-read int|null $entries_count + * @property-read \App\Models\Genre $genre + * @property-read \App\Models\Platform $platform + * @method static \Illuminate\Database\Eloquent\Builder|Game newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Game newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Game query() + * @method static \Illuminate\Database\Eloquent\Builder|Game whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Game whereGenreId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Game whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Game whereName($value) + * @method static \Illuminate\Database\Eloquent\Builder|Game wherePlatformId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Game whereSlug($value) + * @method static \Illuminate\Database\Eloquent\Builder|Game whereUpdatedAt($value) + * @mixin \Eloquent + */ class Game extends Model { /** diff --git a/app/Models/Genre.php b/app/Models/Genre.php index c2d3551..5088894 100644 --- a/app/Models/Genre.php +++ b/app/Models/Genre.php @@ -6,6 +6,24 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +/** + * @property int $id + * @property string $name + * @property string $slug + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \Illuminate\Database\Eloquent\Collection $games + * @property-read int|null $games_count + * @method static \Illuminate\Database\Eloquent\Builder|Genre newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Genre newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Genre query() + * @method static \Illuminate\Database\Eloquent\Builder|Genre whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Genre whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Genre whereName($value) + * @method static \Illuminate\Database\Eloquent\Builder|Genre whereSlug($value) + * @method static \Illuminate\Database\Eloquent\Builder|Genre whereUpdatedAt($value) + * @mixin \Eloquent + */ class Genre extends Model { protected $fillable = [ 'name', 'slug' ]; diff --git a/app/Models/Language.php b/app/Models/Language.php index a2de75b..ba1cca0 100644 --- a/app/Models/Language.php +++ b/app/Models/Language.php @@ -6,6 +6,24 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +/** + * @property int $id + * @property string $name + * @property string $slug + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \Illuminate\Database\Eloquent\Collection $entries + * @property-read int|null $entries_count + * @method static \Illuminate\Database\Eloquent\Builder|Language newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Language newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Language query() + * @method static \Illuminate\Database\Eloquent\Builder|Language whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Language whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Language whereName($value) + * @method static \Illuminate\Database\Eloquent\Builder|Language whereSlug($value) + * @method static \Illuminate\Database\Eloquent\Builder|Language whereUpdatedAt($value) + * @mixin \Eloquent + */ class Language extends Model { protected $fillable = [ 'name', 'slug' ]; diff --git a/app/Models/Level.php b/app/Models/Level.php new file mode 100644 index 0000000..d205d45 --- /dev/null +++ b/app/Models/Level.php @@ -0,0 +1,26 @@ +|Level newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Level newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Level query() + * @method static \Illuminate\Database\Eloquent\Builder|Level whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Level whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Level whereName($value) + * @method static \Illuminate\Database\Eloquent\Builder|Level whereSlug($value) + * @method static \Illuminate\Database\Eloquent\Builder|Level whereUpdatedAt($value) + * @mixin \Eloquent + */ +class Level extends Model +{ + protected $fillable = ['name', 'slug']; +} diff --git a/app/Models/LogXfUser.php b/app/Models/LogXfUser.php new file mode 100644 index 0000000..def689a --- /dev/null +++ b/app/Models/LogXfUser.php @@ -0,0 +1,129 @@ +|LogXfUser newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser query() + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereActivityVisible($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereAlertsUnread($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereAlertsUnviewed($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereAvatarDate($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereAvatarHeight($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereAvatarHighdpi($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereAvatarOptimized($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereAvatarWidth($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereConversationsUnread($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereCustomTitle($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereDisplayStyleGroupId($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereEmail($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereGravatar($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereIsAdmin($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereIsBanned($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereIsModerator($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereIsStaff($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereLanguageId($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereLastActivity($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereLastSummaryEmailDate($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereMessageCount($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser wherePermissionCombinationId($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser wherePrivacyPolicyAccepted($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereQuestionSolutionCount($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereReactionScore($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereRegisterDate($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereRhpzEntryCount($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereSecondaryGroupIds($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereSecretKey($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereSecurityLock($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereStyleId($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereStyleVariation($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereTermsAccepted($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereTimezone($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereTrophyPoints($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereUserGroupId($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereUserId($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereUserState($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereUsername($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereUsernameDate($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereUsernameDateVisible($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereVisible($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereVoteScore($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereWarningPoints($value) + * @mixin \Eloquent + */ +class LogXfUser extends Model +{ + + protected $connection = 'xenforo'; + protected $table = 'user'; + protected $primaryKey = 'user_id'; + public $timestamps = false; + public $incrementing = true; + + public function save(array $options = []) + { + return false; + } + + public function delete() + { + return false; + } + + public function update(array $attributes = [], array $options = []) + { + return false; + } + +} diff --git a/app/Models/MigrationUserPlan.php b/app/Models/MigrationUserPlan.php new file mode 100644 index 0000000..e31d701 --- /dev/null +++ b/app/Models/MigrationUserPlan.php @@ -0,0 +1,53 @@ +|MigrationUserPlan newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|MigrationUserPlan newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|MigrationUserPlan query() + * @property int $id + * @property int|null $wp_user_id + * @property int|null $xf_user_id + * @property string $match_type + * @property string|null $email + * @property string|null $wp_username + * @property string|null $xf_username + * @property string|null $note + * @property string $status + * @property int|null $user_id + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @method static \Illuminate\Database\Eloquent\Builder|MigrationUserPlan whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|MigrationUserPlan whereEmail($value) + * @method static \Illuminate\Database\Eloquent\Builder|MigrationUserPlan whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|MigrationUserPlan whereMatchType($value) + * @method static \Illuminate\Database\Eloquent\Builder|MigrationUserPlan whereNote($value) + * @method static \Illuminate\Database\Eloquent\Builder|MigrationUserPlan whereStatus($value) + * @method static \Illuminate\Database\Eloquent\Builder|MigrationUserPlan whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|MigrationUserPlan whereUserId($value) + * @method static \Illuminate\Database\Eloquent\Builder|MigrationUserPlan whereWpUserId($value) + * @method static \Illuminate\Database\Eloquent\Builder|MigrationUserPlan whereWpUsername($value) + * @method static \Illuminate\Database\Eloquent\Builder|MigrationUserPlan whereXfUserId($value) + * @method static \Illuminate\Database\Eloquent\Builder|MigrationUserPlan whereXfUsername($value) + * @property int $wp_password_bridge + * @method static \Illuminate\Database\Eloquent\Builder|MigrationUserPlan whereWpPasswordBridge($value) + * @mixin \Eloquent + */ +class MigrationUserPlan extends Model +{ + protected $table = 'migration_user_plan'; + + protected $fillable = [ + 'wp_user_id', + 'xf_user_id', + 'match_type', + 'email', + 'wp_username', + 'xf_username', + 'note', + 'status', + ]; +} diff --git a/app/Models/Modification.php b/app/Models/Modification.php index fd7054f..d2596d1 100644 --- a/app/Models/Modification.php +++ b/app/Models/Modification.php @@ -4,6 +4,22 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +/** + * @property int $id + * @property string $name + * @property string $slug + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @method static \Illuminate\Database\Eloquent\Builder|Modification newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Modification newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Modification query() + * @method static \Illuminate\Database\Eloquent\Builder|Modification whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Modification whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Modification whereName($value) + * @method static \Illuminate\Database\Eloquent\Builder|Modification whereSlug($value) + * @method static \Illuminate\Database\Eloquent\Builder|Modification whereUpdatedAt($value) + * @mixin \Eloquent + */ class Modification extends Model { protected $fillable = [ 'name', 'slug' ]; diff --git a/app/Models/News.php b/app/Models/News.php new file mode 100644 index 0000000..4eee9a6 --- /dev/null +++ b/app/Models/News.php @@ -0,0 +1,130 @@ + $gallery + * @property-read int|null $gallery_count + * @method static Builder|News inQueue(int $daysRejected = 7) + * @method static Builder|News newModelQuery() + * @method static Builder|News newQuery() + * @method static Builder|News onlyTrashed() + * @method static Builder|News published() + * @method static Builder|News query() + * @method static Builder|News whereCategoryId($value) + * @method static Builder|News whereCommentsThreadId($value) + * @method static Builder|News whereCreatedAt($value) + * @method static Builder|News whereDeletedAt($value) + * @method static Builder|News whereDescription($value) + * @method static Builder|News whereEntryId($value) + * @method static Builder|News whereId($value) + * @method static Builder|News whereRejectedAt($value) + * @method static Builder|News whereRelevantLink($value) + * @method static Builder|News whereSlug($value) + * @method static Builder|News whereStaffComment($value) + * @method static Builder|News whereState($value) + * @method static Builder|News whereTitle($value) + * @method static Builder|News whereUpdatedAt($value) + * @method static Builder|News whereUserId($value) + * @method static Builder|News whereYoutubeLink($value) + * @method static Builder|News withTrashed(bool $withTrashed = true) + * @method static Builder|News withoutTrashed() + * @mixin \Eloquent + */ +class News extends Model +{ + + use SoftDeletes, HasGallery, HasXenforoUserId; + + protected $table = 'news'; + + protected $fillable = [ + 'title', + 'slug', + 'description', + 'state', + 'category_id', + 'entry_id', + 'relevant_link', + 'youtube_link', + 'user_id', + 'comments_thread_id', + 'staff_comment', + 'rejected_at', + 'created_at' + ]; + + + protected $casts = [ + '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) ); + }); + } + + public function entry(): BelongsTo + { + return $this->belongsTo(Entry::class); + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + public function getDescriptionHtmlAttribute(): string + { + $converter = new GithubFlavoredMarkdownConverter([ + 'html_input' => 'strip', + 'allow_unsafe_links' => false, + ]); + + return $converter->convert($this->description)->getContent(); + } + + public function getYoutubeVideoId(): ?string { + if( !$this->youtube_link ) + return null; + + return EntryHelpers::getYoutubeVideoId( $this->youtube_link ); + } + +} diff --git a/app/Models/Platform.php b/app/Models/Platform.php index cac5bf3..d62581b 100644 --- a/app/Models/Platform.php +++ b/app/Models/Platform.php @@ -5,6 +5,30 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +/** + * @property int $id + * @property string $name + * @property string $slug + * @property string|null $short_name + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \Illuminate\Database\Eloquent\Collection $entries + * @property-read int|null $entries_count + * @property-read \Illuminate\Database\Eloquent\Collection $games + * @property-read int|null $games_count + * @method static \Illuminate\Database\Eloquent\Builder|Platform newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Platform newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Platform query() + * @method static \Illuminate\Database\Eloquent\Builder|Platform whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Platform whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Platform whereName($value) + * @method static \Illuminate\Database\Eloquent\Builder|Platform whereShortName($value) + * @method static \Illuminate\Database\Eloquent\Builder|Platform whereSlug($value) + * @method static \Illuminate\Database\Eloquent\Builder|Platform whereUpdatedAt($value) + * @property string|null $play_online_core + * @method static \Illuminate\Database\Eloquent\Builder|Platform wherePlayOnlineCore($value) + * @mixin \Eloquent + */ class Platform extends Model { diff --git a/app/Models/PlayOnlineSetting.php b/app/Models/PlayOnlineSetting.php new file mode 100644 index 0000000..95c0b98 --- /dev/null +++ b/app/Models/PlayOnlineSetting.php @@ -0,0 +1,45 @@ +|PlayOnlineSetting newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|PlayOnlineSetting newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|PlayOnlineSetting query() + * @method static \Illuminate\Database\Eloquent\Builder|PlayOnlineSetting whereCore($value) + * @method static \Illuminate\Database\Eloquent\Builder|PlayOnlineSetting whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|PlayOnlineSetting whereFileId($value) + * @method static \Illuminate\Database\Eloquent\Builder|PlayOnlineSetting whereThreads($value) + * @method static \Illuminate\Database\Eloquent\Builder|PlayOnlineSetting whereUpdatedAt($value) + * @mixin \Eloquent + */ +class PlayOnlineSetting extends Model +{ + protected $primaryKey = 'file_id'; + public $incrementing = false; + protected $keyType = 'int'; + + protected $fillable = [ + 'file_id', + 'core', + 'threads' + ]; + + protected $casts = [ + 'threads' => 'boolean', + ]; + + public function file(): BelongsTo + { + return $this->belongsTo(EntryFile::class, 'file_id'); + } +} diff --git a/app/Models/Status.php b/app/Models/Status.php index b6276a9..8dc7094 100644 --- a/app/Models/Status.php +++ b/app/Models/Status.php @@ -4,6 +4,22 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +/** + * @property int $id + * @property string $name + * @property string $slug + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @method static \Illuminate\Database\Eloquent\Builder|Status newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Status newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Status query() + * @method static \Illuminate\Database\Eloquent\Builder|Status whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Status whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Status whereName($value) + * @method static \Illuminate\Database\Eloquent\Builder|Status whereSlug($value) + * @method static \Illuminate\Database\Eloquent\Builder|Status whereUpdatedAt($value) + * @mixin \Eloquent + */ class Status extends Model { protected $fillable = ['name', 'slug']; diff --git a/app/Models/System.php b/app/Models/System.php new file mode 100644 index 0000000..65d86e3 --- /dev/null +++ b/app/Models/System.php @@ -0,0 +1,26 @@ +|System newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|System newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|System query() + * @method static \Illuminate\Database\Eloquent\Builder|System whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|System whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|System whereName($value) + * @method static \Illuminate\Database\Eloquent\Builder|System whereSlug($value) + * @method static \Illuminate\Database\Eloquent\Builder|System whereUpdatedAt($value) + * @mixin \Eloquent + */ +class System extends Model +{ + protected $fillable = ['name', 'slug']; +} diff --git a/app/Models/User.php b/app/Models/User.php index f6ba1d2..e46ea30 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,6 +10,31 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +/** + * @property int $id + * @property string $name + * @property string $email + * @property \Illuminate\Support\Carbon|null $email_verified_at + * @property string $password + * @property string|null $remember_token + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \Illuminate\Notifications\DatabaseNotificationCollection $notifications + * @property-read int|null $notifications_count + * @method static \Database\Factories\UserFactory factory($count = null, $state = []) + * @method static \Illuminate\Database\Eloquent\Builder|User newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|User newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|User query() + * @method static \Illuminate\Database\Eloquent\Builder|User whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereEmail($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereEmailVerifiedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereName($value) + * @method static \Illuminate\Database\Eloquent\Builder|User wherePassword($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereRememberToken($value) + * @method static \Illuminate\Database\Eloquent\Builder|User whereUpdatedAt($value) + * @mixin \Eloquent + */ #[Fillable(['name', 'email', 'password'])] #[Hidden(['password', 'remember_token'])] class User extends Authenticatable diff --git a/app/Policies/EntryReviewPolicy.php b/app/Policies/EntryReviewPolicy.php new file mode 100644 index 0000000..454f0b0 --- /dev/null +++ b/app/Policies/EntryReviewPolicy.php @@ -0,0 +1,36 @@ +_can( 'romhackplaza', 'view' ) ) + return true; + + return false; + } + + public function create(\App\Auth\XenForoUser $user, ?EntryReview $review = null ): bool + { + return $user->_can( 'romhackplaza', 'canSubmitEntry' ); + } + + public function update(\App\Auth\XenForoUser $user, ?EntryReview $review = null ): bool + { + // Staff editors + if( $user->_can('romhackplaza', 'canEditOthersEntries') ) + return true; + + // Author. + if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $review->user_id === $user->user_id ) + return true; + + return false; + } +} diff --git a/app/Policies/NewsPolicy.php b/app/Policies/NewsPolicy.php new file mode 100644 index 0000000..75cce62 --- /dev/null +++ b/app/Policies/NewsPolicy.php @@ -0,0 +1,165 @@ +_can( 'romhackplaza', 'view' ) ) + return true; + + return false; + } + + public function viewPending(User $user, News $news): bool + { + // Author. + if( $news->user_id === $user->user_id ) + return true; + + return $user->_can( 'romhackplaza', 'canModerateEntries' ); + } + + public function viewDraft(User $user, News $news): bool { + // Author. + if( $news->user_id === $user->user_id ) + return true; + + return $user->_can( 'romhackplaza', 'canSeeOthersDrafts' ); + } + + public function viewRejected(User $user, News $news): bool + { + // Author. + if( $news->user_id === $user->user_id ) + return true; + + return $user->_can( 'romhackplaza', 'canSeeRejectedEntries' ); + } + + public function viewHidden(User $user, News $news): bool + { + return $user->_can('romhackplaza', 'canSeeHiddenEntries' ); + } + + public function viewLocked(User $user, News $news): bool + { + // Author. + if( $news->user_id === $user->user_id ) + return true; + + return $user->_can('romhackplaza', 'canSeeLockedEntries' ); + } + + public function create(User $user, ?News $news = null ): bool + { + return $user->_can( 'romhackplaza', 'canSubmitEntry' ); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, News $news): bool + { + if( $news->state === 'published' ){ + + // Staff editors + if( $user->_can('romhackplaza', 'canEditOthersEntries') ) + return true; + + // Author. + if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $news->user_id === $user->user_id ) + return true; + + return false; + + } else if( $news->state === 'pending' ){ + + // Staff moderation. + if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can('romhackplaza', 'canModerateEntries') ) + return true; + + // Author. + if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $news->user_id === $user->user_id ) + return true; + + } else if( $news->state === 'draft' ){ + + // Staff. + if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeOthersDrafts' ) ) + return true; + + // Author. + if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $news->user_id === $user->user_id ) + return true; + + } else if( $news->state === 'rejected' ){ + + // Staff. + if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeRejectedEntries' ) ) + return true; + + // Author. + if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $news->user_id === $user->user_id ) + return true; + + } else if( $news->state === 'locked' ){ + + // Staff. + if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeLockedEntries' ) ) + return true; + + return false; + + } else if( $news->state === 'hidden' ){ + + // Staff. + if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeHiddenEntries' ) ) + return true; + + return false; + + } + + return false; + } + + public function skipQueue(User $user, ?News $news = null): bool + { + return $user->_can( 'romhackplaza', 'canSubmitEntryInPublished' ); + } + + public function updateComment(User $user, News $news): bool + { + return $user->_can('romhackplaza', 'canModerateEntries' ); + } + + public function manageButtonsInQueue(User $user, News $news): bool + { + if( $news->state === 'rejected' ){ + return $this->viewRejected( $user, $news ); + } + + return $user->_can('romhackplaza', 'canModerateEntries' ); + } + + public function approve(User $user, News $news): bool + { + return $user->_can('romhackplaza', 'canModerateEntries' ); + } + + public function reject(User $user, News $news): bool + { + return $user->_can('romhackplaza', 'canModerateEntries' ); + } + + public function moderate(User $user, News $news): bool + { + return $user->_can('romhackplaza', 'canModerateEntries' ); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7a1413e..04dd124 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,9 +5,12 @@ namespace App\Providers; use App\Auth\XenForoGuard; use App\Auth\XenForoUser; use App\Policies\TempFilePolicy; +use App\Proxy\VisitorProxy; use App\Services\TemporaryFileService; +use App\Support\XenForoCauserResolver; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; +use Spatie\Activitylog\Support\CauserResolver; class AppServiceProvider extends ServiceProvider { @@ -16,7 +19,7 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->bind(CauserResolver::class, XenForoCauserResolver::class ); } /** @@ -37,5 +40,10 @@ class AppServiceProvider extends ServiceProvider Gate::define('is-mod', function (XenForoUser $user) { return $user->is_moderator === 1; }); + + \View::composer('*', function ($view) { + $view->with('VISITOR', new VisitorProxy( \Auth::user() ) ); + }); + } } diff --git a/app/Proxy/VisitorProxy.php b/app/Proxy/VisitorProxy.php new file mode 100644 index 0000000..e1fffbc --- /dev/null +++ b/app/Proxy/VisitorProxy.php @@ -0,0 +1,43 @@ +currentVisitor = $user; + } + + public function __get( string $name ): mixed + { + return $this->currentVisitor?->$name; + } + + public function __invoke( int $userId ): ?XenForoUser + { + if( !isset( $this->users[$userId] ) ){ + $this->users[$userId] = app(XenforoService::class)->getXfUser($userId); + } + return $this->users[$userId]; + } + + public function loggedIn(): bool + { + return $this->currentVisitor !== null; + } + + public function guest(): bool + { + return $this->currentVisitor === null; + } +} diff --git a/app/Services/ActivityService.php b/app/Services/ActivityService.php new file mode 100644 index 0000000..eb8396c --- /dev/null +++ b/app/Services/ActivityService.php @@ -0,0 +1,257 @@ +merge($this->getEntries()); + } + if( in_array( 'news', $activities ) ) { + $c = $c->merge($this->getNews()); + } + if( in_array( 'messages', $activities ) ) { + $c = $c->merge($this->getMessages()); + } + if( in_array( 'threads', $activities ) ) { + $c = $c->merge($this->getThreads()); + } + if( in_array( 'clubs', $activities ) ) { + $c = $c->merge($this->getClubs()); + } + if( in_array( 'reviews', $activities ) ) { + $c = $c->merge($this->getReviews()); + } + + return $c->sortByDesc('date') + ->values() + ->take(30) + ->map(function(array $item){ + $obj = (object) $item; + $obj->date = Carbon::createFromTimestamp($obj->date); + return $obj; + }); + } + + private function formatEntry( Entry $entry ): array + { + return [ + 'type' => 'entry', + 'title' => $entry->complete_title ?? $entry->title, + 'url' => route('entries.show', ['section' => $entry->type, 'entry' => $entry]), + 'image' => $entry->main_image ? \Storage::url($entry->main_image) : null, + 'date' => $entry->created_at->timestamp, + 'author' => $entry->authors->pluck('name')->implode(', '), + 'user_id' => $entry->user_id, + 'badge' => EntryCard::ENTRY_TYPES_BADGE[$entry->type], + 'badge_class' => $entry->type, + 'excerpt' => $entry->description ? \Str::limit(strip_tags($entry->description), 80) : null, + 'meta' => $entry->getRealPlatform()?->name + ]; + } + + private function formatNews( News $news ): array + { + return [ + 'type' => 'news', + 'title' => $news->title, + 'url' => route('news.show', ['news' => $news]), + 'image' => $news->gallery()->first() ? \Storage::url($news->gallery()->first()->image) : null, + 'date' => $news->created_at->timestamp, + 'author' => null, + 'user_id' => $news->user_id, + 'badge' => 'News', + 'badge_class' => 'news', + 'excerpt' => $news->description ? \Str::limit(strip_tags($news->description), 80) : null, + 'meta' => $news->category?->name + ]; + } + + private function formatMessage( object $message ): array + { + return [ + 'type' => 'message', + 'title' => $message->title, + 'url' => xfRoute('threads/.' ) . $message->thread_id . '/post-' . $message->post_id, + 'image' => null, + 'date' => $message->post_date, + 'author' => null, + 'user_id' => $message->user_id, + 'badge' => 'Post', + 'badge_class' => 'message', + 'excerpt' => $message->message ? \Str::limit(strip_tags($message->message), 80) : null, + 'meta' => null + ]; + + } + + private function formatThread( object $thread ): array + { + return [ + 'type' => 'thread', + 'title' => $thread->title, + 'url' => xfRoute('threads/.' ) . $thread->thread_id, + 'image' => null, + 'date' => $thread->post_date, + 'author' => null, + 'user_id' => $thread->user_id, + 'badge' => 'Thread', + 'badge_class' => 'thread', + 'excerpt' => $thread->message ? \Str::limit(strip_tags($thread->message), 80) : null, + 'meta' => null + ]; + + } + + private function formatClub( object $club ): array + { + return [ + 'type' => 'club', + 'title' => $club->title, + 'url' => xfRoute('forums/.' ) . $club->node_id, + 'image' => null, // TODO: Remplacer par banner_date + 'date' => $club->club_creation_date, + 'author' => null, + 'user_id' => $club->user_id, + 'badge' => 'Club', + 'badge_class' => 'club', + 'excerpt' => $club->description ? \Str::limit(strip_tags($club->description), 80) : null, + 'meta' => null + ]; + } + + private function formatReview( EntryReview $review ): array + { + return [ + 'type' => 'review', + 'title' => $review->title, + 'url' => $review->entry()->exists() ? route('entries.show', ['section' => $review->entry->type, 'entry' => $review->entry]) : '', + 'image' => null, + 'date' => $review->created_at->timestamp, + 'author' => null, + 'user_id' => $review->user_id, + 'badge' => 'Review', + 'badge_class' => 'review', + 'excerpt' => $review->description ? \Str::limit(strip_tags($review->description), 80) : null, + 'meta' => $review->entry()->exists() ? ( $review->entry->complete_title ?? $review->entry->title ) : null, + ]; + } + + private function getEntries(): array + { + return Cache::remember('activity_entries', self::CACHE_ENTRIES, function() { + return Entry::published() + ->with(['authors', 'game.platform']) + ->latest('created_at') + ->limit(self::ITEMS_PER_TYPE) + ->get() + ->map($this->formatEntry(...)) + ->toArray(); + }); + } + + private function getNews(): array + { + return Cache::remember('activity_news', self::CACHE_NEWS, function() { + return News::published() + ->with('gallery') + ->latest('created_at') + ->limit(self::ITEMS_PER_TYPE) + ->get() + ->map($this->formatNews(...)) + ->toArray(); + }); + } + + private function getMessages(): array + { + return Cache::remember('activity_messages', self::CACHE_MESSAGES, function() { + return DB::connection('xenforo') + ->table('post') + ->join('user', 'post.user_id', '=', 'user.user_id') + ->join('thread', 'post.thread_id', '=', 'thread.thread_id') + ->where('post.message_state', 'visible') + ->where('thread.first_post_id', '!=', 'post.post_id') + ->orderByDesc('post.post_date') + ->limit(self::ITEMS_PER_TYPE) + ->select([ + 'thread.title', 'thread.thread_id', 'post.post_id', 'post.post_date', + 'post.user_id', 'post.message' + ]) + ->get() + ->map($this->formatMessage(...)) + ->toArray(); + }); + } + + private function getThreads(): array + { + return Cache::remember('activity_threads', self::CACHE_THREADS, function() { + return DB::connection('xenforo') + ->table('thread') + ->join('user', 'thread.user_id', '=', 'user.user_id') + ->join('post', 'thread.first_post_id', '=', 'post.post_id') + ->where('thread.discussion_state', 'visible') + ->where('thread.discussion_type', '!=', 'redirect' ) + ->orderByDesc('thread.post_date') + ->limit(self::ITEMS_PER_TYPE) + ->select([ + 'thread.title', 'thread.thread_id', 'thread.post_date', 'thread.user_id', + 'post.message' + ]) + ->get() + ->map($this->formatThread(...)) + ->toArray(); + }); + } + + private function getClubs(): array + { + return Cache::remember('activity_clubs', self::CACHE_CLUBS, function() { + return DB::connection('xenforo') + ->table('club') + ->where('club_state', 'visible') + ->orderByDesc('club_creation_date') + ->limit(self::ITEMS_PER_TYPE) + ->select([ + 'club.title', 'club.description', 'club.node_id', + 'club.club_creation_date', 'club.user_id' + ]) + ->get() + ->map($this->formatClub(...)) + ->toArray(); + }); + } + + private function getReviews(): array + { + return Cache::remember('activity_reviews', self::CACHE_REVIEWS, function() { + return EntryReview::with(['entry']) + ->latest('created_at') + ->limit(self::ITEMS_PER_TYPE) + ->get() + ->map($this->formatReview(...)) + ->toArray(); + }); + } +} diff --git a/app/Services/FileServersService.php b/app/Services/FileServersService.php index 42d82c0..d798e5f 100644 --- a/app/Services/FileServersService.php +++ b/app/Services/FileServersService.php @@ -3,10 +3,13 @@ namespace App\Services; use App\Models\EntryFile; +use App\Models\LogXfUser; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; class FileServersService { @@ -68,15 +71,38 @@ class FileServersService { * @param EntryFile $file * @return string */ - public function getDownloadFileUrl( EntryFile $file ): string + public function getDownloadFileUrl( EntryFile $file, bool $countDownload = true ): string { $serverKey = $this->getEntryFileServerKey( $file ); $url = $this->servers[$serverKey]['download'] ?? "#"; if( $url === "#" ) return $url; - return $url . "&" . http_build_query( [ 'filename' => $file->filename, 'filepath' => $file->filepath ] ); + $args = [ 'filename' => $file->filename, 'filepath' => $file->filepath ]; + if( !$countDownload ) + $args['count_download'] = false; + return $url . "&" . http_build_query( $args ); + + } + + + public function getArchiveExplorerUrl( EntryFile $file ): ?string + { + if( !Str::endsWith( $file->filename, ['zip', 'rar', '7z'] ) ) + return null; + + $serverKey = $this->getEntryFileServerKey( $file ); + $url = $this->servers[$serverKey]['file_explorer'] ?? "#"; + + if( $url === "#" ) + return null; + + $args = [ 'filename' => $file->filename, 'filepath' => $file->filepath, + 'zeus' => $this->generateZeusToken(\Auth::user()->user_id ?? 0, $this->servers[$serverKey]['base_url'], 'Fileexplorer') + ]; + + return $url . "&" . http_build_query( $args ); } /** @@ -110,7 +136,6 @@ class FileServersService { 'filename' => $filename, 'current_chunk' => $currentChunk, 'total_chunks' => $totalChunks, - // TODO : Must replace User ID 'zeus' => $this->generateZeusToken( \Auth::user()->user_id, $server['base_url'], "Uploadchunk" ), ]); @@ -129,4 +154,45 @@ class FileServersService { return $data; } + public function deleteFile( + string $filePath, + string $fileName, + int $userId + ): bool { + + foreach( $this->servers as $serverKey => $server ){ + + $token = $this->generateZeusToken( $userId, $server['base_url'], "Deletefile" ); + + $response = Http::asForm()->withHeaders([]) + ->post( $server['delete_file'], [ + 'filepath' => $filePath, + 'filename' => $fileName, + 'zeus' => $token, + ]); + + if (!$response->successful()) { + throw new \RuntimeException( $response->body() ); + } + + $data = $response->json(); + if( isset( $data['status'] ) && $data['status'] === 'deleted' ){ + continue; + } else { + return false; + } + } + + activity('entry-file') + ->causedBy(LogXfUser::find($userId)) + ->withProperties([ + 'filepath' => $filePath, + 'filename' => $fileName, + ]) + ->event('file_deletion') + ->log('File deleted'); + + return true; + } + } diff --git a/app/Services/NewsService.php b/app/Services/NewsService.php new file mode 100644 index 0000000..1bd4a75 --- /dev/null +++ b/app/Services/NewsService.php @@ -0,0 +1,194 @@ +request = $request; + $user_id = \Auth::user()->user_id; + + $news = DB::transaction(function () use ($user_id) { + + // STEP 1 : Slug + $slug = EntryHelpers::uniqueSlug( $this->request->input('title'), News::class ); + + $fields = [ + 'title' => $this->request->input('title'), + 'slug' => $slug, + 'category_id' => $this->request->input('category'), + 'description' => $this->request->input('description'), + 'state' => $this->request->input('submit-state'), + 'entry_id' => $this->request->input('entry_id'), + 'relevant_link' => $this->request->input('release_site'), + 'youtube_link' => $this->request->input('youtube_link'), + 'user_id' => $user_id, + ]; + + $news = News::create( $fields ); + + // STEP 3 : Prepare Gallery images. + $this->Step3a_PrepareGalleryImages( $news ); + + return $news; + + }); + + $this->Step3b_SaveGalleryImages( $news ); + + $this->Step4_CreateCommentsThread( $news ); + + return $news; + } + + private function Step3a_PrepareGalleryImages(News $news): void + { + foreach( $this->request->input('gallery', [] ) ?? [] as $i => $imagePath ){ + $news->gallery()->create([ + 'image' => $imagePath, + 'order' => $i + ]); + } + } + + private function Step3b_SaveGalleryImages(News $news): void + { + foreach ( $news->gallery ?? [] as $galleryItem ) { + $newPath = 'news/gallery-images/' . $news->id . '/' . basename($galleryItem->image); + + if( !Storage::disk('public')->move($galleryItem->image, $newPath) ) + continue; + + $galleryItem->update(['image' => $newPath]); + } + } + + private function Step4_CreateCommentsThread( News $news ): void + { + if( $news->state !== 'published' ) + return; + + if( !$news->comments_thread_id ) + CreateXenForoCommentsThread::dispatch( $news ); + } + + public function editNews(Request $request, News $news): News + { + $this->request = $request; + $this->news = $news; + + if( \Auth::user()->can('moderate', $news) ){ + $user_id = $this->request->input('owner_user_id'); + } else { + $user_id = \Auth::user()->user_id; + } + + $galleryPaths = []; + + $news = DB::transaction(function () use ($user_id, &$galleryPaths) { + + // STEP 1 : Refresh slug. + if( $this->request->input('title') !== $this->news->title ){ + $this->news->slug = EntryHelpers::uniqueSlug( $this->request->input('title'), News::class, $this->news->id ); + } + + $fields = [ + 'title' => $this->request->input('title'), + 'slug' => $this->news->slug, + 'category_id' => $this->request->input('category'), + 'description' => $this->request->input('description'), + 'state' => $this->request->input('submit-state'), + 'entry_id' => $this->request->input('entry_id'), + 'relevant_link' => $this->request->input('release_site'), + 'youtube_link' => $this->request->input('youtube_link'), + 'user_id' => $user_id, + ]; + + if( \Auth::user()->can('moderate', $this->news) ){ + $fields['staff_comment'] = $this->request->input('staff_comment'); + $fields['comments_thread_id'] = $this->request->input('comments_thread_id'); + } + + $this->news->update( $fields ); + + $galleryPaths = $this->eStep3a_UpdateGalleryImages(); + + return $this->news; + + }); + + $this->eStep3b_UpdateGalleryImages( $galleryPaths ); + + $this->step4_CreateCommentsThread( $news ); + + return $news; + + } + + private function eStep3a_UpdateGalleryImages(): array + { + $requestGallery = $this->request->input('gallery', [] ) ?? []; + $existingGalleryPaths = $this->news->gallery->pluck('image')->toArray(); + + $needDeletion = array_diff( $existingGalleryPaths, $requestGallery ); + + if( !empty( $needDeletion ) ){ + $this->news->gallery()->whereIn('image', $needDeletion )->delete(); + } + + $needAddition = array_diff( $requestGallery, $existingGalleryPaths ); + $images = []; + foreach( $needAddition as $imagePath ){ + $images[] = $this->news->gallery()->create([ + 'image' => $imagePath, + ]); + } + + foreach( $requestGallery as $i => $imagePath ){ + $this->news->gallery()->where('image', $imagePath )->update(['order' => $i]); + } + + return [ 'addition' => $images, 'deletion' => $needDeletion ]; + } + + private function eStep3b_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 = 'news/gallery-images/' . $this->news->id . '/' . basename( $galleryItem->image ); + + if( !Storage::disk('public')->move( $galleryItem->image, $newPath ) ){ + continue; + } + + $galleryItem->update(['image' => $newPath]); + } + } + +} diff --git a/app/Services/ReviewsService.php b/app/Services/ReviewsService.php new file mode 100644 index 0000000..fa7234c --- /dev/null +++ b/app/Services/ReviewsService.php @@ -0,0 +1,46 @@ +request = $request; + $this->entry = $entry; + $user_id = \Auth::user()->user_id; + + $review = DB::transaction(function () use ($user_id) { + + $fields = [ + 'entry_id' => $this->entry->id, + 'title' => $this->request->input('title'), + 'rating'=> $this->request->input('rating'), + 'description' => $this->request->input('description'), + 'user_id' => $user_id, + ]; + + $review = EntryReview::create($fields); + + return $review; + + }); + + return $review; + } +} diff --git a/app/Services/SubmissionsService.php b/app/Services/SubmissionsService.php index c397316..bef15f7 100644 --- a/app/Services/SubmissionsService.php +++ b/app/Services/SubmissionsService.php @@ -4,19 +4,23 @@ namespace App\Services; use App\Exceptions\SubmissionException; use App\Helpers\EntryHelpers; +use App\Helpers\PlayOnlineHelpers; use App\Helpers\XenForoHelpers; use App\Http\Requests\StoreEntryRequest; use App\Jobs\CreateXenForoCommentsThread; +use App\Jobs\DeleteFile; use App\Models\Author; +use App\Models\Category; use App\Models\Entry; use App\Models\EntryFile; -use App\Models\EntryGallery; +use App\Models\Gallery; use App\Models\EntryHash; use App\Models\Game; use App\Models\Genre; use App\Models\Language; use App\Models\Modification; use App\Models\Platform; +use App\Models\System; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -59,8 +63,10 @@ class SubmissionsService { if( $files === [] ) return []; + $service = app(FileServersService::class); + return array_map( - function( string $uuid ) { + function( string $uuid ) use ($service) { $file = EntryFile::where('file_uuid', $uuid)->first(); if( $file ) @@ -74,8 +80,15 @@ class SubmissionsService { 'error' => null, 'uuid' => $uuid, 'state' => $file->state, + 'file_explorer' => $service->getArchiveExplorerUrl($file), + 'file_explorer_files' => null, + 'download_url' => $service->getDownloadFileUrl($file, false), + 'can_be_online_patched' => EntryHelpers::enableOnlinePatcherBasedOnExtension($file['filename']), 'meta_online_patcher' => $file->online_patcher, 'meta_secondary_online_patcher' => $file->secondary_online_patcher, + 'meta_play_online' => $file->playOnlineSetting()->exists() ? true : false, + 'meta_play_online_core' => $file->playOnlineSetting()->exists() ? $file->playOnlineSetting->core : '', + 'meta_play_online_threads' => $file->playOnlineSetting()->exists() ? $file->playOnlineSetting->threads : false, ]; $file = Cache::get("uploaded_file_{$uuid}"); @@ -90,8 +103,15 @@ class SubmissionsService { 'error' => null, 'uuid' => $uuid, 'state' => $file['state'], + 'file_explorer' => null, + 'file_explorer_files' => null, + 'download_url' => null, + 'can_be_online_patched' => EntryHelpers::enableOnlinePatcherBasedOnExtension($file['filename']), 'meta_online_patcher' => false, 'meta_secondary_online_patcher' => false, + 'meta_play_online' => false, + 'meta_play_online_core' => null, + 'meta_play_online_threads' => false ]; return null; @@ -118,11 +138,7 @@ class SubmissionsService { $entry = DB::transaction(function () use ( $user_id ) { // STEP 2 : Create game. - - $gameId = null; - if( section_must_be( ['romhacks', 'translations'], $this->section ) ){ - $gameId = $this->Step2_CreateAndReturnGameId(); - } + $gameId = $this->Step2_CreateAndReturnGameId(); // STEP 3 : Create Complete title. $completeTitle = $this->Step3_BuildCompleteTitle( $gameId ); @@ -132,7 +148,7 @@ class SubmissionsService { if( section_must_be( 'translations', $this->section ) && !$this->request->input('entry_title') ){ - $entryTitle = Game::find($gameId)->name; + $entryTitle = Game::find($gameId)->name ?? ""; } else { $entryTitle = $this->request->input('entry_title'); } @@ -149,6 +165,7 @@ class SubmissionsService { 'main_image' => $this->request->input('main-image'), 'state' => $this->request->input('submit-state'), 'game_id' => $gameId, + 'platform_id' => $this->request->input('platform_only_id'), 'status_id' => $this->request->input('status'), 'version' => $this->request->input('version'), 'release_date' => $this->request->input('release-date'), @@ -157,6 +174,7 @@ class SubmissionsService { 'youtube_link' => $this->request->input('youtube_video'), 'user_id' => $user_id, 'complete_title' => $completeTitle, + 'level_id' => $this->request->input('level') ]; $entry = Entry::create( $fields ); @@ -165,19 +183,28 @@ class SubmissionsService { $this->Step7_SaveEntryFiles( $entry ); // STEP 8 : Save hashes. - $this->Step8_SaveHashes( $entry->id ); + if( section_must_be( ['translations', 'romhacks' ], $this->section ) ) + $this->Step8_SaveHashes( $entry->id ); // STEP 9 : Save Authors. $this->Step9_SaveAuthors( $entry ); // STEP 10 : Save Modifications. - if( section_must_be( 'romhacks', $this->section ) ){ + if( section_must_be( ['romhacks','lua-scripts'], $this->section ) ){ $this->Step10_SaveRomhacksModifications( $entry ); } + if( section_must_be( 'utilities', $this->section ) ){ + $this->Step10_SaveUtilitiesSystems( $entry ); + } // STEP 11 : Save Languages $this->Step11_SaveLanguages( $entry ); + // STEP 11.5 : Save Categories + if( section_must_be( ['utilities', 'documents'], $this->section ) ) { + $this->Step11_5_SaveCategories($entry); + } + // STEP 12 : Prepare Gallery images. $this->Step12a_PrepareGalleryImages( $entry ); @@ -207,6 +234,10 @@ class SubmissionsService { */ private function Step2_CreateAndReturnGameId(): ?int { + $mode = $this->request->input('game_selection_mode', 'game'); + if( $mode !== 'game' ) + return null; + // Already existing game. if( $this->request->input('game_id') ) return $this->request->input('game_id'); @@ -261,9 +292,9 @@ class SubmissionsService { 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', 'translations', 'homebrew', 'lua-scripts', 'tutorials'], $this->section ) ) { + if( section_must_be(['romhacks', 'translations', 'homebrew', 'lua-scripts'], $this->section ) ) { // TODO: Add single platform ID compatibility. - $fields['platform_name'] = $gameId ? Game::find( $gameId )->platform->name : null; + $fields['platform_name'] = $gameId ? Game::find( $gameId )->platform->name : Platform::find( $this->request->input('platform_only_id') )?->name ?? null; } return EntryHelpers::buildCompleteTitle( $this->section, $fields ); @@ -285,15 +316,20 @@ class SubmissionsService { 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. If it's an edition, delete all your new files and retry." ); + throw new SubmissionException( "File {$uuid} has expired. Please delete all your files and retry. If it's an edition, delete all the new files and retry." ); - $onlinePatcher = (bool)($metadataArray[$uuid]['online_patcher'] ?? false); - if( !$onlinePatcher ) - $onlinePatcher = EntryHelpers::enableOnlinePatcherBasedOnExtension( $fileData['filename'] ); + if( section_must_be( [ 'romhacks', 'translations' ], $entry->type ) ) { + $onlinePatcher = (bool)($metadataArray[$uuid]['online_patcher'] ?? false); + if (!$onlinePatcher) + $onlinePatcher = EntryHelpers::enableOnlinePatcherBasedOnExtension($fileData['filename']); - $secondaryOnlinePatcher = (bool)($metadataArray[$uuid]['secondary_online_patcher'] ?? false); + $secondaryOnlinePatcher = (bool)($metadataArray[$uuid]['secondary_online_patcher'] ?? false); + } else { + $onlinePatcher = false; + $secondaryOnlinePatcher = false; + } - EntryFile::create([ + $file = EntryFile::create([ 'entry_id' => $entry->id, 'file_uuid' => $uuid, 'filename' => $fileData['filename'], @@ -306,6 +342,26 @@ class SubmissionsService { 'secondary_online_patcher' => $secondaryOnlinePatcher, ]); + if( section_must_be( ['romhacks', 'translations', 'homebrew'], $entry->type ) ) { + $playOnline = (bool)($metadataArray[$uuid]['play_online'] ?? false); + $playOnlineCore = $metadataArray[$uuid]['play_online_core'] ?? null; + $playOnlineThreads = (bool)($metadataArray[$uuid]['play_online_threads'] ?? false); + + if (!$playOnline && $entry->getRealPlatform()?->play_online_core !== null) { + $playOnline = true; + $playOnlineCore = $entry->getRealPlatform()?->play_online_core; + } + + if ($playOnline) { + $file->playOnlineSetting()->updateOrCreate( + ['file_id' => $file->id], + [ + 'core' => $playOnlineCore, + 'threads' => $playOnlineThreads, + ] + ); + } + } } } @@ -381,6 +437,24 @@ class SubmissionsService { } } + /** + * @param Entry $entry + * + * @return void + * @throws SubmissionException + */ + private function Step10_SaveUtilitiesSystems( Entry $entry ): void + { + // TODO: Replace by edit version + + foreach ( $this->request->input('systems', [] ) ?? [] as $systemId ) { + $system = System::find( $systemId ); + if( !$system ) + throw new SubmissionException( "System {$systemId} does not exist." ); + $entry->systems()->attach( $system->id ); + } + } + /** * @param Entry $entry * @@ -400,12 +474,31 @@ class SubmissionsService { } + /** + * @param Entry $entry + * + * @return void + * @throws SubmissionException + */ + private function Step11_5_SaveCategories( Entry $entry ): void + { + // TODO: Replace by edit version. + + foreach ( $this->request->input('categories', [] ) ?? [] as $categoryId ) { + $category = Category::find( $categoryId ); + if( !$category ) + throw new SubmissionException( "Category {$categoryId} does not exist." ); + $entry->categories()->attach( $category->id ); + } + + } + private function Step12a_PrepareGalleryImages( Entry $entry ): void { - foreach ( $this->request->input('gallery', [] ) ?? [] as $imagePath ) { - EntryGallery::create([ - 'entry_id' => $entry->id, + foreach ( $this->request->input('gallery', [] ) ?? [] as $i => $imagePath ) { + $entry->gallery()->create([ 'image' => $imagePath, + 'order' => $i ]); } } @@ -464,9 +557,7 @@ class SubmissionsService { // STEP 2: Create game if different. $gameId = null; - if( section_must_be( ['romhacks', 'translations' ], $this->section ) ){ - $gameId = $this->eStep2_VerifyCreateAndEditGameId(); - } + $gameId = $this->eStep2_VerifyCreateAndEditGameId(); // STEP 3: Recreate complete title and refresh slug if needed. $completeTitle = $this->Step3_BuildCompleteTitle( $gameId ); @@ -494,6 +585,7 @@ class SubmissionsService { 'main_image' => $this->request->input('main-image'), 'state' => $this->request->input('submit-state'), 'game_id' => $gameId, + 'platform_id' => $this->request->input('platform_only_id'), 'status_id' => $this->request->input('status'), 'version' => $this->request->input('version'), 'release_date' => $this->request->input('release-date'), @@ -502,12 +594,20 @@ class SubmissionsService { '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') ?? false, + 'level_id' => $this->request->input('level'), ]; if( \Auth::user()->can('moderate', $this->entry) ){ $fields['staff_comment'] = $this->request->input('staff_comment'); + $fields['featured'] = $this->request->input('featured') ?? false; + if( $fields['featured'] == true && $this->entry->featured_at === null ) + $fields['featured_at'] = now(); + if( $fields['featured'] == false ) + $fields['featured_at'] = null; + $fields['comments_thread_id'] = $this->request->input('comments_thread_id'); + $refresh_created_at = $this->request->input('refresh_created_at') ?? false; + if( $refresh_created_at ) + $fields['created_at'] = now(); } $this->entry->update( $fields ); @@ -516,19 +616,27 @@ class SubmissionsService { $this->eStep6_UpdateEntryFiles( $this->entry->id ); // STEP 7: Update hashes. - $this->eStep7_UpdateHashes( $this->entry->id ); + if( section_must_be( ['translations', 'romhacks' ], $this->section ) ) + $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 ) ) { + if( section_must_be( ['romhacks', 'lua-scripts'], $this->section ) ) { $this->eStep9_UpdateRomhacksModifications(); } + if( section_must_be( 'utilities', $this->section ) ) { + $this->eStep9_UpdateUtilitiesSystems(); + } // STEP 10: Update Languages. $this->eStep10_UpdateLanguages(); + // STEP 10.5 : Update categories + if( section_must_be( ['utilities', 'documents'], $this->section ) ) + $this->eStep10_5_UpdateCategories(); + // STEP 11: Prepare new gallery images and prepare deletion of others ones. $galleryPaths = $this->eStep11a_UpdateGalleryImages(); @@ -558,8 +666,14 @@ class SubmissionsService { /** * @throws SubmissionException */ - private function eStep2_VerifyCreateAndEditGameId(): int + private function eStep2_VerifyCreateAndEditGameId(): ?int { + + $mode = $this->request->input('game_selection_mode', 'game'); + if ($mode !== 'game') { + return null; + } + // Already existing game. if( $this->request->input('game_id') ){ @@ -603,7 +717,11 @@ class SubmissionsService { $needDeletion = array_diff( $existingUuids, $requestUuids ); if( !empty( $needDeletion ) ){ - EntryFile::where('entry_id', $entryId)->whereIn('file_uuid', $needDeletion)->delete(); + $userId = \Auth::user()->user_id; + EntryFile::where('entry_id', $entryId)->whereIn('file_uuid', $needDeletion)->whereNot('state', 'archived')->get()->each( function ( $f ) use ( $userId ) { + DeleteFile::dispatch( $f->filepath, $f->filename, $userId); + }); + EntryFile::where('entry_id', $entryId)->whereIn('file_uuid', $needDeletion)->whereNot('state', 'archived')->delete(); } $needAddition = array_diff( $requestUuids, $existingUuids ); @@ -617,15 +735,45 @@ class SubmissionsService { foreach( $stateMap as $uuid => $state ){ - $onlinePatcher = (bool)($metadataArray[$uuid]['online_patcher'] ?? false); - $secondaryOnlinePatcher = (bool)($metadataArray[$uuid]['secondary_online_patcher'] ?? false); + if( section_must_be( ['romhacks', 'translations'], $this->entry->type ) ) { + $onlinePatcher = (bool)($metadataArray[$uuid]['online_patcher'] ?? false); + $secondaryOnlinePatcher = (bool)($metadataArray[$uuid]['secondary_online_patcher'] ?? false); + } else { + $onlinePatcher = false; + $secondaryOnlinePatcher = false; + } - EntryFile::where('file_uuid', $uuid)->where('entry_id', $entryId)->where('state', '!=', 'archived') - ->update([ - 'state' => $state, - 'online_patcher' => $onlinePatcher, - 'secondary_online_patcher' => $secondaryOnlinePatcher, - ]); + $entryFile = EntryFile::where('file_uuid', $uuid)->where('entry_id', $entryId)->where('state', '!=', 'archived')->first(); + if( !$entryFile ) + continue; + + $entryFile->update([ + 'state' => $state, + 'online_patcher' => $onlinePatcher, + 'secondary_online_patcher' => $secondaryOnlinePatcher, + ]); + + if( section_must_be( ['romhacks', 'translations', 'homebrew'], $this->entry->type ) ) { + + $playOnline = (bool)($metadataArray[$uuid]['play_online'] ?? false); + $playOnlineCore = $metadataArray[$uuid]['play_online_core'] ?? null; + $playOnlineThreads = (bool)($metadataArray[$uuid]['play_online_threads'] ?? false); + + if ($playOnline) { + if ($playOnlineCore === null || !in_array($playOnlineCore, PlayOnlineHelpers::getCoreLists())) + $playOnlineCore = $this->entry->getRealPlatform()->play_online_core ? $this->entry->getRealPlatform()->play_online_core : 'nes'; + + $entryFile->playOnlineSetting()->updateOrCreate( + ['file_id' => $entryFile->id], + [ + 'core' => $playOnlineCore, + 'threads' => $playOnlineThreads, + ] + ); + } else { + $entryFile->playOnlineSetting()->delete(); + } + } } } @@ -721,6 +869,27 @@ class SubmissionsService { } + /** + * @return void + * @throws SubmissionException + */ + private function eStep9_UpdateUtilitiesSystems(): void + { + $requestSystems = $this->request->input('systems', [] ) ?? []; + if( !empty( $requestSystems ) ){ + $valid = System::whereIn( 'id', $requestSystems )->pluck('id')->toArray(); + + if( count( $valid ) !== count( $requestSystems ) ){ + throw new SubmissionException( "One of the systems doesn't exist." ); + } + + + } + + $this->entry->systems()->sync( $requestSystems ); + + } + /** * @return void * @throws SubmissionException @@ -739,6 +908,24 @@ class SubmissionsService { $this->entry->languages()->sync( $requestLanguages ); } + /** + * @return void + * @throws SubmissionException + */ + private function eStep10_5_UpdateCategories(): void + { + $requestCategories = $this->request->input('categories', [] ) ?? []; + if( !empty( $requestCategories ) ){ + $valid = Category::whereIn( 'id', $requestCategories )->pluck('id')->toArray(); + if( count( $valid ) !== count( $requestCategories ) ){ + throw new SubmissionException( "One of the categories doesn't exist." ); + } + + } + + $this->entry->categories()->sync( $requestCategories ); + } + private function eStep11a_UpdateGalleryImages(): array { $requestGallery = $this->request->input('gallery', [] ) ?? []; @@ -747,18 +934,21 @@ class SubmissionsService { $needDeletion = array_diff( $existingGalleryPaths, $requestGallery ); if( !empty( $needDeletion ) ){ - EntryGallery::where('entry_id', $this->entry->id)->whereIn('image', $needDeletion )->delete(); + $this->entry->gallery()->whereIn('image', $needDeletion )->delete(); } $needAddition = array_diff( $requestGallery, $existingGalleryPaths ); $images = []; foreach( $needAddition as $imagePath ){ - $images[] = EntryGallery::create([ - 'entry_id' => $this->entry->id, + $images[] = $this->entry->gallery()->create([ 'image' => $imagePath, ]); } + foreach ( $requestGallery as $i => $imagePath ){ + $this->entry->gallery()->where('image', $imagePath )->update(['order' => $i]); + } + return [ 'addition' => $images, 'deletion' => $needDeletion ]; } diff --git a/app/Services/XenforoApiService.php b/app/Services/XenforoApiService.php index f1ce2ef..7de52ff 100644 --- a/app/Services/XenforoApiService.php +++ b/app/Services/XenforoApiService.php @@ -24,7 +24,7 @@ class XenforoApiService { */ private function get(string $endpoint, ?int $customUserId = null ): mixed { - $response = Http::withHeaders([ + $response = Http::timeout(30)->withHeaders([ 'XF-Api-Key' => $this->apiKey, 'XF-Api-User' => $customUserId ?? $this->superUserId, ])->get("{$this->apiUrl}/{$endpoint}"); @@ -37,7 +37,7 @@ class XenforoApiService { private function post(string $endpoint, ?int $customUserId = null, array $data = [] ): mixed { - $response = Http::withHeaders([ + $response = Http::timeout(30)->withHeaders([ 'XF-Api-Key' => $this->apiKey, 'XF-Api-User' => $customUserId ?? $this->superUserId, ])->post("{$this->apiUrl}/{$endpoint}", $data); @@ -50,7 +50,7 @@ class XenforoApiService { private function delete(string $endpoint, ?int $customUserId = null, array $data = [] ): mixed { - $response = Http::withHeaders([ + $response = Http::timeout(30)->withHeaders([ 'XF-Api-Key' => $this->apiKey, 'XF-Api-User' => $customUserId ?? $this->superUserId, ])->delete("{$this->apiUrl}/{$endpoint}", $data); @@ -75,7 +75,7 @@ class XenforoApiService { public function markAllNotificationsRead(int $userId): void { Cache::forget("xf_alerts_{$userId}"); - $this->post("alerts/marl-all", $userId ); + $this->post("alerts/mark-all", $userId ); } public function getConversations(int $userId): mixed @@ -87,12 +87,13 @@ class XenforoApiService { 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] ); - + $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 + public function createCommentsThread( Entry|News $entry ): bool { if( !$entry->comments_thread_id || $entry->comments_thread_id <= 0 ){ $data = [ @@ -134,6 +135,14 @@ class XenforoApiService { return $response['success'] ?? false; } + public function featuredRequest( Entry $entry ): bool + { + $response = $this->post("romhackplaza_entry/featured", data: [ + 'entry_id' => $entry->id, 'user_id' => $entry->user_id, 'entry_title' => $entry->complete_title ?? $entry->title, + ]); + return $response['success'] ?? false; + } + public function deleteThreadWithEntry(int $threadId): bool { return (bool) $this->delete( "threads/{$threadId}", data: ['reason' => "Deletion with entry." ] ); @@ -144,4 +153,13 @@ class XenforoApiService { return (bool) $this->post("threads/{$threadId}/undelete" ); } + public function _migrateUser(array $data): array + { + $response = $this->post("migrate/user", data: $data ); + if( !$response || !$response['success'] ) + return [false,false]; + + return [ $response['user_id'] ?? false, $response['password_set'] ?? false ]; + } + } diff --git a/app/Services/XenforoService.php b/app/Services/XenforoService.php index fc8d0d1..47ca18b 100644 --- a/app/Services/XenforoService.php +++ b/app/Services/XenforoService.php @@ -195,9 +195,9 @@ class XenforoService { } - private function hashCSRFToken( string $token ): string + private function hashCSRFToken( string $token, int $timestamp ): string { - return hash_hmac('md5', $token . time(), config('app.xf_salt') ); + return hash_hmac('md5', $token . $timestamp, config('app.xf_salt') ); } public function getCSRFToken(): string { @@ -207,6 +207,28 @@ class XenforoService { Cookie::queue('xf_csrf', $token, 0, '/', config('session.domain'), 0, false, false ); } - return time() . ',' . $this->hashCSRFToken($token); + $timestamp = time(); + return $timestamp . ',' . $this->hashCSRFToken($token, $timestamp); + } + public function verifyCSRFToken( string $requestToken ): bool + { + $token = Cookie::get('xf_csrf'); + if( !$token ){ + return false; + } + + try { + [$timestamp, $hash] = explode(',', $requestToken); + } catch (\Throwable $th) { + return false; + } + + $timestamp = intval($timestamp); + $currentTimestamp = time(); + + if( abs( $currentTimestamp - $timestamp ) > 3600 ) + return false; + + return $hash === $this->hashCSRFToken($token, $timestamp); } } diff --git a/app/Support/XenForoCauserResolver.php b/app/Support/XenForoCauserResolver.php new file mode 100644 index 0000000..0e9e749 --- /dev/null +++ b/app/Support/XenForoCauserResolver.php @@ -0,0 +1,22 @@ +getAuthIdentifier() ){ + return LogXfUser::find($user->getAuthIdentifier()); + } + + return null; + } +} diff --git a/app/Traits/HasGallery.php b/app/Traits/HasGallery.php new file mode 100644 index 0000000..57f0ed0 --- /dev/null +++ b/app/Traits/HasGallery.php @@ -0,0 +1,14 @@ +morphMany(Gallery::class, 'galleryable')->orderBy('order')->orderBy('id'); + } +} diff --git a/app/Traits/HasXenforoUserId.php b/app/Traits/HasXenforoUserId.php new file mode 100644 index 0000000..5e47a35 --- /dev/null +++ b/app/Traits/HasXenforoUserId.php @@ -0,0 +1,15 @@ +getXfUser( $this->user_id ); + } +} diff --git a/app/View/Components/CategorySelector.php b/app/View/Components/CategorySelector.php new file mode 100644 index 0000000..9e0f412 --- /dev/null +++ b/app/View/Components/CategorySelector.php @@ -0,0 +1,42 @@ +categories = Category::query() + ->where(function ($query) { + $query->whereJsonContains('restricted_to', $this->section) + ->orWhereNull('restricted_to'); + }) + ->orderBy('name') + ->get(); + } + + /** + * Get the view / contents that represent the component. + */ + public function render(): View|Closure|string + { + return $this->news === true ? view('components.news-category-selector' ) : view('components.category-selector'); + } +} diff --git a/app/View/Components/DatabaseFilterWithModeSearch.php b/app/View/Components/DatabaseFilterWithModeSearch.php index d8212cc..4066970 100644 --- a/app/View/Components/DatabaseFilterWithModeSearch.php +++ b/app/View/Components/DatabaseFilterWithModeSearch.php @@ -17,7 +17,7 @@ class DatabaseFilterWithModeSearch extends Component public string $model, public string $modeModel, public string $selectedMode, - + public string $searchModel, public string $idProperty = 'id', public string $nameProperty = 'name', ) diff --git a/app/View/Components/DatabaseFilterWithoutModeSearch.php b/app/View/Components/DatabaseFilterWithoutModeSearch.php index 6c8e09e..7932236 100644 --- a/app/View/Components/DatabaseFilterWithoutModeSearch.php +++ b/app/View/Components/DatabaseFilterWithoutModeSearch.php @@ -15,7 +15,7 @@ class DatabaseFilterWithoutModeSearch extends Component public string $title, public $items, public string $model, - + public string $searchModel, public string $idProperty = 'id', public string $nameProperty = 'name', ) diff --git a/app/View/Components/ErrorBlock.php b/app/View/Components/ErrorBlock.php index 52cc2cb..f0bc778 100644 --- a/app/View/Components/ErrorBlock.php +++ b/app/View/Components/ErrorBlock.php @@ -17,6 +17,10 @@ class ErrorBlock extends Component 'page-not-allowed' => [ 'icon' => 'shield-ban', 'message' => "You do not have permission to access this page.\nRequired permission: %s" + ], + 'user-state-not-valid' => [ + 'icon' => 'shield-ban', + 'message' => "You do not have permission to access this page.\nYour user profile is incomplete: %s\nGo back to the forum for more details." ] ]; diff --git a/app/View/Components/NewsCard.php b/app/View/Components/NewsCard.php new file mode 100644 index 0000000..fdb81dc --- /dev/null +++ b/app/View/Components/NewsCard.php @@ -0,0 +1,31 @@ +entry === null ) - $this->entry = 'App\Models\Entry'; + if( $this->entry === null ){ + if( $news ) + $this->entry = 'App\Models\News'; + else + $this->entry = 'App\Models\Entry'; + } } public function availableStates(): array diff --git a/app/helpers.php b/app/helpers.php index fb7eaff..ec3fad8 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -1,5 +1,7 @@ style_variation ?? 'default'; + } + return \Illuminate\Support\Facades\Cookie::get('xf_style_variation', 'default'); + } +} - function databaseRoute( array $params = [] ): string +if( !function_exists( 'databaseRoute' ) ) { + + function databaseRoute(array $params = []): string { $defaults = [ 'types' => [], @@ -41,6 +52,11 @@ if( !function_exists( 'databaseRoute' ) ){ 'languagesMode' => 'or', 'modifications' => [], 'modificationsMode' => 'or', + 'categories' => [], + 'categoriesMode' => 'or', + 'systems' => [], + 'systemsMode' => 'or', + 'levels' => [], 'sort' => 'created_at', 'dir' => 'desc', 's' => '' @@ -48,9 +64,9 @@ if( !function_exists( 'databaseRoute' ) ){ $query = array_filter( array_merge($defaults, $params), - fn($v,$k) => match(true){ + fn($v, $k) => match (true) { is_array($v) => !empty($v), - in_array($k, ['authorsMode', 'languagesMode', 'modificationsMode']) => $v !== 'or', + in_array($k, ['authorsMode', 'languagesMode', 'modificationsMode', 'categoriesMode', 'systemsMode']) => $v !== 'or', $k === 'sort' => $v !== 'created_at', $k === 'dir' => $v !== 'desc', default => $v !== '', @@ -58,6 +74,57 @@ if( !function_exists( 'databaseRoute' ) ){ ARRAY_FILTER_USE_BOTH ); - return route('entries.index', $query ); + return route('entries.index', $query); + } + +} + +if( !function_exists( 'newsRoute' ) ){ + function newsRoute(array $params = []): string + { + $defaults = [ + 'categories' => [], + 'sort' => 'created_at', + 'dir' => 'desc', + 's' => '' + ]; + + $query = array_filter( + array_merge($defaults, $params), + fn($v, $k) => match (true) { + is_array($v) => !empty($v), + $k === 'sort' => $v !== 'created_at', + $k === 'dir' => $v !== 'desc', + default => $v !== '', + }, + ARRAY_FILTER_USE_BOTH + ); + + return route('news.index', $query); + } +} + +if( !function_exists('reviewsRoute') ){ + function reviewsRoute( array $params = [] ): string + { + $defaults = [ + 'entryId' => null, + 'rating' => null, + 'sort' => 'created_at', + 'dir' => 'desc', + ]; + + $query = array_filter( + array_merge($defaults, $params), + fn($v,$k) => match(true){ + is_array($v) => !empty($v), + $k === 'sort' => $v !== 'created_at', + $k === 'dir' => $v !== 'desc', + default => $v !== '', + }, + ARRAY_FILTER_USE_BOTH + ); + + return route('reviews.index', $query ); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 3928513..36f39a2 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -13,7 +13,7 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - $middleware->encryptCookies(except: ['xf_session','xf_user','xf_csrf','theme','entries_per_page']); + $middleware->encryptCookies(except: ['xf_session','xf_user','xf_csrf','xf_style_variation','activity_filters']); $middleware->alias([ 'xf.auth' => \App\Http\Middleware\CheckXenForoPermissions::class, ]); @@ -21,6 +21,7 @@ return Application::configure(basePath: dirname(__DIR__)) if( $request->is('manage*')) abort(403); }); + $middleware->append(\App\Http\Middleware\CheckXenForoUserState::class); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index dbd334c..69a58be 100644 --- a/composer.json +++ b/composer.json @@ -7,17 +7,22 @@ "license": "MIT", "require": { "php": "^8.4", + "ext-pdo": "*", + "ext-simplexml": "*", + "ext-xmlreader": "*", "diglactic/laravel-breadcrumbs": "^10.1", "filament/filament": "^5.6", "laravel/framework": "^13.7", "laravel/tinker": "^3.0", + "league/commonmark": "^2.8", + "league/html-to-markdown": "^5.1", "livewire/livewire": "^4.3", "predis/predis": "^3.4", - "ext-xmlreader": "*", - "ext-simplexml": "*" + "spatie/laravel-activitylog": "^5.0" }, "require-dev": { "barryvdh/laravel-debugbar": "^4.2", + "barryvdh/laravel-ide-helper": "^3.7", "fakerphp/faker": "^1.23", "larastan/larastan": "^3.9", "laravel/pail": "^1.2.5", diff --git a/composer.lock b/composer.lock index 953b57e..a4a2542 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a1c836758aaea45cc09c0c7786ea366f", + "content-hash": "ea9258b1759d46665d2487b066c686ee", "packages": [ { "name": "blade-ui-kit/blade-heroicons", @@ -2967,6 +2967,95 @@ }, "time": "2026-01-23T15:30:45+00:00" }, + { + "name": "league/html-to-markdown", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/html-to-markdown.git", + "reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/0b4066eede55c48f38bcee4fb8f0aa85654390fd", + "reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-xml": "*", + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "mikehaertl/php-shellcommand": "^1.1.0", + "phpstan/phpstan": "^1.8.8", + "phpunit/phpunit": "^8.5 || ^9.2", + "scrutinizer/ocular": "^1.6", + "unleashedtech/php-coding-standard": "^2.7 || ^3.0", + "vimeo/psalm": "^4.22 || ^5.0" + }, + "bin": [ + "bin/html-to-markdown" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\HTMLToMarkdown\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + }, + { + "name": "Nick Cernis", + "email": "nick@cern.is", + "homepage": "http://modernnerd.net", + "role": "Original Author" + } + ], + "description": "An HTML-to-markdown conversion helper for PHP", + "homepage": "https://github.com/thephpleague/html-to-markdown", + "keywords": [ + "html", + "markdown" + ], + "support": { + "issues": "https://github.com/thephpleague/html-to-markdown/issues", + "source": "https://github.com/thephpleague/html-to-markdown/tree/5.1.1" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/html-to-markdown", + "type": "tidelift" + } + ], + "time": "2023-07-12T21:21:09+00:00" + }, { "name": "league/mime-type-detection", "version": "1.16.0", @@ -5273,6 +5362,99 @@ ], "time": "2024-05-17T09:06:10+00:00" }, + { + "name": "spatie/laravel-activitylog", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-activitylog.git", + "reference": "0e00fe74fd071cc572a045459f6d4c9de33130bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/0e00fe74fd071cc572a045459f6d4c9de33130bd", + "reference": "0e00fe74fd071cc572a045459f6d4c9de33130bd", + "shasum": "" + }, + "require": { + "illuminate/config": "^12.0 || ^13.0", + "illuminate/database": "^12.0 || ^13.0", + "illuminate/support": "^12.0 || ^13.0", + "php": "^8.4", + "spatie/laravel-package-tools": "^1.6.3" + }, + "require-dev": { + "ext-json": "*", + "larastan/larastan": "^3.0", + "laravel/pint": "^1.29", + "orchestra/testbench": "^10.0 || ^11.0", + "pestphp/pest": "^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Activitylog\\ActivitylogServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Activitylog\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Sebastian De Deyne", + "email": "sebastian@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Tom Witkowski", + "email": "dev.gummibeer@gmail.com", + "homepage": "https://gummibeer.de", + "role": "Developer" + } + ], + "description": "A very simple activity logger to monitor the users of your website or application", + "homepage": "https://github.com/spatie/activitylog", + "keywords": [ + "activity", + "laravel", + "log", + "spatie", + "user" + ], + "support": { + "issues": "https://github.com/spatie/laravel-activitylog/issues", + "source": "https://github.com/spatie/laravel-activitylog/tree/5.0.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-03-25T10:04:54+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.93.0", @@ -8316,6 +8498,301 @@ ], "time": "2026-04-20T13:31:29+00:00" }, + { + "name": "barryvdh/laravel-ide-helper", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-ide-helper.git", + "reference": "ad7e37676f1ff985d55ef1b6b96a0c0a40f2609a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-ide-helper/zipball/ad7e37676f1ff985d55ef1b6b96a0c0a40f2609a", + "reference": "ad7e37676f1ff985d55ef1b6b96a0c0a40f2609a", + "shasum": "" + }, + "require": { + "barryvdh/reflection-docblock": "^2.4", + "composer/class-map-generator": "^1.0", + "ext-json": "*", + "illuminate/console": "^11.15 || ^12 || ^13.0", + "illuminate/database": "^11.15 || ^12 || ^13.0", + "illuminate/filesystem": "^11.15 || ^12 || ^13.0", + "illuminate/support": "^11.15 || ^12 || ^13.0", + "php": "^8.2" + }, + "require-dev": { + "ext-pdo_sqlite": "*", + "friendsofphp/php-cs-fixer": "^3", + "illuminate/config": "^11.15 || ^12 || ^13.0", + "illuminate/view": "^11.15 || ^12 || ^13.0", + "larastan/larastan": "^3.1", + "mockery/mockery": "^1.4", + "orchestra/testbench": "^9.2 || ^10 || ^11.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5 || ^11.5.3 || ^12.5.12", + "spatie/phpunit-snapshot-assertions": "^4 || ^5", + "vlucas/phpdotenv": "^5" + }, + "suggest": { + "illuminate/events": "Required for automatic helper generation (^6|^7|^8|^9|^10|^11)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Barryvdh\\LaravelIdeHelper\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "Laravel IDE Helper, generates correct PHPDocs for all Facade classes, to improve auto-completion.", + "keywords": [ + "autocomplete", + "codeintel", + "dev", + "helper", + "ide", + "laravel", + "netbeans", + "phpdoc", + "phpstorm", + "sublime" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-ide-helper/issues", + "source": "https://github.com/barryvdh/laravel-ide-helper/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2026-03-17T14:12:51+00:00" + }, + { + "name": "barryvdh/reflection-docblock", + "version": "v2.4.1", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/ReflectionDocBlock.git", + "reference": "4f5ba70c30c81f2ce03a16a9965832cfcc31ed3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/ReflectionDocBlock/zipball/4f5ba70c30c81f2ce03a16a9965832cfcc31ed3b", + "reference": "4f5ba70c30c81f2ce03a16a9965832cfcc31ed3b", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.14|^9" + }, + "suggest": { + "dflydev/markdown": "~1.0", + "erusev/parsedown": "~1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.3.x-dev" + } + }, + "autoload": { + "psr-0": { + "Barryvdh": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "mike.vanriel@naenius.com" + } + ], + "support": { + "source": "https://github.com/barryvdh/ReflectionDocBlock/tree/v2.4.1" + }, + "time": "2026-03-05T20:09:01+00:00" + }, + { + "name": "composer/class-map-generator", + "version": "1.7.3", + "source": { + "type": "git", + "url": "https://github.com/composer/class-map-generator.git", + "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/86d8208fc3c649a3a999daf1a63c25201be2990f", + "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f", + "shasum": "" + }, + "require": { + "composer/pcre": "^2.1 || ^3.1", + "php": "^7.2 || ^8.0", + "symfony/finder": "^4.4 || ^5.3 || ^6 || ^7 || ^8" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-deprecation-rules": "^1 || ^2", + "phpstan/phpstan-phpunit": "^1 || ^2", + "phpstan/phpstan-strict-rules": "^1.1 || ^2", + "phpunit/phpunit": "^8", + "symfony/filesystem": "^5.4 || ^6 || ^7 || ^8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\ClassMapGenerator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Utilities to scan PHP code and generate class maps.", + "keywords": [ + "classmap" + ], + "support": { + "issues": "https://github.com/composer/class-map-generator/issues", + "source": "https://github.com/composer/class-map-generator/tree/1.7.3" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-05-05T09:17:07+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, { "name": "fakerphp/faker", "version": "v1.24.1", @@ -10945,7 +11422,10 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.4" + "php": "^8.4", + "ext-pdo": "*", + "ext-simplexml": "*", + "ext-xmlreader": "*" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/config/database.php b/config/database.php index c3c8ad2..dfaa57d 100644 --- a/config/database.php +++ b/config/database.php @@ -129,6 +129,39 @@ return [ 'database' => storage_path('hashes.sqlite'), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + ], + + 'discord' => [ + 'driver' => 'sqlite', + 'database' => env('DISCORD_DB_PATH'), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + ], + + // Migration. + + 'old_wp' => [ + 'driver' => 'mysql', + 'host' => env('MIG_WP_DB_HOST'), + 'port' => env('MIG_WP_DB_PORT', '3306'), + 'database' => env('MIG_WP_DB_NAME'), + 'username' => env('MIG_WP_DB_USERNAME'), + 'password' => env('MIG_WP_DB_PASSWORD'), + 'charset' => env('MIG_WP_DB_CHARSET', 'utf8mb4'), + 'prefix' => 'wp_', + 'collation' => 'utf8mb4_unicode_ci', + ], + + 'old_xf' => [ + 'driver' => 'mysql', + 'host' => env('MIG_XF_DB_HOST'), + 'port' => env('MIG_XF_DB_PORT', '3306'), + 'database' => env('MIG_XF_DB_NAME'), + 'username' => env('MIG_XF_DB_USERNAME'), + 'password' => env('MIG_XF_DB_PASSWORD'), + 'charset' => env('MIG_XF_DB_CHARSET', 'utf8mb4'), + 'prefix' => 'xf_', + 'collation' => 'utf8mb4_unicode_ci', ] ], diff --git a/config/menu.php b/config/menu.php index 18f3fc1..2e18180 100644 --- a/config/menu.php +++ b/config/menu.php @@ -18,16 +18,21 @@ return [ 'icon' => 'database', 'route' => 'entries.index' ], + [ + 'name' => 'News', + 'icon' => 'newspaper', + 'route' => 'news.index' + ], [ 'name' => "Submissions queue", 'icon' => 'gavel', 'route' => 'queue.index' ], [ - 'name' => "My Drafts", + 'name' => "My drafts", 'icon' => 'scissors', 'route' => 'entries.drafts', - 'auth' => true + 'requires_auth' => true ] ] ], @@ -68,12 +73,7 @@ return [ [ 'name' => 'ROM Hasher', 'icon' => 'hash', - 'route' => 'home' - ], - [ - 'name' => 'ROM Checker', - 'icon' => 'check', - 'route' => 'home' + 'route' => 'tools.hash' ] ] ], 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 0c6a41f..4cac30a 100644 --- a/database/migrations/2026_05_10_072747_create_entries_table.php +++ b/database/migrations/2026_05_10_072747_create_entries_table.php @@ -23,7 +23,6 @@ return new class extends Migration $table->longText( 'description' ); $table->string( 'main_image' )->nullable(); - // TODO: Replace it by state. $table->enum( 'state', [ 'draft', 'pending', 'published', 'locked', 'hidden' ] )->default('draft'); $table->boolean('featured')->default(false); diff --git a/database/migrations/2026_06_09_122425_create_category_table.php b/database/migrations/2026_06_09_122425_create_category_table.php new file mode 100644 index 0000000..4fe1bd3 --- /dev/null +++ b/database/migrations/2026_06_09_122425_create_category_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('categories'); + } +}; diff --git a/database/migrations/2026_06_09_122655_create_os_table.php b/database/migrations/2026_06_09_122655_create_os_table.php new file mode 100644 index 0000000..7e119f4 --- /dev/null +++ b/database/migrations/2026_06_09_122655_create_os_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('systems'); + } +}; diff --git a/database/migrations/2026_06_09_122817_create_level_table.php b/database/migrations/2026_06_09_122817_create_level_table.php new file mode 100644 index 0000000..13d321f --- /dev/null +++ b/database/migrations/2026_06_09_122817_create_level_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('levels'); + } +}; diff --git a/database/migrations/2026_06_09_123242_add_entry_level_id_field.php b/database/migrations/2026_06_09_123242_add_entry_level_id_field.php new file mode 100644 index 0000000..a263bb9 --- /dev/null +++ b/database/migrations/2026_06_09_123242_add_entry_level_id_field.php @@ -0,0 +1,28 @@ +foreignId('level_id')->nullable()->constrained('levels')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('entries', function (Blueprint $table) { + $table->dropColumn('level_id'); + }); + } +}; diff --git a/database/migrations/2026_06_09_123458_create_entry_systems_table.php b/database/migrations/2026_06_09_123458_create_entry_systems_table.php new file mode 100644 index 0000000..a4e3fbf --- /dev/null +++ b/database/migrations/2026_06_09_123458_create_entry_systems_table.php @@ -0,0 +1,28 @@ +foreignId('entry_id')->constrained('entries')->cascadeOnDelete(); + $table->foreignId('system_id')->constrained('systems')->cascadeOnDelete(); + $table->primary(['entry_id', 'system_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('entry_systems'); + } +}; diff --git a/database/migrations/2026_06_09_123533_create_entry_categories_table.php b/database/migrations/2026_06_09_123533_create_entry_categories_table.php new file mode 100644 index 0000000..fce71b1 --- /dev/null +++ b/database/migrations/2026_06_09_123533_create_entry_categories_table.php @@ -0,0 +1,28 @@ +foreignId('entry_id')->constrained('entries')->cascadeOnDelete(); + $table->foreignId('category_id')->constrained('categories')->cascadeOnDelete(); + $table->primary(['entry_id', 'category_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('entry_categories'); + } +}; diff --git a/database/migrations/2026_06_09_124832_add_restricted_field_to_categories.php b/database/migrations/2026_06_09_124832_add_restricted_field_to_categories.php new file mode 100644 index 0000000..f63e4a6 --- /dev/null +++ b/database/migrations/2026_06_09_124832_add_restricted_field_to_categories.php @@ -0,0 +1,28 @@ +json('restricted_to')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('categories', function (Blueprint $table) { + $table->dropColumn('restricted_to'); + }); + } +}; diff --git a/database/migrations/2026_06_10_084936_make_entry_galleries_polymorphic.php b/database/migrations/2026_06_10_084936_make_entry_galleries_polymorphic.php new file mode 100644 index 0000000..391bf73 --- /dev/null +++ b/database/migrations/2026_06_10_084936_make_entry_galleries_polymorphic.php @@ -0,0 +1,40 @@ +string('galleryable_type')->default('App\\Models\\Entry') + ->after('id'); + + $table->renameColumn('entry_id', 'galleryable_id'); + + $table->index(['galleryable_type', 'galleryable_id']); + + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('galleries', function (Blueprint $table) { + $table->dropColumn('galleryable_type'); + $table->renameColumn('galleryable_id', 'entry_id'); + $table->dropIndex(['galleryable_type', 'galleryable_id']); + }); + + Schema::rename('galleries', 'entry_galleries'); + } +}; diff --git a/database/migrations/2026_06_10_090320_create_news_table.php b/database/migrations/2026_06_10_090320_create_news_table.php new file mode 100644 index 0000000..28ea81a --- /dev/null +++ b/database/migrations/2026_06_10_090320_create_news_table.php @@ -0,0 +1,42 @@ +id(); + $table->string('title'); + $table->string('slug')->unique(); + $table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete(); + $table->longText('description'); + $table->enum('state', [ 'draft', 'pending', 'published', 'locked', 'rejected', 'hidden' ] )->default('draft'); + + $table->foreignId('entry_id')->nullable()->constrained('entries')->nullOnDelete(); + $table->string('relevant_link', 500 )->nullable(); + $table->string('youtube_link', 500 )->nullable(); + + $table->unsignedBigInteger( 'user_id' ); // xf_user_id + $table->unsignedBigInteger( 'comments_thread_id' )->nullable(); // xf_thread + + $table->softDeletes(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('news'); + } +}; diff --git a/database/migrations/2026_06_10_091105_add_fields_for_queue_to_news.php b/database/migrations/2026_06_10_091105_add_fields_for_queue_to_news.php new file mode 100644 index 0000000..0e3f9b1 --- /dev/null +++ b/database/migrations/2026_06_10_091105_add_fields_for_queue_to_news.php @@ -0,0 +1,29 @@ +text("staff_comment")->nullable()->after("state"); + $table->timestamp("rejected_at")->nullable()->after("staff_comment"); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('news', function (Blueprint $table) { + $table->dropColumn(['staff_comment', 'rejected_at']); + }); + } +}; diff --git a/database/migrations/2026_06_11_155053_add_order_to_galleries_table.php b/database/migrations/2026_06_11_155053_add_order_to_galleries_table.php new file mode 100644 index 0000000..3a770f6 --- /dev/null +++ b/database/migrations/2026_06_11_155053_add_order_to_galleries_table.php @@ -0,0 +1,28 @@ +unsignedSmallInteger('order')->default(0)->after('image'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('galleries', function (Blueprint $table) { + $table->dropColumn('order'); + }); + } +}; diff --git a/database/migrations/2026_06_13_210606_add_featured_at_field_to_entries.php b/database/migrations/2026_06_13_210606_add_featured_at_field_to_entries.php new file mode 100644 index 0000000..53af415 --- /dev/null +++ b/database/migrations/2026_06_13_210606_add_featured_at_field_to_entries.php @@ -0,0 +1,28 @@ +dateTime('featured_at')->nullable()->after('featured'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('entries', function (Blueprint $table) { + $table->dropColumn('featured_at'); + }); + } +}; diff --git a/database/migrations/2026_06_14_163648_create_play_online_settings_table.php b/database/migrations/2026_06_14_163648_create_play_online_settings_table.php new file mode 100644 index 0000000..d0a4e6d --- /dev/null +++ b/database/migrations/2026_06_14_163648_create_play_online_settings_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('file_id')->primary(); + $table->foreign('file_id') + ->references('id') + ->on('entry_files') + ->onDelete('cascade'); + + $table->string('core', 30); + $table->boolean('threads')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('play_online_settings'); + } +}; diff --git a/database/migrations/2026_06_14_174906_add_default_core_play_online_for_platforms.php b/database/migrations/2026_06_14_174906_add_default_core_play_online_for_platforms.php new file mode 100644 index 0000000..8ae5710 --- /dev/null +++ b/database/migrations/2026_06_14_174906_add_default_core_play_online_for_platforms.php @@ -0,0 +1,28 @@ +string('play_online_core')->nullable()->default(null); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('platforms', function (Blueprint $table) { + $table->dropColumn('play_online_core'); + }); + } +}; diff --git a/database/migrations/2026_06_16_100941_create_activity_log_table.php b/database/migrations/2026_06_16_100941_create_activity_log_table.php new file mode 100644 index 0000000..5c17c24 --- /dev/null +++ b/database/migrations/2026_06_16_100941_create_activity_log_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('log_name')->nullable()->index(); + $table->text('description'); + $table->nullableMorphs('subject', 'subject'); + $table->string('event')->nullable(); + $table->nullableMorphs('causer', 'causer'); + $table->json('attribute_changes')->nullable(); + $table->json('properties')->nullable(); + $table->timestamps(); + }); + } +}; diff --git a/database/migrations/2026_06_16_122812_add_download_field_to_entry_files.php b/database/migrations/2026_06_16_122812_add_download_field_to_entry_files.php new file mode 100644 index 0000000..9c241e1 --- /dev/null +++ b/database/migrations/2026_06_16_122812_add_download_field_to_entry_files.php @@ -0,0 +1,28 @@ +unsignedBigInteger('download_count')->default(0); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('entry_files', function (Blueprint $table) { + $table->dropColumn('download_count'); + }); + } +}; diff --git a/database/migrations/2026_06_17_114641_create_reviews_table.php b/database/migrations/2026_06_17_114641_create_reviews_table.php new file mode 100644 index 0000000..7ad9e1a --- /dev/null +++ b/database/migrations/2026_06_17_114641_create_reviews_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('entry_id')->constrained()->cascadeOnDelete(); + $table->string('title', 255); + $table->integer('rating'); + $table->text('description'); + $table->unsignedBigInteger( 'user_id' ); // xf_user_id + $table->softDeletes(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('entry_reviews'); + } +}; diff --git a/database/migrations/2026_06_18_084700_create_migration_user_plan_table.php b/database/migrations/2026_06_18_084700_create_migration_user_plan_table.php new file mode 100644 index 0000000..a6f18a3 --- /dev/null +++ b/database/migrations/2026_06_18_084700_create_migration_user_plan_table.php @@ -0,0 +1,39 @@ +id(); + $table->unsignedBigInteger('wp_user_id')->nullable(); + $table->unsignedBigInteger('xf_user_id')->nullable(); + $table->string('match_type'); + $table->string('email')->nullable(); + $table->string('wp_username')->nullable(); + $table->string('xf_username')->nullable(); + $table->text('note')->nullable(); + $table->string('status')->default('pending'); + $table->unsignedBigInteger('user_id')->nullable(); + $table->timestamps(); + + $table->index('match_type'); + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('migration_user_plan'); + } +}; diff --git a/database/migrations/2026_06_19_080416_create_migration_settings_table.php b/database/migrations/2026_06_19_080416_create_migration_settings_table.php new file mode 100644 index 0000000..1296602 --- /dev/null +++ b/database/migrations/2026_06_19_080416_create_migration_settings_table.php @@ -0,0 +1,28 @@ +string('key')->primary(); + $table->json('value'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('migration_settings'); + } +}; diff --git a/database/migrations/2026_06_19_125237_create_migrations_logs_table.php b/database/migrations/2026_06_19_125237_create_migrations_logs_table.php new file mode 100644 index 0000000..10f3306 --- /dev/null +++ b/database/migrations/2026_06_19_125237_create_migrations_logs_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('source_system'); + $table->string('source_table'); + $table->unsignedBigInteger('source_id'); + $table->string('target_table'); + $table->unsignedBigInteger('target_id'); + $table->string('status')->default('pending'); + $table->dateTime('migrated_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('migrations_logs'); + } +}; diff --git a/database/migrations/2026_06_19_134939_alter_migration_user_plan_table.php b/database/migrations/2026_06_19_134939_alter_migration_user_plan_table.php new file mode 100644 index 0000000..1c7e8b3 --- /dev/null +++ b/database/migrations/2026_06_19_134939_alter_migration_user_plan_table.php @@ -0,0 +1,28 @@ +boolean('wp_password_bridge')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('migration_user_plan', function (Blueprint $table) { + $table->dropColumn('wp_password_bridge'); + }); + } +}; diff --git a/database/migrations/2026_06_21_085615_create_migration_game_plan_table.php b/database/migrations/2026_06_21_085615_create_migration_game_plan_table.php new file mode 100644 index 0000000..069f637 --- /dev/null +++ b/database/migrations/2026_06_21_085615_create_migration_game_plan_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('wp_game_id'); + $table->unsignedBigInteger('wp_platform_id'); + $table->unsignedBigInteger('game_id')->nullable(); + $table->unsignedBigInteger('wp_genre_id')->nullable(); + $table->unsignedInteger('post_count')->default(0); + $table->boolean('genre_conflict')->default(false); + $table->timestamps(); + + $table->unique(['wp_game_id', 'wp_platform_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('migration_game_plan'); + } +}; diff --git a/database/migrations/2026_06_22_132647_delete_gallery_constraint.php b/database/migrations/2026_06_22_132647_delete_gallery_constraint.php new file mode 100644 index 0000000..37da73c --- /dev/null +++ b/database/migrations/2026_06_22_132647_delete_gallery_constraint.php @@ -0,0 +1,26 @@ +dropForeign('entry_galleries_entry_id_foreign'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // + } +}; diff --git a/database/migrations/discord/2026_06_12_130031_create_actions_table.php b/database/migrations/discord/2026_06_12_130031_create_actions_table.php new file mode 100644 index 0000000..7715239 --- /dev/null +++ b/database/migrations/discord/2026_06_12_130031_create_actions_table.php @@ -0,0 +1,29 @@ +create('actions', function (Blueprint $table) { + $table->id(); + $table->string('action'); + $table->json('data'); + $table->boolean('done')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::connection('discord')->dropIfExists('actions'); + } +}; diff --git a/database/migrations/discord/2026_06_12_131701_add_fields_to_actions.php b/database/migrations/discord/2026_06_12_131701_add_fields_to_actions.php new file mode 100644 index 0000000..d2da0dd --- /dev/null +++ b/database/migrations/discord/2026_06_12_131701_add_fields_to_actions.php @@ -0,0 +1,28 @@ +table('actions', function (Blueprint $table) { + $table->string('errors')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::connection('discord')->table('actions', function (Blueprint $table) { + $table->dropColumn('errors'); + }); + } +}; diff --git a/database/schema/mariadb-schema.sql b/database/schema/mariadb-schema.sql new file mode 100644 index 0000000..457e03a --- /dev/null +++ b/database/schema/mariadb-schema.sql @@ -0,0 +1,634 @@ +/*M!999999\- enable the sandbox mode */ +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*M!100616 SET @OLD_NOTE_VERBOSITY=@@NOTE_VERBOSITY, NOTE_VERBOSITY=0 */; +DROP TABLE IF EXISTS `activity_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `activity_log` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `log_name` varchar(255) DEFAULT NULL, + `description` text NOT NULL, + `subject_type` varchar(255) DEFAULT NULL, + `subject_id` bigint(20) unsigned DEFAULT NULL, + `event` varchar(255) DEFAULT NULL, + `causer_type` varchar(255) DEFAULT NULL, + `causer_id` bigint(20) unsigned DEFAULT NULL, + `attribute_changes` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`attribute_changes`)), + `properties` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`properties`)), + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `subject` (`subject_type`,`subject_id`), + KEY `causer` (`causer_type`,`causer_id`), + KEY `activity_log_log_name_index` (`log_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `authors`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `authors` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `slug` varchar(255) NOT NULL, + `website` varchar(500) DEFAULT NULL, + `user_id` int(10) unsigned DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `authors_slug_unique` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `cache`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `cache` ( + `key` varchar(255) NOT NULL, + `value` mediumtext NOT NULL, + `expiration` bigint(20) NOT NULL, + PRIMARY KEY (`key`), + KEY `cache_expiration_index` (`expiration`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `cache_locks`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `cache_locks` ( + `key` varchar(255) NOT NULL, + `owner` varchar(255) NOT NULL, + `expiration` bigint(20) NOT NULL, + PRIMARY KEY (`key`), + KEY `cache_locks_expiration_index` (`expiration`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `categories`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `categories` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `slug` varchar(255) NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + `restricted_to` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`restricted_to`)), + PRIMARY KEY (`id`), + UNIQUE KEY `categories_slug_unique` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `entries`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `entries` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `type` enum('translations','romhacks','homebrew','utilities','documents','lua-scripts','tutorials') NOT NULL, + `title` varchar(255) DEFAULT NULL, + `slug` varchar(255) DEFAULT NULL, + `description` longtext DEFAULT NULL, + `main_image` varchar(255) DEFAULT NULL, + `state` enum('draft','pending','published','locked','rejected','hidden') NOT NULL DEFAULT 'draft', + `staff_comment` text DEFAULT NULL, + `rejected_at` timestamp NULL DEFAULT NULL, + `featured` tinyint(1) NOT NULL DEFAULT 0, + `featured_at` datetime DEFAULT NULL, + `game_id` bigint(20) unsigned DEFAULT NULL, + `platform_id` bigint(20) unsigned DEFAULT NULL, + `status_id` bigint(20) unsigned DEFAULT NULL, + `version` varchar(50) DEFAULT NULL, + `release_date` date DEFAULT NULL, + `staff_credits` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`staff_credits`)), + `relevant_link` varchar(500) DEFAULT NULL, + `youtube_link` varchar(500) DEFAULT NULL, + `user_id` bigint(20) unsigned NOT NULL, + `comments_thread_id` bigint(20) unsigned DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + `complete_title` varchar(255) DEFAULT NULL, + `deleted_at` timestamp NULL DEFAULT NULL, + `level_id` bigint(20) unsigned DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `entries_slug_unique` (`slug`), + KEY `entries_game_id_foreign` (`game_id`), + KEY `entries_platform_id_foreign` (`platform_id`), + KEY `entries_status_id_foreign` (`status_id`), + KEY `entries_type_state_game_id_platform_id_status_id_index` (`type`,`state`,`game_id`,`platform_id`,`status_id`), + KEY `entries_level_id_foreign` (`level_id`), + CONSTRAINT `entries_game_id_foreign` FOREIGN KEY (`game_id`) REFERENCES `games` (`id`) ON DELETE SET NULL, + CONSTRAINT `entries_level_id_foreign` FOREIGN KEY (`level_id`) REFERENCES `levels` (`id`) ON DELETE SET NULL, + CONSTRAINT `entries_platform_id_foreign` FOREIGN KEY (`platform_id`) REFERENCES `platforms` (`id`) ON DELETE SET NULL, + CONSTRAINT `entries_status_id_foreign` FOREIGN KEY (`status_id`) REFERENCES `statuses` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `entry_authors`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `entry_authors` ( + `entry_id` bigint(20) unsigned NOT NULL, + `author_id` bigint(20) unsigned NOT NULL, + PRIMARY KEY (`entry_id`,`author_id`), + KEY `entry_authors_author_id_foreign` (`author_id`), + CONSTRAINT `entry_authors_author_id_foreign` FOREIGN KEY (`author_id`) REFERENCES `authors` (`id`) ON DELETE CASCADE, + CONSTRAINT `entry_authors_entry_id_foreign` FOREIGN KEY (`entry_id`) REFERENCES `entries` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `entry_categories`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `entry_categories` ( + `entry_id` bigint(20) unsigned NOT NULL, + `category_id` bigint(20) unsigned NOT NULL, + PRIMARY KEY (`entry_id`,`category_id`), + KEY `entry_categories_category_id_foreign` (`category_id`), + CONSTRAINT `entry_categories_category_id_foreign` FOREIGN KEY (`category_id`) REFERENCES `categories` (`id`) ON DELETE CASCADE, + CONSTRAINT `entry_categories_entry_id_foreign` FOREIGN KEY (`entry_id`) REFERENCES `entries` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `entry_files`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `entry_files` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `entry_id` bigint(20) unsigned NOT NULL, + `filename` varchar(1024) NOT NULL, + `filepath` varchar(1024) NOT NULL, + `favorite_server` varchar(11) NOT NULL, + `favorite_at` timestamp NOT NULL, + `filesize` bigint(20) unsigned DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + `file_uuid` uuid NOT NULL, + `state` enum('public','private','archived') NOT NULL DEFAULT 'public', + `online_patcher` tinyint(1) NOT NULL DEFAULT 0, + `secondary_online_patcher` tinyint(1) NOT NULL DEFAULT 0, + `download_count` bigint(20) unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `entry_files_file_uuid_unique` (`file_uuid`), + KEY `entry_files_entry_id_foreign` (`entry_id`), + CONSTRAINT `entry_files_entry_id_foreign` FOREIGN KEY (`entry_id`) REFERENCES `entries` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `entry_hashes`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `entry_hashes` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `entry_id` bigint(20) unsigned NOT NULL, + `filename` varchar(256) NOT NULL, + `hash_crc32` varchar(256) NOT NULL, + `hash_sha1` varchar(256) NOT NULL, + `verified` varchar(256) NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `entry_hashes_entry_id_foreign` (`entry_id`), + CONSTRAINT `entry_hashes_entry_id_foreign` FOREIGN KEY (`entry_id`) REFERENCES `entries` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `entry_languages`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `entry_languages` ( + `entry_id` bigint(20) unsigned NOT NULL, + `language_id` bigint(20) unsigned NOT NULL, + PRIMARY KEY (`entry_id`,`language_id`), + KEY `entry_languages_language_id_foreign` (`language_id`), + CONSTRAINT `entry_languages_entry_id_foreign` FOREIGN KEY (`entry_id`) REFERENCES `entries` (`id`) ON DELETE CASCADE, + CONSTRAINT `entry_languages_language_id_foreign` FOREIGN KEY (`language_id`) REFERENCES `languages` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `entry_modifications`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `entry_modifications` ( + `entry_id` bigint(20) unsigned NOT NULL, + `modification_id` bigint(20) unsigned NOT NULL, + PRIMARY KEY (`entry_id`,`modification_id`), + KEY `entry_modifications_modification_id_foreign` (`modification_id`), + CONSTRAINT `entry_modifications_entry_id_foreign` FOREIGN KEY (`entry_id`) REFERENCES `entries` (`id`) ON DELETE CASCADE, + CONSTRAINT `entry_modifications_modification_id_foreign` FOREIGN KEY (`modification_id`) REFERENCES `modifications` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `entry_reviews`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `entry_reviews` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `entry_id` bigint(20) unsigned NOT NULL, + `title` varchar(255) NOT NULL, + `rating` int(11) NOT NULL, + `description` text NOT NULL, + `user_id` bigint(20) unsigned NOT NULL, + `deleted_at` timestamp NULL DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `entry_reviews_entry_id_foreign` (`entry_id`), + CONSTRAINT `entry_reviews_entry_id_foreign` FOREIGN KEY (`entry_id`) REFERENCES `entries` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `entry_systems`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `entry_systems` ( + `entry_id` bigint(20) unsigned NOT NULL, + `system_id` bigint(20) unsigned NOT NULL, + PRIMARY KEY (`entry_id`,`system_id`), + KEY `entry_systems_system_id_foreign` (`system_id`), + CONSTRAINT `entry_systems_entry_id_foreign` FOREIGN KEY (`entry_id`) REFERENCES `entries` (`id`) ON DELETE CASCADE, + CONSTRAINT `entry_systems_system_id_foreign` FOREIGN KEY (`system_id`) REFERENCES `systems` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `failed_jobs`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `failed_jobs` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `uuid` varchar(255) NOT NULL, + `connection` text NOT NULL, + `queue` text NOT NULL, + `payload` longtext NOT NULL, + `exception` longtext NOT NULL, + `failed_at` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `failed_jobs_uuid_unique` (`uuid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `galleries`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `galleries` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `galleryable_type` varchar(255) NOT NULL DEFAULT 'AppModelsEntry', + `galleryable_id` bigint(20) unsigned NOT NULL, + `image` varchar(255) NOT NULL, + `order` smallint(5) unsigned NOT NULL DEFAULT 0, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `entry_galleries_entry_id_foreign` (`galleryable_id`), + KEY `galleries_galleryable_type_galleryable_id_index` (`galleryable_type`,`galleryable_id`), + CONSTRAINT `entry_galleries_entry_id_foreign` FOREIGN KEY (`galleryable_id`) REFERENCES `entries` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `games`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `games` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `slug` varchar(255) NOT NULL, + `platform_id` bigint(20) unsigned NOT NULL, + `genre_id` bigint(20) unsigned NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `games_slug_unique` (`slug`), + KEY `games_platform_id_foreign` (`platform_id`), + KEY `games_genre_id_foreign` (`genre_id`), + CONSTRAINT `games_genre_id_foreign` FOREIGN KEY (`genre_id`) REFERENCES `genres` (`id`), + CONSTRAINT `games_platform_id_foreign` FOREIGN KEY (`platform_id`) REFERENCES `platforms` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `genres`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `genres` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `slug` varchar(255) NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `genres_slug_unique` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `job_batches`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `job_batches` ( + `id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `total_jobs` int(11) NOT NULL, + `pending_jobs` int(11) NOT NULL, + `failed_jobs` int(11) NOT NULL, + `failed_job_ids` longtext NOT NULL, + `options` mediumtext DEFAULT NULL, + `cancelled_at` int(11) DEFAULT NULL, + `created_at` int(11) NOT NULL, + `finished_at` int(11) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `jobs`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `jobs` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `queue` varchar(255) NOT NULL, + `payload` longtext NOT NULL, + `attempts` smallint(5) unsigned NOT NULL, + `reserved_at` int(10) unsigned DEFAULT NULL, + `available_at` int(10) unsigned NOT NULL, + `created_at` int(10) unsigned NOT NULL, + PRIMARY KEY (`id`), + KEY `jobs_queue_index` (`queue`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `languages`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `languages` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `slug` varchar(255) NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `languages_slug_unique` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `levels`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `levels` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `slug` varchar(255) NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `levels_slug_unique` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `migration_game_plan`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `migration_game_plan` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `wp_game_id` bigint(20) unsigned NOT NULL, + `wp_platform_id` bigint(20) unsigned NOT NULL, + `game_id` bigint(20) unsigned DEFAULT NULL, + `wp_genre_id` bigint(20) unsigned DEFAULT NULL, + `post_count` int(10) unsigned NOT NULL DEFAULT 0, + `genre_conflict` tinyint(1) NOT NULL DEFAULT 0, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `migration_game_plan_wp_game_id_wp_platform_id_unique` (`wp_game_id`,`wp_platform_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `migration_settings`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `migration_settings` ( + `key` varchar(255) NOT NULL, + `value` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(`value`)), + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `migration_user_plan`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `migration_user_plan` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `wp_user_id` bigint(20) unsigned DEFAULT NULL, + `xf_user_id` bigint(20) unsigned DEFAULT NULL, + `match_type` varchar(255) NOT NULL, + `email` varchar(255) DEFAULT NULL, + `wp_username` varchar(255) DEFAULT NULL, + `xf_username` varchar(255) DEFAULT NULL, + `note` text DEFAULT NULL, + `status` varchar(255) NOT NULL DEFAULT 'pending', + `user_id` bigint(20) unsigned DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + `wp_password_bridge` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `migration_user_plan_match_type_index` (`match_type`), + KEY `migration_user_plan_status_index` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `migrations`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `migrations` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `migration` varchar(255) NOT NULL, + `batch` int(11) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `migrations_logs`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `migrations_logs` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `source_system` varchar(255) NOT NULL, + `source_table` varchar(255) NOT NULL, + `source_id` bigint(20) unsigned NOT NULL, + `target_table` varchar(255) NOT NULL, + `target_id` bigint(20) unsigned NOT NULL, + `status` varchar(255) NOT NULL DEFAULT 'pending', + `migrated_at` datetime DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `modifications`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `modifications` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `slug` varchar(255) NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `news`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `news` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `title` varchar(255) NOT NULL, + `slug` varchar(255) NOT NULL, + `category_id` bigint(20) unsigned DEFAULT NULL, + `description` longtext NOT NULL, + `state` enum('draft','pending','published','locked','rejected','hidden') NOT NULL DEFAULT 'draft', + `staff_comment` text DEFAULT NULL, + `rejected_at` timestamp NULL DEFAULT NULL, + `entry_id` bigint(20) unsigned DEFAULT NULL, + `relevant_link` varchar(500) DEFAULT NULL, + `youtube_link` varchar(500) DEFAULT NULL, + `user_id` bigint(20) unsigned NOT NULL, + `comments_thread_id` bigint(20) unsigned DEFAULT NULL, + `deleted_at` timestamp NULL DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `news_slug_unique` (`slug`), + KEY `news_category_id_foreign` (`category_id`), + KEY `news_entry_id_foreign` (`entry_id`), + CONSTRAINT `news_category_id_foreign` FOREIGN KEY (`category_id`) REFERENCES `categories` (`id`) ON DELETE SET NULL, + CONSTRAINT `news_entry_id_foreign` FOREIGN KEY (`entry_id`) REFERENCES `entries` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `password_reset_tokens`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `password_reset_tokens` ( + `email` varchar(255) NOT NULL, + `token` varchar(255) NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `platforms`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `platforms` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL, + `slug` varchar(100) NOT NULL, + `short_name` varchar(30) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + `play_online_core` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `platforms_slug_unique` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `play_online_settings`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `play_online_settings` ( + `file_id` bigint(20) unsigned NOT NULL, + `core` varchar(30) NOT NULL, + `threads` tinyint(1) NOT NULL DEFAULT 0, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`file_id`), + CONSTRAINT `play_online_settings_file_id_foreign` FOREIGN KEY (`file_id`) REFERENCES `entry_files` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `sessions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `sessions` ( + `id` varchar(255) NOT NULL, + `user_id` bigint(20) unsigned DEFAULT NULL, + `ip_address` varchar(45) DEFAULT NULL, + `user_agent` text DEFAULT NULL, + `payload` longtext NOT NULL, + `last_activity` int(11) NOT NULL, + PRIMARY KEY (`id`), + KEY `sessions_user_id_index` (`user_id`), + KEY `sessions_last_activity_index` (`last_activity`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `statuses`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `statuses` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `slug` varchar(255) NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `status_slug_unique` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `systems`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `systems` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `slug` varchar(255) NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `systems_slug_unique` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `users`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `users` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `email` varchar(255) NOT NULL, + `email_verified_at` timestamp NULL DEFAULT NULL, + `password` varchar(255) NOT NULL, + `remember_token` varchar(100) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `users_email_unique` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*M!100616 SET NOTE_VERBOSITY=@OLD_NOTE_VERBOSITY */; + +/*M!999999\- enable the sandbox mode */ +SET @OLD_AUTOCOMMIT=@@AUTOCOMMIT, @@AUTOCOMMIT=0; +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (1,'0001_01_01_000000_create_users_table',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (2,'0001_01_01_000001_create_cache_table',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (3,'0001_01_01_000002_create_jobs_table',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (4,'2026_05_09_153326_create_platforms_table',2); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (5,'2026_05_09_153901_add_platform_timestamp',3); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (6,'2026_05_10_071323_create_genres_table',4); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (7,'2026_05_10_071400_create_games_table',5); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (8,'2026_05_10_072139_create_languages_table',6); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (9,'2026_05_10_072201_create_authors_table',6); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (10,'2026_05_10_072332_create_modifications_table',6); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (11,'2026_05_10_072441_create_status_table',6); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (12,'2026_05_10_072747_create_entries_table',7); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (13,'2026_05_10_074735_create_entry_authors_table',8); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (14,'2026_05_10_074830_create_entry_languages_table',8); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (15,'2026_05_10_074907_create_entry_modifications_table',8); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (16,'2026_05_12_114815_create_entry_files_table',9); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (19,'2026_05_12_115134_create_entry_hashes_table',10); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (20,'2026_05_13_084522_add_fields_to_entry_files',11); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (22,'2026_05_16_134002_create_entry_gallery_table',12); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (23,'2026_05_18_131712_change_staff_credits_field',13); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (24,'2026_05_19_090838_add_complete_title_to_entry',14); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (25,'2026_05_27_192635_add_fields_for_queue_to_entries',15); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (26,'2026_05_27_193235_add_rejected_state_to_entries',16); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (27,'2026_06_04_083346_make_entries_fields_draft_compatible',17); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (35,'2026_06_05_163235_add_online_patcher_fields_to_files',18); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (36,'2026_06_09_122425_create_category_table',18); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (37,'2026_06_09_122655_create_os_table',18); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (38,'2026_06_09_122817_create_level_table',18); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (39,'2026_06_09_123242_add_entry_level_id_field',18); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (40,'2026_06_09_123458_create_entry_systems_table',18); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (41,'2026_06_09_123533_create_entry_categories_table',18); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (42,'2026_06_09_124832_add_restricted_field_to_categories',19); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (43,'2026_06_10_084936_make_entry_galleries_polymorphic',20); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (44,'2026_06_10_090320_create_news_table',21); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (45,'2026_06_10_091105_add_fields_for_queue_to_news',22); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (46,'2026_06_11_155053_add_order_to_galleries_table',23); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (47,'2026_06_13_210606_add_featured_at_field_to_entries',24); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (48,'2026_06_14_163648_create_play_online_settings_table',25); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (49,'2026_06_14_174906_add_default_core_play_online_for_platforms',26); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (50,'2026_06_16_100941_create_activity_log_table',27); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (51,'2026_06_16_122812_add_download_field_to_entry_files',28); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (53,'2026_06_17_114641_create_reviews_table',29); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (54,'2026_06_18_084700_create_migration_user_plan_table',30); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (55,'2026_06_19_080416_create_migration_settings_table',31); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (56,'2026_06_19_125237_create_migrations_logs_table',32); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (57,'2026_06_19_134939_alter_migration_user_plan_table',33); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (59,'2026_06_21_085615_create_migration_game_plan_table',34); +COMMIT; +SET AUTOCOMMIT=@OLD_AUTOCOMMIT; diff --git a/extra.less b/extra.less index ea5932a..fd587f1 100644 --- a/extra.less +++ b/extra.less @@ -201,6 +201,86 @@ ul { } } +@media (max-width: 1024px) { + .\$stats-grid { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 15px; + margin-bottom: 25px; + } + + .\$stat-card { + padding: 15px; + gap: 12px; + } +} + +@media (max-width: 768px) { + .\$stats-grid { + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-bottom: 20px; + } + + .\$stat-card { + padding: 12px; + gap: 10px; + font-size: 0.9rem; + } + + .\$stat-card i { + width: 28px; + height: 28px; + } + + .\$entry-card-info { + padding: 12px; + } + + .\$entry-card-title { + font-size: 0.95rem; + margin-bottom: 4px; + } + + .\$entry-card-author { + font-size: 0.8rem; + margin-bottom: 8px; + } + + .\$entry-card-meta { + font-size: 0.75rem; + } +} + +@media (max-width: 600px) { + .\$stats-grid { + grid-template-columns: 1fr; + gap: 10px; + margin-bottom: 15px; + } + + .\$stat-card { + padding: 10px; + flex-direction: row; + } + + .\$entry-card { + &:hover { + transform: none; + } + } + + .\$entry-card-title { + font-size: 0.9rem; + } + + .\$entry-badge { + top: 5px; + right: 5px; + padding: 3px 6px; + font-size: 0.65rem; + } +} + /* File: resources/css/components/common.css */ /* BUTTONS */ @@ -309,6 +389,11 @@ ul { color: var(--text); border-color: var(--success2); } +.\$badge.yellow, .\$badge.utilities { + background-color: #fdeb0f; + color: #000; + border-color: #fdeb0f; +} .\$topbar-badge { position: absolute; @@ -347,6 +432,7 @@ ul { .\$breadcrumb { margin-bottom: 15px; + flex-shrink: 0; } /* PAGE */ @@ -356,6 +442,7 @@ ul { font-weight: 300; margin-bottom: 20px; color: var(--text); + flex-shrink: 0; } /* TEXTS */ @@ -396,6 +483,91 @@ ul { cursor: pointer; } +@media (max-width: 768px) { + .\$btn { + padding: 7px 12px; + font-size: 0.85rem; + gap: 6px; + } + + .\$block { + padding: 15px; + margin-bottom: 15px; + } + + .\$block-header { + font-size: 1.05rem; + margin-bottom: 12px; + padding-bottom: 8px; + } + + .\$page-title { + font-size: 1.5rem; + margin-bottom: 15px; + } + + .\$content-title { + margin: 20px 0 12px 0; + padding-left: 8px; + } + + .\$quote { + padding: 12px; + margin-top: 20px; + font-size: 0.95rem; + } + + .\$whisper { + margin-bottom: 12px; + font-size: 0.9rem; + } + + .\$breadcrumb { + font-size: 0.85rem; + } +} + +@media (max-width: 600px) { + .\$btn { + padding: 6px 10px; + font-size: 0.8rem; + gap: 4px; + justify-content: center; + } + + .\$btn.primary, .\$btn.danger, .\$btn.success { + width: 100%; + } + + .\$block { + padding: 12px; + margin-bottom: 12px; + } + + .\$block-header { + font-size: 0.95rem; + margin-bottom: 10px; + padding-bottom: 6px; + } + + .\$page-title { + font-size: 1.2rem; + margin-bottom: 12px; + } + + .\$badge { + padding: 2px 6px; + font-size: 0.7rem; + } + + .\$topbar-badge { + min-width: 16px; + height: 16px; + padding: 0 3px; + font-size: 0.6rem; + } +} + /* File: resources/css/components/database.css */ .\$filter-bar { @@ -659,6 +831,10 @@ ul { flex-direction: column; } + .\$database-wrapper { + flex-direction: column; + } + .\$database-filters { width: 100%; display: grid; @@ -675,19 +851,63 @@ ul { } } +@media (max-width: 768px) { + .\$database-search { + gap: 8px; + margin-bottom: 15px; + flex-wrap: wrap; + } + + .\$database-wrapper { + flex-direction: column; + gap: 15px; + } + + .\$database-filters { + width: 100%; + grid-template-columns: 1fr; + order: -1; + margin-bottom: 10px; + } + + .\$database-filter-group { + padding: 12px 0; + } + + .\$grid-entries { + grid-template-columns: repeat(3, 1fr); + gap: 15px; + } +} + @media (max-width: 600px) { + .\$database-search { + flex-direction: column; + } + .\$database-filters { grid-template-columns: 1fr; } .\$grid-entries { grid-template-columns: repeat(2, 1fr); + gap: 12px; + } + + .\$database-filter-group { + padding: 10px 0; } } @media (max-width: 420px) { .\$grid-entries { grid-template-columns: 1fr; + gap: 10px; + } + + .\$database-search input { + font-size: 0.85rem; + padding: 6px 8px; } } @@ -791,6 +1011,269 @@ ul { +/* File: resources/css/components/drafts.css */ +.\$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: normal; + 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; + } +} + +@media (max-width: 768px) { + .\$drafts-count { + font-size: 0.8rem; + margin-bottom: 12px; + padding-bottom: 8px; + } + + .\$drafts-item { + gap: 15px; + padding: 15px; + } + + .\$drafts-cover { + width: 70px; + height: 70px; + } + + .\$drafts-top { + gap: 12px; + } + + .\$drafts-title { + font-size: 0.95rem; + } + + .\$drafts-meta { + font-size: 0.8rem; + } + + .\$drafts-actions { + gap: 6px; + } + + .\$drafts-actions .\$btn { + padding: 6px 10px; + font-size: 0.8rem; + } +} + +@media (max-width: 600px) { + .\$drafts-empty { + padding: 60px 15px; + gap: 12px; + } + + .\$drafts-empty h3 { + font-size: 1rem; + } + + .\$drafts-empty p { + font-size: 0.85rem; + } + + .\$drafts-item { + flex-direction: column; + gap: 12px; + padding: 12px; + } + + .\$drafts-cover { + width: 100%; + height: 150px; + } + + .\$drafts-top { + flex-direction: column; + gap: 10px; + } + + .\$drafts-title { + font-size: 0.9rem; + } + + .\$drafts-meta { + font-size: 0.75rem; + } + + .\$drafts-progress { + flex-direction: column; + gap: 8px; + } + + .\$drafts-progress-bar { + width: 100%; + } + + .\$drafts-actions { + flex-direction: row; + gap: 6px; + flex-wrap: wrap; + } + + .\$drafts-actions .\$btn { + flex: 1; + min-width: 80px; + padding: 5px 8px; + font-size: 0.75rem; + } +} + + /* File: resources/css/components/easymde.css */ .\$EasyMDEContainer { display: flex; @@ -1264,6 +1747,50 @@ ul { flex-direction: column; gap: 5px; } +.\$gallery-item { + position: relative; + cursor: grab; + transition: opacity 0.2s, transform 0.15s; + user-select: none; +} + +.gallery-item:active { cursor: grabbing; } + +.\$gallery-item--dragging { + opacity: 0.4; + transform: scale(0.97); +} + +.\$gallery-drag-handle { + position: absolute; + top: 4px; + left: 4px; + z-index: 10; + background-color: rgba(0,0,0,0.6); + color: #fff; + padding: 3px 4px; + display: flex; + align-items: center; + cursor: grab; + opacity: 0; + transition: opacity 0.15s; +} + +.gallery-item:hover .gallery-drag-handle { opacity: 1; } + +.\$gallery-order-badge { + position: absolute; + bottom: 4px; + left: 4px; + z-index: 10; + background-color: rgba(0,0,0,0.7); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + padding: 2px 6px; + min-width: 20px; + text-align: center; +} .\$authors-list { display: grid; grid-template-columns: repeat(4, 1fr); @@ -1379,6 +1906,17 @@ ul { flex-direction: row; gap: 15px; } + +@media (max-width: 600px) { + .\$upload-item-actions { + flex-direction: column; + gap: 8px; + } + + .\$upload-item-actions .\$btn { + width: 100%; + } +} .file-state-icon { width: 18px; height: 18px; } .file-state-icon--public { color: var(--success); } .file-state-icon--private { color: var(--text2); } @@ -1444,6 +1982,130 @@ ul { .author-search-item:hover { background-color: var(--bg3); } +.\$game-selector-mode { + display: flex; + gap: 0; + margin-bottom: 15px; + border: 1px solid var(--border); +} + +.\$game-selector-mode-btn { + display: flex; + align-items: center; + gap: 7px; + padding: 8px 14px; + background: none; + border: none; + border-right: 1px solid var(--border); + color: var(--text2); + font-size: 0.85rem; + cursor: pointer; + font-family: var(--font-family); + transition: background-color 0.1s, color 0.1s; +} + +.\$game-selector-mode-btn:last-child { + border-right: none; +} + +.\$game-selector-mode-btn:hover { + background-color: var(--bg3); + color: var(--text); +} + +.\$game-selector-mode-btn.active { + background-color: var(--bg3); + color: var(--rhpz-orange); + border-bottom: 2px solid var(--rhpz-orange); +} + +.\$game-selector-platform-only { + grid-column: span 1; +} + +@media (max-width: 768px) { + .\$form-group.level { + padding: 20px; + margin-bottom: 25px; + } + + .\$form-group-title { + font-size: 1rem; + margin-bottom: 15px; + padding-bottom: 8px; + } + + .\$form-group label, .\$form-label { + margin-bottom: 6px; + font-size: 0.9rem; + } + + .\$form-input, .\$form-select, .\$form-textarea, .\$form-field { + padding: 8px 10px; + font-size: 0.9rem; + } + + .\$form-textarea { + min-height: 100px; + } + + .\$game-selector-mode { + flex-direction: column; + gap: 0; + } + + .\$game-selector-mode-btn { + padding: 10px 12px; + border-right: none; + border-bottom: 1px solid var(--border); + } + + .\$game-selector-mode-btn:last-child { + border-bottom: none; + } + + .\$submit, .\$submit-level, .\$main-image-grid { + flex-direction: column; + } + + .\$grid-hashes { + grid-template-columns: 1fr; + } + + .\$hash-first { + display: none; + } +} + +@media (max-width: 600px) { + .\$form-group { + margin-bottom: 15px; + } + + .\$form-group.level { + padding: 15px; + margin-bottom: 20px; + } + + .\$form-group-title { + font-size: 0.95rem; + margin-bottom: 12px; + } + + .\$form-group label, .\$form-label { + font-size: 0.85rem; + } + + .\$form-input, .\$form-select, .\$form-textarea, .\$form-field { + padding: 6px 8px; + font-size: 0.85rem; + } + + .\$form-error-text { + font-size: 0.8rem; + } +} + /* File: resources/css/components/grid.css */ .\$grid-c2 { @@ -1487,11 +2149,12 @@ ul { /* File: resources/css/components/hovercard.css */ .\$hovercard-overlay { - position: absolute; - z-index: 2000; + position: fixed; + z-index: 3500; background-color: var(--bg2); border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + pointer-events: auto; } .\$hovercard-overlay-loading { @@ -1606,6 +2269,38 @@ ul { font-size: 0.82rem; } +@media (max-width: 768px) { + .\$hovercard { + width: 260px; + } + + .\$hovercard-actions { + gap: 6px; + } + + .\$hovercard-actions .\$btn { + font-size: 0.75rem; + padding: 6px 8px; + } +} + +@media (max-width: 600px) { + .\$hovercard { + width: calc(100vw - 40px); + max-width: 280px; + } + + .\$hovercard-actions { + flex-direction: column; + gap: 6px; + } + + .\$hovercard-actions .\$btn { + width: 100%; + justify-content: center; + } +} + /* File: resources/css/components/modal.css */ .\$modal-overlay { @@ -1658,11 +2353,688 @@ ul { } -.\$modal-content { +.\$modal-content, .\$modal-body { padding: 20px; } +/* File: resources/css/components/modcp.css */ +.\$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; } + +.\$log-filters { + margin-bottom: 16px; + background-color: var(--bg3); + border: 1px solid var(--border); +} + +.\$log-filters-main { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + flex-wrap: wrap; +} + +.\$log-search-wrap { + flex: 1; + min-width: 200px; + position: relative; + display: flex; + align-items: center; +} + +.\$log-search-wrap i { + position: absolute; + left: 10px; + color: var(--text2); + pointer-events: none; +} + +.log-search-wrap .form-input { padding-left: 30px; } + +.log-select { min-width: 130px; } + +.\$log-filter-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--rhpz-orange); + flex-shrink: 0; +} + +.\$log-filters-extra { + border-top: 1px solid var(--border); + padding: 12px 14px; +} + +.\$log-filters-extra-inner { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.\$log-filter-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.\$log-filter-label { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text2); +} + +.log-transition-enter { transition: all .15s ease; } +.log-transition-leave { transition: all .1s ease; } + +.\$log-results-bar { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.78rem; + color: var(--text2); + margin-bottom: 10px; + padding: 0 2px; +} + +.log-loading { opacity: 0.5; } + +.log-item { align-items: flex-start; padding: 11px 14px; } +.log-item--open { background-color: var(--bg3); } + +.\$log-event-dot { + width: 26px; + height: 26px; + flex-shrink: 0; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-top: 2px; + border: 1px solid var(--border); + background-color: var(--bg3); + color: var(--text2); +} + +.\$log-event-dot--created { + background-color: rgba(129,199,132,.1); + border-color: rgba(129,199,132,.35); + color: var(--success); +} + +.\$log-event-dot--updated { + background-color: rgba(255,115,0,.1); + border-color: rgba(255,115,0,.35); + color: var(--rhpz-orange); +} + +.\$log-event-dot--deleted { + background-color: rgba(229,115,115,.1); + border-color: rgba(229,115,115,.35); + color: var(--error); +} + +.\$log-channel-badge { + display: inline-flex; + align-items: center; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 1px 6px; + background-color: rgba(255,255,255,.05); + border: 1px solid var(--border); + color: var(--text2); +} + +.log-id { color: var(--text2); font-size: 0.78rem; } +.log-sep { color: var(--border); } + +.\$log-item-right { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + margin-left: auto; +} + +.\$log-timestamp { + font-size: 0.75rem; + color: var(--text2); + white-space: nowrap; +} + +.log-expand-btn { padding: 4px 7px; } + +.\$log-properties { + background-color: var(--bg); + border-bottom: 1px solid var(--border); + padding: 14px 14px 14px 54px; +} + +.\$log-diff-label { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.7px; + color: var(--text2); + margin-bottom: 8px; +} + +.\$log-diff { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.\$log-diff th { + text-align: left; + padding: 5px 10px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text2); + border-bottom: 1px solid var(--border); +} + +.\$log-diff td { + padding: 5px 10px; + border-bottom: 1px solid var(--border); + vertical-align: top; + max-width: 300px; + overflow-wrap: break-word; +} + +.log-diff tr:last-child td { border-bottom: none; } + +.\$log-diff-key { + font-size: 0.78rem; + font-weight: 600; + color: var(--text); + width: 160px; + white-space: nowrap; +} + +.log-diff-old-head { color: var(--error) !important; } +.log-diff-new-head { color: var(--success) !important; } + +.\$log-diff-old { + color: var(--error); + background-color: rgba(229,115,115,.05); +} + +.\$log-diff-new { + color: var(--success); + background-color: rgba(129,199,132,.05); +} + +.\$log-raw { + font-family: monospace; + font-size: 0.78rem; + color: var(--text2); + background-color: var(--bg2); + border: 1px solid var(--border); + padding: 10px 12px; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; +} + +.\$log-pagination { + padding: 14px 0 4px; + border-top: 1px solid var(--border); +} + +@media (max-width: 1024px) { + .\$modcp-wrapper { + min-height: auto; + } + + .\$modcp-sidebar { + width: 200px; + margin-right: 10px; + } + + .\$modcp-content { + padding: 20px; + } + + .\$modcp-page-title { + font-size: 1.15rem; + } +} + +@media (max-width: 768px) { + .\$modcp-wrapper { + flex-direction: column; + gap: 0; + } + + .\$modcp-sidebar { + width: 100%; + flex-shrink: 1; + position: relative; + top: auto; + align-self: auto; + margin-right: 0; + margin-bottom: 15px; + border: 1px solid var(--border); + max-height: 300px; + overflow-y: auto; + } + + .\$modcp-sidebar-header { + padding: 12px 14px; + font-size: 0.8rem; + } + + .\$modcp-nav-label { + padding: 6px 14px 3px; + font-size: 0.65rem; + } + + .\$modcp-nav-item { + padding: 6px 14px; + font-size: 0.8rem; + gap: 8px; + } + + .\$modcp-content { + padding: 15px; + } + + .\$modcp-page-title { + font-size: 1rem; + margin-bottom: 12px; + } + + .\$modcp-page-actions { + flex-direction: row; + gap: 8px; + } + + .\$modcp-table { + font-size: 0.85rem; + } + + .\$modcp-table th, .\$modcp-table td { + padding: 8px; + } + + .\$modcp-table tbody tr { + height: auto; + } +} + +@media (max-width: 600px) { + .\$modcp-sidebar { + max-height: 200px; + } + + .\$modcp-sidebar-header { + padding: 10px 12px; + font-size: 0.75rem; + } + + .\$modcp-nav-item { + padding: 5px 12px; + font-size: 0.75rem; + } + + .\$modcp-content { + padding: 12px; + } + + .\$modcp-page-title { + font-size: 0.95rem; + } + + .\$modcp-table { + font-size: 0.8rem; + overflow-x: auto; + display: block; + } + + .\$modcp-table th, .\$modcp-table td { + padding: 6px; + } + + .\$log-diff-key { + width: auto; + white-space: normal; + } + + .\$log-raw { + font-size: 0.7rem; + padding: 8px 10px; + } +} + + /* File: resources/css/components/notifications.css */ .\$notifications, .\$conversations { position: absolute; @@ -1687,6 +3059,28 @@ ul { } } +@media (max-width: 768px) { + .\$notifications, .\$conversations { + position: fixed; + width: calc(100% - 30px); + max-width: 340px; + right: 15px; + top: auto; + bottom: 15px; + max-height: calc(100vh - 130px); + z-index: 3000 !important; + } +} + +@media (max-width: 600px) { + .\$notifications, .\$conversations { + width: calc(100% - 20px); + right: 10px; + bottom: 10px; + max-width: 100%; + } +} + @keyframes dropdown-enter { from { opacity: 0; transform: translateY(-6px); } from { opacity: 0; transform: translateY(-6px); } @@ -1993,6 +3387,85 @@ ul { border: 1px solid var(--border); } +@media (max-width: 768px) { + .\$queue-item { + padding: 15px; + margin-bottom: 15px; + } + + .\$queue-item-header { + flex-direction: column; + gap: 12px; + } + + .\$queue-item-title { + font-size: 1rem; + } + + .\$queue-item-meta { + font-size: 0.8rem; + } + + .\$queue-item-actions-header { + gap: 6px; + flex-wrap: wrap; + width: 100%; + } + + .\$timeline { + font-size: 0.85rem; + } + + .\$timeline-container { + padding: 12px 15px; + } + + .\$queue-mod-actions { + flex-direction: column; + gap: 6px; + } +} + +@media (max-width: 600px) { + .\$queue-empty { + padding: 60px 15px; + font-size: 0.9rem; + } + + .\$queue-item { + padding: 12px; + border-left-width: 3px; + } + + .\$queue-item-title { + font-size: 0.95rem; + } + + .\$queue-item-meta { + font-size: 0.75rem; + } + + .\$queue-item-actions-header { + width: 100%; + } + + .\$timeline { + font-size: 0.8rem; + } + + .\$timeline-container { + padding: 10px 12px; + } + + .\$queue-mod-actions { + flex-direction: column; + } + + .\$queue-mod-actions .\$btn { + width: 100%; + } +} + /* File: resources/css/components/settings.css */ @@ -2007,6 +3480,29 @@ ul { z-index: 2000; } +@media (max-width: 768px) { + .\$settings-dropdown { + position: fixed; + width: calc(100% - 30px); + max-width: 240px; + right: 15px; + top: auto; + bottom: 15px; + max-height: calc(100vh - 130px); + overflow-y: auto; + z-index: 3000 !important; + } +} + +@media (max-width: 600px) { + .\$settings-dropdown { + width: calc(100% - 20px); + right: 10px; + bottom: 10px; + max-width: 100%; + } +} + .\$settings-header { padding: 12px 16px; border-bottom: 1px solid var(--border); @@ -2154,6 +3650,706 @@ ul { } +/* File: resources/css/components/tools.css */ +.\$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-container { + padding: 20px; + } + + .\$patcher-dropzone { + padding: 40px 15px; + gap: 12px; + } + + .\$embed-patch-box { + padding: 20px; + height: auto; + } + + .\$embed-patch-box-icon { + gap: 12px; + } + + .\$embed-patch-box-icon-block { + width: 40px; + height: 40px; + } +} + +@media (max-width: 600px) { + .\$patcher-container { + padding: 15px; + margin-bottom: 15px; + } + + .\$patcher-grid { + gap: 15px; + } + + .\$patcher-dropzone { + padding: 30px 12px; + gap: 10px; + font-size: 0.9rem; + } + + .\$patcher-status-box { + margin-top: 15px; + padding: 12px; + font-size: 0.9rem; + } + + .\$embed-patch-box { + padding: 15px; + gap: 12px; + } + + .\$btn:disabled { + padding: 6px 8px; + font-size: 0.8rem; + } +} + +.\$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; +} + + +/* File: resources/css/layout/activity.css */ + +.\$activity-hero-excerpt { + font-size: 0.9rem; + color: rgba(255,255,255,0.75); + margin-bottom: 12px; + line-height: 1.5; + max-width: 600px; +} + +.\$activity-tl-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 1px solid var(--border); + gap: 15px; + flex-wrap: wrap; +} + +.\$activity-tl-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 1.15rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.\$activity-tl-filters { + display: flex; + gap: 5px; + flex-wrap: wrap; +} + +.\$activity-tl-filter { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 12px; + background: none; + border: 1px solid var(--border); + color: var(--text2); + font-size: 0.8rem; + cursor: pointer; + font-family: var(--typography); + transition: all 0.1s; +} + +.activity-tl-filter:hover { background-color: var(--bg3); color: var(--text); } +.\$activity-tl-filter.active { + background-color: var(--bg3); + border-color: var(--rhpz-orange); + color: var(--rhpz-orange); +} + +.\$activity-day-sep { + display: flex; + align-items: center; + gap: 10px; + padding-left: 54px; + margin: 20px 0 12px; +} + +.\$activity-day-label { + font-size: 0.72rem; + font-weight: 600; + color: var(--text2); + text-transform: uppercase; + letter-spacing: 0.8px; + white-space: nowrap; +} + +.\$activity-day-line { + flex: 1; + height: 1px; + background-color: var(--border); +} + +.\$activity-tl-item { + display: flex; + gap: 0; + margin-bottom: 2px; +} + +.\$activity-tl-left { + width: 54px; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + padding-top: 12px; +} + +.\$activity-tl-dot { + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid var(--border); + background-color: var(--bg2); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + z-index: 1; +} + +.\$activity-tl-dot--entry { + background-color: rgba(255,115,0,0.1); + border-color: rgba(255,115,0,0.4); + color: var(--rhpz-orange); +} + +.\$activity-tl-dot--news, .\$activity-tl-dot--review { + background-color: rgba(129,199,132,0.1); + border-color: rgba(129,199,132,0.4); + color: var(--success); +} + +.\$activity-tl-dot--message, .\$activity-tl-dot--thread, .\$activity-tl-dot--club { + background-color: rgba(25,118,210,0.1); + border-color: rgba(25,118,210,0.4); + color: var(--info); +} + +.\$activity-tl-line { + width: 1px; + flex: 1; + background-color: var(--border); + margin-top: 4px; + min-height: 16px; +} + +.activity-tl-item:last-of-type .activity-tl-line { display: none; } + +.\$activity-tl-card { + flex: 1; + display: flex; + align-items: center; + gap: 12px; + background-color: var(--bg2); + border: 1px solid var(--border); + padding: 10px 14px; + margin-bottom: 8px; + text-decoration: none; + transition: border-color 0.15s, background-color 0.1s; + min-width: 0; +} + +.\$activity-tl-card:hover { + border-color: var(--rhpz-orange); + background-color: var(--bg3); + text-decoration: none; +} + +.\$activity-tl-thumb { + width: 52px; + height: 52px; + flex-shrink: 0; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--bg3); + border: 1px solid var(--border); +} + +.\$activity-tl-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.\$activity-tl-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.\$activity-tl-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; + padding: 2px 7px; + width: fit-content; +} + +.\$activity-tl-badge--entry { + background-color: rgba(255,115,0,0.1); + color: var(--rhpz-orange); + border: 1px solid rgba(255,115,0,0.25); +} + +.\$activity-tl-badge--news, .\$activity-tl-badge--review { + background-color: rgba(129,199,132,0.1); + color: var(--success); + border: 1px solid rgba(129,199,132,0.25); +} + +.\$activity-tl-badge--message, .\$activity-tl-badge--thread, .\$activity-tl-dot--club { + background-color: rgba(25,118,210,0.1); + color: var(--info); + border: 1px solid rgba(25,118,210,0.25); +} + +.\$activity-tl-card-title { + font-size: 0.92rem; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.3; +} + +.\$activity-tl-card-description { + font-size: 0.8rem; + color: var(--text2); + white-space: nowrap; + text-overflow: ellipsis; + line-height: 1.3; +} + +.\$activity-tl-meta { + display: flex; + align-items: center; + gap: 10px; + font-size: 0.75rem; + color: var(--text2); + flex-wrap: wrap; +} + +.\$activity-tl-meta span { + display: flex; + align-items: center; + gap: 3px; +} + +.\$activity-tl-time { + font-size: 0.72rem; + color: var(--text2); + white-space: nowrap; + flex-shrink: 0; + align-self: center; +} + +.\$activity-tl-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 60px; + color: var(--text2); + text-align: center; + padding-left: 54px; +} + +@media (max-width: 600px) { + .activity-tl-header { flex-direction: column; align-items: flex-start; } + .activity-tl-thumb { display: none; } + .activity-day-sep { padding-left: 44px; } + .activity-tl-left { width: 44px; } + + .\$activity-tl-date { + font-size: 0.75rem; + } + + .\$activity-tl-content-title { + font-size: 0.9rem; + } +} + +@media (max-width: 768px) { + .\$activity-timeline { + padding-left: 50px; + } + + .\$activity-tl-left { + width: 40px; + } + + .\$activity-tl-header { + gap: 10px; + } + + .\$activity-tl-date { + font-size: 0.8rem; + } +} + +.\$home-section { + margin-bottom: 30px; +} + +.\$home-section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); + margin-bottom: 14px; +} + +.\$home-section-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 1.05rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.\$home-section-more { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + color: var(--text2); + border: 1px solid var(--border); + padding: 4px 10px; + text-decoration: none; + transition: color 0.1s, border-color 0.1s; +} + +.\$home-section-more:hover { + color: var(--rhpz-orange); + border-color: var(--rhpz-orange); +} + +.\$news-strip { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 10px; +} + +.\$news-strip-card { + display: flex; + flex-direction: column; + background-color: var(--bg2); + border: 1px solid var(--border); + text-decoration: none; + overflow: hidden; + transition: border-color 0.15s; +} + +.news-strip-card:hover { border-color: var(--rhpz-orange); text-decoration: none; } + +.\$news-strip-cover { + height: 110px; + background-color: var(--bg3); + background-size: cover; + background-position: center; + position: relative; + flex-shrink: 0; +} + +.\$news-strip-date { + position: absolute; + bottom: 6px; + left: 8px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255,255,255,0.8); + background: rgba(0,0,0,0.55); + padding: 2px 6px; + border: 1px solid rgba(255,255,255,0.07); +} + +.\$news-strip-body { + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; +} + +.\$news-strip-badge { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--success); + background: rgba(129,199,132,0.1); + border: 1px solid rgba(129,199,132,0.25); + padding: 1px 6px; + width: fit-content; +} + +.\$news-strip-title { + font-size: 0.85rem; + font-weight: 600; + color: var(--text); + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin: 0; +} + +.\$news-strip-meta { + font-size: 0.72rem; + color: var(--text2); + margin-top: auto; +} + +.\$featured-entries-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; +} + +.\$featured-entry-card { + display: flex; + flex-direction: column; + background-color: var(--bg2); + border: 1px solid var(--border); + text-decoration: none; + overflow: hidden; + transition: border-color 0.15s; +} + +.featured-entry-card:hover { border-color: var(--rhpz-orange); text-decoration: none; } + +.\$featured-entry-cover { + height: 80px; + background-color: var(--bg3); + position: relative; + flex-shrink: 0; + overflow: hidden; +} + +.\$featured-entry-cover img { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.85; +} + +.\$featured-entry-star { + position: absolute; + top: 6px; + right: 6px; + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + background: rgba(255,115,0,0.9); + color: #111; + padding: 2px 6px; + border: 1px solid rgba(255,115,0,0.5); + display: flex; + align-items: center; + gap: 3px; +} + +.\$featured-entry-body { + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; +} + +.\$featured-entry-platform { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--rhpz-orange); + background: rgba(255,115,0,0.1); + border: 1px solid rgba(255,115,0,0.25); + padding: 1px 6px; + width: fit-content; +} + +.\$featured-entry-title { + font-size: 0.88rem; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.\$featured-entry-meta { + font-size: 0.72rem; + color: var(--text2); + margin-top: auto; +} + +@media (max-width: 900px) { + .news-strip { grid-template-columns: repeat(3, 1fr); } + .featured-entries-grid { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 768px) { + .\$news-strip { + grid-template-columns: repeat(2, 1fr); + gap: 15px; + } + + .\$featured-entries-grid { + grid-template-columns: 1fr; + gap: 15px; + } + + .\$home-section { + margin-bottom: 20px; + } + + .\$news-strip-cover { + height: 100px; + } + + .\$featured-entry-title { + font-size: 0.95rem; + } +} + +@media (max-width: 600px) { + .news-strip { grid-template-columns: 1fr; } + .featured-entries-grid { grid-template-columns: 1fr; } + .news-strip-cover { height: 80px; } + + .\$news-strip-item { + padding: 10px; + } + + .\$news-strip-title { + font-size: 0.85rem; + } + + .\$featured-entry-title { + font-size: 0.9rem; + } + + .\$featured-entry-meta { + font-size: 0.7rem; + } + + .\$home-section-title { + font-size: 0.95rem; + } +} + + /* File: resources/css/layout/content.css */ #main-wrapper { flex-grow: 1; @@ -2180,24 +4376,6 @@ ul { cursor: pointer; } - .\$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%; - } - } - .\$topbar-actions { display: flex; gap: 8px; @@ -2210,6 +4388,173 @@ ul { } +.\$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%; + } +} + +.\$topbar-more-container { + display: none; +} + +.\$topbar-more-menu { + position: fixed; + top: 60px; + right: 0; + background-color: var(--bg2); + border: 1px solid var(--border); + border-top: none; + border-right: none; + z-index: 2000; + min-width: 180px; + max-height: calc(100vh - 60px); + overflow-y: auto; +} + +.\$topbar-more-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + color: var(--text2); + text-decoration: none; + font-size: 0.9rem; + border-bottom: 1px solid var(--border); + transition: all 0.15s; + + &:last-child { + border-bottom: none; + } + + &:hover { + background-color: var(--bg3); + color: var(--text); + } + + i { + width: 16px; + height: 16px; + flex-shrink: 0; + } + + span { + flex-grow: 1; + text-align: left; + } +} + +@media (min-width: 769px) { + .\$topbar-more-container { + display: none !important; + } + + .topbar-admin-btn, + .\$topbar-mod-btn { + display: flex !important; + } +} +@media (max-width: 768px) { + .\$topbar-more-container { + display: block; + } + + .topbar-admin-btn, + .\$topbar-mod-btn { + display: none !important; + } + + #topbar { + padding: 0 10px; + } + + .\$search-bar { + display: none !important; + } + + .\$topbar-actions { + gap: 8px !important; + } + + .\$topbar-actions .\$btn { + padding: 8px 6px; + font-size: 0.85rem; + display: flex; + align-items: center; + justify-content: center; + } + + .\$topbar-actions i { + width: 16px !important; + height: 16px !important; + } + + .\$topbar-badge { + font-size: 0.65rem; + width: 18px; + height: 18px; + } +} + +@media (max-width: 600px) { + #topbar { + padding: 0 8px; + } + + .\$topbar-actions { + gap: 8px !important; + } + + .\$topbar-actions .\$btn { + padding: 6px 4px; + font-size: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + } + + .\$topbar-actions i { + width: 14px !important; + height: 14px !important; + } + + .\$topbar-badge { + font-size: 0.6rem; + width: 16px; + height: 16px; + } +} + +.\$search-scope-select { + background-color: var(--bg2); + border: none; + border-right: 1px solid var(--border); + color: var(--text2); + font-size: 0.8rem; + padding: 8px 10px; + cursor: pointer; + outline: none; + appearance: none; + transition: color 0.15s; +} + +.search-scope-select:hover, +.\$search-scope-select:focus { + color: var(--text); +} + #content { flex-grow: 1; padding: 30px; @@ -2236,8 +4581,8 @@ ul { gap: 30px; .\$entry-cover { - width: 200px; - height: 280px; + width: 220px; + height: 220px; background-color: var(--bg); border: 1px solid var(--border); display: flex; @@ -2251,7 +4596,8 @@ ul { img { width: 100%; height: 100%; - object-fit: cover; + object-fit: contain; + padding: 8px; } } @@ -2489,36 +4835,6 @@ ul { 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; - } } } } @@ -2603,6 +4919,245 @@ ul { } } +.\$markdown-body { + 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; + } +} + +.markdown-body h1, .markdown-body h2, .markdown-body h3, +.\$markdown-body h4, .\$markdown-body h5, .\$markdown-body h6 { + color: var(--text); + font-weight: 600; + margin: 16px 0 8px; + line-height: 1.3; +} + +.markdown-body h1 { font-size: 1.4rem; } +.markdown-body h2 { font-size: 1.2rem; } +.markdown-body h3 { font-size: 1.05rem; } + +.markdown-body strong { color: var(--text); font-weight: 700; } +.markdown-body em { color: var(--text2); } + +.\$markdown-body ul, .\$markdown-body ol { + margin: 0 0 12px 20px; + color: var(--text); +} + +.markdown-body li { margin-bottom: 4px; line-height: 1.5; } + +.\$markdown-body hr { + border: none; + border-top: 1px solid var(--border); + margin: 16px 0; +} + +.\$markdown-body table { + width: 100%; + border-collapse: collapse; + margin: 12px 0; + font-size: 0.9rem; +} + +.\$markdown-body th, .\$markdown-body td { + border: 1px solid var(--border); + padding: 6px 10px; + text-align: left; +} + +.\$markdown-body th { + background-color: var(--bg3); + font-weight: 600; + color: var(--text); +} + +.\$markdown-body del { + color: var(--text2); + text-decoration: line-through; +} + +.\$markdown-body img { + max-width: 100%; + border: 1px solid var(--border); + margin: 8px 0; +} + +.\$hack-actions { + display: flex; + gap: 10px; +} + +@media (max-width: 768px) { + .\$entry-header { + flex-direction: column; + padding: 20px; + gap: 20px; + + .\$entry-cover { + width: 100%; + height: 280px; + max-width: 300px; + margin: 0 auto; + } + + .\$entry-info { + .\$entry-title { + font-size: 1.6rem; + } + + .\$entry-authors { + font-size: 0.95rem; + } + + .\$entry-meta-grid { + grid-template-columns: 1fr; + gap: 12px; + margin-bottom: 20px; + } + + .\$entry-actions { + flex-direction: column; + gap: 10px; + + .\$btn { + width: 100%; + } + } + } + } + + .\$entry-content { + padding: 20px; + + .\$entry-section-title { + font-size: 1.1rem; + } + + .\$entry-gallery { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 12px; + margin-bottom: 20px; + } + } + + .\$comment-block { + gap: 12px; + padding: 15px 0; + + .\$comment-avatar { + width: 40px; + height: 40px; + } + + .\$comment-content { + .\$comment-body { + font-size: 0.9rem; + } + } + } + + .\$video-thumbnail-wrapper { + max-width: 100%; + } + + .\$gallery-modal-close { + top: 10px; + right: 15px; + font-size: 30px; + } + + .\$hack-actions { + flex-direction: column; + } +} + +@media (max-width: 600px) { + .\$entry-header { + padding: 15px; + gap: 15px; + + .\$entry-cover { + height: 240px; + } + + .\$entry-info { + .\$entry-title { + font-size: 1.3rem; + margin-bottom: 8px; + } + + .\$entry-authors { + font-size: 0.85rem; + margin-bottom: 15px; + } + + .\$entry-actions { + gap: 8px; + + .\$btn { + padding: 8px 12px; + font-size: 0.85rem; + } + } + } + } + + .\$entry-content { + padding: 15px; + + .\$entry-gallery { + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 8px; + margin-bottom: 15px; + } + } + + .\$comment-block { + padding: 10px 0; + + .\$comment-avatar { + width: 36px; + height: 36px; + } + } + + .markdown-body h1 { font-size: 1.15rem; } + .markdown-body h2 { font-size: 1rem; } + .markdown-body h3 { font-size: 0.95rem; } + + .\$hack-actions { + flex-direction: column; + } +} + + /* File: resources/css/layout/menu.css */ #menu { @@ -2616,12 +5171,18 @@ ul { z-index: 100; .\$menu-header { - padding: 20px; + padding: 10px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--border); + img { + width: 100%; + height: 100%; + object-fit: contain; + } + .\$menu-logo { width: 32px; height: 32px; @@ -2785,6 +5346,1344 @@ ul { } } +#news-container { + background-color: var(--bg2); + border: 1px solid var(--border); + display: flex; + flex-direction: column; +} + +.\$news-header { + width: 100%; + height: 300px; + background-size: cover; + background-position: center; + display: flex; + align-items: flex-end; + padding: 40px 30px; + border-bottom: 1px solid var(--border); + position: relative; +} + +.\$news-header-content { + position: relative; + z-index: 2; +} + +.\$news-header .\$news-title { + font-size: 2.5rem; + font-weight: 600; + color: var(--text); + margin-bottom: 12px; + text-shadow: 0 2px 4px rgba(0,0,0,0.6); + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; +} + +@media (max-width: 768px) { + .\$news-header .\$news-title { + font-size: 1.8rem; + } +} + +@media (max-width: 600px) { + .\$news-header .\$news-title { + font-size: 1.4rem; + } +} + +.\$news-header .\$news-meta { + color: var(--text2); + display: flex; + gap: 20px; + font-size: 0.9rem; + align-items: center; +} + +.\$news-header .\$meta-item { + display: flex; + align-items: center; + gap: 6px; + background-color: rgba(0, 0, 0, 0.4); + padding: 4px 10px; + border-radius: 2px; + border: 1px solid rgba(255,255,255,0.05); +} + +.\$news-layout { + display: flex; + flex-direction: row; + gap: 30px; + padding: 30px; +} + +@media (max-width: 992px) { + .\$news-layout { + flex-direction: column; + } +} + +.\$news-main-content { + flex-grow: 1; + flex-basis: 0; + min-width: 0; +} + +.\$news-body-text { + line-height: 1.75; + color: var(--text); + font-size: 1.05rem; + margin-bottom: 15px; +} + +.\$news-body-text p { + margin-bottom: 20px; +} + +.\$news-sidebar { + width: 320px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 25px; +} + +@media (max-width: 992px) { + .\$news-sidebar { + width: 100%; + } +} + +.\$sidebar-block { + background-color: var(--bg); + border: 1px solid var(--border); + padding: 20px; + border-radius: 4px; +} + +.\$sidebar-title { + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text); + margin-bottom: 15px; + display: flex; + align-items: center; + gap: 8px; + border-bottom: 1px solid var(--border); + padding-bottom: 8px; +} + +.\$btn-sidebar { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 10px 15px; + font-size: 0.95rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + border-radius: 2px; + transition: background-color 0.2s ease, border-color 0.2s ease; + text-align: center; +} + +.\$btn-orange { + background-color: var(--rhpz-orange); + color: #fff; + border: 1px solid transparent; +} + +.\$btn-orange:hover { + background-color: var(--rhpz-orange-hover); +} + +.\$related-card { + display: flex; + flex-direction: column; + gap: 12px; +} + +.\$related-card-cover { + width: 100%; + height: 150px; + background-color: var(--bg2); + border: 1px solid var(--border); + overflow: hidden; + border-radius: 2px; +} + +.\$related-card-cover img { + width: 100%; + height: 100%; + object-fit: contain; + padding: 5px; +} + +.\$related-card-info h4 { + font-size: 1.1rem; + color: var(--text); + margin-bottom: 10px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.\$news-sidebar .\$video-thumbnail-wrapper { + position: relative; + width: 100%; + aspect-ratio: 16/9; + background-color: #000; + border: 1px solid var(--border); + cursor: pointer; + overflow: hidden; + border-radius: 2px; +} + +.\$news-sidebar .\$video-thumbnail-wrapper img { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.7; + transition: transform 0.3s ease, opacity 0.3s ease; +} + +.\$news-sidebar .\$video-thumbnail-wrapper:hover img { + transform: scale(1.03); + opacity: 0.9; +} + +.\$news-sidebar .\$play-trigger { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 50px; + height: 50px; + background-color: rgba(0, 0, 0, 0.75); + border: 2px solid #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + transition: background-color 0.2s, transform 0.2s ease-out; +} + +.\$news-sidebar .\$video-thumbnail-wrapper:hover .\$play-trigger { + background-color: var(--rhpz-orange); + transform: translate(-50%, -50%) scale(1.1); +} + +.\$news-actions { + display: flex; + align-items: center; + gap: 8px; + margin-top: 15px; + flex-wrap: wrap; +} + +.\$news-header .\$news-actions .\$btn { + background-color: rgba(0, 0, 0, 0.5); + border-color: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(4px); + color: var(--text); + transition: background-color 0.15s, border-color 0.15s; +} + +.\$news-header .\$news-actions .\$btn:hover { + background-color: rgba(0, 0, 0, 0.7); + border-color: rgba(255, 255, 255, 0.3); +} + +.\$news-header .\$news-actions .\$btn.success { + background-color: rgba(56, 142, 60, 0.6); + border-color: rgba(129, 199, 132, 0.4); + color: #81c784; +} + +.\$news-header .\$news-actions .\$btn.danger { + background-color: rgba(183, 28, 28, 0.5); + border-color: rgba(229, 115, 115, 0.4); + color: #e57373; +} + +.\$news-hero { + display: block; + position: relative; + width: 100%; + height: 360px; + margin-bottom: 20px; + border: 1px solid var(--border); + overflow: hidden; + text-decoration: none; + transition: border-color 0.2s; +} + +.\$news-hero:hover { + border-color: var(--rhpz-orange); + text-decoration: none; +} + +.\$news-hero-bg { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; + background-color: var(--bg3); + transition: transform 0.4s ease; +} + +.\$news-hero:hover .\$news-hero-bg { + transform: scale(1.02); +} + +.\$news-hero-content { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 30px; + z-index: 2; +} + +.\$news-hero-badge { + display: inline-flex; + align-items: center; + gap: 5px; + background-color: var(--rhpz-orange); + color: #111; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; + padding: 4px 10px; + margin-bottom: 12px; +} + +.\$news-hero-title { + font-size: 2rem; + font-weight: 600; + color: #fff; + margin-bottom: 12px; + text-shadow: 0 2px 8px rgba(0,0,0,0.5); + line-height: 1.2; +} + +.\$news-hero-meta { + display: flex; + align-items: center; + gap: 15px; + font-size: 0.85rem; + color: rgba(255,255,255,0.75); +} + +.\$news-hero-meta span { + display: flex; + align-items: center; + gap: 5px; + background-color: rgba(0,0,0,0.4); + padding: 3px 10px; + border: 1px solid rgba(255,255,255,0.08); +} + +.\$news-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 15px; + margin-bottom: 20px; +} + +.\$news-card { + display: flex; + flex-direction: column; + background-color: var(--bg2); + border: 1px solid var(--border); + text-decoration: none; + overflow: hidden; + transition: border-color 0.15s, transform 0.15s; +} + +.\$news-card:hover { + border-color: var(--rhpz-orange); + transform: translateY(-2px); + text-decoration: none; +} + +.\$news-card-cover { + height: 160px; + background-size: cover; + background-position: center; + background-color: var(--bg3); + position: relative; + flex-shrink: 0; + transition: transform 0.3s ease; +} + +.\$news-card:hover .\$news-card-cover { + transform: scale(1.03); +} + +.\$news-card-state-badge { + position: absolute; + top: 10px; + right: 10px; + display: inline-flex; + align-items: center; + gap: 4px; + background-color: rgba(0,0,0,0.7); + color: var(--rhpz-orange); + font-size: 0.72rem; + font-weight: 600; + padding: 3px 8px; + border: 1px solid rgba(255,115,0,0.3); +} + +.\$news-card-body { + padding: 16px; + display: flex; + flex-direction: column; + flex: 1; + gap: 8px; +} + +.\$news-card-title { + font-size: 1rem; + font-weight: 600; + color: var(--text); + line-height: 1.3; + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.\$news-card-excerpt { + font-size: 0.82rem; + color: var(--text2); + line-height: 1.5; + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + flex: 1; +} + +.\$news-card-meta { + display: flex; + align-items: center; + gap: 12px; + font-size: 0.75rem; + color: var(--text2); + margin-top: auto; + padding-top: 8px; + border-top: 1px solid var(--border); +} + +.\$news-card-meta span { + display: flex; + align-items: center; + gap: 4px; +} + +@media (max-width: 600px) { + .news-hero { height: 240px; } + .news-hero-title { font-size: 1.4rem; } + .news-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 768px) { + + .\$news-header .\$news-meta { + gap: 12px; + font-size: 0.85rem; + } + + .\$news-layout { + flex-direction: column; + gap: 20px; + padding: 20px; + } + + .\$news-sidebar { + width: 100%; + } + + .\$news-content { + padding: 25px; + font-size: 1rem; + } + + .\$news-body-text { + font-size: 1rem; + } + + .\$sidebar-block { + padding: 15px; + } +} + +@media (max-width: 600px) { + .\$news-header .\$news-meta { + gap: 8px; + font-size: 0.8rem; + flex-wrap: wrap; + } + + .\$news-header .\$meta-item { + padding: 3px 8px; + font-size: 0.75rem; + } + + .\$news-layout { + gap: 15px; + padding: 15px; + } + + .\$news-main-content { + min-width: 0; + } + + .\$news-content { + padding: 15px; + font-size: 0.95rem; + } + + .\$news-body-text { + font-size: 0.95rem; + margin-bottom: 12px; + } + + .\$news-body-text p { + margin-bottom: 15px; + } + + .\$news-sidebar { + width: 100%; + gap: 15px; + } + + .\$sidebar-block { + padding: 12px; + } + + .\$sidebar-block h3 { + font-size: 0.95rem; + } + + .\$sidebar-block p { + font-size: 0.9rem; + } + + .\$news-card-title { + font-size: 0.9rem; + } +} + +@media (max-width: 420px) { + + .\$news-header .\$news-meta { + font-size: 0.75rem; + } + + .\$news-layout { + padding: 12px; + } + + .\$news-content { + padding: 12px; + } +} + + +/* File: resources/css/layout/responsive.css */ + +@media (max-width: 768px) { + :root { + --menu-size: 280px; + } + + #menu { + position: fixed; + left: 0; + top: 60px; + height: calc(100vh - 60px); + transform: translateX(-100%); + transition: transform 0.3s ease-in-out; + z-index: 999; + box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5); + } + + #menu.mobile-open { + transform: translateX(0); + } + + #app.menu-open::before { + content: ''; + position: fixed; + top: 60px; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 998; + } + + .\$mobile-toggle { + display: flex !important; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + } + + #main-wrapper { + width: 100%; + } + + #content { + padding: 20px; + } + + #topbar { + padding: 0 10px; + gap: 10px; + } + + .\$search-bar { + width: 100%; + max-width: 250px; + } + + .\$search-scope-select { + font-size: 0.7rem; + padding: 6px 8px; + } + + .\$topbar-actions { + gap: 4px; + overflow-x: auto; + flex-shrink: 1; + } + + .\$topbar-actions .\$btn { + flex-shrink: 0; + padding: 6px 8px; + } + + .\$vertical-separator { + height: 30px; + } +} + +@media (max-width: 600px) { + :root { + --menu-size: 240px; + } + + #content { + padding: 15px; + } + + #topbar { + padding: 0 8px; + height: 55px; + } + + #topbar { + flex-wrap: wrap; + gap: 8px; + } + + .\$search-bar { + max-width: 100%; + order: 3; + width: 100%; + margin-top: 8px; + } + + .\$topbar-actions { + gap: 2px; + max-width: 100%; + } + + .\$topbar-actions .\$btn { + padding: 4px 6px; + font-size: 0.9rem; + } + + .\$search-scope-select { + display: none; + } + + .\$search-bar input { + padding: 4px; + font-size: 0.9rem; + } + + #menu { + width: 240px; + } + + .\$menu-title { + display: none; + } + + .\$menu-logo { + width: 40px; + height: 40px; + } + + .\$menu-header { + padding: 8px; + justify-content: center; + } + + .\$menu-user-info .\$username { + font-size: 0.9rem; + } +} + +@media (max-width: 420px) { + :root { + --menu-size: 200px; + } + + #content { + padding: 12px; + } + + #topbar { + padding: 0 6px; + height: 50px; + } + + .\$mobile-toggle { + width: 35px; + height: 35px; + } + + .\$topbar-actions .\$btn { + padding: 3px 4px; + font-size: 0.8rem; + } + + .\$vertical-separator { + display: none; + } + + .\$menu-item { + padding: 8px 12px; + font-size: 0.9rem; + } + + .\$menu-group-title { + padding: 0 12px; + font-size: 0.65rem; + } + + .\$menu-user-info { + display: none; + } +} + +@media (max-height: 500px) and (max-width: 768px) { + #topbar { + height: 50px; + } + + #content { + padding: 12px; + } + + .\$menu-header { + padding: 8px; + } +} + +@media (max-width: 1024px) and (min-width: 769px) { + :root { + --menu-size: 240px; + } + + #content { + padding: 25px; + } + + .\$search-bar { + width: 250px; + } +} + +@media (min-width: 769px) { + + #menu { + transform: translateX(0) !important; + position: relative !important; + top: auto !important; + height: auto !important; + box-shadow: none !important; + } + + + #app.menu-open::before { + display: none; + } + + + .\$mobile-toggle { + display: none !important; + } + + + #app { + display: flex; + } + + #main-wrapper { + width: calc(100% - var(--menu-size)); + flex-grow: 1; + } +} + + +@media (hover: none) and (pointer: coarse) { + + .btn, + .menu-item, + button { + min-height: 44px; + min-width: 44px; + } + + + .\$btn { + padding: 8px 12px; + } + + + .\$menu-item:hover { + background-color: var(--bg2); + } +} + + +/* File: resources/css/layout/reviews.css */ +.\$review-section-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 10px; +} + +.\$review-header-right { + display: flex; + align-items: center; + gap: 10px; +} + +.\$review-avg-badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.85rem; + font-weight: 600; + color: var(--rhpz-orange); + background-color: rgba(255,115,0,.1); + border: 1px solid rgba(255,115,0,.3); + padding: 4px 10px; +} + +.\$review-avg-badge--lg { + font-size: 1rem; + padding: 8px 16px; +} + +.\$review-avg-count { + color: var(--text2); + font-weight: 400; + font-size: 0.85rem; +} + +.\$star-rating-display { + display: inline-flex; + align-items: center; + gap: 1px; +} + +.star-rating-display .star-filled { color: var(--rhpz-orange); fill: var(--rhpz-orange); } +.star-rating-display .star-empty { color: var(--border); } + +.\$review-title { + font-size: 0.98rem; + font-weight: 700; + color: var(--text); + margin-bottom: 4px; +} + +.\$star-input { + display: flex; + gap: 4px; +} + +.star-input-icon.star-filled svg { color: var(--rhpz-orange); fill: var(--rhpz-orange); } +.star-input-icon.star-empty svg { color: var(--border); } + +.\$star-input-icon { + cursor: pointer; + transition: transform 0.1s; +} + +.\$star-input-icon:hover { + transform: scale(1.15); +} + +.\$reviews-page-header { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 25px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border); +} + +.\$reviews-back-link { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + color: var(--text2); + text-decoration: none; + width: fit-content; +} + +.reviews-back-link:hover { color: var(--rhpz-orange); } + +.\$reviews-page-title { + font-size: 1.4rem; + font-weight: 600; + color: var(--text); +} + +@media (max-width: 768px) { + .\$review-section-header { + gap: 8px; + margin-bottom: 8px; + } + + .\$review-header-right { + gap: 8px; + flex-wrap: wrap; + } + + .\$review-avg-badge { + font-size: 0.8rem; + padding: 3px 8px; + } + + .\$review-avg-badge--lg { + font-size: 0.95rem; + padding: 6px 12px; + } + + .\$reviews-page-header { + gap: 8px; + margin-bottom: 20px; + padding-bottom: 15px; + } + + .\$reviews-page-title { + font-size: 1.15rem; + } + + .\$review-title { + font-size: 0.9rem; + } +} + +@media (max-width: 600px) { + .\$review-avg-badge { + font-size: 0.75rem; + padding: 2px 6px; + } + + .\$reviews-page-title { + font-size: 1rem; + } + + .\$review-title { + font-size: 0.85rem; + } + + .\$star-rating-display { + gap: 0; + } + + .\$star-input { + gap: 3px; + } +} + + +/* File: resources/css/layout/submit.css */ +.\$submit-hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + background-color: var(--bg2); + border-bottom: 1px solid var(--border); + padding: 40px 36px 32px; +} + +.\$submit-eyebrow { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--rhpz-orange); + background: rgba(255,115,0,.1); + border: 1px solid rgba(255,115,0,.3); + padding: 3px 10px; + margin-bottom: 16px; +} + +.\$submit-hero-title { + font-size: 1.9rem; + font-weight: 300; + color: var(--text); + margin-bottom: 10px; + line-height: 1.25; +} + +.\$submit-hero-sub { + font-size: 0.9rem; + color: var(--text2); + max-width: 460px; + line-height: 1.65; +} + +.\$submit-review-note { + font-size: 0.8rem; + color: var(--text2); + border: 1px solid var(--border); + background: var(--bg3); + padding: 14px 18px; + max-width: 210px; + line-height: 1.6; + flex-shrink: 0; +} + +.submit-review-note strong { color: var(--rhpz-orange); } + +.submit-body { padding: 28px 36px 40px; } + +.\$submit-section-label { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text2); + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.\$submit-section-label::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); +} + +.\$submit-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-bottom: 30px; +} + +.\$submit-card { + display: flex; + flex-direction: column; + background: var(--bg2); + border: 1px solid var(--border); + text-decoration: none; + transition: border-color .15s, background .1s; +} + +.\$submit-card:hover { + border-color: var(--card-color); + background: var(--bg3); + text-decoration: none; +} + +.\$submit-card-top { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 20px 20px 16px; + border-bottom: 1px solid var(--border); +} + +.\$submit-card-icon { + width: 38px; + height: 38px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--card-bg); + border: 1px solid var(--card-border); + color: var(--card-color); +} + +.\$submit-card-title { + font-size: 0.95rem; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; +} + +.\$submit-card-desc { + font-size: 0.78rem; + color: var(--text2); + line-height: 1.55; +} + +.\$submit-card-bottom { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 20px; +} + +.\$submit-card-tag { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--card-color); + background: var(--card-bg); + border: 1px solid var(--card-border); + padding: 2px 7px; +} + +.\$submit-card-cta { + font-size: 0.75rem; + color: var(--text2); + display: flex; + align-items: center; + gap: 4px; + transition: color .15s; +} + +.submit-card:hover .submit-card-cta { color: var(--card-color); } + +.\$submit-news-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-bottom: 28px; +} + +.\$submit-news-card { + display: flex; + align-items: center; + gap: 14px; + background: var(--bg2); + border: 1px solid var(--border); + padding: 18px 22px; + text-decoration: none; + transition: border-color .15s, background .1s; +} + +.\$submit-news-card:hover { + border-color: var(--success); + background: var(--bg3); +} + +.submit-news-card--disabled { opacity: .5; cursor: not-allowed; } +.submit-news-card--disabled:hover { border-color: var(--border); background: var(--bg2); } + +.\$submit-news-icon { + width: 38px; + height: 38px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(129,199,132,.1); + border: 1px solid rgba(129,199,132,.3); + color: var(--success); +} + +.\$submit-news-title { + font-size: 0.95rem; + font-weight: 600; + color: var(--text); + margin-bottom: 3px; +} + +.submit-news-desc { font-size: 0.78rem; color: var(--text2); line-height: 1.55; } + +.\$submit-news-cta { + flex-shrink: 0; + font-size: 0.75rem; + color: var(--text2); + display: flex; + align-items: center; + gap: 4px; + transition: color .15s; +} + +.submit-news-card:hover .submit-news-cta { color: var(--success); } + +.\$submit-news-staff-note { + background: var(--bg2); + border: 1px solid var(--border); + padding: 18px 22px; + font-size: 0.8rem; + color: var(--text2); + line-height: 1.65; + display: flex; + flex-direction: column; + gap: 10px; +} + +.\$submit-news-staff-badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .6px; + color: var(--rhpz-orange); + background: rgba(255,115,0,.1); + border: 1px solid rgba(255,115,0,.3); + padding: 2px 8px; + width: fit-content; +} + +.submit-news-staff-note a { color: var(--text2); text-decoration: underline; } +.submit-news-staff-note a:hover { color: var(--text); } + +.\$submit-rules { + display: flex; + gap: 0; + background: var(--bg2); + border: 1px solid var(--border); +} + +.\$submit-rule { + flex: 1; + display: flex; + align-items: flex-start; + gap: 12px; + padding: 18px 22px; + border-right: 1px solid var(--border); + font-size: 0.8rem; + color: var(--text2); + line-height: 1.6; +} + +.submit-rule:last-child { border-right: none; } +.submit-rule strong { color: var(--text); } + +.\$submit-rule-num { + width: 22px; + height: 22px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255,115,0,.1); + border: 1px solid rgba(255,115,0,.3); + color: var(--rhpz-orange); + font-size: 0.72rem; + font-weight: 700; +} + +@media (max-width: 900px) { + .submit-hero { flex-direction: column; align-items: flex-start; } + .submit-grid { grid-template-columns: repeat(2, 1fr); } + .submit-rules { flex-direction: column; } + .submit-rule { border-right: none; border-bottom: 1px solid var(--border); } + .submit-rule:last-child { border-bottom: none; } +} + +@media (max-width: 768px) { + .\$submit-hero { + flex-direction: column; + gap: 20px; + padding: 25px 20px; + } + + .\$submit-grid { + grid-template-columns: 1fr; + gap: 15px; + } + + .\$submit-body { + padding: 25px 20px; + } + + .\$submit-rules { + gap: 0; + } + + .\$submit-rule { + padding: 15px; + } +} + +@media (max-width: 600px) { + .\$submit-hero, .\$submit-body { + padding-left: 15px; + padding-right: 15px; + } + + .submit-grid { grid-template-columns: 1fr; } + .submit-news-row { grid-template-columns: 1fr; } + .submit-review-note { max-width: 100%; } + + .\$submit-hero { + gap: 15px; + padding: 15px; + } + + .\$submit-body { + padding: 15px; + } + + .\$submit-rule { + padding: 12px; + font-size: 0.9rem; + } + + .\$submit-hero-title { + font-size: 1.3rem; + } + + .\$submit-grid > * { + margin-bottom: 10px; + } +} + /* File: resources/css/xenforo.css */ .\$xf-menu-user-avatar-fix { diff --git a/public/ZELDA.ips b/public/ZELDA.ips deleted file mode 100644 index 53748da..0000000 Binary files a/public/ZELDA.ips and /dev/null differ diff --git a/public/favicon.ico b/public/favicon.ico index e69de29..e6ee837 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/link.ips b/public/link.ips new file mode 100644 index 0000000..5f5f532 Binary files /dev/null and b/public/link.ips differ diff --git a/public/logo/plaza-logo-text.png b/public/logo/plaza-logo-text.png new file mode 100644 index 0000000..b41c6e3 Binary files /dev/null and b/public/logo/plaza-logo-text.png differ diff --git a/public/logo/plaza-logo-wide.png b/public/logo/plaza-logo-wide.png new file mode 100644 index 0000000..4bd308a Binary files /dev/null and b/public/logo/plaza-logo-wide.png differ diff --git a/public/logo/plaza-logo.png b/public/logo/plaza-logo.png new file mode 100644 index 0000000..3b05580 Binary files /dev/null and b/public/logo/plaza-logo.png differ diff --git a/public/rom-patcher-js/RomPatcher.webapp.js b/public/rom-patcher-js/RomPatcher.webapp.js index 6e2fedd..3e6ba3e 100644 --- a/public/rom-patcher-js/RomPatcher.webapp.js +++ b/public/rom-patcher-js/RomPatcher.webapp.js @@ -6,19 +6,19 @@ * 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 @@ -35,7 +35,7 @@ - switch to ES6 classes and modules? */ -const ROM_PATCHER_JS_PATH = './rom-patcher-js/'; +const ROM_PATCHER_JS_PATH = '../../rom-patcher-js/'; const RomPatcherWeb = (function () { const SCRIPT_DEPENDENCIES = [ @@ -198,7 +198,6 @@ const RomPatcherWeb = (function () { ZIPManager.unzipEmbededPatches(arrayBuffer, currentEmbededPatches); } else { const parsedPatch = _parseEmbededPatchInfo(embededPatchInfo); - currentEmbededPatches = [parsedPatch]; const option = document.createElement('option'); option.innerHTML = parsedPatch.name; @@ -612,6 +611,7 @@ const RomPatcherWeb = (function () { const containerOptionalPatches = document.createElement('div'); containerOptionalPatches.id = 'rom-patcher-container-optional-patches'; containerOptionalPatches.style.display = 'none'; + containerOptionalPatches.classList.add("form-group", "level", "form-type-of-checkboxes"); htmlSelectPatch.parentElement.appendChild(containerOptionalPatches); } else { const htmlInputFilePatch = htmlElements.get('input-file-patch'); @@ -1415,9 +1415,16 @@ const ZIPManager = (function (romPatcherWeb) { const optionalPatches = []; for (var i = 0; i < filteredEntries.length; i++) { const embededPatchInfo = embededPatchesInfo.find((embededPatchInfo) => embededPatchInfo.file === filteredEntries[i].filename); - if (embededPatchInfo && embededPatchInfo.optional) + if (embededPatchInfo && embededPatchInfo.optional ) optionalPatches.push(filteredEntries[i]); - else + else if( filteredEntries[i].filename.startsWith('optional_') ){ + embededPatchesInfo.push({ + file: filteredEntries[i].filename, + name: filteredEntries[i].filename.replace(/^optional_/, '').replace(/_/g, ' ').replace(/\.[^.]+$/, ''), + optional: true + }); + optionalPatches.push(filteredEntries[i]); + } else selectablePatches.push(filteredEntries[i]); } @@ -1446,6 +1453,7 @@ const ZIPManager = (function (romPatcherWeb) { const embededPatchInfo = embededPatchesInfo.find((embededPatchInfo) => embededPatchInfo.file === optionalPatches[i].filename); const checkbox = document.createElement('input'); + checkbox.classList.add('form-checkbox'); checkbox.type = 'checkbox'; checkbox.value = i; checkbox.checked = false; @@ -2164,4 +2172,4 @@ const ROM_PATCHER_LOCALE = { 'Invalid patch file': '無效的patch檔', 'Using big files is not recommended': '不建議使用大檔。' } -}; \ No newline at end of file +}; diff --git a/resources/css/app.css b/resources/css/app.css index 60437ec..fa6b501 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -5,6 +5,10 @@ @import './layout/content.css'; @import './layout/entry.css'; @import './layout/news.css'; +@import './layout/activity.css'; +@import './layout/submit.css'; +@import './layout/reviews.css'; +@import './layout/responsive.css'; @import './components/common.css'; @import './components/grid.css'; diff --git a/resources/css/base/variables.css b/resources/css/base/variables.css index 5dd4727..2117b58 100644 --- a/resources/css/base/variables.css +++ b/resources/css/base/variables.css @@ -28,6 +28,9 @@ /* Menu settings */ --menu-size: 260px; --menu-user-avatar-bg: #555; + + /* Gap */ + --gap: 15px; } .light-mode { diff --git a/resources/css/components/cards.css b/resources/css/components/cards.css index ba5d9f9..ca2a285 100644 --- a/resources/css/components/cards.css +++ b/resources/css/components/cards.css @@ -29,6 +29,7 @@ background-color: var(--bg2); border: 1px solid var(--border); display: flex; + min-width: 0; flex-direction: column; transition: transform 0.2s, border-color 0.2s; cursor: pointer; @@ -41,6 +42,7 @@ .entry-cover-wrapper { position: relative; aspect-ratio: 4/3; + min-width: 0; background-color: var(--bg); border-bottom: 1px solid var(--border); display: flex; @@ -80,6 +82,7 @@ font-size: 1.1rem; margin-bottom: 5px; line-height: 1.3; + word-wrap: break-word; } .entry-card-author { @@ -97,3 +100,83 @@ align-items: center; } } + +@media (max-width: 1024px) { + .stats-grid { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 15px; + margin-bottom: 25px; + } + + .stat-card { + padding: 15px; + gap: 12px; + } +} + +@media (max-width: 768px) { + .stats-grid { + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-bottom: 20px; + } + + .stat-card { + padding: 12px; + gap: 10px; + font-size: 0.9rem; + } + + .stat-card i { + width: 28px; + height: 28px; + } + + .entry-card-info { + padding: 12px; + } + + .entry-card-title { + font-size: 0.95rem; + margin-bottom: 4px; + } + + .entry-card-author { + font-size: 0.8rem; + margin-bottom: 8px; + } + + .entry-card-meta { + font-size: 0.75rem; + } +} + +@media (max-width: 600px) { + .stats-grid { + grid-template-columns: 1fr; + gap: 10px; + margin-bottom: 15px; + } + + .stat-card { + padding: 10px; + flex-direction: row; + } + + .entry-card { + &:hover { + transform: none; + } + } + + .entry-card-title { + font-size: 0.9rem; + } + + .entry-badge { + top: 5px; + right: 5px; + padding: 3px 6px; + font-size: 0.65rem; + } +} diff --git a/resources/css/components/common.css b/resources/css/components/common.css index c74aa62..1669a08 100644 --- a/resources/css/components/common.css +++ b/resources/css/components/common.css @@ -104,6 +104,11 @@ color: var(--text); border-color: var(--success2); } +.badge.yellow, .badge.utilities { + background-color: #fdeb0f; + color: #000; + border-color: #fdeb0f; +} .topbar-badge { position: absolute; @@ -141,6 +146,7 @@ .breadcrumb { margin-bottom: 15px; + flex-shrink: 0; } /* PAGE */ @@ -150,6 +156,7 @@ font-weight: 300; margin-bottom: 20px; color: var(--text); + flex-shrink: 0; } /* TEXTS */ @@ -188,3 +195,88 @@ border: none; cursor: pointer; } + +@media (max-width: 768px) { + .btn { + padding: 7px 12px; + font-size: 0.85rem; + gap: 6px; + } + + .block { + padding: 15px; + margin-bottom: 15px; + } + + .block-header { + font-size: 1.05rem; + margin-bottom: 12px; + padding-bottom: 8px; + } + + .page-title { + font-size: 1.5rem; + margin-bottom: 15px; + } + + .content-title { + margin: 20px 0 12px 0; + padding-left: 8px; + } + + .quote { + padding: 12px; + margin-top: 20px; + font-size: 0.95rem; + } + + .whisper { + margin-bottom: 12px; + font-size: 0.9rem; + } + + .breadcrumb { + font-size: 0.85rem; + } +} + +@media (max-width: 600px) { + .btn { + padding: 6px 10px; + font-size: 0.8rem; + gap: 4px; + justify-content: center; + } + + .btn.primary, .btn.danger, .btn.success { + width: 100%; + } + + .block { + padding: 12px; + margin-bottom: 12px; + } + + .block-header { + font-size: 0.95rem; + margin-bottom: 10px; + padding-bottom: 6px; + } + + .page-title { + font-size: 1.2rem; + margin-bottom: 12px; + } + + .badge { + padding: 2px 6px; + font-size: 0.7rem; + } + + .topbar-badge { + min-width: 16px; + height: 16px; + padding: 0 3px; + font-size: 0.6rem; + } +} diff --git a/resources/css/components/database.css b/resources/css/components/database.css index c406ad6..905cb07 100644 --- a/resources/css/components/database.css +++ b/resources/css/components/database.css @@ -259,6 +259,10 @@ flex-direction: column; } + .database-wrapper { + flex-direction: column; + } + .database-filters { width: 100%; display: grid; @@ -275,19 +279,63 @@ } } +@media (max-width: 768px) { + .database-search { + gap: 8px; + margin-bottom: 15px; + flex-wrap: wrap; + } + + .database-wrapper { + flex-direction: column; + gap: 15px; + } + + .database-filters { + width: 100%; + grid-template-columns: 1fr; + order: -1; + margin-bottom: 10px; + } + + .database-filter-group { + padding: 12px 0; + } + + .grid-entries { + grid-template-columns: repeat(3, 1fr); + gap: 15px; + } +} + @media (max-width: 600px) { + .database-search { + flex-direction: column; + } + .database-filters { grid-template-columns: 1fr; } .grid-entries { grid-template-columns: repeat(2, 1fr); + gap: 12px; + } + + .database-filter-group { + padding: 10px 0; } } @media (max-width: 420px) { .grid-entries { grid-template-columns: 1fr; + gap: 10px; + } + + .database-search input { + font-size: 0.85rem; + padding: 6px 8px; } } diff --git a/resources/css/components/drafts.css b/resources/css/components/drafts.css index d2ce20d..2898644 100644 --- a/resources/css/components/drafts.css +++ b/resources/css/components/drafts.css @@ -93,7 +93,7 @@ font-weight: 600; color: var(--text); margin-bottom: 6px; - white-space: nowrap; + white-space: normal; overflow: hidden; text-overflow: ellipsis; } @@ -158,3 +158,103 @@ white-space: nowrap; } } + +@media (max-width: 768px) { + .drafts-count { + font-size: 0.8rem; + margin-bottom: 12px; + padding-bottom: 8px; + } + + .drafts-item { + gap: 15px; + padding: 15px; + } + + .drafts-cover { + width: 70px; + height: 70px; + } + + .drafts-top { + gap: 12px; + } + + .drafts-title { + font-size: 0.95rem; + } + + .drafts-meta { + font-size: 0.8rem; + } + + .drafts-actions { + gap: 6px; + } + + .drafts-actions .btn { + padding: 6px 10px; + font-size: 0.8rem; + } +} + +@media (max-width: 600px) { + .drafts-empty { + padding: 60px 15px; + gap: 12px; + } + + .drafts-empty h3 { + font-size: 1rem; + } + + .drafts-empty p { + font-size: 0.85rem; + } + + .drafts-item { + flex-direction: column; + gap: 12px; + padding: 12px; + } + + .drafts-cover { + width: 100%; + height: 150px; + } + + .drafts-top { + flex-direction: column; + gap: 10px; + } + + .drafts-title { + font-size: 0.9rem; + } + + .drafts-meta { + font-size: 0.75rem; + } + + .drafts-progress { + flex-direction: column; + gap: 8px; + } + + .drafts-progress-bar { + width: 100%; + } + + .drafts-actions { + flex-direction: row; + gap: 6px; + flex-wrap: wrap; + } + + .drafts-actions .btn { + flex: 1; + min-width: 80px; + padding: 5px 8px; + font-size: 0.75rem; + } +} diff --git a/resources/css/components/forms.css b/resources/css/components/forms.css index f1fa4ff..4f75308 100644 --- a/resources/css/components/forms.css +++ b/resources/css/components/forms.css @@ -349,6 +349,50 @@ flex-direction: column; gap: 5px; } +.gallery-item { + position: relative; + cursor: grab; + transition: opacity 0.2s, transform 0.15s; + user-select: none; +} + +.gallery-item:active { cursor: grabbing; } + +.gallery-item--dragging { + opacity: 0.4; + transform: scale(0.97); +} + +.gallery-drag-handle { + position: absolute; + top: 4px; + left: 4px; + z-index: 10; + background-color: rgba(0,0,0,0.6); + color: #fff; + padding: 3px 4px; + display: flex; + align-items: center; + cursor: grab; + opacity: 0; + transition: opacity 0.15s; +} + +.gallery-item:hover .gallery-drag-handle { opacity: 1; } + +.gallery-order-badge { + position: absolute; + bottom: 4px; + left: 4px; + z-index: 10; + background-color: rgba(0,0,0,0.7); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + padding: 2px 6px; + min-width: 20px; + text-align: center; +} .authors-list { display: grid; grid-template-columns: repeat(4, 1fr); @@ -464,6 +508,17 @@ flex-direction: row; gap: 15px; } + +@media (max-width: 600px) { + .upload-item-actions { + flex-direction: column; + gap: 8px; + } + + .upload-item-actions .btn { + width: 100%; + } +} .file-state-icon { width: 18px; height: 18px; } .file-state-icon--public { color: var(--success); } .file-state-icon--private { color: var(--text2); } @@ -528,3 +583,127 @@ } .author-search-item:hover { background-color: var(--bg3); } + +.game-selector-mode { + display: flex; + gap: 0; + margin-bottom: 15px; + border: 1px solid var(--border); +} + +.game-selector-mode-btn { + display: flex; + align-items: center; + gap: 7px; + padding: 8px 14px; + background: none; + border: none; + border-right: 1px solid var(--border); + color: var(--text2); + font-size: 0.85rem; + cursor: pointer; + font-family: var(--font-family); + transition: background-color 0.1s, color 0.1s; +} + +.game-selector-mode-btn:last-child { + border-right: none; +} + +.game-selector-mode-btn:hover { + background-color: var(--bg3); + color: var(--text); +} + +.game-selector-mode-btn.active { + background-color: var(--bg3); + color: var(--rhpz-orange); + border-bottom: 2px solid var(--rhpz-orange); +} + +.game-selector-platform-only { + grid-column: span 1; +} + +@media (max-width: 768px) { + .form-group.level { + padding: 20px; + margin-bottom: 25px; + } + + .form-group-title { + font-size: 1rem; + margin-bottom: 15px; + padding-bottom: 8px; + } + + .form-group label, .form-label { + margin-bottom: 6px; + font-size: 0.9rem; + } + + .form-input, .form-select, .form-textarea, .form-field { + padding: 8px 10px; + font-size: 0.9rem; + } + + .form-textarea { + min-height: 100px; + } + + .game-selector-mode { + flex-direction: column; + gap: 0; + } + + .game-selector-mode-btn { + padding: 10px 12px; + border-right: none; + border-bottom: 1px solid var(--border); + } + + .game-selector-mode-btn:last-child { + border-bottom: none; + } + + .submit, .submit-level, .main-image-grid { + flex-direction: column; + } + + .grid-hashes { + grid-template-columns: 1fr; + } + + .hash-first { + display: none; + } +} + +@media (max-width: 600px) { + .form-group { + margin-bottom: 15px; + } + + .form-group.level { + padding: 15px; + margin-bottom: 20px; + } + + .form-group-title { + font-size: 0.95rem; + margin-bottom: 12px; + } + + .form-group label, .form-label { + font-size: 0.85rem; + } + + .form-input, .form-select, .form-textarea, .form-field { + padding: 6px 8px; + font-size: 0.85rem; + } + + .form-error-text { + font-size: 0.8rem; + } +} diff --git a/resources/css/components/hovercard.css b/resources/css/components/hovercard.css index 5260bc8..54d8b24 100644 --- a/resources/css/components/hovercard.css +++ b/resources/css/components/hovercard.css @@ -1,9 +1,10 @@ .hovercard-overlay { - position: absolute; - z-index: 2000; + position: fixed; + z-index: 3500; background-color: var(--bg2); border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + pointer-events: auto; } .hovercard-overlay-loading { @@ -117,3 +118,35 @@ justify-content: center; font-size: 0.82rem; } + +@media (max-width: 768px) { + .hovercard { + width: 260px; + } + + .hovercard-actions { + gap: 6px; + } + + .hovercard-actions .btn { + font-size: 0.75rem; + padding: 6px 8px; + } +} + +@media (max-width: 600px) { + .hovercard { + width: calc(100vw - 40px); + max-width: 280px; + } + + .hovercard-actions { + flex-direction: column; + gap: 6px; + } + + .hovercard-actions .btn { + width: 100%; + justify-content: center; + } +} diff --git a/resources/css/components/modal.css b/resources/css/components/modal.css index 934b109..ec8e6eb 100644 --- a/resources/css/components/modal.css +++ b/resources/css/components/modal.css @@ -48,6 +48,6 @@ } -.modal-content { +.modal-content, .modal-body { padding: 20px; } diff --git a/resources/css/components/modcp.css b/resources/css/components/modcp.css index e5cddb9..2c5912b 100644 --- a/resources/css/components/modcp.css +++ b/resources/css/components/modcp.css @@ -318,3 +318,357 @@ .modcp-list-item-edit--game .form-input { min-width: 180px; flex: 2; } .modcp-list-item-edit--game .form-select { flex: 1; min-width: 120px; } + +.log-filters { + margin-bottom: 16px; + background-color: var(--bg3); + border: 1px solid var(--border); +} + +.log-filters-main { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + flex-wrap: wrap; +} + +.log-search-wrap { + flex: 1; + min-width: 200px; + position: relative; + display: flex; + align-items: center; +} + +.log-search-wrap i { + position: absolute; + left: 10px; + color: var(--text2); + pointer-events: none; +} + +.log-search-wrap .form-input { padding-left: 30px; } + +.log-select { min-width: 130px; } + +.log-filter-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--rhpz-orange); + flex-shrink: 0; +} + +.log-filters-extra { + border-top: 1px solid var(--border); + padding: 12px 14px; +} + +.log-filters-extra-inner { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.log-filter-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.log-filter-label { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text2); +} + +.log-transition-enter { transition: all .15s ease; } +.log-transition-leave { transition: all .1s ease; } + +.log-results-bar { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.78rem; + color: var(--text2); + margin-bottom: 10px; + padding: 0 2px; +} + +.log-loading { opacity: 0.5; } + +.log-item { align-items: flex-start; padding: 11px 14px; } +.log-item--open { background-color: var(--bg3); } + +.log-event-dot { + width: 26px; + height: 26px; + flex-shrink: 0; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-top: 2px; + border: 1px solid var(--border); + background-color: var(--bg3); + color: var(--text2); +} + +.log-event-dot--created { + background-color: rgba(129,199,132,.1); + border-color: rgba(129,199,132,.35); + color: var(--success); +} + +.log-event-dot--updated { + background-color: rgba(255,115,0,.1); + border-color: rgba(255,115,0,.35); + color: var(--rhpz-orange); +} + +.log-event-dot--deleted { + background-color: rgba(229,115,115,.1); + border-color: rgba(229,115,115,.35); + color: var(--error); +} + +.log-channel-badge { + display: inline-flex; + align-items: center; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 1px 6px; + background-color: rgba(255,255,255,.05); + border: 1px solid var(--border); + color: var(--text2); +} + +.log-id { color: var(--text2); font-size: 0.78rem; } +.log-sep { color: var(--border); } + +.log-item-right { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + margin-left: auto; +} + +.log-timestamp { + font-size: 0.75rem; + color: var(--text2); + white-space: nowrap; +} + +.log-expand-btn { padding: 4px 7px; } + +.log-properties { + background-color: var(--bg); + border-bottom: 1px solid var(--border); + padding: 14px 14px 14px 54px; +} + +.log-diff-label { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.7px; + color: var(--text2); + margin-bottom: 8px; +} + +.log-diff { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.log-diff th { + text-align: left; + padding: 5px 10px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text2); + border-bottom: 1px solid var(--border); +} + +.log-diff td { + padding: 5px 10px; + border-bottom: 1px solid var(--border); + vertical-align: top; + max-width: 300px; + overflow-wrap: break-word; +} + +.log-diff tr:last-child td { border-bottom: none; } + +.log-diff-key { + font-size: 0.78rem; + font-weight: 600; + color: var(--text); + width: 160px; + white-space: nowrap; +} + +.log-diff-old-head { color: var(--error) !important; } +.log-diff-new-head { color: var(--success) !important; } + +.log-diff-old { + color: var(--error); + background-color: rgba(229,115,115,.05); +} + +.log-diff-new { + color: var(--success); + background-color: rgba(129,199,132,.05); +} + +.log-raw { + font-family: monospace; + font-size: 0.78rem; + color: var(--text2); + background-color: var(--bg2); + border: 1px solid var(--border); + padding: 10px 12px; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; +} + +.log-pagination { + padding: 14px 0 4px; + border-top: 1px solid var(--border); +} + +@media (max-width: 1024px) { + .modcp-wrapper { + min-height: auto; + } + + .modcp-sidebar { + width: 200px; + margin-right: 10px; + } + + .modcp-content { + padding: 20px; + } + + .modcp-page-title { + font-size: 1.15rem; + } +} + +@media (max-width: 768px) { + .modcp-wrapper { + flex-direction: column; + gap: 0; + } + + .modcp-sidebar { + width: 100%; + flex-shrink: 1; + position: relative; + top: auto; + align-self: auto; + margin-right: 0; + margin-bottom: 15px; + border: 1px solid var(--border); + max-height: 300px; + overflow-y: auto; + } + + .modcp-sidebar-header { + padding: 12px 14px; + font-size: 0.8rem; + } + + .modcp-nav-label { + padding: 6px 14px 3px; + font-size: 0.65rem; + } + + .modcp-nav-item { + padding: 6px 14px; + font-size: 0.8rem; + gap: 8px; + } + + .modcp-content { + padding: 15px; + } + + .modcp-page-title { + font-size: 1rem; + margin-bottom: 12px; + } + + .modcp-page-actions { + flex-direction: row; + gap: 8px; + } + + .modcp-table { + font-size: 0.85rem; + } + + .modcp-table th, .modcp-table td { + padding: 8px; + } + + .modcp-table tbody tr { + height: auto; + } +} + +@media (max-width: 600px) { + .modcp-sidebar { + max-height: 200px; + } + + .modcp-sidebar-header { + padding: 10px 12px; + font-size: 0.75rem; + } + + .modcp-nav-item { + padding: 5px 12px; + font-size: 0.75rem; + } + + .modcp-content { + padding: 12px; + } + + .modcp-page-title { + font-size: 0.95rem; + } + + .modcp-table { + font-size: 0.8rem; + overflow-x: auto; + display: block; + } + + .modcp-table th, .modcp-table td { + padding: 6px; + } + + .log-diff-key { + width: auto; + white-space: normal; + } + + .log-raw { + font-size: 0.7rem; + padding: 8px 10px; + } +} diff --git a/resources/css/components/notifications.css b/resources/css/components/notifications.css index 728141c..58d093b 100644 --- a/resources/css/components/notifications.css +++ b/resources/css/components/notifications.css @@ -21,6 +21,28 @@ } } +@media (max-width: 768px) { + .notifications, .conversations { + position: fixed; + width: calc(100% - 30px); + max-width: 340px; + right: 15px; + top: auto; + bottom: 15px; + max-height: calc(100vh - 130px); + z-index: 3000 !important; + } +} + +@media (max-width: 600px) { + .notifications, .conversations { + width: calc(100% - 20px); + right: 10px; + bottom: 10px; + max-width: 100%; + } +} + @keyframes dropdown-enter { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } diff --git a/resources/css/components/queue.css b/resources/css/components/queue.css index 5848bc0..1496ecf 100644 --- a/resources/css/components/queue.css +++ b/resources/css/components/queue.css @@ -185,3 +185,82 @@ border: 1px solid var(--border); } +@media (max-width: 768px) { + .queue-item { + padding: 15px; + margin-bottom: 15px; + } + + .queue-item-header { + flex-direction: column; + gap: 12px; + } + + .queue-item-title { + font-size: 1rem; + } + + .queue-item-meta { + font-size: 0.8rem; + } + + .queue-item-actions-header { + gap: 6px; + flex-wrap: wrap; + width: 100%; + } + + .timeline { + font-size: 0.85rem; + } + + .timeline-container { + padding: 12px 15px; + } + + .queue-mod-actions { + flex-direction: column; + gap: 6px; + } +} + +@media (max-width: 600px) { + .queue-empty { + padding: 60px 15px; + font-size: 0.9rem; + } + + .queue-item { + padding: 12px; + border-left-width: 3px; + } + + .queue-item-title { + font-size: 0.95rem; + } + + .queue-item-meta { + font-size: 0.75rem; + } + + .queue-item-actions-header { + width: 100%; + } + + .timeline { + font-size: 0.8rem; + } + + .timeline-container { + padding: 10px 12px; + } + + .queue-mod-actions { + flex-direction: column; + } + + .queue-mod-actions .btn { + width: 100%; + } +} + diff --git a/resources/css/components/settings.css b/resources/css/components/settings.css index 19cf473..f20fcda 100644 --- a/resources/css/components/settings.css +++ b/resources/css/components/settings.css @@ -9,6 +9,29 @@ z-index: 2000; } +@media (max-width: 768px) { + .settings-dropdown { + position: fixed; + width: calc(100% - 30px); + max-width: 240px; + right: 15px; + top: auto; + bottom: 15px; + max-height: calc(100vh - 130px); + overflow-y: auto; + z-index: 3000 !important; + } +} + +@media (max-width: 600px) { + .settings-dropdown { + width: calc(100% - 20px); + right: 10px; + bottom: 10px; + max-width: 100%; + } +} + .settings-header { padding: 12px 16px; border-bottom: 1px solid var(--border); diff --git a/resources/css/components/tools.css b/resources/css/components/tools.css index 2ad0697..a3c762b 100644 --- a/resources/css/components/tools.css +++ b/resources/css/components/tools.css @@ -15,6 +15,62 @@ .patcher-grid { grid-template-columns: 1fr; } + + .patcher-container { + padding: 20px; + } + + .patcher-dropzone { + padding: 40px 15px; + gap: 12px; + } + + .embed-patch-box { + padding: 20px; + height: auto; + } + + .embed-patch-box-icon { + gap: 12px; + } + + .embed-patch-box-icon-block { + width: 40px; + height: 40px; + } +} + +@media (max-width: 600px) { + .patcher-container { + padding: 15px; + margin-bottom: 15px; + } + + .patcher-grid { + gap: 15px; + } + + .patcher-dropzone { + padding: 30px 12px; + gap: 10px; + font-size: 0.9rem; + } + + .patcher-status-box { + margin-top: 15px; + padding: 12px; + font-size: 0.9rem; + } + + .embed-patch-box { + padding: 15px; + gap: 12px; + } + + .btn:disabled { + padding: 6px 8px; + font-size: 0.8rem; + } } .patcher-dropzone { diff --git a/resources/css/layout/activity.css b/resources/css/layout/activity.css new file mode 100644 index 0000000..54d7b07 --- /dev/null +++ b/resources/css/layout/activity.css @@ -0,0 +1,555 @@ + +.activity-hero-excerpt { + font-size: 0.9rem; + color: rgba(255,255,255,0.75); + margin-bottom: 12px; + line-height: 1.5; + max-width: 600px; +} + +.activity-tl-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 1px solid var(--border); + gap: 15px; + flex-wrap: wrap; +} + +.activity-tl-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 1.15rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.activity-tl-filters { + display: flex; + gap: 5px; + flex-wrap: wrap; +} + +.activity-tl-filter { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 12px; + background: none; + border: 1px solid var(--border); + color: var(--text2); + font-size: 0.8rem; + cursor: pointer; + font-family: var(--typography); + transition: all 0.1s; +} + +.activity-tl-filter:hover { background-color: var(--bg3); color: var(--text); } +.activity-tl-filter.active { + background-color: var(--bg3); + border-color: var(--rhpz-orange); + color: var(--rhpz-orange); +} + +.activity-day-sep { + display: flex; + align-items: center; + gap: 10px; + padding-left: 54px; + margin: 20px 0 12px; +} + +.activity-day-label { + font-size: 0.72rem; + font-weight: 600; + color: var(--text2); + text-transform: uppercase; + letter-spacing: 0.8px; + white-space: nowrap; +} + +.activity-day-line { + flex: 1; + height: 1px; + background-color: var(--border); +} + +.activity-tl-item { + display: flex; + gap: 0; + margin-bottom: 2px; +} + +.activity-tl-left { + width: 54px; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + padding-top: 12px; +} + +.activity-tl-dot { + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid var(--border); + background-color: var(--bg2); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + z-index: 1; +} + +.activity-tl-dot--entry { + background-color: rgba(255,115,0,0.1); + border-color: rgba(255,115,0,0.4); + color: var(--rhpz-orange); +} + +.activity-tl-dot--news, .activity-tl-dot--review { + background-color: rgba(129,199,132,0.1); + border-color: rgba(129,199,132,0.4); + color: var(--success); +} + +.activity-tl-dot--message, .activity-tl-dot--thread, .activity-tl-dot--club { + background-color: rgba(25,118,210,0.1); + border-color: rgba(25,118,210,0.4); + color: var(--info); +} + +.activity-tl-line { + width: 1px; + flex: 1; + background-color: var(--border); + margin-top: 4px; + min-height: 16px; +} + +.activity-tl-item:last-of-type .activity-tl-line { display: none; } + +.activity-tl-card { + flex: 1; + display: flex; + align-items: center; + gap: 12px; + background-color: var(--bg2); + border: 1px solid var(--border); + padding: 10px 14px; + margin-bottom: 8px; + text-decoration: none; + transition: border-color 0.15s, background-color 0.1s; + min-width: 0; +} + +.activity-tl-card:hover { + border-color: var(--rhpz-orange); + background-color: var(--bg3); + text-decoration: none; +} + +.activity-tl-thumb { + width: 52px; + height: 52px; + flex-shrink: 0; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--bg3); + border: 1px solid var(--border); +} + +.activity-tl-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.activity-tl-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.activity-tl-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; + padding: 2px 7px; + width: fit-content; +} + +.activity-tl-badge--entry { + background-color: rgba(255,115,0,0.1); + color: var(--rhpz-orange); + border: 1px solid rgba(255,115,0,0.25); +} + +.activity-tl-badge--news, .activity-tl-badge--review { + background-color: rgba(129,199,132,0.1); + color: var(--success); + border: 1px solid rgba(129,199,132,0.25); +} + +.activity-tl-badge--message, .activity-tl-badge--thread, .activity-tl-dot--club { + background-color: rgba(25,118,210,0.1); + color: var(--info); + border: 1px solid rgba(25,118,210,0.25); +} + +.activity-tl-card-title { + font-size: 0.92rem; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.3; +} + +.activity-tl-card-description { + font-size: 0.8rem; + color: var(--text2); + white-space: nowrap; + text-overflow: ellipsis; + line-height: 1.3; +} + +.activity-tl-meta { + display: flex; + align-items: center; + gap: 10px; + font-size: 0.75rem; + color: var(--text2); + flex-wrap: wrap; +} + +.activity-tl-meta span { + display: flex; + align-items: center; + gap: 3px; +} + +.activity-tl-time { + font-size: 0.72rem; + color: var(--text2); + white-space: nowrap; + flex-shrink: 0; + align-self: center; +} + +.activity-tl-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 60px; + color: var(--text2); + text-align: center; + padding-left: 54px; +} + +@media (max-width: 600px) { + .activity-tl-header { flex-direction: column; align-items: flex-start; } + .activity-tl-thumb { display: none; } + .activity-day-sep { padding-left: 44px; } + .activity-tl-left { width: 44px; } + + .activity-tl-date { + font-size: 0.75rem; + } + + .activity-tl-content-title { + font-size: 0.9rem; + } +} + +@media (max-width: 768px) { + .activity-timeline { + padding-left: 50px; + } + + .activity-tl-left { + width: 40px; + } + + .activity-tl-header { + gap: 10px; + } + + .activity-tl-date { + font-size: 0.8rem; + } +} + +.home-section { + margin-bottom: 30px; +} + +.home-section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); + margin-bottom: 14px; +} + +.home-section-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 1.05rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.home-section-more { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + color: var(--text2); + border: 1px solid var(--border); + padding: 4px 10px; + text-decoration: none; + transition: color 0.1s, border-color 0.1s; +} + +.home-section-more:hover { + color: var(--rhpz-orange); + border-color: var(--rhpz-orange); +} + +.news-strip { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 10px; +} + +.news-strip-card { + display: flex; + flex-direction: column; + background-color: var(--bg2); + border: 1px solid var(--border); + text-decoration: none; + overflow: hidden; + transition: border-color 0.15s; +} + +.news-strip-card:hover { border-color: var(--rhpz-orange); text-decoration: none; } + +.news-strip-cover { + height: 110px; + background-color: var(--bg3); + background-size: cover; + background-position: center; + position: relative; + flex-shrink: 0; +} + +.news-strip-date { + position: absolute; + bottom: 6px; + left: 8px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255,255,255,0.8); + background: rgba(0,0,0,0.55); + padding: 2px 6px; + border: 1px solid rgba(255,255,255,0.07); +} + +.news-strip-body { + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; +} + +.news-strip-badge { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--success); + background: rgba(129,199,132,0.1); + border: 1px solid rgba(129,199,132,0.25); + padding: 1px 6px; + width: fit-content; +} + +.news-strip-title { + font-size: 0.85rem; + font-weight: 600; + color: var(--text); + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin: 0; +} + +.news-strip-meta { + font-size: 0.72rem; + color: var(--text2); + margin-top: auto; +} + +.featured-entries-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; +} + +.featured-entry-card { + display: flex; + flex-direction: column; + background-color: var(--bg2); + border: 1px solid var(--border); + text-decoration: none; + overflow: hidden; + transition: border-color 0.15s; +} + +.featured-entry-card:hover { border-color: var(--rhpz-orange); text-decoration: none; } + +.featured-entry-cover { + height: 80px; + background-color: var(--bg3); + position: relative; + flex-shrink: 0; + overflow: hidden; +} + +.featured-entry-cover img { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.85; +} + +.featured-entry-star { + position: absolute; + top: 6px; + right: 6px; + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + background: rgba(255,115,0,0.9); + color: #111; + padding: 2px 6px; + border: 1px solid rgba(255,115,0,0.5); + display: flex; + align-items: center; + gap: 3px; +} + +.featured-entry-body { + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; +} + +.featured-entry-platform { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--rhpz-orange); + background: rgba(255,115,0,0.1); + border: 1px solid rgba(255,115,0,0.25); + padding: 1px 6px; + width: fit-content; +} + +.featured-entry-title { + font-size: 0.88rem; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.featured-entry-meta { + font-size: 0.72rem; + color: var(--text2); + margin-top: auto; +} + +@media (max-width: 900px) { + .news-strip { grid-template-columns: repeat(3, 1fr); } + .featured-entries-grid { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 768px) { + .news-strip { + grid-template-columns: repeat(2, 1fr); + gap: 15px; + } + + .featured-entries-grid { + grid-template-columns: 1fr; + gap: 15px; + } + + .home-section { + margin-bottom: 20px; + } + + .news-strip-cover { + height: 100px; + } + + .featured-entry-title { + font-size: 0.95rem; + } +} + +@media (max-width: 600px) { + .news-strip { grid-template-columns: 1fr; } + .featured-entries-grid { grid-template-columns: 1fr; } + .news-strip-cover { height: 80px; } + + .news-strip-item { + padding: 10px; + } + + .news-strip-title { + font-size: 0.85rem; + } + + .featured-entry-title { + font-size: 0.9rem; + } + + .featured-entry-meta { + font-size: 0.7rem; + } + + .home-section-title { + font-size: 0.95rem; + } +} diff --git a/resources/css/layout/content.css b/resources/css/layout/content.css index d13c635..f4dbf3f 100644 --- a/resources/css/layout/content.css +++ b/resources/css/layout/content.css @@ -53,6 +53,155 @@ } } +.topbar-more-container { + display: none; +} + +.topbar-more-menu { + position: fixed; + top: 60px; + right: 0; + background-color: var(--bg2); + border: 1px solid var(--border); + border-top: none; + border-right: none; + z-index: 2000; + min-width: 180px; + max-height: calc(100vh - 60px); + overflow-y: auto; +} + +.topbar-more-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + color: var(--text2); + text-decoration: none; + font-size: 0.9rem; + border-bottom: 1px solid var(--border); + transition: all 0.15s; + + &:last-child { + border-bottom: none; + } + + &:hover { + background-color: var(--bg3); + color: var(--text); + } + + i { + width: 16px; + height: 16px; + flex-shrink: 0; + } + + span { + flex-grow: 1; + text-align: left; + } +} + +@media (min-width: 769px) { + .topbar-more-container { + display: none !important; + } + + .topbar-admin-btn, + .topbar-mod-btn { + display: flex !important; + } +} +@media (max-width: 768px) { + .topbar-more-container { + display: block; + } + + .topbar-admin-btn, + .topbar-mod-btn { + display: none !important; + } + + #topbar { + padding: 0 10px; + } + + .search-bar { + display: none !important; + } + + .topbar-actions { + gap: 8px !important; + } + + .topbar-actions .btn { + padding: 8px 6px; + font-size: 0.85rem; + display: flex; + align-items: center; + justify-content: center; + } + + .topbar-actions i { + width: 16px !important; + height: 16px !important; + } + + .topbar-badge { + font-size: 0.65rem; + width: 18px; + height: 18px; + } +} + +@media (max-width: 600px) { + #topbar { + padding: 0 8px; + } + + .topbar-actions { + gap: 8px !important; + } + + .topbar-actions .btn { + padding: 6px 4px; + font-size: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + } + + .topbar-actions i { + width: 14px !important; + height: 14px !important; + } + + .topbar-badge { + font-size: 0.6rem; + width: 16px; + height: 16px; + } +} + +.search-scope-select { + background-color: var(--bg2); + border: none; + border-right: 1px solid var(--border); + color: var(--text2); + font-size: 0.8rem; + padding: 8px 10px; + cursor: pointer; + outline: none; + appearance: none; + transition: color 0.15s; +} + +.search-scope-select:hover, +.search-scope-select:focus { + color: var(--text); +} + #content { flex-grow: 1; padding: 30px; diff --git a/resources/css/layout/entry.css b/resources/css/layout/entry.css index 958262f..56db6c4 100644 --- a/resources/css/layout/entry.css +++ b/resources/css/layout/entry.css @@ -268,36 +268,6 @@ 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; - } } } } @@ -381,3 +351,242 @@ margin-right: 4px; } } + +.markdown-body { + 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; + } +} + +.markdown-body h1, .markdown-body h2, .markdown-body h3, +.markdown-body h4, .markdown-body h5, .markdown-body h6 { + color: var(--text); + font-weight: 600; + margin: 16px 0 8px; + line-height: 1.3; +} + +.markdown-body h1 { font-size: 1.4rem; } +.markdown-body h2 { font-size: 1.2rem; } +.markdown-body h3 { font-size: 1.05rem; } + +.markdown-body strong { color: var(--text); font-weight: 700; } +.markdown-body em { color: var(--text2); } + +.markdown-body ul, .markdown-body ol { + margin: 0 0 12px 20px; + color: var(--text); +} + +.markdown-body li { margin-bottom: 4px; line-height: 1.5; } + +.markdown-body hr { + border: none; + border-top: 1px solid var(--border); + margin: 16px 0; +} + +.markdown-body table { + width: 100%; + border-collapse: collapse; + margin: 12px 0; + font-size: 0.9rem; +} + +.markdown-body th, .markdown-body td { + border: 1px solid var(--border); + padding: 6px 10px; + text-align: left; +} + +.markdown-body th { + background-color: var(--bg3); + font-weight: 600; + color: var(--text); +} + +.markdown-body del { + color: var(--text2); + text-decoration: line-through; +} + +.markdown-body img { + max-width: 100%; + border: 1px solid var(--border); + margin: 8px 0; +} + +.hack-actions { + display: flex; + gap: 10px; +} + +@media (max-width: 768px) { + .entry-header { + flex-direction: column; + padding: 20px; + gap: 20px; + + .entry-cover { + width: 100%; + height: 280px; + max-width: 300px; + margin: 0 auto; + } + + .entry-info { + .entry-title { + font-size: 1.6rem; + } + + .entry-authors { + font-size: 0.95rem; + } + + .entry-meta-grid { + grid-template-columns: 1fr; + gap: 12px; + margin-bottom: 20px; + } + + .entry-actions { + flex-direction: column; + gap: 10px; + + .btn { + width: 100%; + } + } + } + } + + .entry-content { + padding: 20px; + + .entry-section-title { + font-size: 1.1rem; + } + + .entry-gallery { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 12px; + margin-bottom: 20px; + } + } + + .comment-block { + gap: 12px; + padding: 15px 0; + + .comment-avatar { + width: 40px; + height: 40px; + } + + .comment-content { + .comment-body { + font-size: 0.9rem; + } + } + } + + .video-thumbnail-wrapper { + max-width: 100%; + } + + .gallery-modal-close { + top: 10px; + right: 15px; + font-size: 30px; + } + + .hack-actions { + flex-direction: column; + } +} + +@media (max-width: 600px) { + .entry-header { + padding: 15px; + gap: 15px; + + .entry-cover { + height: 240px; + } + + .entry-info { + .entry-title { + font-size: 1.3rem; + margin-bottom: 8px; + } + + .entry-authors { + font-size: 0.85rem; + margin-bottom: 15px; + } + + .entry-actions { + gap: 8px; + + .btn { + padding: 8px 12px; + font-size: 0.85rem; + } + } + } + } + + .entry-content { + padding: 15px; + + .entry-gallery { + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 8px; + margin-bottom: 15px; + } + } + + .comment-block { + padding: 10px 0; + + .comment-avatar { + width: 36px; + height: 36px; + } + } + + .markdown-body h1 { font-size: 1.15rem; } + .markdown-body h2 { font-size: 1rem; } + .markdown-body h3 { font-size: 0.95rem; } + + .hack-actions { + flex-direction: column; + } +} + diff --git a/resources/css/layout/menu.css b/resources/css/layout/menu.css index 564c25d..e7f3e47 100644 --- a/resources/css/layout/menu.css +++ b/resources/css/layout/menu.css @@ -9,12 +9,18 @@ z-index: 100; .menu-header { - padding: 20px; + padding: 10px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--border); + img { + width: 100%; + height: 100%; + object-fit: contain; + } + .menu-logo { width: 32px; height: 32px; diff --git a/resources/css/layout/news.css b/resources/css/layout/news.css index eb73bc6..f1a7b19 100644 --- a/resources/css/layout/news.css +++ b/resources/css/layout/news.css @@ -46,3 +46,558 @@ margin-bottom: 20px; } } + +#news-container { + background-color: var(--bg2); + border: 1px solid var(--border); + display: flex; + flex-direction: column; +} + +.news-header { + width: 100%; + height: 300px; + background-size: cover; + background-position: center; + display: flex; + align-items: flex-end; + padding: 40px 30px; + border-bottom: 1px solid var(--border); + position: relative; +} + +.news-header-content { + position: relative; + z-index: 2; +} + +.news-header .news-title { + font-size: 2.5rem; + font-weight: 600; + color: var(--text); + margin-bottom: 12px; + text-shadow: 0 2px 4px rgba(0,0,0,0.6); + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; +} + +@media (max-width: 768px) { + .news-header .news-title { + font-size: 1.8rem; + } +} + +@media (max-width: 600px) { + .news-header .news-title { + font-size: 1.4rem; + } +} + +.news-header .news-meta { + color: var(--text2); + display: flex; + gap: 20px; + font-size: 0.9rem; + align-items: center; +} + +.news-header .meta-item { + display: flex; + align-items: center; + gap: 6px; + background-color: rgba(0, 0, 0, 0.4); + padding: 4px 10px; + border-radius: 2px; + border: 1px solid rgba(255,255,255,0.05); +} + +.news-layout { + display: flex; + flex-direction: row; + gap: 30px; + padding: 30px; +} + +@media (max-width: 992px) { + .news-layout { + flex-direction: column; + } +} + +.news-main-content { + flex-grow: 1; + flex-basis: 0; + min-width: 0; +} + +.news-body-text { + line-height: 1.75; + color: var(--text); + font-size: 1.05rem; + margin-bottom: 15px; +} + +.news-body-text p { + margin-bottom: 20px; +} + +.news-sidebar { + width: 320px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 25px; +} + +@media (max-width: 992px) { + .news-sidebar { + width: 100%; + } +} + +.sidebar-block { + background-color: var(--bg); + border: 1px solid var(--border); + padding: 20px; + border-radius: 4px; +} + +.sidebar-title { + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text); + margin-bottom: 15px; + display: flex; + align-items: center; + gap: 8px; + border-bottom: 1px solid var(--border); + padding-bottom: 8px; +} + +.btn-sidebar { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 10px 15px; + font-size: 0.95rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + border-radius: 2px; + transition: background-color 0.2s ease, border-color 0.2s ease; + text-align: center; +} + +.btn-orange { + background-color: var(--rhpz-orange); + color: #fff; + border: 1px solid transparent; +} + +.btn-orange:hover { + background-color: var(--rhpz-orange-hover); +} + +.related-card { + display: flex; + flex-direction: column; + gap: 12px; +} + +.related-card-cover { + width: 100%; + height: 150px; + background-color: var(--bg2); + border: 1px solid var(--border); + overflow: hidden; + border-radius: 2px; +} + +.related-card-cover img { + width: 100%; + height: 100%; + object-fit: contain; + padding: 5px; +} + +.related-card-info h4 { + font-size: 1.1rem; + color: var(--text); + margin-bottom: 10px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.news-sidebar .video-thumbnail-wrapper { + position: relative; + width: 100%; + aspect-ratio: 16/9; + background-color: #000; + border: 1px solid var(--border); + cursor: pointer; + overflow: hidden; + border-radius: 2px; +} + +.news-sidebar .video-thumbnail-wrapper img { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.7; + transition: transform 0.3s ease, opacity 0.3s ease; +} + +.news-sidebar .video-thumbnail-wrapper:hover img { + transform: scale(1.03); + opacity: 0.9; +} + +.news-sidebar .play-trigger { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 50px; + height: 50px; + background-color: rgba(0, 0, 0, 0.75); + border: 2px solid #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + transition: background-color 0.2s, transform 0.2s ease-out; +} + +.news-sidebar .video-thumbnail-wrapper:hover .play-trigger { + background-color: var(--rhpz-orange); + transform: translate(-50%, -50%) scale(1.1); +} + +.news-actions { + display: flex; + align-items: center; + gap: 8px; + margin-top: 15px; + flex-wrap: wrap; +} + +.news-header .news-actions .btn { + background-color: rgba(0, 0, 0, 0.5); + border-color: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(4px); + color: var(--text); + transition: background-color 0.15s, border-color 0.15s; +} + +.news-header .news-actions .btn:hover { + background-color: rgba(0, 0, 0, 0.7); + border-color: rgba(255, 255, 255, 0.3); +} + +.news-header .news-actions .btn.success { + background-color: rgba(56, 142, 60, 0.6); + border-color: rgba(129, 199, 132, 0.4); + color: #81c784; +} + +.news-header .news-actions .btn.danger { + background-color: rgba(183, 28, 28, 0.5); + border-color: rgba(229, 115, 115, 0.4); + color: #e57373; +} + +.news-hero { + display: block; + position: relative; + width: 100%; + height: 360px; + margin-bottom: 20px; + border: 1px solid var(--border); + overflow: hidden; + text-decoration: none; + transition: border-color 0.2s; +} + +.news-hero:hover { + border-color: var(--rhpz-orange); + text-decoration: none; +} + +.news-hero-bg { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; + background-color: var(--bg3); + transition: transform 0.4s ease; +} + +.news-hero:hover .news-hero-bg { + transform: scale(1.02); +} + +.news-hero-content { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 30px; + z-index: 2; +} + +.news-hero-badge { + display: inline-flex; + align-items: center; + gap: 5px; + background-color: var(--rhpz-orange); + color: #111; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; + padding: 4px 10px; + margin-bottom: 12px; +} + +.news-hero-title { + font-size: 2rem; + font-weight: 600; + color: #fff; + margin-bottom: 12px; + text-shadow: 0 2px 8px rgba(0,0,0,0.5); + line-height: 1.2; +} + +.news-hero-meta { + display: flex; + align-items: center; + gap: 15px; + font-size: 0.85rem; + color: rgba(255,255,255,0.75); +} + +.news-hero-meta span { + display: flex; + align-items: center; + gap: 5px; + background-color: rgba(0,0,0,0.4); + padding: 3px 10px; + border: 1px solid rgba(255,255,255,0.08); +} + +.news-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 15px; + margin-bottom: 20px; +} + +.news-card { + display: flex; + flex-direction: column; + background-color: var(--bg2); + border: 1px solid var(--border); + text-decoration: none; + overflow: hidden; + transition: border-color 0.15s, transform 0.15s; +} + +.news-card:hover { + border-color: var(--rhpz-orange); + transform: translateY(-2px); + text-decoration: none; +} + +.news-card-cover { + height: 160px; + background-size: cover; + background-position: center; + background-color: var(--bg3); + position: relative; + flex-shrink: 0; + transition: transform 0.3s ease; +} + +.news-card:hover .news-card-cover { + transform: scale(1.03); +} + +.news-card-state-badge { + position: absolute; + top: 10px; + right: 10px; + display: inline-flex; + align-items: center; + gap: 4px; + background-color: rgba(0,0,0,0.7); + color: var(--rhpz-orange); + font-size: 0.72rem; + font-weight: 600; + padding: 3px 8px; + border: 1px solid rgba(255,115,0,0.3); +} + +.news-card-body { + padding: 16px; + display: flex; + flex-direction: column; + flex: 1; + gap: 8px; +} + +.news-card-title { + font-size: 1rem; + font-weight: 600; + color: var(--text); + line-height: 1.3; + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.news-card-excerpt { + font-size: 0.82rem; + color: var(--text2); + line-height: 1.5; + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + flex: 1; +} + +.news-card-meta { + display: flex; + align-items: center; + gap: 12px; + font-size: 0.75rem; + color: var(--text2); + margin-top: auto; + padding-top: 8px; + border-top: 1px solid var(--border); +} + +.news-card-meta span { + display: flex; + align-items: center; + gap: 4px; +} + +@media (max-width: 600px) { + .news-hero { height: 240px; } + .news-hero-title { font-size: 1.4rem; } + .news-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 768px) { + + .news-header .news-meta { + gap: 12px; + font-size: 0.85rem; + } + + .news-layout { + flex-direction: column; + gap: 20px; + padding: 20px; + } + + .news-sidebar { + width: 100%; + } + + .news-content { + padding: 25px; + font-size: 1rem; + } + + .news-body-text { + font-size: 1rem; + } + + .sidebar-block { + padding: 15px; + } +} + +@media (max-width: 600px) { + .news-header .news-meta { + gap: 8px; + font-size: 0.8rem; + flex-wrap: wrap; + } + + .news-header .meta-item { + padding: 3px 8px; + font-size: 0.75rem; + } + + .news-layout { + gap: 15px; + padding: 15px; + } + + .news-main-content { + min-width: 0; + } + + .news-content { + padding: 15px; + font-size: 0.95rem; + } + + .news-body-text { + font-size: 0.95rem; + margin-bottom: 12px; + } + + .news-body-text p { + margin-bottom: 15px; + } + + .news-sidebar { + width: 100%; + gap: 15px; + } + + .sidebar-block { + padding: 12px; + } + + .sidebar-block h3 { + font-size: 0.95rem; + } + + .sidebar-block p { + font-size: 0.9rem; + } + + .news-card-title { + font-size: 0.9rem; + } +} + +@media (max-width: 420px) { + + .news-header .news-meta { + font-size: 0.75rem; + } + + .news-layout { + padding: 12px; + } + + .news-content { + padding: 12px; + } +} diff --git a/resources/css/layout/responsive.css b/resources/css/layout/responsive.css new file mode 100644 index 0000000..56e3287 --- /dev/null +++ b/resources/css/layout/responsive.css @@ -0,0 +1,270 @@ + +@media (max-width: 768px) { + :root { + --menu-size: 280px; + } + + #menu { + position: fixed; + left: 0; + top: 60px; + height: calc(100vh - 60px); + transform: translateX(-100%); + transition: transform 0.3s ease-in-out; + z-index: 999; + box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5); + } + + #menu.mobile-open { + transform: translateX(0); + } + + #app.menu-open::before { + content: ''; + position: fixed; + top: 60px; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 998; + } + + .mobile-toggle { + display: flex !important; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + } + + #main-wrapper { + width: 100%; + } + + #content { + padding: 20px; + } + + #topbar { + padding: 0 10px; + gap: 10px; + } + + .search-bar { + width: 100%; + max-width: 250px; + } + + .search-scope-select { + font-size: 0.7rem; + padding: 6px 8px; + } + + .topbar-actions { + gap: 4px; + overflow-x: auto; + flex-shrink: 1; + } + + .topbar-actions .btn { + flex-shrink: 0; + padding: 6px 8px; + } + + .vertical-separator { + height: 30px; + } +} + +@media (max-width: 600px) { + :root { + --menu-size: 240px; + } + + #content { + padding: 15px; + } + + #topbar { + padding: 0 8px; + height: 55px; + } + + #topbar { + flex-wrap: wrap; + gap: 8px; + } + + .search-bar { + max-width: 100%; + order: 3; + width: 100%; + margin-top: 8px; + } + + .topbar-actions { + gap: 2px; + max-width: 100%; + } + + .topbar-actions .btn { + padding: 4px 6px; + font-size: 0.9rem; + } + + .search-scope-select { + display: none; + } + + .search-bar input { + padding: 4px; + font-size: 0.9rem; + } + + #menu { + width: 240px; + } + + .menu-title { + display: none; + } + + .menu-logo { + width: 40px; + height: 40px; + } + + .menu-header { + padding: 8px; + justify-content: center; + } + + .menu-user-info .username { + font-size: 0.9rem; + } +} + +@media (max-width: 420px) { + :root { + --menu-size: 200px; + } + + #content { + padding: 12px; + } + + #topbar { + padding: 0 6px; + height: 50px; + } + + .mobile-toggle { + width: 35px; + height: 35px; + } + + .topbar-actions .btn { + padding: 3px 4px; + font-size: 0.8rem; + } + + .vertical-separator { + display: none; + } + + .menu-item { + padding: 8px 12px; + font-size: 0.9rem; + } + + .menu-group-title { + padding: 0 12px; + font-size: 0.65rem; + } + + .menu-user-info { + display: none; + } +} + +@media (max-height: 500px) and (max-width: 768px) { + #topbar { + height: 50px; + } + + #content { + padding: 12px; + } + + .menu-header { + padding: 8px; + } +} + +@media (max-width: 1024px) and (min-width: 769px) { + :root { + --menu-size: 240px; + } + + #content { + padding: 25px; + } + + .search-bar { + width: 250px; + } +} + +@media (min-width: 769px) { + + #menu { + transform: translateX(0) !important; + position: relative !important; + top: auto !important; + height: auto !important; + box-shadow: none !important; + } + + + #app.menu-open::before { + display: none; + } + + + .mobile-toggle { + display: none !important; + } + + + #app { + display: flex; + } + + #main-wrapper { + width: calc(100% - var(--menu-size)); + flex-grow: 1; + } +} + + +@media (hover: none) and (pointer: coarse) { + + .btn, + .menu-item, + button { + min-height: 44px; + min-width: 44px; + } + + + .btn { + padding: 8px 12px; + } + + + .menu-item:hover { + background-color: var(--bg2); + } +} diff --git a/resources/css/layout/reviews.css b/resources/css/layout/reviews.css new file mode 100644 index 0000000..2d80836 --- /dev/null +++ b/resources/css/layout/reviews.css @@ -0,0 +1,156 @@ +.review-section-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 10px; +} + +.review-header-right { + display: flex; + align-items: center; + gap: 10px; +} + +.review-avg-badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.85rem; + font-weight: 600; + color: var(--rhpz-orange); + background-color: rgba(255,115,0,.1); + border: 1px solid rgba(255,115,0,.3); + padding: 4px 10px; +} + +.review-avg-badge--lg { + font-size: 1rem; + padding: 8px 16px; +} + +.review-avg-count { + color: var(--text2); + font-weight: 400; + font-size: 0.85rem; +} + +.star-rating-display { + display: inline-flex; + align-items: center; + gap: 1px; +} + +.star-rating-display .star-filled { color: var(--rhpz-orange); fill: var(--rhpz-orange); } +.star-rating-display .star-empty { color: var(--border); } + +.review-title { + font-size: 0.98rem; + font-weight: 700; + color: var(--text); + margin-bottom: 4px; +} + +.star-input { + display: flex; + gap: 4px; +} + +.star-input-icon.star-filled svg { color: var(--rhpz-orange); fill: var(--rhpz-orange); } +.star-input-icon.star-empty svg { color: var(--border); } + +.star-input-icon { + cursor: pointer; + transition: transform 0.1s; +} + +.star-input-icon:hover { + transform: scale(1.15); +} + +.reviews-page-header { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 25px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border); +} + +.reviews-back-link { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + color: var(--text2); + text-decoration: none; + width: fit-content; +} + +.reviews-back-link:hover { color: var(--rhpz-orange); } + +.reviews-page-title { + font-size: 1.4rem; + font-weight: 600; + color: var(--text); +} + +@media (max-width: 768px) { + .review-section-header { + gap: 8px; + margin-bottom: 8px; + } + + .review-header-right { + gap: 8px; + flex-wrap: wrap; + } + + .review-avg-badge { + font-size: 0.8rem; + padding: 3px 8px; + } + + .review-avg-badge--lg { + font-size: 0.95rem; + padding: 6px 12px; + } + + .reviews-page-header { + gap: 8px; + margin-bottom: 20px; + padding-bottom: 15px; + } + + .reviews-page-title { + font-size: 1.15rem; + } + + .review-title { + font-size: 0.9rem; + } +} + +@media (max-width: 600px) { + .review-avg-badge { + font-size: 0.75rem; + padding: 2px 6px; + } + + .reviews-page-title { + font-size: 1rem; + } + + .review-title { + font-size: 0.85rem; + } + + .star-rating-display { + gap: 0; + } + + .star-input { + gap: 3px; + } +} diff --git a/resources/css/layout/submit.css b/resources/css/layout/submit.css new file mode 100644 index 0000000..5c251da --- /dev/null +++ b/resources/css/layout/submit.css @@ -0,0 +1,348 @@ +.submit-hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + background-color: var(--bg2); + border-bottom: 1px solid var(--border); + padding: 40px 36px 32px; +} + +.submit-eyebrow { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--rhpz-orange); + background: rgba(255,115,0,.1); + border: 1px solid rgba(255,115,0,.3); + padding: 3px 10px; + margin-bottom: 16px; +} + +.submit-hero-title { + font-size: 1.9rem; + font-weight: 300; + color: var(--text); + margin-bottom: 10px; + line-height: 1.25; +} + +.submit-hero-sub { + font-size: 0.9rem; + color: var(--text2); + max-width: 460px; + line-height: 1.65; +} + +.submit-review-note { + font-size: 0.8rem; + color: var(--text2); + border: 1px solid var(--border); + background: var(--bg3); + padding: 14px 18px; + max-width: 210px; + line-height: 1.6; + flex-shrink: 0; +} + +.submit-review-note strong { color: var(--rhpz-orange); } + +.submit-body { padding: 28px 36px 40px; } + +.submit-section-label { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text2); + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.submit-section-label::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); +} + +.submit-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-bottom: 30px; +} + +.submit-card { + display: flex; + flex-direction: column; + background: var(--bg2); + border: 1px solid var(--border); + text-decoration: none; + transition: border-color .15s, background .1s; +} + +.submit-card:hover { + border-color: var(--card-color); + background: var(--bg3); + text-decoration: none; +} + +.submit-card-top { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 20px 20px 16px; + border-bottom: 1px solid var(--border); +} + +.submit-card-icon { + width: 38px; + height: 38px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--card-bg); + border: 1px solid var(--card-border); + color: var(--card-color); +} + +.submit-card-title { + font-size: 0.95rem; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; +} + +.submit-card-desc { + font-size: 0.78rem; + color: var(--text2); + line-height: 1.55; +} + +.submit-card-bottom { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 20px; +} + +.submit-card-tag { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--card-color); + background: var(--card-bg); + border: 1px solid var(--card-border); + padding: 2px 7px; +} + +.submit-card-cta { + font-size: 0.75rem; + color: var(--text2); + display: flex; + align-items: center; + gap: 4px; + transition: color .15s; +} + +.submit-card:hover .submit-card-cta { color: var(--card-color); } + +.submit-news-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-bottom: 28px; +} + +.submit-news-card { + display: flex; + align-items: center; + gap: 14px; + background: var(--bg2); + border: 1px solid var(--border); + padding: 18px 22px; + text-decoration: none; + transition: border-color .15s, background .1s; +} + +.submit-news-card:hover { + border-color: var(--success); + background: var(--bg3); +} + +.submit-news-card--disabled { opacity: .5; cursor: not-allowed; } +.submit-news-card--disabled:hover { border-color: var(--border); background: var(--bg2); } + +.submit-news-icon { + width: 38px; + height: 38px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(129,199,132,.1); + border: 1px solid rgba(129,199,132,.3); + color: var(--success); +} + +.submit-news-title { + font-size: 0.95rem; + font-weight: 600; + color: var(--text); + margin-bottom: 3px; +} + +.submit-news-desc { font-size: 0.78rem; color: var(--text2); line-height: 1.55; } + +.submit-news-cta { + flex-shrink: 0; + font-size: 0.75rem; + color: var(--text2); + display: flex; + align-items: center; + gap: 4px; + transition: color .15s; +} + +.submit-news-card:hover .submit-news-cta { color: var(--success); } + +.submit-news-staff-note { + background: var(--bg2); + border: 1px solid var(--border); + padding: 18px 22px; + font-size: 0.8rem; + color: var(--text2); + line-height: 1.65; + display: flex; + flex-direction: column; + gap: 10px; +} + +.submit-news-staff-badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .6px; + color: var(--rhpz-orange); + background: rgba(255,115,0,.1); + border: 1px solid rgba(255,115,0,.3); + padding: 2px 8px; + width: fit-content; +} + +.submit-news-staff-note a { color: var(--text2); text-decoration: underline; } +.submit-news-staff-note a:hover { color: var(--text); } + +.submit-rules { + display: flex; + gap: 0; + background: var(--bg2); + border: 1px solid var(--border); +} + +.submit-rule { + flex: 1; + display: flex; + align-items: flex-start; + gap: 12px; + padding: 18px 22px; + border-right: 1px solid var(--border); + font-size: 0.8rem; + color: var(--text2); + line-height: 1.6; +} + +.submit-rule:last-child { border-right: none; } +.submit-rule strong { color: var(--text); } + +.submit-rule-num { + width: 22px; + height: 22px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255,115,0,.1); + border: 1px solid rgba(255,115,0,.3); + color: var(--rhpz-orange); + font-size: 0.72rem; + font-weight: 700; +} + +@media (max-width: 900px) { + .submit-hero { flex-direction: column; align-items: flex-start; } + .submit-grid { grid-template-columns: repeat(2, 1fr); } + .submit-rules { flex-direction: column; } + .submit-rule { border-right: none; border-bottom: 1px solid var(--border); } + .submit-rule:last-child { border-bottom: none; } +} + +@media (max-width: 768px) { + .submit-hero { + flex-direction: column; + gap: 20px; + padding: 25px 20px; + } + + .submit-grid { + grid-template-columns: 1fr; + gap: 15px; + } + + .submit-body { + padding: 25px 20px; + } + + .submit-rules { + gap: 0; + } + + .submit-rule { + padding: 15px; + } +} + +@media (max-width: 600px) { + .submit-hero, .submit-body { + padding-left: 15px; + padding-right: 15px; + } + + .submit-grid { grid-template-columns: 1fr; } + .submit-news-row { grid-template-columns: 1fr; } + .submit-review-note { max-width: 100%; } + + .submit-hero { + gap: 15px; + padding: 15px; + } + + .submit-body { + padding: 15px; + } + + .submit-rule { + padding: 12px; + font-size: 0.9rem; + } + + .submit-hero-title { + font-size: 1.3rem; + } + + .submit-grid > * { + margin-bottom: 10px; + } +} diff --git a/resources/js/HashesChecker.js b/resources/js/HashesChecker.js new file mode 100644 index 0000000..06a154a --- /dev/null +++ b/resources/js/HashesChecker.js @@ -0,0 +1,47 @@ +import { calculate as calculateHashes } from "./hashes.js"; + +window.HashesChecker = function( wire ) { + return { + + /** + * Wire variable instance. + */ + $wire: wire, + + /** + * If a file hash is currently calculated or not. + * @type {boolean} + */ + isCalculating: false, + + /** + * An error on hash calculation. + * @type {any|null} + */ + error: null, + + async handleSubmitFile(e){ + if( this.isCalculating === true ) // Calculation already done for another file. + return; + + this.error = null; // Reset. + const FILE = e.target.files[0]; + + if( !FILE ) + return; // No file sent. + + this.isCalculating = true; + + try { + const RESULT = await calculateHashes(FILE); + await this.$wire.addHash(RESULT.filename, RESULT.crc32, RESULT.sha1); // Send a signal to livewire. + window.refreshIcons(); + } catch(err) { + this.error = err.message; + } finally { + this.isCalculating = false; + } + } + + } +} diff --git a/resources/js/PlayOnline.js b/resources/js/PlayOnline.js new file mode 100644 index 0000000..4950b03 --- /dev/null +++ b/resources/js/PlayOnline.js @@ -0,0 +1,43 @@ +window.PlayOnline = function( filePath = "", emulatorJsConfig = {} ){ + + return { + + fileUrl: filePath, + emuConfig: emulatorJsConfig, + + init(){ + this.launchEmulatorJs(); + }, + + cleanEmulatorJsVars() { + ['EJS_player','EJS_core','EJS_gameUrl','EJS_pathtodata', + 'EJS_startOnLoaded','EJS_threads'] + .forEach(k => delete window[k]); + }, + + prepareEmulatorJs(){ + window.EJS_player = '#game'; + window.EJS_core = this.emuConfig.core; + window.EJS_gameUrl = this.filePath; + window.EJS_pathtodata = "https://cdn.emulatorjs.org/stable/data/"; + window.EJS_startOnLoaded = true; + window.EJS_threads = this.emuConfig.threads ?? false; + }, + + launchEmulatorJs(){ + + this.cleanEmulatorJsVars(); + this.prepareEmulatorJs(); + + const script = document.createElement('script'); + script.id = 'ejs-loader'; + script.src = 'https://cdn.emulatorjs.org/stable/data/loader.js'; + document.body.appendChild(script); + + this.launchGame = true; + + } + + } + +} diff --git a/resources/js/PlayOnlineAndPatcher.js b/resources/js/PlayOnlineAndPatcher.js new file mode 100644 index 0000000..30fe09f --- /dev/null +++ b/resources/js/PlayOnlineAndPatcher.js @@ -0,0 +1,85 @@ +import { RomPatcher } from './RomPatcher.js'; + +window.PlayOnline = function( initialPatches = {}, emulatorJsConfig = {} ){ + + const parent = RomPatcher( initialPatches ); + + return { + + ...parent, + + currentBlobUrl: null, + emuConfig: emulatorJsConfig, + launchGame: false, + + init(){ + parent.init({ + language: 'en', + requireValidation: false, + onpatch: this.handlePatchedRomFile.bind(this), + }); + }, + + cleanEmulatorJsVars() { + ['EJS_player','EJS_core','EJS_gameUrl','EJS_pathtodata', + 'EJS_startOnLoaded','EJS_threads'] + .forEach(k => delete window[k]); + }, + + prepareEmulatorJs(){ + window.EJS_player = '#game'; + window.EJS_core = this.emuConfig.core; + window.EJS_gameUrl = this.currentBlobUrl; + window.EJS_pathtodata = "https://cdn.emulatorjs.org/stable/data/"; + window.EJS_startOnLoaded = true; + window.EJS_threads = this.emuConfig.threads ?? false; + }, + + launchEmulatorJs(){ + if(!this.currentBlobUrl){ + console.error("EmulatorJS: Empty Blob field"); + return; + } + + console.log(this.currentBlobUrl); + + this.cleanEmulatorJsVars(); + this.prepareEmulatorJs(); + + const script = document.createElement('script'); + script.id = 'ejs-loader'; + script.src = 'https://cdn.emulatorjs.org/stable/data/loader.js'; + document.body.appendChild(script); + + this.launchGame = true; + + }, + + /** + * @param {BinFile} patchedRomFile + */ + handlePatchedRomFile( patchedRomFile ){ + + patchedRomFile.save = function(){ + // Remove save. + return; + } + + const u8 = patchedRomFile._u8array; + if( !u8 || u8.byteLength === 0 ){ + console.error("Patch error: Empty ROM file"); + return; + } + + if(this.currentBlobUrl){ + URL.revokeObjectURL(this.currentBlobUrl); + } + + const blob = new Blob([u8], { type: 'application/octet-stream' }); + this.currentBlobUrl = URL.createObjectURL(blob); + + this.launchEmulatorJs() + } + } + +} diff --git a/resources/js/RomPatcher.js b/resources/js/RomPatcher.js index 185d0dc..63c7e54 100644 --- a/resources/js/RomPatcher.js +++ b/resources/js/RomPatcher.js @@ -1,4 +1,4 @@ -export function RomPatcher( initialPatches = {} ) { +export const RomPatcher = function( initialPatches = {} ) { let patchesArray = []; if (initialPatches) { @@ -39,9 +39,9 @@ export function RomPatcher( initialPatches = {} ) { patchesData: patchesArray, hasEmbedded: patchesArray.length > 0, - init() { + init( config = {language: 'en', requireValidation: false} ) { - const CONFIG = {language: 'en', requireValidation: false}; + const CONFIG = config; if (!RomPatcherWeb.isInitialized()){ if (this.hasEmbedded) { @@ -112,3 +112,7 @@ export function RomPatcher( initialPatches = {} ) { } } } + +window.RomPatcher = RomPatcher; + + diff --git a/resources/js/SubmissionsClass/FSFileData.js b/resources/js/SubmissionsClass/FSFileData.js index a44b3c5..623e885 100644 --- a/resources/js/SubmissionsClass/FSFileData.js +++ b/resources/js/SubmissionsClass/FSFileData.js @@ -1,6 +1,10 @@ /** @typedef { import('types/UploadchunkResponse.js').UploadchunkResponse} UploadchunkResponse */ -export const CHUNK_SIZE = 8192; +export const CHUNK_SIZE = 8192 * 1024; + +const PATCH_EXTENSIONS = new Set([ + 'ips', 'bps', 'ups', 'aps', 'ppf', 'xdelta', "zip" +]); /** * An uploaded file instance. @@ -12,6 +16,8 @@ export const CHUNK_SIZE = 8192; */ export function FSFileData(name, totalChunks, rawFile ) { + const extension = name.split('.').pop().toLowerCase(); + return { /** @@ -66,6 +72,17 @@ export function FSFileData(name, totalChunks, rawFile ) { */ state: 'public', + file_explorer: null, + + file_explorer_files: null, + + /** + * For files already uploaded, download URL. + */ + download_url: null, + + can_be_online_patched: PATCH_EXTENSIONS.has(extension), + /** * If the online patcher is enabled */ @@ -76,6 +93,21 @@ export function FSFileData(name, totalChunks, rawFile ) { */ meta_secondary_online_patcher: false, + /** + * If this patch can be played online. + */ + meta_play_online: false, + + /** + * Selected core for play online + */ + meta_play_online_core: null, + + /** + * If the threads are enabled for playing online. + */ + meta_play_online_threads: null, + /** * Look if this file is currently uploading. * @returns {boolean} @@ -164,6 +196,6 @@ export function FSFileData(name, totalChunks, rawFile ) { } } -} + } } diff --git a/resources/js/SubmissionsClass/FSUploader.js b/resources/js/SubmissionsClass/FSUploader.js index 96af2ed..8c7a597 100644 --- a/resources/js/SubmissionsClass/FSUploader.js +++ b/resources/js/SubmissionsClass/FSUploader.js @@ -105,6 +105,29 @@ export function FSUploader(){ }, + handleDownloadFile( index ){ + let download_url = this.files[index].download_url; + window.location.href = download_url; + }, + + async handleFileExplorer( index ){ + + if( this.files[index].file_explorer_files !== null ) + return; + + let file_explorer_url = this.files[index].file_explorer; + + let response = await fetch(file_explorer_url, { method: 'GET', headers: { 'Content-Type': 'application/json' } }); + let json = await response.json(); + + if( !json.files ){ + this.files[index].file_explorer_files = [ "An error occurred during request" ]; + return; + } + + this.files[index].file_explorer_files = json.files; + }, + /** * Retry file uploading. * @@ -126,6 +149,8 @@ export function FSUploader(){ * @param {number} index FSFileData index in this.files. */ handleRemoveFile( index ){ + if( this.files[index].state === 'archived') + return; this.files.splice(index, 1); }, diff --git a/resources/js/SubmissionsClass/GalleryManager.js b/resources/js/SubmissionsClass/GalleryManager.js index 1f680c2..3cd11e6 100644 --- a/resources/js/SubmissionsClass/GalleryManager.js +++ b/resources/js/SubmissionsClass/GalleryManager.js @@ -12,6 +12,8 @@ export function GalleryManager() { */ images: [], + dragSrcI: null, + /** * Forward to this.images.length * @returns {number} @@ -123,6 +125,25 @@ export function GalleryManager() { handleRemoveFile(index){ this.images[index].handleRemoveFile(null); this.images.splice(index, 1); + }, + + dragStart(index){ + this.dragSrcI = index; + }, + + dragOver(e, index){ + e.preventDefault(); + + if( this.dragSrcI === null || this.dragSrcI === index ) + return; + + const moved = this.images.splice(this.dragSrcI, 1)[0]; + this.images.splice(index, 0, moved); + this.dragSrcI = index; + }, + + dragEnd(){ + this.dragSrcI = null; } } } diff --git a/resources/js/app.js b/resources/js/app.js index 068c39a..8fc69c6 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -7,7 +7,7 @@ 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"; +import { initMobileMenu } from "./mobile-menu.js"; /** * Get config defined in meta.blade.php @@ -15,7 +15,7 @@ import {RomPatcher} from "./RomPatcher.js"; * @return {string|null} */ window.getConfig = function( key ){ - return document.querySelector('meta[name="config-' + key + '"]').getAttribute('content') ?? null; + return document.querySelector('meta[name="config-' + key + '"]')?.getAttribute('content') ?? null; } // Lucide icons. @@ -45,5 +45,5 @@ Alpine.store('conversations', conversations() ); // Settings Alpine.store('settings', settings() ); -// ROMPatcher -window.RomPatcher = RomPatcher; +// Mobile Menu +document.addEventListener('DOMContentLoaded', initMobileMenu); diff --git a/resources/js/mobile-menu.js b/resources/js/mobile-menu.js new file mode 100644 index 0000000..2f3b8e1 --- /dev/null +++ b/resources/js/mobile-menu.js @@ -0,0 +1,46 @@ +export function initMobileMenu() { + const menuToggle = document.querySelector('.mobile-toggle'); + const menu = document.getElementById('menu'); + const app = document.getElementById('app'); + const content = document.getElementById('content'); + + if (!menuToggle || !menu) return; + + menuToggle.addEventListener('click', (e) => { + e.stopPropagation(); + menu.classList.toggle('mobile-open'); + app.classList.toggle('menu-open'); + }); + + const menuItems = menu.querySelectorAll('.menu-item'); + menuItems.forEach(item => { + item.addEventListener('click', () => { + menu.classList.remove('mobile-open'); + app.classList.remove('menu-open'); + }); + }); + + document.addEventListener('click', (e) => { + const isClickInsideMenu = menu.contains(e.target); + const isClickOnToggle = menuToggle.contains(e.target); + + if (!isClickInsideMenu && !isClickOnToggle && menu.classList.contains('mobile-open')) { + menu.classList.remove('mobile-open'); + app.classList.remove('menu-open'); + } + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && menu.classList.contains('mobile-open')) { + menu.classList.remove('mobile-open'); + app.classList.remove('menu-open'); + } + }); + + window.addEventListener('resize', () => { + if (window.innerWidth > 768) { + menu.classList.remove('mobile-open'); + app.classList.remove('menu-open'); + } + }); +} diff --git a/resources/js/news-submissions.js b/resources/js/news-submissions.js new file mode 100644 index 0000000..905f150 --- /dev/null +++ b/resources/js/news-submissions.js @@ -0,0 +1,169 @@ +import { GalleryManager } from "./SubmissionsClass/GalleryManager.js"; + +/** + * If there is some server side errors. + * We may need reload some things. + * @type {boolean} + */ +const SERVER_SIDE_ERRORS = document.querySelector('meta[name="submission-has-errors"]')?.content === '1'; + +/** + * Object map of errors messages + * @type {Object} + */ +const ERROR_TABLE = { + noDescription: "Please provide a description.", + noGalleryImages: "Please select at least a gallery image.", + isSubmitting: "The entry is already during submission." +} + +window.GalleryManager = GalleryManager; + +/** + * Verify if an EasyMDE field is filled. + * + * @param {string} fieldName + * @returns {boolean} + */ +function verifyMDE( fieldName ){ + const textarea = document.querySelector('#field_' + fieldName); + if( textarea && textarea.value.trim().length > 0 ) { + return true; + } + + const field = window['mde_' + fieldName] || null; + return field && typeof field.value === 'function' && field.value().trim().length > 0; +} + +window.SubmissionVerifications = { + + /** + * Verify if the description field has at least one character. + * @returns {boolean} + */ + step1_VerifyDescription: function(){ + return verifyMDE('description'); + }, + + /** + * Verify if at least one image is uploaded in the gallery. + * @param element this.$el + * @return {boolean} + */ + step2_verifyGallery: function( element){ + let GalleryData = element.querySelector('[x-data="GalleryManager()"]'); + GalleryData = GalleryData ? Alpine.$data(GalleryData) : null; + + if( ! GalleryData ){ + return false; + } + + return GalleryData.number > 0 && GalleryData.allUploaded; + } + +} + +/** + * Handle entire submission process. + */ +window.NewsSubmission = function(){ + return { + + /** + * If the script is during a try of submission process. + * @type {boolean} + */ + duringSubmissionProcess: false, + + /** + * Error checked. + * @type {string|null} + */ + errorKey: null, + + /** + * Return error message. + * @return {string} + */ + get errorMessage(){ + return ERROR_TABLE[this.errorKey] ?? "Unknown error"; + }, + + init(){ + }, + + /** + * Do each form verifications. + * Update also this.errorKey. + * + * @returns {boolean} + */ + verifyForm(){ + + console.log( "Step 1" ); + if( !SubmissionVerifications.step1_VerifyDescription() ){ + this.errorKey = "noDescription"; + return false; + } + + console.log( "Step 2" ); + if( !SubmissionVerifications.step2_verifyGallery( this.$el )){ + this.errorKey = "noGalleryImages"; + return false; + } + + return true; + + }, + + /** + * Scroll to the specific error field. + */ + scrollToError(){ + const refMap = { + noDescription: 'descriptionField', + noGalleryImages: 'gallery-field', + isSubmitting: 'submitButton' + }; + + const target = this.$refs[refMap[this.errorKey]] + || this.$el.querySelector('.upload-list') + || this.$el.querySelector('.form-upload'); + + if (target) { + target.scrollIntoView({behavior: 'smooth', block: 'center'}); + return; + } + }, + + /** + * If you want to submit the form. + * @param {Event} e + */ + submitForm( e ){ + + if( this.duringSubmissionProcess ) + return; // Don't submit two times. + + 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(); + + this.duringSubmissionProcess = false; + return; + } + + e.target.submit(); + } + + } +} diff --git a/resources/js/settings.js b/resources/js/settings.js index 51194fb..56cda37 100644 --- a/resources/js/settings.js +++ b/resources/js/settings.js @@ -14,20 +14,15 @@ export default function settings() { */ xfUrls: {}, - /** - * @type {number[]} - */ - entriesPerPage: [ 12, 30, 48 ], - /** * @type {string} */ - currentTheme: Cookies.get("theme") ?? 'default', + currentTheme: 'default', /** - * @type {number} + * @type {list|null} */ - currentEntriesPerPage: Cookies.get("entries_per_page") ?? 30, + currentActivityFilters: null, /** * @@ -43,7 +38,7 @@ export default function settings() { 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') } ); + // Cookies.set('theme', this.currentTheme, { expires: 365, path: '/', domain: window.getConfig('session-domain') } ); this.syncXF(); }, @@ -62,22 +57,48 @@ export default function settings() { 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; }, + + async toggleActivityFilter( type ){ + + if( this.currentActivityFilters === null ) + return; + + const i = this.currentActivityFilters.indexOf( type ); + if( i !== -1 && this.currentActivityFilters.length === 1) + return; + + if( i === -1 ) + this.currentActivityFilters.push( type ); + else + this.currentActivityFilters.splice( i, 1 ); + + Cookies.set( 'activity_filters', JSON.stringify(this.currentActivityFilters), { expires: 365, path: '/', domain: window.getConfig('session-domain') } ); + await this.syncTimeline(); + }, + + async syncTimeline(){ + + const tl = document.getElementById('activity-timeline'); + if( !tl ) + return; + + tl.style.opacity = 0.5; + + const params = this.currentActivityFilters.join(','); + const response = await fetch(`/api/dynamic/activity/feed?filters=${params}`); + const data = await response.json(); + + if( !data.html ) + return; + + tl.innerHTML = data.html; + tl.style.opacity = 1; + + refreshIcons(tl); + + } + } } diff --git a/resources/js/submissions.js b/resources/js/submissions.js index e9b0a67..7e73593 100644 --- a/resources/js/submissions.js +++ b/resources/js/submissions.js @@ -22,11 +22,12 @@ const ERROR_TABLE = { uploadError: "One or more files failed to upload.", notAllFilesDone: "Not all the files have finished uploading yet.", noModifications: "Please select at least a type of hack.", + noSystems: "Please select at least a system.", noDescription: "Please provide a description.", noGame: "Please provide a game or create a new one and fill all the required fields.", noLanguages: "Please select at least a language.", noAuthors: "Please provide at least an author or create a new one and fill all the required fields.", - noMainImage: "Please select a main image.", + noMainImage: "Please upload a main image.", noGalleryImages: "Please select at least a gallery image.", isSubmitting: "The entry is already during submission." } @@ -37,6 +38,7 @@ const ERROR_TABLE = { * @constructor */ const SECTION = () => document.querySelector("meta[name='fs-section']")?.content ?? ''; +const CSRF = () => document.querySelector("meta[name='csrf-token']")?.content ?? ''; window.FSUploader = FSUploader; window.HashesManager = HashesManager; @@ -117,6 +119,10 @@ window.SubmissionVerifications = { return verifyCheckboxes( document.querySelector( '#modifications-group' ) ); }, + step5_UtilitiesSystemsCheckboxes: function(){ + return verifyCheckboxes( document.querySelector( '#systems-group' ) ); + }, + /** * Verify if the description field has at least one character. * @returns {boolean} @@ -132,6 +138,10 @@ window.SubmissionVerifications = { */ step7_VerifyGame: function( element ){ + const GAME_SELECTOR_MODE = document.querySelector('input[name="game_selection_mode"]')?.value ?? "game"; + if( GAME_SELECTOR_MODE === 'platform' || GAME_SELECTOR_MODE === 'none' ) + return true; + // Check if we have an already existent selected game. const GAME_ID_INPUT = document.querySelector('input[name="game_id"]'); if( GAME_ID_INPUT ){ @@ -259,69 +269,75 @@ window.Submission = function(){ */ verifyForm(){ - console.log( "Step 1" ); + console.info( "Step 1: During File upload" ); if( !SubmissionVerifications.step1_DuringFSUpload( this.Uploader ) ){ this.errorKey = "isUploading"; return false; } - console.log( "Step 2" ); + console.info( "Step 2: No files uploaded" ); if( !SubmissionVerifications.step2_NoFilesFSUpload( this.Uploader ) ){ this.errorKey = "noFiles"; return false; } - console.log( "Step 3" ); + console.info( 'Step 3: Error in file upload') if( !SubmissionVerifications.step3_ErrorsFSUpload( this.Uploader ) ){ this.errorKey = "uploadError"; return false; } - console.log( "Step 4" ); + console.info("Step 4: All files uploaded"); if( !SubmissionVerifications.step4_AllFilesUploadedFSUpload( this.Uploader ) ){ this.errorKey = "notAllFilesDone"; return false; } - if( SECTION() === "romhacks" ){ - console.log( "Step 5" ); + if( SECTION() === "romhacks" || SECTION() === "lua-scripts" ){ + console.info( "Step 5: Verify modifications") if( !SubmissionVerifications.step5_RomhacksModificationsCheckboxes()){ this.errorKey = "noModifications"; return false; } + } else if( SECTION() === "utilities" ){ + console.info( "Step 5: Verify systems"); + if( !SubmissionVerifications.step5_UtilitiesSystemsCheckboxes()){ + this.errorKey = "noSystems"; + return false; + } } - console.log( "Step 6" ); + console.info( "Step 6: Verify description"); if( !SubmissionVerifications.step6_VerifyDescription() ){ this.errorKey = "noDescription"; return false; } - console.log( "Step 7" ); + console.info( "Step 7: Verify game"); if( !SubmissionVerifications.step7_VerifyGame( this.$el ) ){ this.errorKey = "noGame"; return false; } - console.log( "Step 8" ); + console.info("Step 8: Verify languages"); if( !SubmissionVerifications.step8_LanguagesCheckboxes()){ this.errorKey = "noLanguages"; return false; } - console.log( "Step 9" ); + console.info( "Step 9: Verify authors" ); if( !SubmissionVerifications.step9_verifyAuthors()){ this.errorKey = "noAuthors"; return false; } - console.log( "Step 10" ); + console.info( "Step 10: Verify Main image" ); if( !SubmissionVerifications.step10_verifyMainImage( this.$el )){ this.errorKey = "noMainImage"; return false; } - console.log( "Step 11" ); + console.info( "Step 11: Verify gallery images" ); if( !SubmissionVerifications.step11_verifyGallery( this.$el )){ this.errorKey = "noGalleryImages"; return false; @@ -341,6 +357,7 @@ window.Submission = function(){ notAllFilesDone: 'uploadTarget', uploadError: 'uploadTarget', noModifications: 'modificationsGroup', + noSystems: 'systemsGroup', noDescription: 'descriptionField', noGame: 'gameSelector', noLanguages: 'languagesGroup', @@ -350,9 +367,13 @@ window.Submission = function(){ isSubmitting: 'submitButton' }; - const target = this.$refs[refMap[this.errorKey]] - || this.$el.querySelector('.upload-list') - || this.$el.querySelector('.form-upload'); + const targetKey = refMap[this.errorKey]; + + const target = this.$refs[targetKey] + || this.$el.querySelector(`[data-target="${targetKey}"]`) + || this.$el.querySelector(`[x-ref="${targetKey}"]`) + || this.$el.querySelector('.upload-list') + || this.$el.querySelector('.form-upload'); if (target) { target.scrollIntoView({behavior: 'smooth', block: 'center'}); @@ -387,6 +408,31 @@ window.Submission = function(){ } e.target.submit(); + }, + + async requestFeatured( entryId ){ + const csrf = CSRF(); + + const response = await fetch(`/api/entry/${entryId}/featured`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf + } + }); + const json = await response.json(); + + const entry_featured_button = document.querySelector('#entry-featured-button'); + const entry_featured_body = document.querySelector('#entry-featured-body'); + + if( json.success ){ + entry_featured_body.innerHTML = '

Request submitted

'; + entry_featured_button.style.display = 'none'; + } else { + entry_featured_body.innerHTML = '

Request failed. Please refresh the page and retry.

'; + entry_featured_button.style.display = 'none'; + } + } } diff --git a/resources/views/activity/featured-entries.blade.php b/resources/views/activity/featured-entries.blade.php new file mode 100644 index 0000000..b518475 --- /dev/null +++ b/resources/views/activity/featured-entries.blade.php @@ -0,0 +1,40 @@ + +@if($featuredEntries->isNotEmpty()) +
+
+

+ + Featured entries +

+
+ + +
+@endif diff --git a/resources/views/activity/latest-news.blade.php b/resources/views/activity/latest-news.blade.php new file mode 100644 index 0000000..6d13d74 --- /dev/null +++ b/resources/views/activity/latest-news.blade.php @@ -0,0 +1,34 @@ + +
+
+

+ + Latest news +

+ + See all + +
+ + +
diff --git a/resources/views/activity/timeline.blade.php b/resources/views/activity/timeline.blade.php new file mode 100644 index 0000000..e8bf9c1 --- /dev/null +++ b/resources/views/activity/timeline.blade.php @@ -0,0 +1,105 @@ +@php $currentDay = null; @endphp +
+ @forelse($items as $item) + + @php + $day = $item->date->format('Y-m-d'); + $dayLabel = $item->date->isToday() ? 'Today' + : ($item->date->isYesterday() ? 'Yesterday' + : $item->date->format('M d, Y')); + @endphp + + @if($day !== $currentDay) + @php $currentDay = $day; @endphp +
+ {{ $dayLabel }} +
+
+ @endif + + + + @empty +
+ +

No recent activity.

+
+ @endforelse +
diff --git a/resources/views/components/category-selector.blade.php b/resources/views/components/category-selector.blade.php new file mode 100644 index 0000000..2a2193f --- /dev/null +++ b/resources/views/components/category-selector.blade.php @@ -0,0 +1,29 @@ + +
+ +
+ @foreach( $categories as $category ) + + @endforeach +
+ +
diff --git a/resources/views/components/database-filter-with-mode-search.blade.php b/resources/views/components/database-filter-with-mode-search.blade.php index 44c035c..caae804 100644 --- a/resources/views/components/database-filter-with-mode-search.blade.php +++ b/resources/views/components/database-filter-with-mode-search.blade.php @@ -1,4 +1,4 @@ -
+

{{ $title }}

@@ -18,11 +18,11 @@
@foreach($items as $item) -