feat: Add Mastodon social media integration
- Add Mastodon to Platform enum with color #6364FF, 500 char limit, 4 max images - Add MastodonPost to ContentType enum - Create MastodonController with dynamic OAuth app registration per instance - Create MastodonPublisher service for posting statuses with media - Create MastodonConnect.vue for instance URL input - Create MastodonPreview.vue with Mastodon-styled post preview - Update PlatformPreview.vue and Edit.vue to support Mastodon - Add Mastodon config toggle in trypost.php Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b8c4b704ad
commit
099cd5d118
11 changed files with 677 additions and 1 deletions
|
|
@ -44,6 +44,9 @@ enum ContentType: string
|
|||
// Bluesky
|
||||
case BlueskyPost = 'bluesky_post';
|
||||
|
||||
// Mastodon
|
||||
case MastodonPost = 'mastodon_post';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
|
|
@ -63,6 +66,7 @@ public function label(): string
|
|||
self::PinterestVideoPin => 'Video Pin',
|
||||
self::PinterestCarousel => 'Carousel',
|
||||
self::BlueskyPost => 'Post',
|
||||
self::MastodonPost => 'Post',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +89,7 @@ public function description(): string
|
|||
self::PinterestVideoPin => 'Video pin (4s - 15min)',
|
||||
self::PinterestCarousel => 'Multi-image carousel (2-5 images)',
|
||||
self::BlueskyPost => 'Text post with optional images',
|
||||
self::MastodonPost => 'Text post with optional media',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +106,7 @@ public function platform(): SocialPlatform
|
|||
self::ThreadsPost => SocialPlatform::Threads,
|
||||
self::PinterestPin, self::PinterestVideoPin, self::PinterestCarousel => SocialPlatform::Pinterest,
|
||||
self::BlueskyPost => SocialPlatform::Bluesky,
|
||||
self::MastodonPost => SocialPlatform::Mastodon,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +139,7 @@ public function maxMediaCount(): int
|
|||
self::PinterestPin, self::PinterestVideoPin => 1,
|
||||
self::PinterestCarousel => 5,
|
||||
self::BlueskyPost => 4,
|
||||
self::MastodonPost => 4,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -150,6 +157,7 @@ public function supportsVideo(): bool
|
|||
self::PinterestVideoPin => true,
|
||||
self::PinterestPin, self::PinterestCarousel => false,
|
||||
self::BlueskyPost => true,
|
||||
self::MastodonPost => true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -171,6 +179,7 @@ public function requiresMedia(): bool
|
|||
self::XPost => false,
|
||||
self::ThreadsPost => false,
|
||||
self::BlueskyPost => false,
|
||||
self::MastodonPost => false,
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
|
@ -204,6 +213,7 @@ public static function defaultFor(SocialPlatform $platform): self
|
|||
SocialPlatform::Threads => self::ThreadsPost,
|
||||
SocialPlatform::Pinterest => self::PinterestPin,
|
||||
SocialPlatform::Bluesky => self::BlueskyPost,
|
||||
SocialPlatform::Mastodon => self::MastodonPost,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ enum Platform: string
|
|||
case Threads = 'threads';
|
||||
case Pinterest = 'pinterest';
|
||||
case Bluesky = 'bluesky';
|
||||
case Mastodon = 'mastodon';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
|
|
@ -30,6 +31,7 @@ public function label(): string
|
|||
self::Threads => 'Threads',
|
||||
self::Pinterest => 'Pinterest',
|
||||
self::Bluesky => 'Bluesky',
|
||||
self::Mastodon => 'Mastodon',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +47,7 @@ public function color(): string
|
|||
self::Threads => '#000000',
|
||||
self::Pinterest => '#E60023',
|
||||
self::Bluesky => '#0085FF',
|
||||
self::Mastodon => '#6364FF',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -60,6 +63,7 @@ public function allowedMediaTypes(): array
|
|||
self::Threads => [MediaType::Image, MediaType::Video],
|
||||
self::Pinterest => [MediaType::Image, MediaType::Video],
|
||||
self::Bluesky => [MediaType::Image, MediaType::Video],
|
||||
self::Mastodon => [MediaType::Image, MediaType::Video],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -75,6 +79,7 @@ public function maxImages(): int
|
|||
self::Threads => 10,
|
||||
self::Pinterest => 5,
|
||||
self::Bluesky => 4,
|
||||
self::Mastodon => 4,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +95,7 @@ public function maxContentLength(): int
|
|||
self::Threads => 500,
|
||||
self::Pinterest => 800,
|
||||
self::Bluesky => 300,
|
||||
self::Mastodon => 500,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +111,7 @@ public function supportsTextOnly(): bool
|
|||
self::Threads => true,
|
||||
self::Pinterest => false,
|
||||
self::Bluesky => true,
|
||||
self::Mastodon => true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
245
app/Http/Controllers/Auth/MastodonController.php
Normal file
245
app/Http/Controllers/Auth/MastodonController.php
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Models\Workspace;
|
||||
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;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
|
||||
class MastodonController extends SocialController
|
||||
{
|
||||
protected SocialPlatform $platform = SocialPlatform::Mastodon;
|
||||
|
||||
private const SCOPES = 'read:accounts write:statuses write:media';
|
||||
|
||||
/**
|
||||
* Show form to enter Mastodon instance URL
|
||||
*/
|
||||
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/MastodonConnect', [
|
||||
'errors' => session('errors')?->getBag('default')?->toArray() ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register app on instance and redirect to OAuth
|
||||
*/
|
||||
public function authorizeInstance(Request $request): SymfonyResponse|RedirectResponse
|
||||
{
|
||||
$this->ensurePlatformEnabled();
|
||||
|
||||
$request->validate([
|
||||
'instance' => 'required|url',
|
||||
]);
|
||||
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if (! $workspace) {
|
||||
return redirect()->route('workspaces.create');
|
||||
}
|
||||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
$instance = rtrim($request->instance, '/');
|
||||
|
||||
try {
|
||||
// Register app on the instance
|
||||
$appResponse = Http::post("{$instance}/api/v1/apps", [
|
||||
'client_name' => config('app.name'),
|
||||
'redirect_uris' => route('social.mastodon.callback'),
|
||||
'scopes' => self::SCOPES,
|
||||
'website' => config('app.url'),
|
||||
]);
|
||||
|
||||
if ($appResponse->failed()) {
|
||||
Log::error('Mastodon app registration failed', [
|
||||
'instance' => $instance,
|
||||
'status' => $appResponse->status(),
|
||||
'body' => $appResponse->body(),
|
||||
]);
|
||||
|
||||
return back()->withErrors(['instance' => 'Could not connect to this Mastodon instance.']);
|
||||
}
|
||||
|
||||
$app = $appResponse->json();
|
||||
|
||||
// Store in session for callback
|
||||
$state = bin2hex(random_bytes(16));
|
||||
session([
|
||||
'mastodon_instance' => $instance,
|
||||
'mastodon_client_id' => $app['client_id'],
|
||||
'mastodon_client_secret' => $app['client_secret'],
|
||||
'mastodon_oauth_state' => $state,
|
||||
'social_connect_workspace' => $workspace->id,
|
||||
]);
|
||||
|
||||
// Redirect to OAuth
|
||||
$params = http_build_query([
|
||||
'client_id' => $app['client_id'],
|
||||
'response_type' => 'code',
|
||||
'redirect_uri' => route('social.mastodon.callback'),
|
||||
'scope' => self::SCOPES,
|
||||
'state' => $state,
|
||||
]);
|
||||
|
||||
return Inertia::location("{$instance}/oauth/authorize?{$params}");
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Mastodon connection error', [
|
||||
'instance' => $instance,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return back()->withErrors(['instance' => 'Error connecting to Mastodon instance.']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback
|
||||
*/
|
||||
public function callback(Request $request): View
|
||||
{
|
||||
$workspaceId = session('social_connect_workspace');
|
||||
$savedState = session('mastodon_oauth_state');
|
||||
$instance = session('mastodon_instance');
|
||||
$clientId = session('mastodon_client_id');
|
||||
$clientSecret = session('mastodon_client_secret');
|
||||
|
||||
if (! $workspaceId || ! $instance) {
|
||||
$this->clearMastodonSession();
|
||||
|
||||
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
|
||||
}
|
||||
|
||||
if ($request->state !== $savedState) {
|
||||
$this->clearMastodonSession();
|
||||
|
||||
return $this->popupCallback(false, 'Invalid state. Please try again.', $this->platform->value);
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
|
||||
$this->clearMastodonSession();
|
||||
|
||||
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange code for token
|
||||
$tokenResponse = Http::asForm()->post("{$instance}/oauth/token", [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
'redirect_uri' => route('social.mastodon.callback'),
|
||||
'code' => $request->code,
|
||||
]);
|
||||
|
||||
if ($tokenResponse->failed()) {
|
||||
Log::error('Mastodon token exchange failed', [
|
||||
'status' => $tokenResponse->status(),
|
||||
'body' => $tokenResponse->body(),
|
||||
]);
|
||||
$this->clearMastodonSession();
|
||||
|
||||
return $this->popupCallback(false, 'Failed to authenticate.', $this->platform->value);
|
||||
}
|
||||
|
||||
$tokenData = $tokenResponse->json();
|
||||
$accessToken = $tokenData['access_token'];
|
||||
|
||||
// Get user profile
|
||||
$profileResponse = Http::withToken($accessToken)
|
||||
->get("{$instance}/api/v1/accounts/verify_credentials");
|
||||
|
||||
if ($profileResponse->failed()) {
|
||||
$this->clearMastodonSession();
|
||||
|
||||
return $this->popupCallback(false, 'Failed to get profile.', $this->platform->value);
|
||||
}
|
||||
|
||||
$profile = $profileResponse->json();
|
||||
|
||||
// Check existing
|
||||
$existingAccount = $workspace->socialAccounts()
|
||||
->where('platform', $this->platform->value)
|
||||
->first();
|
||||
|
||||
if ($existingAccount && ! $existingAccount->isDisconnected()) {
|
||||
$this->clearMastodonSession();
|
||||
|
||||
return $this->popupCallback(false, 'Mastodon is already connected.', $this->platform->value);
|
||||
}
|
||||
|
||||
$avatarPath = isset($profile['avatar']) ? uploadFromUrl($profile['avatar']) : null;
|
||||
|
||||
$accountData = [
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => $profile['id'],
|
||||
'username' => $profile['acct'],
|
||||
'display_name' => $profile['display_name'] ?: $profile['username'],
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => null, // Mastodon tokens don't expire
|
||||
'token_expires_at' => null,
|
||||
'meta' => [
|
||||
'instance' => $instance,
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
],
|
||||
];
|
||||
|
||||
if ($existingAccount) {
|
||||
$existingAccount->update($accountData);
|
||||
$existingAccount->markAsConnected();
|
||||
$this->clearMastodonSession();
|
||||
|
||||
return $this->popupCallback(true, 'Mastodon account reconnected!', $this->platform->value);
|
||||
}
|
||||
|
||||
$accountData['status'] = Status::Connected;
|
||||
$workspace->socialAccounts()->create($accountData);
|
||||
|
||||
$this->clearMastodonSession();
|
||||
|
||||
return $this->popupCallback(true, 'Mastodon account connected!', $this->platform->value);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Mastodon callback error', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
$this->clearMastodonSession();
|
||||
|
||||
return $this->popupCallback(false, 'Error connecting account.', $this->platform->value);
|
||||
}
|
||||
}
|
||||
|
||||
private function clearMastodonSession(): void
|
||||
{
|
||||
session()->forget([
|
||||
'mastodon_instance',
|
||||
'mastodon_client_id',
|
||||
'mastodon_client_secret',
|
||||
'mastodon_oauth_state',
|
||||
'social_connect_workspace',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
use App\Services\Social\InstagramPublisher;
|
||||
use App\Services\Social\LinkedInPagePublisher;
|
||||
use App\Services\Social\LinkedInPublisher;
|
||||
use App\Services\Social\MastodonPublisher;
|
||||
use App\Services\Social\PinterestPublisher;
|
||||
use App\Services\Social\ThreadsPublisher;
|
||||
use App\Services\Social\TikTokPublisher;
|
||||
|
|
@ -72,7 +73,7 @@ private function broadcastStatus(): void
|
|||
PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh());
|
||||
}
|
||||
|
||||
private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher
|
||||
private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher
|
||||
{
|
||||
return match ($this->postPlatform->platform) {
|
||||
SocialPlatform::LinkedIn => app(LinkedInPublisher::class),
|
||||
|
|
@ -85,6 +86,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis
|
|||
SocialPlatform::Threads => app(ThreadsPublisher::class),
|
||||
SocialPlatform::Pinterest => app(PinterestPublisher::class),
|
||||
SocialPlatform::Bluesky => app(BlueskyPublisher::class),
|
||||
SocialPlatform::Mastodon => app(MastodonPublisher::class),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
125
app/Services/Social/MastodonPublisher.php
Normal file
125
app/Services/Social/MastodonPublisher.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?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 MastodonPublisher
|
||||
{
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$account = $postPlatform->socialAccount;
|
||||
$instance = $account->meta['instance'] ?? 'https://mastodon.social';
|
||||
|
||||
$medias = $postPlatform->media;
|
||||
$mediaIds = [];
|
||||
|
||||
// Upload media first (max 4)
|
||||
foreach ($medias->take(4) as $media) {
|
||||
$mediaId = $this->uploadMedia($account, $instance, $media->url, $media->filename);
|
||||
if ($mediaId) {
|
||||
$mediaIds[] = $mediaId;
|
||||
}
|
||||
}
|
||||
|
||||
// Create status
|
||||
$payload = [
|
||||
'status' => $postPlatform->content ?? '',
|
||||
'visibility' => 'public',
|
||||
];
|
||||
|
||||
if (! empty($mediaIds)) {
|
||||
$payload['media_ids'] = $mediaIds;
|
||||
}
|
||||
|
||||
Log::info('Mastodon publishing status', [
|
||||
'instance' => $instance,
|
||||
'user_id' => $account->platform_user_id,
|
||||
'has_media' => count($mediaIds) > 0,
|
||||
]);
|
||||
|
||||
$response = Http::withToken($account->access_token)
|
||||
->post("{$instance}/api/v1/statuses", $payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Mastodon post failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
$this->handleApiError($response);
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
Log::info('Mastodon post created', [
|
||||
'id' => $data['id'],
|
||||
'url' => $data['url'],
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $data['id'],
|
||||
'url' => $data['url'],
|
||||
];
|
||||
}
|
||||
|
||||
private function uploadMedia(SocialAccount $account, string $instance, string $url, ?string $filename): ?string
|
||||
{
|
||||
try {
|
||||
$fileContent = file_get_contents($url);
|
||||
if ($fileContent === false) {
|
||||
Log::error('Mastodon failed to read media', ['url' => $url]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine filename from URL if not provided
|
||||
$name = $filename ?? basename(parse_url($url, PHP_URL_PATH));
|
||||
if (empty($name)) {
|
||||
$name = 'media';
|
||||
}
|
||||
|
||||
$response = Http::withToken($account->access_token)
|
||||
->attach('file', $fileContent, $name)
|
||||
->post("{$instance}/api/v1/media");
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Mastodon media upload failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
Log::info('Mastodon media uploaded', ['id' => $data['id']]);
|
||||
|
||||
return $data['id'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Mastodon media upload error', [
|
||||
'error' => $e->getMessage(),
|
||||
'url' => $url,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function handleApiError(Response $response): void
|
||||
{
|
||||
$body = $response->json() ?? [];
|
||||
$error = $body['error'] ?? $response->body();
|
||||
|
||||
if ($response->status() === 401 || $response->status() === 403) {
|
||||
throw new TokenExpiredException("Mastodon: {$error}");
|
||||
}
|
||||
|
||||
throw new \Exception("Mastodon API error: {$error}");
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,9 @@
|
|||
'bluesky' => [
|
||||
'enabled' => env('TRYPOST_BLUESKY_ENABLED', true),
|
||||
],
|
||||
'mastodon' => [
|
||||
'enabled' => env('TRYPOST_MASTODON_ENABLED', true),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
197
resources/js/components/posts/previews/MastodonPreview.vue
Normal file
197
resources/js/components/posts/previews/MastodonPreview.vue
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
IconDots,
|
||||
IconX,
|
||||
IconPhoto,
|
||||
IconHeart,
|
||||
IconMessageCircle,
|
||||
IconRepeat,
|
||||
IconBookmark,
|
||||
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-[#191b22] rounded-xl overflow-hidden border border-gray-200 dark:border-[#313543]">
|
||||
<!-- 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-lg object-cover"
|
||||
/>
|
||||
<div v-else class="h-11 w-11 rounded-lg bg-[#6364FF] 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 flex-col">
|
||||
<span class="font-semibold text-[15px] text-gray-900 dark:text-white leading-tight">
|
||||
{{ socialAccount.display_name }}
|
||||
</span>
|
||||
<span class="text-gray-500 text-sm">
|
||||
@{{ socialAccount.username }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="p-1 hover:bg-gray-100 dark:hover:bg-[#282c37] rounded">
|
||||
<IconDots class="h-5 w-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Post Content -->
|
||||
<div class="mt-2">
|
||||
<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 is on your mind?"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<div class="mt-3 text-gray-500 text-sm">
|
||||
now
|
||||
</div>
|
||||
|
||||
<!-- Engagement Actions -->
|
||||
<div class="flex items-center justify-between mt-3 pt-3 border-t border-gray-200 dark:border-[#313543] -ml-2 -mr-2">
|
||||
<button class="flex items-center gap-1 px-3 py-1.5 hover:bg-[#6364FF]/10 rounded group text-gray-500">
|
||||
<IconMessageCircle class="h-5 w-5 group-hover:text-[#6364FF]" />
|
||||
<span class="text-sm group-hover:text-[#6364FF]">0</span>
|
||||
</button>
|
||||
<button class="flex items-center gap-1 px-3 py-1.5 hover:bg-green-500/10 rounded group text-gray-500">
|
||||
<IconRepeat class="h-5 w-5 group-hover:text-green-500" />
|
||||
<span class="text-sm group-hover:text-green-500">0</span>
|
||||
</button>
|
||||
<button class="flex items-center gap-1 px-3 py-1.5 hover:bg-yellow-500/10 rounded group text-gray-500">
|
||||
<IconHeart class="h-5 w-5 group-hover:text-yellow-500" />
|
||||
<span class="text-sm group-hover:text-yellow-500">0</span>
|
||||
</button>
|
||||
<button class="px-3 py-1.5 hover:bg-[#6364FF]/10 rounded group text-gray-500">
|
||||
<IconBookmark class="h-5 w-5 group-hover:text-[#6364FF]" />
|
||||
</button>
|
||||
<button class="px-3 py-1.5 hover:bg-[#6364FF]/10 rounded group text-gray-500">
|
||||
<IconShare class="h-5 w-5 group-hover:text-[#6364FF]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with upload and char count -->
|
||||
<div class="border-t border-gray-200 dark:border-[#313543] px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-[#1f2128]">
|
||||
<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-[#313543] transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="emit('upload', $event)"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
<IconPhoto class="h-5 w-5 text-gray-500" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -9,6 +9,7 @@ import TikTokPreview from './TikTokPreview.vue';
|
|||
import YouTubePreview from './YouTubePreview.vue';
|
||||
import PinterestPreview from './PinterestPreview.vue';
|
||||
import BlueskyPreview from './BlueskyPreview.vue';
|
||||
import MastodonPreview from './MastodonPreview.vue';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
@ -78,6 +79,8 @@ const previewComponent = computed(() => {
|
|||
return PinterestPreview;
|
||||
case 'bluesky':
|
||||
return BlueskyPreview;
|
||||
case 'mastodon':
|
||||
return MastodonPreview;
|
||||
default:
|
||||
return LinkedInPreview;
|
||||
}
|
||||
|
|
|
|||
77
resources/js/pages/accounts/MastodonConnect.vue
Normal file
77
resources/js/pages/accounts/MastodonConnect.vue
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<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 { authorize as authorizeMastodon } from '@/routes/social/mastodon';
|
||||
|
||||
interface Props {
|
||||
errors?: Record<string, string>;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const formRef = ref<HTMLFormElement | null>(null);
|
||||
const instance = ref('https://mastodon.social');
|
||||
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 Mastodon">
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<img src="/images/accounts/mastodon.png" alt="Mastodon" class="h-12 w-12" />
|
||||
<div>
|
||||
<h1 class="text-xl font-bold tracking-tight">Connect Mastodon</h1>
|
||||
<p class="text-sm text-muted-foreground">Enter your Mastodon instance</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
ref="formRef"
|
||||
:action="authorizeMastodon.url()"
|
||||
method="POST"
|
||||
@submit.prevent="submit"
|
||||
class="space-y-4"
|
||||
>
|
||||
<input type="hidden" name="_token" :value="csrfToken" />
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="instance">Instance URL</Label>
|
||||
<Input
|
||||
id="instance"
|
||||
name="instance"
|
||||
v-model="instance"
|
||||
type="url"
|
||||
placeholder="https://mastodon.social"
|
||||
:class="{ 'border-destructive': errors?.instance }"
|
||||
required
|
||||
/>
|
||||
<p v-if="errors?.instance" class="text-sm text-destructive">
|
||||
{{ errors.instance }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<IconInfoCircle class="h-4 w-4" />
|
||||
<AlertDescription class="inline">Enter your Mastodon instance URL (e.g., mastodon.social, techhub.social)</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button type="submit" :disabled="isSubmitting" class="w-full">
|
||||
{{ isSubmitting ? 'Connecting...' : 'Continue with Mastodon' }}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</PopupLayout>
|
||||
</template>
|
||||
|
|
@ -191,6 +191,7 @@ const getPlatformLogo = (platform: string): string => {
|
|||
'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';
|
||||
};
|
||||
|
|
@ -207,6 +208,7 @@ const getPlatformLabel = (platform: string): string => {
|
|||
'threads': 'Threads',
|
||||
'pinterest': 'Pinterest',
|
||||
'bluesky': 'Bluesky',
|
||||
'mastodon': 'Mastodon',
|
||||
};
|
||||
return labels[platform] || platform;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Http\Controllers\Auth\InstagramController;
|
||||
use App\Http\Controllers\Auth\LinkedInController;
|
||||
use App\Http\Controllers\Auth\LinkedInPageController;
|
||||
use App\Http\Controllers\Auth\MastodonController;
|
||||
use App\Http\Controllers\Auth\PinterestController;
|
||||
use App\Http\Controllers\Auth\SocialController;
|
||||
use App\Http\Controllers\Auth\ThreadsController;
|
||||
|
|
@ -93,6 +94,10 @@
|
|||
|
||||
Route::get('connect/bluesky', [BlueskyController::class, 'connect'])->name('social.bluesky.connect');
|
||||
Route::post('connect/bluesky', [BlueskyController::class, 'store'])->name('social.bluesky.store');
|
||||
|
||||
Route::get('connect/mastodon', [MastodonController::class, 'connect'])->name('social.mastodon.connect');
|
||||
Route::post('connect/mastodon', [MastodonController::class, 'authorizeInstance'])->name('social.mastodon.authorize');
|
||||
Route::get('accounts/mastodon/callback', [MastodonController::class, 'callback'])->name('social.mastodon.callback');
|
||||
});
|
||||
|
||||
// Routes that require active subscription and completed onboarding
|
||||
|
|
|
|||
Loading…
Reference in a new issue