chore: first commit

This commit is contained in:
Paulo Castellano 2026-01-15 14:24:39 -03:00
parent 6890147aa6
commit 7c80d717b1
136 changed files with 8931 additions and 2032 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
};
}
}

View file

@ -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,
};
}
}

View 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 [];
}
}
}

View 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 [];
}
}
}

View file

@ -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.');
}
}

View file

@ -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.');
}
}

View file

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

View 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.');
}
}
}

View file

@ -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.');
}
}
}

View file

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

View 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 [];
}
}
}

View file

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

View file

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

View file

@ -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!');
}
}

View file

@ -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.');
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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}",
];
}
}

View 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');
}
}

View file

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

View file

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

View 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');
}
}

View file

@ -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');
}
}

View file

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

View 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');
}
}

View file

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

View file

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

File diff suppressed because it is too large Load diff

82
config/broadcasting.php Normal file
View 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
View 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),
],
],
],
];

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View 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"

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

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

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

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

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

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

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

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

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

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

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

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

View 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

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

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

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

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

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

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

View file

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

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

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

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

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

View 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"

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

View file

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