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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -13,35 +13,91 @@ class FollowerController extends Controller
|
||||
{
|
||||
$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,
|
||||
'name' => $row->name,
|
||||
'username' => $row->username,
|
||||
'uname' => $row->username ?? $row->name,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
|
||||
'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,
|
||||
'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,
|
||||
'filters' => [
|
||||
'q' => $search,
|
||||
'sort' => $sort,
|
||||
'relationship' => $relationship,
|
||||
],
|
||||
'summary' => $summary,
|
||||
'page_title' => 'My Followers',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -13,21 +13,60 @@ class FollowingController extends Controller
|
||||
{
|
||||
$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()
|
||||
@@ -39,12 +78,26 @@ class FollowingController extends Controller
|
||||
'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,
|
||||
'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,
|
||||
'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.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasTable('forum_spam_keywords')) {
|
||||
Schema::create('forum_spam_keywords', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasTable('forum_spam_learning')) {
|
||||
Schema::create('forum_spam_learning', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasTable('forum_spam_learning')) {
|
||||
Schema::create('forum_spam_learning', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasTable('forum_tags')) {
|
||||
Schema::create('forum_tags', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('uploads')) {
|
||||
$missingUploadColumns = [
|
||||
'tags' => ! 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -606,8 +606,64 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
|
||||
|
||||
if (uploadsV2Enabled) {
|
||||
return (
|
||||
<section className="px-4 py-1">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<section className="min-h-[calc(100vh-4rem)] bg-[#07111c] text-slate-100">
|
||||
<div className="relative isolate overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[420px] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.22),_transparent_32%),radial-gradient(circle_at_top_right,_rgba(251,146,60,0.16),_transparent_30%),linear-gradient(180deg,_rgba(8,17,28,0.98),_rgba(7,17,28,1))]" />
|
||||
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
|
||||
<div className="overflow-hidden rounded-[32px] border border-white/10 bg-[#08111c]/92 shadow-[0_30px_120px_rgba(2,8,23,0.38)]">
|
||||
<div className="grid gap-8 border-b border-white/8 px-5 py-6 sm:px-8 lg:grid-cols-[1.45fr_0.85fr] lg:items-start lg:gap-10 lg:py-8">
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-[0.28em] text-sky-200/80">Skinbase Upload Studio</p>
|
||||
<h1 className="mt-3 max-w-3xl text-3xl font-semibold tracking-tight text-white sm:text-4xl">
|
||||
Upload artwork with less friction and better control.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-300 sm:text-base">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
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) => (
|
||||
<div key={item.title} className="rounded-2xl border border-white/8 bg-white/[0.04] p-4">
|
||||
<p className="text-sm font-semibold text-white">{item.title}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="rounded-[28px] border border-white/8 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-5 shadow-[0_18px_70px_rgba(0,0,0,0.16)]">
|
||||
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Before you start</p>
|
||||
<div className="mt-4 space-y-4">
|
||||
{[
|
||||
'Choose the final file you actually want published. Replacing after upload requires a reset.',
|
||||
'ZIP, RAR, and 7Z packs still need at least one screenshot for preview generation.',
|
||||
'You will confirm rights and visibility before the final publish step.',
|
||||
].map((item, index) => (
|
||||
<div key={item} className="flex items-start gap-3">
|
||||
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-sky-300/20 bg-sky-400/10 text-xs font-semibold text-sky-100">
|
||||
{index + 1}
|
||||
</span>
|
||||
<p className="text-sm leading-6 text-slate-300">{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-5 sm:px-6 lg:px-8 lg:py-8">
|
||||
<UploadWizard
|
||||
initialDraftId={draftId ?? null}
|
||||
chunkSize={chunkSize}
|
||||
@@ -615,6 +671,9 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
|
||||
suggestedTags={Array.isArray(props?.suggested_tags) ? props.suggested_tags : []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,30 +29,23 @@ 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 (
|
||||
<div className="space-y-3">
|
||||
{!hasContentType ? (
|
||||
<div className="rounded-lg bg-white/[0.025] px-3 py-3 text-sm text-white/45 ring-1 ring-white/8">
|
||||
Select a content type to load categories.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (categories.length === 0) {
|
||||
return (
|
||||
) : categories.length === 0 ? (
|
||||
<div className="rounded-lg bg-white/[0.025] px-3 py-3 text-sm text-white/45 ring-1 ring-white/8">
|
||||
No categories available for this content type.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Root categories */}
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Category">
|
||||
{categories.map((root) => {
|
||||
const active = String(root.id) === String(rootCategoryId || '')
|
||||
@@ -74,6 +67,7 @@ export default function CategorySelector({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subcategories (shown when root has children) */}
|
||||
{hasSubcategories && (
|
||||
@@ -122,7 +116,7 @@ export default function CategorySelector({
|
||||
}}
|
||||
>
|
||||
<option value="">Select root category</option>
|
||||
{allRoots.map((root) => (
|
||||
{rootOptions.map((root) => (
|
||||
<option key={root.id} value={String(root.id)}>{root.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -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 (
|
||||
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-5 space-y-5 h-fit">
|
||||
<div className="h-fit space-y-5 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur">
|
||||
{/* Preview + title */}
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Thumbnail */}
|
||||
@@ -139,24 +158,45 @@ export default function PublishPanel({
|
||||
<div className="border-t border-white/8" />
|
||||
|
||||
{/* Readiness checklist */}
|
||||
<div className="rounded-2xl border border-white/8 bg-white/[0.03] p-4">
|
||||
<ReadinessChecklist items={checklist} />
|
||||
</div>
|
||||
|
||||
{/* Visibility */}
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase tracking-wider text-white/40 mb-1.5" htmlFor="publish-visibility">
|
||||
<label className="mb-2 block text-[10px] uppercase tracking-wider text-white/40" htmlFor="publish-visibility">
|
||||
Visibility
|
||||
</label>
|
||||
<select
|
||||
id="publish-visibility"
|
||||
value={visibility}
|
||||
onChange={(e) => onVisibilityChange?.(e.target.value)}
|
||||
<div id="publish-visibility" className="grid gap-2">
|
||||
{visibilityOptions.map((option) => {
|
||||
const active = visibility === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onVisibilityChange?.(option.value)}
|
||||
disabled={!canPublish && machineState !== 'ready_to_publish'}
|
||||
className="w-full appearance-none rounded-lg border border-white/15 bg-white/8 px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-sky-400/60 disabled:opacity-50"
|
||||
className={[
|
||||
'flex items-start justify-between gap-3 rounded-2xl border px-4 py-3 text-left transition disabled:opacity-50',
|
||||
active
|
||||
? 'border-sky-300/30 bg-sky-400/10 text-white'
|
||||
: 'border-white/10 bg-white/[0.03] text-white/75 hover:border-white/20 hover:bg-white/[0.06]',
|
||||
].join(' ')}
|
||||
>
|
||||
<option value="public">Public</option>
|
||||
<option value="unlisted">Unlisted</option>
|
||||
<option value="private">Private (draft)</option>
|
||||
</select>
|
||||
<div>
|
||||
<div className="text-sm font-semibold">{option.label}</div>
|
||||
<div className="mt-1 text-xs text-white/50">{option.hint}</div>
|
||||
</div>
|
||||
<span className={[
|
||||
'mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full border text-[10px]',
|
||||
active ? 'border-sky-300/40 bg-sky-400/20 text-sky-100' : 'border-white/10 bg-white/5 text-white/35',
|
||||
].join(' ')}>
|
||||
{active ? '✓' : ''}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule picker – only shows when upload is ready */}
|
||||
@@ -171,7 +211,7 @@ export default function PublishPanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rights confirmation (required before publish) */}
|
||||
{showRightsConfirmation && (
|
||||
<div>
|
||||
<Checkbox
|
||||
id="publish-rights-confirm"
|
||||
@@ -185,6 +225,7 @@ export default function PublishPanel({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary action button */}
|
||||
<button
|
||||
@@ -193,7 +234,7 @@ export default function PublishPanel({
|
||||
onClick={() => onPublish?.()}
|
||||
title={!canPublish ? 'Complete all requirements first' : undefined}
|
||||
className={[
|
||||
'w-full rounded-xl py-2.5 text-sm font-semibold transition',
|
||||
'w-full rounded-2xl py-3 text-sm font-semibold transition',
|
||||
canSchedulePublish && !isPublishing
|
||||
? publishMode === 'schedule'
|
||||
? 'bg-violet-500/80 text-white hover:bg-violet-500 shadow-[0_4px_16px_rgba(139,92,246,0.25)]'
|
||||
|
||||
@@ -51,11 +51,11 @@ export default function StudioStatusBar({
|
||||
const stateColor = STATE_COLORS[machineState] ?? 'bg-white/8 text-white/50 border-white/15'
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-20 -mx-4 px-4 pt-2 pb-0 sm:-mx-6 sm:px-6">
|
||||
<div className="sticky top-0 z-20 -mx-4 px-4 pb-0 pt-2 sm:-mx-6 sm:px-6">
|
||||
{/* Blur backdrop */}
|
||||
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-md" aria-hidden="true" />
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative overflow-hidden rounded-[24px] border border-white/8 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))] px-3 shadow-[0_14px_44px_rgba(2,8,23,0.24)] sm:px-4">
|
||||
{/* Step pills row */}
|
||||
<nav aria-label="Upload steps">
|
||||
<ol className="flex flex-nowrap items-center gap-2 overflow-x-auto py-3 pr-1 sm:gap-3">
|
||||
@@ -69,7 +69,7 @@ export default function StudioStatusBar({
|
||||
const btnClass = [
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] sm:text-xs transition',
|
||||
isActive
|
||||
? 'border-sky-300/70 bg-sky-500/25 text-white'
|
||||
? 'border-sky-300/70 bg-sky-500/25 text-white shadow-[0_10px_30px_rgba(14,165,233,0.14)]'
|
||||
: isComplete
|
||||
? 'border-emerald-300/30 bg-emerald-500/15 text-emerald-100 hover:bg-emerald-500/25 cursor-pointer'
|
||||
: isLocked
|
||||
@@ -127,7 +127,7 @@ export default function StudioStatusBar({
|
||||
|
||||
{/* Progress bar (shown during upload/processing) */}
|
||||
{showProgress && (
|
||||
<div className="h-0.5 w-full overflow-hidden rounded-full bg-white/8">
|
||||
<div className="mb-2 h-1 w-full overflow-hidden rounded-full bg-white/8">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-gradient-to-r from-sky-400 via-cyan-300 to-emerald-300"
|
||||
animate={{ width: `${progress}%` }}
|
||||
|
||||
@@ -91,8 +91,12 @@ export default function UploadActions({
|
||||
}
|
||||
|
||||
return (
|
||||
<footer data-testid="wizard-action-bar" className={`${mobileSticky ? 'sticky fixed inset-x-0 bottom-0 z-20 px-4 pb-3 lg:static lg:px-0 lg:pb-0' : ''}`}>
|
||||
<div className="mx-auto w-full max-w-4xl rounded-xl border border-white/10 bg-nova-800/80 p-3 shadow-[0_-12px_32px_rgba(2,8,23,0.65)] backdrop-blur-sm sm:p-4 lg:shadow-none">
|
||||
<footer data-testid="wizard-action-bar" className={`${mobileSticky ? 'sticky bottom-0 z-20 px-4 pb-3 lg:static lg:px-0 lg:pb-0' : ''}`}>
|
||||
<div className="mx-auto w-full max-w-4xl rounded-[24px] border border-white/10 bg-[#08111c]/88 p-3 shadow-[0_-12px_32px_rgba(2,8,23,0.65)] backdrop-blur-sm sm:p-4 lg:shadow-none">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-white/35">
|
||||
{step === 1 ? 'Step 1 of 3' : step === 2 ? 'Step 2 of 3' : 'Step 3 of 3'}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2.5">
|
||||
{canGoBack && (
|
||||
<button
|
||||
@@ -149,6 +153,7 @@ export default function UploadActions({
|
||||
{renderPrimary()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function UploadDropzone({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`rounded-xl bg-gradient-to-br p-0 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors ${invalid ? 'ring-1 ring-red-400/40 from-red-500/10 to-slate-900/45' : 'ring-1 ring-white/10 from-slate-900/80 to-slate-900/50'}`}>
|
||||
<section className={`rounded-[28px] bg-gradient-to-br p-0 shadow-[0_20px_60px_rgba(0,0,0,0.30)] transition-colors ${invalid ? 'ring-1 ring-red-400/40 from-red-500/10 to-slate-900/45' : 'ring-1 ring-white/10 from-slate-900/80 to-slate-900/50'}`}>
|
||||
{/* Intended props: file, dragState, accept, onDrop, onBrowse, onReset, disabled */}
|
||||
<motion.div
|
||||
data-testid="upload-dropzone"
|
||||
@@ -100,7 +100,7 @@ export default function UploadDropzone({
|
||||
}}
|
||||
animate={prefersReducedMotion ? undefined : { scale: dragging ? 1.01 : 1 }}
|
||||
transition={dragTransition}
|
||||
className={`group rounded-xl border-2 border-dashed border-white/15 py-6 px-4 text-center transition hover:border-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 ${locked ? 'cursor-default bg-white/5 opacity-75' : 'cursor-pointer'} ${invalid ? 'border-red-300/70 bg-red-500/10 shadow-[0_0_0_1px_rgba(248,113,113,0.2)]' : dragging ? 'border-cyan-300 bg-cyan-500/20 shadow-[0_0_0_1px_rgba(103,232,249,0.35)]' : locked ? 'bg-white/5' : 'bg-sky-500/5 hover:bg-sky-500/12'}`}
|
||||
className={`group rounded-[26px] border-2 border-dashed border-white/15 px-5 py-7 text-center transition hover:border-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 sm:px-6 ${locked ? 'cursor-default bg-white/5 opacity-75' : 'cursor-pointer'} ${invalid ? 'border-red-300/70 bg-red-500/10 shadow-[0_0_0_1px_rgba(248,113,113,0.2)]' : dragging ? 'border-cyan-300 bg-cyan-500/20 shadow-[0_0_0_1px_rgba(103,232,249,0.35)]' : locked ? 'bg-white/5' : 'bg-[linear-gradient(180deg,rgba(14,165,233,0.08),rgba(255,255,255,0.02))] hover:bg-sky-500/12'}`}
|
||||
>
|
||||
<h3 className="mt-3 text-sm font-semibold text-white">{title}</h3>
|
||||
<p className="mt-1 text-xs text-soft">{description}</p>
|
||||
@@ -122,7 +122,7 @@ export default function UploadDropzone({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full border border-sky-400/60 bg-sky-500/12 text-sky-100 shadow-sm">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl border border-sky-400/40 bg-sky-500/12 text-sky-100 shadow-[0_14px_40px_rgba(14,165,233,0.18)]">
|
||||
<svg viewBox="0 0 24 24" className="h-7 w-7" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 15v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4" />
|
||||
<path d="M7 10l5-5 5 5" />
|
||||
@@ -130,10 +130,14 @@ export default function UploadDropzone({
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-xs text-soft">Accepted: JPG, JPEG, PNG, WEBP, ZIP, RAR, 7Z, TAR, GZ</p>
|
||||
<p className="text-xs text-soft">Max size: images 50MB · archives 200MB</p>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-center gap-2 text-[11px] text-white/65">
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1">JPG, PNG, WEBP</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1">ZIP, RAR, 7Z</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1">50MB images</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1">200MB archives</span>
|
||||
</div>
|
||||
|
||||
<span className={`btn-secondary mt-3 inline-flex text-sm ${locked ? 'opacity-80' : 'group-focus-visible:bg-white/15'}`}>
|
||||
<span className={`btn-secondary mt-4 inline-flex text-sm ${locked ? 'opacity-80' : 'group-focus-visible:bg-white/15'}`}>
|
||||
Click to browse files
|
||||
</span>
|
||||
</>
|
||||
@@ -155,7 +159,7 @@ export default function UploadDropzone({
|
||||
/>
|
||||
|
||||
{(previewUrl || (fileMeta && String(fileMeta.type || '').startsWith('image/'))) && (
|
||||
<div className="mt-3 rounded-lg ring-1 ring-white/10 bg-black/25 px-3 py-2 text-left text-xs text-white/80">
|
||||
<div className="mt-4 rounded-2xl ring-1 ring-white/10 bg-black/25 px-4 py-3 text-left text-xs text-white/80">
|
||||
<div className="font-medium text-white/85">Selected file</div>
|
||||
<div className="mt-1 truncate">{fileName || fileHint}</div>
|
||||
{fileMeta && (
|
||||
|
||||
@@ -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,47 +109,69 @@ export default function UploadOverlay({
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
key="upload-overlay"
|
||||
initial={prefersReducedMotion ? false : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={prefersReducedMotion ? {} : { opacity: 0 }}
|
||||
transition={overlayTransition}
|
||||
className="fixed inset-0 z-[80] flex items-center justify-center p-4 sm:p-6"
|
||||
>
|
||||
<div className="absolute inset-0 bg-slate-950/72 backdrop-blur-sm" aria-hidden="true" />
|
||||
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="upload-overlay-title"
|
||||
aria-describedby="upload-overlay-description"
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, y: 18, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={prefersReducedMotion ? {} : { opacity: 0, y: 12, scale: 0.98 }}
|
||||
transition={overlayTransition}
|
||||
className="relative w-full max-w-xl overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(6,14,24,0.96),rgba(2,8,23,0.92))] px-5 pb-5 pt-5 shadow-[0_30px_120px_rgba(2,8,23,0.72)] ring-1 ring-inset ring-white/8 backdrop-blur-xl sm:px-6 sm:pb-6 sm:pt-6"
|
||||
>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={`${meta.label}${progress > 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 }}
|
||||
transition={overlayTransition}
|
||||
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none"
|
||||
>
|
||||
{/* Fade-out gradient so step content peeks through above */}
|
||||
<div
|
||||
className="absolute inset-x-0 -top-12 h-12 bg-gradient-to-t from-slate-950/70 to-transparent pointer-events-none rounded-b-2xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="pointer-events-auto mx-0 rounded-b-2xl rounded-t-xl border border-white/10 bg-slate-950/88 px-5 pb-5 pt-4 shadow-2xl shadow-black/70 ring-1 ring-inset ring-white/6 backdrop-blur-xl">
|
||||
{/* ── Header: icon + state label + percentage ── */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className={`flex items-center gap-2 ${meta.color}`}>
|
||||
{meta.icon}
|
||||
<span className="text-sm font-semibold tracking-wide">
|
||||
<span id="upload-overlay-title" className="text-xl font-semibold tracking-tight">
|
||||
{meta.label}
|
||||
</span>
|
||||
{/* Pulsing dot for active states */}
|
||||
{machineState !== 'error' && (
|
||||
<span className="relative flex h-2 w-2 shrink-0" aria-hidden="true">
|
||||
<span className="relative flex h-2.5 w-2.5 shrink-0" aria-hidden="true">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-50" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-current opacity-80" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-current opacity-80" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p id="upload-overlay-description" className="mt-2 text-sm text-white/60">
|
||||
{machineState === 'error'
|
||||
? 'The upload was interrupted. You can retry safely or start over.'
|
||||
: 'Keep this tab open while we finish the upload and process your artwork.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{machineState !== 'error' && (
|
||||
<span className={`tabular-nums text-sm font-bold ${meta.color}`}>
|
||||
<span className={`shrink-0 tabular-nums text-2xl font-bold ${meta.color}`}>
|
||||
{progress}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Progress bar ── */}
|
||||
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-white/8">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className={`text-lg font-semibold ${meta.color}`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
{machineState !== 'error' && (
|
||||
<span className="text-sm text-white/45">Secure pipeline active</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 h-3 w-full overflow-hidden rounded-full bg-white/8">
|
||||
<motion.div
|
||||
className={`h-full rounded-full bg-gradient-to-r ${meta.barColor}`}
|
||||
animate={{ width: machineState === 'error' ? '100%' : `${progress}%` }}
|
||||
@@ -158,7 +180,6 @@ export default function UploadOverlay({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Sublabel / transparency message ── */}
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{machineState !== 'error' && displayLabel && (
|
||||
<motion.p
|
||||
@@ -167,14 +188,20 @@ export default function UploadOverlay({
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="mt-2 text-xs text-white/50"
|
||||
className="mt-4 text-sm text-white/60"
|
||||
>
|
||||
{displayLabel}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ── Error details + actions ── */}
|
||||
{machineState !== 'error' && (
|
||||
<p className="mt-2 text-xs uppercase tracking-[0.2em] text-white/30">
|
||||
Progress updates are live
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{machineState === 'error' && (
|
||||
<motion.div
|
||||
@@ -185,22 +212,22 @@ export default function UploadOverlay({
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-3 rounded-lg border border-rose-400/20 bg-rose-500/10 px-3 py-2.5">
|
||||
<p className="text-xs text-rose-200 leading-relaxed">
|
||||
<div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-4">
|
||||
<p className="text-sm leading-relaxed text-rose-100">
|
||||
{error || 'Something went wrong. You can retry safely.'}
|
||||
</p>
|
||||
<div className="mt-2.5 flex gap-2">
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="rounded-md border border-rose-300/30 bg-rose-400/15 px-3 py-1 text-xs font-medium text-rose-100 transition hover:bg-rose-400/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300/60"
|
||||
className="rounded-lg border border-rose-300/30 bg-rose-400/15 px-3.5 py-2 text-sm font-medium text-rose-100 transition hover:bg-rose-400/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300/60"
|
||||
>
|
||||
Retry upload
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="rounded-md border border-white/20 bg-white/8 px-3 py-1 text-xs font-medium text-white/60 transition hover:bg-white/14 hover:text-white/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
|
||||
className="rounded-lg border border-white/20 bg-white/8 px-3.5 py-2 text-sm font-medium text-white/70 transition hover:bg-white/14 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
|
||||
>
|
||||
Start over
|
||||
</button>
|
||||
@@ -211,6 +238,7 @@ export default function UploadOverlay({
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function UploadSidebar({
|
||||
onToggleRights,
|
||||
}) {
|
||||
return (
|
||||
<aside className="rounded-2xl border border-white/7 bg-gradient-to-br from-slate-900/55 to-slate-900/35 p-6 shadow-[0_10px_24px_rgba(0,0,0,0.22)] sm:p-7">
|
||||
<aside className="rounded-[28px] border border-white/8 bg-gradient-to-br from-slate-900/55 to-slate-900/35 p-6 shadow-[0_18px_60px_rgba(0,0,0,0.22)] sm:p-7">
|
||||
{showHeader && (
|
||||
<div className="mb-5 rounded-xl border border-white/8 bg-white/[0.04] p-4">
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
@@ -25,7 +25,7 @@ export default function UploadSidebar({
|
||||
)}
|
||||
|
||||
<div className="space-y-5">
|
||||
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-white">Basics</h4>
|
||||
<p className="mt-1 text-xs text-white/60">Add a clear title and short description.</p>
|
||||
@@ -58,7 +58,7 @@ export default function UploadSidebar({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-white">Tags</h4>
|
||||
<p className="mt-1 text-xs text-white/60">Use keywords people would search for. Press Enter, comma, or Tab to add a tag.</p>
|
||||
@@ -74,7 +74,7 @@ export default function UploadSidebar({
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<Checkbox
|
||||
id="upload-sidebar-rights"
|
||||
checked={Boolean(metadata.rightsAccepted)}
|
||||
|
||||
@@ -201,14 +201,12 @@ export default function UploadWizard({
|
||||
const metadataErrors = useMemo(() => {
|
||||
const errors = {}
|
||||
if (!String(metadata.title || '').trim()) errors.title = 'Title is required.'
|
||||
if (!String(metadata.description || '').trim()) errors.description = 'Description is required.'
|
||||
if (!metadata.contentType) errors.contentType = 'Content type is required.'
|
||||
if (!metadata.rootCategoryId) errors.category = 'Root category is required.'
|
||||
if (metadata.rootCategoryId && requiresSubCategory && !metadata.subCategoryId) {
|
||||
errors.category = 'Subcategory is required for the selected category.'
|
||||
}
|
||||
if (!metadata.rightsAccepted) errors.rights = 'Rights confirmation is required.'
|
||||
if (!Array.isArray(metadata.tags) || metadata.tags.length === 0) errors.tags = 'At least one tag is required.'
|
||||
return errors
|
||||
}, [metadata, requiresSubCategory])
|
||||
|
||||
@@ -465,7 +463,7 @@ export default function UploadWizard({
|
||||
return (
|
||||
<section
|
||||
ref={stepContentRef}
|
||||
className="space-y-4 pb-32 text-white lg:pb-6"
|
||||
className="space-y-5 pb-32 text-white lg:pb-8"
|
||||
data-is-archive={isArchive ? 'true' : 'false'}
|
||||
>
|
||||
{notices.length > 0 && (
|
||||
@@ -492,7 +490,7 @@ export default function UploadWizard({
|
||||
|
||||
{/* Restored draft banner */}
|
||||
{showRestoredBanner && (
|
||||
<div className="rounded-xl ring-1 ring-sky-300/25 bg-sky-500/10 px-4 py-2.5 text-sm text-sky-100">
|
||||
<div className="rounded-2xl border border-sky-300/20 bg-sky-500/10 px-4 py-3 text-sm text-sky-100 shadow-[0_14px_44px_rgba(14,165,233,0.10)]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>Draft restored. Continue from your previous upload session.</span>
|
||||
<button
|
||||
@@ -518,11 +516,11 @@ export default function UploadWizard({
|
||||
/>
|
||||
|
||||
{/* ── Main body: two-column on desktop ─────────────────────────────── */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:gap-6">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:gap-8">
|
||||
{/* Left / main column: step content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Step content + floating progress overlay */}
|
||||
<div className={`relative transition-[padding-bottom] duration-300 ${showOverlay ? 'pb-36' : ''}`}>
|
||||
{/* Step content + centered progress overlay */}
|
||||
<div className="relative">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`step-${activeStep}`}
|
||||
@@ -547,7 +545,7 @@ export default function UploadWizard({
|
||||
|
||||
{/* Wizard action bar (nav: back/next/start/retry) */}
|
||||
{machine.state !== machineStates.complete && (
|
||||
<div className="mt-4">
|
||||
<div className="mt-5">
|
||||
<UploadActions
|
||||
step={activeStep}
|
||||
canStart={canStartUpload && [machineStates.idle, machineStates.error, machineStates.cancelled].includes(machine.state)}
|
||||
@@ -585,7 +583,7 @@ export default function UploadWizard({
|
||||
|
||||
{/* Right column: PublishPanel (sticky sidebar on lg+) */}
|
||||
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && (
|
||||
<div className="hidden lg:block lg:w-72 xl:w-80 shrink-0 lg:sticky lg:top-20 lg:self-start">
|
||||
<div className="hidden shrink-0 lg:block lg:w-80 xl:w-[22rem] lg:sticky lg:top-20 lg:self-start">
|
||||
<PublishPanel
|
||||
primaryPreviewUrl={primaryPreviewUrl}
|
||||
isArchive={isArchive}
|
||||
@@ -600,6 +598,7 @@ export default function UploadWizard({
|
||||
scheduledAt={scheduledAt}
|
||||
timezone={userTimezone}
|
||||
visibility={visibility}
|
||||
showRightsConfirmation={activeStep === 3}
|
||||
onPublishModeChange={setPublishMode}
|
||||
onScheduleAt={setScheduledAt}
|
||||
onVisibilityChange={setVisibility}
|
||||
@@ -617,8 +616,9 @@ export default function UploadWizard({
|
||||
<div className="fixed bottom-4 right-4 z-30 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open publish panel"
|
||||
onClick={() => setShowMobilePublishPanel((v) => !v)}
|
||||
className="flex items-center gap-2 rounded-full bg-sky-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-900/40 transition hover:bg-sky-400 active:scale-95"
|
||||
className="flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-500 px-4 py-2.5 text-sm font-semibold text-white shadow-[0_18px_50px_rgba(14,165,233,0.35)] transition hover:bg-sky-400 active:scale-95"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
@@ -671,6 +671,7 @@ export default function UploadWizard({
|
||||
scheduledAt={scheduledAt}
|
||||
timezone={userTimezone}
|
||||
visibility={visibility}
|
||||
showRightsConfirmation={activeStep === 3}
|
||||
onPublishModeChange={setPublishMode}
|
||||
onScheduleAt={setScheduledAt}
|
||||
onVisibilityChange={setVisibility}
|
||||
|
||||
@@ -48,7 +48,7 @@ function installAxiosStubs({ statusValue = 'ready', initError = null, holdChunk
|
||||
return Promise.resolve({ data: { processing_state: statusValue, status: statusValue } })
|
||||
}
|
||||
|
||||
if (url === '/api/uploads/session-1/publish') {
|
||||
if (/^\/api\/uploads\/[^/]+\/publish$/.test(url)) {
|
||||
return Promise.resolve({ data: { success: true, status: 'published' } })
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ async function completeStep1ToReady() {
|
||||
|
||||
describe('UploadWizard step flow', () => {
|
||||
let originalImage
|
||||
let originalScrollTo
|
||||
let originalScrollIntoView
|
||||
let consoleErrorSpy
|
||||
|
||||
@@ -122,7 +123,9 @@ describe('UploadWizard step flow', () => {
|
||||
window.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
originalImage = global.Image
|
||||
originalScrollTo = window.scrollTo
|
||||
originalScrollIntoView = Element.prototype.scrollIntoView
|
||||
window.scrollTo = vi.fn()
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const text = args.map((arg) => String(arg)).join(' ')
|
||||
@@ -143,6 +146,7 @@ describe('UploadWizard step flow', () => {
|
||||
|
||||
afterEach(() => {
|
||||
global.Image = originalImage
|
||||
window.scrollTo = originalScrollTo
|
||||
Element.prototype.scrollIntoView = originalScrollIntoView
|
||||
consoleErrorSpy?.mockRestore()
|
||||
cleanup()
|
||||
|
||||
@@ -29,9 +29,9 @@ export default function Step1FileUpload({
|
||||
machine,
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-6 space-y-5">
|
||||
<div className="space-y-5 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-6 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur sm:p-7">
|
||||
{/* Step header */}
|
||||
<div className="rounded-xl bg-white/[0.04] px-4 py-3 ring-1 ring-white/8">
|
||||
<div className="rounded-2xl border border-white/8 bg-white/[0.04] px-5 py-4">
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
@@ -45,9 +45,31 @@ export default function Step1FileUpload({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
title: '1. Add the file',
|
||||
body: 'Drop an image or archive pack into the upload area.',
|
||||
},
|
||||
{
|
||||
title: '2. Check validation',
|
||||
body: 'We flag unsupported formats, missing screenshots, and basic file issues immediately.',
|
||||
},
|
||||
{
|
||||
title: '3. Start upload',
|
||||
body: 'Once the file is clean, the secure processing pipeline takes over.',
|
||||
},
|
||||
].map((item) => (
|
||||
<div key={item.title} className="rounded-2xl border border-white/8 bg-white/[0.03] p-4">
|
||||
<p className="text-sm font-semibold text-white">{item.title}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">{item.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Locked notice */}
|
||||
{fileSelectionLocked && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-amber-500/10 px-3 py-2 text-xs text-amber-100 ring-1 ring-amber-300/30">
|
||||
<div className="flex items-center gap-2 rounded-2xl bg-amber-500/10 px-4 py-3 text-xs text-amber-100 ring-1 ring-amber-300/30">
|
||||
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
|
||||
@@ -36,9 +36,9 @@ export default function Step2Details({
|
||||
onToggleRights,
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-6 space-y-5">
|
||||
<div className="space-y-5 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-6 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur sm:p-7">
|
||||
{/* Step header */}
|
||||
<div className="rounded-xl bg-white/[0.04] px-4 py-3 ring-1 ring-white/8">
|
||||
<div className="rounded-2xl border border-white/8 bg-white/[0.04] px-5 py-4">
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
@@ -52,7 +52,7 @@ export default function Step2Details({
|
||||
</div>
|
||||
|
||||
{/* Uploaded asset summary */}
|
||||
<div className="rounded-xl ring-1 ring-white/8 bg-white/[0.025] p-4">
|
||||
<div className="rounded-2xl ring-1 ring-white/8 bg-white/[0.025] p-5">
|
||||
<p className="mb-3 text-[11px] uppercase tracking-wide text-white/45">Uploaded asset</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
{/* Thumbnail / Archive icon */}
|
||||
@@ -96,7 +96,7 @@ export default function Step2Details({
|
||||
</div>
|
||||
|
||||
{/* Content type selector */}
|
||||
<section className="rounded-xl ring-1 ring-white/10 bg-gradient-to-br from-white/[0.04] to-white/[0.01] p-4 sm:p-5">
|
||||
<section className="rounded-2xl ring-1 ring-white/10 bg-gradient-to-br from-white/[0.04] to-white/[0.01] p-5 sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Content type</h3>
|
||||
@@ -116,7 +116,7 @@ export default function Step2Details({
|
||||
</section>
|
||||
|
||||
{/* Category selector */}
|
||||
<section className="rounded-xl ring-1 ring-white/10 bg-gradient-to-br from-white/[0.04] to-white/[0.01] p-4 sm:p-5">
|
||||
<section className="rounded-2xl ring-1 ring-white/10 bg-gradient-to-br from-white/[0.04] to-white/[0.01] p-5 sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Category</h3>
|
||||
|
||||
@@ -76,9 +76,9 @@ export default function Step3Publish({
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-6 space-y-5">
|
||||
<div className="space-y-5 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-6 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur sm:p-7">
|
||||
{/* Step header */}
|
||||
<div className="rounded-xl bg-white/[0.04] px-4 py-3 ring-1 ring-white/8">
|
||||
<div className="rounded-2xl border border-white/8 bg-white/[0.04] px-5 py-4">
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
@@ -92,7 +92,7 @@ export default function Step3Publish({
|
||||
</div>
|
||||
|
||||
{/* Preview + summary */}
|
||||
<div className="rounded-xl ring-1 ring-white/8 bg-white/[0.025] p-4">
|
||||
<div className="rounded-2xl ring-1 ring-white/8 bg-white/[0.025] p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
{/* Artwork thumbnail */}
|
||||
<div className="shrink-0">
|
||||
|
||||
@@ -359,26 +359,60 @@ function SuggestionChip({ href, label, icon, highlight = false, onNavigate }) {
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewMetric({ label, value, href, icon, accent = 'sky', onNavigate }) {
|
||||
function OverviewMetric({ label, value, href, icon, accent = 'sky', caption = null, onNavigate }) {
|
||||
const accents = {
|
||||
sky: 'text-sky-200 border-sky-300/20 bg-sky-400/10',
|
||||
amber: 'text-amber-200 border-amber-300/20 bg-amber-400/10',
|
||||
emerald: 'text-emerald-200 border-emerald-300/20 bg-emerald-400/10',
|
||||
rose: 'text-rose-200 border-rose-300/20 bg-rose-400/10',
|
||||
slate: 'text-slate-200 border-white/10 bg-white/5',
|
||||
sky: {
|
||||
icon: 'text-sky-100 border-sky-300/20 bg-sky-400/12',
|
||||
card: 'hover:border-sky-300/35 hover:bg-[#102033]',
|
||||
glow: 'from-sky-400/18 via-sky-400/8 to-transparent',
|
||||
caption: 'text-sky-100/75',
|
||||
},
|
||||
amber: {
|
||||
icon: 'text-amber-100 border-amber-300/20 bg-amber-400/12',
|
||||
card: 'hover:border-amber-300/35 hover:bg-[#1a2130]',
|
||||
glow: 'from-amber-400/18 via-amber-400/8 to-transparent',
|
||||
caption: 'text-amber-100/75',
|
||||
},
|
||||
emerald: {
|
||||
icon: 'text-emerald-100 border-emerald-300/20 bg-emerald-400/12',
|
||||
card: 'hover:border-emerald-300/35 hover:bg-[#0f2130]',
|
||||
glow: 'from-emerald-400/18 via-emerald-400/8 to-transparent',
|
||||
caption: 'text-emerald-100/75',
|
||||
},
|
||||
rose: {
|
||||
icon: 'text-rose-100 border-rose-300/20 bg-rose-400/12',
|
||||
card: 'hover:border-rose-300/35 hover:bg-[#1d1d31]',
|
||||
glow: 'from-rose-400/18 via-rose-400/8 to-transparent',
|
||||
caption: 'text-rose-100/75',
|
||||
},
|
||||
slate: {
|
||||
icon: 'text-slate-100 border-white/10 bg-white/5',
|
||||
card: 'hover:border-white/20 hover:bg-[#102033]',
|
||||
glow: 'from-white/10 via-white/5 to-transparent',
|
||||
caption: 'text-slate-300/80',
|
||||
},
|
||||
}
|
||||
|
||||
const tone = accents[accent] || accents.slate
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={() => onNavigate?.(href, label)}
|
||||
className="group rounded-2xl border border-white/10 bg-[#0b1826]/85 p-4 shadow-lg shadow-black/20 transition hover:-translate-y-0.5 hover:border-sky-300/35 hover:bg-[#102033]"
|
||||
className={[
|
||||
'group relative overflow-hidden rounded-2xl border border-white/10 bg-[#0b1826]/85 p-4 shadow-lg shadow-black/20 transition hover:-translate-y-0.5',
|
||||
tone.card,
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={`pointer-events-none absolute inset-x-0 top-0 h-20 bg-gradient-to-b ${tone.glow}`} />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border ${accents[accent] || accents.slate}`}>
|
||||
<span className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border ${tone.icon}`}>
|
||||
<i className={icon} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-2xl font-semibold text-white">{value}</span>
|
||||
<div className="text-right">
|
||||
<span className="block text-2xl font-semibold text-white">{value}</span>
|
||||
{caption ? <span className={`mt-1 block text-[11px] uppercase tracking-[0.16em] ${tone.caption}`}>{caption}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-400">{label}</p>
|
||||
</a>
|
||||
@@ -386,15 +420,64 @@ function OverviewMetric({ label, value, href, icon, accent = 'sky', onNavigate }
|
||||
}
|
||||
|
||||
function SectionLinkCard({ item, onNavigate, onTogglePin, isPinned = false }) {
|
||||
const accents = {
|
||||
sky: {
|
||||
icon: 'border-sky-300/20 bg-sky-400/12 text-sky-100',
|
||||
badge: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
||||
preview: 'text-sky-200/80',
|
||||
open: 'text-sky-100',
|
||||
hover: 'hover:border-sky-300/35 hover:bg-[#102033]',
|
||||
glow: 'from-sky-400/16 via-sky-400/8 to-transparent',
|
||||
},
|
||||
emerald: {
|
||||
icon: 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100',
|
||||
badge: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
|
||||
preview: 'text-emerald-200/80',
|
||||
open: 'text-emerald-100',
|
||||
hover: 'hover:border-emerald-300/35 hover:bg-[#102033]',
|
||||
glow: 'from-emerald-400/16 via-emerald-400/8 to-transparent',
|
||||
},
|
||||
amber: {
|
||||
icon: 'border-amber-300/20 bg-amber-400/12 text-amber-100',
|
||||
badge: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
|
||||
preview: 'text-amber-200/80',
|
||||
open: 'text-amber-100',
|
||||
hover: 'hover:border-amber-300/35 hover:bg-[#1a2130]',
|
||||
glow: 'from-amber-400/16 via-amber-400/8 to-transparent',
|
||||
},
|
||||
rose: {
|
||||
icon: 'border-rose-300/20 bg-rose-400/12 text-rose-100',
|
||||
badge: 'border-rose-300/20 bg-rose-400/10 text-rose-100',
|
||||
preview: 'text-rose-200/80',
|
||||
open: 'text-rose-100',
|
||||
hover: 'hover:border-rose-300/35 hover:bg-[#1d1d31]',
|
||||
glow: 'from-rose-400/16 via-rose-400/8 to-transparent',
|
||||
},
|
||||
slate: {
|
||||
icon: 'border-white/10 bg-white/5 text-sky-200',
|
||||
badge: 'border-white/10 bg-white/5 text-slate-200',
|
||||
preview: 'text-sky-200/80',
|
||||
open: 'text-sky-100',
|
||||
hover: 'hover:border-white/20 hover:bg-[#102033]',
|
||||
glow: 'from-white/10 via-white/5 to-transparent',
|
||||
},
|
||||
}
|
||||
|
||||
const tone = accents[item.accent] || accents.slate
|
||||
|
||||
return (
|
||||
<article className="group rounded-2xl border border-white/10 bg-[#0b1826]/80 p-4 shadow-lg shadow-black/20 transition hover:-translate-y-0.5 hover:border-sky-300/35 hover:bg-[#102033]">
|
||||
<article className={[
|
||||
'group relative overflow-hidden rounded-2xl border border-white/10 bg-[#0b1826]/80 p-4 shadow-lg shadow-black/20 transition hover:-translate-y-0.5',
|
||||
tone.hover,
|
||||
].join(' ')}>
|
||||
<div className={`pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-b ${tone.glow}`} />
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-lg text-sky-200">
|
||||
<span className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border text-lg ${tone.icon}`}>
|
||||
<i className={item.icon} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.badge ? (
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100">
|
||||
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${tone.badge}`}>
|
||||
{item.badge}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -418,12 +501,12 @@ function SectionLinkCard({ item, onNavigate, onTogglePin, isPinned = false }) {
|
||||
<div className="mt-4">
|
||||
<h3 className="text-base font-semibold text-white transition group-hover:text-sky-100">{item.label}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">{item.description}</p>
|
||||
{item.preview ? <p className="mt-3 text-xs font-semibold uppercase tracking-[0.14em] text-sky-200/80">{item.preview}</p> : null}
|
||||
{item.preview ? <p className={`mt-3 text-xs font-semibold uppercase tracking-[0.14em] ${tone.preview}`}>{item.preview}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-3 text-xs uppercase tracking-[0.18em] text-slate-400">
|
||||
<span>{item.meta}</span>
|
||||
<a href={item.href} onClick={() => onNavigate?.(item.href, item.label)} className="text-sky-200 transition group-hover:translate-x-0.5">
|
||||
<a href={item.href} onClick={() => onNavigate?.(item.href, item.label)} className={`${tone.open} transition group-hover:translate-x-0.5`}>
|
||||
Open
|
||||
</a>
|
||||
</div>
|
||||
@@ -849,8 +932,8 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
|
||||
},
|
||||
{
|
||||
eyebrow: 'Community',
|
||||
title: 'Followers, following, and saved work',
|
||||
description: 'Move between your people-focused spaces without digging through the navigation.',
|
||||
title: 'Audience, network, and saved work',
|
||||
description: 'Stay close to the people around your account, from new followers to the creators shaping your feed.',
|
||||
items: [
|
||||
{
|
||||
label: 'Followers',
|
||||
@@ -860,6 +943,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
|
||||
meta: 'Dashboard Followers',
|
||||
badge: overviewStats.followers > 0 ? String(overviewStats.followers) : null,
|
||||
preview: previewLabelForRoute('/dashboard/followers', overviewStats),
|
||||
accent: 'sky',
|
||||
},
|
||||
{
|
||||
label: 'Following',
|
||||
@@ -869,6 +953,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
|
||||
meta: 'Dashboard Following',
|
||||
badge: overviewStats.following > 0 ? String(overviewStats.following) : null,
|
||||
preview: previewLabelForRoute('/dashboard/following', overviewStats),
|
||||
accent: 'emerald',
|
||||
},
|
||||
{
|
||||
label: 'Favorites',
|
||||
@@ -878,6 +963,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
|
||||
meta: 'Dashboard Favorites',
|
||||
badge: overviewStats.favorites > 0 ? String(overviewStats.favorites) : null,
|
||||
preview: previewLabelForRoute('/dashboard/favorites', overviewStats),
|
||||
accent: 'rose',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -933,6 +1019,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
|
||||
href: '/dashboard/notifications',
|
||||
icon: 'fa-solid fa-bell',
|
||||
accent: overviewStats.notifications > 0 ? 'amber' : 'slate',
|
||||
caption: overviewStats.notifications > 0 ? 'Needs review' : 'All clear',
|
||||
},
|
||||
{
|
||||
label: 'Followers',
|
||||
@@ -940,13 +1027,15 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
|
||||
href: '/dashboard/followers',
|
||||
icon: 'fa-solid fa-user-group',
|
||||
accent: 'sky',
|
||||
caption: overviewStats.followers > 0 ? 'Audience' : 'Build audience',
|
||||
},
|
||||
{
|
||||
label: 'Following',
|
||||
value: overviewStats.following,
|
||||
href: '/dashboard/following',
|
||||
icon: 'fa-solid fa-users',
|
||||
accent: 'slate',
|
||||
accent: 'emerald',
|
||||
caption: overviewStats.following > 0 ? 'Network' : 'Find creators',
|
||||
},
|
||||
{
|
||||
label: 'Saved favorites',
|
||||
@@ -954,6 +1043,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
|
||||
href: '/dashboard/favorites',
|
||||
icon: 'fa-solid fa-bookmark',
|
||||
accent: 'rose',
|
||||
caption: overviewStats.favorites > 0 ? 'Inspiration' : 'Nothing saved',
|
||||
},
|
||||
{
|
||||
label: 'Artworks',
|
||||
@@ -961,6 +1051,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
|
||||
href: '/dashboard/artworks',
|
||||
icon: 'fa-solid fa-layer-group',
|
||||
accent: 'emerald',
|
||||
caption: overviewStats.artworks > 0 ? 'Portfolio' : 'Start uploading',
|
||||
},
|
||||
{
|
||||
label: 'Stories',
|
||||
@@ -968,6 +1059,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
|
||||
href: isCreator ? '/creator/stories' : '/creator/stories/create',
|
||||
icon: 'fa-solid fa-pen-nib',
|
||||
accent: 'amber',
|
||||
caption: overviewStats.stories > 0 ? 'Creator voice' : 'Tell your story',
|
||||
},
|
||||
]
|
||||
const suggestions = [
|
||||
@@ -1069,7 +1161,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
|
||||
<h1 className="mt-3 max-w-3xl text-3xl font-semibold tracking-tight text-white sm:text-4xl">
|
||||
Welcome back, {username}
|
||||
</h1>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<LevelBadge level={level} rank={rank} />
|
||||
</div>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-300 sm:text-base">
|
||||
@@ -1090,7 +1182,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<HeroStat label="Level" value={`Lv. ${level}`} tone="sky" />
|
||||
<HeroStat label="Rank" value={rank} tone="amber" />
|
||||
<HeroStat
|
||||
|
||||
@@ -1,29 +1,36 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
function actorLabel(item) {
|
||||
if (!item?.user) {
|
||||
return 'Someone'
|
||||
if (!item?.actor) {
|
||||
return item?.type === 'notification' ? 'System' : 'Someone'
|
||||
}
|
||||
|
||||
return item.user.username ? `@${item.user.username}` : item.user.name || 'User'
|
||||
return item.actor.username ? `@${item.actor.username}` : item.actor.name || 'User'
|
||||
}
|
||||
|
||||
function describeActivity(item) {
|
||||
const artworkTitle = item?.artwork?.title || 'an artwork'
|
||||
const mentionTarget = item?.mentioned_user?.username || item?.mentioned_user?.name || 'someone'
|
||||
const reactionLabel = item?.reaction?.label || 'reacted'
|
||||
|
||||
switch (item?.type) {
|
||||
case 'comment':
|
||||
return `commented on ${artworkTitle}`
|
||||
case 'reply':
|
||||
return `replied on ${artworkTitle}`
|
||||
case 'reaction':
|
||||
return `${reactionLabel.toLowerCase()} on ${artworkTitle}`
|
||||
case 'mention':
|
||||
return `mentioned @${mentionTarget} on ${artworkTitle}`
|
||||
return item?.context?.artwork_title ? `commented on ${item.context.artwork_title}` : 'commented on your artwork'
|
||||
case 'new_follower':
|
||||
return 'started following you'
|
||||
case 'notification':
|
||||
return item?.message || 'sent a notification'
|
||||
default:
|
||||
return 'shared new activity'
|
||||
return item?.message || 'shared new activity'
|
||||
}
|
||||
}
|
||||
|
||||
function activityIcon(type) {
|
||||
switch (type) {
|
||||
case 'comment':
|
||||
return 'fa-solid fa-comment-dots'
|
||||
case 'new_follower':
|
||||
return 'fa-solid fa-user-plus'
|
||||
case 'notification':
|
||||
return 'fa-solid fa-bell'
|
||||
default:
|
||||
return 'fa-solid fa-bolt'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,17 +83,26 @@ export default function ActivityFeed() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Activity Feed</h2>
|
||||
<span className="text-xs text-gray-400">Recent actions</span>
|
||||
<section className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.32)]">
|
||||
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Live activity</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Activity Feed</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-300">
|
||||
Recent followers, artwork comments, and notifications that deserve your attention.
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center justify-center rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-300 sm:justify-start">Recent actions</span>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="text-sm text-gray-400">Loading activity...</p> : null}
|
||||
{loading ? <p className="text-sm text-slate-400">Loading activity...</p> : null}
|
||||
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
|
||||
|
||||
{!loading && !error && items.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No recent activity yet.</p>
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-5 py-6 text-sm text-slate-300">
|
||||
<p className="font-medium text-white">No recent activity yet.</p>
|
||||
<p className="mt-2 text-slate-400">New followers, comments, and notifications will appear here as they happen.</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !error && items.length > 0 ? (
|
||||
@@ -96,19 +112,43 @@ export default function ActivityFeed() {
|
||||
key={item.id}
|
||||
className={`rounded-xl border p-3 transition ${
|
||||
item.is_unread
|
||||
? 'border-cyan-500/40 bg-cyan-500/10'
|
||||
: 'border-gray-700 bg-gray-900/60'
|
||||
? 'border-sky-400/30 bg-sky-400/10'
|
||||
: 'border-white/8 bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm text-gray-100">
|
||||
<span className="font-semibold text-white">{actorLabel(item)}</span> {describeActivity(item)}
|
||||
</p>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-slate-950/60">
|
||||
{item.actor?.avatar ? (
|
||||
<img src={item.actor.avatar} alt={actorLabel(item)} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<i className={`${activityIcon(item.type)} text-sm text-sky-100/80`} />
|
||||
)}
|
||||
</div>
|
||||
{item.comment?.body ? (
|
||||
<p className="mt-2 line-clamp-2 text-xs text-gray-300">{item.comment.body}</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-100">
|
||||
<span className="font-semibold text-white">{actorLabel(item)}</span>{' '}
|
||||
<span>{describeActivity(item)}</span>
|
||||
</p>
|
||||
{item.message && item.type !== 'notification' ? (
|
||||
<p className="mt-1 text-xs text-slate-400">{item.message}</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-xs text-gray-400">{timeLabel(item.created_at)}</p>
|
||||
</div>
|
||||
<span className="text-[11px] uppercase tracking-wide text-slate-400 sm:shrink-0">{timeLabel(item.created_at)}</span>
|
||||
</div>
|
||||
|
||||
{item.context?.artwork_url ? (
|
||||
<a
|
||||
href={item.context.artwork_url}
|
||||
className="mt-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-200 transition hover:border-white/20 hover:bg-white/10"
|
||||
>
|
||||
View artwork
|
||||
<i className="fa-solid fa-arrow-right text-[10px]" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { useEffect, useState } from 'react'
|
||||
|
||||
function Widget({ label, value }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-700 bg-gray-900/70 p-4 shadow-lg transition hover:scale-[1.02]">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">{label}</p>
|
||||
<div className="rounded-2xl border border-white/8 bg-white/[0.04] p-4 shadow-lg transition hover:-translate-y-0.5 hover:border-white/15 hover:bg-white/[0.06]">
|
||||
<p className="text-[11px] uppercase tracking-[0.16em] text-slate-400">{label}</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-white">{value}</p>
|
||||
</div>
|
||||
)
|
||||
@@ -37,18 +37,23 @@ export default function CreatorAnalytics({ isCreator }) {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Creator Analytics</h2>
|
||||
<a href="/creator/analytics" className="text-xs text-cyan-300 hover:text-cyan-200">
|
||||
<section className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.32)]">
|
||||
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Creator space</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Creator Analytics</h2>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-slate-300">Snapshot metrics for the work you publish and the audience building around it.</p>
|
||||
</div>
|
||||
<a href="/creator/analytics" className="inline-flex items-center justify-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-400/15 sm:justify-start">
|
||||
Open analytics
|
||||
<i className="fa-solid fa-arrow-right text-[10px]" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="text-sm text-gray-400">Loading analytics...</p> : null}
|
||||
{loading ? <p className="text-sm text-slate-400">Loading analytics...</p> : null}
|
||||
|
||||
{!loading && !isCreator && !data?.is_creator ? (
|
||||
<div className="rounded-xl border border-gray-700 bg-gray-900/60 p-4 text-sm text-gray-300">
|
||||
<div className="rounded-2xl border border-white/8 bg-white/[0.04] p-4 text-sm text-slate-300">
|
||||
Upload your first artwork to unlock creator-only insights.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -29,31 +29,35 @@ export default function RecentAchievements() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<section className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-gray-400">Achievements</p>
|
||||
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Achievements</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Recent Unlocks</h2>
|
||||
<p className="mt-2 max-w-xs text-sm leading-6 text-slate-300">Recent badges and milestones that reflect how your account is developing.</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-gray-300">
|
||||
<span className="inline-flex items-center justify-center rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300 sm:justify-start">
|
||||
{data?.counts?.unlocked || 0} / {data?.counts?.total || 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="mt-4 text-sm text-gray-400">Loading achievements...</p> : null}
|
||||
{loading ? <p className="mt-4 text-sm text-slate-400">Loading achievements...</p> : null}
|
||||
|
||||
{!loading && (!Array.isArray(data?.recent) || data.recent.length === 0) ? (
|
||||
<p className="mt-4 text-sm text-gray-400">No achievements unlocked yet.</p>
|
||||
<div className="mt-4 rounded-2xl border border-white/8 bg-white/[0.04] px-4 py-5 text-sm text-slate-300">
|
||||
<p className="font-medium text-white">No achievements unlocked yet.</p>
|
||||
<p className="mt-2 text-slate-400">Keep posting, engaging, and growing your profile to start earning them.</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && Array.isArray(data?.recent) && data.recent.length > 0 ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
{data.recent.map((achievement) => (
|
||||
<article key={achievement.id} className="rounded-xl border border-gray-700 bg-gray-900/60 p-3">
|
||||
<article key={achievement.id} className="rounded-2xl border border-white/8 bg-white/[0.04] p-4 transition hover:border-white/15 hover:bg-white/[0.06]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-white">{achievement.name}</p>
|
||||
<p className="mt-1 text-xs text-gray-400">{achievement.description}</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{achievement.description}</p>
|
||||
</div>
|
||||
<AchievementBadge achievement={achievement} compact />
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import LevelBadge from '../../components/xp/LevelBadge'
|
||||
export default function RecommendedCreators() {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busyId, setBusyId] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -13,6 +15,11 @@ export default function RecommendedCreators() {
|
||||
const response = await window.axios.get('/api/dashboard/recommended-creators')
|
||||
if (!cancelled) {
|
||||
setItems(Array.isArray(response.data?.data) ? response.data.data : [])
|
||||
setError('')
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError('Could not load creator recommendations right now.')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
@@ -28,19 +35,51 @@ export default function RecommendedCreators() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleFollow(creator) {
|
||||
if (!creator?.username || busyId === creator.id) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusyId(creator.id)
|
||||
|
||||
try {
|
||||
const response = await window.axios.post(`/@${creator.username}/follow`)
|
||||
const isFollowing = Boolean(response.data?.following)
|
||||
|
||||
if (isFollowing) {
|
||||
setItems((current) => current.filter((item) => item.id !== creator.id))
|
||||
}
|
||||
} catch {
|
||||
setError('Could not update follow state right now.')
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Recommended Creators</h2>
|
||||
<a className="text-xs text-cyan-300 hover:text-cyan-200" href="/creators/top">
|
||||
<section className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.32)]">
|
||||
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Community</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Recommended Creators</h2>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-slate-300">
|
||||
Strong accounts you are not following yet, selected to help you improve your feed and discover new audiences.
|
||||
</p>
|
||||
</div>
|
||||
<a className="inline-flex items-center justify-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-400/15 sm:justify-start" href="/creators/top">
|
||||
See all
|
||||
<i className="fa-solid fa-arrow-right text-[10px]" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="text-sm text-gray-400">Loading creators...</p> : null}
|
||||
{loading ? <p className="text-sm text-slate-400">Loading creators...</p> : null}
|
||||
{error ? <p className="mb-4 text-sm text-rose-300">{error}</p> : null}
|
||||
|
||||
{!loading && items.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No creator recommendations right now.</p>
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-5 py-6 text-sm text-slate-300">
|
||||
<p className="font-medium text-white">No creator recommendations right now.</p>
|
||||
<p className="mt-2 text-slate-400">Browse the full creator directory to keep expanding your network.</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && items.length > 0 ? (
|
||||
@@ -48,31 +87,50 @@ export default function RecommendedCreators() {
|
||||
{items.map((creator) => (
|
||||
<article
|
||||
key={creator.id}
|
||||
className="flex items-center justify-between rounded-xl border border-gray-700 bg-gray-900/70 p-3 transition hover:scale-[1.02]"
|
||||
className="flex flex-col gap-4 rounded-2xl border border-white/8 bg-white/[0.04] p-4 transition hover:-translate-y-0.5 hover:border-white/15 hover:bg-white/[0.06] sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<a href={creator.url || '#'} className="flex min-w-0 items-center gap-3">
|
||||
<a href={creator.url || '#'} className="flex min-w-0 items-center gap-4">
|
||||
<img
|
||||
src={creator.avatar || '/images/default-avatar.png'}
|
||||
alt={creator.username || creator.name || 'Creator'}
|
||||
className="h-10 w-10 rounded-full border border-gray-600 object-cover"
|
||||
className="h-12 w-12 rounded-2xl border border-white/10 object-cover"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="truncate text-sm font-semibold text-white">
|
||||
{creator.username ? `@${creator.username}` : creator.name}
|
||||
</p>
|
||||
<span className="inline-flex items-center rounded-full border border-sky-400/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wide text-sky-100">
|
||||
Suggested
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<LevelBadge level={creator.level} rank={creator.rank} compact />
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">{creator.followers_count} followers</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-slate-400">
|
||||
<span>{Number(creator.followers_count || 0).toLocaleString()} followers</span>
|
||||
<span>{Number(creator.uploads_count || 0).toLocaleString()} uploads</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center sm:self-auto">
|
||||
<a
|
||||
href={creator.url || '#'}
|
||||
className="rounded-lg border border-cyan-400/60 px-3 py-1 text-xs font-semibold text-cyan-200 transition hover:bg-cyan-500/20"
|
||||
className="inline-flex items-center justify-center rounded-full border border-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-slate-200 transition hover:border-white/20 hover:bg-white/5"
|
||||
>
|
||||
Follow
|
||||
View profile
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFollow(creator)}
|
||||
disabled={busyId === creator.id || !creator.username}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-3.5 py-2 text-xs font-semibold uppercase tracking-wide text-emerald-100 transition hover:border-emerald-300/40 hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<i className={`fa-solid ${busyId === creator.id ? 'fa-circle-notch fa-spin' : 'fa-user-plus'} text-[10px]`} />
|
||||
Follow
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -31,21 +31,26 @@ export default function TopCreatorsWidget() {
|
||||
const items = Array.isArray(data?.items) ? data.items.slice(0, 5) : []
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<section className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-gray-400">Leaderboard</p>
|
||||
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Leaderboard</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Top Creators</h2>
|
||||
<p className="mt-2 max-w-xs text-sm leading-6 text-slate-300">A quick weekly pulse on who is gaining the most traction across the platform.</p>
|
||||
</div>
|
||||
<a href="/leaderboard?type=creators&period=weekly" className="text-xs font-semibold uppercase tracking-[0.14em] text-sky-300 hover:text-sky-200">
|
||||
<a href="/leaderboard?type=creators&period=weekly" className="inline-flex items-center justify-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-400/15 sm:justify-start">
|
||||
View all
|
||||
<i className="fa-solid fa-arrow-right text-[10px]" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="mt-4 text-sm text-gray-400">Loading leaderboard...</p> : null}
|
||||
{loading ? <p className="mt-4 text-sm text-slate-400">Loading leaderboard...</p> : null}
|
||||
|
||||
{!loading && items.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-gray-400">No creators ranked yet.</p>
|
||||
<div className="mt-4 rounded-2xl border border-white/8 bg-white/[0.04] px-4 py-5 text-sm text-slate-300">
|
||||
<p className="font-medium text-white">No creators ranked yet.</p>
|
||||
<p className="mt-2 text-slate-400">Rankings will appear here once weekly creator scoring is available.</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && items.length > 0 ? (
|
||||
@@ -54,18 +59,21 @@ export default function TopCreatorsWidget() {
|
||||
const entity = item.entity || {}
|
||||
|
||||
return (
|
||||
<a key={item.rank} href={entity.url || '#'} className="flex items-center gap-3 rounded-xl border border-gray-700 bg-gray-900/60 p-3 transition hover:border-sky-400/40">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-sm font-black text-white">
|
||||
<a key={item.rank} href={entity.url || '#'} className="flex items-center gap-3 rounded-2xl border border-white/8 bg-white/[0.04] p-4 transition hover:-translate-y-0.5 hover:border-white/15 hover:bg-white/[0.06]">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-sky-300/20 bg-sky-400/12 text-sm font-black text-white">
|
||||
#{item.rank}
|
||||
</div>
|
||||
{entity.avatar ? <img src={entity.avatar} alt={entity.name || 'Creator'} className="h-11 w-11 rounded-xl object-cover" loading="lazy" /> : null}
|
||||
{entity.avatar ? <img src={entity.avatar} alt={entity.name || 'Creator'} className="h-11 w-11 rounded-xl border border-white/10 object-cover" loading="lazy" /> : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-white">{entity.name}</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<LevelBadge level={entity.level} rank={entity.rank} compact />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-sky-300">{Math.round(item.score)}</span>
|
||||
<div className="shrink-0 text-right">
|
||||
<span className="block text-sm font-semibold text-sky-300">{Math.round(item.score)}</span>
|
||||
<span className="mt-1 block text-[11px] uppercase tracking-[0.16em] text-slate-400">Weekly score</span>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -38,16 +38,19 @@ export default function XPProgressWidget({ initialLevel = 1, initialRank = 'Newb
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<section className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-gray-400">Progression</p>
|
||||
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Progression</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">XP Progress</h2>
|
||||
<p className="mt-2 max-w-xs text-sm leading-6 text-slate-300">Track how close you are to the next level and keep your creator momentum visible.</p>
|
||||
</div>
|
||||
<div className="sm:self-start">
|
||||
<LevelBadge level={data.level} rank={data.rank} compact />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mt-5 rounded-2xl border border-white/8 bg-white/[0.04] p-4">
|
||||
<XPProgressBar
|
||||
xp={data.xp}
|
||||
currentLevelXp={data.current_level_xp}
|
||||
@@ -55,9 +58,14 @@ export default function XPProgressWidget({ initialLevel = 1, initialRank = 'Newb
|
||||
progressPercent={data.progress_percent}
|
||||
maxLevel={data.max_level}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-3 text-xs uppercase tracking-[0.16em] text-slate-400">
|
||||
<span>Total XP</span>
|
||||
<span className="font-semibold text-sky-100">{Number(data.xp || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="mt-3 text-xs text-gray-400">Syncing your latest XP...</p> : null}
|
||||
{loading ? <p className="mt-4 text-xs text-slate-400">Syncing your latest XP...</p> : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -134,6 +134,9 @@ export default function useFileValidation(primaryFile, screenshots, isArchive) {
|
||||
|
||||
const primaryRunRef = useRef(0)
|
||||
const screenshotRunRef = useRef(0)
|
||||
const effectiveIsArchive = typeof isArchive === 'boolean'
|
||||
? isArchive
|
||||
: detectFileType(primaryFile) === 'archive'
|
||||
|
||||
// Primary file validation
|
||||
useEffect(() => {
|
||||
@@ -168,7 +171,7 @@ export default function useFileValidation(primaryFile, screenshots, isArchive) {
|
||||
let cancelled = false
|
||||
|
||||
;(async () => {
|
||||
const result = await validateScreenshots(screenshots, isArchive)
|
||||
const result = await validateScreenshots(screenshots, effectiveIsArchive)
|
||||
if (cancelled || runId !== screenshotRunRef.current) return
|
||||
setScreenshotErrors(result.errors)
|
||||
setScreenshotPerFileErrors(result.perFileErrors)
|
||||
@@ -177,15 +180,15 @@ export default function useFileValidation(primaryFile, screenshots, isArchive) {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [screenshots, isArchive])
|
||||
}, [screenshots, effectiveIsArchive])
|
||||
|
||||
// Clear screenshots when file changes to a non-archive
|
||||
useEffect(() => {
|
||||
if (!isArchive) {
|
||||
if (!effectiveIsArchive) {
|
||||
setScreenshotErrors([])
|
||||
setScreenshotPerFileErrors([])
|
||||
}
|
||||
}, [isArchive])
|
||||
}, [effectiveIsArchive])
|
||||
|
||||
// Revoke preview URL on unmount
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
@extends('admin::layout.default')
|
||||
|
||||
@section('content')
|
||||
<x-page-layout>
|
||||
@include('admin::blocks.notification_error')
|
||||
|
||||
@if(session('msg_success'))
|
||||
<div class="alert alert-success alert-dismissible">
|
||||
<button type="button" class="close" data-dismiss="alert">×</button>
|
||||
{{ session('msg_success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-8">
|
||||
<h3 class="mb-1">Countries</h3>
|
||||
<p class="text-muted mb-0">Read-only ISO country catalog with manual sync support.</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-right">
|
||||
<form method="POST" action="{{ route('admin.cp.countries.sync') }}">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="fa-solid fa-rotate"></i> Sync countries
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<form method="GET" action="{{ route('admin.cp.countries.main') }}" class="form-inline">
|
||||
<div class="input-group input-group-sm" style="max-width: 420px; width: 100%;">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
value="{{ $search }}"
|
||||
placeholder="Search by code or name"
|
||||
class="form-control"
|
||||
/>
|
||||
<div class="input-group-append">
|
||||
<button type="submit" class="btn btn-default">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Country</th>
|
||||
<th>ISO2</th>
|
||||
<th>ISO3</th>
|
||||
<th>Region</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($countries as $country)
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center" style="gap: 10px;">
|
||||
@if ($country->local_flag_path)
|
||||
<img
|
||||
src="{{ $country->local_flag_path }}"
|
||||
alt="{{ $country->name_common }}"
|
||||
style="width: 24px; height: 16px; object-fit: cover; border-radius: 2px;"
|
||||
onerror="this.style.display='none'"
|
||||
>
|
||||
@endif
|
||||
<div>
|
||||
<div>{{ $country->name_common }}</div>
|
||||
@if ($country->name_official)
|
||||
<small class="text-muted">{{ $country->name_official }}</small>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><code>{{ $country->iso2 }}</code></td>
|
||||
<td><code>{{ $country->iso3 ?? '—' }}</code></td>
|
||||
<td>{{ $country->region ?? '—' }}</td>
|
||||
<td>
|
||||
@if ($country->active)
|
||||
<span class="badge badge-success">Active</span>
|
||||
@else
|
||||
<span class="badge badge-secondary">Inactive</span>
|
||||
@endif
|
||||
|
||||
@if ($country->is_featured)
|
||||
<span class="badge badge-info">Featured</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted py-4">No countries found.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card-footer clearfix">
|
||||
{{ $countries->links() }}
|
||||
</div>
|
||||
</div>
|
||||
</x-page-layout>
|
||||
@endsection
|
||||
@@ -1,99 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-6xl px-4 py-8">
|
||||
<div class="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-100">Countries</h1>
|
||||
<p class="mt-1 text-sm text-gray-400">Read-only ISO country catalog with manual sync support.</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('admin.countries.sync') }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sm text-sky-200">
|
||||
Sync countries
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if (session('success'))
|
||||
<div class="mb-4 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-200">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('error'))
|
||||
<div class="mb-4 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="get" action="{{ route('admin.countries.index') }}" class="mb-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
value="{{ $search }}"
|
||||
placeholder="Search by code or name"
|
||||
class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-100 placeholder:text-gray-500 md:max-w-sm"
|
||||
/>
|
||||
<button type="submit" class="rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-700 bg-gray-900">
|
||||
<table class="min-w-full divide-y divide-gray-700 text-sm">
|
||||
<thead class="bg-gray-800 text-gray-300">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Country</th>
|
||||
<th class="px-4 py-3 text-left">ISO2</th>
|
||||
<th class="px-4 py-3 text-left">ISO3</th>
|
||||
<th class="px-4 py-3 text-left">Region</th>
|
||||
<th class="px-4 py-3 text-left">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-800 text-gray-200">
|
||||
@forelse ($countries as $country)
|
||||
<tr>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@if ($country->local_flag_path)
|
||||
<img
|
||||
src="{{ $country->local_flag_path }}"
|
||||
alt="{{ $country->name_common }}"
|
||||
class="h-4 w-6 rounded-sm object-cover"
|
||||
onerror="this.style.display='none'"
|
||||
>
|
||||
@endif
|
||||
<div>
|
||||
<div>{{ $country->name_common }}</div>
|
||||
@if ($country->name_official)
|
||||
<div class="text-xs text-gray-500">{{ $country->name_official }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono">{{ $country->iso2 }}</td>
|
||||
<td class="px-4 py-3 font-mono">{{ $country->iso3 ?? '—' }}</td>
|
||||
<td class="px-4 py-3">{{ $country->region ?? '—' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-1 text-xs {{ $country->active ? 'bg-emerald-500/10 text-emerald-200' : 'bg-gray-700 text-gray-300' }}">
|
||||
{{ $country->active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
@if ($country->is_featured)
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-sky-500/10 px-2.5 py-1 text-xs text-sky-200">Featured</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-6 text-center text-gray-400">No countries found.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $countries->links() }}</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,156 +0,0 @@
|
||||
@extends('layouts.nova.content-layout')
|
||||
|
||||
@section('page-content')
|
||||
<div class="max-w-4xl space-y-8">
|
||||
|
||||
{{-- ── Header ── --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white">Early-Stage Growth System</h2>
|
||||
<p class="mt-1 text-sm text-neutral-400">
|
||||
A non-deceptive layer that keeps Nova feeling alive when uploads are sparse.
|
||||
Toggle via <code class="text-sky-400">.env</code> — no deployment required for mode changes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Cache flush button --}}
|
||||
<form method="POST" action="{{ route('admin.early-growth.cache.flush') }}" onsubmit="return confirm('Flush all EGS caches?')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-neutral-800 px-4 py-2 text-sm font-medium text-white
|
||||
hover:bg-neutral-700 border border-neutral-700 transition">
|
||||
🔄 Flush EGS Cache
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="rounded-lg bg-green-900/40 border border-green-700 px-4 py-3 text-green-300 text-sm">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ── Live Status ── --}}
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-4">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">Live Status</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
@php
|
||||
$pill = fn(bool $on) => $on
|
||||
? '<span class="inline-block rounded-full bg-emerald-800 px-3 py-0.5 text-xs font-semibold text-emerald-200">ON</span>'
|
||||
: '<span class="inline-block rounded-full bg-neutral-700 px-3 py-0.5 text-xs font-semibold text-neutral-400">OFF</span>';
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">System</p>
|
||||
{!! $pill($status['enabled']) !!}
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Mode</p>
|
||||
<span class="text-sm font-mono font-semibold {{ $mode === 'aggressive' ? 'text-amber-400' : ($mode === 'light' ? 'text-sky-400' : 'text-neutral-400') }}">
|
||||
{{ strtoupper($mode) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Adaptive Window</p>
|
||||
{!! $pill($status['adaptive_window']) !!}
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Grid Filler</p>
|
||||
{!! $pill($status['grid_filler']) !!}
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Spotlight</p>
|
||||
{!! $pill($status['spotlight']) !!}
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Activity Layer</p>
|
||||
{!! $pill($status['activity_layer']) !!}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Upload Stats ── --}}
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-4">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">Upload Metrics</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Uploads / day (7-day avg)</p>
|
||||
<p class="text-2xl font-bold text-white">{{ number_format($uploads_per_day, 1) }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Active trending window</p>
|
||||
<p class="text-2xl font-bold text-white">{{ $window_days }}d</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Activity Signals ── --}}
|
||||
@if($status['activity_layer'] && !empty($activity))
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-3">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">Activity Signals</h3>
|
||||
<ul class="space-y-2">
|
||||
@foreach($activity as $signal)
|
||||
<li class="text-sm text-neutral-200">{{ $signal['icon'] }} {{ $signal['text'] }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ── ENV Toggles ── --}}
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-4">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">ENV Configuration</h3>
|
||||
<p class="text-xs text-neutral-500">Edit <code class="text-sky-400">.env</code> to change these values. Run <code class="text-sky-400">php artisan config:clear</code> after changes.</p>
|
||||
<div class="overflow-hidden rounded-lg border border-neutral-800">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 bg-neutral-800/40">
|
||||
<th class="px-4 py-2 text-left text-xs text-neutral-400">Variable</th>
|
||||
<th class="px-4 py-2 text-left text-xs text-neutral-400">Current Value</th>
|
||||
<th class="px-4 py-2 text-left text-xs text-neutral-400">Effect</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($env_toggles as $t)
|
||||
<tr class="border-b border-neutral-800/50">
|
||||
<td class="px-4 py-2 font-mono text-sky-400">{{ $t['key'] }}</td>
|
||||
<td class="px-4 py-2 font-mono text-white">{{ $t['current'] }}</td>
|
||||
<td class="px-4 py-2 text-neutral-400">
|
||||
@switch($t['key'])
|
||||
@case('NOVA_EARLY_GROWTH_ENABLED') Master switch. Set to <code>false</code> to disable entire system. @break
|
||||
@case('NOVA_EARLY_GROWTH_MODE') <code>off</code> / <code>light</code> / <code>aggressive</code> @break
|
||||
@case('NOVA_EGS_ADAPTIVE_WINDOW') Widen trending window when uploads low. @break
|
||||
@case('NOVA_EGS_GRID_FILLER') Backfill page-1 grids to 12 items. @break
|
||||
@case('NOVA_EGS_SPOTLIGHT') Daily-rotating curated picks. @break
|
||||
@case('NOVA_EGS_ACTIVITY_LAYER') Real activity summary badges. @break
|
||||
@endswitch
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-neutral-800/30 border border-neutral-700 p-4 text-xs text-neutral-400 space-y-1">
|
||||
<p><strong class="text-white">To enable (light mode):</strong></p>
|
||||
<pre class="text-sky-400 font-mono">NOVA_EARLY_GROWTH_ENABLED=true
|
||||
NOVA_EARLY_GROWTH_MODE=light</pre>
|
||||
<p class="mt-2"><strong class="text-white">To disable instantly:</strong></p>
|
||||
<pre class="text-sky-400 font-mono">NOVA_EARLY_GROWTH_ENABLED=false</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Cache Keys Reference ── --}}
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-3">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">Cache Keys</h3>
|
||||
<ul class="space-y-1">
|
||||
@foreach($cache_keys as $key)
|
||||
<li class="font-mono text-xs text-neutral-400">{{ $key }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<p class="text-xs text-neutral-600">Use the "Flush EGS Cache" button above to clear these in one action.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,24 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8 py-10">
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6">
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Reports Hub</h1>
|
||||
<p class="mt-2 text-sm text-gray-500">Internal reporting entry points for moderation and discovery analytics.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<a href="{{ route('admin.reports.tags') }}" class="rounded-xl border border-sky-200 bg-sky-50 p-6 transition hover:border-sky-300 hover:bg-sky-100/80 dark:border-sky-900/60 dark:bg-sky-950/30 dark:hover:border-sky-700 dark:hover:bg-sky-950/50">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-sky-100">Tag Interaction Report</h2>
|
||||
<p class="mt-2 text-sm text-slate-600 dark:text-sky-200/70">Inspect top surfaces, tags, search terms, and related-tag transitions from the new tag analytics pipeline.</p>
|
||||
</a>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Moderation Queue</h2>
|
||||
<p class="mt-2 text-sm text-gray-500">Use the API endpoint <code>/api/reports</code> to submit reports and review records in the <code>reports</code> table.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,216 +0,0 @@
|
||||
@extends('layouts.nova.content-layout')
|
||||
|
||||
@section('page-content')
|
||||
<div class="mx-auto max-w-7xl space-y-8">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Tag Interaction Report</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm text-neutral-400">
|
||||
Internal dashboard for tag discovery clicks. Use it to inspect surface performance, top tags, query demand, and tag-to-tag transitions for recommendation tuning.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3 text-xs">
|
||||
<a href="{{ route('api.admin.reports.tags', request()->query()) }}" class="inline-flex items-center rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2 font-medium text-neutral-200 transition hover:border-sky-500 hover:text-white">JSON report</a>
|
||||
<a href="{{ route('admin.reports.queue') }}" class="inline-flex items-center rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2 font-medium text-neutral-200 transition hover:border-sky-500 hover:text-white">Reports hub</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<form method="GET" action="{{ route('admin.reports.tags') }}" class="grid gap-4 md:grid-cols-4">
|
||||
<label class="space-y-2 text-sm text-neutral-300">
|
||||
<span class="block text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">From</span>
|
||||
<input type="date" name="from" value="{{ $filters['from'] }}" class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-white focus:border-sky-500 focus:outline-none">
|
||||
</label>
|
||||
<label class="space-y-2 text-sm text-neutral-300">
|
||||
<span class="block text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">To</span>
|
||||
<input type="date" name="to" value="{{ $filters['to'] }}" class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-white focus:border-sky-500 focus:outline-none">
|
||||
</label>
|
||||
<label class="space-y-2 text-sm text-neutral-300">
|
||||
<span class="block text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Row limit</span>
|
||||
<input type="number" min="1" max="100" name="limit" value="{{ $filters['limit'] }}" class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-white focus:border-sky-500 focus:outline-none">
|
||||
</label>
|
||||
<div class="flex items-end gap-3">
|
||||
<button type="submit" class="inline-flex items-center rounded-lg bg-sky-500 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-400">Refresh</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-4 text-xs text-neutral-500">
|
||||
<span>Latest aggregated date: <span class="font-medium text-neutral-300">{{ $latestAggregatedDate ?? 'not aggregated yet' }}</span></span>
|
||||
<span>Latest raw event: <span class="font-medium text-neutral-300">{{ $overview['latest_event_at'] ?? 'n/a' }}</span></span>
|
||||
</div>
|
||||
|
||||
@if(app()->environment('local'))
|
||||
<div class="mt-4 rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-100">
|
||||
<p class="font-semibold">Local demo data</p>
|
||||
<p class="mt-1 text-amber-100/80">
|
||||
This report can be filled locally with seeded click data. Run
|
||||
<code class="rounded bg-black/30 px-2 py-1 text-xs text-amber-50">php artisan analytics:seed-tag-interaction-demo --days=14 --per-day=80 --refresh</code>
|
||||
and refresh this page to inspect realistic search, recommendation, and transition metrics.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Total clicks</p>
|
||||
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['total_clicks']) }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Unique users</p>
|
||||
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['unique_users']) }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Unique sessions</p>
|
||||
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['unique_sessions']) }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Distinct tags</p>
|
||||
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['distinct_tags']) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Daily Click Trend</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500">Daily rollups for tuning trending and recommendation decisions.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@forelse($dailyClicks as $row)
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-950/70 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-neutral-500">{{ $row['date'] }}</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($row['clicks']) }}</p>
|
||||
<p class="mt-1 text-xs text-neutral-500">clicks</p>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-neutral-500">No aggregated rows available for the selected range yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<h2 class="text-lg font-semibold text-white">Top Surfaces</h2>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
|
||||
<th class="pb-3 pr-4">Surface</th>
|
||||
<th class="pb-3 pr-4">Clicks</th>
|
||||
<th class="pb-3 pr-4">Users</th>
|
||||
<th class="pb-3 pr-4">Sessions</th>
|
||||
<th class="pb-3">Avg pos.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($bySurface as $row)
|
||||
<tr class="border-b border-neutral-800/70 text-neutral-200">
|
||||
<td class="py-3 pr-4 font-medium text-white">{{ $row['surface'] }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['unique_users']) }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['unique_sessions']) }}</td>
|
||||
<td class="py-3">{{ $row['avg_position'] > 0 ? number_format($row['avg_position'], 2) : 'n/a' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="py-4 text-neutral-500">No surface data in this range.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<h2 class="text-lg font-semibold text-white">Top Query Terms</h2>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
|
||||
<th class="pb-3 pr-4">Query</th>
|
||||
<th class="pb-3 pr-4">Clicks</th>
|
||||
<th class="pb-3 pr-4">Sessions</th>
|
||||
<th class="pb-3">Resolved tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($topQueries as $row)
|
||||
<tr class="border-b border-neutral-800/70 text-neutral-200">
|
||||
<td class="py-3 pr-4 font-medium text-white">{{ $row['query'] }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['unique_sessions']) }}</td>
|
||||
<td class="py-3">{{ number_format($row['resolved_tags']) }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4" class="py-4 text-neutral-500">No query data in this range.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<h2 class="text-lg font-semibold text-white">Top Tags</h2>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
|
||||
<th class="pb-3 pr-4">Tag</th>
|
||||
<th class="pb-3 pr-4">Clicks</th>
|
||||
<th class="pb-3 pr-4">Recommendation</th>
|
||||
<th class="pb-3 pr-4">Search</th>
|
||||
<th class="pb-3">Sessions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($topTags as $row)
|
||||
<tr class="border-b border-neutral-800/70 text-neutral-200">
|
||||
<td class="py-3 pr-4 font-medium text-white">#{{ $row['tag_slug'] }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['recommendation_clicks']) }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['search_clicks']) }}</td>
|
||||
<td class="py-3">{{ number_format($row['unique_sessions']) }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="py-4 text-neutral-500">No tag click data in this range.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<h2 class="text-lg font-semibold text-white">Top Tag Transitions</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500">Most-clicked source tag to target tag paths from related-tag surfaces.</p>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
|
||||
<th class="pb-3 pr-4">Source</th>
|
||||
<th class="pb-3 pr-4">Target</th>
|
||||
<th class="pb-3 pr-4">Clicks</th>
|
||||
<th class="pb-3 pr-4">Sessions</th>
|
||||
<th class="pb-3">Avg pos.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($topTransitions as $row)
|
||||
<tr class="border-b border-neutral-800/70 text-neutral-200">
|
||||
<td class="py-3 pr-4 font-medium text-white">#{{ $row['source_tag_slug'] }}</td>
|
||||
<td class="py-3 pr-4 font-medium text-white">#{{ $row['tag_slug'] }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['unique_sessions']) }}</td>
|
||||
<td class="py-3">{{ $row['avg_position'] > 0 ? number_format($row['avg_position'], 2) : 'n/a' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="py-4 text-neutral-500">No transition data in this range.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,36 +0,0 @@
|
||||
@extends('layouts.nova.content-layout')
|
||||
|
||||
@section('page-content')
|
||||
<div class="max-w-5xl">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Staff / Contact Submissions</h2>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-neutral-800 bg-nova-900 p-4">
|
||||
<table class="w-full table-auto text-left text-sm">
|
||||
<thead>
|
||||
<tr class="text-neutral-400">
|
||||
<th class="py-2">When</th>
|
||||
<th class="py-2">Topic</th>
|
||||
<th class="py-2">Name</th>
|
||||
<th class="py-2">Email</th>
|
||||
<th class="py-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($items as $i)
|
||||
<tr class="border-t border-neutral-800">
|
||||
<td class="py-3 text-neutral-400">{{ $i->created_at->toDayDateTimeString() }}</td>
|
||||
<td class="py-3">{{ ucfirst($i->topic) }}</td>
|
||||
<td class="py-3">{{ $i->name }}</td>
|
||||
<td class="py-3">{{ $i->email }}</td>
|
||||
<td class="py-3"><a class="text-sky-400 hover:underline" href="{{ route('admin.applications.show', $i->id) }}">View</a></td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="py-6 text-neutral-400">No submissions yet.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="mt-4">{{ $items->links() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,36 +0,0 @@
|
||||
@extends('layouts.nova.content-layout')
|
||||
|
||||
@section('page-content')
|
||||
<div class="max-w-3xl">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Submission</h2>
|
||||
|
||||
<div class="rounded-lg border border-neutral-800 bg-nova-900 p-6">
|
||||
<dl class="grid grid-cols-1 gap-4 text-sm text-neutral-300">
|
||||
<div>
|
||||
<dt class="text-neutral-400">Topic</dt>
|
||||
<dd>{{ ucfirst($item->topic) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-neutral-400">Name</dt>
|
||||
<dd>{{ $item->name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-neutral-400">Email</dt>
|
||||
<dd>{{ $item->email }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-neutral-400">Portfolio</dt>
|
||||
<dd>{{ $item->portfolio }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-neutral-400">Message</dt>
|
||||
<dd class="whitespace-pre-line">{{ $item->message }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-neutral-400">Received</dt>
|
||||
<dd>{{ $item->created_at->toDayDateTimeString() }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,8 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
<h1 class="mb-2 text-xl font-semibold text-gray-100">Story Comments Moderation</h1>
|
||||
<p class="text-sm text-gray-400">Story comments currently use the existing profile comment pipeline. Use this page as moderation entrypoint and link to the global comments moderation tools.</p>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,8 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-100">Create Story</h1>
|
||||
@include('admin.stories.partials.form', ['action' => route('admin.stories.store'), 'method' => 'POST'])
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,21 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-100">Edit Story</h1>
|
||||
@include('admin.stories.partials.form', ['action' => route('admin.stories.update', $story->id), 'method' => 'PUT'])
|
||||
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<form method="POST" action="{{ route('admin.stories.publish', $story->id) }}">
|
||||
@csrf
|
||||
<button class="rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-4 py-2 text-emerald-200">Publish</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('admin.stories.destroy', $story->id) }}" onsubmit="return confirm('Delete this story?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2 text-rose-200">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,42 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-6xl px-4 py-8">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-100">Stories Management</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ route('admin.stories.review') }}" class="rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-200">Review queue</a>
|
||||
<a href="{{ route('admin.stories.create') }}" class="rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sm text-sky-200">Create story</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-700 bg-gray-900">
|
||||
<table class="min-w-full divide-y divide-gray-700 text-sm">
|
||||
<thead class="bg-gray-800 text-gray-300">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Title</th>
|
||||
<th class="px-4 py-3 text-left">Creator</th>
|
||||
<th class="px-4 py-3 text-left">Status</th>
|
||||
<th class="px-4 py-3 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-800 text-gray-200">
|
||||
@foreach($stories as $story)
|
||||
<tr>
|
||||
<td class="px-4 py-3">{{ $story->title }}</td>
|
||||
<td class="px-4 py-3">{{ $story->creator?->username ?? 'n/a' }}</td>
|
||||
<td class="px-4 py-3">{{ $story->status }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="{{ route('admin.stories.show', ['story' => $story->id]) }}" class="text-amber-300">View</a>
|
||||
<span class="mx-1 text-gray-500">|</span>
|
||||
<a href="{{ route('admin.stories.edit', ['story' => $story->id]) }}" class="text-sky-300">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $stories->links() }}</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,71 +0,0 @@
|
||||
<form method="POST" action="{{ $action }}" class="space-y-5 rounded-xl border border-gray-700 bg-gray-800/60 p-6">
|
||||
@csrf
|
||||
@if($method !== 'POST')
|
||||
@method($method)
|
||||
@endif
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-200">Creator</label>
|
||||
<select name="creator_id" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white" required>
|
||||
@foreach($creators as $creator)
|
||||
<option value="{{ $creator->id }}" @selected(old('creator_id', $story->creator_id ?? '') == $creator->id)>{{ $creator->username }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-200">Status</label>
|
||||
<select name="status" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white" required>
|
||||
@foreach(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'] as $status)
|
||||
<option value="{{ $status }}" @selected(old('status', $story->status ?? 'draft') === $status)>{{ ucfirst($status) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-200">Title</label>
|
||||
<input name="title" value="{{ old('title', $story->title ?? '') }}" required class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-200">Cover image URL</label>
|
||||
<input name="cover_image" value="{{ old('cover_image', $story->cover_image ?? '') }}" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-200">Excerpt</label>
|
||||
<textarea name="excerpt" rows="3" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white">{{ old('excerpt', $story->excerpt ?? '') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-200">Story type</label>
|
||||
<select name="story_type" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white" required>
|
||||
@foreach(['creator_story', 'tutorial', 'interview', 'project_breakdown', 'announcement', 'resource'] as $type)
|
||||
<option value="{{ $type }}" @selected(old('story_type', $story->story_type ?? 'creator_story') === $type)>{{ str_replace('_', ' ', ucfirst($type)) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-200">Tags</label>
|
||||
<select name="tags[]" multiple class="min-h-24 w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white">
|
||||
@php
|
||||
$selectedTags = collect(old('tags', isset($story) ? $story->tags->pluck('id')->all() : []))->map(fn($id) => (int) $id)->all();
|
||||
@endphp
|
||||
@foreach($tags as $tag)
|
||||
<option value="{{ $tag->id }}" @selected(in_array($tag->id, $selectedTags, true))>{{ $tag->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-200">Content</label>
|
||||
<textarea name="content" rows="14" required class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white">{{ old('content', $story->content ?? '') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="rounded-lg border border-sky-500/40 bg-sky-500/10 px-4 py-2 text-sky-200">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,46 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-6xl px-4 py-8">
|
||||
<div class="mb-5 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-100">Stories Review Queue</h1>
|
||||
<p class="text-sm text-gray-300">Pending creator stories waiting for moderation.</p>
|
||||
</div>
|
||||
<a href="{{ route('admin.stories.index') }}" class="rounded-lg border border-gray-600 px-3 py-2 text-sm text-gray-200">All stories</a>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-700 bg-gray-900">
|
||||
<table class="min-w-full divide-y divide-gray-700 text-sm">
|
||||
<thead class="bg-gray-800 text-gray-300">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Story</th>
|
||||
<th class="px-4 py-3 text-left">Creator</th>
|
||||
<th class="px-4 py-3 text-left">Submitted</th>
|
||||
<th class="px-4 py-3 text-left">Status</th>
|
||||
<th class="px-4 py-3 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-800 text-gray-200">
|
||||
@forelse($stories as $story)
|
||||
<tr>
|
||||
<td class="px-4 py-3">{{ $story->title }}</td>
|
||||
<td class="px-4 py-3">{{ $story->creator?->username ?? 'n/a' }}</td>
|
||||
<td class="px-4 py-3">{{ optional($story->submitted_for_review_at)->diffForHumans() ?? optional($story->updated_at)->diffForHumans() }}</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full border border-amber-500/40 px-2 py-1 text-xs text-amber-200">{{ $story->status }}</span></td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="{{ route('admin.stories.show', ['story' => $story->id]) }}" class="text-sky-300 hover:text-sky-200">Review</a>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-6 text-center text-gray-400">No stories pending review.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $stories->links() }}</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,49 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-6xl px-4 py-8">
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-semibold text-gray-100">Review Story</h1>
|
||||
<a href="{{ route('admin.stories.review') }}" class="rounded-lg border border-gray-600 px-3 py-2 text-sm text-gray-200">Back to queue</a>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-12">
|
||||
<article class="lg:col-span-8 rounded-xl border border-gray-700 bg-gray-900/80 p-5">
|
||||
<p class="text-xs uppercase tracking-wide text-gray-400">{{ $story->story_type }}</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold text-white">{{ $story->title }}</h2>
|
||||
<p class="mt-1 text-sm text-gray-300">Creator: @{{ $story->creator?->username ?? 'unknown' }}</p>
|
||||
@if($story->excerpt)
|
||||
<p class="mt-3 text-sm text-gray-200">{{ $story->excerpt }}</p>
|
||||
@endif
|
||||
<div class="prose prose-invert mt-5 max-w-none prose-a:text-sky-300">
|
||||
{!! preg_replace('/<(script|style)\\b[^>]*>.*?<\\/\\1>/is', '', (string) $story->content) !!}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside class="space-y-4 lg:col-span-4">
|
||||
<div class="rounded-xl border border-gray-700 bg-gray-800/70 p-4">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-300">Moderation Actions</h3>
|
||||
<form method="POST" action="{{ route('admin.stories.approve', ['story' => $story->id]) }}" class="mt-3">
|
||||
@csrf
|
||||
<button class="w-full rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200 transition hover:scale-[1.02]">Approve & Publish</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('admin.stories.reject', ['story' => $story->id]) }}" class="mt-3 space-y-2">
|
||||
@csrf
|
||||
<label class="block text-xs uppercase tracking-wide text-gray-400">Rejection feedback</label>
|
||||
<textarea name="reason" rows="4" required class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-white" placeholder="Explain what needs to change..."></textarea>
|
||||
<button class="w-full rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-200 transition hover:scale-[1.02]">Reject Story</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-700 bg-gray-800/70 p-4">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-300">Quick Links</h3>
|
||||
<div class="mt-3 flex flex-col gap-2 text-sm">
|
||||
<a href="{{ route('admin.stories.edit', ['story' => $story->id]) }}" class="rounded-lg border border-gray-600 px-3 py-2 text-gray-200">Edit in admin form</a>
|
||||
<a href="{{ route('creator.stories.preview', ['story' => $story->id]) }}" class="rounded-lg border border-gray-600 px-3 py-2 text-gray-200">Open creator preview</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
68
resources/views/components/dashboard/filter-select.blade.php
Normal file
68
resources/views/components/dashboard/filter-select.blade.php
Normal file
@@ -0,0 +1,68 @@
|
||||
@props([
|
||||
'name',
|
||||
'value' => null,
|
||||
'options' => [],
|
||||
])
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
open: false,
|
||||
value: @js((string) ($value ?? '')),
|
||||
options: @js(collect($options)->map(fn ($option) => [
|
||||
'value' => (string) ($option['value'] ?? ''),
|
||||
'label' => (string) ($option['label'] ?? ''),
|
||||
])->values()->all()),
|
||||
labelFor(selectedValue) {
|
||||
const match = this.options.find((option) => option.value === selectedValue)
|
||||
return match ? match.label : (this.options[0]?.label ?? '')
|
||||
},
|
||||
select(nextValue) {
|
||||
this.value = nextValue
|
||||
this.open = false
|
||||
},
|
||||
}"
|
||||
class="relative"
|
||||
@click.outside="open = false"
|
||||
@keydown.escape.window="open = false"
|
||||
>
|
||||
<input type="hidden" name="{{ $name }}" x-model="value">
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="open = !open"
|
||||
class="flex w-full items-center justify-between gap-3 rounded-xl border border-white/[0.08] bg-black/20 px-4 py-3 text-left text-sm text-white transition-colors hover:border-white/[0.14] focus:border-sky-400/40 focus:outline-none"
|
||||
:aria-expanded="open.toString()"
|
||||
>
|
||||
<span class="truncate" x-text="labelFor(value)"></span>
|
||||
<i class="fa-solid fa-chevron-down text-xs text-white/40 transition-transform" :class="open ? 'rotate-180' : ''"></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-cloak
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 -translate-y-1"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-1"
|
||||
class="absolute left-0 right-0 z-50 mt-2 overflow-hidden rounded-2xl border border-white/[0.08] bg-slate-950/95 shadow-[0_24px_70px_rgba(0,0,0,0.45)] backdrop-blur"
|
||||
style="display: none;"
|
||||
>
|
||||
<div class="p-2">
|
||||
<template x-for="option in options" :key="option.value">
|
||||
<button
|
||||
type="button"
|
||||
@click="select(option.value)"
|
||||
class="flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left text-sm transition-colors"
|
||||
:class="value === option.value
|
||||
? 'bg-sky-400/20 text-white'
|
||||
: 'text-white/75 hover:bg-white/[0.06] hover:text-white'"
|
||||
>
|
||||
<span x-text="option.label"></span>
|
||||
<i x-show="value === option.value" class="fa-solid fa-check text-xs text-sky-200"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,27 +1,288 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto py-8 max-w-3xl">
|
||||
<h1 class="text-2xl font-semibold mb-6">My Followers</h1>
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="pt-0">
|
||||
<div class="mx-auto w-full">
|
||||
<div class="relative min-h-[calc(120vh-64px)] md:min-h-[calc(100vh-64px)]">
|
||||
<main class="w-full">
|
||||
<x-nova-page-header
|
||||
section="Dashboard"
|
||||
title="People Following Me"
|
||||
icon="fa-users"
|
||||
:breadcrumbs="collect([
|
||||
(object) ['name' => 'Dashboard', 'url' => '/dashboard'],
|
||||
(object) ['name' => 'Followers', 'url' => route('dashboard.followers')],
|
||||
])"
|
||||
description="A clearer view of who follows you, who you follow back, and who still needs a response."
|
||||
actionsClass="lg:pt-8"
|
||||
>
|
||||
<x-slot name="actions">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a href="{{ route('dashboard.following') }}"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white">
|
||||
<i class="fa-solid fa-user-check text-xs"></i>
|
||||
People I follow
|
||||
</a>
|
||||
<a href="{{ route('discover.trending') }}"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-sky-400/30 bg-sky-400/10 px-4 py-2 text-sm font-medium text-sky-100 transition-colors hover:border-sky-300/40 hover:bg-sky-400/15">
|
||||
<i class="fa-solid fa-compass text-xs"></i>
|
||||
Discover creators
|
||||
</a>
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-nova-page-header>
|
||||
|
||||
<section class="px-6 pb-16 pt-8 md:px-10">
|
||||
@php
|
||||
$newestFollower = $followers->getCollection()->first();
|
||||
$newestFollowerName = $newestFollower ? ($newestFollower->name ?: $newestFollower->uname) : null;
|
||||
$latestFollowedAt = $newestFollower && !empty($newestFollower->followed_at)
|
||||
? \Carbon\Carbon::parse($newestFollower->followed_at)->diffForHumans()
|
||||
: null;
|
||||
@endphp
|
||||
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
|
||||
<p class="text-xs uppercase tracking-widest text-white/35">Total followers</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{{ number_format($summary['total_followers']) }}</p>
|
||||
<p class="mt-2 text-xs text-white/40">People currently following your profile</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
|
||||
<p class="text-xs uppercase tracking-widest text-white/35">Followed back</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{{ number_format($summary['following_back']) }}</p>
|
||||
<p class="mt-2 text-xs text-white/40">Followers you also follow</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
|
||||
<p class="text-xs uppercase tracking-widest text-white/35">Not followed back</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{{ number_format($summary['not_followed']) }}</p>
|
||||
<p class="mt-2 text-xs text-white/40">Followers still waiting on your follow-back</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-sky-400/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.12),rgba(255,255,255,0.03))] p-5 shadow-[0_16px_60px_rgba(14,165,233,0.08)]">
|
||||
<p class="text-xs uppercase tracking-widest text-sky-100/60">Newest follower</p>
|
||||
<p class="mt-2 truncate text-xl font-semibold text-white">{{ $newestFollowerName ?? '—' }}</p>
|
||||
<p class="mt-2 text-xs text-sky-50/60">{{ $latestFollowedAt ?? 'No recent follower activity' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-4 shadow-[0_16px_60px_rgba(0,0,0,0.12)]">
|
||||
@php
|
||||
$sortOptions = [
|
||||
['value' => 'recent', 'label' => 'Most recent'],
|
||||
['value' => 'oldest', 'label' => 'Oldest first'],
|
||||
['value' => 'name', 'label' => 'Name A-Z'],
|
||||
['value' => 'uploads', 'label' => 'Most uploads'],
|
||||
['value' => 'followers', 'label' => 'Most followers'],
|
||||
];
|
||||
|
||||
$relationshipOptions = [
|
||||
['value' => 'all', 'label' => 'All followers'],
|
||||
['value' => 'following-back', 'label' => 'I follow back'],
|
||||
['value' => 'not-followed', 'label' => 'Not followed back'],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<form method="GET" action="{{ route('dashboard.followers') }}" class="grid gap-4 lg:grid-cols-[minmax(0,1.35fr)_220px_220px_auto] lg:items-end">
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-xs font-semibold uppercase tracking-widest text-white/35">Search follower</span>
|
||||
<div class="relative">
|
||||
<i class="fa-solid fa-magnifying-glass pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-xs text-white/30"></i>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
value="{{ $filters['q'] }}"
|
||||
placeholder="Search by username or display name"
|
||||
class="w-full rounded-xl border border-white/[0.08] bg-black/20 py-3 pl-10 pr-4 text-sm text-white placeholder:text-white/30 focus:border-sky-400/40 focus:outline-none"
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-xs font-semibold uppercase tracking-widest text-white/35">Sort by</span>
|
||||
<x-dashboard.filter-select
|
||||
name="sort"
|
||||
:value="$filters['sort']"
|
||||
:options="$sortOptions"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-xs font-semibold uppercase tracking-widest text-white/35">Relationship</span>
|
||||
<x-dashboard.filter-select
|
||||
name="relationship"
|
||||
:value="$filters['relationship']"
|
||||
:options="$relationshipOptions"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap gap-3 lg:justify-end">
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-xl border border-sky-400/30 bg-sky-400/10 px-4 py-3 text-sm font-medium text-sky-100 transition-colors hover:border-sky-300/40 hover:bg-sky-400/15">
|
||||
<i class="fa-solid fa-sliders text-xs"></i>
|
||||
Apply
|
||||
</button>
|
||||
@if($filters['q'] !== '' || $filters['sort'] !== 'recent' || $filters['relationship'] !== 'all')
|
||||
<a href="{{ route('dashboard.followers') }}" class="inline-flex items-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white">
|
||||
<i class="fa-solid fa-rotate-left text-xs"></i>
|
||||
Reset
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-white/50">{{ number_format($followers->count()) }} visible on this page</span>
|
||||
@if($filters['q'] !== '')
|
||||
<span class="inline-flex items-center rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-sky-100/80">Search: {{ $filters['q'] }}</span>
|
||||
@endif
|
||||
@if($filters['relationship'] !== 'all')
|
||||
<span class="inline-flex items-center rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-emerald-100/80">
|
||||
{{ $filters['relationship'] === 'following-back' ? 'Following back only' : 'Not followed back' }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($followers->isEmpty())
|
||||
<p class="text-sm text-gray-500">You have no followers yet.</p>
|
||||
<div class="rounded-2xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center shadow-[0_20px_80px_rgba(0,0,0,0.18)]">
|
||||
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-white/[0.08] bg-white/[0.04] text-white/60">
|
||||
<i class="fa-solid fa-users text-lg"></i>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-white">No followers match these filters</h2>
|
||||
<p class="mx-auto mt-2 max-w-xl text-sm text-white/45">
|
||||
Try resetting the filters, or discover more creators and activity to grow your audience.
|
||||
</p>
|
||||
<div class="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<a href="{{ route('dashboard.followers') }}" class="inline-flex items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white">
|
||||
<i class="fa-solid fa-rotate-left text-xs"></i>
|
||||
Reset filters
|
||||
</a>
|
||||
<a href="{{ route('discover.trending') }}" class="inline-flex items-center gap-2 rounded-lg border border-sky-400/30 bg-sky-400/10 px-4 py-2 text-sm font-medium text-sky-100 transition-colors hover:border-sky-300/40 hover:bg-sky-400/15">
|
||||
<i class="fa-solid fa-compass text-xs"></i>
|
||||
Explore creators
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
<div class="grid gap-5 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
@foreach($followers as $f)
|
||||
<a href="{{ $f->profile_url }}" class="flex items-center gap-4 p-3 rounded-lg hover:bg-white/5 transition">
|
||||
<img src="{{ $f->avatar_url }}" alt="{{ $f->uname }}" class="w-10 h-10 rounded-full object-cover">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{{ $f->uname }}</div>
|
||||
<div class="text-xs text-gray-500">{{ $f->uploads }} uploads · followed {{ \Carbon\Carbon::parse($f->followed_at)->diffForHumans() }}</div>
|
||||
@php
|
||||
$displayName = $f->name ?: $f->uname;
|
||||
$profileUsername = strtolower((string) ($f->username ?? ''));
|
||||
@endphp
|
||||
<article class="group overflow-hidden rounded-2xl border border-white/[0.06] bg-[linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0.02))] shadow-[0_18px_70px_rgba(0,0,0,0.14)] transition-all hover:-translate-y-0.5 hover:border-white/[0.10] hover:shadow-[0_24px_90px_rgba(0,0,0,0.20)]">
|
||||
<div class="flex items-start justify-between gap-4 border-b border-white/[0.05] px-5 py-5">
|
||||
<a href="{{ $f->profile_url }}" class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
<img src="{{ $f->avatar_url }}"
|
||||
alt="{{ $displayName }}"
|
||||
class="h-14 w-14 flex-shrink-0 rounded-2xl object-cover ring-1 ring-white/[0.10]"
|
||||
onerror="this.src='https://files.skinbase.org/default/avatar_default.webp'">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="truncate text-base font-semibold text-white/95 group-hover:text-white">{{ $displayName }}</h2>
|
||||
<span class="inline-flex items-center rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wide text-emerald-200">
|
||||
Follows you
|
||||
</span>
|
||||
@if($f->is_following_back)
|
||||
<span class="inline-flex items-center rounded-full border border-sky-400/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wide text-sky-100">
|
||||
Mutual
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@if(!empty($f->username))
|
||||
<div class="mt-1 truncate text-xs text-white/35">{{ '@' . $f->username }}</div>
|
||||
@endif
|
||||
<div class="mt-2 text-xs text-white/45">
|
||||
Followed you {{ !empty($f->followed_at) ? \Carbon\Carbon::parse($f->followed_at)->diffForHumans() : 'recently' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
{{ $followers->links() }}
|
||||
<div class="shrink-0">
|
||||
@if(!empty($profileUsername))
|
||||
<div x-data="{
|
||||
following: {{ $f->is_following_back ? 'true' : 'false' }},
|
||||
count: {{ (int) $f->followers_count }},
|
||||
loading: false,
|
||||
hovering: false,
|
||||
async toggle() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('{{ route('profile.follow', ['username' => $profileUsername]) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok) {
|
||||
this.following = d.following;
|
||||
this.count = d.follower_count;
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
}">
|
||||
<button @click="toggle"
|
||||
@mouseenter="hovering = true"
|
||||
@mouseleave="hovering = false"
|
||||
:disabled="loading"
|
||||
class="inline-flex items-center gap-2 rounded-xl border px-3.5 py-2 text-sm font-medium transition-all"
|
||||
:class="following
|
||||
? 'border-emerald-400/25 bg-emerald-400/10 text-emerald-100 hover:border-rose-400/30 hover:bg-rose-400/10 hover:text-rose-100'
|
||||
: 'border-sky-400/30 bg-sky-400/10 text-sky-100 hover:bg-sky-400/15 hover:border-sky-300/40'">
|
||||
<i class="fa-solid fa-fw text-xs"
|
||||
:class="loading ? 'fa-circle-notch fa-spin' : (following ? (hovering ? 'fa-user-minus' : 'fa-user-check') : 'fa-user-plus')"></i>
|
||||
<span x-text="following ? (hovering ? 'Unfollow' : 'Following back') : 'Follow back'"></span>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 px-5 py-4 sm:grid-cols-3">
|
||||
<div class="rounded-xl border border-white/[0.06] bg-black/10 p-3">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-white/35">Uploads</p>
|
||||
<p class="mt-1 text-lg font-semibold text-white">{{ number_format((int) $f->uploads) }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/[0.06] bg-black/10 p-3">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-white/35">Followers</p>
|
||||
<p class="mt-1 text-lg font-semibold text-white">{{ number_format((int) $f->followers_count) }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/[0.06] bg-black/10 p-3">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-white/35">Relationship</p>
|
||||
<p class="mt-1 text-sm font-semibold {{ $f->is_following_back ? 'text-emerald-200' : 'text-amber-200' }}">
|
||||
{{ $f->is_following_back ? 'Mutual follow' : 'Follower only' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 border-t border-white/[0.05] px-5 py-4 text-xs text-white/45">
|
||||
<span>
|
||||
{{ $f->is_following_back && !empty($f->followed_back_at)
|
||||
? 'You followed back ' . \Carbon\Carbon::parse($f->followed_back_at)->diffForHumans()
|
||||
: 'Not followed back yet' }}
|
||||
</span>
|
||||
<a href="{{ $f->profile_url }}" class="inline-flex items-center gap-2 font-medium text-white/70 transition-colors hover:text-white">
|
||||
View profile
|
||||
<i class="fa-solid fa-arrow-right text-[10px]"></i>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-center">
|
||||
{{ $followers->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -18,84 +18,262 @@
|
||||
actionsClass="lg:pt-8"
|
||||
>
|
||||
<x-slot name="actions">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a href="{{ route('dashboard.followers') }}"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white">
|
||||
<i class="fa-solid fa-users text-xs"></i>
|
||||
My followers
|
||||
</a>
|
||||
<a href="{{ route('discover.trending') }}"
|
||||
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors">
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-sky-400/30 bg-sky-400/10 px-4 py-2 text-sm font-medium text-sky-100 transition-colors hover:border-sky-300/40 hover:bg-sky-400/15">
|
||||
<i class="fa-solid fa-compass text-xs"></i>
|
||||
Discover creators
|
||||
</a>
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-nova-page-header>
|
||||
|
||||
<section class="px-6 pb-16 pt-8 md:px-10">
|
||||
|
||||
@if($following->isEmpty())
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<p class="text-white/40 text-sm">You are not following anyone yet.</p>
|
||||
<a href="{{ route('discover.trending') }}" class="mt-4 inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors">
|
||||
<i class="fa-solid fa-compass text-xs"></i>
|
||||
Start following creators
|
||||
</a>
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$firstFollow = $following->getCollection()->first();
|
||||
$latestFollowedAt = $firstFollow && !empty($firstFollow->followed_at)
|
||||
? \Carbon\Carbon::parse($firstFollow->followed_at)->diffForHumans()
|
||||
: null;
|
||||
$latestFollowedName = $firstFollow ? ($firstFollow->name ?: $firstFollow->uname) : null;
|
||||
@endphp
|
||||
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div class="rounded-xl border border-white/[0.08] bg-white/[0.03] p-4">
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
|
||||
<p class="text-xs uppercase tracking-widest text-white/35">Following</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($following->total()) }}</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{{ number_format($summary['total_following']) }}</p>
|
||||
<p class="mt-2 text-xs text-white/40">People you currently follow</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/[0.08] bg-white/[0.03] p-4">
|
||||
<p class="text-xs uppercase tracking-widest text-white/35">On this page</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($following->count()) }}</p>
|
||||
<div class="rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
|
||||
<p class="text-xs uppercase tracking-widest text-white/35">Mutual follows</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{{ number_format($summary['mutual']) }}</p>
|
||||
<p class="mt-2 text-xs text-white/40">People who follow you back</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/[0.08] bg-white/[0.03] p-4 sm:col-span-2 xl:col-span-1">
|
||||
<p class="text-xs uppercase tracking-widest text-white/35">Last followed</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-white">{{ $latestFollowedAt ?? '—' }}</p>
|
||||
<div class="rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
|
||||
<p class="text-xs uppercase tracking-widest text-white/35">One-way follows</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{{ number_format($summary['one_way']) }}</p>
|
||||
<p class="mt-2 text-xs text-white/40">People you follow who do not follow back</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-sky-400/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.12),rgba(255,255,255,0.03))] p-5 shadow-[0_16px_60px_rgba(14,165,233,0.08)]">
|
||||
<p class="text-xs uppercase tracking-widest text-sky-100/60">Latest followed</p>
|
||||
<p class="mt-2 truncate text-xl font-semibold text-white">{{ $latestFollowedName ?? '—' }}</p>
|
||||
<p class="mt-2 text-xs text-sky-50/60">{{ $latestFollowedAt ?? 'No recent follow activity' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/[0.06] overflow-hidden">
|
||||
<div class="grid grid-cols-[1fr_auto_auto] items-center gap-4 px-5 py-3 bg-white/[0.03] border-b border-white/[0.06]">
|
||||
<span class="text-xs font-semibold uppercase tracking-widest text-white/30">Creator</span>
|
||||
<span class="hidden sm:block text-xs font-semibold uppercase tracking-widest text-white/30 text-right">Stats</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-widest text-white/30 text-right">Followed</span>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-white/[0.04]">
|
||||
@foreach($following as $f)
|
||||
<div class="mb-6 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-4 shadow-[0_16px_60px_rgba(0,0,0,0.12)]">
|
||||
@php
|
||||
$displayName = $f->name ?: $f->uname;
|
||||
$sortOptions = [
|
||||
['value' => 'recent', 'label' => 'Most recent'],
|
||||
['value' => 'oldest', 'label' => 'Oldest first'],
|
||||
['value' => 'name', 'label' => 'Name A-Z'],
|
||||
['value' => 'uploads', 'label' => 'Most uploads'],
|
||||
['value' => 'followers', 'label' => 'Most followers'],
|
||||
];
|
||||
|
||||
$relationshipOptions = [
|
||||
['value' => 'all', 'label' => 'Everyone I follow'],
|
||||
['value' => 'mutual', 'label' => 'Mutual follows'],
|
||||
['value' => 'one-way', 'label' => 'Not following me back'],
|
||||
];
|
||||
@endphp
|
||||
<a href="{{ $f->profile_url }}"
|
||||
class="grid grid-cols-[1fr_auto_auto] items-center gap-4 px-5 py-4 hover:bg-white/[0.03] transition-colors">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<img src="{{ $f->avatar_url }}"
|
||||
alt="{{ $displayName }}"
|
||||
class="w-11 h-11 rounded-full object-cover flex-shrink-0 ring-1 ring-white/[0.10]"
|
||||
onerror="this.src='https://files.skinbase.org/default/avatar_default.webp'">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold text-white/90">{{ $displayName }}</div>
|
||||
@if(!empty($f->username))
|
||||
<div class="truncate text-xs text-white/35">{{ '@' . $f->username }}</div>
|
||||
|
||||
<form method="GET" action="{{ route('dashboard.following') }}" class="grid gap-4 lg:grid-cols-[minmax(0,1.35fr)_220px_220px_auto] lg:items-end">
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-xs font-semibold uppercase tracking-widest text-white/35">Search creator</span>
|
||||
<div class="relative">
|
||||
<i class="fa-solid fa-magnifying-glass pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-xs text-white/30"></i>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
value="{{ $filters['q'] }}"
|
||||
placeholder="Search by username or display name"
|
||||
class="w-full rounded-xl border border-white/[0.08] bg-black/20 py-3 pl-10 pr-4 text-sm text-white placeholder:text-white/30 focus:border-sky-400/40 focus:outline-none"
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-xs font-semibold uppercase tracking-widest text-white/35">Sort by</span>
|
||||
<x-dashboard.filter-select
|
||||
name="sort"
|
||||
:value="$filters['sort']"
|
||||
:options="$sortOptions"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-xs font-semibold uppercase tracking-widest text-white/35">Relationship</span>
|
||||
<x-dashboard.filter-select
|
||||
name="relationship"
|
||||
:value="$filters['relationship']"
|
||||
:options="$relationshipOptions"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap gap-3 lg:justify-end">
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-xl border border-sky-400/30 bg-sky-400/10 px-4 py-3 text-sm font-medium text-sky-100 transition-colors hover:border-sky-300/40 hover:bg-sky-400/15">
|
||||
<i class="fa-solid fa-sliders text-xs"></i>
|
||||
Apply
|
||||
</button>
|
||||
@if($filters['q'] !== '' || $filters['sort'] !== 'recent' || $filters['relationship'] !== 'all')
|
||||
<a href="{{ route('dashboard.following') }}" class="inline-flex items-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white">
|
||||
<i class="fa-solid fa-rotate-left text-xs"></i>
|
||||
Reset
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-white/50">{{ number_format($following->count()) }} visible on this page</span>
|
||||
@if($filters['q'] !== '')
|
||||
<span class="inline-flex items-center rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-sky-100/80">Search: {{ $filters['q'] }}</span>
|
||||
@endif
|
||||
@if($filters['relationship'] !== 'all')
|
||||
<span class="inline-flex items-center rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-emerald-100/80">
|
||||
{{ $filters['relationship'] === 'mutual' ? 'Mutual follows only' : 'Not following you back' }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:block text-right text-xs text-white/55">
|
||||
{{ number_format((int) $f->uploads) }} uploads · {{ number_format((int) $f->followers_count) }} followers
|
||||
@if($following->isEmpty())
|
||||
<div class="rounded-2xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center shadow-[0_20px_80px_rgba(0,0,0,0.18)]">
|
||||
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-white/[0.08] bg-white/[0.04] text-white/60">
|
||||
<i class="fa-solid fa-user-group text-lg"></i>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-white">No followed creators match these filters</h2>
|
||||
<p class="mx-auto mt-2 max-w-xl text-sm text-white/45">
|
||||
Try resetting the filters, or discover more creators to build a stronger network.
|
||||
</p>
|
||||
<div class="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<a href="{{ route('dashboard.following') }}" class="inline-flex items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white">
|
||||
<i class="fa-solid fa-rotate-left text-xs"></i>
|
||||
Reset filters
|
||||
</a>
|
||||
<a href="{{ route('discover.trending') }}" class="inline-flex items-center gap-2 rounded-lg border border-sky-400/30 bg-sky-400/10 px-4 py-2 text-sm font-medium text-sky-100 transition-colors hover:border-sky-300/40 hover:bg-sky-400/15">
|
||||
<i class="fa-solid fa-compass text-xs"></i>
|
||||
Discover creators
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="grid gap-5 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
@foreach($following as $f)
|
||||
@php
|
||||
$displayName = $f->name ?: $f->uname;
|
||||
$profileUsername = strtolower((string) ($f->username ?? ''));
|
||||
@endphp
|
||||
<article
|
||||
x-data="{
|
||||
following: true,
|
||||
count: {{ (int) $f->followers_count }},
|
||||
loading: false,
|
||||
hovering: false,
|
||||
async toggle() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('{{ route('profile.follow', ['username' => $profileUsername]) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok) {
|
||||
this.following = d.following;
|
||||
this.count = d.follower_count;
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
}"
|
||||
:class="following ? 'opacity-100' : 'opacity-50'"
|
||||
class="group overflow-hidden rounded-2xl border border-white/[0.06] bg-[linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0.02))] shadow-[0_18px_70px_rgba(0,0,0,0.14)] transition-all hover:-translate-y-0.5 hover:border-white/[0.10] hover:shadow-[0_24px_90px_rgba(0,0,0,0.20)]">
|
||||
<div class="flex items-start justify-between gap-4 border-b border-white/[0.05] px-5 py-5">
|
||||
<a href="{{ $f->profile_url }}" class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
<img src="{{ $f->avatar_url }}"
|
||||
alt="{{ $displayName }}"
|
||||
class="h-14 w-14 flex-shrink-0 rounded-2xl object-cover ring-1 ring-white/[0.10]"
|
||||
onerror="this.src='https://files.skinbase.org/default/avatar_default.webp'">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="truncate text-base font-semibold text-white/95 group-hover:text-white">{{ $displayName }}</h2>
|
||||
<span class="inline-flex items-center rounded-full border border-sky-400/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wide text-sky-100">
|
||||
You follow
|
||||
</span>
|
||||
@if($f->follows_you)
|
||||
<span class="inline-flex items-center rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wide text-emerald-200">
|
||||
Mutual
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@if(!empty($f->username))
|
||||
<div class="mt-1 truncate text-xs text-white/35">{{ '@' . $f->username }}</div>
|
||||
@endif
|
||||
<div class="mt-2 text-xs text-white/45">
|
||||
You followed {{ !empty($f->followed_at) ? \Carbon\Carbon::parse($f->followed_at)->diffForHumans() : 'recently' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right text-xs text-white/45 whitespace-nowrap">
|
||||
{{ !empty($f->followed_at) ? \Carbon\Carbon::parse($f->followed_at)->diffForHumans() : '—' }}
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
|
||||
<div class="shrink-0">
|
||||
@if(!empty($profileUsername))
|
||||
<div>
|
||||
<button @click="toggle"
|
||||
@mouseenter="hovering = true"
|
||||
@mouseleave="hovering = false"
|
||||
:disabled="loading"
|
||||
class="inline-flex items-center gap-2 rounded-xl border border-emerald-400/25 bg-emerald-400/10 px-3.5 py-2 text-sm font-medium text-emerald-100 transition-all hover:border-rose-400/30 hover:bg-rose-400/10 hover:text-rose-100"
|
||||
:class="!following ? 'border-white/[0.08] bg-white/[0.04] text-white/60' : ''">
|
||||
<i class="fa-solid fa-fw text-xs"
|
||||
:class="loading ? 'fa-circle-notch fa-spin' : (following ? (hovering ? 'fa-user-minus' : 'fa-user-check') : 'fa-user-plus')"></i>
|
||||
<span x-text="following ? (hovering ? 'Unfollow' : 'Following') : 'Follow back'"></span>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 px-5 py-4 sm:grid-cols-3">
|
||||
<div class="rounded-xl border border-white/[0.06] bg-black/10 p-3">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-white/35">Uploads</p>
|
||||
<p class="mt-1 text-lg font-semibold text-white">{{ number_format((int) $f->uploads) }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/[0.06] bg-black/10 p-3">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-white/35">Followers</p>
|
||||
<p class="mt-1 text-lg font-semibold text-white" x-text="typeof count !== 'undefined' ? Number(count).toLocaleString() : '{{ number_format((int) $f->followers_count) }}'"></p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/[0.06] bg-black/10 p-3">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-white/35">Relationship</p>
|
||||
<p class="mt-1 text-sm font-semibold {{ $f->follows_you ? 'text-emerald-200' : 'text-amber-200' }}">
|
||||
{{ $f->follows_you ? 'Mutual follow' : 'You follow them' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 border-t border-white/[0.05] px-5 py-4 text-xs text-white/45">
|
||||
<span>
|
||||
{{ $f->follows_you && !empty($f->follows_you_at)
|
||||
? 'They followed you ' . \Carbon\Carbon::parse($f->follows_you_at)->diffForHumans()
|
||||
: 'They do not follow you back yet' }}
|
||||
</span>
|
||||
<a href="{{ $f->profile_url }}" class="inline-flex items-center gap-2 font-medium text-white/70 transition-colors hover:text-white">
|
||||
View profile
|
||||
<i class="fa-solid fa-arrow-right text-[10px]"></i>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-center">
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
use App\Notifications\StoryStatusNotification;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
function createPendingReviewStory(User $creator): Story
|
||||
{
|
||||
return Story::query()->create([
|
||||
'creator_id' => $creator->id,
|
||||
'title' => 'Pending Story ' . Str::random(6),
|
||||
'slug' => 'pending-story-' . Str::lower(Str::random(8)),
|
||||
'content' => '<p>Pending review content</p>',
|
||||
'story_type' => 'creator_story',
|
||||
'status' => 'pending_review',
|
||||
'submitted_for_review_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('non moderator cannot access admin stories review queue', function () {
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.stories.review'))
|
||||
->assertStatus(403);
|
||||
});
|
||||
|
||||
it('admin can approve a pending story and notify creator', function () {
|
||||
Notification::fake();
|
||||
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$creator = User::factory()->create();
|
||||
|
||||
$story = createPendingReviewStory($creator);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->post(route('admin.stories.approve', ['story' => $story->id]));
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$story->refresh();
|
||||
|
||||
expect($story->status)->toBe('published');
|
||||
expect($story->reviewed_by_id)->toBe($admin->id);
|
||||
expect($story->reviewed_at)->not->toBeNull();
|
||||
expect($story->published_at)->not->toBeNull();
|
||||
|
||||
Notification::assertSentTo($creator, StoryStatusNotification::class);
|
||||
});
|
||||
|
||||
it('moderator can reject a pending story with reason and notify creator', function () {
|
||||
Notification::fake();
|
||||
|
||||
$moderator = User::factory()->create(['role' => 'moderator']);
|
||||
$creator = User::factory()->create();
|
||||
|
||||
$story = createPendingReviewStory($creator);
|
||||
|
||||
$response = $this->actingAs($moderator)
|
||||
->post(route('admin.stories.reject', ['story' => $story->id]), [
|
||||
'reason' => 'Please remove promotional external links and resubmit.',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$story->refresh();
|
||||
|
||||
expect($story->status)->toBe('rejected');
|
||||
expect($story->reviewed_by_id)->toBe($moderator->id);
|
||||
expect($story->reviewed_at)->not->toBeNull();
|
||||
expect($story->rejected_reason)->toContain('promotional external links');
|
||||
|
||||
Notification::assertSentTo($creator, StoryStatusNotification::class);
|
||||
});
|
||||
|
||||
it('admin approval records a story publish activity event', function () {
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$creator = User::factory()->create();
|
||||
|
||||
$story = createPendingReviewStory($creator);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.stories.approve', ['story' => $story->id]))
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('activity_events', [
|
||||
'actor_id' => $creator->id,
|
||||
'type' => ActivityEvent::TYPE_UPLOAD,
|
||||
'target_type' => ActivityEvent::TARGET_STORY,
|
||||
'target_id' => $story->id,
|
||||
]);
|
||||
});
|
||||
173
tests/e2e/dashboard-mobile-layout.spec.ts
Normal file
173
tests/e2e/dashboard-mobile-layout.spec.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
type DashboardFixture = {
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
}
|
||||
|
||||
const VITE_MANIFEST_PATH = path.join(process.cwd(), 'public', 'build', 'manifest.json')
|
||||
|
||||
function ensureCompiledAssets() {
|
||||
if (existsSync(VITE_MANIFEST_PATH)) {
|
||||
return
|
||||
}
|
||||
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
|
||||
execFileSync(npmCommand, ['run', 'build'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
}
|
||||
|
||||
function seedDashboardFixture(): DashboardFixture {
|
||||
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
|
||||
const email = `e2e-dashboard-mobile-${token}@example.test`
|
||||
const username = `e2em${token}`.slice(0, 20)
|
||||
|
||||
const script = [
|
||||
'use App\\Models\\User;',
|
||||
'use Illuminate\\Support\\Facades\\Hash;',
|
||||
`$user = User::updateOrCreate(['email' => '${email}'], [`,
|
||||
" 'name' => 'E2E Mobile Dashboard User',",
|
||||
` 'username' => '${username}',`,
|
||||
" 'onboarding_step' => 'complete',",
|
||||
" 'email_verified_at' => now(),",
|
||||
" 'is_active' => 1,",
|
||||
" 'password' => Hash::make('password'),",
|
||||
']);',
|
||||
"echo json_encode(['email' => $user->email, 'password' => 'password', 'username' => $user->username]);",
|
||||
].join(' ')
|
||||
|
||||
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
const lines = raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{') && line.endsWith('}'))
|
||||
if (!jsonLine) {
|
||||
throw new Error(`Unable to parse fixture JSON from tinker output: ${raw}`)
|
||||
}
|
||||
|
||||
return JSON.parse(jsonLine) as DashboardFixture
|
||||
}
|
||||
|
||||
function resetBotProtectionState() {
|
||||
const script = [
|
||||
'use Illuminate\\Support\\Facades\\DB;',
|
||||
'use Illuminate\\Support\\Facades\\Schema;',
|
||||
"foreach (['forum_bot_logs', 'forum_bot_ip_blacklist', 'forum_bot_device_fingerprints', 'forum_bot_behavior_profiles'] as $table) {",
|
||||
' if (Schema::hasTable($table)) {',
|
||||
' DB::table($table)->delete();',
|
||||
' }',
|
||||
'}',
|
||||
"echo 'ok';",
|
||||
].join(' ')
|
||||
|
||||
execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
}
|
||||
|
||||
async function login(page: Page, fixture: DashboardFixture) {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
await page.goto('/login')
|
||||
const emailField = page.locator('input[name="email"]')
|
||||
const internalServerError = page.getByText('Internal Server Error')
|
||||
|
||||
await Promise.race([
|
||||
emailField.waitFor({ state: 'visible', timeout: 8000 }),
|
||||
internalServerError.waitFor({ state: 'visible', timeout: 8000 }),
|
||||
])
|
||||
|
||||
if ((await internalServerError.isVisible().catch(() => false)) && !(await emailField.isVisible().catch(() => false))) {
|
||||
throw new Error('Dashboard mobile layout login failed because the login page returned an internal server error.')
|
||||
}
|
||||
|
||||
await emailField.fill(fixture.email)
|
||||
await page.locator('input[name="password"]').fill(fixture.password)
|
||||
await page.getByRole('button', { name: 'Sign In' }).click()
|
||||
|
||||
try {
|
||||
await page.waitForURL((url) => url.pathname !== '/login', { timeout: 8000, waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('button', { name: /E2E Mobile Dashboard User/i })).toBeVisible()
|
||||
return
|
||||
} catch {
|
||||
const suspiciousActivity = page.getByText('Suspicious activity detected.')
|
||||
if (attempt === 0 && (await suspiciousActivity.isVisible().catch(() => false))) {
|
||||
resetBotProtectionState()
|
||||
continue
|
||||
}
|
||||
|
||||
throw new Error('Dashboard mobile layout login failed before reaching an authenticated page.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function expectNoHorizontalOverflow(page: Page) {
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}))
|
||||
|
||||
expect(
|
||||
dimensions.scrollWidth,
|
||||
`Expected no horizontal overflow, but scrollWidth=${dimensions.scrollWidth} and clientWidth=${dimensions.clientWidth}`
|
||||
).toBeLessThanOrEqual(dimensions.clientWidth + 1)
|
||||
}
|
||||
|
||||
test.describe('Dashboard mobile layout', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
test.use({ viewport: { width: 390, height: 844 } })
|
||||
|
||||
let fixture: DashboardFixture
|
||||
|
||||
test.beforeAll(() => {
|
||||
ensureCompiledAssets()
|
||||
fixture = seedDashboardFixture()
|
||||
})
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetBotProtectionState()
|
||||
})
|
||||
|
||||
test('dashboard home fits mobile width', async ({ page }) => {
|
||||
await login(page, fixture)
|
||||
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await expectNoHorizontalOverflow(page)
|
||||
await expect(page.getByRole('heading', { name: 'Your dashboard snapshot' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('followers page fits mobile width', async ({ page }) => {
|
||||
await login(page, fixture)
|
||||
await page.goto('/dashboard/followers', { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('heading', { name: 'People Following Me' })).toBeVisible()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await expectNoHorizontalOverflow(page)
|
||||
await expect(page.getByRole('link', { name: /discover creators/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('following page fits mobile width', async ({ page }) => {
|
||||
await login(page, fixture)
|
||||
await page.goto('/dashboard/following', { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('heading', { name: 'People I Follow' })).toBeVisible()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await expectNoHorizontalOverflow(page)
|
||||
await expect(page.getByRole('link', { name: /my followers/i })).toBeVisible()
|
||||
})
|
||||
})
|
||||
173
tests/e2e/upload-layout.spec.ts
Normal file
173
tests/e2e/upload-layout.spec.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
type UploadFixture = {
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
}
|
||||
|
||||
const VITE_MANIFEST_PATH = path.join(process.cwd(), 'public', 'build', 'manifest.json')
|
||||
|
||||
function ensureCompiledAssets() {
|
||||
if (existsSync(VITE_MANIFEST_PATH)) {
|
||||
return
|
||||
}
|
||||
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
|
||||
execFileSync(npmCommand, ['run', 'build'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
}
|
||||
|
||||
function seedUploadFixture(): UploadFixture {
|
||||
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
|
||||
const email = `e2e-upload-${token}@example.test`
|
||||
const username = `e2eu${token}`.slice(0, 20)
|
||||
|
||||
const script = [
|
||||
'use App\\Models\\User;',
|
||||
'use Illuminate\\Support\\Facades\\Hash;',
|
||||
`$user = User::updateOrCreate(['email' => '${email}'], [`,
|
||||
" 'name' => 'E2E Upload User',",
|
||||
` 'username' => '${username}',`,
|
||||
" 'onboarding_step' => 'complete',",
|
||||
" 'email_verified_at' => now(),",
|
||||
" 'is_active' => 1,",
|
||||
" 'password' => Hash::make('password'),",
|
||||
']);',
|
||||
"echo json_encode(['email' => $user->email, 'password' => 'password', 'username' => $user->username]);",
|
||||
].join(' ')
|
||||
|
||||
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
const lines = raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{') && line.endsWith('}'))
|
||||
if (!jsonLine) {
|
||||
throw new Error(`Unable to parse fixture JSON from tinker output: ${raw}`)
|
||||
}
|
||||
|
||||
return JSON.parse(jsonLine) as UploadFixture
|
||||
}
|
||||
|
||||
function resetBotProtectionState() {
|
||||
const script = [
|
||||
'use Illuminate\\Support\\Facades\\DB;',
|
||||
'use Illuminate\\Support\\Facades\\Schema;',
|
||||
"foreach (['forum_bot_logs', 'forum_bot_ip_blacklist', 'forum_bot_device_fingerprints', 'forum_bot_behavior_profiles'] as $table) {",
|
||||
' if (Schema::hasTable($table)) {',
|
||||
' DB::table($table)->delete();',
|
||||
' }',
|
||||
'}',
|
||||
"echo 'ok';",
|
||||
].join(' ')
|
||||
|
||||
execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
}
|
||||
|
||||
async function login(page: Page, fixture: UploadFixture) {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
await page.goto('/login')
|
||||
const emailField = page.locator('input[name="email"]')
|
||||
const internalServerError = page.getByText('Internal Server Error')
|
||||
|
||||
await Promise.race([
|
||||
emailField.waitFor({ state: 'visible', timeout: 8000 }),
|
||||
internalServerError.waitFor({ state: 'visible', timeout: 8000 }),
|
||||
])
|
||||
|
||||
if ((await internalServerError.isVisible().catch(() => false)) && !(await emailField.isVisible().catch(() => false))) {
|
||||
throw new Error('Upload Playwright login failed because the login page returned an internal server error.')
|
||||
}
|
||||
|
||||
await emailField.fill(fixture.email)
|
||||
await page.locator('input[name="password"]').fill(fixture.password)
|
||||
await page.getByRole('button', { name: 'Sign In' }).click()
|
||||
|
||||
try {
|
||||
await page.waitForURL((url) => url.pathname !== '/login', { timeout: 8000, waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('button', { name: /E2E Upload User/i })).toBeVisible()
|
||||
return
|
||||
} catch {
|
||||
const suspiciousActivity = page.getByText('Suspicious activity detected.')
|
||||
if (attempt === 0 && (await suspiciousActivity.isVisible().catch(() => false))) {
|
||||
resetBotProtectionState()
|
||||
continue
|
||||
}
|
||||
|
||||
throw new Error('Upload Playwright login failed before reaching an authenticated page.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function expectNoHorizontalOverflow(page: Page) {
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}))
|
||||
|
||||
expect(
|
||||
dimensions.scrollWidth,
|
||||
`Expected no horizontal overflow, but scrollWidth=${dimensions.scrollWidth} and clientWidth=${dimensions.clientWidth}`
|
||||
).toBeLessThanOrEqual(dimensions.clientWidth + 1)
|
||||
}
|
||||
|
||||
async function dismissCookieConsent(page: Page) {
|
||||
const essentialOnly = page.getByRole('button', { name: 'Essential only' })
|
||||
if (await essentialOnly.isVisible().catch(() => false)) {
|
||||
await essentialOnly.click({ force: true })
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Upload page layout', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: UploadFixture
|
||||
|
||||
test.beforeAll(() => {
|
||||
ensureCompiledAssets()
|
||||
fixture = seedUploadFixture()
|
||||
})
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetBotProtectionState()
|
||||
})
|
||||
|
||||
test('upload page loads updated studio shell', async ({ page }) => {
|
||||
await login(page, fixture)
|
||||
await page.goto('/upload', { waitUntil: 'domcontentloaded' })
|
||||
await dismissCookieConsent(page)
|
||||
|
||||
await expect(page.getByText('Skinbase Upload Studio')).toBeVisible()
|
||||
await expect(page.getByText('Before you start')).toBeVisible()
|
||||
await expect(page.getByRole('heading', { level: 2, name: 'Upload your artwork' })).toBeVisible()
|
||||
await expect(page.getByLabel('Upload file input')).toBeAttached()
|
||||
await expect(page.getByRole('button', { name: /start upload/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('upload page fits mobile width', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await login(page, fixture)
|
||||
await page.goto('/upload', { waitUntil: 'domcontentloaded' })
|
||||
await dismissCookieConsent(page)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await expect(page.getByText('Skinbase Upload Studio')).toBeVisible()
|
||||
await expectNoHorizontalOverflow(page)
|
||||
await expect(page.getByRole('button', { name: /start upload/i })).toBeVisible()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user