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:
@@ -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);
|
||||
|
||||
@@ -104,20 +104,22 @@ final class UploadPipelineService
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function processAndPublish(string $sessionId, string $hash, int $artworkId): array
|
||||
public function processAndPublish(string $sessionId, string $hash, int $artworkId, ?string $originalFileName = null): array
|
||||
{
|
||||
$session = $this->sessions->getOrFail($sessionId);
|
||||
|
||||
$originalPath = $this->derivatives->storeOriginal($session->tempPath, $hash);
|
||||
$originalRelative = $this->storage->sectionRelativePath('originals', $hash, 'orig.webp');
|
||||
$this->artworkFiles->upsert($artworkId, 'orig', $originalRelative, 'image/webp', (int) filesize($originalPath));
|
||||
$originalPath = $this->derivatives->storeOriginal($session->tempPath, $hash, $originalFileName);
|
||||
$origFilename = basename($originalPath);
|
||||
$originalRelative = $this->storage->sectionRelativePath('original', $hash, $origFilename);
|
||||
$origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream';
|
||||
$this->artworkFiles->upsert($artworkId, 'orig', $originalRelative, $origMime, (int) filesize($originalPath));
|
||||
|
||||
$publicAbsolute = $this->derivatives->generatePublicDerivatives($session->tempPath, $hash);
|
||||
$publicRelative = [];
|
||||
|
||||
foreach ($publicAbsolute as $variant => $absolutePath) {
|
||||
$filename = $variant . '.webp';
|
||||
$relativePath = $this->storage->publicRelativePath($hash, $filename);
|
||||
$filename = $hash . '.webp';
|
||||
$relativePath = $this->storage->sectionRelativePath($variant, $hash, $filename);
|
||||
$this->artworkFiles->upsert($artworkId, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath));
|
||||
$publicRelative[$variant] = $relativePath;
|
||||
}
|
||||
@@ -126,13 +128,14 @@ final class UploadPipelineService
|
||||
$width = is_array($dimensions) && isset($dimensions[0]) ? (int) $dimensions[0] : 1;
|
||||
$height = is_array($dimensions) && isset($dimensions[1]) ? (int) $dimensions[1] : 1;
|
||||
|
||||
$origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: '');
|
||||
Artwork::query()->whereKey($artworkId)->update([
|
||||
'file_name' => basename($originalRelative),
|
||||
'file_name' => $origFilename,
|
||||
'file_path' => '',
|
||||
'file_size' => (int) filesize($originalPath),
|
||||
'mime_type' => 'image/webp',
|
||||
'mime_type' => $origMime,
|
||||
'hash' => $hash,
|
||||
'file_ext' => 'webp',
|
||||
'file_ext' => $origExt,
|
||||
'thumb_ext' => 'webp',
|
||||
'width' => max(1, $width),
|
||||
'height' => max(1, $height),
|
||||
@@ -152,6 +155,11 @@ final class UploadPipelineService
|
||||
];
|
||||
}
|
||||
|
||||
public function originalHashExists(string $hash): bool
|
||||
{
|
||||
return $this->storage->originalHashExists($hash);
|
||||
}
|
||||
|
||||
private function quarantine(UploadSessionData $session, string $reason): void
|
||||
{
|
||||
$newPath = $this->storage->moveToSection($session->tempPath, 'quarantine');
|
||||
|
||||
@@ -76,33 +76,6 @@ final class UploadStorageService
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public function publicHashDirectory(string $hash): string
|
||||
{
|
||||
$prefix = trim((string) config('uploads.public_img_prefix', 'img'), DIRECTORY_SEPARATOR);
|
||||
$base = $this->sectionPath('public') . DIRECTORY_SEPARATOR . $prefix;
|
||||
|
||||
if (! File::exists($base)) {
|
||||
File::makeDirectory($base, 0755, true);
|
||||
}
|
||||
|
||||
$segments = $this->hashSegments($hash);
|
||||
$dir = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments);
|
||||
|
||||
if (! File::exists($dir)) {
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public function publicRelativePath(string $hash, string $filename): string
|
||||
{
|
||||
$prefix = trim((string) config('uploads.public_img_prefix', 'img'), DIRECTORY_SEPARATOR);
|
||||
$segments = $this->hashSegments($hash);
|
||||
|
||||
return $prefix . '/' . implode('/', $segments) . '/' . ltrim($filename, '/');
|
||||
}
|
||||
|
||||
public function sectionRelativePath(string $section, string $hash, string $filename): string
|
||||
{
|
||||
$segments = $this->hashSegments($hash);
|
||||
@@ -111,6 +84,24 @@ final class UploadStorageService
|
||||
return $section . '/' . implode('/', $segments) . '/' . ltrim($filename, '/');
|
||||
}
|
||||
|
||||
public function originalHashExists(string $hash): bool
|
||||
{
|
||||
$segments = $this->hashSegments($hash);
|
||||
$dir = $this->sectionPath('original') . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments);
|
||||
|
||||
if (! File::isDirectory($dir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$normalizedHash = strtolower(preg_replace('/[^a-z0-9]/', '', $hash) ?? '');
|
||||
if ($normalizedHash === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$matches = File::glob($dir . DIRECTORY_SEPARATOR . $normalizedHash . '.*');
|
||||
return is_array($matches) && count($matches) > 0;
|
||||
}
|
||||
|
||||
private function safeExtension(UploadedFile $file): string
|
||||
{
|
||||
$extension = (string) $file->guessExtension();
|
||||
@@ -125,10 +116,11 @@ final class UploadStorageService
|
||||
$hash = preg_replace('/[^a-z0-9]/', '', $hash) ?? '';
|
||||
$hash = str_pad($hash, 6, '0');
|
||||
|
||||
// Use two 2-char segments for directory sharding: first two chars, next two chars.
|
||||
// Result: <section>/<aa>/<bb>/<filename>
|
||||
$segments = [
|
||||
substr($hash, 0, 2),
|
||||
substr($hash, 2, 2),
|
||||
substr($hash, 4, 2),
|
||||
];
|
||||
|
||||
return array_map(static fn (string $part): string => $part === '' ? '00' : $part, $segments);
|
||||
|
||||
Reference in New Issue
Block a user