feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile

Forum:
- TipTap WYSIWYG editor with full toolbar
- @emoji-mart/react emoji picker (consistent with tweets)
- @mention autocomplete with user search API
- Fix PHP 8.4 parse errors in Blade templates
- Fix thread data display (paginator items)
- Align forum page widths to max-w-5xl

Discover:
- Extract shared _nav.blade.php partial
- Add missing nav links to for-you page
- Add Following link for authenticated users

Feed/Posts:
- Post model, controllers, policies, migrations
- Feed page components (PostComposer, FeedCard, etc)
- Post reactions, comments, saves, reports, sharing
- Scheduled publishing support
- Link preview controller

Profile:
- Profile page components (ProfileHero, ProfileTabs)
- Profile API controller

Uploads:
- Upload wizard enhancements
- Scheduled publish picker
- Studio status bar and readiness checklist
This commit is contained in:
2026-03-03 09:48:31 +01:00
parent 1266f81d35
commit dc51d65440
178 changed files with 14308 additions and 665 deletions

View File

@@ -27,34 +27,81 @@ final class UploadDerivativesService
}
}
public function storeOriginal(string $sourcePath, string $hash): string
public function storeOriginal(string $sourcePath, string $hash, ?string $originalFileName = null): string
{
$this->assertImageAvailable();
// Preserve original file extension and store with filename = <hash>.<ext>
$dir = $this->storage->ensureHashDirectory('original', $hash);
$dir = $this->storage->ensureHashDirectory('originals', $hash);
$target = $dir . DIRECTORY_SEPARATOR . 'orig.webp';
$quality = (int) config('uploads.quality', 85);
$origExt = $this->resolveOriginalExtension($sourcePath, $originalFileName);
$target = $dir . DIRECTORY_SEPARATOR . $hash . ($origExt !== '' ? '.' . $origExt : '');
/** @var InterventionImageInterface $img */
$img = $this->manager->read($sourcePath);
$encoder = new \Intervention\Image\Encoders\WebpEncoder($quality);
$encoded = (string) $img->encode($encoder);
File::put($target, $encoded);
// Try a direct copy first (works for images and archives). If that fails,
// fall back to re-encoding image to webp as a last resort.
try {
if (! File::copy($sourcePath, $target)) {
throw new \RuntimeException('Copy failed');
}
} catch (\Throwable $e) {
// Fallback: encode to webp
$quality = (int) config('uploads.quality', 85);
/** @var InterventionImageInterface $img */
$img = $this->manager->read($sourcePath);
$encoder = new \Intervention\Image\Encoders\WebpEncoder($quality);
$encoded = (string) $img->encode($encoder);
$target = $dir . DIRECTORY_SEPARATOR . $hash . '.webp';
File::put($target, $encoded);
}
return $target;
}
private function resolveOriginalExtension(string $sourcePath, ?string $originalFileName): string
{
$fromClientName = strtolower((string) pathinfo((string) $originalFileName, PATHINFO_EXTENSION));
if ($fromClientName !== '' && preg_match('/^[a-z0-9]{1,12}$/', $fromClientName) === 1) {
return $fromClientName;
}
$fromSource = strtolower((string) pathinfo($sourcePath, PATHINFO_EXTENSION));
if ($fromSource !== '' && $fromSource !== 'upload' && preg_match('/^[a-z0-9]{1,12}$/', $fromSource) === 1) {
return $fromSource;
}
$mime = File::exists($sourcePath) ? (string) (File::mimeType($sourcePath) ?? '') : '';
return $this->extensionFromMime($mime);
}
private function extensionFromMime(string $mime): string
{
return match (strtolower($mime)) {
'image/jpeg', 'image/jpg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'image/gif' => 'gif',
'image/bmp' => 'bmp',
'image/tiff' => 'tif',
'application/zip', 'application/x-zip-compressed' => 'zip',
'application/x-rar-compressed', 'application/vnd.rar' => 'rar',
'application/x-7z-compressed' => '7z',
'application/x-tar' => 'tar',
'application/gzip', 'application/x-gzip' => 'gz',
default => 'bin',
};
}
public function generatePublicDerivatives(string $sourcePath, string $hash): array
{
$this->assertImageAvailable();
$quality = (int) config('uploads.quality', 85);
$variants = (array) config('uploads.derivatives', []);
$dir = $this->storage->publicHashDirectory($hash);
$written = [];
foreach ($variants as $variant => $options) {
$variant = (string) $variant;
$path = $dir . DIRECTORY_SEPARATOR . $variant . '.webp';
$dir = $this->storage->ensureHashDirectory($variant, $hash);
// store derivative filename as <hash>.webp per variant directory
$path = $dir . DIRECTORY_SEPARATOR . $hash . '.webp';
/** @var InterventionImageInterface $img */
$img = $this->manager->read($sourcePath);