login update
This commit is contained in:
13
.env.example
13
.env.example
@@ -265,3 +265,16 @@ NOVA_EGS_SPOTLIGHT_TTL=3600
|
|||||||
NOVA_EGS_BLEND_TTL=300
|
NOVA_EGS_BLEND_TTL=300
|
||||||
NOVA_EGS_WINDOW_TTL=600
|
NOVA_EGS_WINDOW_TTL=600
|
||||||
NOVA_EGS_ACTIVITY_TTL=1800
|
NOVA_EGS_ACTIVITY_TTL=1800
|
||||||
|
# ─── OAuth / Social Login ─────────────────────────────────────────────────────
|
||||||
|
# Google — https://console.cloud.google.com/apis/credentials
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
GOOGLE_REDIRECT_URI=/auth/google/callback
|
||||||
|
|
||||||
|
# Discord — https://discord.com/developers/applications
|
||||||
|
DISCORD_CLIENT_ID=
|
||||||
|
DISCORD_CLIENT_SECRET=
|
||||||
|
DISCORD_REDIRECT_URI=/auth/discord/callback
|
||||||
|
|
||||||
|
# Apple — https://developer.apple.com/account/resources/identifiers/list/serviceId
|
||||||
|
# Apple sign in removed
|
||||||
|
|||||||
89
app/Console/Commands/AvatarsBulkUpdate.php
Normal file
89
app/Console/Commands/AvatarsBulkUpdate.php
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class AvatarsBulkUpdate extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'avatars:bulk-update
|
||||||
|
{path=./user_profiles_avatar.csv : CSV file path (user_id,avatar_hash)}
|
||||||
|
{--dry-run : Do not write to database}
|
||||||
|
';
|
||||||
|
|
||||||
|
protected $description = 'Bulk update user_profiles.avatar_hash from CSV (user_id,avatar_hash)';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$path = $this->argument('path');
|
||||||
|
$dry = $this->option('dry-run');
|
||||||
|
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
$this->error("CSV file not found: {$path}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Reading CSV: ' . $path);
|
||||||
|
|
||||||
|
if (($handle = fopen($path, 'r')) === false) {
|
||||||
|
$this->error('Unable to open CSV file');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = 0;
|
||||||
|
$updates = 0;
|
||||||
|
|
||||||
|
while (($data = fgetcsv($handle)) !== false) {
|
||||||
|
$row++;
|
||||||
|
// Skip empty rows
|
||||||
|
if (count($data) === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect at least two columns: user_id, avatar_hash
|
||||||
|
$userId = isset($data[0]) ? trim($data[0]) : null;
|
||||||
|
$hash = isset($data[1]) ? trim($data[1]) : null;
|
||||||
|
|
||||||
|
// If first row looks like a header, skip it
|
||||||
|
if ($row === 1 && (!is_numeric($userId) || $userId === 'user_id')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($userId === '' || $hash === '') {
|
||||||
|
$this->line("[skip] row={$row} invalid data");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int) $userId;
|
||||||
|
|
||||||
|
if ($dry) {
|
||||||
|
$this->line("[dry] user={$userId} would set avatar_hash={$hash}");
|
||||||
|
$updates++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$affected = DB::table('user_profiles')
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->update([ 'avatar_hash' => $hash, 'avatar_updated_at' => now() ]);
|
||||||
|
|
||||||
|
if ($affected) {
|
||||||
|
$this->line("[ok] user={$userId} avatar_hash updated");
|
||||||
|
$updates++;
|
||||||
|
} else {
|
||||||
|
$this->line("[noop] user={$userId} no row updated (missing profile?)");
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error("[error] user={$userId} {$e->getMessage()}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
$this->info("Done. Processed rows={$row} updates={$updates}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Console\Commands;
|
|||||||
|
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserProfile;
|
use App\Models\UserProfile;
|
||||||
use Intervention\Image\ImageManagerStatic as Image;
|
use Intervention\Image\ImageManagerStatic as Image;
|
||||||
@@ -39,6 +40,7 @@ class AvatarsMigrate extends Command
|
|||||||
protected $allowed = [
|
protected $allowed = [
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
'image/png',
|
'image/png',
|
||||||
|
'image/gif',
|
||||||
'image/webp',
|
'image/webp',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ class AvatarsMigrate extends Command
|
|||||||
*
|
*
|
||||||
* @var int[]
|
* @var int[]
|
||||||
*/
|
*/
|
||||||
protected $sizes = [32, 64, 128, 256, 512];
|
protected $sizes = [32, 40, 64, 128, 256, 512];
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
@@ -56,6 +58,7 @@ class AvatarsMigrate extends Command
|
|||||||
$removeLegacy = $this->option('remove-legacy');
|
$removeLegacy = $this->option('remove-legacy');
|
||||||
$legacyPath = base_path($this->option('path'));
|
$legacyPath = base_path($this->option('path'));
|
||||||
$userId = $this->option('user-id') ? (int) $this->option('user-id') : null;
|
$userId = $this->option('user-id') ? (int) $this->option('user-id') : null;
|
||||||
|
$verbose = $this->output->isVerbose();
|
||||||
|
|
||||||
$this->info('Starting avatar migration' . ($dry ? ' (dry-run)' : '') . ($userId ? " for user={$userId}" : ''));
|
$this->info('Starting avatar migration' . ($dry ? ' (dry-run)' : '') . ($userId ? " for user={$userId}" : ''));
|
||||||
|
|
||||||
@@ -72,7 +75,7 @@ class AvatarsMigrate extends Command
|
|||||||
$query->where('id', $userId);
|
$query->where('id', $userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$query->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention) {
|
$query->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention, $verbose) {
|
||||||
foreach ($users as $user) {
|
foreach ($users as $user) {
|
||||||
/** @var UserProfile|null $profile */
|
/** @var UserProfile|null $profile */
|
||||||
$profile = $user->profile;
|
$profile = $user->profile;
|
||||||
@@ -87,10 +90,13 @@ class AvatarsMigrate extends Command
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$source = $this->findLegacyFile($profile, $user->id, $legacyPath);
|
$source = $this->findLegacyFile($profile, $user->id, $legacyPath, 'legacy');
|
||||||
|
|
||||||
|
//dd($source);
|
||||||
if (!$source) {
|
if (!$source) {
|
||||||
|
if ($verbose) {
|
||||||
$this->line("[noop] user={$user->id} no legacy file found");
|
$this->line("[noop] user={$user->id} no legacy file found");
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,14 +129,19 @@ class AvatarsMigrate extends Command
|
|||||||
$contentPart = substr(sha1($originalBlob), 0, 12);
|
$contentPart = substr(sha1($originalBlob), 0, 12);
|
||||||
$hash = sprintf('%s_%s', $idPart, $contentPart);
|
$hash = sprintf('%s_%s', $idPart, $contentPart);
|
||||||
|
|
||||||
if ($dry) {
|
// Precompute storage dir for dry-run and real run
|
||||||
$this->line("[dry] user={$user->id} would write avatars for hash={$hash}");
|
|
||||||
} else {
|
|
||||||
// Use hash-based directory structure: avatars/ab/cd/{hash}/
|
|
||||||
$hashPrefix1 = substr($hash, 0, 2);
|
$hashPrefix1 = substr($hash, 0, 2);
|
||||||
$hashPrefix2 = substr($hash, 2, 2);
|
$hashPrefix2 = substr($hash, 2, 2);
|
||||||
$dir = "avatars/{$hashPrefix1}/{$hashPrefix2}/{$hash}";
|
$dir = "avatars/{$hashPrefix1}/{$hashPrefix2}/{$hash}";
|
||||||
Storage::disk('public')->makeDirectory($dir);
|
|
||||||
|
// CDN base for public URLs
|
||||||
|
$cdnBase = rtrim((string) config('cdn.avatar_url', 'https://files.skinbase.org'), '/');
|
||||||
|
|
||||||
|
if ($dry) {
|
||||||
|
$absPathDry = Storage::disk('public')->path("{$dir}/original.webp");
|
||||||
|
$publicUrlDry = sprintf('%s/%s/original.webp?v=%s', $cdnBase, $dir, $hash);
|
||||||
|
$this->line("[dry] user={$user->id} would write avatars for hash={$hash} path={$absPathDry} url={$publicUrlDry}");
|
||||||
|
} else {
|
||||||
|
|
||||||
// Save original.webp
|
// Save original.webp
|
||||||
Storage::disk('public')->put("{$dir}/original.webp", $originalBlob);
|
Storage::disk('public')->put("{$dir}/original.webp", $originalBlob);
|
||||||
@@ -155,7 +166,9 @@ class AvatarsMigrate extends Command
|
|||||||
$profile->avatar_updated_at = Carbon::now();
|
$profile->avatar_updated_at = Carbon::now();
|
||||||
$profile->save();
|
$profile->save();
|
||||||
|
|
||||||
$this->line("[ok] user={$user->id} migrated hash={$hash}");
|
$absPath = Storage::disk('public')->path("{$dir}/original.webp");
|
||||||
|
$publicUrl = sprintf('%s/%s/original.webp?v=%s', $cdnBase, $dir, $hash);
|
||||||
|
$this->line("[ok] user={$user->id} migrated hash={$hash} path={$absPath} url={$publicUrl}");
|
||||||
|
|
||||||
if ($removeLegacy && !empty($profile->avatar_legacy)) {
|
if ($removeLegacy && !empty($profile->avatar_legacy)) {
|
||||||
$legacyFile = base_path("public/files/usericons/{$profile->avatar_legacy}");
|
$legacyFile = base_path("public/files/usericons/{$profile->avatar_legacy}");
|
||||||
@@ -185,8 +198,19 @@ class AvatarsMigrate extends Command
|
|||||||
* @param string $legacyBase
|
* @param string $legacyBase
|
||||||
* @return string|null
|
* @return string|null
|
||||||
*/
|
*/
|
||||||
protected function findLegacyFile(UserProfile $profile, int $userId, string $legacyBase): ?string
|
protected function findLegacyFile(UserProfile $profile, int $userId, string $legacyBase, ?string $legacyConnection = null): ?string
|
||||||
{
|
{
|
||||||
|
|
||||||
|
$avatar = DB::connection('legacy')->table('users')->where('user_id', $userId)->value('icon');
|
||||||
|
|
||||||
|
if (!empty($profile->avatar_legacy)) {
|
||||||
|
$p = $legacyBase . DIRECTORY_SEPARATOR . $avatar;
|
||||||
|
if (file_exists($p)) {
|
||||||
|
return $p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// 1) If profile->avatar_legacy looks like a filename, try it
|
// 1) If profile->avatar_legacy looks like a filename, try it
|
||||||
if (!empty($profile->avatar_legacy)) {
|
if (!empty($profile->avatar_legacy)) {
|
||||||
$p = $legacyBase . DIRECTORY_SEPARATOR . $profile->avatar_legacy;
|
$p = $legacyBase . DIRECTORY_SEPARATOR . $profile->avatar_legacy;
|
||||||
@@ -212,6 +236,34 @@ class AvatarsMigrate extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4) Fallback: try legacy database connection (connection name 'legacy')
|
||||||
|
// If a legacy DB connection is configured, query `users.icon` for avatar filename.
|
||||||
|
try {
|
||||||
|
$conn = $legacyConnection ?: (config('database.connections.legacy') ? 'legacy' : null);
|
||||||
|
if ($conn) {
|
||||||
|
$icon = DB::connection($conn)->table('users')->where('id', $userId)->value('icon');
|
||||||
|
if (!empty($icon)) {
|
||||||
|
// If icon looks like an absolute path, use it directly; otherwise resolve under legacy base path
|
||||||
|
$p = $icon;
|
||||||
|
if (!file_exists($p)) {
|
||||||
|
$p = $legacyBase . DIRECTORY_SEPARATOR . ltrim($icon, '\/');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_exists($p)) {
|
||||||
|
if ($this->output->isVerbose()) {
|
||||||
|
$this->line("[legacy-db] user={$userId} icon={$icon} resolved={$p}");
|
||||||
|
}
|
||||||
|
return $p;
|
||||||
|
}
|
||||||
|
if ($this->output->isVerbose()) {
|
||||||
|
$this->line("[legacy-db] user={$userId} icon={$icon} not found at resolved path {$p}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Non-fatal: just skip legacy DB if query fails or connection missing
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +360,53 @@ class AvatarsMigrate extends Command
|
|||||||
return imagecreatefromwebp($path);
|
return imagecreatefromwebp($path);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
case 'image/gif':
|
||||||
|
if (function_exists('imagecreatefromgif')) {
|
||||||
|
$res = imagecreatefromgif($path);
|
||||||
|
if (!$res) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure returned resource is truecolor (WebP requires truecolor)
|
||||||
|
if (!imageistruecolor($res)) {
|
||||||
|
$w = imagesx($res);
|
||||||
|
$h = imagesy($res);
|
||||||
|
$true = imagecreatetruecolor($w, $h);
|
||||||
|
|
||||||
|
// Preserve transparency where possible
|
||||||
|
imagealphablending($true, false);
|
||||||
|
imagesavealpha($true, true);
|
||||||
|
|
||||||
|
// Fill with fully transparent color
|
||||||
|
$transparent = imagecolorallocatealpha($true, 0, 0, 0, 127);
|
||||||
|
imagefilledrectangle($true, 0, 0, $w, $h, $transparent);
|
||||||
|
|
||||||
|
// If the source has an indexed transparent color, try to preserve it
|
||||||
|
$transIndex = imagecolortransparent($res);
|
||||||
|
if ($transIndex >= 0) {
|
||||||
|
try {
|
||||||
|
$colorTotal = imagecolorstotal($res);
|
||||||
|
if ($transIndex >= 0 && $transIndex < $colorTotal) {
|
||||||
|
$colors = imagecolorsforindex($res, $transIndex);
|
||||||
|
if (is_array($colors)) {
|
||||||
|
$alphaColor = imagecolorallocatealpha($true, $colors['red'], $colors['green'], $colors['blue'], 127);
|
||||||
|
imagefilledrectangle($true, 0, 0, $w, $h, $alphaColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Non-fatal: skip preserving indexed transparent color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy pixels
|
||||||
|
imagecopy($true, $res, 0, 0, 0, 0, $w, $h);
|
||||||
|
imagedestroy($res);
|
||||||
|
return $true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
197
app/Console/Commands/MigrateStoriesCommand.php
Normal file
197
app/Console/Commands/MigrateStoriesCommand.php
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Story;
|
||||||
|
use App\Models\StoryAuthor;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate legacy interview records into the new Stories system.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan stories:migrate-legacy
|
||||||
|
* php artisan stories:migrate-legacy --dry-run
|
||||||
|
* php artisan stories:migrate-legacy --legacy-connection=legacy --chunk=100
|
||||||
|
*
|
||||||
|
* Idempotent: running multiple times will not duplicate records.
|
||||||
|
* Legacy records are identified via `legacy_interview_id` column on stories table.
|
||||||
|
*/
|
||||||
|
final class MigrateStoriesCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'stories:migrate-legacy
|
||||||
|
{--chunk=50 : number of records to process per batch}
|
||||||
|
{--dry-run : preview migration without persisting changes}
|
||||||
|
{--legacy-connection= : DB connection name for legacy database (default: uses default connection)}
|
||||||
|
{--legacy-table=interviews : legacy interviews table name}
|
||||||
|
';
|
||||||
|
|
||||||
|
protected $description = 'Migrate legacy interview records into the new nova Stories system (idempotent)';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$chunk = max(1, (int) $this->option('chunk'));
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$legacyConn = $this->option('legacy-connection') ?: null;
|
||||||
|
$table = (string) $this->option('legacy-table');
|
||||||
|
|
||||||
|
$this->info('Nova Stories — legacy interview migration');
|
||||||
|
$this->info("Table: {$table} | Chunk: {$chunk} | Dry-run: " . ($dryRun ? 'YES' : 'NO'));
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = $legacyConn ? DB::connection($legacyConn) : DB::connection();
|
||||||
|
// Quick existence check
|
||||||
|
$db->table($table)->limit(1)->get();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->error("Cannot access table `{$table}`: " . $e->getMessage());
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inserted = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
$db->table($table)->orderBy('id')->chunkById($chunk, function ($rows) use (
|
||||||
|
$dryRun, &$inserted, &$skipped, &$failed
|
||||||
|
) {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$legacyId = (int) ($row->id ?? 0);
|
||||||
|
|
||||||
|
if (! $legacyId) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency: skip if already migrated
|
||||||
|
if (Story::where('legacy_interview_id', $legacyId)->exists()) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── Resolve / create author ──────────────────────────────
|
||||||
|
$authorName = $this->coerceString($row->username ?? $row->author ?? $row->uname ?? '');
|
||||||
|
$authorAvatar = $this->coerceString($row->icon ?? $row->avatar ?? '');
|
||||||
|
|
||||||
|
$author = null;
|
||||||
|
if ($authorName) {
|
||||||
|
$author = StoryAuthor::firstOrCreate(
|
||||||
|
['name' => $authorName],
|
||||||
|
['avatar' => $authorAvatar ?: null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build slug ───────────────────────────────────────────
|
||||||
|
$rawTitle = $this->coerceString(
|
||||||
|
$row->headline ?? $row->title ?? $row->subject ?? ''
|
||||||
|
) ?: 'interview-' . $legacyId;
|
||||||
|
|
||||||
|
$slugBase = Str::slug(Str::limit($rawTitle, 180));
|
||||||
|
$slug = $slugBase ?: 'interview-' . $legacyId;
|
||||||
|
|
||||||
|
// Ensure uniqueness
|
||||||
|
$slug = $this->uniqueSlug($slug);
|
||||||
|
|
||||||
|
// ── Excerpt ──────────────────────────────────────────────
|
||||||
|
$fullContent = $this->coerceString(
|
||||||
|
$row->content ?? $row->tekst ?? $row->body ?? $row->text ?? ''
|
||||||
|
);
|
||||||
|
|
||||||
|
$excerpt = $this->coerceString($row->excerpt ?? $row->intro ?? $row->lead ?? '');
|
||||||
|
if (! $excerpt && $fullContent) {
|
||||||
|
$excerpt = Str::limit(strip_tags($fullContent), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cover image ──────────────────────────────────────────
|
||||||
|
$coverRaw = $this->coerceString($row->pic ?? $row->image ?? $row->cover ?? $row->photo ?? '');
|
||||||
|
$coverImage = $coverRaw ? 'legacy/interviews/' . ltrim($coverRaw, '/') : null;
|
||||||
|
|
||||||
|
// ── Published date ───────────────────────────────────────
|
||||||
|
$publishedAt = null;
|
||||||
|
foreach (['datum', 'published_at', 'date', 'created_at'] as $field) {
|
||||||
|
$val = $row->{$field} ?? null;
|
||||||
|
if ($val) {
|
||||||
|
$ts = strtotime((string) $val);
|
||||||
|
if ($ts) {
|
||||||
|
$publishedAt = date('Y-m-d H:i:s', $ts);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->line(" [DRY-RUN] Would import: #{$legacyId} → {$slug}");
|
||||||
|
$inserted++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Story::create([
|
||||||
|
'slug' => $slug,
|
||||||
|
'title' => Str::limit($rawTitle, 255),
|
||||||
|
'excerpt' => $excerpt ?: null,
|
||||||
|
'content' => $fullContent ?: null,
|
||||||
|
'cover_image' => $coverImage,
|
||||||
|
'author_id' => $author?->id,
|
||||||
|
'views' => max(0, (int) ($row->views ?? $row->hits ?? 0)),
|
||||||
|
'featured' => false,
|
||||||
|
'status' => 'published',
|
||||||
|
'published_at' => $publishedAt,
|
||||||
|
'legacy_interview_id' => $legacyId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->line(" Imported: #{$legacyId} → {$slug}");
|
||||||
|
$inserted++;
|
||||||
|
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
$this->warn(" FAILED #{$legacyId}: " . $e->getMessage());
|
||||||
|
Log::warning("stories:migrate-legacy failed for id={$legacyId}", ['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("Migration complete.");
|
||||||
|
$this->table(
|
||||||
|
['Inserted', 'Skipped (existing)', 'Failed'],
|
||||||
|
[[$inserted, $skipped, $failed]]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function coerceString(mixed $value, string $default = ''): string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
$str = trim((string) $value);
|
||||||
|
return $str !== '' ? $str : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the slug is unique, appending a numeric suffix if needed.
|
||||||
|
*/
|
||||||
|
private function uniqueSlug(string $slug): string
|
||||||
|
{
|
||||||
|
if (! Story::where('slug', $slug)->exists()) {
|
||||||
|
return $slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
$i = 2;
|
||||||
|
do {
|
||||||
|
$candidate = $slug . '-' . $i++;
|
||||||
|
} while (Story::where('slug', $candidate)->exists());
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ class Kernel extends ConsoleKernel
|
|||||||
ImportCategories::class,
|
ImportCategories::class,
|
||||||
MigrateFeaturedWorks::class,
|
MigrateFeaturedWorks::class,
|
||||||
\App\Console\Commands\AvatarsMigrate::class,
|
\App\Console\Commands\AvatarsMigrate::class,
|
||||||
|
\App\Console\Commands\AvatarsBulkUpdate::class,
|
||||||
\App\Console\Commands\ResetAllUserPasswords::class,
|
\App\Console\Commands\ResetAllUserPasswords::class,
|
||||||
CleanupUploadsCommand::class,
|
CleanupUploadsCommand::class,
|
||||||
PublishScheduledArtworksCommand::class,
|
PublishScheduledArtworksCommand::class,
|
||||||
|
|||||||
188
app/Http/Controllers/Api/StoriesApiController.php
Normal file
188
app/Http/Controllers/Api/StoriesApiController.php
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Story;
|
||||||
|
use App\Models\StoryTag;
|
||||||
|
use App\Models\StoryAuthor;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stories API — JSON endpoints for React frontend.
|
||||||
|
*
|
||||||
|
* GET /api/stories list published stories (paginated)
|
||||||
|
* GET /api/stories/{slug} single story detail
|
||||||
|
* GET /api/stories/tag/{tag} stories by tag
|
||||||
|
* GET /api/stories/author/{author} stories by author
|
||||||
|
* GET /api/stories/featured featured stories
|
||||||
|
*/
|
||||||
|
final class StoriesApiController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* List published stories (paginated).
|
||||||
|
* GET /api/stories?page=1&per_page=12
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$perPage = min((int) $request->get('per_page', 12), 50);
|
||||||
|
$page = (int) $request->get('page', 1);
|
||||||
|
|
||||||
|
$cacheKey = "stories:api:list:{$perPage}:{$page}";
|
||||||
|
|
||||||
|
$stories = Cache::remember($cacheKey, 300, fn () =>
|
||||||
|
Story::published()
|
||||||
|
->with('author', 'tags')
|
||||||
|
->orderByDesc('published_at')
|
||||||
|
->paginate($perPage, ['*'], 'page', $page)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $stories->currentPage(),
|
||||||
|
'last_page' => $stories->lastPage(),
|
||||||
|
'per_page' => $stories->perPage(),
|
||||||
|
'total' => $stories->total(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single story detail.
|
||||||
|
* GET /api/stories/{slug}
|
||||||
|
*/
|
||||||
|
public function show(string $slug): JsonResponse
|
||||||
|
{
|
||||||
|
$story = Cache::remember('stories:api:' . $slug, 600, fn () =>
|
||||||
|
Story::published()
|
||||||
|
->with('author', 'tags')
|
||||||
|
->where('slug', $slug)
|
||||||
|
->firstOrFail()
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($this->formatFull($story));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Featured story.
|
||||||
|
* GET /api/stories/featured
|
||||||
|
*/
|
||||||
|
public function featured(): JsonResponse
|
||||||
|
{
|
||||||
|
$story = Cache::remember('stories:api:featured', 300, fn () =>
|
||||||
|
Story::published()->featured()
|
||||||
|
->with('author', 'tags')
|
||||||
|
->orderByDesc('published_at')
|
||||||
|
->first()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $story) {
|
||||||
|
return response()->json(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($this->formatFull($story));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stories by tag.
|
||||||
|
* GET /api/stories/tag/{tag}?page=1
|
||||||
|
*/
|
||||||
|
public function byTag(Request $request, string $tag): JsonResponse
|
||||||
|
{
|
||||||
|
$storyTag = StoryTag::where('slug', $tag)->firstOrFail();
|
||||||
|
$page = (int) $request->get('page', 1);
|
||||||
|
|
||||||
|
$stories = Cache::remember("stories:api:tag:{$tag}:{$page}", 300, fn () =>
|
||||||
|
Story::published()
|
||||||
|
->with('author', 'tags')
|
||||||
|
->whereHas('tags', fn ($q) => $q->where('stories_tags.id', $storyTag->id))
|
||||||
|
->orderByDesc('published_at')
|
||||||
|
->paginate(12, ['*'], 'page', $page)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'tag' => ['id' => $storyTag->id, 'slug' => $storyTag->slug, 'name' => $storyTag->name],
|
||||||
|
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $stories->currentPage(),
|
||||||
|
'last_page' => $stories->lastPage(),
|
||||||
|
'per_page' => $stories->perPage(),
|
||||||
|
'total' => $stories->total(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stories by author.
|
||||||
|
* GET /api/stories/author/{username}?page=1
|
||||||
|
*/
|
||||||
|
public function byAuthor(Request $request, string $username): JsonResponse
|
||||||
|
{
|
||||||
|
$author = StoryAuthor::whereHas('user', fn ($q) => $q->where('username', $username))->first()
|
||||||
|
?? StoryAuthor::where('name', $username)->firstOrFail();
|
||||||
|
|
||||||
|
$page = (int) $request->get('page', 1);
|
||||||
|
|
||||||
|
$stories = Cache::remember("stories:api:author:{$author->id}:{$page}", 300, fn () =>
|
||||||
|
Story::published()
|
||||||
|
->with('author', 'tags')
|
||||||
|
->where('author_id', $author->id)
|
||||||
|
->orderByDesc('published_at')
|
||||||
|
->paginate(12, ['*'], 'page', $page)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'author' => $this->formatAuthor($author),
|
||||||
|
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $stories->currentPage(),
|
||||||
|
'last_page' => $stories->lastPage(),
|
||||||
|
'per_page' => $stories->perPage(),
|
||||||
|
'total' => $stories->total(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private formatters ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function formatCard(Story $story): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $story->id,
|
||||||
|
'slug' => $story->slug,
|
||||||
|
'url' => $story->url,
|
||||||
|
'title' => $story->title,
|
||||||
|
'excerpt' => $story->excerpt,
|
||||||
|
'cover_image' => $story->cover_url,
|
||||||
|
'author' => $story->author ? $this->formatAuthor($story->author) : null,
|
||||||
|
'tags' => $story->tags->map(fn ($t) => ['id' => $t->id, 'slug' => $t->slug, 'name' => $t->name, 'url' => $t->url]),
|
||||||
|
'views' => $story->views,
|
||||||
|
'featured' => $story->featured,
|
||||||
|
'reading_time' => $story->reading_time,
|
||||||
|
'published_at' => $story->published_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatFull(Story $story): array
|
||||||
|
{
|
||||||
|
return array_merge($this->formatCard($story), [
|
||||||
|
'content' => $story->content,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatAuthor(StoryAuthor $author): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $author->id,
|
||||||
|
'name' => $author->name,
|
||||||
|
'avatar_url' => $author->avatar_url,
|
||||||
|
'bio' => $author->bio,
|
||||||
|
'profile_url' => $author->profile_url,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
252
app/Http/Controllers/Auth/OAuthController.php
Normal file
252
app/Http/Controllers/Auth/OAuthController.php
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\SocialAccount;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\UniqueConstraintViolationException;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Laravel\Socialite\Contracts\User as SocialiteUser;
|
||||||
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class OAuthController extends Controller
|
||||||
|
{
|
||||||
|
/** Providers enabled for OAuth login. */
|
||||||
|
private const ALLOWED_PROVIDERS = ['google', 'discord'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect the user to the provider's OAuth page.
|
||||||
|
*/
|
||||||
|
public function redirectToProvider(string $provider): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->abortIfInvalidProvider($provider);
|
||||||
|
|
||||||
|
return Socialite::driver($provider)->redirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the provider callback and authenticate the user.
|
||||||
|
*/
|
||||||
|
public function handleProviderCallback(string $provider): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->abortIfInvalidProvider($provider);
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** @var SocialiteUser $socialUser */
|
||||||
|
$socialUser = Socialite::driver($provider)->user();
|
||||||
|
} catch (Throwable) {
|
||||||
|
return redirect()->route('login')
|
||||||
|
->withErrors(['oauth' => 'Authentication failed. Please try again.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$providerId = (string) $socialUser->getId();
|
||||||
|
$providerEmail = $this->resolveEmail($socialUser);
|
||||||
|
$verified = $this->isEmailVerifiedByProvider($provider, $socialUser);
|
||||||
|
|
||||||
|
// ── 1. Provider account already linked → login ───────────────────────
|
||||||
|
$existing = SocialAccount::query()
|
||||||
|
->where('provider', $provider)
|
||||||
|
->where('provider_id', $providerId)
|
||||||
|
->with('user')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing !== null && $existing->user !== null) {
|
||||||
|
return $this->loginAndRedirect($existing->user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Email match → link to existing account ────────────────────────
|
||||||
|
// Covers both verified and unverified users: if the OAuth provider
|
||||||
|
// has confirmed this email we can safely link it and mark it verified,
|
||||||
|
// preventing a duplicate-email insert when the user had started
|
||||||
|
// registration via email but never finished verification.
|
||||||
|
if ($providerEmail !== null && $verified) {
|
||||||
|
$userByEmail = User::query()
|
||||||
|
->where('email', strtolower($providerEmail))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($userByEmail !== null) {
|
||||||
|
// If their email was not yet verified, promote it now — the
|
||||||
|
// OAuth provider has already verified it on our behalf.
|
||||||
|
if ($userByEmail->email_verified_at === null) {
|
||||||
|
$userByEmail->forceFill([
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'is_active' => true,
|
||||||
|
// Keep their onboarding step unless already complete
|
||||||
|
'onboarding_step' => $userByEmail->onboarding_step === 'email'
|
||||||
|
? 'username'
|
||||||
|
: ($userByEmail->onboarding_step ?? 'username'),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->createSocialAccount($userByEmail, $provider, $providerId, $providerEmail, $socialUser->getAvatar());
|
||||||
|
|
||||||
|
return $this->loginAndRedirect($userByEmail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Provider email not verified → reject auto-link ────────────────
|
||||||
|
if ($providerEmail !== null && ! $verified) {
|
||||||
|
return redirect()->route('login')
|
||||||
|
->withErrors(['oauth' => 'Your ' . ucfirst($provider) . ' email is not verified. Please verify it and try again.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. No email at all → cannot proceed ──────────────────────────────
|
||||||
|
if ($providerEmail === null) {
|
||||||
|
return redirect()->route('login')
|
||||||
|
->withErrors(['oauth' => 'Could not retrieve your email address from ' . ucfirst($provider) . '. Please register with email.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. New user creation ──────────────────────────────────────────────
|
||||||
|
try {
|
||||||
|
$user = $this->createOAuthUser($socialUser, $provider, $providerId, $providerEmail);
|
||||||
|
} catch (UniqueConstraintViolationException) {
|
||||||
|
// Race condition: another request inserted the same email between
|
||||||
|
// the lookup above and this insert. Fetch and link instead.
|
||||||
|
$user = User::query()->where('email', strtolower($providerEmail))->first();
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
return redirect()->route('login')
|
||||||
|
->withErrors(['oauth' => 'An error occurred during sign in. Please try again.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->createSocialAccount($user, $provider, $providerId, $providerEmail, $socialUser->getAvatar());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->loginAndRedirect($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function abortIfInvalidProvider(string $provider): void
|
||||||
|
{
|
||||||
|
abort_unless(in_array($provider, self::ALLOWED_PROVIDERS, true), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create social_accounts row linked to a user.
|
||||||
|
*/
|
||||||
|
private function createSocialAccount(
|
||||||
|
User $user,
|
||||||
|
string $provider,
|
||||||
|
string $providerId,
|
||||||
|
?string $providerEmail,
|
||||||
|
?string $avatar
|
||||||
|
): void {
|
||||||
|
SocialAccount::query()->updateOrCreate(
|
||||||
|
['provider' => $provider, 'provider_id' => $providerId],
|
||||||
|
[
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'provider_email' => $providerEmail !== null ? strtolower($providerEmail) : null,
|
||||||
|
'avatar' => $avatar,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a brand-new user from OAuth data.
|
||||||
|
*/
|
||||||
|
private function createOAuthUser(
|
||||||
|
SocialiteUser $socialUser,
|
||||||
|
string $provider,
|
||||||
|
string $providerId,
|
||||||
|
string $providerEmail
|
||||||
|
): User {
|
||||||
|
$user = DB::transaction(function () use ($socialUser, $provider, $providerId, $providerEmail): User {
|
||||||
|
$name = $this->resolveDisplayName($socialUser, $providerEmail);
|
||||||
|
|
||||||
|
$user = User::query()->create([
|
||||||
|
'username' => null,
|
||||||
|
'name' => $name,
|
||||||
|
'email' => strtolower($providerEmail),
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'password' => Hash::make(Str::random(64)),
|
||||||
|
'is_active' => true,
|
||||||
|
'onboarding_step' => 'username',
|
||||||
|
'username_changed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->createSocialAccount(
|
||||||
|
$user,
|
||||||
|
$provider,
|
||||||
|
$providerId,
|
||||||
|
$providerEmail,
|
||||||
|
$socialUser->getAvatar()
|
||||||
|
);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login the user and redirect appropriately.
|
||||||
|
*/
|
||||||
|
private function loginAndRedirect(User $user): RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::login($user, remember: true);
|
||||||
|
|
||||||
|
request()->session()->regenerate();
|
||||||
|
|
||||||
|
$step = strtolower((string) ($user->onboarding_step ?? ''));
|
||||||
|
|
||||||
|
if (in_array($step, ['username', 'password'], true)) {
|
||||||
|
return redirect()->route('setup.username.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard', absolute: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a usable display name from the social user.
|
||||||
|
*/
|
||||||
|
private function resolveDisplayName(SocialiteUser $socialUser, string $email): string
|
||||||
|
{
|
||||||
|
$name = trim((string) ($socialUser->getName() ?? ''));
|
||||||
|
|
||||||
|
if ($name !== '') {
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Str::before($email, '@');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort email resolution. Apple can return null email on repeat logins.
|
||||||
|
*/
|
||||||
|
private function resolveEmail(SocialiteUser $socialUser): ?string
|
||||||
|
{
|
||||||
|
$email = $socialUser->getEmail();
|
||||||
|
|
||||||
|
if ($email === null || $email === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtolower(trim($email));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the provider has verified the user's email.
|
||||||
|
*
|
||||||
|
* - Google: returns email_verified flag in raw data
|
||||||
|
* - Discord: returns verified flag in raw data
|
||||||
|
* - Apple: only issues tokens for verified Apple IDs
|
||||||
|
*/
|
||||||
|
private function isEmailVerifiedByProvider(string $provider, SocialiteUser $socialUser): bool
|
||||||
|
{
|
||||||
|
$raw = (array) ($socialUser->getRaw() ?? []);
|
||||||
|
|
||||||
|
return match ($provider) {
|
||||||
|
'google' => filter_var($raw['email_verified'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'discord' => (bool) ($raw['verified'] ?? false),
|
||||||
|
'apple' => true, // Apple only issues tokens for verified Apple IDs
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,8 +34,9 @@ class FollowingController extends Controller
|
|||||||
->through(fn ($row) => (object) [
|
->through(fn ($row) => (object) [
|
||||||
'id' => $row->id,
|
'id' => $row->id,
|
||||||
'username' => $row->username,
|
'username' => $row->username,
|
||||||
|
'name' => $row->name,
|
||||||
'uname' => $row->username ?? $row->name,
|
'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)),
|
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
|
||||||
'uploads' => $row->uploads_count ?? 0,
|
'uploads' => $row->uploads_count ?? 0,
|
||||||
'followers_count'=> $row->followers_count ?? 0,
|
'followers_count'=> $row->followers_count ?? 0,
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ class TopAuthorsController extends Controller
|
|||||||
$query = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
|
$query = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
|
||||||
->mergeBindings($sub->getQuery())
|
->mergeBindings($sub->getQuery())
|
||||||
->join('users as u', 'u.id', '=', 't.user_id')
|
->join('users as u', 'u.id', '=', 't.user_id')
|
||||||
->select('u.id as user_id', 'u.name as uname', 'u.username', 't.total_metric', 't.latest_published')
|
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||||
|
->select('u.id as user_id', 'u.name as uname', 'u.username', 'up.avatar_hash', 't.total_metric', 't.latest_published')
|
||||||
->orderByDesc('t.total_metric')
|
->orderByDesc('t.total_metric')
|
||||||
->orderByDesc('t.latest_published');
|
->orderByDesc('t.latest_published');
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ class TopAuthorsController extends Controller
|
|||||||
'user_id' => $row->user_id,
|
'user_id' => $row->user_id,
|
||||||
'uname' => $row->uname,
|
'uname' => $row->uname,
|
||||||
'username' => $row->username,
|
'username' => $row->username,
|
||||||
|
'avatar_hash' => $row->avatar_hash,
|
||||||
'total' => (int) $row->total_metric,
|
'total' => (int) $row->total_metric,
|
||||||
'metric' => $metric,
|
'metric' => $metric,
|
||||||
];
|
];
|
||||||
|
|||||||
40
app/Http/Controllers/RSS/BlogFeedController.php
Normal file
40
app/Http/Controllers/RSS/BlogFeedController.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\RSS;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\BlogPost;
|
||||||
|
use App\Services\RSS\RSSFeedBuilder;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BlogFeedController
|
||||||
|
*
|
||||||
|
* GET /rss/blog → latest blog posts feed (spec §3.6)
|
||||||
|
*/
|
||||||
|
final class BlogFeedController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly RSSFeedBuilder $builder) {}
|
||||||
|
|
||||||
|
public function __invoke(): Response
|
||||||
|
{
|
||||||
|
$feedUrl = url('/rss/blog');
|
||||||
|
$posts = Cache::remember('rss:blog', 600, fn () =>
|
||||||
|
BlogPost::published()
|
||||||
|
->with('author:id,username')
|
||||||
|
->latest('published_at')
|
||||||
|
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||||
|
->get()
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->builder->buildFromBlogPosts(
|
||||||
|
'Blog',
|
||||||
|
'Latest posts from the Skinbase blog.',
|
||||||
|
$feedUrl,
|
||||||
|
$posts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Http/Controllers/RSS/CreatorFeedController.php
Normal file
49
app/Http/Controllers/RSS/CreatorFeedController.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\RSS;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\RSS\RSSFeedBuilder;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CreatorFeedController
|
||||||
|
*
|
||||||
|
* GET /rss/creator/{username} → latest artworks by a given creator (spec §3.5)
|
||||||
|
*/
|
||||||
|
final class CreatorFeedController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly RSSFeedBuilder $builder) {}
|
||||||
|
|
||||||
|
public function __invoke(string $username): Response
|
||||||
|
{
|
||||||
|
$user = User::where('username', $username)->first();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
throw new NotFoundHttpException("Creator [{$username}] not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$feedUrl = url('/rss/creator/' . $username);
|
||||||
|
$artworks = Cache::remember('rss:creator:' . strtolower($username), 300, fn () =>
|
||||||
|
Artwork::public()->published()
|
||||||
|
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
|
||||||
|
->where('artworks.user_id', $user->id)
|
||||||
|
->latest('artworks.published_at')
|
||||||
|
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||||
|
->get()
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->builder->buildFromArtworks(
|
||||||
|
$user->username . '\'s Artworks',
|
||||||
|
'Latest artworks by ' . $user->username . ' on Skinbase.',
|
||||||
|
$feedUrl,
|
||||||
|
$artworks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/Http/Controllers/RSS/DiscoverFeedController.php
Normal file
98
app/Http/Controllers/RSS/DiscoverFeedController.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\RSS;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Services\RSS\RSSFeedBuilder;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DiscoverFeedController
|
||||||
|
*
|
||||||
|
* Powers the /rss/discover/* feeds (spec §3.2).
|
||||||
|
*
|
||||||
|
* GET /rss/discover → fresh/latest (default)
|
||||||
|
* GET /rss/discover/trending → trending by trending_score_7d
|
||||||
|
* GET /rss/discover/fresh → latest published
|
||||||
|
* GET /rss/discover/rising → rising by heat_score
|
||||||
|
*/
|
||||||
|
final class DiscoverFeedController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly RSSFeedBuilder $builder) {}
|
||||||
|
|
||||||
|
/** /rss/discover → redirect to fresh */
|
||||||
|
public function index(): Response
|
||||||
|
{
|
||||||
|
return $this->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /rss/discover/trending */
|
||||||
|
public function trending(): Response
|
||||||
|
{
|
||||||
|
$feedUrl = url('/rss/discover/trending');
|
||||||
|
$artworks = Cache::remember('rss:discover:trending', 600, fn () =>
|
||||||
|
Artwork::public()->published()
|
||||||
|
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
|
||||||
|
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||||
|
->orderByDesc('artwork_stats.trending_score_7d')
|
||||||
|
->orderByDesc('artworks.published_at')
|
||||||
|
->select('artworks.*')
|
||||||
|
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||||
|
->get()
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->builder->buildFromArtworks(
|
||||||
|
'Trending Artworks',
|
||||||
|
'The most-viewed and trending artworks on Skinbase over the past 7 days.',
|
||||||
|
$feedUrl,
|
||||||
|
$artworks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /rss/discover/fresh */
|
||||||
|
public function fresh(): Response
|
||||||
|
{
|
||||||
|
$feedUrl = url('/rss/discover/fresh');
|
||||||
|
$artworks = Cache::remember('rss:discover:fresh', 300, fn () =>
|
||||||
|
Artwork::public()->published()
|
||||||
|
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
|
||||||
|
->latest('published_at')
|
||||||
|
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||||
|
->get()
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->builder->buildFromArtworks(
|
||||||
|
'Fresh Uploads',
|
||||||
|
'The latest artworks just published on Skinbase.',
|
||||||
|
$feedUrl,
|
||||||
|
$artworks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /rss/discover/rising */
|
||||||
|
public function rising(): Response
|
||||||
|
{
|
||||||
|
$feedUrl = url('/rss/discover/rising');
|
||||||
|
$artworks = Cache::remember('rss:discover:rising', 600, fn () =>
|
||||||
|
Artwork::public()->published()
|
||||||
|
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
|
||||||
|
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||||
|
->orderByDesc('artwork_stats.heat_score')
|
||||||
|
->orderByDesc('artworks.published_at')
|
||||||
|
->select('artworks.*')
|
||||||
|
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||||
|
->get()
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->builder->buildFromArtworks(
|
||||||
|
'Rising Artworks',
|
||||||
|
'Fastest-growing artworks gaining momentum on Skinbase right now.',
|
||||||
|
$feedUrl,
|
||||||
|
$artworks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
app/Http/Controllers/RSS/ExploreFeedController.php
Normal file
105
app/Http/Controllers/RSS/ExploreFeedController.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\RSS;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\ContentType;
|
||||||
|
use App\Services\RSS\RSSFeedBuilder;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExploreFeedController
|
||||||
|
*
|
||||||
|
* Powers the /rss/explore/* feeds (spec §3.3).
|
||||||
|
*
|
||||||
|
* GET /rss/explore/{type} → latest by content type
|
||||||
|
* GET /rss/explore/{type}/{mode} → sorted by mode (trending|latest|best)
|
||||||
|
*
|
||||||
|
* Valid types: artworks | wallpapers | skins | photography | other
|
||||||
|
* Valid modes: trending | latest | best
|
||||||
|
*/
|
||||||
|
final class ExploreFeedController extends Controller
|
||||||
|
{
|
||||||
|
private const SORT_TTL = [
|
||||||
|
'trending' => 600,
|
||||||
|
'best' => 600,
|
||||||
|
'latest' => 300,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(private readonly RSSFeedBuilder $builder) {}
|
||||||
|
|
||||||
|
/** /rss/explore/{type} — defaults to latest */
|
||||||
|
public function byType(string $type): Response
|
||||||
|
{
|
||||||
|
return $this->feed($type, 'latest');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /rss/explore/{type}/{mode} */
|
||||||
|
public function byTypeMode(string $type, string $mode): Response
|
||||||
|
{
|
||||||
|
return $this->feed($type, $mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function feed(string $type, string $mode): Response
|
||||||
|
{
|
||||||
|
$mode = in_array($mode, ['trending', 'latest', 'best'], true) ? $mode : 'latest';
|
||||||
|
$ttl = self::SORT_TTL[$mode] ?? 300;
|
||||||
|
$feedUrl = url('/rss/explore/' . $type . ($mode !== 'latest' ? '/' . $mode : ''));
|
||||||
|
$label = ucfirst(str_replace('-', ' ', $type));
|
||||||
|
|
||||||
|
$artworks = Cache::remember("rss:explore:{$type}:{$mode}", $ttl, function () use ($type, $mode) {
|
||||||
|
$contentType = ContentType::where('slug', $type)->first();
|
||||||
|
|
||||||
|
$query = Artwork::public()->published()
|
||||||
|
->with(['user:id,username', 'categories:id,name,slug,content_type_id']);
|
||||||
|
|
||||||
|
if ($contentType) {
|
||||||
|
$query->whereHas('categories', fn ($q) =>
|
||||||
|
$q->where('content_type_id', $contentType->id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($mode) {
|
||||||
|
'trending' => $query
|
||||||
|
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||||
|
->orderByDesc('artwork_stats.trending_score_7d')
|
||||||
|
->orderByDesc('artworks.published_at')
|
||||||
|
->select('artworks.*')
|
||||||
|
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||||
|
->get(),
|
||||||
|
|
||||||
|
'best' => $query
|
||||||
|
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||||
|
->orderByDesc('artwork_stats.favorites')
|
||||||
|
->orderByDesc('artwork_stats.downloads')
|
||||||
|
->select('artworks.*')
|
||||||
|
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||||
|
->get(),
|
||||||
|
|
||||||
|
default => $query
|
||||||
|
->latest('artworks.published_at')
|
||||||
|
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||||
|
->get(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$modeLabel = match ($mode) {
|
||||||
|
'trending' => 'Trending',
|
||||||
|
'best' => 'Best',
|
||||||
|
default => 'Latest',
|
||||||
|
};
|
||||||
|
|
||||||
|
return $this->builder->buildFromArtworks(
|
||||||
|
"{$modeLabel} {$label}",
|
||||||
|
"{$modeLabel} {$label} artworks on Skinbase.",
|
||||||
|
$feedUrl,
|
||||||
|
$artworks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Http/Controllers/RSS/GlobalFeedController.php
Normal file
40
app/Http/Controllers/RSS/GlobalFeedController.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\RSS;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Services\RSS\RSSFeedBuilder;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalFeedController
|
||||||
|
*
|
||||||
|
* GET /rss → global latest-artworks feed (spec §3.1)
|
||||||
|
*/
|
||||||
|
final class GlobalFeedController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly RSSFeedBuilder $builder) {}
|
||||||
|
|
||||||
|
public function __invoke(): Response
|
||||||
|
{
|
||||||
|
$feedUrl = url('/rss');
|
||||||
|
$artworks = Cache::remember('rss:global', 300, fn () =>
|
||||||
|
Artwork::public()->published()
|
||||||
|
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
|
||||||
|
->latest('published_at')
|
||||||
|
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||||
|
->get()
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->builder->buildFromArtworks(
|
||||||
|
'Latest Artworks',
|
||||||
|
'The newest artworks published on Skinbase.',
|
||||||
|
$feedUrl,
|
||||||
|
$artworks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Http/Controllers/RSS/TagFeedController.php
Normal file
49
app/Http/Controllers/RSS/TagFeedController.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\RSS;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\Tag;
|
||||||
|
use App\Services\RSS\RSSFeedBuilder;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TagFeedController
|
||||||
|
*
|
||||||
|
* GET /rss/tag/{slug} → artworks tagged with given slug (spec §3.4)
|
||||||
|
*/
|
||||||
|
final class TagFeedController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly RSSFeedBuilder $builder) {}
|
||||||
|
|
||||||
|
public function __invoke(string $slug): Response
|
||||||
|
{
|
||||||
|
$tag = Tag::where('slug', $slug)->first();
|
||||||
|
|
||||||
|
if (! $tag) {
|
||||||
|
throw new NotFoundHttpException("Tag [{$slug}] not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$feedUrl = url('/rss/tag/' . $slug);
|
||||||
|
$artworks = Cache::remember('rss:tag:' . $slug, 600, fn () =>
|
||||||
|
Artwork::public()->published()
|
||||||
|
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
|
||||||
|
->whereHas('tags', fn ($q) => $q->where('tags.id', $tag->id))
|
||||||
|
->latest('artworks.published_at')
|
||||||
|
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||||
|
->get()
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->builder->buildFromArtworks(
|
||||||
|
ucwords(str_replace('-', ' ', $slug)) . ' Artworks',
|
||||||
|
'Latest Skinbase artworks tagged "' . $tag->name . '".',
|
||||||
|
$feedUrl,
|
||||||
|
$artworks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ class TopAuthorsController extends Controller
|
|||||||
$query = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
|
$query = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
|
||||||
->mergeBindings($sub->getQuery())
|
->mergeBindings($sub->getQuery())
|
||||||
->join('users as u', 'u.id', '=', 't.user_id')
|
->join('users as u', 'u.id', '=', 't.user_id')
|
||||||
->select('u.id as user_id', 'u.name as uname', 'u.username', 't.total_metric', 't.latest_published')
|
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||||
|
->select('u.id as user_id', 'u.name as uname', 'u.username', 'up.avatar_hash', 't.total_metric', 't.latest_published')
|
||||||
->orderByDesc('t.total_metric')
|
->orderByDesc('t.total_metric')
|
||||||
->orderByDesc('t.latest_published');
|
->orderByDesc('t.latest_published');
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ class TopAuthorsController extends Controller
|
|||||||
'user_id' => $row->user_id,
|
'user_id' => $row->user_id,
|
||||||
'uname' => $row->uname,
|
'uname' => $row->uname,
|
||||||
'username' => $row->username,
|
'username' => $row->username,
|
||||||
|
'avatar_hash' => $row->avatar_hash,
|
||||||
'total' => (int) $row->total_metric,
|
'total' => (int) $row->total_metric,
|
||||||
'metric' => $metric,
|
'metric' => $metric,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -13,18 +13,66 @@ use Illuminate\View\View;
|
|||||||
/**
|
/**
|
||||||
* RssFeedController
|
* RssFeedController
|
||||||
*
|
*
|
||||||
* GET /rss-feeds → info page listing available feeds
|
* GET /rss-feeds → info page listing all available feeds
|
||||||
* GET /rss/latest-uploads.xml → all published artworks
|
* GET /rss/latest-uploads.xml → all published artworks (legacy)
|
||||||
* GET /rss/latest-skins.xml → skins only
|
* GET /rss/latest-skins.xml → skins only (legacy)
|
||||||
* GET /rss/latest-wallpapers.xml → wallpapers only
|
* GET /rss/latest-wallpapers.xml → wallpapers only (legacy)
|
||||||
* GET /rss/latest-photos.xml → photography only
|
* GET /rss/latest-photos.xml → photography only (legacy)
|
||||||
|
*
|
||||||
|
* Nova feeds live in App\Http\Controllers\RSS\*.
|
||||||
*/
|
*/
|
||||||
final class RssFeedController extends Controller
|
final class RssFeedController extends Controller
|
||||||
{
|
{
|
||||||
/** Number of items per feed. */
|
/** Number of items per legacy feed. */
|
||||||
private const FEED_LIMIT = 25;
|
private const FEED_LIMIT = 25;
|
||||||
|
|
||||||
/** Feed definitions shown on the info page. */
|
/**
|
||||||
|
* Grouped feed definitions shown on the /rss-feeds info page.
|
||||||
|
* Each group has a 'label' and an array of 'feeds' with title + url.
|
||||||
|
*/
|
||||||
|
public const FEED_GROUPS = [
|
||||||
|
'global' => [
|
||||||
|
'label' => 'Global',
|
||||||
|
'feeds' => [
|
||||||
|
['title' => 'Latest Artworks', 'url' => '/rss', 'description' => 'All new artworks across the platform.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'discover' => [
|
||||||
|
'label' => 'Discover',
|
||||||
|
'feeds' => [
|
||||||
|
['title' => 'Fresh Uploads', 'url' => '/rss/discover/fresh', 'description' => 'The newest artworks just published.'],
|
||||||
|
['title' => 'Trending', 'url' => '/rss/discover/trending', 'description' => 'Most-viewed artworks over the past 7 days.'],
|
||||||
|
['title' => 'Rising', 'url' => '/rss/discover/rising', 'description' => 'Artworks gaining momentum right now.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'explore' => [
|
||||||
|
'label' => 'Explore',
|
||||||
|
'feeds' => [
|
||||||
|
['title' => 'All Artworks', 'url' => '/rss/explore/artworks', 'description' => 'Latest artworks of all types.'],
|
||||||
|
['title' => 'Wallpapers', 'url' => '/rss/explore/wallpapers', 'description' => 'Latest wallpapers.'],
|
||||||
|
['title' => 'Skins', 'url' => '/rss/explore/skins', 'description' => 'Latest skins.'],
|
||||||
|
['title' => 'Photography', 'url' => '/rss/explore/photography', 'description' => 'Latest photography.'],
|
||||||
|
['title' => 'Trending Wallpapers', 'url' => '/rss/explore/wallpapers/trending', 'description' => 'Trending wallpapers this week.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'blog' => [
|
||||||
|
'label' => 'Blog',
|
||||||
|
'feeds' => [
|
||||||
|
['title' => 'Blog Posts', 'url' => '/rss/blog', 'description' => 'Latest posts from the Skinbase blog.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'legacy' => [
|
||||||
|
'label' => 'Legacy Feeds',
|
||||||
|
'feeds' => [
|
||||||
|
['title' => 'Latest Uploads (XML)', 'url' => '/rss/latest-uploads.xml', 'description' => 'Legacy XML feed.'],
|
||||||
|
['title' => 'Latest Skins (XML)', 'url' => '/rss/latest-skins.xml', 'description' => 'Legacy XML feed.'],
|
||||||
|
['title' => 'Latest Wallpapers (XML)', 'url' => '/rss/latest-wallpapers.xml', 'description' => 'Legacy XML feed.'],
|
||||||
|
['title' => 'Latest Photos (XML)', 'url' => '/rss/latest-photos.xml', 'description' => 'Legacy XML feed.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Flat feed list kept for backward-compatibility (old view logic). */
|
||||||
public const FEEDS = [
|
public const FEEDS = [
|
||||||
'uploads' => ['title' => 'Latest Uploads', 'url' => '/rss/latest-uploads.xml'],
|
'uploads' => ['title' => 'Latest Uploads', 'url' => '/rss/latest-uploads.xml'],
|
||||||
'skins' => ['title' => 'Latest Skins', 'url' => '/rss/latest-skins.xml'],
|
'skins' => ['title' => 'Latest Skins', 'url' => '/rss/latest-skins.xml'],
|
||||||
@@ -46,6 +94,7 @@ final class RssFeedController extends Controller
|
|||||||
(object) ['name' => 'RSS Feeds', 'url' => '/rss-feeds'],
|
(object) ['name' => 'RSS Feeds', 'url' => '/rss-feeds'],
|
||||||
]),
|
]),
|
||||||
'feeds' => self::FEEDS,
|
'feeds' => self::FEEDS,
|
||||||
|
'feed_groups' => self::FEED_GROUPS,
|
||||||
'center_content' => true,
|
'center_content' => true,
|
||||||
'center_max' => '3xl',
|
'center_max' => '3xl',
|
||||||
]);
|
]);
|
||||||
|
|||||||
59
app/Http/Controllers/Web/StoriesAuthorController.php
Normal file
59
app/Http/Controllers/Web/StoriesAuthorController.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Story;
|
||||||
|
use App\Models\StoryAuthor;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stories filtered by author — /stories/author/{username}
|
||||||
|
*/
|
||||||
|
final class StoriesAuthorController extends Controller
|
||||||
|
{
|
||||||
|
public function show(Request $request, string $username): View
|
||||||
|
{
|
||||||
|
// Resolve by linked user username first, then by author name slug
|
||||||
|
$author = StoryAuthor::whereHas('user', fn ($q) => $q->where('username', $username))
|
||||||
|
->with('user')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $author) {
|
||||||
|
// Fallback: author name matches slug-style
|
||||||
|
$author = StoryAuthor::where('name', $username)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $author) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stories = Cache::remember('stories:author:' . $author->id . ':page:' . ($request->get('page', 1)), 300, fn () =>
|
||||||
|
Story::published()
|
||||||
|
->with('author', 'tags')
|
||||||
|
->where('author_id', $author->id)
|
||||||
|
->orderByDesc('published_at')
|
||||||
|
->paginate(12)
|
||||||
|
->withQueryString()
|
||||||
|
);
|
||||||
|
|
||||||
|
$authorName = $author->user?->username ?? $author->name;
|
||||||
|
|
||||||
|
return view('web.stories.author', [
|
||||||
|
'author' => $author,
|
||||||
|
'stories' => $stories,
|
||||||
|
'page_title' => 'Stories by ' . $authorName . ' — Skinbase',
|
||||||
|
'page_meta_description' => 'All stories and interviews by ' . $authorName . ' on Skinbase.',
|
||||||
|
'page_canonical' => url('/stories/author/' . $username),
|
||||||
|
'page_robots' => 'index,follow',
|
||||||
|
'breadcrumbs' => collect([
|
||||||
|
(object) ['name' => 'Stories', 'url' => '/stories'],
|
||||||
|
(object) ['name' => $authorName, 'url' => '/stories/author/' . $username],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Http/Controllers/Web/StoriesController.php
Normal file
47
app/Http/Controllers/Web/StoriesController.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Story;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stories listing page — /stories
|
||||||
|
*/
|
||||||
|
final class StoriesController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$featured = Cache::remember('stories:featured', 300, fn () =>
|
||||||
|
Story::published()->featured()
|
||||||
|
->with('author', 'tags')
|
||||||
|
->orderByDesc('published_at')
|
||||||
|
->first()
|
||||||
|
);
|
||||||
|
|
||||||
|
$stories = Cache::remember('stories:list:page:' . ($request->get('page', 1)), 300, fn () =>
|
||||||
|
Story::published()
|
||||||
|
->with('author', 'tags')
|
||||||
|
->orderByDesc('published_at')
|
||||||
|
->paginate(12)
|
||||||
|
->withQueryString()
|
||||||
|
);
|
||||||
|
|
||||||
|
return view('web.stories.index', [
|
||||||
|
'featured' => $featured,
|
||||||
|
'stories' => $stories,
|
||||||
|
'page_title' => 'Stories — Skinbase',
|
||||||
|
'page_meta_description' => 'Artist interviews, community spotlights, tutorials and announcements from Skinbase.',
|
||||||
|
'page_canonical' => url('/stories'),
|
||||||
|
'page_robots' => 'index,follow',
|
||||||
|
'breadcrumbs' => collect([
|
||||||
|
(object) ['name' => 'Stories', 'url' => '/stories'],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Http/Controllers/Web/StoriesTagController.php
Normal file
45
app/Http/Controllers/Web/StoriesTagController.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Story;
|
||||||
|
use App\Models\StoryTag;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stories filtered by tag — /stories/tag/{tag}
|
||||||
|
*/
|
||||||
|
final class StoriesTagController extends Controller
|
||||||
|
{
|
||||||
|
public function show(Request $request, string $tag): View
|
||||||
|
{
|
||||||
|
$storyTag = StoryTag::where('slug', $tag)->firstOrFail();
|
||||||
|
|
||||||
|
$stories = Cache::remember('stories:tag:' . $tag . ':page:' . ($request->get('page', 1)), 300, fn () =>
|
||||||
|
Story::published()
|
||||||
|
->with('author', 'tags')
|
||||||
|
->whereHas('tags', fn ($q) => $q->where('stories_tags.id', $storyTag->id))
|
||||||
|
->orderByDesc('published_at')
|
||||||
|
->paginate(12)
|
||||||
|
->withQueryString()
|
||||||
|
);
|
||||||
|
|
||||||
|
return view('web.stories.tag', [
|
||||||
|
'storyTag' => $storyTag,
|
||||||
|
'stories' => $stories,
|
||||||
|
'page_title' => '#' . $storyTag->name . ' Stories — Skinbase',
|
||||||
|
'page_meta_description' => 'Stories tagged with "' . $storyTag->name . '" on Skinbase.',
|
||||||
|
'page_canonical' => url('/stories/tag/' . $storyTag->slug),
|
||||||
|
'page_robots' => 'index,follow',
|
||||||
|
'breadcrumbs' => collect([
|
||||||
|
(object) ['name' => 'Stories', 'url' => '/stories'],
|
||||||
|
(object) ['name' => '#' . $storyTag->name, 'url' => '/stories/tag/' . $storyTag->slug],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Http/Controllers/Web/StoryController.php
Normal file
86
app/Http/Controllers/Web/StoryController.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Story;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single story page — /stories/{slug}
|
||||||
|
*/
|
||||||
|
final class StoryController extends Controller
|
||||||
|
{
|
||||||
|
public function show(string $slug): View
|
||||||
|
{
|
||||||
|
$story = Cache::remember('stories:' . $slug, 600, fn () =>
|
||||||
|
Story::published()
|
||||||
|
->with('author', 'tags')
|
||||||
|
->where('slug', $slug)
|
||||||
|
->firstOrFail()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Increment view counter (fire-and-forget, no cache invalidation needed)
|
||||||
|
Story::where('id', $story->id)->increment('views');
|
||||||
|
|
||||||
|
// Related stories: shared tags → same author → newest
|
||||||
|
$related = Cache::remember('stories:related:' . $story->id, 600, function () use ($story) {
|
||||||
|
$tagIds = $story->tags->pluck('id');
|
||||||
|
|
||||||
|
$related = collect();
|
||||||
|
|
||||||
|
if ($tagIds->isNotEmpty()) {
|
||||||
|
$related = Story::published()
|
||||||
|
->with('author', 'tags')
|
||||||
|
->whereHas('tags', fn ($q) => $q->whereIn('stories_tags.id', $tagIds))
|
||||||
|
->where('id', '!=', $story->id)
|
||||||
|
->orderByDesc('published_at')
|
||||||
|
->limit(6)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($related->count() < 3 && $story->author_id) {
|
||||||
|
$byAuthor = Story::published()
|
||||||
|
->with('author', 'tags')
|
||||||
|
->where('author_id', $story->author_id)
|
||||||
|
->where('id', '!=', $story->id)
|
||||||
|
->whereNotIn('id', $related->pluck('id'))
|
||||||
|
->orderByDesc('published_at')
|
||||||
|
->limit(6 - $related->count())
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$related = $related->merge($byAuthor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($related->count() < 3) {
|
||||||
|
$newest = Story::published()
|
||||||
|
->with('author', 'tags')
|
||||||
|
->where('id', '!=', $story->id)
|
||||||
|
->whereNotIn('id', $related->pluck('id'))
|
||||||
|
->orderByDesc('published_at')
|
||||||
|
->limit(6 - $related->count())
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$related = $related->merge($newest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $related->take(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
return view('web.stories.show', [
|
||||||
|
'story' => $story,
|
||||||
|
'related' => $related,
|
||||||
|
'page_title' => $story->title . ' — Skinbase Stories',
|
||||||
|
'page_meta_description' => $story->meta_excerpt,
|
||||||
|
'page_canonical' => $story->url,
|
||||||
|
'page_robots' => 'index,follow',
|
||||||
|
'breadcrumbs' => collect([
|
||||||
|
(object) ['name' => 'Stories', 'url' => '/stories'],
|
||||||
|
(object) ['name' => $story->title, 'url' => $story->url],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use App\Models\ContentType;
|
|||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Services\ArtworkSearchService;
|
use App\Services\ArtworkSearchService;
|
||||||
use App\Services\EarlyGrowth\GridFiller;
|
use App\Services\EarlyGrowth\GridFiller;
|
||||||
|
use App\Services\ThumbnailPresenter;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
@@ -60,11 +61,10 @@ final class TagController extends Controller
|
|||||||
$page = max(1, (int) $request->query('page', 1));
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
$artworks = $this->gridFiller->fill($artworks, 0, $page);
|
$artworks = $this->gridFiller->fill($artworks, 0, $page);
|
||||||
|
|
||||||
// Eager-load relations needed by the artwork-card component.
|
// Eager-load relations used by the gallery presenter and thumbnails.
|
||||||
// Scout returns bare Eloquent models; without this, each card triggers N+1 queries.
|
$artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories']));
|
||||||
$artworks->getCollection()->loadMissing(['user.profile']);
|
|
||||||
|
|
||||||
// Sidebar: content type links (same as browse gallery)
|
// Sidebar: main content type links (same as browse gallery)
|
||||||
$mainCategories = ContentType::orderBy('id')->get(['name', 'slug'])
|
$mainCategories = ContentType::orderBy('id')->get(['name', 'slug'])
|
||||||
->map(fn ($type) => (object) [
|
->map(fn ($type) => (object) [
|
||||||
'id' => $type->id,
|
'id' => $type->id,
|
||||||
@@ -73,14 +73,75 @@ final class TagController extends Controller
|
|||||||
'url' => '/' . strtolower($type->slug),
|
'url' => '/' . strtolower($type->slug),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return view('tags.show', [
|
// Map artworks into the lightweight shape expected by the gallery React component.
|
||||||
'tag' => $tag,
|
$galleryCollection = $artworks->getCollection()->map(function ($a) {
|
||||||
|
$primaryCategory = $a->categories->sortBy('sort_order')->first();
|
||||||
|
$present = ThumbnailPresenter::present($a, 'md');
|
||||||
|
$avatarUrl = \App\Support\AvatarUrl::forUser((int) ($a->user_id ?? 0), $a->user?->profile?->avatar_hash ?? null, 64);
|
||||||
|
|
||||||
|
return (object) [
|
||||||
|
'id' => $a->id,
|
||||||
|
'name' => $a->title ?? ($a->name ?? null),
|
||||||
|
'category_name' => $primaryCategory->name ?? '',
|
||||||
|
'category_slug' => $primaryCategory->slug ?? '',
|
||||||
|
'thumb_url' => $present['url'] ?? ($a->thumbUrl('md') ?? null),
|
||||||
|
'thumb_srcset' => $present['srcset'] ?? null,
|
||||||
|
'uname' => $a->user?->name ?? '',
|
||||||
|
'username' => $a->user?->username ?? '',
|
||||||
|
'avatar_url' => $avatarUrl,
|
||||||
|
'published_at' => $a->published_at ?? null,
|
||||||
|
'width' => $a->width ?? null,
|
||||||
|
'height' => $a->height ?? null,
|
||||||
|
'slug' => $a->slug ?? null,
|
||||||
|
];
|
||||||
|
})->values();
|
||||||
|
|
||||||
|
// Replace paginator collection with the gallery-shaped collection so
|
||||||
|
// the gallery.index blade will generate the expected JSON payload.
|
||||||
|
if (method_exists($artworks, 'setCollection')) {
|
||||||
|
$artworks->setCollection($galleryCollection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine gallery sort mapping so the gallery UI highlights the right tab.
|
||||||
|
$sortMapToGallery = [
|
||||||
|
'popular' => 'trending',
|
||||||
|
'latest' => 'latest',
|
||||||
|
'likes' => 'top-rated',
|
||||||
|
'downloads' => 'downloaded',
|
||||||
|
];
|
||||||
|
$gallerySort = $sortMapToGallery[$sort] ?? 'trending';
|
||||||
|
|
||||||
|
// Build simple pagination SEO links
|
||||||
|
$prev = method_exists($artworks, 'previousPageUrl') ? $artworks->previousPageUrl() : null;
|
||||||
|
$next = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
|
||||||
|
|
||||||
|
return view('gallery.index', [
|
||||||
|
'gallery_type' => 'tag',
|
||||||
|
'mainCategories' => $mainCategories,
|
||||||
|
'subcategories' => collect(),
|
||||||
|
'contentType' => null,
|
||||||
|
'category' => null,
|
||||||
'artworks' => $artworks,
|
'artworks' => $artworks,
|
||||||
'sort' => $sort,
|
'current_sort' => $gallerySort,
|
||||||
'ogImage' => null,
|
'sort_options' => [
|
||||||
|
['value' => 'trending', 'label' => '🔥 Trending'],
|
||||||
|
['value' => 'fresh', 'label' => '🆕 New & Hot'],
|
||||||
|
['value' => 'top-rated', 'label' => '⭐ Top Rated'],
|
||||||
|
['value' => 'latest', 'label' => '🕐 Latest'],
|
||||||
|
],
|
||||||
|
'hero_title' => $tag->name,
|
||||||
|
'hero_description' => 'Artworks tagged "' . $tag->name . '"',
|
||||||
|
'breadcrumbs' => collect([
|
||||||
|
(object) ['name' => 'Home', 'url' => '/'],
|
||||||
|
(object) ['name' => 'Tags', 'url' => route('tags.index')],
|
||||||
|
(object) ['name' => $tag->name, 'url' => route('tags.show', $tag->slug)],
|
||||||
|
]),
|
||||||
'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase',
|
'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase',
|
||||||
'page_meta_description' => 'Browse all Skinbase artworks tagged "' . $tag->name . '". Discover photography, wallpapers and skins.',
|
'page_meta_description' => 'Browse all Skinbase artworks tagged "' . $tag->name . '".',
|
||||||
|
'page_meta_keywords' => $tag->slug . ', skinbase, artworks, tag',
|
||||||
'page_canonical' => route('tags.show', $tag->slug),
|
'page_canonical' => route('tags.show', $tag->slug),
|
||||||
|
'page_rel_prev' => $prev,
|
||||||
|
'page_rel_next' => $next,
|
||||||
'page_robots' => 'index,follow',
|
'page_robots' => 'index,follow',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
|
|
||||||
class EnsureOnboardingComplete
|
class EnsureOnboardingComplete
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Paths that must always be reachable regardless of onboarding state,
|
||||||
|
* so authenticated users can log out, complete OAuth flows, etc.
|
||||||
|
*/
|
||||||
|
private const ALWAYS_ALLOW = [
|
||||||
|
'logout',
|
||||||
|
'auth/*', // OAuth redirects & callbacks
|
||||||
|
'verify/*', // email verification links
|
||||||
|
'setup/*', // all /setup/* pages (password, username)
|
||||||
|
'up', // health check
|
||||||
|
];
|
||||||
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
@@ -20,6 +32,11 @@ class EnsureOnboardingComplete
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always allow critical auth / setup paths through.
|
||||||
|
if ($request->is(self::ALWAYS_ALLOW)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
$target = match ($step) {
|
$target = match ($step) {
|
||||||
'email' => '/login',
|
'email' => '/login',
|
||||||
'verified' => '/setup/password',
|
'verified' => '/setup/password',
|
||||||
@@ -27,10 +44,6 @@ class EnsureOnboardingComplete
|
|||||||
default => '/setup/password',
|
default => '/setup/password',
|
||||||
};
|
};
|
||||||
|
|
||||||
if ($request->is(ltrim($target, '/'))) {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect($target);
|
return redirect($target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ class VerifyCsrfToken extends Middleware
|
|||||||
protected $except = [
|
protected $except = [
|
||||||
'chat_post',
|
'chat_post',
|
||||||
'chat_post/*',
|
'chat_post/*',
|
||||||
|
// Apple Sign In removed — no special CSRF exception required
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
24
app/Models/SocialAccount.php
Normal file
24
app/Models/SocialAccount.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class SocialAccount extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'social_accounts';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'provider',
|
||||||
|
'provider_id',
|
||||||
|
'provider_email',
|
||||||
|
'avatar',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
113
app/Models/Story.php
Normal file
113
app/Models/Story.php
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Story — editorial content replacing the legacy Interviews module.
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property string $slug
|
||||||
|
* @property string $title
|
||||||
|
* @property string|null $excerpt
|
||||||
|
* @property string|null $content
|
||||||
|
* @property string|null $cover_image
|
||||||
|
* @property int|null $author_id
|
||||||
|
* @property int $views
|
||||||
|
* @property bool $featured
|
||||||
|
* @property string $status draft|published
|
||||||
|
* @property \Carbon\Carbon|null $published_at
|
||||||
|
* @property int|null $legacy_interview_id
|
||||||
|
*/
|
||||||
|
class Story extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'stories';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'slug',
|
||||||
|
'title',
|
||||||
|
'excerpt',
|
||||||
|
'content',
|
||||||
|
'cover_image',
|
||||||
|
'author_id',
|
||||||
|
'views',
|
||||||
|
'featured',
|
||||||
|
'status',
|
||||||
|
'published_at',
|
||||||
|
'legacy_interview_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'featured' => 'boolean',
|
||||||
|
'published_at' => 'datetime',
|
||||||
|
'views' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Relations ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function author()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(StoryAuthor::class, 'author_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tags()
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(StoryTag::class, 'stories_tag_relation', 'story_id', 'tag_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scopes ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function scopePublished($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', 'published')
|
||||||
|
->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeFeatured($query)
|
||||||
|
{
|
||||||
|
return $query->where('featured', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Accessors ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function getUrlAttribute(): string
|
||||||
|
{
|
||||||
|
return url('/stories/' . $this->slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCoverUrlAttribute(): ?string
|
||||||
|
{
|
||||||
|
if (! $this->cover_image) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_starts_with($this->cover_image, 'http') ? $this->cover_image : asset($this->cover_image);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimated reading time in minutes based on word count.
|
||||||
|
*/
|
||||||
|
public function getReadingTimeAttribute(): int
|
||||||
|
{
|
||||||
|
$wordCount = str_word_count(strip_tags((string) $this->content));
|
||||||
|
|
||||||
|
return max(1, (int) ceil($wordCount / 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Short excerpt for meta descriptions / cards.
|
||||||
|
* Strips HTML, truncates to ~160 characters.
|
||||||
|
*/
|
||||||
|
public function getMetaExcerptAttribute(): string
|
||||||
|
{
|
||||||
|
$text = $this->excerpt ?: strip_tags((string) $this->content);
|
||||||
|
|
||||||
|
return \Illuminate\Support\Str::limit($text, 160);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Models/StoryAuthor.php
Normal file
63
app/Models/StoryAuthor.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Story Author - flexible author entity for the Stories system.
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property int|null $user_id
|
||||||
|
* @property string $name
|
||||||
|
* @property string|null $avatar
|
||||||
|
* @property string|null $bio
|
||||||
|
*/
|
||||||
|
class StoryAuthor extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'stories_authors';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'name',
|
||||||
|
'avatar',
|
||||||
|
'bio',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Relations ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stories()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Story::class, 'author_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Accessors ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function getAvatarUrlAttribute(): string
|
||||||
|
{
|
||||||
|
if ($this->avatar) {
|
||||||
|
return str_starts_with($this->avatar, 'http') ? $this->avatar : asset($this->avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
return asset('gfx/default-avatar.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProfileUrlAttribute(): string
|
||||||
|
{
|
||||||
|
if ($this->user) {
|
||||||
|
return url('/@' . $this->user->username);
|
||||||
|
}
|
||||||
|
|
||||||
|
return url('/stories');
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Models/StoryTag.php
Normal file
41
app/Models/StoryTag.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Story Tag — editorial tag for the Stories system.
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property string $slug
|
||||||
|
* @property string $name
|
||||||
|
*/
|
||||||
|
class StoryTag extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'stories_tags';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'slug',
|
||||||
|
'name',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Relations ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function stories()
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Story::class, 'stories_tag_relation', 'tag_id', 'story_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Accessors ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function getUrlAttribute(): string
|
||||||
|
{
|
||||||
|
return url('/stories/tag/' . $this->slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use App\Models\SocialAccount;
|
||||||
use App\Models\Conversation;
|
use App\Models\Conversation;
|
||||||
use App\Models\ConversationParticipant;
|
use App\Models\ConversationParticipant;
|
||||||
use App\Models\Message;
|
use App\Models\Message;
|
||||||
@@ -76,6 +77,11 @@ class User extends Authenticatable
|
|||||||
return $this->hasMany(Artwork::class);
|
return $this->hasMany(Artwork::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function socialAccounts(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(SocialAccount::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function profile(): HasOne
|
public function profile(): HasOne
|
||||||
{
|
{
|
||||||
return $this->hasOne(UserProfile::class, 'user_id');
|
return $this->hasOne(UserProfile::class, 'user_id');
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
ArtworkComment::observe(ArtworkCommentObserver::class);
|
ArtworkComment::observe(ArtworkCommentObserver::class);
|
||||||
ArtworkReaction::observe(ArtworkReactionObserver::class);
|
ArtworkReaction::observe(ArtworkReactionObserver::class);
|
||||||
|
|
||||||
|
// ── OAuth / SocialiteProviders ──────────────────────────────────────
|
||||||
|
Event::listen(
|
||||||
|
\SocialiteProviders\Manager\SocialiteWasCalled::class,
|
||||||
|
\SocialiteProviders\Discord\DiscordExtendSocialite::class,
|
||||||
|
);
|
||||||
|
// Apple provider removed — no listener registered
|
||||||
|
|
||||||
// ── Posts / Feed System Events ──────────────────────────────────────
|
// ── Posts / Feed System Events ──────────────────────────────────────
|
||||||
Event::listen(
|
Event::listen(
|
||||||
\App\Events\Posts\ArtworkShared::class,
|
\App\Events\Posts\ArtworkShared::class,
|
||||||
|
|||||||
@@ -594,7 +594,7 @@ final class HomepageService
|
|||||||
$authorName = $artwork->user?->name ?? 'Artist';
|
$authorName = $artwork->user?->name ?? 'Artist';
|
||||||
$authorUsername = $artwork->user?->username ?? '';
|
$authorUsername = $artwork->user?->username ?? '';
|
||||||
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
|
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
|
||||||
$authorAvatar = AvatarUrl::forUser((int) $authorId, $avatarHash, 40);
|
$authorAvatar = AvatarUrl::forUser((int) $authorId, $avatarHash, 64);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $artwork->id,
|
'id' => $artwork->id,
|
||||||
|
|||||||
138
app/Services/RSS/RSSFeedBuilder.php
Normal file
138
app/Services/RSS/RSSFeedBuilder.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\RSS;
|
||||||
|
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RSSFeedBuilder
|
||||||
|
*
|
||||||
|
* Responsible for:
|
||||||
|
* - normalising feed items from Eloquent collections
|
||||||
|
* - enforcing feed limits (max 20 items)
|
||||||
|
* - rendering RSS 2.0 XML via the rss.channel Blade template
|
||||||
|
* - returning a properly typed HTTP Response
|
||||||
|
*/
|
||||||
|
final class RSSFeedBuilder
|
||||||
|
{
|
||||||
|
/** Hard item limit per feed (spec §7). */
|
||||||
|
public const FEED_LIMIT = 20;
|
||||||
|
|
||||||
|
// ── Public builders ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an RSS 2.0 Response from an Artwork Eloquent collection.
|
||||||
|
* Artworks must have 'user' and 'categories' relations preloaded.
|
||||||
|
*/
|
||||||
|
public function buildFromArtworks(
|
||||||
|
string $channelTitle,
|
||||||
|
string $channelDescription,
|
||||||
|
string $feedUrl,
|
||||||
|
Collection $artworks,
|
||||||
|
): Response {
|
||||||
|
$items = $artworks->take(self::FEED_LIMIT)->map(fn ($a) => $this->artworkToItem($a));
|
||||||
|
|
||||||
|
return $this->buildResponse($channelTitle, $channelDescription, url('/'), $feedUrl, $items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an RSS 2.0 Response from a BlogPost Eloquent collection.
|
||||||
|
* Posts must have 'author' relation preloaded.
|
||||||
|
*/
|
||||||
|
public function buildFromBlogPosts(
|
||||||
|
string $channelTitle,
|
||||||
|
string $channelDescription,
|
||||||
|
string $feedUrl,
|
||||||
|
Collection $posts,
|
||||||
|
): Response {
|
||||||
|
$items = $posts->take(self::FEED_LIMIT)->map(fn ($p) => $this->blogPostToItem($p));
|
||||||
|
|
||||||
|
return $this->buildResponse($channelTitle, $channelDescription, url('/blog'), $feedUrl, $items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function buildResponse(
|
||||||
|
string $channelTitle,
|
||||||
|
string $channelDescription,
|
||||||
|
string $channelLink,
|
||||||
|
string $feedUrl,
|
||||||
|
Collection $items,
|
||||||
|
): Response {
|
||||||
|
$xml = view('rss.channel', [
|
||||||
|
'channelTitle' => trim($channelTitle) . ' — Skinbase',
|
||||||
|
'channelDescription' => $channelDescription,
|
||||||
|
'channelLink' => $channelLink,
|
||||||
|
'feedUrl' => $feedUrl,
|
||||||
|
'items' => $items,
|
||||||
|
'buildDate' => now()->toRfc2822String(),
|
||||||
|
])->render();
|
||||||
|
|
||||||
|
return response($xml, 200, [
|
||||||
|
'Content-Type' => 'application/rss+xml; charset=utf-8',
|
||||||
|
'Cache-Control' => 'public, max-age=300',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert an Artwork model to an RSS item array. */
|
||||||
|
private function artworkToItem(object $artwork): array
|
||||||
|
{
|
||||||
|
$link = url('/art/' . $artwork->id . '/' . ($artwork->slug ?? ''));
|
||||||
|
$thumb = method_exists($artwork, 'thumbUrl') ? $artwork->thumbUrl('sm') : null;
|
||||||
|
|
||||||
|
// Primary category from eagerly loaded relation (avoid N+1)
|
||||||
|
$primaryCategory = ($artwork->relationLoaded('categories'))
|
||||||
|
? $artwork->categories->first()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Build HTML description embedded in CDATA
|
||||||
|
$descParts = [];
|
||||||
|
if ($thumb) {
|
||||||
|
$descParts[] = '<img src="' . htmlspecialchars($thumb, ENT_XML1) . '" '
|
||||||
|
. 'alt="' . htmlspecialchars((string) $artwork->title, ENT_XML1) . '" />';
|
||||||
|
}
|
||||||
|
if (!empty($artwork->description)) {
|
||||||
|
$descParts[] = '<p>' . htmlspecialchars(strip_tags((string) $artwork->description), ENT_XML1) . '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => (string) $artwork->title,
|
||||||
|
'link' => $link,
|
||||||
|
'guid' => $link,
|
||||||
|
'description' => implode('', $descParts),
|
||||||
|
'pubDate' => $artwork->published_at?->toRfc2822String(),
|
||||||
|
'author' => $artwork->user?->username ?? 'Unknown',
|
||||||
|
'category' => $primaryCategory?->name,
|
||||||
|
'enclosure' => $thumb ? [
|
||||||
|
'url' => $thumb,
|
||||||
|
'length' => 0,
|
||||||
|
'type' => 'image/jpeg',
|
||||||
|
] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a BlogPost model to an RSS item array. */
|
||||||
|
private function blogPostToItem(object $post): array
|
||||||
|
{
|
||||||
|
$link = url('/blog/' . $post->slug);
|
||||||
|
$excerpt = $post->excerpt ?? strip_tags((string) ($post->body ?? ''));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => (string) $post->title,
|
||||||
|
'link' => $link,
|
||||||
|
'guid' => $link,
|
||||||
|
'description' => $excerpt,
|
||||||
|
'pubDate' => $post->published_at?->toRfc2822String(),
|
||||||
|
'author' => $post->author?->username ?? 'Skinbase',
|
||||||
|
'category' => null,
|
||||||
|
'enclosure' => !empty($post->featured_image) ? [
|
||||||
|
'url' => $post->featured_image,
|
||||||
|
'length' => 0,
|
||||||
|
'type' => 'image/jpeg',
|
||||||
|
] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Support;
|
namespace App\Support;
|
||||||
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class AvatarUrl
|
class AvatarUrl
|
||||||
{
|
{
|
||||||
@@ -26,6 +27,9 @@ class AvatarUrl
|
|||||||
$p1 = substr($avatarHash, 0, 2);
|
$p1 = substr($avatarHash, 0, 2);
|
||||||
$p2 = substr($avatarHash, 2, 2);
|
$p2 = substr($avatarHash, 2, 2);
|
||||||
|
|
||||||
|
$diskPath = sprintf('avatars/%s/%s/%s/%d.webp', $p1, $p2, $avatarHash, $size);
|
||||||
|
|
||||||
|
// Always use CDN-hosted avatar files.
|
||||||
return sprintf('%s/avatars/%s/%s/%s/%d.webp?v=%s', $base, $p1, $p2, $avatarHash, $size, $avatarHash);
|
return sprintf('%s/avatars/%s/%s/%s/%d.webp?v=%s', $base, $p1, $p2, $avatarHash, $size, $avatarHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,15 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
\App\Http\Middleware\HandleInertiaRequests::class,
|
\App\Http\Middleware\HandleInertiaRequests::class,
|
||||||
|
// Runs on every web request; no-ops for guests, redirects authenticated
|
||||||
|
// users who have not finished onboarding (e.g. OAuth users awaiting username).
|
||||||
|
\App\Http\Middleware\EnsureOnboardingComplete::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'admin.moderation' => \App\Http\Middleware\EnsureAdminOrModerator::class,
|
'admin.moderation' => \App\Http\Middleware\EnsureAdminOrModerator::class,
|
||||||
'ensure.onboarding.complete'=> \App\Http\Middleware\EnsureOnboardingComplete::class,
|
'ensure.onboarding.complete'=> \App\Http\Middleware\EnsureOnboardingComplete::class,
|
||||||
|
'onboarding' => \App\Http\Middleware\EnsureOnboardingComplete::class,
|
||||||
'normalize.username' => \App\Http\Middleware\NormalizeUsername::class,
|
'normalize.username' => \App\Http\Middleware\NormalizeUsername::class,
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
@@ -82,6 +86,11 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In debug mode let Laravel/Ignition render the full error page.
|
||||||
|
if (config('app.debug')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$correlationId = app(\App\Services\NotFoundLogger::class)->log500($e, $request);
|
$correlationId = app(\App\Services\NotFoundLogger::class)->log500($e, $request);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
|
|||||||
@@ -14,10 +14,12 @@
|
|||||||
"intervention/image": "^3.11",
|
"intervention/image": "^3.11",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/scout": "^10.24",
|
"laravel/scout": "^10.24",
|
||||||
|
"laravel/socialite": "^5.24",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"league/commonmark": "^2.8",
|
"league/commonmark": "^2.8",
|
||||||
"meilisearch/meilisearch-php": "^1.16",
|
"meilisearch/meilisearch-php": "^1.16",
|
||||||
"predis/predis": "^3.4"
|
"predis/predis": "^3.4",
|
||||||
|
"socialiteproviders/discord": "^4.2"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|||||||
566
composer.lock
generated
566
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "e49ab9bf98b9dc4002e839deb7b45cdf",
|
"content-hash": "f41a3c183d57f21c1da57768230a539d",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -508,6 +508,69 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-03-06T22:45:56+00:00"
|
"time": "2025-03-06T22:45:56+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "firebase/php-jwt",
|
||||||
|
"version": "v7.0.3",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/firebase/php-jwt.git",
|
||||||
|
"reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/firebase/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e",
|
||||||
|
"reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"guzzlehttp/guzzle": "^7.4",
|
||||||
|
"phpspec/prophecy-phpunit": "^2.0",
|
||||||
|
"phpunit/phpunit": "^9.5",
|
||||||
|
"psr/cache": "^2.0||^3.0",
|
||||||
|
"psr/http-client": "^1.0",
|
||||||
|
"psr/http-factory": "^1.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-sodium": "Support EdDSA (Ed25519) signatures",
|
||||||
|
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Firebase\\JWT\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Neuman Vong",
|
||||||
|
"email": "neuman+pear@twilio.com",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Anant Narayanan",
|
||||||
|
"email": "anant@php.net",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
|
||||||
|
"homepage": "https://github.com/firebase/php-jwt",
|
||||||
|
"keywords": [
|
||||||
|
"jwt",
|
||||||
|
"php"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/firebase/php-jwt/issues",
|
||||||
|
"source": "https://github.com/firebase/php-jwt/tree/v7.0.3"
|
||||||
|
},
|
||||||
|
"time": "2026-02-25T22:16:40+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "fruitcake/php-cors",
|
"name": "fruitcake/php-cors",
|
||||||
"version": "v1.4.0",
|
"version": "v1.4.0",
|
||||||
@@ -1687,6 +1750,78 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-02-03T06:55:34+00:00"
|
"time": "2026-02-03T06:55:34+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "laravel/socialite",
|
||||||
|
"version": "v5.24.3",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/laravel/socialite.git",
|
||||||
|
"reference": "0feb62267e7b8abc68593ca37639ad302728c129"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/laravel/socialite/zipball/0feb62267e7b8abc68593ca37639ad302728c129",
|
||||||
|
"reference": "0feb62267e7b8abc68593ca37639ad302728c129",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"firebase/php-jwt": "^6.4|^7.0",
|
||||||
|
"guzzlehttp/guzzle": "^6.0|^7.0",
|
||||||
|
"illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"league/oauth1-client": "^1.11",
|
||||||
|
"php": "^7.2|^8.0",
|
||||||
|
"phpseclib/phpseclib": "^3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"mockery/mockery": "^1.0",
|
||||||
|
"orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8|^11.0",
|
||||||
|
"phpstan/phpstan": "^1.12.23",
|
||||||
|
"phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"aliases": {
|
||||||
|
"Socialite": "Laravel\\Socialite\\Facades\\Socialite"
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
"Laravel\\Socialite\\SocialiteServiceProvider"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "5.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Laravel\\Socialite\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Taylor Otwell",
|
||||||
|
"email": "taylor@laravel.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.",
|
||||||
|
"homepage": "https://laravel.com",
|
||||||
|
"keywords": [
|
||||||
|
"laravel",
|
||||||
|
"oauth"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/laravel/socialite/issues",
|
||||||
|
"source": "https://github.com/laravel/socialite"
|
||||||
|
},
|
||||||
|
"time": "2026-02-21T13:32:50+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/tinker",
|
"name": "laravel/tinker",
|
||||||
"version": "v2.11.1",
|
"version": "v2.11.1",
|
||||||
@@ -2130,6 +2265,82 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-09-21T08:32:55+00:00"
|
"time": "2024-09-21T08:32:55+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "league/oauth1-client",
|
||||||
|
"version": "v1.11.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/thephpleague/oauth1-client.git",
|
||||||
|
"reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055",
|
||||||
|
"reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"ext-openssl": "*",
|
||||||
|
"guzzlehttp/guzzle": "^6.0|^7.0",
|
||||||
|
"guzzlehttp/psr7": "^1.7|^2.0",
|
||||||
|
"php": ">=7.1||>=8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"ext-simplexml": "*",
|
||||||
|
"friendsofphp/php-cs-fixer": "^2.17",
|
||||||
|
"mockery/mockery": "^1.3.3",
|
||||||
|
"phpstan/phpstan": "^0.12.42",
|
||||||
|
"phpunit/phpunit": "^7.5||9.5"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-simplexml": "For decoding XML-based responses."
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "1.0-dev",
|
||||||
|
"dev-develop": "2.0-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"League\\OAuth1\\Client\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Ben Corlett",
|
||||||
|
"email": "bencorlett@me.com",
|
||||||
|
"homepage": "http://www.webcomm.com.au",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "OAuth 1.0 Client Library",
|
||||||
|
"keywords": [
|
||||||
|
"Authentication",
|
||||||
|
"SSO",
|
||||||
|
"authorization",
|
||||||
|
"bitbucket",
|
||||||
|
"identity",
|
||||||
|
"idp",
|
||||||
|
"oauth",
|
||||||
|
"oauth1",
|
||||||
|
"single sign on",
|
||||||
|
"trello",
|
||||||
|
"tumblr",
|
||||||
|
"twitter"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/thephpleague/oauth1-client/issues",
|
||||||
|
"source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0"
|
||||||
|
},
|
||||||
|
"time": "2024-12-10T19:59:05+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "league/uri",
|
"name": "league/uri",
|
||||||
"version": "7.8.0",
|
"version": "7.8.0",
|
||||||
@@ -2899,6 +3110,125 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-11-20T02:34:59+00:00"
|
"time": "2025-11-20T02:34:59+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "paragonie/constant_time_encoding",
|
||||||
|
"version": "v3.1.3",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/paragonie/constant_time_encoding.git",
|
||||||
|
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
|
||||||
|
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^8"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"infection/infection": "^0",
|
||||||
|
"nikic/php-fuzzer": "^0",
|
||||||
|
"phpunit/phpunit": "^9|^10|^11",
|
||||||
|
"vimeo/psalm": "^4|^5|^6"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"ParagonIE\\ConstantTime\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Paragon Initiative Enterprises",
|
||||||
|
"email": "security@paragonie.com",
|
||||||
|
"homepage": "https://paragonie.com",
|
||||||
|
"role": "Maintainer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Steve 'Sc00bz' Thomas",
|
||||||
|
"email": "steve@tobtu.com",
|
||||||
|
"homepage": "https://www.tobtu.com",
|
||||||
|
"role": "Original Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
|
||||||
|
"keywords": [
|
||||||
|
"base16",
|
||||||
|
"base32",
|
||||||
|
"base32_decode",
|
||||||
|
"base32_encode",
|
||||||
|
"base64",
|
||||||
|
"base64_decode",
|
||||||
|
"base64_encode",
|
||||||
|
"bin2hex",
|
||||||
|
"encoding",
|
||||||
|
"hex",
|
||||||
|
"hex2bin",
|
||||||
|
"rfc4648"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"email": "info@paragonie.com",
|
||||||
|
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
|
||||||
|
"source": "https://github.com/paragonie/constant_time_encoding"
|
||||||
|
},
|
||||||
|
"time": "2025-09-24T15:06:41+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "paragonie/random_compat",
|
||||||
|
"version": "v9.99.100",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/paragonie/random_compat.git",
|
||||||
|
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
|
||||||
|
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">= 7"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "4.*|5.*",
|
||||||
|
"vimeo/psalm": "^1"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Paragon Initiative Enterprises",
|
||||||
|
"email": "security@paragonie.com",
|
||||||
|
"homepage": "https://paragonie.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
|
||||||
|
"keywords": [
|
||||||
|
"csprng",
|
||||||
|
"polyfill",
|
||||||
|
"pseudorandom",
|
||||||
|
"random"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"email": "info@paragonie.com",
|
||||||
|
"issues": "https://github.com/paragonie/random_compat/issues",
|
||||||
|
"source": "https://github.com/paragonie/random_compat"
|
||||||
|
},
|
||||||
|
"time": "2020-10-15T08:29:30+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "php-http/discovery",
|
"name": "php-http/discovery",
|
||||||
"version": "1.20.0",
|
"version": "1.20.0",
|
||||||
@@ -3053,6 +3383,116 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-27T19:41:33+00:00"
|
"time": "2025-12-27T19:41:33+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "phpseclib/phpseclib",
|
||||||
|
"version": "3.0.49",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/phpseclib/phpseclib.git",
|
||||||
|
"reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/6233a1e12584754e6b5daa69fe1289b47775c1b9",
|
||||||
|
"reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"paragonie/constant_time_encoding": "^1|^2|^3",
|
||||||
|
"paragonie/random_compat": "^1.4|^2.0|^9.99.99",
|
||||||
|
"php": ">=5.6.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "*"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-dom": "Install the DOM extension to load XML formatted public keys.",
|
||||||
|
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
|
||||||
|
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
|
||||||
|
"ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
|
||||||
|
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"phpseclib/bootstrap.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"phpseclib3\\": "phpseclib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jim Wigginton",
|
||||||
|
"email": "terrafrost@php.net",
|
||||||
|
"role": "Lead Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Patrick Monnerat",
|
||||||
|
"email": "pm@datasphere.ch",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Andreas Fischer",
|
||||||
|
"email": "bantu@phpbb.com",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hans-Jürgen Petrich",
|
||||||
|
"email": "petrich@tronic-media.com",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Graham Campbell",
|
||||||
|
"email": "graham@alt-three.com",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
|
||||||
|
"homepage": "http://phpseclib.sourceforge.net",
|
||||||
|
"keywords": [
|
||||||
|
"BigInteger",
|
||||||
|
"aes",
|
||||||
|
"asn.1",
|
||||||
|
"asn1",
|
||||||
|
"blowfish",
|
||||||
|
"crypto",
|
||||||
|
"cryptography",
|
||||||
|
"encryption",
|
||||||
|
"rsa",
|
||||||
|
"security",
|
||||||
|
"sftp",
|
||||||
|
"signature",
|
||||||
|
"signing",
|
||||||
|
"ssh",
|
||||||
|
"twofish",
|
||||||
|
"x.509",
|
||||||
|
"x509"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/phpseclib/phpseclib/issues",
|
||||||
|
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.49"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/terrafrost",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://www.patreon.com/phpseclib",
|
||||||
|
"type": "patreon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-01-27T09:17:28+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "predis/predis",
|
"name": "predis/predis",
|
||||||
"version": "v3.4.1",
|
"version": "v3.4.1",
|
||||||
@@ -3805,6 +4245,130 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-12-14T04:43:48+00:00"
|
"time": "2025-12-14T04:43:48+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "socialiteproviders/discord",
|
||||||
|
"version": "4.2.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/SocialiteProviders/Discord.git",
|
||||||
|
"reference": "c71c379acfdca5ba4aa65a3db5ae5222852a919c"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/SocialiteProviders/Discord/zipball/c71c379acfdca5ba4aa65a3db5ae5222852a919c",
|
||||||
|
"reference": "c71c379acfdca5ba4aa65a3db5ae5222852a919c",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"php": "^7.4 || ^8.0",
|
||||||
|
"socialiteproviders/manager": "~4.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"SocialiteProviders\\Discord\\": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Christopher Eklund",
|
||||||
|
"email": "eklundchristopher@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Discord OAuth2 Provider for Laravel Socialite",
|
||||||
|
"keywords": [
|
||||||
|
"discord",
|
||||||
|
"laravel",
|
||||||
|
"oauth",
|
||||||
|
"provider",
|
||||||
|
"socialite"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"docs": "https://socialiteproviders.com/discord",
|
||||||
|
"issues": "https://github.com/socialiteproviders/providers/issues",
|
||||||
|
"source": "https://github.com/socialiteproviders/providers"
|
||||||
|
},
|
||||||
|
"time": "2023-07-24T23:28:47+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "socialiteproviders/manager",
|
||||||
|
"version": "v4.8.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/SocialiteProviders/Manager.git",
|
||||||
|
"reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/8180ec14bef230ec2351cff993d5d2d7ca470ef4",
|
||||||
|
"reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0",
|
||||||
|
"laravel/socialite": "^5.5",
|
||||||
|
"php": "^8.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"mockery/mockery": "^1.2",
|
||||||
|
"phpunit/phpunit": "^9.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"SocialiteProviders\\Manager\\ServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"SocialiteProviders\\Manager\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Andy Wendt",
|
||||||
|
"email": "andy@awendt.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Anton Komarev",
|
||||||
|
"email": "a.komarev@cybercog.su"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Miguel Piedrafita",
|
||||||
|
"email": "soy@miguelpiedrafita.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "atymic",
|
||||||
|
"email": "atymicq@gmail.com",
|
||||||
|
"homepage": "https://atymic.dev"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Easily add new or override built-in providers in Laravel Socialite.",
|
||||||
|
"homepage": "https://socialiteproviders.com",
|
||||||
|
"keywords": [
|
||||||
|
"laravel",
|
||||||
|
"manager",
|
||||||
|
"oauth",
|
||||||
|
"providers",
|
||||||
|
"socialite"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/socialiteproviders/manager/issues",
|
||||||
|
"source": "https://github.com/socialiteproviders/manager"
|
||||||
|
},
|
||||||
|
"time": "2025-02-24T19:33:30+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/clock",
|
"name": "symfony/clock",
|
||||||
"version": "v8.0.0",
|
"version": "v8.0.0",
|
||||||
|
|||||||
@@ -54,6 +54,21 @@ return [
|
|||||||
'timeout' => (int) env('TURNSTILE_TIMEOUT', 5),
|
'timeout' => (int) env('TURNSTILE_TIMEOUT', 5),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// ── OAuth providers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
'google' => [
|
||||||
|
'client_id' => env('GOOGLE_CLIENT_ID'),
|
||||||
|
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
|
||||||
|
'redirect' => env('GOOGLE_REDIRECT_URI', '/auth/google/callback'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'discord' => [
|
||||||
|
'client_id' => env('DISCORD_CLIENT_ID'),
|
||||||
|
'client_secret' => env('DISCORD_CLIENT_SECRET'),
|
||||||
|
'redirect' => env('DISCORD_REDIRECT_URI', '/auth/discord/callback'),
|
||||||
|
],
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Google AdSense
|
* Google AdSense
|
||||||
* Set GOOGLE_ADSENSE_PUBLISHER_ID to your ca-pub-XXXXXXXXXXXXXXXX value.
|
* Set GOOGLE_ADSENSE_PUBLISHER_ID to your ca-pub-XXXXXXXXXXXXXXXX value.
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
Schema::create('stories_authors', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->string('name', 255);
|
||||||
|
$table->string('avatar', 500)->nullable();
|
||||||
|
$table->text('bio')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('stories_authors');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
Schema::create('stories', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('slug', 255)->unique();
|
||||||
|
$table->string('title', 255);
|
||||||
|
$table->text('excerpt')->nullable();
|
||||||
|
$table->longText('content')->nullable();
|
||||||
|
$table->string('cover_image', 500)->nullable();
|
||||||
|
$table->foreignId('author_id')->nullable()
|
||||||
|
->constrained('stories_authors')->nullOnDelete();
|
||||||
|
$table->unsignedInteger('views')->default(0);
|
||||||
|
$table->boolean('featured')->default(false);
|
||||||
|
$table->enum('status', ['draft', 'published'])->default('draft');
|
||||||
|
$table->timestamp('published_at')->nullable();
|
||||||
|
$table->unsignedBigInteger('legacy_interview_id')->nullable()->unique()->comment('Original ID from legacy interviews table');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('published_at');
|
||||||
|
$table->index('featured');
|
||||||
|
$table->index('views');
|
||||||
|
$table->index(['status', 'published_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('stories');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
Schema::create('stories_tags', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('slug', 255)->unique();
|
||||||
|
$table->string('name', 255);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('stories_tags');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
Schema::create('stories_tag_relation', function (Blueprint $table) {
|
||||||
|
$table->foreignId('story_id')->constrained('stories')->cascadeOnDelete();
|
||||||
|
$table->foreignId('tag_id')->constrained('stories_tags')->cascadeOnDelete();
|
||||||
|
$table->primary(['story_id', 'tag_id']);
|
||||||
|
|
||||||
|
$table->index('story_id');
|
||||||
|
$table->index('tag_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('stories_tag_relation');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
Schema::create('social_accounts', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('user_id');
|
||||||
|
$table->string('provider', 50);
|
||||||
|
$table->string('provider_id', 255);
|
||||||
|
$table->string('provider_email', 255)->nullable();
|
||||||
|
$table->string('avatar', 500)->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('user_id')
|
||||||
|
->references('id')
|
||||||
|
->on('users')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
|
||||||
|
$table->index('user_id');
|
||||||
|
$table->index('provider');
|
||||||
|
$table->index('provider_id');
|
||||||
|
$table->unique(['provider', 'provider_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('social_accounts');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState, useCallback, useEffect } from 'react'
|
||||||
|
|
||||||
const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp'
|
const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp'
|
||||||
const FALLBACK_LG = 'https://files.skinbase.org/default/missing_lg.webp'
|
const FALLBACK_LG = 'https://files.skinbase.org/default/missing_lg.webp'
|
||||||
@@ -18,10 +18,29 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
|
|||||||
const hasRealArtworkImage = Boolean(mdSource || lgSource || xlSource)
|
const hasRealArtworkImage = Boolean(mdSource || lgSource || xlSource)
|
||||||
const blurBackdropSrc = mdSource || lgSource || xlSource || null
|
const blurBackdropSrc = mdSource || lgSource || xlSource || null
|
||||||
|
|
||||||
const width = Number(artwork?.width)
|
const dbWidth = Number(artwork?.width)
|
||||||
const height = Number(artwork?.height)
|
const dbHeight = Number(artwork?.height)
|
||||||
const hasKnownAspect = width > 0 && height > 0
|
const hasDbDims = dbWidth > 0 && dbHeight > 0
|
||||||
const aspectRatio = hasKnownAspect ? `${width} / ${height}` : '16 / 9'
|
|
||||||
|
// Natural dimensions — seeded from DB if available, otherwise probed from
|
||||||
|
// the xl thumbnail (largest available, never upscaled past the original).
|
||||||
|
const [naturalDims, setNaturalDims] = useState(
|
||||||
|
hasDbDims ? { w: dbWidth, h: dbHeight } : null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Probe the xl image to discover real dimensions when DB has none
|
||||||
|
useEffect(() => {
|
||||||
|
if (naturalDims || !xlSource) return
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||||
|
setNaturalDims({ w: img.naturalWidth, h: img.naturalHeight })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.src = xlSource
|
||||||
|
}, [xlSource, naturalDims])
|
||||||
|
|
||||||
|
const aspectRatio = naturalDims ? `${naturalDims.w} / ${naturalDims.h}` : '16 / 9'
|
||||||
|
|
||||||
const srcSet = `${md} 640w, ${lg} 1280w, ${xl} 1920w`
|
const srcSet = `${md} 640w, ${lg} 1280w, ${xl} 1920w`
|
||||||
|
|
||||||
@@ -60,8 +79,8 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
|
|||||||
|
|
||||||
<div className="relative min-w-0 flex-1">
|
<div className="relative min-w-0 flex-1">
|
||||||
<div
|
<div
|
||||||
className={`relative mx-auto w-full max-h-[70vh] overflow-hidden ] ${onOpenViewer ? 'cursor-zoom-in' : ''}`}
|
className={`relative mx-auto w-full max-h-[70vh] overflow-hidden transition-[max-width] duration-300 ${onOpenViewer ? 'cursor-zoom-in' : ''}`}
|
||||||
style={{ aspectRatio }}
|
style={{ aspectRatio, maxWidth: naturalDims ? `${naturalDims.w}px` : undefined }}
|
||||||
onClick={onOpenViewer}
|
onClick={onOpenViewer}
|
||||||
role={onOpenViewer ? 'button' : undefined}
|
role={onOpenViewer ? 'button' : undefined}
|
||||||
aria-label={onOpenViewer ? 'Open artwork lightbox' : undefined}
|
aria-label={onOpenViewer ? 'Open artwork lightbox' : undefined}
|
||||||
|
|||||||
79
resources/js/components/auth/SocialLoginButtons.jsx
Normal file
79
resources/js/components/auth/SocialLoginButtons.jsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const providers = [
|
||||||
|
{
|
||||||
|
key: 'google',
|
||||||
|
label: 'Continue with Google',
|
||||||
|
href: '/auth/google/redirect',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" className="w-5 h-5" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
fill="#4285F4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
fill="#34A853"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
fill="#FBBC05"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
fill="#EA4335"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
className:
|
||||||
|
'border-white/15 hover:border-white/30 hover:bg-white/5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'discord',
|
||||||
|
label: 'Continue with Discord',
|
||||||
|
href: '/auth/discord/redirect',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" className="w-5 h-5" aria-hidden="true" fill="#5865F2">
|
||||||
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057c.002.022.015.04.033.05a19.89 19.89 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
className:
|
||||||
|
'border-white/15 hover:border-white/30 hover:bg-discord/20',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Social login buttons for Google, Apple, and Discord.
|
||||||
|
*
|
||||||
|
* @param {{ dividerLabel?: string }} props
|
||||||
|
*/
|
||||||
|
export default function SocialLoginButtons({ dividerLabel = 'or continue with email' }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{providers.map(({ key, label, href, icon, className }) => (
|
||||||
|
<a
|
||||||
|
key={key}
|
||||||
|
href={href}
|
||||||
|
className={[
|
||||||
|
'flex items-center justify-center gap-3 w-full rounded-lg border px-4 py-3',
|
||||||
|
'text-sm font-medium text-white transition-colors duration-150',
|
||||||
|
className,
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span>{label}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{dividerLabel && (
|
||||||
|
<div className="relative flex items-center py-1">
|
||||||
|
<div className="flex-grow border-t border-white/10" />
|
||||||
|
<span className="mx-3 text-xs text-white/40 whitespace-nowrap">
|
||||||
|
{dividerLabel}
|
||||||
|
</span>
|
||||||
|
<div className="flex-grow border-t border-white/10" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,14 @@
|
|||||||
|
|
||||||
<x-auth-session-status class="mt-4 mb-2 text-green-300" :status="session('status')" />
|
<x-auth-session-status class="mt-4 mb-2 text-green-300" :status="session('status')" />
|
||||||
|
|
||||||
|
@if($errors->has('oauth'))
|
||||||
|
<div class="rounded-lg bg-red-900/40 border border-red-500/40 px-4 py-3 text-sm text-red-300 mb-4">
|
||||||
|
{{ $errors->first('oauth') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@include('auth.partials.social-login')
|
||||||
|
|
||||||
<form method="POST" action="{{ route('login') }}" class="space-y-5">
|
<form method="POST" action="{{ route('login') }}" class="space-y-5">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
|
|||||||
34
resources/views/auth/partials/social-login.blade.php
Normal file
34
resources/views/auth/partials/social-login.blade.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{{-- Social login / register buttons for Google, Apple, Discord --}}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{{-- Google --}}
|
||||||
|
<a
|
||||||
|
href="{{ route('oauth.redirect', 'google') }}"
|
||||||
|
class="flex items-center justify-center gap-3 w-full rounded-lg border border-white/15 px-4 py-3 text-sm font-medium text-white hover:border-white/30 hover:bg-white/5 transition-colors duration-150"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" class="w-5 h-5 shrink-0" aria-hidden="true">
|
||||||
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||||
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||||
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||||
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||||
|
</svg>
|
||||||
|
<span>Continue with Google</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{-- Discord --}}
|
||||||
|
<a
|
||||||
|
href="{{ route('oauth.redirect', 'discord') }}"
|
||||||
|
class="flex items-center justify-center gap-3 w-full rounded-lg border border-white/15 px-4 py-3 text-sm font-medium text-white hover:border-white/30 hover:bg-white/5 transition-colors duration-150"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" class="w-5 h-5 shrink-0" aria-hidden="true" fill="#5865F2">
|
||||||
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057c.002.022.015.04.033.05a19.89 19.89 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Continue with Discord</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{-- Divider --}}
|
||||||
|
<div class="relative flex items-center py-1">
|
||||||
|
<div class="flex-grow border-t border-white/10"></div>
|
||||||
|
<span class="mx-3 text-xs text-white/40 whitespace-nowrap">{{ $dividerLabel ?? 'or continue with email' }}</span>
|
||||||
|
<div class="flex-grow border-t border-white/10"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -7,7 +7,13 @@
|
|||||||
<h2 class="text-2xl font-semibold mb-2 text-white">Create Account</h2>
|
<h2 class="text-2xl font-semibold mb-2 text-white">Create Account</h2>
|
||||||
|
|
||||||
<p class="text-sm text-white/60 mb-6">Start with your email. You’ll choose a password and username after verification.</p>
|
<p class="text-sm text-white/60 mb-6">Start with your email. You’ll choose a password and username after verification.</p>
|
||||||
|
@if($errors->has('oauth'))
|
||||||
|
<div class="rounded-lg bg-red-900/40 border border-red-500/40 px-4 py-3 text-sm text-red-300 mb-4">
|
||||||
|
{{ $errors->first('oauth') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@include('auth.partials.social-login', ['dividerLabel' => 'or register with email'])
|
||||||
<form method="POST" action="{{ route('register') }}" class="space-y-5">
|
<form method="POST" action="{{ route('register') }}" class="space-y-5">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
$avatarUserId = $art->user->id ?? $art->user_id ?? null;
|
$avatarUserId = $art->user->id ?? $art->user_id ?? null;
|
||||||
$avatarHash = $art->user->profile->avatar_hash ?? $art->avatar_hash ?? null;
|
$avatarHash = $art->user->profile->avatar_hash ?? $art->avatar_hash ?? null;
|
||||||
$avatarUrl = \App\Support\AvatarUrl::forUser((int) ($avatarUserId ?? 0), $avatarHash, 40);
|
$avatarUrl = \App\Support\AvatarUrl::forUser((int) ($avatarUserId ?? 0), $avatarHash, 64);
|
||||||
|
|
||||||
$license = trim((string) ($art->license ?? 'Standard'));
|
$license = trim((string) ($art->license ?? 'Standard'));
|
||||||
$resolution = trim((string) ($art->resolution ?? ((isset($art->width, $art->height) && $art->width && $art->height) ? ($art->width . '×' . $art->height) : '')));
|
$resolution = trim((string) ($art->resolution ?? ((isset($art->width, $art->height) && $art->width && $art->height) ? ($art->width . '×' . $art->height) : '')));
|
||||||
|
|||||||
@@ -1,25 +1,92 @@
|
|||||||
@extends('layouts.nova')
|
@extends('layouts.nova')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="container mx-auto py-8 max-w-3xl">
|
<div class="px-6 pt-10 pb-16 md:px-10">
|
||||||
<h1 class="text-2xl font-semibold mb-6">People I Follow</h1>
|
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Dashboard</p>
|
||||||
|
<h1 class="text-3xl font-bold text-white leading-tight">People I Follow</h1>
|
||||||
|
<p class="mt-1 text-sm text-white/50">Creators and members you follow, with quick stats and recent follow time.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<i class="fa-solid fa-compass text-xs"></i>
|
||||||
|
Discover creators
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if($following->isEmpty())
|
@if($following->isEmpty())
|
||||||
<p class="text-sm text-gray-500">You are not following anyone yet. <a href="{{ route('discover.trending') }}" class="underline">Discover creators</a></p>
|
<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
|
@else
|
||||||
<div class="space-y-3">
|
@php
|
||||||
|
$firstFollow = $following->getCollection()->first();
|
||||||
|
$latestFollowedAt = $firstFollow && !empty($firstFollow->followed_at)
|
||||||
|
? \Carbon\Carbon::parse($firstFollow->followed_at)->diffForHumans()
|
||||||
|
: 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">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
</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)
|
@foreach($following as $f)
|
||||||
<a href="{{ $f->profile_url }}" class="flex items-center gap-4 p-3 rounded-lg hover:bg-white/5 transition">
|
@php
|
||||||
<img src="{{ $f->avatar_url }}" alt="{{ $f->uname }}" class="w-10 h-10 rounded-full object-cover">
|
$displayName = $f->name ?: $f->uname;
|
||||||
<div class="flex-1 min-w-0">
|
@endphp
|
||||||
<div class="font-medium truncate">{{ $f->uname }}</div>
|
<a href="{{ $f->profile_url }}"
|
||||||
<div class="text-xs text-gray-500">{{ $f->uploads }} uploads · {{ $f->followers_count }} followers · followed {{ \Carbon\Carbon::parse($f->followed_at)->diffForHumans() }}</div>
|
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>
|
||||||
|
@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
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-right text-xs text-white/45 whitespace-nowrap">
|
||||||
|
{{ !empty($f->followed_at) ? \Carbon\Carbon::parse($f->followed_at)->diffForHumans() : '—' }}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-8 flex justify-center">
|
||||||
{{ $following->links() }}
|
{{ $following->links() }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -37,4 +37,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
@if(config('app.debug') && isset($exception))
|
||||||
|
<div class="mt-8 mx-auto max-w-4xl text-left bg-black/40 rounded-xl p-4 overflow-auto">
|
||||||
|
<div class="font-semibold text-white/80 mb-3">Exception: {{ $exception->getMessage() }}</div>
|
||||||
|
<pre class="text-xs text-white/60 whitespace-pre-wrap">{{ $exception->getTraceAsString() }}</pre>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
<link rel="next" href="{{ $page_rel_next }}" />
|
<link rel="next" href="{{ $page_rel_next }}" />
|
||||||
@endisset
|
@endisset
|
||||||
|
|
||||||
|
{{-- Global RSS feed discovery --}}
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="Skinbase Latest Artworks" href="{{ url('/rss') }}">
|
||||||
|
|
||||||
<!-- Icons (kept for now to preserve current visual output) -->
|
<!-- Icons (kept for now to preserve current visual output) -->
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
|
||||||
|
|
||||||
|
|||||||
@@ -223,7 +223,10 @@
|
|||||||
$routeEditProfile = Route::has('dashboard.profile')
|
$routeEditProfile = Route::has('dashboard.profile')
|
||||||
? route('dashboard.profile')
|
? route('dashboard.profile')
|
||||||
: (Route::has('settings') ? route('settings') : '/settings');
|
: (Route::has('settings') ? route('settings') : '/settings');
|
||||||
$routePublicProfile = Route::has('profile.show') ? route('profile.show', ['username' => $toolbarUsername]) : '/@'.$toolbarUsername;
|
// Guard: username may be null for OAuth users still in onboarding.
|
||||||
|
$routePublicProfile = $toolbarUsername !== ''
|
||||||
|
? (Route::has('profile.show') ? route('profile.show', ['username' => $toolbarUsername]) : '/@'.$toolbarUsername)
|
||||||
|
: route('setup.username.create');
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeUpload }}">
|
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeUpload }}">
|
||||||
@@ -333,7 +336,10 @@
|
|||||||
|
|
||||||
@php
|
@php
|
||||||
$mobileUsername = strtolower((string) (Auth::user()->username ?? ''));
|
$mobileUsername = strtolower((string) (Auth::user()->username ?? ''));
|
||||||
$mobileProfile = Route::has('profile.show') ? route('profile.show', ['username' => $mobileUsername]) : '/@'.$mobileUsername;
|
// Guard: username may be null for OAuth users still in onboarding.
|
||||||
|
$mobileProfile = $mobileUsername !== ''
|
||||||
|
? (Route::has('profile.show') ? route('profile.show', ['username' => $mobileUsername]) : '/@'.$mobileUsername)
|
||||||
|
: route('setup.username.create');
|
||||||
@endphp
|
@endphp
|
||||||
<div class="pt-1 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">My Account</div>
|
<div class="pt-1 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">My Account</div>
|
||||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/studio/artworks">
|
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/studio/artworks">
|
||||||
|
|||||||
62
resources/views/privacy/data-deletion.blade.php
Normal file
62
resources/views/privacy/data-deletion.blade.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
@extends('layouts.nova.content-layout')
|
||||||
|
|
||||||
|
@section('page-title', 'Data Deletion')
|
||||||
|
|
||||||
|
@section('page-content')
|
||||||
|
<div class="max-w-3xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<p class="text-sm text-white/40 mb-1">Last updated: <time datetime="2026-03-05">March 5, 2026</time></p>
|
||||||
|
|
||||||
|
<p class="text-white/60 text-sm leading-relaxed mb-8">This page explains how users can delete their account and request removal of their personal data. Follow the steps below depending on whether you still have access to your account.</p>
|
||||||
|
|
||||||
|
<nav class="mb-8 rounded-xl border border-white/[0.08] bg-white/[0.02] px-6 py-4">
|
||||||
|
<h2 class="text-xs font-semibold uppercase tracking-widest text-white/40 mb-2">Contents</h2>
|
||||||
|
<ol class="space-y-1 text-sm text-sky-400">
|
||||||
|
<li><a href="#signed-in" class="hover:text-sky-300 hover:underline">If you can sign in</a></li>
|
||||||
|
<li><a href="#cant-sign-in" class="hover:text-sky-300 hover:underline">If you cannot sign in</a></li>
|
||||||
|
<li><a href="#what-we-remove" class="hover:text-sky-300 hover:underline">What we remove</a></li>
|
||||||
|
<li><a href="#providers" class="hover:text-sky-300 hover:underline">Providers</a></li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="space-y-10">
|
||||||
|
<section id="signed-in">
|
||||||
|
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">If you can sign in</h2>
|
||||||
|
<div class="text-sm text-neutral-400 leading-relaxed">
|
||||||
|
<ol class="list-decimal list-inside space-y-2">
|
||||||
|
<li>Sign in to your account.</li>
|
||||||
|
<li>Go to your <strong>Settings</strong> → <strong>Account</strong> page. {{ Route::has('settings') ? '(You can open <a href="'.route('settings').'" class="text-sky-400 hover:underline">Settings</a>.)' : '' }}</li>
|
||||||
|
<li>Use the <strong>Delete account</strong> action to request permanent removal. Follow the confirmation steps — some actions are irreversible.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="cant-sign-in">
|
||||||
|
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">If you cannot sign in</h2>
|
||||||
|
<div class="text-sm text-neutral-400 leading-relaxed">
|
||||||
|
<p class="mb-3">If you no longer have access to the email address on your account or cannot sign in, please contact our support team so we can verify ownership and process the request.</p>
|
||||||
|
<ul class="list-disc list-inside">
|
||||||
|
<li>Your account username or profile URL (if known)</li>
|
||||||
|
<li>The email address you used to register (if known)</li>
|
||||||
|
<li>A short explanation why you cannot sign in</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mt-3">Contact us via the <a href="{{ Route::has('contact') ? route('contact') : url('/contact') }}" class="text-sky-400 hover:underline">Contact</a> page or the staff contact methods listed on the site.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="what-we-remove">
|
||||||
|
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">What we remove</h2>
|
||||||
|
<div class="text-sm text-neutral-400 leading-relaxed">
|
||||||
|
<p>When an account is deleted we remove personal data associated with that account in accordance with our retention and legal obligations. Publicly visible content you uploaded may remain unless you request its removal specifically.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="providers">
|
||||||
|
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">Providers</h2>
|
||||||
|
<div class="text-sm text-neutral-400 leading-relaxed">
|
||||||
|
<p>If you are adding this URL to an OAuth provider's developer settings for data-deletion instructions, use: <strong>{{ url('/data-deletion') }}</strong></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
39
resources/views/rss/channel.blade.php
Normal file
39
resources/views/rss/channel.blade.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php echo '<?xml version="1.0" encoding="UTF-8"?>'; ?>
|
||||||
|
<rss version="2.0"
|
||||||
|
xmlns:atom="http://www.w3.org/2005/Atom"
|
||||||
|
xmlns:media="http://search.yahoo.com/mrss/"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<channel>
|
||||||
|
<title>{{ htmlspecialchars($channelTitle) }}</title>
|
||||||
|
<link>{{ $channelLink }}</link>
|
||||||
|
<description>{{ htmlspecialchars($channelDescription) }}</description>
|
||||||
|
<language>en-us</language>
|
||||||
|
<lastBuildDate>{{ $buildDate }}</lastBuildDate>
|
||||||
|
<atom:link href="{{ $feedUrl }}" rel="self" type="application/rss+xml" />
|
||||||
|
@foreach ($items as $item)
|
||||||
|
<item>
|
||||||
|
<title><![CDATA[{{ $item['title'] }}]]></title>
|
||||||
|
<link>{{ $item['link'] }}</link>
|
||||||
|
<guid isPermaLink="true">{{ $item['guid'] }}</guid>
|
||||||
|
@if (!empty($item['pubDate']))
|
||||||
|
<pubDate>{{ $item['pubDate'] }}</pubDate>
|
||||||
|
@endif
|
||||||
|
@if (!empty($item['author']))
|
||||||
|
<dc:creator><![CDATA[{{ $item['author'] }}]]></dc:creator>
|
||||||
|
@endif
|
||||||
|
@if (!empty($item['category']))
|
||||||
|
<category><![CDATA[{{ $item['category'] }}]]></category>
|
||||||
|
@endif
|
||||||
|
<description><![CDATA[{!! $item['description'] !!}]]></description>
|
||||||
|
@if (!empty($item['enclosure']))
|
||||||
|
<enclosure url="{{ $item['enclosure']['url'] }}"
|
||||||
|
length="{{ $item['enclosure']['length'] }}"
|
||||||
|
type="{{ $item['enclosure']['type'] }}" />
|
||||||
|
@endif
|
||||||
|
@if (!empty($item['enclosure']))
|
||||||
|
<media:content url="{{ $item['enclosure']['url'] }}" medium="image" />
|
||||||
|
@endif
|
||||||
|
</item>
|
||||||
|
@endforeach
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
@push('head')
|
@push('head')
|
||||||
<link rel="canonical" href="{{ $page_canonical }}">
|
<link rel="canonical" href="{{ $page_canonical }}">
|
||||||
<meta name="robots" content="{{ $page_robots ?? 'index,follow' }}">
|
<meta name="robots" content="{{ $page_robots ?? 'index,follow' }}">
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="{{ $tag->name }} Artworks — Skinbase" href="{{ url('/rss/tag/' . $tag->slug) }}">
|
||||||
@if(!empty($ogImage))
|
@if(!empty($ogImage))
|
||||||
<meta property="og:image" content="{{ $ogImage }}">
|
<meta property="og:image" content="{{ $ogImage }}">
|
||||||
<meta property="og:image:alt" content="{{ $tag->name }} artworks on Skinbase">
|
<meta property="og:image:alt" content="{{ $tag->name }} artworks on Skinbase">
|
||||||
|
|||||||
@@ -36,9 +36,61 @@
|
|||||||
|
|
||||||
{{-- ── Leaderboard ── --}}
|
{{-- ── Leaderboard ── --}}
|
||||||
<div class="px-6 pb-16 md:px-10">
|
<div class="px-6 pb-16 md:px-10">
|
||||||
@php $offset = ($authors->currentPage() - 1) * $authors->perPage(); @endphp
|
@php
|
||||||
|
$offset = ($authors->currentPage() - 1) * $authors->perPage();
|
||||||
|
$isFirstPage = $authors->currentPage() === 1;
|
||||||
|
$showcaseTop = $isFirstPage ? $authors->getCollection()->take(3)->values() : collect();
|
||||||
|
$tableAuthors = $isFirstPage ? $authors->getCollection()->slice(3)->values() : $authors->getCollection();
|
||||||
|
$rankBase = $isFirstPage ? 3 : 0;
|
||||||
|
@endphp
|
||||||
|
|
||||||
@if ($authors->isNotEmpty())
|
@if ($authors->isNotEmpty())
|
||||||
|
@if ($showcaseTop->isNotEmpty())
|
||||||
|
<div class="mb-6 grid gap-4 md:grid-cols-3">
|
||||||
|
@foreach ($showcaseTop as $i => $author)
|
||||||
|
@php
|
||||||
|
$rank = $i + 1;
|
||||||
|
$profileUrl = ($author->username ?? null)
|
||||||
|
? '/@' . $author->username
|
||||||
|
: '/profile/' . (int) $author->user_id;
|
||||||
|
$avatarUrl = \App\Support\AvatarUrl::forUser((int) $author->user_id, $author->avatar_hash ?? null, 64);
|
||||||
|
|
||||||
|
$rankClasses = $rank === 1
|
||||||
|
? 'bg-amber-400/15 text-amber-300 ring-amber-400/30'
|
||||||
|
: ($rank === 2
|
||||||
|
? 'bg-slate-400/15 text-slate-300 ring-slate-400/30'
|
||||||
|
: 'bg-orange-700/20 text-orange-400 ring-orange-600/30');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<a href="{{ $profileUrl }}"
|
||||||
|
class="rounded-xl border border-white/[0.08] bg-white/[0.03] p-5 hover:bg-white/[0.05] transition-colors">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full text-sm font-bold ring-1 {{ $rankClasses }}">
|
||||||
|
{{ $rank }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-widest {{ $metric === 'downloads' ? 'text-emerald-300/80' : 'text-sky-300/80' }}">
|
||||||
|
{{ $metric === 'downloads' ? 'Downloads' : 'Views' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<img src="{{ $avatarUrl }}" alt="{{ $author->uname }}"
|
||||||
|
class="w-14 h-14 rounded-full object-cover ring-1 ring-white/[0.12]">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="truncate text-base font-semibold text-white">{{ $author->uname ?? 'Unknown' }}</div>
|
||||||
|
@if (!empty($author->username))
|
||||||
|
<div class="truncate text-xs text-white/40">{{ '@' . $author->username }}</div>
|
||||||
|
@endif
|
||||||
|
<div class="mt-1 text-lg font-bold {{ $metric === 'downloads' ? 'text-emerald-400' : 'text-sky-400' }}">
|
||||||
|
{{ number_format($author->total ?? 0) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="rounded-xl border border-white/[0.06] overflow-hidden">
|
<div class="rounded-xl border border-white/[0.06] overflow-hidden">
|
||||||
|
|
||||||
{{-- Table header --}}
|
{{-- Table header --}}
|
||||||
@@ -52,13 +104,13 @@
|
|||||||
|
|
||||||
{{-- Rows --}}
|
{{-- Rows --}}
|
||||||
<div class="divide-y divide-white/[0.04]">
|
<div class="divide-y divide-white/[0.04]">
|
||||||
@foreach ($authors as $i => $author)
|
@foreach ($tableAuthors as $i => $author)
|
||||||
@php
|
@php
|
||||||
$rank = $offset + $i + 1;
|
$rank = $offset + $rankBase + $i + 1;
|
||||||
$profileUrl = ($author->username ?? null)
|
$profileUrl = ($author->username ?? null)
|
||||||
? '/@' . $author->username
|
? '/@' . $author->username
|
||||||
: '/profile/' . (int) $author->user_id;
|
: '/profile/' . (int) $author->user_id;
|
||||||
$avatarUrl = \App\Support\AvatarUrl::forUser((int) $author->user_id, null, 40);
|
$avatarUrl = \App\Support\AvatarUrl::forUser((int) $author->user_id, $author->avatar_hash ?? null, 64);
|
||||||
@endphp
|
@endphp
|
||||||
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-4
|
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-4
|
||||||
{{ $rank <= 3 ? 'bg-white/[0.015]' : '' }} hover:bg-white/[0.03] transition-colors">
|
{{ $rank <= 3 ? 'bg-white/[0.015]' : '' }} hover:bg-white/[0.03] transition-colors">
|
||||||
|
|||||||
@@ -21,9 +21,52 @@
|
|||||||
|
|
||||||
{{-- ── Leaderboard ── --}}
|
{{-- ── Leaderboard ── --}}
|
||||||
<div class="px-6 pb-16 md:px-10">
|
<div class="px-6 pb-16 md:px-10">
|
||||||
@php $offset = ($rows->currentPage() - 1) * $rows->perPage(); @endphp
|
@php
|
||||||
|
$offset = ($rows->currentPage() - 1) * $rows->perPage();
|
||||||
|
$isFirstPage = $rows->currentPage() === 1;
|
||||||
|
$showcaseTop = $isFirstPage ? $rows->getCollection()->take(3)->values() : collect();
|
||||||
|
$tableRows = $isFirstPage ? $rows->getCollection()->slice(3)->values() : $rows->getCollection();
|
||||||
|
$rankBase = $isFirstPage ? 3 : 0;
|
||||||
|
@endphp
|
||||||
|
|
||||||
@if ($rows->isNotEmpty())
|
@if ($rows->isNotEmpty())
|
||||||
|
@if ($showcaseTop->isNotEmpty())
|
||||||
|
<div class="mb-6 grid gap-4 md:grid-cols-3">
|
||||||
|
@foreach ($showcaseTop as $i => $row)
|
||||||
|
@php
|
||||||
|
$rank = $i + 1;
|
||||||
|
$profileUrl = ($row->user_username ?? null) ? '/@' . $row->user_username : '/profile/' . (int)($row->user_id ?? 0);
|
||||||
|
$avatarUrl = \App\Support\AvatarUrl::forUser((int)($row->user_id ?? 0), null, 64);
|
||||||
|
|
||||||
|
$rankClasses = $rank === 1
|
||||||
|
? 'bg-amber-400/15 text-amber-300 ring-amber-400/30'
|
||||||
|
: ($rank === 2
|
||||||
|
? 'bg-slate-400/15 text-slate-300 ring-slate-400/30'
|
||||||
|
: 'bg-orange-700/20 text-orange-400 ring-orange-600/30');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<a href="{{ $profileUrl }}"
|
||||||
|
class="rounded-xl border border-white/[0.08] bg-white/[0.03] p-5 hover:bg-white/[0.05] transition-colors">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full text-sm font-bold ring-1 {{ $rankClasses }}">
|
||||||
|
{{ $rank }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-widest text-violet-300/80">Comments</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<img src="{{ $avatarUrl }}" alt="{{ $row->uname ?? 'User' }}"
|
||||||
|
class="w-14 h-14 rounded-full object-cover ring-1 ring-white/[0.12]">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="truncate text-base font-semibold text-white">{{ $row->uname ?? 'Unknown' }}</div>
|
||||||
|
<div class="mt-1 text-lg font-bold text-violet-400">{{ number_format((int)($row->num_comments ?? 0)) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="rounded-xl border border-white/[0.06] overflow-hidden">
|
<div class="rounded-xl border border-white/[0.06] overflow-hidden">
|
||||||
|
|
||||||
{{-- Table header --}}
|
{{-- Table header --}}
|
||||||
@@ -35,11 +78,11 @@
|
|||||||
|
|
||||||
{{-- Rows --}}
|
{{-- Rows --}}
|
||||||
<div class="divide-y divide-white/[0.04]">
|
<div class="divide-y divide-white/[0.04]">
|
||||||
@foreach ($rows as $i => $row)
|
@foreach ($tableRows as $i => $row)
|
||||||
@php
|
@php
|
||||||
$rank = $offset + $i + 1;
|
$rank = $offset + $rankBase + $i + 1;
|
||||||
$profileUrl = ($row->user_username ?? null) ? '/@' . $row->user_username : '/profile/' . (int)($row->user_id ?? 0);
|
$profileUrl = ($row->user_username ?? null) ? '/@' . $row->user_username : '/profile/' . (int)($row->user_id ?? 0);
|
||||||
$avatarUrl = \App\Support\AvatarUrl::forUser((int)($row->user_id ?? 0), null, 40);
|
$avatarUrl = \App\Support\AvatarUrl::forUser((int)($row->user_id ?? 0), null, 64);
|
||||||
@endphp
|
@endphp
|
||||||
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-4
|
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-4
|
||||||
{{ $rank <= 3 ? 'bg-white/[0.015]' : '' }} hover:bg-white/[0.03] transition-colors">
|
{{ $rank <= 3 ? 'bg-white/[0.015]' : '' }} hover:bg-white/[0.03] transition-colors">
|
||||||
|
|||||||
@@ -21,9 +21,58 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-6 pb-16 md:px-10">
|
<div class="px-6 pb-16 md:px-10">
|
||||||
@php $offset = ($creators->currentPage() - 1) * $creators->perPage(); @endphp
|
@php
|
||||||
|
$offset = ($creators->currentPage() - 1) * $creators->perPage();
|
||||||
|
$isFirstPage = $creators->currentPage() === 1;
|
||||||
|
$showcaseTop = $isFirstPage ? $creators->getCollection()->take(3)->values() : collect();
|
||||||
|
$tableCreators = $isFirstPage ? $creators->getCollection()->slice(3)->values() : $creators->getCollection();
|
||||||
|
$rankBase = $isFirstPage ? 3 : 0;
|
||||||
|
@endphp
|
||||||
|
|
||||||
@if ($creators->isNotEmpty())
|
@if ($creators->isNotEmpty())
|
||||||
|
@if ($showcaseTop->isNotEmpty())
|
||||||
|
<div class="mb-6 grid gap-4 md:grid-cols-3">
|
||||||
|
@foreach ($showcaseTop as $i => $creator)
|
||||||
|
@php
|
||||||
|
$rank = $i + 1;
|
||||||
|
$profileUrl = ($creator->username ?? null)
|
||||||
|
? '/@' . $creator->username
|
||||||
|
: '/profile/' . (int) $creator->user_id;
|
||||||
|
$avatarUrl = \App\Support\AvatarUrl::forUser((int) $creator->user_id, $creator->avatar_hash ?? null, 64);
|
||||||
|
|
||||||
|
$rankClasses = $rank === 1
|
||||||
|
? 'bg-amber-400/15 text-amber-300 ring-amber-400/30'
|
||||||
|
: ($rank === 2
|
||||||
|
? 'bg-slate-400/15 text-slate-300 ring-slate-400/30'
|
||||||
|
: 'bg-orange-700/20 text-orange-400 ring-orange-600/30');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<a href="{{ $profileUrl }}"
|
||||||
|
class="rounded-xl border border-white/[0.08] bg-white/[0.03] p-5 hover:bg-white/[0.05] transition-colors">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full text-sm font-bold ring-1 {{ $rankClasses }}">
|
||||||
|
{{ $rank }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-widest text-sky-300/80">Recent Views</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<img src="{{ $avatarUrl }}" alt="{{ $creator->uname }}"
|
||||||
|
class="w-14 h-14 rounded-full object-cover ring-1 ring-white/[0.12]"
|
||||||
|
onerror="this.src='https://files.skinbase.org/default/avatar_default.webp'" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="truncate text-base font-semibold text-white">{{ $creator->uname ?? 'Unknown' }}</div>
|
||||||
|
@if($creator->username ?? null)
|
||||||
|
<div class="truncate text-xs text-white/40">{{ '@' . $creator->username }}</div>
|
||||||
|
@endif
|
||||||
|
<div class="mt-1 text-lg font-bold text-sky-400">{{ number_format($creator->total ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="rounded-xl border border-white/[0.06] overflow-hidden">
|
<div class="rounded-xl border border-white/[0.06] overflow-hidden">
|
||||||
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-3 bg-white/[0.03] border-b border-white/[0.06]">
|
<div class="grid grid-cols-[3rem_1fr_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 text-center">#</span>
|
<span class="text-xs font-semibold uppercase tracking-widest text-white/30 text-center">#</span>
|
||||||
@@ -32,13 +81,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divide-y divide-white/[0.04]">
|
<div class="divide-y divide-white/[0.04]">
|
||||||
@foreach ($creators as $i => $creator)
|
@foreach ($tableCreators as $i => $creator)
|
||||||
@php
|
@php
|
||||||
$rank = $offset + $i + 1;
|
$rank = $offset + $rankBase + $i + 1;
|
||||||
$profileUrl = ($creator->username ?? null)
|
$profileUrl = ($creator->username ?? null)
|
||||||
? '/@' . $creator->username
|
? '/@' . $creator->username
|
||||||
: '/profile/' . (int) $creator->user_id;
|
: '/profile/' . (int) $creator->user_id;
|
||||||
$avatarUrl = \App\Support\AvatarUrl::forUser((int) $creator->user_id, $creator->avatar_hash ?? null, 40);
|
$avatarUrl = \App\Support\AvatarUrl::forUser((int) $creator->user_id, $creator->avatar_hash ?? null, 64);
|
||||||
@endphp
|
@endphp
|
||||||
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-4
|
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-4
|
||||||
{{ $rank <= 3 ? 'bg-white/[0.015]' : '' }} hover:bg-white/[0.03] transition-colors">
|
{{ $rank <= 3 ? 'bg-white/[0.015]' : '' }} hover:bg-white/[0.03] transition-colors">
|
||||||
|
|||||||
@@ -1,12 +1,69 @@
|
|||||||
@extends('layouts.nova.content-layout')
|
@extends('layouts.nova.content-layout')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
{{-- Global RSS alternate links discoverable by feed readers --}}
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="Skinbase Latest Artworks" href="{{ url('/rss') }}">
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="Skinbase Trending Artworks" href="{{ url('/rss/discover/trending') }}">
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="Skinbase Blog" href="{{ url('/rss/blog') }}">
|
||||||
|
@foreach ($feeds as $key => $feed)
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="{{ $feed['title'] }} — Skinbase" href="{{ url($feed['url']) }}">
|
||||||
|
@endforeach
|
||||||
|
@endpush
|
||||||
|
|
||||||
@section('page-content')
|
@section('page-content')
|
||||||
|
|
||||||
<div class="max-w-2xl space-y-10">
|
<div x-data="{ copied: null }" class="max-w-3xl space-y-10">
|
||||||
|
|
||||||
{{-- Feed list --}}
|
{{-- Introduction --}}
|
||||||
|
<p class="text-neutral-400 text-sm leading-relaxed">
|
||||||
|
Subscribe to Skinbase RSS feeds in your feed reader, Discord bot, or automation tool.
|
||||||
|
Every feed returns valid RSS 2.0 XML with preview images and artwork links.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{-- Grouped feed list --}}
|
||||||
|
@if (!empty($feed_groups))
|
||||||
|
@foreach ($feed_groups as $groupKey => $group)
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-white mb-4">Available Feeds</h2>
|
<h2 class="text-base font-semibold text-neutral-300 uppercase tracking-wider mb-3">
|
||||||
|
{{ $group['label'] }}
|
||||||
|
</h2>
|
||||||
|
<ul class="divide-y divide-neutral-800 rounded-lg border border-neutral-800 overflow-hidden">
|
||||||
|
@foreach ($group['feeds'] as $feed)
|
||||||
|
<li class="flex items-start gap-4 px-5 py-4 bg-nova-900/50 hover:bg-nova-800/60 transition-colors">
|
||||||
|
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-orange-400" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M6.18 15.64a2.18 2.18 0 012.18 2.18C8.36 19.01 7.38 20 6.18 20C4.98 20 4 19.01 4 17.82a2.18 2.18 0 012.18-2.18M4 4.44A15.56 15.56 0 0119.56 20h-2.83A12.73 12.73 0 004 7.27V4.44m0 5.66a9.9 9.9 0 019.9 9.9h-2.83A7.07 7.07 0 004 12.93V10.1z"/>
|
||||||
|
</svg>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-white">{{ $feed['title'] }}</p>
|
||||||
|
@if (!empty($feed['description']))
|
||||||
|
<p class="text-xs text-neutral-500 mt-0.5">{{ $feed['description'] }}</p>
|
||||||
|
@endif
|
||||||
|
<p class="text-xs text-neutral-600 truncate mt-1 font-mono">{{ url($feed['url']) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="navigator.clipboard.writeText('{{ url($feed['url']) }}').then(() => { copied = '{{ $feed['url'] }}'; setTimeout(() => copied = null, 2000) })"
|
||||||
|
class="rounded-md border border-neutral-700 px-3 py-1.5 text-xs text-neutral-400 hover:border-neutral-500 hover:text-white transition-colors"
|
||||||
|
:class="copied === '{{ $feed['url'] }}' ? 'border-green-600 !text-green-400' : ''"
|
||||||
|
>
|
||||||
|
<span x-show="copied !== '{{ $feed['url'] }}'">Copy URL</span>
|
||||||
|
<span x-show="copied === '{{ $feed['url'] }}'" x-cloak>✓ Copied</span>
|
||||||
|
</button>
|
||||||
|
<a href="{{ $feed['url'] }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="rounded-md border border-neutral-700 px-3 py-1.5 text-xs text-neutral-400 hover:border-orange-500 hover:text-orange-400 transition-colors">
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
@else
|
||||||
|
{{-- Fallback: flat feeds list --}}
|
||||||
<ul class="divide-y divide-neutral-800 rounded-lg border border-neutral-800 overflow-hidden">
|
<ul class="divide-y divide-neutral-800 rounded-lg border border-neutral-800 overflow-hidden">
|
||||||
@foreach ($feeds as $key => $feed)
|
@foreach ($feeds as $key => $feed)
|
||||||
<li class="flex items-center gap-4 px-5 py-4 bg-nova-900/50 hover:bg-nova-800/60 transition-colors">
|
<li class="flex items-center gap-4 px-5 py-4 bg-nova-900/50 hover:bg-nova-800/60 transition-colors">
|
||||||
@@ -24,40 +81,47 @@
|
|||||||
</li>
|
</li>
|
||||||
@endforeach
|
@endforeach
|
||||||
</ul>
|
</ul>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Tag & Creator feed instructions --}}
|
||||||
|
<div class="rounded-lg border border-neutral-800 bg-nova-900/30 px-5 py-4 space-y-2">
|
||||||
|
<h2 class="text-sm font-semibold text-white">Tag & Creator Feeds</h2>
|
||||||
|
<p class="text-xs text-neutral-400 leading-relaxed">
|
||||||
|
Subscribe to any tag or creator using the dynamic URL patterns below:
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-1 text-xs font-mono text-neutral-300">
|
||||||
|
<li><span class="text-neutral-500 mr-2">Tag:</span>{{ url('/rss/tag/') }}<em class="text-orange-400 not-italic">{tag-slug}</em></li>
|
||||||
|
<li><span class="text-neutral-500 mr-2">Creator:</span>{{ url('/rss/creator/') }}<em class="text-orange-400 not-italic">{username}</em></li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-xs text-neutral-500 mt-2">
|
||||||
|
Examples:
|
||||||
|
<a href="/rss/tag/digital-art" class="text-neutral-300 hover:text-orange-400 underline" target="_blank">/rss/tag/digital-art</a>
|
||||||
|
•
|
||||||
|
<a href="/rss/creator/gregor" class="text-neutral-300 hover:text-orange-400 underline" target="_blank">/rss/creator/gregor</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- About RSS --}}
|
{{-- About RSS --}}
|
||||||
<div class="prose prose-invert prose-sm max-w-none">
|
<div class="prose prose-invert prose-sm max-w-none">
|
||||||
<h2>About RSS</h2>
|
<h2>About RSS</h2>
|
||||||
<p>
|
<p>
|
||||||
RSS is a family of web feed formats used to publish frequently updated digital content,
|
RSS is a widely supported web feed format. By subscribing to a Skinbase
|
||||||
such as blogs, news feeds, or upload streams. By subscribing to an RSS feed you can
|
RSS feed you can follow updates in any feed reader, wire up Discord bots,
|
||||||
follow Skinbase updates in your favourite feed reader without needing to visit the site.
|
or power autoposting workflows — without visiting the site.
|
||||||
</p>
|
</p>
|
||||||
<h3>How to subscribe</h3>
|
<h3>How to subscribe</h3>
|
||||||
<p>
|
<p>
|
||||||
Copy one of the feed URLs above and paste it into your feed reader (e.g. Feedly, Inoreader,
|
Copy a feed URL above and paste it into your feed reader (e.g. Feedly, Inoreader,
|
||||||
or any app that supports RSS 2.0). The reader will automatically check for new content and
|
NetNewsWire) or any tool that supports RSS 2.0.
|
||||||
notify you of updates.
|
|
||||||
</p>
|
</p>
|
||||||
<h3>Feed formats</h3>
|
<h3>Feed format</h3>
|
||||||
<ul>
|
|
||||||
<li>Really Simple Syndication (RSS 2.0)</li>
|
|
||||||
<li>Rich Site Summary (RSS 0.91, RSS 1.0)</li>
|
|
||||||
<li>RDF Site Summary (RSS 0.9 and 1.0)</li>
|
|
||||||
</ul>
|
|
||||||
<p>
|
<p>
|
||||||
RSS delivers its information as an XML file. Our feeds include title, description,
|
All feeds return RSS 2.0 XML with <code>application/rss+xml</code> content-type,
|
||||||
author, publication date, and a media thumbnail for each item.
|
UTF-8 encoding, preview thumbnails via <code><enclosure></code> and
|
||||||
|
<code><media:content></code>, and a hard limit of 20 items per feed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@push('head')
|
|
||||||
@foreach ($feeds as $key => $feed)
|
|
||||||
<link rel="alternate" type="application/rss+xml" title="{{ $feed['title'] }} — Skinbase" href="{{ url($feed['url']) }}">
|
|
||||||
@endforeach
|
|
||||||
@endpush
|
|
||||||
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
98
resources/views/web/stories/author.blade.php
Normal file
98
resources/views/web/stories/author.blade.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
{{--
|
||||||
|
Author stories page — /stories/author/{username}
|
||||||
|
Uses ContentLayout.
|
||||||
|
--}}
|
||||||
|
@extends('layouts.nova.content-layout')
|
||||||
|
|
||||||
|
@php
|
||||||
|
$authorDisplayName = $author->user?->username ?? $author->name;
|
||||||
|
$hero_title = 'Stories by ' . $authorDisplayName;
|
||||||
|
$hero_description = 'All stories and interviews by ' . $authorDisplayName . ' on Skinbase.';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@section('page-content')
|
||||||
|
|
||||||
|
{{-- Author spotlight --}}
|
||||||
|
<div class="flex items-center gap-5 rounded-xl border border-white/[0.06] bg-white/[0.02] p-6 mb-10">
|
||||||
|
@if($author->avatar_url)
|
||||||
|
<img src="{{ $author->avatar_url }}" alt="{{ $author->name }}"
|
||||||
|
class="w-16 h-16 rounded-full object-cover border-2 border-white/10 flex-shrink-0" />
|
||||||
|
@else
|
||||||
|
<div class="w-16 h-16 rounded-full bg-nova-700 flex items-center justify-center flex-shrink-0">
|
||||||
|
<i class="fa-solid fa-user text-xl text-white/30"></i>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="text-lg font-semibold text-white">{{ $author->name }}</h2>
|
||||||
|
@if($author->bio)
|
||||||
|
<p class="mt-1 text-sm text-white/50 line-clamp-2">{{ $author->bio }}</p>
|
||||||
|
@endif
|
||||||
|
@if($author->user)
|
||||||
|
<a href="{{ $author->profile_url }}" class="mt-2 inline-flex items-center gap-1 text-xs text-sky-400 hover:text-sky-300 transition-colors">
|
||||||
|
View profile <i class="fa-solid fa-arrow-right text-[10px]"></i>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Stories grid --}}
|
||||||
|
@if($stories->isNotEmpty())
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
@foreach($stories as $story)
|
||||||
|
<a href="{{ $story->url }}"
|
||||||
|
class="group block rounded-xl border border-white/[0.06] bg-white/[0.02] overflow-hidden hover:bg-white/[0.05] transition-colors">
|
||||||
|
@if($story->cover_url)
|
||||||
|
<div class="aspect-video bg-nova-800 overflow-hidden">
|
||||||
|
<img src="{{ $story->cover_url }}" alt="{{ $story->title }}"
|
||||||
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
loading="lazy" />
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="aspect-video bg-gradient-to-br from-sky-900/20 to-indigo-900/20 flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-feather-pointed text-3xl text-white/15"></i>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div class="p-5">
|
||||||
|
@if($story->tags->isNotEmpty())
|
||||||
|
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||||
|
@foreach($story->tags->take(3) as $tag)
|
||||||
|
<span class="rounded-full px-2 py-0.5 text-[11px] font-medium bg-sky-500/10 text-sky-400 border border-sky-500/20">
|
||||||
|
#{{ $tag->name }}
|
||||||
|
</span>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<h2 class="text-base font-semibold text-white group-hover:text-sky-300 transition-colors line-clamp-2 leading-snug">
|
||||||
|
{{ $story->title }}
|
||||||
|
</h2>
|
||||||
|
@if($story->excerpt)
|
||||||
|
<p class="mt-2 text-sm text-white/45 line-clamp-2">{{ $story->excerpt }}</p>
|
||||||
|
@endif
|
||||||
|
<div class="mt-4 flex items-center gap-2 text-xs text-white/30">
|
||||||
|
@if($story->published_at)
|
||||||
|
<time datetime="{{ $story->published_at->toIso8601String() }}">
|
||||||
|
{{ $story->published_at->format('M j, Y') }}
|
||||||
|
</time>
|
||||||
|
<span>·</span>
|
||||||
|
@endif
|
||||||
|
<span>{{ $story->reading_time }} min read</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 flex justify-center">
|
||||||
|
{{ $stories->withQueryString()->links() }}
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-16 text-center">
|
||||||
|
<i class="fa-solid fa-feather-pointed text-4xl text-white/20 mb-4 block"></i>
|
||||||
|
<p class="text-white/40 text-sm">No published stories from this author yet.</p>
|
||||||
|
<a href="/stories" class="mt-4 inline-block text-sm text-sky-400 hover:text-sky-300 transition-colors">
|
||||||
|
Browse all stories →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@endsection
|
||||||
155
resources/views/web/stories/index.blade.php
Normal file
155
resources/views/web/stories/index.blade.php
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
{{--
|
||||||
|
Stories index — /stories
|
||||||
|
Uses ContentLayout.
|
||||||
|
--}}
|
||||||
|
@extends('layouts.nova.content-layout')
|
||||||
|
|
||||||
|
@php
|
||||||
|
$hero_title = 'Skinbase Stories';
|
||||||
|
$hero_description = 'Artist interviews, community spotlights, tutorials and announcements.';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
{{-- WebSite / Blog structured data --}}
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{!! json_encode([
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'Blog',
|
||||||
|
'name' => 'Skinbase Stories',
|
||||||
|
'description' => 'Artist interviews, community spotlights, tutorials and announcements from Skinbase.',
|
||||||
|
'url' => url('/stories'),
|
||||||
|
'publisher' => ['@type' => 'Organization', 'name' => 'Skinbase', 'url' => url('/')],
|
||||||
|
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('page-content')
|
||||||
|
|
||||||
|
{{-- Featured story hero --}}
|
||||||
|
@if($featured)
|
||||||
|
<a href="{{ $featured->url }}"
|
||||||
|
class="group block rounded-2xl border border-white/[0.06] bg-white/[0.02] overflow-hidden hover:bg-white/[0.04] transition-colors mb-10">
|
||||||
|
<div class="md:flex">
|
||||||
|
@if($featured->cover_url)
|
||||||
|
<div class="md:w-1/2 aspect-video md:aspect-auto overflow-hidden bg-nova-900">
|
||||||
|
<img src="{{ $featured->cover_url }}" alt="{{ $featured->title }}"
|
||||||
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
|
loading="eager" />
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="md:w-1/2 aspect-video md:aspect-auto bg-gradient-to-br from-sky-900/40 to-purple-900/40 flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-star text-4xl text-white/20"></i>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div class="md:w-1/2 p-8 flex flex-col justify-center">
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium bg-yellow-400/10 text-yellow-300 border border-yellow-400/20">
|
||||||
|
<i class="fa-solid fa-star text-[10px]"></i> Featured
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold text-white group-hover:text-sky-300 transition-colors leading-snug">
|
||||||
|
{{ $featured->title }}
|
||||||
|
</h2>
|
||||||
|
@if($featured->excerpt)
|
||||||
|
<p class="mt-3 text-sm text-white/50 line-clamp-3">{{ $featured->excerpt }}</p>
|
||||||
|
@endif
|
||||||
|
<div class="mt-5 flex items-center gap-3 text-xs text-white/30">
|
||||||
|
@if($featured->author)
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
@if($featured->author->avatar_url)
|
||||||
|
<img src="{{ $featured->author->avatar_url }}" alt="{{ $featured->author->name }}"
|
||||||
|
class="w-5 h-5 rounded-full object-cover" />
|
||||||
|
@endif
|
||||||
|
{{ $featured->author->name }}
|
||||||
|
</span>
|
||||||
|
<span>·</span>
|
||||||
|
@endif
|
||||||
|
@if($featured->published_at)
|
||||||
|
<time datetime="{{ $featured->published_at->toIso8601String() }}">
|
||||||
|
{{ $featured->published_at->format('M j, Y') }}
|
||||||
|
</time>
|
||||||
|
<span>·</span>
|
||||||
|
@endif
|
||||||
|
<span>{{ $featured->reading_time }} min read</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Stories grid --}}
|
||||||
|
@if($stories->isNotEmpty())
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
@foreach($stories as $story)
|
||||||
|
@if($featured && $story->id === $featured->id)
|
||||||
|
@continue
|
||||||
|
@endif
|
||||||
|
<a href="{{ $story->url }}"
|
||||||
|
class="group block rounded-xl border border-white/[0.06] bg-white/[0.02] overflow-hidden hover:bg-white/[0.05] transition-colors">
|
||||||
|
@if($story->cover_url)
|
||||||
|
<div class="aspect-video bg-nova-800 overflow-hidden">
|
||||||
|
<img src="{{ $story->cover_url }}" alt="{{ $story->title }}"
|
||||||
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
loading="lazy" />
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="aspect-video bg-gradient-to-br from-sky-900/20 to-indigo-900/20 flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-feather-pointed text-3xl text-white/15"></i>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="p-5">
|
||||||
|
{{-- Tags --}}
|
||||||
|
@if($story->tags->isNotEmpty())
|
||||||
|
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||||
|
@foreach($story->tags->take(3) as $tag)
|
||||||
|
<span class="rounded-full px-2 py-0.5 text-[11px] font-medium bg-sky-500/10 text-sky-400 border border-sky-500/20">
|
||||||
|
#{{ $tag->name }}
|
||||||
|
</span>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<h2 class="text-base font-semibold text-white group-hover:text-sky-300 transition-colors line-clamp-2 leading-snug">
|
||||||
|
{{ $story->title }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
@if($story->excerpt)
|
||||||
|
<p class="mt-2 text-sm text-white/45 line-clamp-2">{{ $story->excerpt }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center gap-2 text-xs text-white/30">
|
||||||
|
@if($story->author)
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
@if($story->author->avatar_url)
|
||||||
|
<img src="{{ $story->author->avatar_url }}" alt="{{ $story->author->name }}"
|
||||||
|
class="w-4 h-4 rounded-full object-cover" />
|
||||||
|
@endif
|
||||||
|
{{ $story->author->name }}
|
||||||
|
</span>
|
||||||
|
<span>·</span>
|
||||||
|
@endif
|
||||||
|
@if($story->published_at)
|
||||||
|
<time datetime="{{ $story->published_at->toIso8601String() }}">
|
||||||
|
{{ $story->published_at->format('M j, Y') }}
|
||||||
|
</time>
|
||||||
|
<span>·</span>
|
||||||
|
@endif
|
||||||
|
<span>{{ $story->reading_time }} min read</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 flex justify-center">
|
||||||
|
{{ $stories->withQueryString()->links() }}
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-16 text-center">
|
||||||
|
<i class="fa-solid fa-feather-pointed text-4xl text-white/20 mb-4 block"></i>
|
||||||
|
<p class="text-white/40 text-sm">No stories published yet. Check back soon!</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@endsection
|
||||||
229
resources/views/web/stories/show.blade.php
Normal file
229
resources/views/web/stories/show.blade.php
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
{{--
|
||||||
|
Single story page — /stories/{slug}
|
||||||
|
Uses ContentLayout.
|
||||||
|
Includes: Hero, Article content, Author box, Related stories, Share buttons.
|
||||||
|
--}}
|
||||||
|
@extends('layouts.nova.content-layout')
|
||||||
|
|
||||||
|
@php
|
||||||
|
$hero_title = $story->title;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
{{-- OpenGraph --}}
|
||||||
|
<meta property="og:type" content="article" />
|
||||||
|
<meta property="og:title" content="{{ $story->title }}" />
|
||||||
|
<meta property="og:description" content="{{ $story->meta_excerpt }}" />
|
||||||
|
@if($story->cover_url)
|
||||||
|
<meta property="og:image" content="{{ $story->cover_url }}" />
|
||||||
|
@endif
|
||||||
|
<meta property="og:url" content="{{ $story->url }}" />
|
||||||
|
<meta property="og:site_name" content="Skinbase" />
|
||||||
|
|
||||||
|
@if($story->published_at)
|
||||||
|
<meta property="article:published_time" content="{{ $story->published_at->toIso8601String() }}" />
|
||||||
|
@endif
|
||||||
|
@if($story->updated_at)
|
||||||
|
<meta property="article:modified_time" content="{{ $story->updated_at->toIso8601String() }}" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Twitter / X card --}}
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="{{ $story->title }}" />
|
||||||
|
<meta name="twitter:description" content="{{ $story->meta_excerpt }}" />
|
||||||
|
@if($story->cover_url)
|
||||||
|
<meta name="twitter:image" content="{{ $story->cover_url }}" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Article structured data (schema.org) --}}
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{!! json_encode(array_filter([
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'Article',
|
||||||
|
'headline' => $story->title,
|
||||||
|
'description' => $story->meta_excerpt,
|
||||||
|
'image' => $story->cover_url,
|
||||||
|
'datePublished' => $story->published_at?->toIso8601String(),
|
||||||
|
'dateModified' => $story->updated_at?->toIso8601String(),
|
||||||
|
'mainEntityOfPage' => $story->url,
|
||||||
|
'author' => $story->author ? [
|
||||||
|
'@type' => 'Person',
|
||||||
|
'name' => $story->author->name,
|
||||||
|
] : [
|
||||||
|
'@type' => 'Organization',
|
||||||
|
'name' => 'Skinbase',
|
||||||
|
],
|
||||||
|
'publisher' => [
|
||||||
|
'@type' => 'Organization',
|
||||||
|
'name' => 'Skinbase',
|
||||||
|
'url' => url('/'),
|
||||||
|
],
|
||||||
|
]), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('page-content')
|
||||||
|
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
|
||||||
|
{{-- ── HERO ──────────────────────────────────────────────────────────────── --}}
|
||||||
|
@if($story->cover_url)
|
||||||
|
<div class="rounded-2xl overflow-hidden mb-8 aspect-video">
|
||||||
|
<img src="{{ $story->cover_url }}" alt="{{ $story->title }}"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
loading="eager" />
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Meta bar --}}
|
||||||
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-white/40 mb-6">
|
||||||
|
@if($story->author)
|
||||||
|
<a href="{{ $story->author->profile_url }}"
|
||||||
|
class="flex items-center gap-2 hover:text-white/60 transition-colors">
|
||||||
|
@if($story->author->avatar_url)
|
||||||
|
<img src="{{ $story->author->avatar_url }}" alt="{{ $story->author->name }}"
|
||||||
|
class="w-6 h-6 rounded-full object-cover" />
|
||||||
|
@endif
|
||||||
|
<span>{{ $story->author->name }}</span>
|
||||||
|
</a>
|
||||||
|
<span>·</span>
|
||||||
|
@endif
|
||||||
|
@if($story->published_at)
|
||||||
|
<time datetime="{{ $story->published_at->toIso8601String() }}">
|
||||||
|
{{ $story->published_at->format('F j, Y') }}
|
||||||
|
</time>
|
||||||
|
<span>·</span>
|
||||||
|
@endif
|
||||||
|
<span>{{ $story->reading_time }} min read</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span><i class="fa-regular fa-eye mr-1 text-xs"></i>{{ number_format($story->views) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Tags --}}
|
||||||
|
@if($story->tags->isNotEmpty())
|
||||||
|
<div class="flex flex-wrap gap-2 mb-8">
|
||||||
|
@foreach($story->tags as $tag)
|
||||||
|
<a href="{{ $tag->url }}"
|
||||||
|
class="rounded-full px-3 py-1 text-xs font-medium bg-sky-500/10 text-sky-400 border border-sky-500/20 hover:bg-sky-500/20 transition-colors">
|
||||||
|
#{{ $tag->name }}
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- ── ARTICLE CONTENT ──────────────────────────────────────────────────── --}}
|
||||||
|
<article class="prose prose-invert prose-headings:text-white prose-a:text-sky-400 hover:prose-a:text-sky-300 prose-p:text-white/70 prose-strong:text-white prose-blockquote:border-sky-500 prose-blockquote:text-white/60 max-w-none">
|
||||||
|
@if($story->content)
|
||||||
|
{!! $story->content !!}
|
||||||
|
@else
|
||||||
|
<p class="text-white/40 italic">Content not available.</p>
|
||||||
|
@endif
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{{-- ── SHARE BUTTONS ────────────────────────────────────────────────────── --}}
|
||||||
|
<div class="mt-12 pt-8 border-t border-white/[0.06]">
|
||||||
|
<p class="text-sm text-white/40 mb-4">Share this story</p>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<a href="https://twitter.com/intent/tweet?text={{ urlencode($story->title) }}&url={{ urlencode($story->url) }}"
|
||||||
|
target="_blank" rel="noopener"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.03] px-4 py-2 text-sm text-white/60 hover:bg-white/[0.07] hover:text-white transition-colors">
|
||||||
|
<i class="fa-brands fa-x-twitter text-xs"></i> Share on X
|
||||||
|
</a>
|
||||||
|
<a href="https://www.reddit.com/submit?title={{ urlencode($story->title) }}&url={{ urlencode($story->url) }}"
|
||||||
|
target="_blank" rel="noopener"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.03] px-4 py-2 text-sm text-white/60 hover:bg-white/[0.07] hover:text-white transition-colors">
|
||||||
|
<i class="fa-brands fa-reddit text-xs"></i> Reddit
|
||||||
|
</a>
|
||||||
|
<button type="button"
|
||||||
|
onclick="navigator.clipboard.writeText('{{ $story->url }}').then(() => { this.textContent = '✓ Copied!'; setTimeout(() => { this.innerHTML = '<i class=\'fa-regular fa-link text-xs\'></i> Copy link'; }, 2000); })"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.03] px-4 py-2 text-sm text-white/60 hover:bg-white/[0.07] hover:text-white transition-colors">
|
||||||
|
<i class="fa-regular fa-link text-xs"></i> Copy link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ── AUTHOR BOX ───────────────────────────────────────────────────────── --}}
|
||||||
|
@if($story->author)
|
||||||
|
<div class="mt-12 rounded-2xl border border-white/[0.06] bg-white/[0.02] p-6">
|
||||||
|
<div class="flex items-start gap-5">
|
||||||
|
@if($story->author->avatar_url)
|
||||||
|
<img src="{{ $story->author->avatar_url }}" alt="{{ $story->author->name }}"
|
||||||
|
class="w-16 h-16 rounded-full object-cover border border-white/10 flex-shrink-0" />
|
||||||
|
@else
|
||||||
|
<div class="w-16 h-16 rounded-full bg-nova-700 flex items-center justify-center flex-shrink-0">
|
||||||
|
<i class="fa-solid fa-user text-xl text-white/30"></i>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-xs uppercase tracking-widest text-white/30 mb-1">About the author</p>
|
||||||
|
<h3 class="text-lg font-semibold text-white">{{ $story->author->name }}</h3>
|
||||||
|
@if($story->author->bio)
|
||||||
|
<p class="mt-2 text-sm text-white/55">{{ $story->author->bio }}</p>
|
||||||
|
@endif
|
||||||
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
|
@if($story->author->user)
|
||||||
|
<a href="{{ $story->author->profile_url }}"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm text-sky-400 hover:text-sky-300 transition-colors">
|
||||||
|
More from this artist <i class="fa-solid fa-arrow-right text-xs"></i>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
<a href="/stories/author/{{ $story->author->user?->username ?? urlencode($story->author->name) }}"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm text-white/40 hover:text-white/60 transition-colors">
|
||||||
|
All stories
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- ── RELATED STORIES ─────────────────────────────────────────────────── --}}
|
||||||
|
@if($related->isNotEmpty())
|
||||||
|
<div class="mt-12">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-6">Related Stories</h2>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
|
@foreach($related as $rel)
|
||||||
|
<a href="{{ $rel->url }}"
|
||||||
|
class="group block rounded-xl border border-white/[0.06] bg-white/[0.02] overflow-hidden hover:bg-white/[0.05] transition-colors">
|
||||||
|
@if($rel->cover_url)
|
||||||
|
<div class="aspect-video overflow-hidden bg-nova-800">
|
||||||
|
<img src="{{ $rel->cover_url }}" alt="{{ $rel->title }}"
|
||||||
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
loading="lazy" />
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="aspect-video bg-gradient-to-br from-sky-900/20 to-indigo-900/20 flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-feather-pointed text-2xl text-white/15"></i>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="text-sm font-medium text-white group-hover:text-sky-300 transition-colors line-clamp-2 leading-snug">
|
||||||
|
{{ $rel->title }}
|
||||||
|
</h3>
|
||||||
|
<div class="mt-2 flex items-center gap-2 text-xs text-white/30">
|
||||||
|
@if($rel->published_at)
|
||||||
|
<time datetime="{{ $rel->published_at->toIso8601String() }}">
|
||||||
|
{{ $rel->published_at->format('M j, Y') }}
|
||||||
|
</time>
|
||||||
|
<span>·</span>
|
||||||
|
@endif
|
||||||
|
<span>{{ $rel->reading_time }} min read</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Back link --}}
|
||||||
|
<div class="mt-12 pt-8 border-t border-white/[0.06]">
|
||||||
|
<a href="/stories" class="inline-flex items-center gap-2 text-sm text-sky-400 hover:text-sky-300 transition-colors">
|
||||||
|
<i class="fa-solid fa-arrow-left text-xs"></i>
|
||||||
|
Back to Stories
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
68
resources/views/web/stories/tag.blade.php
Normal file
68
resources/views/web/stories/tag.blade.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{{--
|
||||||
|
Tag stories page — /stories/tag/{tag}
|
||||||
|
Uses ContentLayout.
|
||||||
|
--}}
|
||||||
|
@extends('layouts.nova.content-layout')
|
||||||
|
|
||||||
|
@php
|
||||||
|
$hero_title = '#' . $storyTag->name;
|
||||||
|
$hero_description = 'Stories tagged with "' . $storyTag->name . '" on Skinbase.';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@section('page-content')
|
||||||
|
|
||||||
|
@if($stories->isNotEmpty())
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
@foreach($stories as $story)
|
||||||
|
<a href="{{ $story->url }}"
|
||||||
|
class="group block rounded-xl border border-white/[0.06] bg-white/[0.02] overflow-hidden hover:bg-white/[0.05] transition-colors">
|
||||||
|
@if($story->cover_url)
|
||||||
|
<div class="aspect-video bg-nova-800 overflow-hidden">
|
||||||
|
<img src="{{ $story->cover_url }}" alt="{{ $story->title }}"
|
||||||
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
loading="lazy" />
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="aspect-video bg-gradient-to-br from-sky-900/20 to-indigo-900/20 flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-feather-pointed text-3xl text-white/15"></i>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div class="p-5">
|
||||||
|
<h2 class="text-base font-semibold text-white group-hover:text-sky-300 transition-colors line-clamp-2 leading-snug">
|
||||||
|
{{ $story->title }}
|
||||||
|
</h2>
|
||||||
|
@if($story->excerpt)
|
||||||
|
<p class="mt-2 text-sm text-white/45 line-clamp-2">{{ $story->excerpt }}</p>
|
||||||
|
@endif
|
||||||
|
<div class="mt-4 flex items-center gap-2 text-xs text-white/30">
|
||||||
|
@if($story->author)
|
||||||
|
<span>{{ $story->author->name }}</span>
|
||||||
|
<span>·</span>
|
||||||
|
@endif
|
||||||
|
@if($story->published_at)
|
||||||
|
<time datetime="{{ $story->published_at->toIso8601String() }}">
|
||||||
|
{{ $story->published_at->format('M j, Y') }}
|
||||||
|
</time>
|
||||||
|
<span>·</span>
|
||||||
|
@endif
|
||||||
|
<span>{{ $story->reading_time }} min read</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 flex justify-center">
|
||||||
|
{{ $stories->withQueryString()->links() }}
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-16 text-center">
|
||||||
|
<i class="fa-solid fa-tag text-4xl text-white/20 mb-4 block"></i>
|
||||||
|
<p class="text-white/40 text-sm">No stories found for this tag.</p>
|
||||||
|
<a href="/stories" class="mt-4 inline-block text-sm text-sky-400 hover:text-sky-300 transition-colors">
|
||||||
|
Browse all stories →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@endsection
|
||||||
@@ -570,3 +570,21 @@ Route::middleware(['web', 'auth'])
|
|||||||
|
|
||||||
// ── Artwork search for share modal (public, throttled) ────────────────────────
|
// ── Artwork search for share modal (public, throttled) ────────────────────────
|
||||||
// GET /api/search/artworks?q=...&shareable=1 → reuses existing ArtworkSearchController
|
// GET /api/search/artworks?q=...&shareable=1 → reuses existing ArtworkSearchController
|
||||||
|
|
||||||
|
// ── Stories API (public, throttled) ──────────────────────────────────────────
|
||||||
|
Route::middleware(['throttle:60,1'])
|
||||||
|
->prefix('stories')
|
||||||
|
->name('api.stories.')
|
||||||
|
->group(function () {
|
||||||
|
Route::get('/', [\App\Http\Controllers\Api\StoriesApiController::class, 'index'])->name('index');
|
||||||
|
Route::get('/featured', [\App\Http\Controllers\Api\StoriesApiController::class, 'featured'])->name('featured');
|
||||||
|
Route::get('/tag/{tag}', [\App\Http\Controllers\Api\StoriesApiController::class, 'byTag'])
|
||||||
|
->where('tag', '[a-z0-9\-]+')
|
||||||
|
->name('tag');
|
||||||
|
Route::get('/author/{username}',[\App\Http\Controllers\Api\StoriesApiController::class, 'byAuthor'])
|
||||||
|
->where('username', '[A-Za-z0-9_\-]{1,50}')
|
||||||
|
->name('author');
|
||||||
|
Route::get('/{slug}', [\App\Http\Controllers\Api\StoriesApiController::class, 'show'])
|
||||||
|
->where('slug', '[a-z0-9\-]+')
|
||||||
|
->name('show');
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,9 +11,22 @@ use App\Http\Controllers\Auth\RegisteredUserController;
|
|||||||
use App\Http\Controllers\Auth\RegistrationVerificationController;
|
use App\Http\Controllers\Auth\RegistrationVerificationController;
|
||||||
use App\Http\Controllers\Auth\SetupPasswordController;
|
use App\Http\Controllers\Auth\SetupPasswordController;
|
||||||
use App\Http\Controllers\Auth\SetupUsernameController;
|
use App\Http\Controllers\Auth\SetupUsernameController;
|
||||||
|
use App\Http\Controllers\Auth\OAuthController;
|
||||||
use App\Http\Controllers\Auth\VerifyEmailController;
|
use App\Http\Controllers\Auth\VerifyEmailController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
// ── OAuth / Social Login ─────────────────────────────────────────────────────
|
||||||
|
Route::middleware('guest')->group(function (): void {
|
||||||
|
Route::get('auth/{provider}/redirect', [OAuthController::class, 'redirectToProvider'])
|
||||||
|
->where('provider', 'google|discord')
|
||||||
|
->name('oauth.redirect');
|
||||||
|
|
||||||
|
// Google and Discord use GET callbacks; Apple sends a POST on first login.
|
||||||
|
Route::match(['get', 'post'], 'auth/{provider}/callback', [OAuthController::class, 'handleProviderCallback'])
|
||||||
|
->where('provider', 'google|discord')
|
||||||
|
->name('oauth.callback');
|
||||||
|
});
|
||||||
|
|
||||||
Route::middleware(['guest', 'normalize.username'])->group(function () {
|
Route::middleware(['guest', 'normalize.username'])->group(function () {
|
||||||
Route::get('register', [RegisteredUserController::class, 'create'])
|
Route::get('register', [RegisteredUserController::class, 'create'])
|
||||||
->name('register');
|
->name('register');
|
||||||
|
|||||||
@@ -36,10 +36,20 @@ use App\Http\Controllers\Web\DiscoverController;
|
|||||||
use App\Http\Controllers\Web\ExploreController;
|
use App\Http\Controllers\Web\ExploreController;
|
||||||
use App\Http\Controllers\Web\BlogController;
|
use App\Http\Controllers\Web\BlogController;
|
||||||
use App\Http\Controllers\Web\PageController;
|
use App\Http\Controllers\Web\PageController;
|
||||||
|
use App\Http\Controllers\Web\StoriesController;
|
||||||
|
use App\Http\Controllers\Web\StoryController;
|
||||||
|
use App\Http\Controllers\Web\StoriesTagController;
|
||||||
|
use App\Http\Controllers\Web\StoriesAuthorController;
|
||||||
use App\Http\Controllers\Web\FooterController;
|
use App\Http\Controllers\Web\FooterController;
|
||||||
use App\Http\Controllers\Web\StaffController;
|
use App\Http\Controllers\Web\StaffController;
|
||||||
use App\Http\Controllers\Web\RssFeedController;
|
use App\Http\Controllers\Web\RssFeedController;
|
||||||
use App\Http\Controllers\Web\ApplicationController;
|
use App\Http\Controllers\Web\ApplicationController;
|
||||||
|
use App\Http\Controllers\RSS\GlobalFeedController;
|
||||||
|
use App\Http\Controllers\RSS\DiscoverFeedController;
|
||||||
|
use App\Http\Controllers\RSS\ExploreFeedController;
|
||||||
|
use App\Http\Controllers\RSS\TagFeedController;
|
||||||
|
use App\Http\Controllers\RSS\CreatorFeedController;
|
||||||
|
use App\Http\Controllers\RSS\BlogFeedController;
|
||||||
use App\Http\Controllers\Web\StaffApplicationAdminController;
|
use App\Http\Controllers\Web\StaffApplicationAdminController;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
@@ -117,12 +127,50 @@ Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function ()
|
|||||||
Route::get('/applications/{staffApplication}', [StaffApplicationAdminController::class, 'show'])->name('applications.show');
|
Route::get('/applications/{staffApplication}', [StaffApplicationAdminController::class, 'show'])->name('applications.show');
|
||||||
});
|
});
|
||||||
|
|
||||||
// RSS XML feeds
|
// RSS XML feeds (legacy .xml routes — kept for backward compatibility)
|
||||||
Route::get('/rss/latest-uploads.xml', [RssFeedController::class, 'latestUploads'])->name('rss.uploads');
|
Route::get('/rss/latest-uploads.xml', [RssFeedController::class, 'latestUploads'])->name('rss.uploads');
|
||||||
Route::get('/rss/latest-skins.xml', [RssFeedController::class, 'latestSkins'])->name('rss.skins');
|
Route::get('/rss/latest-skins.xml', [RssFeedController::class, 'latestSkins'])->name('rss.skins');
|
||||||
Route::get('/rss/latest-wallpapers.xml', [RssFeedController::class, 'latestWallpapers'])->name('rss.wallpapers');
|
Route::get('/rss/latest-wallpapers.xml', [RssFeedController::class, 'latestWallpapers'])->name('rss.wallpapers');
|
||||||
Route::get('/rss/latest-photos.xml', [RssFeedController::class, 'latestPhotos'])->name('rss.photos');
|
Route::get('/rss/latest-photos.xml', [RssFeedController::class, 'latestPhotos'])->name('rss.photos');
|
||||||
|
|
||||||
|
// ── RSS 2.0 Nova feeds (/rss/*) ───────────────────────────────────────────────
|
||||||
|
Route::middleware('throttle:60,1')->group(function () {
|
||||||
|
// Global feed
|
||||||
|
Route::get('/rss', GlobalFeedController::class)->name('rss.global');
|
||||||
|
|
||||||
|
// Discover feeds
|
||||||
|
Route::prefix('rss/discover')->name('rss.discover.')->group(function () {
|
||||||
|
Route::get('/', [DiscoverFeedController::class, 'index'])->name('index');
|
||||||
|
Route::get('/trending', [DiscoverFeedController::class, 'trending'])->name('trending');
|
||||||
|
Route::get('/fresh', [DiscoverFeedController::class, 'fresh'])->name('fresh');
|
||||||
|
Route::get('/rising', [DiscoverFeedController::class, 'rising'])->name('rising');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Explore category feeds
|
||||||
|
Route::prefix('rss/explore')->name('rss.explore.')->group(function () {
|
||||||
|
Route::get('/{type}', [ExploreFeedController::class, 'byType'])
|
||||||
|
->where('type', 'artworks|wallpapers|skins|photography|other')
|
||||||
|
->name('type');
|
||||||
|
Route::get('/{type}/{mode}', [ExploreFeedController::class, 'byTypeMode'])
|
||||||
|
->where('type', 'artworks|wallpapers|skins|photography|other')
|
||||||
|
->where('mode', 'trending|latest|best')
|
||||||
|
->name('type.mode');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tag feeds
|
||||||
|
Route::get('/rss/tag/{slug}', TagFeedController::class)
|
||||||
|
->where('slug', '[a-z0-9\-]+')
|
||||||
|
->name('rss.tag');
|
||||||
|
|
||||||
|
// Creator feeds
|
||||||
|
Route::get('/rss/creator/{username}', CreatorFeedController::class)
|
||||||
|
->where('username', '[A-Za-z0-9_\-]{3,20}')
|
||||||
|
->name('rss.creator');
|
||||||
|
|
||||||
|
// Blog feed
|
||||||
|
Route::get('/rss/blog', BlogFeedController::class)->name('rss.blog');
|
||||||
|
});
|
||||||
|
|
||||||
// ── 301 REDIRECTS: Legacy → Canonical URLs ────────────────────────────────────
|
// ── 301 REDIRECTS: Legacy → Canonical URLs ────────────────────────────────────
|
||||||
// §6.1 — /browse → /explore
|
// §6.1 — /browse → /explore
|
||||||
Route::get('/browse-redirect', fn () => redirect('/explore', 301))->name('legacy.browse.redirect');
|
Route::get('/browse-redirect', fn () => redirect('/explore', 301))->name('legacy.browse.redirect');
|
||||||
@@ -137,8 +185,19 @@ Route::prefix('creators')->name('creators.')->group(function () {
|
|||||||
Route::get('/rising', [\App\Http\Controllers\Web\DiscoverController::class, 'risingCreators'])->name('rising');
|
Route::get('/rising', [\App\Http\Controllers\Web\DiscoverController::class, 'risingCreators'])->name('rising');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Creator Stories → canonical rename of /interviews
|
// ── STORIES routes (/stories/*) — Nova Stories System ────────────────────────
|
||||||
Route::get('/stories', [\App\Http\Controllers\Community\InterviewController::class, 'index'])->name('stories');
|
Route::prefix('stories')->name('stories.')->group(function () {
|
||||||
|
Route::get('/', [StoriesController::class, 'index'])->name('index');
|
||||||
|
Route::get('/tag/{tag}', [StoriesTagController::class, 'show'])
|
||||||
|
->where('tag', '[a-z0-9\-]+')
|
||||||
|
->name('tag');
|
||||||
|
Route::get('/author/{username}', [StoriesAuthorController::class, 'show'])
|
||||||
|
->where('username', '[A-Za-z0-9_\-]{1,50}')
|
||||||
|
->name('author');
|
||||||
|
Route::get('/{slug}', [StoryController::class, 'show'])
|
||||||
|
->where('slug', '[a-z0-9\-]+')
|
||||||
|
->name('show');
|
||||||
|
});
|
||||||
|
|
||||||
// Tags listing page
|
// Tags listing page
|
||||||
Route::get('/tags', [\App\Http\Controllers\Web\TagController::class, 'index'])->name('tags.index');
|
Route::get('/tags', [\App\Http\Controllers\Web\TagController::class, 'index'])->name('tags.index');
|
||||||
@@ -410,6 +469,10 @@ require __DIR__.'/auth.php';
|
|||||||
Route::get('/search', [\App\Http\Controllers\Web\SearchController::class, 'index'])
|
Route::get('/search', [\App\Http\Controllers\Web\SearchController::class, 'index'])
|
||||||
->name('search');
|
->name('search');
|
||||||
|
|
||||||
|
// Public instructions page used by OAuth providers for user data deletion requests
|
||||||
|
Route::view('/data-deletion', 'privacy.data-deletion')
|
||||||
|
->name('privacy.data_deletion');
|
||||||
|
|
||||||
Route::get('/tag/{tag:slug}', [\App\Http\Controllers\Web\TagController::class, 'show'])
|
Route::get('/tag/{tag:slug}', [\App\Http\Controllers\Web\TagController::class, 'show'])
|
||||||
->where('tag', '[a-z0-9\-]+')
|
->where('tag', '[a-z0-9\-]+')
|
||||||
->name('tags.show');
|
->name('tags.show');
|
||||||
|
|||||||
6623
user_profiles_avatar.csv
Normal file
6623
user_profiles_avatar.csv
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user