feat: media gallery picker, custom emoji picker, preview tabs, real platform logos

- gallery: extract /assets tabs (uploads, Unsplash, Giphy) into shared
  GalleryBrowser used by both /assets and a new MediaPickerDialog inside the
  post editor; add JSON search endpoint for workspace assets with tests
- emoji: replace broken emoji-picker-element web component with a custom
  EmojiPicker (full Unicode set, search, categories, recently-used,
  light/dark, i18n)
- preview tab: platform selector pills, variant tabs (data-driven from
  content_types map) so the user can switch Feed/Reel/Story etc. and have
  it autosave through the same handler ScheduleTab uses
- platform logos: shared usePlatformLogo composable (logo + label + content
  types); replaces inline maps across 5 components, fixes
  instagram-facebook falling back to default.png
- tooltips: hover details (display_name · @username + platform label) on
  platform avatars across editor, posts list and calendar
- settings cards: show ` · @username` in the title bar so multiple accounts
  on the same network are distinguishable
- routes: drop the throttle:6,1 group middleware on social connect routes
  (was 429ing legitimate OAuth retries) and rely on the default limiter
This commit is contained in:
Paulo Castellano 2026-05-01 14:53:49 -03:00
parent fc750e9bc0
commit dafdd5da43
51 changed files with 4808 additions and 1320 deletions

View file

@ -165,9 +165,9 @@ public static function allQueues(): array
public function instagramGraphBaseUrl(): string
{
return match ($this) {
self::InstagramFacebook => 'https://graph.facebook.com/v20.0',
self::Instagram => 'https://graph.instagram.com/v24.0',
default => 'https://graph.instagram.com/v24.0',
self::InstagramFacebook => 'https://graph.facebook.com/v25.0',
self::Instagram => 'https://graph.instagram.com/v25.0',
default => 'https://graph.instagram.com/v25.0',
};
}

View file

@ -17,6 +17,11 @@ class PostPlatformStatusUpdated implements ShouldBroadcastNow
public function __construct(public PostPlatform $postPlatform) {}
public function broadcastAs(): string
{
return 'PostPlatformStatusUpdated';
}
public function broadcastOn(): array
{
return [

View file

@ -12,6 +12,7 @@
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@ -31,13 +32,25 @@ public function index(Request $request): Response|RedirectResponse
$this->authorize('createPost', $workspace);
return Inertia::render('assets/Index');
}
public function search(Request $request): AnonymousResourceCollection
{
$workspace = $request->user()->currentWorkspace;
$this->authorize('createPost', $workspace);
$term = trim((string) $request->input('search', ''));
$type = $request->input('type');
$assets = $workspace->getMedia('assets')
->when($term !== '', fn ($query) => $query->where('original_filename', 'ilike', '%'.$term.'%'))
->when(in_array($type, ['image', 'video'], true), fn ($query) => $query->where('type', $type))
->latest()
->paginate(config('app.pagination.default'));
return Inertia::render('assets/Index', [
'assets' => Inertia::scroll(fn () => $assets),
]);
return MediaResource::collection($assets);
}
public function store(StoreAssetRequest $request): MediaResource
@ -46,7 +59,9 @@ public function store(StoreAssetRequest $request): MediaResource
$this->authorize('createPost', $workspace);
$media = $workspace->addMedia($request->file('media'), 'assets');
$clientMeta = (array) $request->input('meta', []);
$media = $workspace->addMedia($request->file('media'), 'assets', $clientMeta);
return new MediaResource($media);
}

View file

@ -269,7 +269,7 @@ public function select(Request $request): View
private function fetchPages(string $userToken): array
{
try {
$response = Http::get('https://graph.facebook.com/v24.0/me/accounts', [
$response = Http::get('https://graph.facebook.com/v25.0/me/accounts', [
'access_token' => $userToken,
'fields' => 'id,name,username,picture{url},access_token',
]);

View file

@ -86,7 +86,7 @@ public function callback(Request $request): View|RedirectResponse
->user();
// Trigger public_profile API call for Meta app review verification
Http::get('https://graph.facebook.com/v20.0/me', [
Http::get('https://graph.facebook.com/v25.0/me', [
'fields' => 'id,name',
'access_token' => $socialUser->token,
]);
@ -238,7 +238,7 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData,
private function fetchPagesWithInstagram(string $userToken): array
{
try {
$response = Http::get('https://graph.facebook.com/v20.0/me/accounts', [
$response = Http::get('https://graph.facebook.com/v25.0/me/accounts', [
'access_token' => $userToken,
'fields' => 'id,name,username,picture{url},access_token,instagram_business_account',
]);
@ -263,7 +263,7 @@ private function fetchPagesWithInstagram(string $userToken): array
}
// Fetch IG account details
$igResponse = Http::get("https://graph.facebook.com/v20.0/{$igAccountId}", [
$igResponse = Http::get("https://graph.facebook.com/v25.0/{$igAccountId}", [
'access_token' => data_get($page, 'access_token'),
'fields' => 'username,name,profile_picture_url',
]);

View file

@ -56,6 +56,7 @@ public function index(Request $request): Response|RedirectResponse
$this->authorize('view', $workspace);
$connectedAccounts = $workspace->socialAccounts()
->orderBy('id')
->get();
$platforms = collect(SocialPlatform::enabled())->map(fn ($platform) => [

View file

@ -17,6 +17,10 @@ public function rules(): array
{
return [
'media' => ['required', 'file', 'max:1048576', 'mimetypes:image/jpeg,image/png,image/gif,image/webp,video/mp4'],
'meta' => ['sometimes', 'array'],
'meta.width' => ['sometimes', 'integer', 'min:1'],
'meta.height' => ['sometimes', 'integer', 'min:1'],
'meta.duration' => ['sometimes', 'numeric', 'min:0'],
];
}
}

View file

@ -66,7 +66,7 @@ public function user(): BelongsTo
public function postPlatforms(): HasMany
{
return $this->hasMany(PostPlatform::class);
return $this->hasMany(PostPlatform::class)->orderBy('id');
}
public function aiMessages(): HasMany

View file

@ -9,8 +9,12 @@
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\Encoders\JpegEncoder;
use Intervention\Image\ImageManager;
trait HasMedia
{
@ -71,12 +75,20 @@ public function addMedia(UploadedFile $file, string $collection = 'default', arr
$mimeType = $file->getMimeType();
$type = $this->getMediaType($mimeType);
$extension = $file->getClientOriginalExtension();
$filename = Str::uuid().'.'.$extension;
// Normalize non-JPEG still images to JPEG q100 for universal platform compatibility.
// GIF is preserved (animation kept for X/Bluesky/Mastodon).
[$normalizedBytes, $normalizedMime, $normalizedExt] = $this->normalizeImageFormat(
$file->getPathname(),
$mimeType,
$type,
$file->getClientOriginalExtension(),
);
$filename = Str::uuid().'.'.$normalizedExt;
$path = 'medias/'.$filename;
Storage::put($path, file_get_contents($file->getPathname()));
Storage::put($path, $normalizedBytes);
return $this->media()->create([
'group_id' => $groupId ?? Str::uuid()->toString(),
@ -84,10 +96,10 @@ public function addMedia(UploadedFile $file, string $collection = 'default', arr
'type' => $type,
'path' => $path,
'original_filename' => $file->getClientOriginalName(),
'mime_type' => $mimeType,
'size' => $file->getSize(),
'mime_type' => $normalizedMime,
'size' => strlen($normalizedBytes),
'order' => 0,
'meta' => array_merge($this->getMediaMeta($file, $type), $meta),
'meta' => array_merge($this->getMediaMetaFromBytes($normalizedBytes, $type, $meta), $meta),
]);
}
@ -102,22 +114,19 @@ public function addMediaFromPath(string $filePath, string $originalFilename, str
$mimeType = mime_content_type($filePath);
$type = $this->getMediaType($mimeType);
$size = filesize($filePath);
$extension = pathinfo($originalFilename, PATHINFO_EXTENSION);
$filename = Str::uuid().'.'.$extension;
[$normalizedBytes, $normalizedMime, $normalizedExt] = $this->normalizeImageFormat(
$filePath,
$mimeType,
$type,
$extension,
);
$filename = Str::uuid().'.'.$normalizedExt;
$storagePath = 'medias/'.$filename;
Storage::put($storagePath, file_get_contents($filePath));
$mediaMeta = [];
if ($type === 'image') {
$imageInfo = @getimagesize($filePath);
if ($imageInfo) {
$mediaMeta['width'] = $imageInfo[0];
$mediaMeta['height'] = $imageInfo[1];
}
}
Storage::put($storagePath, $normalizedBytes);
return $this->media()->create([
'group_id' => $groupId ?? Str::uuid()->toString(),
@ -125,10 +134,10 @@ public function addMediaFromPath(string $filePath, string $originalFilename, str
'type' => $type,
'path' => $storagePath,
'original_filename' => $originalFilename,
'mime_type' => $mimeType,
'size' => $size,
'mime_type' => $normalizedMime,
'size' => strlen($normalizedBytes),
'order' => 0,
'meta' => array_merge($mediaMeta, $meta),
'meta' => array_merge($this->getMediaMetaFromBytes($normalizedBytes, $type, $meta), $meta),
]);
}
@ -172,4 +181,55 @@ private function getMediaMeta(UploadedFile $file, string $type): array
return $meta;
}
/**
* Extract width/height from raw image bytes (used after format normalization
* when we no longer have the original file path).
*/
private function getMediaMetaFromBytes(string $bytes, string $type, array $clientMeta = []): array
{
$meta = [];
if ($type === 'image') {
$imageInfo = @getimagesizefromstring($bytes);
if ($imageInfo) {
$meta['width'] = $imageInfo[0];
$meta['height'] = $imageInfo[1];
}
}
return $meta;
}
/**
* Convert PNG/WebP/HEIC/AVIF to JPEG at q100 (keeps dimensions). GIF and
* JPEG are returned untouched. Non-image types are passed through.
*
* @return array{0: string, 1: string, 2: string} [bytes, mime_type, extension]
*/
private function normalizeImageFormat(string $filePath, string $mimeType, string $type, string $originalExtension): array
{
if ($type !== 'image') {
return [file_get_contents($filePath), $mimeType, $originalExtension];
}
// Formats that publish safely everywhere (JPEG is universal, GIF needed for X/Bluesky/Mastodon).
if (in_array($mimeType, ['image/jpeg', 'image/jpg', 'image/gif'], true)) {
return [file_get_contents($filePath), $mimeType, $originalExtension];
}
try {
$manager = new ImageManager(new Driver);
$encoded = (string) $manager->decodePath($filePath)->encode(new JpegEncoder(quality: 100));
return [$encoded, 'image/jpeg', 'jpg'];
} catch (\Throwable $e) {
Log::warning('HasMedia: image normalization failed, storing original', [
'mime' => $mimeType,
'error' => $e->getMessage(),
]);
return [file_get_contents($filePath), $mimeType, $originalExtension];
}
}
}

View file

@ -63,14 +63,31 @@ public function optimizeImage(string $filePath, Platform $platform): string
$image->scaleDown(width: $maxWidth);
}
// Encode to target format
// Encode to target format at the target quality (never reduced)
$tempFile = tempnam(sys_get_temp_dir(), 'media_opt_');
$encoded = $image->encodeUsingMediaType($format, quality: $quality);
file_put_contents($tempFile, (string) $encoded);
// Reduce quality iteratively if file still too large
while (filesize($tempFile) > $maxSize && $quality > 30) {
$quality -= 10;
// If still above the platform size budget, iteratively shrink DIMENSIONS
// (not quality) by 10 % per step until it fits. Postiz-style, preserves
// pixel quality while lowering the byte count.
while (filesize($tempFile) > $maxSize) {
$newWidth = (int) ($image->width() * 0.9);
$newHeight = (int) ($image->height() * 0.9);
// Safety floor: don't shrink below 100 px on the longer side.
if ($newWidth < 100 || $newHeight < 100) {
Log::warning('MediaOptimizer: image cannot fit platform size budget', [
'platform' => $platform->value,
'final_width' => $image->width(),
'final_height' => $image->height(),
'final_bytes' => filesize($tempFile),
'budget_bytes' => $maxSize,
]);
break;
}
$image->scale(width: $newWidth, height: $newHeight);
$encoded = $image->encodeUsingMediaType($format, quality: $quality);
file_put_contents($tempFile, (string) $encoded);
}
@ -88,55 +105,55 @@ private function getImageConfig(Platform $platform): array
'max_width' => 1440,
'max_size' => 8 * 1024 * 1024,
'format' => 'image/jpeg',
'quality' => 90,
'quality' => 100,
],
Platform::Facebook => [
'max_width' => 2048,
'max_size' => 4 * 1024 * 1024,
'format' => 'image/jpeg',
'quality' => 90,
'quality' => 100,
],
Platform::X => [
'max_width' => 2048,
'max_size' => 5 * 1024 * 1024,
'format' => 'image/jpeg',
'quality' => 90,
'quality' => 100,
],
Platform::TikTok => [
'max_width' => 1080,
'max_size' => 20 * 1024 * 1024,
'format' => 'image/jpeg',
'quality' => 95,
'quality' => 100,
],
Platform::LinkedIn, Platform::LinkedInPage => [
'max_width' => 2048,
'max_size' => 10 * 1024 * 1024,
'format' => 'image/jpeg',
'quality' => 90,
'quality' => 100,
],
Platform::Pinterest => [
'max_width' => 1000,
'max_size' => 20 * 1024 * 1024,
'format' => 'image/jpeg',
'quality' => 90,
'quality' => 100,
],
Platform::Bluesky => [
'max_width' => 2048,
'max_size' => 976 * 1024,
'format' => 'image/jpeg',
'quality' => 85,
'quality' => 100,
],
Platform::Mastodon => [
'max_width' => 2048,
'max_size' => 10 * 1024 * 1024,
'format' => 'image/jpeg',
'quality' => 90,
'quality' => 100,
],
Platform::YouTube => [
'max_width' => 1920,
'max_size' => 2 * 1024 * 1024,
'format' => 'image/jpeg',
'quality' => 90,
'quality' => 100,
],
};
}

View file

@ -377,7 +377,7 @@ private function verifyX(SocialAccount $account): bool
private function verifyInstagram(SocialAccount $account): bool
{
$response = Http::get('https://graph.instagram.com/v24.0/me', [
$response = Http::get('https://graph.instagram.com/v25.0/me', [
'fields' => 'id,username',
'access_token' => $account->access_token,
]);
@ -398,7 +398,7 @@ private function verifyInstagram(SocialAccount $account): bool
private function verifyFacebook(SocialAccount $account): bool
{
$response = Http::get('https://graph.facebook.com/v24.0/me', [
$response = Http::get('https://graph.facebook.com/v25.0/me', [
'fields' => 'id,name',
'access_token' => $account->access_token,
]);

View file

@ -15,7 +15,7 @@ class FacebookPublisher
{
use HasSocialHttpClient;
private string $baseUrl = 'https://graph.facebook.com/v24.0';
private string $baseUrl = 'https://graph.facebook.com/v25.0';
public function publish(PostPlatform $postPlatform): array
{

View file

@ -18,12 +18,15 @@ class InstagramProvider extends AbstractProvider implements ProviderInterface
protected function getAuthUrl($state): string
{
return 'https://www.instagram.com/oauth/authorize?'.http_build_query([
// enable_fb_login=0 forces the pure Instagram Login flow (without
// delegating to Facebook OAuth). Tokens issued from the FB-delegated
// path can't be exchanged via graph.instagram.com/access_token.
return 'https://www.instagram.com/oauth/authorize?enable_fb_login=0&'.http_build_query([
'client_id' => $this->clientId,
'redirect_uri' => $this->redirectUrl,
'response_type' => 'code',
'state' => $state,
'scope' => implode(',', $this->getScopes()),
'state' => $state,
]);
}
@ -34,7 +37,7 @@ protected function getTokenUrl(): string
protected function getUserByToken($token): array
{
$response = $this->getHttpClient()->get('https://graph.instagram.com/v22.0/me', [
$response = $this->getHttpClient()->get('https://graph.instagram.com/v25.0/me', [
RequestOptions::QUERY => [
'access_token' => $token,
'fields' => 'id,username,account_type,name,profile_picture_url',
@ -56,21 +59,29 @@ protected function mapUserToObject(array $user): User
public function getAccessTokenResponse($code): array
{
// Meta's docs document this endpoint with curl -F flags (multipart/form-data).
$multipart = [];
foreach ($this->getTokenFields($code) as $name => $contents) {
$multipart[] = ['name' => $name, 'contents' => (string) $contents];
}
$response = $this->getHttpClient()->post($this->getTokenUrl(), [
RequestOptions::FORM_PARAMS => $this->getTokenFields($code),
RequestOptions::MULTIPART => $multipart,
]);
$data = json_decode((string) $response->getBody(), true);
// Exchange short-lived token for long-lived token
return $this->exchangeForLongLivedToken($data);
}
protected function exchangeForLongLivedToken(array $data): array
{
// Although Meta's docs don't list `client_id` as a required parameter,
// the API in practice rejects the request without it.
$response = $this->getHttpClient()->get('https://graph.instagram.com/access_token', [
RequestOptions::QUERY => [
'grant_type' => 'ig_exchange_token',
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'access_token' => data_get($data, 'access_token'),
],

View file

@ -23,6 +23,8 @@
'save_to_assets' => 'Save to Assets',
'saved' => 'Saved to your assets!',
'create_post' => 'Create post',
'add_to_post' => 'Add to post',
'search_placeholder' => 'Search media...',
'delete' => [
'title' => 'Delete asset',

View file

@ -76,6 +76,40 @@
'branded_policy' => 'Branded Content Policy',
],
],
'instagram' => [
'settings' => 'Instagram Settings',
'posting_to' => 'Posting to',
'variant_label' => 'Post type',
'variant' => [
'feed' => 'Feed Post',
'reel' => 'Reel',
'story' => 'Story',
],
],
'facebook' => [
'settings' => 'Facebook Settings',
'posting_to' => 'Posting to',
'variant_label' => 'Post type',
'variant' => [
'post' => 'Post',
'reel' => 'Reel',
'story' => 'Story',
],
],
'warnings' => [
'no_variant' => 'Pick a post type to continue.',
'requires_media' => 'This post type requires at least one image or video.',
'max_files_exceeded' => 'This post type accepts up to :max media files (you have :current).',
'min_files_required' => 'This post type requires at least :min media files (you have :current).',
'no_video_allowed' => 'This post type does not accept videos.',
'no_image_allowed' => 'This post type accepts only videos.',
'gif_not_allowed' => 'This platform does not accept GIF. Remove the GIF or choose a different network.',
'image_too_large' => 'Image exceeds the :max limit for this post type (yours is :current).',
'video_too_large' => 'Video exceeds the :max limit for this post type (yours is :current).',
'video_too_long' => 'Video is :current long, but this post type allows up to :max.',
'aspect_ratio_too_narrow' => 'Aspect ratio :current is too tall for this post type (min :min).',
'aspect_ratio_too_wide' => 'Aspect ratio :current is too wide for this post type (max :max).',
],
],
'status' => [
@ -123,6 +157,10 @@
'hashtags' => 'Hashtags',
'view_on_platform' => 'View on platform',
'platform_status' => 'Platform status',
'compliance_incomplete' => 'Some platform settings are incomplete or incompatible with the attached media.',
'publishing' => 'Publishing...',
'publishing_overlay_title' => 'Your post is being published',
'publishing_overlay_subtitle' => 'This can take a few moments. You can safely leave this page.',
'tabs' => [
'preview' => 'Preview',
@ -133,6 +171,30 @@
'writing_assistant_empty' => 'AI writing assistant coming soon.',
],
'media_picker' => [
'title' => 'Pick from gallery',
'search' => 'Search media...',
'empty' => 'No media in your gallery yet',
'cancel' => 'Cancel',
'add' => 'Add',
'add_count' => 'Add :count',
],
'emoji_picker' => [
'search' => 'Search emoji',
'empty' => 'No emojis found',
'recent' => 'Frequently used',
'smileys' => 'Smileys & emotion',
'people' => 'People & body',
'nature' => 'Animals & nature',
'food' => 'Food & drink',
'activities' => 'Activities',
'travel' => 'Travel & places',
'objects' => 'Objects',
'symbols' => 'Symbols',
'flags' => 'Flags',
],
'status' => [
'published' => 'Published',
'publishing' => 'Publishing...',

View file

@ -25,6 +25,8 @@
'save_to_assets' => 'Guardar en la biblioteca',
'saved' => '¡Guardado en tu biblioteca!',
'create_post' => 'Crear post',
'add_to_post' => 'Agregar al post',
'search_placeholder' => 'Buscar media...',
'delete' => [
'title' => 'Eliminar medio',

View file

@ -76,6 +76,40 @@
'branded_policy' => 'Política de Contenido Patrocinado',
],
],
'instagram' => [
'settings' => 'Configuración de Instagram',
'posting_to' => 'Publicando en',
'variant_label' => 'Tipo de publicación',
'variant' => [
'feed' => 'Publicación',
'reel' => 'Reel',
'story' => 'Historia',
],
],
'facebook' => [
'settings' => 'Configuración de Facebook',
'posting_to' => 'Publicando en',
'variant_label' => 'Tipo de publicación',
'variant' => [
'post' => 'Publicación',
'reel' => 'Reel',
'story' => 'Historia',
],
],
'warnings' => [
'no_variant' => 'Elige un tipo de publicación para continuar.',
'requires_media' => 'Este tipo requiere al menos una imagen o video.',
'max_files_exceeded' => 'Este tipo acepta hasta :max archivos (tienes :current).',
'min_files_required' => 'Este tipo requiere al menos :min archivos (tienes :current).',
'no_video_allowed' => 'Este tipo no acepta videos.',
'no_image_allowed' => 'Este tipo acepta solo videos.',
'gif_not_allowed' => 'Esta red no acepta GIF. Elimínalo o selecciona otra red.',
'image_too_large' => 'La imagen supera el límite de :max (la tuya es :current).',
'video_too_large' => 'El video supera el límite de :max (el tuyo es :current).',
'video_too_long' => 'El video dura :current, pero este tipo permite hasta :max.',
'aspect_ratio_too_narrow' => 'La proporción :current es demasiado alta (mínimo :min).',
'aspect_ratio_too_wide' => 'La proporción :current es demasiado ancha (máximo :max).',
],
],
'status' => [
@ -131,6 +165,10 @@
'schedule_date' => 'Fecha de programación',
'view_on_platform' => 'Ver en la plataforma',
'platform_status' => 'Estado de la plataforma',
'compliance_incomplete' => 'Algunas configuraciones de plataforma están incompletas o son incompatibles con los medios adjuntos.',
'publishing' => 'Publicando...',
'publishing_overlay_title' => 'Tu publicación se está enviando',
'publishing_overlay_subtitle' => 'Esto puede tardar unos momentos. Puedes salir de esta página sin problemas.',
'tabs' => [
'preview' => 'Vista previa',
@ -141,6 +179,30 @@
'writing_assistant_empty' => 'Asistente de escritura próximamente.',
],
'media_picker' => [
'title' => 'Elegir de la galería',
'search' => 'Buscar media...',
'empty' => 'Aún no hay archivos en tu galería',
'cancel' => 'Cancelar',
'add' => 'Agregar',
'add_count' => 'Agregar :count',
],
'emoji_picker' => [
'search' => 'Buscar emoji',
'empty' => 'No se encontraron emojis',
'recent' => 'Usados con frecuencia',
'smileys' => 'Caritas y emociones',
'people' => 'Personas y cuerpo',
'nature' => 'Animales y naturaleza',
'food' => 'Comida y bebida',
'activities' => 'Actividades',
'travel' => 'Viajes y lugares',
'objects' => 'Objetos',
'symbols' => 'Símbolos',
'flags' => 'Banderas',
],
'status' => [
'published' => 'Publicado',
'publishing' => 'Publicando...',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -25,6 +25,8 @@
'save_to_assets' => 'Salvar na biblioteca',
'saved' => 'Salvo na sua biblioteca!',
'create_post' => 'Criar post',
'add_to_post' => 'Adicionar ao post',
'search_placeholder' => 'Buscar mídia...',
'delete' => [
'title' => 'Excluir mídia',

View file

@ -76,6 +76,40 @@
'branded_policy' => 'Política de Conteúdo Patrocinado',
],
],
'instagram' => [
'settings' => 'Configurações do Instagram',
'posting_to' => 'Publicando em',
'variant_label' => 'Tipo de publicação',
'variant' => [
'feed' => 'Post',
'reel' => 'Reel',
'story' => 'Story',
],
],
'facebook' => [
'settings' => 'Configurações do Facebook',
'posting_to' => 'Publicando em',
'variant_label' => 'Tipo de publicação',
'variant' => [
'post' => 'Post',
'reel' => 'Reel',
'story' => 'Story',
],
],
'warnings' => [
'no_variant' => 'Escolha um tipo de publicação para continuar.',
'requires_media' => 'Este tipo exige pelo menos uma imagem ou vídeo.',
'max_files_exceeded' => 'Este tipo aceita até :max arquivos (você tem :current).',
'min_files_required' => 'Este tipo exige pelo menos :min arquivos (você tem :current).',
'no_video_allowed' => 'Este tipo não aceita vídeos.',
'no_image_allowed' => 'Este tipo aceita apenas vídeos.',
'gif_not_allowed' => 'Esta rede não aceita GIF. Remova o GIF ou escolha outra rede.',
'image_too_large' => 'A imagem passa do limite de :max (a sua tem :current).',
'video_too_large' => 'O vídeo passa do limite de :max (o seu tem :current).',
'video_too_long' => 'O vídeo dura :current, mas este tipo permite no máximo :max.',
'aspect_ratio_too_narrow' => 'A proporção :current está muito alta (mínimo :min).',
'aspect_ratio_too_wide' => 'A proporção :current está muito larga (máximo :max).',
],
],
'status' => [
@ -131,6 +165,10 @@
'schedule_date' => 'Data de agendamento',
'view_on_platform' => 'Ver na plataforma',
'platform_status' => 'Status da plataforma',
'compliance_incomplete' => 'Algumas configurações de plataforma estão incompletas ou incompatíveis com a mídia anexada.',
'publishing' => 'Publicando...',
'publishing_overlay_title' => 'Seu post está sendo publicado',
'publishing_overlay_subtitle' => 'Isso pode levar alguns instantes. Você pode sair desta página sem problemas.',
'tabs' => [
'preview' => 'Pré-visualização',
@ -141,6 +179,30 @@
'writing_assistant_empty' => 'Assistente de escrita em breve.',
],
'media_picker' => [
'title' => 'Escolher da galeria',
'search' => 'Buscar mídia...',
'empty' => 'Sua galeria ainda está vazia',
'cancel' => 'Cancelar',
'add' => 'Adicionar',
'add_count' => 'Adicionar :count',
],
'emoji_picker' => [
'search' => 'Buscar emoji',
'empty' => 'Nenhum emoji encontrado',
'recent' => 'Usados recentemente',
'smileys' => 'Sorrisos e emoções',
'people' => 'Pessoas e corpo',
'nature' => 'Animais e natureza',
'food' => 'Comidas e bebidas',
'activities' => 'Atividades',
'travel' => 'Viagens e lugares',
'objects' => 'Objetos',
'symbols' => 'Símbolos',
'flags' => 'Bandeiras',
],
'status' => [
'published' => 'Publicado',
'publishing' => 'Publicando...',

View file

@ -8,6 +8,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { toggle as toggleAccount } from '@/routes/app/accounts';
export interface SocialAccount {
@ -101,24 +102,6 @@ const emit = defineEmits<{
disconnect: [accountId: string];
}>();
const getPlatformLogo = (platform: string): string => {
const logos: Record<string, string> = {
'linkedin': '/images/accounts/linkedin.png',
'linkedin-page': '/images/accounts/linkedin.png',
'x': '/images/accounts/x.png',
'tiktok': '/images/accounts/tiktok.png',
'instagram': '/images/accounts/instagram.png',
'instagram-facebook': '/images/accounts/instagram.png',
'facebook': '/images/accounts/facebook.png',
'youtube': '/images/accounts/youtube.png',
'threads': '/images/accounts/threads.png',
'bluesky': '/images/accounts/bluesky.png',
'pinterest': '/images/accounts/pinterest.png',
'mastodon': '/images/accounts/mastodon.png',
};
return logos[platform] || '/images/accounts/linkedin.png';
};
const getProfileUrl = (platform: string, username: string | null, platformUserId: string | null = null): string | null => {
if (platform === 'facebook') {
const identifier = username || platformUserId;

View file

@ -3,7 +3,6 @@ import { router } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { onMounted, onUnmounted } from 'vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
@ -11,6 +10,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
export interface AvailablePlatform {
value: string;
@ -24,24 +24,6 @@ defineProps<{
const open = defineModel<boolean>('open', { default: false });
const getPlatformLogo = (platform: string): string => {
const logos: Record<string, string> = {
'linkedin': '/images/accounts/linkedin.png',
'linkedin-page': '/images/accounts/linkedin.png',
'x': '/images/accounts/x.png',
'tiktok': '/images/accounts/tiktok.png',
'instagram': '/images/accounts/instagram.png',
'instagram-facebook': '/images/accounts/instagram.png',
'facebook': '/images/accounts/facebook.png',
'youtube': '/images/accounts/youtube.png',
'threads': '/images/accounts/threads.png',
'bluesky': '/images/accounts/bluesky.png',
'pinterest': '/images/accounts/pinterest.png',
'mastodon': '/images/accounts/mastodon.png',
};
return logos[platform] || '/images/accounts/linkedin.png';
};
const getPlatformDescription = (platform: string): string => {
return trans(`accounts.descriptions.${platform}`);
};

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
export interface AnalyticsAccount {
id: string;
@ -17,24 +18,6 @@ defineProps<{
const emit = defineEmits<{
select: [accountId: string];
}>();
const getPlatformLogo = (platform: string): string => {
const logos: Record<string, string> = {
tiktok: '/images/accounts/tiktok.png',
instagram: '/images/accounts/instagram.png',
'instagram-facebook': '/images/accounts/instagram.png',
facebook: '/images/accounts/facebook.png',
youtube: '/images/accounts/youtube.png',
linkedin: '/images/accounts/linkedin.png',
'linkedin-page': '/images/accounts/linkedin.png',
x: '/images/accounts/x.png',
threads: '/images/accounts/threads.png',
pinterest: '/images/accounts/pinterest.png',
bluesky: '/images/accounts/bluesky.png',
mastodon: '/images/accounts/mastodon.png',
};
return logos[platform] || '/images/accounts/default.png';
};
</script>
<template>

View file

@ -0,0 +1,853 @@
<script setup lang="ts">
import { router, useHttp } from '@inertiajs/vue3';
import { IconCloudUpload, IconLoader2, IconPencilPlus, IconPhoto, IconPlus, IconSearch, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
import { toast } from 'vue-sonner';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import EmptyState from '@/components/EmptyState.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import debounce from '@/debounce';
import { destroy as assetsDestroy, search as assetsSearch, store as assetsStore, storeFromUrl } from '@/routes/app/assets';
import { search as giphySearch, trending as giphyTrending } from '@/routes/app/assets/giphy';
import { search as unsplashSearch, trending as unsplashTrending } from '@/routes/app/assets/unsplash';
import { store as storePost } from '@/routes/app/posts';
interface AssetMedia {
id: string;
path: string;
url: string;
type: string;
mime_type: string;
original_filename: string;
size: number;
meta: { width?: number; height?: number; duration?: number } | null;
created_at: string;
}
interface UnsplashPhoto {
id: string;
url_small: string;
url_regular: string;
url_full: string;
download_location: string;
description: string | null;
width: number;
height: number;
author: { name: string; url: string };
}
interface GiphyGif {
id: string;
title: string;
url_preview: string;
url_original: string;
url_downsized: string;
width: number;
height: number;
size: number;
}
interface SavedMedia {
id: string;
path: string;
url: string;
type: string;
mime_type: string;
}
interface PickedMedia {
id: string;
path: string;
url: string;
type: string;
mime_type: string;
original_filename?: string;
size?: number;
meta?: { width?: number; height?: number; duration?: number };
}
const props = defineProps<{
mode: 'standalone' | 'picker';
}>();
const selected = defineModel<PickedMedia[]>('selected', { default: () => [] });
const isPicker = computed(() => props.mode === 'picker');
const selectedIds = computed(() => new Set(selected.value.map((m) => m.id)));
const isSelected = (id: string) => selectedIds.value.has(id);
const selectionIndex = (id: string) => selected.value.findIndex((m) => m.id === id) + 1;
const toggleSelect = (asset: AssetMedia | SavedMedia, extra?: Partial<PickedMedia>) => {
if (!isPicker.value) return;
if (isSelected(asset.id)) {
selected.value = selected.value.filter((m) => m.id !== asset.id);
} else {
selected.value = [
...selected.value,
{
id: asset.id,
path: asset.path,
url: asset.url,
type: asset.type,
mime_type: asset.mime_type,
...extra,
},
];
}
};
// Uploads tab
const uploads = ref<AssetMedia[]>([]);
const uploadsSearch = ref('');
const uploadsPage = ref(1);
const uploadsLastPage = ref(1);
const uploadsLoading = ref(false);
const uploadsLoadingMore = ref(false);
const uploadsHasMore = computed(() => uploadsPage.value < uploadsLastPage.value);
const fileInput = ref<HTMLInputElement | null>(null);
const uploadsSentinel = useTemplateRef<HTMLDivElement>('uploadsSentinel');
const isDragging = ref(false);
const uploading = ref(false);
const httpUpload = useHttp<{ media: File | null }>({ media: null });
let uploadsObserver: IntersectionObserver | null = null;
const fetchUploads = async (page: number, term: string) => {
const response = await fetch(
assetsSearch.url({ query: { search: term, page: String(page) } }),
{ headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin' },
);
if (!response.ok) throw new Error('Failed to load uploads');
return (await response.json()) as { data: AssetMedia[]; meta: { current_page: number; last_page: number } };
};
const loadUploadsFirstPage = async () => {
uploadsLoading.value = true;
try {
const response = await fetchUploads(1, uploadsSearch.value.trim());
uploads.value = response.data;
uploadsPage.value = response.meta.current_page;
uploadsLastPage.value = response.meta.last_page;
} catch {
uploads.value = [];
} finally {
uploadsLoading.value = false;
}
};
const loadMoreUploads = async () => {
if (uploadsLoadingMore.value || !uploadsHasMore.value) return;
uploadsLoadingMore.value = true;
try {
const response = await fetchUploads(uploadsPage.value + 1, uploadsSearch.value.trim());
uploads.value.push(...response.data);
uploadsPage.value = response.meta.current_page;
uploadsLastPage.value = response.meta.last_page;
} catch {
// ignore
} finally {
uploadsLoadingMore.value = false;
}
};
const debouncedUploadsSearch = debounce(() => {
void loadUploadsFirstPage();
}, 300);
watch(uploadsSearch, () => debouncedUploadsSearch());
const setupUploadsObserver = () => {
uploadsObserver?.disconnect();
uploadsObserver = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && uploadsHasMore.value && !uploadsLoadingMore.value) {
void loadMoreUploads();
}
},
{ rootMargin: '200px' },
);
if (uploadsSentinel.value) uploadsObserver.observe(uploadsSentinel.value);
};
watch(uploadsSentinel, async () => {
await nextTick();
setupUploadsObserver();
});
const triggerFileInput = () => fileInput.value?.click();
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files) {
void uploadFiles(Array.from(target.files));
target.value = '';
}
};
const handleDrop = (event: DragEvent) => {
isDragging.value = false;
if (event.dataTransfer?.files) {
void uploadFiles(Array.from(event.dataTransfer.files));
}
};
const uploadFiles = async (files: File[]) => {
uploading.value = true;
for (const file of files) {
try {
httpUpload.media = file;
await httpUpload.post(assetsStore.url());
} catch {
// ignore individual failure
}
}
uploading.value = false;
await loadUploadsFirstPage();
};
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const handleDelete = (assetId: string) => {
deleteModal.value?.open({ url: assetsDestroy.url(assetId) });
};
const createPostFromAsset = (asset: AssetMedia) => {
router.post(storePost.url(), {
media: [{ id: asset.id, path: asset.path, url: asset.url, type: asset.type, mime_type: asset.mime_type }],
});
};
// Unsplash tab
const httpUnsplash = useHttp<Record<string, never>, { results: UnsplashPhoto[]; total_pages?: number }>({});
const httpSaveFromUrl = useHttp<{ url: string; filename: string; download_location?: string }, SavedMedia>({
url: '',
filename: '',
});
const unsplashQuery = ref('');
const unsplashResults = ref<UnsplashPhoto[]>([]);
const unsplashPage = ref(1);
const unsplashTotalPages = ref(0);
const unsplashLoading = ref(false);
const trendingPhotos = ref<UnsplashPhoto[]>([]);
const trendingPage = ref(1);
const trendingHasMore = ref(true);
const savingPhotoId = ref<string | null>(null);
const unsplashSentinel = useTemplateRef<HTMLDivElement>('unsplashSentinel');
let unsplashObserver: IntersectionObserver | null = null;
const displayedPhotos = computed(() =>
unsplashQuery.value && unsplashResults.value.length > 0
? unsplashResults.value
: !unsplashQuery.value
? trendingPhotos.value
: [],
);
const hasMorePhotos = computed(() =>
unsplashQuery.value ? unsplashPage.value < unsplashTotalPages.value : trendingHasMore.value,
);
const loadTrending = async (page = 1) => {
if (unsplashLoading.value) return;
unsplashLoading.value = true;
try {
const response = await httpUnsplash.get(unsplashTrending.url({ query: { page: String(page) } }));
const results = response?.results ?? [];
if (page === 1) trendingPhotos.value = results;
else trendingPhotos.value.push(...results);
trendingPage.value = page;
trendingHasMore.value = results.length >= 25;
} catch {
// ignore
} finally {
unsplashLoading.value = false;
}
};
const searchUnsplashFn = debounce(async () => {
if (!unsplashQuery.value.trim()) {
unsplashResults.value = [];
return;
}
unsplashLoading.value = true;
unsplashPage.value = 1;
try {
const response = await httpUnsplash.get(unsplashSearch.url({ query: { query: unsplashQuery.value, page: '1' } }));
unsplashResults.value = response?.results ?? [];
unsplashTotalPages.value = response?.total_pages ?? 0;
} catch {
unsplashResults.value = [];
} finally {
unsplashLoading.value = false;
}
}, 400);
const loadMoreUnsplash = async () => {
if (unsplashPage.value >= unsplashTotalPages.value || unsplashLoading.value) return;
unsplashLoading.value = true;
unsplashPage.value++;
try {
const response = await httpUnsplash.get(
unsplashSearch.url({ query: { query: unsplashQuery.value, page: String(unsplashPage.value) } }),
);
unsplashResults.value.push(...(response?.results ?? []));
} catch {
// ignore
} finally {
unsplashLoading.value = false;
}
};
const loadMorePhotosOnScroll = async () => {
if (unsplashLoading.value) return;
if (unsplashQuery.value) await loadMoreUnsplash();
else await loadTrending(trendingPage.value + 1);
};
const setupUnsplashObserver = () => {
if (unsplashObserver) unsplashObserver.disconnect();
unsplashObserver = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasMorePhotos.value && !unsplashLoading.value) {
void loadMorePhotosOnScroll();
}
},
{ rootMargin: '200px' },
);
if (unsplashSentinel.value) unsplashObserver.observe(unsplashSentinel.value);
};
const saveMediaFromUrl = async (payload: { url: string; filename: string; download_location?: string }): Promise<SavedMedia | null> => {
httpSaveFromUrl.url = payload.url;
httpSaveFromUrl.filename = payload.filename;
httpSaveFromUrl.download_location = payload.download_location;
try {
return (await httpSaveFromUrl.post(storeFromUrl.url())) ?? null;
} catch {
return null;
}
};
const saveAndPickUnsplash = async (photo: UnsplashPhoto) => {
savingPhotoId.value = photo.id;
const media = await saveMediaFromUrl({
url: photo.url_regular,
filename: `unsplash-${photo.id}.jpg`,
download_location: photo.download_location,
});
savingPhotoId.value = null;
if (!media) return;
if (isPicker.value) {
toggleSelect(media);
} else {
toast.success(trans('assets.saved'));
await loadUploadsFirstPage();
}
};
const createPostFromUnsplash = async (photo: UnsplashPhoto) => {
savingPhotoId.value = photo.id;
const media = await saveMediaFromUrl({
url: photo.url_regular,
filename: `unsplash-${photo.id}.jpg`,
download_location: photo.download_location,
});
if (!media) {
savingPhotoId.value = null;
return;
}
router.post(storePost.url(), {
media: [{ id: media.id, path: media.path, url: media.url, type: media.type, mime_type: media.mime_type }],
});
};
// Giphy tab
const httpGiphy = useHttp<Record<string, never>, { results: GiphyGif[]; total_pages?: number }>({});
const giphyQuery = ref('');
const giphyResults = ref<GiphyGif[]>([]);
const giphyPage = ref(1);
const giphyTotalPages = ref(0);
const giphyLoading = ref(false);
const giphyTrendingItems = ref<GiphyGif[]>([]);
const giphyTrendingPage = ref(1);
const giphyTrendingHasMore = ref(true);
const savingGifId = ref<string | null>(null);
const giphySentinel = useTemplateRef<HTMLDivElement>('giphySentinel');
let giphyObserver: IntersectionObserver | null = null;
const displayedGifs = computed(() =>
giphyQuery.value && giphyResults.value.length > 0
? giphyResults.value
: !giphyQuery.value
? giphyTrendingItems.value
: [],
);
const hasMoreGifs = computed(() =>
giphyQuery.value ? giphyPage.value < giphyTotalPages.value : giphyTrendingHasMore.value,
);
const loadGiphyTrending = async (page = 1) => {
if (giphyLoading.value) return;
giphyLoading.value = true;
try {
const response = await httpGiphy.get(giphyTrending.url({ query: { page: String(page) } }));
const results = response?.results ?? [];
if (page === 1) giphyTrendingItems.value = results;
else giphyTrendingItems.value.push(...results);
giphyTrendingPage.value = page;
giphyTrendingHasMore.value = results.length >= 25;
} catch {
// ignore
} finally {
giphyLoading.value = false;
}
};
const searchGiphyFn = debounce(async () => {
if (!giphyQuery.value.trim()) {
giphyResults.value = [];
return;
}
giphyLoading.value = true;
giphyPage.value = 1;
try {
const response = await httpGiphy.get(giphySearch.url({ query: { query: giphyQuery.value, page: '1' } }));
giphyResults.value = response?.results ?? [];
giphyTotalPages.value = response?.total_pages ?? 0;
} catch {
giphyResults.value = [];
} finally {
giphyLoading.value = false;
}
}, 400);
const loadMoreGiphy = async () => {
if (giphyPage.value >= giphyTotalPages.value || giphyLoading.value) return;
giphyLoading.value = true;
giphyPage.value++;
try {
const response = await httpGiphy.get(giphySearch.url({ query: { query: giphyQuery.value, page: String(giphyPage.value) } }));
giphyResults.value.push(...(response?.results ?? []));
} catch {
// ignore
} finally {
giphyLoading.value = false;
}
};
const loadMoreGifsOnScroll = async () => {
if (giphyLoading.value) return;
if (giphyQuery.value) await loadMoreGiphy();
else await loadGiphyTrending(giphyTrendingPage.value + 1);
};
const setupGiphyObserver = () => {
if (giphyObserver) giphyObserver.disconnect();
giphyObserver = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasMoreGifs.value && !giphyLoading.value) {
void loadMoreGifsOnScroll();
}
},
{ rootMargin: '200px' },
);
if (giphySentinel.value) giphyObserver.observe(giphySentinel.value);
};
const saveAndPickGiphy = async (gif: GiphyGif) => {
savingGifId.value = gif.id;
const media = await saveMediaFromUrl({
url: gif.url_downsized,
filename: `giphy-${gif.id}.gif`,
});
savingGifId.value = null;
if (!media) return;
if (isPicker.value) {
toggleSelect(media);
} else {
toast.success(trans('assets.saved'));
await loadUploadsFirstPage();
}
};
const createPostFromGiphy = async (gif: GiphyGif) => {
savingGifId.value = gif.id;
const media = await saveMediaFromUrl({
url: gif.url_downsized,
filename: `giphy-${gif.id}.gif`,
});
if (!media) {
savingGifId.value = null;
return;
}
router.post(storePost.url(), {
media: [{ id: media.id, path: media.path, url: media.url, type: media.type, mime_type: media.mime_type }],
});
};
// Lifecycle
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
};
const initialize = async () => {
await loadUploadsFirstPage();
};
const onUnsplashTabMounted = async () => {
if (trendingPhotos.value.length === 0) await loadTrending();
await nextTick();
setupUnsplashObserver();
};
const onGiphyTabMounted = async () => {
if (giphyTrendingItems.value.length === 0) await loadGiphyTrending();
await nextTick();
setupGiphyObserver();
};
defineExpose({ initialize, refreshUploads: loadUploadsFirstPage });
onMounted(() => {
void initialize();
});
onUnmounted(() => {
uploadsObserver?.disconnect();
unsplashObserver?.disconnect();
giphyObserver?.disconnect();
});
</script>
<template>
<div>
<Tabs default-value="uploads">
<TabsList>
<TabsTrigger value="uploads">{{ trans('assets.tabs.my_uploads') }}</TabsTrigger>
<TabsTrigger value="stock">{{ trans('assets.tabs.stock_photos') }}</TabsTrigger>
<TabsTrigger value="gifs">{{ trans('assets.tabs.gifs') }}</TabsTrigger>
</TabsList>
<!-- My Uploads -->
<TabsContent value="uploads" class="mt-4">
<div
class="relative mb-4 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors"
:class="isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'"
@click="triggerFileInput"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
>
<IconCloudUpload class="mb-2 size-8 text-muted-foreground" />
<p class="text-sm font-medium">{{ trans('assets.upload.drag_drop') }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ trans('assets.upload.formats') }}</p>
<input
ref="fileInput"
type="file"
class="hidden"
multiple
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4"
@change="handleFileSelect"
/>
<div v-if="uploading" class="absolute inset-0 flex items-center justify-center rounded-lg bg-background/80">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<IconLoader2 class="size-4 animate-spin" />
{{ trans('assets.upload.uploading') }}
</div>
</div>
</div>
<div class="relative mb-4">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="uploadsSearch"
type="search"
:placeholder="trans('assets.search_placeholder')"
class="pl-9"
/>
</div>
<div v-if="uploadsLoading" class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<Skeleton v-for="i in 8" :key="i" class="aspect-square rounded-lg" />
</div>
<div v-else-if="uploads.length === 0" class="flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground">
<IconPhoto class="size-10" />
<p class="text-sm">{{ trans('assets.empty.title') }}</p>
</div>
<div v-else class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<div
v-for="asset in uploads"
:key="asset.id"
class="group relative overflow-hidden rounded-lg border-2 bg-muted transition-all"
:class="[
isPicker ? 'cursor-pointer' : '',
isPicker && isSelected(asset.id)
? 'border-primary ring-2 ring-primary/30'
: 'border-transparent',
]"
@click="isPicker ? toggleSelect(asset) : null"
>
<div class="aspect-square">
<video
v-if="asset.type === 'video'"
:src="asset.url"
class="size-full object-cover"
muted
preload="metadata"
/>
<img
v-else
:src="asset.url"
:alt="asset.original_filename"
class="size-full object-cover"
loading="lazy"
/>
</div>
<div
v-if="isPicker && isSelected(asset.id)"
class="absolute right-2 top-2 flex size-6 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground"
>
{{ selectionIndex(asset.id) }}
</div>
<div
v-if="!isPicker"
class="absolute inset-0 flex flex-col justify-between bg-black/60 p-2 opacity-0 transition-opacity group-hover:opacity-100"
>
<div class="flex justify-end gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="secondary" size="icon" class="size-7" @click="createPostFromAsset(asset)">
<IconPencilPlus class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ trans('assets.create_post') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button variant="destructive" size="icon" class="size-7" @click="handleDelete(asset.id)">
<IconTrash class="size-3.5" />
</Button>
</div>
<div class="space-y-0.5">
<p class="truncate text-xs font-medium text-white">{{ asset.original_filename }}</p>
<p class="text-xs text-white/70">{{ formatFileSize(asset.size) }}</p>
</div>
</div>
</div>
</div>
<div v-if="uploadsHasMore" ref="uploadsSentinel" class="mt-4 flex justify-center">
<IconLoader2 v-if="uploadsLoadingMore" class="size-5 animate-spin text-muted-foreground" />
</div>
</TabsContent>
<!-- Stock Photos (Unsplash) -->
<TabsContent value="stock" class="mt-4" @vue:mounted="onUnsplashTabMounted">
<div class="relative mb-4">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="unsplashQuery"
:placeholder="trans('assets.unsplash.search_placeholder')"
class="pl-9"
@input="searchUnsplashFn"
/>
</div>
<div v-if="displayedPhotos.length > 0" class="space-y-3">
<p v-if="!unsplashQuery && trendingPhotos.length > 0" class="text-sm font-medium text-muted-foreground">
{{ trans('assets.unsplash.trending') }}
</p>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
<div
v-for="photo in displayedPhotos"
:key="photo.id"
class="group relative overflow-hidden rounded-lg bg-muted"
>
<div class="aspect-[4/3]">
<img
:src="photo.url_small"
:alt="photo.description || 'Unsplash photo'"
class="size-full object-cover"
loading="lazy"
/>
</div>
<div class="absolute inset-0 flex flex-col justify-between bg-black/60 p-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="flex justify-end gap-1">
<TooltipProvider v-if="!isPicker">
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
size="icon"
class="size-7"
:disabled="savingPhotoId === photo.id"
@click="createPostFromUnsplash(photo)"
>
<IconPencilPlus class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ trans('assets.create_post') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
size="icon"
class="size-7"
:disabled="savingPhotoId === photo.id"
@click="saveAndPickUnsplash(photo)"
>
<IconLoader2 v-if="savingPhotoId === photo.id" class="size-3.5 animate-spin" />
<IconPlus v-else class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{{ isPicker ? trans('assets.add_to_post') : trans('assets.save_to_assets') }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p class="text-xs text-white/80">
<a :href="photo.author.url + '?utm_source=trypost&utm_medium=referral'" target="_blank" rel="noopener noreferrer" class="hover:text-white">
{{ photo.author.name }}
</a>
<span class="text-white/50"> / </span>
<a href="https://unsplash.com/?utm_source=trypost&utm_medium=referral" target="_blank" rel="noopener noreferrer" class="hover:text-white">
Unsplash
</a>
</p>
</div>
</div>
</div>
</div>
<EmptyState
v-else-if="unsplashQuery && !unsplashLoading"
:icon="IconSearch"
:title="trans('assets.unsplash.no_results')"
:description="trans('assets.unsplash.no_results_description')"
/>
<div v-if="unsplashLoading" class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
<Skeleton v-for="i in 8" :key="i" class="aspect-[4/3] rounded-lg" />
</div>
<div v-if="hasMorePhotos" ref="unsplashSentinel" class="h-1" />
</TabsContent>
<!-- GIFs (Giphy) -->
<TabsContent value="gifs" class="mt-4" @vue:mounted="onGiphyTabMounted">
<div class="relative mb-4">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="giphyQuery"
:placeholder="trans('assets.giphy.search_placeholder')"
class="pl-9"
@input="searchGiphyFn"
/>
</div>
<div v-if="displayedGifs.length > 0" class="space-y-3">
<p v-if="!giphyQuery && giphyTrendingItems.length > 0" class="text-sm font-medium text-muted-foreground">
{{ trans('assets.giphy.trending') }}
</p>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
<div v-for="gif in displayedGifs" :key="gif.id" class="group relative overflow-hidden rounded-lg bg-muted">
<div class="aspect-[4/3]">
<img :src="gif.url_preview" :alt="gif.title || 'GIF'" class="size-full object-cover" loading="lazy" />
</div>
<div class="absolute inset-0 flex flex-col justify-between bg-black/60 p-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="flex justify-end gap-1">
<TooltipProvider v-if="!isPicker">
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
size="icon"
class="size-7"
:disabled="savingGifId === gif.id"
@click="createPostFromGiphy(gif)"
>
<IconPencilPlus class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ trans('assets.create_post') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
size="icon"
class="size-7"
:disabled="savingGifId === gif.id"
@click="saveAndPickGiphy(gif)"
>
<IconLoader2 v-if="savingGifId === gif.id" class="size-3.5 animate-spin" />
<IconPlus v-else class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{{ isPicker ? trans('assets.add_to_post') : trans('assets.save_to_assets') }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p v-if="gif.title" class="truncate text-xs text-white/80">{{ gif.title }}</p>
</div>
</div>
</div>
</div>
<EmptyState
v-else-if="giphyQuery && !giphyLoading"
:icon="IconSearch"
:title="trans('assets.giphy.no_results')"
:description="trans('assets.giphy.no_results_description')"
/>
<div v-if="giphyLoading" class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
<Skeleton v-for="i in 8" :key="i" class="aspect-[4/3] rounded-lg" />
</div>
<div v-if="hasMoreGifs" ref="giphySentinel" class="h-1" />
<div v-if="displayedGifs.length > 0" class="mt-4 text-center">
<a href="https://giphy.com" target="_blank" rel="noopener noreferrer" class="text-xs text-muted-foreground hover:text-foreground">
{{ trans('assets.giphy.powered_by') }}
</a>
</div>
</TabsContent>
</Tabs>
<ConfirmDeleteModal
ref="deleteModal"
:title="trans('assets.delete.title')"
:description="trans('assets.delete.description')"
:action="trans('assets.delete.confirm')"
:cancel="trans('assets.delete.cancel')"
/>
</div>
</template>

View file

@ -0,0 +1,234 @@
<script setup lang="ts">
import { trans } from 'laravel-vue-i18n';
import { computed, nextTick, onBeforeUnmount, ref, useTemplateRef, watch } from 'vue';
import { Input } from '@/components/ui/input';
import { CATEGORY_ICON, EMOJIS, EMOJI_CATEGORIES, type Emoji, type EmojiCategory } from '@/data/emojis';
const RECENTS_KEY = 'trypost.emoji.recents';
const RECENTS_MAX = 24;
const emit = defineEmits<{
select: [emoji: string];
}>();
const search = ref('');
const activeCategory = ref<EmojiCategory | 'recent'>('smileys');
const scrollEl = useTemplateRef<HTMLDivElement>('scrollEl');
const headerRefs = ref<Record<string, HTMLElement | null>>({});
const setHeaderRef = (key: string) => (el: unknown) => {
headerRefs.value[key] = el instanceof HTMLElement ? el : null;
};
const readRecents = (): string[] => {
try {
const raw = localStorage.getItem(RECENTS_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((c) => typeof c === 'string') : [];
} catch {
return [];
}
};
const recents = ref<string[]>(readRecents());
const recentEmojis = computed<Emoji[]>(() => {
const map = new Map(EMOJIS.map((e) => [e.c, e]));
return recents.value
.map((c) => map.get(c))
.filter((e): e is Emoji => Boolean(e));
});
const grouped = computed<Record<EmojiCategory, Emoji[]>>(() => {
const result: Record<EmojiCategory, Emoji[]> = {
smileys: [],
people: [],
nature: [],
food: [],
activities: [],
travel: [],
objects: [],
symbols: [],
flags: [],
};
for (const emoji of EMOJIS) {
result[emoji.g].push(emoji);
}
return result;
});
const searchResults = computed<Emoji[]>(() => {
const q = search.value.trim().toLowerCase();
if (!q) return [];
const tokens = q.split(/\s+/).filter(Boolean);
return EMOJIS.filter((e) => {
const haystack = `${e.n} ${e.k}`.toLowerCase();
return tokens.every((t) => haystack.includes(t));
}).slice(0, 200);
});
const isSearching = computed(() => search.value.trim().length > 0);
const categoryLabel = (category: EmojiCategory | 'recent'): string =>
trans(`posts.edit.emoji_picker.${category}`);
const persistRecents = () => {
try {
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.value));
} catch {
// localStorage may be unavailable; safe to ignore.
}
};
const onPick = (emoji: Emoji) => {
emit('select', emoji.c);
const next = [emoji.c, ...recents.value.filter((c) => c !== emoji.c)].slice(0, RECENTS_MAX);
recents.value = next;
persistRecents();
};
const scrollToCategory = (category: EmojiCategory | 'recent') => {
const target = headerRefs.value[category];
const container = scrollEl.value;
if (!target || !container) return;
container.scrollTo({ top: target.offsetTop - 4, behavior: 'smooth' });
activeCategory.value = category;
};
const onScroll = () => {
const container = scrollEl.value;
if (!container || isSearching.value) return;
const top = container.scrollTop + 8;
let current: EmojiCategory | 'recent' = recentEmojis.value.length > 0 ? 'recent' : 'smileys';
for (const category of EMOJI_CATEGORIES) {
const header = headerRefs.value[category];
if (header && header.offsetTop <= top) {
current = category;
}
}
activeCategory.value = current;
};
watch(search, async () => {
await nextTick();
if (scrollEl.value) scrollEl.value.scrollTop = 0;
});
onBeforeUnmount(() => {
headerRefs.value = {};
});
</script>
<template>
<div class="flex w-[340px] flex-col overflow-hidden rounded-md bg-popover text-popover-foreground">
<div class="border-b p-2">
<Input
v-model="search"
type="search"
:placeholder="trans('posts.edit.emoji_picker.search')"
class="h-8 text-sm"
/>
</div>
<div
ref="scrollEl"
class="relative h-72 overflow-y-auto px-2 py-1"
@scroll.passive="onScroll"
>
<template v-if="isSearching">
<div
v-if="searchResults.length === 0"
class="flex h-full items-center justify-center px-4 text-center text-xs text-muted-foreground"
>
{{ trans('posts.edit.emoji_picker.empty') }}
</div>
<div v-else class="grid grid-cols-8 gap-0.5 py-1">
<button
v-for="emoji in searchResults"
:key="emoji.c"
type="button"
class="flex h-9 w-9 items-center justify-center rounded text-xl transition-colors hover:bg-muted focus:bg-muted focus:outline-none"
:title="emoji.n"
:aria-label="emoji.n"
@click="onPick(emoji)"
>
{{ emoji.c }}
</button>
</div>
</template>
<template v-else>
<section v-if="recentEmojis.length > 0">
<h3
:ref="setHeaderRef('recent')"
class="sticky top-0 z-10 bg-popover/95 px-1 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground backdrop-blur"
>
{{ categoryLabel('recent') }}
</h3>
<div class="grid grid-cols-8 gap-0.5 pb-2">
<button
v-for="emoji in recentEmojis"
:key="`recent-${emoji.c}`"
type="button"
class="flex h-9 w-9 items-center justify-center rounded text-xl transition-colors hover:bg-muted focus:bg-muted focus:outline-none"
:title="emoji.n"
:aria-label="emoji.n"
@click="onPick(emoji)"
>
{{ emoji.c }}
</button>
</div>
</section>
<section v-for="category in EMOJI_CATEGORIES" :key="category">
<h3
:ref="setHeaderRef(category)"
class="sticky top-0 z-10 bg-popover/95 px-1 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground backdrop-blur"
>
{{ categoryLabel(category) }}
</h3>
<div class="grid grid-cols-8 gap-0.5 pb-2">
<button
v-for="emoji in grouped[category]"
:key="emoji.c"
type="button"
class="flex h-9 w-9 items-center justify-center rounded text-xl transition-colors hover:bg-muted focus:bg-muted focus:outline-none"
:title="emoji.n"
:aria-label="emoji.n"
@click="onPick(emoji)"
>
{{ emoji.c }}
</button>
</div>
</section>
</template>
</div>
<div class="flex items-center justify-between border-t px-1 py-1">
<button
v-if="recentEmojis.length > 0"
type="button"
class="flex h-8 w-8 items-center justify-center rounded text-base transition-colors hover:bg-muted focus:bg-muted focus:outline-none"
:class="activeCategory === 'recent' && !isSearching ? 'bg-muted' : ''"
:title="categoryLabel('recent')"
:aria-label="categoryLabel('recent')"
@click="scrollToCategory('recent')"
>
🕘
</button>
<button
v-for="category in EMOJI_CATEGORIES"
:key="category"
type="button"
class="flex h-8 w-8 items-center justify-center rounded text-base transition-colors hover:bg-muted focus:bg-muted focus:outline-none"
:class="activeCategory === category && !isSearching ? 'bg-muted' : ''"
:title="categoryLabel(category)"
:aria-label="categoryLabel(category)"
@click="scrollToCategory(category)"
>
{{ CATEGORY_ICON[category] }}
</button>
</div>
</div>
</template>

View file

@ -0,0 +1,76 @@
<script setup lang="ts">
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import GalleryBrowser from '@/components/assets/GalleryBrowser.vue';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
interface PickedMedia {
id: string;
path: string;
url: string;
type: string;
mime_type: string;
original_filename?: string;
size?: number;
meta?: { width?: number; height?: number; duration?: number };
}
const emit = defineEmits<{
(e: 'select', media: PickedMedia[]): void;
}>();
const isOpen = ref(false);
const selected = ref<PickedMedia[]>([]);
const selectedCount = computed(() => selected.value.length);
const reset = () => {
selected.value = [];
};
const open = () => {
reset();
isOpen.value = true;
};
const close = () => {
isOpen.value = false;
};
const confirmSelection = () => {
if (selected.value.length === 0) return;
emit('select', selected.value);
isOpen.value = false;
};
defineExpose({ open, close });
</script>
<template>
<Dialog v-model:open="isOpen">
<DialogContent class="flex h-[85vh] max-w-5xl flex-col gap-0 p-0 sm:max-w-5xl">
<DialogHeader class="border-b px-6 py-4">
<DialogTitle>{{ trans('posts.edit.media_picker.title') }}</DialogTitle>
</DialogHeader>
<div class="flex-1 overflow-y-auto px-6 py-4">
<GalleryBrowser v-model:selected="selected" mode="picker" />
</div>
<DialogFooter class="border-t px-6 py-3">
<Button type="button" :disabled="selectedCount === 0" @click="confirmSelection">
<template v-if="selectedCount > 0">
{{ trans('posts.edit.media_picker.add_count', { count: String(selectedCount) }) }}
</template>
<template v-else>
{{ trans('posts.edit.media_picker.add') }}
</template>
</Button>
<Button type="button" variant="ghost" @click="close">
{{ trans('posts.edit.media_picker.cancel') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,113 @@
<script setup lang="ts">
import { IconAlertTriangle, IconBrandFacebook, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning } from '@/composables/useMedia';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface MediaItem {
id: string;
type?: string;
mime_type?: string;
}
interface Props {
socialAccount: SocialAccount | null;
contentType: string;
media: MediaItem[];
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
});
const emit = defineEmits<{
'update:contentType': [value: string];
}>();
const open = ref(false);
const variants = [
{ value: 'facebook_post', labelKey: 'posts.form.facebook.variant.post' },
{ value: 'facebook_reel', labelKey: 'posts.form.facebook.variant.reel' },
{ value: 'facebook_story', labelKey: 'posts.form.facebook.variant.story' },
];
const pickVariant = (value: string) => {
if (props.disabled) return;
emit('update:contentType', value);
};
const warning = computed(() => getMediaValidationWarning(props.contentType, props.media));
</script>
<template>
<div class="rounded-lg border">
<button
type="button"
class="flex w-full items-center justify-between p-4 text-sm font-medium"
@click="open = !open"
>
<span class="flex items-center gap-2">
<IconBrandFacebook class="h-4 w-4" />
<span>{{ $t('posts.form.facebook.settings') }}</span>
<span v-if="socialAccount" class="text-muted-foreground">·&nbsp;@{{ socialAccount.username }}</span>
</span>
<IconChevronUp v-if="open" class="h-4 w-4 text-muted-foreground" />
<IconChevronDown v-else class="h-4 w-4 text-muted-foreground" />
</button>
<div v-if="open" class="space-y-5 border-t px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-muted/50 p-3">
<Avatar
:src="socialAccount.avatar_url"
:name="socialAccount.display_name"
class="h-9 w-9 shrink-0 rounded-full"
/>
<div class="min-w-0 flex-1">
<p class="text-xs text-muted-foreground">{{ $t('posts.form.facebook.posting_to') }}</p>
<p class="truncate text-sm font-medium">
{{ socialAccount.display_name }}
<span class="text-muted-foreground">@{{ socialAccount.username }}</span>
</p>
</div>
</div>
<div class="space-y-2">
<p class="text-sm font-medium">{{ $t('posts.form.facebook.variant_label') }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="variant in variants"
:key="variant.value"
type="button"
class="rounded-full border px-3 py-1.5 text-xs transition-colors"
:class="contentType === variant.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'"
:disabled="disabled"
@click="pickVariant(variant.value)"
>
{{ $t(variant.labelKey) }}
</button>
</div>
</div>
<p
v-if="warning"
class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive"
>
<IconAlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
{{ $t(`posts.form.warnings.${warning.key}`, warning.params) }}
</p>
</div>
</div>
</template>

View file

@ -0,0 +1,113 @@
<script setup lang="ts">
import { IconAlertTriangle, IconBrandInstagram, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning } from '@/composables/useMedia';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface MediaItem {
id: string;
type?: string;
mime_type?: string;
}
interface Props {
socialAccount: SocialAccount | null;
contentType: string;
media: MediaItem[];
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
});
const emit = defineEmits<{
'update:contentType': [value: string];
}>();
const open = ref(false);
const variants = [
{ value: 'instagram_feed', labelKey: 'posts.form.instagram.variant.feed' },
{ value: 'instagram_reel', labelKey: 'posts.form.instagram.variant.reel' },
{ value: 'instagram_story', labelKey: 'posts.form.instagram.variant.story' },
];
const pickVariant = (value: string) => {
if (props.disabled) return;
emit('update:contentType', value);
};
const warning = computed(() => getMediaValidationWarning(props.contentType, props.media));
</script>
<template>
<div class="rounded-lg border">
<button
type="button"
class="flex w-full items-center justify-between p-4 text-sm font-medium"
@click="open = !open"
>
<span class="flex items-center gap-2">
<IconBrandInstagram class="h-4 w-4" />
<span>{{ $t('posts.form.instagram.settings') }}</span>
<span v-if="socialAccount" class="text-muted-foreground">·&nbsp;@{{ socialAccount.username }}</span>
</span>
<IconChevronUp v-if="open" class="h-4 w-4 text-muted-foreground" />
<IconChevronDown v-else class="h-4 w-4 text-muted-foreground" />
</button>
<div v-if="open" class="space-y-5 border-t px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-muted/50 p-3">
<Avatar
:src="socialAccount.avatar_url"
:name="socialAccount.display_name"
class="h-9 w-9 shrink-0 rounded-full"
/>
<div class="min-w-0 flex-1">
<p class="text-xs text-muted-foreground">{{ $t('posts.form.instagram.posting_to') }}</p>
<p class="truncate text-sm font-medium">
{{ socialAccount.display_name }}
<span class="text-muted-foreground">@{{ socialAccount.username }}</span>
</p>
</div>
</div>
<div class="space-y-2">
<p class="text-sm font-medium">{{ $t('posts.form.instagram.variant_label') }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="variant in variants"
:key="variant.value"
type="button"
class="rounded-full border px-3 py-1.5 text-xs transition-colors"
:class="contentType === variant.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'"
:disabled="disabled"
@click="pickVariant(variant.value)"
>
{{ $t(variant.labelKey) }}
</button>
</div>
</div>
<p
v-if="warning"
class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive"
>
<IconAlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
{{ $t(`posts.form.warnings.${warning.key}`, warning.params) }}
</p>
</div>
</div>
</template>

View file

@ -1,6 +1,11 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import PhoneMockup from '@/components/PhoneMockup.vue';
import { PlatformPreview } from '@/components/posts/previews';
import { Avatar } from '@/components/ui/avatar';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { getContentTypeOptions, getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
interface MediaItem {
id: string;
@ -19,19 +24,122 @@ interface SocialAccount {
avatar_url: string | null;
}
defineProps<{
interface PostPlatform {
id: string;
platform: string;
platform_name: string | null;
platform_avatar: string | null;
content_type: string | null;
social_account: SocialAccount | null;
}
const props = defineProps<{
platforms: PostPlatform[];
content: string;
media: MediaItem[];
socialAccount: SocialAccount | null;
contentType: string | null;
platformContentTypes: Record<string, string>;
}>();
const emit = defineEmits<{
'update:platformContentType': [platformId: string, contentType: string];
}>();
const getPlatformAvatar = (pp: PostPlatform): string | null => pp.social_account?.avatar_url ?? pp.platform_avatar ?? null;
const getPlatformDisplayName = (pp: PostPlatform): string => pp.social_account?.display_name ?? pp.platform_name ?? pp.platform;
const activeId = ref<string | null>(props.platforms[0]?.id ?? null);
watch(
() => props.platforms,
(next) => {
if (!next.find((pp) => pp.id === activeId.value)) {
activeId.value = next[0]?.id ?? null;
}
},
);
const activePlatform = computed(() => props.platforms.find((pp) => pp.id === activeId.value) ?? null);
const activeContentType = computed(() => {
if (!activePlatform.value) return null;
return props.platformContentTypes[activePlatform.value.id] ?? activePlatform.value.content_type;
});
const activeVariants = computed(() => {
if (!activePlatform.value) return [];
const options = getContentTypeOptions(activePlatform.value.platform);
return options.length > 1 ? options : [];
});
const pickVariant = (value: string) => {
if (!activePlatform.value) return;
emit('update:platformContentType', activePlatform.value.id, value);
};
</script>
<template>
<div class="flex justify-center py-8 px-4 bg-muted/30 min-h-full">
<PhoneMockup>
<PlatformPreview :platform="platform" :content="content" :media="media" :social-account="socialAccount" :content-type="contentType" />
</PhoneMockup>
<div class="flex h-full flex-col">
<div v-if="platforms.length > 1" class="border-b px-4 py-3">
<div class="flex flex-wrap gap-3">
<TooltipProvider v-for="pp in platforms" :key="pp.id" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
class="relative transition-opacity"
:class="activeId === pp.id ? 'opacity-100' : 'opacity-40 hover:opacity-70'"
@click="activeId = pp.id"
>
<Avatar
:src="getPlatformAvatar(pp)"
:name="getPlatformDisplayName(pp)"
class="h-9 w-9 shrink-0 rounded-full ring-2 ring-offset-2"
:class="activeId === pp.id ? 'ring-primary' : 'ring-transparent'"
/>
<img
:src="getPlatformLogo(pp.platform)"
:alt="pp.platform"
class="absolute -bottom-1.5 -right-1.5 h-4 w-4 rounded-full bg-background object-contain ring-1 ring-border"
/>
</button>
</TooltipTrigger>
<TooltipContent>
<div class="space-y-0.5 text-xs">
<p class="font-semibold">{{ getPlatformDisplayName(pp) }}<span v-if="pp.social_account?.username" class="font-normal opacity-80">&nbsp;·&nbsp;@{{ pp.social_account.username }}</span></p>
<p class="opacity-70">{{ getPlatformLabel(pp.platform) }}</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div v-if="activeVariants.length > 0" class="border-b px-4 py-3">
<div class="flex flex-wrap justify-center gap-2">
<button
v-for="variant in activeVariants"
:key="variant.value"
type="button"
class="rounded-full border px-3 py-1.5 text-xs transition-colors"
:class="activeContentType === variant.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'"
@click="pickVariant(variant.value)"
>
{{ $t(variant.labelKey) }}
</button>
</div>
</div>
<div class="flex flex-1 justify-center bg-muted/30 px-4 py-8">
<PhoneMockup v-if="activePlatform">
<PlatformPreview
:platform="activePlatform.platform"
:content="content"
:media="media"
:social-account="activePlatform.social_account"
:content-type="activeContentType"
/>
</PhoneMockup>
</div>
</div>
</template>

View file

@ -1,24 +1,14 @@
<script setup lang="ts">
import {
IconBrandBluesky,
IconBrandFacebook,
IconBrandInstagram,
IconBrandLinkedin,
IconBrandMastodon,
IconBrandPinterest,
IconBrandThreads,
IconBrandTiktok,
IconBrandX,
IconBrandYoutube,
IconCircleCheck,
IconExternalLink,
IconLoader2,
} from '@tabler/icons-vue';
import { computed, type Component } from 'vue';
import { IconCircleCheck, IconExternalLink, IconLoader2 } from '@tabler/icons-vue';
import { computed } from 'vue';
import FacebookSettings from '@/components/posts/editor/FacebookSettings.vue';
import InstagramSettings from '@/components/posts/editor/InstagramSettings.vue';
import TikTokSettings from '@/components/posts/editor/TikTokSettings.vue';
import { Avatar } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
interface SocialAccount {
id: string;
@ -89,6 +79,7 @@ const props = defineProps<{
isReadOnly: boolean;
platformConfigs: Record<string, PlatformConfig>;
platformMeta: Record<string, Record<string, any>>;
platformContentTypes: Record<string, string>;
tiktokCreatorInfos?: Record<string, TikTokCreatorInfo> | null;
media?: MediaItem[];
}>();
@ -97,6 +88,7 @@ const emit = defineEmits<{
togglePlatform: [platformId: string];
toggleLabel: [labelId: string];
'update:platformMeta': [platformId: string, meta: Record<string, any>];
'update:platformContentType': [platformId: string, contentType: string];
}>();
const selectedTikTokPlatforms = computed(() =>
@ -105,6 +97,19 @@ const selectedTikTokPlatforms = computed(() =>
),
);
const selectedInstagramPlatforms = computed(() =>
props.postPlatforms.filter(
(pp) => ['instagram', 'instagram-facebook'].includes(pp.platform)
&& props.selectedPlatformIds.includes(pp.id),
),
);
const selectedFacebookPlatforms = computed(() =>
props.postPlatforms.filter(
(pp) => pp.platform === 'facebook' && props.selectedPlatformIds.includes(pp.id),
),
);
const getPublishConfig = (pp: PostPlatform): Record<string, any> | null =>
pp.social_account_id ? props.platformConfigs[pp.social_account_id]?.publishConfig ?? null : null;
@ -117,23 +122,6 @@ const videoDurationSec = computed(() => {
return typeof duration === 'number' ? Math.ceil(duration) : null;
});
const platformIcons: Record<string, Component> = {
linkedin: IconBrandLinkedin,
'linkedin-page': IconBrandLinkedin,
x: IconBrandX,
tiktok: IconBrandTiktok,
youtube: IconBrandYoutube,
facebook: IconBrandFacebook,
instagram: IconBrandInstagram,
'instagram-facebook': IconBrandInstagram,
threads: IconBrandThreads,
pinterest: IconBrandPinterest,
bluesky: IconBrandBluesky,
mastodon: IconBrandMastodon,
};
const getPlatformIcon = (platform: string): Component => platformIcons[platform] || IconBrandX;
const getPlatformDisplayName = (pp: PostPlatform): string =>
pp.social_account?.display_name ?? pp.platform_name ?? pp.platform;
@ -149,26 +137,38 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
{{ $t('posts.edit.publish_to') }}
</p>
<div class="flex flex-wrap gap-3">
<button
v-for="pp in postPlatforms"
:key="pp.id"
type="button"
class="flex w-20 flex-col items-center gap-1.5 transition-opacity"
:class="selectedPlatformIds.includes(pp.id) ? 'opacity-100' : 'opacity-40 hover:opacity-70'"
@click="emit('togglePlatform', pp.id)"
>
<div class="relative">
<Avatar :src="getPlatformAvatar(pp)" :name="getPlatformDisplayName(pp)" class="h-10 w-10 shrink-0 rounded-full ring-2 ring-offset-2" :class="selectedPlatformIds.includes(pp.id) ? 'ring-primary' : 'ring-transparent'" />
<span class="absolute -bottom-0.5 -right-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-background ring-1 ring-border">
<component :is="getPlatformIcon(pp.platform)" class="h-3 w-3" />
</span>
<Badge v-if="pp.status === 'published'" variant="default" class="absolute -top-1 -right-1 h-4 w-4 p-0">
<IconCircleCheck class="h-2.5 w-2.5" />
</Badge>
<Badge v-else-if="pp.status === 'failed'" variant="destructive" class="absolute -top-1 -right-1 h-4 w-4 p-0 text-[9px]">!</Badge>
</div>
<span class="line-clamp-2 text-center text-xs leading-tight">{{ getPlatformDisplayName(pp) }}</span>
</button>
<TooltipProvider v-for="pp in postPlatforms" :key="pp.id" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
class="flex w-20 flex-col items-center gap-1.5 transition-opacity"
:class="selectedPlatformIds.includes(pp.id) ? 'opacity-100' : 'opacity-40 hover:opacity-70'"
@click="emit('togglePlatform', pp.id)"
>
<div class="relative">
<Avatar :src="getPlatformAvatar(pp)" :name="getPlatformDisplayName(pp)" class="h-10 w-10 shrink-0 rounded-full ring-2 ring-offset-2" :class="selectedPlatformIds.includes(pp.id) ? 'ring-primary' : 'ring-transparent'" />
<img
:src="getPlatformLogo(pp.platform)"
:alt="pp.platform"
class="absolute -bottom-1.5 -right-1.5 h-5 w-5 rounded-full bg-background object-contain ring-1 ring-border"
/>
<Badge v-if="pp.status === 'published'" variant="default" class="absolute -top-1 -right-1 h-4 w-4 p-0">
<IconCircleCheck class="h-2.5 w-2.5" />
</Badge>
<Badge v-else-if="pp.status === 'failed'" variant="destructive" class="absolute -top-1 -right-1 h-4 w-4 p-0 text-[9px]">!</Badge>
</div>
<span class="line-clamp-2 text-center text-xs leading-tight">{{ getPlatformDisplayName(pp) }}</span>
</button>
</TooltipTrigger>
<TooltipContent>
<div class="space-y-0.5 text-xs">
<p class="font-semibold">{{ getPlatformDisplayName(pp) }}<span v-if="pp.social_account?.username" class="font-normal opacity-80">&nbsp;·&nbsp;@{{ pp.social_account.username }}</span></p>
<p class="opacity-70">{{ getPlatformLabel(pp.platform) }}</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
@ -179,7 +179,7 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
<div class="space-y-2">
<div v-for="pp in postPlatforms.filter(p => p.enabled)" :key="pp.id" class="flex items-center justify-between rounded-lg border p-3">
<div class="flex items-center gap-2">
<component :is="getPlatformIcon(pp.platform)" class="h-4 w-4 text-muted-foreground" />
<img :src="getPlatformLogo(pp.platform)" :alt="pp.platform" class="h-4 w-4 object-contain" />
<span class="text-sm">{{ getPlatformDisplayName(pp) }}</span>
</div>
<div class="flex items-center gap-2">
@ -213,6 +213,30 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
/>
</div>
<div v-if="selectedInstagramPlatforms.length > 0" class="space-y-4">
<InstagramSettings
v-for="pp in selectedInstagramPlatforms"
:key="pp.id"
:social-account="pp.social_account"
:content-type="platformContentTypes[pp.id] ?? ''"
:media="media ?? []"
:disabled="isReadOnly"
@update:content-type="emit('update:platformContentType', pp.id, $event)"
/>
</div>
<div v-if="selectedFacebookPlatforms.length > 0" class="space-y-4">
<FacebookSettings
v-for="pp in selectedFacebookPlatforms"
:key="pp.id"
:social-account="pp.social_account"
:content-type="platformContentTypes[pp.id] ?? ''"
:media="media ?? []"
:disabled="isReadOnly"
@update:content-type="emit('update:platformContentType', pp.id, $event)"
/>
</div>
<div>
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{{ $t('posts.edit.labels') }}

View file

@ -186,7 +186,8 @@ watch(
>
<span class="flex items-center gap-2">
<IconBrandTiktok class="h-4 w-4" />
{{ $t('posts.form.tiktok.settings') }}
<span>{{ $t('posts.form.tiktok.settings') }}</span>
<span v-if="socialAccount" class="text-muted-foreground">·&nbsp;@{{ socialAccount.username }}</span>
</span>
<IconChevronUp v-if="open" class="h-4 w-4 text-muted-foreground" />
<IconChevronDown v-else class="h-4 w-4 text-muted-foreground" />

View file

@ -1,6 +1,9 @@
<script setup lang="ts">
import { IconDots } from '@tabler/icons-vue';
import { computed, ref, watch } from 'vue';
import { IconDots, IconPhoto } from '@tabler/icons-vue';
import { computed } from 'vue';
import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue';
import type { MediaItem } from '@/composables/useMedia';
interface SocialAccount {
id: string;
@ -10,13 +13,6 @@ interface SocialAccount {
avatar_url: string | null;
}
interface MediaItem {
id: string;
url: string;
type: string;
original_filename: string;
}
interface Props {
socialAccount: SocialAccount;
content: string;
@ -35,16 +31,6 @@ const isReel = computed(() => props.contentType === 'facebook_reel');
const isStory = computed(() => props.contentType === 'facebook_story');
const isFeed = computed(() => !isReel.value && !isStory.value);
// Carousel state
const currentIndex = ref(0);
// Reset carousel index when media changes
watch(() => props.media.length, () => {
if (currentIndex.value >= props.media.length) {
currentIndex.value = Math.max(0, props.media.length - 1);
}
});
// Format numbers like Facebook
const formatNumber = (num: number): string => {
if (num >= 1000000) {
@ -138,25 +124,12 @@ const displayName = computed(() => props.socialAccount.display_name || props.soc
<!-- Post Media -->
<div class="flex-1 relative bg-black min-h-0">
<template v-if="media.length > 0">
<img v-if="media[currentIndex]?.type === 'image'" :src="media[currentIndex].url"
:alt="media[currentIndex].original_filename" class="w-full h-full object-cover" />
<video v-else-if="media[currentIndex]" :src="media[currentIndex].url"
class="w-full h-full object-cover" muted loop playsinline />
<!-- Multiple images indicator -->
<div v-if="media.length > 1"
class="absolute top-2 right-2 bg-black/60 text-white text-[10px] font-semibold px-2 py-0.5 rounded-full">
{{ currentIndex + 1 }}/{{ media.length }}
</div>
</template>
<div v-else
class="w-full h-full flex items-center justify-center bg-[#f0f2f5] dark:bg-[#3a3b3c]">
<svg class="w-12 h-12 text-[#bcc0c4] dark:text-[#4e4f50]" viewBox="0 0 24 24"
fill="currentColor">
<path
d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" />
</svg>
</div>
<PostMediaPreview
:media="media"
:placeholder-icon="IconPhoto"
dot-active-class="bg-[#1877f2]"
placeholder-class="w-full h-full flex items-center justify-center bg-[#f0f2f5] dark:bg-[#3a3b3c]"
/>
</div>
<!-- Reactions Bar -->
@ -224,15 +197,13 @@ const displayName = computed(() => props.socialAccount.display_name || props.soc
<div class="relative flex-1 bg-black overflow-hidden">
<!-- Video/Media - Full screen -->
<div class="absolute inset-0">
<template v-if="media.length > 0">
<img v-if="media[0].type === 'image'" :src="media[0].url" class="w-full h-full object-cover" />
<video v-else :src="media[0].url" class="w-full h-full object-cover" muted loop playsinline />
</template>
<div v-else class="w-full h-full flex items-center justify-center bg-[#18191a]">
<svg class="w-12 h-12 text-white/30" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
</div>
<PostMediaPreview
:media="media"
:placeholder-icon="IconPhoto"
:show-arrows="false"
:show-dots="false"
placeholder-class="w-full h-full flex items-center justify-center bg-[#18191a]"
/>
</div>
<!-- Gradient overlay -->
@ -365,17 +336,13 @@ const displayName = computed(() => props.socialAccount.display_name || props.soc
<div class="relative flex-1 bg-black overflow-hidden">
<!-- Media - Full screen -->
<div class="absolute inset-0">
<template v-if="media.length > 0">
<img v-if="media[0].type === 'image'" :src="media[0].url" class="w-full h-full object-cover" />
<video v-else :src="media[0].url" class="w-full h-full object-cover" muted loop playsinline />
</template>
<div v-else
class="w-full h-full flex items-center justify-center bg-gradient-to-b from-[#1877f2]/50 to-[#833ab4]/50">
<svg class="w-12 h-12 text-white/30" viewBox="0 0 24 24" fill="currentColor">
<path
d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" />
</svg>
</div>
<PostMediaPreview
:media="media"
:placeholder-icon="IconPhoto"
:show-arrows="false"
:show-dots="false"
placeholder-class="w-full h-full flex items-center justify-center bg-gradient-to-b from-[#1877f2]/50 to-[#833ab4]/50"
/>
</div>
<!-- Progress Bars -->

View file

@ -10,10 +10,11 @@ import {
IconCamera,
IconPlayerPlayFilled,
IconPhoto,
IconChevronLeft,
IconChevronRight,
} from '@tabler/icons-vue';
import { computed, ref, watch } from 'vue';
import { computed } from 'vue';
import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue';
import type { MediaItem } from '@/composables/useMedia';
interface SocialAccount {
id: string;
@ -23,13 +24,6 @@ interface SocialAccount {
avatar_url: string | null;
}
interface MediaItem {
id: string;
url: string;
type: string;
original_filename: string;
}
interface ContentTypeOption {
value: string;
label: string;
@ -55,16 +49,6 @@ const isReel = computed(() => props.contentType === 'instagram_reel');
const isStory = computed(() => props.contentType === 'instagram_story');
const isFeed = computed(() => !isReel.value && !isStory.value);
// Carousel state
const currentIndex = ref(0);
// Reset carousel index when media changes
watch(() => props.media.length, () => {
if (currentIndex.value >= props.media.length) {
currentIndex.value = Math.max(0, props.media.length - 1);
}
});
// Format numbers like Instagram
const formatNumber = (num: number): string => {
if (num >= 1000000) {
@ -84,23 +68,6 @@ const truncatedCaption = computed(() => {
});
const username = computed(() => props.socialAccount.username || props.socialAccount.display_name);
// Carousel navigation
const goToPrevious = () => {
if (currentIndex.value > 0) {
currentIndex.value--;
}
};
const goToNext = () => {
if (currentIndex.value < props.media.length - 1) {
currentIndex.value++;
}
};
const goToSlide = (index: number) => {
currentIndex.value = index;
};
</script>
<template>
@ -142,35 +109,12 @@ const goToSlide = (index: number) => {
<!-- Post Media - Fixed height to prevent overflow -->
<div class="flex-1 relative bg-black min-h-0">
<template v-if="media.length > 0">
<img v-if="media[currentIndex]?.type === 'image'" :src="media[currentIndex].url"
:alt="media[currentIndex].original_filename" class="w-full h-full object-cover" />
<video v-else-if="media[currentIndex]" :src="media[currentIndex].url"
class="w-full h-full object-cover" muted loop playsinline />
<!-- Carousel navigation arrows -->
<template v-if="media.length > 1">
<!-- Previous arrow -->
<button v-if="currentIndex > 0" @click="goToPrevious"
class="absolute left-1.5 top-1/2 -translate-y-1/2 w-6 h-6 bg-white/90 rounded-full flex items-center justify-center shadow-sm hover:bg-white transition-colors z-10">
<IconChevronLeft class="w-4 h-4 text-[#262626]" />
</button>
<!-- Next arrow -->
<button v-if="currentIndex < media.length - 1" @click="goToNext"
class="absolute right-1.5 top-1/2 -translate-y-1/2 w-6 h-6 bg-white/90 rounded-full flex items-center justify-center shadow-sm hover:bg-white transition-colors z-10">
<IconChevronRight class="w-4 h-4 text-[#262626]" />
</button>
<!-- Carousel dots -->
<div class="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1">
<button v-for="(_, i) in media" :key="i" @click="goToSlide(i)"
class="w-[6px] h-[6px] rounded-full transition-colors"
:class="i === currentIndex ? 'bg-[#0095f6]' : 'bg-white/50 hover:bg-white/70'" />
</div>
</template>
</template>
<div v-else class="w-full h-full flex items-center justify-center bg-[#fafafa] dark:bg-[#121212]">
<IconPhoto class="w-12 h-12 text-[#dbdbdb] dark:text-[#363636]" />
</div>
<PostMediaPreview
:media="media"
:placeholder-icon="IconPhoto"
dot-active-class="bg-[#0095f6]"
placeholder-class="w-full h-full flex items-center justify-center bg-[#fafafa] dark:bg-[#121212]"
/>
</div>
<!-- Action Buttons -->
@ -203,13 +147,13 @@ const goToSlide = (index: number) => {
<div class="relative flex-1 bg-[#fafafa] dark:bg-black overflow-hidden">
<!-- Video/Media - Full screen -->
<div class="absolute inset-0">
<template v-if="media.length > 0">
<img v-if="media[0].type === 'image'" :src="media[0].url" class="w-full h-full object-cover" />
<video v-else :src="media[0].url" class="w-full h-full object-cover" muted loop playsinline />
</template>
<div v-else class="w-full h-full flex items-center justify-center">
<IconPlayerPlayFilled class="w-12 h-12 text-[#dbdbdb] dark:text-white/30" />
</div>
<PostMediaPreview
:media="media"
:placeholder-icon="IconPlayerPlayFilled"
:show-arrows="false"
:show-dots="false"
placeholder-class="w-full h-full flex items-center justify-center"
/>
</div>
<!-- Top Bar - below status bar -->
@ -269,13 +213,13 @@ const goToSlide = (index: number) => {
<div class="relative flex-1 bg-[#fafafa] dark:bg-black overflow-hidden">
<!-- Media - Full screen -->
<div class="absolute inset-0">
<template v-if="media.length > 0">
<img v-if="media[0].type === 'image'" :src="media[0].url" class="w-full h-full object-cover" />
<video v-else :src="media[0].url" class="w-full h-full object-cover" muted loop playsinline />
</template>
<div v-else class="w-full h-full flex items-center justify-center">
<IconPhoto class="w-12 h-12 text-[#dbdbdb] dark:text-white/30" />
</div>
<PostMediaPreview
:media="media"
:placeholder-icon="IconPhoto"
:show-arrows="false"
:show-dots="false"
placeholder-class="w-full h-full flex items-center justify-center"
/>
</div>
<!-- Progress Bars - below status bar -->

View file

@ -0,0 +1,110 @@
<script setup lang="ts">
import { IconChevronLeft, IconChevronRight, IconPhoto } from '@tabler/icons-vue';
import { computed, ref, watch, type Component } from 'vue';
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
interface Props {
media: MediaItem[];
placeholderIcon?: Component;
showArrows?: boolean;
showDots?: boolean;
dotActiveClass?: string;
dotInactiveClass?: string;
mediaClass?: string;
placeholderClass?: string;
}
const props = withDefaults(defineProps<Props>(), {
placeholderIcon: () => IconPhoto,
showArrows: true,
showDots: true,
dotActiveClass: 'bg-white',
dotInactiveClass: 'bg-white/50 hover:bg-white/70',
mediaClass: 'w-full h-full object-cover',
placeholderClass: 'w-full h-full flex items-center justify-center bg-muted',
});
const currentIndex = ref(0);
watch(
() => props.media.length,
() => {
if (currentIndex.value >= props.media.length) {
currentIndex.value = Math.max(0, props.media.length - 1);
}
},
);
const hasMultiple = computed(() => props.media.length > 1);
const goToPrevious = () => {
if (currentIndex.value > 0) currentIndex.value--;
};
const goToNext = () => {
if (currentIndex.value < props.media.length - 1) currentIndex.value++;
};
const goToSlide = (index: number) => {
currentIndex.value = index;
};
</script>
<template>
<template v-if="media.length > 0">
<template v-for="(item, index) in media" :key="item.id">
<video
v-if="isVideoMedia(item) && index === currentIndex"
:src="item.url"
:class="mediaClass"
muted
loop
playsinline
/>
<img
v-else-if="index === currentIndex"
:src="item.url"
:alt="item.original_filename"
:class="mediaClass"
/>
</template>
<template v-if="hasMultiple && showArrows">
<button
v-if="currentIndex > 0"
type="button"
class="absolute left-1.5 top-1/2 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-white/90 shadow-sm transition-colors hover:bg-white"
@click="goToPrevious"
>
<IconChevronLeft class="h-4 w-4 text-foreground" />
</button>
<button
v-if="currentIndex < media.length - 1"
type="button"
class="absolute right-1.5 top-1/2 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-white/90 shadow-sm transition-colors hover:bg-white"
@click="goToNext"
>
<IconChevronRight class="h-4 w-4 text-foreground" />
</button>
</template>
<div
v-if="hasMultiple && showDots"
class="absolute bottom-2 left-1/2 flex -translate-x-1/2 gap-1"
>
<button
v-for="(_, i) in media"
:key="i"
type="button"
class="h-[6px] w-[6px] rounded-full transition-colors"
:class="i === currentIndex ? dotActiveClass : dotInactiveClass"
@click="goToSlide(i)"
/>
</div>
</template>
<div v-else :class="placeholderClass">
<component :is="placeholderIcon" class="h-12 w-12 text-muted-foreground/40" />
</div>
</template>

View file

@ -0,0 +1,176 @@
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
export interface MediaItem {
id: string;
url: string;
type?: string;
mime_type?: string;
original_filename?: string;
size?: number;
meta?: {
width?: number;
height?: number;
duration?: number;
};
}
export interface MediaValidationWarning {
key: string; // short key, e.g. 'gif_not_allowed'
params: Record<string, string | number>;
}
const formatBytes = (bytes: number): string => {
if (bytes >= 1024 * 1024 * 1024) return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
return bytes + ' B';
};
const formatDuration = (seconds: number): string => {
const s = Math.round(seconds);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
return rem === 0 ? `${m}min` : `${m}min ${rem}s`;
};
const formatAspect = (ratio: number): string => ratio.toFixed(2);
/**
* Return the first violation found for a given content_type + media list.
* Returns null when everything is valid.
* Checks are prioritized: presence counts format per-item constraints.
*/
export const getMediaValidationWarning = (
contentType: string,
media: MediaItem[],
): MediaValidationWarning | null => {
if (! contentType) return { key: 'no_variant', params: {} };
const rules = getMediaRulesForContentType(contentType);
const videos = media.filter((m) => m.type === 'video' || m.mime_type?.startsWith('video/'));
const images = media.filter((m) => m.type === 'image' || m.mime_type?.startsWith('image/'));
const gifs = media.filter((m) => m.mime_type === 'image/gif');
const total = media.length;
if (rules.requiresMedia && total === 0) {
return { key: 'requires_media', params: {} };
}
if (total > rules.maxFiles) {
return { key: 'max_files_exceeded', params: { max: rules.maxFiles, current: total } };
}
if (rules.minFiles && total < rules.minFiles) {
return { key: 'min_files_required', params: { min: rules.minFiles, current: total } };
}
if (! rules.acceptVideos && videos.length > 0) {
return { key: 'no_video_allowed', params: {} };
}
if (! rules.acceptImages && images.length > 0) {
return { key: 'no_image_allowed', params: {} };
}
if (! rules.acceptsGif && gifs.length > 0) {
return { key: 'gif_not_allowed', params: {} };
}
for (const m of media) {
const isVideo = m.type === 'video' || m.mime_type?.startsWith('video/');
const size = m.size ?? 0;
const width = m.meta?.width ?? 0;
const height = m.meta?.height ?? 0;
const duration = m.meta?.duration ?? 0;
if (isVideo) {
if (rules.maxVideoBytes && size > rules.maxVideoBytes) {
return {
key: 'video_too_large',
params: { max: formatBytes(rules.maxVideoBytes), current: formatBytes(size) },
};
}
if (rules.maxVideoDurationSec && duration > rules.maxVideoDurationSec) {
return {
key: 'video_too_long',
params: { max: formatDuration(rules.maxVideoDurationSec), current: formatDuration(duration) },
};
}
} else if (rules.maxImageBytes && size > rules.maxImageBytes) {
return {
key: 'image_too_large',
params: { max: formatBytes(rules.maxImageBytes), current: formatBytes(size) },
};
}
if (width > 0 && height > 0) {
const ratio = width / height;
if (rules.aspectRatioMin && ratio < rules.aspectRatioMin) {
return {
key: 'aspect_ratio_too_narrow',
params: { current: formatAspect(ratio), min: formatAspect(rules.aspectRatioMin) },
};
}
if (rules.aspectRatioMax && ratio > rules.aspectRatioMax) {
return {
key: 'aspect_ratio_too_wide',
params: { current: formatAspect(ratio), max: formatAspect(rules.aspectRatioMax) },
};
}
}
}
return null;
};
/**
* Read metadata from a File in the browser before uploading.
* Returns width/height for images, width/height/duration for videos.
*/
export const readFileMetadata = async (file: File): Promise<{ width?: number; height?: number; duration?: number }> => {
if (file.type.startsWith('image/')) {
return new Promise((resolve) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
resolve({ width: img.naturalWidth, height: img.naturalHeight });
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve({});
};
img.src = url;
});
}
if (file.type.startsWith('video/')) {
return new Promise((resolve) => {
const video = document.createElement('video');
const url = URL.createObjectURL(file);
video.preload = 'metadata';
video.onloadedmetadata = () => {
URL.revokeObjectURL(url);
resolve({
width: video.videoWidth,
height: video.videoHeight,
duration: video.duration,
});
};
video.onerror = () => {
URL.revokeObjectURL(url);
resolve({});
};
video.src = url;
});
}
return {};
};
export const isVideoMedia = (item: MediaItem | null | undefined): boolean => {
if (! item) return false;
return item.type === 'video' || Boolean(item.mime_type?.startsWith('video/'));
};
export const isImageMedia = (item: MediaItem | null | undefined): boolean => {
if (! item) return false;
if (isVideoMedia(item)) return false;
return true;
};

View file

@ -6,47 +6,140 @@ export interface MediaRules {
acceptImages: boolean;
acceptVideos: boolean;
requiresMedia: boolean;
acceptsGif: boolean;
maxImageBytes?: number;
maxVideoBytes?: number;
maxVideoDurationSec?: number;
aspectRatioMin?: number;
aspectRatioMax?: number;
}
const MB = 1024 * 1024;
const GB = 1024 * MB;
const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
// Instagram
instagram_feed: { maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: true },
instagram_reel: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true },
instagram_story: { maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: true },
instagram_feed: {
maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60,
aspectRatioMin: 0.8, aspectRatioMax: 1.91,
},
instagram_reel: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 1 * GB, maxVideoDurationSec: 15 * 60,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
},
instagram_story: {
maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
},
// Facebook
facebook_post: { maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false },
facebook_reel: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true },
facebook_story: { maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: true },
facebook_post: {
maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: false,
maxImageBytes: 4 * MB, maxVideoBytes: 10 * GB, maxVideoDurationSec: 240 * 60,
},
facebook_reel: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 1 * GB, maxVideoDurationSec: 90,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
},
facebook_story: {
maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxImageBytes: 4 * MB, maxVideoDurationSec: 60,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
},
// LinkedIn
linkedin_post: { maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: false },
linkedin_carousel: { maxFiles: 20, acceptImages: true, acceptVideos: false, requiresMedia: true },
linkedin_page_post: { maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: false },
linkedin_page_carousel: { maxFiles: 20, acceptImages: true, acceptVideos: false, requiresMedia: true },
linkedin_post: {
maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: false,
maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60,
},
linkedin_carousel: {
maxFiles: 20, acceptImages: true, acceptVideos: false, requiresMedia: true,
acceptsGif: false,
maxImageBytes: 5 * MB,
aspectRatioMin: 0.5, aspectRatioMax: 1,
},
linkedin_page_post: {
maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: false,
maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60,
},
linkedin_page_carousel: {
maxFiles: 20, acceptImages: true, acceptVideos: false, requiresMedia: true,
acceptsGif: false,
maxImageBytes: 5 * MB,
aspectRatioMin: 0.5, aspectRatioMax: 1,
},
// TikTok
tiktok_video: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true },
tiktok_video: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
// maxVideoDurationSec is enforced dynamically via creator_info
},
// YouTube
youtube_short: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true },
youtube_short: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 256 * GB, maxVideoDurationSec: 60,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
},
// Pinterest
pinterest_pin: { maxFiles: 1, acceptImages: true, acceptVideos: false, requiresMedia: true },
pinterest_video_pin: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true },
pinterest_carousel: { maxFiles: 5, minFiles: 2, acceptImages: true, acceptVideos: false, requiresMedia: true },
pinterest_pin: {
maxFiles: 1, acceptImages: true, acceptVideos: false, requiresMedia: true,
acceptsGif: false,
maxImageBytes: 20 * MB,
},
pinterest_video_pin: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 2 * GB, maxVideoDurationSec: 15 * 60,
},
pinterest_carousel: {
maxFiles: 5, minFiles: 2, acceptImages: true, acceptVideos: false, requiresMedia: true,
acceptsGif: false,
maxImageBytes: 20 * MB,
},
// X (Twitter)
x_post: { maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false },
// X (Twitter) — accepts GIF with animation
x_post: {
maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: true,
maxImageBytes: 5 * MB, maxVideoBytes: 512 * MB, maxVideoDurationSec: 140,
},
// Threads
threads_post: { maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false },
threads_post: {
maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: false,
maxImageBytes: 8 * MB, maxVideoBytes: 1 * GB, maxVideoDurationSec: 5 * 60,
},
// Bluesky
bluesky_post: { maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false },
// Bluesky — accepts GIF; tight image size (auto-resized by backend)
bluesky_post: {
maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: true,
maxVideoBytes: 100 * MB, maxVideoDurationSec: 60,
},
// Mastodon
mastodon_post: { maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false },
// Mastodon — accepts GIF
mastodon_post: {
maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: true,
maxImageBytes: 10 * MB, maxVideoBytes: 40 * MB,
},
};
const DEFAULT_RULES: MediaRules = {
@ -54,6 +147,7 @@ const DEFAULT_RULES: MediaRules = {
acceptImages: true,
acceptVideos: true,
requiresMedia: false,
acceptsGif: true,
};
export function useMediaRules(contentType: Ref<string> | ComputedRef<string>) {

View file

@ -0,0 +1,61 @@
const PLATFORM_LOGOS: Record<string, string> = {
linkedin: '/images/accounts/linkedin.png',
'linkedin-page': '/images/accounts/linkedin.png',
x: '/images/accounts/x.png',
tiktok: '/images/accounts/tiktok.png',
instagram: '/images/accounts/instagram.png',
'instagram-facebook': '/images/accounts/instagram.png',
facebook: '/images/accounts/facebook.png',
youtube: '/images/accounts/youtube.png',
threads: '/images/accounts/threads.png',
bluesky: '/images/accounts/bluesky.png',
pinterest: '/images/accounts/pinterest.png',
mastodon: '/images/accounts/mastodon.png',
};
const PLATFORM_LABELS: Record<string, string> = {
linkedin: 'LinkedIn',
'linkedin-page': 'LinkedIn Page',
x: 'X',
tiktok: 'TikTok',
instagram: 'Instagram',
'instagram-facebook': 'Instagram',
facebook: 'Facebook',
youtube: 'YouTube',
threads: 'Threads',
bluesky: 'Bluesky',
pinterest: 'Pinterest',
mastodon: 'Mastodon',
};
const PLATFORM_CONTENT_TYPES: Record<string, string[]> = {
instagram: ['instagram_feed', 'instagram_reel', 'instagram_story'],
'instagram-facebook': ['instagram_feed', 'instagram_reel', 'instagram_story'],
linkedin: ['linkedin_post', 'linkedin_carousel'],
'linkedin-page': ['linkedin_page_post', 'linkedin_page_carousel'],
facebook: ['facebook_post', 'facebook_reel', 'facebook_story'],
tiktok: ['tiktok_video'],
youtube: ['youtube_short'],
x: ['x_post'],
threads: ['threads_post'],
pinterest: ['pinterest_pin', 'pinterest_video_pin', 'pinterest_carousel'],
bluesky: ['bluesky_post'],
mastodon: ['mastodon_post'],
};
export interface ContentTypeOption {
value: string;
labelKey: string;
}
export const getPlatformLogo = (platform: string): string =>
PLATFORM_LOGOS[platform] ?? PLATFORM_LOGOS.linkedin;
export const getPlatformLabel = (platform: string): string =>
PLATFORM_LABELS[platform] ?? platform;
export const getContentTypeOptions = (platform: string): ContentTypeOption[] =>
(PLATFORM_CONTENT_TYPES[platform] ?? []).map((value) => ({
value,
labelKey: `posts.content_types.${value}.label`,
}));

1964
resources/js/data/emojis.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import date from '@/date';
import AppLayout from '@/layouts/AppLayout.vue';
import { disconnect as disconnectAccount, toggle as toggleAccount } from '@/routes/app/accounts';
@ -37,24 +38,6 @@ const props = defineProps<Props>();
const isAddDialogOpen = ref(false);
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const getPlatformLogo = (platform: string): string => {
const logos: Record<string, string> = {
'linkedin': '/images/accounts/linkedin.png',
'linkedin-page': '/images/accounts/linkedin.png',
'x': '/images/accounts/x.png',
'tiktok': '/images/accounts/tiktok.png',
'instagram': '/images/accounts/instagram.png',
'instagram-facebook': '/images/accounts/instagram.png',
'facebook': '/images/accounts/facebook.png',
'youtube': '/images/accounts/youtube.png',
'threads': '/images/accounts/threads.png',
'bluesky': '/images/accounts/bluesky.png',
'pinterest': '/images/accounts/pinterest.png',
'mastodon': '/images/accounts/mastodon.png',
};
return logos[platform] || '/images/accounts/linkedin.png';
};
const getProfileUrl = (platform: string, username: string | null, platformUserId: string | null = null): string | null => {
if (platform === 'facebook') {
const identifier = username || platformUserId;

View file

@ -1,479 +1,8 @@
<script setup lang="ts">
import { Head, InfiniteScroll, router, useHttp } from '@inertiajs/vue3';
import { IconCloudUpload, IconPencilPlus, IconPhoto, IconPlus, IconSearch, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, onUnmounted, ref } from 'vue';
import { toast } from 'vue-sonner';
import { Head } from '@inertiajs/vue3';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import EmptyState from '@/components/EmptyState.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import debounce from '@/debounce';
import GalleryBrowser from '@/components/assets/GalleryBrowser.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import { destroy as assetsDestroy, store as assetsStore, storeFromUrl } from '@/routes/app/assets';
import { search as giphySearch, trending as giphyTrending } from '@/routes/app/assets/giphy';
import { search as unsplashSearch, trending as unsplashTrending } from '@/routes/app/assets/unsplash';
import { store as storePost } from '@/routes/app/posts';
interface AssetMedia {
id: string;
path: string;
url: string;
type: string;
mime_type: string;
original_filename: string;
size: number;
meta: { width?: number; height?: number } | null;
created_at: string;
}
interface ScrollAssets {
data: AssetMedia[];
meta: {
hasNextPage: boolean;
};
}
interface UnsplashPhoto {
id: string;
url_small: string;
url_regular: string;
url_full: string;
download_location: string;
description: string | null;
width: number;
height: number;
author: {
name: string;
url: string;
};
}
interface GiphyGif {
id: string;
title: string;
url_preview: string;
url_original: string;
url_downsized: string;
width: number;
height: number;
size: number;
}
const props = defineProps<{
assets: ScrollAssets;
}>();
interface SavedMedia {
id: string;
path: string;
url: string;
type: string;
mime_type: string;
}
interface UnsplashListResponse {
results: UnsplashPhoto[];
total_pages?: number;
total?: number;
}
interface GiphyListResponse {
results: GiphyGif[];
total_pages?: number;
total?: number;
}
const httpUnsplash = useHttp<Record<string, never>, UnsplashListResponse>({});
const httpGiphy = useHttp<Record<string, never>, GiphyListResponse>({});
const httpUpload = useHttp<{ media: File | null }>({ media: null });
const httpSaveFromUrl = useHttp<{ url: string; filename: string; download_location?: string }, SavedMedia>({
url: '',
filename: '',
});
// Upload
const fileInput = ref<HTMLInputElement | null>(null);
const isDragging = ref(false);
const uploading = ref(false);
const triggerFileInput = () => fileInput.value?.click();
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files) {
uploadFiles(Array.from(target.files));
target.value = '';
}
};
const handleDrop = (event: DragEvent) => {
isDragging.value = false;
if (event.dataTransfer?.files) {
uploadFiles(Array.from(event.dataTransfer.files));
}
};
const uploadFiles = async (files: File[]) => {
uploading.value = true;
for (const file of files) {
try {
httpUpload.media = file;
await httpUpload.post(assetsStore.url());
} catch {
// Silently handle individual file failures
}
}
uploading.value = false;
router.reload({ only: ['assets'], reset: ['assets'] });
};
// Delete
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const handleDelete = (assetId: string) => {
deleteModal.value?.open({
url: assetsDestroy.url(assetId),
});
};
// Unsplash
const unsplashQuery = ref('');
const unsplashResults = ref<UnsplashPhoto[]>([]);
const unsplashPage = ref(1);
const unsplashTotalPages = ref(0);
const unsplashLoading = ref(false);
const savingPhotoId = ref<string | null>(null);
const trendingPhotos = ref<UnsplashPhoto[]>([]);
const trendingPage = ref(1);
const trendingHasMore = ref(true);
const scrollSentinel = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const displayedPhotos = computed(() => {
if (unsplashQuery.value && unsplashResults.value.length > 0) {
return unsplashResults.value;
}
if (!unsplashQuery.value) {
return trendingPhotos.value;
}
return [];
});
const hasMorePhotos = computed(() => {
if (unsplashQuery.value) {
return unsplashPage.value < unsplashTotalPages.value;
}
return trendingHasMore.value;
});
const loadTrending = async (page = 1) => {
if (unsplashLoading.value) return;
unsplashLoading.value = true;
try {
const response = await httpUnsplash.get(unsplashTrending.url({ query: { page } }));
const results = response?.results ?? [];
if (page === 1) {
trendingPhotos.value = results;
} else {
trendingPhotos.value.push(...results);
}
trendingPage.value = page;
trendingHasMore.value = results.length >= 25;
} catch {
// ignore
} finally {
unsplashLoading.value = false;
}
};
const loadMorePhotos = async () => {
if (unsplashLoading.value) return;
if (unsplashQuery.value) {
await loadMoreUnsplash();
} else {
await loadTrending(trendingPage.value + 1);
}
};
const setupScrollObserver = () => {
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasMorePhotos.value && !unsplashLoading.value) {
loadMorePhotos();
}
},
{ rootMargin: '200px' },
);
if (scrollSentinel.value) {
observer.observe(scrollSentinel.value);
}
};
onUnmounted(() => {
observer?.disconnect();
giphyObserver?.disconnect();
});
const searchUnsplash = debounce(async () => {
if (!unsplashQuery.value.trim()) {
unsplashResults.value = [];
return;
}
unsplashLoading.value = true;
unsplashPage.value = 1;
try {
const response = await httpUnsplash.get(unsplashSearch.url({ query: { query: unsplashQuery.value, page: 1 } }));
unsplashResults.value = response?.results ?? [];
unsplashTotalPages.value = response?.total_pages ?? 0;
} catch {
unsplashResults.value = [];
} finally {
unsplashLoading.value = false;
}
}, 400);
const loadMoreUnsplash = async () => {
if (unsplashPage.value >= unsplashTotalPages.value || unsplashLoading.value) return;
unsplashLoading.value = true;
unsplashPage.value++;
try {
const response = await httpUnsplash.get(unsplashSearch.url({ query: { query: unsplashQuery.value, page: unsplashPage.value } }));
unsplashResults.value.push(...(response?.results ?? []));
} catch {
// ignore
} finally {
unsplashLoading.value = false;
}
};
const saveMediaFromUrl = async (payload: { url: string; filename: string; download_location?: string }): Promise<SavedMedia | null> => {
httpSaveFromUrl.url = payload.url;
httpSaveFromUrl.filename = payload.filename;
httpSaveFromUrl.download_location = payload.download_location;
try {
return (await httpSaveFromUrl.post(storeFromUrl.url())) ?? null;
} catch {
return null;
}
};
const saveFromUnsplash = async (photo: UnsplashPhoto) => {
savingPhotoId.value = photo.id;
const media = await saveMediaFromUrl({
url: photo.url_regular,
filename: `unsplash-${photo.id}.jpg`,
download_location: photo.download_location,
});
savingPhotoId.value = null;
if (media) {
toast.success(trans('assets.saved'));
router.reload({ only: ['assets'], reset: ['assets'] });
}
};
const createPostFromUnsplash = async (photo: UnsplashPhoto) => {
savingPhotoId.value = photo.id;
const media = await saveMediaFromUrl({
url: photo.url_regular,
filename: `unsplash-${photo.id}.jpg`,
download_location: photo.download_location,
});
if (! media) {
savingPhotoId.value = null;
return;
}
router.post(storePost.url(), {
media: [{ id: media.id, path: media.path, url: media.url, type: media.type, mime_type: media.mime_type }],
});
};
// Giphy
const giphyQuery = ref('');
const giphyResults = ref<GiphyGif[]>([]);
const giphyPage = ref(1);
const giphyTotalPages = ref(0);
const giphyLoading = ref(false);
const giphyTrendingPhotos = ref<GiphyGif[]>([]);
const giphyTrendingPage = ref(1);
const giphyTrendingHasMore = ref(true);
const savingGifId = ref<string | null>(null);
const giphyScrollSentinel = ref<HTMLElement | null>(null);
let giphyObserver: IntersectionObserver | null = null;
const displayedGifs = computed(() => {
if (giphyQuery.value && giphyResults.value.length > 0) {
return giphyResults.value;
}
if (!giphyQuery.value) {
return giphyTrendingPhotos.value;
}
return [];
});
const hasMoreGifs = computed(() => {
if (giphyQuery.value) {
return giphyPage.value < giphyTotalPages.value;
}
return giphyTrendingHasMore.value;
});
const loadGiphyTrending = async (page = 1) => {
if (giphyLoading.value) return;
giphyLoading.value = true;
try {
const response = await httpGiphy.get(giphyTrending.url({ query: { page } }));
const results = response?.results ?? [];
if (page === 1) {
giphyTrendingPhotos.value = results;
} else {
giphyTrendingPhotos.value.push(...results);
}
giphyTrendingPage.value = page;
giphyTrendingHasMore.value = results.length >= 25;
} catch {
// ignore
} finally {
giphyLoading.value = false;
}
};
const searchGiphy = debounce(async () => {
if (!giphyQuery.value.trim()) {
giphyResults.value = [];
return;
}
giphyLoading.value = true;
giphyPage.value = 1;
try {
const response = await httpGiphy.get(giphySearch.url({ query: { query: giphyQuery.value, page: 1 } }));
giphyResults.value = response?.results ?? [];
giphyTotalPages.value = response?.total_pages ?? 0;
} catch {
giphyResults.value = [];
} finally {
giphyLoading.value = false;
}
}, 400);
const loadMoreGiphy = async () => {
if (giphyPage.value >= giphyTotalPages.value || giphyLoading.value) return;
giphyLoading.value = true;
giphyPage.value++;
try {
const response = await httpGiphy.get(giphySearch.url({ query: { query: giphyQuery.value, page: giphyPage.value } }));
giphyResults.value.push(...(response?.results ?? []));
} catch {
// ignore
} finally {
giphyLoading.value = false;
}
};
const loadMoreGifs = async () => {
if (giphyLoading.value) return;
if (giphyQuery.value) {
await loadMoreGiphy();
} else {
await loadGiphyTrending(giphyTrendingPage.value + 1);
}
};
const setupGiphyScrollObserver = () => {
if (giphyObserver) giphyObserver.disconnect();
giphyObserver = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasMoreGifs.value && !giphyLoading.value) {
loadMoreGifs();
}
},
{ rootMargin: '200px' },
);
if (giphyScrollSentinel.value) {
giphyObserver.observe(giphyScrollSentinel.value);
}
};
const saveFromGiphy = async (gif: GiphyGif) => {
savingGifId.value = gif.id;
const media = await saveMediaFromUrl({
url: gif.url_downsized,
filename: `giphy-${gif.id}.gif`,
});
savingGifId.value = null;
if (media) {
toast.success(trans('assets.saved'));
router.reload({ only: ['assets'], reset: ['assets'] });
}
};
const createPostFromGiphy = async (gif: GiphyGif) => {
savingGifId.value = gif.id;
const media = await saveMediaFromUrl({
url: gif.url_downsized,
filename: `giphy-${gif.id}.gif`,
});
if (! media) {
savingGifId.value = null;
return;
}
router.post(storePost.url(), {
media: [{ id: media.id, path: media.path, url: media.url, type: media.type, mime_type: media.mime_type }],
});
};
const createPostFromAsset = (asset: AssetMedia) => {
router.post(storePost.url(), {
media: [{ id: asset.id, path: asset.path, url: asset.url, type: asset.type, mime_type: asset.mime_type }],
});
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
};
</script>
<template>
@ -481,321 +10,7 @@ const formatFileSize = (bytes: number): string => {
<AppLayout :title="$t('assets.title')">
<div class="flex flex-col gap-6 p-6">
<Tabs default-value="uploads">
<TabsList>
<TabsTrigger value="uploads">{{ $t('assets.tabs.my_uploads') }}</TabsTrigger>
<TabsTrigger value="stock">{{ $t('assets.tabs.stock_photos') }}</TabsTrigger>
<TabsTrigger value="gifs">{{ $t('assets.tabs.gifs') }}</TabsTrigger>
</TabsList>
<!-- My Uploads -->
<TabsContent value="uploads" class="mt-6">
<!-- Upload Zone -->
<div
class="relative mb-6 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-10 transition-colors"
:class="isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'"
@click="triggerFileInput"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
>
<IconCloudUpload class="mb-3 size-10 text-muted-foreground" />
<p class="text-sm font-medium">{{ $t('assets.upload.drag_drop') }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ $t('assets.upload.formats') }}</p>
<input
ref="fileInput"
type="file"
class="hidden"
multiple
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4"
@change="handleFileSelect"
/>
<div v-if="uploading" class="absolute inset-0 flex items-center justify-center rounded-lg bg-background/80">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<div class="size-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
{{ $t('assets.upload.uploading') }}
</div>
</div>
</div>
<!-- Assets Grid -->
<div v-if="assets.data.length > 0" class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<div
v-for="asset in assets.data"
:key="asset.id"
class="group relative overflow-hidden rounded-lg border bg-muted"
>
<div class="aspect-square">
<video
v-if="asset.type === 'video'"
:src="asset.url"
class="size-full object-cover"
muted
/>
<img
v-else
:src="asset.url"
:alt="asset.original_filename"
class="size-full object-cover"
loading="lazy"
/>
</div>
<!-- Hover overlay -->
<div class="absolute inset-0 flex flex-col justify-between bg-black/60 p-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="flex justify-end gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
size="icon"
class="size-7"
@click="createPostFromAsset(asset)"
>
<IconPencilPlus class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('assets.create_post') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
variant="destructive"
size="icon"
class="size-7"
@click="handleDelete(asset.id)"
>
<IconTrash class="size-3.5" />
</Button>
</div>
<div class="space-y-0.5">
<p class="truncate text-xs font-medium text-white">{{ asset.original_filename }}</p>
<p class="text-xs text-white/70">{{ formatFileSize(asset.size) }}</p>
</div>
</div>
</div>
</div>
<InfiniteScroll data="assets" #default="{ loading }">
<div v-if="loading" class="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<Skeleton v-for="i in 5" :key="i" class="aspect-square rounded-lg" />
</div>
</InfiniteScroll>
</TabsContent>
<!-- Stock Photos (Unsplash) -->
<TabsContent value="stock" class="mt-6" @vue:mounted="() => { loadTrending(); setupScrollObserver(); }">
<div class="relative mb-6">
<IconSearch class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="unsplashQuery"
:placeholder="$t('assets.unsplash.search_placeholder')"
class="pl-9"
@input="searchUnsplash"
/>
</div>
<!-- Unsplash Results Grid (search or trending) -->
<div v-if="displayedPhotos.length > 0" class="space-y-3">
<p v-if="!unsplashQuery && trendingPhotos.length > 0" class="text-sm font-medium text-muted-foreground">
{{ $t('assets.unsplash.trending') }}
</p>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
<div
v-for="photo in displayedPhotos"
:key="photo.id"
class="group relative overflow-hidden rounded-lg bg-muted"
>
<div class="aspect-[4/3]">
<img
:src="photo.url_small"
:alt="photo.description || 'Unsplash photo'"
class="size-full object-cover"
loading="lazy"
/>
</div>
<!-- Hover overlay -->
<div class="absolute inset-0 flex flex-col justify-between bg-black/60 p-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="flex justify-end gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
size="icon"
class="size-7"
:disabled="savingPhotoId === photo.id"
@click="createPostFromUnsplash(photo)"
>
<IconPencilPlus class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('assets.create_post') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
size="icon"
class="size-7"
:disabled="savingPhotoId === photo.id"
@click="saveFromUnsplash(photo)"
>
<div v-if="savingPhotoId === photo.id" class="size-3.5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<IconPlus v-else class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('assets.save_to_assets') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p class="text-xs text-white/80">
<a
:href="photo.author.url + '?utm_source=trypost&utm_medium=referral'"
target="_blank"
rel="noopener noreferrer"
class="hover:text-white"
>{{ photo.author.name }}</a>
<span class="text-white/50"> / </span>
<a
href="https://unsplash.com/?utm_source=trypost&utm_medium=referral"
target="_blank"
rel="noopener noreferrer"
class="hover:text-white"
>Unsplash</a>
</p>
</div>
</div>
</div>
</div>
<EmptyState
v-else-if="unsplashQuery && !unsplashLoading"
:icon="IconSearch"
:title="$t('assets.unsplash.no_results')"
:description="$t('assets.unsplash.no_results_description')"
/>
<!-- Loading skeletons -->
<div v-if="unsplashLoading" class="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
<Skeleton v-for="i in 8" :key="i" class="aspect-[4/3] rounded-lg" />
</div>
<!-- Scroll sentinel for infinite scroll -->
<div v-if="hasMorePhotos" ref="scrollSentinel" class="h-1" @vue:mounted="setupScrollObserver" />
</TabsContent>
<!-- GIFs (Giphy) -->
<TabsContent value="gifs" class="mt-6" @vue:mounted="() => { loadGiphyTrending(); setupGiphyScrollObserver(); }">
<div class="relative mb-6">
<IconSearch class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="giphyQuery"
:placeholder="$t('assets.giphy.search_placeholder')"
class="pl-9"
@input="searchGiphy"
/>
</div>
<!-- Giphy Results Grid (search or trending) -->
<div v-if="displayedGifs.length > 0" class="space-y-3">
<p v-if="!giphyQuery && giphyTrendingPhotos.length > 0" class="text-sm font-medium text-muted-foreground">
{{ $t('assets.giphy.trending') }}
</p>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
<div
v-for="gif in displayedGifs"
:key="gif.id"
class="group relative overflow-hidden rounded-lg bg-muted"
>
<div class="aspect-[4/3]">
<img
:src="gif.url_preview"
:alt="gif.title || 'GIF'"
class="size-full object-cover"
loading="lazy"
/>
</div>
<!-- Hover overlay -->
<div class="absolute inset-0 flex flex-col justify-between bg-black/60 p-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="flex justify-end gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
size="icon"
class="size-7"
:disabled="savingGifId === gif.id"
@click="createPostFromGiphy(gif)"
>
<IconPencilPlus class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('assets.create_post') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
size="icon"
class="size-7"
:disabled="savingGifId === gif.id"
@click="saveFromGiphy(gif)"
>
<div v-if="savingGifId === gif.id" class="size-3.5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<IconPlus v-else class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('assets.save_to_assets') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p v-if="gif.title" class="truncate text-xs text-white/80">{{ gif.title }}</p>
</div>
</div>
</div>
</div>
<EmptyState
v-else-if="giphyQuery && !giphyLoading"
:icon="IconSearch"
:title="$t('assets.giphy.no_results')"
:description="$t('assets.giphy.no_results_description')"
/>
<!-- Loading skeletons -->
<div v-if="giphyLoading" class="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
<Skeleton v-for="i in 8" :key="i" class="aspect-[4/3] rounded-lg" />
</div>
<!-- Scroll sentinel for infinite scroll -->
<div v-if="hasMoreGifs" ref="giphyScrollSentinel" class="h-1" @vue:mounted="setupGiphyScrollObserver" />
<!-- Giphy attribution (required by API terms) -->
<div v-if="displayedGifs.length > 0" class="mt-4 text-center">
<a href="https://giphy.com" target="_blank" rel="noopener noreferrer" class="text-xs text-muted-foreground hover:text-foreground">
{{ $t('assets.giphy.powered_by') }}
</a>
</div>
</TabsContent>
</Tabs>
<GalleryBrowser mode="standalone" />
</div>
</AppLayout>
<ConfirmDeleteModal
ref="deleteModal"
:title="$t('assets.delete.title')"
:description="$t('assets.delete.description')"
:action="$t('assets.delete.confirm')"
:cancel="$t('assets.delete.cancel')"
/>
</template>

View file

@ -6,6 +6,8 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
import DatePicker from '@/components/DatePicker.vue';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import date from '@/date';
import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
@ -21,6 +23,7 @@ interface PostPlatform {
id: string;
platform: string;
display_name: string;
username: string | null;
};
}
@ -225,23 +228,6 @@ const getStatusColor = (status: string): string => {
return colors[status] || 'bg-neutral-100 border-neutral-300 text-neutral-700 dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-300';
};
const getPlatformLogo = (platform: string): string => {
const logos: Record<string, string> = {
'linkedin': '/images/accounts/linkedin.png',
'linkedin-page': '/images/accounts/linkedin.png',
'x': '/images/accounts/x.png',
'tiktok': '/images/accounts/tiktok.png',
'youtube': '/images/accounts/youtube.png',
'facebook': '/images/accounts/facebook.png',
'instagram': '/images/accounts/instagram.png',
'threads': '/images/accounts/threads.png',
'pinterest': '/images/accounts/pinterest.png',
'bluesky': '/images/accounts/bluesky.png',
'mastodon': '/images/accounts/mastodon.png',
};
return logos[platform];
};
const getPostUrl = (post: Post): string => {
return editPost.url(post.id);
};
@ -316,9 +302,19 @@ const formatTime = (scheduledAt: string): string => {
<!-- Platforms -->
<div class="flex -space-x-1 mb-2">
<img v-for="pp in post.post_platforms.slice(0, 5)" :key="pp.id"
:src="getPlatformLogo(pp.platform)" :alt="pp.platform"
class="h-6 w-6 rounded-full ring-2 ring-background" />
<TooltipProvider v-for="pp in post.post_platforms.slice(0, 5)" :key="pp.id" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<img :src="getPlatformLogo(pp.platform)" :alt="pp.platform" class="h-6 w-6 rounded-full ring-2 ring-background" />
</TooltipTrigger>
<TooltipContent>
<div class="space-y-0.5 text-xs">
<p class="font-semibold">{{ pp.social_account?.display_name ?? pp.platform }}<span v-if="pp.social_account?.username" class="font-normal opacity-80">&nbsp;·&nbsp;@{{ pp.social_account.username }}</span></p>
<p class="opacity-70">{{ getPlatformLabel(pp.platform) }}</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span v-if="post.post_platforms.length > 5"
class="flex items-center justify-center h-6 w-6 rounded-full bg-muted text-xs font-medium ring-2 ring-background">
+{{ post.post_platforms.length - 5 }}
@ -378,9 +374,19 @@ const formatTime = (scheduledAt: string): string => {
<!-- Platforms -->
<div class="flex -space-x-1 mb-1.5">
<img v-for="pp in post.post_platforms.slice(0, 4)" :key="pp.id"
:src="getPlatformLogo(pp.platform)" :alt="pp.platform"
class="h-5 w-5 rounded-full ring-2 ring-background" />
<TooltipProvider v-for="pp in post.post_platforms.slice(0, 4)" :key="pp.id" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<img :src="getPlatformLogo(pp.platform)" :alt="pp.platform" class="h-5 w-5 rounded-full ring-2 ring-background" />
</TooltipTrigger>
<TooltipContent>
<div class="space-y-0.5 text-xs">
<p class="font-semibold">{{ pp.social_account?.display_name ?? pp.platform }}<span v-if="pp.social_account?.username" class="font-normal opacity-80">&nbsp;·&nbsp;@{{ pp.social_account.username }}</span></p>
<p class="opacity-70">{{ getPlatformLabel(pp.platform) }}</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span v-if="post.post_platforms.length > 4"
class="flex items-center justify-center h-5 w-5 rounded-full bg-muted text-[10px] font-medium ring-2 ring-background">
+{{ post.post_platforms.length - 4 }}
@ -441,9 +447,23 @@ const formatTime = (scheduledAt: string): string => {
:class="getStatusColor(post.status)">
<span class="font-medium shrink-0">{{ formatTime(post.scheduled_at) }}</span>
<div class="flex -space-x-1 shrink-0">
<img v-for="pp in post.post_platforms.slice(0, post.post_platforms.length > 4 ? 3 : 4)"
:key="pp.id" :src="getPlatformLogo(pp.platform)" :alt="pp.platform"
class="h-4 w-4 rounded-full ring-1 ring-background" />
<TooltipProvider
v-for="pp in post.post_platforms.slice(0, post.post_platforms.length > 4 ? 3 : 4)"
:key="pp.id"
:delay-duration="200"
>
<Tooltip>
<TooltipTrigger as-child>
<img :src="getPlatformLogo(pp.platform)" :alt="pp.platform" class="h-4 w-4 rounded-full ring-1 ring-background" />
</TooltipTrigger>
<TooltipContent>
<div class="space-y-0.5 text-xs">
<p class="font-semibold">{{ pp.social_account?.display_name ?? pp.platform }}<span v-if="pp.social_account?.username" class="font-normal opacity-80">&nbsp;·&nbsp;@{{ pp.social_account.username }}</span></p>
<p class="opacity-70">{{ getPlatformLabel(pp.platform) }}</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span v-if="post.post_platforms.length > 4"
class="flex items-center justify-center h-4 w-4 rounded-full bg-muted text-[9px] font-medium ring-1 ring-background">
+{{ post.post_platforms.length - 3 }}

View file

@ -4,12 +4,11 @@ import { useEcho } from '@laravel/echo-vue';
import {
IconCalendar,
IconCircleCheck,
IconCloudUpload,
IconHash,
IconLibraryPhoto,
IconLoader2,
IconMessage2,
IconMoodSmile,
IconPhoto,
IconSparkles,
IconTrash,
} from '@tabler/icons-vue';
@ -17,17 +16,21 @@ import { trans } from 'laravel-vue-i18n';
import { computed, onUnmounted, ref, watch } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import HashtagsModal from '@/components/posts/HashtagsModal.vue';
import PickTimePopover from '@/components/posts/PickTimePopover.vue';
import CommentsTab from '@/components/posts/editor/CommentsTab.vue';
import PreviewTab from '@/components/posts/editor/PreviewTab.vue';
import ScheduleTab from '@/components/posts/editor/ScheduleTab.vue';
import WritingAssistantTab from '@/components/posts/editor/WritingAssistantTab.vue';
import EmojiPicker from '@/components/posts/EmojiPicker.vue';
import HashtagsModal from '@/components/posts/HashtagsModal.vue';
import MediaPickerDialog from '@/components/posts/MediaPickerDialog.vue';
import PickTimePopover from '@/components/posts/PickTimePopover.vue';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { readFileMetadata } from '@/composables/useMedia';
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
import dayjs from '@/dayjs';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
@ -41,6 +44,8 @@ interface MediaItem {
type?: string;
mime_type?: string;
original_filename?: string;
size?: number;
meta?: { width?: number; height?: number; duration?: number };
}
interface SocialAccount {
@ -108,7 +113,9 @@ const props = defineProps<{
}>();
const post = computed(() => props.post);
const isReadOnly = computed(() => ['published', 'partially_published'].includes(post.value.status));
const isReadOnly = computed(() => ['publishing', 'published', 'partially_published'].includes(post.value.status));
const isPublishing = computed(() => post.value.status === 'publishing');
const isPublished = computed(() => ['published', 'partially_published'].includes(post.value.status));
// Content
const content = ref(post.value.content || '');
@ -128,6 +135,72 @@ const updatePlatformMeta = (platformId: string, meta: Record<string, any>) => {
platformMeta.value = { ...platformMeta.value, [platformId]: meta };
};
// Per-platform content_type (Instagram Feed/Reel/Story, Facebook Post/Reel/Story, etc.)
const platformContentTypes = ref<Record<string, string>>(
Object.fromEntries(post.value.post_platforms.map((pp) => [pp.id, pp.content_type ?? ''])),
);
const updatePlatformContentType = (platformId: string, contentType: string) => {
platformContentTypes.value = { ...platformContentTypes.value, [platformId]: contentType };
};
const isMediaValidForContentType = (contentType: string, mediaItems: MediaItem[]): boolean => {
const rules = getMediaRulesForContentType(contentType);
const videos = mediaItems.filter((m) => m.type === 'video' || m.mime_type?.startsWith('video/'));
const images = mediaItems.filter((m) => m.type === 'image' || m.mime_type?.startsWith('image/'));
const gifs = mediaItems.filter((m) => m.mime_type === 'image/gif');
const total = mediaItems.length;
if (rules.requiresMedia && total === 0) return false;
if (total > rules.maxFiles) return false;
if (rules.minFiles && total < rules.minFiles) return false;
if (! rules.acceptVideos && videos.length > 0) return false;
if (! rules.acceptImages && images.length > 0) return false;
if (! rules.acceptsGif && gifs.length > 0) return false;
// Size / duration / aspect checks only when metadata is available.
for (const m of mediaItems) {
const isVideo = m.type === 'video' || m.mime_type?.startsWith('video/');
const size = m.size ?? 0;
const width = m.meta?.width ?? 0;
const height = m.meta?.height ?? 0;
const duration = m.meta?.duration ?? 0;
if (isVideo) {
if (rules.maxVideoBytes && size > 0 && size > rules.maxVideoBytes) return false;
if (rules.maxVideoDurationSec && duration > 0 && duration > rules.maxVideoDurationSec) return false;
} else {
if (rules.maxImageBytes && size > 0 && size > rules.maxImageBytes) return false;
}
if (width > 0 && height > 0 && (rules.aspectRatioMin || rules.aspectRatioMax)) {
const ratio = width / height;
if (rules.aspectRatioMin && ratio < rules.aspectRatioMin) return false;
if (rules.aspectRatioMax && ratio > rules.aspectRatioMax) return false;
}
}
return true;
};
const instagramComplianceValid = computed(() =>
post.value.post_platforms
.filter((pp) => ['instagram', 'instagram-facebook'].includes(pp.platform) && selectedPlatformIds.value.includes(pp.id))
.every((pp) => {
const contentType = platformContentTypes.value[pp.id];
return Boolean(contentType) && isMediaValidForContentType(contentType, media.value);
}),
);
const facebookComplianceValid = computed(() =>
post.value.post_platforms
.filter((pp) => pp.platform === 'facebook' && selectedPlatformIds.value.includes(pp.id))
.every((pp) => {
const contentType = platformContentTypes.value[pp.id];
return Boolean(contentType) && isMediaValidForContentType(contentType, media.value);
}),
);
// TikTok compliance per docs:
// - privacy_level must be explicitly selected
// - if disclosure toggle is ON, at least one sub-toggle must be selected
@ -168,15 +241,13 @@ const showSaved = ref(false);
const activeTab = ref('schedule');
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const hashtagsModal = ref<InstanceType<typeof HashtagsModal> | null>(null);
const mediaPickerDialog = ref<InstanceType<typeof MediaPickerDialog> | null>(null);
const commentsTabRef = ref<InstanceType<typeof CommentsTab> | null>(null);
const emojiOpen = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
const isDragging = ref(false);
const uploading = ref(false);
const emojiList = ['😀', '😂', '🔥', '💯', '🎉', '👏', '❤️', '🚀', '✨', '💡', '📈', '💪', '🙌', '👀', '😊', '🤝', '💼', '📊', '🎯', '💎', '⚡️', '🎁', '🌟', '📱'];
// Toggle platform
const togglePlatform = (platformId: string) => {
if (isReadOnly.value) return;
@ -188,25 +259,14 @@ const togglePlatform = (platformId: string) => {
}
};
// First enabled platform for preview
const previewPlatform = computed(() => {
const enabledId = selectedPlatformIds.value[0];
return post.value.post_platforms.find((pp) => pp.id === enabledId) || post.value.post_platforms[0];
const previewablePlatforms = computed(() => {
const selected = post.value.post_platforms.filter((pp) => selectedPlatformIds.value.includes(pp.id));
return selected.length > 0 ? selected : post.value.post_platforms.slice(0, 1);
});
// Media upload
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
const triggerFileInput = () => fileInput.value?.click();
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files) {
uploadFiles(Array.from(target.files));
target.value = '';
}
};
const handleDrop = (event: DragEvent) => {
isDragging.value = false;
if (event.dataTransfer?.files) {
@ -218,8 +278,13 @@ const uploadFiles = async (files: File[]) => {
uploading.value = true;
for (const file of files) {
const clientMeta = await readFileMetadata(file);
const formData = new FormData();
formData.append('media', file);
if (clientMeta.width) formData.append('meta[width]', String(clientMeta.width));
if (clientMeta.height) formData.append('meta[height]', String(clientMeta.height));
if (clientMeta.duration) formData.append('meta[duration]', String(clientMeta.duration));
try {
const response = await fetch(storeAsset.url(), {
@ -244,6 +309,8 @@ const uploadFiles = async (files: File[]) => {
type: data.type,
mime_type: data.mime_type,
original_filename: data.original_filename,
size: data.size,
meta: data.meta,
},
];
} catch {
@ -258,6 +325,13 @@ const removeMedia = (mediaId: string) => {
media.value = media.value.filter((m) => m.id !== mediaId);
};
const addMediaFromGallery = (picked: MediaItem[]) => {
const existingIds = new Set(media.value.map((m) => m.id));
const additions = picked.filter((m) => !existingIds.has(m.id));
if (additions.length === 0) return;
media.value = [...media.value, ...additions];
};
const addedTextFromMessageIds = ref<Set<string>>(new Set());
const addMediaFromAssistant = (payload: {
@ -282,7 +356,7 @@ const getSubmitData = () => {
.filter((pp) => selectedPlatformIds.value.includes(pp.id))
.map((pp) => ({
id: pp.id,
content_type: pp.content_type,
content_type: platformContentTypes.value[pp.id] ?? pp.content_type,
meta: platformMeta.value[pp.id] ?? pp.meta ?? {},
}));
@ -331,7 +405,7 @@ const triggerAutosave = () => {
}
};
watch([content, media, selectedPlatformIds, scheduledDateTime, selectedLabelIds, platformMeta], triggerAutosave, { deep: true });
watch([content, media, selectedPlatformIds, scheduledDateTime, selectedLabelIds, platformMeta, platformContentTypes], triggerAutosave, { deep: true });
onUnmounted(() => {
debouncedSave.cancel();
@ -382,11 +456,15 @@ const deletePost = () => {
deleteModal.value?.open({ url: destroyPost.url(post.value.id) });
};
// Echo: listen for real-time platform status updates
// Echo: listen for real-time platform status updates.
// Event fires when any post_platform completes publishing (success or fail).
// Full reload of the post prop so the new status + post_platforms propagate and
// the overlay dismisses.
useEcho(`post.${post.value.id}`, '.PostPlatformStatusUpdated', () => {
router.reload({ only: ['post'], preserveScroll: true });
});
// Echo: listen for real-time comments
useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
commentsTabRef.value?.addCommentFromBroadcast(e.comment);
@ -409,6 +487,10 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
<IconCircleCheck class="h-3.5 w-3.5 text-green-500" />
{{ $t('posts.edit.saved') }}
</span>
<span v-else-if="isPublished" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<IconCircleCheck class="h-3.5 w-3.5 text-green-500" />
{{ $t('posts.edit.status.published') }}
</span>
<span v-else class="flex items-center gap-1.5 text-xs text-muted-foreground">
<span class="h-2 w-2 rounded-full bg-muted-foreground/50" />
{{ $t('posts.edit.draft') }}
@ -438,15 +520,15 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
<PickTimePopover
v-model="scheduledDateTime"
:disabled="isSubmitting || selectedPlatformIds.length === 0 || !tiktokComplianceValid"
:disabled="isSubmitting || selectedPlatformIds.length === 0 || !tiktokComplianceValid || !instagramComplianceValid || !facebookComplianceValid"
@confirm="hasPickedTime = true"
>
<Button
type="button"
variant="secondary"
size="sm"
:disabled="isSubmitting || selectedPlatformIds.length === 0 || !tiktokComplianceValid"
:title="!tiktokComplianceValid ? $t('posts.form.tiktok.compliance_incomplete') : ''"
:disabled="isSubmitting || selectedPlatformIds.length === 0 || !tiktokComplianceValid || !instagramComplianceValid || !facebookComplianceValid"
:title="!tiktokComplianceValid || !instagramComplianceValid || !facebookComplianceValid ? $t('posts.edit.compliance_incomplete') : ''"
>
<IconCalendar class="h-4 w-4" />
{{ pickTimeLabel }}
@ -456,8 +538,8 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
<Button
type="button"
size="sm"
:disabled="isSubmitting || selectedPlatformIds.length === 0 || !tiktokComplianceValid"
:title="!tiktokComplianceValid ? $t('posts.form.tiktok.compliance_incomplete') : ''"
:disabled="isSubmitting || selectedPlatformIds.length === 0 || !tiktokComplianceValid || !instagramComplianceValid || !facebookComplianceValid"
:title="!tiktokComplianceValid || !instagramComplianceValid || !facebookComplianceValid ? $t('posts.edit.compliance_incomplete') : ''"
@click="submit(hasPickedTime ? 'scheduled' : 'publishing')"
>
{{ hasPickedTime ? $t('posts.edit.schedule') : $t('posts.edit.post_now') }}
@ -465,7 +547,17 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
</div>
</div>
<div class="flex-1 overflow-hidden">
<div class="relative flex-1 overflow-hidden">
<div
v-if="isPublishing"
class="absolute inset-0 z-40 flex flex-col items-center justify-center gap-3 bg-background/80 backdrop-blur-sm"
>
<IconLoader2 class="h-8 w-8 animate-spin text-primary" />
<div class="text-center">
<p class="text-sm font-medium">{{ $t('posts.edit.publishing_overlay_title') }}</p>
<p class="text-xs text-muted-foreground">{{ $t('posts.edit.publishing_overlay_subtitle') }}</p>
</div>
</div>
<div class="h-full flex">
<!-- Composition column -->
<div class="w-full lg:w-2/3 lg:border-r overflow-y-auto">
@ -504,28 +596,26 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
<!-- Inline toolbar -->
<div v-if="!isReadOnly" class="flex items-center gap-1 border-t px-3 py-2">
<Popover v-model:open="emojiOpen">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<PopoverTrigger as-child>
<Button type="button" variant="ghost" size="icon-sm" class="h-8 w-8 text-muted-foreground hover:text-foreground">
<PopoverAnchor as-child>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="ghost"
size="icon-sm"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
@click="emojiOpen = !emojiOpen"
>
<IconMoodSmile class="h-4 w-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Emoji</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent class="w-64 p-2" align="start">
<div class="grid grid-cols-6 gap-1">
<button
v-for="emoji in emojiList"
:key="emoji"
type="button"
class="flex h-8 w-8 items-center justify-center rounded-md text-lg transition-colors hover:bg-muted"
@click="appendEmoji(emoji)"
>{{ emoji }}</button>
</div>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.emoji_picker.search') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
</PopoverAnchor>
<PopoverContent class="w-auto p-0" align="start">
<EmojiPicker @select="appendEmoji" />
</PopoverContent>
</Popover>
@ -571,9 +661,9 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
variant="ghost"
size="icon-sm"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
@click="triggerFileInput"
@click="mediaPickerDialog?.open()"
>
<IconPhoto class="h-4 w-4" />
<IconLibraryPhoto class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.add_media') }}</TooltipContent>
@ -621,9 +711,9 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
v-if="!isReadOnly"
type="button"
class="flex aspect-square items-center justify-center rounded-lg border-2 border-dashed border-border text-muted-foreground transition-colors hover:border-primary/50 hover:text-primary"
@click="triggerFileInput"
@click="mediaPickerDialog?.open()"
>
<IconCloudUpload class="h-6 w-6" />
<IconLibraryPhoto class="h-6 w-6" />
</button>
</div>
</div>
@ -633,14 +723,6 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
{{ $t('posts.edit.drag_drop') }}
</div>
<input
ref="fileInput"
type="file"
class="hidden"
multiple
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4"
@change="handleFileSelect"
/>
</div>
</div>
</div>
@ -657,12 +739,11 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
<TabsContent value="preview" class="flex-1 overflow-y-auto">
<PreviewTab
v-if="previewPlatform"
:platform="previewPlatform.platform"
:platforms="previewablePlatforms"
:content="content"
:media="media"
:social-account="previewPlatform.social_account"
:content-type="previewPlatform.content_type"
:platform-content-types="platformContentTypes"
@update:platform-content-type="updatePlatformContentType"
/>
</TabsContent>
@ -675,11 +756,13 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
:is-read-only="isReadOnly"
:platform-configs="platformConfigs"
:platform-meta="platformMeta"
:platform-content-types="platformContentTypes"
:tiktok-creator-infos="tiktokCreatorInfos"
:media="media"
@toggle-platform="togglePlatform"
@toggle-label="toggleLabel"
@update:platformMeta="updatePlatformMeta"
@update:platformContentType="updatePlatformContentType"
/>
</TabsContent>
@ -705,4 +788,5 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
:cancel="$t('posts.delete.cancel')"
/>
<HashtagsModal ref="hashtagsModal" :hashtags="hashtags" @select="appendHashtags" />
<MediaPickerDialog ref="mediaPickerDialog" @select="addMediaFromGallery" />
</template>

View file

@ -13,6 +13,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import dayjs from '@/dayjs';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
@ -103,23 +104,6 @@ const pageTitle = computed(() => {
return trans('posts.all_posts');
});
const getPlatformLogo = (platform: string): string => {
const logos: Record<string, string> = {
'linkedin': '/images/accounts/linkedin.png',
'linkedin-page': '/images/accounts/linkedin.png',
'x': '/images/accounts/x.png',
'tiktok': '/images/accounts/tiktok.png',
'youtube': '/images/accounts/youtube.png',
'facebook': '/images/accounts/facebook.png',
'instagram': '/images/accounts/instagram.png',
'threads': '/images/accounts/threads.png',
'pinterest': '/images/accounts/pinterest.png',
'bluesky': '/images/accounts/bluesky.png',
'mastodon': '/images/accounts/mastodon.png',
};
return logos[platform] || '/images/accounts/default.png';
};
const getStatusConfig = (status: string) => {
const configs: Record<string, { color: string; icon: typeof IconFileText }> = {
'draft': { color: 'bg-neutral-100 text-neutral-800', icon: IconFileText },
@ -230,9 +214,25 @@ const handleDelete = (post: Post) => {
<div class="flex items-center justify-between mt-auto">
<div class="flex items-center gap-2">
<div class="flex -space-x-2">
<img v-for="pp in getEnabledPlatforms(post).slice(0, 4)" :key="pp.id"
:src="getPlatformLogo(pp.platform)" :alt="pp.platform"
class="h-6 w-6 rounded-full ring-2 ring-background" />
<TooltipProvider v-for="pp in getEnabledPlatforms(post).slice(0, 4)" :key="pp.id" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<img
:src="getPlatformLogo(pp.platform)"
:alt="pp.platform"
class="h-6 w-6 rounded-full ring-2 ring-background"
/>
</TooltipTrigger>
<TooltipContent>
<div class="space-y-0.5 text-xs">
<p class="font-semibold">
{{ pp.social_account?.display_name ?? pp.platform }}<span v-if="pp.social_account?.username" class="font-normal opacity-80">&nbsp;·&nbsp;@{{ pp.social_account.username }}</span>
</p>
<p class="opacity-70">{{ getPlatformLabel(pp.platform) }}</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<span v-if="getEnabledPlatforms(post).length > 4"
class="text-xs text-muted-foreground">

View file

@ -56,7 +56,7 @@
});
// Social Connect routes
Route::middleware(['auth', 'verified', 'throttle:6,1'])->group(function () {
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('connect/linkedin', [LinkedInController::class, 'connect'])->name('app.social.linkedin.connect');
Route::get('accounts/linkedin/callback', [LinkedInController::class, 'callback'])->name('app.social.linkedin.callback');
@ -172,6 +172,7 @@
// Assets
Route::get('assets', [AssetController::class, 'index'])->name('app.assets.index');
Route::get('assets/search', [AssetController::class, 'search'])->name('app.assets.search');
Route::post('assets', [AssetController::class, 'store'])->name('app.assets.store');
Route::post('assets/chunked', [AssetController::class, 'storeChunked'])->name('app.assets.store-chunked');
Route::post('assets/from-url', [AssetController::class, 'storeFromUrl'])->name('app.assets.store-from-url');

View file

@ -39,10 +39,7 @@
$response = $this->actingAs($this->user)->get(route('app.assets.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('assets/Index', false)
->has('assets')
);
$response->assertInertia(fn ($page) => $page->component('assets/Index', false));
});
test('assets index requires authentication', function () {
@ -51,6 +48,48 @@
$response->assertRedirect(route('login'));
});
test('assets search returns paginated json filtered by name', function () {
$matching = $this->workspace->addMedia(UploadedFile::fake()->image('vacation-beach.jpg'), 'assets');
$this->workspace->addMedia(UploadedFile::fake()->image('office-shot.jpg'), 'assets');
$response = $this->actingAs($this->user)
->getJson(route('app.assets.search', ['search' => 'vacation']));
$response->assertOk();
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $matching->id);
});
test('assets search filters by type', function () {
$this->workspace->addMedia(UploadedFile::fake()->image('photo.jpg'), 'assets');
$this->workspace->addMedia(UploadedFile::fake()->create('clip.mp4', 100, 'video/mp4'), 'assets');
$response = $this->actingAs($this->user)
->getJson(route('app.assets.search', ['type' => 'video']));
$response->assertOk();
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.type', 'video');
});
test('assets search only returns the current workspace assets', function () {
$this->workspace->addMedia(UploadedFile::fake()->image('mine.jpg'), 'assets');
$otherAccount = Account::factory()->create();
$otherUser = User::factory()->create(['account_id' => $otherAccount->id]);
$otherWorkspace = Workspace::factory()->create([
'account_id' => $otherAccount->id,
'user_id' => $otherUser->id,
]);
$otherWorkspace->addMedia(UploadedFile::fake()->image('theirs.jpg'), 'assets');
$response = $this->actingAs($this->user)
->getJson(route('app.assets.search'));
$response->assertOk();
$response->assertJsonCount(1, 'data');
});
test('can upload an image asset', function () {
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);

View file

@ -243,7 +243,7 @@
'token_type' => 'bearer',
'expires_in' => 5184000,
], 200),
'graph.instagram.com/v24.0/me*' => Http::response(['id' => '123', 'username' => 'test'], 200),
'graph.instagram.com/v25.0/me*' => Http::response(['id' => '123', 'username' => 'test'], 200),
]);
$account = SocialAccount::factory()->instagram()->create([

View file

@ -406,7 +406,7 @@
]);
Http::fake([
'https://graph.facebook.com/v24.0/page_123/photos' => Http::response([
'https://graph.facebook.com/v25.0/page_123/photos' => Http::response([
'id' => 'photo-123',
'post_id' => 'post-123',
], 200),

View file

@ -59,16 +59,16 @@
]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response([
'id' => 'container-123',
], 200),
'https://graph.instagram.com/v24.0/container-123*' => Http::response([
'https://graph.instagram.com/v25.0/container-123*' => Http::response([
'status_code' => 'FINISHED',
], 200),
'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response([
'id' => 'media-123456789',
], 200),
'https://graph.instagram.com/v24.0/media-123456789*' => Http::response([
'https://graph.instagram.com/v25.0/media-123456789*' => Http::response([
'permalink' => 'https://www.instagram.com/p/ABC123/',
], 200),
]);
@ -103,16 +103,16 @@
]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response([
'id' => 'container-123',
], 200),
'https://graph.instagram.com/v24.0/container-123*' => Http::response([
'https://graph.instagram.com/v25.0/container-123*' => Http::response([
'status_code' => 'FINISHED',
], 200),
'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response([
'id' => 'reel-123456789',
], 200),
'https://graph.instagram.com/v24.0/reel-123456789*' => Http::response([
'https://graph.instagram.com/v25.0/reel-123456789*' => Http::response([
'permalink' => 'https://www.instagram.com/reel/ABC123/',
], 200),
]);
@ -146,16 +146,16 @@
]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response([
'id' => 'story-container-123',
], 200),
'https://graph.instagram.com/v24.0/story-container-123*' => Http::response([
'https://graph.instagram.com/v25.0/story-container-123*' => Http::response([
'status_code' => 'FINISHED',
], 200),
'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response([
'id' => 'story-123456789',
], 200),
'https://graph.instagram.com/v24.0/story-123456789*' => Http::response([
'https://graph.instagram.com/v25.0/story-123456789*' => Http::response([
'permalink' => 'https://www.instagram.com/stories/testuser/123/',
], 200),
]);
@ -187,16 +187,16 @@
]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response([
'id' => 'story-container-123',
], 200),
'https://graph.instagram.com/v24.0/story-container-123*' => Http::response([
'https://graph.instagram.com/v25.0/story-container-123*' => Http::response([
'status_code' => 'FINISHED',
], 200),
'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response([
'id' => 'story-video-123456789',
], 200),
'https://graph.instagram.com/v24.0/story-video-123456789*' => Http::response([
'https://graph.instagram.com/v25.0/story-video-123456789*' => Http::response([
'permalink' => 'https://www.instagram.com/stories/testuser/456/',
], 200),
]);
@ -221,18 +221,18 @@
'media' => $mediaItems]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::sequence()
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::sequence()
->push(['id' => 'child-1'], 200)
->push(['id' => 'child-2'], 200)
->push(['id' => 'child-3'], 200)
->push(['id' => 'carousel-container-123'], 200),
'https://graph.instagram.com/v24.0/carousel-container-123*' => Http::response([
'https://graph.instagram.com/v25.0/carousel-container-123*' => Http::response([
'status_code' => 'FINISHED',
], 200),
'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response([
'id' => 'carousel-123456789',
], 200),
'https://graph.instagram.com/v24.0/carousel-123456789*' => Http::response([
'https://graph.instagram.com/v25.0/carousel-123456789*' => Http::response([
'permalink' => 'https://www.instagram.com/p/CAROUSEL123/',
], 200),
]);
@ -264,20 +264,20 @@
]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::sequence()
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::sequence()
->push(['id' => 'child-1'], 200)
->push(['id' => 'child-2'], 200)
->push(['id' => 'carousel-container-123'], 200),
'https://graph.instagram.com/v24.0/child-2*' => Http::response([
'https://graph.instagram.com/v25.0/child-2*' => Http::response([
'status_code' => 'FINISHED',
], 200),
'https://graph.instagram.com/v24.0/carousel-container-123*' => Http::response([
'https://graph.instagram.com/v25.0/carousel-container-123*' => Http::response([
'status_code' => 'FINISHED',
], 200),
'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response([
'id' => 'carousel-mix-123456789',
], 200),
'https://graph.instagram.com/v24.0/carousel-mix-123456789*' => Http::response([
'https://graph.instagram.com/v25.0/carousel-mix-123456789*' => Http::response([
'permalink' => 'https://www.instagram.com/p/CAROUSELMIX/',
], 200),
]);
@ -301,7 +301,7 @@
]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response([
'error' => [
'message' => 'Invalid parameter',
'type' => 'GraphMethodException',
@ -328,7 +328,7 @@
]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response([
'error' => [
'message' => 'Error validating access token',
'type' => 'OAuthException',
@ -355,7 +355,7 @@
]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response([
'error' => [
'message' => 'Session has expired',
'type' => 'OAuthException',
@ -402,7 +402,7 @@
]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response([
'success' => true,
// No id returned
], 200),
@ -426,10 +426,10 @@
]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response([
'id' => 'container-123',
], 200),
'https://graph.instagram.com/v24.0/container-123*' => Http::response([
'https://graph.instagram.com/v25.0/container-123*' => Http::response([
'status_code' => 'ERROR',
], 200),
]);
@ -452,17 +452,17 @@
]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response([
'id' => 'container-123',
], 200),
'https://graph.instagram.com/v24.0/container-123*' => Http::sequence()
'https://graph.instagram.com/v25.0/container-123*' => Http::sequence()
->push(['status_code' => 'IN_PROGRESS'], 200)
->push(['status_code' => 'IN_PROGRESS'], 200)
->push(['status_code' => 'FINISHED'], 200),
'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response([
'id' => 'media-123456789',
], 200),
'https://graph.instagram.com/v24.0/media-123456789*' => Http::response([
'https://graph.instagram.com/v25.0/media-123456789*' => Http::response([
'permalink' => 'https://www.instagram.com/p/ABC123/',
], 200),
]);
@ -487,7 +487,7 @@
'media' => $mediaItems]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response([
'error' => ['message' => 'Upload failed'],
], 400),
]);
@ -511,16 +511,16 @@
]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response([
'id' => 'container-123',
], 200),
'https://graph.instagram.com/v24.0/container-123*' => Http::response([
'https://graph.instagram.com/v25.0/container-123*' => Http::response([
'status_code' => 'FINISHED',
], 200),
'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response([
'id' => 'media-null-content',
], 200),
'https://graph.instagram.com/v24.0/media-null-content*' => Http::response([
'https://graph.instagram.com/v25.0/media-null-content*' => Http::response([
'permalink' => 'https://www.instagram.com/p/NULL123/',
], 200),
]);
@ -547,16 +547,16 @@
]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response([
'id' => 'container-123',
], 200),
'https://graph.instagram.com/v24.0/container-123*' => Http::response([
'https://graph.instagram.com/v25.0/container-123*' => Http::response([
'status_code' => 'FINISHED',
], 200),
'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response([
'id' => 'reel-null-content',
], 200),
'https://graph.instagram.com/v24.0/reel-null-content*' => Http::response([
'https://graph.instagram.com/v25.0/reel-null-content*' => Http::response([
'permalink' => 'https://www.instagram.com/reel/NULL123/',
], 200),
]);
@ -588,17 +588,17 @@
]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::sequence()
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::sequence()
->push(['id' => 'child-1'], 200)
->push(['id' => 'child-2'], 200)
->push(['id' => 'carousel-container-123'], 200),
'https://graph.instagram.com/v24.0/carousel-container-123*' => Http::response([
'https://graph.instagram.com/v25.0/carousel-container-123*' => Http::response([
'status_code' => 'FINISHED',
], 200),
'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response([
'id' => 'carousel-null-content',
], 200),
'https://graph.instagram.com/v24.0/carousel-null-content*' => Http::response([
'https://graph.instagram.com/v25.0/carousel-null-content*' => Http::response([
'permalink' => 'https://www.instagram.com/p/CAROUSELNULL/',
], 200),
]);
@ -623,16 +623,16 @@
]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response([
'id' => 'container-123',
], 200),
'https://graph.instagram.com/v24.0/container-123*' => Http::response([
'https://graph.instagram.com/v25.0/container-123*' => Http::response([
'status_code' => 'FINISHED',
], 200),
'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response([
'id' => 'media-empty-content',
], 200),
'https://graph.instagram.com/v24.0/media-empty-content*' => Http::response([
'https://graph.instagram.com/v25.0/media-empty-content*' => Http::response([
'permalink' => 'https://www.instagram.com/p/EMPTY123/',
], 200),
]);
@ -657,16 +657,16 @@
]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response([
'id' => 'reel-container-999',
], 200),
'https://graph.instagram.com/v24.0/reel-container-999*' => Http::response([
'https://graph.instagram.com/v25.0/reel-container-999*' => Http::response([
'status_code' => 'FINISHED',
], 200),
'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response([
'id' => 'feed-reel-123',
], 200),
'https://graph.instagram.com/v24.0/feed-reel-123*' => Http::response([
'https://graph.instagram.com/v25.0/feed-reel-123*' => Http::response([
'permalink' => 'https://www.instagram.com/reel/FEEDVID/',
], 200),
]);
@ -696,13 +696,13 @@
]);
Http::fake([
'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response([
'id' => 'container-123',
], 200),
'https://graph.instagram.com/v24.0/container-123*' => Http::response([
'https://graph.instagram.com/v25.0/container-123*' => Http::response([
'status_code' => 'FINISHED',
], 200),
'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response([
'error' => [
'message' => 'Publish failed',
'type' => 'GraphMethodException',

View file

@ -254,3 +254,58 @@
$media = new Media(['path' => 'medias/test.mp4', 'mime_type' => 'video/quicktime']);
expect($media->isVideo())->toBeTrue();
});
test('PNG upload is converted to JPEG at upload time', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->image('photo.png', 200, 150);
$media = $workspace->addMedia($file, 'assets');
expect($media->mime_type)->toBe('image/jpeg')
->and($media->path)->toEndWith('.jpg')
->and(pathinfo($media->path, PATHINFO_EXTENSION))->toBe('jpg');
$bytes = Storage::get($media->path);
expect(substr($bytes, 0, 3))->toBe("\xFF\xD8\xFF"); // JPEG SOI marker
});
test('JPEG upload stays as JPEG (no-op)', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->image('photo.jpg', 200, 150);
$media = $workspace->addMedia($file, 'assets');
expect($media->mime_type)->toBe('image/jpeg')
->and(pathinfo($media->path, PATHINFO_EXTENSION))->toBe('jpg');
});
test('GIF upload preserves GIF format for animation', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->image('anim.gif', 200, 150);
$media = $workspace->addMedia($file, 'assets');
expect($media->mime_type)->toBe('image/gif')
->and(pathinfo($media->path, PATHINFO_EXTENSION))->toBe('gif');
});
test('WebP upload is converted to JPEG', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->image('photo.webp', 200, 150);
$media = $workspace->addMedia($file, 'assets');
expect($media->mime_type)->toBe('image/jpeg')
->and(pathinfo($media->path, PATHINFO_EXTENSION))->toBe('jpg');
});
test('client meta is merged into media meta', function () {
$workspace = Workspace::factory()->create();
$file = UploadedFile::fake()->image('photo.jpg', 640, 480);
$media = $workspace->addMedia($file, 'assets', ['duration' => 12.5]);
expect($media->meta)->toHaveKey('duration', 12.5)
->and($media->meta)->toHaveKey('width')
->and($media->meta)->toHaveKey('height');
});