From d538537706e081e94bc7476512ca6a7b09a2cd37 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 17 Jan 2026 12:36:49 -0300 Subject: [PATCH] feat: Implement user onboarding, subscription management, and refactor social integrations with new UI components and mail templates. --- app/Enums/PostPlatform/ContentType.php | 179 ++++ app/Http/Controllers/MediaController.php | 30 + app/Http/Controllers/PostController.php | 22 +- .../WorkspaceHashtagController.php | 135 +++ .../Controllers/WorkspaceLabelController.php | 135 +++ app/Models/Post.php | 2 + app/Models/PostPlatform.php | 3 + app/Models/Workspace.php | 10 + app/Models/WorkspaceHashtag.php | 25 + app/Models/WorkspaceLabel.php | 25 + app/Services/Social/FacebookPublisher.php | 161 ++- app/Services/Social/InstagramPublisher.php | 61 +- app/Services/Social/LinkedInPagePublisher.php | 88 +- app/Services/Social/LinkedInPublisher.php | 82 +- .../factories/WorkspaceHashtagFactory.php | 26 + database/factories/WorkspaceLabelFactory.php | 28 + .../2026_01_14_232318_create_posts_table.php | 1 + ...d_content_type_to_post_platforms_table.php | 28 + ...152319_create_workspace_hashtags_table.php | 30 + ...7_153347_create_workspace_labels_table.php | 30 + package-lock.json | 163 ++- package.json | 5 +- resources/css/app.css | 201 ++-- resources/js/components/AppSidebar.vue | 14 +- .../posts/previews/FacebookPreview.vue | 201 ++++ .../posts/previews/InstagramPreview.vue | 370 +++++++ .../posts/previews/LinkedInPreview.vue | 206 ++++ .../posts/previews/PlatformPreview.vue | 98 ++ .../posts/previews/ThreadsPreview.vue | 191 ++++ .../posts/previews/TikTokPreview.vue | 190 ++++ .../js/components/posts/previews/XPreview.vue | 207 ++++ .../posts/previews/YouTubePreview.vue | 200 ++++ .../js/components/posts/previews/index.ts | 8 + .../ui/aspect-ratio/AspectRatio.vue | 16 + .../js/components/ui/aspect-ratio/index.ts | 1 + .../ui/button-group/ButtonGroup.vue | 22 + .../ui/button-group/ButtonGroupSeparator.vue | 24 + .../ui/button-group/ButtonGroupText.vue | 29 + .../js/components/ui/button-group/index.ts | 25 + .../js/components/ui/carousel/Carousel.vue | 53 + .../ui/carousel/CarouselContent.vue | 33 + .../components/ui/carousel/CarouselItem.vue | 24 + .../components/ui/carousel/CarouselNext.vue | 41 + .../ui/carousel/CarouselPrevious.vue | 41 + resources/js/components/ui/carousel/index.ts | 10 + .../js/components/ui/carousel/interface.ts | 26 + .../js/components/ui/carousel/useCarousel.ts | 56 ++ .../js/components/ui/chart/ChartContainer.vue | 59 ++ .../ui/chart/ChartLegendContent.vue | 60 ++ .../js/components/ui/chart/ChartStyle.vue | 42 + .../ui/chart/ChartTooltipContent.vue | 105 ++ resources/js/components/ui/chart/index.ts | 29 + resources/js/components/ui/chart/utils.ts | 44 + .../js/components/ui/command/Command.vue | 87 ++ .../components/ui/command/CommandDialog.vue | 31 + .../js/components/ui/command/CommandEmpty.vue | 27 + .../js/components/ui/command/CommandGroup.vue | 45 + .../js/components/ui/command/CommandInput.vue | 39 + .../js/components/ui/command/CommandItem.vue | 76 ++ .../js/components/ui/command/CommandList.vue | 25 + .../ui/command/CommandSeparator.vue | 21 + .../components/ui/command/CommandShortcut.vue | 17 + resources/js/components/ui/command/index.ts | 25 + .../ui/context-menu/ContextMenu.vue | 18 + .../context-menu/ContextMenuCheckboxItem.vue | 39 + .../ui/context-menu/ContextMenuContent.vue | 37 + .../ui/context-menu/ContextMenuGroup.vue | 15 + .../ui/context-menu/ContextMenuItem.vue | 38 + .../ui/context-menu/ContextMenuLabel.vue | 22 + .../ui/context-menu/ContextMenuPortal.vue | 15 + .../ui/context-menu/ContextMenuRadioGroup.vue | 21 + .../ui/context-menu/ContextMenuRadioItem.vue | 39 + .../ui/context-menu/ContextMenuSeparator.vue | 21 + .../ui/context-menu/ContextMenuShortcut.vue | 17 + .../ui/context-menu/ContextMenuSub.vue | 21 + .../ui/context-menu/ContextMenuSubContent.vue | 32 + .../ui/context-menu/ContextMenuSubTrigger.vue | 32 + .../ui/context-menu/ContextMenuTrigger.vue | 17 + .../js/components/ui/context-menu/index.ts | 14 + resources/js/components/ui/drawer/Drawer.vue | 23 + .../js/components/ui/drawer/DrawerClose.vue | 15 + .../js/components/ui/drawer/DrawerContent.vue | 38 + .../ui/drawer/DrawerDescription.vue | 21 + .../js/components/ui/drawer/DrawerFooter.vue | 17 + .../js/components/ui/drawer/DrawerHeader.vue | 17 + .../js/components/ui/drawer/DrawerOverlay.vue | 19 + .../js/components/ui/drawer/DrawerTitle.vue | 21 + .../js/components/ui/drawer/DrawerTrigger.vue | 15 + resources/js/components/ui/drawer/index.ts | 9 + resources/js/components/ui/empty/Empty.vue | 20 + .../js/components/ui/empty/EmptyContent.vue | 20 + .../components/ui/empty/EmptyDescription.vue | 20 + .../js/components/ui/empty/EmptyHeader.vue | 20 + .../js/components/ui/empty/EmptyMedia.vue | 21 + .../js/components/ui/empty/EmptyTitle.vue | 17 + resources/js/components/ui/empty/index.ts | 26 + .../js/components/ui/hover-card/HoverCard.vue | 19 + .../ui/hover-card/HoverCardContent.vue | 43 + .../ui/hover-card/HoverCardTrigger.vue | 15 + .../js/components/ui/hover-card/index.ts | 3 + .../components/ui/input-group/InputGroup.vue | 35 + .../ui/input-group/InputGroupAddon.vue | 36 + .../ui/input-group/InputGroupButton.vue | 21 + .../ui/input-group/InputGroupInput.vue | 19 + .../ui/input-group/InputGroupText.vue | 19 + .../ui/input-group/InputGroupTextarea.vue | 19 + .../js/components/ui/input-group/index.ts | 59 ++ resources/js/components/ui/input/index.ts | 2 +- resources/js/components/ui/kbd/Kbd.vue | 21 + resources/js/components/ui/kbd/KbdGroup.vue | 17 + resources/js/components/ui/kbd/index.ts | 2 + .../js/components/ui/progress/Progress.vue | 38 + resources/js/components/ui/progress/index.ts | 1 + .../components/ui/radio-group/RadioGroup.vue | 25 + .../ui/radio-group/RadioGroupItem.vue | 40 + .../js/components/ui/radio-group/index.ts | 2 + .../ui/range-calendar/RangeCalendar.vue | 62 ++ .../ui/range-calendar/RangeCalendarCell.vue | 23 + .../RangeCalendarCellTrigger.vue | 41 + .../ui/range-calendar/RangeCalendarGrid.vue | 23 + .../range-calendar/RangeCalendarGridBody.vue | 15 + .../range-calendar/RangeCalendarGridHead.vue | 15 + .../range-calendar/RangeCalendarGridRow.vue | 22 + .../range-calendar/RangeCalendarHeadCell.vue | 23 + .../ui/range-calendar/RangeCalendarHeader.vue | 23 + .../range-calendar/RangeCalendarHeading.vue | 30 + .../RangeCalendarNextButton.vue | 32 + .../RangeCalendarPrevButton.vue | 32 + .../js/components/ui/range-calendar/index.ts | 12 + .../ui/resizable/ResizableHandle.vue | 30 + .../ui/resizable/ResizablePanel.vue | 21 + .../ui/resizable/ResizablePanelGroup.vue | 25 + resources/js/components/ui/resizable/index.ts | 3 + .../components/ui/scroll-area/ScrollArea.vue | 33 + .../components/ui/scroll-area/ScrollBar.vue | 32 + .../js/components/ui/scroll-area/index.ts | 2 + resources/js/components/ui/sonner/Sonner.vue | 42 + resources/js/components/ui/sonner/index.ts | 1 + resources/js/components/ui/tabs/Tabs.vue | 24 + .../js/components/ui/tabs/TabsContent.vue | 21 + resources/js/components/ui/tabs/TabsList.vue | 24 + .../js/components/ui/tabs/TabsTrigger.vue | 26 + resources/js/components/ui/tabs/index.ts | 4 + .../js/components/ui/tags-input/TagsInput.vue | 26 + .../ui/tags-input/TagsInputInput.vue | 17 + .../ui/tags-input/TagsInputItem.vue | 20 + .../ui/tags-input/TagsInputItemDelete.vue | 22 + .../ui/tags-input/TagsInputItemText.vue | 17 + .../js/components/ui/tags-input/index.ts | 5 + .../js/components/ui/textarea/Textarea.vue | 28 + resources/js/components/ui/textarea/index.ts | 1 + resources/js/pages/hashtags/Create.vue | 100 ++ resources/js/pages/hashtags/Edit.vue | 107 ++ resources/js/pages/hashtags/Index.vue | 127 +++ resources/js/pages/labels/Create.vue | 116 +++ resources/js/pages/labels/Edit.vue | 123 +++ resources/js/pages/labels/Index.vue | 121 +++ resources/js/pages/posts/Edit.vue | 935 ++++++++++++------ routes/web.php | 19 + tests/Feature/Auth/AuthenticationTest.php | 4 +- tests/Feature/Auth/EmailVerificationTest.php | 8 +- tests/Feature/Auth/RegistrationTest.php | 6 +- .../Auth/VerificationNotificationTest.php | 4 +- tests/Feature/DashboardTest.php | 35 - 164 files changed, 7446 insertions(+), 497 deletions(-) create mode 100644 app/Enums/PostPlatform/ContentType.php create mode 100644 app/Http/Controllers/WorkspaceHashtagController.php create mode 100644 app/Http/Controllers/WorkspaceLabelController.php create mode 100644 app/Models/WorkspaceHashtag.php create mode 100644 app/Models/WorkspaceLabel.php create mode 100644 database/factories/WorkspaceHashtagFactory.php create mode 100644 database/factories/WorkspaceLabelFactory.php create mode 100644 database/migrations/2026_01_17_150140_add_content_type_to_post_platforms_table.php create mode 100644 database/migrations/2026_01_17_152319_create_workspace_hashtags_table.php create mode 100644 database/migrations/2026_01_17_153347_create_workspace_labels_table.php create mode 100644 resources/js/components/posts/previews/FacebookPreview.vue create mode 100644 resources/js/components/posts/previews/InstagramPreview.vue create mode 100644 resources/js/components/posts/previews/LinkedInPreview.vue create mode 100644 resources/js/components/posts/previews/PlatformPreview.vue create mode 100644 resources/js/components/posts/previews/ThreadsPreview.vue create mode 100644 resources/js/components/posts/previews/TikTokPreview.vue create mode 100644 resources/js/components/posts/previews/XPreview.vue create mode 100644 resources/js/components/posts/previews/YouTubePreview.vue create mode 100644 resources/js/components/posts/previews/index.ts create mode 100644 resources/js/components/ui/aspect-ratio/AspectRatio.vue create mode 100644 resources/js/components/ui/aspect-ratio/index.ts create mode 100644 resources/js/components/ui/button-group/ButtonGroup.vue create mode 100644 resources/js/components/ui/button-group/ButtonGroupSeparator.vue create mode 100644 resources/js/components/ui/button-group/ButtonGroupText.vue create mode 100644 resources/js/components/ui/button-group/index.ts create mode 100644 resources/js/components/ui/carousel/Carousel.vue create mode 100644 resources/js/components/ui/carousel/CarouselContent.vue create mode 100644 resources/js/components/ui/carousel/CarouselItem.vue create mode 100644 resources/js/components/ui/carousel/CarouselNext.vue create mode 100644 resources/js/components/ui/carousel/CarouselPrevious.vue create mode 100644 resources/js/components/ui/carousel/index.ts create mode 100644 resources/js/components/ui/carousel/interface.ts create mode 100644 resources/js/components/ui/carousel/useCarousel.ts create mode 100644 resources/js/components/ui/chart/ChartContainer.vue create mode 100644 resources/js/components/ui/chart/ChartLegendContent.vue create mode 100644 resources/js/components/ui/chart/ChartStyle.vue create mode 100644 resources/js/components/ui/chart/ChartTooltipContent.vue create mode 100644 resources/js/components/ui/chart/index.ts create mode 100644 resources/js/components/ui/chart/utils.ts create mode 100644 resources/js/components/ui/command/Command.vue create mode 100644 resources/js/components/ui/command/CommandDialog.vue create mode 100644 resources/js/components/ui/command/CommandEmpty.vue create mode 100644 resources/js/components/ui/command/CommandGroup.vue create mode 100644 resources/js/components/ui/command/CommandInput.vue create mode 100644 resources/js/components/ui/command/CommandItem.vue create mode 100644 resources/js/components/ui/command/CommandList.vue create mode 100644 resources/js/components/ui/command/CommandSeparator.vue create mode 100644 resources/js/components/ui/command/CommandShortcut.vue create mode 100644 resources/js/components/ui/command/index.ts create mode 100644 resources/js/components/ui/context-menu/ContextMenu.vue create mode 100644 resources/js/components/ui/context-menu/ContextMenuCheckboxItem.vue create mode 100644 resources/js/components/ui/context-menu/ContextMenuContent.vue create mode 100644 resources/js/components/ui/context-menu/ContextMenuGroup.vue create mode 100644 resources/js/components/ui/context-menu/ContextMenuItem.vue create mode 100644 resources/js/components/ui/context-menu/ContextMenuLabel.vue create mode 100644 resources/js/components/ui/context-menu/ContextMenuPortal.vue create mode 100644 resources/js/components/ui/context-menu/ContextMenuRadioGroup.vue create mode 100644 resources/js/components/ui/context-menu/ContextMenuRadioItem.vue create mode 100644 resources/js/components/ui/context-menu/ContextMenuSeparator.vue create mode 100644 resources/js/components/ui/context-menu/ContextMenuShortcut.vue create mode 100644 resources/js/components/ui/context-menu/ContextMenuSub.vue create mode 100644 resources/js/components/ui/context-menu/ContextMenuSubContent.vue create mode 100644 resources/js/components/ui/context-menu/ContextMenuSubTrigger.vue create mode 100644 resources/js/components/ui/context-menu/ContextMenuTrigger.vue create mode 100644 resources/js/components/ui/context-menu/index.ts create mode 100644 resources/js/components/ui/drawer/Drawer.vue create mode 100644 resources/js/components/ui/drawer/DrawerClose.vue create mode 100644 resources/js/components/ui/drawer/DrawerContent.vue create mode 100644 resources/js/components/ui/drawer/DrawerDescription.vue create mode 100644 resources/js/components/ui/drawer/DrawerFooter.vue create mode 100644 resources/js/components/ui/drawer/DrawerHeader.vue create mode 100644 resources/js/components/ui/drawer/DrawerOverlay.vue create mode 100644 resources/js/components/ui/drawer/DrawerTitle.vue create mode 100644 resources/js/components/ui/drawer/DrawerTrigger.vue create mode 100644 resources/js/components/ui/drawer/index.ts create mode 100644 resources/js/components/ui/empty/Empty.vue create mode 100644 resources/js/components/ui/empty/EmptyContent.vue create mode 100644 resources/js/components/ui/empty/EmptyDescription.vue create mode 100644 resources/js/components/ui/empty/EmptyHeader.vue create mode 100644 resources/js/components/ui/empty/EmptyMedia.vue create mode 100644 resources/js/components/ui/empty/EmptyTitle.vue create mode 100644 resources/js/components/ui/empty/index.ts create mode 100644 resources/js/components/ui/hover-card/HoverCard.vue create mode 100644 resources/js/components/ui/hover-card/HoverCardContent.vue create mode 100644 resources/js/components/ui/hover-card/HoverCardTrigger.vue create mode 100644 resources/js/components/ui/hover-card/index.ts create mode 100644 resources/js/components/ui/input-group/InputGroup.vue create mode 100644 resources/js/components/ui/input-group/InputGroupAddon.vue create mode 100644 resources/js/components/ui/input-group/InputGroupButton.vue create mode 100644 resources/js/components/ui/input-group/InputGroupInput.vue create mode 100644 resources/js/components/ui/input-group/InputGroupText.vue create mode 100644 resources/js/components/ui/input-group/InputGroupTextarea.vue create mode 100644 resources/js/components/ui/input-group/index.ts create mode 100644 resources/js/components/ui/kbd/Kbd.vue create mode 100644 resources/js/components/ui/kbd/KbdGroup.vue create mode 100644 resources/js/components/ui/kbd/index.ts create mode 100644 resources/js/components/ui/progress/Progress.vue create mode 100644 resources/js/components/ui/progress/index.ts create mode 100644 resources/js/components/ui/radio-group/RadioGroup.vue create mode 100644 resources/js/components/ui/radio-group/RadioGroupItem.vue create mode 100644 resources/js/components/ui/radio-group/index.ts create mode 100644 resources/js/components/ui/range-calendar/RangeCalendar.vue create mode 100644 resources/js/components/ui/range-calendar/RangeCalendarCell.vue create mode 100644 resources/js/components/ui/range-calendar/RangeCalendarCellTrigger.vue create mode 100644 resources/js/components/ui/range-calendar/RangeCalendarGrid.vue create mode 100644 resources/js/components/ui/range-calendar/RangeCalendarGridBody.vue create mode 100644 resources/js/components/ui/range-calendar/RangeCalendarGridHead.vue create mode 100644 resources/js/components/ui/range-calendar/RangeCalendarGridRow.vue create mode 100644 resources/js/components/ui/range-calendar/RangeCalendarHeadCell.vue create mode 100644 resources/js/components/ui/range-calendar/RangeCalendarHeader.vue create mode 100644 resources/js/components/ui/range-calendar/RangeCalendarHeading.vue create mode 100644 resources/js/components/ui/range-calendar/RangeCalendarNextButton.vue create mode 100644 resources/js/components/ui/range-calendar/RangeCalendarPrevButton.vue create mode 100644 resources/js/components/ui/range-calendar/index.ts create mode 100644 resources/js/components/ui/resizable/ResizableHandle.vue create mode 100644 resources/js/components/ui/resizable/ResizablePanel.vue create mode 100644 resources/js/components/ui/resizable/ResizablePanelGroup.vue create mode 100644 resources/js/components/ui/resizable/index.ts create mode 100644 resources/js/components/ui/scroll-area/ScrollArea.vue create mode 100644 resources/js/components/ui/scroll-area/ScrollBar.vue create mode 100644 resources/js/components/ui/scroll-area/index.ts create mode 100644 resources/js/components/ui/sonner/Sonner.vue create mode 100644 resources/js/components/ui/sonner/index.ts create mode 100644 resources/js/components/ui/tabs/Tabs.vue create mode 100644 resources/js/components/ui/tabs/TabsContent.vue create mode 100644 resources/js/components/ui/tabs/TabsList.vue create mode 100644 resources/js/components/ui/tabs/TabsTrigger.vue create mode 100644 resources/js/components/ui/tabs/index.ts create mode 100644 resources/js/components/ui/tags-input/TagsInput.vue create mode 100644 resources/js/components/ui/tags-input/TagsInputInput.vue create mode 100644 resources/js/components/ui/tags-input/TagsInputItem.vue create mode 100644 resources/js/components/ui/tags-input/TagsInputItemDelete.vue create mode 100644 resources/js/components/ui/tags-input/TagsInputItemText.vue create mode 100644 resources/js/components/ui/tags-input/index.ts create mode 100644 resources/js/components/ui/textarea/Textarea.vue create mode 100644 resources/js/components/ui/textarea/index.ts create mode 100644 resources/js/pages/hashtags/Create.vue create mode 100644 resources/js/pages/hashtags/Edit.vue create mode 100644 resources/js/pages/hashtags/Index.vue create mode 100644 resources/js/pages/labels/Create.vue create mode 100644 resources/js/pages/labels/Edit.vue create mode 100644 resources/js/pages/labels/Index.vue delete mode 100644 tests/Feature/DashboardTest.php diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php new file mode 100644 index 00000000..50ef5fdc --- /dev/null +++ b/app/Enums/PostPlatform/ContentType.php @@ -0,0 +1,179 @@ + 'Feed Post', + self::InstagramReel => 'Reel', + self::InstagramStory => 'Story', + self::LinkedInPost, self::LinkedInPagePost => 'Post', + self::LinkedInCarousel, self::LinkedInPageCarousel => 'Carousel', + self::FacebookPost => 'Post', + self::FacebookReel => 'Reel', + self::FacebookStory => 'Story', + self::TikTokVideo => 'Video', + self::YouTubeShort => 'Short', + self::XPost => 'Post', + self::ThreadsPost => 'Post', + }; + } + + public function description(): string + { + return match ($this) { + self::InstagramFeed => 'Appears in your feed and profile', + self::InstagramReel => 'Short video up to 90 seconds', + self::InstagramStory => 'Disappears after 24 hours', + self::LinkedInPost, self::LinkedInPagePost => 'Standard post with text and media', + self::LinkedInCarousel, self::LinkedInPageCarousel => 'Swipeable document or images', + self::FacebookPost => 'Standard post on your page', + self::FacebookReel => 'Short video up to 90 seconds', + self::FacebookStory => 'Disappears after 24 hours', + self::TikTokVideo => 'Short-form video content', + self::YouTubeShort => 'Vertical video up to 60 seconds', + self::XPost => 'Tweet with text and media', + self::ThreadsPost => 'Text post with optional media', + }; + } + + public function platform(): SocialPlatform + { + return match ($this) { + self::InstagramFeed, self::InstagramReel, self::InstagramStory => SocialPlatform::Instagram, + self::LinkedInPost, self::LinkedInCarousel => SocialPlatform::LinkedIn, + self::LinkedInPagePost, self::LinkedInPageCarousel => SocialPlatform::LinkedInPage, + self::FacebookPost, self::FacebookReel, self::FacebookStory => SocialPlatform::Facebook, + self::TikTokVideo => SocialPlatform::TikTok, + self::YouTubeShort => SocialPlatform::YouTube, + self::XPost => SocialPlatform::X, + self::ThreadsPost => SocialPlatform::Threads, + }; + } + + public function aspectRatio(): ?string + { + return match ($this) { + self::InstagramFeed => '1:1', + self::InstagramReel, self::InstagramStory => '9:16', + self::FacebookReel, self::FacebookStory => '9:16', + self::TikTokVideo, self::YouTubeShort => '9:16', + default => null, + }; + } + + public function maxMediaCount(): int + { + return match ($this) { + self::InstagramFeed => 10, + self::InstagramReel, self::InstagramStory => 1, + self::LinkedInPost, self::LinkedInPagePost => 1, + self::LinkedInCarousel, self::LinkedInPageCarousel => 20, + self::FacebookPost => 10, + self::FacebookReel, self::FacebookStory => 1, + self::TikTokVideo => 1, + self::YouTubeShort => 1, + self::XPost => 4, + self::ThreadsPost => 10, + }; + } + + public function supportsVideo(): bool + { + return match ($this) { + self::InstagramFeed, self::InstagramReel, self::InstagramStory => true, + self::LinkedInPost, self::LinkedInPagePost => true, + self::LinkedInCarousel, self::LinkedInPageCarousel => false, + self::FacebookPost, self::FacebookReel, self::FacebookStory => true, + self::TikTokVideo => true, + self::YouTubeShort => true, + self::XPost => true, + self::ThreadsPost => true, + }; + } + + public function supportsImage(): bool + { + return match ($this) { + self::InstagramReel => false, + self::TikTokVideo => false, + self::YouTubeShort => false, + default => true, + }; + } + + public function requiresMedia(): bool + { + return match ($this) { + self::LinkedInPost, self::LinkedInPagePost => false, + self::XPost => false, + self::ThreadsPost => false, + default => true, + }; + } + + /** + * Get all content types for a specific platform. + * + * @return array + */ + public static function forPlatform(SocialPlatform $platform): array + { + return array_filter( + self::cases(), + fn (self $type) => $type->platform() === $platform + ); + } + + /** + * Get the default content type for a platform. + */ + public static function defaultFor(SocialPlatform $platform): self + { + return match ($platform) { + SocialPlatform::Instagram => self::InstagramFeed, + SocialPlatform::LinkedIn => self::LinkedInPost, + SocialPlatform::LinkedInPage => self::LinkedInPagePost, + SocialPlatform::Facebook => self::FacebookPost, + SocialPlatform::TikTok => self::TikTokVideo, + SocialPlatform::YouTube => self::YouTubeShort, + SocialPlatform::X => self::XPost, + SocialPlatform::Threads => self::ThreadsPost, + }; + } +} diff --git a/app/Http/Controllers/MediaController.php b/app/Http/Controllers/MediaController.php index 70e18688..05a7e9c2 100644 --- a/app/Http/Controllers/MediaController.php +++ b/app/Http/Controllers/MediaController.php @@ -44,6 +44,36 @@ public function destroy(PostMedia $media): JsonResponse return response()->json(['success' => true]); } + public function duplicate(PostMedia $media): JsonResponse + { + $postPlatformIds = request()->input('post_platform_ids', []); + + $duplicates = []; + + foreach ($postPlatformIds as $postPlatformId) { + $duplicate = PostMedia::create([ + 'post_platform_id' => $postPlatformId, + 'type' => $media->type, + 'path' => $media->path, + 'original_filename' => $media->original_filename, + 'mime_type' => $media->mime_type, + 'size' => $media->size, + 'order' => $media->order, + 'meta' => $media->meta, + ]); + + $duplicates[] = [ + 'id' => $duplicate->id, + 'post_platform_id' => $duplicate->post_platform_id, + 'url' => $duplicate->url, + 'type' => $duplicate->type->value, + 'original_filename' => $duplicate->original_filename, + ]; + } + + return response()->json($duplicates); + } + private function getMediaType(string $mimeType): MediaType { if (str_starts_with($mimeType, 'image/')) { diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php index 238c4481..00039bd8 100644 --- a/app/Http/Controllers/PostController.php +++ b/app/Http/Controllers/PostController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Enums\PostPlatform\ContentType; use App\Enums\PostStatus; use App\Http\Requests\StorePostRequest; use App\Http\Requests\UpdatePostRequest; @@ -100,6 +101,7 @@ public function create(Request $request): RedirectResponse $post = $workspace->posts()->create([ 'user_id' => $request->user()->id, 'status' => PostStatus::Draft, + 'synced' => true, 'scheduled_at' => $scheduledAt, ]); @@ -109,7 +111,9 @@ public function create(Request $request): RedirectResponse 'social_account_id' => $account->id, 'platform' => $account->platform->value, 'content' => '', + 'content_type' => ContentType::defaultFor($account->platform), 'status' => 'pending', + 'enabled' => true, ]); } @@ -129,6 +133,7 @@ public function store(StorePostRequest $request): RedirectResponse $post = $workspace->posts()->create([ 'user_id' => $request->user()->id, 'status' => $request->input('status', PostStatus::Draft), + 'synced' => $request->input('synced', true), 'scheduled_at' => $request->input('scheduled_at'), ]); @@ -137,7 +142,9 @@ public function store(StorePostRequest $request): RedirectResponse 'social_account_id' => $platformData['social_account_id'], 'platform' => $platformData['platform'], 'content' => $platformData['content'], + 'content_type' => $platformData['content_type'] ?? null, 'status' => 'pending', + 'enabled' => $platformData['enabled'] ?? true, ]); if (! empty($platformData['media_ids'])) { @@ -254,6 +261,7 @@ public function update(UpdatePostRequest $request, Post $post): RedirectResponse $post->update([ 'status' => $status === 'publishing' ? PostStatus::Publishing : $status, + 'synced' => $request->input('synced', $post->synced), 'scheduled_at' => $scheduledAt, ]); @@ -269,6 +277,7 @@ public function update(UpdatePostRequest $request, Post $post): RedirectResponse ->update([ 'enabled' => true, 'content' => $platformData['content'], + 'content_type' => $platformData['content_type'] ?? null, ]); } @@ -282,10 +291,19 @@ public function update(UpdatePostRequest $request, Post $post): RedirectResponse return redirect()->route('posts.show', $post); } - session()->flash('flash.banner', 'Post updated successfully!'); + // Redirect to show page for schedule action + if ($status === 'scheduled') { + session()->flash('flash.banner', 'Post scheduled successfully!'); + session()->flash('flash.bannerStyle', 'success'); + + return redirect()->route('posts.show', $post); + } + + // Stay on edit page for save action + session()->flash('flash.banner', 'Post saved!'); session()->flash('flash.bannerStyle', 'success'); - return redirect()->route('posts.show', $post); + return back(); } public function destroy(Request $request, Post $post): RedirectResponse diff --git a/app/Http/Controllers/WorkspaceHashtagController.php b/app/Http/Controllers/WorkspaceHashtagController.php new file mode 100644 index 00000000..392efb22 --- /dev/null +++ b/app/Http/Controllers/WorkspaceHashtagController.php @@ -0,0 +1,135 @@ +user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + + $this->authorize('view', $workspace); + + return Inertia::render('hashtags/Index', [ + 'workspace' => $workspace, + 'hashtags' => $workspace->hashtags()->latest()->get(), + ]); + } + + public function create(Request $request): Response|RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + + $this->authorize('view', $workspace); + + return Inertia::render('hashtags/Create', [ + 'workspace' => $workspace, + ]); + } + + public function store(Request $request): RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + + $this->authorize('view', $workspace); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'hashtags' => ['required', 'string'], + ]); + + $workspace->hashtags()->create($validated); + + session()->flash('flash.banner', 'Hashtag group created successfully!'); + session()->flash('flash.bannerStyle', 'success'); + + return redirect()->route('hashtags.index'); + } + + public function edit(Request $request, WorkspaceHashtag $hashtag): Response|RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + + $this->authorize('view', $workspace); + + if ($hashtag->workspace_id !== $workspace->id) { + abort(404); + } + + return Inertia::render('hashtags/Edit', [ + 'workspace' => $workspace, + 'hashtag' => $hashtag, + ]); + } + + public function update(Request $request, WorkspaceHashtag $hashtag): RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + + $this->authorize('view', $workspace); + + if ($hashtag->workspace_id !== $workspace->id) { + abort(404); + } + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'hashtags' => ['required', 'string'], + ]); + + $hashtag->update($validated); + + session()->flash('flash.banner', 'Hashtag group updated successfully!'); + session()->flash('flash.bannerStyle', 'success'); + + return redirect()->route('hashtags.index'); + } + + public function destroy(Request $request, WorkspaceHashtag $hashtag): RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + + $this->authorize('view', $workspace); + + if ($hashtag->workspace_id !== $workspace->id) { + abort(404); + } + + $hashtag->delete(); + + session()->flash('flash.banner', 'Hashtag group deleted successfully!'); + session()->flash('flash.bannerStyle', 'success'); + + return redirect()->route('hashtags.index'); + } +} diff --git a/app/Http/Controllers/WorkspaceLabelController.php b/app/Http/Controllers/WorkspaceLabelController.php new file mode 100644 index 00000000..c7a38957 --- /dev/null +++ b/app/Http/Controllers/WorkspaceLabelController.php @@ -0,0 +1,135 @@ +user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + + $this->authorize('view', $workspace); + + return Inertia::render('labels/Index', [ + 'workspace' => $workspace, + 'labels' => $workspace->labels()->latest()->get(), + ]); + } + + public function create(Request $request): Response|RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + + $this->authorize('view', $workspace); + + return Inertia::render('labels/Create', [ + 'workspace' => $workspace, + ]); + } + + public function store(Request $request): RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + + $this->authorize('view', $workspace); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'color' => ['required', 'string', 'max:7', 'regex:/^#[0-9A-Fa-f]{6}$/'], + ]); + + $workspace->labels()->create($validated); + + session()->flash('flash.banner', 'Label created successfully!'); + session()->flash('flash.bannerStyle', 'success'); + + return redirect()->route('labels.index'); + } + + public function edit(Request $request, WorkspaceLabel $label): Response|RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + + $this->authorize('view', $workspace); + + if ($label->workspace_id !== $workspace->id) { + abort(404); + } + + return Inertia::render('labels/Edit', [ + 'workspace' => $workspace, + 'label' => $label, + ]); + } + + public function update(Request $request, WorkspaceLabel $label): RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + + $this->authorize('view', $workspace); + + if ($label->workspace_id !== $workspace->id) { + abort(404); + } + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'color' => ['required', 'string', 'max:7', 'regex:/^#[0-9A-Fa-f]{6}$/'], + ]); + + $label->update($validated); + + session()->flash('flash.banner', 'Label updated successfully!'); + session()->flash('flash.bannerStyle', 'success'); + + return redirect()->route('labels.index'); + } + + public function destroy(Request $request, WorkspaceLabel $label): RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + + $this->authorize('view', $workspace); + + if ($label->workspace_id !== $workspace->id) { + abort(404); + } + + $label->delete(); + + session()->flash('flash.banner', 'Label deleted successfully!'); + session()->flash('flash.bannerStyle', 'success'); + + return redirect()->route('labels.index'); + } +} diff --git a/app/Models/Post.php b/app/Models/Post.php index bbec52b0..5e7cb9c5 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -19,6 +19,7 @@ class Post extends Model 'workspace_id', 'user_id', 'status', + 'synced', 'scheduled_at', 'published_at', ]; @@ -27,6 +28,7 @@ protected function casts(): array { return [ 'status' => PostStatus::class, + 'synced' => 'boolean', 'scheduled_at' => 'datetime', 'published_at' => 'datetime', ]; diff --git a/app/Models/PostPlatform.php b/app/Models/PostPlatform.php index 878fc382..8be22cfa 100644 --- a/app/Models/PostPlatform.php +++ b/app/Models/PostPlatform.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\PostPlatform\ContentType; use App\Enums\SocialPlatform; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -20,6 +21,7 @@ class PostPlatform extends Model 'enabled', 'platform', 'content', + 'content_type', 'status', 'platform_post_id', 'platform_url', @@ -33,6 +35,7 @@ protected function casts(): array return [ 'enabled' => 'boolean', 'platform' => SocialPlatform::class, + 'content_type' => ContentType::class, 'published_at' => 'datetime', 'meta' => 'array', ]; diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index 5310da9c..6ebd0bfe 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -53,6 +53,16 @@ public function pendingInvites(): HasMany return $this->invites()->pending(); } + public function hashtags(): HasMany + { + return $this->hasMany(WorkspaceHashtag::class); + } + + public function labels(): HasMany + { + return $this->hasMany(WorkspaceLabel::class); + } + public function hasMember(User $user): bool { return $this->user_id === $user->id || $this->members()->where('user_id', $user->id)->exists(); diff --git a/app/Models/WorkspaceHashtag.php b/app/Models/WorkspaceHashtag.php new file mode 100644 index 00000000..5f4f2bec --- /dev/null +++ b/app/Models/WorkspaceHashtag.php @@ -0,0 +1,25 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'workspace_id', + 'name', + 'hashtags', + ]; + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } +} diff --git a/app/Models/WorkspaceLabel.php b/app/Models/WorkspaceLabel.php new file mode 100644 index 00000000..72a08a2e --- /dev/null +++ b/app/Models/WorkspaceLabel.php @@ -0,0 +1,25 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'workspace_id', + 'name', + 'color', + ]; + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } +} diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 31123283..e73f9bd2 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -2,6 +2,7 @@ namespace App\Services\Social; +use App\Enums\PostPlatform\ContentType; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use Illuminate\Http\Client\Response; @@ -37,10 +38,21 @@ public function publish(PostPlatform $postPlatform): array $accessToken = $account->access_token; $media = $postPlatform->media; + $contentType = $postPlatform->content_type; + return match ($contentType) { + ContentType::FacebookReel => $this->publishReel($pageId, $accessToken, $postPlatform->content, $media->first()), + ContentType::FacebookStory => $this->publishStory($pageId, $accessToken, $media->first()), + ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $postPlatform->content, $media), + default => throw new \Exception("Unsupported Facebook content type: {$contentType?->value}"), + }; + } + + private function publishPost(string $pageId, string $accessToken, string $content, $media): array + { // Text only post if ($media->isEmpty()) { - return $this->publishTextPost($pageId, $accessToken, $postPlatform->content); + return $this->publishTextPost($pageId, $accessToken, $content); } $firstMedia = $media->first(); @@ -48,16 +60,16 @@ public function publish(PostPlatform $postPlatform): array $isImage = str_starts_with($firstMedia->mime_type, 'image/'); if ($isVideo) { - return $this->publishVideoPost($pageId, $accessToken, $postPlatform->content, $firstMedia); + return $this->publishVideoPost($pageId, $accessToken, $content, $firstMedia); } if ($isImage) { // Single or multiple images if ($media->count() === 1) { - return $this->publishSingleImagePost($pageId, $accessToken, $postPlatform->content, $firstMedia); + return $this->publishSingleImagePost($pageId, $accessToken, $content, $firstMedia); } - return $this->publishMultiImagePost($pageId, $accessToken, $postPlatform->content, $media); + return $this->publishMultiImagePost($pageId, $accessToken, $content, $media); } throw new \Exception('Unsupported media type for Facebook'); @@ -210,6 +222,147 @@ private function publishVideoPost(string $pageId, string $accessToken, string $c ]; } + private function publishReel(string $pageId, string $accessToken, string $content, $media): array + { + Log::info('Facebook publishing reel', ['page_id' => $pageId]); + + // Upload video as reel + $response = Http::post("{$this->baseUrl}/{$pageId}/video_reels", [ + 'upload_phase' => 'start', + 'access_token' => $accessToken, + ]); + + if ($response->failed()) { + Log::error('Facebook reel upload start failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + $this->handleApiError($response, 'Facebook API error'); + } + + $data = $response->json(); + $videoId = $data['video_id']; + + // Upload the video file + $uploadResponse = Http::post("{$this->baseUrl}/{$videoId}", [ + 'upload_phase' => 'transfer', + 'video_file_chunk' => $media->url, + 'access_token' => $accessToken, + ]); + + if ($uploadResponse->failed()) { + Log::error('Facebook reel upload transfer failed', [ + 'body' => $uploadResponse->body(), + ]); + $this->handleApiError($uploadResponse, 'Facebook API error'); + } + + // Finish and publish the reel + $finishResponse = Http::post("{$this->baseUrl}/{$pageId}/video_reels", [ + 'upload_phase' => 'finish', + 'video_id' => $videoId, + 'video_state' => 'PUBLISHED', + 'description' => $content, + 'access_token' => $accessToken, + ]); + + if ($finishResponse->failed()) { + Log::error('Facebook reel finish failed', [ + 'body' => $finishResponse->body(), + ]); + $this->handleApiError($finishResponse, 'Facebook API error'); + } + + $finishData = $finishResponse->json(); + $reelId = $finishData['id'] ?? $videoId; + + return [ + 'id' => $reelId, + 'url' => "https://www.facebook.com/reel/{$reelId}", + ]; + } + + private function publishStory(string $pageId, string $accessToken, $media): array + { + Log::info('Facebook publishing story', ['page_id' => $pageId]); + + $isVideo = str_starts_with($media->mime_type, 'video/'); + + if ($isVideo) { + // Video story + $response = Http::post("{$this->baseUrl}/{$pageId}/video_stories", [ + 'upload_phase' => 'start', + 'access_token' => $accessToken, + ]); + + if ($response->failed()) { + $this->handleApiError($response, 'Facebook API error'); + } + + $videoId = $response->json()['video_id']; + + // Transfer the video + Http::post("{$this->baseUrl}/{$videoId}", [ + 'upload_phase' => 'transfer', + 'video_file_chunk' => $media->url, + 'access_token' => $accessToken, + ]); + + // Finish the story + $finishResponse = Http::post("{$this->baseUrl}/{$pageId}/video_stories", [ + 'upload_phase' => 'finish', + 'video_id' => $videoId, + 'access_token' => $accessToken, + ]); + + if ($finishResponse->failed()) { + $this->handleApiError($finishResponse, 'Facebook API error'); + } + + $storyId = $finishResponse->json()['post_id'] ?? $videoId; + + return [ + 'id' => $storyId, + 'url' => "https://www.facebook.com/stories/{$pageId}/{$storyId}", + ]; + } + + // Image story + $response = Http::post("{$this->baseUrl}/{$pageId}/photo_stories", [ + 'photo_id' => $this->uploadUnpublishedPhoto($pageId, $accessToken, $media), + 'access_token' => $accessToken, + ]); + + if ($response->failed()) { + Log::error('Facebook photo story failed', [ + 'body' => $response->body(), + ]); + $this->handleApiError($response, 'Facebook API error'); + } + + $storyId = $response->json()['post_id'] ?? $response->json()['id']; + + return [ + 'id' => $storyId, + 'url' => "https://www.facebook.com/stories/{$pageId}/{$storyId}", + ]; + } + + private function uploadUnpublishedPhoto(string $pageId, string $accessToken, $media): string + { + $response = Http::post("{$this->baseUrl}/{$pageId}/photos", [ + 'url' => $media->url, + 'published' => 'false', + 'access_token' => $accessToken, + ]); + + if ($response->failed()) { + $this->handleApiError($response, 'Facebook API error'); + } + + return $response->json()['id']; + } + private function handleApiError(Response $response, string $context): void { $body = $response->json() ?? []; diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 8845dd76..7d9d4c5e 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -2,6 +2,7 @@ namespace App\Services\Social; +use App\Enums\PostPlatform\ContentType; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use Illuminate\Http\Client\Response; @@ -43,19 +44,16 @@ public function publish(PostPlatform $postPlatform): array } $firstMedia = $media->first(); - $isVideo = str_starts_with($firstMedia->mime_type, 'video/'); + $contentType = $postPlatform->content_type; - // Single media - if ($media->count() === 1) { - if ($isVideo) { - return $this->publishReel($instagramId, $accessToken, $postPlatform->content, $firstMedia); - } - - return $this->publishSingleImage($instagramId, $accessToken, $postPlatform->content, $firstMedia); - } - - // Multiple media - carousel - return $this->publishCarousel($instagramId, $accessToken, $postPlatform->content, $media); + return match ($contentType) { + ContentType::InstagramReel => $this->publishReel($instagramId, $accessToken, $postPlatform->content, $firstMedia), + ContentType::InstagramStory => $this->publishStory($instagramId, $accessToken, $firstMedia), + ContentType::InstagramFeed => $media->count() > 1 + ? $this->publishCarousel($instagramId, $accessToken, $postPlatform->content, $media) + : $this->publishSingleImage($instagramId, $accessToken, $postPlatform->content, $firstMedia), + default => throw new \Exception("Unsupported Instagram content type: {$contentType?->value}"), + }; } private function publishSingleImage(string $instagramId, string $accessToken, string $content, $media): array @@ -112,6 +110,45 @@ private function publishReel(string $instagramId, string $accessToken, string $c return $this->publishContainer($instagramId, $accessToken, $containerId); } + private function publishStory(string $instagramId, string $accessToken, $media): array + { + Log::info('Instagram publishing story', ['instagram_id' => $instagramId]); + + $isVideo = str_starts_with($media->mime_type, 'video/'); + + $params = [ + 'media_type' => 'STORIES', + 'access_token' => $accessToken, + ]; + + if ($isVideo) { + $params['video_url'] = $media->url; + } else { + $params['image_url'] = $media->url; + } + + // Step 1: Create story container + $containerResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", $params); + + if ($containerResponse->failed()) { + Log::error('Instagram story container creation failed', [ + 'status' => $containerResponse->status(), + 'body' => $containerResponse->body(), + ]); + $this->handleApiError($containerResponse, 'Instagram API error'); + } + + $containerId = $containerResponse->json()['id']; + + // Wait for video processing if needed + if ($isVideo) { + $this->waitForMediaProcessing($containerId, $accessToken); + } + + // Step 2: Publish story container + return $this->publishContainer($instagramId, $accessToken, $containerId); + } + private function publishCarousel(string $instagramId, string $accessToken, string $content, $mediaCollection): array { Log::info('Instagram publishing carousel', [ diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index e22c2887..2e60ecfd 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -2,6 +2,7 @@ namespace App\Services\Social; +use App\Enums\PostPlatform\ContentType; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; @@ -47,10 +48,20 @@ public function publish(PostPlatform $postPlatform): array } $organizationUrn = "urn:li:organization:{$organizationId}"; + $contentType = $postPlatform->content_type; + return match ($contentType) { + ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $postPlatform->content, $postPlatform->media, $account), + ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $postPlatform->content, $postPlatform->media, $account), + default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"), + }; + } + + private function publishPost(string $organizationUrn, string $content, $media, $account): array + { $payload = [ 'author' => $organizationUrn, - 'commentary' => $postPlatform->content, + 'commentary' => $content, 'visibility' => 'PUBLIC', 'distribution' => [ 'feedDistribution' => 'MAIN_FEED', @@ -60,8 +71,6 @@ public function publish(PostPlatform $postPlatform): array 'lifecycleState' => 'PUBLISHED', ]; - $media = $postPlatform->media; - if ($media->isNotEmpty()) { $firstMedia = $media->first(); $mediaUrn = $this->uploadMedia($firstMedia, $organizationUrn); @@ -102,6 +111,79 @@ public function publish(PostPlatform $postPlatform): array ]; } + private function publishCarousel(string $organizationUrn, string $content, $mediaCollection, $account): array + { + Log::info('LinkedIn Page publishing carousel', [ + 'owner' => $organizationUrn, + 'media_count' => $mediaCollection->count(), + ]); + + // Upload images and build carousel items + $carouselItems = []; + + foreach ($mediaCollection as $media) { + if (! str_starts_with($media->mime_type, 'image/')) { + continue; + } + + $imageUrn = $this->uploadImage($media, $organizationUrn); + + if ($imageUrn) { + $carouselItems[] = [ + 'altText' => $media->original_filename ?? 'Carousel image', + 'media' => $imageUrn, + ]; + } + } + + if (empty($carouselItems)) { + throw new \Exception('No valid images for LinkedIn Page carousel'); + } + + $payload = [ + 'author' => $organizationUrn, + 'commentary' => $content, + 'visibility' => 'PUBLIC', + 'distribution' => [ + 'feedDistribution' => 'MAIN_FEED', + 'targetEntities' => [], + 'thirdPartyDistributionChannels' => [], + ], + 'lifecycleState' => 'PUBLISHED', + 'content' => [ + 'multiImage' => [ + 'images' => $carouselItems, + ], + ], + ]; + + Log::info('LinkedIn Page creating carousel post', ['payload' => $payload]); + + $response = $this->getHttpClient() + ->post("{$this->baseUrl}/rest/posts", $payload); + + if ($response->failed()) { + Log::error('LinkedIn Page carousel post creation failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + $this->handleApiError($response, 'LinkedIn Page API error'); + } + + $postId = $response->header('x-restli-id'); + + // Build company page URL + $username = $account->username; + $postUrl = $username + ? "https://www.linkedin.com/company/{$username}/posts/" + : "https://www.linkedin.com/feed/update/{$postId}"; + + return [ + 'id' => $postId ?? 'unknown', + 'url' => $postId ? $postUrl : null, + ]; + } + private function getHttpClient(): PendingRequest { return Http::withToken($this->accessToken) diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index 6a96e1fd..4ac701e3 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -2,6 +2,7 @@ namespace App\Services\Social; +use App\Enums\PostPlatform\ContentType; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; @@ -41,10 +42,20 @@ public function publish(PostPlatform $postPlatform): array $this->accessToken = $account->access_token; $personUrn = "urn:li:person:{$account->platform_user_id}"; + $contentType = $postPlatform->content_type; + return match ($contentType) { + ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $postPlatform->content, $postPlatform->media), + ContentType::LinkedInPost => $this->publishPost($personUrn, $postPlatform->content, $postPlatform->media), + default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"), + }; + } + + private function publishPost(string $personUrn, string $content, $media): array + { $payload = [ 'author' => $personUrn, - 'commentary' => $postPlatform->content, + 'commentary' => $content, 'visibility' => 'PUBLIC', 'distribution' => [ 'feedDistribution' => 'MAIN_FEED', @@ -54,8 +65,6 @@ public function publish(PostPlatform $postPlatform): array 'lifecycleState' => 'PUBLISHED', ]; - $media = $postPlatform->media; - if ($media->isNotEmpty()) { $firstMedia = $media->first(); $mediaUrn = $this->uploadMedia($firstMedia, $personUrn); @@ -90,6 +99,73 @@ public function publish(PostPlatform $postPlatform): array ]; } + private function publishCarousel(string $personUrn, string $content, $mediaCollection): array + { + Log::info('LinkedIn publishing carousel', [ + 'owner' => $personUrn, + 'media_count' => $mediaCollection->count(), + ]); + + // Upload images and build carousel items + $carouselItems = []; + + foreach ($mediaCollection as $media) { + if (! str_starts_with($media->mime_type, 'image/')) { + continue; + } + + $imageUrn = $this->uploadImage($media, $personUrn); + + if ($imageUrn) { + $carouselItems[] = [ + 'altText' => $media->original_filename ?? 'Carousel image', + 'media' => $imageUrn, + ]; + } + } + + if (empty($carouselItems)) { + throw new \Exception('No valid images for LinkedIn carousel'); + } + + $payload = [ + 'author' => $personUrn, + 'commentary' => $content, + 'visibility' => 'PUBLIC', + 'distribution' => [ + 'feedDistribution' => 'MAIN_FEED', + 'targetEntities' => [], + 'thirdPartyDistributionChannels' => [], + ], + 'lifecycleState' => 'PUBLISHED', + 'content' => [ + 'multiImage' => [ + 'images' => $carouselItems, + ], + ], + ]; + + Log::info('LinkedIn creating carousel post', ['payload' => $payload]); + + $response = $this->getHttpClient() + ->post("{$this->baseUrl}/rest/posts", $payload); + + if ($response->failed()) { + Log::error('LinkedIn carousel post creation failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + $this->handleApiError($response, 'LinkedIn API error'); + } + + $postId = $response->header('x-restli-id'); + + return [ + 'id' => $postId ?? 'unknown', + 'url' => $postId ? "https://www.linkedin.com/feed/update/{$postId}" : null, + ]; + } + private function getHttpClient(): PendingRequest { return Http::withToken($this->accessToken) diff --git a/database/factories/WorkspaceHashtagFactory.php b/database/factories/WorkspaceHashtagFactory.php new file mode 100644 index 00000000..93de23f8 --- /dev/null +++ b/database/factories/WorkspaceHashtagFactory.php @@ -0,0 +1,26 @@ + + */ +class WorkspaceHashtagFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'workspace_id' => Workspace::factory(), + 'name' => fake()->words(2, true), + 'hashtags' => '#'.implode(' #', fake()->words(5)), + ]; + } +} diff --git a/database/factories/WorkspaceLabelFactory.php b/database/factories/WorkspaceLabelFactory.php new file mode 100644 index 00000000..064251fc --- /dev/null +++ b/database/factories/WorkspaceLabelFactory.php @@ -0,0 +1,28 @@ + + */ +class WorkspaceLabelFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $colors = ['#FDFD96', '#FFD580', '#FFB3BA', '#FF69B4', '#DDA0DD', '#89CFF0', '#90EE90', '#D2B48C', '#D3D3D3']; + + return [ + 'workspace_id' => Workspace::factory(), + 'name' => fake()->word(), + 'color' => fake()->randomElement($colors), + ]; + } +} diff --git a/database/migrations/2026_01_14_232318_create_posts_table.php b/database/migrations/2026_01_14_232318_create_posts_table.php index 471e2306..1d545980 100644 --- a/database/migrations/2026_01_14_232318_create_posts_table.php +++ b/database/migrations/2026_01_14_232318_create_posts_table.php @@ -16,6 +16,7 @@ public function up(): void $table->uuid('workspace_id'); $table->uuid('user_id'); $table->string('status')->default('draft'); + $table->boolean('synced'); $table->timestamp('scheduled_at')->nullable(); $table->timestamp('published_at')->nullable(); $table->timestamps(); diff --git a/database/migrations/2026_01_17_150140_add_content_type_to_post_platforms_table.php b/database/migrations/2026_01_17_150140_add_content_type_to_post_platforms_table.php new file mode 100644 index 00000000..666b67d1 --- /dev/null +++ b/database/migrations/2026_01_17_150140_add_content_type_to_post_platforms_table.php @@ -0,0 +1,28 @@ +string('content_type')->nullable()->after('content'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('post_platforms', function (Blueprint $table) { + $table->dropColumn('content_type'); + }); + } +}; diff --git a/database/migrations/2026_01_17_152319_create_workspace_hashtags_table.php b/database/migrations/2026_01_17_152319_create_workspace_hashtags_table.php new file mode 100644 index 00000000..c0803b9b --- /dev/null +++ b/database/migrations/2026_01_17_152319_create_workspace_hashtags_table.php @@ -0,0 +1,30 @@ +uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->text('hashtags'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('workspace_hashtags'); + } +}; diff --git a/database/migrations/2026_01_17_153347_create_workspace_labels_table.php b/database/migrations/2026_01_17_153347_create_workspace_labels_table.php new file mode 100644 index 00000000..7789f28b --- /dev/null +++ b/database/migrations/2026_01_17_153347_create_workspace_labels_table.php @@ -0,0 +1,30 @@ +uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('color', 7); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('workspace_labels'); + } +}; diff --git a/package-lock.json b/package-lock.json index 5840a3bb..51deede8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.19", + "embla-carousel-vue": "^8.6.0", "laravel-vite-plugin": "^2.0.0", "lucide-vue-next": "^0.468.0", "maska": "^3.2.0", @@ -18,8 +19,10 @@ "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.1", "tw-animate-css": "^1.2.5", + "vaul-vue": "^0.4.1", "vue": "^3.5.13", - "vue-input-otp": "^0.3.2" + "vue-input-otp": "^0.3.2", + "vue-sonner": "^2.0.9" }, "devDependencies": { "@eslint/js": "^9.19.0", @@ -3220,6 +3223,34 @@ "node": ">= 0.4" } }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/embla-carousel-vue": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-vue/-/embla-carousel-vue-8.6.0.tgz", + "integrity": "sha512-v8UO5UsyLocZnu/LbfQA7Dn2QHuZKurJY93VUmZYP//QRWoCWOsionmvLLAlibkET3pGPs7++03VhJKbWD7vhQ==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "vue": "^3.2.37" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -7062,6 +7093,114 @@ "dev": true, "license": "MIT" }, + "node_modules/vaul-vue": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/vaul-vue/-/vaul-vue-0.4.1.tgz", + "integrity": "sha512-A6jOWOZX5yvyo1qMn7IveoWN91mJI5L3BUKsIwkg6qrTGgHs1Sb1JF/vyLJgnbN1rH4OOOxFbtqL9A46bOyGUQ==", + "dependencies": { + "@vueuse/core": "^10.8.0", + "reka-ui": "^2.0.0", + "vue": "^3.4.5" + }, + "peerDependencies": { + "reka-ui": "^2.0.0", + "vue": "^3.3.0" + } + }, + "node_modules/vaul-vue/node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/vaul-vue/node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/vaul-vue/node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vaul-vue/node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/vaul-vue/node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/vaul-vue/node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -7253,6 +7392,28 @@ "vue": "^3.2.0" } }, + "node_modules/vue-sonner": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/vue-sonner/-/vue-sonner-2.0.9.tgz", + "integrity": "sha512-i6BokNlNDL93fpzNxN/LZSn6D6MzlO+i3qXt6iVZne3x1k7R46d5HlFB4P8tYydhgqOrRbIZEsnRd3kG7qGXyw==", + "license": "MIT", + "peerDependencies": { + "@nuxt/kit": "^4.0.3", + "@nuxt/schema": "^4.0.3", + "nuxt": "^4.0.3" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@nuxt/schema": { + "optional": true + }, + "nuxt": { + "optional": true + } + } + }, "node_modules/vue-tsc": { "version": "2.2.12", "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", diff --git a/package.json b/package.json index 7c79f906..f061612b 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.19", + "embla-carousel-vue": "^8.6.0", "laravel-vite-plugin": "^2.0.0", "lucide-vue-next": "^0.468.0", "maska": "^3.2.0", @@ -49,8 +50,10 @@ "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.1", "tw-animate-css": "^1.2.5", + "vaul-vue": "^0.4.1", "vue": "^3.5.13", - "vue-input-otp": "^0.3.2" + "vue-input-otp": "^0.3.2", + "vue-sonner": "^2.0.9" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.9.5", diff --git a/resources/css/app.css b/resources/css/app.css index b5307aec..55f9c7d1 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -13,31 +13,45 @@ @theme inline { '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); + + --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); @@ -45,24 +59,6 @@ @theme inline { --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); } /* @@ -96,111 +92,76 @@ @layer utilities { } :root { - --background: #f8f9fa; - --foreground: #0c0c1d; - --card: #ffffff; - --card-foreground: #0c0c1d; - --popover: #ffffff; - --popover-foreground: #0c0c1d; - --primary: #ff00c8; - --primary-foreground: #ffffff; - --secondary: #f0f0ff; - --secondary-foreground: #0c0c1d; - --muted: #f0f0ff; - --muted-foreground: #0c0c1d; - --accent: #00ffcc; - --accent-foreground: #0c0c1d; - --destructive: #ff3d00; - --destructive-foreground: #ffffff; - --border: #dfe6e9; - --input: #dfe6e9; - --ring: #ff00c8; - --chart-1: #ff00c8; - --chart-2: #9000ff; - --chart-3: #00e5ff; - --chart-4: #00ffcc; - --chart-5: #ffe600; - --sidebar: #f0f0ff; - --sidebar-foreground: #0c0c1d; - --sidebar-primary: #ff00c8; - --sidebar-primary-foreground: #ffffff; - --sidebar-accent: #00ffcc; - --sidebar-accent-foreground: #0c0c1d; - --sidebar-border: #dfe6e9; - --sidebar-ring: #ff00c8; - --font-sans: Outfit, sans-serif; - --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; - --font-mono: Fira Code, monospace; + --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; - --shadow-x: 0px; - --shadow-y: 4px; - --shadow-blur: 8px; - --shadow-spread: -2px; - --shadow-opacity: 0.1; - --shadow-color: hsl(0 0% 0%); - --shadow-2xs: 0px 4px 8px -2px hsl(0 0% 0% / 0.05); - --shadow-xs: 0px 4px 8px -2px hsl(0 0% 0% / 0.05); - --shadow-sm: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 1px 2px -3px hsl(0 0% 0% / 0.10); - --shadow: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 1px 2px -3px hsl(0 0% 0% / 0.10); - --shadow-md: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 2px 4px -3px hsl(0 0% 0% / 0.10); - --shadow-lg: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 4px 6px -3px hsl(0 0% 0% / 0.10); - --shadow-xl: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 8px 10px -3px hsl(0 0% 0% / 0.10); - --shadow-2xl: 0px 4px 8px -2px hsl(0 0% 0% / 0.25); - --tracking-normal: 0em; - --spacing: 0.25rem; + --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%); } .dark { - --background: #0c0c1d; - --foreground: #eceff4; - --card: #1e1e3f; - --card-foreground: #eceff4; - --popover: #1e1e3f; - --popover-foreground: #eceff4; - --primary: #ff00c8; - --primary-foreground: #ffffff; - --secondary: #1e1e3f; - --secondary-foreground: #eceff4; - --muted: #151530; - --muted-foreground: #8085a6; - --accent: #00ffcc; - --accent-foreground: #0c0c1d; - --destructive: #ff3d00; - --destructive-foreground: #ffffff; - --border: #2e2e5e; - --input: #2e2e5e; - --ring: #ff00c8; - --chart-1: #ff00c8; - --chart-2: #9000ff; - --chart-3: #00e5ff; - --chart-4: #00ffcc; - --chart-5: #ffe600; - --sidebar: #0c0c1d; - --sidebar-foreground: #eceff4; - --sidebar-primary: #ff00c8; - --sidebar-primary-foreground: #ffffff; - --sidebar-accent: #00ffcc; - --sidebar-accent-foreground: #0c0c1d; - --sidebar-border: #2e2e5e; - --sidebar-ring: #ff00c8; - --font-sans: Outfit, sans-serif; - --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; - --font-mono: Fira Code, monospace; - --radius: 0.5rem; - --shadow-x: 0px; - --shadow-y: 4px; - --shadow-blur: 8px; - --shadow-spread: -2px; - --shadow-opacity: 0.1; - --shadow-color: hsl(0 0% 0%); - --shadow-2xs: 0px 4px 8px -2px hsl(0 0% 0% / 0.05); - --shadow-xs: 0px 4px 8px -2px hsl(0 0% 0% / 0.05); - --shadow-sm: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 1px 2px -3px hsl(0 0% 0% / 0.10); - --shadow: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 1px 2px -3px hsl(0 0% 0% / 0.10); - --shadow-md: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 2px 4px -3px hsl(0 0% 0% / 0.10); - --shadow-lg: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 4px 6px -3px hsl(0 0% 0% / 0.10); - --shadow-xl: 0px 4px 8px -2px hsl(0 0% 0% / 0.10), 0px 8px 10px -3px hsl(0 0% 0% / 0.10); - --shadow-2xl: 0px 4px 8px -2px hsl(0 0% 0% / 0.25); + --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%); } @layer base { diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index ac7a959f..cb29c7be 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@ + +