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:
parent
1c798eb927
commit
adfba2755e
39 changed files with 1472 additions and 235 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
127
app/Http/Controllers/Auth/PinterestController.php
Normal file
127
app/Http/Controllers/Auth/PinterestController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
22
app/Models/Language.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
418
app/Services/Social/PinterestPublisher.php
Normal file
418
app/Services/Social/PinterestPublisher.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
52
composer.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@
|
|||
'threads' => [
|
||||
'enabled' => env('TRYPOST_THREADS_ENABLED', true),
|
||||
],
|
||||
'pinterest' => [
|
||||
'enabled' => env('TRYPOST_PINTEREST_ENABLED', true),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
database/seeders/LanguageSeeder.php
Normal file
18
database/seeders/LanguageSeeder.php
Normal 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
28
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
96
resources/js/components/LanguageCombobox.vue
Normal file
96
resources/js/components/LanguageCombobox.vue
Normal 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>
|
||||
298
resources/js/components/posts/previews/PinterestPreview.vue
Normal file
298
resources/js/components/posts/previews/PinterestPreview.vue
Normal 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>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue