From 979e011257c75b9739f54b304e94abe887feeba8 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sat, 21 Mar 2026 11:02:22 +0100 Subject: [PATCH] Refactor dashboard and upload flows Remove dead admin UI code, redesign dashboard followers/following and upload experiences, and add schema audit tooling with repair migrations for forum and upload drift. --- .../Commands/AuditMigrationSchemaCommand.php | 519 ++++++++++++++++++ .../Admin/CountryAdminController.php | 95 ---- .../Admin/EarlyGrowthAdminController.php | 119 ---- .../Admin/StoryAdminController.php | 211 ------- .../Admin/TagInteractionReportController.php | 47 -- .../Dashboard/FollowerController.php | 74 ++- .../Dashboard/FollowingController.php | 79 ++- .../Web/StaffApplicationAdminController.php | 21 - app/Providers/AppServiceProvider.php | 2 - ...000_recreate_missing_forum_spam_tables.php | 58 ++ ...recreate_missing_forum_learning_tables.php | 50 ++ ...recreate_missing_forum_learning_tables.php | 50 ++ ...orce_recreate_missing_forum_tag_tables.php | 37 ++ ...ir_missing_upload_and_forum_ai_columns.php | 111 ++++ resources/js/Pages/Upload/Index.jsx | 75 ++- .../js/components/upload/CategorySelector.jsx | 72 ++- .../js/components/upload/PublishPanel.jsx | 99 +++- .../js/components/upload/StudioStatusBar.jsx | 8 +- .../js/components/upload/UploadActions.jsx | 11 +- .../js/components/upload/UploadDropzone.jsx | 18 +- .../js/components/upload/UploadOverlay.jsx | 210 ++++--- .../js/components/upload/UploadSidebar.jsx | 8 +- .../js/components/upload/UploadWizard.jsx | 21 +- .../upload/__tests__/UploadWizard.test.jsx | 6 +- .../upload/steps/Step1FileUpload.jsx | 28 +- .../components/upload/steps/Step2Details.jsx | 10 +- .../components/upload/steps/Step3Publish.jsx | 6 +- resources/js/dashboard/DashboardPage.jsx | 130 ++++- .../js/dashboard/components/ActivityFeed.jsx | 102 ++-- .../dashboard/components/CreatorAnalytics.jsx | 21 +- .../components/RecentAchievements.jsx | 20 +- .../components/RecommendedCreators.jsx | 96 +++- .../components/TopCreatorsWidget.jsx | 28 +- .../dashboard/components/XPProgressWidget.jsx | 20 +- .../js/hooks/upload/useFileValidation.js | 11 +- .../views/admin/countries/cpad.blade.php | 108 ---- .../views/admin/countries/index.blade.php | 99 ---- .../views/admin/early-growth/index.blade.php | 156 ------ resources/views/admin/reports/queue.blade.php | 24 - resources/views/admin/reports/tags.blade.php | 216 -------- .../admin/staff_applications/index.blade.php | 36 -- .../admin/staff_applications/show.blade.php | 36 -- .../stories/comments-moderation.blade.php | 8 - .../views/admin/stories/create.blade.php | 8 - resources/views/admin/stories/edit.blade.php | 21 - resources/views/admin/stories/index.blade.php | 42 -- .../admin/stories/partials/form.blade.php | 71 --- .../views/admin/stories/review.blade.php | 46 -- resources/views/admin/stories/show.blade.php | 49 -- .../dashboard/filter-select.blade.php | 68 +++ resources/views/dashboard/followers.blade.php | 299 +++++++++- resources/views/dashboard/following.blade.php | 298 ++++++++-- .../AdminStoryModerationWorkflowTest.php | 95 ---- tests/e2e/dashboard-mobile-layout.spec.ts | 173 ++++++ tests/e2e/upload-layout.spec.ts | 173 ++++++ 55 files changed, 2576 insertions(+), 1923 deletions(-) create mode 100644 app/Console/Commands/AuditMigrationSchemaCommand.php delete mode 100644 app/Http/Controllers/Admin/CountryAdminController.php delete mode 100644 app/Http/Controllers/Admin/EarlyGrowthAdminController.php delete mode 100644 app/Http/Controllers/Admin/StoryAdminController.php delete mode 100644 app/Http/Controllers/Admin/TagInteractionReportController.php delete mode 100644 app/Http/Controllers/Web/StaffApplicationAdminController.php create mode 100644 database/migrations/2026_03_21_120000_recreate_missing_forum_spam_tables.php create mode 100644 database/migrations/2026_03_21_121000_recreate_missing_forum_learning_tables.php create mode 100644 database/migrations/2026_03_21_121100_force_recreate_missing_forum_learning_tables.php create mode 100644 database/migrations/2026_03_21_121200_force_recreate_missing_forum_tag_tables.php create mode 100644 database/migrations/2026_03_21_122000_repair_missing_upload_and_forum_ai_columns.php delete mode 100644 resources/views/admin/countries/cpad.blade.php delete mode 100644 resources/views/admin/countries/index.blade.php delete mode 100644 resources/views/admin/early-growth/index.blade.php delete mode 100644 resources/views/admin/reports/queue.blade.php delete mode 100644 resources/views/admin/reports/tags.blade.php delete mode 100644 resources/views/admin/staff_applications/index.blade.php delete mode 100644 resources/views/admin/staff_applications/show.blade.php delete mode 100644 resources/views/admin/stories/comments-moderation.blade.php delete mode 100644 resources/views/admin/stories/create.blade.php delete mode 100644 resources/views/admin/stories/edit.blade.php delete mode 100644 resources/views/admin/stories/index.blade.php delete mode 100644 resources/views/admin/stories/partials/form.blade.php delete mode 100644 resources/views/admin/stories/review.blade.php delete mode 100644 resources/views/admin/stories/show.blade.php create mode 100644 resources/views/components/dashboard/filter-select.blade.php delete mode 100644 tests/Feature/Stories/AdminStoryModerationWorkflowTest.php create mode 100644 tests/e2e/dashboard-mobile-layout.spec.ts create mode 100644 tests/e2e/upload-layout.spec.ts diff --git a/app/Console/Commands/AuditMigrationSchemaCommand.php b/app/Console/Commands/AuditMigrationSchemaCommand.php new file mode 100644 index 00000000..97aed5c3 --- /dev/null +++ b/app/Console/Commands/AuditMigrationSchemaCommand.php @@ -0,0 +1,519 @@ + ['id'], + 'timestamps' => ['created_at', 'updated_at'], + 'timestampsTz' => ['created_at', 'updated_at'], + 'softDeletes' => ['deleted_at'], + 'softDeletesTz' => ['deleted_at'], + 'rememberToken' => ['remember_token'], + ]; + + private const NON_COLUMN_METHODS = [ + 'index', + 'unique', + 'primary', + 'foreign', + 'foreignIdFor', + 'dropColumn', + 'dropColumns', + 'dropIndex', + 'dropUnique', + 'dropPrimary', + 'dropForeign', + 'dropConstrainedForeignId', + 'renameColumn', + 'renameIndex', + 'constrained', + 'cascadeOnDelete', + 'restrictOnDelete', + 'nullOnDelete', + 'cascadeOnUpdate', + 'restrictOnUpdate', + 'nullOnUpdate', + 'after', + 'nullable', + 'default', + 'useCurrent', + 'useCurrentOnUpdate', + 'comment', + 'charset', + 'collation', + 'storedAs', + 'virtualAs', + 'generatedAs', + 'always', + 'invisible', + 'first', + ]; + + public function handle(): int + { + $migrationFiles = $this->discoverMigrationFiles(); + $ranMigrations = collect(DB::table('migrations')->pluck('migration')->all()) + ->mapWithKeys(fn (string $migration): array => [$migration => true]) + ->all(); + + $expected = []; + $parsedFiles = 0; + + foreach ($migrationFiles as $migrationName => $path) { + if (! $this->option('all-files') && ! isset($ranMigrations[$migrationName])) { + continue; + } + + $parsedFiles++; + $operations = $this->parseMigrationFile($path); + + foreach ($operations as $operation) { + $table = $operation['table']; + + if ($operation['type'] === 'create-table' && isset($expected[$table])) { + $expected[$table]['sources'][$migrationName] = true; + + if (Schema::hasTable($table)) { + $actualColumns = array_fill_keys( + array_map('strtolower', Schema::getColumnListing($table)), + true + ); + + $existingColumns = array_fill_keys(array_keys($expected[$table]['columns']), true); + $replacementColumns = []; + + foreach ($operation['add'] as $column) { + if (! isset($existingColumns[$column]) && isset($actualColumns[$column])) { + $replacementColumns[$column] = true; + } + } + + if ($replacementColumns !== []) { + foreach ($replacementColumns as $column => $_) { + $expected[$table]['columns'][$column] = true; + } + + foreach (array_keys($expected[$table]['columns']) as $column) { + if (! isset($actualColumns[$column]) && ! isset($replacementColumns[$column])) { + unset($expected[$table]['columns'][$column]); + } + } + } + } + + continue; + } + + if ($operation['type'] === 'alter-table' && ! isset($expected[$table]) && ! Schema::hasTable($table)) { + continue; + } + + $expected[$table] ??= [ + 'columns' => [], + 'sources' => [], + ]; + + $expected[$table]['sources'][$migrationName] = true; + + if ($operation['type'] === 'drop-table') { + unset($expected[$table]); + continue; + } + + foreach ($operation['add'] as $column) { + $expected[$table]['columns'][$column] = true; + } + + foreach ($operation['drop'] as $column) { + unset($expected[$table]['columns'][$column]); + } + } + } + + ksort($expected); + + $report = [ + 'parsed_files' => $parsedFiles, + 'expected_tables' => count($expected), + 'missing_tables' => [], + 'missing_columns' => [], + ]; + + foreach ($expected as $table => $spec) { + $sources = array_keys($spec['sources']); + sort($sources); + + if (! Schema::hasTable($table)) { + $report['missing_tables'][] = [ + 'table' => $table, + 'sources' => $sources, + ]; + continue; + } + + $actualColumns = array_map('strtolower', Schema::getColumnListing($table)); + $expectedColumns = array_keys($spec['columns']); + sort($expectedColumns); + + $missing = array_values(array_diff($expectedColumns, $actualColumns)); + if ($missing !== []) { + $report['missing_columns'][] = [ + 'table' => $table, + 'columns' => $missing, + 'sources' => $sources, + ]; + } + } + + if ((bool) $this->option('json')) { + $this->line(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } else { + $this->renderReport($report); + } + + return ($report['missing_tables'] === [] && $report['missing_columns'] === []) + ? self::SUCCESS + : self::FAILURE; + } + + /** + * @return array + */ + private function discoverMigrationFiles(): array + { + $paths = [ + database_path('migrations'), + base_path('packages/klevze'), + ]; + + foreach ((array) $this->option('base-path') as $relativePath) { + $resolved = base_path((string) $relativePath); + if (is_dir($resolved)) { + $paths[] = $resolved; + } + } + + $finder = new Finder(); + $finder->files()->name('*.php'); + + foreach ($paths as $path) { + if (is_dir($path)) { + $finder->in($path); + } + } + + $files = []; + foreach ($finder as $file) { + $realPath = $file->getRealPath(); + if (! $realPath) { + continue; + } + + $normalized = str_replace('\\', '/', $realPath); + if (! str_contains($normalized, '/database/migrations/') && ! str_contains($normalized, '/Migrations/')) { + continue; + } + + $files[pathinfo($realPath, PATHINFO_FILENAME)] = $realPath; + } + + ksort($files); + + return $files; + } + + /** + * @return array, drop:array}> + */ + private function parseMigrationFile(string $path): array + { + $content = File::get($path); + $upBody = $this->extractMethodBody($content, 'up'); + if ($upBody === null) { + return []; + } + + $operations = []; + + foreach ($this->extractSchemaClosures($upBody) as $closure) { + $operations[] = [ + 'type' => $closure['operation'], + 'table' => $closure['table'], + 'add' => $this->extractAddedColumns($closure['body']), + 'drop' => $this->extractDroppedColumns($closure['body']), + ]; + } + + if (preg_match_all("/Schema::drop(?:IfExists)?\(\s*['\"]([^'\"]+)['\"]\s*\)/", $upBody, $matches)) { + foreach ($matches[1] as $table) { + $operations[] = [ + 'type' => 'drop-table', + 'table' => strtolower((string) $table), + 'add' => [], + 'drop' => [], + ]; + } + } + + foreach ($this->extractRawAlterTableChanges($upBody) as $change) { + $operations[] = [ + 'type' => 'alter-table', + 'table' => $change['table'], + 'add' => [$change['new_column']], + 'drop' => [$change['old_column']], + ]; + } + + return $operations; + } + + /** + * @return array + */ + private function extractRawAlterTableChanges(string $upBody): array + { + $changes = []; + + if (preg_match_all( + '/ALTER\s+TABLE\s+[`"]?([^`"\s]+)[`"]?\s+CHANGE(?:\s+COLUMN)?\s+[`"]?([^`"\s]+)[`"]?\s+[`"]?([^`"\s]+)[`"]?/i', + $upBody, + $matches, + PREG_SET_ORDER + )) { + foreach ($matches as $match) { + $oldColumn = strtolower((string) $match[2]); + $newColumn = strtolower((string) $match[3]); + + if ($oldColumn === $newColumn) { + continue; + } + + $changes[] = [ + 'table' => strtolower((string) $match[1]), + 'old_column' => $oldColumn, + 'new_column' => $newColumn, + ]; + } + } + + return $changes; + } + + private function extractMethodBody(string $content, string $method): ?string + { + if (! preg_match('/function\s+' . preg_quote($method, '/') . '\s*\([^)]*\)\s*(?::\s*[^{]+)?\s*\{/m', $content, $match, PREG_OFFSET_CAPTURE)) { + return null; + } + + $start = $match[0][1] + strlen($match[0][0]) - 1; + $end = $this->findMatchingBrace($content, $start); + + if ($end === null) { + return null; + } + + return substr($content, $start + 1, $end - $start - 1); + } + + private function findMatchingBrace(string $content, int $openingBracePos): ?int + { + $length = strlen($content); + $depth = 0; + $inSingle = false; + $inDouble = false; + + for ($index = $openingBracePos; $index < $length; $index++) { + $char = $content[$index]; + $prev = $index > 0 ? $content[$index - 1] : ''; + + if ($char === "'" && ! $inDouble && $prev !== '\\') { + $inSingle = ! $inSingle; + continue; + } + + if ($char === '"' && ! $inSingle && $prev !== '\\') { + $inDouble = ! $inDouble; + continue; + } + + if ($inSingle || $inDouble) { + continue; + } + + if ($char === '{') { + $depth++; + continue; + } + + if ($char === '}') { + $depth--; + if ($depth === 0) { + return $index; + } + } + } + + return null; + } + + /** + * @return array + */ + private function extractSchemaClosures(string $upBody): array + { + preg_match_all('/Schema::(create|table)\(\s*[\'\"]([^\'\"]+)[\'\"]\s*,\s*function/s', $upBody, $matches, PREG_OFFSET_CAPTURE); + + $closures = []; + foreach ($matches[0] as $index => $fullMatch) { + $offset = (int) $fullMatch[1]; + $operation = strtolower((string) $matches[1][$index][0]) === 'create' ? 'create-table' : 'alter-table'; + $table = strtolower((string) $matches[2][$index][0]); + + $bracePos = strpos($upBody, '{', $offset); + if ($bracePos === false) { + continue; + } + + $closing = $this->findMatchingBrace($upBody, $bracePos); + if ($closing === null) { + continue; + } + + $closures[] = [ + 'operation' => $operation, + 'table' => $table, + 'body' => substr($upBody, $bracePos + 1, $closing - $bracePos - 1), + ]; + } + + return $closures; + } + + /** + * @return array + */ + private function extractAddedColumns(string $body): array + { + $columns = []; + + if (preg_match_all('/\$table->([A-Za-z_][A-Za-z0-9_]*)\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $method = (string) $match[1]; + $column = strtolower((string) $match[2]); + + if (in_array($method, self::NON_COLUMN_METHODS, true)) { + continue; + } + + $columns[$column] = true; + } + } + + if (preg_match_all('/\$table->([A-Za-z_][A-Za-z0-9_]*)\(\s*\)(?:[^;]*)?;/s', $body, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $method = (string) $match[1]; + foreach (self::NO_ARG_COLUMN_METHODS[$method] ?? [] as $column) { + $columns[$column] = true; + } + } + } + + if (preg_match_all('/\$table->(nullableMorphs|morphs|uuidMorphs|nullableUuidMorphs|ulidMorphs|nullableUlidMorphs)\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $prefix = strtolower((string) $match[2]); + $columns[$prefix . '_type'] = true; + $columns[$prefix . '_id'] = true; + } + } + + ksort($columns); + + return array_keys($columns); + } + + /** + * @return array + */ + private function extractDroppedColumns(string $body): array + { + $columns = []; + + if (preg_match_all('/\$table->dropColumn\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches)) { + foreach ($matches[1] as $column) { + $columns[strtolower((string) $column)] = true; + } + } + + if (preg_match_all('/\$table->dropColumn\(\s*\[(.*?)\]\s*\);/s', $body, $matches)) { + foreach ($matches[1] as $arrayBody) { + if (preg_match_all('/[\'\"]([^\'\"]+)[\'\"]/', $arrayBody, $columnMatches)) { + foreach ($columnMatches[1] as $column) { + $columns[strtolower((string) $column)] = true; + } + } + } + } + + if (preg_match_all('/\$table->renameColumn\(\s*[\'\"]([^\'\"]+)[\'\"]\s*,\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $columns[strtolower((string) $match[1])] = true; + } + } + + ksort($columns); + + return array_keys($columns); + } + + /** + * @param array{parsed_files:int, expected_tables:int, missing_tables:array}>, missing_columns:array,sources:array}>} $report + */ + private function renderReport(array $report): void + { + $this->info(sprintf( + 'Parsed %d migration file(s). Expected schema covers %d table(s).', + $report['parsed_files'], + $report['expected_tables'] + )); + + if ($report['missing_tables'] === [] && $report['missing_columns'] === []) { + $this->info('Schema audit passed. No missing tables or columns detected.'); + return; + } + + if ($report['missing_tables'] !== []) { + $this->newLine(); + $this->error('Missing tables:'); + foreach ($report['missing_tables'] as $item) { + $this->line(sprintf(' - %s', $item['table'])); + $this->line(sprintf(' sources: %s', implode(', ', $item['sources']))); + } + } + + if ($report['missing_columns'] !== []) { + $this->newLine(); + $this->error('Missing columns:'); + foreach ($report['missing_columns'] as $item) { + $this->line(sprintf(' - %s: %s', $item['table'], implode(', ', $item['columns']))); + $this->line(sprintf(' sources: %s', implode(', ', $item['sources']))); + } + } + } +} diff --git a/app/Http/Controllers/Admin/CountryAdminController.php b/app/Http/Controllers/Admin/CountryAdminController.php deleted file mode 100644 index 81480aee..00000000 --- a/app/Http/Controllers/Admin/CountryAdminController.php +++ /dev/null @@ -1,95 +0,0 @@ -query('q', '')); - - $countries = Country::query() - ->when($search !== '', function ($query) use ($search): void { - $query->where(function ($countryQuery) use ($search): void { - $countryQuery - ->where('iso2', 'like', '%'.$search.'%') - ->orWhere('iso3', 'like', '%'.$search.'%') - ->orWhere('name_common', 'like', '%'.$search.'%') - ->orWhere('name_official', 'like', '%'.$search.'%'); - }); - }) - ->ordered() - ->paginate(50) - ->withQueryString(); - - return view('admin.countries.index', [ - 'countries' => $countries, - 'search' => $search, - ]); - } - - public function sync(Request $request, CountrySyncService $countrySyncService): RedirectResponse - { - try { - $summary = $countrySyncService->sync(); - } catch (Throwable $exception) { - return redirect() - ->route('admin.countries.index') - ->with('error', $exception->getMessage()); - } - - $message = sprintf( - 'Countries synced from %s. Inserted %d, updated %d, skipped %d, deactivated %d.', - (string) ($summary['source'] ?? 'unknown'), - (int) ($summary['inserted'] ?? 0), - (int) ($summary['updated'] ?? 0), - (int) ($summary['skipped'] ?? 0), - (int) ($summary['deactivated'] ?? 0), - ); - - return redirect() - ->route('admin.countries.index') - ->with('success', $message); - } - - public function cpMain(Request $request): View - { - $view = $this->index($request); - - return view('admin.countries.cpad', $view->getData()); - } - - public function cpSync(Request $request, CountrySyncService $countrySyncService): RedirectResponse - { - try { - $summary = $countrySyncService->sync(); - } catch (Throwable $exception) { - return redirect() - ->route('admin.cp.countries.main') - ->with('msg_error', $exception->getMessage()); - } - - $message = sprintf( - 'Countries synced from %s. Inserted %d, updated %d, skipped %d, deactivated %d.', - (string) ($summary['source'] ?? 'unknown'), - (int) ($summary['inserted'] ?? 0), - (int) ($summary['updated'] ?? 0), - (int) ($summary['skipped'] ?? 0), - (int) ($summary['deactivated'] ?? 0), - ); - - return redirect() - ->route('admin.cp.countries.main') - ->with('msg_success', $message); - } -} diff --git a/app/Http/Controllers/Admin/EarlyGrowthAdminController.php b/app/Http/Controllers/Admin/EarlyGrowthAdminController.php deleted file mode 100644 index b5bb868c..00000000 --- a/app/Http/Controllers/Admin/EarlyGrowthAdminController.php +++ /dev/null @@ -1,119 +0,0 @@ -timeWindow->getUploadsPerDay(); - - return view('admin.early-growth.index', [ - 'status' => EarlyGrowth::status(), - 'mode' => EarlyGrowth::mode(), - 'uploads_per_day' => $uploadsPerDay, - 'window_days' => $this->timeWindow->getTrendingWindowDays(30), - 'activity' => $this->activityLayer->getSignals(), - 'cache_keys' => [ - 'egs.uploads_per_day', - 'egs.auto_disable_check', - 'egs.spotlight.*', - 'egs.curated.*', - 'egs.grid_filler.*', - 'egs.activity_signals', - 'homepage.fresh.*', - 'discover.trending.*', - 'discover.rising.*', - ], - 'env_toggles' => [ - ['key' => 'NOVA_EARLY_GROWTH_ENABLED', 'current' => env('NOVA_EARLY_GROWTH_ENABLED', 'false')], - ['key' => 'NOVA_EARLY_GROWTH_MODE', 'current' => env('NOVA_EARLY_GROWTH_MODE', 'off')], - ['key' => 'NOVA_EGS_ADAPTIVE_WINDOW', 'current' => env('NOVA_EGS_ADAPTIVE_WINDOW', 'true')], - ['key' => 'NOVA_EGS_GRID_FILLER', 'current' => env('NOVA_EGS_GRID_FILLER', 'true')], - ['key' => 'NOVA_EGS_SPOTLIGHT', 'current' => env('NOVA_EGS_SPOTLIGHT', 'true')], - ['key' => 'NOVA_EGS_ACTIVITY_LAYER', 'current' => env('NOVA_EGS_ACTIVITY_LAYER', 'false')], - ], - ]); - } - - /** - * DELETE /admin/early-growth/cache - * Flush all EGS-related cache keys so new config changes take effect immediately. - */ - public function flushCache(Request $request): RedirectResponse - { - $keys = [ - 'egs.uploads_per_day', - 'egs.auto_disable_check', - 'egs.activity_signals', - ]; - - // Flush the EGS daily spotlight caches for today - $today = now()->format('Y-m-d'); - foreach ([6, 12, 18, 24] as $n) { - Cache::forget("egs.spotlight.{$today}.{$n}"); - Cache::forget("egs.curated.{$today}.{$n}.7"); - } - - // Flush fresh/trending homepage sections - foreach ([6, 8, 10, 12] as $limit) { - foreach (['off', 'light', 'aggressive'] as $mode) { - Cache::forget("homepage.fresh.{$limit}.egs-{$mode}"); - Cache::forget("homepage.fresh.{$limit}.std"); - } - Cache::forget("homepage.trending.{$limit}"); - Cache::forget("homepage.rising.{$limit}"); - } - - // Flush key keys - foreach ($keys as $key) { - Cache::forget($key); - } - - return redirect()->route('admin.early-growth.index') - ->with('success', 'Early Growth System cache flushed. Changes will take effect on next page load.'); - } - - /** - * GET /admin/early-growth/status (JSON — for monitoring/healthcheck) - */ - public function status(): JsonResponse - { - return response()->json([ - 'egs' => EarlyGrowth::status(), - 'uploads_per_day' => $this->timeWindow->getUploadsPerDay(), - 'window_days' => $this->timeWindow->getTrendingWindowDays(30), - ]); - } -} diff --git a/app/Http/Controllers/Admin/StoryAdminController.php b/app/Http/Controllers/Admin/StoryAdminController.php deleted file mode 100644 index 6bf00e97..00000000 --- a/app/Http/Controllers/Admin/StoryAdminController.php +++ /dev/null @@ -1,211 +0,0 @@ -with(['creator']) - ->latest('created_at') - ->paginate(25); - - return view('admin.stories.index', ['stories' => $stories]); - } - - public function review(): View - { - $stories = Story::query() - ->with(['creator']) - ->where('status', 'pending_review') - ->orderByDesc('submitted_for_review_at') - ->paginate(25); - - return view('admin.stories.review', ['stories' => $stories]); - } - - public function create(): View - { - return view('admin.stories.create', [ - 'creators' => User::query()->orderBy('username')->limit(200)->get(['id', 'username']), - 'tags' => StoryTag::query()->orderBy('name')->get(['id', 'name']), - ]); - } - - public function store(Request $request): RedirectResponse - { - $validated = $request->validate([ - 'creator_id' => ['required', 'integer', 'exists:users,id'], - 'title' => ['required', 'string', 'max:255'], - 'excerpt' => ['nullable', 'string', 'max:500'], - 'cover_image' => ['nullable', 'string', 'max:500'], - 'content' => ['required', 'string'], - 'story_type' => ['required', 'in:creator_story,tutorial,interview,project_breakdown,announcement,resource'], - 'status' => ['required', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])], - 'tags' => ['nullable', 'array'], - 'tags.*' => ['integer', 'exists:story_tags,id'], - ]); - - $story = Story::query()->create([ - 'creator_id' => (int) $validated['creator_id'], - 'title' => $validated['title'], - 'slug' => $this->uniqueSlug($validated['title']), - 'excerpt' => $validated['excerpt'] ?? null, - 'cover_image' => $validated['cover_image'] ?? null, - 'content' => $validated['content'], - 'story_type' => $validated['story_type'], - 'reading_time' => max(1, (int) ceil(str_word_count(strip_tags((string) $validated['content'])) / 200)), - 'status' => $validated['status'], - 'published_at' => $validated['status'] === 'published' ? now() : null, - 'submitted_for_review_at' => $validated['status'] === 'pending_review' ? now() : null, - ]); - - if (! empty($validated['tags'])) { - $story->tags()->sync($validated['tags']); - } - - if ($validated['status'] === 'published') { - app(StoryPublicationService::class)->afterPersistence($story, 'published', false); - } - - return redirect()->route('admin.stories.edit', ['story' => $story->id]) - ->with('status', 'Story created.'); - } - - public function edit(Story $story): View - { - $story->load('tags'); - - return view('admin.stories.edit', [ - 'story' => $story, - 'creators' => User::query()->orderBy('username')->limit(200)->get(['id', 'username']), - 'tags' => StoryTag::query()->orderBy('name')->get(['id', 'name']), - ]); - } - - public function update(Request $request, Story $story): RedirectResponse - { - $wasPublished = $story->published_at !== null || $story->status === 'published'; - - $validated = $request->validate([ - 'creator_id' => ['required', 'integer', 'exists:users,id'], - 'title' => ['required', 'string', 'max:255'], - 'excerpt' => ['nullable', 'string', 'max:500'], - 'cover_image' => ['nullable', 'string', 'max:500'], - 'content' => ['required', 'string'], - 'story_type' => ['required', 'in:creator_story,tutorial,interview,project_breakdown,announcement,resource'], - 'status' => ['required', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])], - 'tags' => ['nullable', 'array'], - 'tags.*' => ['integer', 'exists:story_tags,id'], - ]); - - $story->update([ - 'creator_id' => (int) $validated['creator_id'], - 'title' => $validated['title'], - 'excerpt' => $validated['excerpt'] ?? null, - 'cover_image' => $validated['cover_image'] ?? null, - 'content' => $validated['content'], - 'story_type' => $validated['story_type'], - 'reading_time' => max(1, (int) ceil(str_word_count(strip_tags((string) $validated['content'])) / 200)), - 'status' => $validated['status'], - 'published_at' => $validated['status'] === 'published' ? ($story->published_at ?? now()) : $story->published_at, - 'submitted_for_review_at' => $validated['status'] === 'pending_review' ? ($story->submitted_for_review_at ?? now()) : $story->submitted_for_review_at, - ]); - - $story->tags()->sync($validated['tags'] ?? []); - - if (! $wasPublished && $validated['status'] === 'published') { - app(StoryPublicationService::class)->afterPersistence($story, 'published', false); - } - - return back()->with('status', 'Story updated.'); - } - - public function destroy(Story $story): RedirectResponse - { - $story->delete(); - - return redirect()->route('admin.stories.index')->with('status', 'Story deleted.'); - } - - public function publish(Story $story): RedirectResponse - { - app(StoryPublicationService::class)->publish($story, 'published', [ - 'published_at' => $story->published_at ?? now(), - 'reviewed_at' => now(), - ]); - - return back()->with('status', 'Story published.'); - } - - public function show(Story $story): View - { - return view('admin.stories.show', [ - 'story' => $story->load(['creator', 'tags']), - ]); - } - - public function approve(Request $request, Story $story): RedirectResponse - { - app(StoryPublicationService::class)->publish($story, 'approved', [ - 'published_at' => $story->published_at ?? now(), - 'reviewed_at' => now(), - 'reviewed_by_id' => (int) $request->user()->id, - 'rejected_reason' => null, - ]); - - return back()->with('status', 'Story approved and published.'); - } - - public function reject(Request $request, Story $story): RedirectResponse - { - $validated = $request->validate([ - 'reason' => ['required', 'string', 'max:1000'], - ]); - - $story->update([ - 'status' => 'rejected', - 'reviewed_at' => now(), - 'reviewed_by_id' => (int) $request->user()->id, - 'rejected_reason' => $validated['reason'], - ]); - - $story->creator?->notify(new StoryStatusNotification($story, 'rejected', $validated['reason'])); - - return back()->with('status', 'Story rejected and creator notified.'); - } - - public function moderateComments(): View - { - return view('admin.stories.comments-moderation'); - } - - private function uniqueSlug(string $title): string - { - $base = Str::slug($title); - $slug = $base; - $n = 2; - - while (Story::query()->where('slug', $slug)->exists()) { - $slug = $base . '-' . $n; - $n++; - } - - return $slug; - } -} diff --git a/app/Http/Controllers/Admin/TagInteractionReportController.php b/app/Http/Controllers/Admin/TagInteractionReportController.php deleted file mode 100644 index e385e833..00000000 --- a/app/Http/Controllers/Admin/TagInteractionReportController.php +++ /dev/null @@ -1,47 +0,0 @@ -validate([ - 'from' => ['nullable', 'date_format:Y-m-d'], - 'to' => ['nullable', 'date_format:Y-m-d'], - 'limit' => ['nullable', 'integer', 'min:1', 'max:100'], - ]); - - $from = (string) ($validated['from'] ?? now()->subDays(13)->toDateString()); - $to = (string) ($validated['to'] ?? now()->toDateString()); - $limit = (int) ($validated['limit'] ?? 15); - - abort_if($from > $to, 422, 'Invalid date range.'); - - $report = $this->reportService->buildReport($from, $to, $limit); - - return view('admin.reports.tags', [ - 'filters' => [ - 'from' => $from, - 'to' => $to, - 'limit' => $limit, - ], - 'overview' => $report['overview'], - 'dailyClicks' => $report['daily_clicks'], - 'bySurface' => $report['by_surface'], - 'topTags' => $report['top_tags'], - 'topQueries' => $report['top_queries'], - 'topTransitions' => $report['top_transitions'], - 'latestAggregatedDate' => $report['latest_aggregated_date'], - ]); - } -} diff --git a/app/Http/Controllers/Dashboard/FollowerController.php b/app/Http/Controllers/Dashboard/FollowerController.php index 32f31c51..25725f45 100644 --- a/app/Http/Controllers/Dashboard/FollowerController.php +++ b/app/Http/Controllers/Dashboard/FollowerController.php @@ -11,37 +11,93 @@ class FollowerController extends Controller { public function index(Request $request) { - $user = $request->user(); + $user = $request->user(); $perPage = 30; + $search = trim((string) $request->query('q', '')); + $sort = (string) $request->query('sort', 'recent'); + $relationship = (string) $request->query('relationship', 'all'); + + $allowedSorts = ['recent', 'oldest', 'name', 'uploads', 'followers']; + $allowedRelationships = ['all', 'following-back', 'not-followed']; + + if (! in_array($sort, $allowedSorts, true)) { + $sort = 'recent'; + } + + if (! in_array($relationship, $allowedRelationships, true)) { + $relationship = 'all'; + } // People who follow $user (user_id = $user being followed) - $followers = DB::table('user_followers as uf') + $baseQuery = DB::table('user_followers as uf') ->join('users as u', 'u.id', '=', 'uf.follower_id') ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') ->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id') + ->leftJoin('user_followers as mutual', function ($join) use ($user): void { + $join->on('mutual.user_id', '=', 'uf.follower_id') + ->where('mutual.follower_id', '=', $user->id); + }) ->where('uf.user_id', $user->id) ->whereNull('u.deleted_at') - ->orderByDesc('uf.created_at') + ->when($search !== '', function ($query) use ($search): void { + $query->where(function ($inner) use ($search): void { + $inner->where('u.username', 'like', '%' . $search . '%') + ->orWhere('u.name', 'like', '%' . $search . '%'); + }); + }) + ->when($relationship === 'following-back', function ($query): void { + $query->whereNotNull('mutual.created_at'); + }) + ->when($relationship === 'not-followed', function ($query): void { + $query->whereNull('mutual.created_at'); + }); + + $summaryBaseQuery = clone $baseQuery; + + $followers = $baseQuery + ->when($sort === 'recent', fn ($query) => $query->orderByDesc('uf.created_at')) + ->when($sort === 'oldest', fn ($query) => $query->orderBy('uf.created_at')) + ->when($sort === 'name', fn ($query) => $query->orderByRaw('COALESCE(u.username, u.name) asc')) + ->when($sort === 'uploads', fn ($query) => $query->orderByDesc('us.uploads_count')->orderByRaw('COALESCE(u.username, u.name) asc')) + ->when($sort === 'followers', fn ($query) => $query->orderByDesc('us.followers_count')->orderByRaw('COALESCE(u.username, u.name) asc')) ->select([ 'u.id', 'u.username', 'u.name', 'up.avatar_hash', 'us.uploads_count', + 'us.followers_count', 'uf.created_at as followed_at', + 'mutual.created_at as followed_back_at', ]) ->paginate($perPage) ->withQueryString() ->through(fn ($row) => (object) [ - 'id' => $row->id, - 'username' => $row->username, - 'uname' => $row->username ?? $row->name, - 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50), + 'id' => $row->id, + 'name' => $row->name, + 'username' => $row->username, + 'uname' => $row->username ?? $row->name, + 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64), 'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)), - 'uploads' => $row->uploads_count ?? 0, + 'uploads' => $row->uploads_count ?? 0, + 'followers_count' => $row->followers_count ?? 0, + 'is_following_back' => $row->followed_back_at !== null, + 'followed_back_at' => $row->followed_back_at, 'followed_at' => $row->followed_at, ]); + $summary = [ + 'total_followers' => (clone $summaryBaseQuery)->count(), + 'following_back' => (clone $summaryBaseQuery)->whereNotNull('mutual.created_at')->count(), + 'not_followed' => (clone $summaryBaseQuery)->whereNull('mutual.created_at')->count(), + ]; + return view('dashboard.followers', [ - 'followers' => $followers, + 'followers' => $followers, + 'filters' => [ + 'q' => $search, + 'sort' => $sort, + 'relationship' => $relationship, + ], + 'summary' => $summary, 'page_title' => 'My Followers', ]); } diff --git a/app/Http/Controllers/Dashboard/FollowingController.php b/app/Http/Controllers/Dashboard/FollowingController.php index 78a508a7..7a396b04 100644 --- a/app/Http/Controllers/Dashboard/FollowingController.php +++ b/app/Http/Controllers/Dashboard/FollowingController.php @@ -11,40 +11,93 @@ class FollowingController extends Controller { public function index(Request $request) { - $user = $request->user(); + $user = $request->user(); $perPage = 30; + $search = trim((string) $request->query('q', '')); + $sort = (string) $request->query('sort', 'recent'); + $relationship = (string) $request->query('relationship', 'all'); + + $allowedSorts = ['recent', 'oldest', 'name', 'uploads', 'followers']; + $allowedRelationships = ['all', 'mutual', 'one-way']; + + if (! in_array($sort, $allowedSorts, true)) { + $sort = 'recent'; + } + + if (! in_array($relationship, $allowedRelationships, true)) { + $relationship = 'all'; + } // People that $user follows (follower_id = $user) - $following = DB::table('user_followers as uf') + $baseQuery = DB::table('user_followers as uf') ->join('users as u', 'u.id', '=', 'uf.user_id') ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') ->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id') + ->leftJoin('user_followers as mutual', function ($join) use ($user): void { + $join->on('mutual.follower_id', '=', 'uf.user_id') + ->where('mutual.user_id', '=', $user->id); + }) ->where('uf.follower_id', $user->id) ->whereNull('u.deleted_at') - ->orderByDesc('uf.created_at') + ->when($search !== '', function ($query) use ($search): void { + $query->where(function ($inner) use ($search): void { + $inner->where('u.username', 'like', '%' . $search . '%') + ->orWhere('u.name', 'like', '%' . $search . '%'); + }); + }) + ->when($relationship === 'mutual', function ($query): void { + $query->whereNotNull('mutual.created_at'); + }) + ->when($relationship === 'one-way', function ($query): void { + $query->whereNull('mutual.created_at'); + }); + + $summaryBaseQuery = clone $baseQuery; + + $following = $baseQuery + ->when($sort === 'recent', fn ($query) => $query->orderByDesc('uf.created_at')) + ->when($sort === 'oldest', fn ($query) => $query->orderBy('uf.created_at')) + ->when($sort === 'name', fn ($query) => $query->orderByRaw('COALESCE(u.username, u.name) asc')) + ->when($sort === 'uploads', fn ($query) => $query->orderByDesc('us.uploads_count')->orderByRaw('COALESCE(u.username, u.name) asc')) + ->when($sort === 'followers', fn ($query) => $query->orderByDesc('us.followers_count')->orderByRaw('COALESCE(u.username, u.name) asc')) ->select([ 'u.id', 'u.username', 'u.name', 'up.avatar_hash', 'us.uploads_count', 'us.followers_count', 'uf.created_at as followed_at', + 'mutual.created_at as follows_you_at', ]) ->paginate($perPage) ->withQueryString() ->through(fn ($row) => (object) [ - 'id' => $row->id, - 'username' => $row->username, - 'name' => $row->name, - 'uname' => $row->username ?? $row->name, - 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64), - 'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)), - 'uploads' => $row->uploads_count ?? 0, - 'followers_count'=> $row->followers_count ?? 0, - 'followed_at' => $row->followed_at, + 'id' => $row->id, + 'username' => $row->username, + 'name' => $row->name, + 'uname' => $row->username ?? $row->name, + 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64), + 'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)), + 'uploads' => $row->uploads_count ?? 0, + 'followers_count' => $row->followers_count ?? 0, + 'follows_you' => $row->follows_you_at !== null, + 'follows_you_at' => $row->follows_you_at, + 'followed_at' => $row->followed_at, ]); + $summary = [ + 'total_following' => (clone $summaryBaseQuery)->count(), + 'mutual' => (clone $summaryBaseQuery)->whereNotNull('mutual.created_at')->count(), + 'one_way' => (clone $summaryBaseQuery)->whereNull('mutual.created_at')->count(), + ]; + return view('dashboard.following', [ - 'following' => $following, + 'following' => $following, + 'filters' => [ + 'q' => $search, + 'sort' => $sort, + 'relationship' => $relationship, + ], + 'summary' => $summary, 'page_title' => 'People I Follow', ]); } diff --git a/app/Http/Controllers/Web/StaffApplicationAdminController.php b/app/Http/Controllers/Web/StaffApplicationAdminController.php deleted file mode 100644 index de64c620..00000000 --- a/app/Http/Controllers/Web/StaffApplicationAdminController.php +++ /dev/null @@ -1,21 +0,0 @@ -paginate(25); - return view('admin.staff_applications.index', ['items' => $items]); - } - - public function show(StaffApplication $staffApplication) - { - return view('admin.staff_applications.show', ['item' => $staffApplication]); - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8d1c17b3..b38f25aa 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -396,8 +396,6 @@ class AppServiceProvider extends ServiceProvider try { /** @var Menu $menu */ $menu = $this->app->make(Menu::class); - $menu->addHeaderItem('Countries', 'fa-solid fa-flag', 'admin.cp.countries.main'); - $menu->addItem('Users', 'Countries', 'fa-solid fa-flag', 'admin.cp.countries.main'); } catch (\Throwable) { // Control panel menu registration should never block the app boot. } diff --git a/database/migrations/2026_03_21_120000_recreate_missing_forum_spam_tables.php b/database/migrations/2026_03_21_120000_recreate_missing_forum_spam_tables.php new file mode 100644 index 00000000..e303ab3e --- /dev/null +++ b/database/migrations/2026_03_21_120000_recreate_missing_forum_spam_tables.php @@ -0,0 +1,58 @@ +id(); + $table->string('keyword', 120)->unique(); + $table->timestamp('created_at')->useCurrent(); + }); + } + + if (!Schema::hasTable('forum_spam_domains')) { + Schema::create('forum_spam_domains', function (Blueprint $table): void { + $table->id(); + $table->string('domain', 191)->unique(); + $table->timestamp('created_at')->useCurrent(); + }); + } + + foreach ((array) config('forum.moderation.defaults.keywords', []) as $keyword) { + $keyword = trim((string) $keyword); + if ($keyword === '') { + continue; + } + + DB::table('forum_spam_keywords')->updateOrInsert( + ['keyword' => $keyword], + ['created_at' => now()] + ); + } + + foreach ((array) config('forum.moderation.defaults.domains', []) as $domain) { + $domain = strtolower(trim((string) $domain)); + if ($domain === '') { + continue; + } + + DB::table('forum_spam_domains')->updateOrInsert( + ['domain' => $domain], + ['created_at' => now()] + ); + } + } + + public function down(): void + { + Schema::dropIfExists('forum_spam_domains'); + Schema::dropIfExists('forum_spam_keywords'); + } +}; diff --git a/database/migrations/2026_03_21_121000_recreate_missing_forum_learning_tables.php b/database/migrations/2026_03_21_121000_recreate_missing_forum_learning_tables.php new file mode 100644 index 00000000..d0356239 --- /dev/null +++ b/database/migrations/2026_03_21_121000_recreate_missing_forum_learning_tables.php @@ -0,0 +1,50 @@ +id(); + $table->string('content_hash', 64)->index(); + $table->string('decision', 32)->index(); + $table->string('pattern_signature', 191)->nullable()->index(); + $table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->useCurrent(); + }); + } + + if (!Schema::hasTable('forum_ai_logs')) { + Schema::create('forum_ai_logs', function (Blueprint $table): void { + $table->id(); + $table->foreignId('post_id')->constrained('forum_posts')->cascadeOnDelete(); + $table->unsignedSmallInteger('ai_score')->default(0); + $table->unsignedSmallInteger('behavior_score')->default(0); + $table->unsignedSmallInteger('link_score')->default(0); + $table->integer('learning_score')->default(0); + $table->unsignedSmallInteger('firewall_score')->default(0); + $table->unsignedSmallInteger('bot_risk_score')->default(0); + $table->unsignedSmallInteger('risk_score')->default(0)->index(); + $table->string('decision', 32)->default('allow')->index(); + $table->string('provider', 64)->nullable()->index(); + $table->string('source_ip_hash', 64)->nullable()->index(); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['post_id', 'created_at']); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('forum_ai_logs'); + Schema::dropIfExists('forum_spam_learning'); + } +}; diff --git a/database/migrations/2026_03_21_121100_force_recreate_missing_forum_learning_tables.php b/database/migrations/2026_03_21_121100_force_recreate_missing_forum_learning_tables.php new file mode 100644 index 00000000..d0356239 --- /dev/null +++ b/database/migrations/2026_03_21_121100_force_recreate_missing_forum_learning_tables.php @@ -0,0 +1,50 @@ +id(); + $table->string('content_hash', 64)->index(); + $table->string('decision', 32)->index(); + $table->string('pattern_signature', 191)->nullable()->index(); + $table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->useCurrent(); + }); + } + + if (!Schema::hasTable('forum_ai_logs')) { + Schema::create('forum_ai_logs', function (Blueprint $table): void { + $table->id(); + $table->foreignId('post_id')->constrained('forum_posts')->cascadeOnDelete(); + $table->unsignedSmallInteger('ai_score')->default(0); + $table->unsignedSmallInteger('behavior_score')->default(0); + $table->unsignedSmallInteger('link_score')->default(0); + $table->integer('learning_score')->default(0); + $table->unsignedSmallInteger('firewall_score')->default(0); + $table->unsignedSmallInteger('bot_risk_score')->default(0); + $table->unsignedSmallInteger('risk_score')->default(0)->index(); + $table->string('decision', 32)->default('allow')->index(); + $table->string('provider', 64)->nullable()->index(); + $table->string('source_ip_hash', 64)->nullable()->index(); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['post_id', 'created_at']); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('forum_ai_logs'); + Schema::dropIfExists('forum_spam_learning'); + } +}; diff --git a/database/migrations/2026_03_21_121200_force_recreate_missing_forum_tag_tables.php b/database/migrations/2026_03_21_121200_force_recreate_missing_forum_tag_tables.php new file mode 100644 index 00000000..78b38ba0 --- /dev/null +++ b/database/migrations/2026_03_21_121200_force_recreate_missing_forum_tag_tables.php @@ -0,0 +1,37 @@ +id(); + $table->string('name', 80); + $table->string('slug', 80)->unique(); + $table->timestamps(); + }); + } + + if (!Schema::hasTable('forum_topic_tags')) { + Schema::create('forum_topic_tags', function (Blueprint $table): void { + $table->id(); + $table->foreignId('topic_id')->constrained('forum_topics')->cascadeOnDelete(); + $table->foreignId('tag_id')->constrained('forum_tags')->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['topic_id', 'tag_id']); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('forum_topic_tags'); + Schema::dropIfExists('forum_tags'); + } +}; diff --git a/database/migrations/2026_03_21_122000_repair_missing_upload_and_forum_ai_columns.php b/database/migrations/2026_03_21_122000_repair_missing_upload_and_forum_ai_columns.php new file mode 100644 index 00000000..7919fabe --- /dev/null +++ b/database/migrations/2026_03_21_122000_repair_missing_upload_and_forum_ai_columns.php @@ -0,0 +1,111 @@ + ! Schema::hasColumn('uploads', 'tags'), + 'license' => ! Schema::hasColumn('uploads', 'license'), + 'nsfw' => ! Schema::hasColumn('uploads', 'nsfw'), + 'is_scanned' => ! Schema::hasColumn('uploads', 'is_scanned'), + 'has_tags' => ! Schema::hasColumn('uploads', 'has_tags'), + 'published_at' => ! Schema::hasColumn('uploads', 'published_at'), + 'final_path' => ! Schema::hasColumn('uploads', 'final_path'), + ]; + + if (in_array(true, $missingUploadColumns, true)) { + Schema::table('uploads', function (Blueprint $table) use ($missingUploadColumns): void { + if ($missingUploadColumns['tags']) { + $table->json('tags')->nullable(); + } + + if ($missingUploadColumns['license']) { + $table->string('license', 64)->nullable(); + } + + if ($missingUploadColumns['nsfw']) { + $table->boolean('nsfw')->default(false); + } + + if ($missingUploadColumns['is_scanned']) { + $table->boolean('is_scanned')->default(false)->index(); + } + + if ($missingUploadColumns['has_tags']) { + $table->boolean('has_tags')->default(false)->index(); + } + + if ($missingUploadColumns['published_at']) { + $table->timestamp('published_at')->nullable()->index(); + } + + if ($missingUploadColumns['final_path']) { + $table->string('final_path')->nullable(); + } + }); + } + } + + if (Schema::hasTable('forum_ai_logs')) { + $missingForumAiColumns = [ + 'firewall_score' => ! Schema::hasColumn('forum_ai_logs', 'firewall_score'), + 'bot_risk_score' => ! Schema::hasColumn('forum_ai_logs', 'bot_risk_score'), + ]; + + if (in_array(true, $missingForumAiColumns, true)) { + Schema::table('forum_ai_logs', function (Blueprint $table) use ($missingForumAiColumns): void { + if ($missingForumAiColumns['firewall_score']) { + $table->unsignedSmallInteger('firewall_score')->default(0); + } + + if ($missingForumAiColumns['bot_risk_score']) { + $table->unsignedSmallInteger('bot_risk_score')->default(0); + } + }); + } + } + } + + public function down(): void + { + if (Schema::hasTable('forum_ai_logs')) { + Schema::table('forum_ai_logs', function (Blueprint $table): void { + $dropColumns = []; + + if (Schema::hasColumn('forum_ai_logs', 'firewall_score')) { + $dropColumns[] = 'firewall_score'; + } + + if (Schema::hasColumn('forum_ai_logs', 'bot_risk_score')) { + $dropColumns[] = 'bot_risk_score'; + } + + if ($dropColumns !== []) { + $table->dropColumn($dropColumns); + } + }); + } + + if (Schema::hasTable('uploads')) { + Schema::table('uploads', function (Blueprint $table): void { + $dropColumns = []; + + foreach (['tags', 'license', 'nsfw', 'is_scanned', 'has_tags', 'published_at', 'final_path'] as $column) { + if (Schema::hasColumn('uploads', $column)) { + $dropColumns[] = $column; + } + } + + if ($dropColumns !== []) { + $table->dropColumn($dropColumns); + } + }); + } + } +}; diff --git a/resources/js/Pages/Upload/Index.jsx b/resources/js/Pages/Upload/Index.jsx index 41812c4c..30da2b11 100644 --- a/resources/js/Pages/Upload/Index.jsx +++ b/resources/js/Pages/Upload/Index.jsx @@ -606,14 +606,73 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) { if (uploadsV2Enabled) { return ( -
-
- +
+
+
+
+
+
+
+

Skinbase Upload Studio

+

+ Upload artwork with less friction and better control. +

+

+ The upload flow now stays focused on three steps: add the file, finish the metadata, then publish with confidence. The interface is simpler, but the secure processing pipeline stays intact. +

+ +
+ {[ + { + title: 'Fast onboarding', + description: 'Clearer file requirements and a friendlier first step.', + }, + { + title: 'Safer publishing', + description: 'Processing state, rights, and readiness stay visible the whole time.', + }, + { + title: 'Cleaner review', + description: 'Metadata and publish options are easier to scan before going live.', + }, + ].map((item) => ( +
+

{item.title}

+

{item.description}

+
+ ))} +
+
+ + +
+ +
+ +
+
+
) diff --git a/resources/js/components/upload/CategorySelector.jsx b/resources/js/components/upload/CategorySelector.jsx index 96d669ae..fb62dd92 100644 --- a/resources/js/components/upload/CategorySelector.jsx +++ b/resources/js/components/upload/CategorySelector.jsx @@ -29,51 +29,45 @@ export default function CategorySelector({ allRoots = [], onRootChangeAll, }) { + const rootOptions = hasContentType ? categories : allRoots const selectedRoot = categories.find((c) => String(c.id) === String(rootCategoryId || '')) ?? null const hasSubcategories = Boolean( selectedRoot && Array.isArray(selectedRoot.children) && selectedRoot.children.length > 0 ) - if (!hasContentType) { - return ( -
- Select a content type to load categories. -
- ) - } - - if (categories.length === 0) { - return ( -
- No categories available for this content type. -
- ) - } - return (
- {/* Root categories */} -
- {categories.map((root) => { - const active = String(root.id) === String(rootCategoryId || '') - return ( - - ) - })} -
+ {!hasContentType ? ( +
+ Select a content type to load categories. +
+ ) : categories.length === 0 ? ( +
+ No categories available for this content type. +
+ ) : ( +
+ {categories.map((root) => { + const active = String(root.id) === String(rootCategoryId || '') + return ( + + ) + })} +
+ )} {/* Subcategories (shown when root has children) */} {hasSubcategories && ( @@ -122,7 +116,7 @@ export default function CategorySelector({ }} > - {allRoots.map((root) => ( + {rootOptions.map((root) => ( ))} diff --git a/resources/js/components/upload/PublishPanel.jsx b/resources/js/components/upload/PublishPanel.jsx index 57dcde4f..1315e9f5 100644 --- a/resources/js/components/upload/PublishPanel.jsx +++ b/resources/js/components/upload/PublishPanel.jsx @@ -49,6 +49,7 @@ export default function PublishPanel({ scheduledAt = null, timezone = Intl.DateTimeFormat().resolvedOptions().timeZone, visibility = 'public', // 'public' | 'unlisted' | 'private' + showRightsConfirmation = true, onPublishModeChange, onScheduleAt, onVisibilityChange, @@ -93,8 +94,26 @@ export default function PublishPanel({ const rightsError = uploadReady && !hasRights ? 'Rights confirmation is required.' : null + const visibilityOptions = [ + { + value: 'public', + label: 'Public', + hint: 'Visible to everyone', + }, + { + value: 'unlisted', + label: 'Unlisted', + hint: 'Available by direct link', + }, + { + value: 'private', + label: 'Private', + hint: 'Keep as draft visibility', + }, + ] + return ( -
+
{/* Preview + title */}
{/* Thumbnail */} @@ -139,24 +158,45 @@ export default function PublishPanel({
{/* Readiness checklist */} - +
+ +
{/* Visibility */}
-
{/* Schedule picker – only shows when upload is ready */} @@ -171,20 +211,21 @@ export default function PublishPanel({ /> )} - {/* Rights confirmation (required before publish) */} -
- onToggleRights?.(event.target.checked)} - variant="emerald" - size={18} - label={I confirm I own the rights to this content.} - hint={Required before publishing.} - error={rightsError} - required - /> -
+ {showRightsConfirmation && ( +
+ onToggleRights?.(event.target.checked)} + variant="emerald" + size={18} + label={I confirm I own the rights to this content.} + hint={Required before publishing.} + error={rightsError} + required + /> +
+ )} {/* Primary action button */}
+
) } diff --git a/resources/js/components/upload/UploadDropzone.jsx b/resources/js/components/upload/UploadDropzone.jsx index fab142e4..a825c015 100644 --- a/resources/js/components/upload/UploadDropzone.jsx +++ b/resources/js/components/upload/UploadDropzone.jsx @@ -67,7 +67,7 @@ export default function UploadDropzone({ } return ( -
+
{/* Intended props: file, dragState, accept, onDrop, onBrowse, onReset, disabled */}

{title}

{description}

@@ -122,7 +122,7 @@ export default function UploadDropzone({
) : ( <> -
+
-

Accepted: JPG, JPEG, PNG, WEBP, ZIP, RAR, 7Z, TAR, GZ

-

Max size: images 50MB · archives 200MB

+
+ JPG, PNG, WEBP + ZIP, RAR, 7Z + 50MB images + 200MB archives +
- + Click to browse files @@ -155,7 +159,7 @@ export default function UploadDropzone({ /> {(previewUrl || (fileMeta && String(fileMeta.type || '').startsWith('image/'))) && ( -
+
Selected file
{fileName || fileHint}
{fileMeta && ( diff --git a/resources/js/components/upload/UploadOverlay.jsx b/resources/js/components/upload/UploadOverlay.jsx index 1016a333..c2ea795d 100644 --- a/resources/js/components/upload/UploadOverlay.jsx +++ b/resources/js/components/upload/UploadOverlay.jsx @@ -4,8 +4,8 @@ import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' /** * UploadOverlay * - * A frosted-glass floating panel that rises from the bottom of the step content - * area while an upload or processing job is in flight. + * A centered modal-style progress overlay shown while an upload or processing + * job is in flight. * * Shows: * - State icon + label + live percentage @@ -109,107 +109,135 @@ export default function UploadOverlay({ {isVisible && ( 0 ? ` — ${progress}%` : ''}`} - initial={prefersReducedMotion ? false : { opacity: 0, y: 24, scale: 0.98 }} - animate={{ opacity: 1, y: 0, scale: 1 }} - exit={prefersReducedMotion ? {} : { opacity: 0, y: 16, scale: 0.98 }} + initial={prefersReducedMotion ? false : { opacity: 0 }} + animate={{ opacity: 1 }} + exit={prefersReducedMotion ? {} : { opacity: 0 }} transition={overlayTransition} - className="absolute inset-x-0 bottom-0 z-30 pointer-events-none" + className="fixed inset-0 z-[80] flex items-center justify-center p-4 sm:p-6" > - {/* Fade-out gradient so step content peeks through above */} -