feat: Add Pinterest integration and user language preferences

Pinterest Integration:
- Add Pinterest OAuth controller and routes
- Add PinterestPublisher service with support for pins, video pins, and carousels
- Add PinterestPreview component with board selector and content type options
- Add Pinterest content types enum (Pin, VideoPin, Carousel)
- Add Pinterest to Platform enum with proper configuration
- Support sandbox mode via PINTEREST_SANDBOX env variable
- Pass platform-specific data (boards) through PlatformPreview

Language Feature:
- Add languages table with migration
- Add Language model and seeder (en-US, pt-BR)
- Add LanguageCombobox component for profile settings
- Set default language (en-US) on user registration
- Add language_id foreign key to users table

UI Improvements:
- Refactor PlatformPreview to support contentTypeOptions, meta, and platformData props
- Move content type and board selectors into platform-specific preview components

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Paulo Castellano 2026-01-18 13:10:01 -03:00
parent 1c798eb927
commit adfba2755e
39 changed files with 1472 additions and 235 deletions

View file

@ -8,7 +8,7 @@ # Laravel Boost Guidelines
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.16
- php - 8.4.17
- inertiajs/inertia-laravel (INERTIA) - v2
- laravel/cashier (CASHIER) - v16
- laravel/fortify (FORTIFY) - v1

View file

@ -8,7 +8,7 @@ # Laravel Boost Guidelines
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.16
- php - 8.4.17
- inertiajs/inertia-laravel (INERTIA) - v2
- laravel/cashier (CASHIER) - v16
- laravel/fortify (FORTIFY) - v1

View file

@ -4,6 +4,7 @@
use App\Concerns\ProfileValidationRules;
use App\Enums\User\Setup;
use App\Models\Language;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
@ -22,16 +23,20 @@ class CreateNewUser implements CreatesNewUsers
public function create(array $input): User
{
Validator::make($input, [
...$this->profileRules(),
'name' => $this->nameRules(),
'email' => $this->emailRules(),
'password' => ['required', 'string', Password::default()],
])->validate();
return DB::transaction(function () use ($input) {
$defaultLanguage = Language::where('code', 'en-US')->first();
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
'setup' => Setup::Role,
'language_id' => $defaultLanguage?->id,
]);
// Create default workspace for new user

View file

@ -2,6 +2,7 @@
namespace App\Concerns;
use App\Models\Language;
use App\Models\User;
use Illuminate\Validation\Rule;
@ -17,9 +18,20 @@ protected function profileRules(string|int|null $userId = null): array
return [
'name' => $this->nameRules(),
'email' => $this->emailRules($userId),
'language_id' => $this->languageRules(),
];
}
/**
* Get the validation rules used to validate user language.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function languageRules(): array
{
return ['required', Rule::exists(Language::class, 'id')];
}
/**
* Get the validation rules used to validate user names.
*

View file

@ -36,6 +36,11 @@ enum ContentType: string
// Threads
case ThreadsPost = 'threads_post';
// Pinterest
case PinterestPin = 'pinterest_pin';
case PinterestVideoPin = 'pinterest_video_pin';
case PinterestCarousel = 'pinterest_carousel';
public function label(): string
{
return match ($this) {
@ -51,6 +56,9 @@ public function label(): string
self::YouTubeShort => 'Short',
self::XPost => 'Post',
self::ThreadsPost => 'Post',
self::PinterestPin => 'Pin',
self::PinterestVideoPin => 'Video Pin',
self::PinterestCarousel => 'Carousel',
};
}
@ -69,6 +77,9 @@ public function description(): string
self::YouTubeShort => 'Vertical video up to 60 seconds',
self::XPost => 'Tweet with text and media',
self::ThreadsPost => 'Text post with optional media',
self::PinterestPin => 'Standard image pin',
self::PinterestVideoPin => 'Video pin (4s - 15min)',
self::PinterestCarousel => 'Multi-image carousel (2-5 images)',
};
}
@ -83,6 +94,7 @@ public function platform(): SocialPlatform
self::YouTubeShort => SocialPlatform::YouTube,
self::XPost => SocialPlatform::X,
self::ThreadsPost => SocialPlatform::Threads,
self::PinterestPin, self::PinterestVideoPin, self::PinterestCarousel => SocialPlatform::Pinterest,
};
}
@ -93,6 +105,8 @@ public function aspectRatio(): ?string
self::InstagramReel, self::InstagramStory => '9:16',
self::FacebookReel, self::FacebookStory => '9:16',
self::TikTokVideo, self::YouTubeShort => '9:16',
self::PinterestPin, self::PinterestCarousel => '2:3',
self::PinterestVideoPin => '9:16',
default => null,
};
}
@ -110,6 +124,8 @@ public function maxMediaCount(): int
self::YouTubeShort => 1,
self::XPost => 4,
self::ThreadsPost => 10,
self::PinterestPin, self::PinterestVideoPin => 1,
self::PinterestCarousel => 5,
};
}
@ -124,6 +140,8 @@ public function supportsVideo(): bool
self::YouTubeShort => true,
self::XPost => true,
self::ThreadsPost => true,
self::PinterestVideoPin => true,
self::PinterestPin, self::PinterestCarousel => false,
};
}
@ -133,6 +151,7 @@ public function supportsImage(): bool
self::InstagramReel => false,
self::TikTokVideo => false,
self::YouTubeShort => false,
self::PinterestVideoPin => false,
default => true,
};
}
@ -174,6 +193,7 @@ public static function defaultFor(SocialPlatform $platform): self
SocialPlatform::YouTube => self::YouTubeShort,
SocialPlatform::X => self::XPost,
SocialPlatform::Threads => self::ThreadsPost,
SocialPlatform::Pinterest => self::PinterestPin,
};
}
}

View file

@ -14,6 +14,7 @@ enum Platform: string
case Facebook = 'facebook';
case Instagram = 'instagram';
case Threads = 'threads';
case Pinterest = 'pinterest';
public function label(): string
{
@ -26,6 +27,7 @@ public function label(): string
self::Facebook => 'Facebook Page',
self::Instagram => 'Instagram',
self::Threads => 'Threads',
self::Pinterest => 'Pinterest',
};
}
@ -39,6 +41,7 @@ public function color(): string
self::Facebook => '#1877F2',
self::Instagram => '#E4405F',
self::Threads => '#000000',
self::Pinterest => '#E60023',
};
}
@ -52,6 +55,7 @@ public function allowedMediaTypes(): array
self::Facebook => [MediaType::Image, MediaType::Video],
self::Instagram => [MediaType::Image, MediaType::Video],
self::Threads => [MediaType::Image, MediaType::Video],
self::Pinterest => [MediaType::Image, MediaType::Video],
};
}
@ -65,6 +69,7 @@ public function maxImages(): int
self::Facebook => 10,
self::Instagram => 10,
self::Threads => 10,
self::Pinterest => 5,
};
}
@ -78,6 +83,7 @@ public function maxContentLength(): int
self::Facebook => 63206,
self::Instagram => 2200,
self::Threads => 500,
self::Pinterest => 800,
};
}
@ -91,6 +97,7 @@ public function supportsTextOnly(): bool
self::Facebook => true,
self::Instagram => false,
self::Threads => true,
self::Pinterest => false,
};
}

View file

@ -0,0 +1,127 @@
<?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\Log;
use Illuminate\View\View;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;
class PinterestController extends SocialController
{
protected string $driver = 'pinterest';
protected SocialPlatform $platform = SocialPlatform::Pinterest;
protected array $scopes = [
'boards:read',
'boards:write',
'pins:read',
'pins:write',
'user_accounts:read',
];
public function connect(Request $request): Response|RedirectResponse
{
$this->ensurePlatformEnabled();
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
$existingAccount = $workspace->socialAccounts()
->where('platform', $this->platform->value)
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->with('error', 'This platform is already connected.');
}
return $this->redirectToProvider($request, $this->driver, $this->scopes);
}
public function callback(Request $request): View
{
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
}
try {
$socialUser = Socialite::driver($this->driver)->user();
$existingAccount = $workspace->socialAccounts()
->where('platform', $this->platform->value)
->first();
// If account exists and is connected, don't allow duplicate
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value);
}
Log::info('Pinterest OAuth User Data', [
'id' => $socialUser->getId(),
'nickname' => $socialUser->getNickname(),
'name' => $socialUser->getName(),
'user' => $socialUser->user ?? [],
]);
$avatarPath = uploadFromUrl($socialUser->getAvatar());
if ($existingAccount) {
// Reconnect existing account
$existingAccount->update([
'platform_user_id' => $socialUser->getId(),
'username' => $socialUser->getNickname(),
'display_name' => $socialUser->getName(),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : now()->addDays(30),
'scopes' => $socialUser->approvedScopes ?? $this->scopes,
]);
$existingAccount->markAsConnected();
return $this->popupCallback(true, 'Pinterest account reconnected!', $this->platform->value);
}
// Create new account
$workspace->socialAccounts()->create([
'platform' => $this->platform->value,
'platform_user_id' => $socialUser->getId(),
'username' => $socialUser->getNickname(),
'display_name' => $socialUser->getName(),
'avatar_url' => $avatarPath,
'access_token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : now()->addDays(30),
'scopes' => $socialUser->approvedScopes ?? $this->scopes,
'status' => Status::Connected,
]);
return $this->popupCallback(true, 'Pinterest account connected!', $this->platform->value);
} catch (\Exception $e) {
Log::error('Pinterest OAuth Error', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
}
}
}

View file

@ -4,10 +4,12 @@
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Jobs\PublishPost;
use App\Models\Post;
use App\Services\Social\PinterestPublisher;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -233,11 +235,23 @@ public function edit(Request $request, Post $post): Response|RedirectResponse
];
});
// Fetch Pinterest boards if Pinterest account exists
$pinterestBoards = [];
$pinterestAccount = $socialAccounts->firstWhere('platform', Platform::Pinterest);
if ($pinterestAccount) {
try {
$pinterestBoards = app(PinterestPublisher::class)->getBoards($pinterestAccount);
} catch (\Exception $e) {
// Silently fail - boards will be empty
}
}
return Inertia::render('posts/Edit', [
'workspace' => $workspace,
'post' => $post,
'socialAccounts' => $socialAccounts,
'platformConfigs' => $platformConfigs,
'pinterestBoards' => $pinterestBoards,
]);
}
@ -282,13 +296,20 @@ public function update(UpdatePostRequest $request, Post $post): RedirectResponse
$post->postPlatforms()->update(['enabled' => false]);
foreach ($request->input('platforms', []) as $platformData) {
$updateData = [
'enabled' => true,
'content' => $platformData['content'],
'content_type' => $platformData['content_type'] ?? null,
];
if (isset($platformData['meta'])) {
$postPlatform = $post->postPlatforms()->where('id', $platformData['id'])->first();
$updateData['meta'] = array_merge($postPlatform->meta ?? [], $platformData['meta']);
}
$post->postPlatforms()
->where('id', $platformData['id'])
->update([
'enabled' => true,
'content' => $platformData['content'],
'content_type' => $platformData['content_type'] ?? null,
]);
->update($updateData);
}
// Dispatch publish job if publishing now

View file

@ -5,6 +5,7 @@
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ProfileDeleteRequest;
use App\Http\Requests\Settings\ProfileUpdateRequest;
use App\Models\Language;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -22,6 +23,7 @@ public function edit(Request $request): Response
return Inertia::render('settings/Profile', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => $request->session()->get('status'),
'languages' => Language::all(),
]);
}

View file

@ -10,6 +10,7 @@
use App\Services\Social\InstagramPublisher;
use App\Services\Social\LinkedInPagePublisher;
use App\Services\Social\LinkedInPublisher;
use App\Services\Social\PinterestPublisher;
use App\Services\Social\ThreadsPublisher;
use App\Services\Social\TikTokPublisher;
use App\Services\Social\XPublisher;
@ -70,7 +71,7 @@ private function broadcastStatus(): void
PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh());
}
private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher
private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher
{
return match ($this->postPlatform->platform) {
SocialPlatform::LinkedIn => app(LinkedInPublisher::class),
@ -81,6 +82,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis
SocialPlatform::Facebook => app(FacebookPublisher::class),
SocialPlatform::Instagram => app(InstagramPublisher::class),
SocialPlatform::Threads => app(ThreadsPublisher::class),
SocialPlatform::Pinterest => app(PinterestPublisher::class),
};
}

22
app/Models/Language.php Normal file
View file

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Language extends Model
{
use HasUuids;
protected $fillable = [
'name',
'code',
];
public function users(): HasMany
{
return $this->hasMany(User::class);
}
}

View file

@ -33,6 +33,7 @@ class User extends Authenticatable implements MustVerifyEmail
'setup',
'persona',
'current_workspace_id',
'language_id',
];
/**
@ -104,6 +105,14 @@ public function currentWorkspace(): BelongsTo
return $this->belongsTo(Workspace::class, 'current_workspace_id');
}
/**
* Get the user's language.
*/
public function language(): BelongsTo
{
return $this->belongsTo(Language::class);
}
/**
* Switch to a different workspace.
*/

View file

@ -19,6 +19,7 @@
use SocialiteProviders\Facebook\FacebookExtendSocialite;
use SocialiteProviders\LinkedIn\LinkedInExtendSocialite;
use SocialiteProviders\Manager\SocialiteWasCalled;
use SocialiteProviders\Pinterest\PinterestExtendSocialite;
use SocialiteProviders\TikTok\TikTokExtendSocialite;
class AppServiceProvider extends ServiceProvider
@ -61,6 +62,7 @@ protected function configureSocialite(): void
Event::listen(SocialiteWasCalled::class, FacebookExtendSocialite::class);
Event::listen(SocialiteWasCalled::class, LinkedInExtendSocialite::class);
Event::listen(SocialiteWasCalled::class, LinkedInPageExtendSocialite::class);
Event::listen(SocialiteWasCalled::class, PinterestExtendSocialite::class);
Event::listen(SocialiteWasCalled::class, TikTokExtendSocialite::class);
}

View file

@ -0,0 +1,418 @@
<?php
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
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 PinterestPublisher
{
private function getApiBase(): string
{
return config('services.pinterest.sandbox', false)
? 'https://api-sandbox.pinterest.com/v5'
: 'https://api.pinterest.com/v5';
}
/**
* Pinterest API error codes that indicate token issues.
*/
private const TOKEN_ERROR_CODES = [
1, // Invalid access token
2, // Access token has expired
];
public function publish(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshToken($account);
$account->refresh();
}
return match ($postPlatform->content_type) {
ContentType::PinterestPin => $this->publishImagePin($postPlatform),
ContentType::PinterestVideoPin => $this->publishVideoPin($postPlatform),
ContentType::PinterestCarousel => $this->publishCarousel($postPlatform),
default => throw new \Exception("Unsupported content type: {$postPlatform->content_type->value}"),
};
}
private function publishImagePin(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
$media = $postPlatform->media->first();
if (! $media) {
throw new \Exception('Pinterest requires at least one image');
}
$boardId = $postPlatform->meta['board_id'] ?? $account->meta['default_board_id'] ?? null;
if (! $boardId) {
throw new \Exception('Pinterest board_id is required');
}
Log::info('Pinterest publishing image pin', [
'user_id' => $account->platform_user_id,
'board_id' => $boardId,
'image_url' => $media->url,
]);
$payload = [
'board_id' => $boardId,
'media_source' => [
'source_type' => 'image_url',
'url' => $media->url,
],
];
if ($postPlatform->content) {
$payload['description'] = $postPlatform->content;
}
if (! empty($postPlatform->meta['title'])) {
$payload['title'] = substr($postPlatform->meta['title'], 0, 100);
}
if (! empty($postPlatform->meta['link'])) {
$payload['link'] = $postPlatform->meta['link'];
}
if (! empty($postPlatform->meta['alt_text'])) {
$payload['alt_text'] = substr($postPlatform->meta['alt_text'], 0, 500);
}
$response = Http::withToken($account->access_token)
->post($this->getApiBase().'/pins', $payload);
if ($response->failed()) {
Log::error('Pinterest pin creation failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
$this->handleApiError($response, 'Pinterest API error');
}
$data = $response->json();
Log::info('Pinterest pin created successfully', ['pin_id' => $data['id']]);
return [
'id' => $data['id'],
'url' => "https://pinterest.com/pin/{$data['id']}",
];
}
private function publishVideoPin(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
$media = $postPlatform->media->first();
if (! $media) {
throw new \Exception('Pinterest requires a video');
}
$boardId = $postPlatform->meta['board_id'] ?? $account->meta['default_board_id'] ?? null;
if (! $boardId) {
throw new \Exception('Pinterest board_id is required');
}
Log::info('Pinterest publishing video pin', [
'user_id' => $account->platform_user_id,
'board_id' => $boardId,
]);
// Step 1: Register media upload
$registerResponse = Http::withToken($account->access_token)
->post($this->getApiBase().'/media', [
'media_type' => 'video',
]);
if ($registerResponse->failed()) {
Log::error('Pinterest media registration failed', [
'status' => $registerResponse->status(),
'body' => $registerResponse->body(),
]);
$this->handleApiError($registerResponse, 'Pinterest media registration error');
}
$registerData = $registerResponse->json();
$mediaId = $registerData['media_id'];
Log::info('Pinterest media registered', ['media_id' => $mediaId]);
// Step 2: Upload video to S3
$uploadParams = $registerData['upload_parameters'] ?? [];
$uploadUrl = $registerData['upload_url'] ?? null;
if (! $uploadUrl) {
throw new \Exception('Pinterest did not return upload URL');
}
// Build multipart form data
$multipart = [];
foreach ($uploadParams as $key => $value) {
$multipart[] = ['name' => $key, 'contents' => $value];
}
// Get video content
$videoContent = file_get_contents($media->url);
if ($videoContent === false) {
throw new \Exception('Failed to read video file');
}
$multipart[] = [
'name' => 'file',
'contents' => $videoContent,
'filename' => basename($media->url),
];
$uploadResponse = Http::asMultipart()
->post($uploadUrl, $multipart);
if ($uploadResponse->failed()) {
Log::error('Pinterest video upload failed', [
'status' => $uploadResponse->status(),
'body' => $uploadResponse->body(),
]);
throw new \Exception('Pinterest video upload failed');
}
Log::info('Pinterest video uploaded');
// Step 3: Wait for processing
$this->waitForMediaProcessing($account, $mediaId);
// Step 4: Create pin with video
$payload = [
'board_id' => $boardId,
'media_source' => [
'source_type' => 'video_id',
'media_id' => $mediaId,
],
];
if ($postPlatform->content) {
$payload['description'] = $postPlatform->content;
}
if (! empty($postPlatform->meta['title'])) {
$payload['title'] = substr($postPlatform->meta['title'], 0, 100);
}
if (! empty($postPlatform->meta['link'])) {
$payload['link'] = $postPlatform->meta['link'];
}
if (! empty($postPlatform->meta['cover_image_url'])) {
$payload['media_source']['cover_image_url'] = $postPlatform->meta['cover_image_url'];
}
$response = Http::withToken($account->access_token)
->post($this->getApiBase().'/pins', $payload);
if ($response->failed()) {
Log::error('Pinterest video pin creation failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
$this->handleApiError($response, 'Pinterest API error');
}
$data = $response->json();
Log::info('Pinterest video pin created successfully', ['pin_id' => $data['id']]);
return [
'id' => $data['id'],
'url' => "https://pinterest.com/pin/{$data['id']}",
];
}
private function publishCarousel(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
$medias = $postPlatform->media;
if ($medias->count() < 2 || $medias->count() > 5) {
throw new \Exception('Pinterest carousel requires 2-5 images');
}
$boardId = $postPlatform->meta['board_id'] ?? $account->meta['default_board_id'] ?? null;
if (! $boardId) {
throw new \Exception('Pinterest board_id is required');
}
Log::info('Pinterest publishing carousel', [
'user_id' => $account->platform_user_id,
'board_id' => $boardId,
'image_count' => $medias->count(),
]);
$items = $medias->map(fn ($media) => [
'url' => $media->url,
])->toArray();
$payload = [
'board_id' => $boardId,
'media_source' => [
'source_type' => 'multiple_image_urls',
'items' => $items,
],
];
if ($postPlatform->content) {
$payload['description'] = $postPlatform->content;
}
if (! empty($postPlatform->meta['title'])) {
$payload['title'] = substr($postPlatform->meta['title'], 0, 100);
}
if (! empty($postPlatform->meta['link'])) {
$payload['link'] = $postPlatform->meta['link'];
}
$response = Http::withToken($account->access_token)
->post($this->getApiBase().'/pins', $payload);
if ($response->failed()) {
Log::error('Pinterest carousel creation failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
$this->handleApiError($response, 'Pinterest API error');
}
$data = $response->json();
Log::info('Pinterest carousel created successfully', ['pin_id' => $data['id']]);
return [
'id' => $data['id'],
'url' => "https://pinterest.com/pin/{$data['id']}",
];
}
private function waitForMediaProcessing(SocialAccount $account, string $mediaId, int $maxAttempts = 30): void
{
for ($i = 0; $i < $maxAttempts; $i++) {
$response = Http::withToken($account->access_token)
->get($this->getApiBase()."/media/{$mediaId}");
if ($response->failed()) {
Log::warning('Pinterest media status check failed', [
'media_id' => $mediaId,
'attempt' => $i,
'body' => $response->body(),
]);
sleep(3);
continue;
}
$data = $response->json();
$status = $data['status'] ?? 'unknown';
Log::info('Pinterest media processing status', [
'media_id' => $mediaId,
'status' => $status,
'attempt' => $i,
]);
if ($status === 'succeeded') {
return;
}
if ($status === 'failed') {
$failureCode = $data['failure_code'] ?? 'unknown';
throw new \Exception("Pinterest media processing failed: {$failureCode}");
}
sleep(3);
}
throw new \Exception("Pinterest media processing timeout after {$maxAttempts} attempts");
}
public function refreshToken(SocialAccount $account): void
{
Log::info('Pinterest refreshing token', ['user_id' => $account->platform_user_id]);
$response = Http::asForm()
->withBasicAuth(
config('services.pinterest.client_id'),
config('services.pinterest.client_secret')
)
->post($this->getApiBase().'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('Pinterest token refresh failed', ['body' => $response->body()]);
$this->handleApiError($response, 'Failed to refresh Pinterest token');
}
$data = $response->json();
$account->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : now()->addDays(30),
]);
Log::info('Pinterest token refreshed successfully');
}
/**
* Get user's boards for board selection.
*/
public function getBoards(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshToken($account);
$account->refresh();
}
$response = Http::withToken($account->access_token)
->get($this->getApiBase().'/boards', [
'page_size' => 100,
]);
if ($response->failed()) {
Log::error('Pinterest get boards failed', ['body' => $response->body()]);
$this->handleApiError($response, 'Pinterest API error');
}
return $response->json()['items'] ?? [];
}
private function handleApiError(Response $response, string $context): void
{
$body = $response->json() ?? [];
$code = $body['code'] ?? $response->status();
$message = $body['message'] ?? $response->body();
$isTokenError = $response->status() === 401
|| in_array($code, self::TOKEN_ERROR_CODES);
if ($isTokenError) {
throw new TokenExpiredException(
"{$context}: {$message}",
is_int($code) ? (string) $code : null
);
}
throw new \Exception("{$context}: {$message}");
}
}

View file

@ -24,6 +24,7 @@
"socialiteproviders/facebook": "^4.1",
"socialiteproviders/instagram": "^5.1",
"socialiteproviders/linkedin": "^5.0",
"socialiteproviders/pinterest": "^4.3",
"socialiteproviders/tiktok": "^5.2",
"socialiteproviders/twitter": "^4.1"
},

52
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "2154f2c0783c4818692dc434812ea32b",
"content-hash": "162908adce6139201ce70a7e1c4db434",
"packages": [
{
"name": "aws/aws-crt-php",
@ -5942,6 +5942,56 @@
},
"time": "2025-02-24T19:33:30+00:00"
},
{
"name": "socialiteproviders/pinterest",
"version": "4.3.0",
"source": {
"type": "git",
"url": "https://github.com/SocialiteProviders/Pinterest.git",
"reference": "7f7e04c78b9988ab350a6a53e8542ebddcd09527"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SocialiteProviders/Pinterest/zipball/7f7e04c78b9988ab350a6a53e8542ebddcd09527",
"reference": "7f7e04c78b9988ab350a6a53e8542ebddcd09527",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^8.0",
"socialiteproviders/manager": "^4.4"
},
"type": "library",
"autoload": {
"psr-4": {
"SocialiteProviders\\Pinterest\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Michael Tournaud",
"email": "ollibrius@gmail.com"
}
],
"description": "Pinterest OAuth2 Provider for Laravel Socialite",
"keywords": [
"laravel",
"oauth",
"pinterest",
"provider",
"socialite"
],
"support": {
"docs": "https://socialiteproviders.com/pinterest",
"issues": "https://github.com/socialiteproviders/providers/issues",
"source": "https://github.com/socialiteproviders/providers"
},
"time": "2023-08-31T07:33:03+00:00"
},
{
"name": "socialiteproviders/tiktok",
"version": "5.2.0",

View file

@ -88,4 +88,12 @@
'redirect' => env('THREADS_CLIENT_REDIRECT'),
],
// Pinterest
'pinterest' => [
'client_id' => env('PINTEREST_CLIENT_ID'),
'client_secret' => env('PINTEREST_CLIENT_SECRET'),
'redirect' => env('PINTEREST_CLIENT_REDIRECT'),
'sandbox' => env('PINTEREST_SANDBOX', false),
],
];

View file

@ -38,6 +38,9 @@
'threads' => [
'enabled' => env('TRYPOST_THREADS_ENABLED', true),
],
'pinterest' => [
'enabled' => env('TRYPOST_PINTEREST_ENABLED', true),
],
],
];

View file

@ -11,6 +11,13 @@
*/
public function up(): void
{
Schema::create('languages', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('name');
$table->string('code')->unique();
$table->timestamps();
});
Schema::create('users', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('name');
@ -28,7 +35,10 @@ public function up(): void
$table->string('setup')->nullable();
$table->string('persona')->nullable();
$table->uuid('current_workspace_id')->nullable();
$table->uuid('language_id')->nullable();
$table->timestamps();
$table->foreign('language_id')->references('id')->on('languages')->nullOnDelete();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
@ -52,8 +62,9 @@ public function up(): void
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('users');
Schema::dropIfExists('languages');
}
};

View file

@ -2,7 +2,6 @@
namespace Database\Seeders;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
@ -13,11 +12,8 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
$this->call([
LanguageSeeder::class,
]);
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace Database\Seeders;
use App\Models\Language;
use Illuminate\Database\Seeder;
class LanguageSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
Language::create(['name' => 'English', 'code' => 'en-US']);
Language::create(['name' => 'Português', 'code' => 'pt-BR']);
}
}

28
package-lock.json generated
View file

@ -7,6 +7,7 @@
"dependencies": {
"@inertiajs/vue3": "^2.3.7",
"@tabler/icons-vue": "^3.36.1",
"@tailwindcss/typography": "^0.5.19",
"@vueuse/core": "^12.8.2",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
@ -1654,6 +1655,31 @@
"node": ">= 10"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
@ -3030,7 +3056,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
@ -7108,7 +7133,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/vaul-vue": {

View file

@ -38,6 +38,7 @@
"dependencies": {
"@inertiajs/vue3": "^2.3.7",
"@tabler/icons-vue": "^3.36.1",
"@tailwindcss/typography": "^0.5.19",
"@vueuse/core": "^12.8.2",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",

View file

@ -7,169 +7,185 @@
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans:
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@layer utilities {
body,
html {
--font-sans:
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
body,
html {
--font-sans:
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(0 0% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(0 0% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(0 0% 3.9%);
--primary: hsl(0 0% 9%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(0 0% 92.1%);
--secondary-foreground: hsl(0 0% 9%);
--muted: hsl(0 0% 96.1%);
--muted-foreground: hsl(0 0% 45.1%);
--accent: hsl(0 0% 96.1%);
--accent-foreground: hsl(0 0% 9%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 92.8%);
--input: hsl(0 0% 89.8%);
--ring: hsl(0 0% 3.9%);
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
--radius: 0.5rem;
--sidebar-background: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(0 0% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(0 0% 94%);
--sidebar-accent-foreground: hsl(0 0% 30%);
--sidebar-border: hsl(0 0% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(0 0% 98%);
--background: #faf9f5;
--foreground: #3d3929;
--card: #faf9f5;
--card-foreground: #141413;
--popover: #ffffff;
--popover-foreground: #28261b;
--primary: #c96442;
--primary-foreground: #ffffff;
--secondary: #e9e6dc;
--secondary-foreground: #535146;
--muted: #ede9de;
--muted-foreground: #83827d;
--accent: #e9e6dc;
--accent-foreground: #28261b;
--destructive: #141413;
--destructive-foreground: #ffffff;
--border: #dad9d4;
--input: #b4b2a7;
--ring: #c96442;
--chart-1: #b05730;
--chart-2: #9c87f5;
--chart-3: #ded8c4;
--chart-4: #dbd3f0;
--chart-5: #b4552d;
--sidebar: #f5f4ee;
--sidebar-foreground: #3d3d3a;
--sidebar-primary: #c96442;
--sidebar-primary-foreground: #fbfbfb;
--sidebar-accent: #e9e6dc;
--sidebar-accent-foreground: #343434;
--sidebar-border: #ebebeb;
--sidebar-ring: #b5b5b5;
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--radius: 0.5rem;
--shadow-x: 0;
--shadow-y: 1px;
--shadow-blur: 3px;
--shadow-spread: 0px;
--shadow-opacity: 0.1;
--shadow-color: oklch(0 0 0);
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: hsl(0 0% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(0 0% 3.9%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(0 0% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(0 0% 9%);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 16.08%);
--muted-foreground: hsl(0 0% 63.9%);
--accent: hsl(0 0% 14.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 84% 60%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 14.9%);
--input: hsl(0 0% 14.9%);
--ring: hsl(0 0% 83.1%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(0 0% 7%);
--sidebar-foreground: hsl(0 0% 95.9%);
--sidebar-primary: hsl(360, 100%, 100%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(0 0% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(0 0% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(240 5.9% 10%);
--background: #262624;
--foreground: #c3c0b6;
--card: #262624;
--card-foreground: #faf9f5;
--popover: #30302e;
--popover-foreground: #e5e5e2;
--primary: #d97757;
--primary-foreground: #ffffff;
--secondary: #faf9f5;
--secondary-foreground: #30302e;
--muted: #1b1b19;
--muted-foreground: #b7b5a9;
--accent: #1a1915;
--accent-foreground: #f5f4ee;
--destructive: #ef4444;
--destructive-foreground: #ffffff;
--border: #3e3e38;
--input: #52514a;
--ring: #d97757;
--chart-1: #b05730;
--chart-2: #9c87f5;
--chart-3: #1a1915;
--chart-4: #2f2b48;
--chart-5: #b4552d;
--sidebar: #1f1e1d;
--sidebar-foreground: #c3c0b6;
--sidebar-primary: #343434;
--sidebar-primary-foreground: #fbfbfb;
--sidebar-accent: #0f0f0e;
--sidebar-accent-foreground: #c3c0b6;
--sidebar-border: #ebebeb;
--sidebar-ring: #b5b5b5;
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--radius: 0.5rem;
--shadow-x: 0;
--shadow-y: 1px;
--shadow-blur: 3px;
--shadow-spread: 0px;
--shadow-opacity: 0.1;
--shadow-color: oklch(0 0 0);
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
body {
@apply bg-background text-foreground;
}
}

View file

@ -77,7 +77,7 @@ const mainNavItems: NavItem[] = [
<template>
<div>
<div class="border-b border-sidebar-border/80">
<div class="border-b border-border">
<div class="mx-auto flex h-16 items-center px-4 md:max-w-7xl">
<!-- Mobile Menu -->
<div v-if="currentWorkspace" class="lg:hidden">
@ -167,7 +167,7 @@ const mainNavItems: NavItem[] = [
</div>
</div>
<div v-if="props.breadcrumbs.length > 1" class="flex w-full border-b border-sidebar-border/70">
<div v-if="props.breadcrumbs.length > 1" class="flex w-full border-b border-border">
<div class="mx-auto flex h-12 w-full items-center justify-start px-4 text-neutral-500 md:max-w-7xl">
<Breadcrumbs :breadcrumbs="breadcrumbs" />
</div>

View file

@ -15,8 +15,7 @@ withDefaults(
<template>
<header
class="flex h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/70 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4"
>
class="flex h-16 shrink-0 items-center gap-2 border-b border-border px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4">
<div class="flex items-center gap-2">
<SidebarTrigger class="-ml-1" />
<template v-if="breadcrumbs && breadcrumbs.length > 0">
@ -24,4 +23,4 @@ withDefaults(
</template>
</div>
</header>
</template>
</template>

View file

@ -0,0 +1,96 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button';
import {
Combobox,
ComboboxAnchor,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxList,
ComboboxTrigger,
} from '@/components/ui/combobox';
import { IconCheck, IconChevronDown, IconSearch } from '@tabler/icons-vue';
import { FocusScope } from 'reka-ui';
import { ref, watchEffect } from 'vue';
interface Language {
id: string;
name: string;
code: string;
}
interface Props {
modelValue?: string | null;
languages: Language[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:modelValue': [value: string | null];
}>();
const selectedLanguage = ref<Language | undefined>();
watchEffect(() => {
selectedLanguage.value = props.languages.find(
(lang) => lang.id === props.modelValue,
);
});
</script>
<template>
<FocusScope as-child>
<Combobox
:model-value="selectedLanguage"
@update:model-value="
(v: Language) => {
selectedLanguage = v;
emit('update:modelValue', v?.id || null);
}
"
>
<ComboboxAnchor as-child>
<ComboboxTrigger as-child>
<Button
variant="outline"
class="w-full justify-between"
>
{{
selectedLanguage
? selectedLanguage.name
: 'Select language'
}}
<IconChevronDown
class="ml-2 h-4 w-4 shrink-0 opacity-50"
/>
</Button>
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxList class="w-full">
<div class="relative">
<ComboboxInput placeholder="Search language..." />
<span
class="absolute inset-y-0 start-0 flex items-center justify-center px-3"
>
<IconSearch class="size-4 text-muted-foreground" />
</span>
</div>
<ComboboxEmpty>No language found</ComboboxEmpty>
<ComboboxGroup>
<ComboboxItem
v-for="lang in languages"
:key="lang.id"
:value="lang"
>
<span class="min-w-0 flex-1 truncate">{{ lang.name }}</span>
<ComboboxItemIndicator>
<IconCheck class="ml-auto h-4 w-4" />
</ComboboxItemIndicator>
</ComboboxItem>
</ComboboxGroup>
</ComboboxList>
</Combobox>
</FocusScope>
</template>

View file

@ -0,0 +1,298 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
IconX,
IconPhoto,
IconShare,
IconUpload,
} from '@tabler/icons-vue';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
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 ContentTypeOption {
value: string;
label: string;
description: string;
}
interface PinterestBoard {
id: string;
name: string;
}
interface Props {
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
contentType?: string;
contentTypeOptions?: ContentTypeOption[];
meta?: Record<string, any>;
platformData?: {
boards?: PinterestBoard[];
};
charCount: number;
maxLength: number;
isValid: boolean;
validationMessage: string;
isUploading?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:content': [value: string];
'update:contentType': [value: string];
'update:meta': [value: Record<string, any>];
'upload': [event: Event];
'remove-media': [mediaId: string];
}>();
const isCarousel = computed(() => props.contentType === 'pinterest_carousel');
const isVideoPin = computed(() => props.contentType === 'pinterest_video_pin');
const boards = computed(() => props.platformData?.boards || []);
const hasMultipleContentTypes = computed(() => (props.contentTypeOptions?.length || 0) > 1);
const updateBoardId = (boardId: string) => {
emit('update:meta', { ...props.meta, board_id: boardId });
};
</script>
<template>
<div class="space-y-4">
<!-- Settings Bar -->
<div v-if="hasMultipleContentTypes || boards.length > 0" class="flex items-center justify-center gap-4 flex-wrap">
<!-- Content Type Selector -->
<div v-if="hasMultipleContentTypes" class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">Type:</span>
<Select
:model-value="contentType"
@update:model-value="emit('update:contentType', $event)"
>
<SelectTrigger class="w-[140px] h-8">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in contentTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Board Selector -->
<div v-if="boards.length > 0" class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">Board:</span>
<Select
:model-value="meta?.board_id || ''"
@update:model-value="updateBoardId"
>
<SelectTrigger class="w-[160px] h-8">
<SelectValue placeholder="Select board" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="board in boards"
:key="board.id"
:value="board.id"
>
{{ board.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- No Boards Warning -->
<div v-else class="text-sm text-amber-600 dark:text-amber-400">
No boards found. Create a board on Pinterest first.
</div>
</div>
<!-- Pinterest Card Preview -->
<div class="bg-white dark:bg-[#1e1e1e] rounded-2xl overflow-hidden border border-gray-200 dark:border-gray-700 max-w-[350px] mx-auto shadow-sm">
<!-- Pin Image/Video Area -->
<div class="relative">
<!-- Media Display -->
<div v-if="media.length > 0" class="relative">
<!-- Single Image or Video -->
<div v-if="!isCarousel || media.length === 1" class="relative">
<img
v-if="media[0].type === 'image'"
:src="media[0].url"
:alt="media[0].original_filename"
class="w-full aspect-[2/3] object-cover"
/>
<video
v-else
:src="media[0].url"
class="w-full aspect-[2/3] object-cover bg-black"
muted
loop
playsinline
/>
<!-- Remove button -->
<button
type="button"
@click="emit('remove-media', media[0].id)"
class="absolute top-3 right-3 bg-black/60 text-white rounded-full p-1.5 opacity-0 hover:opacity-100 transition-opacity"
>
<IconX class="h-4 w-4" />
</button>
<!-- Video indicator -->
<div v-if="media[0].type === 'video'" class="absolute bottom-3 left-3 bg-black/60 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1">
<IconUpload class="h-3 w-3" />
Video
</div>
</div>
<!-- Carousel -->
<div v-else class="relative">
<div class="flex overflow-x-auto snap-x snap-mandatory scrollbar-hide">
<div
v-for="(item, index) in media"
:key="item.id"
class="relative flex-shrink-0 w-full snap-center"
>
<img
:src="item.url"
:alt="item.original_filename"
class="w-full aspect-[2/3] object-cover"
/>
<button
type="button"
@click="emit('remove-media', item.id)"
class="absolute top-3 right-3 bg-black/60 text-white rounded-full p-1.5 opacity-0 hover:opacity-100 transition-opacity"
>
<IconX class="h-4 w-4" />
</button>
</div>
</div>
<!-- Carousel indicator -->
<div class="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-1.5">
<div
v-for="(_, index) in media"
:key="index"
class="w-1.5 h-1.5 rounded-full"
:class="index === 0 ? 'bg-white' : 'bg-white/50'"
/>
</div>
</div>
<!-- Pin Actions Overlay -->
<div class="absolute top-3 left-3 right-3 flex justify-between opacity-0 hover:opacity-100 transition-opacity">
<button class="bg-black/60 text-white rounded-full p-2">
<IconShare class="h-4 w-4" />
</button>
<button class="bg-[#e60023] text-white rounded-full px-4 py-2 font-semibold text-sm hover:bg-[#ad081b] transition-colors">
Save
</button>
</div>
</div>
<!-- Empty State - No Media -->
<div v-else class="aspect-[2/3] bg-gray-100 dark:bg-gray-800 flex flex-col items-center justify-center">
<label class="cursor-pointer flex flex-col items-center gap-3 p-6 text-center">
<div class="p-4 bg-gray-200 dark:bg-gray-700 rounded-full">
<IconPhoto class="h-8 w-8 text-gray-500 dark:text-gray-400" />
</div>
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ isVideoPin ? 'Add a video' : 'Add an image' }}
</p>
<p class="text-xs text-gray-500 mt-1">
{{ isCarousel ? '2-5 images for carousel' : 'Recommended: 2:3 aspect ratio' }}
</p>
</div>
<input
type="file"
:accept="isVideoPin ? 'video/*' : 'image/*'"
:multiple="isCarousel"
class="hidden"
@change="emit('upload', $event)"
:disabled="isUploading"
/>
</label>
</div>
</div>
<!-- Pin Content -->
<div class="p-4">
<!-- Description -->
<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-sm text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-400"
placeholder="Add a description..."
/>
<!-- User Info -->
<div class="flex items-center gap-2 mt-3 pt-3 border-t border-gray-100 dark:border-gray-700">
<img
v-if="socialAccount.avatar_url"
:src="socialAccount.avatar_url"
:alt="socialAccount.display_name"
class="h-8 w-8 rounded-full object-cover"
/>
<div v-else class="h-8 w-8 rounded-full bg-[#e60023] flex items-center justify-center text-white font-bold text-sm">
{{ socialAccount.display_name?.charAt(0) }}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-white truncate">
{{ socialAccount.display_name }}
</p>
<p class="text-xs text-gray-500">
{{ socialAccount.username ? `@${socialAccount.username}` : 'Pinterest' }}
</p>
</div>
</div>
</div>
<!-- Footer with upload and char count -->
<div class="border-t border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-800/50">
<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 v-if="media.length > 0 && (isCarousel && media.length < 5)" class="cursor-pointer p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 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>
</div>
</template>

View file

@ -7,6 +7,7 @@ import InstagramPreview from './InstagramPreview.vue';
import ThreadsPreview from './ThreadsPreview.vue';
import TikTokPreview from './TikTokPreview.vue';
import YouTubePreview from './YouTubePreview.vue';
import PinterestPreview from './PinterestPreview.vue';
interface SocialAccount {
id: string;
@ -23,12 +24,21 @@ interface MediaItem {
original_filename: string;
}
interface ContentTypeOption {
value: string;
label: string;
description: string;
}
interface Props {
platform: string;
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
contentType?: string;
contentTypeOptions?: ContentTypeOption[];
meta?: Record<string, any>;
platformData?: Record<string, any>;
charCount: number;
maxLength: number;
isValid: boolean;
@ -40,6 +50,8 @@ const props = defineProps<Props>();
const emit = defineEmits<{
'update:content': [value: string];
'update:contentType': [value: string];
'update:meta': [value: Record<string, any>];
'upload': [event: Event];
'remove-media': [mediaId: string];
}>();
@ -61,6 +73,8 @@ const previewComponent = computed(() => {
return TikTokPreview;
case 'youtube':
return YouTubePreview;
case 'pinterest':
return PinterestPreview;
default:
return LinkedInPreview;
}
@ -70,6 +84,14 @@ const handleContentUpdate = (value: string) => {
emit('update:content', value);
};
const handleContentTypeUpdate = (value: string) => {
emit('update:contentType', value);
};
const handleMetaUpdate = (value: Record<string, any>) => {
emit('update:meta', value);
};
const handleUpload = (event: Event) => {
emit('upload', event);
};
@ -86,12 +108,17 @@ const handleRemoveMedia = (mediaId: string) => {
:content="content"
:media="media"
:content-type="contentType"
:content-type-options="contentTypeOptions"
:meta="meta"
:platform-data="platformData"
:char-count="charCount"
:max-length="maxLength"
:is-valid="isValid"
:validation-message="validationMessage"
:is-uploading="isUploading"
@update:content="handleContentUpdate"
@update:content-type="handleContentTypeUpdate"
@update:meta="handleMetaUpdate"
@upload="handleUpload"
@remove-media="handleRemoveMedia"
/>

View file

@ -6,3 +6,4 @@ export { default as InstagramPreview } from './InstagramPreview.vue';
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';

View file

@ -20,14 +20,10 @@ const modelValue = useVModel(props, "modelValue", emits, {
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)"
>
</template>
<input v-model="modelValue" data-slot="input" :class="cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)">
</template>

View file

@ -1,2 +1,2 @@
export { default as Input } from "./Input.vue"
export { default as Input } from './Input.vue';
export { default as InputMask } from './InputMask.vue';

View file

@ -3,24 +3,33 @@ import type { LabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Label } from "reka-ui"
import { IconInfoCircle } from "@tabler/icons-vue"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"], required?: boolean, tooltip?: string }>()
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class", "required", "tooltip")
</script>
<template>
<Label
data-slot="label"
v-bind="delegatedProps"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
props.class,
)
"
>
<Label data-slot="label" v-bind="delegatedProps" :class="cn(
'flex items-center gap-0.5 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
props.class,
)
">
<slot />
<span v-if="required" class="text-red-500">*</span>
<TooltipProvider v-if="tooltip">
<Tooltip>
<TooltipTrigger as-child>
<IconInfoCircle class="size-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>{{ tooltip }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
</template>
</template>

View file

@ -61,7 +61,7 @@ const getHashtagCount = (hashtags: string): number => {
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex flex-col gap-6 p-6">
<div class="flex items-center justify-between">
<div v-if="hashtags.length > 0" class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">Hashtags</h1>
<p class="text-muted-foreground">

View file

@ -57,7 +57,7 @@ const handleDelete = (labelId: string) => {
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex flex-col gap-6 p-6">
<div class="flex items-center justify-between">
<div v-if="labels.length > 0" class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">Labels</h1>
<p class="text-muted-foreground">

View file

@ -70,6 +70,12 @@ interface PostPlatform {
status: string;
social_account: SocialAccount;
media: MediaItem[];
meta?: Record<string, any>;
}
interface PinterestBoard {
id: string;
name: string;
}
interface ContentTypeOption {
@ -104,6 +110,7 @@ interface Props {
post: Post;
socialAccounts: SocialAccount[];
platformConfigs: Record<string, PlatformConfig>;
pinterestBoards: PinterestBoard[];
}
const props = defineProps<Props>();
@ -131,6 +138,9 @@ const platformMedia = ref<Record<string, MediaItem[]>>(
const platformContentTypes = ref<Record<string, string>>(
Object.fromEntries(props.post.post_platforms.map(pp => [pp.id, pp.content_type || getDefaultContentType(pp.platform)]))
);
const platformMeta = ref<Record<string, Record<string, any>>>(
Object.fromEntries(props.post.post_platforms.map(pp => [pp.id, pp.meta || {}]))
);
const isUploading = ref<Record<string, boolean>>({});
const isSubmitting = ref(false);
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
@ -179,6 +189,7 @@ const getPlatformLogo = (platform: string): string => {
'facebook': '/images/accounts/facebook.png',
'instagram': '/images/accounts/instagram.png',
'threads': '/images/accounts/threads.png',
'pinterest': '/images/accounts/pinterest.png',
};
return logos[platform] || '/images/accounts/default.png';
};
@ -193,6 +204,7 @@ const getPlatformLabel = (platform: string): string => {
'facebook': 'Facebook Page',
'instagram': 'Instagram',
'threads': 'Threads',
'pinterest': 'Pinterest',
};
return labels[platform] || platform;
};
@ -229,6 +241,11 @@ const contentTypeOptions: Record<string, ContentTypeOption[]> = {
'threads': [
{ value: 'threads_post', label: 'Post', description: 'Text post with optional media' },
],
'pinterest': [
{ value: 'pinterest_pin', label: 'Pin', description: 'Image pin with link' },
{ value: 'pinterest_video_pin', label: 'Video Pin', description: 'Video content' },
{ value: 'pinterest_carousel', label: 'Carousel', description: '2-5 images' },
],
};
function getDefaultContentType(platform: string): string {
@ -241,6 +258,7 @@ function getDefaultContentType(platform: string): string {
'youtube': 'youtube_short',
'x': 'x_post',
'threads': 'threads_post',
'pinterest': 'pinterest_pin',
};
return defaults[platform] || '';
}
@ -253,6 +271,13 @@ function hasMultipleContentTypes(platform: string): boolean {
return (contentTypeOptions[platform]?.length || 0) > 1;
}
function getPlatformData(platform: string): Record<string, any> {
if (platform === 'pinterest') {
return { boards: props.pinterestBoards };
}
return {};
}
const getConfig = (postPlatform: PostPlatform): PlatformConfig => {
return props.platformConfigs[postPlatform.social_account_id] || {
maxContentLength: 5000,
@ -374,8 +399,12 @@ const contentValidation = computed(() => {
const withinLimit = charCount <= config.maxContentLength;
const media = platformMedia.value[pp.id] || [];
const hasMedia = media.length > 0;
const meta = platformMeta.value[pp.id] || {};
if (!config.supportsTextOnly && !hasMedia) {
// Pinterest requires a board
if (pp.platform === 'pinterest' && !meta.board_id) {
results[pp.id] = { valid: false, message: 'Select a board', charCount, maxLength: config.maxContentLength };
} else if (!config.supportsTextOnly && !hasMedia) {
results[pp.id] = { valid: false, message: 'Requires media', charCount, maxLength: config.maxContentLength };
} else if (!hasContent && !hasMedia) {
results[pp.id] = { valid: false, message: 'No content', charCount, maxLength: config.maxContentLength };
@ -562,6 +591,7 @@ const getSubmitData = () => {
id: pp.id,
content: synced.value ? globalContent.value : platformContents.value[pp.id],
content_type: platformContentTypes.value[pp.id],
meta: platformMeta.value[pp.id] || {},
}));
// Combine date and time into ISO format
@ -762,30 +792,6 @@ const deletePost = () => {
</Label>
</div>
<!-- Content Type Selector -->
<div v-if="hasMultipleContentTypes(activePlatform.platform)" class="flex items-center justify-center gap-2 mb-4">
<Label class="text-sm text-muted-foreground">Post as:</Label>
<Select
:model-value="currentContentType"
@update:model-value="setContentType(activePlatform.id, $event)"
>
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in getContentTypeOptions(activePlatform.platform)"
:key="option.value"
:value="option.value"
>
<div class="flex flex-col">
<span>{{ option.label }}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Platform Preview -->
<PlatformPreview
:key="activePlatform.id"
@ -794,12 +800,17 @@ const deletePost = () => {
:content="getContent(activePlatform.id)"
:media="currentPlatformMedia"
:content-type="currentContentType"
:content-type-options="getContentTypeOptions(activePlatform.platform)"
:meta="platformMeta[activePlatform.id]"
:platform-data="getPlatformData(activePlatform.platform)"
:char-count="contentValidation[activePlatform.id]?.charCount || 0"
:max-length="contentValidation[activePlatform.id]?.maxLength || 5000"
:is-valid="contentValidation[activePlatform.id]?.valid ?? false"
:validation-message="contentValidation[activePlatform.id]?.message || ''"
:is-uploading="isUploading[activePlatform.id]"
@update:content="setContent(activePlatform.id, $event)"
@update:content-type="setContentType(activePlatform.id, $event)"
@update:meta="platformMeta[activePlatform.id] = $event"
@upload="handleFileUpload($event, activePlatform.id)"
@remove-media="removeMedia(activePlatform.id, $event)"
/>

View file

@ -5,6 +5,7 @@ import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileCo
import DeleteUser from '@/components/DeleteUser.vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
import InputError from '@/components/InputError.vue';
import LanguageCombobox from '@/components/LanguageCombobox.vue';
import PhotoUpload from '@/components/PhotoUpload.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -14,13 +15,26 @@ import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/profile';
import { send } from '@/routes/verification';
import { type BreadcrumbItem } from '@/types';
import { ref } from 'vue';
interface Language {
id: string;
name: string;
code: string;
}
interface Props {
mustVerifyEmail: boolean;
status?: string;
languages: Language[];
}
defineProps<Props>();
const props = defineProps<Props>();
const page = usePage();
const user = page.props.auth.user;
const languageId = ref(user.language_id);
const breadcrumbItems: BreadcrumbItem[] = [
{
@ -28,9 +42,6 @@ const breadcrumbItems: BreadcrumbItem[] = [
href: edit().url,
},
];
const page = usePage();
const user = page.props.auth.user;
</script>
<template>
@ -54,7 +65,7 @@ const user = page.props.auth.user;
:photo="user.avatar"
collection="avatar"
:reload-only="['auth']"
rounded="full"
rounded="lg"
/>
</div>
@ -92,6 +103,16 @@ const user = page.props.auth.user;
<InputError class="mt-2" :message="errors.email" />
</div>
<div class="grid gap-2">
<Label>Language</Label>
<input type="hidden" name="language_id" :value="languageId" />
<LanguageCombobox
v-model="languageId"
:languages="props.languages"
/>
<InputError class="mt-2" :message="errors.language_id" />
</div>
<div v-if="mustVerifyEmail && !user.email_verified_at">
<p class="-mt-4 text-sm text-muted-foreground">
Your email address is unverified.

View file

@ -31,7 +31,7 @@
}
</style>
<title inertia>{{ config('app.name', 'Laravel') }}</title>
<title inertia>{{ config('app.name', 'TryPost.it') }}</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">

View file

@ -4,6 +4,7 @@
use App\Http\Controllers\Auth\InstagramController;
use App\Http\Controllers\Auth\LinkedInController;
use App\Http\Controllers\Auth\LinkedInPageController;
use App\Http\Controllers\Auth\PinterestController;
use App\Http\Controllers\Auth\SocialController;
use App\Http\Controllers\Auth\ThreadsController;
use App\Http\Controllers\Auth\TikTokController;
@ -85,6 +86,9 @@
Route::get('connect/threads', [ThreadsController::class, 'connect'])->name('social.threads.connect');
Route::get('accounts/threads/callback', [ThreadsController::class, 'callback'])->name('social.threads.callback');
Route::get('connect/pinterest', [PinterestController::class, 'connect'])->name('social.pinterest.connect');
Route::get('accounts/pinterest/callback', [PinterestController::class, 'callback'])->name('social.pinterest.callback');
});
// Routes that require active subscription and completed onboarding