trypost/resources/js/components/posts/previews/BlueskyPreview.vue
Paulo Castellano 8648ed9720 feat(bluesky): add link preview cards for posts
Bluesky does not hydrate link cards server-side, so build the app.bsky.embed.external embed at publish time: detect the first URL, scrape its OpenGraph metadata, and re-upload the og:image as the card thumb. Works for web, API and MCP. Adds a posts/link-preview endpoint so the editor renders the card live. The thumb download is SSRF-guarded and does not follow redirects.
2026-07-17 14:32:40 -03:00

199 lines
No EOL
9.7 KiB
Vue

<script setup lang="ts">
import { toRef } from 'vue';
import LinkCard from "@/components/posts/previews/LinkCard.vue";
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { useLinkCard } from '@/composables/useLinkCard';
import { isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface Props {
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
}
const props = defineProps<Props>();
const { card: linkCard, loading: linkCardLoading } = useLinkCard(
toRef(props, 'content'),
toRef(props, 'media'),
);
</script>
<template>
<div class="w-full h-full bg-white dark:bg-[#0a1424] text-black dark:text-white overflow-hidden flex flex-col">
<!-- Bluesky Mobile Header -->
<div
class="flex-shrink-0 h-11 flex items-center justify-between px-4 border-b border-neutral-200 dark:border-[#1e3a5f]">
<!-- Back arrow -->
<button class="p-1 -ml-1">
<svg class="h-5 w-5 text-black dark:text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
<!-- Title -->
<span class="text-[16px] font-semibold">Post</span>
<!-- More options -->
<button class="p-1 -mr-1">
<svg class="h-5 w-5 text-black dark:text-white" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="1.5" />
<circle cx="12" cy="6" r="1.5" />
<circle cx="12" cy="18" r="1.5" />
</svg>
</button>
</div>
<!-- Main Content -->
<div class="flex-1 overflow-y-auto">
<!-- Post -->
<div class="px-3 pt-3 pb-2">
<!-- Author row -->
<div class="flex items-start gap-3">
<!-- Avatar -->
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
:alt="socialAccount.display_name" class="h-11 w-11 rounded-full object-cover shrink-0" />
<div v-else
class="h-11 w-11 rounded-full bg-gradient-to-br from-[#0085ff] to-[#00d4ff] flex items-center justify-center text-white font-semibold shrink-0">
{{ socialAccount.display_name?.charAt(0) }}
</div>
<!-- Name and handle -->
<div class="flex-1 min-w-0">
<div class="font-semibold text-[15px] text-black dark:text-white">
{{ socialAccount.display_name }}
</div>
<div class="text-[14px] text-neutral-500 dark:text-[#7b8d9e] truncate">
@{{ socialAccount.username || 'handle' }}.bsky.social
</div>
</div>
</div>
<!-- Post content -->
<div v-if="content"
class="mt-3 text-[16px] text-black dark:text-white leading-[22px] whitespace-pre-wrap">
{{ content }}
</div>
<!-- Media -->
<div v-if="media.length > 0" class="mt-3">
<div class="rounded-lg overflow-hidden" :class="{
'grid grid-cols-2 gap-0.5': media.length >= 2,
}">
<div v-for="(item, index) in media.slice(0, 4)" :key="item.id" class="relative overflow-hidden"
:class="{
'aspect-[4/3]': media.length === 1,
'aspect-square': media.length > 1,
'col-span-2': media.length === 3 && index === 0,
}">
<img v-if="!isVideoMedia(item)" :src="item.url" :alt="item.original_filename"
class="w-full h-full object-cover" />
<VideoPreview v-else :src="item.url" video-class="w-full h-full object-cover bg-black" />
<div v-if="media.length > 4 && index === 3"
class="absolute inset-0 bg-black/60 flex items-center justify-center">
<span class="text-white text-xl font-semibold">+{{ media.length - 4 }}</span>
</div>
</div>
</div>
</div>
<!-- Link preview card -->
<div
v-if="media.length === 0 && linkCardLoading"
class="mt-3 h-24 animate-pulse rounded-xl border border-neutral-200 bg-neutral-100 dark:border-[#1e3a5f] dark:bg-[#0f2138]"
></div>
<LinkCard v-else-if="media.length === 0 && linkCard" :card="linkCard" />
<!-- Timestamp -->
<div class="mt-3 text-[13px] text-neutral-500 dark:text-[#7b8d9e]">
4:18 PM · Jan 21, 2026
</div>
</div>
<!-- Engagement Actions -->
<div class="border-t border-neutral-200 dark:border-[#1e3a5f]">
<div class="flex items-center justify-between px-3 py-1">
<!-- Reply -->
<button class="p-2 hover:bg-neutral-100 dark:hover:bg-[#1e3a5f]/50 rounded-full group">
<svg class="h-5 w-5 text-neutral-500 dark:text-[#7b8d9e] group-hover:text-[#1185fe]"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</button>
<!-- Repost -->
<button class="p-2 hover:bg-neutral-100 dark:hover:bg-[#1e3a5f]/50 rounded-full group">
<svg class="h-5 w-5 text-neutral-500 dark:text-[#7b8d9e] group-hover:text-[#00ba7c]"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M17 1l4 4-4 4" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<path d="M7 23l-4-4 4-4" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
</button>
<!-- Like -->
<button class="p-2 hover:bg-neutral-100 dark:hover:bg-[#1e3a5f]/50 rounded-full group">
<svg class="h-5 w-5 text-neutral-500 dark:text-[#7b8d9e] group-hover:text-[#f91880]"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
</button>
<!-- Bookmark -->
<button class="p-2 hover:bg-neutral-100 dark:hover:bg-[#1e3a5f]/50 rounded-full group">
<svg class="h-5 w-5 text-neutral-500 dark:text-[#7b8d9e] group-hover:text-[#1185fe]"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
<!-- Share -->
<button class="p-2 hover:bg-neutral-100 dark:hover:bg-[#1e3a5f]/50 rounded-full group">
<svg class="h-5 w-5 text-neutral-500 dark:text-[#7b8d9e] group-hover:text-[#1185fe]"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
</svg>
</button>
<!-- More -->
<button class="p-2 hover:bg-neutral-100 dark:hover:bg-[#1e3a5f]/50 rounded-full group">
<svg class="h-5 w-5 text-neutral-500 dark:text-[#7b8d9e] group-hover:text-[#1185fe]"
viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="1.5" />
<circle cx="19" cy="12" r="1.5" />
<circle cx="5" cy="12" r="1.5" />
</svg>
</button>
</div>
</div>
<!-- Reply input -->
<div class="px-4 py-3 border-t border-neutral-200 dark:border-[#1e3a5f] flex items-center gap-3">
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url" :alt="socialAccount.display_name"
class="h-8 w-8 rounded-full object-cover shrink-0" />
<div v-else
class="h-8 w-8 rounded-full bg-gradient-to-br from-[#0085ff] to-[#00d4ff] flex items-center justify-center text-white text-xs font-semibold shrink-0">
{{ socialAccount.display_name?.charAt(0) }}
</div>
<div class="flex-1 text-[15px] text-neutral-400 dark:text-[#7b8d9e]">
Write your reply
</div>
</div>
</div>
</div>
</template>