feat: Add Bluesky social media integration

- Add Bluesky platform to Platform and ContentType enums
- Create BlueskyController with custom auth flow (not OAuth)
- Create BlueskyPublisher service for posting via AT Protocol
- Add BlueskyConnect.vue page with handle/app password form
- Add BlueskyPreview.vue component for post preview
- Register Bluesky in PublishToSocialPlatform job
- Update Edit.vue with Bluesky logo and content type options
- Add Bluesky config toggle in trypost.php

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Paulo Castellano 2026-01-18 13:33:54 -03:00
parent adfba2755e
commit b8c4b704ad
12 changed files with 789 additions and 1 deletions

View file

@ -41,6 +41,9 @@ enum ContentType: string
case PinterestVideoPin = 'pinterest_video_pin';
case PinterestCarousel = 'pinterest_carousel';
// Bluesky
case BlueskyPost = 'bluesky_post';
public function label(): string
{
return match ($this) {
@ -59,6 +62,7 @@ public function label(): string
self::PinterestPin => 'Pin',
self::PinterestVideoPin => 'Video Pin',
self::PinterestCarousel => 'Carousel',
self::BlueskyPost => 'Post',
};
}
@ -80,6 +84,7 @@ public function description(): string
self::PinterestPin => 'Standard image pin',
self::PinterestVideoPin => 'Video pin (4s - 15min)',
self::PinterestCarousel => 'Multi-image carousel (2-5 images)',
self::BlueskyPost => 'Text post with optional images',
};
}
@ -95,6 +100,7 @@ public function platform(): SocialPlatform
self::XPost => SocialPlatform::X,
self::ThreadsPost => SocialPlatform::Threads,
self::PinterestPin, self::PinterestVideoPin, self::PinterestCarousel => SocialPlatform::Pinterest,
self::BlueskyPost => SocialPlatform::Bluesky,
};
}
@ -126,6 +132,7 @@ public function maxMediaCount(): int
self::ThreadsPost => 10,
self::PinterestPin, self::PinterestVideoPin => 1,
self::PinterestCarousel => 5,
self::BlueskyPost => 4,
};
}
@ -142,6 +149,7 @@ public function supportsVideo(): bool
self::ThreadsPost => true,
self::PinterestVideoPin => true,
self::PinterestPin, self::PinterestCarousel => false,
self::BlueskyPost => true,
};
}
@ -162,6 +170,7 @@ public function requiresMedia(): bool
self::LinkedInPost, self::LinkedInPagePost => false,
self::XPost => false,
self::ThreadsPost => false,
self::BlueskyPost => false,
default => true,
};
}
@ -194,6 +203,7 @@ public static function defaultFor(SocialPlatform $platform): self
SocialPlatform::X => self::XPost,
SocialPlatform::Threads => self::ThreadsPost,
SocialPlatform::Pinterest => self::PinterestPin,
SocialPlatform::Bluesky => self::BlueskyPost,
};
}
}

View file

@ -15,6 +15,7 @@ enum Platform: string
case Instagram = 'instagram';
case Threads = 'threads';
case Pinterest = 'pinterest';
case Bluesky = 'bluesky';
public function label(): string
{
@ -28,6 +29,7 @@ public function label(): string
self::Instagram => 'Instagram',
self::Threads => 'Threads',
self::Pinterest => 'Pinterest',
self::Bluesky => 'Bluesky',
};
}
@ -42,6 +44,7 @@ public function color(): string
self::Instagram => '#E4405F',
self::Threads => '#000000',
self::Pinterest => '#E60023',
self::Bluesky => '#0085FF',
};
}
@ -56,6 +59,7 @@ public function allowedMediaTypes(): array
self::Instagram => [MediaType::Image, MediaType::Video],
self::Threads => [MediaType::Image, MediaType::Video],
self::Pinterest => [MediaType::Image, MediaType::Video],
self::Bluesky => [MediaType::Image, MediaType::Video],
};
}
@ -70,6 +74,7 @@ public function maxImages(): int
self::Instagram => 10,
self::Threads => 10,
self::Pinterest => 5,
self::Bluesky => 4,
};
}
@ -84,6 +89,7 @@ public function maxContentLength(): int
self::Instagram => 2200,
self::Threads => 500,
self::Pinterest => 800,
self::Bluesky => 300,
};
}
@ -98,6 +104,7 @@ public function supportsTextOnly(): bool
self::Instagram => false,
self::Threads => true,
self::Pinterest => false,
self::Bluesky => true,
};
}

View file

@ -0,0 +1,134 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response;
class BlueskyController extends SocialController
{
protected SocialPlatform $platform = SocialPlatform::Bluesky;
public function connect(Request $request): Response|RedirectResponse
{
$this->ensurePlatformEnabled();
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
return Inertia::render('accounts/BlueskyConnect', [
'errors' => session('errors')?->getBag('default')?->toArray() ?? [],
]);
}
public function store(Request $request): View|RedirectResponse
{
$this->ensurePlatformEnabled();
$request->validate([
'identifier' => 'required|string',
'password' => 'required|string|min:3',
]);
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
$service = 'https://bsky.social';
try {
// Authenticate with Bluesky
$response = Http::post("{$service}/xrpc/com.atproto.server.createSession", [
'identifier' => $request->identifier,
'password' => $request->password,
]);
if ($response->failed()) {
Log::error('Bluesky authentication failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
$errorMessage = 'Invalid credentials';
$body = $response->json();
if (isset($body['message'])) {
$errorMessage = $body['message'];
}
return back()->withErrors(['password' => $errorMessage]);
}
$data = $response->json();
// Get profile
$profileResponse = Http::withToken($data['accessJwt'])
->get("{$service}/xrpc/app.bsky.actor.getProfile", [
'actor' => $data['did'],
]);
$profile = $profileResponse->successful() ? $profileResponse->json() : [];
// Check existing
$existingAccount = $workspace->socialAccounts()
->where('platform', $this->platform->value)
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->withErrors(['identifier' => 'Bluesky is already connected.']);
}
$avatarPath = isset($profile['avatar']) ? uploadFromUrl($profile['avatar']) : null;
$accountData = [
'platform' => $this->platform->value,
'platform_user_id' => $data['did'],
'username' => $data['handle'],
'display_name' => $profile['displayName'] ?? $data['handle'],
'avatar_url' => $avatarPath,
'access_token' => $data['accessJwt'],
'refresh_token' => $data['refreshJwt'],
'token_expires_at' => now()->addHours(2),
'meta' => [
'service' => $service,
'identifier' => $request->identifier,
'password' => encrypt($request->password),
],
];
if ($existingAccount) {
$existingAccount->update($accountData);
$existingAccount->markAsConnected();
return $this->popupCallback(true, 'Bluesky account reconnected!', $this->platform->value);
}
$accountData['status'] = Status::Connected;
$workspace->socialAccounts()->create($accountData);
return $this->popupCallback(true, 'Bluesky account connected!', $this->platform->value);
} catch (\Exception $e) {
Log::error('Bluesky connection error', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return back()->withErrors(['password' => 'Error connecting to Bluesky. Please try again.']);
}
}
}

View file

@ -6,6 +6,7 @@
use App\Events\PostPlatformStatusUpdated;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Services\Social\BlueskyPublisher;
use App\Services\Social\FacebookPublisher;
use App\Services\Social\InstagramPublisher;
use App\Services\Social\LinkedInPagePublisher;
@ -71,7 +72,7 @@ private function broadcastStatus(): void
PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh());
}
private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher
private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher
{
return match ($this->postPlatform->platform) {
SocialPlatform::LinkedIn => app(LinkedInPublisher::class),
@ -83,6 +84,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis
SocialPlatform::Instagram => app(InstagramPublisher::class),
SocialPlatform::Threads => app(ThreadsPublisher::class),
SocialPlatform::Pinterest => app(PinterestPublisher::class),
SocialPlatform::Bluesky => app(BlueskyPublisher::class),
};
}

View file

@ -0,0 +1,327 @@
<?php
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class BlueskyPublisher
{
public function publish(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
$service = $account->meta['service'] ?? 'https://bsky.social';
// Refresh token if needed
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshToken($account);
$account->refresh();
}
$medias = $postPlatform->media;
$embed = null;
// Upload images if present (max 4)
if ($medias->count() > 0) {
$images = [];
foreach ($medias->take(4) as $media) {
if (str_starts_with($media->mime_type, 'image/')) {
$blob = $this->uploadBlob($account, $service, $media->url, $media->mime_type);
if ($blob) {
$images[] = [
'alt' => '',
'image' => $blob,
];
}
}
}
if (count($images) > 0) {
$embed = [
'$type' => 'app.bsky.embed.images',
'images' => $images,
];
}
}
// Parse facets (links, mentions, hashtags) from text
$text = $postPlatform->content ?? '';
$facets = $this->parseFacets($text);
// Create post record
$record = [
'$type' => 'app.bsky.feed.post',
'text' => $text,
'createdAt' => now()->toIso8601ZuluString(),
];
if ($embed) {
$record['embed'] = $embed;
}
if (! empty($facets)) {
$record['facets'] = $facets;
}
Log::info('Bluesky publishing post', [
'user_id' => $account->platform_user_id,
'has_embed' => $embed !== null,
'facet_count' => count($facets),
]);
$response = Http::withToken($account->access_token)
->post("{$service}/xrpc/com.atproto.repo.createRecord", [
'repo' => $account->platform_user_id,
'collection' => 'app.bsky.feed.post',
'record' => $record,
]);
if ($response->failed()) {
Log::error('Bluesky post failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
$this->handleApiError($response);
}
$data = $response->json();
// Extract post ID from URI (at://did/app.bsky.feed.post/xxx)
$uri = $data['uri'];
$postId = basename($uri);
Log::info('Bluesky post created successfully', [
'uri' => $uri,
'post_id' => $postId,
]);
return [
'id' => $postId,
'url' => $this->buildPostUrl($account->username, $postId),
];
}
private function uploadBlob(SocialAccount $account, string $service, string $url, string $mimeType): ?array
{
try {
$imageContent = file_get_contents($url);
if ($imageContent === false) {
Log::error('Bluesky failed to read image', ['url' => $url]);
return null;
}
// Bluesky has 1MB limit for images
if (strlen($imageContent) > 1000000) {
Log::warning('Bluesky image exceeds 1MB limit', [
'size' => strlen($imageContent),
'url' => $url,
]);
// TODO: Resize image if needed
}
$response = Http::withToken($account->access_token)
->withHeaders(['Content-Type' => $mimeType])
->withBody($imageContent, $mimeType)
->post("{$service}/xrpc/com.atproto.repo.uploadBlob");
if ($response->failed()) {
Log::error('Bluesky blob upload failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
return null;
}
return $response->json()['blob'];
} catch (\Exception $e) {
Log::error('Bluesky blob upload exception', [
'error' => $e->getMessage(),
'url' => $url,
]);
return null;
}
}
private function parseFacets(string $text): array
{
$facets = [];
// Parse URLs
preg_match_all(
'/(https?:\/\/[^\s]+)/u',
$text,
$urlMatches,
PREG_OFFSET_CAPTURE
);
foreach ($urlMatches[0] as $match) {
$url = $match[0];
$start = $this->getUtf8ByteOffset($text, $match[1]);
$end = $start + strlen($url);
$facets[] = [
'index' => [
'byteStart' => $start,
'byteEnd' => $end,
],
'features' => [
[
'$type' => 'app.bsky.richtext.facet#link',
'uri' => $url,
],
],
];
}
// Parse mentions (@handle.bsky.social)
preg_match_all(
'/@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/u',
$text,
$mentionMatches,
PREG_OFFSET_CAPTURE
);
foreach ($mentionMatches[0] as $match) {
$mention = $match[0];
$handle = substr($mention, 1); // Remove @
$start = $this->getUtf8ByteOffset($text, $match[1]);
$end = $start + strlen($mention);
$facets[] = [
'index' => [
'byteStart' => $start,
'byteEnd' => $end,
],
'features' => [
[
'$type' => 'app.bsky.richtext.facet#mention',
'did' => $handle, // Will be resolved by Bluesky
],
],
];
}
// Parse hashtags (#tag)
preg_match_all(
'/#[^\s\p{P}]+/u',
$text,
$hashtagMatches,
PREG_OFFSET_CAPTURE
);
foreach ($hashtagMatches[0] as $match) {
$hashtag = $match[0];
$tag = substr($hashtag, 1); // Remove #
$start = $this->getUtf8ByteOffset($text, $match[1]);
$end = $start + strlen($hashtag);
$facets[] = [
'index' => [
'byteStart' => $start,
'byteEnd' => $end,
],
'features' => [
[
'$type' => 'app.bsky.richtext.facet#tag',
'tag' => $tag,
],
],
];
}
return $facets;
}
private function getUtf8ByteOffset(string $text, int $charOffset): int
{
return strlen(substr($text, 0, $charOffset));
}
private function buildPostUrl(string $handle, string $postId): string
{
return "https://bsky.app/profile/{$handle}/post/{$postId}";
}
public function refreshToken(SocialAccount $account): void
{
$service = $account->meta['service'] ?? 'https://bsky.social';
Log::info('Bluesky refreshing token', ['user_id' => $account->platform_user_id]);
// Try refresh first
$response = Http::withToken($account->refresh_token)
->post("{$service}/xrpc/com.atproto.server.refreshSession");
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => $data['accessJwt'],
'refresh_token' => $data['refreshJwt'],
'token_expires_at' => now()->addHours(2),
]);
Log::info('Bluesky token refreshed via refresh token');
return;
}
Log::warning('Bluesky refresh token failed, trying re-authentication', [
'status' => $response->status(),
'body' => $response->body(),
]);
// If refresh fails, re-authenticate with stored credentials
if (isset($account->meta['password'])) {
try {
$password = decrypt($account->meta['password']);
$identifier = $account->meta['identifier'];
$response = Http::post("{$service}/xrpc/com.atproto.server.createSession", [
'identifier' => $identifier,
'password' => $password,
]);
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => $data['accessJwt'],
'refresh_token' => $data['refreshJwt'],
'token_expires_at' => now()->addHours(2),
]);
Log::info('Bluesky token refreshed via re-authentication');
return;
}
} catch (\Exception $e) {
Log::error('Bluesky re-authentication failed', [
'error' => $e->getMessage(),
]);
}
}
throw new TokenExpiredException('Bluesky session expired');
}
private function handleApiError(Response $response): void
{
$body = $response->json() ?? [];
$error = $body['error'] ?? 'Unknown error';
$message = $body['message'] ?? $response->body();
if ($error === 'ExpiredToken' || $error === 'InvalidToken') {
throw new TokenExpiredException("Bluesky: {$message}");
}
throw new \Exception("Bluesky API error: {$message}");
}
}

View file

@ -41,6 +41,9 @@
'pinterest' => [
'enabled' => env('TRYPOST_PINTEREST_ENABLED', true),
],
'bluesky' => [
'enabled' => env('TRYPOST_BLUESKY_ENABLED', true),
],
],
];

View file

@ -0,0 +1,191 @@
<script setup lang="ts">
import {
IconDots,
IconX,
IconPhoto,
IconHeart,
IconMessageCircle,
IconRepeat,
IconShare,
} from '@tabler/icons-vue';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface MediaItem {
id: string;
url: string;
type: string;
original_filename: string;
}
interface Props {
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
charCount: number;
maxLength: number;
isValid: boolean;
validationMessage: string;
isUploading?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:content': [value: string];
'upload': [event: Event];
'remove-media': [mediaId: string];
}>();
</script>
<template>
<div class="bg-white dark:bg-[#161e27] rounded-xl overflow-hidden border border-gray-200 dark:border-[#2e3f50]">
<!-- Post Content -->
<div class="p-4">
<div class="flex gap-3">
<!-- Avatar -->
<div class="shrink-0">
<img
v-if="socialAccount.avatar_url"
:src="socialAccount.avatar_url"
:alt="socialAccount.display_name"
class="h-11 w-11 rounded-full object-cover"
/>
<div v-else class="h-11 w-11 rounded-full bg-[#0085ff] flex items-center justify-center text-white font-bold">
{{ socialAccount.display_name?.charAt(0) }}
</div>
</div>
<!-- Content Area -->
<div class="flex-1 min-w-0">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<span class="font-semibold text-[15px] text-gray-900 dark:text-white">
{{ socialAccount.display_name }}
</span>
<span class="text-gray-500 text-sm">
@{{ socialAccount.username }}
</span>
<span class="text-gray-400 text-sm">· now</span>
</div>
<button class="p-1 hover:bg-gray-100 dark:hover:bg-[#1e2a35] rounded-full">
<IconDots class="h-5 w-5 text-gray-400" />
</button>
</div>
<!-- Post Content -->
<div class="mt-1">
<textarea
:value="content"
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
class="w-full min-h-[60px] bg-transparent border-0 p-0 text-[15px] text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-400"
placeholder="What's up?"
/>
</div>
<!-- Media -->
<div v-if="media.length > 0" class="mt-3">
<div
class="grid gap-1 rounded-lg overflow-hidden"
:class="{
'grid-cols-1': media.length === 1,
'grid-cols-2': media.length === 2,
'grid-cols-2 grid-rows-2': media.length >= 3,
}"
>
<div
v-for="(item, index) in media.slice(0, 4)"
:key="item.id"
class="relative group overflow-hidden"
:class="{
'aspect-video': media.length === 1,
'aspect-square': media.length > 1,
'col-span-2': media.length === 3 && index === 0,
}"
>
<img
v-if="item.type === 'image'"
:src="item.url"
:alt="item.original_filename"
class="w-full h-full object-cover"
/>
<video
v-else
:src="item.url"
class="w-full h-full object-cover bg-black"
muted
loop
playsinline
/>
<button
type="button"
@click="emit('remove-media', item.id)"
class="absolute top-2 right-2 bg-black/70 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
>
<IconX class="h-4 w-4" />
</button>
<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>
<!-- Engagement Actions -->
<div class="flex items-center justify-between mt-3 -ml-2">
<div class="flex items-center gap-1">
<button class="flex items-center gap-1 px-2 py-1.5 hover:bg-[#0085ff]/10 rounded-full group text-gray-500">
<IconMessageCircle class="h-5 w-5 group-hover:text-[#0085ff]" />
<span class="text-sm group-hover:text-[#0085ff]">12</span>
</button>
<button class="flex items-center gap-1 px-2 py-1.5 hover:bg-green-500/10 rounded-full group text-gray-500">
<IconRepeat class="h-5 w-5 group-hover:text-green-500" />
<span class="text-sm group-hover:text-green-500">5</span>
</button>
<button class="flex items-center gap-1 px-2 py-1.5 hover:bg-red-500/10 rounded-full group text-gray-500">
<IconHeart class="h-5 w-5 group-hover:text-red-500" />
<span class="text-sm group-hover:text-red-500">89</span>
</button>
</div>
<button class="p-1.5 hover:bg-[#0085ff]/10 rounded-full group text-gray-500">
<IconShare class="h-5 w-5 group-hover:text-[#0085ff]" />
</button>
</div>
</div>
</div>
</div>
<!-- Footer with upload and char count -->
<div class="border-t border-gray-200 dark:border-[#2e3f50] px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-[#1a2634]">
<div class="flex items-center gap-2">
<span
class="text-xs px-2 py-1 rounded-full"
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
>
{{ validationMessage }}
</span>
</div>
<label class="cursor-pointer p-2 rounded-full hover:bg-gray-200 dark:hover:bg-[#2e3f50] transition-colors">
<input
type="file"
accept="image/*"
multiple
class="hidden"
@change="emit('upload', $event)"
:disabled="isUploading"
/>
<IconPhoto class="h-5 w-5 text-gray-500" />
</label>
</div>
</div>
</template>

View file

@ -8,6 +8,7 @@ import ThreadsPreview from './ThreadsPreview.vue';
import TikTokPreview from './TikTokPreview.vue';
import YouTubePreview from './YouTubePreview.vue';
import PinterestPreview from './PinterestPreview.vue';
import BlueskyPreview from './BlueskyPreview.vue';
interface SocialAccount {
id: string;
@ -75,6 +76,8 @@ const previewComponent = computed(() => {
return YouTubePreview;
case 'pinterest':
return PinterestPreview;
case 'bluesky':
return BlueskyPreview;
default:
return LinkedInPreview;
}

View file

@ -7,3 +7,4 @@ export { default as ThreadsPreview } from './ThreadsPreview.vue';
export { default as TikTokPreview } from './TikTokPreview.vue';
export { default as YouTubePreview } from './YouTubePreview.vue';
export { default as PinterestPreview } from './PinterestPreview.vue';
export { default as BlueskyPreview } from './BlueskyPreview.vue';

View file

@ -0,0 +1,100 @@
<script setup lang="ts">
import { ref } from 'vue';
import { IconInfoCircle } from '@tabler/icons-vue';
import PopupLayout from '@/layouts/PopupLayout.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { store as storeBluesky } from '@/routes/social/bluesky';
interface Props {
errors?: Record<string, string>;
}
const props = defineProps<Props>();
const formRef = ref<HTMLFormElement | null>(null);
const identifier = ref('');
const password = ref('');
const isSubmitting = ref(false);
const submit = () => {
isSubmitting.value = true;
formRef.value?.submit();
};
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
</script>
<template>
<PopupLayout title="Connect Bluesky">
<div class="max-w-md mx-auto">
<div class="flex items-center gap-3 mb-6">
<img src="/images/accounts/bluesky.png" alt="Bluesky" class="h-12 w-12" />
<div>
<h1 class="text-xl font-bold tracking-tight">Connect Bluesky</h1>
<p class="text-sm text-muted-foreground">Enter your credentials to connect</p>
</div>
</div>
<form
ref="formRef"
:action="storeBluesky.url()"
method="POST"
@submit.prevent="submit"
class="space-y-4"
>
<input type="hidden" name="_token" :value="csrfToken" />
<div class="space-y-2">
<Label for="identifier">Handle or Email</Label>
<Input
id="identifier"
name="identifier"
v-model="identifier"
type="text"
placeholder="yourhandle.bsky.social"
:class="{ 'border-destructive': errors?.identifier }"
required
/>
<p v-if="errors?.identifier" class="text-sm text-destructive">
{{ errors.identifier }}
</p>
</div>
<div class="space-y-2">
<Label for="password">App Password</Label>
<Input
id="password"
name="password"
v-model="password"
type="password"
placeholder="xxxx-xxxx-xxxx-xxxx"
:class="{ 'border-destructive': errors?.password }"
required
/>
<p v-if="errors?.password" class="text-sm text-destructive">
{{ errors.password }}
</p>
</div>
<Alert>
<IconInfoCircle class="h-4 w-4" />
<AlertDescription class="inline">
Use an <strong>App Password</strong> for security. Create one at <a href="https://bsky.app/settings/app-passwords" target="_blank" class="underline">bsky.app/settings</a>.
</AlertDescription>
</Alert>
<Button
type="submit"
:disabled="isSubmitting"
class="w-full"
>
{{ isSubmitting ? 'Connecting...' : 'Connect Bluesky' }}
</Button>
</form>
</div>
</PopupLayout>
</template>

View file

@ -190,6 +190,7 @@ const getPlatformLogo = (platform: string): string => {
'instagram': '/images/accounts/instagram.png',
'threads': '/images/accounts/threads.png',
'pinterest': '/images/accounts/pinterest.png',
'bluesky': '/images/accounts/bluesky.png',
};
return logos[platform] || '/images/accounts/default.png';
};
@ -205,6 +206,7 @@ const getPlatformLabel = (platform: string): string => {
'instagram': 'Instagram',
'threads': 'Threads',
'pinterest': 'Pinterest',
'bluesky': 'Bluesky',
};
return labels[platform] || platform;
};
@ -246,6 +248,9 @@ const contentTypeOptions: Record<string, ContentTypeOption[]> = {
{ value: 'pinterest_video_pin', label: 'Video Pin', description: 'Video content' },
{ value: 'pinterest_carousel', label: 'Carousel', description: '2-5 images' },
],
'bluesky': [
{ value: 'bluesky_post', label: 'Post', description: 'Text post with optional images' },
],
};
function getDefaultContentType(platform: string): string {
@ -259,6 +264,7 @@ function getDefaultContentType(platform: string): string {
'x': 'x_post',
'threads': 'threads_post',
'pinterest': 'pinterest_pin',
'bluesky': 'bluesky_post',
};
return defaults[platform] || '';
}

View file

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Auth\BlueskyController;
use App\Http\Controllers\Auth\FacebookController;
use App\Http\Controllers\Auth\InstagramController;
use App\Http\Controllers\Auth\LinkedInController;
@ -89,6 +90,9 @@
Route::get('connect/pinterest', [PinterestController::class, 'connect'])->name('social.pinterest.connect');
Route::get('accounts/pinterest/callback', [PinterestController::class, 'callback'])->name('social.pinterest.callback');
Route::get('connect/bluesky', [BlueskyController::class, 'connect'])->name('social.bluesky.connect');
Route::post('connect/bluesky', [BlueskyController::class, 'store'])->name('social.bluesky.store');
});
// Routes that require active subscription and completed onboarding