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:
Paulo Castellano 2026-01-18 13:59:43 -03:00
parent b8c4b704ad
commit 099cd5d118
11 changed files with 677 additions and 1 deletions

View file

@ -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,
};
}
}

View file

@ -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,
};
}

View 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',
]);
}
}

View file

@ -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),
};
}

View 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}");
}
}

View file

@ -44,6 +44,9 @@
'bluesky' => [
'enabled' => env('TRYPOST_BLUESKY_ENABLED', true),
],
'mastodon' => [
'enabled' => env('TRYPOST_MASTODON_ENABLED', true),
],
],
];

View 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>

View file

@ -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;
}

View 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>

View file

@ -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;
};

View file

@ -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