feat: Implement user onboarding, subscription management, and refactor social integrations with new UI components and mail templates.
This commit is contained in:
parent
d284a14c75
commit
d538537706
164 changed files with 7446 additions and 497 deletions
179
app/Enums/PostPlatform/ContentType.php
Normal file
179
app/Enums/PostPlatform/ContentType.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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/')) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
135
app/Http/Controllers/WorkspaceHashtagController.php
Normal file
135
app/Http/Controllers/WorkspaceHashtagController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
135
app/Http/Controllers/WorkspaceLabelController.php
Normal file
135
app/Http/Controllers/WorkspaceLabelController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
25
app/Models/WorkspaceHashtag.php
Normal file
25
app/Models/WorkspaceHashtag.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
app/Models/WorkspaceLabel.php
Normal file
25
app/Models/WorkspaceLabel.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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() ?? [];
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
26
database/factories/WorkspaceHashtagFactory.php
Normal file
26
database/factories/WorkspaceHashtagFactory.php
Normal 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)),
|
||||
];
|
||||
}
|
||||
}
|
||||
28
database/factories/WorkspaceLabelFactory.php
Normal file
28
database/factories/WorkspaceLabelFactory.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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
163
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
201
resources/js/components/posts/previews/FacebookPreview.vue
Normal file
201
resources/js/components/posts/previews/FacebookPreview.vue
Normal 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>
|
||||
370
resources/js/components/posts/previews/InstagramPreview.vue
Normal file
370
resources/js/components/posts/previews/InstagramPreview.vue
Normal 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>
|
||||
206
resources/js/components/posts/previews/LinkedInPreview.vue
Normal file
206
resources/js/components/posts/previews/LinkedInPreview.vue
Normal 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>
|
||||
98
resources/js/components/posts/previews/PlatformPreview.vue
Normal file
98
resources/js/components/posts/previews/PlatformPreview.vue
Normal 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>
|
||||
191
resources/js/components/posts/previews/ThreadsPreview.vue
Normal file
191
resources/js/components/posts/previews/ThreadsPreview.vue
Normal 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>
|
||||
190
resources/js/components/posts/previews/TikTokPreview.vue
Normal file
190
resources/js/components/posts/previews/TikTokPreview.vue
Normal 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>
|
||||
207
resources/js/components/posts/previews/XPreview.vue
Normal file
207
resources/js/components/posts/previews/XPreview.vue
Normal 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>
|
||||
200
resources/js/components/posts/previews/YouTubePreview.vue
Normal file
200
resources/js/components/posts/previews/YouTubePreview.vue
Normal 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>
|
||||
8
resources/js/components/posts/previews/index.ts
Normal file
8
resources/js/components/posts/previews/index.ts
Normal 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';
|
||||
16
resources/js/components/ui/aspect-ratio/AspectRatio.vue
Normal file
16
resources/js/components/ui/aspect-ratio/AspectRatio.vue
Normal 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>
|
||||
1
resources/js/components/ui/aspect-ratio/index.ts
Normal file
1
resources/js/components/ui/aspect-ratio/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as AspectRatio } from "./AspectRatio.vue"
|
||||
22
resources/js/components/ui/button-group/ButtonGroup.vue
Normal file
22
resources/js/components/ui/button-group/ButtonGroup.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
29
resources/js/components/ui/button-group/ButtonGroupText.vue
Normal file
29
resources/js/components/ui/button-group/ButtonGroupText.vue
Normal 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>
|
||||
25
resources/js/components/ui/button-group/index.ts
Normal file
25
resources/js/components/ui/button-group/index.ts
Normal 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>
|
||||
53
resources/js/components/ui/carousel/Carousel.vue
Normal file
53
resources/js/components/ui/carousel/Carousel.vue
Normal 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>
|
||||
33
resources/js/components/ui/carousel/CarouselContent.vue
Normal file
33
resources/js/components/ui/carousel/CarouselContent.vue
Normal 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>
|
||||
24
resources/js/components/ui/carousel/CarouselItem.vue
Normal file
24
resources/js/components/ui/carousel/CarouselItem.vue
Normal 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>
|
||||
41
resources/js/components/ui/carousel/CarouselNext.vue
Normal file
41
resources/js/components/ui/carousel/CarouselNext.vue
Normal 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>
|
||||
41
resources/js/components/ui/carousel/CarouselPrevious.vue
Normal file
41
resources/js/components/ui/carousel/CarouselPrevious.vue
Normal 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>
|
||||
10
resources/js/components/ui/carousel/index.ts
Normal file
10
resources/js/components/ui/carousel/index.ts
Normal 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"
|
||||
26
resources/js/components/ui/carousel/interface.ts
Normal file
26
resources/js/components/ui/carousel/interface.ts
Normal 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"]
|
||||
}
|
||||
56
resources/js/components/ui/carousel/useCarousel.ts
Normal file
56
resources/js/components/ui/carousel/useCarousel.ts
Normal 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 }
|
||||
59
resources/js/components/ui/chart/ChartContainer.vue
Normal file
59
resources/js/components/ui/chart/ChartContainer.vue
Normal 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>
|
||||
60
resources/js/components/ui/chart/ChartLegendContent.vue
Normal file
60
resources/js/components/ui/chart/ChartLegendContent.vue
Normal 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>
|
||||
42
resources/js/components/ui/chart/ChartStyle.vue
Normal file
42
resources/js/components/ui/chart/ChartStyle.vue
Normal 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>
|
||||
105
resources/js/components/ui/chart/ChartTooltipContent.vue
Normal file
105
resources/js/components/ui/chart/ChartTooltipContent.vue
Normal 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>
|
||||
29
resources/js/components/ui/chart/index.ts
Normal file
29
resources/js/components/ui/chart/index.ts
Normal 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"
|
||||
44
resources/js/components/ui/chart/utils.ts
Normal file
44
resources/js/components/ui/chart/utils.ts
Normal 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
|
||||
}
|
||||
}
|
||||
87
resources/js/components/ui/command/Command.vue
Normal file
87
resources/js/components/ui/command/Command.vue
Normal 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>
|
||||
31
resources/js/components/ui/command/CommandDialog.vue
Normal file
31
resources/js/components/ui/command/CommandDialog.vue
Normal 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>
|
||||
27
resources/js/components/ui/command/CommandEmpty.vue
Normal file
27
resources/js/components/ui/command/CommandEmpty.vue
Normal 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>
|
||||
45
resources/js/components/ui/command/CommandGroup.vue
Normal file
45
resources/js/components/ui/command/CommandGroup.vue
Normal 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>
|
||||
39
resources/js/components/ui/command/CommandInput.vue
Normal file
39
resources/js/components/ui/command/CommandInput.vue
Normal 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>
|
||||
76
resources/js/components/ui/command/CommandItem.vue
Normal file
76
resources/js/components/ui/command/CommandItem.vue
Normal 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>
|
||||
25
resources/js/components/ui/command/CommandList.vue
Normal file
25
resources/js/components/ui/command/CommandList.vue
Normal 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>
|
||||
21
resources/js/components/ui/command/CommandSeparator.vue
Normal file
21
resources/js/components/ui/command/CommandSeparator.vue
Normal 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>
|
||||
17
resources/js/components/ui/command/CommandShortcut.vue
Normal file
17
resources/js/components/ui/command/CommandShortcut.vue
Normal 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>
|
||||
25
resources/js/components/ui/command/index.ts
Normal file
25
resources/js/components/ui/command/index.ts
Normal 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")
|
||||
18
resources/js/components/ui/context-menu/ContextMenu.vue
Normal file
18
resources/js/components/ui/context-menu/ContextMenu.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
15
resources/js/components/ui/context-menu/ContextMenuGroup.vue
Normal file
15
resources/js/components/ui/context-menu/ContextMenuGroup.vue
Normal 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>
|
||||
38
resources/js/components/ui/context-menu/ContextMenuItem.vue
Normal file
38
resources/js/components/ui/context-menu/ContextMenuItem.vue
Normal 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>
|
||||
22
resources/js/components/ui/context-menu/ContextMenuLabel.vue
Normal file
22
resources/js/components/ui/context-menu/ContextMenuLabel.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
21
resources/js/components/ui/context-menu/ContextMenuSub.vue
Normal file
21
resources/js/components/ui/context-menu/ContextMenuSub.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
14
resources/js/components/ui/context-menu/index.ts
Normal file
14
resources/js/components/ui/context-menu/index.ts
Normal 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"
|
||||
23
resources/js/components/ui/drawer/Drawer.vue
Normal file
23
resources/js/components/ui/drawer/Drawer.vue
Normal 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>
|
||||
15
resources/js/components/ui/drawer/DrawerClose.vue
Normal file
15
resources/js/components/ui/drawer/DrawerClose.vue
Normal 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>
|
||||
38
resources/js/components/ui/drawer/DrawerContent.vue
Normal file
38
resources/js/components/ui/drawer/DrawerContent.vue
Normal 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>
|
||||
21
resources/js/components/ui/drawer/DrawerDescription.vue
Normal file
21
resources/js/components/ui/drawer/DrawerDescription.vue
Normal 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>
|
||||
17
resources/js/components/ui/drawer/DrawerFooter.vue
Normal file
17
resources/js/components/ui/drawer/DrawerFooter.vue
Normal 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>
|
||||
17
resources/js/components/ui/drawer/DrawerHeader.vue
Normal file
17
resources/js/components/ui/drawer/DrawerHeader.vue
Normal 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>
|
||||
19
resources/js/components/ui/drawer/DrawerOverlay.vue
Normal file
19
resources/js/components/ui/drawer/DrawerOverlay.vue
Normal 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>
|
||||
21
resources/js/components/ui/drawer/DrawerTitle.vue
Normal file
21
resources/js/components/ui/drawer/DrawerTitle.vue
Normal 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>
|
||||
15
resources/js/components/ui/drawer/DrawerTrigger.vue
Normal file
15
resources/js/components/ui/drawer/DrawerTrigger.vue
Normal 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>
|
||||
9
resources/js/components/ui/drawer/index.ts
Normal file
9
resources/js/components/ui/drawer/index.ts
Normal 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"
|
||||
20
resources/js/components/ui/empty/Empty.vue
Normal file
20
resources/js/components/ui/empty/Empty.vue
Normal 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>
|
||||
20
resources/js/components/ui/empty/EmptyContent.vue
Normal file
20
resources/js/components/ui/empty/EmptyContent.vue
Normal 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>
|
||||
20
resources/js/components/ui/empty/EmptyDescription.vue
Normal file
20
resources/js/components/ui/empty/EmptyDescription.vue
Normal 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>
|
||||
20
resources/js/components/ui/empty/EmptyHeader.vue
Normal file
20
resources/js/components/ui/empty/EmptyHeader.vue
Normal 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>
|
||||
21
resources/js/components/ui/empty/EmptyMedia.vue
Normal file
21
resources/js/components/ui/empty/EmptyMedia.vue
Normal 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>
|
||||
17
resources/js/components/ui/empty/EmptyTitle.vue
Normal file
17
resources/js/components/ui/empty/EmptyTitle.vue
Normal 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>
|
||||
26
resources/js/components/ui/empty/index.ts
Normal file
26
resources/js/components/ui/empty/index.ts
Normal 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>
|
||||
19
resources/js/components/ui/hover-card/HoverCard.vue
Normal file
19
resources/js/components/ui/hover-card/HoverCard.vue
Normal 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>
|
||||
43
resources/js/components/ui/hover-card/HoverCardContent.vue
Normal file
43
resources/js/components/ui/hover-card/HoverCardContent.vue
Normal 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>
|
||||
15
resources/js/components/ui/hover-card/HoverCardTrigger.vue
Normal file
15
resources/js/components/ui/hover-card/HoverCardTrigger.vue
Normal 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>
|
||||
3
resources/js/components/ui/hover-card/index.ts
Normal file
3
resources/js/components/ui/hover-card/index.ts
Normal 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
Loading…
Reference in a new issue