From adfba2755e4c6e44415baf810b1bb43f9f7d56f2 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 18 Jan 2026 13:10:01 -0300 Subject: [PATCH] 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 --- CLAUDE.md | 2 +- GEMINI.md | 2 +- app/Actions/Fortify/CreateNewUser.php | 7 +- app/Concerns/ProfileValidationRules.php | 12 + app/Enums/PostPlatform/ContentType.php | 20 + app/Enums/SocialAccount/Platform.php | 7 + .../Controllers/Auth/PinterestController.php | 127 ++++++ app/Http/Controllers/PostController.php | 31 +- .../Settings/ProfileController.php | 2 + app/Jobs/PublishToSocialPlatform.php | 4 +- app/Models/Language.php | 22 + app/Models/User.php | 9 + app/Providers/AppServiceProvider.php | 2 + app/Services/Social/PinterestPublisher.php | 418 ++++++++++++++++++ composer.json | 1 + composer.lock | 52 ++- config/services.php | 8 + config/trypost.php | 3 + .../0001_01_01_000000_create_users_table.php | 15 +- database/seeders/DatabaseSeeder.php | 8 +- database/seeders/LanguageSeeder.php | 18 + package-lock.json | 28 +- package.json | 1 + resources/css/app.css | 320 +++++++------- resources/js/components/AppHeader.vue | 4 +- resources/js/components/AppSidebarHeader.vue | 5 +- resources/js/components/LanguageCombobox.vue | 96 ++++ .../posts/previews/PinterestPreview.vue | 298 +++++++++++++ .../posts/previews/PlatformPreview.vue | 27 ++ .../js/components/posts/previews/index.ts | 1 + resources/js/components/ui/input/Input.vue | 18 +- resources/js/components/ui/input/index.ts | 2 +- resources/js/components/ui/label/Label.vue | 35 +- resources/js/pages/hashtags/Index.vue | 2 +- resources/js/pages/labels/Index.vue | 2 +- resources/js/pages/posts/Edit.vue | 61 +-- resources/js/pages/settings/Profile.vue | 31 +- resources/views/app.blade.php | 2 +- routes/web.php | 4 + 39 files changed, 1472 insertions(+), 235 deletions(-) create mode 100644 app/Http/Controllers/Auth/PinterestController.php create mode 100644 app/Models/Language.php create mode 100644 app/Services/Social/PinterestPublisher.php create mode 100644 database/seeders/LanguageSeeder.php create mode 100644 resources/js/components/LanguageCombobox.vue create mode 100644 resources/js/components/posts/previews/PinterestPreview.vue diff --git a/CLAUDE.md b/CLAUDE.md index dc73db65..b209a4bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/GEMINI.md b/GEMINI.md index dc73db65..b209a4bc 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -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 diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 8f2f8c95..59982961 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -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 diff --git a/app/Concerns/ProfileValidationRules.php b/app/Concerns/ProfileValidationRules.php index 92878f9c..22ffcaab 100644 --- a/app/Concerns/ProfileValidationRules.php +++ b/app/Concerns/ProfileValidationRules.php @@ -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|string> + */ + protected function languageRules(): array + { + return ['required', Rule::exists(Language::class, 'id')]; + } + /** * Get the validation rules used to validate user names. * diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 63a119ca..18938818 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -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, }; } } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index bede82cf..0f5b9f44 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -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, }; } diff --git a/app/Http/Controllers/Auth/PinterestController.php b/app/Http/Controllers/Auth/PinterestController.php new file mode 100644 index 00000000..9416a148 --- /dev/null +++ b/app/Http/Controllers/Auth/PinterestController.php @@ -0,0 +1,127 @@ +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); + } + } +} diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php index 165c2128..c86afb66 100644 --- a/app/Http/Controllers/PostController.php +++ b/app/Http/Controllers/PostController.php @@ -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 diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php index ab07fb9b..42d23c3e 100644 --- a/app/Http/Controllers/Settings/ProfileController.php +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -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(), ]); } diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 15b9c317..6f0aab5d 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -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), }; } diff --git a/app/Models/Language.php b/app/Models/Language.php new file mode 100644 index 00000000..bebc8ba6 --- /dev/null +++ b/app/Models/Language.php @@ -0,0 +1,22 @@ +hasMany(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index b8864246..8213d3b5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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. */ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d352e14a..56e529d7 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); } diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php new file mode 100644 index 00000000..a8580836 --- /dev/null +++ b/app/Services/Social/PinterestPublisher.php @@ -0,0 +1,418 @@ +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}"); + } +} diff --git a/composer.json b/composer.json index 0e50018e..71e5229e 100644 --- a/composer.json +++ b/composer.json @@ -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" }, diff --git a/composer.lock b/composer.lock index 94e0af28..16b59696 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/services.php b/config/services.php index a838345e..470d7751 100644 --- a/config/services.php +++ b/config/services.php @@ -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), + ], + ]; diff --git a/config/trypost.php b/config/trypost.php index e01db3e0..8aee8d69 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -38,6 +38,9 @@ 'threads' => [ 'enabled' => env('TRYPOST_THREADS_ENABLED', true), ], + 'pinterest' => [ + 'enabled' => env('TRYPOST_PINTEREST_ENABLED', true), + ], ], ]; diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index ec245af1..1e583ee3 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -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'); } }; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef2..563e208b 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -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, ]); } } diff --git a/database/seeders/LanguageSeeder.php b/database/seeders/LanguageSeeder.php new file mode 100644 index 00000000..0db7da70 --- /dev/null +++ b/database/seeders/LanguageSeeder.php @@ -0,0 +1,18 @@ + 'English', 'code' => 'en-US']); + Language::create(['name' => 'Português', 'code' => 'pt-BR']); + } +} diff --git a/package-lock.json b/package-lock.json index 663b0b58..cf9928f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 3048ea6f..47b69a00 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/css/app.css b/resources/css/app.css index 55f9c7d1..1faa81a5 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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; + } } \ No newline at end of file diff --git a/resources/js/components/AppHeader.vue b/resources/js/components/AppHeader.vue index 2562d7a0..b07e5535 100644 --- a/resources/js/components/AppHeader.vue +++ b/resources/js/components/AppHeader.vue @@ -77,7 +77,7 @@ const mainNavItems: NavItem[] = [ \ No newline at end of file diff --git a/resources/js/components/LanguageCombobox.vue b/resources/js/components/LanguageCombobox.vue new file mode 100644 index 00000000..28bc379e --- /dev/null +++ b/resources/js/components/LanguageCombobox.vue @@ -0,0 +1,96 @@ + + + diff --git a/resources/js/components/posts/previews/PinterestPreview.vue b/resources/js/components/posts/previews/PinterestPreview.vue new file mode 100644 index 00000000..f0af0abc --- /dev/null +++ b/resources/js/components/posts/previews/PinterestPreview.vue @@ -0,0 +1,298 @@ + + +