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:
parent
adfba2755e
commit
b8c4b704ad
12 changed files with 789 additions and 1 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
134
app/Http/Controllers/Auth/BlueskyController.php
Normal file
134
app/Http/Controllers/Auth/BlueskyController.php
Normal 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.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
327
app/Services/Social/BlueskyPublisher.php
Normal file
327
app/Services/Social/BlueskyPublisher.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,9 @@
|
|||
'pinterest' => [
|
||||
'enabled' => env('TRYPOST_PINTEREST_ENABLED', true),
|
||||
],
|
||||
'bluesky' => [
|
||||
'enabled' => env('TRYPOST_BLUESKY_ENABLED', true),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
191
resources/js/components/posts/previews/BlueskyPreview.vue
Normal file
191
resources/js/components/posts/previews/BlueskyPreview.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
100
resources/js/pages/accounts/BlueskyConnect.vue
Normal file
100
resources/js/pages/accounts/BlueskyConnect.vue
Normal 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>
|
||||
|
|
@ -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] || '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue