chore: first commit
This commit is contained in:
parent
6890147aa6
commit
7c80d717b1
136 changed files with 8931 additions and 2032 deletions
|
|
@ -15,6 +15,7 @@ ## Foundational Context
|
|||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/horizon (HORIZON) - v5
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/reverb (REVERB) - v1
|
||||
- laravel/socialite (SOCIALITE) - v5
|
||||
- laravel/wayfinder (WAYFINDER) - v0
|
||||
- laravel/mcp (MCP) - v0
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ ## Foundational Context
|
|||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/horizon (HORIZON) - v5
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/reverb (REVERB) - v1
|
||||
- laravel/socialite (SOCIALITE) - v5
|
||||
- laravel/wayfinder (WAYFINDER) - v0
|
||||
- laravel/mcp (MCP) - v0
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ enum InviteStatus: string
|
|||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'Pendente',
|
||||
self::Accepted => 'Aceito',
|
||||
self::Expired => 'Expirado',
|
||||
self::Cancelled => 'Cancelado',
|
||||
self::Pending => 'Pending',
|
||||
self::Accepted => 'Accepted',
|
||||
self::Expired => 'Expired',
|
||||
self::Cancelled => 'Cancelled',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ enum PostStatus: string
|
|||
case Scheduled = 'scheduled';
|
||||
case Publishing = 'publishing';
|
||||
case Published = 'published';
|
||||
case PartiallyPublished = 'partially_published';
|
||||
case Failed = 'failed';
|
||||
|
||||
public function label(): string
|
||||
|
|
@ -17,6 +18,7 @@ public function label(): string
|
|||
self::Scheduled => 'Agendado',
|
||||
self::Publishing => 'Publicando',
|
||||
self::Published => 'Publicado',
|
||||
self::PartiallyPublished => 'Parcialmente Publicado',
|
||||
self::Failed => 'Falhou',
|
||||
};
|
||||
}
|
||||
|
|
@ -28,6 +30,7 @@ public function color(): string
|
|||
self::Scheduled => 'blue',
|
||||
self::Publishing => 'yellow',
|
||||
self::Published => 'green',
|
||||
self::PartiallyPublished => 'orange',
|
||||
self::Failed => 'red',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ enum SocialPlatform: string
|
|||
case LinkedInPage = 'linkedin-page';
|
||||
case X = 'x';
|
||||
case TikTok = 'tiktok';
|
||||
case YouTube = 'youtube';
|
||||
case Facebook = 'facebook';
|
||||
case Instagram = 'instagram';
|
||||
case Threads = 'threads';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
|
|
@ -16,6 +20,10 @@ public function label(): string
|
|||
self::LinkedInPage => 'LinkedIn Page',
|
||||
self::X => 'X',
|
||||
self::TikTok => 'TikTok',
|
||||
self::YouTube => 'YouTube Shorts',
|
||||
self::Facebook => 'Facebook Page',
|
||||
self::Instagram => 'Instagram',
|
||||
self::Threads => 'Threads',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -25,6 +33,10 @@ public function color(): string
|
|||
self::LinkedIn, self::LinkedInPage => '#0A66C2',
|
||||
self::X => '#000000',
|
||||
self::TikTok => '#000000',
|
||||
self::YouTube => '#FF0000',
|
||||
self::Facebook => '#1877F2',
|
||||
self::Instagram => '#E4405F',
|
||||
self::Threads => '#000000',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -34,6 +46,10 @@ public function allowedMediaTypes(): array
|
|||
self::LinkedIn, self::LinkedInPage => [MediaType::Image, MediaType::Video, MediaType::Document],
|
||||
self::X => [MediaType::Image, MediaType::Video],
|
||||
self::TikTok => [MediaType::Video],
|
||||
self::YouTube => [MediaType::Video],
|
||||
self::Facebook => [MediaType::Image, MediaType::Video],
|
||||
self::Instagram => [MediaType::Image, MediaType::Video],
|
||||
self::Threads => [MediaType::Image, MediaType::Video],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -43,6 +59,10 @@ public function maxImages(): int
|
|||
self::LinkedIn, self::LinkedInPage => 1,
|
||||
self::X => 4,
|
||||
self::TikTok => 0,
|
||||
self::YouTube => 0,
|
||||
self::Facebook => 10,
|
||||
self::Instagram => 10,
|
||||
self::Threads => 10,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +72,10 @@ public function maxContentLength(): int
|
|||
self::LinkedIn, self::LinkedInPage => 3000,
|
||||
self::X => 280,
|
||||
self::TikTok => 2200,
|
||||
self::YouTube => 5000,
|
||||
self::Facebook => 63206,
|
||||
self::Instagram => 2200,
|
||||
self::Threads => 500,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -61,6 +85,10 @@ public function supportsTextOnly(): bool
|
|||
self::LinkedIn, self::LinkedInPage => true,
|
||||
self::X => true,
|
||||
self::TikTok => false,
|
||||
self::YouTube => false,
|
||||
self::Facebook => true,
|
||||
self::Instagram => false,
|
||||
self::Threads => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,31 @@
|
|||
enum WorkspaceRole: string
|
||||
{
|
||||
case Owner = 'owner';
|
||||
case Admin = 'admin';
|
||||
case Member = 'member';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Owner => 'Proprietário',
|
||||
self::Member => 'Membro',
|
||||
self::Owner => 'Owner',
|
||||
self::Admin => 'Admin',
|
||||
self::Member => 'Member',
|
||||
};
|
||||
}
|
||||
|
||||
public function canManageTeam(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::Owner, self::Admin => true,
|
||||
self::Member => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function canManageAccounts(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::Owner, self::Admin => true,
|
||||
self::Member => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
246
app/Http/Controllers/Auth/FacebookController.php
Normal file
246
app/Http/Controllers/Auth/FacebookController.php
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Enums\SocialPlatform;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class FacebookController extends SocialController
|
||||
{
|
||||
protected string $driver = 'facebook';
|
||||
|
||||
protected SocialPlatform $platform = SocialPlatform::Facebook;
|
||||
|
||||
protected array $scopes = [
|
||||
'pages_show_list',
|
||||
'pages_read_engagement',
|
||||
'pages_manage_posts',
|
||||
'pages_read_user_content',
|
||||
];
|
||||
|
||||
public function connect(Request $request, Workspace $workspace): Response
|
||||
{
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
return back()->with('error', 'This platform is already connected.');
|
||||
}
|
||||
|
||||
session(['social_connect_workspace' => $workspace->id]);
|
||||
|
||||
return Inertia::location(
|
||||
Socialite::driver($this->driver)
|
||||
->scopes($this->scopes)
|
||||
->redirect()
|
||||
->getTargetUrl()
|
||||
);
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
$workspaceId = session('social_connect_workspace');
|
||||
|
||||
if (! $workspaceId) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Session expired. Please try again.');
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'This platform is already connected.');
|
||||
}
|
||||
|
||||
try {
|
||||
$socialUser = Socialite::driver($this->driver)->user();
|
||||
|
||||
// Fetch pages the user manages
|
||||
$pages = $this->fetchPages($socialUser->token);
|
||||
|
||||
if (empty($pages)) {
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'No Facebook Pages found. You need to be an admin of at least one page.');
|
||||
}
|
||||
|
||||
// If only one page, connect directly
|
||||
if (count($pages) === 1) {
|
||||
$page = $pages[0];
|
||||
$avatarPath = uploadFromUrl($page['picture']);
|
||||
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => $page['id'],
|
||||
'username' => $page['username'] ?? null,
|
||||
'display_name' => $page['name'],
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $page['access_token'],
|
||||
'refresh_token' => null, // Page tokens don't expire if user token is long-lived
|
||||
'token_expires_at' => null,
|
||||
'scopes' => $this->scopes,
|
||||
'meta' => [
|
||||
'page_id' => $page['id'],
|
||||
'user_id' => $socialUser->getId(),
|
||||
'user_token' => $socialUser->token,
|
||||
],
|
||||
]);
|
||||
|
||||
session()->forget('social_connect_workspace');
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('success', 'Facebook Page connected successfully!');
|
||||
}
|
||||
|
||||
// Multiple pages - store data and show selection
|
||||
session([
|
||||
'facebook_oauth' => [
|
||||
'user_token' => $socialUser->token,
|
||||
'user_id' => $socialUser->getId(),
|
||||
'pages' => $pages,
|
||||
],
|
||||
]);
|
||||
|
||||
return redirect()->route('social.facebook.select-page');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Facebook OAuth Error', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'Error connecting account. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
public function selectPage(Request $request)
|
||||
{
|
||||
$oauthData = session('facebook_oauth');
|
||||
$workspaceId = session('social_connect_workspace');
|
||||
|
||||
if (! $oauthData || ! $workspaceId) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Session expired. Please try again.');
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if (! $workspace) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
return Inertia::render('accounts/FacebookPageSelect', [
|
||||
'workspace' => $workspace,
|
||||
'pages' => $oauthData['pages'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function select(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'page_id' => 'required|string',
|
||||
]);
|
||||
|
||||
$oauthData = session('facebook_oauth');
|
||||
$workspaceId = session('social_connect_workspace');
|
||||
|
||||
if (! $oauthData || ! $workspaceId) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Session expired. Please try again.');
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
try {
|
||||
$selectedPage = collect($oauthData['pages'])->firstWhere('id', $request->page_id);
|
||||
|
||||
if (! $selectedPage) {
|
||||
return redirect()->route('social.facebook.select-page')
|
||||
->with('error', 'Page not found.');
|
||||
}
|
||||
|
||||
$avatarPath = uploadFromUrl($selectedPage['picture']);
|
||||
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => $selectedPage['id'],
|
||||
'username' => $selectedPage['username'] ?? null,
|
||||
'display_name' => $selectedPage['name'],
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $selectedPage['access_token'],
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => null,
|
||||
'scopes' => $this->scopes,
|
||||
'meta' => [
|
||||
'page_id' => $selectedPage['id'],
|
||||
'user_id' => $oauthData['user_id'],
|
||||
'user_token' => $oauthData['user_token'],
|
||||
],
|
||||
]);
|
||||
|
||||
session()->forget(['facebook_oauth', 'social_connect_workspace']);
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('success', 'Facebook Page connected successfully!');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Facebook page selection error', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'Error connecting page. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
private function fetchPages(string $userToken): array
|
||||
{
|
||||
try {
|
||||
$response = Http::get('https://graph.facebook.com/v21.0/me/accounts', [
|
||||
'access_token' => $userToken,
|
||||
'fields' => 'id,name,username,picture{url},access_token',
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Facebook pages fetch failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
return collect($data['data'] ?? [])->map(fn ($page) => [
|
||||
'id' => $page['id'],
|
||||
'name' => $page['name'],
|
||||
'username' => $page['username'] ?? null,
|
||||
'picture' => $page['picture']['data']['url'] ?? null,
|
||||
'access_token' => $page['access_token'],
|
||||
])->toArray();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Facebook pages fetch error', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
260
app/Http/Controllers/Auth/InstagramController.php
Normal file
260
app/Http/Controllers/Auth/InstagramController.php
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Enums\SocialPlatform;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class InstagramController extends SocialController
|
||||
{
|
||||
protected string $driver = 'facebook';
|
||||
|
||||
protected SocialPlatform $platform = SocialPlatform::Instagram;
|
||||
|
||||
protected array $scopes = [
|
||||
'instagram_basic',
|
||||
'instagram_content_publish',
|
||||
'pages_show_list',
|
||||
'pages_read_engagement',
|
||||
];
|
||||
|
||||
public function connect(Request $request, Workspace $workspace): Response
|
||||
{
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
return back()->with('error', 'This platform is already connected.');
|
||||
}
|
||||
|
||||
session(['social_connect_workspace' => $workspace->id]);
|
||||
|
||||
return Inertia::location(
|
||||
Socialite::driver($this->driver)
|
||||
->scopes($this->scopes)
|
||||
->redirect()
|
||||
->getTargetUrl()
|
||||
);
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
$workspaceId = session('social_connect_workspace');
|
||||
|
||||
if (! $workspaceId) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Session expired. Please try again.');
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'This platform is already connected.');
|
||||
}
|
||||
|
||||
try {
|
||||
$socialUser = Socialite::driver($this->driver)->user();
|
||||
|
||||
// Fetch Instagram accounts linked to Facebook pages
|
||||
$accounts = $this->fetchInstagramAccounts($socialUser->token);
|
||||
|
||||
if (empty($accounts)) {
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'No Instagram Business accounts found. Make sure your Instagram is connected to a Facebook Page.');
|
||||
}
|
||||
|
||||
// If only one account, connect directly
|
||||
if (count($accounts) === 1) {
|
||||
$account = $accounts[0];
|
||||
$avatarPath = uploadFromUrl($account['profile_picture_url']);
|
||||
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => $account['id'],
|
||||
'username' => $account['username'],
|
||||
'display_name' => $account['name'] ?? $account['username'],
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $account['page_access_token'],
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => null,
|
||||
'scopes' => $this->scopes,
|
||||
'meta' => [
|
||||
'instagram_id' => $account['id'],
|
||||
'page_id' => $account['page_id'],
|
||||
'user_id' => $socialUser->getId(),
|
||||
'user_token' => $socialUser->token,
|
||||
],
|
||||
]);
|
||||
|
||||
session()->forget('social_connect_workspace');
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('success', 'Instagram account connected successfully!');
|
||||
}
|
||||
|
||||
// Multiple accounts - store data and show selection
|
||||
session([
|
||||
'instagram_oauth' => [
|
||||
'user_token' => $socialUser->token,
|
||||
'user_id' => $socialUser->getId(),
|
||||
'accounts' => $accounts,
|
||||
],
|
||||
]);
|
||||
|
||||
return redirect()->route('social.instagram.select-account');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Instagram OAuth Error', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'Error connecting account. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
public function selectAccount(Request $request)
|
||||
{
|
||||
$oauthData = session('instagram_oauth');
|
||||
$workspaceId = session('social_connect_workspace');
|
||||
|
||||
if (! $oauthData || ! $workspaceId) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Session expired. Please try again.');
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if (! $workspace) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
return Inertia::render('accounts/InstagramAccountSelect', [
|
||||
'workspace' => $workspace,
|
||||
'accounts' => $oauthData['accounts'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function select(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'account_id' => 'required|string',
|
||||
]);
|
||||
|
||||
$oauthData = session('instagram_oauth');
|
||||
$workspaceId = session('social_connect_workspace');
|
||||
|
||||
if (! $oauthData || ! $workspaceId) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Session expired. Please try again.');
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
try {
|
||||
$selectedAccount = collect($oauthData['accounts'])->firstWhere('id', $request->account_id);
|
||||
|
||||
if (! $selectedAccount) {
|
||||
return redirect()->route('social.instagram.select-account')
|
||||
->with('error', 'Account not found.');
|
||||
}
|
||||
|
||||
$avatarPath = uploadFromUrl($selectedAccount['profile_picture_url']);
|
||||
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => $selectedAccount['id'],
|
||||
'username' => $selectedAccount['username'],
|
||||
'display_name' => $selectedAccount['name'] ?? $selectedAccount['username'],
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $selectedAccount['page_access_token'],
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => null,
|
||||
'scopes' => $this->scopes,
|
||||
'meta' => [
|
||||
'instagram_id' => $selectedAccount['id'],
|
||||
'page_id' => $selectedAccount['page_id'],
|
||||
'user_id' => $oauthData['user_id'],
|
||||
'user_token' => $oauthData['user_token'],
|
||||
],
|
||||
]);
|
||||
|
||||
session()->forget(['instagram_oauth', 'social_connect_workspace']);
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('success', 'Instagram account connected successfully!');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Instagram account selection error', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'Error connecting account. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
private function fetchInstagramAccounts(string $userToken): array
|
||||
{
|
||||
try {
|
||||
// First, get all pages with their Instagram business accounts
|
||||
$response = Http::get('https://graph.facebook.com/v21.0/me/accounts', [
|
||||
'access_token' => $userToken,
|
||||
'fields' => 'id,name,access_token,instagram_business_account{id,username,name,profile_picture_url,followers_count}',
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Instagram accounts fetch failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$accounts = [];
|
||||
|
||||
foreach ($data['data'] ?? [] as $page) {
|
||||
if (isset($page['instagram_business_account'])) {
|
||||
$ig = $page['instagram_business_account'];
|
||||
$accounts[] = [
|
||||
'id' => $ig['id'],
|
||||
'username' => $ig['username'],
|
||||
'name' => $ig['name'] ?? $ig['username'],
|
||||
'profile_picture_url' => $ig['profile_picture_url'] ?? null,
|
||||
'followers_count' => $ig['followers_count'] ?? 0,
|
||||
'page_id' => $page['id'],
|
||||
'page_name' => $page['name'],
|
||||
'page_access_token' => $page['access_token'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $accounts;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Instagram accounts fetch error', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,10 +27,10 @@ class LinkedInController extends SocialController
|
|||
|
||||
public function connect(Request $request, Workspace $workspace): Response
|
||||
{
|
||||
$this->authorize('update', $workspace);
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
return back()->with('error', 'Esta plataforma já está conectada.');
|
||||
return back()->with('error', 'This platform is already connected.');
|
||||
}
|
||||
|
||||
return $this->redirectToProvider($workspace, $this->driver, $this->scopes);
|
||||
|
|
@ -42,19 +42,19 @@ public function callback(Request $request): RedirectResponse
|
|||
|
||||
if (! $workspaceId) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Sessão expirada. Tente novamente.');
|
||||
->with('error', 'Session expired. Please try again.');
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if (! $workspace || ! $request->user()->can('update', $workspace)) {
|
||||
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Workspace não encontrado.');
|
||||
->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'Esta plataforma já está conectada.');
|
||||
->with('error', 'This platform is already connected.');
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -86,14 +86,14 @@ public function callback(Request $request): RedirectResponse
|
|||
session()->forget('social_connect_workspace');
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('success', 'Conta conectada com sucesso!');
|
||||
->with('success', 'Account connected successfully!');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('LinkedIn OAuth Error', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'Erro ao conectar conta. Tente novamente.');
|
||||
->with('error', 'Error connecting account. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,10 +31,10 @@ class LinkedInPageController extends SocialController
|
|||
|
||||
public function connect(Request $request, Workspace $workspace): SymfonyResponse
|
||||
{
|
||||
$this->authorize('update', $workspace);
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
return back()->with('error', 'Esta plataforma já está conectada.');
|
||||
return back()->with('error', 'This platform is already connected.');
|
||||
}
|
||||
|
||||
session(['social_connect_workspace' => $workspace->id]);
|
||||
|
|
@ -56,14 +56,14 @@ public function callback(Request $request): RedirectResponse
|
|||
|
||||
if (! $workspaceId) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Sessão expirada. Tente novamente.');
|
||||
->with('error', 'Session expired. Please try again.');
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if (! $workspace || ! $request->user()->can('update', $workspace)) {
|
||||
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Workspace não encontrado.');
|
||||
->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -81,7 +81,7 @@ public function callback(Request $request): RedirectResponse
|
|||
session()->forget('social_connect_workspace');
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'Você não é administrador de nenhuma página do LinkedIn.');
|
||||
->with('error', 'You are not an administrator of any LinkedIn page.');
|
||||
}
|
||||
|
||||
// Store data in session and redirect to selection page
|
||||
|
|
@ -105,7 +105,7 @@ public function callback(Request $request): RedirectResponse
|
|||
]);
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'Erro ao conectar conta. Tente novamente.');
|
||||
->with('error', 'Error connecting account. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,14 +115,14 @@ public function selectPage(Request $request): Response|RedirectResponse
|
|||
|
||||
if (! $pendingData) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Sessão expirada. Tente novamente.');
|
||||
->with('error', 'Session expired. Please try again.');
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($pendingData['workspace_id']);
|
||||
|
||||
if (! $workspace || ! $request->user()->can('update', $workspace)) {
|
||||
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Workspace não encontrado.');
|
||||
->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
return Inertia::render('accounts/LinkedInPageSelect', [
|
||||
|
|
@ -144,14 +144,14 @@ public function select(Request $request): RedirectResponse
|
|||
|
||||
if (! $pendingData) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Sessão expirada. Tente novamente.');
|
||||
->with('error', 'Session expired. Please try again.');
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($pendingData['workspace_id']);
|
||||
|
||||
if (! $workspace || ! $request->user()->can('update', $workspace)) {
|
||||
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Workspace não encontrado.');
|
||||
->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -176,14 +176,14 @@ public function select(Request $request): RedirectResponse
|
|||
session()->forget(['social_connect_workspace', 'linkedin_page_pending']);
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('success', 'LinkedIn Page conectada com sucesso!');
|
||||
->with('success', 'LinkedIn Page connected successfully!');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('LinkedIn Page selection error', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'Erro ao conectar página. Tente novamente.');
|
||||
->with('error', 'Error connecting page. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,7 @@
|
|||
use App\Models\Workspace;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
|
@ -45,7 +42,7 @@ public function index(Workspace $workspace): Response
|
|||
|
||||
public function disconnect(Workspace $workspace, SocialAccount $account): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $workspace);
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
if ($account->workspace_id !== $workspace->id) {
|
||||
abort(403);
|
||||
|
|
@ -53,7 +50,10 @@ public function disconnect(Workspace $workspace, SocialAccount $account): Redire
|
|||
|
||||
$account->delete();
|
||||
|
||||
return back()->with('success', 'Conta desconectada com sucesso!');
|
||||
session()->flash('flash.banner', 'Account disconnected successfully!');
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
protected function redirectToProvider(Workspace $workspace, string $driver, array $scopes): SymfonyResponse
|
||||
|
|
@ -76,20 +76,26 @@ protected function handleCallback(
|
|||
$workspaceId = session('social_connect_workspace');
|
||||
|
||||
if (! $workspaceId) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Sessão expirada. Tente novamente.');
|
||||
session()->flash('flash.banner', 'Session expired. Please try again.');
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('workspaces.index');
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if (! $workspace || ! $request->user()->can('update', $workspace)) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Workspace não encontrado.');
|
||||
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
|
||||
session()->flash('flash.banner', 'Workspace not found.');
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('workspaces.index');
|
||||
}
|
||||
|
||||
if ($workspace->hasConnectedPlatform($platform->value)) {
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'Esta plataforma já está conectada.');
|
||||
session()->flash('flash.banner', 'This platform is already connected.');
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace);
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -110,16 +116,20 @@ protected function handleCallback(
|
|||
|
||||
session()->forget('social_connect_workspace');
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('success', 'Conta conectada com sucesso!');
|
||||
session()->flash('flash.banner', 'Account connected successfully!');
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Social OAuth Error', [
|
||||
'platform' => $platform->value,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'Erro ao conectar conta. Tente novamente.');
|
||||
session()->flash('flash.banner', 'Error connecting account. Please try again.');
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
156
app/Http/Controllers/Auth/ThreadsController.php
Normal file
156
app/Http/Controllers/Auth/ThreadsController.php
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Enums\SocialPlatform;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ThreadsController extends SocialController
|
||||
{
|
||||
protected SocialPlatform $platform = SocialPlatform::Threads;
|
||||
|
||||
protected array $scopes = [
|
||||
'threads_basic',
|
||||
'threads_content_publish',
|
||||
'threads_manage_replies',
|
||||
'threads_read_replies',
|
||||
];
|
||||
|
||||
public function connect(Request $request, Workspace $workspace): Response
|
||||
{
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
return back()->with('error', 'This platform is already connected.');
|
||||
}
|
||||
|
||||
session(['social_connect_workspace' => $workspace->id]);
|
||||
|
||||
$state = bin2hex(random_bytes(16));
|
||||
session(['threads_oauth_state' => $state]);
|
||||
|
||||
$params = http_build_query([
|
||||
'client_id' => config('services.threads.client_id'),
|
||||
'redirect_uri' => config('services.threads.redirect'),
|
||||
'scope' => implode(',', $this->scopes),
|
||||
'response_type' => 'code',
|
||||
'state' => $state,
|
||||
]);
|
||||
|
||||
return Inertia::location("https://threads.net/oauth/authorize?{$params}");
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
$workspaceId = session('social_connect_workspace');
|
||||
$savedState = session('threads_oauth_state');
|
||||
|
||||
if (! $workspaceId) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Session expired. Please try again.');
|
||||
}
|
||||
|
||||
if ($request->state !== $savedState) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Invalid state. Please try again.');
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'This platform is already connected.');
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange code for short-lived token
|
||||
$tokenResponse = Http::asForm()->post('https://graph.threads.net/oauth/access_token', [
|
||||
'client_id' => config('services.threads.client_id'),
|
||||
'client_secret' => config('services.threads.client_secret'),
|
||||
'grant_type' => 'authorization_code',
|
||||
'redirect_uri' => config('services.threads.redirect'),
|
||||
'code' => $request->code,
|
||||
]);
|
||||
|
||||
if ($tokenResponse->failed()) {
|
||||
Log::error('Threads token exchange failed', [
|
||||
'status' => $tokenResponse->status(),
|
||||
'body' => $tokenResponse->body(),
|
||||
]);
|
||||
throw new \Exception('Failed to exchange token');
|
||||
}
|
||||
|
||||
$tokenData = $tokenResponse->json();
|
||||
$shortLivedToken = $tokenData['access_token'];
|
||||
$userId = $tokenData['user_id'];
|
||||
|
||||
// Exchange for long-lived token
|
||||
$longLivedResponse = Http::get('https://graph.threads.net/access_token', [
|
||||
'grant_type' => 'th_exchange_token',
|
||||
'client_secret' => config('services.threads.client_secret'),
|
||||
'access_token' => $shortLivedToken,
|
||||
]);
|
||||
|
||||
$longLivedToken = $shortLivedToken;
|
||||
$expiresIn = null;
|
||||
|
||||
if ($longLivedResponse->successful()) {
|
||||
$longLivedData = $longLivedResponse->json();
|
||||
$longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken;
|
||||
$expiresIn = $longLivedData['expires_in'] ?? null;
|
||||
}
|
||||
|
||||
// Fetch user profile
|
||||
$profileResponse = Http::get("https://graph.threads.net/v1.0/{$userId}", [
|
||||
'access_token' => $longLivedToken,
|
||||
'fields' => 'id,username,name,threads_profile_picture_url',
|
||||
]);
|
||||
|
||||
if ($profileResponse->failed()) {
|
||||
Log::error('Threads profile fetch failed', [
|
||||
'body' => $profileResponse->body(),
|
||||
]);
|
||||
throw new \Exception('Failed to fetch profile');
|
||||
}
|
||||
|
||||
$profile = $profileResponse->json();
|
||||
$avatarPath = uploadFromUrl($profile['threads_profile_picture_url'] ?? null);
|
||||
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => $profile['id'],
|
||||
'username' => $profile['username'],
|
||||
'display_name' => $profile['name'] ?? $profile['username'],
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $longLivedToken,
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null,
|
||||
'scopes' => $this->scopes,
|
||||
]);
|
||||
|
||||
session()->forget(['social_connect_workspace', 'threads_oauth_state']);
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('success', 'Threads account connected successfully!');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Threads OAuth Error', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'Error connecting account. Please try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,10 +24,10 @@ class TikTokController extends SocialController
|
|||
|
||||
public function connect(Request $request, Workspace $workspace): Response
|
||||
{
|
||||
$this->authorize('update', $workspace);
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
return back()->with('error', 'Esta plataforma já está conectada.');
|
||||
return back()->with('error', 'This platform is already connected.');
|
||||
}
|
||||
|
||||
return $this->redirectToProvider($workspace, $this->driver, $this->scopes);
|
||||
|
|
@ -39,19 +39,19 @@ public function callback(Request $request): RedirectResponse
|
|||
|
||||
if (! $workspaceId) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Sessão expirada. Tente novamente.');
|
||||
->with('error', 'Session expired. Please try again.');
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if (! $workspace || ! $request->user()->can('update', $workspace)) {
|
||||
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Workspace não encontrado.');
|
||||
->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'Esta plataforma já está conectada.');
|
||||
->with('error', 'This platform is already connected.');
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -84,14 +84,14 @@ public function callback(Request $request): RedirectResponse
|
|||
session()->forget('social_connect_workspace');
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('success', 'Conta conectada com sucesso!');
|
||||
->with('success', 'Account connected successfully!');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('TikTok OAuth Error', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'Erro ao conectar conta. Tente novamente.');
|
||||
->with('error', 'Error connecting account. Please try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
class XController extends SocialController
|
||||
{
|
||||
protected string $driver = 'twitter';
|
||||
protected string $driver = 'x';
|
||||
|
||||
protected SocialPlatform $platform = SocialPlatform::X;
|
||||
|
||||
|
|
@ -18,15 +18,16 @@ class XController extends SocialController
|
|||
'tweet.read',
|
||||
'tweet.write',
|
||||
'users.read',
|
||||
'media.write',
|
||||
'offline.access',
|
||||
];
|
||||
|
||||
public function connect(Request $request, Workspace $workspace): Response
|
||||
{
|
||||
$this->authorize('update', $workspace);
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
return back()->with('error', 'Esta plataforma já está conectada.');
|
||||
return back()->with('error', 'This platform is already connected.');
|
||||
}
|
||||
|
||||
return $this->redirectToProvider($workspace, $this->driver, $this->scopes);
|
||||
|
|
|
|||
264
app/Http/Controllers/Auth/YouTubeController.php
Normal file
264
app/Http/Controllers/Auth/YouTubeController.php
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Enums\SocialPlatform;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class YouTubeController extends SocialController
|
||||
{
|
||||
protected string $driver = 'google';
|
||||
|
||||
protected SocialPlatform $platform = SocialPlatform::YouTube;
|
||||
|
||||
protected array $scopes = [
|
||||
'https://www.googleapis.com/auth/youtube.upload',
|
||||
'https://www.googleapis.com/auth/youtube.readonly',
|
||||
'https://www.googleapis.com/auth/youtube.force-ssl',
|
||||
];
|
||||
|
||||
public function connect(Request $request, Workspace $workspace): Response
|
||||
{
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
return back()->with('error', 'This platform is already connected.');
|
||||
}
|
||||
|
||||
session(['social_connect_workspace' => $workspace->id]);
|
||||
|
||||
return $this->redirectToGoogle($workspace);
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
$workspaceId = session('social_connect_workspace');
|
||||
|
||||
if (! $workspaceId) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Session expired. Please try again.');
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
if ($workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'This platform is already connected.');
|
||||
}
|
||||
|
||||
try {
|
||||
$socialUser = Socialite::driver($this->driver)->user();
|
||||
|
||||
// Fetch the channels the user authorized
|
||||
$channels = $this->fetchChannels($socialUser->token);
|
||||
|
||||
if (empty($channels)) {
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'No YouTube channels found. Please create a channel first.');
|
||||
}
|
||||
|
||||
// If only one channel, connect directly (most common case)
|
||||
if (count($channels) === 1) {
|
||||
$channel = $channels[0];
|
||||
$avatarPath = uploadFromUrl($channel['thumbnail']);
|
||||
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => $channel['id'],
|
||||
'username' => $channel['custom_url'] ?? $channel['id'],
|
||||
'display_name' => $channel['title'],
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $socialUser->token,
|
||||
'refresh_token' => $socialUser->refreshToken,
|
||||
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
|
||||
'scopes' => $this->scopes,
|
||||
'meta' => [
|
||||
'channel_id' => $channel['id'],
|
||||
'google_user_id' => $socialUser->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
session()->forget('social_connect_workspace');
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('success', 'YouTube channel connected successfully!');
|
||||
}
|
||||
|
||||
// Multiple channels - store data and show selection screen
|
||||
session([
|
||||
'youtube_oauth' => [
|
||||
'access_token' => $socialUser->token,
|
||||
'refresh_token' => $socialUser->refreshToken,
|
||||
'expires_in' => $socialUser->expiresIn,
|
||||
'user_id' => $socialUser->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
return redirect()->route('social.youtube.select-channel');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('YouTube OAuth Error', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'Error connecting account. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
public function selectChannel(Request $request)
|
||||
{
|
||||
$oauthData = session('youtube_oauth');
|
||||
$workspaceId = session('social_connect_workspace');
|
||||
|
||||
if (! $oauthData || ! $workspaceId) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Session expired. Please try again.');
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if (! $workspace) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
// Fetch YouTube channels
|
||||
$channels = $this->fetchChannels($oauthData['access_token']);
|
||||
|
||||
if (empty($channels)) {
|
||||
session()->forget(['youtube_oauth', 'social_connect_workspace']);
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'No YouTube channels found. Please create a channel first.');
|
||||
}
|
||||
|
||||
return inertia('accounts/YouTubeChannelSelect', [
|
||||
'workspace' => $workspace,
|
||||
'channels' => $channels,
|
||||
]);
|
||||
}
|
||||
|
||||
public function select(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'channel_id' => 'required|string',
|
||||
]);
|
||||
|
||||
$oauthData = session('youtube_oauth');
|
||||
$workspaceId = session('social_connect_workspace');
|
||||
|
||||
if (! $oauthData || ! $workspaceId) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Session expired. Please try again.');
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
try {
|
||||
$channels = $this->fetchChannels($oauthData['access_token']);
|
||||
$selectedChannel = collect($channels)->firstWhere('id', $request->channel_id);
|
||||
|
||||
if (! $selectedChannel) {
|
||||
return redirect()->route('social.youtube.select-channel')
|
||||
->with('error', 'Channel not found.');
|
||||
}
|
||||
|
||||
$avatarPath = uploadFromUrl($selectedChannel['thumbnail']);
|
||||
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => $selectedChannel['id'],
|
||||
'username' => $selectedChannel['custom_url'] ?? $selectedChannel['id'],
|
||||
'display_name' => $selectedChannel['title'],
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $oauthData['access_token'],
|
||||
'refresh_token' => $oauthData['refresh_token'],
|
||||
'token_expires_at' => $oauthData['expires_in'] ? now()->addSeconds($oauthData['expires_in']) : null,
|
||||
'scopes' => $this->scopes,
|
||||
'meta' => [
|
||||
'channel_id' => $selectedChannel['id'],
|
||||
'google_user_id' => $oauthData['user_id'],
|
||||
],
|
||||
]);
|
||||
|
||||
session()->forget(['youtube_oauth', 'social_connect_workspace']);
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('success', 'YouTube channel connected successfully!');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('YouTube channel selection error', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'Error connecting channel. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
private function redirectToGoogle(Workspace $workspace): Response
|
||||
{
|
||||
return \Inertia\Inertia::location(
|
||||
Socialite::driver($this->driver)
|
||||
->scopes($this->scopes)
|
||||
->with([
|
||||
'access_type' => 'offline',
|
||||
'prompt' => 'consent',
|
||||
])
|
||||
->redirect()
|
||||
->getTargetUrl()
|
||||
);
|
||||
}
|
||||
|
||||
private function fetchChannels(string $accessToken): array
|
||||
{
|
||||
try {
|
||||
$response = \Illuminate\Support\Facades\Http::withToken($accessToken)
|
||||
->get('https://www.googleapis.com/youtube/v3/channels', [
|
||||
'part' => 'snippet,contentDetails,statistics',
|
||||
'mine' => 'true',
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('YouTube channels fetch failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
return collect($data['items'] ?? [])->map(fn ($channel) => [
|
||||
'id' => $channel['id'],
|
||||
'title' => $channel['snippet']['title'],
|
||||
'description' => $channel['snippet']['description'] ?? '',
|
||||
'thumbnail' => $channel['snippet']['thumbnails']['default']['url'] ?? null,
|
||||
'custom_url' => $channel['snippet']['customUrl'] ?? null,
|
||||
'subscriber_count' => $channel['statistics']['subscriberCount'] ?? 0,
|
||||
])->toArray();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('YouTube channels fetch error', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,8 +6,6 @@
|
|||
use App\Http\Requests\StoreMediaRequest;
|
||||
use App\Models\PostMedia;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MediaController extends Controller
|
||||
{
|
||||
|
|
@ -18,12 +16,11 @@ public function store(StoreMediaRequest $request): JsonResponse
|
|||
|
||||
$type = $this->getMediaType($mimeType);
|
||||
|
||||
$path = $file->store('media/' . now()->format('Y/m'), 'r2');
|
||||
$path = $file->store('media/'.now()->format('Y-m'));
|
||||
|
||||
$media = PostMedia::create([
|
||||
'post_platform_id' => null,
|
||||
'post_platform_id' => $request->input('post_platform_id'),
|
||||
'type' => $type,
|
||||
'disk' => 'r2',
|
||||
'path' => $path,
|
||||
'original_filename' => $file->getClientOriginalName(),
|
||||
'mime_type' => $mimeType,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Enums\PostStatus;
|
||||
use App\Http\Requests\StorePostRequest;
|
||||
use App\Http\Requests\UpdatePostRequest;
|
||||
use App\Jobs\PublishPost;
|
||||
use App\Models\Post;
|
||||
use App\Models\Workspace;
|
||||
use Carbon\Carbon;
|
||||
|
|
@ -34,46 +35,68 @@ public function calendar(Request $request, Workspace $workspace): Response
|
|||
{
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
$month = $request->input('month', now()->month);
|
||||
$year = $request->input('year', now()->year);
|
||||
$tz = $workspace->timezone;
|
||||
|
||||
$startOfMonth = Carbon::create($year, $month, 1)->startOfMonth();
|
||||
$endOfMonth = Carbon::create($year, $month, 1)->endOfMonth();
|
||||
$weekStart = $request->input('week')
|
||||
? Carbon::parse($request->input('week'), $tz)->startOfWeek()
|
||||
: Carbon::now($tz)->startOfWeek();
|
||||
|
||||
$weekEnd = $weekStart->copy()->endOfWeek();
|
||||
|
||||
// Convert to UTC for database query
|
||||
$weekStartUtc = $weekStart->copy()->utc();
|
||||
$weekEndUtc = $weekEnd->copy()->utc();
|
||||
|
||||
$posts = $workspace->posts()
|
||||
->with(['postPlatforms.socialAccount'])
|
||||
->whereBetween('scheduled_at', [$startOfMonth, $endOfMonth])
|
||||
->whereBetween('scheduled_at', [$weekStartUtc, $weekEndUtc])
|
||||
->orderBy('scheduled_at')
|
||||
->get()
|
||||
->groupBy(fn ($post) => $post->scheduled_at?->format('Y-m-d'));
|
||||
|
||||
$socialAccounts = $workspace->socialAccounts;
|
||||
->groupBy(fn ($post) => $post->scheduled_at?->setTimezone($tz)->format('Y-m-d'));
|
||||
|
||||
return Inertia::render('posts/Calendar', [
|
||||
'workspace' => $workspace,
|
||||
'posts' => $posts,
|
||||
'socialAccounts' => $socialAccounts,
|
||||
'currentMonth' => $month,
|
||||
'currentYear' => $year,
|
||||
'currentWeekStart' => $weekStart->format('Y-m-d'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request, Workspace $workspace): Response|RedirectResponse
|
||||
public function create(Request $request, Workspace $workspace): RedirectResponse
|
||||
{
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
$socialAccounts = $workspace->socialAccounts;
|
||||
|
||||
if ($socialAccounts->isEmpty()) {
|
||||
return redirect()->route('workspaces.accounts', $workspace)
|
||||
->with('error', 'Conecte pelo menos uma rede social antes de criar um post.');
|
||||
session()->flash('flash.banner', 'Connect at least one social network before creating a post.');
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('workspaces.accounts', $workspace);
|
||||
}
|
||||
|
||||
return Inertia::render('posts/Create', [
|
||||
'workspace' => $workspace,
|
||||
'socialAccounts' => $socialAccounts,
|
||||
'scheduledDate' => $request->input('date'),
|
||||
// Create a draft post - default to today if no date provided
|
||||
$date = $request->input('date') ?: Carbon::now($workspace->timezone)->format('Y-m-d');
|
||||
$scheduledAt = Carbon::parse($date, $workspace->timezone)
|
||||
->setTime(9, 0)
|
||||
->utc();
|
||||
|
||||
$post = $workspace->posts()->create([
|
||||
'user_id' => $request->user()->id,
|
||||
'status' => PostStatus::Draft,
|
||||
'scheduled_at' => $scheduledAt,
|
||||
]);
|
||||
|
||||
// Create post_platforms for each connected account
|
||||
foreach ($socialAccounts as $account) {
|
||||
$post->postPlatforms()->create([
|
||||
'social_account_id' => $account->id,
|
||||
'platform' => $account->platform->value,
|
||||
'content' => '',
|
||||
'status' => 'pending',
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('workspaces.posts.edit', [$workspace, $post]);
|
||||
}
|
||||
|
||||
public function store(StorePostRequest $request, Workspace $workspace): RedirectResponse
|
||||
|
|
@ -105,8 +128,10 @@ public function store(StorePostRequest $request, Workspace $workspace): Redirect
|
|||
? 'workspaces.calendar'
|
||||
: 'workspaces.posts.index';
|
||||
|
||||
return redirect()->route($route, $workspace)
|
||||
->with('success', 'Post criado com sucesso!');
|
||||
session()->flash('flash.banner', 'Post created successfully!');
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return redirect()->route($route, $workspace);
|
||||
}
|
||||
|
||||
public function show(Workspace $workspace, Post $post): Response
|
||||
|
|
@ -134,17 +159,33 @@ public function edit(Workspace $workspace, Post $post): Response|RedirectRespons
|
|||
}
|
||||
|
||||
if ($post->status === PostStatus::Published) {
|
||||
return redirect()->route('workspaces.posts.show', [$workspace, $post])
|
||||
->with('error', 'Posts publicados não podem ser editados.');
|
||||
session()->flash('flash.banner', 'Published posts cannot be edited.');
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('workspaces.posts.show', [$workspace, $post]);
|
||||
}
|
||||
|
||||
$post->load(['postPlatforms.socialAccount', 'postPlatforms.media']);
|
||||
$socialAccounts = $workspace->socialAccounts;
|
||||
|
||||
$platformConfigs = $socialAccounts->mapWithKeys(function ($account) {
|
||||
$platform = $account->platform;
|
||||
|
||||
return [
|
||||
$account->id => [
|
||||
'maxContentLength' => $platform->maxContentLength(),
|
||||
'maxImages' => $platform->maxImages(),
|
||||
'allowedMediaTypes' => array_map(fn ($type) => $type->value, $platform->allowedMediaTypes()),
|
||||
'supportsTextOnly' => $platform->supportsTextOnly(),
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('posts/Edit', [
|
||||
'workspace' => $workspace,
|
||||
'post' => $post,
|
||||
'socialAccounts' => $socialAccounts,
|
||||
'platformConfigs' => $platformConfigs,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -157,24 +198,53 @@ public function update(UpdatePostRequest $request, Workspace $workspace, Post $p
|
|||
}
|
||||
|
||||
if ($post->status === PostStatus::Published) {
|
||||
return back()->with('error', 'Posts publicados não podem ser editados.');
|
||||
session()->flash('flash.banner', 'Published posts cannot be edited.');
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
$scheduledAt = $post->scheduled_at;
|
||||
if ($request->has('scheduled_at') && $request->input('scheduled_at')) {
|
||||
$scheduledAt = Carbon::parse($request->input('scheduled_at'), $workspace->timezone)->utc();
|
||||
}
|
||||
|
||||
$status = $request->input('status', $post->status);
|
||||
|
||||
$post->update([
|
||||
'status' => $request->input('status', $post->status),
|
||||
'scheduled_at' => $request->input('scheduled_at', $post->scheduled_at),
|
||||
'status' => $status === 'publishing' ? PostStatus::Publishing : $status,
|
||||
'scheduled_at' => $scheduledAt,
|
||||
]);
|
||||
|
||||
// Get selected platform IDs
|
||||
$selectedPlatformIds = collect($request->input('platforms', []))->pluck('id')->toArray();
|
||||
|
||||
// Update all platforms - disable those not selected, update content for selected ones
|
||||
$post->postPlatforms()->update(['enabled' => false]);
|
||||
|
||||
foreach ($request->input('platforms', []) as $platformData) {
|
||||
$post->postPlatforms()
|
||||
->where('id', $platformData['id'])
|
||||
->update([
|
||||
'enabled' => true,
|
||||
'content' => $platformData['content'],
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('workspaces.posts.show', [$workspace, $post])
|
||||
->with('success', 'Post atualizado com sucesso!');
|
||||
// Dispatch publish job if publishing now
|
||||
if ($status === 'publishing') {
|
||||
PublishPost::dispatch($post);
|
||||
|
||||
session()->flash('flash.banner', 'Post is being published!');
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return redirect()->route('workspaces.posts.show', [$workspace, $post]);
|
||||
}
|
||||
|
||||
session()->flash('flash.banner', 'Post updated successfully!');
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return redirect()->route('workspaces.posts.show', [$workspace, $post]);
|
||||
}
|
||||
|
||||
public function destroy(Workspace $workspace, Post $post): RedirectResponse
|
||||
|
|
@ -187,7 +257,9 @@ public function destroy(Workspace $workspace, Post $post): RedirectResponse
|
|||
|
||||
$post->delete();
|
||||
|
||||
return redirect()->route('workspaces.calendar', $workspace)
|
||||
->with('success', 'Post excluído com sucesso!');
|
||||
session()->flash('flash.banner', 'Post deleted successfully!');
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return redirect()->route('workspaces.calendar', $workspace);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ public function create(Request $request): Response|RedirectResponse
|
|||
// First workspace is free, subsequent ones require subscription
|
||||
if ($user->ownedWorkspacesCount() > 0 && ! $user->hasActiveSubscription()) {
|
||||
return redirect()->route('billing.index')
|
||||
->with('message', 'Assine para criar mais workspaces.');
|
||||
->with('message', 'Subscribe to create more workspaces.');
|
||||
}
|
||||
|
||||
return Inertia::render('workspaces/Create');
|
||||
|
|
@ -45,20 +45,18 @@ public function store(StoreWorkspaceRequest $request): RedirectResponse
|
|||
// First workspace is free, subsequent ones require subscription
|
||||
if ($user->ownedWorkspacesCount() > 0 && ! $user->hasActiveSubscription()) {
|
||||
return redirect()->route('billing.index')
|
||||
->with('message', 'Assine para criar mais workspaces.');
|
||||
->with('message', 'Subscribe to create more workspaces.');
|
||||
}
|
||||
|
||||
$workspace = $user->workspaces()->create($request->validated());
|
||||
|
||||
$workspace->members()->attach($user->id, ['role' => 'owner']);
|
||||
|
||||
// Increment subscription quantity if user has subscription
|
||||
if ($user->hasActiveSubscription()) {
|
||||
$user->incrementWorkspaceQuantity();
|
||||
}
|
||||
|
||||
return redirect()->route('workspaces.show', $workspace)
|
||||
->with('success', 'Workspace criado com sucesso!');
|
||||
->with('success', 'Workspace created successfully!');
|
||||
}
|
||||
|
||||
public function show(Request $request, Workspace $workspace): Response
|
||||
|
|
@ -98,7 +96,33 @@ public function update(UpdateWorkspaceRequest $request, Workspace $workspace): R
|
|||
$workspace->update($request->validated());
|
||||
|
||||
return redirect()->route('workspaces.show', $workspace)
|
||||
->with('success', 'Workspace atualizado com sucesso!');
|
||||
->with('success', 'Workspace updated successfully!');
|
||||
}
|
||||
|
||||
public function settings(Workspace $workspace): Response
|
||||
{
|
||||
$this->authorize('update', $workspace);
|
||||
|
||||
$timezones = collect(timezone_identifiers_list())
|
||||
->mapWithKeys(fn ($tz) => [$tz => $tz])
|
||||
->toArray();
|
||||
|
||||
return Inertia::render('workspaces/Settings', [
|
||||
'workspace' => $workspace,
|
||||
'timezones' => $timezones,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateSettings(UpdateWorkspaceRequest $request, Workspace $workspace): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $workspace);
|
||||
|
||||
$workspace->update($request->validated());
|
||||
|
||||
session()->flash('flash.banner', 'Settings updated successfully!');
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return redirect()->route('workspaces.settings', $workspace);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Workspace $workspace): RedirectResponse
|
||||
|
|
@ -115,6 +139,6 @@ public function destroy(Request $request, Workspace $workspace): RedirectRespons
|
|||
}
|
||||
|
||||
return redirect()->route('workspaces.index')
|
||||
->with('success', 'Workspace excluído com sucesso!');
|
||||
->with('success', 'Workspace deleted successfully!');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\InviteStatus;
|
||||
use App\Enums\WorkspaceRole;
|
||||
use App\Http\Requests\StoreWorkspaceInviteRequest;
|
||||
use App\Models\Workspace;
|
||||
|
|
@ -17,7 +16,7 @@ class WorkspaceInviteController extends Controller
|
|||
{
|
||||
public function index(Workspace $workspace): Response
|
||||
{
|
||||
$this->authorize('update', $workspace);
|
||||
$this->authorize('manageTeam', $workspace);
|
||||
|
||||
return Inertia::render('workspaces/Invites', [
|
||||
'workspace' => $workspace,
|
||||
|
|
@ -25,12 +24,15 @@ public function index(Workspace $workspace): Response
|
|||
->with('inviter')
|
||||
->latest()
|
||||
->get(),
|
||||
'members' => $workspace->members()->get()->map(fn ($member) => [
|
||||
'id' => $member->id,
|
||||
'name' => $member->name,
|
||||
'email' => $member->email,
|
||||
'role' => $member->pivot->role,
|
||||
]),
|
||||
'members' => $workspace->members()
|
||||
->where('user_id', '!=', $workspace->user_id)
|
||||
->get()
|
||||
->map(fn ($member) => [
|
||||
'id' => $member->id,
|
||||
'name' => $member->name,
|
||||
'email' => $member->email,
|
||||
'role' => $member->pivot->role,
|
||||
]),
|
||||
'owner' => [
|
||||
'id' => $workspace->owner->id,
|
||||
'name' => $workspace->owner->name,
|
||||
|
|
@ -48,7 +50,7 @@ public function index(Workspace $workspace): Response
|
|||
|
||||
public function store(StoreWorkspaceInviteRequest $request, Workspace $workspace): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $workspace);
|
||||
$this->authorize('manageTeam', $workspace);
|
||||
|
||||
$existingInvite = $workspace->invites()
|
||||
->where('email', $request->email)
|
||||
|
|
@ -57,13 +59,13 @@ public function store(StoreWorkspaceInviteRequest $request, Workspace $workspace
|
|||
|
||||
if ($existingInvite) {
|
||||
return back()->withErrors([
|
||||
'email' => 'Já existe um convite pendente para este email.',
|
||||
'email' => 'A pending invite already exists for this email.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($workspace->members()->where('email', $request->email)->exists()) {
|
||||
return back()->withErrors([
|
||||
'email' => 'Este usuário já é membro do workspace.',
|
||||
'email' => 'This user is already a member of the workspace.',
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -75,12 +77,12 @@ public function store(StoreWorkspaceInviteRequest $request, Workspace $workspace
|
|||
|
||||
$invite->notify(new WorkspaceInviteNotification($invite));
|
||||
|
||||
return back()->with('success', 'Convite enviado com sucesso!');
|
||||
return back()->with('success', 'Invite sent successfully!');
|
||||
}
|
||||
|
||||
public function destroy(Workspace $workspace, WorkspaceInvite $invite): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $workspace);
|
||||
$this->authorize('manageTeam', $workspace);
|
||||
|
||||
if ($invite->workspace_id !== $workspace->id) {
|
||||
abort(404);
|
||||
|
|
@ -88,7 +90,7 @@ public function destroy(Workspace $workspace, WorkspaceInvite $invite): Redirect
|
|||
|
||||
$invite->cancel();
|
||||
|
||||
return back()->with('success', 'Convite cancelado.');
|
||||
return back()->with('success', 'Invite cancelled.');
|
||||
}
|
||||
|
||||
public function accept(Request $request, string $token): RedirectResponse
|
||||
|
|
@ -98,11 +100,11 @@ public function accept(Request $request, string $token): RedirectResponse
|
|||
if (! $invite->isValid()) {
|
||||
if ($invite->isExpired()) {
|
||||
return redirect()->route('workspaces.index')
|
||||
->withErrors(['invite' => 'Este convite expirou.']);
|
||||
->withErrors(['invite' => 'This invite has expired.']);
|
||||
}
|
||||
|
||||
return redirect()->route('workspaces.index')
|
||||
->withErrors(['invite' => 'Este convite não é mais válido.']);
|
||||
->withErrors(['invite' => 'This invite is no longer valid.']);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
|
@ -111,30 +113,30 @@ public function accept(Request $request, string $token): RedirectResponse
|
|||
session(['pending_invite_token' => $token]);
|
||||
|
||||
return redirect()->route('login')
|
||||
->with('message', 'Faça login para aceitar o convite.');
|
||||
->with('message', 'Please log in to accept the invite.');
|
||||
}
|
||||
|
||||
if ($invite->workspace->hasMember($user)) {
|
||||
return redirect()->route('workspaces.show', $invite->workspace)
|
||||
->with('message', 'Você já é membro deste workspace.');
|
||||
->with('message', 'You are already a member of this workspace.');
|
||||
}
|
||||
|
||||
$invite->accept($user);
|
||||
|
||||
return redirect()->route('workspaces.show', $invite->workspace)
|
||||
->with('success', 'Você agora é membro do workspace!');
|
||||
->with('success', 'You are now a member of the workspace!');
|
||||
}
|
||||
|
||||
public function removeMember(Workspace $workspace, string $userId): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $workspace);
|
||||
$this->authorize('manageTeam', $workspace);
|
||||
|
||||
if ($workspace->user_id === $userId) {
|
||||
return back()->withErrors(['member' => 'Não é possível remover o dono do workspace.']);
|
||||
return back()->withErrors(['member' => 'Cannot remove the workspace owner.']);
|
||||
}
|
||||
|
||||
$workspace->members()->detach($userId);
|
||||
|
||||
return back()->with('success', 'Membro removido com sucesso.');
|
||||
return back()->with('success', 'Member removed successfully.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ public function share(Request $request): array
|
|||
'user' => $request->user(),
|
||||
],
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
'flash' => $request->session()->get('flash', []),
|
||||
'env' => config('app.env'),
|
||||
'locale' => app()->getLocale(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\PostStatus;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdatePostRequest extends FormRequest
|
||||
{
|
||||
|
|
@ -16,8 +14,8 @@ public function authorize(): bool
|
|||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['sometimes', Rule::enum(PostStatus::class)],
|
||||
'scheduled_at' => ['sometimes', 'nullable', 'date', 'after:now'],
|
||||
'status' => ['sometimes', 'string'],
|
||||
'scheduled_at' => ['sometimes', 'nullable', 'string'],
|
||||
'platforms' => ['sometimes', 'array'],
|
||||
'platforms.*.id' => ['required', 'uuid', 'exists:post_platforms,id'],
|
||||
'platforms.*.content' => ['nullable', 'string', 'max:5000'],
|
||||
|
|
@ -27,7 +25,7 @@ public function rules(): array
|
|||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'scheduled_at.after' => 'A data de agendamento deve ser no futuro.',
|
||||
'scheduled_at.after' => 'The scheduled date must be in the future.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,14 +15,17 @@ public function rules(): array
|
|||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'timezone' => ['required', 'string', 'timezone:all'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'O nome do workspace é obrigatório.',
|
||||
'name.max' => 'O nome do workspace deve ter no máximo 255 caracteres.',
|
||||
'name.required' => 'The workspace name is required.',
|
||||
'name.max' => 'The workspace name must be at most 255 characters.',
|
||||
'timezone.required' => 'Please select a timezone.',
|
||||
'timezone.timezone' => 'Please select a valid timezone.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ public function handle(): void
|
|||
{
|
||||
$this->post->markAsPublishing();
|
||||
|
||||
foreach ($this->post->postPlatforms as $postPlatform) {
|
||||
foreach ($this->post->postPlatforms()->where('enabled', true)->get() as $postPlatform) {
|
||||
PublishToSocialPlatform::dispatch($postPlatform);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\PostStatus;
|
||||
use App\Enums\SocialPlatform;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Services\Social\FacebookPublisher;
|
||||
use App\Services\Social\InstagramPublisher;
|
||||
use App\Services\Social\LinkedInPagePublisher;
|
||||
use App\Services\Social\LinkedInPublisher;
|
||||
use App\Services\Social\ThreadsPublisher;
|
||||
use App\Services\Social\TikTokPublisher;
|
||||
use App\Services\Social\XPublisher;
|
||||
use App\Services\Social\YouTubePublisher;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
|
@ -32,8 +35,6 @@ public function handle(): void
|
|||
$result = $publisher->publish($this->postPlatform);
|
||||
|
||||
$this->postPlatform->markAsPublished($result['id'], $result['url'] ?? null);
|
||||
|
||||
$this->checkPostComplete();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to publish to social platform', [
|
||||
'post_platform_id' => $this->postPlatform->id,
|
||||
|
|
@ -42,30 +43,47 @@ public function handle(): void
|
|||
]);
|
||||
|
||||
$this->postPlatform->markAsFailed($e->getMessage());
|
||||
$this->postPlatform->post->markAsFailed();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Always check and update post status after each platform finishes
|
||||
$this->updatePostStatus();
|
||||
}
|
||||
|
||||
private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher
|
||||
private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher
|
||||
{
|
||||
return match ($this->postPlatform->platform) {
|
||||
SocialPlatform::LinkedIn => app(LinkedInPublisher::class),
|
||||
SocialPlatform::LinkedInPage => app(LinkedInPagePublisher::class),
|
||||
SocialPlatform::X => app(XPublisher::class),
|
||||
SocialPlatform::TikTok => app(TikTokPublisher::class),
|
||||
SocialPlatform::YouTube => app(YouTubePublisher::class),
|
||||
SocialPlatform::Facebook => app(FacebookPublisher::class),
|
||||
SocialPlatform::Instagram => app(InstagramPublisher::class),
|
||||
SocialPlatform::Threads => app(ThreadsPublisher::class),
|
||||
};
|
||||
}
|
||||
|
||||
private function checkPostComplete(): void
|
||||
private function updatePostStatus(): void
|
||||
{
|
||||
$post = $this->postPlatform->post->fresh();
|
||||
$enabledPlatforms = $post->postPlatforms->where('enabled', true);
|
||||
|
||||
$allPublished = $post->postPlatforms->every(fn ($pp) => $pp->status === 'published');
|
||||
$total = $enabledPlatforms->count();
|
||||
$publishedCount = $enabledPlatforms->where('status', 'published')->count();
|
||||
$failedCount = $enabledPlatforms->where('status', 'failed')->count();
|
||||
$finishedCount = $publishedCount + $failedCount;
|
||||
|
||||
if ($allPublished) {
|
||||
// Only update post status when all platforms have finished
|
||||
if ($finishedCount < $total) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($publishedCount === $total) {
|
||||
$post->markAsPublished();
|
||||
} elseif ($publishedCount > 0) {
|
||||
$post->markAsPartiallyPublished();
|
||||
} else {
|
||||
$post->markAsFailed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,14 @@ public function markAsPublished(): void
|
|||
]);
|
||||
}
|
||||
|
||||
public function markAsPartiallyPublished(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => PostStatus::PartiallyPublished,
|
||||
'published_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsFailed(): void
|
||||
{
|
||||
$this->update(['status' => PostStatus::Failed]);
|
||||
|
|
|
|||
|
|
@ -15,10 +15,11 @@ class PostMedia extends Model
|
|||
/** @use HasFactory<\Database\Factories\PostMediaFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $appends = ['url'];
|
||||
|
||||
protected $fillable = [
|
||||
'post_platform_id',
|
||||
'type',
|
||||
'disk',
|
||||
'path',
|
||||
'original_filename',
|
||||
'mime_type',
|
||||
|
|
@ -45,13 +46,13 @@ public function postPlatform(): BelongsTo
|
|||
protected function url(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => Storage::disk($this->disk)->url($this->path),
|
||||
get: fn () => Storage::url($this->path),
|
||||
);
|
||||
}
|
||||
|
||||
public function getTemporaryUrl(int $expirationMinutes = 60): string
|
||||
{
|
||||
return Storage::disk($this->disk)->temporaryUrl(
|
||||
return Storage::temporaryUrl(
|
||||
$this->path,
|
||||
now()->addMinutes($expirationMinutes)
|
||||
);
|
||||
|
|
@ -59,7 +60,7 @@ public function getTemporaryUrl(int $expirationMinutes = 60): string
|
|||
|
||||
public function delete(): bool
|
||||
{
|
||||
Storage::disk($this->disk)->delete($this->path);
|
||||
Storage::delete($this->path);
|
||||
|
||||
return parent::delete();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class PostPlatform extends Model
|
|||
protected $fillable = [
|
||||
'post_id',
|
||||
'social_account_id',
|
||||
'enabled',
|
||||
'platform',
|
||||
'content',
|
||||
'status',
|
||||
|
|
@ -30,6 +31,7 @@ class PostPlatform extends Model
|
|||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'platform' => SocialPlatform::class,
|
||||
'published_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\WorkspaceRole;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
|
@ -18,6 +17,7 @@ class Workspace extends Model
|
|||
protected $fillable = [
|
||||
'user_id',
|
||||
'name',
|
||||
'timezone',
|
||||
];
|
||||
|
||||
public function owner(): BelongsTo
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Enums\WorkspaceRole;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
|
|
@ -14,7 +15,7 @@ public function viewAny(User $user): bool
|
|||
|
||||
public function view(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $user->id === $workspace->user_id || $workspace->members->contains($user);
|
||||
return $this->isOwner($user, $workspace) || $workspace->members->contains($user);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
|
|
@ -24,21 +25,52 @@ public function create(User $user): bool
|
|||
|
||||
public function update(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $user->id === $workspace->user_id;
|
||||
return $this->isOwner($user, $workspace) || $this->isAdmin($user, $workspace);
|
||||
}
|
||||
|
||||
public function delete(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $user->id === $workspace->user_id;
|
||||
return $this->isOwner($user, $workspace);
|
||||
}
|
||||
|
||||
public function restore(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $user->id === $workspace->user_id;
|
||||
return $this->isOwner($user, $workspace);
|
||||
}
|
||||
|
||||
public function forceDelete(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $this->isOwner($user, $workspace);
|
||||
}
|
||||
|
||||
public function manageTeam(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $this->isOwner($user, $workspace) || $this->isAdmin($user, $workspace);
|
||||
}
|
||||
|
||||
public function manageAccounts(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $this->isOwner($user, $workspace) || $this->isAdmin($user, $workspace);
|
||||
}
|
||||
|
||||
public function createPost(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $this->isOwner($user, $workspace) || $workspace->members->contains($user);
|
||||
}
|
||||
|
||||
private function isOwner(User $user, Workspace $workspace): bool
|
||||
{
|
||||
return $user->id === $workspace->user_id;
|
||||
}
|
||||
|
||||
private function isAdmin(User $user, Workspace $workspace): bool
|
||||
{
|
||||
$member = $workspace->members()->where('user_id', $user->id)->first();
|
||||
|
||||
if (! $member) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $member->pivot->role === WorkspaceRole::Admin->value;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,17 +2,16 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Socialite\LinkedInPageExtendSocialite;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use App\Socialite\LinkedInPageExtendSocialite;
|
||||
use SocialiteProviders\LinkedIn\LinkedInExtendSocialite;
|
||||
use SocialiteProviders\Manager\SocialiteWasCalled;
|
||||
use SocialiteProviders\TikTok\TikTokExtendSocialite;
|
||||
use SocialiteProviders\Twitter\TwitterExtendSocialite;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
|
@ -38,7 +37,6 @@ protected function configureSocialite(): void
|
|||
Event::listen(SocialiteWasCalled::class, LinkedInExtendSocialite::class);
|
||||
Event::listen(SocialiteWasCalled::class, LinkedInPageExtendSocialite::class);
|
||||
Event::listen(SocialiteWasCalled::class, TikTokExtendSocialite::class);
|
||||
Event::listen(SocialiteWasCalled::class, TwitterExtendSocialite::class);
|
||||
}
|
||||
|
||||
protected function configureDefaults(): void
|
||||
|
|
|
|||
192
app/Services/Social/FacebookPublisher.php
Normal file
192
app/Services/Social/FacebookPublisher.php
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Social;
|
||||
|
||||
use App\Models\PostPlatform;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class FacebookPublisher
|
||||
{
|
||||
private string $baseUrl = 'https://graph.facebook.com/v21.0';
|
||||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$account = $postPlatform->socialAccount;
|
||||
$pageId = $account->platform_user_id;
|
||||
$accessToken = $account->access_token;
|
||||
|
||||
$media = $postPlatform->media;
|
||||
|
||||
// Text only post
|
||||
if ($media->isEmpty()) {
|
||||
return $this->publishTextPost($pageId, $accessToken, $postPlatform->content);
|
||||
}
|
||||
|
||||
$firstMedia = $media->first();
|
||||
$isVideo = str_starts_with($firstMedia->mime_type, 'video/');
|
||||
$isImage = str_starts_with($firstMedia->mime_type, 'image/');
|
||||
|
||||
if ($isVideo) {
|
||||
return $this->publishVideoPost($pageId, $accessToken, $postPlatform->content, $firstMedia);
|
||||
}
|
||||
|
||||
if ($isImage) {
|
||||
// Single or multiple images
|
||||
if ($media->count() === 1) {
|
||||
return $this->publishSingleImagePost($pageId, $accessToken, $postPlatform->content, $firstMedia);
|
||||
}
|
||||
|
||||
return $this->publishMultiImagePost($pageId, $accessToken, $postPlatform->content, $media);
|
||||
}
|
||||
|
||||
throw new \Exception('Unsupported media type for Facebook');
|
||||
}
|
||||
|
||||
private function publishTextPost(string $pageId, string $accessToken, string $content): array
|
||||
{
|
||||
Log::info('Facebook publishing text post', ['page_id' => $pageId]);
|
||||
|
||||
$response = Http::post("{$this->baseUrl}/{$pageId}/feed", [
|
||||
'message' => $content,
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Facebook text post failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
throw new \Exception('Facebook API error: '.$response->body());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$postId = $data['id'];
|
||||
|
||||
return [
|
||||
'id' => $postId,
|
||||
'url' => "https://www.facebook.com/{$postId}",
|
||||
];
|
||||
}
|
||||
|
||||
private function publishSingleImagePost(string $pageId, string $accessToken, string $content, $media): array
|
||||
{
|
||||
Log::info('Facebook publishing single image post', ['page_id' => $pageId]);
|
||||
|
||||
$response = Http::post("{$this->baseUrl}/{$pageId}/photos", [
|
||||
'message' => $content,
|
||||
'url' => $media->url,
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Facebook single image post failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
throw new \Exception('Facebook API error: '.$response->body());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$postId = $data['post_id'] ?? $data['id'];
|
||||
|
||||
return [
|
||||
'id' => $postId,
|
||||
'url' => "https://www.facebook.com/{$postId}",
|
||||
];
|
||||
}
|
||||
|
||||
private function publishMultiImagePost(string $pageId, string $accessToken, string $content, $mediaCollection): array
|
||||
{
|
||||
Log::info('Facebook publishing multi-image post', [
|
||||
'page_id' => $pageId,
|
||||
'image_count' => $mediaCollection->count(),
|
||||
]);
|
||||
|
||||
// Upload each image as unpublished
|
||||
$attachedMedia = [];
|
||||
|
||||
foreach ($mediaCollection as $media) {
|
||||
if (! str_starts_with($media->mime_type, 'image/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$uploadResponse = Http::post("{$this->baseUrl}/{$pageId}/photos", [
|
||||
'url' => $media->url,
|
||||
'published' => 'false',
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
|
||||
if ($uploadResponse->failed()) {
|
||||
Log::error('Facebook image upload failed', [
|
||||
'body' => $uploadResponse->body(),
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$uploadData = $uploadResponse->json();
|
||||
$attachedMedia[] = ['media_fbid' => $uploadData['id']];
|
||||
}
|
||||
|
||||
if (empty($attachedMedia)) {
|
||||
throw new \Exception('Failed to upload any images to Facebook');
|
||||
}
|
||||
|
||||
// Create the post with attached media
|
||||
$postData = [
|
||||
'message' => $content,
|
||||
'access_token' => $accessToken,
|
||||
];
|
||||
|
||||
foreach ($attachedMedia as $index => $media) {
|
||||
$postData["attached_media[{$index}]"] = json_encode($media);
|
||||
}
|
||||
|
||||
$response = Http::post("{$this->baseUrl}/{$pageId}/feed", $postData);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Facebook multi-image post failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
throw new \Exception('Facebook API error: '.$response->body());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$postId = $data['id'];
|
||||
|
||||
return [
|
||||
'id' => $postId,
|
||||
'url' => "https://www.facebook.com/{$postId}",
|
||||
];
|
||||
}
|
||||
|
||||
private function publishVideoPost(string $pageId, string $accessToken, string $content, $media): array
|
||||
{
|
||||
Log::info('Facebook publishing video post', ['page_id' => $pageId]);
|
||||
|
||||
// Use resumable upload for videos
|
||||
$response = Http::post("{$this->baseUrl}/{$pageId}/videos", [
|
||||
'description' => $content,
|
||||
'file_url' => $media->url,
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Facebook video post failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
throw new \Exception('Facebook API error: '.$response->body());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$videoId = $data['id'];
|
||||
|
||||
return [
|
||||
'id' => $videoId,
|
||||
'url' => "https://www.facebook.com/{$pageId}/videos/{$videoId}",
|
||||
];
|
||||
}
|
||||
}
|
||||
229
app/Services/Social/InstagramPublisher.php
Normal file
229
app/Services/Social/InstagramPublisher.php
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Social;
|
||||
|
||||
use App\Models\PostPlatform;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class InstagramPublisher
|
||||
{
|
||||
private string $baseUrl = 'https://graph.facebook.com/v21.0';
|
||||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$account = $postPlatform->socialAccount;
|
||||
$instagramId = $account->platform_user_id;
|
||||
$accessToken = $account->access_token;
|
||||
|
||||
$media = $postPlatform->media;
|
||||
|
||||
if ($media->isEmpty()) {
|
||||
throw new \Exception('Instagram requires at least one image or video.');
|
||||
}
|
||||
|
||||
$firstMedia = $media->first();
|
||||
$isVideo = str_starts_with($firstMedia->mime_type, 'video/');
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
private function publishSingleImage(string $instagramId, string $accessToken, string $content, $media): array
|
||||
{
|
||||
Log::info('Instagram publishing single image', ['instagram_id' => $instagramId]);
|
||||
|
||||
// Step 1: Create container
|
||||
$containerResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", [
|
||||
'image_url' => $media->url,
|
||||
'caption' => $content,
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
|
||||
if ($containerResponse->failed()) {
|
||||
Log::error('Instagram container creation failed', [
|
||||
'status' => $containerResponse->status(),
|
||||
'body' => $containerResponse->body(),
|
||||
]);
|
||||
throw new \Exception('Instagram API error: '.$containerResponse->body());
|
||||
}
|
||||
|
||||
$containerId = $containerResponse->json()['id'];
|
||||
|
||||
// Step 2: Publish container
|
||||
return $this->publishContainer($instagramId, $accessToken, $containerId);
|
||||
}
|
||||
|
||||
private function publishReel(string $instagramId, string $accessToken, string $content, $media): array
|
||||
{
|
||||
Log::info('Instagram publishing reel', ['instagram_id' => $instagramId]);
|
||||
|
||||
// Step 1: Create container for video/reel
|
||||
$containerResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", [
|
||||
'video_url' => $media->url,
|
||||
'caption' => $content,
|
||||
'media_type' => 'REELS',
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
|
||||
if ($containerResponse->failed()) {
|
||||
Log::error('Instagram reel container creation failed', [
|
||||
'status' => $containerResponse->status(),
|
||||
'body' => $containerResponse->body(),
|
||||
]);
|
||||
throw new \Exception('Instagram API error: '.$containerResponse->body());
|
||||
}
|
||||
|
||||
$containerId = $containerResponse->json()['id'];
|
||||
|
||||
// Wait for video processing
|
||||
$this->waitForMediaProcessing($containerId, $accessToken);
|
||||
|
||||
// Step 2: Publish container
|
||||
return $this->publishContainer($instagramId, $accessToken, $containerId);
|
||||
}
|
||||
|
||||
private function publishCarousel(string $instagramId, string $accessToken, string $content, $mediaCollection): array
|
||||
{
|
||||
Log::info('Instagram publishing carousel', [
|
||||
'instagram_id' => $instagramId,
|
||||
'media_count' => $mediaCollection->count(),
|
||||
]);
|
||||
|
||||
// Step 1: Create containers for each media item
|
||||
$childContainers = [];
|
||||
|
||||
foreach ($mediaCollection as $media) {
|
||||
$isVideo = str_starts_with($media->mime_type, 'video/');
|
||||
|
||||
$params = [
|
||||
'is_carousel_item' => 'true',
|
||||
'access_token' => $accessToken,
|
||||
];
|
||||
|
||||
if ($isVideo) {
|
||||
$params['video_url'] = $media->url;
|
||||
$params['media_type'] = 'VIDEO';
|
||||
} else {
|
||||
$params['image_url'] = $media->url;
|
||||
}
|
||||
|
||||
$containerResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", $params);
|
||||
|
||||
if ($containerResponse->failed()) {
|
||||
Log::error('Instagram carousel item creation failed', [
|
||||
'body' => $containerResponse->body(),
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$childId = $containerResponse->json()['id'];
|
||||
|
||||
// Wait for video processing if needed
|
||||
if ($isVideo) {
|
||||
$this->waitForMediaProcessing($childId, $accessToken);
|
||||
}
|
||||
|
||||
$childContainers[] = $childId;
|
||||
}
|
||||
|
||||
if (empty($childContainers)) {
|
||||
throw new \Exception('Failed to create any carousel items');
|
||||
}
|
||||
|
||||
// Step 2: Create carousel container
|
||||
$carouselResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", [
|
||||
'media_type' => 'CAROUSEL',
|
||||
'caption' => $content,
|
||||
'children' => implode(',', $childContainers),
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
|
||||
if ($carouselResponse->failed()) {
|
||||
Log::error('Instagram carousel container creation failed', [
|
||||
'body' => $carouselResponse->body(),
|
||||
]);
|
||||
throw new \Exception('Instagram API error: '.$carouselResponse->body());
|
||||
}
|
||||
|
||||
$carouselId = $carouselResponse->json()['id'];
|
||||
|
||||
// Step 3: Publish carousel
|
||||
return $this->publishContainer($instagramId, $accessToken, $carouselId);
|
||||
}
|
||||
|
||||
private function publishContainer(string $instagramId, string $accessToken, string $containerId): array
|
||||
{
|
||||
$publishResponse = Http::post("{$this->baseUrl}/{$instagramId}/media_publish", [
|
||||
'creation_id' => $containerId,
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
|
||||
if ($publishResponse->failed()) {
|
||||
Log::error('Instagram publish failed', [
|
||||
'status' => $publishResponse->status(),
|
||||
'body' => $publishResponse->body(),
|
||||
]);
|
||||
throw new \Exception('Instagram publish error: '.$publishResponse->body());
|
||||
}
|
||||
|
||||
$mediaId = $publishResponse->json()['id'];
|
||||
|
||||
// Get permalink
|
||||
$permalinkResponse = Http::get("{$this->baseUrl}/{$mediaId}", [
|
||||
'fields' => 'permalink',
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
|
||||
$permalink = $permalinkResponse->json()['permalink'] ?? null;
|
||||
|
||||
Log::info('Instagram publish success', ['media_id' => $mediaId, 'permalink' => $permalink]);
|
||||
|
||||
return [
|
||||
'id' => $mediaId,
|
||||
'url' => $permalink,
|
||||
];
|
||||
}
|
||||
|
||||
private function waitForMediaProcessing(string $containerId, string $accessToken, int $maxAttempts = 30): void
|
||||
{
|
||||
for ($i = 0; $i < $maxAttempts; $i++) {
|
||||
$statusResponse = Http::get("{$this->baseUrl}/{$containerId}", [
|
||||
'fields' => 'status_code',
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
|
||||
if ($statusResponse->failed()) {
|
||||
sleep(5);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = $statusResponse->json()['status_code'] ?? 'UNKNOWN';
|
||||
|
||||
Log::info('Instagram media processing status', ['status' => $status, 'attempt' => $i]);
|
||||
|
||||
if ($status === 'FINISHED') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($status === 'ERROR') {
|
||||
throw new \Exception('Instagram media processing failed');
|
||||
}
|
||||
|
||||
sleep(5);
|
||||
}
|
||||
|
||||
Log::warning('Instagram media processing timeout, proceeding anyway');
|
||||
}
|
||||
}
|
||||
|
|
@ -3,14 +3,29 @@
|
|||
namespace App\Services\Social;
|
||||
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class LinkedInPagePublisher
|
||||
{
|
||||
private string $baseUrl = 'https://api.linkedin.com';
|
||||
|
||||
private string $apiVersion = '202501';
|
||||
|
||||
private string $accessToken;
|
||||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$account = $postPlatform->socialAccount;
|
||||
$accessToken = $account->access_token;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
$this->refreshToken($account);
|
||||
$account->refresh();
|
||||
}
|
||||
|
||||
$this->accessToken = $account->access_token;
|
||||
|
||||
$organizationId = $account->meta['organization_id'] ?? null;
|
||||
|
||||
|
|
@ -22,48 +37,296 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
$payload = [
|
||||
'author' => $organizationUrn,
|
||||
'commentary' => $postPlatform->content,
|
||||
'visibility' => 'PUBLIC',
|
||||
'distribution' => [
|
||||
'feedDistribution' => 'MAIN_FEED',
|
||||
'targetEntities' => [],
|
||||
'thirdPartyDistributionChannels' => [],
|
||||
],
|
||||
'lifecycleState' => 'PUBLISHED',
|
||||
'specificContent' => [
|
||||
'com.linkedin.ugc.ShareContent' => [
|
||||
'shareCommentary' => [
|
||||
'text' => $postPlatform->content,
|
||||
],
|
||||
'shareMediaCategory' => 'NONE',
|
||||
],
|
||||
],
|
||||
'visibility' => [
|
||||
'com.linkedin.ugc.MemberNetworkVisibility' => 'PUBLIC',
|
||||
],
|
||||
];
|
||||
|
||||
$media = $postPlatform->media;
|
||||
|
||||
if ($media->isNotEmpty()) {
|
||||
$firstMedia = $media->first();
|
||||
$mediaUrn = $this->uploadMedia($firstMedia, $organizationUrn);
|
||||
|
||||
if ($firstMedia->type->value === 'image') {
|
||||
$payload['specificContent']['com.linkedin.ugc.ShareContent']['shareMediaCategory'] = 'IMAGE';
|
||||
$payload['specificContent']['com.linkedin.ugc.ShareContent']['media'] = [
|
||||
[
|
||||
'status' => 'READY',
|
||||
'originalUrl' => $firstMedia->url,
|
||||
if ($mediaUrn) {
|
||||
$payload['content'] = [
|
||||
'media' => [
|
||||
'id' => $mediaUrn,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$response = Http::withToken($accessToken)
|
||||
->post('https://api.linkedin.com/v2/ugcPosts', $payload);
|
||||
Log::info('LinkedIn Page creating post', ['payload' => $payload]);
|
||||
|
||||
$response = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/rest/posts", $payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new \Exception("LinkedIn Page API error: " . $response->body());
|
||||
Log::error('LinkedIn Page post creation failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
throw new \Exception('LinkedIn Page API error: '.$response->body());
|
||||
}
|
||||
|
||||
$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)
|
||||
->withHeaders([
|
||||
'X-Restli-Protocol-Version' => '2.0.0',
|
||||
'LinkedIn-Version' => $this->apiVersion,
|
||||
'Content-Type' => 'application/json',
|
||||
])
|
||||
->timeout(300);
|
||||
}
|
||||
|
||||
private function uploadMedia($mediaItem, string $ownerUrn): ?string
|
||||
{
|
||||
$mimeType = $mediaItem->mime_type;
|
||||
$isVideo = str_starts_with($mimeType, 'video/');
|
||||
$isImage = str_starts_with($mimeType, 'image/');
|
||||
|
||||
if ($isVideo) {
|
||||
return $this->uploadVideo($mediaItem, $ownerUrn);
|
||||
}
|
||||
|
||||
if ($isImage) {
|
||||
return $this->uploadImage($mediaItem, $ownerUrn);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function uploadImage($mediaItem, string $ownerUrn): ?string
|
||||
{
|
||||
Log::info('LinkedIn Page initializing image upload', ['owner' => $ownerUrn]);
|
||||
|
||||
// Step 1: Initialize upload
|
||||
$initResponse = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/rest/images?action=initializeUpload", [
|
||||
'initializeUploadRequest' => [
|
||||
'owner' => $ownerUrn,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($initResponse->failed()) {
|
||||
Log::error('LinkedIn Page image init failed', ['body' => $initResponse->body()]);
|
||||
throw new \Exception('Failed to initialize LinkedIn Page image upload: '.$initResponse->body());
|
||||
}
|
||||
|
||||
$initData = $initResponse->json();
|
||||
$uploadUrl = $initData['value']['uploadUrl'] ?? null;
|
||||
$imageUrn = $initData['value']['image'] ?? null;
|
||||
|
||||
if (! $uploadUrl || ! $imageUrn) {
|
||||
throw new \Exception('LinkedIn Page image upload init missing uploadUrl or image URN');
|
||||
}
|
||||
|
||||
Log::info('LinkedIn Page image init success', ['imageUrn' => $imageUrn]);
|
||||
|
||||
// Step 2: Upload binary data
|
||||
$imageContent = file_get_contents($mediaItem->url);
|
||||
|
||||
$uploadResponse = Http::withToken($this->accessToken)
|
||||
->withHeaders([
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
])
|
||||
->withBody($imageContent, 'application/octet-stream')
|
||||
->put($uploadUrl);
|
||||
|
||||
if ($uploadResponse->failed()) {
|
||||
Log::error('LinkedIn Page image upload failed', ['body' => $uploadResponse->body()]);
|
||||
throw new \Exception('Failed to upload LinkedIn Page image: '.$uploadResponse->body());
|
||||
}
|
||||
|
||||
Log::info('LinkedIn Page image upload success', ['imageUrn' => $imageUrn]);
|
||||
|
||||
return $imageUrn;
|
||||
}
|
||||
|
||||
private function uploadVideo($mediaItem, string $ownerUrn): ?string
|
||||
{
|
||||
$videoContent = file_get_contents($mediaItem->url);
|
||||
$fileSize = strlen($videoContent);
|
||||
|
||||
Log::info('LinkedIn Page initializing video upload', [
|
||||
'owner' => $ownerUrn,
|
||||
'fileSize' => $fileSize,
|
||||
]);
|
||||
|
||||
// Step 1: Initialize upload
|
||||
$initResponse = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/rest/videos?action=initializeUpload", [
|
||||
'initializeUploadRequest' => [
|
||||
'owner' => $ownerUrn,
|
||||
'fileSizeBytes' => $fileSize,
|
||||
'uploadCaptions' => false,
|
||||
'uploadThumbnail' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($initResponse->failed()) {
|
||||
Log::error('LinkedIn Page video init failed', ['body' => $initResponse->body()]);
|
||||
throw new \Exception('Failed to initialize LinkedIn Page video upload: '.$initResponse->body());
|
||||
}
|
||||
|
||||
$initData = $initResponse->json();
|
||||
$videoUrn = $initData['value']['video'] ?? null;
|
||||
$uploadInstructions = $initData['value']['uploadInstructions'] ?? [];
|
||||
$uploadToken = $initData['value']['uploadToken'] ?? '';
|
||||
|
||||
if (! $videoUrn || empty($uploadInstructions)) {
|
||||
throw new \Exception('LinkedIn Page video upload init missing video URN or upload instructions');
|
||||
}
|
||||
|
||||
Log::info('LinkedIn Page video init success', [
|
||||
'videoUrn' => $videoUrn,
|
||||
'chunks' => count($uploadInstructions),
|
||||
]);
|
||||
|
||||
// Step 2: Upload chunks
|
||||
$uploadedPartIds = [];
|
||||
|
||||
foreach ($uploadInstructions as $index => $instruction) {
|
||||
$uploadUrl = $instruction['uploadUrl'];
|
||||
$firstByte = $instruction['firstByte'];
|
||||
$lastByte = $instruction['lastByte'];
|
||||
|
||||
$chunkData = substr($videoContent, $firstByte, $lastByte - $firstByte + 1);
|
||||
|
||||
Log::info('LinkedIn Page uploading video chunk', [
|
||||
'index' => $index,
|
||||
'firstByte' => $firstByte,
|
||||
'lastByte' => $lastByte,
|
||||
'chunkSize' => strlen($chunkData),
|
||||
]);
|
||||
|
||||
$chunkResponse = Http::withToken($this->accessToken)
|
||||
->withHeaders([
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
])
|
||||
->timeout(600)
|
||||
->withBody($chunkData, 'application/octet-stream')
|
||||
->put($uploadUrl);
|
||||
|
||||
if ($chunkResponse->failed()) {
|
||||
Log::error('LinkedIn Page video chunk upload failed', [
|
||||
'index' => $index,
|
||||
'body' => $chunkResponse->body(),
|
||||
]);
|
||||
throw new \Exception('Failed to upload LinkedIn Page video chunk: '.$chunkResponse->body());
|
||||
}
|
||||
|
||||
$etag = $chunkResponse->header('etag');
|
||||
if ($etag) {
|
||||
$uploadedPartIds[] = $etag;
|
||||
}
|
||||
|
||||
Log::info('LinkedIn Page video chunk uploaded', ['index' => $index, 'etag' => $etag]);
|
||||
}
|
||||
|
||||
// Step 3: Finalize upload
|
||||
Log::info('LinkedIn Page finalizing video upload', ['videoUrn' => $videoUrn]);
|
||||
|
||||
$finalizeResponse = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/rest/videos?action=finalizeUpload", [
|
||||
'finalizeUploadRequest' => [
|
||||
'video' => $videoUrn,
|
||||
'uploadToken' => $uploadToken,
|
||||
'uploadedPartIds' => $uploadedPartIds,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($finalizeResponse->failed()) {
|
||||
Log::error('LinkedIn Page video finalize failed', ['body' => $finalizeResponse->body()]);
|
||||
throw new \Exception('Failed to finalize LinkedIn Page video upload: '.$finalizeResponse->body());
|
||||
}
|
||||
|
||||
Log::info('LinkedIn Page video upload finalized', ['videoUrn' => $videoUrn]);
|
||||
|
||||
// Step 4: Wait for processing
|
||||
$this->waitForVideoProcessing($videoUrn);
|
||||
|
||||
return $videoUrn;
|
||||
}
|
||||
|
||||
private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30): void
|
||||
{
|
||||
$encodedUrn = urlencode($videoUrn);
|
||||
|
||||
for ($i = 0; $i < $maxAttempts; $i++) {
|
||||
$response = $this->getHttpClient()
|
||||
->get("{$this->baseUrl}/rest/videos/{$encodedUrn}");
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::warning('LinkedIn Page video status check failed', ['attempt' => $i]);
|
||||
sleep(5);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$status = $data['status'] ?? 'UNKNOWN';
|
||||
|
||||
Log::info('LinkedIn Page video processing status', ['status' => $status, 'attempt' => $i]);
|
||||
|
||||
if ($status === 'AVAILABLE') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($status === 'PROCESSING_FAILED') {
|
||||
throw new \Exception('LinkedIn Page video processing failed');
|
||||
}
|
||||
|
||||
sleep(5);
|
||||
}
|
||||
|
||||
Log::warning('LinkedIn Page video processing timeout, proceeding anyway');
|
||||
}
|
||||
|
||||
private function refreshToken(SocialAccount $account): void
|
||||
{
|
||||
if (! $account->refresh_token) {
|
||||
throw new \Exception('No refresh token available for LinkedIn Page account');
|
||||
}
|
||||
|
||||
$response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $account->refresh_token,
|
||||
'client_id' => config('services.linkedin-openid.client_id'),
|
||||
'client_secret' => config('services.linkedin-openid.client_secret'),
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new \Exception('Failed to refresh LinkedIn Page token: '.$response->body());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
return [
|
||||
'id' => $data['id'] ?? 'unknown',
|
||||
'url' => null,
|
||||
];
|
||||
$account->update([
|
||||
'access_token' => $data['access_token'],
|
||||
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
|
||||
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,61 +3,318 @@
|
|||
namespace App\Services\Social;
|
||||
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class LinkedInPublisher
|
||||
{
|
||||
private string $baseUrl = 'https://api.linkedin.com';
|
||||
|
||||
private string $apiVersion = '202501';
|
||||
|
||||
private string $accessToken;
|
||||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$account = $postPlatform->socialAccount;
|
||||
$accessToken = $account->access_token;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
$this->refreshToken($account);
|
||||
$account->refresh();
|
||||
}
|
||||
|
||||
$this->accessToken = $account->access_token;
|
||||
|
||||
$personUrn = "urn:li:person:{$account->platform_user_id}";
|
||||
|
||||
$payload = [
|
||||
'author' => $personUrn,
|
||||
'commentary' => $postPlatform->content,
|
||||
'visibility' => 'PUBLIC',
|
||||
'distribution' => [
|
||||
'feedDistribution' => 'MAIN_FEED',
|
||||
'targetEntities' => [],
|
||||
'thirdPartyDistributionChannels' => [],
|
||||
],
|
||||
'lifecycleState' => 'PUBLISHED',
|
||||
'specificContent' => [
|
||||
'com.linkedin.ugc.ShareContent' => [
|
||||
'shareCommentary' => [
|
||||
'text' => $postPlatform->content,
|
||||
],
|
||||
'shareMediaCategory' => 'NONE',
|
||||
],
|
||||
],
|
||||
'visibility' => [
|
||||
'com.linkedin.ugc.MemberNetworkVisibility' => 'PUBLIC',
|
||||
],
|
||||
];
|
||||
|
||||
$media = $postPlatform->media;
|
||||
|
||||
if ($media->isNotEmpty()) {
|
||||
$firstMedia = $media->first();
|
||||
$mediaUrn = $this->uploadMedia($firstMedia, $personUrn);
|
||||
|
||||
if ($firstMedia->type->value === 'image') {
|
||||
$payload['specificContent']['com.linkedin.ugc.ShareContent']['shareMediaCategory'] = 'IMAGE';
|
||||
$payload['specificContent']['com.linkedin.ugc.ShareContent']['media'] = [
|
||||
[
|
||||
'status' => 'READY',
|
||||
'originalUrl' => $firstMedia->url,
|
||||
if ($mediaUrn) {
|
||||
$payload['content'] = [
|
||||
'media' => [
|
||||
'id' => $mediaUrn,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$response = Http::withToken($accessToken)
|
||||
->post('https://api.linkedin.com/v2/ugcPosts', $payload);
|
||||
Log::info('LinkedIn creating post', ['payload' => $payload]);
|
||||
|
||||
$response = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/rest/posts", $payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new \Exception("LinkedIn API error: " . $response->body());
|
||||
Log::error('LinkedIn post creation failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
throw new \Exception('LinkedIn API error: '.$response->body());
|
||||
}
|
||||
|
||||
$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)
|
||||
->withHeaders([
|
||||
'X-Restli-Protocol-Version' => '2.0.0',
|
||||
'LinkedIn-Version' => $this->apiVersion,
|
||||
'Content-Type' => 'application/json',
|
||||
])
|
||||
->timeout(300);
|
||||
}
|
||||
|
||||
private function uploadMedia($mediaItem, string $ownerUrn): ?string
|
||||
{
|
||||
$mimeType = $mediaItem->mime_type;
|
||||
$isVideo = str_starts_with($mimeType, 'video/');
|
||||
$isImage = str_starts_with($mimeType, 'image/');
|
||||
|
||||
if ($isVideo) {
|
||||
return $this->uploadVideo($mediaItem, $ownerUrn);
|
||||
}
|
||||
|
||||
if ($isImage) {
|
||||
return $this->uploadImage($mediaItem, $ownerUrn);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function uploadImage($mediaItem, string $ownerUrn): ?string
|
||||
{
|
||||
Log::info('LinkedIn initializing image upload', ['owner' => $ownerUrn]);
|
||||
|
||||
// Step 1: Initialize upload
|
||||
$initResponse = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/rest/images?action=initializeUpload", [
|
||||
'initializeUploadRequest' => [
|
||||
'owner' => $ownerUrn,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($initResponse->failed()) {
|
||||
Log::error('LinkedIn image init failed', ['body' => $initResponse->body()]);
|
||||
throw new \Exception('Failed to initialize LinkedIn image upload: '.$initResponse->body());
|
||||
}
|
||||
|
||||
$initData = $initResponse->json();
|
||||
$uploadUrl = $initData['value']['uploadUrl'] ?? null;
|
||||
$imageUrn = $initData['value']['image'] ?? null;
|
||||
|
||||
if (! $uploadUrl || ! $imageUrn) {
|
||||
throw new \Exception('LinkedIn image upload init missing uploadUrl or image URN');
|
||||
}
|
||||
|
||||
Log::info('LinkedIn image init success', ['imageUrn' => $imageUrn]);
|
||||
|
||||
// Step 2: Upload binary data
|
||||
$imageContent = file_get_contents($mediaItem->url);
|
||||
|
||||
$uploadResponse = Http::withToken($this->accessToken)
|
||||
->withHeaders([
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
])
|
||||
->withBody($imageContent, 'application/octet-stream')
|
||||
->put($uploadUrl);
|
||||
|
||||
if ($uploadResponse->failed()) {
|
||||
Log::error('LinkedIn image upload failed', ['body' => $uploadResponse->body()]);
|
||||
throw new \Exception('Failed to upload LinkedIn image: '.$uploadResponse->body());
|
||||
}
|
||||
|
||||
Log::info('LinkedIn image upload success', ['imageUrn' => $imageUrn]);
|
||||
|
||||
return $imageUrn;
|
||||
}
|
||||
|
||||
private function uploadVideo($mediaItem, string $ownerUrn): ?string
|
||||
{
|
||||
$videoContent = file_get_contents($mediaItem->url);
|
||||
$fileSize = strlen($videoContent);
|
||||
|
||||
Log::info('LinkedIn initializing video upload', [
|
||||
'owner' => $ownerUrn,
|
||||
'fileSize' => $fileSize,
|
||||
]);
|
||||
|
||||
// Step 1: Initialize upload
|
||||
$initResponse = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/rest/videos?action=initializeUpload", [
|
||||
'initializeUploadRequest' => [
|
||||
'owner' => $ownerUrn,
|
||||
'fileSizeBytes' => $fileSize,
|
||||
'uploadCaptions' => false,
|
||||
'uploadThumbnail' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($initResponse->failed()) {
|
||||
Log::error('LinkedIn video init failed', ['body' => $initResponse->body()]);
|
||||
throw new \Exception('Failed to initialize LinkedIn video upload: '.$initResponse->body());
|
||||
}
|
||||
|
||||
$initData = $initResponse->json();
|
||||
$videoUrn = $initData['value']['video'] ?? null;
|
||||
$uploadInstructions = $initData['value']['uploadInstructions'] ?? [];
|
||||
$uploadToken = $initData['value']['uploadToken'] ?? '';
|
||||
|
||||
if (! $videoUrn || empty($uploadInstructions)) {
|
||||
throw new \Exception('LinkedIn video upload init missing video URN or upload instructions');
|
||||
}
|
||||
|
||||
Log::info('LinkedIn video init success', [
|
||||
'videoUrn' => $videoUrn,
|
||||
'chunks' => count($uploadInstructions),
|
||||
]);
|
||||
|
||||
// Step 2: Upload chunks
|
||||
$uploadedPartIds = [];
|
||||
|
||||
foreach ($uploadInstructions as $index => $instruction) {
|
||||
$uploadUrl = $instruction['uploadUrl'];
|
||||
$firstByte = $instruction['firstByte'];
|
||||
$lastByte = $instruction['lastByte'];
|
||||
|
||||
$chunkData = substr($videoContent, $firstByte, $lastByte - $firstByte + 1);
|
||||
|
||||
Log::info('LinkedIn uploading video chunk', [
|
||||
'index' => $index,
|
||||
'firstByte' => $firstByte,
|
||||
'lastByte' => $lastByte,
|
||||
'chunkSize' => strlen($chunkData),
|
||||
]);
|
||||
|
||||
$chunkResponse = Http::withToken($this->accessToken)
|
||||
->withHeaders([
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
])
|
||||
->timeout(600)
|
||||
->withBody($chunkData, 'application/octet-stream')
|
||||
->put($uploadUrl);
|
||||
|
||||
if ($chunkResponse->failed()) {
|
||||
Log::error('LinkedIn video chunk upload failed', [
|
||||
'index' => $index,
|
||||
'body' => $chunkResponse->body(),
|
||||
]);
|
||||
throw new \Exception('Failed to upload LinkedIn video chunk: '.$chunkResponse->body());
|
||||
}
|
||||
|
||||
$etag = $chunkResponse->header('etag');
|
||||
if ($etag) {
|
||||
$uploadedPartIds[] = $etag;
|
||||
}
|
||||
|
||||
Log::info('LinkedIn video chunk uploaded', ['index' => $index, 'etag' => $etag]);
|
||||
}
|
||||
|
||||
// Step 3: Finalize upload
|
||||
Log::info('LinkedIn finalizing video upload', ['videoUrn' => $videoUrn]);
|
||||
|
||||
$finalizeResponse = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/rest/videos?action=finalizeUpload", [
|
||||
'finalizeUploadRequest' => [
|
||||
'video' => $videoUrn,
|
||||
'uploadToken' => $uploadToken,
|
||||
'uploadedPartIds' => $uploadedPartIds,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($finalizeResponse->failed()) {
|
||||
Log::error('LinkedIn video finalize failed', ['body' => $finalizeResponse->body()]);
|
||||
throw new \Exception('Failed to finalize LinkedIn video upload: '.$finalizeResponse->body());
|
||||
}
|
||||
|
||||
Log::info('LinkedIn video upload finalized', ['videoUrn' => $videoUrn]);
|
||||
|
||||
// Step 4: Wait for processing
|
||||
$this->waitForVideoProcessing($videoUrn);
|
||||
|
||||
return $videoUrn;
|
||||
}
|
||||
|
||||
private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30): void
|
||||
{
|
||||
$encodedUrn = urlencode($videoUrn);
|
||||
|
||||
for ($i = 0; $i < $maxAttempts; $i++) {
|
||||
$response = $this->getHttpClient()
|
||||
->get("{$this->baseUrl}/rest/videos/{$encodedUrn}");
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::warning('LinkedIn video status check failed', ['attempt' => $i]);
|
||||
sleep(5);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$status = $data['status'] ?? 'UNKNOWN';
|
||||
|
||||
Log::info('LinkedIn video processing status', ['status' => $status, 'attempt' => $i]);
|
||||
|
||||
if ($status === 'AVAILABLE') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($status === 'PROCESSING_FAILED') {
|
||||
throw new \Exception('LinkedIn video processing failed');
|
||||
}
|
||||
|
||||
sleep(5);
|
||||
}
|
||||
|
||||
Log::warning('LinkedIn video processing timeout, proceeding anyway');
|
||||
}
|
||||
|
||||
private function refreshToken(SocialAccount $account): void
|
||||
{
|
||||
if (! $account->refresh_token) {
|
||||
throw new \Exception('No refresh token available for LinkedIn account');
|
||||
}
|
||||
|
||||
$response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $account->refresh_token,
|
||||
'client_id' => config('services.linkedin.client_id'),
|
||||
'client_secret' => config('services.linkedin.client_secret'),
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new \Exception('Failed to refresh LinkedIn token: '.$response->body());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
return [
|
||||
'id' => $data['id'] ?? 'unknown',
|
||||
'url' => null,
|
||||
];
|
||||
$account->update([
|
||||
'access_token' => $data['access_token'],
|
||||
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
|
||||
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
287
app/Services/Social/ThreadsPublisher.php
Normal file
287
app/Services/Social/ThreadsPublisher.php
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Social;
|
||||
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ThreadsPublisher
|
||||
{
|
||||
private string $baseUrl = 'https://graph.threads.net/v1.0';
|
||||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
$this->refreshToken($account);
|
||||
$account->refresh();
|
||||
}
|
||||
|
||||
$userId = $account->platform_user_id;
|
||||
$accessToken = $account->access_token;
|
||||
|
||||
$media = $postPlatform->media;
|
||||
|
||||
// Text only post
|
||||
if ($media->isEmpty()) {
|
||||
return $this->publishTextPost($userId, $accessToken, $postPlatform->content);
|
||||
}
|
||||
|
||||
$firstMedia = $media->first();
|
||||
$isVideo = str_starts_with($firstMedia->mime_type, 'video/');
|
||||
|
||||
// Single media
|
||||
if ($media->count() === 1) {
|
||||
if ($isVideo) {
|
||||
return $this->publishVideoPost($userId, $accessToken, $postPlatform->content, $firstMedia);
|
||||
}
|
||||
|
||||
return $this->publishImagePost($userId, $accessToken, $postPlatform->content, $firstMedia);
|
||||
}
|
||||
|
||||
// Multiple media - carousel
|
||||
return $this->publishCarousel($userId, $accessToken, $postPlatform->content, $media);
|
||||
}
|
||||
|
||||
private function publishTextPost(string $userId, string $accessToken, string $content): array
|
||||
{
|
||||
Log::info('Threads publishing text post', ['user_id' => $userId]);
|
||||
|
||||
// Step 1: Create container
|
||||
$containerResponse = Http::post("{$this->baseUrl}/{$userId}/threads", [
|
||||
'media_type' => 'TEXT',
|
||||
'text' => $content,
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
|
||||
if ($containerResponse->failed()) {
|
||||
Log::error('Threads container creation failed', [
|
||||
'status' => $containerResponse->status(),
|
||||
'body' => $containerResponse->body(),
|
||||
]);
|
||||
throw new \Exception('Threads API error: '.$containerResponse->body());
|
||||
}
|
||||
|
||||
$containerId = $containerResponse->json()['id'];
|
||||
|
||||
// Step 2: Publish
|
||||
return $this->publishContainer($userId, $accessToken, $containerId);
|
||||
}
|
||||
|
||||
private function publishImagePost(string $userId, string $accessToken, string $content, $media): array
|
||||
{
|
||||
Log::info('Threads publishing image post', ['user_id' => $userId]);
|
||||
|
||||
// Step 1: Create container
|
||||
$containerResponse = Http::post("{$this->baseUrl}/{$userId}/threads", [
|
||||
'media_type' => 'IMAGE',
|
||||
'image_url' => $media->url,
|
||||
'text' => $content,
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
|
||||
if ($containerResponse->failed()) {
|
||||
Log::error('Threads image container creation failed', [
|
||||
'status' => $containerResponse->status(),
|
||||
'body' => $containerResponse->body(),
|
||||
]);
|
||||
throw new \Exception('Threads API error: '.$containerResponse->body());
|
||||
}
|
||||
|
||||
$containerId = $containerResponse->json()['id'];
|
||||
|
||||
// Step 2: Publish
|
||||
return $this->publishContainer($userId, $accessToken, $containerId);
|
||||
}
|
||||
|
||||
private function publishVideoPost(string $userId, string $accessToken, string $content, $media): array
|
||||
{
|
||||
Log::info('Threads publishing video post', ['user_id' => $userId]);
|
||||
|
||||
// Step 1: Create container
|
||||
$containerResponse = Http::post("{$this->baseUrl}/{$userId}/threads", [
|
||||
'media_type' => 'VIDEO',
|
||||
'video_url' => $media->url,
|
||||
'text' => $content,
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
|
||||
if ($containerResponse->failed()) {
|
||||
Log::error('Threads video container creation failed', [
|
||||
'status' => $containerResponse->status(),
|
||||
'body' => $containerResponse->body(),
|
||||
]);
|
||||
throw new \Exception('Threads API error: '.$containerResponse->body());
|
||||
}
|
||||
|
||||
$containerId = $containerResponse->json()['id'];
|
||||
|
||||
// Wait for video processing
|
||||
$this->waitForMediaProcessing($containerId, $accessToken);
|
||||
|
||||
// Step 2: Publish
|
||||
return $this->publishContainer($userId, $accessToken, $containerId);
|
||||
}
|
||||
|
||||
private function publishCarousel(string $userId, string $accessToken, string $content, $mediaCollection): array
|
||||
{
|
||||
Log::info('Threads publishing carousel', [
|
||||
'user_id' => $userId,
|
||||
'media_count' => $mediaCollection->count(),
|
||||
]);
|
||||
|
||||
// Step 1: Create containers for each media item
|
||||
$childContainers = [];
|
||||
|
||||
foreach ($mediaCollection as $media) {
|
||||
$isVideo = str_starts_with($media->mime_type, 'video/');
|
||||
|
||||
$params = [
|
||||
'is_carousel_item' => 'true',
|
||||
'access_token' => $accessToken,
|
||||
];
|
||||
|
||||
if ($isVideo) {
|
||||
$params['media_type'] = 'VIDEO';
|
||||
$params['video_url'] = $media->url;
|
||||
} else {
|
||||
$params['media_type'] = 'IMAGE';
|
||||
$params['image_url'] = $media->url;
|
||||
}
|
||||
|
||||
$containerResponse = Http::post("{$this->baseUrl}/{$userId}/threads", $params);
|
||||
|
||||
if ($containerResponse->failed()) {
|
||||
Log::error('Threads carousel item creation failed', [
|
||||
'body' => $containerResponse->body(),
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$childId = $containerResponse->json()['id'];
|
||||
|
||||
// Wait for video processing if needed
|
||||
if ($isVideo) {
|
||||
$this->waitForMediaProcessing($childId, $accessToken);
|
||||
}
|
||||
|
||||
$childContainers[] = $childId;
|
||||
}
|
||||
|
||||
if (empty($childContainers)) {
|
||||
throw new \Exception('Failed to create any carousel items');
|
||||
}
|
||||
|
||||
// Step 2: Create carousel container
|
||||
$carouselResponse = Http::post("{$this->baseUrl}/{$userId}/threads", [
|
||||
'media_type' => 'CAROUSEL',
|
||||
'text' => $content,
|
||||
'children' => implode(',', $childContainers),
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
|
||||
if ($carouselResponse->failed()) {
|
||||
Log::error('Threads carousel container creation failed', [
|
||||
'body' => $carouselResponse->body(),
|
||||
]);
|
||||
throw new \Exception('Threads API error: '.$carouselResponse->body());
|
||||
}
|
||||
|
||||
$carouselId = $carouselResponse->json()['id'];
|
||||
|
||||
// Step 3: Publish carousel
|
||||
return $this->publishContainer($userId, $accessToken, $carouselId);
|
||||
}
|
||||
|
||||
private function publishContainer(string $userId, string $accessToken, string $containerId): array
|
||||
{
|
||||
$publishResponse = Http::post("{$this->baseUrl}/{$userId}/threads_publish", [
|
||||
'creation_id' => $containerId,
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
|
||||
if ($publishResponse->failed()) {
|
||||
Log::error('Threads publish failed', [
|
||||
'status' => $publishResponse->status(),
|
||||
'body' => $publishResponse->body(),
|
||||
]);
|
||||
throw new \Exception('Threads publish error: '.$publishResponse->body());
|
||||
}
|
||||
|
||||
$mediaId = $publishResponse->json()['id'];
|
||||
|
||||
// Get permalink
|
||||
$permalinkResponse = Http::get("{$this->baseUrl}/{$mediaId}", [
|
||||
'fields' => 'permalink',
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
|
||||
$permalink = $permalinkResponse->json()['permalink'] ?? null;
|
||||
|
||||
Log::info('Threads publish success', ['media_id' => $mediaId, 'permalink' => $permalink]);
|
||||
|
||||
return [
|
||||
'id' => $mediaId,
|
||||
'url' => $permalink,
|
||||
];
|
||||
}
|
||||
|
||||
private function waitForMediaProcessing(string $containerId, string $accessToken, int $maxAttempts = 30): void
|
||||
{
|
||||
for ($i = 0; $i < $maxAttempts; $i++) {
|
||||
$statusResponse = Http::get("{$this->baseUrl}/{$containerId}", [
|
||||
'fields' => 'status',
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
|
||||
if ($statusResponse->failed()) {
|
||||
sleep(3);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = $statusResponse->json()['status'] ?? 'UNKNOWN';
|
||||
|
||||
Log::info('Threads media processing status', ['status' => $status, 'attempt' => $i]);
|
||||
|
||||
if ($status === 'FINISHED') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($status === 'ERROR') {
|
||||
throw new \Exception('Threads media processing failed');
|
||||
}
|
||||
|
||||
sleep(3);
|
||||
}
|
||||
|
||||
Log::warning('Threads media processing timeout, proceeding anyway');
|
||||
}
|
||||
|
||||
private function refreshToken(SocialAccount $account): void
|
||||
{
|
||||
// Threads uses long-lived tokens that can be refreshed
|
||||
$response = Http::get('https://graph.threads.net/refresh_access_token', [
|
||||
'grant_type' => 'th_refresh_token',
|
||||
'access_token' => $account->access_token,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Threads token refresh failed', ['body' => $response->body()]);
|
||||
throw new \Exception('Failed to refresh Threads token: '.$response->body());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
$account->update([
|
||||
'access_token' => $data['access_token'],
|
||||
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
|
||||
]);
|
||||
|
||||
Log::info('Threads token refreshed successfully');
|
||||
}
|
||||
}
|
||||
|
|
@ -3,43 +3,249 @@
|
|||
namespace App\Services\Social;
|
||||
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TikTokPublisher
|
||||
{
|
||||
private string $baseUrl = 'https://open.tiktokapis.com/v2';
|
||||
|
||||
private string $accessToken;
|
||||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$account = $postPlatform->socialAccount;
|
||||
$accessToken = $account->access_token;
|
||||
|
||||
$media = $postPlatform->media->first();
|
||||
|
||||
if (! $media || $media->type->value !== 'video') {
|
||||
throw new \Exception('TikTok requires a video to publish.');
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
$this->refreshToken($account);
|
||||
$account->refresh();
|
||||
}
|
||||
|
||||
$response = Http::withToken($accessToken)
|
||||
->post('https://open.tiktokapis.com/v2/post/publish/inbox/video/init/', [
|
||||
$this->accessToken = $account->access_token;
|
||||
|
||||
$media = $postPlatform->media;
|
||||
|
||||
if ($media->isEmpty()) {
|
||||
throw new \Exception('TikTok requires media (video or photos) to publish.');
|
||||
}
|
||||
|
||||
$firstMedia = $media->first();
|
||||
$isVideo = str_starts_with($firstMedia->mime_type, 'video/');
|
||||
$isImage = str_starts_with($firstMedia->mime_type, 'image/');
|
||||
|
||||
if ($isVideo) {
|
||||
return $this->publishVideo($postPlatform, $firstMedia);
|
||||
}
|
||||
|
||||
if ($isImage) {
|
||||
return $this->publishPhotos($postPlatform, $media);
|
||||
}
|
||||
|
||||
throw new \Exception('TikTok only supports video or image content.');
|
||||
}
|
||||
|
||||
private function getHttpClient(): PendingRequest
|
||||
{
|
||||
return Http::withToken($this->accessToken)
|
||||
->withHeaders([
|
||||
'Content-Type' => 'application/json; charset=UTF-8',
|
||||
])
|
||||
->timeout(120);
|
||||
}
|
||||
|
||||
private function publishVideo(PostPlatform $postPlatform, $media): array
|
||||
{
|
||||
Log::info('TikTok publishing video', [
|
||||
'video_url' => $media->url,
|
||||
'content' => $postPlatform->content,
|
||||
]);
|
||||
|
||||
$response = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/post/publish/video/init/", [
|
||||
'post_info' => [
|
||||
'title' => $postPlatform->content,
|
||||
'privacy_level' => 'PUBLIC_TO_EVERYONE',
|
||||
'disable_duet' => false,
|
||||
'disable_comment' => false,
|
||||
'disable_stitch' => false,
|
||||
],
|
||||
'source_info' => [
|
||||
'source' => 'PULL_FROM_URL',
|
||||
'video_url' => $media->url,
|
||||
],
|
||||
'post_info' => [
|
||||
'title' => $postPlatform->content,
|
||||
'privacy_level' => 'PUBLIC_TO_EVERYONE',
|
||||
],
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new \Exception("TikTok API error: " . $response->body());
|
||||
Log::error('TikTok video publish failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
throw new \Exception('TikTok API error: '.$response->body());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
Log::info('TikTok video init response', ['data' => $data]);
|
||||
|
||||
$publishId = $data['data']['publish_id'] ?? null;
|
||||
|
||||
if (! $publishId) {
|
||||
throw new \Exception('TikTok did not return a publish_id');
|
||||
}
|
||||
|
||||
// Wait for processing and get final status
|
||||
$finalStatus = $this->waitForPublishStatus($publishId);
|
||||
|
||||
return [
|
||||
'id' => $data['data']['publish_id'] ?? 'unknown',
|
||||
'url' => null,
|
||||
'id' => $publishId,
|
||||
'url' => $this->buildTikTokUrl($postPlatform->socialAccount),
|
||||
];
|
||||
}
|
||||
|
||||
private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): array
|
||||
{
|
||||
$photoUrls = $mediaCollection
|
||||
->filter(fn ($m) => str_starts_with($m->mime_type, 'image/'))
|
||||
->map(fn ($m) => $m->url)
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
if (empty($photoUrls)) {
|
||||
throw new \Exception('No valid images found for TikTok photo post');
|
||||
}
|
||||
|
||||
Log::info('TikTok publishing photos', [
|
||||
'photo_count' => count($photoUrls),
|
||||
'content' => $postPlatform->content,
|
||||
]);
|
||||
|
||||
$response = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/post/publish/content/init/", [
|
||||
'post_info' => [
|
||||
'title' => $postPlatform->content,
|
||||
'privacy_level' => 'PUBLIC_TO_EVERYONE',
|
||||
'disable_comment' => false,
|
||||
],
|
||||
'source_info' => [
|
||||
'source' => 'PULL_FROM_URL',
|
||||
'photo_cover_index' => 0,
|
||||
'photo_images' => $photoUrls,
|
||||
],
|
||||
'post_mode' => 'DIRECT_POST',
|
||||
'media_type' => 'PHOTO',
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('TikTok photo publish failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
throw new \Exception('TikTok API error: '.$response->body());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
Log::info('TikTok photo init response', ['data' => $data]);
|
||||
|
||||
$publishId = $data['data']['publish_id'] ?? null;
|
||||
|
||||
if (! $publishId) {
|
||||
throw new \Exception('TikTok did not return a publish_id');
|
||||
}
|
||||
|
||||
// Wait for processing and get final status
|
||||
$finalStatus = $this->waitForPublishStatus($publishId);
|
||||
|
||||
return [
|
||||
'id' => $publishId,
|
||||
'url' => $this->buildTikTokUrl($postPlatform->socialAccount),
|
||||
];
|
||||
}
|
||||
|
||||
private function waitForPublishStatus(string $publishId, int $maxAttempts = 20): array
|
||||
{
|
||||
for ($i = 0; $i < $maxAttempts; $i++) {
|
||||
sleep(3);
|
||||
|
||||
$response = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/post/publish/status/fetch/", [
|
||||
'publish_id' => $publishId,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::warning('TikTok status check failed', [
|
||||
'attempt' => $i,
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$status = $data['data']['status'] ?? 'UNKNOWN';
|
||||
|
||||
Log::info('TikTok publish status', [
|
||||
'status' => $status,
|
||||
'attempt' => $i,
|
||||
'data' => $data,
|
||||
]);
|
||||
|
||||
if ($status === 'PUBLISH_COMPLETE') {
|
||||
return $data['data'] ?? [];
|
||||
}
|
||||
|
||||
if (in_array($status, ['FAILED', 'PUBLISH_FAILED'])) {
|
||||
$errorCode = $data['data']['fail_reason'] ?? 'Unknown error';
|
||||
throw new \Exception("TikTok publish failed: {$errorCode}");
|
||||
}
|
||||
|
||||
// PROCESSING_UPLOAD, PROCESSING_DOWNLOAD, SENDING_TO_USER_INBOX - continue waiting
|
||||
}
|
||||
|
||||
Log::warning('TikTok publish status timeout, returning publish_id anyway');
|
||||
|
||||
return ['publish_id' => $publishId];
|
||||
}
|
||||
|
||||
private function buildTikTokUrl(SocialAccount $account): ?string
|
||||
{
|
||||
$username = $account->username;
|
||||
|
||||
if ($username) {
|
||||
return "https://www.tiktok.com/@{$username}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function refreshToken(SocialAccount $account): void
|
||||
{
|
||||
if (! $account->refresh_token) {
|
||||
throw new \Exception('No refresh token available for TikTok account');
|
||||
}
|
||||
|
||||
$response = Http::asForm()->post('https://open.tiktokapis.com/v2/oauth/token/', [
|
||||
'client_key' => config('services.tiktok.client_id'),
|
||||
'client_secret' => config('services.tiktok.client_secret'),
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $account->refresh_token,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('TikTok token refresh failed', ['body' => $response->body()]);
|
||||
throw new \Exception('Failed to refresh TikTok token: '.$response->body());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
$account->update([
|
||||
'access_token' => $data['access_token'],
|
||||
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
|
||||
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
|
||||
]);
|
||||
|
||||
Log::info('TikTok token refreshed successfully');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,47 +3,69 @@
|
|||
namespace App\Services\Social;
|
||||
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class XPublisher
|
||||
{
|
||||
private string $baseUrl = 'https://api.x.com';
|
||||
|
||||
private string $accessToken;
|
||||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$account = $postPlatform->socialAccount;
|
||||
$accessToken = $account->access_token;
|
||||
|
||||
$payload = [
|
||||
// Refresh token if expired or expiring soon
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
$this->refreshToken($account);
|
||||
$account->refresh();
|
||||
}
|
||||
|
||||
$this->accessToken = $account->access_token;
|
||||
|
||||
$data = [
|
||||
'text' => $postPlatform->content,
|
||||
];
|
||||
|
||||
$mediaIds = [];
|
||||
$media = $postPlatform->media;
|
||||
|
||||
if ($media->isNotEmpty()) {
|
||||
$mediaIds = [];
|
||||
|
||||
foreach ($media as $mediaItem) {
|
||||
$uploadedMediaId = $this->uploadMedia($accessToken, $mediaItem);
|
||||
if ($uploadedMediaId) {
|
||||
$mediaIds[] = $uploadedMediaId;
|
||||
Log::info('Uploading media to X', [
|
||||
'url' => $mediaItem->url,
|
||||
'mime_type' => $mediaItem->mime_type,
|
||||
]);
|
||||
|
||||
$uploadedMedia = $this->uploadMedia($mediaItem);
|
||||
|
||||
Log::info('X media upload response', ['response' => $uploadedMedia]);
|
||||
|
||||
// v2 API returns data.id, v1 returns media_id
|
||||
$mediaId = $uploadedMedia['data']['id'] ?? $uploadedMedia['media_id'] ?? null;
|
||||
if ($mediaId) {
|
||||
$mediaIds[] = $mediaId;
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($mediaIds)) {
|
||||
$payload['media'] = [
|
||||
'media_ids' => $mediaIds,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$response = Http::withToken($accessToken)
|
||||
->post('https://api.twitter.com/2/tweets', $payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new \Exception("X API error: " . $response->body());
|
||||
if (! empty($mediaIds)) {
|
||||
$data['media'] = [
|
||||
'media_ids' => $mediaIds,
|
||||
];
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$tweetId = $data['data']['id'] ?? null;
|
||||
Log::info('Posting tweet', ['data' => $data]);
|
||||
|
||||
$response = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/2/tweets", $data)
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$tweetId = $response['data']['id'] ?? null;
|
||||
|
||||
return [
|
||||
'id' => $tweetId ?? 'unknown',
|
||||
|
|
@ -51,8 +73,259 @@ public function publish(PostPlatform $postPlatform): array
|
|||
];
|
||||
}
|
||||
|
||||
private function uploadMedia(string $accessToken, $mediaItem): ?string
|
||||
private function getHttpClient(): PendingRequest
|
||||
{
|
||||
return Http::withToken($this->accessToken)
|
||||
->withHeaders([
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
])
|
||||
->timeout(360);
|
||||
}
|
||||
|
||||
private function uploadMedia($mediaItem): ?array
|
||||
{
|
||||
$mediaContent = file_get_contents($mediaItem->url);
|
||||
$mimeType = $mediaItem->mime_type;
|
||||
$fileSize = strlen($mediaContent);
|
||||
|
||||
// Create temp file
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'x_media_');
|
||||
file_put_contents($tempFile, $mediaContent);
|
||||
|
||||
try {
|
||||
$mediaCategory = $this->getMediaCategory($mimeType, $fileSize);
|
||||
|
||||
$isVideo = str_starts_with($mimeType, 'video/');
|
||||
$isGif = $mimeType === 'image/gif';
|
||||
|
||||
// Use chunked upload for:
|
||||
// - Videos (always)
|
||||
// - GIFs (need async processing)
|
||||
// - Files > 5MB (API limit for simple upload)
|
||||
$useChunkedUpload = $isVideo || $isGif || $fileSize > 5 * 1024 * 1024;
|
||||
|
||||
if ($useChunkedUpload) {
|
||||
return $this->chunkedUpload($mediaContent, $mimeType, $mediaCategory);
|
||||
}
|
||||
|
||||
// Simple upload for small images
|
||||
$response = Http::withToken($this->accessToken)
|
||||
->timeout(360)
|
||||
->attach(
|
||||
'media',
|
||||
file_get_contents($tempFile),
|
||||
basename($tempFile),
|
||||
['Content-Type' => $mimeType]
|
||||
);
|
||||
|
||||
$formParams = [];
|
||||
if ($mediaCategory) {
|
||||
$formParams['media_category'] = $mediaCategory;
|
||||
}
|
||||
|
||||
$response = $response->post("{$this->baseUrl}/2/media/upload", $formParams);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('X media upload error', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
throw new \Exception('Failed to upload media: '.$response->status().' - '.$response->body());
|
||||
}
|
||||
|
||||
$responseData = $response->json();
|
||||
|
||||
// v2 API returns data.id
|
||||
$mediaId = $responseData['data']['id'] ?? $responseData['media_id'] ?? null;
|
||||
|
||||
if ($isGif && $mediaId) {
|
||||
$this->waitForProcessing($mediaId);
|
||||
}
|
||||
|
||||
return $responseData;
|
||||
} finally {
|
||||
if (file_exists($tempFile)) {
|
||||
unlink($tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function chunkedUpload(string $mediaContent, string $mimeType, string $mediaCategory): array
|
||||
{
|
||||
$totalBytes = strlen($mediaContent);
|
||||
|
||||
Log::info('X chunked upload INIT', [
|
||||
'total_bytes' => $totalBytes,
|
||||
'media_type' => $mimeType,
|
||||
'media_category' => $mediaCategory,
|
||||
]);
|
||||
|
||||
// INIT - Use dedicated initialize endpoint
|
||||
$initResponse = Http::withToken($this->accessToken)
|
||||
->timeout(60)
|
||||
->post("{$this->baseUrl}/2/media/upload/initialize", [
|
||||
'media_type' => $mimeType,
|
||||
'media_category' => $mediaCategory,
|
||||
'total_bytes' => $totalBytes,
|
||||
]);
|
||||
|
||||
if ($initResponse->failed()) {
|
||||
Log::error('X chunked upload INIT error', [
|
||||
'status' => $initResponse->status(),
|
||||
'body' => $initResponse->body(),
|
||||
]);
|
||||
throw new \Exception('Failed to initialize chunked upload: '.$initResponse->body());
|
||||
}
|
||||
|
||||
$initData = $initResponse->json();
|
||||
$mediaId = $initData['data']['id'] ?? $initData['media_id'] ?? null;
|
||||
|
||||
if (! $mediaId) {
|
||||
throw new \Exception('No media_id returned from INIT');
|
||||
}
|
||||
|
||||
Log::info('X chunked upload INIT success', ['media_id' => $mediaId]);
|
||||
|
||||
// APPEND - Upload in 1MB chunks (API limit)
|
||||
$chunkSize = 1 * 1024 * 1024;
|
||||
$chunks = str_split($mediaContent, $chunkSize);
|
||||
|
||||
foreach ($chunks as $index => $chunk) {
|
||||
Log::info('X chunked upload APPEND', [
|
||||
'media_id' => $mediaId,
|
||||
'segment' => $index,
|
||||
'chunk_size' => strlen($chunk),
|
||||
]);
|
||||
|
||||
// APPEND uses the new v2 endpoint with media_id in URL
|
||||
$appendResponse = Http::withToken($this->accessToken)
|
||||
->timeout(300)
|
||||
->attach('media', $chunk, 'chunk'.$index)
|
||||
->post("{$this->baseUrl}/2/media/upload/{$mediaId}/append", [
|
||||
'segment_index' => $index,
|
||||
]);
|
||||
|
||||
if ($appendResponse->failed()) {
|
||||
Log::error('X chunked upload APPEND error', [
|
||||
'status' => $appendResponse->status(),
|
||||
'body' => $appendResponse->body(),
|
||||
'segment' => $index,
|
||||
]);
|
||||
throw new \Exception('Failed to append chunk: '.$appendResponse->body());
|
||||
}
|
||||
}
|
||||
|
||||
// FINALIZE - Use the new v2 endpoint
|
||||
Log::info('X chunked upload FINALIZE', ['media_id' => $mediaId]);
|
||||
|
||||
$finalizeResponse = Http::withToken($this->accessToken)
|
||||
->timeout(60)
|
||||
->post("{$this->baseUrl}/2/media/upload/{$mediaId}/finalize");
|
||||
|
||||
if ($finalizeResponse->failed()) {
|
||||
Log::error('X chunked upload FINALIZE error', [
|
||||
'status' => $finalizeResponse->status(),
|
||||
'body' => $finalizeResponse->body(),
|
||||
]);
|
||||
throw new \Exception('Failed to finalize chunked upload: '.$finalizeResponse->body());
|
||||
}
|
||||
|
||||
$finalizeData = $finalizeResponse->json();
|
||||
|
||||
// Wait for processing (videos need transcoding)
|
||||
if (isset($finalizeData['processing_info']) || str_starts_with($mimeType, 'video/')) {
|
||||
$this->waitForProcessing($mediaId);
|
||||
}
|
||||
|
||||
// Return in same format as simple upload
|
||||
return [
|
||||
'data' => [
|
||||
'id' => $mediaId,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function getMediaCategory(string $mimeType, int $fileSize): ?string
|
||||
{
|
||||
if (str_starts_with($mimeType, 'video/')) {
|
||||
return $fileSize > 15 * 1024 * 1024 ? 'amplify_video' : 'tweet_video';
|
||||
}
|
||||
|
||||
if ($mimeType === 'image/gif') {
|
||||
return 'tweet_gif';
|
||||
}
|
||||
|
||||
if (str_starts_with($mimeType, 'image/')) {
|
||||
return 'tweet_image';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function waitForProcessing(string $mediaId, int $maxAttempts = 20): bool
|
||||
{
|
||||
for ($i = 0; $i < $maxAttempts; $i++) {
|
||||
$response = $this->getHttpClient()
|
||||
->get("{$this->baseUrl}/2/media/{$mediaId}");
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('X media status check error: '.$response->body());
|
||||
sleep(3);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$responseData = $response->json();
|
||||
|
||||
// If processing_info doesn't exist, assume it's ready
|
||||
if (! isset($responseData['processing_info'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$state = $responseData['processing_info']['state'] ?? 'unknown';
|
||||
|
||||
if ($state === 'succeeded') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($state === 'failed') {
|
||||
$error = $responseData['processing_info']['error'] ?? 'Unknown error';
|
||||
Log::error('X media processing failed: '.$error);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wait before checking again
|
||||
$waitTime = $responseData['processing_info']['check_after_secs'] ?? 3;
|
||||
sleep($waitTime);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function refreshToken(SocialAccount $account): void
|
||||
{
|
||||
if (! $account->refresh_token) {
|
||||
throw new \Exception('No refresh token available for X account');
|
||||
}
|
||||
|
||||
$response = Http::asForm()->post("{$this->baseUrl}/2/oauth2/token", [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $account->refresh_token,
|
||||
'client_id' => config('services.x.client_id'),
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new \Exception('Failed to refresh X token: '.$response->body());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
$account->update([
|
||||
'access_token' => $data['access_token'],
|
||||
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
|
||||
'token_expires_at' => now()->addSeconds($data['expires_in'] ?? 7200),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
182
app/Services/Social/YouTubePublisher.php
Normal file
182
app/Services/Social/YouTubePublisher.php
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Social;
|
||||
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class YouTubePublisher
|
||||
{
|
||||
private string $baseUrl = 'https://www.googleapis.com';
|
||||
|
||||
private string $accessToken;
|
||||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
$this->refreshToken($account);
|
||||
$account->refresh();
|
||||
}
|
||||
|
||||
$this->accessToken = $account->access_token;
|
||||
|
||||
$media = $postPlatform->media;
|
||||
|
||||
if ($media->isEmpty()) {
|
||||
throw new \Exception('YouTube Shorts requires a video to publish.');
|
||||
}
|
||||
|
||||
$firstMedia = $media->first();
|
||||
$isVideo = str_starts_with($firstMedia->mime_type, 'video/');
|
||||
|
||||
if (! $isVideo) {
|
||||
throw new \Exception('YouTube Shorts only supports video content.');
|
||||
}
|
||||
|
||||
return $this->publishShort($postPlatform, $firstMedia);
|
||||
}
|
||||
|
||||
private function getHttpClient(): PendingRequest
|
||||
{
|
||||
return Http::withToken($this->accessToken)
|
||||
->timeout(600);
|
||||
}
|
||||
|
||||
private function publishShort(PostPlatform $postPlatform, $media): array
|
||||
{
|
||||
$title = $this->buildTitle($postPlatform->content);
|
||||
$description = $postPlatform->content;
|
||||
|
||||
Log::info('YouTube Shorts publishing video', [
|
||||
'video_url' => $media->url,
|
||||
'title' => $title,
|
||||
]);
|
||||
|
||||
// Step 1: Get video content
|
||||
$videoContent = file_get_contents($media->url);
|
||||
$fileSize = strlen($videoContent);
|
||||
|
||||
Log::info('YouTube video file size', ['size' => $fileSize]);
|
||||
|
||||
// Step 2: Initialize resumable upload
|
||||
$initResponse = $this->getHttpClient()
|
||||
->withHeaders([
|
||||
'Content-Type' => 'application/json; charset=UTF-8',
|
||||
'X-Upload-Content-Length' => $fileSize,
|
||||
'X-Upload-Content-Type' => $media->mime_type,
|
||||
])
|
||||
->post("{$this->baseUrl}/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status", [
|
||||
'snippet' => [
|
||||
'title' => $title,
|
||||
'description' => $description,
|
||||
'categoryId' => '22', // People & Blogs
|
||||
],
|
||||
'status' => [
|
||||
'privacyStatus' => 'public',
|
||||
'selfDeclaredMadeForKids' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($initResponse->failed()) {
|
||||
Log::error('YouTube upload init failed', [
|
||||
'status' => $initResponse->status(),
|
||||
'body' => $initResponse->body(),
|
||||
]);
|
||||
throw new \Exception('YouTube API error: '.$initResponse->body());
|
||||
}
|
||||
|
||||
$uploadUrl = $initResponse->header('Location');
|
||||
|
||||
if (! $uploadUrl) {
|
||||
throw new \Exception('YouTube did not return an upload URL');
|
||||
}
|
||||
|
||||
Log::info('YouTube upload initialized', ['uploadUrl' => $uploadUrl]);
|
||||
|
||||
// Step 3: Upload the video content
|
||||
$uploadResponse = Http::withToken($this->accessToken)
|
||||
->withHeaders([
|
||||
'Content-Type' => $media->mime_type,
|
||||
'Content-Length' => $fileSize,
|
||||
])
|
||||
->timeout(600)
|
||||
->withBody($videoContent, $media->mime_type)
|
||||
->put($uploadUrl);
|
||||
|
||||
if ($uploadResponse->failed()) {
|
||||
Log::error('YouTube video upload failed', [
|
||||
'status' => $uploadResponse->status(),
|
||||
'body' => $uploadResponse->body(),
|
||||
]);
|
||||
throw new \Exception('YouTube upload error: '.$uploadResponse->body());
|
||||
}
|
||||
|
||||
$data = $uploadResponse->json();
|
||||
|
||||
Log::info('YouTube upload response', ['data' => $data]);
|
||||
|
||||
$videoId = $data['id'] ?? null;
|
||||
|
||||
if (! $videoId) {
|
||||
throw new \Exception('YouTube did not return a video ID');
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $videoId,
|
||||
'url' => "https://www.youtube.com/shorts/{$videoId}",
|
||||
];
|
||||
}
|
||||
|
||||
private function buildTitle(string $content): string
|
||||
{
|
||||
// YouTube title max is 100 characters
|
||||
// For Shorts, add #Shorts hashtag to help YouTube classify it
|
||||
$maxLength = 100;
|
||||
$shortsTag = ' #Shorts';
|
||||
$availableLength = $maxLength - strlen($shortsTag);
|
||||
|
||||
// Get first line or first sentence as title
|
||||
$title = strtok($content, "\n");
|
||||
$title = strtok($title, '.');
|
||||
|
||||
if (strlen($title) > $availableLength) {
|
||||
$title = substr($title, 0, $availableLength - 3).'...';
|
||||
}
|
||||
|
||||
return $title.$shortsTag;
|
||||
}
|
||||
|
||||
private function refreshToken(SocialAccount $account): void
|
||||
{
|
||||
if (! $account->refresh_token) {
|
||||
throw new \Exception('No refresh token available for YouTube account');
|
||||
}
|
||||
|
||||
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
|
||||
'client_id' => config('services.google.client_id'),
|
||||
'client_secret' => config('services.google.client_secret'),
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $account->refresh_token,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('YouTube token refresh failed', ['body' => $response->body()]);
|
||||
throw new \Exception('Failed to refresh YouTube token: '.$response->body());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
$account->update([
|
||||
'access_token' => $data['access_token'],
|
||||
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
|
||||
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
|
||||
]);
|
||||
|
||||
Log::info('YouTube token refreshed successfully');
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
channels: __DIR__.'/../routes/channels.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"laravel/fortify": "^1.30",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/horizon": "^5.42",
|
||||
"laravel/reverb": "^1.0",
|
||||
"laravel/socialite": "^5.24",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"laravel/wayfinder": "^0.1.9",
|
||||
|
|
|
|||
1007
composer.lock
generated
1007
composer.lock
generated
File diff suppressed because it is too large
Load diff
82
config/broadcasting.php
Normal file
82
config/broadcasting.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Broadcaster
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default broadcaster that will be used by the
|
||||
| framework when an event needs to be broadcast. You may set this to
|
||||
| any of the connections defined in the "connections" array below.
|
||||
|
|
||||
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('BROADCAST_CONNECTION', 'null'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Broadcast Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the broadcast connections that will be used
|
||||
| to broadcast events to other systems or over WebSockets. Samples of
|
||||
| each available type of connection are provided inside this array.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'reverb' => [
|
||||
'driver' => 'reverb',
|
||||
'key' => env('REVERB_APP_KEY'),
|
||||
'secret' => env('REVERB_APP_SECRET'),
|
||||
'app_id' => env('REVERB_APP_ID'),
|
||||
'options' => [
|
||||
'host' => env('REVERB_HOST'),
|
||||
'port' => env('REVERB_PORT', 443),
|
||||
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'client_options' => [
|
||||
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||
],
|
||||
],
|
||||
|
||||
'pusher' => [
|
||||
'driver' => 'pusher',
|
||||
'key' => env('PUSHER_APP_KEY'),
|
||||
'secret' => env('PUSHER_APP_SECRET'),
|
||||
'app_id' => env('PUSHER_APP_ID'),
|
||||
'options' => [
|
||||
'cluster' => env('PUSHER_APP_CLUSTER'),
|
||||
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
|
||||
'port' => env('PUSHER_PORT', 443),
|
||||
'scheme' => env('PUSHER_SCHEME', 'https'),
|
||||
'encrypted' => true,
|
||||
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'client_options' => [
|
||||
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||
],
|
||||
],
|
||||
|
||||
'ably' => [
|
||||
'driver' => 'ably',
|
||||
'key' => env('ABLY_KEY'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'driver' => 'log',
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'null',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
95
config/reverb.php
Normal file
95
config/reverb.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Reverb Server
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default server used by Reverb to handle
|
||||
| incoming messages as well as broadcasting message to all your
|
||||
| connected clients. At this time only "reverb" is supported.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('REVERB_SERVER', 'reverb'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Reverb Servers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define details for each of the supported Reverb servers.
|
||||
| Each server has its own configuration options that are defined in
|
||||
| the array below. You should ensure all the options are present.
|
||||
|
|
||||
*/
|
||||
|
||||
'servers' => [
|
||||
|
||||
'reverb' => [
|
||||
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
|
||||
'port' => env('REVERB_SERVER_PORT', 8080),
|
||||
'path' => env('REVERB_SERVER_PATH', ''),
|
||||
'hostname' => env('REVERB_HOST'),
|
||||
'options' => [
|
||||
'tls' => [],
|
||||
],
|
||||
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
|
||||
'scaling' => [
|
||||
'enabled' => env('REVERB_SCALING_ENABLED', false),
|
||||
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
|
||||
'server' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'timeout' => env('REDIS_TIMEOUT', 60),
|
||||
],
|
||||
],
|
||||
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
|
||||
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Reverb Applications
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define how Reverb applications are managed. If you choose
|
||||
| to use the "config" provider, you may define an array of apps which
|
||||
| your server will support, including their connection credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'apps' => [
|
||||
|
||||
'provider' => 'config',
|
||||
|
||||
'apps' => [
|
||||
[
|
||||
'key' => env('REVERB_APP_KEY'),
|
||||
'secret' => env('REVERB_APP_SECRET'),
|
||||
'app_id' => env('REVERB_APP_ID'),
|
||||
'options' => [
|
||||
'host' => env('REVERB_HOST'),
|
||||
'port' => env('REVERB_PORT', 443),
|
||||
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'allowed_origins' => ['*'],
|
||||
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
|
||||
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
|
||||
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
|
||||
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
'redirect_page' => env('LINKEDIN_PAGE_CLIENT_REDIRECT'),
|
||||
],
|
||||
|
||||
'twitter' => [
|
||||
'x' => [
|
||||
'client_id' => env('X_CLIENT_ID'),
|
||||
'client_secret' => env('X_CLIENT_SECRET'),
|
||||
'redirect' => env('X_CLIENT_REDIRECT'),
|
||||
|
|
@ -60,6 +60,27 @@
|
|||
'redirect' => env('TIKTOK_CLIENT_REDIRECT'),
|
||||
],
|
||||
|
||||
// Google OAuth (used for YouTube)
|
||||
'google' => [
|
||||
'client_id' => env('GOOGLE_CLIENT_ID'),
|
||||
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
|
||||
'redirect' => env('GOOGLE_CLIENT_REDIRECT'),
|
||||
],
|
||||
|
||||
// Facebook (used for Facebook Pages and Instagram)
|
||||
'facebook' => [
|
||||
'client_id' => env('FACEBOOK_CLIENT_ID'),
|
||||
'client_secret' => env('FACEBOOK_CLIENT_SECRET'),
|
||||
'redirect' => env('FACEBOOK_CLIENT_REDIRECT'),
|
||||
],
|
||||
|
||||
// Threads
|
||||
'threads' => [
|
||||
'client_id' => env('THREADS_CLIENT_ID'),
|
||||
'client_secret' => env('THREADS_CLIENT_SECRET'),
|
||||
'redirect' => env('THREADS_CLIENT_REDIRECT'),
|
||||
],
|
||||
|
||||
'stripe' => [
|
||||
'price_id' => env('STRIPE_PRICE_ID'),
|
||||
'workspace_price' => env('STRIPE_WORKSPACE_PRICE', 2000), // $20.00 in cents
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ public function up(): void
|
|||
$table->uuid('id')->primary();
|
||||
$table->uuid('post_platform_id');
|
||||
$table->string('type');
|
||||
$table->string('disk')->default('r2');
|
||||
$table->string('path');
|
||||
$table->string('original_filename');
|
||||
$table->string('mime_type');
|
||||
|
|
|
|||
|
|
@ -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_media', function (Blueprint $table) {
|
||||
$table->uuid('post_platform_id')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('post_media', function (Blueprint $table) {
|
||||
$table->uuid('post_platform_id')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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('workspaces', function (Blueprint $table) {
|
||||
$table->string('timezone')->default('America/New_York')->after('name');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('workspaces', function (Blueprint $table) {
|
||||
$table->dropColumn('timezone');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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->boolean('enabled')->default(true)->after('social_account_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('post_platforms', function (Blueprint $table) {
|
||||
$table->dropColumn('enabled');
|
||||
});
|
||||
}
|
||||
};
|
||||
189
package-lock.json
generated
189
package-lock.json
generated
|
|
@ -6,11 +6,14 @@
|
|||
"": {
|
||||
"dependencies": {
|
||||
"@inertiajs/vue3": "^2.3.7",
|
||||
"@tabler/icons-vue": "^3.36.1",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
"lucide-vue-next": "^0.468.0",
|
||||
"maska": "^3.2.0",
|
||||
"reka-ui": "^2.7.0",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.1",
|
||||
|
|
@ -20,6 +23,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@laravel/echo-vue": "^2.3.0",
|
||||
"@laravel/vite-plugin-wayfinder": "^0.1.3",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/node": "^22.13.5",
|
||||
|
|
@ -31,9 +35,11 @@
|
|||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"laravel-echo": "^2.3.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-organize-imports": "^4.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"pusher-js": "^8.4.0",
|
||||
"typescript": "^5.2.2",
|
||||
"typescript-eslint": "^8.23.0",
|
||||
"vite": "^7.0.4",
|
||||
|
|
@ -932,6 +938,21 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@laravel/echo-vue": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@laravel/echo-vue/-/echo-vue-2.3.0.tgz",
|
||||
"integrity": "sha512-IWHFrRw1A7OE7rwB2q2ZqsuBjeI4HGUzaRSxBzPzkx8cKq0ten8/1jN6v3jg+Xe2dLGKYQXQoMNj9xbJxV4gbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pusher-js": "*",
|
||||
"socket.io-client": "*",
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@laravel/vite-plugin-wayfinder": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@laravel/vite-plugin-wayfinder/-/vite-plugin-wayfinder-0.1.7.tgz",
|
||||
|
|
@ -1329,6 +1350,14 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.18",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
|
||||
|
|
@ -1338,6 +1367,32 @@
|
|||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tabler/icons": {
|
||||
"version": "3.36.1",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.1.tgz",
|
||||
"integrity": "sha512-f4Jg3Fof/Vru5ioix/UO4GX+sdDsF9wQo47FbtvG+utIYYVQ/QVAC0QYgcBbAjQGfbdOh2CCf0BgiFOF9Ixtjw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
}
|
||||
},
|
||||
"node_modules/@tabler/icons-vue": {
|
||||
"version": "3.36.1",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons-vue/-/icons-vue-3.36.1.tgz",
|
||||
"integrity": "sha512-ssIu0KSmGFKvvoC5PJhSYAj/J2kTEbIVIix0528zdRKfN6yohUlQt014tGnDDdJ/MyyG/xRXsKD1U6dyQY8rrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tabler/icons": ""
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
|
||||
|
|
@ -3022,6 +3077,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.19",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/de-indent": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||
|
|
@ -3149,6 +3210,32 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
||||
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.18.4",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
|
||||
|
|
@ -4924,6 +5011,20 @@
|
|||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/laravel-echo": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.3.0.tgz",
|
||||
"integrity": "sha512-wgHPnnBvfHmu2I58xJ4asZH37Nu6P0472ku6zuoGRLc3zEWwIbpovDLYTiOshDH1SM7rA6AjZTKuu+jYoM1tpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pusher-js": "*",
|
||||
"socket.io-client": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/laravel-precognition": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-1.0.0.tgz",
|
||||
|
|
@ -5271,6 +5372,12 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/maska": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/maska/-/maska-3.2.0.tgz",
|
||||
"integrity": "sha512-zSmSgs5/q9vMSmrdZT3rKOv9uLznNWR/niuuAdBZDTvB3SMKOX9vhMtDijFyExz+B4UClu2rvksylUh/ea1bLA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
|
@ -5852,6 +5959,16 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pusher-js": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0.tgz",
|
||||
"integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
|
|
@ -6344,6 +6461,38 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
|
||||
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
|
@ -6655,6 +6804,13 @@
|
|||
"url": "https://github.com/sponsors/Wombosvideo"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||
"dev": true,
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
|
@ -7216,6 +7372,29 @@
|
|||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
|
||||
|
|
@ -7226,6 +7405,16 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@laravel/echo-vue": "^2.3.0",
|
||||
"@laravel/vite-plugin-wayfinder": "^0.1.3",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/node": "^22.13.5",
|
||||
|
|
@ -23,9 +24,11 @@
|
|||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"laravel-echo": "^2.3.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-organize-imports": "^4.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"pusher-js": "^8.4.0",
|
||||
"typescript": "^5.2.2",
|
||||
"typescript-eslint": "^8.23.0",
|
||||
"vite": "^7.0.4",
|
||||
|
|
@ -33,11 +36,14 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@inertiajs/vue3": "^2.3.7",
|
||||
"@tabler/icons-vue": "^3.36.1",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
"lucide-vue-next": "^0.468.0",
|
||||
"maska": "^3.2.0",
|
||||
"reka-ui": "^2.7.0",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.1",
|
||||
|
|
|
|||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/images/trypost/icon.png
Normal file
BIN
public/images/trypost/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/images/trypost/logo-dark.png
Normal file
BIN
public/images/trypost/logo-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
public/images/trypost/logo-light.png
Normal file
BIN
public/images/trypost/logo-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -1,4 +1,5 @@
|
|||
import '../css/app.css';
|
||||
import './echo';
|
||||
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
|
|
@ -6,8 +7,13 @@ import type { DefineComponent } from 'vue';
|
|||
import { createApp, h } from 'vue';
|
||||
|
||||
import { initializeTheme } from './composables/useAppearance';
|
||||
import { configureEcho } from '@laravel/echo-vue';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
configureEcho({
|
||||
broadcaster: 'reverb',
|
||||
});
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'TryPost.it';
|
||||
|
||||
createInertiaApp({
|
||||
title: (title) => (title ? `${title} - ${appName}` : appName),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import type { InertiaLinkProps } from '@inertiajs/vue3';
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-vue-next';
|
||||
import { Briefcase, Menu } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import AppLogo from '@/components/AppLogo.vue';
|
||||
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -27,17 +25,9 @@ import {
|
|||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import UserMenuContent from '@/components/UserMenuContent.vue';
|
||||
import { useActiveUrl } from '@/composables/useActiveUrl';
|
||||
import { getInitials } from '@/composables/useInitials';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import { dashboard } from '@/routes';
|
||||
import type { BreadcrumbItem, NavItem } from '@/types';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -60,22 +50,9 @@ function activeItemStyles(url: NonNullable<InertiaLinkProps['href']>) {
|
|||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
];
|
||||
|
||||
const rightNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
href: 'https://github.com/laravel/vue-starter-kit',
|
||||
icon: Folder,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://laravel.com/docs/starter-kits#vue',
|
||||
icon: BookOpen,
|
||||
title: 'Workspaces',
|
||||
href: '/workspaces',
|
||||
icon: Briefcase,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
|
@ -88,171 +65,69 @@ const rightNavItems: NavItem[] = [
|
|||
<div class="lg:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger :as-child="true">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="mr-2 h-9 w-9"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="mr-2 h-9 w-9">
|
||||
<Menu class="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" class="w-[300px] p-6">
|
||||
<SheetTitle class="sr-only"
|
||||
>Navigation Menu</SheetTitle
|
||||
>
|
||||
<SheetTitle class="sr-only">Navigation Menu</SheetTitle>
|
||||
<SheetHeader class="flex justify-start text-left">
|
||||
<AppLogoIcon
|
||||
class="size-6 fill-current text-black dark:text-white"
|
||||
/>
|
||||
<img src="/images/trypost/logo-light.png" alt="TryPost"
|
||||
class="dark:hidden h-8 w-auto" />
|
||||
<img src="/images/trypost/logo-dark.png" alt="TryPost"
|
||||
class="hidden dark:block h-8 w-auto" />
|
||||
</SheetHeader>
|
||||
<div
|
||||
class="flex h-full flex-1 flex-col justify-between space-y-4 py-6"
|
||||
>
|
||||
<div class="flex h-full flex-1 flex-col justify-between space-y-4 py-6">
|
||||
<nav class="-mx-3 space-y-1">
|
||||
<Link
|
||||
v-for="item in mainNavItems"
|
||||
:key="item.title"
|
||||
:href="item.href"
|
||||
<Link v-for="item in mainNavItems" :key="item.title" :href="item.href"
|
||||
class="flex items-center gap-x-3 rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent"
|
||||
:class="activeItemStyles(item.href)"
|
||||
>
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
:class="activeItemStyles(item.href)">
|
||||
<component v-if="item.icon" :is="item.icon" class="h-5 w-5" />
|
||||
{{ item.title }}
|
||||
</Link>
|
||||
</nav>
|
||||
<div class="flex flex-col space-y-4">
|
||||
<a
|
||||
v-for="item in rightNavItems"
|
||||
:key="item.title"
|
||||
:href="toUrl(item.href)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center space-x-2 text-sm font-medium"
|
||||
>
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
<span>{{ item.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<Link :href="dashboard()" class="flex items-center gap-x-2">
|
||||
<AppLogo />
|
||||
<Link href="/workspaces" class="flex items-center gap-x-2">
|
||||
<img src="/images/trypost/logo-light.png" alt="TryPost" class="dark:hidden h-8 w-auto" />
|
||||
<img src="/images/trypost/logo-dark.png" alt="TryPost" class="hidden dark:block h-8 w-auto" />
|
||||
</Link>
|
||||
|
||||
<!-- Desktop Menu -->
|
||||
<div class="hidden h-full lg:flex lg:flex-1">
|
||||
<NavigationMenu class="ml-10 flex h-full items-stretch">
|
||||
<NavigationMenuList
|
||||
class="flex h-full items-stretch space-x-2"
|
||||
>
|
||||
<NavigationMenuItem
|
||||
v-for="(item, index) in mainNavItems"
|
||||
:key="index"
|
||||
class="relative flex h-full items-center"
|
||||
>
|
||||
<Link
|
||||
:class="[
|
||||
navigationMenuTriggerStyle(),
|
||||
activeItemStyles(item.href),
|
||||
'h-9 cursor-pointer px-3',
|
||||
]"
|
||||
:href="item.href"
|
||||
>
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="mr-2 h-4 w-4"
|
||||
/>
|
||||
<NavigationMenuList class="flex h-full items-stretch space-x-2">
|
||||
<NavigationMenuItem v-for="(item, index) in mainNavItems" :key="index"
|
||||
class="relative flex h-full items-center">
|
||||
<Link :class="[
|
||||
navigationMenuTriggerStyle(),
|
||||
activeItemStyles(item.href),
|
||||
'h-9 cursor-pointer px-3',
|
||||
]" :href="item.href">
|
||||
<component v-if="item.icon" :is="item.icon" class="mr-2 h-4 w-4" />
|
||||
{{ item.title }}
|
||||
</Link>
|
||||
<div
|
||||
v-if="urlIsActive(item.href)"
|
||||
class="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-black dark:bg-white"
|
||||
></div>
|
||||
<div v-if="urlIsActive(item.href)"
|
||||
class="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-black dark:bg-white">
|
||||
</div>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center space-x-2">
|
||||
<div class="relative flex items-center space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="group h-9 w-9 cursor-pointer"
|
||||
>
|
||||
<Search
|
||||
class="size-5 opacity-80 group-hover:opacity-100"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<div class="hidden space-x-1 lg:flex">
|
||||
<template
|
||||
v-for="item in rightNavItems"
|
||||
:key="item.title"
|
||||
>
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
as-child
|
||||
class="group h-9 w-9 cursor-pointer"
|
||||
>
|
||||
<a
|
||||
:href="toUrl(item.href)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span class="sr-only">{{
|
||||
item.title
|
||||
}}</span>
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="size-5 opacity-80 group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ item.title }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger :as-child="true">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="relative size-10 w-auto rounded-full p-1 focus-within:ring-2 focus-within:ring-primary"
|
||||
>
|
||||
<Avatar
|
||||
class="size-8 overflow-hidden rounded-full"
|
||||
>
|
||||
<AvatarImage
|
||||
v-if="auth.user.avatar"
|
||||
:src="auth.user.avatar"
|
||||
:alt="auth.user.name"
|
||||
/>
|
||||
<Button variant="ghost" size="icon"
|
||||
class="relative size-10 w-auto rounded-full p-1 focus-within:ring-2 focus-within:ring-primary">
|
||||
<Avatar class="size-8 overflow-hidden rounded-full">
|
||||
<AvatarImage v-if="auth.user.avatar" :src="auth.user.avatar"
|
||||
:alt="auth.user.name" />
|
||||
<AvatarFallback
|
||||
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white"
|
||||
>
|
||||
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white">
|
||||
{{ getInitials(auth.user?.name) }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
|
@ -266,15 +141,10 @@ const rightNavItems: NavItem[] = [
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="props.breadcrumbs.length > 1"
|
||||
class="flex w-full border-b border-sidebar-border/70"
|
||||
>
|
||||
<div
|
||||
class="mx-auto flex h-12 w-full items-center justify-start px-4 text-neutral-500 md:max-w-7xl"
|
||||
>
|
||||
<div v-if="props.breadcrumbs.length > 1" class="flex w-full border-b border-sidebar-border/70">
|
||||
<div class="mx-auto flex h-12 w-full items-center justify-start px-4 text-neutral-500 md:max-w-7xl">
|
||||
<Breadcrumbs :breadcrumbs="breadcrumbs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
>
|
||||
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
|
||||
</div>
|
||||
<div class="ml-1 grid flex-1 text-left text-sm">
|
||||
<span class="mb-0.5 truncate leading-tight font-semibold"
|
||||
>Laravel Starter Kit</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
interface Props {
|
||||
className?: HTMLAttributes['class'];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 40 42"
|
||||
:class="className"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { BookOpen, Folder, LayoutGrid } from 'lucide-vue-next';
|
||||
|
||||
import NavFooter from '@/components/NavFooter.vue';
|
||||
import NavMain from '@/components/NavMain.vue';
|
||||
import NavUser from '@/components/NavUser.vue';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { dashboard } from '@/routes';
|
||||
import { type NavItem } from '@/types';
|
||||
|
||||
import AppLogo from './AppLogo.vue';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
];
|
||||
|
||||
const footerNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Github Repo',
|
||||
href: 'https://github.com/laravel/vue-starter-kit',
|
||||
icon: Folder,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://laravel.com/docs/starter-kits#vue',
|
||||
icon: BookOpen,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" as-child>
|
||||
<Link :href="dashboard()">
|
||||
<AppLogo />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<NavMain :items="mainNavItems" />
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<NavFooter :items="footerNavItems" />
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<slot />
|
||||
</template>
|
||||
128
resources/js/components/ConfirmDeleteModal.vue
Normal file
128
resources/js/components/ConfirmDeleteModal.vue
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Are you sure?',
|
||||
},
|
||||
|
||||
description: {
|
||||
type: String,
|
||||
default:
|
||||
'Are you sure you want to perform this action? This action cannot be undone.',
|
||||
},
|
||||
|
||||
action: {
|
||||
type: String,
|
||||
default: 'Delete',
|
||||
},
|
||||
|
||||
cancel: {
|
||||
type: String,
|
||||
default: 'Cancel',
|
||||
},
|
||||
|
||||
method: {
|
||||
type: String,
|
||||
default: 'delete',
|
||||
},
|
||||
|
||||
preserveState: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
preserveScroll: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['deleted', 'closed']);
|
||||
|
||||
const isOpen = ref<boolean>(false);
|
||||
const loading = ref<boolean>(false);
|
||||
const url = ref<string | null>(null);
|
||||
|
||||
function remove() {
|
||||
if (!url.value) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
router[props.method as 'delete' | 'get' | 'post' | 'put' | 'patch'](
|
||||
url.value,
|
||||
{},
|
||||
{
|
||||
preserveState: props.preserveState,
|
||||
preserveScroll: props.preserveScroll,
|
||||
onSuccess: () => {
|
||||
close();
|
||||
emit('deleted');
|
||||
},
|
||||
onError: (errors) => {
|
||||
console.error(errors);
|
||||
},
|
||||
onFinish: () => {
|
||||
loading.value = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function open(data: { url: string }) {
|
||||
url.value = data.url;
|
||||
loading.value = false;
|
||||
isOpen.value = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false;
|
||||
loading.value = false;
|
||||
emit('closed');
|
||||
}
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
isOpen.value = value;
|
||||
if (!value) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialog :open="isOpen" @update:open="onOpenChange">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ title }}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{{ description }}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel :disabled="loading">
|
||||
{{ cancel }}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction :disabled="loading" variant="default" @click="remove">
|
||||
{{ action }}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
157
resources/js/components/DatePicker.vue
Normal file
157
resources/js/components/DatePicker.vue
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { InputMask } from '@/components/ui/input';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { useDateMaska } from '@/composables/useDateMaska';
|
||||
import dayjs from '@/dayjs';
|
||||
import { parseDate } from '@internationalized/date';
|
||||
import { IconCalendar } from '@tabler/icons-vue';
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
align: {
|
||||
type: String as () => 'start' | 'center' | 'end',
|
||||
default: 'end',
|
||||
validator: (value: string) => ['start', 'end'].includes(value),
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const { dateOptions } = useDateMaska();
|
||||
|
||||
// Parse input value into date
|
||||
function parseInput(value: string) {
|
||||
if (!value) return undefined;
|
||||
|
||||
// Parse date string (YYYY-MM-DD format)
|
||||
try {
|
||||
return parseDate(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const internalDate = ref(parseInput(props.modelValue));
|
||||
const displayValue = ref(
|
||||
props.modelValue && dayjs(props.modelValue).isValid()
|
||||
? dayjs(props.modelValue).format('MM/DD/YYYY')
|
||||
: '',
|
||||
);
|
||||
const popoverOpen = ref(false);
|
||||
|
||||
// Parse American date format (MM/DD/YYYY) to YYYY-MM-DD
|
||||
function parseAmericanDate(value: string): string | null {
|
||||
if (!value) return null;
|
||||
|
||||
// Remove any non-digit characters
|
||||
const digits = value.replace(/\D/g, '');
|
||||
|
||||
// Try to parse MM/DD/YYYY format
|
||||
if (digits.length === 8) {
|
||||
const month = digits.substring(0, 2);
|
||||
const day = digits.substring(2, 4);
|
||||
const year = digits.substring(4, 8);
|
||||
|
||||
const dateStr = `${year}-${month}-${day}`;
|
||||
const parsed = dayjs(dateStr, 'YYYY-MM-DD');
|
||||
|
||||
if (parsed.isValid()) {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle manual input change
|
||||
function onInputChange(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
displayValue.value = value;
|
||||
|
||||
const parsedDate = parseAmericanDate(value);
|
||||
if (parsedDate) {
|
||||
internalDate.value = parseInput(parsedDate);
|
||||
emit('update:modelValue', parsedDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse input value into date component
|
||||
const isInternalUpdate = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (isInternalUpdate.value) {
|
||||
isInternalUpdate.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
internalDate.value = parseInput(newVal);
|
||||
|
||||
// Update display value
|
||||
if (newVal) {
|
||||
const parsed = dayjs(newVal);
|
||||
if (parsed.isValid()) {
|
||||
displayValue.value = parsed.format('MM/DD/YYYY');
|
||||
}
|
||||
} else {
|
||||
displayValue.value = '';
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Watch internal date changes from calendar
|
||||
watch(internalDate, (newDate) => {
|
||||
if (!newDate || isInternalUpdate.value) return;
|
||||
|
||||
isInternalUpdate.value = true;
|
||||
const formatted = newDate.toString();
|
||||
emit('update:modelValue', formatted);
|
||||
popoverOpen.value = false;
|
||||
|
||||
// Update display value when date is selected from calendar
|
||||
if (formatted) {
|
||||
const parsed = dayjs(formatted);
|
||||
if (parsed.isValid()) {
|
||||
displayValue.value = parsed.format('MM/DD/YYYY');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<InputMask :id="name" v-model="displayValue" :mask-options="dateOptions" placeholder="MM/DD/YYYY"
|
||||
@input="onInputChange" class="pr-10" :disabled="disabled" />
|
||||
<Popover v-model:open="popoverOpen">
|
||||
<PopoverTrigger :disabled="disabled">
|
||||
<Button type="button" variant="ghost" size="sm" class="absolute top-0 right-0 z-10 h-full" :disabled="disabled">
|
||||
<IconCalendar class="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0" :align="align">
|
||||
<Calendar v-model="internalDate" :placeholder="internalDate" layout="month-and-year" locale="en-US"
|
||||
calendar-label="Date picker" initial-focus />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
129
resources/js/components/TimezoneCombobox.vue
Normal file
129
resources/js/components/TimezoneCombobox.vue
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxAnchor,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxItemIndicator,
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
} from '@/components/ui/combobox';
|
||||
import { IconCheck, IconChevronDown, IconSearch } from '@tabler/icons-vue';
|
||||
import { FocusScope } from 'reka-ui';
|
||||
import { ref, watchEffect } from 'vue';
|
||||
|
||||
interface Timezone {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | null;
|
||||
timezones: Record<string, string>;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | null];
|
||||
}>();
|
||||
|
||||
// Common timezones to show first
|
||||
const commonTimezoneIds = [
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
'America/Denver',
|
||||
'America/Los_Angeles',
|
||||
'America/Sao_Paulo',
|
||||
'America/Mexico_City',
|
||||
'Europe/London',
|
||||
'Europe/Paris',
|
||||
'Europe/Berlin',
|
||||
'Asia/Tokyo',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Dubai',
|
||||
'Australia/Sydney',
|
||||
'Pacific/Auckland',
|
||||
];
|
||||
|
||||
// Build timezone list from props
|
||||
const timezones = Object.keys(props.timezones)
|
||||
.sort((a, b) => {
|
||||
const aIsCommon = commonTimezoneIds.includes(a);
|
||||
const bIsCommon = commonTimezoneIds.includes(b);
|
||||
if (aIsCommon && !bIsCommon) return -1;
|
||||
if (!aIsCommon && bIsCommon) return 1;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((tz) => ({
|
||||
value: tz,
|
||||
label: tz.replace(/_/g, ' '),
|
||||
}));
|
||||
|
||||
const selectedTimezone = ref<Timezone | undefined>();
|
||||
|
||||
watchEffect(() => {
|
||||
selectedTimezone.value = timezones.find(
|
||||
(tz) => tz.value === props.modelValue,
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FocusScope as-child>
|
||||
<Combobox
|
||||
:model-value="selectedTimezone"
|
||||
@update:model-value="
|
||||
(v: Timezone) => {
|
||||
selectedTimezone = v;
|
||||
emit('update:modelValue', v?.value || null);
|
||||
}
|
||||
"
|
||||
>
|
||||
<ComboboxAnchor as-child>
|
||||
<ComboboxTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full justify-between"
|
||||
>
|
||||
{{
|
||||
selectedTimezone
|
||||
? selectedTimezone.label
|
||||
: 'Select timezone'
|
||||
}}
|
||||
<IconChevronDown
|
||||
class="ml-2 h-4 w-4 shrink-0 opacity-50"
|
||||
/>
|
||||
</Button>
|
||||
</ComboboxTrigger>
|
||||
</ComboboxAnchor>
|
||||
<ComboboxList class="w-full">
|
||||
<div class="relative">
|
||||
<ComboboxInput placeholder="Search timezone..." />
|
||||
<span
|
||||
class="absolute inset-y-0 start-0 flex items-center justify-center px-3"
|
||||
>
|
||||
<IconSearch class="size-4 text-muted-foreground" />
|
||||
</span>
|
||||
</div>
|
||||
<ComboboxEmpty>No timezone found</ComboboxEmpty>
|
||||
<ComboboxGroup>
|
||||
<ComboboxItem
|
||||
v-for="tz in timezones"
|
||||
:key="tz.value"
|
||||
:value="tz"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{{
|
||||
tz.label
|
||||
}}</span>
|
||||
<ComboboxItemIndicator>
|
||||
<IconCheck class="ml-auto h-4 w-4" />
|
||||
</ComboboxItemIndicator>
|
||||
</ComboboxItem>
|
||||
</ComboboxGroup>
|
||||
</ComboboxList>
|
||||
</Combobox>
|
||||
</FocusScope>
|
||||
</template>
|
||||
127
resources/js/components/Toast.vue
Normal file
127
resources/js/components/Toast.vue
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<script setup lang="ts">
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconInfoCircle,
|
||||
IconX,
|
||||
} from '@tabler/icons-vue';
|
||||
|
||||
const show = ref(false);
|
||||
const animate = ref(false);
|
||||
const style = computed(() => usePage().props.flash?.bannerStyle || 'success');
|
||||
const message = computed(() => usePage().props.flash?.banner || '');
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
watch(
|
||||
message,
|
||||
(newMessage) => {
|
||||
if (newMessage) {
|
||||
show.value = true;
|
||||
// Reseta a animação
|
||||
animate.value = false;
|
||||
|
||||
// Inicia a animação logo após mostrar
|
||||
setTimeout(() => {
|
||||
animate.value = true;
|
||||
}, 10);
|
||||
|
||||
// Limpa timeout anterior se existir
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
// Define novo timeout
|
||||
timeoutId = setTimeout(() => {
|
||||
show.value = false;
|
||||
animate.value = false;
|
||||
timeoutId = null;
|
||||
}, 3000);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="show && message"
|
||||
class="pointer-events-none fixed inset-0 z-50 flex px-4 py-6 sm:items-start sm:p-6"
|
||||
>
|
||||
<div
|
||||
class="absolute right-5 bottom-5 flex w-full flex-col items-end space-y-4"
|
||||
>
|
||||
<transition
|
||||
enter-active-class="transform ease-out duration-300 transition"
|
||||
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
|
||||
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="ring-opacity-5 pointer-events-auto relative w-full max-w-sm overflow-hidden rounded-lg bg-zinc-800 shadow-2xl ring-1 ring-transparent dark:ring-zinc-700"
|
||||
>
|
||||
<!-- Barra de progresso animada -->
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full transition-all duration-[3000ms] ease-linear"
|
||||
:class="{
|
||||
'w-0': !animate,
|
||||
'w-full': animate,
|
||||
'bg-green-500/10': style === 'success',
|
||||
'bg-red-500/10': style === 'danger',
|
||||
'bg-blue-500/10': style === 'info',
|
||||
'bg-yellow-500/10': style === 'warning',
|
||||
}"
|
||||
/>
|
||||
<div class="relative p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="mt-0.5 flex-shrink-0">
|
||||
<IconCircleCheck
|
||||
v-if="style == 'success'"
|
||||
class="h-6 w-6 stroke-2 text-green-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconCircleX
|
||||
v-if="style == 'danger'"
|
||||
class="h-6 w-6 stroke-2 text-red-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconInfoCircle
|
||||
v-if="style == 'info'"
|
||||
class="h-6 w-6 stroke-2 text-blue-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconAlertTriangle
|
||||
v-if="style == 'warning'"
|
||||
class="h-6 w-6 stroke-2 text-yellow-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3 w-0 flex-1 pt-0.5">
|
||||
<p class="text-sm font-medium text-white">
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-4 flex flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
@click="show = false"
|
||||
class="inline-flex rounded-md bg-transparent text-zinc-400 hover:text-zinc-500 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none"
|
||||
>
|
||||
<span class="sr-only">Close</span>
|
||||
<IconX class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
15
resources/js/components/ui/alert-dialog/AlertDialog.vue
Normal file
15
resources/js/components/ui/alert-dialog/AlertDialog.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
|
||||
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<AlertDialogProps>()
|
||||
const emits = defineEmits<AlertDialogEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import type { AlertDialogActionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogAction } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
|
||||
<slot />
|
||||
</AlertDialogAction>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import type { AlertDialogCancelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogCancel } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogCancel
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogCancel>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<script setup lang="ts">
|
||||
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogContent,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<AlertDialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
class="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"
|
||||
/>
|
||||
<AlertDialogContent
|
||||
data-slot="alert-dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import type { AlertDialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogDescription,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogDescription
|
||||
data-slot="alert-dialog-description"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogDescription>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<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="alert-dialog-footer"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</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>
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
resources/js/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
21
resources/js/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { AlertDialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogTitle } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTitle
|
||||
data-slot="alert-dialog-title"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-lg font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogTitle>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { AlertDialogTriggerProps } from "reka-ui"
|
||||
import { AlertDialogTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<AlertDialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
|
||||
<slot />
|
||||
</AlertDialogTrigger>
|
||||
</template>
|
||||
9
resources/js/components/ui/alert-dialog/index.ts
Normal file
9
resources/js/components/ui/alert-dialog/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export { default as AlertDialog } from "./AlertDialog.vue"
|
||||
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
|
||||
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
|
||||
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
|
||||
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
|
||||
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
|
||||
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
|
||||
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
|
||||
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"
|
||||
160
resources/js/components/ui/calendar/Calendar.vue
Normal file
160
resources/js/components/ui/calendar/Calendar.vue
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarRootEmits, CalendarRootProps, DateValue } from "reka-ui"
|
||||
import type { HTMLAttributes, Ref } from "vue"
|
||||
import type { LayoutTypes } from "."
|
||||
import { getLocalTimeZone, today } from "@internationalized/date"
|
||||
import { createReusableTemplate, reactiveOmit, useVModel } from "@vueuse/core"
|
||||
import { CalendarRoot, useDateFormatter, useForwardPropsEmits } from "reka-ui"
|
||||
import { createYear, createYearRange, toDate } from "reka-ui/date"
|
||||
import { computed, toRaw } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, CalendarGridHead, CalendarGridRow, CalendarHeadCell, CalendarHeader, CalendarHeading, CalendarNextButton, CalendarPrevButton } from "."
|
||||
|
||||
const props = withDefaults(defineProps<CalendarRootProps & { class?: HTMLAttributes["class"], layout?: LayoutTypes, yearRange?: DateValue[] }>(), {
|
||||
modelValue: undefined,
|
||||
layout: undefined,
|
||||
})
|
||||
const emits = defineEmits<CalendarRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "layout", "placeholder")
|
||||
|
||||
const placeholder = useVModel(props, "placeholder", emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultPlaceholder ?? today(getLocalTimeZone()),
|
||||
}) as Ref<DateValue>
|
||||
|
||||
const formatter = useDateFormatter(props.locale ?? "en")
|
||||
|
||||
const yearRange = computed(() => {
|
||||
return props.yearRange ?? createYearRange({
|
||||
start: props?.minValue ?? (toRaw(props.placeholder) ?? props.defaultPlaceholder ?? today(getLocalTimeZone()))
|
||||
.cycle("year", -100),
|
||||
|
||||
end: props?.maxValue ?? (toRaw(props.placeholder) ?? props.defaultPlaceholder ?? today(getLocalTimeZone()))
|
||||
.cycle("year", 10),
|
||||
})
|
||||
})
|
||||
|
||||
const [DefineMonthTemplate, ReuseMonthTemplate] = createReusableTemplate<{ date: DateValue }>()
|
||||
const [DefineYearTemplate, ReuseYearTemplate] = createReusableTemplate<{ date: DateValue }>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DefineMonthTemplate v-slot="{ date }">
|
||||
<div class="**:data-[slot=native-select-icon]:right-1">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex h-full items-center text-sm pl-2 pointer-events-none">
|
||||
{{ formatter.custom(toDate(date), { month: 'short' }) }}
|
||||
</div>
|
||||
<NativeSelect
|
||||
class="text-xs h-8 pr-6 pl-2 text-transparent relative"
|
||||
@change="(e: Event) => {
|
||||
placeholder = placeholder.set({
|
||||
month: Number((e?.target as any)?.value),
|
||||
})
|
||||
}"
|
||||
>
|
||||
<NativeSelectOption v-for="(month) in createYear({ dateObj: date })" :key="month.toString()" :value="month.month" :selected="date.month === month.month">
|
||||
{{ formatter.custom(toDate(month), { month: 'short' }) }}
|
||||
</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
</DefineMonthTemplate>
|
||||
|
||||
<DefineYearTemplate v-slot="{ date }">
|
||||
<div class="**:data-[slot=native-select-icon]:right-1">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex h-full items-center text-sm pl-2 pointer-events-none">
|
||||
{{ formatter.custom(toDate(date), { year: 'numeric' }) }}
|
||||
</div>
|
||||
<NativeSelect
|
||||
class="text-xs h-8 pr-6 pl-2 text-transparent relative"
|
||||
@change="(e: Event) => {
|
||||
placeholder = placeholder.set({
|
||||
year: Number((e?.target as any)?.value),
|
||||
})
|
||||
}"
|
||||
>
|
||||
<NativeSelectOption v-for="(year) in yearRange" :key="year.toString()" :value="year.year" :selected="date.year === year.year">
|
||||
{{ formatter.custom(toDate(year), { year: 'numeric' }) }}
|
||||
</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
</DefineYearTemplate>
|
||||
|
||||
<CalendarRoot
|
||||
v-slot="{ grid, weekDays, date }"
|
||||
v-bind="forwarded"
|
||||
v-model:placeholder="placeholder"
|
||||
data-slot="calendar"
|
||||
:class="cn('p-3', props.class)"
|
||||
>
|
||||
<CalendarHeader class="pt-0">
|
||||
<nav class="flex items-center gap-1 absolute top-0 inset-x-0 justify-between">
|
||||
<CalendarPrevButton>
|
||||
<slot name="calendar-prev-icon" />
|
||||
</CalendarPrevButton>
|
||||
<CalendarNextButton>
|
||||
<slot name="calendar-next-icon" />
|
||||
</CalendarNextButton>
|
||||
</nav>
|
||||
|
||||
<slot name="calendar-heading" :date="date" :month="ReuseMonthTemplate" :year="ReuseYearTemplate">
|
||||
<template v-if="layout === 'month-and-year'">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<ReuseMonthTemplate :date="date" />
|
||||
<ReuseYearTemplate :date="date" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="layout === 'month-only'">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<ReuseMonthTemplate :date="date" />
|
||||
{{ formatter.custom(toDate(date), { year: 'numeric' }) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="layout === 'year-only'">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
{{ formatter.custom(toDate(date), { month: 'short' }) }}
|
||||
<ReuseYearTemplate :date="date" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<CalendarHeading />
|
||||
</template>
|
||||
</slot>
|
||||
</CalendarHeader>
|
||||
|
||||
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
|
||||
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
|
||||
<CalendarGridHead>
|
||||
<CalendarGridRow>
|
||||
<CalendarHeadCell
|
||||
v-for="day in weekDays" :key="day"
|
||||
>
|
||||
{{ day }}
|
||||
</CalendarHeadCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridHead>
|
||||
<CalendarGridBody>
|
||||
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
|
||||
<CalendarCell
|
||||
v-for="weekDate in weekDates"
|
||||
:key="weekDate.toString()"
|
||||
:date="weekDate"
|
||||
>
|
||||
<CalendarCellTrigger
|
||||
:day="weekDate"
|
||||
:month="month.value"
|
||||
/>
|
||||
</CalendarCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridBody>
|
||||
</CalendarGrid>
|
||||
</div>
|
||||
</CalendarRoot>
|
||||
</template>
|
||||
23
resources/js/components/ui/calendar/CalendarCell.vue
Normal file
23
resources/js/components/ui/calendar/CalendarCell.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarCellProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarCell, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarCell
|
||||
data-slot="calendar-cell"
|
||||
:class="cn('relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarCell>
|
||||
</template>
|
||||
39
resources/js/components/ui/calendar/CalendarCellTrigger.vue
Normal file
39
resources/js/components/ui/calendar/CalendarCellTrigger.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarCellTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarCellTrigger, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes["class"] }>(), {
|
||||
as: "button",
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarCellTrigger
|
||||
data-slot="calendar-cell-trigger"
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'size-8 p-0 font-normal aria-selected:opacity-100 cursor-default',
|
||||
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
|
||||
// Selected
|
||||
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
|
||||
// Disabled
|
||||
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
|
||||
// Unavailable
|
||||
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
|
||||
// Outside months
|
||||
'data-[outside-view]:text-muted-foreground',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarCellTrigger>
|
||||
</template>
|
||||
23
resources/js/components/ui/calendar/CalendarGrid.vue
Normal file
23
resources/js/components/ui/calendar/CalendarGrid.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarGridProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarGrid, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CalendarGridProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGrid
|
||||
data-slot="calendar-grid"
|
||||
:class="cn('w-full border-collapse space-x-1', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarGrid>
|
||||
</template>
|
||||
15
resources/js/components/ui/calendar/CalendarGridBody.vue
Normal file
15
resources/js/components/ui/calendar/CalendarGridBody.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarGridBodyProps } from "reka-ui"
|
||||
import { CalendarGridBody } from "reka-ui"
|
||||
|
||||
const props = defineProps<CalendarGridBodyProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridBody
|
||||
data-slot="calendar-grid-body"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</CalendarGridBody>
|
||||
</template>
|
||||
16
resources/js/components/ui/calendar/CalendarGridHead.vue
Normal file
16
resources/js/components/ui/calendar/CalendarGridHead.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarGridHeadProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { CalendarGridHead } from "reka-ui"
|
||||
|
||||
const props = defineProps<CalendarGridHeadProps & { class?: HTMLAttributes["class"] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridHead
|
||||
data-slot="calendar-grid-head"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</CalendarGridHead>
|
||||
</template>
|
||||
22
resources/js/components/ui/calendar/CalendarGridRow.vue
Normal file
22
resources/js/components/ui/calendar/CalendarGridRow.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarGridRowProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarGridRow, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CalendarGridRowProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridRow
|
||||
data-slot="calendar-grid-row"
|
||||
:class="cn('flex', props.class)" v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarGridRow>
|
||||
</template>
|
||||
23
resources/js/components/ui/calendar/CalendarHeadCell.vue
Normal file
23
resources/js/components/ui/calendar/CalendarHeadCell.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarHeadCellProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarHeadCell, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CalendarHeadCellProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarHeadCell
|
||||
data-slot="calendar-head-cell"
|
||||
:class="cn('text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem]', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarHeadCell>
|
||||
</template>
|
||||
23
resources/js/components/ui/calendar/CalendarHeader.vue
Normal file
23
resources/js/components/ui/calendar/CalendarHeader.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarHeaderProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarHeader, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CalendarHeaderProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarHeader
|
||||
data-slot="calendar-header"
|
||||
:class="cn('flex justify-center pt-1 relative items-center w-full px-8', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarHeader>
|
||||
</template>
|
||||
30
resources/js/components/ui/calendar/CalendarHeading.vue
Normal file
30
resources/js/components/ui/calendar/CalendarHeading.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarHeadingProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarHeading, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
defineSlots<{
|
||||
default: (props: { headingValue: string }) => any
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarHeading
|
||||
v-slot="{ headingValue }"
|
||||
data-slot="calendar-heading"
|
||||
:class="cn('text-sm font-medium', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot :heading-value>
|
||||
{{ headingValue }}
|
||||
</slot>
|
||||
</CalendarHeading>
|
||||
</template>
|
||||
31
resources/js/components/ui/calendar/CalendarNextButton.vue
Normal file
31
resources/js/components/ui/calendar/CalendarNextButton.vue
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarNextProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronRight } from "lucide-vue-next"
|
||||
import { CalendarNext, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<CalendarNextProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarNext
|
||||
data-slot="calendar-next-button"
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot>
|
||||
<ChevronRight class="size-4" />
|
||||
</slot>
|
||||
</CalendarNext>
|
||||
</template>
|
||||
31
resources/js/components/ui/calendar/CalendarPrevButton.vue
Normal file
31
resources/js/components/ui/calendar/CalendarPrevButton.vue
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarPrevProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronLeft } from "lucide-vue-next"
|
||||
import { CalendarPrev, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<CalendarPrevProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarPrev
|
||||
data-slot="calendar-prev-button"
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot>
|
||||
<ChevronLeft class="size-4" />
|
||||
</slot>
|
||||
</CalendarPrev>
|
||||
</template>
|
||||
14
resources/js/components/ui/calendar/index.ts
Normal file
14
resources/js/components/ui/calendar/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export { default as Calendar } from "./Calendar.vue"
|
||||
export { default as CalendarCell } from "./CalendarCell.vue"
|
||||
export { default as CalendarCellTrigger } from "./CalendarCellTrigger.vue"
|
||||
export { default as CalendarGrid } from "./CalendarGrid.vue"
|
||||
export { default as CalendarGridBody } from "./CalendarGridBody.vue"
|
||||
export { default as CalendarGridHead } from "./CalendarGridHead.vue"
|
||||
export { default as CalendarGridRow } from "./CalendarGridRow.vue"
|
||||
export { default as CalendarHeadCell } from "./CalendarHeadCell.vue"
|
||||
export { default as CalendarHeader } from "./CalendarHeader.vue"
|
||||
export { default as CalendarHeading } from "./CalendarHeading.vue"
|
||||
export { default as CalendarNextButton } from "./CalendarNextButton.vue"
|
||||
export { default as CalendarPrevButton } from "./CalendarPrevButton.vue"
|
||||
|
||||
export type LayoutTypes = "month-and-year" | "month-only" | "year-only" | undefined
|
||||
19
resources/js/components/ui/combobox/Combobox.vue
Normal file
19
resources/js/components/ui/combobox/Combobox.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
import type { ComboboxRootEmits, ComboboxRootProps } from "reka-ui"
|
||||
import {
|
||||
ComboboxRoot,
|
||||
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
|
||||
const props = defineProps<ComboboxRootProps>()
|
||||
const emits = defineEmits<ComboboxRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxRoot data-slot="combobox" v-bind="forwarded">
|
||||
<slot />
|
||||
</ComboboxRoot>
|
||||
</template>
|
||||
30
resources/js/components/ui/combobox/ComboboxAnchor.vue
Normal file
30
resources/js/components/ui/combobox/ComboboxAnchor.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<script setup lang="ts">
|
||||
import type { ComboboxAnchorProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
ComboboxAnchor,
|
||||
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<
|
||||
ComboboxAnchorProps & { class?: HTMLAttributes["class"] }
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxAnchor
|
||||
data-slot="combobox-anchor"
|
||||
v-bind="forwarded"
|
||||
:class="cn('w-[200px]', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</ComboboxAnchor>
|
||||
</template>
|
||||
24
resources/js/components/ui/combobox/ComboboxEmpty.vue
Normal file
24
resources/js/components/ui/combobox/ComboboxEmpty.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
import type { ComboboxEmptyProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ComboboxEmpty } from "reka-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<
|
||||
ComboboxEmptyProps & { class?: HTMLAttributes["class"] }
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxEmpty
|
||||
data-slot="combobox-empty"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('py-6 text-center text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</ComboboxEmpty>
|
||||
</template>
|
||||
35
resources/js/components/ui/combobox/ComboboxGroup.vue
Normal file
35
resources/js/components/ui/combobox/ComboboxGroup.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
import type { ComboboxGroupProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ComboboxGroup, ComboboxLabel } from "reka-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<
|
||||
ComboboxGroupProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
heading?: string
|
||||
}
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxGroup
|
||||
data-slot="combobox-group"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn('overflow-y-auto max-h-[240px] p-1 text-foreground', props.class)
|
||||
"
|
||||
>
|
||||
<ComboboxLabel
|
||||
v-if="heading"
|
||||
class="px-2 py-1.5 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{{ heading }}
|
||||
</ComboboxLabel>
|
||||
<slot />
|
||||
</ComboboxGroup>
|
||||
</template>
|
||||
50
resources/js/components/ui/combobox/ComboboxInput.vue
Normal file
50
resources/js/components/ui/combobox/ComboboxInput.vue
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<script setup lang="ts">
|
||||
import type { ComboboxInputEmits, ComboboxInputProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { IconSearch } from '@tabler/icons-vue'
|
||||
import {
|
||||
ComboboxInput,
|
||||
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<
|
||||
ComboboxInputProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
}
|
||||
>()
|
||||
|
||||
const emits = defineEmits<ComboboxInputEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
class="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<IconSearch class="size-4 shrink-0 opacity-50" />
|
||||
<ComboboxInput
|
||||
data-slot="command-input"
|
||||
: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,
|
||||
)
|
||||
"
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
>
|
||||
<slot />
|
||||
</ComboboxInput>
|
||||
</div>
|
||||
</template>
|
||||
35
resources/js/components/ui/combobox/ComboboxItem.vue
Normal file
35
resources/js/components/ui/combobox/ComboboxItem.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
import type { ComboboxItemEmits, ComboboxItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
ComboboxItem,
|
||||
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<
|
||||
ComboboxItemProps & { class?: HTMLAttributes["class"] }
|
||||
>()
|
||||
const emits = defineEmits<ComboboxItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxItem
|
||||
data-slot="combobox-item"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
`data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full min-w-0 cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</ComboboxItem>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<script setup lang="ts">
|
||||
import type { ComboboxItemIndicatorProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
ComboboxItemIndicator,
|
||||
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<
|
||||
ComboboxItemIndicatorProps & { class?: HTMLAttributes["class"] }
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxItemIndicator
|
||||
data-slot="combobox-item-indicator"
|
||||
v-bind="forwarded"
|
||||
:class="cn('ml-auto', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</ComboboxItemIndicator>
|
||||
</template>
|
||||
43
resources/js/components/ui/combobox/ComboboxList.vue
Normal file
43
resources/js/components/ui/combobox/ComboboxList.vue
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<script setup lang="ts">
|
||||
import type { ComboboxContentEmits, ComboboxContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
ComboboxContent,
|
||||
|
||||
ComboboxPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<ComboboxContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
position: "popper",
|
||||
align: "center",
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<ComboboxContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
data-slot="combobox-list"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'z-50 rounded-md border bg-popover text-popover-foreground origin-(--reka-combobox-content-transform-origin) overflow-hidden shadow-md outline-none 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 min-w-[var(--reka-combobox-trigger-width)] w-auto truncate max-w-[var(--reka-combobox-trigger-width)]',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
</template>
|
||||
24
resources/js/components/ui/combobox/ComboboxSeparator.vue
Normal file
24
resources/js/components/ui/combobox/ComboboxSeparator.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
import type { ComboboxSeparatorProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ComboboxSeparator } from "reka-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<
|
||||
ComboboxSeparatorProps & { class?: HTMLAttributes["class"] }
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxSeparator
|
||||
data-slot="combobox-separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-border -mx-1 h-px', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</ComboboxSeparator>
|
||||
</template>
|
||||
31
resources/js/components/ui/combobox/ComboboxTrigger.vue
Normal file
31
resources/js/components/ui/combobox/ComboboxTrigger.vue
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
import type { ComboboxTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
ComboboxTrigger,
|
||||
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<
|
||||
ComboboxTriggerProps & { class?: HTMLAttributes["class"] }
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxTrigger
|
||||
data-slot="combobox-trigger"
|
||||
v-bind="forwarded"
|
||||
:class="cn('', props.class)"
|
||||
tabindex="0"
|
||||
>
|
||||
<slot />
|
||||
</ComboboxTrigger>
|
||||
</template>
|
||||
35
resources/js/components/ui/combobox/ComboboxViewport.vue
Normal file
35
resources/js/components/ui/combobox/ComboboxViewport.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
import type { ComboboxViewportProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
ComboboxViewport,
|
||||
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<
|
||||
ComboboxViewportProps & { class?: HTMLAttributes["class"] }
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxViewport
|
||||
data-slot="combobox-viewport"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</ComboboxViewport>
|
||||
</template>
|
||||
12
resources/js/components/ui/combobox/index.ts
Normal file
12
resources/js/components/ui/combobox/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export { default as Combobox } from "./Combobox.vue"
|
||||
export { default as ComboboxAnchor } from "./ComboboxAnchor.vue"
|
||||
export { default as ComboboxEmpty } from "./ComboboxEmpty.vue"
|
||||
export { default as ComboboxGroup } from "./ComboboxGroup.vue"
|
||||
export { default as ComboboxInput } from "./ComboboxInput.vue"
|
||||
export { default as ComboboxItem } from "./ComboboxItem.vue"
|
||||
export { default as ComboboxItemIndicator } from "./ComboboxItemIndicator.vue"
|
||||
export { default as ComboboxList } from "./ComboboxList.vue"
|
||||
export { default as ComboboxSeparator } from "./ComboboxSeparator.vue"
|
||||
export { default as ComboboxViewport } from "./ComboboxViewport.vue"
|
||||
|
||||
export { ComboboxCancel, ComboboxTrigger } from "reka-ui"
|
||||
40
resources/js/components/ui/input/InputMask.vue
Normal file
40
resources/js/components/ui/input/InputMask.vue
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
import { vMaska } from 'maska/vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const props = defineProps<{
|
||||
defaultValue?: string | number;
|
||||
modelValue?: string | number;
|
||||
class?: HTMLAttributes['class'];
|
||||
maskOptions?: Record<string, any>;
|
||||
emitMasked?: boolean;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
'update:modelValue': [value: string | number];
|
||||
}>();
|
||||
|
||||
function onMaska(event: { detail: { masked: string; unmasked: string; completed: boolean } }) {
|
||||
const value = props.emitMasked ? event.detail.masked : event.detail.unmasked;
|
||||
emits('update:modelValue', value || '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-maska
|
||||
v-bind="maskOptions"
|
||||
:value="modelValue"
|
||||
data-slot="input"
|
||||
:class="
|
||||
cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
@maska="onMaska"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -1 +1,2 @@
|
|||
export { default as Input } from "./Input.vue"
|
||||
export { default as InputMask } from './InputMask.vue';
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue