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.
This commit is contained in:
519
app/Console/Commands/AuditMigrationSchemaCommand.php
Normal file
519
app/Console/Commands/AuditMigrationSchemaCommand.php
Normal file
@@ -0,0 +1,519 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
class AuditMigrationSchemaCommand extends Command
|
||||
{
|
||||
protected $signature = 'schema:audit-migrations
|
||||
{--all-files : Audit all discovered migration files, not only migrations marked as ran}
|
||||
{--json : Output the report as JSON}
|
||||
{--base-path=* : Additional base paths to scan for migrations, relative to project root}';
|
||||
|
||||
protected $description = 'Compare the live database schema against executed migration files and report missing tables or columns';
|
||||
|
||||
private const NO_ARG_COLUMN_METHODS = [
|
||||
'id' => ['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<string, string>
|
||||
*/
|
||||
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<int, array{type:string, table:string, add:array<int,string>, drop:array<int,string>}>
|
||||
*/
|
||||
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<int, array{table:string, old_column:string, new_column:string}>
|
||||
*/
|
||||
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<int, array{operation:string, table:string, body:string}>
|
||||
*/
|
||||
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<int, string>
|
||||
*/
|
||||
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<int, string>
|
||||
*/
|
||||
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<int,array{table:string,sources:array<int,string>}>, missing_columns:array<int,array{table:string,columns:array<int,string>,sources:array<int,string>}>} $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'])));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Country;
|
||||
use App\Services\Countries\CountrySyncService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Throwable;
|
||||
|
||||
final class CountryAdminController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$search = trim((string) $request->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);
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\EarlyGrowth\ActivityLayer;
|
||||
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
||||
use App\Services\EarlyGrowth\EarlyGrowth;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* EarlyGrowthAdminController (§14)
|
||||
*
|
||||
* Admin panel for the Early-Stage Growth System.
|
||||
* All toggles are ENV-driven; updating .env requires a deploy.
|
||||
* This panel provides a read-only status view plus a cache-flush action.
|
||||
*
|
||||
* Future v2: wire to a `settings` DB table so admins can toggle without
|
||||
* a deploy. The EarlyGrowth::enabled() contract already supports this.
|
||||
*/
|
||||
final class EarlyGrowthAdminController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdaptiveTimeWindow $timeWindow,
|
||||
private readonly ActivityLayer $activityLayer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* GET /admin/early-growth
|
||||
* Status dashboard: shows current config, live stats, toggle instructions.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$uploadsPerDay = $this->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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryTag;
|
||||
use App\Models\User;
|
||||
use App\Notifications\StoryStatusNotification;
|
||||
use App\Services\StoryPublicationService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class StoryAdminController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$stories = Story::query()
|
||||
->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;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Analytics\TagInteractionReportService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class TagInteractionReportController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TagInteractionReportService $reportService) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$validated = $request->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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\StaffApplication;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class StaffApplicationAdminController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$items = StaffApplication::orderBy('created_at', 'desc')->paginate(25);
|
||||
return view('admin.staff_applications.index', ['items' => $items]);
|
||||
}
|
||||
|
||||
public function show(StaffApplication $staffApplication)
|
||||
{
|
||||
return view('admin.staff_applications.show', ['item' => $staffApplication]);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user