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/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/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/Http/Controllers/DynamicLoadController.php b/app/Http/Controllers/DynamicLoadController.php index 0f91890..5cde67a 100644 --- a/app/Http/Controllers/DynamicLoadController.php +++ b/app/Http/Controllers/DynamicLoadController.php @@ -68,7 +68,7 @@ class DynamicLoadController extends Controller public function activityFeed(Request $request): JsonResponse { - $availableFilters = ['entries', 'news', 'messages', 'threads', 'clubs']; + $availableFilters = ['entries', 'news', 'messages', 'threads', 'clubs', 'reviews']; $requested = $request->query('filters') ? explode(',', $request->query('filters')) diff --git a/app/Http/Controllers/EntryController.php b/app/Http/Controllers/EntryController.php index 80e4094..dcb5c84 100644 --- a/app/Http/Controllers/EntryController.php +++ b/app/Http/Controllers/EntryController.php @@ -50,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')); } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 3b003d9..eafa41e 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -15,7 +15,7 @@ class HomeController extends Controller public function index( Request $request ): View { - $filters = [ 'entries', 'news', 'messages', 'threads', 'clubs' ]; + $filters = [ 'entries', 'news', 'messages', 'threads', 'clubs', 'reviews' ]; $cookie = $request->cookie('activity_filters'); $activeFilters = $cookie ? array_intersect( json_decode( $cookie, true ) ?? [], $filters ) : $filters; @@ -32,6 +32,7 @@ class HomeController extends Controller '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(); 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/ToolsController.php b/app/Http/Controllers/ToolsController.php index 36fa137..f1b5e9b 100644 --- a/app/Http/Controllers/ToolsController.php +++ b/app/Http/Controllers/ToolsController.php @@ -61,4 +61,10 @@ class ToolsController extends Controller 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 e9b7a58..276f472 100644 --- a/app/Http/Requests/StoreEntryRequest.php +++ b/app/Http/Requests/StoreEntryRequest.php @@ -73,7 +73,7 @@ class StoreEntryRequest extends FormRequest 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', $section ) ){ + } else if( section_must_be( ['utilities','documents'], $section ) ){ $rules['categories'] = 'array|required|min:1'; $rules['categories.*'] = 'integer|exists:categories,id'; } @@ -85,7 +85,7 @@ class StoreEntryRequest extends FormRequest $rules['version'] = 'required|string|max:50'; $rules['release-date'] = 'required|date'; - if( section_must_not_be( 'utilities', $section ) ){ + if( section_must_not_be( ['utilities', 'documents'], $section ) ){ $rules['status'] = 'required|integer|exists:statuses,id'; } else { $rules['level'] = 'required|integer|exists:levels,id'; @@ -159,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; @@ -167,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/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/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/Database.php b/app/Livewire/Database.php index da4a926..4f44940 100644 --- a/app/Livewire/Database.php +++ b/app/Livewire/Database.php @@ -128,7 +128,7 @@ class Database extends Component * Categories mode and/or * @var string */ - #[Url(except:['or'])] + #[Url(except:'or')] public string $categoriesMode = 'or'; /** @@ -142,7 +142,7 @@ class Database extends Component * Systems mode and/or * @var string */ - #[Url(except:['or'])] + #[Url(except:'or')] public string $systemsMode = 'or'; /** @@ -152,6 +152,9 @@ class Database extends Component #[Url(except:[])] public array $levels = []; + #[Url(except:null)] + public ?int $userId = null; + /** * Sort by field. * @var string @@ -185,7 +188,6 @@ class Database extends Component 'utilities' => 'Utilities', 'documents' => 'Documents', 'lua-scripts' => 'Lua Scripts', - 'tutorials' => 'Tutorials', ]; public const int PAGINATION = 30; @@ -207,12 +209,14 @@ class Database extends Component 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', 'categories', 'categoriesMode', 'systems', 'systemsMode', 'levels' + 'search', 'types', 'platforms', 'genres', 'statuses', 'authors', 'authorsMode', 'languages', 'languagesMode', 'modifications', 'modificationsMode', 'categories', 'categoriesMode', 'systems', 'systemsMode', 'levels', 'userId' ]); + $this->dispatch('filters-updated'); $this->resetPage(); } @@ -320,18 +324,59 @@ class Database extends Component } } + 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(), 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/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/Entry.php b/app/Models/Entry.php index 22a2795..5fcb025 100644 --- a/app/Models/Entry.php +++ b/app/Models/Entry.php @@ -4,12 +4,14 @@ 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; @@ -97,12 +99,17 @@ use Spatie\Activitylog\Support\LogOptions; * @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, HasGallery, LogsActivity; + use SoftDeletes, HasGallery, LogsActivity, HasXenforoUserId; /** * @var string[] @@ -143,6 +150,15 @@ class Entry extends Model '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' ); } @@ -207,6 +223,30 @@ class Entry extends Model return $this->hasMany(EntryHash::class); } + 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 ); } 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/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/News.php b/app/Models/News.php index 7bd8548..4eee9a6 100644 --- a/app/Models/News.php +++ b/app/Models/News.php @@ -4,10 +4,12 @@ namespace App\Models; use App\Helpers\EntryHelpers; use App\Traits\HasGallery; +use App\Traits\HasXenforoUserId; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; +use League\CommonMark\GithubFlavoredMarkdownConverter; /** * @property int $id @@ -59,7 +61,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class News extends Model { - use SoftDeletes, HasGallery; + use SoftDeletes, HasGallery, HasXenforoUserId; protected $table = 'news'; @@ -108,6 +110,16 @@ class News extends Model 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; 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/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index bba7ec1..04dd124 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,7 @@ 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; @@ -39,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 index 896f4b1..eb8396c 100644 --- a/app/Services/ActivityService.php +++ b/app/Services/ActivityService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\Entry; +use App\Models\EntryReview; use App\Models\News; use App\View\Components\EntryCard; use Illuminate\Support\Carbon; @@ -17,9 +18,10 @@ class ActivityService private const CACHE_MESSAGES = 300; // seconds. private const CACHE_THREADS = 300; // seconds. private const CACHE_CLUBS = 300; // seconds. + private const CACHE_REVIEWS = 300; // seconds. private const ITEMS_PER_TYPE = 15; - public function getActivities( array $activities = [ 'entries', 'news', 'messages', 'threads', 'clubs' ] ): Collection + public function getActivities( array $activities = [ 'entries', 'news', 'messages', 'threads', 'clubs', 'reviews' ] ): Collection { $c = collect(); if( in_array( 'entries', $activities ) ) { @@ -37,6 +39,9 @@ class ActivityService 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() @@ -135,6 +140,23 @@ class ActivityService ]; } + 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() { @@ -220,4 +242,16 @@ class ActivityService ->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/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 e89c394..bef15f7 100644 --- a/app/Services/SubmissionsService.php +++ b/app/Services/SubmissionsService.php @@ -201,7 +201,7 @@ class SubmissionsService { $this->Step11_SaveLanguages( $entry ); // STEP 11.5 : Save Categories - if( section_must_be( 'utilities', $this->section ) ) { + if( section_must_be( ['utilities', 'documents'], $this->section ) ) { $this->Step11_5_SaveCategories($entry); } @@ -605,6 +605,9 @@ class SubmissionsService { 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 ); @@ -631,7 +634,7 @@ class SubmissionsService { $this->eStep10_UpdateLanguages(); // STEP 10.5 : Update categories - if( section_must_be( 'utilities', $this->section ) ) + if( section_must_be( ['utilities', 'documents'], $this->section ) ) $this->eStep10_5_UpdateCategories(); // STEP 11: Prepare new gallery images and prepare deletion of others ones. diff --git a/app/Services/XenforoApiService.php b/app/Services/XenforoApiService.php index 602c6e3..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,8 +87,9 @@ 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; } @@ -152,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/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/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/ReviewCard.php b/app/View/Components/ReviewCard.php new file mode 100644 index 0000000..bf6fb76 --- /dev/null +++ b/app/View/Components/ReviewCard.php @@ -0,0 +1,30 @@ + [], @@ -50,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' => '' @@ -57,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 !== '', @@ -67,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 641e643..36f39a2 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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 9df3a22..69a58be 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,8 @@ "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", "spatie/laravel-activitylog": "^5.0" diff --git a/composer.lock b/composer.lock index de6c591..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": "d561062afd8c291a93e8fd1e00f4e901", + "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", diff --git a/config/database.php b/config/database.php index c806590..dfaa57d 100644 --- a/config/database.php +++ b/config/database.php @@ -136,6 +136,32 @@ return [ '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 89cabe7..2e18180 100644 --- a/config/menu.php +++ b/config/menu.php @@ -73,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_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/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 2611c34..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 */ @@ -352,6 +432,7 @@ ul { .\$breadcrumb { margin-bottom: 15px; + flex-shrink: 0; } /* PAGE */ @@ -361,6 +442,7 @@ ul { font-weight: 300; margin-bottom: 20px; color: var(--text); + flex-shrink: 0; } /* TEXTS */ @@ -401,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 { @@ -664,6 +831,10 @@ ul { flex-direction: column; } + .\$database-wrapper { + flex-direction: column; + } + .\$database-filters { width: 100%; display: grid; @@ -680,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; } } @@ -892,7 +1107,7 @@ ul { font-weight: 600; color: var(--text); margin-bottom: 6px; - white-space: nowrap; + white-space: normal; overflow: hidden; text-overflow: ellipsis; } @@ -958,6 +1173,106 @@ ul { } } +@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 { @@ -1591,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); } @@ -1697,6 +2023,89 @@ ul { 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 { @@ -1740,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 { @@ -1859,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 { @@ -2467,6 +2909,131 @@ ul { 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 { @@ -2492,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); } @@ -2798,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 */ @@ -2812,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); @@ -2977,6 +3668,62 @@ ul { .\$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 { @@ -3160,7 +3907,7 @@ ul { color: var(--rhpz-orange); } -.\$activity-tl-dot--news { +.\$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); @@ -3246,7 +3993,7 @@ ul { border: 1px solid rgba(255,115,0,0.25); } -.\$activity-tl-badge--news { +.\$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); @@ -3315,6 +4062,32 @@ ul { .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 { @@ -3526,10 +4299,54 @@ ul { .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: repeat(2, 1fr); } - .featured-entries-grid { grid-template-columns: repeat(2, 1fr); } + .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; + } } @@ -3589,6 +4406,155 @@ ul { } } +.\$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; @@ -3869,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; - } } } } @@ -3983,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 { @@ -4201,6 +5376,21 @@ ul { 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 { @@ -4422,7 +5612,6 @@ ul { color: #e57373; } -/* ── Hero ────────────────────────────────────────────────── */ .\$news-hero { display: block; position: relative; @@ -4609,6 +5798,541 @@ ul { .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 { @@ -4902,11 +6626,62 @@ ul { .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: 20px; padding-right: 20px; } + .\$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/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/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/resources/css/app.css b/resources/css/app.css index ebd3d5b..fa6b501 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -7,6 +7,8 @@ @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 e4e392e..1669a08 100644 --- a/resources/css/components/common.css +++ b/resources/css/components/common.css @@ -146,6 +146,7 @@ .breadcrumb { margin-bottom: 15px; + flex-shrink: 0; } /* PAGE */ @@ -155,6 +156,7 @@ font-weight: 300; margin-bottom: 20px; color: var(--text); + flex-shrink: 0; } /* TEXTS */ @@ -193,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 94156bd..4f75308 100644 --- a/resources/css/components/forms.css +++ b/resources/css/components/forms.css @@ -508,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); } @@ -613,3 +624,86 @@ .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/modcp.css b/resources/css/components/modcp.css index cafe51f..2c5912b 100644 --- a/resources/css/components/modcp.css +++ b/resources/css/components/modcp.css @@ -547,3 +547,128 @@ 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 index 34e71cf..54d7b07 100644 --- a/resources/css/layout/activity.css +++ b/resources/css/layout/activity.css @@ -112,7 +112,7 @@ color: var(--rhpz-orange); } -.activity-tl-dot--news { +.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); @@ -198,7 +198,7 @@ border: 1px solid rgba(255,115,0,0.25); } -.activity-tl-badge--news { +.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); @@ -267,6 +267,32 @@ .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 { @@ -478,8 +504,52 @@ .featured-entries-grid { grid-template-columns: repeat(2, 1fr); } } -@media (max-width: 600px) { - .news-strip { grid-template-columns: repeat(2, 1fr); } - .featured-entries-grid { grid-template-columns: repeat(2, 1fr); } - .news-strip-cover { height: 80px; } +@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/news.css b/resources/css/layout/news.css index e5159e1..f1a7b19 100644 --- a/resources/css/layout/news.css +++ b/resources/css/layout/news.css @@ -77,6 +77,21 @@ 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 { @@ -298,7 +313,6 @@ color: #e57373; } -/* ── Hero ────────────────────────────────────────────────── */ .news-hero { display: block; position: relative; @@ -484,3 +498,106 @@ .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 index 92c22bf..5c251da 100644 --- a/resources/css/layout/submit.css +++ b/resources/css/layout/submit.css @@ -289,9 +289,60 @@ .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: 20px; padding-right: 20px; } + .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/app.js b/resources/js/app.js index 4cd824a..8fc69c6 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -7,6 +7,7 @@ import hovercard from "./hovercard.js"; import notifications from "./notifications.js"; import conversations from "./conversations.js"; import settings from "./settings.js"; +import { initMobileMenu } from "./mobile-menu.js"; /** * Get config defined in meta.blade.php @@ -43,3 +44,6 @@ Alpine.store('conversations', conversations() ); // Settings Alpine.store('settings', settings() ); + +// 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/submissions.js b/resources/js/submissions.js index ae70c19..7e73593 100644 --- a/resources/js/submissions.js +++ b/resources/js/submissions.js @@ -27,7 +27,7 @@ const ERROR_TABLE = { 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." } @@ -269,75 +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" || SECTION() === "lua-scripts" ){ - console.log( "Step 5" ); + console.info( "Step 5: Verify modifications") if( !SubmissionVerifications.step5_RomhacksModificationsCheckboxes()){ this.errorKey = "noModifications"; return false; } } else if( SECTION() === "utilities" ){ - console.log( "Step 5" ); + 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; @@ -367,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'}); diff --git a/resources/views/activity/timeline.blade.php b/resources/views/activity/timeline.blade.php index a9aa66e..e8bf9c1 100644 --- a/resources/views/activity/timeline.blade.php +++ b/resources/views/activity/timeline.blade.php @@ -31,6 +31,8 @@ @elseif($item->type === 'club') + @elseif($item->type === 'review') + @else @endif 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) -