feat: Implement user onboarding, subscription management, and refactor social integrations with new UI components and mail templates.

This commit is contained in:
Paulo Castellano 2026-01-17 12:36:49 -03:00
parent d284a14c75
commit d538537706
164 changed files with 7446 additions and 497 deletions

View file

@ -0,0 +1,179 @@
<?php
namespace App\Enums\PostPlatform;
use App\Enums\SocialPlatform;
enum ContentType: string
{
// Instagram
case InstagramFeed = 'instagram_feed';
case InstagramReel = 'instagram_reel';
case InstagramStory = 'instagram_story';
// LinkedIn
case LinkedInPost = 'linkedin_post';
case LinkedInCarousel = 'linkedin_carousel';
// LinkedIn Page
case LinkedInPagePost = 'linkedin_page_post';
case LinkedInPageCarousel = 'linkedin_page_carousel';
// Facebook
case FacebookPost = 'facebook_post';
case FacebookReel = 'facebook_reel';
case FacebookStory = 'facebook_story';
// TikTok
case TikTokVideo = 'tiktok_video';
// YouTube
case YouTubeShort = 'youtube_short';
// X (Twitter)
case XPost = 'x_post';
// Threads
case ThreadsPost = 'threads_post';
public function label(): string
{
return match ($this) {
self::InstagramFeed => '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<self>
*/
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,
};
}
}

View file

@ -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/')) {

View file

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

View file

@ -0,0 +1,135 @@
<?php
namespace App\Http\Controllers;
use App\Models\WorkspaceHashtag;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class WorkspaceHashtagController extends Controller
{
public function index(Request $request): Response|RedirectResponse
{
$workspace = $request->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');
}
}

View file

@ -0,0 +1,135 @@
<?php
namespace App\Http\Controllers;
use App\Models\WorkspaceLabel;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class WorkspaceLabelController extends Controller
{
public function index(Request $request): Response|RedirectResponse
{
$workspace = $request->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');
}
}

View file

@ -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',
];

View file

@ -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',
];

View file

@ -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();

View file

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WorkspaceHashtag extends Model
{
/** @use HasFactory<\Database\Factories\WorkspaceHashtagFactory> */
use HasFactory, HasUuids;
protected $fillable = [
'workspace_id',
'name',
'hashtags',
];
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WorkspaceLabel extends Model
{
/** @use HasFactory<\Database\Factories\WorkspaceLabelFactory> */
use HasFactory, HasUuids;
protected $fillable = [
'workspace_id',
'name',
'color',
];
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
}

View file

@ -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() ?? [];

View file

@ -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', [

View file

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

View file

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

View file

@ -0,0 +1,26 @@
<?php
namespace Database\Factories;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WorkspaceHashtag>
*/
class WorkspaceHashtagFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'workspace_id' => Workspace::factory(),
'name' => fake()->words(2, true),
'hashtags' => '#'.implode(' #', fake()->words(5)),
];
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace Database\Factories;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WorkspaceLabel>
*/
class WorkspaceLabelFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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),
];
}
}

View file

@ -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();

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('post_platforms', function (Blueprint $table) {
$table->string('content_type')->nullable()->after('content');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('post_platforms', function (Blueprint $table) {
$table->dropColumn('content_type');
});
}
};

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('workspace_hashtags', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('workspace_labels', function (Blueprint $table) {
$table->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');
}
};

163
package-lock.json generated
View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, router, usePage } from '@inertiajs/vue3';
import { Calendar, Check, ChevronsUpDown, CreditCard, LogOut, Plus, Settings, Share2, Sparkles, Users } from 'lucide-vue-next';
import { Calendar, Check, ChevronsUpDown, CreditCard, Hash, LogOut, Plus, Settings, Share2, Sparkles, Tag, Users } from 'lucide-vue-next';
import { computed } from 'vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@ -28,6 +28,8 @@ import {
} from '@/components/ui/sidebar';
import { useInitials } from '@/composables/useInitials';
import { accounts, calendar, logout, members, settings } from '@/routes';
import { index as hashtags } from '@/routes/hashtags';
import { index as labels } from '@/routes/labels';
import { index as billing } from '@/routes/billing';
import { edit as editProfile } from '@/routes/profile';
import { create as createWorkspaceRoute, switchMethod } from '@/routes/workspaces';
@ -56,6 +58,16 @@ const mainNavItems: NavItem[] = [
href: accounts.url(),
icon: Share2,
},
{
title: 'Hashtags',
href: hashtags.url(),
icon: Hash,
},
{
title: 'Labels',
href: labels.url(),
icon: Tag,
},
{
title: 'Members',
href: members.url(),

View file

@ -0,0 +1,201 @@
<script setup lang="ts">
import {
IconDots,
IconVideo,
IconX,
IconPhoto,
IconThumbUp,
IconMessageCircle,
IconShare,
IconWorld,
} from '@tabler/icons-vue';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface MediaItem {
id: string;
url: string;
type: string;
original_filename: string;
}
interface Props {
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
charCount: number;
maxLength: number;
isValid: boolean;
validationMessage: string;
isUploading?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:content': [value: string];
'upload': [event: Event];
'remove-media': [mediaId: string];
}>();
</script>
<template>
<div class="bg-white dark:bg-[#242526] rounded-lg shadow-md overflow-hidden">
<!-- Post Header -->
<div class="p-4 pb-2">
<div class="flex items-start gap-2">
<img
v-if="socialAccount.avatar_url"
:src="socialAccount.avatar_url"
:alt="socialAccount.display_name"
class="h-10 w-10 rounded-full object-cover"
/>
<div v-else class="h-10 w-10 rounded-full bg-[#1877f2] flex items-center justify-center text-white font-bold">
{{ socialAccount.display_name?.charAt(0) }}
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-[15px] text-gray-900 dark:text-white hover:underline cursor-pointer">
{{ socialAccount.display_name }}
</p>
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<span>Just now</span>
<span>·</span>
<IconWorld class="h-3 w-3" />
</div>
</div>
<button class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full">
<IconDots class="h-5 w-5 text-gray-500" />
</button>
</div>
<!-- Content -->
<div class="mt-2">
<textarea
:value="content"
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
class="w-full min-h-[100px] bg-transparent border-0 p-0 text-[15px] text-gray-900 dark:text-[#e4e6eb] resize-none focus:outline-none focus:ring-0 placeholder:text-gray-500"
placeholder="What's on your mind?"
/>
</div>
</div>
<!-- Media -->
<div v-if="media.length > 0">
<div
class="grid"
:class="{
'grid-cols-1': media.length === 1,
'grid-cols-2': media.length === 2,
'grid-cols-2': media.length >= 3,
}"
>
<div
v-for="(item, index) in media.slice(0, 5)"
:key="item.id"
class="relative group overflow-hidden"
:class="{
'aspect-video': media.length === 1,
'aspect-square': media.length === 2,
'col-span-2 aspect-video': media.length >= 3 && index === 0,
'aspect-square': media.length >= 3 && index > 0,
}"
>
<img
v-if="item.type === 'image'"
:src="item.url"
:alt="item.original_filename"
class="w-full h-full object-cover"
/>
<video
v-else
:src="item.url"
class="w-full h-full object-cover bg-black"
muted
loop
playsinline
/>
<button
type="button"
@click="emit('remove-media', item.id)"
class="absolute top-2 right-2 bg-black/70 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
>
<IconX class="h-4 w-4" />
</button>
<div
v-if="media.length > 5 && index === 4"
class="absolute inset-0 bg-black/60 flex items-center justify-center"
>
<span class="text-white text-3xl font-semibold">+{{ media.length - 5 }}</span>
</div>
</div>
</div>
</div>
<!-- Reactions Summary -->
<div class="px-4 py-2 flex items-center justify-between text-sm text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-1">
<div class="flex -space-x-1">
<div class="h-[18px] w-[18px] rounded-full bg-[#1877f2] flex items-center justify-center ring-2 ring-white dark:ring-[#242526]">
<IconThumbUp class="h-2.5 w-2.5 text-white" fill="white" />
</div>
<div class="h-[18px] w-[18px] rounded-full bg-red-500 flex items-center justify-center ring-2 ring-white dark:ring-[#242526]">
<span class="text-[10px]">❤️</span>
</div>
<div class="h-[18px] w-[18px] rounded-full bg-yellow-400 flex items-center justify-center ring-2 ring-white dark:ring-[#242526]">
<span class="text-[10px]">😂</span>
</div>
</div>
<span class="ml-1">156</span>
</div>
<div class="flex items-center gap-3">
<span>23 comments</span>
<span>8 shares</span>
</div>
</div>
<!-- Action Buttons -->
<div class="px-2 py-1 flex items-center border-b border-gray-200 dark:border-gray-700">
<button class="flex-1 flex items-center justify-center gap-2 py-2.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg text-gray-600 dark:text-gray-300 font-medium text-[15px]">
<IconThumbUp class="h-5 w-5" />
<span>Like</span>
</button>
<button class="flex-1 flex items-center justify-center gap-2 py-2.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg text-gray-600 dark:text-gray-300 font-medium text-[15px]">
<IconMessageCircle class="h-5 w-5" />
<span>Comment</span>
</button>
<button class="flex-1 flex items-center justify-center gap-2 py-2.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg text-gray-600 dark:text-gray-300 font-medium text-[15px]">
<IconShare class="h-5 w-5" />
<span>Share</span>
</button>
</div>
<!-- Footer with upload and char count -->
<div class="px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-[#3a3b3c]">
<div class="flex items-center gap-2">
<span
class="text-xs px-2 py-1 rounded-full"
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
>
{{ validationMessage }}
</span>
</div>
<label class="cursor-pointer p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
<input
type="file"
accept="image/*,video/*"
multiple
class="hidden"
@change="emit('upload', $event)"
:disabled="isUploading"
/>
<IconPhoto class="h-5 w-5 text-[#45bd62]" />
</label>
</div>
</div>
</template>

View file

@ -0,0 +1,370 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
IconDots,
IconVideo,
IconX,
IconPhoto,
IconHeart,
IconMessageCircle,
IconSend,
IconBookmark,
IconMusic,
} from '@tabler/icons-vue';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface MediaItem {
id: string;
url: string;
type: string;
original_filename: string;
}
interface Props {
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
contentType?: string;
charCount: number;
maxLength: number;
isValid: boolean;
validationMessage: string;
isUploading?: boolean;
}
const props = defineProps<Props>();
// Computed helpers for content type
const isReel = computed(() => props.contentType === 'instagram_reel');
const isStory = computed(() => props.contentType === 'instagram_story');
const isFeed = computed(() => props.contentType === 'instagram_feed' || !props.contentType);
const emit = defineEmits<{
'update:content': [value: string];
'upload': [event: Event];
'remove-media': [mediaId: string];
}>();
</script>
<template>
<!-- Reel/Story Preview (vertical) -->
<div v-if="isReel || isStory" class="mx-auto" style="max-width: 320px;">
<div class="relative bg-black rounded-2xl overflow-hidden" style="aspect-ratio: 9/16;">
<!-- Media Area -->
<div v-if="media.length > 0" class="w-full h-full">
<img
v-if="media[0].type === 'image'"
:src="media[0].url"
:alt="media[0].original_filename"
class="w-full h-full object-cover"
/>
<video
v-else
:src="media[0].url"
class="w-full h-full object-cover bg-black"
muted
loop
playsinline
/>
<!-- Remove button -->
<button
type="button"
@click="emit('remove-media', media[0].id)"
class="absolute top-3 right-3 bg-black/70 text-white rounded-full p-2 z-20"
>
<IconX class="h-4 w-4" />
</button>
</div>
<div v-else class="w-full h-full flex flex-col items-center justify-center text-gray-400">
<IconVideo class="h-16 w-16 mb-2" />
<p class="text-sm">Add a {{ isReel ? 'video' : 'photo or video' }}</p>
<label class="mt-2 cursor-pointer text-blue-500 font-medium text-sm hover:text-blue-600">
<input
type="file"
:accept="isReel ? 'video/*' : 'image/*,video/*'"
class="hidden"
@change="emit('upload', $event)"
:disabled="isUploading"
/>
Upload
</label>
</div>
<!-- Overlay UI -->
<div class="absolute inset-0 pointer-events-none">
<!-- Top bar -->
<div class="absolute top-0 left-0 right-0 p-3 flex items-center justify-between">
<div v-if="isStory" class="flex items-center gap-2">
<div class="p-0.5 bg-gradient-to-tr from-yellow-400 via-red-500 to-purple-600 rounded-full">
<div class="p-0.5 bg-black rounded-full">
<img
v-if="socialAccount.avatar_url"
:src="socialAccount.avatar_url"
:alt="socialAccount.display_name"
class="h-8 w-8 rounded-full object-cover"
/>
<div v-else class="h-8 w-8 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-bold text-sm">
{{ socialAccount.display_name?.charAt(0) }}
</div>
</div>
</div>
<span class="text-white text-sm font-semibold">{{ socialAccount.username || socialAccount.display_name }}</span>
</div>
<div v-if="isReel" class="bg-black/50 text-white text-xs px-2 py-1 rounded flex items-center gap-1">
<IconVideo class="h-3 w-3" />
Reel
</div>
</div>
<!-- Right sidebar actions (Reels) -->
<div v-if="isReel" class="absolute bottom-20 right-3 flex flex-col items-center gap-4">
<button class="flex flex-col items-center gap-1">
<IconHeart class="h-7 w-7 text-white drop-shadow" />
<span class="text-white text-xs drop-shadow">1.2K</span>
</button>
<button class="flex flex-col items-center gap-1">
<IconMessageCircle class="h-7 w-7 text-white drop-shadow" />
<span class="text-white text-xs drop-shadow">89</span>
</button>
<button class="flex flex-col items-center gap-1">
<IconSend class="h-7 w-7 text-white drop-shadow" />
</button>
<button class="flex flex-col items-center gap-1">
<IconDots class="h-7 w-7 text-white drop-shadow" />
</button>
</div>
<!-- Bottom content (Reels) -->
<div v-if="isReel" class="absolute bottom-3 left-3 right-14">
<div class="flex items-center gap-2 mb-2">
<img
v-if="socialAccount.avatar_url"
:src="socialAccount.avatar_url"
:alt="socialAccount.display_name"
class="h-8 w-8 rounded-full object-cover border-2 border-white"
/>
<div v-else class="h-8 w-8 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-bold text-sm border-2 border-white">
{{ socialAccount.display_name?.charAt(0) }}
</div>
<span class="text-white text-sm font-semibold drop-shadow">{{ socialAccount.username || socialAccount.display_name }}</span>
<button class="ml-1 px-3 py-1 bg-white/20 text-white text-xs font-semibold rounded">Follow</button>
</div>
<p v-if="content" class="text-white text-sm drop-shadow line-clamp-2">{{ content }}</p>
<div class="flex items-center gap-1 mt-2 text-white/80 text-xs">
<IconMusic class="h-3 w-3" />
<span class="truncate">Original audio</span>
</div>
</div>
<!-- Story reply bar -->
<div v-if="isStory" class="absolute bottom-3 left-3 right-3">
<div class="flex items-center gap-2">
<div class="flex-1 bg-white/20 backdrop-blur rounded-full px-4 py-2 text-white/80 text-sm">
Send message
</div>
<IconHeart class="h-6 w-6 text-white" />
<IconSend class="h-6 w-6 text-white" />
</div>
</div>
</div>
</div>
<!-- Caption input for Reels -->
<div v-if="isReel" class="mt-4 p-4 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">Caption</label>
<textarea
:value="content"
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
class="w-full min-h-[80px] bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 text-sm text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-2 focus:ring-pink-500 placeholder:text-gray-500"
placeholder="Write a caption..."
/>
<div class="flex items-center justify-between mt-2">
<span
class="text-xs px-2 py-1 rounded-full"
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
>
{{ validationMessage }}
</span>
<label class="cursor-pointer p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
<input
type="file"
accept="video/*"
class="hidden"
@change="emit('upload', $event)"
:disabled="isUploading"
/>
<IconPhoto class="h-5 w-5 text-gray-500" />
</label>
</div>
</div>
<!-- Story note -->
<div v-if="isStory" class="mt-4 p-4 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800">
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">Stories disappear after 24 hours</p>
<div class="flex items-center justify-between mt-2">
<span
class="text-xs px-2 py-1 rounded-full"
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
>
{{ validationMessage }}
</span>
<label class="cursor-pointer p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
<input
type="file"
accept="image/*,video/*"
class="hidden"
@change="emit('upload', $event)"
:disabled="isUploading"
/>
<IconPhoto class="h-5 w-5 text-gray-500" />
</label>
</div>
</div>
</div>
<!-- Feed Post Preview (original layout) -->
<div v-else class="bg-white dark:bg-black rounded-lg overflow-hidden border border-gray-200 dark:border-gray-800">
<!-- Post Header -->
<div class="px-3 py-2.5 flex items-center justify-between">
<div class="flex items-center gap-3">
<!-- Avatar with gradient ring -->
<div class="p-0.5 bg-gradient-to-tr from-yellow-400 via-red-500 to-purple-600 rounded-full">
<div class="p-0.5 bg-white dark:bg-black rounded-full">
<img
v-if="socialAccount.avatar_url"
:src="socialAccount.avatar_url"
:alt="socialAccount.display_name"
class="h-8 w-8 rounded-full object-cover"
/>
<div v-else class="h-8 w-8 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-bold text-sm">
{{ socialAccount.display_name?.charAt(0) }}
</div>
</div>
</div>
<div>
<p class="font-semibold text-sm text-gray-900 dark:text-white">
{{ socialAccount.username || socialAccount.display_name }}
</p>
</div>
</div>
<button class="p-1">
<IconDots class="h-5 w-5 text-gray-900 dark:text-white" />
</button>
</div>
<!-- Media (required for Instagram) -->
<div class="relative aspect-square bg-gray-100 dark:bg-gray-900 group">
<div v-if="media.length > 0" class="w-full h-full">
<img
v-if="media[0].type === 'image'"
:src="media[0].url"
:alt="media[0].original_filename"
class="w-full h-full object-cover"
/>
<video
v-else
:src="media[0].url"
class="w-full h-full object-cover bg-black"
muted
loop
playsinline
/>
<!-- Remove button -->
<button
type="button"
@click="emit('remove-media', media[0].id)"
class="absolute top-3 right-3 bg-black/70 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<IconX class="h-4 w-4" />
</button>
</div>
<div v-else class="w-full h-full flex flex-col items-center justify-center text-gray-400">
<IconPhoto class="h-16 w-16 mb-2" />
<p class="text-sm">Add a photo or video</p>
<label class="mt-2 cursor-pointer text-blue-500 font-medium text-sm hover:text-blue-600">
<input
type="file"
accept="image/*,video/*"
class="hidden"
@change="emit('upload', $event)"
:disabled="isUploading"
/>
Upload
</label>
</div>
</div>
<!-- Action Buttons -->
<div class="px-3 py-2 flex items-center justify-between">
<div class="flex items-center gap-4">
<button class="hover:opacity-60 transition-opacity">
<IconHeart class="h-6 w-6 text-gray-900 dark:text-white" />
</button>
<button class="hover:opacity-60 transition-opacity">
<IconMessageCircle class="h-6 w-6 text-gray-900 dark:text-white" />
</button>
<button class="hover:opacity-60 transition-opacity">
<IconSend class="h-6 w-6 text-gray-900 dark:text-white" />
</button>
</div>
<button class="hover:opacity-60 transition-opacity">
<IconBookmark class="h-6 w-6 text-gray-900 dark:text-white" />
</button>
</div>
<!-- Likes -->
<div class="px-3 pb-1">
<p class="font-semibold text-sm text-gray-900 dark:text-white">1,234 likes</p>
</div>
<!-- Caption -->
<div class="px-3 pb-2">
<div class="text-sm">
<span class="font-semibold text-gray-900 dark:text-white mr-1">
{{ socialAccount.username || socialAccount.display_name }}
</span>
<textarea
:value="content"
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
class="w-full min-h-[60px] bg-transparent border-0 p-0 text-sm text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-500"
placeholder="Write a caption..."
/>
</div>
</div>
<!-- Timestamp -->
<div class="px-3 pb-3">
<p class="text-[10px] text-gray-500 uppercase tracking-wide">Just now</p>
</div>
<!-- Footer with char count -->
<div class="border-t border-gray-200 dark:border-gray-800 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-900/50">
<div class="flex items-center gap-2">
<span
class="text-xs px-2 py-1 rounded-full"
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
>
{{ validationMessage }}
</span>
</div>
<label v-if="media.length > 0" class="cursor-pointer p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors">
<input
type="file"
accept="image/*,video/*"
multiple
class="hidden"
@change="emit('upload', $event)"
:disabled="isUploading"
/>
<IconPhoto class="h-5 w-5 text-gray-500" />
</label>
</div>
</div>
</template>

View file

@ -0,0 +1,206 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
IconWorld,
IconThumbUp,
IconMessageCircle,
IconRepeat,
IconSend,
IconDots,
IconVideo,
IconX,
IconPhoto,
IconHeart,
} from '@tabler/icons-vue';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface MediaItem {
id: string;
url: string;
type: string;
original_filename: string;
}
interface Props {
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
charCount: number;
maxLength: number;
isValid: boolean;
validationMessage: string;
isUploading?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:content': [value: string];
'upload': [event: Event];
'remove-media': [mediaId: string];
}>();
</script>
<template>
<div class="bg-white dark:bg-[#1b1f23] rounded-lg shadow-lg overflow-hidden border border-gray-200 dark:border-gray-700">
<!-- Post Header -->
<div class="p-4">
<div class="flex items-start gap-3">
<img
v-if="socialAccount.avatar_url"
:src="socialAccount.avatar_url"
:alt="socialAccount.display_name"
class="h-12 w-12 rounded-full object-cover"
/>
<div v-else class="h-12 w-12 rounded-full bg-[#0a66c2] flex items-center justify-center text-white font-semibold">
{{ socialAccount.display_name?.charAt(0) }}
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm text-gray-900 dark:text-white hover:text-[#0a66c2] hover:underline cursor-pointer">
{{ socialAccount.display_name }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">
Software Engineer
</p>
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<span>now</span>
<span>·</span>
<IconWorld class="h-3 w-3" />
</div>
</div>
<button class="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full">
<IconDots class="h-5 w-5 text-gray-500" />
</button>
</div>
<!-- Content -->
<div class="mt-3">
<textarea
:value="content"
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
class="w-full min-h-[120px] bg-transparent border-0 p-0 text-sm text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-500"
placeholder="What do you want to talk about?"
/>
</div>
</div>
<!-- Media -->
<div v-if="media.length > 0" class="border-t border-gray-200 dark:border-gray-700">
<div
class="grid"
:class="{
'grid-cols-1': media.length === 1,
'grid-cols-2': media.length >= 2,
}"
>
<div
v-for="(item, index) in media.slice(0, 4)"
:key="item.id"
class="relative group aspect-square overflow-hidden"
:class="{
'col-span-2 aspect-video': media.length === 1,
'row-span-2': media.length === 3 && index === 0,
}"
>
<img
v-if="item.type === 'image'"
:src="item.url"
:alt="item.original_filename"
class="w-full h-full object-cover"
/>
<video
v-else
:src="item.url"
class="w-full h-full object-cover bg-black"
muted
loop
playsinline
/>
<button
type="button"
@click="emit('remove-media', item.id)"
class="absolute top-2 right-2 bg-black/70 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
>
<IconX class="h-4 w-4" />
</button>
<div
v-if="media.length > 4 && index === 3"
class="absolute inset-0 bg-black/60 flex items-center justify-center"
>
<span class="text-white text-2xl font-semibold">+{{ media.length - 4 }}</span>
</div>
</div>
</div>
</div>
<!-- Engagement Stats -->
<div class="px-4 py-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-1">
<div class="flex -space-x-1">
<div class="h-4 w-4 rounded-full bg-[#0a66c2] flex items-center justify-center">
<IconThumbUp class="h-2.5 w-2.5 text-white" />
</div>
<div class="h-4 w-4 rounded-full bg-red-500 flex items-center justify-center">
<IconHeart class="h-2.5 w-2.5 text-white" />
</div>
</div>
<span>24</span>
</div>
<div class="flex items-center gap-2">
<span>3 comments</span>
<span>·</span>
<span>1 repost</span>
</div>
</div>
<!-- Actions -->
<div class="px-2 py-1 flex items-center justify-between border-t border-gray-200 dark:border-gray-700">
<button class="flex-1 flex items-center justify-center gap-2 py-3 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-600 dark:text-gray-400 font-medium text-sm">
<IconThumbUp class="h-5 w-5" />
<span class="hidden sm:inline">Like</span>
</button>
<button class="flex-1 flex items-center justify-center gap-2 py-3 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-600 dark:text-gray-400 font-medium text-sm">
<IconMessageCircle class="h-5 w-5" />
<span class="hidden sm:inline">Comment</span>
</button>
<button class="flex-1 flex items-center justify-center gap-2 py-3 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-600 dark:text-gray-400 font-medium text-sm">
<IconRepeat class="h-5 w-5" />
<span class="hidden sm:inline">Repost</span>
</button>
<button class="flex-1 flex items-center justify-center gap-2 py-3 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-600 dark:text-gray-400 font-medium text-sm">
<IconSend class="h-5 w-5" />
<span class="hidden sm:inline">Send</span>
</button>
</div>
<!-- Footer with upload and char count -->
<div class="border-t border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-800/50">
<div class="flex items-center gap-2">
<span
class="text-xs px-2 py-1 rounded-full"
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
>
{{ validationMessage }}
</span>
</div>
<label class="cursor-pointer p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
<input
type="file"
accept="image/*,video/*"
multiple
class="hidden"
@change="emit('upload', $event)"
:disabled="isUploading"
/>
<IconPhoto class="h-5 w-5 text-gray-500" />
</label>
</div>
</div>
</template>

View file

@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed } from 'vue';
import LinkedInPreview from './LinkedInPreview.vue';
import XPreview from './XPreview.vue';
import FacebookPreview from './FacebookPreview.vue';
import InstagramPreview from './InstagramPreview.vue';
import ThreadsPreview from './ThreadsPreview.vue';
import TikTokPreview from './TikTokPreview.vue';
import YouTubePreview from './YouTubePreview.vue';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface MediaItem {
id: string;
url: string;
type: string;
original_filename: string;
}
interface Props {
platform: string;
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
contentType?: string;
charCount: number;
maxLength: number;
isValid: boolean;
validationMessage: string;
isUploading?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:content': [value: string];
'upload': [event: Event];
'remove-media': [mediaId: string];
}>();
const previewComponent = computed(() => {
switch (props.platform) {
case 'linkedin':
case 'linkedin-page':
return LinkedInPreview;
case 'x':
return XPreview;
case 'facebook':
return FacebookPreview;
case 'instagram':
return InstagramPreview;
case 'threads':
return ThreadsPreview;
case 'tiktok':
return TikTokPreview;
case 'youtube':
return YouTubePreview;
default:
return LinkedInPreview;
}
});
const handleContentUpdate = (value: string) => {
emit('update:content', value);
};
const handleUpload = (event: Event) => {
emit('upload', event);
};
const handleRemoveMedia = (mediaId: string) => {
emit('remove-media', mediaId);
};
</script>
<template>
<component
:is="previewComponent"
:social-account="socialAccount"
:content="content"
:media="media"
:content-type="contentType"
:char-count="charCount"
:max-length="maxLength"
:is-valid="isValid"
:validation-message="validationMessage"
:is-uploading="isUploading"
@update:content="handleContentUpdate"
@upload="handleUpload"
@remove-media="handleRemoveMedia"
/>
</template>

View file

@ -0,0 +1,191 @@
<script setup lang="ts">
import {
IconDots,
IconVideo,
IconX,
IconPhoto,
IconHeart,
IconMessageCircle,
IconRepeat,
IconSend,
} from '@tabler/icons-vue';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface MediaItem {
id: string;
url: string;
type: string;
original_filename: string;
}
interface Props {
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
charCount: number;
maxLength: number;
isValid: boolean;
validationMessage: string;
isUploading?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:content': [value: string];
'upload': [event: Event];
'remove-media': [mediaId: string];
}>();
</script>
<template>
<div class="bg-white dark:bg-[#101010] rounded-xl overflow-hidden border border-gray-200 dark:border-gray-800">
<!-- Thread Content -->
<div class="p-4">
<div class="flex gap-3">
<!-- Avatar and thread line -->
<div class="flex flex-col items-center">
<img
v-if="socialAccount.avatar_url"
:src="socialAccount.avatar_url"
:alt="socialAccount.display_name"
class="h-9 w-9 rounded-full object-cover"
/>
<div v-else class="h-9 w-9 rounded-full bg-gradient-to-br from-gray-700 to-gray-900 flex items-center justify-center text-white font-bold text-sm">
{{ socialAccount.display_name?.charAt(0) }}
</div>
<!-- Thread line -->
<div class="w-0.5 flex-1 bg-gray-200 dark:bg-gray-800 mt-2 min-h-[20px]" />
</div>
<!-- Content Area -->
<div class="flex-1 min-w-0 pb-3">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<span class="font-semibold text-[15px] text-gray-900 dark:text-white">
{{ socialAccount.username || socialAccount.display_name }}
</span>
<span class="text-gray-500 text-sm">· now</span>
</div>
<button class="p-1 hover:bg-gray-100 dark:hover:bg-gray-900 rounded-full">
<IconDots class="h-5 w-5 text-gray-500" />
</button>
</div>
<!-- Thread Content -->
<div class="mt-0.5">
<textarea
:value="content"
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
class="w-full min-h-[60px] bg-transparent border-0 p-0 text-[15px] text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-500"
placeholder="Start a thread..."
/>
</div>
<!-- Media -->
<div v-if="media.length > 0" class="mt-2">
<div
class="grid gap-1 rounded-xl overflow-hidden"
:class="{
'grid-cols-1': media.length === 1,
'grid-cols-2': media.length >= 2,
}"
>
<div
v-for="(item, index) in media.slice(0, 4)"
:key="item.id"
class="relative group overflow-hidden"
:class="{
'aspect-video': media.length === 1,
'aspect-square': media.length > 1,
}"
>
<img
v-if="item.type === 'image'"
:src="item.url"
:alt="item.original_filename"
class="w-full h-full object-cover"
/>
<video
v-else
:src="item.url"
class="w-full h-full object-cover bg-black"
muted
loop
playsinline
/>
<button
type="button"
@click="emit('remove-media', item.id)"
class="absolute top-2 right-2 bg-black/70 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
>
<IconX class="h-4 w-4" />
</button>
<div
v-if="media.length > 4 && index === 3"
class="absolute inset-0 bg-black/60 flex items-center justify-center"
>
<span class="text-white text-xl font-semibold">+{{ media.length - 4 }}</span>
</div>
</div>
</div>
</div>
<!-- Engagement Actions -->
<div class="flex items-center gap-1 mt-3 -ml-2">
<button class="p-2 hover:bg-gray-100 dark:hover:bg-gray-900 rounded-full group">
<IconHeart class="h-5 w-5 text-gray-600 dark:text-gray-400 group-hover:text-red-500" />
</button>
<button class="p-2 hover:bg-gray-100 dark:hover:bg-gray-900 rounded-full group">
<IconMessageCircle class="h-5 w-5 text-gray-600 dark:text-gray-400 group-hover:text-blue-500" />
</button>
<button class="p-2 hover:bg-gray-100 dark:hover:bg-gray-900 rounded-full group">
<IconRepeat class="h-5 w-5 text-gray-600 dark:text-gray-400 group-hover:text-green-500" />
</button>
<button class="p-2 hover:bg-gray-100 dark:hover:bg-gray-900 rounded-full group">
<IconSend class="h-5 w-5 text-gray-600 dark:text-gray-400 group-hover:text-blue-500" />
</button>
</div>
<!-- Stats -->
<div class="flex items-center gap-2 mt-1 text-sm text-gray-500">
<span>12 replies</span>
<span>·</span>
<span>89 likes</span>
</div>
</div>
</div>
</div>
<!-- Footer with upload and char count -->
<div class="border-t border-gray-200 dark:border-gray-800 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-900/50">
<div class="flex items-center gap-2">
<span
class="text-xs px-2 py-1 rounded-full"
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
>
{{ validationMessage }}
</span>
</div>
<label class="cursor-pointer p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors">
<input
type="file"
accept="image/*,video/*"
multiple
class="hidden"
@change="emit('upload', $event)"
:disabled="isUploading"
/>
<IconPhoto class="h-5 w-5 text-gray-500" />
</label>
</div>
</div>
</template>

View file

@ -0,0 +1,190 @@
<script setup lang="ts">
import {
IconVideo,
IconX,
IconPhoto,
IconHeart,
IconMessageCircle,
IconBookmark,
IconShare,
IconMusic,
IconPlus,
} from '@tabler/icons-vue';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface MediaItem {
id: string;
url: string;
type: string;
original_filename: string;
}
interface Props {
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
charCount: number;
maxLength: number;
isValid: boolean;
validationMessage: string;
isUploading?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:content': [value: string];
'upload': [event: Event];
'remove-media': [mediaId: string];
}>();
</script>
<template>
<div class="bg-black rounded-xl overflow-hidden relative" style="aspect-ratio: 9/16; max-height: 600px;">
<!-- Video Area -->
<div class="absolute inset-0">
<div v-if="media.length > 0 && media[0].type === 'video'" class="w-full h-full">
<video :src="media[0].url" class="w-full h-full object-cover" muted loop playsinline />
<button
type="button"
@click="emit('remove-media', media[0].id)"
class="absolute top-4 right-4 bg-black/50 text-white rounded-full p-2"
>
<IconX class="h-5 w-5" />
</button>
</div>
<div v-else-if="media.length > 0 && media[0].type === 'image'" class="w-full h-full">
<img :src="media[0].url" :alt="media[0].original_filename" class="w-full h-full object-cover" />
<button
type="button"
@click="emit('remove-media', media[0].id)"
class="absolute top-4 right-4 bg-black/50 text-white rounded-full p-2"
>
<IconX class="h-5 w-5" />
</button>
</div>
<div v-else class="w-full h-full flex flex-col items-center justify-center bg-gray-900 text-gray-400">
<IconVideo class="h-20 w-20 mb-4" />
<p class="text-sm mb-2">Add a video</p>
<label class="cursor-pointer bg-[#fe2c55] text-white px-6 py-2 rounded-full font-semibold text-sm hover:bg-[#e91e4f] transition-colors">
<input
type="file"
accept="video/*,image/*"
class="hidden"
@change="emit('upload', $event)"
:disabled="isUploading"
/>
Upload
</label>
</div>
</div>
<!-- Gradient overlay at bottom -->
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/80 via-black/40 to-transparent pointer-events-none" />
<!-- Right side actions -->
<div class="absolute right-3 bottom-24 flex flex-col items-center gap-4">
<!-- Profile -->
<div class="relative mb-2">
<img
v-if="socialAccount.avatar_url"
:src="socialAccount.avatar_url"
:alt="socialAccount.display_name"
class="h-12 w-12 rounded-full object-cover border-2 border-white"
/>
<div v-else class="h-12 w-12 rounded-full bg-gray-700 flex items-center justify-center text-white font-bold border-2 border-white">
{{ socialAccount.display_name?.charAt(0) }}
</div>
<div class="absolute -bottom-1.5 left-1/2 -translate-x-1/2 h-5 w-5 bg-[#fe2c55] rounded-full flex items-center justify-center">
<IconPlus class="h-3 w-3 text-white" />
</div>
</div>
<!-- Like -->
<button class="flex flex-col items-center">
<div class="h-10 w-10 flex items-center justify-center">
<IconHeart class="h-7 w-7 text-white" />
</div>
<span class="text-white text-xs font-semibold">1.2M</span>
</button>
<!-- Comment -->
<button class="flex flex-col items-center">
<div class="h-10 w-10 flex items-center justify-center">
<IconMessageCircle class="h-7 w-7 text-white" />
</div>
<span class="text-white text-xs font-semibold">8.5K</span>
</button>
<!-- Bookmark -->
<button class="flex flex-col items-center">
<div class="h-10 w-10 flex items-center justify-center">
<IconBookmark class="h-7 w-7 text-white" />
</div>
<span class="text-white text-xs font-semibold">45K</span>
</button>
<!-- Share -->
<button class="flex flex-col items-center">
<div class="h-10 w-10 flex items-center justify-center">
<IconShare class="h-7 w-7 text-white" />
</div>
<span class="text-white text-xs font-semibold">Share</span>
</button>
<!-- Music disc -->
<div class="h-10 w-10 rounded-full bg-gray-800 border-2 border-gray-600 flex items-center justify-center animate-spin" style="animation-duration: 3s;">
<img
v-if="socialAccount.avatar_url"
:src="socialAccount.avatar_url"
class="h-6 w-6 rounded-full object-cover"
/>
<IconMusic v-else class="h-4 w-4 text-white" />
</div>
</div>
<!-- Bottom info -->
<div class="absolute left-3 right-16 bottom-4 text-white">
<p class="font-semibold text-sm">@{{ socialAccount.username || socialAccount.display_name }}</p>
<div class="mt-1">
<textarea
:value="content"
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
class="w-full min-h-[40px] max-h-[80px] bg-transparent border-0 p-0 text-sm text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-400"
placeholder="Add a caption..."
/>
</div>
<div class="flex items-center gap-2 mt-2 text-sm">
<IconMusic class="h-4 w-4" />
<span class="truncate">Original sound - {{ socialAccount.display_name }}</span>
</div>
</div>
<!-- Footer with char count -->
<div class="absolute top-0 left-0 right-0 p-3 flex items-center justify-between bg-gradient-to-b from-black/50 to-transparent">
<span
class="text-xs px-2 py-1 rounded-full"
:class="isValid ? 'bg-green-500/30 text-green-300' : 'bg-red-500/30 text-red-300'"
>
{{ validationMessage }}
</span>
<label v-if="media.length > 0" class="cursor-pointer p-2 rounded-full bg-black/30 hover:bg-black/50 transition-colors">
<input
type="file"
accept="video/*,image/*"
class="hidden"
@change="emit('upload', $event)"
:disabled="isUploading"
/>
<IconPhoto class="h-5 w-5 text-white" />
</label>
</div>
</div>
</template>

View file

@ -0,0 +1,207 @@
<script setup lang="ts">
import {
IconDots,
IconVideo,
IconX,
IconPhoto,
IconHeart,
IconMessageCircle,
IconRepeat,
IconChartBar,
IconBookmark,
IconShare,
} from '@tabler/icons-vue';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface MediaItem {
id: string;
url: string;
type: string;
original_filename: string;
}
interface Props {
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
charCount: number;
maxLength: number;
isValid: boolean;
validationMessage: string;
isUploading?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:content': [value: string];
'upload': [event: Event];
'remove-media': [mediaId: string];
}>();
</script>
<template>
<div class="bg-white dark:bg-black rounded-xl overflow-hidden border border-gray-200 dark:border-gray-800">
<!-- Post Content -->
<div class="p-4">
<div class="flex gap-3">
<!-- Avatar -->
<div class="shrink-0">
<img
v-if="socialAccount.avatar_url"
:src="socialAccount.avatar_url"
:alt="socialAccount.display_name"
class="h-10 w-10 rounded-full object-cover"
/>
<div v-else class="h-10 w-10 rounded-full bg-gray-800 flex items-center justify-center text-white font-bold">
{{ socialAccount.display_name?.charAt(0) }}
</div>
</div>
<!-- Content Area -->
<div class="flex-1 min-w-0">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-1 text-sm">
<span class="font-bold text-gray-900 dark:text-white hover:underline cursor-pointer">
{{ socialAccount.display_name }}
</span>
<span class="text-gray-500">@{{ socialAccount.username || 'username' }}</span>
<span class="text-gray-500">·</span>
<span class="text-gray-500 hover:underline cursor-pointer">now</span>
</div>
<button class="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 rounded-full -mr-1.5">
<IconDots class="h-5 w-5 text-gray-500" />
</button>
</div>
<!-- Tweet Content -->
<div class="mt-1">
<textarea
:value="content"
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
class="w-full min-h-[80px] bg-transparent border-0 p-0 text-[15px] text-gray-900 dark:text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-500"
placeholder="What's happening?"
/>
</div>
<!-- Media -->
<div v-if="media.length > 0" class="mt-3">
<div
class="grid gap-0.5 rounded-2xl overflow-hidden"
:class="{
'grid-cols-1': media.length === 1,
'grid-cols-2': media.length >= 2,
}"
>
<div
v-for="(item, index) in media.slice(0, 4)"
:key="item.id"
class="relative group overflow-hidden"
:class="{
'aspect-video': media.length === 1,
'aspect-square': media.length > 1,
'row-span-2': media.length === 3 && index === 0,
}"
>
<img
v-if="item.type === 'image'"
:src="item.url"
:alt="item.original_filename"
class="w-full h-full object-cover"
/>
<video
v-else
:src="item.url"
class="w-full h-full object-cover bg-black"
muted
loop
playsinline
/>
<button
type="button"
@click="emit('remove-media', item.id)"
class="absolute top-2 right-2 bg-black/70 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
>
<IconX class="h-4 w-4" />
</button>
<div
v-if="media.length > 4 && index === 3"
class="absolute inset-0 bg-black/60 flex items-center justify-center"
>
<span class="text-white text-2xl font-semibold">+{{ media.length - 4 }}</span>
</div>
</div>
</div>
</div>
<!-- Engagement Actions -->
<div class="flex items-center justify-between mt-3 max-w-md text-gray-500">
<button class="flex items-center gap-1 group">
<div class="p-2 rounded-full group-hover:bg-blue-500/10">
<IconMessageCircle class="h-[18px] w-[18px] group-hover:text-blue-500" />
</div>
<span class="text-sm group-hover:text-blue-500">12</span>
</button>
<button class="flex items-center gap-1 group">
<div class="p-2 rounded-full group-hover:bg-green-500/10">
<IconRepeat class="h-[18px] w-[18px] group-hover:text-green-500" />
</div>
<span class="text-sm group-hover:text-green-500">5</span>
</button>
<button class="flex items-center gap-1 group">
<div class="p-2 rounded-full group-hover:bg-pink-500/10">
<IconHeart class="h-[18px] w-[18px] group-hover:text-pink-500" />
</div>
<span class="text-sm group-hover:text-pink-500">89</span>
</button>
<button class="flex items-center gap-1 group">
<div class="p-2 rounded-full group-hover:bg-blue-500/10">
<IconChartBar class="h-[18px] w-[18px] group-hover:text-blue-500" />
</div>
<span class="text-sm group-hover:text-blue-500">1.2K</span>
</button>
<div class="flex items-center gap-1">
<button class="p-2 rounded-full hover:bg-blue-500/10 group">
<IconBookmark class="h-[18px] w-[18px] group-hover:text-blue-500" />
</button>
<button class="p-2 rounded-full hover:bg-blue-500/10 group">
<IconShare class="h-[18px] w-[18px] group-hover:text-blue-500" />
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Footer with upload and char count -->
<div class="border-t border-gray-200 dark:border-gray-800 px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-900/50">
<div class="flex items-center gap-2">
<span
class="text-xs px-2 py-1 rounded-full"
:class="isValid ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
>
{{ validationMessage }}
</span>
</div>
<label class="cursor-pointer p-2 rounded-full hover:bg-blue-500/10 transition-colors">
<input
type="file"
accept="image/*,video/*"
multiple
class="hidden"
@change="emit('upload', $event)"
:disabled="isUploading"
/>
<IconPhoto class="h-5 w-5 text-blue-500" />
</label>
</div>
</div>
</template>

View file

@ -0,0 +1,200 @@
<script setup lang="ts">
import {
IconVideo,
IconX,
IconPhoto,
IconThumbUp,
IconThumbDown,
IconMessageCircle,
IconShare,
IconDots,
} from '@tabler/icons-vue';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface MediaItem {
id: string;
url: string;
type: string;
original_filename: string;
}
interface Props {
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
charCount: number;
maxLength: number;
isValid: boolean;
validationMessage: string;
isUploading?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:content': [value: string];
'upload': [event: Event];
'remove-media': [mediaId: string];
}>();
</script>
<template>
<div class="bg-[#0f0f0f] rounded-xl overflow-hidden relative" style="aspect-ratio: 9/16; max-height: 600px;">
<!-- Video Area -->
<div class="absolute inset-0">
<div v-if="media.length > 0 && media[0].type === 'video'" class="w-full h-full">
<video :src="media[0].url" class="w-full h-full object-cover" muted loop playsinline />
<button
type="button"
@click="emit('remove-media', media[0].id)"
class="absolute top-4 right-4 bg-black/50 text-white rounded-full p-2"
>
<IconX class="h-5 w-5" />
</button>
</div>
<div v-else-if="media.length > 0 && media[0].type === 'image'" class="w-full h-full">
<img :src="media[0].url" :alt="media[0].original_filename" class="w-full h-full object-cover" />
<button
type="button"
@click="emit('remove-media', media[0].id)"
class="absolute top-4 right-4 bg-black/50 text-white rounded-full p-2"
>
<IconX class="h-5 w-5" />
</button>
</div>
<div v-else class="w-full h-full flex flex-col items-center justify-center bg-[#0f0f0f] text-gray-400">
<IconVideo class="h-20 w-20 mb-4" />
<p class="text-sm mb-2">Add a Short video</p>
<label class="cursor-pointer bg-[#ff0000] text-white px-6 py-2 rounded-full font-semibold text-sm hover:bg-[#cc0000] transition-colors">
<input
type="file"
accept="video/*,image/*"
class="hidden"
@change="emit('upload', $event)"
:disabled="isUploading"
/>
Upload
</label>
</div>
</div>
<!-- Gradient overlay at bottom -->
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/80 via-black/40 to-transparent pointer-events-none" />
<!-- Right side actions -->
<div class="absolute right-3 bottom-28 flex flex-col items-center gap-5">
<!-- Like -->
<button class="flex flex-col items-center">
<div class="h-12 w-12 bg-[#272727] rounded-full flex items-center justify-center">
<IconThumbUp class="h-6 w-6 text-white" />
</div>
<span class="text-white text-xs mt-1">12K</span>
</button>
<!-- Dislike -->
<button class="flex flex-col items-center">
<div class="h-12 w-12 bg-[#272727] rounded-full flex items-center justify-center">
<IconThumbDown class="h-6 w-6 text-white" />
</div>
<span class="text-white text-xs mt-1">Dislike</span>
</button>
<!-- Comment -->
<button class="flex flex-col items-center">
<div class="h-12 w-12 bg-[#272727] rounded-full flex items-center justify-center">
<IconMessageCircle class="h-6 w-6 text-white" />
</div>
<span class="text-white text-xs mt-1">234</span>
</button>
<!-- Share -->
<button class="flex flex-col items-center">
<div class="h-12 w-12 bg-[#272727] rounded-full flex items-center justify-center">
<IconShare class="h-6 w-6 text-white" />
</div>
<span class="text-white text-xs mt-1">Share</span>
</button>
<!-- More -->
<button class="flex flex-col items-center">
<div class="h-12 w-12 bg-[#272727] rounded-full flex items-center justify-center">
<IconDots class="h-6 w-6 text-white" />
</div>
</button>
<!-- Channel avatar -->
<div class="relative mt-1">
<img
v-if="socialAccount.avatar_url"
:src="socialAccount.avatar_url"
:alt="socialAccount.display_name"
class="h-10 w-10 rounded-full object-cover border-2 border-white"
/>
<div v-else class="h-10 w-10 rounded-full bg-[#ff0000] flex items-center justify-center text-white font-bold border-2 border-white text-sm">
{{ socialAccount.display_name?.charAt(0) }}
</div>
</div>
</div>
<!-- Bottom info -->
<div class="absolute left-3 right-20 bottom-4 text-white">
<div class="flex items-center gap-2 mb-2">
<img
v-if="socialAccount.avatar_url"
:src="socialAccount.avatar_url"
:alt="socialAccount.display_name"
class="h-8 w-8 rounded-full object-cover"
/>
<div v-else class="h-8 w-8 rounded-full bg-[#ff0000] flex items-center justify-center text-white font-bold text-xs">
{{ socialAccount.display_name?.charAt(0) }}
</div>
<span class="text-sm font-medium">@{{ socialAccount.username || socialAccount.display_name }}</span>
<button class="ml-1 bg-white text-black text-xs font-semibold px-3 py-1 rounded-full hover:bg-gray-200">
Subscribe
</button>
</div>
<div>
<textarea
:value="content"
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
class="w-full min-h-[40px] max-h-[80px] bg-transparent border-0 p-0 text-sm text-white resize-none focus:outline-none focus:ring-0 placeholder:text-gray-400"
placeholder="Add a title..."
/>
</div>
</div>
<!-- Shorts badge -->
<div class="absolute top-4 left-3 flex items-center gap-2">
<div class="bg-[#ff0000] text-white text-xs font-bold px-2 py-1 rounded">
Shorts
</div>
</div>
<!-- Footer with char count -->
<div class="absolute top-4 right-3 flex items-center gap-2">
<span
class="text-xs px-2 py-1 rounded-full"
:class="isValid ? 'bg-green-500/30 text-green-300' : 'bg-red-500/30 text-red-300'"
>
{{ validationMessage }}
</span>
<label v-if="media.length > 0" class="cursor-pointer p-2 rounded-full bg-black/30 hover:bg-black/50 transition-colors">
<input
type="file"
accept="video/*,image/*"
class="hidden"
@change="emit('upload', $event)"
:disabled="isUploading"
/>
<IconPhoto class="h-5 w-5 text-white" />
</label>
</div>
</div>
</template>

View file

@ -0,0 +1,8 @@
export { default as PlatformPreview } from './PlatformPreview.vue';
export { default as LinkedInPreview } from './LinkedInPreview.vue';
export { default as XPreview } from './XPreview.vue';
export { default as FacebookPreview } from './FacebookPreview.vue';
export { default as InstagramPreview } from './InstagramPreview.vue';
export { default as ThreadsPreview } from './ThreadsPreview.vue';
export { default as TikTokPreview } from './TikTokPreview.vue';
export { default as YouTubePreview } from './YouTubePreview.vue';

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import type { AspectRatioProps } from "reka-ui"
import { AspectRatio } from "reka-ui"
const props = defineProps<AspectRatioProps>()
</script>
<template>
<AspectRatio
v-slot="slotProps"
data-slot="aspect-ratio"
v-bind="props"
>
<slot v-bind="slotProps" />
</AspectRatio>
</template>

View file

@ -0,0 +1 @@
export { default as AspectRatio } from "./AspectRatio.vue"

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { ButtonGroupVariants } from "."
import { cn } from "@/lib/utils"
import { buttonGroupVariants } from "."
const props = defineProps<{
class?: HTMLAttributes["class"]
orientation?: ButtonGroupVariants["orientation"]
}>()
</script>
<template>
<div
role="group"
data-slot="button-group"
:data-orientation="props.orientation"
:class="cn(buttonGroupVariants({ orientation: props.orientation }), props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,24 @@
<script setup lang="ts">
import type { SeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { cn } from "@/lib/utils"
import { Separator } from '@/components/ui/separator'
const props = withDefaults(defineProps<SeparatorProps & { class?: HTMLAttributes["class"] }>(), {
orientation: "vertical",
})
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Separator
data-slot="button-group-separator"
v-bind="delegatedProps"
:orientation="props.orientation"
:class="cn(
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
props.class,
)"
/>
</template>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonGroupVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
interface Props extends PrimitiveProps {
class?: HTMLAttributes["class"]
orientation?: ButtonGroupVariants["orientation"]
}
const props = withDefaults(defineProps<Props>(), {
as: "div",
})
</script>
<template>
<Primitive
role="group"
data-slot="button-group"
:data-orientation="props.orientation"
:as="as"
:as-child="asChild"
:class="cn('bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
>
<slot />
</Primitive>
</template>

View file

@ -0,0 +1,25 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as ButtonGroup } from "./ButtonGroup.vue"
export { default as ButtonGroupSeparator } from "./ButtonGroupSeparator.vue"
export { default as ButtonGroupText } from "./ButtonGroupText.vue"
export const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
},
)
export type ButtonGroupVariants = VariantProps<typeof buttonGroupVariants>

View file

@ -0,0 +1,53 @@
<script setup lang="ts">
import type { CarouselEmits, CarouselProps, WithClassAsProps } from "./interface"
import { cn } from "@/lib/utils"
import { useProvideCarousel } from "./useCarousel"
const props = withDefaults(defineProps<CarouselProps & WithClassAsProps>(), {
orientation: "horizontal",
})
const emits = defineEmits<CarouselEmits>()
const { canScrollNext, canScrollPrev, carouselApi, carouselRef, orientation, scrollNext, scrollPrev } = useProvideCarousel(props, emits)
defineExpose({
canScrollNext,
canScrollPrev,
carouselApi,
carouselRef,
orientation,
scrollNext,
scrollPrev,
})
function onKeyDown(event: KeyboardEvent) {
const prevKey = props.orientation === "vertical" ? "ArrowUp" : "ArrowLeft"
const nextKey = props.orientation === "vertical" ? "ArrowDown" : "ArrowRight"
if (event.key === prevKey) {
event.preventDefault()
scrollPrev()
return
}
if (event.key === nextKey) {
event.preventDefault()
scrollNext()
}
}
</script>
<template>
<div
data-slot="carousel"
:class="cn('relative', props.class)"
role="region"
aria-roledescription="carousel"
tabindex="0"
@keydown="onKeyDown"
>
<slot :can-scroll-next :can-scroll-prev :carousel-api :carousel-ref :orientation :scroll-next :scroll-prev />
</div>
</template>

View file

@ -0,0 +1,33 @@
<script setup lang="ts">
import type { WithClassAsProps } from "./interface"
import { cn } from "@/lib/utils"
import { useCarousel } from "./useCarousel"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<WithClassAsProps>()
const { carouselRef, orientation } = useCarousel()
</script>
<template>
<div
ref="carouselRef"
data-slot="carousel-content"
class="overflow-hidden"
>
<div
:class="
cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
props.class,
)"
v-bind="$attrs"
>
<slot />
</div>
</div>
</template>

View file

@ -0,0 +1,24 @@
<script setup lang="ts">
import type { WithClassAsProps } from "./interface"
import { cn } from "@/lib/utils"
import { useCarousel } from "./useCarousel"
const props = defineProps<WithClassAsProps>()
const { orientation } = useCarousel()
</script>
<template>
<div
data-slot="carousel-item"
role="group"
aria-roledescription="slide"
:class="cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
props.class,
)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,41 @@
<script setup lang="ts">
import type { WithClassAsProps } from "./interface"
import type { ButtonVariants } from '@/components/ui/button'
import { ArrowRight } from "lucide-vue-next"
import { cn } from "@/lib/utils"
import { Button } from '@/components/ui/button'
import { useCarousel } from "./useCarousel"
const props = withDefaults(defineProps<{
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
}
& WithClassAsProps>(), {
variant: "outline",
size: "icon",
})
const { orientation, canScrollNext, scrollNext } = useCarousel()
</script>
<template>
<Button
data-slot="carousel-next"
:disabled="!canScrollNext"
:class="cn(
'absolute size-8 rounded-full',
orientation === 'horizontal'
? 'top-1/2 -right-12 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
props.class,
)"
:variant="variant"
:size="size"
@click="scrollNext"
>
<slot>
<ArrowRight />
<span class="sr-only">Next Slide</span>
</slot>
</Button>
</template>

View file

@ -0,0 +1,41 @@
<script setup lang="ts">
import type { WithClassAsProps } from "./interface"
import type { ButtonVariants } from '@/components/ui/button'
import { ArrowLeft } from "lucide-vue-next"
import { cn } from "@/lib/utils"
import { Button } from '@/components/ui/button'
import { useCarousel } from "./useCarousel"
const props = withDefaults(defineProps<{
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
}
& WithClassAsProps>(), {
variant: "outline",
size: "icon",
})
const { orientation, canScrollPrev, scrollPrev } = useCarousel()
</script>
<template>
<Button
data-slot="carousel-previous"
:disabled="!canScrollPrev"
:class="cn(
'absolute size-8 rounded-full',
orientation === 'horizontal'
? 'top-1/2 -left-12 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
props.class,
)"
:variant="variant"
:size="size"
@click="scrollPrev"
>
<slot>
<ArrowLeft />
<span class="sr-only">Previous Slide</span>
</slot>
</Button>
</template>

View file

@ -0,0 +1,10 @@
export { default as Carousel } from "./Carousel.vue"
export { default as CarouselContent } from "./CarouselContent.vue"
export { default as CarouselItem } from "./CarouselItem.vue"
export { default as CarouselNext } from "./CarouselNext.vue"
export { default as CarouselPrevious } from "./CarouselPrevious.vue"
export type {
UnwrapRefCarouselApi as CarouselApi,
} from "./interface"
export { useCarousel } from "./useCarousel"

View file

@ -0,0 +1,26 @@
import type useEmblaCarousel from "embla-carousel-vue"
import type {
EmblaCarouselVueType,
} from "embla-carousel-vue"
import type { HTMLAttributes, UnwrapRef } from "vue"
type CarouselApi = EmblaCarouselVueType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
export type UnwrapRefCarouselApi = UnwrapRef<CarouselApi>
export interface CarouselProps {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
}
export interface CarouselEmits {
(e: "init-api", payload: UnwrapRefCarouselApi): void
}
export interface WithClassAsProps {
class?: HTMLAttributes["class"]
}

View file

@ -0,0 +1,56 @@
import type { UnwrapRefCarouselApi as CarouselApi, CarouselEmits, CarouselProps } from "./interface"
import { createInjectionState } from "@vueuse/core"
import emblaCarouselVue from "embla-carousel-vue"
import { onMounted, ref } from "vue"
const [useProvideCarousel, useInjectCarousel] = createInjectionState(
({
opts,
orientation,
plugins,
}: CarouselProps, emits: CarouselEmits) => {
const [emblaNode, emblaApi] = emblaCarouselVue({
...opts,
axis: orientation === "horizontal" ? "x" : "y",
}, plugins)
function scrollPrev() {
emblaApi.value?.scrollPrev()
}
function scrollNext() {
emblaApi.value?.scrollNext()
}
const canScrollNext = ref(false)
const canScrollPrev = ref(false)
function onSelect(api: CarouselApi) {
canScrollNext.value = api?.canScrollNext() || false
canScrollPrev.value = api?.canScrollPrev() || false
}
onMounted(() => {
if (!emblaApi.value)
return
emblaApi.value?.on("init", onSelect)
emblaApi.value?.on("reInit", onSelect)
emblaApi.value?.on("select", onSelect)
emits("init-api", emblaApi.value)
})
return { carouselRef: emblaNode, carouselApi: emblaApi, canScrollPrev, canScrollNext, scrollPrev, scrollNext, orientation }
},
)
function useCarousel() {
const carouselState = useInjectCarousel()
if (!carouselState)
throw new Error("useCarousel must be used within a <Carousel />")
return carouselState
}
export { useCarousel, useProvideCarousel }

View file

@ -0,0 +1,59 @@
<script lang="ts">
import type { HTMLAttributes } from "vue"
import type { ChartConfig } from "."
import { useId } from "reka-ui"
import { computed, toRefs } from "vue"
import { cn } from "@/lib/utils"
import { provideChartContext } from "."
import ChartStyle from "./ChartStyle.vue"
</script>
<script setup lang="ts">
const props = defineProps<{
id?: HTMLAttributes["id"]
class?: HTMLAttributes["class"]
config: ChartConfig
cursor?: boolean
}>()
defineSlots<{
default: {
id: string
config: ChartConfig
}
}>()
const { config } = toRefs(props)
const uniqueId = useId()
const chartId = computed(() => `chart-${props.id || uniqueId.replace(/:/g, "")}`)
provideChartContext({
id: uniqueId,
config,
})
</script>
<template>
<div
data-slot="chart"
:data-chart="chartId"
:class="cn(
`[&_.tick_text]:!fill-muted-foreground [&_.tick_line]:!stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex flex-col aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden [&_[data-vis-xy-container]]:h-full [&_[data-vis-single-container]]:h-full h-full [&_[data-vis-xy-container]]:w-full [&_[data-vis-single-container]]:w-full w-full `,
props.class,
)"
:style="{
'--vis-tooltip-padding': '0px',
'--vis-tooltip-background-color': 'transparent',
'--vis-tooltip-border-color': 'transparent',
'--vis-tooltip-text-color': 'none',
'--vis-tooltip-shadow-color': 'none',
'--vis-tooltip-backdrop-filter': 'none',
'--vis-crosshair-circle-stroke-color': '#0000',
'--vis-crosshair-line-stroke-width': cursor ? '1px' : '0px',
'--vis-font-family': 'var(--font-sans)',
}"
>
<slot :id="uniqueId" :config="config" />
<ChartStyle :id="chartId" />
</div>
</template>

View file

@ -0,0 +1,60 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed, onMounted, ref } from "vue"
import { cn } from "@/lib/utils"
import { useChart } from "."
const props = withDefaults(defineProps<{
hideIcon?: boolean
nameKey?: string
verticalAlign?: "bottom" | "top"
// payload?: any[]
class?: HTMLAttributes["class"]
}>(), {
verticalAlign: "bottom",
})
const { id, config } = useChart()
const payload = computed(() => Object.entries(config.value).map(([key, value]) => {
return {
key: props.nameKey || key,
itemConfig: config.value[key],
}
}))
const containerSelector = ref("")
onMounted(() => {
containerSelector.value = `[data-chart="chart-${id}"]>[data-vis-xy-container]`
})
</script>
<template>
<div
v-if="containerSelector"
:class="cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
props.class,
)"
>
<div
v-for="{ key, itemConfig } in payload"
:key="key"
:class="cn(
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3',
)"
>
<component :is="itemConfig.icon" v-if="itemConfig?.icon" />
<div
v-else
class="h-2 w-2 shrink-0 rounded-[2px]"
:style="{
backgroundColor: itemConfig.color,
}"
/>
{{ itemConfig?.label }}
</div>
</div>
</template>

View file

@ -0,0 +1,42 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { Primitive } from "reka-ui"
import { computed } from "vue"
import { THEMES, useChart } from "."
defineProps<{
id?: HTMLAttributes["id"]
}>()
const { config } = useChart()
const colorConfig = computed(() => {
return Object.entries(config.value).filter(
([, config]) => config.theme || config.color,
)
})
</script>
<template>
<Primitive
v-if="colorConfig.length"
as="style"
>
{{ Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color
= itemConfig.theme?.[theme as keyof typeof itemConfig.theme]
|| itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`,
)
.join("\n") }}
</Primitive>
</template>

View file

@ -0,0 +1,105 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { ChartConfig } from "."
import { computed } from "vue"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<{
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
labelFormatter?: (d: number | Date) => string
payload?: Record<string, any>
config?: ChartConfig
class?: HTMLAttributes["class"]
color?: string
x?: number | Date
}>(), {
payload: () => ({}),
config: () => ({}),
indicator: "dot",
})
// TODO: currently we use `createElement` and `render` to render the
// const chartContext = useChart(null)
const payload = computed(() => {
return Object.entries(props.payload).map(([key, value]) => {
// const key = `${props.nameKey || item.name || item.dataKey || "value"}`
const itemConfig = props.config[key]
const indicatorColor = props.config[key]?.color ?? props.payload.fill
return { key, value, itemConfig, indicatorColor }
}).filter(i => i.itemConfig)
})
const nestLabel = computed(() => Object.keys(props.payload).length === 1 && props.indicator !== "dot")
const tooltipLabel = computed(() => {
if (props.hideLabel)
return null
if (props.labelFormatter && props.x !== undefined) {
return props.labelFormatter(props.x)
}
return props.labelKey ? props.config[props.labelKey]?.label || props.payload[props.labelKey] : props.x
})
</script>
<template>
<div
:class="cn(
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
props.class,
)"
>
<slot>
<div v-if="!nestLabel && tooltipLabel" class="font-medium">
{{ tooltipLabel }}
</div>
<div class="grid gap-1.5">
<div
v-for="{ value, itemConfig, indicatorColor, key } in payload"
:key="key"
:class="
cn('[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
indicator === 'dot' && 'items-center')"
>
<component :is="itemConfig.icon" v-if="itemConfig?.icon" />
<template v-else-if="!hideIndicator">
<div
:class="cn(
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
},
)"
:style="{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
}"
/>
</template>
<div :class="cn('flex flex-1 justify-between leading-none', nestLabel ? 'items-end' : 'items-center')">
<div class="grid gap-1.5">
<div v-if="nestLabel" class="font-medium">
{{ tooltipLabel }}
</div>
<span class="text-muted-foreground">
{{ itemConfig?.label || value }}
</span>
</div>
<span v-if="value" class="text-foreground font-mono font-medium tabular-nums">
{{ value.toLocaleString() }}
</span>
</div>
</div>
</div>
</slot>
</div>
</template>

View file

@ -0,0 +1,29 @@
import type { Component, Ref } from "vue"
import { createContext } from "reka-ui"
export { default as ChartContainer } from "./ChartContainer.vue"
export { default as ChartLegendContent } from "./ChartLegendContent.vue"
export { default as ChartTooltipContent } from "./ChartTooltipContent.vue"
export { componentToString } from "./utils"
// Format: { THEME_NAME: CSS_SELECTOR }
export const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: string | Component
icon?: string | Component
} & (
| { color?: string, theme?: never }
| { color?: never, theme: Record<keyof typeof THEMES, string> }
)
}
interface ChartContextProps {
id: string
config: Ref<ChartConfig>
}
export const [useChart, provideChartContext] = createContext<ChartContextProps>("Chart")
export { VisCrosshair as ChartCrosshair, VisTooltip as ChartTooltip } from "@unovis/vue"

View file

@ -0,0 +1,44 @@
import type { ChartConfig } from "."
import { isClient } from "@vueuse/core"
import { useId } from "reka-ui"
import { h, render } from "vue"
// Simple cache using a Map to store serialized object keys
const cache = new Map<string, string>()
// Convert object to a consistent string key
function serializeKey(key: Record<string, any>): string {
return JSON.stringify(key, Object.keys(key).sort())
}
interface Constructor<P = any> {
__isFragment?: never
__isTeleport?: never
__isSuspense?: never
new (...args: any[]): {
$props: P
}
}
export function componentToString<P>(config: ChartConfig, component: Constructor<P>, props?: P) {
if (!isClient)
return
// This function will be called once during mount lifecycle
const id = useId()
// https://unovis.dev/docs/auxiliary/Crosshair#component-props
return (_data: any, x: number | Date) => {
const data = "data" in _data ? _data.data : _data
const serializedKey = `${id}-${serializeKey(data)}`
const cachedContent = cache.get(serializedKey)
if (cachedContent)
return cachedContent
const vnode = h<unknown>(component, { ...props, payload: data, config, x })
const div = document.createElement("div")
render(vnode, div)
cache.set(serializedKey, div.innerHTML)
return div.innerHTML
}
}

View file

@ -0,0 +1,87 @@
<script setup lang="ts">
import type { ListboxRootEmits, ListboxRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ListboxRoot, useFilter, useForwardPropsEmits } from "reka-ui"
import { reactive, ref, watch } from "vue"
import { cn } from "@/lib/utils"
import { provideCommandContext } from "."
const props = withDefaults(defineProps<ListboxRootProps & { class?: HTMLAttributes["class"] }>(), {
modelValue: "",
})
const emits = defineEmits<ListboxRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const allItems = ref<Map<string, string>>(new Map())
const allGroups = ref<Map<string, Set<string>>>(new Map())
const { contains } = useFilter({ sensitivity: "base" })
const filterState = reactive({
search: "",
filtered: {
/** The count of all visible items. */
count: 0,
/** Map from visible item id to its search score. */
items: new Map() as Map<string, number>,
/** Set of groups with at least one visible item. */
groups: new Set() as Set<string>,
},
})
function filterItems() {
if (!filterState.search) {
filterState.filtered.count = allItems.value.size
// Do nothing, each item will know to show itself because search is empty
return
}
// Reset the groups
filterState.filtered.groups = new Set()
let itemCount = 0
// Check which items should be included
for (const [id, value] of allItems.value) {
const score = contains(value, filterState.search)
filterState.filtered.items.set(id, score ? 1 : 0)
if (score)
itemCount++
}
// Check which groups have at least 1 item shown
for (const [groupId, group] of allGroups.value) {
for (const itemId of group) {
if (filterState.filtered.items.get(itemId)! > 0) {
filterState.filtered.groups.add(groupId)
break
}
}
}
filterState.filtered.count = itemCount
}
watch(() => filterState.search, () => {
filterItems()
})
provideCommandContext({
allItems,
allGroups,
filterState,
})
</script>
<template>
<ListboxRoot
data-slot="command"
v-bind="forwarded"
:class="cn('bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md', props.class)"
>
<slot />
</ListboxRoot>
</template>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { useForwardPropsEmits } from "reka-ui"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import Command from "./Command.vue"
const props = withDefaults(defineProps<DialogRootProps & {
title?: string
description?: string
}>(), {
title: "Command Palette",
description: "Search for a command to run...",
})
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<Dialog v-slot="slotProps" v-bind="forwarded">
<DialogContent class="overflow-hidden p-0 ">
<DialogHeader class="sr-only">
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription>{{ description }}</DialogDescription>
</DialogHeader>
<Command>
<slot v-bind="slotProps" />
</Command>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Primitive } from "reka-ui"
import { computed } from "vue"
import { cn } from "@/lib/utils"
import { useCommand } from "."
const props = defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const { filterState } = useCommand()
const isRender = computed(() => !!filterState.search && filterState.filtered.count === 0,
)
</script>
<template>
<Primitive
v-if="isRender"
data-slot="command-empty"
v-bind="delegatedProps" :class="cn('py-6 text-center text-sm', props.class)"
>
<slot />
</Primitive>
</template>

View file

@ -0,0 +1,45 @@
<script setup lang="ts">
import type { ListboxGroupProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ListboxGroup, ListboxGroupLabel, useId } from "reka-ui"
import { computed, onMounted, onUnmounted } from "vue"
import { cn } from "@/lib/utils"
import { provideCommandGroupContext, useCommand } from "."
const props = defineProps<ListboxGroupProps & {
class?: HTMLAttributes["class"]
heading?: string
}>()
const delegatedProps = reactiveOmit(props, "class")
const { allGroups, filterState } = useCommand()
const id = useId()
const isRender = computed(() => !filterState.search ? true : filterState.filtered.groups.has(id))
provideCommandGroupContext({ id })
onMounted(() => {
if (!allGroups.value.has(id))
allGroups.value.set(id, new Set())
})
onUnmounted(() => {
allGroups.value.delete(id)
})
</script>
<template>
<ListboxGroup
v-bind="delegatedProps"
:id="id"
data-slot="command-group"
:class="cn('text-foreground overflow-hidden p-1', props.class)"
:hidden="isRender ? undefined : true"
>
<ListboxGroupLabel v-if="heading" data-slot="command-group-heading" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ heading }}
</ListboxGroupLabel>
<slot />
</ListboxGroup>
</template>

View file

@ -0,0 +1,39 @@
<script setup lang="ts">
import type { ListboxFilterProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Search } from "lucide-vue-next"
import { ListboxFilter, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { useCommand } from "."
defineOptions({
inheritAttrs: false,
})
const props = defineProps<ListboxFilterProps & {
class?: HTMLAttributes["class"]
}>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
const { filterState } = useCommand()
</script>
<template>
<div
data-slot="command-input-wrapper"
class="flex h-9 items-center gap-2 border-b px-3"
>
<Search class="size-4 shrink-0 opacity-50" />
<ListboxFilter
v-bind="{ ...forwardedProps, ...$attrs }"
v-model="filterState.search"
data-slot="command-input"
auto-focus
:class="cn('placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50', props.class)"
/>
</div>
</template>

View file

@ -0,0 +1,76 @@
<script setup lang="ts">
import type { ListboxItemEmits, ListboxItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit, useCurrentElement } from "@vueuse/core"
import { ListboxItem, useForwardPropsEmits, useId } from "reka-ui"
import { computed, onMounted, onUnmounted, ref } from "vue"
import { cn } from "@/lib/utils"
import { useCommand, useCommandGroup } from "."
const props = defineProps<ListboxItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<ListboxItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const id = useId()
const { filterState, allItems, allGroups } = useCommand()
const groupContext = useCommandGroup()
const isRender = computed(() => {
if (!filterState.search) {
return true
}
else {
const filteredCurrentItem = filterState.filtered.items.get(id)
// If the filtered items is undefined means not in the all times map yet
// Do the first render to add into the map
if (filteredCurrentItem === undefined) {
return true
}
// Check with filter
return filteredCurrentItem > 0
}
})
const itemRef = ref()
const currentElement = useCurrentElement(itemRef)
onMounted(() => {
if (!(currentElement.value instanceof HTMLElement))
return
// textValue to perform filter
allItems.value.set(id, currentElement.value.textContent ?? (props.value?.toString() ?? ""))
const groupId = groupContext?.id
if (groupId) {
if (!allGroups.value.has(groupId)) {
allGroups.value.set(groupId, new Set([id]))
}
else {
allGroups.value.get(groupId)?.add(id)
}
}
})
onUnmounted(() => {
allItems.value.delete(id)
})
</script>
<template>
<ListboxItem
v-if="isRender"
v-bind="forwarded"
:id="id"
ref="itemRef"
data-slot="command-item"
:class="cn('data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
@select="() => {
filterState.search = ''
}"
>
<slot />
</ListboxItem>
</template>

View file

@ -0,0 +1,25 @@
<script setup lang="ts">
import type { ListboxContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ListboxContent, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ListboxContentProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<ListboxContent
data-slot="command-list"
v-bind="forwarded"
:class="cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', props.class)"
>
<div role="presentation">
<slot />
</div>
</ListboxContent>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { SeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Separator } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SeparatorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Separator
data-slot="command-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 h-px', props.class)"
>
<slot />
</Separator>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span
data-slot="command-shortcut"
:class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)"
>
<slot />
</span>
</template>

View file

@ -0,0 +1,25 @@
import type { Ref } from "vue"
import { createContext } from "reka-ui"
export { default as Command } from "./Command.vue"
export { default as CommandDialog } from "./CommandDialog.vue"
export { default as CommandEmpty } from "./CommandEmpty.vue"
export { default as CommandGroup } from "./CommandGroup.vue"
export { default as CommandInput } from "./CommandInput.vue"
export { default as CommandItem } from "./CommandItem.vue"
export { default as CommandList } from "./CommandList.vue"
export { default as CommandSeparator } from "./CommandSeparator.vue"
export { default as CommandShortcut } from "./CommandShortcut.vue"
export const [useCommand, provideCommandContext] = createContext<{
allItems: Ref<Map<string, string>>
allGroups: Ref<Map<string, Set<string>>>
filterState: {
search: string
filtered: { count: number, items: Map<string, number>, groups: Set<string> }
}
}>("Command")
export const [useCommandGroup, provideCommandGroupContext] = createContext<{
id?: string
}>("CommandGroup")

View file

@ -0,0 +1,18 @@
<script setup lang="ts">
import type { ContextMenuRootEmits, ContextMenuRootProps } from "reka-ui"
import { ContextMenuRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<ContextMenuRootProps>()
const emits = defineEmits<ContextMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<ContextMenuRoot
data-slot="context-menu"
v-bind="forwarded"
>
<slot />
</ContextMenuRoot>
</template>

View file

@ -0,0 +1,39 @@
<script setup lang="ts">
import type { ContextMenuCheckboxItemEmits, ContextMenuCheckboxItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import {
ContextMenuCheckboxItem,
ContextMenuItemIndicator,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ContextMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<ContextMenuCheckboxItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuCheckboxItem
data-slot="context-menu-checkbox-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</ContextMenuItemIndicator>
</span>
<slot />
</ContextMenuCheckboxItem>
</template>

View file

@ -0,0 +1,37 @@
<script setup lang="ts">
import type { ContextMenuContentEmits, ContextMenuContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
ContextMenuContent,
ContextMenuPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<ContextMenuContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<ContextMenuContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuPortal>
<ContextMenuContent
data-slot="context-menu-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-context-menu-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
props.class,
)"
>
<slot />
</ContextMenuContent>
</ContextMenuPortal>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { ContextMenuGroupProps } from "reka-ui"
import { ContextMenuGroup } from "reka-ui"
const props = defineProps<ContextMenuGroupProps>()
</script>
<template>
<ContextMenuGroup
data-slot="context-menu-group"
v-bind="props"
>
<slot />
</ContextMenuGroup>
</template>

View file

@ -0,0 +1,38 @@
<script setup lang="ts">
import type { ContextMenuItemEmits, ContextMenuItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
ContextMenuItem,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<ContextMenuItemProps & {
class?: HTMLAttributes["class"]
inset?: boolean
variant?: "default" | "destructive"
}>(), {
variant: "default",
})
const emits = defineEmits<ContextMenuItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuItem
data-slot="context-menu-item"
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
>
<slot />
</ContextMenuItem>
</template>

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { ContextMenuLabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ContextMenuLabel } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ContextMenuLabelProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<ContextMenuLabel
data-slot="context-menu-label"
:data-inset="inset ? '' : undefined"
v-bind="delegatedProps"
:class="cn('text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)"
>
<slot />
</ContextMenuLabel>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { ContextMenuPortalProps } from "reka-ui"
import { ContextMenuPortal } from "reka-ui"
const props = defineProps<ContextMenuPortalProps>()
</script>
<template>
<ContextMenuPortal
data-slot="context-menu-portal"
v-bind="props"
>
<slot />
</ContextMenuPortal>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { ContextMenuRadioGroupEmits, ContextMenuRadioGroupProps } from "reka-ui"
import {
ContextMenuRadioGroup,
useForwardPropsEmits,
} from "reka-ui"
const props = defineProps<ContextMenuRadioGroupProps>()
const emits = defineEmits<ContextMenuRadioGroupEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<ContextMenuRadioGroup
data-slot="context-menu-radio-group"
v-bind="forwarded"
>
<slot />
</ContextMenuRadioGroup>
</template>

View file

@ -0,0 +1,39 @@
<script setup lang="ts">
import type { ContextMenuRadioItemEmits, ContextMenuRadioItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Circle } from "lucide-vue-next"
import {
ContextMenuItemIndicator,
ContextMenuRadioItem,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ContextMenuRadioItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<ContextMenuRadioItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuRadioItem
data-slot="context-menu-radio-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuItemIndicator>
<slot name="indicator-icon">
<Circle class="size-2 fill-current" />
</slot>
</ContextMenuItemIndicator>
</span>
<slot />
</ContextMenuRadioItem>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { ContextMenuSeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
ContextMenuSeparator,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ContextMenuSeparatorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<ContextMenuSeparator
data-slot="context-menu-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
/>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span
data-slot="context-menu-shortcut"
:class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)"
>
<slot />
</span>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { ContextMenuSubEmits, ContextMenuSubProps } from "reka-ui"
import {
ContextMenuSub,
useForwardPropsEmits,
} from "reka-ui"
const props = defineProps<ContextMenuSubProps>()
const emits = defineEmits<ContextMenuSubEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<ContextMenuSub
data-slot="context-menu-sub"
v-bind="forwarded"
>
<slot />
</ContextMenuSub>
</template>

View file

@ -0,0 +1,32 @@
<script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
ContextMenuSubContent,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuSubContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuSubContent
data-slot="context-menu-sub-content"
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
props.class,
)
"
>
<slot />
</ContextMenuSubContent>
</template>

View file

@ -0,0 +1,32 @@
<script setup lang="ts">
import type { ContextMenuSubTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronRight } from "lucide-vue-next"
import {
ContextMenuSubTrigger,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ContextMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<ContextMenuSubTrigger
data-slot="context-menu-sub-trigger"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
>
<slot />
<ChevronRight class="ml-auto" />
</ContextMenuSubTrigger>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { ContextMenuTriggerProps } from "reka-ui"
import { ContextMenuTrigger, useForwardProps } from "reka-ui"
const props = defineProps<ContextMenuTriggerProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<ContextMenuTrigger
data-slot="context-menu-trigger"
v-bind="forwardedProps"
>
<slot />
</ContextMenuTrigger>
</template>

View file

@ -0,0 +1,14 @@
export { default as ContextMenu } from "./ContextMenu.vue"
export { default as ContextMenuCheckboxItem } from "./ContextMenuCheckboxItem.vue"
export { default as ContextMenuContent } from "./ContextMenuContent.vue"
export { default as ContextMenuGroup } from "./ContextMenuGroup.vue"
export { default as ContextMenuItem } from "./ContextMenuItem.vue"
export { default as ContextMenuLabel } from "./ContextMenuLabel.vue"
export { default as ContextMenuRadioGroup } from "./ContextMenuRadioGroup.vue"
export { default as ContextMenuRadioItem } from "./ContextMenuRadioItem.vue"
export { default as ContextMenuSeparator } from "./ContextMenuSeparator.vue"
export { default as ContextMenuShortcut } from "./ContextMenuShortcut.vue"
export { default as ContextMenuSub } from "./ContextMenuSub.vue"
export { default as ContextMenuSubContent } from "./ContextMenuSubContent.vue"
export { default as ContextMenuSubTrigger } from "./ContextMenuSubTrigger.vue"
export { default as ContextMenuTrigger } from "./ContextMenuTrigger.vue"

View file

@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { DrawerRootEmits, DrawerRootProps } from "vaul-vue"
import { useForwardPropsEmits } from "reka-ui"
import { DrawerRoot } from "vaul-vue"
const props = withDefaults(defineProps<DrawerRootProps>(), {
shouldScaleBackground: true,
})
const emits = defineEmits<DrawerRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DrawerRoot
v-slot="slotProps"
data-slot="drawer"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DrawerRoot>
</template>

View file

@ -0,0 +1,15 @@
<script lang="ts" setup>
import type { DrawerCloseProps } from "vaul-vue"
import { DrawerClose } from "vaul-vue"
const props = defineProps<DrawerCloseProps>()
</script>
<template>
<DrawerClose
data-slot="drawer-close"
v-bind="props"
>
<slot />
</DrawerClose>
</template>

View file

@ -0,0 +1,38 @@
<script lang="ts" setup>
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { useForwardPropsEmits } from "reka-ui"
import { DrawerContent, DrawerPortal } from "vaul-vue"
import { cn } from "@/lib/utils"
import DrawerOverlay from "./DrawerOverlay.vue"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DrawerPortal>
<DrawerOverlay />
<DrawerContent
data-slot="drawer-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn(
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg',
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg',
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:sm:max-w-sm',
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:sm:max-w-sm',
props.class,
)"
>
<div class="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
<slot />
</DrawerContent>
</DrawerPortal>
</template>

View file

@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { DrawerDescriptionProps } from "vaul-vue"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DrawerDescription } from "vaul-vue"
import { cn } from "@/lib/utils"
const props = defineProps<DrawerDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DrawerDescription
data-slot="drawer-description"
v-bind="delegatedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</DrawerDescription>
</template>

View file

@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="drawer-footer"
:class="cn('mt-auto flex flex-col gap-2 p-4', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="drawer-header"
:class="cn('flex flex-col gap-1.5 p-4', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { DialogOverlayProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DrawerOverlay } from "vaul-vue"
import { cn } from "@/lib/utils"
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DrawerOverlay
data-slot="drawer-overlay"
v-bind="delegatedProps"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
/>
</template>

View file

@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { DrawerTitleProps } from "vaul-vue"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DrawerTitle } from "vaul-vue"
import { cn } from "@/lib/utils"
const props = defineProps<DrawerTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DrawerTitle
data-slot="drawer-title"
v-bind="delegatedProps"
:class="cn('text-foreground font-semibold', props.class)"
>
<slot />
</DrawerTitle>
</template>

View file

@ -0,0 +1,15 @@
<script lang="ts" setup>
import type { DrawerTriggerProps } from "vaul-vue"
import { DrawerTrigger } from "vaul-vue"
const props = defineProps<DrawerTriggerProps>()
</script>
<template>
<DrawerTrigger
data-slot="drawer-trigger"
v-bind="props"
>
<slot />
</DrawerTrigger>
</template>

View file

@ -0,0 +1,9 @@
export { default as Drawer } from "./Drawer.vue"
export { default as DrawerClose } from "./DrawerClose.vue"
export { default as DrawerContent } from "./DrawerContent.vue"
export { default as DrawerDescription } from "./DrawerDescription.vue"
export { default as DrawerFooter } from "./DrawerFooter.vue"
export { default as DrawerHeader } from "./DrawerHeader.vue"
export { default as DrawerOverlay } from "./DrawerOverlay.vue"
export { default as DrawerTitle } from "./DrawerTitle.vue"
export { default as DrawerTrigger } from "./DrawerTrigger.vue"

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="empty"
:class="cn(
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12',
props.class,
)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="empty-content"
:class="cn(
'flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm',
props.class,
)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<p
data-slot="empty-description"
:class="cn(
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
$attrs.class ?? '',
)"
>
<slot />
</p>
</template>

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="empty-header"
:class="cn(
'flex max-w-sm flex-col items-center gap-2 text-center',
props.class,
)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { EmptyMediaVariants } from "."
import { cn } from "@/lib/utils"
import { emptyMediaVariants } from "."
const props = defineProps<{
class?: HTMLAttributes["class"]
variant?: EmptyMediaVariants["variant"]
}>()
</script>
<template>
<div
data-slot="empty-icon"
:data-variant="variant"
:class="cn(emptyMediaVariants({ variant }), props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="empty-title"
:class="cn('text-lg font-medium tracking-tight', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,26 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Empty } from "./Empty.vue"
export { default as EmptyContent } from "./EmptyContent.vue"
export { default as EmptyDescription } from "./EmptyDescription.vue"
export { default as EmptyHeader } from "./EmptyHeader.vue"
export { default as EmptyMedia } from "./EmptyMedia.vue"
export { default as EmptyTitle } from "./EmptyTitle.vue"
export const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type EmptyMediaVariants = VariantProps<typeof emptyMediaVariants>

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HoverCardRootEmits, HoverCardRootProps } from "reka-ui"
import { HoverCardRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<HoverCardRootProps>()
const emits = defineEmits<HoverCardRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<HoverCardRoot
v-slot="slotProps"
data-slot="hover-card"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</HoverCardRoot>
</template>

View file

@ -0,0 +1,43 @@
<script setup lang="ts">
import type { HoverCardContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
HoverCardContent,
HoverCardPortal,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<HoverCardContentProps & { class?: HTMLAttributes["class"] }>(),
{
sideOffset: 4,
},
)
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<HoverCardPortal>
<HoverCardContent
data-slot="hover-card-content"
v-bind="{ ...$attrs, ...forwardedProps }"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 rounded-md border p-4 shadow-md outline-hidden',
props.class,
)
"
>
<slot />
</HoverCardContent>
</HoverCardPortal>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HoverCardTriggerProps } from "reka-ui"
import { HoverCardTrigger } from "reka-ui"
const props = defineProps<HoverCardTriggerProps>()
</script>
<template>
<HoverCardTrigger
data-slot="hover-card-trigger"
v-bind="props"
>
<slot />
</HoverCardTrigger>
</template>

View file

@ -0,0 +1,3 @@
export { default as HoverCard } from "./HoverCard.vue"
export { default as HoverCardContent } from "./HoverCardContent.vue"
export { default as HoverCardTrigger } from "./HoverCardTrigger.vue"

Some files were not shown because too many files have changed in this diff Show more