Merge pull request #19 from trypost-it/feature/site-design-system

feat: design system polish (in-page headers, indies tabs, sidebar collapse removal)
This commit is contained in:
Paulo Castellano 2026-05-06 17:28:58 -03:00 committed by GitHub
commit 3953654f89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
204 changed files with 3721 additions and 2957 deletions

View file

@ -93,7 +93,7 @@ jobs:
run: php artisan passport:keys --force
- name: Run tests
run: php artisan test --compact
run: php artisan test --compact --parallel
env:
DB_PORT: ${{ job.services.postgres.ports['5432'] }}
REDIS_PORT: ${{ job.services.redis.ports['6379'] }}

View file

@ -259,6 +259,17 @@ ## Form Validation
- NEVER use HTML5 validation attributes (`required`, `minlength`, `pattern`, etc.) on form inputs. Always rely solely on backend validation.
## Backend Validation
- Validation rules always live in a dedicated `Illuminate\Foundation\Http\FormRequest` subclass under `app/Http/Requests/App/<Group>/`. Controller actions must type-hint the FormRequest as the parameter — NEVER call `$request->validate([...])` inline in the controller.
- Naming: `<Verb><Resource>Request.php` (e.g. `StorePostRequest`, `ApplyPostTemplateRequest`, `IndexPostTemplateRequest`).
## Pest / Feature Tests
- ALWAYS use named routes via the `route()` helper in feature tests. NEVER hardcode URL strings like `'/posts/ai/create'`.
- Example: `$this->postJson(route('app.posts.store'))` instead of `$this->postJson('/posts')`.
- With params: `route('app.posts.ai.create.finalize', $creationId)`.
## Dusk (Browser Tests)
- In Dusk tests, ALWAYS use named routes via `route()` helper. NEVER hardcode URLs like `'https://trypost.test/login'`.

View file

@ -117,12 +117,7 @@ public function index(Request $request): Response|RedirectResponse
'status' => $invoice->status,
'invoice_pdf' => $invoice->invoice_pdf,
]),
'defaultPaymentMethod' => $account->defaultPaymentMethod()?->card?->only([
'brand',
'last4',
'exp_month',
'exp_year',
]),
'defaultPaymentMethod' => $account->displayablePaymentMethod(),
]);
}

View file

@ -54,6 +54,7 @@ public function start(StartPostCreationRequest $request): JsonResponse
socialAccountId: $socialAccountId,
imageCount: (int) $request->input('image_count', 0),
prompt: $request->string('prompt')->toString(),
date: $request->input('date'),
);
return response()->json([
@ -100,6 +101,7 @@ public function finalize(Request $request, string $creationId): JsonResponse
$post = CreatePost::execute($workspace, $request->user(), [
'content' => $caption,
'media' => $media,
'date' => data_get($state, 'date'),
]);
// Sync the post_platform with the wizard choice: set content_type to

View file

@ -12,6 +12,7 @@
use App\Enums\Post\Action as PostAction;
use App\Enums\Post\Status as PostStatus;
use App\Enums\SocialAccount\Platform;
use App\Http\Requests\App\Post\StorePostRequest;
use App\Http\Requests\App\Post\UpdatePostRequest;
use App\Http\Resources\Api\PostResource;
use App\Http\Resources\App\PlatformConfigResource;
@ -136,7 +137,7 @@ public function create(Request $request): Response
]);
}
public function store(Request $request): RedirectResponse|\Symfony\Component\HttpFoundation\Response
public function store(StorePostRequest $request): RedirectResponse|\Symfony\Component\HttpFoundation\Response
{
$workspace = $request->user()->currentWorkspace;

View file

@ -6,13 +6,14 @@
use App\Actions\Post\CreatePost;
use App\Enums\Media\Type as MediaType;
use App\Http\Requests\App\PostTemplate\ApplyPostTemplateRequest;
use App\Http\Requests\App\PostTemplate\IndexPostTemplateRequest;
use App\Http\Resources\App\PostTemplateResource;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Image\TemplateImageGenerator;
use App\Services\PostTemplate\Registry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
@ -22,13 +23,8 @@ class PostTemplateController extends Controller
{
public function __construct(private readonly Registry $registry) {}
public function index(Request $request): InertiaResponse
public function index(IndexPostTemplateRequest $request): InertiaResponse
{
$request->validate([
'platform' => ['nullable', 'string'],
'search' => ['nullable', 'string', 'max:120'],
]);
$paginator = $this->registry->paginate(
locale: app()->getLocale(),
platform: $request->input('platform'),
@ -45,19 +41,16 @@ public function index(Request $request): InertiaResponse
'search' => $request->input('search', ''),
'platform' => $request->input('platform', ''),
],
'date' => $request->input('date'),
]);
}
public function apply(Request $request, string $slug, TemplateImageGenerator $generator): JsonResponse
public function apply(ApplyPostTemplateRequest $request, string $slug, TemplateImageGenerator $generator): JsonResponse
{
$workspace = $request->user()->currentWorkspace;
$this->authorize('createPost', $workspace);
$request->validate([
'social_account_id' => ['nullable', 'uuid'],
]);
$template = $this->registry->find($slug, app()->getLocale());
$socialAccountId = $request->input('social_account_id');
@ -98,6 +91,7 @@ public function apply(Request $request, string $slug, TemplateImageGenerator $ge
$post = CreatePost::execute($workspace, $request->user(), [
'content' => $content,
'media' => $media,
'date' => $request->input('date'),
]);
return response()->json([

View file

@ -111,7 +111,6 @@ public function store(StoreWorkspaceRequest $request, LogoAttacher $logoAttacher
}
$validated = $request->validated();
$isFirstWorkspace = ! $user->workspaces()->exists();
$workspace = CreateWorkspace::execute($user, $validated);
@ -127,9 +126,8 @@ public function store(StoreWorkspaceRequest $request, LogoAttacher $logoAttacher
}
}
return $isFirstWorkspace
? redirect()->route('app.accounts')->with('success', __('workspaces.create.first_workspace_success'))
: redirect()->route('app.calendar')->with('success', __('workspaces.create.success'));
return redirect()->route('app.accounts', ['openDialog' => 'true'])
->with('success', __('workspaces.create.success'));
}
public function switch(Request $request, Workspace $workspace): RedirectResponse

View file

@ -82,6 +82,7 @@ public function index(Request $request): Response|RedirectResponse
'filters' => [
'search' => $request->input('search', ''),
],
'openDialog' => $request->boolean('openDialog'),
]);
}

View file

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware\App;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class HandleAppearance
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
View::share('appearance', $request->cookie('appearance') ?? 'system');
return $next($request);
}
}

View file

@ -30,6 +30,7 @@ public function rules(): array
'image_count' => ['nullable', 'integer', 'min:0', 'max:10'],
// Stories accept 1 image, no carousel — the wizard handles this client-side too.
'prompt' => ['required', 'string', 'max:2000'],
'date' => ['nullable', 'date_format:Y-m-d'],
];
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Post;
use Illuminate\Foundation\Http\FormRequest;
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'date' => ['nullable', 'date_format:Y-m-d'],
'media' => ['nullable', 'array'],
];
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\PostTemplate;
use Illuminate\Foundation\Http\FormRequest;
class ApplyPostTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'social_account_id' => ['nullable', 'uuid'],
'date' => ['nullable', 'date_format:Y-m-d'],
];
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\PostTemplate;
use Illuminate\Foundation\Http\FormRequest;
class IndexPostTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'platform' => ['nullable', 'string'],
'search' => ['nullable', 'string', 'max:120'],
'date' => ['nullable', 'date_format:Y-m-d'],
];
}
}

View file

@ -34,6 +34,7 @@ public function __construct(
public ?string $socialAccountId,
public int $imageCount,
public string $prompt,
public ?string $date = null,
) {
$this->onQueue('ai');
}
@ -251,6 +252,7 @@ private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAcco
'social_account_id' => $this->socialAccountId,
'content' => $caption,
'slides' => $renderedSlides,
'date' => $this->date,
'created_at' => now()->toIso8601String(),
], now()->addMinutes(30));
@ -296,6 +298,7 @@ private function handleSingle(Workspace $workspace, ?SocialAccount $socialAccoun
'image_body' => $imageBody,
'image_keywords' => $keywords,
'image_path' => $imagePath,
'date' => $this->date,
'created_at' => now()->toIso8601String(),
], now()->addMinutes(30));

View file

@ -85,6 +85,30 @@ public function isOnTrial(): bool
return $this->subscription(self::SUBSCRIPTION_NAME)?->onTrial() ?? false;
}
/**
* Returns the displayable card for the billing UI. Falls back to the first
* attached payment method when the customer has no `invoice_settings.default_payment_method`
* (Stripe Checkout trials anchor the card to the subscription, not the customer).
*
* @return array{brand: string, last4: string, exp_month: int, exp_year: int}|null
*/
public function displayablePaymentMethod(): ?array
{
$paymentMethod = $this->defaultPaymentMethod() ?? $this->paymentMethods()->first();
$card = $paymentMethod?->card;
if (! $card) {
return null;
}
return [
'brand' => $card->brand,
'last4' => $card->last4,
'exp_month' => $card->exp_month,
'exp_year' => $card->exp_year,
];
}
public function stripeEmail(): string
{
return $this->billing_email ?? $this->owner?->email ?? '';

View file

@ -44,10 +44,10 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
$post = data_get($response->json(), 'posts.0', []);
return [
['label' => 'Likes', 'value' => data_get($post, 'likeCount', 0)],
['label' => 'Reposts', 'value' => data_get($post, 'repostCount', 0)],
['label' => 'Quotes', 'value' => data_get($post, 'quoteCount', 0)],
['label' => 'Replies', 'value' => data_get($post, 'replyCount', 0)],
['label' => __('analytics.metrics.likes'), 'value' => data_get($post, 'likeCount', 0)],
['label' => __('analytics.metrics.reposts'), 'value' => data_get($post, 'repostCount', 0)],
['label' => __('analytics.metrics.quotes'), 'value' => data_get($post, 'quoteCount', 0)],
['label' => __('analytics.metrics.replies'), 'value' => data_get($post, 'replyCount', 0)],
];
}
}

View file

@ -106,11 +106,11 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
$total = collect($values)->sum('value');
$label = match ($name) {
'page_total_media_view_unique' => 'Page Reach',
'post_total_media_view_unique' => 'Posts Reach',
'page_post_engagements' => 'Posts Engagement',
'page_daily_follows' => 'Page Followers',
'page_media_view' => 'Page Views',
'page_total_media_view_unique' => __('analytics.metrics.page_reach'),
'post_total_media_view_unique' => __('analytics.metrics.posts_reach'),
'page_post_engagements' => __('analytics.metrics.posts_engagement'),
'page_daily_follows' => __('analytics.metrics.page_followers'),
'page_media_view' => __('analytics.metrics.page_views'),
default => ucfirst(str_replace('_', ' ', $name)),
};

View file

@ -142,8 +142,8 @@ private function fetchTimeSeriesMetrics(SocialAccount $account, CarbonInterface
$total = collect($values)->sum('value');
$label = match ($name) {
'reach' => 'Reach',
'follower_count' => 'Followers',
'reach' => __('analytics.metrics.reach'),
'follower_count' => __('analytics.metrics.followers'),
default => ucfirst(str_replace('_', ' ', $name)),
};
@ -181,7 +181,12 @@ private function fetchTotalValueMetrics(SocialAccount $account, CarbonInterface
$value = data_get($metric, 'total_value.value', 0);
$label = match ($name) {
'total_interactions' => 'Interactions',
'total_interactions' => __('analytics.metrics.interactions'),
'likes' => __('analytics.metrics.likes'),
'comments' => __('analytics.metrics.comments'),
'shares' => __('analytics.metrics.shares'),
'saves' => __('analytics.metrics.saves'),
'views' => __('analytics.metrics.views'),
default => ucfirst(str_replace('_', ' ', $name)),
};

View file

@ -75,8 +75,8 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
$data = $response->json();
return [
['label' => 'Likes', 'value' => (int) data_get($data, 'likesSummary.totalLikes', 0)],
['label' => 'Comments', 'value' => (int) data_get($data, 'commentsSummary.aggregatedTotalComments', 0)],
['label' => __('analytics.metrics.likes'), 'value' => (int) data_get($data, 'likesSummary.totalLikes', 0)],
['label' => __('analytics.metrics.comments'), 'value' => (int) data_get($data, 'commentsSummary.aggregatedTotalComments', 0)],
];
}
@ -135,7 +135,7 @@ private function fetchPageStatistics(string $orgUrn, string $timeInterval): arra
$totalPageViews += data_get($element, 'totalPageStatistics.views.allPageViews.pageViews', 0);
}
return $totalPageViews > 0 ? [['label' => 'Page Views', 'value' => $totalPageViews]] : [];
return $totalPageViews > 0 ? [['label' => __('analytics.metrics.page_views'), 'value' => $totalPageViews]] : [];
}
private function fetchFollowerStatistics(string $orgUrn, string $timeInterval): array
@ -164,11 +164,11 @@ private function fetchFollowerStatistics(string $orgUrn, string $timeInterval):
$metrics = [];
if ($organicFollowers > 0) {
$metrics[] = ['label' => 'Organic Followers', 'value' => $organicFollowers];
$metrics[] = ['label' => __('analytics.metrics.organic_followers'), 'value' => $organicFollowers];
}
if ($paidFollowers > 0) {
$metrics[] = ['label' => 'Paid Followers', 'value' => $paidFollowers];
$metrics[] = ['label' => __('analytics.metrics.paid_followers'), 'value' => $paidFollowers];
}
return $metrics;
@ -207,19 +207,19 @@ private function fetchShareStatistics(string $orgUrn, string $timeInterval): arr
$metrics = [];
if ($totalImpressions > 0) {
$metrics[] = ['label' => 'Impressions', 'value' => $totalImpressions];
$metrics[] = ['label' => __('analytics.metrics.impressions'), 'value' => $totalImpressions];
}
if ($totalClicks > 0) {
$metrics[] = ['label' => 'Clicks', 'value' => $totalClicks];
$metrics[] = ['label' => __('analytics.metrics.clicks'), 'value' => $totalClicks];
}
if ($totalLikes > 0) {
$metrics[] = ['label' => 'Likes', 'value' => $totalLikes];
$metrics[] = ['label' => __('analytics.metrics.likes'), 'value' => $totalLikes];
}
if ($totalComments > 0) {
$metrics[] = ['label' => 'Comments', 'value' => $totalComments];
$metrics[] = ['label' => __('analytics.metrics.comments'), 'value' => $totalComments];
}
if ($totalShares > 0) {
$metrics[] = ['label' => 'Shares', 'value' => $totalShares];
$metrics[] = ['label' => __('analytics.metrics.shares'), 'value' => $totalShares];
}
return $metrics;

View file

@ -39,9 +39,9 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
$status = $response->json();
return [
['label' => 'Favourites', 'value' => data_get($status, 'favourites_count', 0)],
['label' => 'Reblogs', 'value' => data_get($status, 'reblogs_count', 0)],
['label' => 'Replies', 'value' => data_get($status, 'replies_count', 0)],
['label' => __('analytics.metrics.favourites'), 'value' => data_get($status, 'favourites_count', 0)],
['label' => __('analytics.metrics.reblogs'), 'value' => data_get($status, 'reblogs_count', 0)],
['label' => __('analytics.metrics.replies'), 'value' => data_get($status, 'replies_count', 0)],
];
}
}

View file

@ -75,11 +75,11 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
$data = data_get($response->json(), 'all.summary_metrics', []);
$rows = [
'IMPRESSION' => 'Impressions',
'SAVE' => 'Saves',
'PIN_CLICK' => 'Pin clicks',
'OUTBOUND_CLICK' => 'Outbound clicks',
'VIDEO_MRC_VIEW' => 'Video views',
'IMPRESSION' => __('analytics.metrics.impressions'),
'SAVE' => __('analytics.metrics.saves'),
'PIN_CLICK' => __('analytics.metrics.pin_clicks'),
'OUTBOUND_CLICK' => __('analytics.metrics.outbound_clicks'),
'VIDEO_MRC_VIEW' => __('analytics.metrics.video_views'),
];
return collect($rows)
@ -148,11 +148,11 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
$avgClickRate = $count > 0 ? round($totals['PIN_CLICK_RATE'] / $count, 4) : 0;
return [
['label' => 'Impressions', 'value' => $totals['IMPRESSION']],
['label' => 'Pin Clicks', 'value' => $totals['PIN_CLICK']],
['label' => 'Engagement', 'value' => $totals['ENGAGEMENT']],
['label' => 'Saves', 'value' => $totals['SAVE']],
['label' => 'Pin Click Rate', 'value' => $avgClickRate],
['label' => __('analytics.metrics.impressions'), 'value' => $totals['IMPRESSION']],
['label' => __('analytics.metrics.pin_clicks'), 'value' => $totals['PIN_CLICK']],
['label' => __('analytics.metrics.engagement'), 'value' => $totals['ENGAGEMENT']],
['label' => __('analytics.metrics.saves'), 'value' => $totals['SAVE']],
['label' => __('analytics.metrics.pin_click_rate'), 'value' => $avgClickRate],
];
}

View file

@ -119,7 +119,14 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
$value = collect($values)->sum('value');
}
$label = ucfirst(str_replace('_', ' ', $name));
$label = match ($name) {
'views' => __('analytics.metrics.views'),
'likes' => __('analytics.metrics.likes'),
'replies' => __('analytics.metrics.replies'),
'reposts' => __('analytics.metrics.reposts'),
'quotes' => __('analytics.metrics.quotes'),
default => ucfirst(str_replace('_', ' ', $name)),
};
$metrics[] = ['label' => $label, 'value' => $value];
}

View file

@ -75,19 +75,19 @@ private function fetchUserStats(): array
$metrics = [];
if (($value = data_get($user, 'follower_count')) !== null) {
$metrics[] = ['label' => 'Followers', 'value' => $value];
$metrics[] = ['label' => __('analytics.metrics.followers'), 'value' => $value];
}
if (($value = data_get($user, 'following_count')) !== null) {
$metrics[] = ['label' => 'Following', 'value' => $value];
$metrics[] = ['label' => __('analytics.metrics.following'), 'value' => $value];
}
if (($value = data_get($user, 'likes_count')) !== null) {
$metrics[] = ['label' => 'Total Likes', 'value' => $value];
$metrics[] = ['label' => __('analytics.metrics.total_likes'), 'value' => $value];
}
if (($value = data_get($user, 'video_count')) !== null) {
$metrics[] = ['label' => 'Videos', 'value' => $value];
$metrics[] = ['label' => __('analytics.metrics.videos'), 'value' => $value];
}
return $metrics;
@ -148,10 +148,10 @@ private function fetchVideoMetrics(): array
}
return [
['label' => 'Views', 'value' => $totalViews],
['label' => 'Recent Likes', 'value' => $totalLikes],
['label' => 'Recent Comments', 'value' => $totalComments],
['label' => 'Recent Shares', 'value' => $totalShares],
['label' => __('analytics.metrics.views'), 'value' => $totalViews],
['label' => __('analytics.metrics.recent_likes'), 'value' => $totalLikes],
['label' => __('analytics.metrics.recent_comments'), 'value' => $totalComments],
['label' => __('analytics.metrics.recent_shares'), 'value' => $totalShares],
];
}

View file

@ -146,12 +146,12 @@ private function fetchTweetMetrics(array $tweetIds): array
}
return [
['label' => 'Impressions', 'value' => $totals['impression_count']],
['label' => 'Likes', 'value' => $totals['like_count']],
['label' => 'Retweets', 'value' => $totals['retweet_count']],
['label' => 'Replies', 'value' => $totals['reply_count']],
['label' => 'Quotes', 'value' => $totals['quote_count']],
['label' => 'Bookmarks', 'value' => $totals['bookmark_count']],
['label' => __('analytics.metrics.impressions'), 'value' => $totals['impression_count']],
['label' => __('analytics.metrics.likes'), 'value' => $totals['like_count']],
['label' => __('analytics.metrics.retweets'), 'value' => $totals['retweet_count']],
['label' => __('analytics.metrics.replies'), 'value' => $totals['reply_count']],
['label' => __('analytics.metrics.quotes'), 'value' => $totals['quote_count']],
['label' => __('analytics.metrics.bookmarks'), 'value' => $totals['bookmark_count']],
];
}
@ -186,12 +186,12 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
$metrics = data_get($response->json(), 'data.public_metrics', []);
return [
['label' => 'Impressions', 'value' => data_get($metrics, 'impression_count', 0)],
['label' => 'Likes', 'value' => data_get($metrics, 'like_count', 0)],
['label' => 'Retweets', 'value' => data_get($metrics, 'retweet_count', 0)],
['label' => 'Replies', 'value' => data_get($metrics, 'reply_count', 0)],
['label' => 'Quotes', 'value' => data_get($metrics, 'quote_count', 0)],
['label' => 'Bookmarks', 'value' => data_get($metrics, 'bookmark_count', 0)],
['label' => __('analytics.metrics.impressions'), 'value' => data_get($metrics, 'impression_count', 0)],
['label' => __('analytics.metrics.likes'), 'value' => data_get($metrics, 'like_count', 0)],
['label' => __('analytics.metrics.retweets'), 'value' => data_get($metrics, 'retweet_count', 0)],
['label' => __('analytics.metrics.replies'), 'value' => data_get($metrics, 'reply_count', 0)],
['label' => __('analytics.metrics.quotes'), 'value' => data_get($metrics, 'quote_count', 0)],
['label' => __('analytics.metrics.bookmarks'), 'value' => data_get($metrics, 'bookmark_count', 0)],
];
}

View file

@ -81,12 +81,12 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
$row = data_get($data, 'rows.0', []);
$labels = [
'views' => 'Views',
'estimatedMinutesWatched' => 'Minutes watched',
'averageViewDuration' => 'Avg view duration (s)',
'likes' => 'Likes',
'comments' => 'Comments',
'shares' => 'Shares',
'views' => __('analytics.metrics.views'),
'estimatedMinutesWatched' => __('analytics.metrics.minutes_watched'),
'averageViewDuration' => __('analytics.metrics.avg_view_duration'),
'likes' => __('analytics.metrics.likes'),
'comments' => __('analytics.metrics.comments'),
'shares' => __('analytics.metrics.shares'),
];
return collect($columns)
@ -141,13 +141,13 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
$value = data_get($values, $index, 0);
$label = match ($name) {
'views' => 'Views',
'estimatedMinutesWatched' => 'Minutes Watched',
'averageViewDuration' => 'Avg. View Duration (s)',
'averageViewPercentage' => 'Avg. View Percentage',
'subscribersGained' => 'Subscribers Gained',
'subscribersLost' => 'Subscribers Lost',
'likes' => 'Likes',
'views' => __('analytics.metrics.views'),
'estimatedMinutesWatched' => __('analytics.metrics.minutes_watched'),
'averageViewDuration' => __('analytics.metrics.avg_view_duration'),
'averageViewPercentage' => __('analytics.metrics.avg_view_percentage'),
'subscribersGained' => __('analytics.metrics.subscribers_gained'),
'subscribersLost' => __('analytics.metrics.subscribers_lost'),
'likes' => __('analytics.metrics.likes'),
default => ucfirst(str_replace('_', ' ', $name)),
};

View file

@ -1,7 +1,6 @@
<?php
use App\Http\Middleware\Api\LoadWorkspaceFromToken;
use App\Http\Middleware\App\HandleAppearance;
use App\Http\Middleware\App\HandleInertiaRequests;
use App\Http\Middleware\App\SetLocale;
use Illuminate\Foundation\Application;
@ -23,11 +22,10 @@
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: '*');
$middleware->encryptCookies(except: ['appearance', 'sidebar_state', 'locale']);
$middleware->encryptCookies(except: ['sidebar_state', 'locale']);
$middleware->web(append: [
SetLocale::class,
HandleAppearance::class,
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
]);

View file

@ -7,6 +7,7 @@
'add_social' => 'Add Social',
'add_social_title' => 'Connect a Social Account',
'add_social_description' => 'Connect a social account to TryPost to start posting',
'connect_cta' => 'Connect',
'no_accounts' => 'No accounts connected yet',
'no_accounts_description' => 'Connect your social networks to start scheduling and publishing posts',
'no_search_results' => 'No accounts match your search',

View file

@ -1,8 +1,52 @@
<?php
return [
'channels' => 'Channels',
'no_accounts' => 'No connected accounts with analytics.',
'no_accounts_match' => 'No accounts match.',
'search_account' => 'Search account…',
'select_account' => 'Select an account to view analytics.',
'no_data' => 'No analytics data available.',
'metrics' => [
'avg_view_duration' => 'Avg. View Duration (s)',
'avg_view_percentage' => 'Avg. View Percentage',
'bookmarks' => 'Bookmarks',
'clicks' => 'Clicks',
'comments' => 'Comments',
'engagement' => 'Engagement',
'favourites' => 'Favourites',
'followers' => 'Followers',
'following' => 'Following',
'impressions' => 'Impressions',
'interactions' => 'Interactions',
'likes' => 'Likes',
'minutes_watched' => 'Minutes Watched',
'organic_followers' => 'Organic Followers',
'outbound_clicks' => 'Outbound Clicks',
'page_followers' => 'Page Followers',
'page_reach' => 'Page Reach',
'page_views' => 'Page Views',
'paid_followers' => 'Paid Followers',
'pin_click_rate' => 'Pin Click Rate',
'pin_clicks' => 'Pin Clicks',
'posts_engagement' => 'Posts Engagement',
'posts_reach' => 'Posts Reach',
'quotes' => 'Quotes',
'reach' => 'Reach',
'reblogs' => 'Reblogs',
'recent_comments' => 'Recent Comments',
'recent_likes' => 'Recent Likes',
'recent_shares' => 'Recent Shares',
'replies' => 'Replies',
'reposts' => 'Reposts',
'retweets' => 'Retweets',
'saves' => 'Saves',
'shares' => 'Shares',
'subscribers_gained' => 'Subscribers Gained',
'subscribers_lost' => 'Subscribers Lost',
'total_likes' => 'Total Likes',
'video_views' => 'Video Views',
'videos' => 'Videos',
'views' => 'Views',
],
];

View file

@ -24,25 +24,26 @@
'subscribe' => [
'page_title' => 'Choose your plan',
'eyebrow' => 'Pricing',
'title' => 'Choose the right plan for you',
'description' => 'Start with a 7-day free trial. No charge until your trial ends.',
'trial_info' => '7-day free trial, then billed automatically',
'monthly' => 'Monthly',
'yearly' => 'Yearly',
'per_month' => 'mo',
'per_year' => 'yr',
'billed_monthly' => 'billed monthly',
'billed_yearly' => 'billed yearly',
'per_month' => 'monthly',
'per_year' => 'yearly',
'billed_monthly' => 'Billed monthly',
'billed_yearly' => 'Billed annually',
'features_included' => "What's included:",
'everything_in' => 'Everything in :plan, plus:',
'save_months' => '2 months free',
'popular' => 'Most popular',
'start_trial' => 'Start 7-day free trial',
'card_required' => 'Credit card required to start your trial.',
'cancel_anytime' => 'No charges will be made before the trial period ends.',
'prices' => [
'starter' => ['monthly' => '$19', 'yearly' => '$190'],
'plus' => ['monthly' => '$29', 'yearly' => '$290'],
'pro' => ['monthly' => '$49', 'yearly' => '$490'],
'max' => ['monthly' => '$99', 'yearly' => '$990'],
'starter' => ['monthly' => '$19', 'yearly_per_month' => '$16', 'yearly' => '$190'],
'plus' => ['monthly' => '$29', 'yearly_per_month' => '$24', 'yearly' => '$290'],
'pro' => ['monthly' => '$49', 'yearly_per_month' => '$41', 'yearly' => '$490'],
'max' => ['monthly' => '$99', 'yearly_per_month' => '$83', 'yearly' => '$990'],
],
'features' => [
'social_accounts' => ':count social accounts',
@ -50,6 +51,12 @@
'members' => ':count team members',
'credits' => ':count AI credits/mo',
],
'credit_tooltips' => [
'starter' => 'Roughly 150 medium-length posts plus 5 AI images per month.',
'plus' => 'Roughly 300 medium-length posts plus 10 AI images per month.',
'pro' => 'Roughly 700 medium-length posts plus 30 AI images per month.',
'max' => 'Roughly 2,000 medium-length posts plus 100 AI images per month.',
],
],
'plan' => [
@ -70,6 +77,8 @@
'title' => 'Subscription',
'description' => 'Manage your payment method, billing details, and subscription.',
'payment_method' => 'Payment method',
'no_payment_method' => 'No payment method on file yet.',
'expires_on' => 'Expires :month/:year',
'manage_label' => 'Subscription',
'manage_stripe' => 'Manage on Stripe',
],

View file

@ -13,4 +13,6 @@
'replying_to' => 'Replying to :name',
'empty' => 'No comments yet. Start the conversation.',
'load_more' => 'Load older comments',
'today' => 'Today',
'yesterday' => 'Yesterday',
];

View file

@ -27,7 +27,23 @@
'select' => 'Select date',
],
'date_range_picker' => [
'placeholder' => 'Pick a date range',
'today' => 'Today',
'yesterday' => 'Yesterday',
'last_7_days' => 'Last 7 days',
'last_30_days' => 'Last 30 days',
'last_3_months' => 'Last 3 months',
'last_6_months' => 'Last 6 months',
'last_12_months' => 'Last 12 months',
'this_month' => 'This month',
'last_month' => 'Last month',
'year_to_date' => 'Year to date',
'last_year' => 'Last year',
],
'cancel' => 'Cancel',
'clear' => 'Clear',
'close' => 'Close',
'loading_more' => 'Loading more...',
];

View file

@ -14,6 +14,11 @@
'created_at' => 'Created',
],
'actions' => [
'edit' => 'Edit label',
'delete' => 'Delete label',
],
'create' => [
'title' => 'Create Label',
'description' => 'Give your label a name and pick a color',

View file

@ -10,6 +10,7 @@
'try_different_search' => 'Try a different keyword or clear the search.',
'start_creating' => 'Start by creating your first post.',
'table' => [
'post' => 'Post',
'status' => 'Status',
'content' => 'Content',
'platforms' => 'Platforms',
@ -199,6 +200,7 @@
'show' => [
'title' => 'Post Details',
'edit' => 'Edit',
'back' => 'Back',
'no_content' => 'No caption',
'platforms' => 'Platforms',
'no_platforms' => 'No platforms selected.',
@ -211,11 +213,6 @@
'metrics_loading' => 'Loading metrics…',
'metrics_unavailable' => 'Metrics unavailable for this platform yet.',
'metrics_empty' => 'No metrics returned.',
'summary' => [
'platforms' => 'Platforms',
'published' => 'Published',
'failed' => 'Failed',
],
],
'edit' => [
@ -230,8 +227,9 @@
'cancel' => 'Cancel',
'delete' => 'Delete',
'schedule_for' => 'Schedule for',
'scheduled_for' => 'Scheduled for',
'scheduled_for' => 'Scheduled · :date',
'schedule_date' => 'Schedule date',
'unschedule' => 'Unschedule',
'saving' => 'Saving...',
'saved' => 'Saved',
'draft' => 'Draft',
@ -305,6 +303,7 @@
],
'status' => [
'scheduled' => 'Scheduled',
'published' => 'Published',
'publishing' => 'Publishing...',
'failed' => 'Failed',
@ -539,6 +538,8 @@
'try_different_search' => 'Try a different keyword or clear the search.',
'slides_count' => '{count} slide|{count} slides',
'all_platforms' => 'All platforms',
'platform_search_placeholder' => 'Search platform…',
'no_platform_match' => 'No platform matches.',
'use_this' => 'Use this template',
'no_templates' => 'No templates available.',
'applying' => 'Applying template…',

View file

@ -15,6 +15,11 @@
'created_at' => 'Created',
],
'actions' => [
'edit' => 'Edit signature',
'delete' => 'Delete signature',
],
'create' => [
'title' => 'Create signature',
'description' => 'Give your signature a name and the content to append (hashtags, links, custom text — anything you reuse).',

View file

@ -49,8 +49,7 @@
'background_color' => 'Background color',
'text_color' => 'Text color',
'submit' => 'Create workspace',
'first_workspace_success' => 'Workspace created. Connect a social account to start posting.',
'success' => 'Workspace created.',
'success' => 'Workspace created. Connect a social account to start posting.',
],
'limit_reached' => 'You have reached your plan limit for workspaces.',

View file

@ -7,6 +7,7 @@
'add_social' => 'Agregar Red Social',
'add_social_title' => 'Conectar una Cuenta Social',
'add_social_description' => 'Conecta una cuenta social a TryPost para empezar a publicar',
'connect_cta' => 'Conectar',
'no_accounts' => 'No hay cuentas conectadas todavía',
'no_accounts_description' => 'Conecta tus redes sociales para empezar a programar y publicar posts',
'no_search_results' => 'Ninguna cuenta coincide con tu búsqueda',

View file

@ -1,8 +1,52 @@
<?php
return [
'channels' => 'Canales',
'no_accounts' => 'No hay cuentas conectadas con analytics.',
'no_accounts_match' => 'Ninguna cuenta coincide.',
'search_account' => 'Buscar cuenta…',
'select_account' => 'Selecciona una cuenta para ver analytics.',
'no_data' => 'No hay datos de analytics disponibles.',
'metrics' => [
'avg_view_duration' => 'Duración Media (s)',
'avg_view_percentage' => 'Porcentaje Medio de Visualización',
'bookmarks' => 'Guardados',
'clicks' => 'Clics',
'comments' => 'Comentarios',
'engagement' => 'Engagement',
'favourites' => 'Favoritos',
'followers' => 'Seguidores',
'following' => 'Siguiendo',
'impressions' => 'Impresiones',
'interactions' => 'Interacciones',
'likes' => 'Me gusta',
'minutes_watched' => 'Minutos Vistos',
'organic_followers' => 'Seguidores Orgánicos',
'outbound_clicks' => 'Clics Externos',
'page_followers' => 'Seguidores de la Página',
'page_reach' => 'Alcance de la Página',
'page_views' => 'Vistas de la Página',
'paid_followers' => 'Seguidores Pagados',
'pin_click_rate' => 'Tasa de Clics en Pines',
'pin_clicks' => 'Clics en Pines',
'posts_engagement' => 'Engagement de Publicaciones',
'posts_reach' => 'Alcance de Publicaciones',
'quotes' => 'Citas',
'reach' => 'Alcance',
'reblogs' => 'Reblogs',
'recent_comments' => 'Comentarios Recientes',
'recent_likes' => 'Me Gusta Recientes',
'recent_shares' => 'Compartidos Recientes',
'replies' => 'Respuestas',
'reposts' => 'Reposts',
'retweets' => 'Retweets',
'saves' => 'Guardados',
'shares' => 'Compartidos',
'subscribers_gained' => 'Suscriptores Ganados',
'subscribers_lost' => 'Suscriptores Perdidos',
'total_likes' => 'Total de Me gusta',
'video_views' => 'Vistas de Vídeo',
'videos' => 'Vídeos',
'views' => 'Vistas',
],
];

View file

@ -24,25 +24,26 @@
'subscribe' => [
'page_title' => 'Elige tu plan',
'eyebrow' => 'Precios',
'title' => 'Elige el plan ideal para ti',
'description' => 'Comienza con 7 días gratis. Sin cargo hasta que termine tu prueba.',
'trial_info' => 'Prueba gratuita de 7 días, luego se cobra automáticamente',
'monthly' => 'Mensual',
'yearly' => 'Anual',
'per_month' => 'mes',
'per_year' => 'año',
'billed_monthly' => 'facturado mensualmente',
'billed_yearly' => 'facturado anualmente',
'per_month' => 'mensual',
'per_year' => 'anual',
'billed_monthly' => 'Facturado mensualmente',
'billed_yearly' => 'Facturado anualmente',
'features_included' => 'Qué incluye:',
'everything_in' => 'Todo lo de :plan, más:',
'save_months' => '2 meses gratis',
'popular' => 'Más popular',
'start_trial' => 'Comenzar prueba de 7 días',
'card_required' => 'Tarjeta de crédito requerida para iniciar tu prueba.',
'cancel_anytime' => 'No se realizará ningún cobro antes de que termine el período de prueba.',
'prices' => [
'starter' => ['monthly' => '$19', 'yearly' => '$190'],
'plus' => ['monthly' => '$29', 'yearly' => '$290'],
'pro' => ['monthly' => '$49', 'yearly' => '$490'],
'max' => ['monthly' => '$99', 'yearly' => '$990'],
'starter' => ['monthly' => '$19', 'yearly_per_month' => '$16', 'yearly' => '$190'],
'plus' => ['monthly' => '$29', 'yearly_per_month' => '$24', 'yearly' => '$290'],
'pro' => ['monthly' => '$49', 'yearly_per_month' => '$41', 'yearly' => '$490'],
'max' => ['monthly' => '$99', 'yearly_per_month' => '$83', 'yearly' => '$990'],
],
'features' => [
'social_accounts' => ':count cuentas sociales',
@ -50,6 +51,12 @@
'members' => ':count miembros del equipo',
'credits' => ':count créditos IA/mes',
],
'credit_tooltips' => [
'starter' => 'En promedio 150 posts de largo medio + 5 imágenes IA por mes.',
'plus' => 'En promedio 300 posts de largo medio + 10 imágenes IA por mes.',
'pro' => 'En promedio 700 posts de largo medio + 30 imágenes IA por mes.',
'max' => 'En promedio 2.000 posts de largo medio + 100 imágenes IA por mes.',
],
],
'plan' => [
@ -70,6 +77,8 @@
'title' => 'Suscripción',
'description' => 'Gestiona tu método de pago, datos de facturación y suscripción.',
'payment_method' => 'Método de pago',
'no_payment_method' => 'Aún no hay método de pago registrado.',
'expires_on' => 'Vence el :month/:year',
'manage_label' => 'Suscripción',
'manage_stripe' => 'Gestionar en Stripe',
],

View file

@ -15,4 +15,6 @@
'replying_to' => 'Respondiendo a :name',
'empty' => 'Todavía no hay comentarios. Inicia la conversación.',
'load_more' => 'Cargar comentarios anteriores',
'today' => 'Hoy',
'yesterday' => 'Ayer',
];

View file

@ -27,7 +27,23 @@
'select' => 'Seleccionar fecha',
],
'date_range_picker' => [
'placeholder' => 'Elige un período',
'today' => 'Hoy',
'yesterday' => 'Ayer',
'last_7_days' => 'Últimos 7 días',
'last_30_days' => 'Últimos 30 días',
'last_3_months' => 'Últimos 3 meses',
'last_6_months' => 'Últimos 6 meses',
'last_12_months' => 'Últimos 12 meses',
'this_month' => 'Este mes',
'last_month' => 'Mes pasado',
'year_to_date' => 'Desde inicio del año',
'last_year' => 'Año pasado',
],
'cancel' => 'Cancelar',
'clear' => 'Limpiar',
'close' => 'Cerrar',
'loading_more' => 'Cargando más...',
];

View file

@ -14,6 +14,11 @@
'created_at' => 'Creado',
],
'actions' => [
'edit' => 'Editar etiqueta',
'delete' => 'Eliminar etiqueta',
],
'create' => [
'title' => 'Crear etiqueta',
'description' => 'Dale un nombre y elige un color para tu etiqueta',

View file

@ -10,6 +10,7 @@
'try_different_search' => 'Prueba otra palabra clave o limpia la búsqueda.',
'start_creating' => 'Empieza creando tu primer post.',
'table' => [
'post' => 'Post',
'status' => 'Estado',
'content' => 'Contenido',
'platforms' => 'Plataformas',
@ -199,6 +200,7 @@
'show' => [
'title' => 'Detalles del post',
'edit' => 'Editar',
'back' => 'Volver',
'no_content' => 'Sin texto',
'platforms' => 'Plataformas',
'no_platforms' => 'Ninguna plataforma seleccionada.',
@ -211,11 +213,6 @@
'metrics_loading' => 'Cargando métricas…',
'metrics_unavailable' => 'Métricas aún no disponibles para esta plataforma.',
'metrics_empty' => 'No se devolvieron métricas.',
'summary' => [
'platforms' => 'Plataformas',
'published' => 'Publicado',
'failed' => 'Falló',
],
],
'edit' => [
@ -226,7 +223,8 @@
'schedule' => 'Programar',
'delete' => 'Eliminar',
'schedule_for' => 'Programar para',
'scheduled_for' => 'Programado para',
'scheduled_for' => 'Programado · :date',
'unschedule' => 'Desprogramar',
'saving' => 'Guardando...',
'saved' => 'Guardado',
'draft' => 'Borrador',
@ -305,6 +303,7 @@
],
'status' => [
'scheduled' => 'Programado',
'published' => 'Publicado',
'publishing' => 'Publicando...',
'failed' => 'Fallido',
@ -540,6 +539,8 @@
'try_different_search' => 'Prueba otra palabra clave o limpia la búsqueda.',
'slides_count' => '{count} slide|{count} slides',
'all_platforms' => 'Todas las plataformas',
'platform_search_placeholder' => 'Buscar plataforma…',
'no_platform_match' => 'Ninguna plataforma coincide.',
'use_this' => 'Usar esta plantilla',
'no_templates' => 'No hay plantillas disponibles.',
'applying' => 'Aplicando plantilla…',

View file

@ -15,6 +15,11 @@
'created_at' => 'Creado',
],
'actions' => [
'edit' => 'Editar firma',
'delete' => 'Eliminar firma',
],
'create' => [
'title' => 'Crear firma',
'description' => 'Dale un nombre a tu firma y el contenido para añadir (hashtags, links, texto libre — lo que reutilizas).',

View file

@ -49,8 +49,7 @@
'background_color' => 'Color de fondo',
'text_color' => 'Color de texto',
'submit' => 'Crear workspace',
'first_workspace_success' => 'Workspace creado. Conecta una cuenta social para empezar a publicar.',
'success' => 'Workspace creado.',
'success' => 'Workspace creado. Conecta una cuenta social para empezar a publicar.',
],
'limit_reached' => 'Has alcanzado el límite de workspaces de tu plan.',

1
lang/php_en.json Normal file

File diff suppressed because one or more lines are too long

1
lang/php_es.json Normal file

File diff suppressed because one or more lines are too long

1
lang/php_pt-BR.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -7,6 +7,7 @@
'add_social' => 'Adicionar Rede Social',
'add_social_title' => 'Conectar uma Conta Social',
'add_social_description' => 'Conecte uma conta social ao TryPost para começar a publicar',
'connect_cta' => 'Conectar',
'no_accounts' => 'Nenhuma conta conectada ainda',
'no_accounts_description' => 'Conecte suas redes sociais para começar a agendar e publicar posts',
'no_search_results' => 'Nenhuma conta corresponde à sua busca',

View file

@ -1,8 +1,52 @@
<?php
return [
'channels' => 'Canais',
'no_accounts' => 'Nenhuma conta conectada com analytics.',
'no_accounts_match' => 'Nenhuma conta corresponde.',
'search_account' => 'Buscar conta…',
'select_account' => 'Selecione uma conta para ver analytics.',
'no_data' => 'Nenhum dado de analytics disponível.',
'metrics' => [
'avg_view_duration' => 'Duração Média (s)',
'avg_view_percentage' => 'Visualização Média',
'bookmarks' => 'Salvos',
'clicks' => 'Cliques',
'comments' => 'Comentários',
'engagement' => 'Engajamento',
'favourites' => 'Favoritos',
'followers' => 'Seguidores',
'following' => 'Seguindo',
'impressions' => 'Impressões',
'interactions' => 'Interações',
'likes' => 'Curtidas',
'minutes_watched' => 'Minutos Assistidos',
'organic_followers' => 'Seguidores Orgânicos',
'outbound_clicks' => 'Cliques Externos',
'page_followers' => 'Seguidores da Página',
'page_reach' => 'Alcance da Página',
'page_views' => 'Visualizações da Página',
'paid_followers' => 'Seguidores Pagos',
'pin_click_rate' => 'Taxa de Clique em Pins',
'pin_clicks' => 'Cliques em Pins',
'posts_engagement' => 'Engajamento dos Posts',
'posts_reach' => 'Alcance dos Posts',
'quotes' => 'Citações',
'reach' => 'Alcance',
'reblogs' => 'Reblogs',
'recent_comments' => 'Comentários Recentes',
'recent_likes' => 'Curtidas Recentes',
'recent_shares' => 'Compartilhamentos Recentes',
'replies' => 'Respostas',
'reposts' => 'Reposts',
'retweets' => 'Retweets',
'saves' => 'Salvos',
'shares' => 'Compartilhamentos',
'subscribers_gained' => 'Inscritos Ganhos',
'subscribers_lost' => 'Inscritos Perdidos',
'total_likes' => 'Curtidas Totais',
'video_views' => 'Visualizações de Vídeo',
'videos' => 'Vídeos',
'views' => 'Visualizações',
],
];

View file

@ -24,25 +24,26 @@
'subscribe' => [
'page_title' => 'Escolha seu plano',
'eyebrow' => 'Preços',
'title' => 'Escolha o plano ideal pra você',
'description' => 'Comece com 7 dias grátis. Sem cobrança até o fim do seu teste.',
'trial_info' => '7 dias grátis, depois cobrança automática',
'monthly' => 'Mensal',
'yearly' => 'Anual',
'per_month' => 'mês',
'per_year' => 'ano',
'billed_monthly' => 'cobrança mensal',
'billed_yearly' => 'cobrança anual',
'per_month' => 'mensal',
'per_year' => 'anual',
'billed_monthly' => 'Cobrança mensal',
'billed_yearly' => 'Cobrança anual',
'features_included' => 'O que está incluído:',
'everything_in' => 'Tudo do :plan, mais:',
'save_months' => '2 meses grátis',
'popular' => 'Mais popular',
'start_trial' => 'Iniciar teste de 7 dias',
'card_required' => 'Cartão de crédito necessário para iniciar o teste.',
'cancel_anytime' => 'Nenhuma cobrança será realizada antes do fim do período de testes.',
'prices' => [
'starter' => ['monthly' => 'R$ 95', 'yearly' => 'R$ 950'],
'plus' => ['monthly' => 'R$ 145', 'yearly' => 'R$ 1450'],
'pro' => ['monthly' => 'R$ 245', 'yearly' => 'R$ 2450'],
'max' => ['monthly' => 'R$ 495', 'yearly' => 'R$ 4950'],
'starter' => ['monthly' => 'R$ 95', 'yearly_per_month' => 'R$ 79', 'yearly' => 'R$ 950'],
'plus' => ['monthly' => 'R$ 145', 'yearly_per_month' => 'R$ 121', 'yearly' => 'R$ 1450'],
'pro' => ['monthly' => 'R$ 245', 'yearly_per_month' => 'R$ 204', 'yearly' => 'R$ 2450'],
'max' => ['monthly' => 'R$ 495', 'yearly_per_month' => 'R$ 413', 'yearly' => 'R$ 4950'],
],
'features' => [
'social_accounts' => ':count contas sociais',
@ -50,6 +51,12 @@
'members' => ':count membros da equipe',
'credits' => ':count créditos IA/mês',
],
'credit_tooltips' => [
'starter' => 'Em média 150 posts de tamanho médio + 5 imagens de IA por mês.',
'plus' => 'Em média 300 posts de tamanho médio + 10 imagens de IA por mês.',
'pro' => 'Em média 700 posts de tamanho médio + 30 imagens de IA por mês.',
'max' => 'Em média 2.000 posts de tamanho médio + 100 imagens de IA por mês.',
],
],
'plan' => [
@ -70,6 +77,8 @@
'title' => 'Assinatura',
'description' => 'Gerencie seu método de pagamento, dados de cobrança e assinatura.',
'payment_method' => 'Método de pagamento',
'no_payment_method' => 'Nenhum método de pagamento cadastrado.',
'expires_on' => 'Expira em :month/:year',
'manage_label' => 'Assinatura',
'manage_stripe' => 'Gerenciar no Stripe',
],

View file

@ -15,4 +15,6 @@
'replying_to' => 'Respondendo a :name',
'empty' => 'Nenhum comentário ainda. Comece a conversa.',
'load_more' => 'Carregar comentários antigos',
'today' => 'Hoje',
'yesterday' => 'Ontem',
];

View file

@ -27,7 +27,23 @@
'select' => 'Selecionar data',
],
'date_range_picker' => [
'placeholder' => 'Escolha um período',
'today' => 'Hoje',
'yesterday' => 'Ontem',
'last_7_days' => 'Últimos 7 dias',
'last_30_days' => 'Últimos 30 dias',
'last_3_months' => 'Últimos 3 meses',
'last_6_months' => 'Últimos 6 meses',
'last_12_months' => 'Últimos 12 meses',
'this_month' => 'Este mês',
'last_month' => 'Mês passado',
'year_to_date' => 'Desde o início do ano',
'last_year' => 'Ano passado',
],
'cancel' => 'Cancelar',
'clear' => 'Limpar',
'close' => 'Fechar',
'loading_more' => 'Carregando mais...',
];

View file

@ -14,6 +14,11 @@
'created_at' => 'Criado',
],
'actions' => [
'edit' => 'Editar etiqueta',
'delete' => 'Excluir etiqueta',
],
'create' => [
'title' => 'Criar Etiqueta',
'description' => 'Dê um nome e escolha uma cor para sua etiqueta',

View file

@ -10,6 +10,7 @@
'try_different_search' => 'Tente outra palavra-chave ou limpe a busca.',
'start_creating' => 'Comece criando seu primeiro post.',
'table' => [
'post' => 'Post',
'status' => 'Status',
'content' => 'Conteúdo',
'platforms' => 'Plataformas',
@ -199,6 +200,7 @@
'show' => [
'title' => 'Detalhes do Post',
'edit' => 'Editar',
'back' => 'Voltar',
'no_content' => 'Sem legenda',
'platforms' => 'Plataformas',
'no_platforms' => 'Nenhuma plataforma selecionada.',
@ -211,11 +213,6 @@
'metrics_loading' => 'Carregando métricas…',
'metrics_unavailable' => 'Métricas ainda não disponíveis para esta plataforma.',
'metrics_empty' => 'Nenhuma métrica retornada.',
'summary' => [
'platforms' => 'Plataformas',
'published' => 'Publicado',
'failed' => 'Falhou',
],
],
'edit' => [
@ -226,7 +223,8 @@
'schedule' => 'Agendar',
'delete' => 'Excluir',
'schedule_for' => 'Agendar para',
'scheduled_for' => 'Agendado para',
'scheduled_for' => 'Agendado · :date',
'unschedule' => 'Desagendar',
'saving' => 'Salvando...',
'saved' => 'Salvo',
'draft' => 'Rascunho',
@ -305,6 +303,7 @@
],
'status' => [
'scheduled' => 'Agendado',
'published' => 'Publicado',
'publishing' => 'Publicando...',
'failed' => 'Falhou',
@ -535,6 +534,8 @@
'browser_title' => 'Escolha um template',
'browser_description' => 'Comece com um template pronto e adapte ao seu jeito.',
'all_platforms' => 'Todas as plataformas',
'platform_search_placeholder' => 'Buscar plataforma…',
'no_platform_match' => 'Nenhuma plataforma corresponde.',
'use_this' => 'Usar este template',
'no_templates' => 'Nenhum template disponível.',
'applying' => 'Aplicando template…',

View file

@ -15,6 +15,11 @@
'created_at' => 'Criado em',
],
'actions' => [
'edit' => 'Editar assinatura',
'delete' => 'Excluir assinatura',
],
'create' => [
'title' => 'Criar assinatura',
'description' => 'Dê um nome à sua assinatura e o conteúdo pra anexar (hashtags, links, texto livre — o que você reutiliza).',

View file

@ -49,8 +49,7 @@
'background_color' => 'Cor de fundo',
'text_color' => 'Cor do texto',
'submit' => 'Criar workspace',
'first_workspace_success' => 'Workspace criado. Conecte uma conta social para começar a postar.',
'success' => 'Workspace criado.',
'success' => 'Workspace criado. Conecte uma conta social para começar a postar.',
],
'limit_reached' => 'Você atingiu o limite de workspaces do seu plano.',

View file

@ -4,147 +4,79 @@
@plugin '@tailwindcss/typography';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@custom-variant dark (&:is(.dark *));
/* Dark mode is intentionally disabled to match the marketing site
(which is light-only). Tailwind v4 defaults `dark:` to the
`prefers-color-scheme: dark` media query, so without this override
the OS theme would still flip preview internals to dark. Pin the
variant to a `.dark` class that we never apply. */
@custom-variant dark (&:where(.dark, .dark *));
:root {
--background: #fafafa;
/* TryPost design system — warm cream + ink + signature violet.
Mirrors the marketing site (~/Herd/trypost-site) so the app and the
site share one identity. All structural borders are ink-black. */
--background: #faf8f5;
--foreground: #0a0a0a;
--card: #ffffff;
--card-foreground: #0a0a0a;
--popover: #ffffff;
--popover-foreground: #0a0a0a;
--primary: #171717;
--primary-foreground: #fafafa;
--secondary: #e5e5e5;
--secondary-foreground: #171717;
--muted: #f5f5f5;
--muted-foreground: #737373;
--accent: #e5e5e5;
--accent-foreground: #171717;
--destructive: #dc2626;
--destructive-foreground: #fafafa;
--primary: #7c3aed;
--primary-foreground: #ffffff;
--secondary: #f4f4f0;
--secondary-foreground: #0a0a0a;
--muted: #f4f4f0;
--muted-foreground: #52525b;
--accent: #ede9fe;
--accent-foreground: #5b21b6;
--destructive: #e54b4f;
--destructive-foreground: #ffffff;
--success: #16a34a;
--error: #dc2626;
--error: #e54b4f;
--warning: #d97706;
--info: #2563eb;
--border: #e5e5e5;
--input: #d4d4d4;
--ring: #525252;
--chart-1: #1f3fad;
--chart-2: #1a4eda;
--chart-3: #2563ef;
--chart-4: #3a81f6;
--chart-5: #91c5ff;
--sidebar: #f5f5f5;
--border: #0a0a0a;
--input: #0a0a0a;
--ring: #7c3aed;
--chart-1: #7c3aed;
--chart-2: #8b5cf6;
--chart-3: #a78bfa;
--chart-4: #5b21b6;
--chart-5: #4c1d95;
--sidebar: #ffffff;
--sidebar-foreground: #0a0a0a;
--sidebar-primary: #1447e6;
--sidebar-primary-foreground: #fafafa;
--sidebar-accent: #e5e5e5;
--sidebar-accent-foreground: #171717;
--sidebar-border: #e5e5e5;
--sidebar-ring: #a3a3a3;
--font-sans:
'Inter', ui-sans-serif, system-ui, -apple-system,
--sidebar-primary: #7c3aed;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #ede9fe;
--sidebar-accent-foreground: #5b21b6;
--sidebar-border: #0a0a0a;
--sidebar-ring: #7c3aed;
--font-sans: Figtree, ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
--radius: 0.725rem;
--shadow-x: 0;
--shadow-y: 1px;
--shadow-blur: 2.5px;
--shadow-spread: 0px;
--shadow-opacity: 0.04;
--shadow-color: oklch(0.3065 0.0243 243.82);
--shadow-2xs: 0 1px 2.5px 0px hsl(0 0% 0% / 0.02);
--shadow-xs: 0 1px 2.5px 0px hsl(0 0% 0% / 0.02);
--shadow-sm:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04);
--shadow:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04);
--shadow-md:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 2px 4px -1px hsl(0 0% 0% / 0.04);
--shadow-lg:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 4px 6px -1px hsl(0 0% 0% / 0.04);
--shadow-xl:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 8px 10px -1px hsl(0 0% 0% / 0.04);
--shadow-2xl: 0 1px 2.5px 0px hsl(0 0% 0% / 0.1);
--font-serif: 'Instrument Serif', ui-serif, Georgia, Cambria,
'Times New Roman', Times, serif;
--font-display: 'Instrument Serif', ui-serif, Georgia, Cambria,
'Times New Roman', Times, serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco,
Consolas, 'Liberation Mono', 'Courier New', monospace;
--radius: 0.75rem;
/* Gumroad-style offset shadows: solid ink, no blur. */
--shadow-2xs: 1px 1px 0 0 #0a0a0a;
--shadow-xs: 2px 2px 0 0 #0a0a0a;
--shadow-sm: 3px 3px 0 0 #0a0a0a;
--shadow: 4px 4px 0 0 #0a0a0a;
--shadow-md: 5px 5px 0 0 #0a0a0a;
--shadow-lg: 6px 6px 0 0 #0a0a0a;
--shadow-xl: 8px 8px 0 0 #0a0a0a;
--shadow-2xl: 10px 10px 0 0 #0a0a0a;
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: #0a0a0a;
--foreground: #fafafa;
--card: #171717;
--card-foreground: #fafafa;
--popover: #262626;
--popover-foreground: #fafafa;
--primary: #e5e5e5;
--primary-foreground: #171717;
--secondary: #262626;
--secondary-foreground: #fafafa;
--muted: #262626;
--muted-foreground: #a1a1a1;
--accent: #404040;
--accent-foreground: #fafafa;
--destructive: #ff6467;
--destructive-foreground: #fafafa;
--success: #16a34a;
--error: #dc2626;
--warning: #d97706;
--info: #2563eb;
--border: #333333;
--input: #343434;
--ring: #737373;
--chart-1: #91c5ff;
--chart-2: #3a81f6;
--chart-3: #2563ef;
--chart-4: #1a4eda;
--chart-5: #1f3fad;
--sidebar: #171717;
--sidebar-foreground: #fafafa;
--sidebar-primary: #1447e6;
--sidebar-primary-foreground: #fafafa;
--sidebar-accent: #262626;
--sidebar-accent-foreground: #fafafa;
--sidebar-border: #282828;
--sidebar-ring: #525252;
--font-sans:
'Inter', ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
--radius: 0.725rem;
--shadow-x: 0;
--shadow-y: 1px;
--shadow-blur: 2.5px;
--shadow-spread: 0px;
--shadow-opacity: 0.04;
--shadow-color: oklch(0 0 0);
--shadow-2xs: 0 1px 2.5px 0px hsl(0 0% 0% / 0.02);
--shadow-xs: 0 1px 2.5px 0px hsl(0 0% 0% / 0.02);
--shadow-sm:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04);
--shadow:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04);
--shadow-md:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 2px 4px -1px hsl(0 0% 0% / 0.04);
--shadow-lg:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 4px 6px -1px hsl(0 0% 0% / 0.04);
--shadow-xl:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 8px 10px -1px hsl(0 0% 0% / 0.04);
--shadow-2xl: 0 1px 2.5px 0px hsl(0 0% 0% / 0.1);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
@ -182,6 +114,7 @@ @theme inline {
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--font-display: var(--font-display);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
@ -205,5 +138,29 @@ @layer base {
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
}
}
/* Site headline + container utility classes — mirrors trypost-site's
`assets/css/utility-patterns.css`. Use `.h1`/.h2`/.h3` for serif
display headlines (Instrument Serif) instead of stacking Tailwind
text-* utilities. */
.h1 {
font-family: var(--font-display);
@apply text-4xl sm:text-5xl lg:text-7xl xl:text-[5.5rem] font-normal tracking-tight leading-[1.05];
}
.h2 {
font-family: var(--font-display);
@apply text-pretty text-3xl font-normal tracking-tight leading-[1.1] sm:text-5xl;
}
.h3 {
font-family: var(--font-display);
@apply text-pretty text-2xl font-normal tracking-tight sm:text-4xl;
}
.trypost-container {
@apply relative max-w-7xl w-full flex-1 mx-auto flex flex-col justify-center;
}

View file

@ -7,7 +7,6 @@ import { i18nVue } from 'laravel-vue-i18n';
import type { DefineComponent } from 'vue';
import { createApp, h } from 'vue';
import { initializeTheme } from './composables/useAppearance';
import { initializeDataLayer } from './datalayer';
import dayjs from './dayjs';
import posthog from './posthog';
@ -79,6 +78,3 @@ createInertiaApp({
color: '#4B5563',
},
});
// This will set light / dark mode on page load...
initializeTheme();

View file

@ -1,30 +1,14 @@
<script setup lang="ts">
import Breadcrumbs from '@/components/Breadcrumbs.vue';
import { SidebarTrigger } from '@/components/ui/sidebar';
import type { BreadcrumbItem } from '@/types';
withDefaults(
defineProps<{
title?: string;
breadcrumbs?: BreadcrumbItem[];
}>(),
{
title: '',
breadcrumbs: () => [],
},
);
</script>
<template>
<header
class="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-border px-4"
class="flex h-14 shrink-0 items-center justify-between gap-2 border-b-2 border-foreground bg-card px-4"
>
<div class="flex items-center gap-2">
<SidebarTrigger class="-ml-1" />
<slot name="left">
<Breadcrumbs v-if="breadcrumbs.length > 0" :breadcrumbs="breadcrumbs" />
<h2 v-else-if="title" class="text-lg font-semibold">{{ title }}</h2>
</slot>
<slot name="left" />
</div>
<div class="flex items-center gap-2">
<slot name="right" />

View file

@ -39,8 +39,8 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { useActiveUrl } from '@/composables/useActiveUrl';
import { useFeatureAccess } from '@/composables/useFeatureAccess';
import { useUpgradeDialog } from '@/composables/useUpgradeDialog';
import { accounts, analytics, calendar, settings as settingsHub } from '@/routes/app';
@ -60,8 +60,6 @@ const page = usePage();
const currentWorkspace = computed<Workspace | null>(() => page.props.auth.currentWorkspace as Workspace | null);
const workspaces = computed<Workspace[]>(() => page.props.auth.workspaces as Workspace[]);
const { state: sidebarState } = useSidebar();
const mainNavItems = computed<NavItem[]>(() => [
{
title: trans('sidebar.posts.calendar'),
@ -130,6 +128,7 @@ const switchWorkspace = (workspaceId: string) => {
const { canCreateWorkspace } = useFeatureAccess();
const { openUpgrade } = useUpgradeDialog();
const { urlIsActive } = useActiveUrl();
const handleCreateWorkspace = () => {
if (!canCreateWorkspace.value) {
@ -141,7 +140,7 @@ const handleCreateWorkspace = () => {
</script>
<template>
<Sidebar collapsible="icon" variant="inset">
<Sidebar collapsible="offcanvas">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
@ -150,8 +149,8 @@ const handleCreateWorkspace = () => {
<SidebarMenuButton size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
<Avatar :src="currentWorkspace?.logo_url" :name="currentWorkspace?.name ?? '?'"
class="h-8 w-8 shrink-0 rounded-lg"
fallback-class="bg-sidebar-accent text-sidebar-accent-foreground" />
class="h-8 w-8 shrink-0 rounded-md border-2 border-foreground"
fallback-class="bg-violet-100 text-violet-700 font-bold" />
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">
{{ currentWorkspace?.name ?? $t('sidebar.select_workspace') }}
@ -160,22 +159,22 @@ const handleCreateWorkspace = () => {
<IconChevronRight class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
<DropdownMenuContent class="w-[--reka-dropdown-menu-trigger-width] min-w-56"
align="start" side="right" :side-offset="4">
<DropdownMenuLabel class="text-xs text-muted-foreground">
<DropdownMenuLabel>
{{ $t('sidebar.workspaces') }}
</DropdownMenuLabel>
<div class="space-y-0.5">
<DropdownMenuItem v-for="workspace in workspaces" :key="workspace.id" class="gap-2"
:class="workspace.id === currentWorkspace?.id ? 'bg-accent' : ''"
<DropdownMenuItem v-for="workspace in workspaces" :key="workspace.id"
:class="workspace.id === currentWorkspace?.id ? 'bg-accent text-accent-foreground' : ''"
@click="switchWorkspace(workspace.id)">
<Avatar :src="workspace.logo_url" :name="workspace.name"
class="h-5 w-5 shrink-0 rounded-md" fallback-class="text-[10px]" />
class="h-6 w-6 shrink-0 rounded-md border-2 border-foreground" fallback-class="text-[10px] bg-violet-100 text-violet-700 font-bold" />
{{ workspace.name }}
</DropdownMenuItem>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem class="gap-2" @click="handleCreateWorkspace">
<DropdownMenuItem @click="handleCreateWorkspace">
<IconPlus class="size-4" />
{{ $t('sidebar.create_workspace') }}
</DropdownMenuItem>
@ -189,9 +188,8 @@ const handleCreateWorkspace = () => {
<!-- Create Post Button -->
<div v-if="currentWorkspace" class="px-2 py-2">
<Link :href="createPost.url()" class="block">
<Button :size="sidebarState === 'collapsed' ? 'icon' : 'default'" class="w-full">
<IconPlus v-if="sidebarState === 'collapsed'" class="size-4" />
<span v-if="sidebarState === 'expanded'">{{ $t('sidebar.create_post') }}</span>
<Button class="w-full">
{{ $t('sidebar.create_post') }}
</Button>
</Link>
</div>
@ -204,7 +202,11 @@ const handleCreateWorkspace = () => {
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton as-child :tooltip="trans('sidebar.settings')">
<SidebarMenuButton
as-child
:tooltip="trans('sidebar.settings')"
:is-active="urlIsActive(settingsHub.url())"
>
<Link :href="settingsHub.url()">
<IconSettings />
<span>{{ $t('sidebar.settings') }}</span>

View file

@ -1,9 +1,10 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { IconCopy, IconX } from '@tabler/icons-vue';
import { IconAlertTriangle, IconCopy } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -120,31 +121,30 @@ defineExpose({
<template>
<Dialog :open="isOpen" @update:open="onOpenChange">
<DialogContent :show-close-button="false" class="sm:max-w-md">
<Button
variant="ghost"
size="icon"
class="absolute top-4 right-4 size-7"
@click="close"
>
<IconX class="size-4" />
<span class="sr-only">Close</span>
</Button>
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription class="space-y-1">
<span class="block">{{ description }}</span>
<span class="block font-semibold text-destructive">
{{ trans('common.confirm_modal.cannot_be_undone') }}
</span>
</DialogDescription>
<DialogHeader class="items-start text-left">
<div class="flex items-start gap-3">
<div
class="inline-flex size-12 -rotate-3 shrink-0 items-center justify-center rounded-2xl border-2 border-foreground bg-rose-200 shadow-2xs"
>
<IconAlertTriangle class="size-6 text-rose-700" stroke-width="2.25" />
</div>
<div class="flex-1 space-y-1">
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription class="space-y-1">
<span class="block">{{ description }}</span>
<span class="block font-semibold text-rose-700">
{{ trans('common.confirm_modal.cannot_be_undone') }}
</span>
</DialogDescription>
</div>
</div>
</DialogHeader>
<div v-if="requiresConfirmation" class="py-2">
<p
class="mb-2 flex flex-wrap items-center gap-1 text-sm text-muted-foreground"
>
<div v-if="requiresConfirmation" class="space-y-2">
<p class="flex flex-wrap items-center gap-1 text-sm text-foreground/80">
<span>{{ trans('common.confirm_modal.type') }}</span>
<code
class="inline-flex items-center gap-1 rounded-sm border bg-zinc-200 px-1.5 text-sm break-all text-foreground dark:bg-zinc-700"
class="inline-flex items-center gap-1.5 rounded-md border-2 border-foreground bg-amber-100 px-1.5 py-0.5 font-mono text-xs font-bold break-all text-foreground shadow-2xs"
>
{{ confirmText }}
<TooltipProvider>
@ -153,7 +153,7 @@ defineExpose({
<button
type="button"
tabindex="-1"
class="inline-flex shrink-0 items-center rounded text-muted-foreground hover:text-foreground"
class="inline-flex shrink-0 cursor-pointer items-center rounded text-foreground/60 hover:text-foreground"
@click="
copyToClipboard(
confirmText,
@ -175,9 +175,11 @@ defineExpose({
<Input
v-model="confirmInput"
autocomplete="off"
autofocus
/>
</div>
<DialogFooter>
<DialogFooter class="sm:justify-start sm:gap-2">
<Button
variant="destructive"
:disabled="processing || !isConfirmed"
@ -186,7 +188,7 @@ defineExpose({
{{ action }}
</Button>
<Button
variant="secondary"
variant="outline"
@click="close"
>
{{ cancel }}

View file

@ -175,20 +175,20 @@ const displayText = computed(() => {
<template>
<Popover v-model:open="popoverOpen">
<PopoverTrigger as-child :disabled="disabled">
<Button :id="name" type="button" variant="outline" class="w-full justify-between text-left font-normal"
:class="[{ 'text-muted-foreground': !displayText }, $attrs.class]" :disabled="disabled">
<Button :id="name" type="button" variant="outline" class="w-full justify-between text-left font-medium"
:class="[{ 'text-foreground/60': !displayText }, $attrs.class]" :disabled="disabled">
<span>{{ displayText || placeholder || $t('common.date_picker.select') }}</span>
<IconCalendar class="h-4 w-4 shrink-0" />
<IconCalendar class="size-4 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" :align="align">
<Calendar v-model="internalDate as any" :placeholder="(internalDate as any)" layout="month-and-year" locale="en"
calendar-label="Date picker" initial-focus />
<!-- Time Picker -->
<div v-if="showTime" class="border-t p-3">
<div v-if="showTime" class="border-t-2 border-foreground/10 p-3">
<div class="flex items-center gap-2">
<Select v-model="selectedHour" @update:model-value="onTimeChange">
<SelectTrigger class="w-[70px]">
<SelectTrigger class="w-[80px]">
<SelectValue placeholder="HH" />
</SelectTrigger>
<SelectContent>
@ -197,9 +197,9 @@ const displayText = computed(() => {
</SelectItem>
</SelectContent>
</Select>
<span class="text-muted-foreground">:</span>
<span class="font-bold text-foreground/40">:</span>
<Select v-model="selectedMinute" @update:model-value="onTimeChange">
<SelectTrigger class="w-[70px]">
<SelectTrigger class="w-[80px]">
<SelectValue placeholder="MM" />
</SelectTrigger>
<SelectContent>

View file

@ -45,12 +45,10 @@ const focusFirstInput = () => {
:title="$t('settings.delete_account.heading')"
:description="$t('settings.delete_account.description')"
/>
<div
class="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10"
>
<div class="relative space-y-0.5 text-red-600 dark:text-red-100">
<p class="font-medium">{{ $t('settings.delete_account.warning') }}</p>
<p class="text-sm">
<div class="space-y-4 rounded-xl border-2 border-foreground bg-rose-50 p-4 shadow-2xs">
<div class="relative space-y-0.5 text-rose-700">
<p class="font-bold">{{ $t('settings.delete_account.warning') }}</p>
<p class="text-sm font-medium">
{{ $t('settings.delete_account.warning_message') }}
</p>
</div>

View file

@ -10,12 +10,23 @@ defineProps<{
<template>
<div
class="flex flex-1 flex-col items-center justify-center rounded-xl border border-dashed p-12"
class="flex flex-1 flex-col items-center justify-center gap-4 rounded-xl border-2 border-dashed border-foreground/25 bg-card p-12 text-center"
>
<component :is="icon" class="mb-4 size-12 text-muted-foreground" />
<h3 class="mb-1 text-lg font-medium">{{ title }}</h3>
<p class="text-sm text-muted-foreground">{{ description }}</p>
<div v-if="$slots.action" class="mt-6">
<div
class="inline-flex size-16 -rotate-2 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-200 shadow-2xs"
>
<component :is="icon" class="size-8 text-foreground" stroke-width="1.75" />
</div>
<div class="space-y-1">
<h3
class="text-xl text-foreground"
style="font-family: var(--font-display)"
>
{{ title }}
</h3>
<p class="text-sm text-foreground/60">{{ description }}</p>
</div>
<div v-if="$slots.action" class="mt-2">
<slot name="action" />
</div>
</div>

View file

@ -9,8 +9,8 @@ defineProps<Props>();
<template>
<header>
<h3 class="mb-0.5 text-base font-medium">{{ title }}</h3>
<p v-if="description" class="text-sm text-muted-foreground">
<h3 class="mb-0.5 text-base font-bold text-foreground">{{ title }}</h3>
<p v-if="description" class="text-sm text-foreground/70">
{{ description }}
</p>
</header>

View file

@ -22,8 +22,8 @@ const emit = defineEmits<{
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
const PRESETS = [
'#000000', '#ffffff', '#ef4444', '#f97316', '#eab308',
'#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899',
'#000000', '#737373', '#ffffff', '#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16', '#22c55e',
'#10b981', '#14b8a6', '#06b6d4', '#0ea5e9', '#3b82f6', '#6366f1', '#8b5cf6', '#d946ef', '#ec4899',
];
const text = ref(props.modelValue ?? '');
@ -207,8 +207,8 @@ const huePointerStyle = computed(() => ({
<button
type="button"
:disabled="disabled"
class="size-9 shrink-0 rounded-md border transition-shadow hover:shadow-sm disabled:cursor-not-allowed disabled:opacity-50"
:style="{ backgroundColor: swatchColor || 'transparent' }"
class="size-10 shrink-0 cursor-pointer rounded-md border-2 border-foreground bg-card shadow-xs transition-shadow hover:shadow-sm disabled:cursor-not-allowed disabled:opacity-50"
:style="swatchColor ? { backgroundColor: swatchColor } : undefined"
:aria-label="placeholder"
/>
</PopoverTrigger>
@ -262,7 +262,7 @@ const huePointerStyle = computed(() => ({
v-for="hex in PRESETS"
:key="hex"
type="button"
class="size-5 rounded-full border border-border/50 transition-transform hover:scale-110"
class="size-5 cursor-pointer rounded-full border-2 border-foreground transition-transform hover:scale-110"
:style="{ backgroundColor: hex }"
:aria-label="hex"
@click="pickPreset(hex)"

View file

@ -22,14 +22,14 @@ import UserMenuContent from './UserMenuContent.vue';
const page = usePage();
const user = computed(() => page.props.auth.user);
const currentWorkspace = computed(() => page.props.auth.currentWorkspace);
const { isMobile, state } = useSidebar();
const { isMobile } = useSidebar();
</script>
<template>
<SidebarMenu>
<SidebarMenuItem>
<div class="flex items-center gap-1">
<NotificationBell v-if="currentWorkspace && state === 'expanded'" />
<NotificationBell v-if="currentWorkspace" />
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
@ -42,7 +42,7 @@ const { isMobile, state } = useSidebar();
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-(--reka-dropdown-menu-trigger-width) min-w-56 rounded-lg"
class="w-(--reka-dropdown-menu-trigger-width) min-w-56"
:side="isMobile ? 'bottom' : 'right'"
align="end"
:side-offset="4"

View file

@ -196,7 +196,7 @@ onBeforeUnmount(() => {
<IconBell class="size-4" />
<span
v-if="unreadCount > 0"
class="absolute -top-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full bg-destructive text-[8px] font-bold text-destructive-foreground"
class="absolute -right-1 -top-1 inline-flex size-4 items-center justify-center rounded-full border-2 border-foreground bg-rose-100 text-[9px] font-bold text-rose-700 shadow-2xs"
>
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
@ -214,20 +214,22 @@ onBeforeUnmount(() => {
<div
v-if="show"
ref="panel"
class="fixed left-[17rem] bottom-2 z-50 w-[22rem] h-[32rem] flex flex-col rounded-xl border border-border bg-card shadow-lg"
class="fixed bottom-2 left-[17rem] z-50 flex h-[32rem] w-[22rem] flex-col overflow-hidden rounded-xl border-2 border-foreground bg-card shadow-md"
>
<!-- Header -->
<div class="flex items-center justify-between px-4 pt-3 pb-2">
<h3 class="text-sm font-semibold">{{ $t('sidebar.notifications') }}</h3>
<div class="flex items-center gap-0.5">
<div class="flex items-center justify-between gap-2 border-b-2 border-foreground/10 px-4 py-3">
<h3 class="text-[11px] font-black uppercase tracking-widest text-foreground/60">
{{ $t('sidebar.notifications') }}
</h3>
<div class="flex items-center gap-1.5">
<Tooltip v-if="notifications.length > 0">
<TooltipTrigger as-child>
<button
type="button"
class="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
class="inline-flex size-7 cursor-pointer items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground shadow-2xs transition-all hover:bg-violet-100"
@click="handleMarkAllAsRead"
>
<IconChecks class="size-4" />
<IconChecks class="size-3.5" stroke-width="2.5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('sidebar.mark_all_read') }}</TooltipContent>
@ -236,67 +238,69 @@ onBeforeUnmount(() => {
<TooltipTrigger as-child>
<button
type="button"
class="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
class="inline-flex size-7 cursor-pointer items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground shadow-2xs transition-all hover:bg-violet-100"
@click="handleArchiveAll"
>
<IconArchive class="size-4" />
<IconArchive class="size-3.5" stroke-width="2.5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('sidebar.archive_all') }}</TooltipContent>
</Tooltip>
<button
type="button"
class="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
class="inline-flex size-7 cursor-pointer items-center justify-center rounded-full border-2 border-foreground bg-card text-foreground shadow-2xs transition-all hover:-rotate-90 hover:bg-rose-100"
@click="close"
>
<IconX class="size-4" />
<IconX class="size-3.5" stroke-width="2.5" />
</button>
</div>
</div>
<!-- Notification list -->
<div class="flex-1 overflow-y-auto">
<div v-if="notifications.length > 0" class="divide-y divide-border">
<div
<div v-if="notifications.length > 0" class="divide-y-2 divide-dashed divide-foreground/15">
<button
v-for="notification in notifications"
:key="notification.id"
class="px-3 py-2.5 flex items-start gap-2.5 hover:bg-muted/50 transition-colors cursor-pointer"
type="button"
class="flex w-full cursor-pointer items-start gap-2.5 px-3 py-3 text-left transition-colors hover:bg-foreground/5"
:class="!notification.read_at ? 'bg-violet-100/40' : ''"
@click="handleNotificationClick(notification)"
>
<div class="flex items-center mt-1.5 shrink-0">
<div
:class="[
'size-1.5 rounded-full',
!notification.read_at ? 'bg-primary' : 'bg-transparent',
]"
/>
</div>
<span
class="mt-1.5 inline-block size-2 shrink-0 rounded-full"
:class="!notification.read_at ? 'bg-violet-500 ring-2 ring-violet-200' : 'bg-transparent'"
/>
<div class="min-w-0 flex-1">
<p class="text-xs font-medium truncate">{{ notification.title }}</p>
<p class="text-xs text-muted-foreground truncate">{{ notification.body }}</p>
<p class="text-[11px] text-muted-foreground/70 mt-0.5">{{ formatTime(notification.created_at) }}</p>
<p class="truncate text-sm font-bold text-foreground">{{ notification.title }}</p>
<p class="truncate text-xs text-foreground/70">{{ notification.body }}</p>
<p class="mt-0.5 text-[11px] font-medium text-foreground/50">{{ formatTime(notification.created_at) }}</p>
</div>
<div class="shrink-0" @click.stop>
<Tooltip v-if="!notification.read_at">
<TooltipTrigger as-child>
<button
type="button"
class="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
class="inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-foreground/60 transition-colors hover:bg-foreground/10 hover:text-foreground"
@click="handleMarkAsRead(notification)"
>
<IconCheck class="size-3.5" />
<IconCheck class="size-3.5" stroke-width="2.5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('sidebar.mark_as_read') }}</TooltipContent>
</Tooltip>
</div>
</div>
</button>
</div>
<!-- Empty state -->
<div v-else-if="!loading" class="flex flex-col items-center justify-center py-12 px-6 text-center">
<IconInbox class="size-8 text-muted-foreground/50 mb-3" />
<p class="text-sm font-medium">{{ $t('sidebar.no_notifications') }}</p>
<div v-else-if="!loading" class="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<div class="inline-flex size-12 -rotate-3 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-200 shadow-2xs">
<IconInbox class="size-6 text-foreground" stroke-width="2" />
</div>
<p class="text-base font-bold text-foreground" style="font-family: var(--font-display)">
{{ $t('sidebar.no_notifications') }}
</p>
</div>
</div>
</div>

View file

@ -7,11 +7,14 @@ defineProps<{
</script>
<template>
<div>
<h2 class="text-xl font-semibold tracking-tight">
<header class="space-y-1">
<h1
class="text-4xl font-semibold leading-tight text-foreground"
style="font-family: var(--font-display)"
>
{{ title }}
<span v-if="total != null && total > 0" class="text-muted-foreground">({{ total }})</span>
</h2>
<p v-if="description" class="text-sm text-muted-foreground">{{ description }}</p>
</div>
<span v-if="total != null && total > 0" class="text-foreground/40">({{ total }})</span>
</h1>
<p v-if="description" class="text-sm text-foreground/70">{{ description }}</p>
</header>
</template>

View file

@ -1,19 +1,20 @@
<script setup lang="ts">
import { router, usePage } from '@inertiajs/vue3';
import { IconCheck } from '@tabler/icons-vue';
import { IconCheck, IconInfoCircle, IconLoader2 } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref, watch } from 'vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useFeatureAccess } from '@/composables/useFeatureAccess';
import { useUpgradeDialog } from '@/composables/useUpgradeDialog';
import { checkout, swap } from '@/routes/app/billing';
type PlanSlug = 'starter' | 'plus' | 'pro' | 'max';
interface Plan {
id: string;
slug: string;
slug: PlanSlug;
name: string;
stripe_monthly_price_id: string | null;
stripe_yearly_price_id: string | null;
@ -23,6 +24,11 @@ interface Plan {
monthly_credits_limit: number;
}
interface Highlight {
text: string;
tooltip?: string;
}
const { open, reason, closeUpgrade } = useUpgradeDialog();
const { usage } = useFeatureAccess();
const page = usePage();
@ -46,8 +52,11 @@ watch(open, (isOpen) => {
}
});
const getPrice = (plan: Plan): string => {
const key = `billing.subscribe.prices.${plan.slug}.${isYearly.value ? 'yearly' : 'monthly'}`;
// Mirrors Subscribe.vue: headline price = the per-month figure regardless
// of toggle. When yearly is selected, we read the pre-computed per-month
// rate so the price stays comparable visually.
const getDisplayPrice = (plan: Plan): string => {
const key = `billing.subscribe.prices.${plan.slug}.${isYearly.value ? 'yearly_per_month' : 'monthly'}`;
return trans(key);
};
@ -60,11 +69,30 @@ const isCurrent = (plan: Plan): boolean => {
const isPopular = (plan: Plan): boolean => plan.slug === 'pro';
const features = (plan: Plan): string[] => [
trans('billing.subscribe.features.social_accounts', { count: String(plan.social_account_limit) }),
trans('billing.subscribe.features.workspaces', { count: String(plan.workspace_limit) }),
trans('billing.subscribe.features.members', { count: String(plan.member_limit) }),
trans('billing.subscribe.features.credits', { count: plan.monthly_credits_limit.toLocaleString() }),
const inheritsFrom: Partial<Record<PlanSlug, PlanSlug>> = {
plus: 'starter',
pro: 'plus',
max: 'pro',
};
const planNameBySlug = (list: Plan[], slug: PlanSlug): string =>
list.find((plan) => plan.slug === slug)?.name ?? slug;
const planTones: Record<PlanSlug, string> = {
starter: 'bg-amber-50',
plus: 'bg-rose-50',
pro: 'bg-violet-100',
max: 'bg-emerald-50',
};
const highlights = (plan: Plan): Highlight[] => [
{ text: trans('billing.subscribe.features.social_accounts', { count: String(plan.social_account_limit) }) },
{
text: trans('billing.subscribe.features.credits', { count: plan.monthly_credits_limit.toLocaleString() }),
tooltip: trans(`billing.subscribe.credit_tooltips.${plan.slug}`),
},
{ text: trans('billing.subscribe.features.workspaces', { count: String(plan.workspace_limit) }) },
{ text: trans('billing.subscribe.features.members', { count: String(plan.member_limit) }) },
];
const downgradeBlocker = (plan: Plan): string | null => {
@ -133,91 +161,162 @@ const onOpenChange = (value: boolean) => {
<template>
<Dialog :open="open" @update:open="onOpenChange">
<DialogContent class="w-[95vw] gap-0 p-0 sm:max-w-4xl">
<div class="flex flex-col gap-4 px-6 pt-6 pb-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
<div class="min-w-0">
<DialogTitle class="text-2xl font-semibold">
<DialogContent class="w-[95vw] gap-0 p-0 sm:max-w-6xl">
<TooltipProvider :delay-duration="150">
<div class="px-6 pt-6 text-center">
<DialogTitle
class="text-3xl font-semibold leading-tight"
style="font-family: var(--font-display)"
>
{{ $t('billing.upgrade_dialog.title') }}
</DialogTitle>
<DialogDescription class="mt-1">
<DialogDescription class="mx-auto mt-2 max-w-2xl text-balance text-base text-foreground/70">
{{ reason ?? $t('billing.upgrade_dialog.description') }}
</DialogDescription>
</div>
<div
v-if="!isOnYearly"
class="inline-flex h-9 shrink-0 items-center rounded-lg bg-muted p-[3px] text-sm"
>
<button
type="button"
class="inline-flex h-full items-center rounded-md px-3 font-medium transition-colors"
:class="!isYearly ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="isYearly = false"
>
{{ $t('billing.subscribe.monthly') }}
</button>
<button
type="button"
class="inline-flex h-full items-center gap-2 rounded-md px-3 font-medium transition-colors"
:class="isYearly ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="isYearly = true"
>
{{ $t('billing.subscribe.yearly') }}
<Badge variant="secondary" class="whitespace-nowrap text-[10px]">
<!-- Billing toggle pill (hidden when user is already on yearly) -->
<div v-if="!isOnYearly" class="flex justify-center pt-6 pb-2">
<div class="relative">
<div
class="inline-flex items-center gap-1 rounded-full border-2 border-foreground bg-card p-1 shadow-xs"
role="tablist"
>
<button
type="button"
role="tab"
:aria-selected="!isYearly"
:class="[
'cursor-pointer whitespace-nowrap rounded-full px-5 py-1.5 text-sm font-semibold transition-colors',
!isYearly ? 'bg-foreground text-background' : 'text-foreground hover:bg-foreground/5',
]"
@click="isYearly = false"
>
{{ $t('billing.subscribe.monthly') }}
</button>
<button
type="button"
role="tab"
:aria-selected="isYearly"
:class="[
'cursor-pointer whitespace-nowrap rounded-full px-5 py-1.5 text-sm font-semibold transition-colors',
isYearly ? 'bg-foreground text-background' : 'text-foreground hover:bg-foreground/5',
]"
@click="isYearly = true"
>
{{ $t('billing.subscribe.yearly') }}
</button>
</div>
<span
:class="[
'pointer-events-none absolute top-1/2 left-full ml-3 -translate-y-1/2 -rotate-3 inline-flex items-center gap-1 whitespace-nowrap rounded-md border-2 border-foreground bg-amber-200 px-2.5 py-1 text-[10px] font-black uppercase tracking-widest text-foreground shadow-xs transition-all',
isYearly ? 'opacity-100' : 'opacity-30 grayscale',
]"
>
<span>←</span>
{{ $t('billing.subscribe.save_months') }}
</Badge>
</button>
</div>
</div>
<div class="grid grid-cols-1 gap-4 px-6 pt-8 pb-6 sm:grid-cols-2 lg:grid-cols-4">
<div
v-for="plan in plans"
:key="plan.id"
class="relative flex flex-col rounded-xl border bg-card p-5"
:class="[
isPopular(plan) ? 'border-primary ring-1 ring-primary' : '',
isCurrent(plan) && !isPopular(plan) ? 'border-foreground/40' : '',
]"
>
<Badge v-if="isPopular(plan)" class="absolute -top-3 left-1/2 -translate-x-1/2 whitespace-nowrap">
{{ $t('billing.subscribe.popular') }}
</Badge>
<Badge v-else-if="isCurrent(plan)" variant="secondary" class="absolute -top-3 left-1/2 -translate-x-1/2 whitespace-nowrap">
{{ $t('billing.upgrade_dialog.current_badge') }}
</Badge>
<h3 class="text-lg font-semibold">{{ plan.name }}</h3>
<div class="mt-2 flex items-baseline gap-1">
<span class="text-3xl font-bold tracking-tight">{{ getPrice(plan) }}</span>
<span class="text-sm text-muted-foreground">
/{{ isYearly ? $t('billing.subscribe.per_year') : $t('billing.subscribe.per_month') }}
</span>
</div>
<ul class="my-5 flex-1 space-y-2">
<li
v-for="feature in features(plan)"
:key="feature"
class="flex items-start gap-2 text-sm"
>
<IconCheck class="mt-0.5 size-4 shrink-0 text-primary" />
<span>{{ feature }}</span>
</li>
</ul>
<Button
class="w-full"
:variant="isCurrent(plan) || isBlocked(plan) ? 'secondary' : isPopular(plan) ? 'default' : 'outline'"
:loading="processing === plan.id"
:disabled="(processing !== null && processing !== plan.id) || isCurrent(plan) || isBlocked(plan)"
:title="downgradeBlocker(plan) ?? undefined"
@click="handleSelect(plan)"
>
{{ ctaLabel(plan) }}
</Button>
</div>
</div>
<!-- Plan cards -->
<div class="grid grid-cols-1 gap-5 px-6 pt-8 pb-6 md:grid-cols-2 xl:grid-cols-4">
<div
v-for="plan in plans"
:key="plan.id"
:class="[
planTones[plan.slug],
'relative flex flex-col rounded-2xl border-2 border-foreground p-6 transition-shadow',
isPopular(plan) ? 'shadow-xl' : 'shadow-sm hover:shadow-md',
]"
>
<span
v-if="isPopular(plan)"
class="absolute -top-3.5 left-1/2 inline-flex -translate-x-1/2 items-center gap-1 whitespace-nowrap rounded-md border-2 border-foreground bg-foreground px-3 py-1 text-[11px] font-black uppercase tracking-widest text-background shadow-2xs"
>
⭐ {{ $t('billing.subscribe.popular') }}
</span>
<span
v-else-if="isCurrent(plan)"
class="absolute -top-3.5 left-1/2 inline-flex -translate-x-1/2 items-center whitespace-nowrap rounded-md border-2 border-foreground bg-card px-3 py-1 text-[11px] font-black uppercase tracking-widest text-foreground shadow-2xs"
>
{{ $t('billing.upgrade_dialog.current_badge') }}
</span>
<div class="mb-4">
<h3 class="text-xl font-bold tracking-tight text-foreground">
{{ plan.name }}
</h3>
</div>
<div class="mb-2">
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black tabular-nums text-foreground">
{{ getDisplayPrice(plan) }}
</span>
<span class="text-sm font-semibold text-foreground/60">
{{ $t('billing.subscribe.per_month') }}
</span>
</div>
<p class="mt-1 text-xs leading-tight text-foreground/60">
{{ isYearly ? $t('billing.subscribe.billed_yearly') : $t('billing.subscribe.billed_monthly') }}
</p>
</div>
<button
type="button"
:disabled="processing !== null || isCurrent(plan) || isBlocked(plan)"
:title="downgradeBlocker(plan) ?? undefined"
:class="[
'my-5 inline-flex w-full cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-full border-2 border-foreground px-4 py-2.5 text-sm font-semibold shadow-2xs transition-shadow hover:shadow-xs disabled:cursor-not-allowed disabled:opacity-60',
isPopular(plan) ? 'bg-foreground text-background' : 'bg-card text-foreground',
]"
@click="handleSelect(plan)"
>
<IconLoader2 v-if="processing === plan.id" class="size-4 animate-spin" />
{{ ctaLabel(plan) }}
</button>
<div class="flex flex-col gap-3">
<p class="text-sm font-bold leading-snug text-foreground">
<template v-if="inheritsFrom[plan.slug]">
{{ trans('billing.subscribe.everything_in', { plan: planNameBySlug(plans, inheritsFrom[plan.slug]!) }) }}
</template>
<template v-else>
{{ $t('billing.subscribe.features_included') }}
</template>
</p>
<div
v-for="highlight in highlights(plan)"
:key="highlight.text"
class="flex items-start gap-2.5"
>
<div class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border-2 border-foreground bg-card shadow-2xs">
<IconCheck class="size-3 text-foreground" stroke-width="3" />
</div>
<span class="inline-flex items-center gap-1.5 text-sm leading-snug text-foreground/80">
{{ highlight.text }}
<Tooltip v-if="highlight.tooltip">
<TooltipTrigger as-child>
<button
type="button"
class="inline-flex items-center justify-center text-foreground/40 transition-colors hover:text-foreground"
:aria-label="highlight.tooltip"
>
<IconInfoCircle class="size-3.5" stroke-width="2" />
</button>
</TooltipTrigger>
<TooltipContent class="max-w-xs text-sm leading-snug">
{{ highlight.tooltip }}
</TooltipContent>
</Tooltip>
</span>
</div>
</div>
</div>
</div>
</TooltipProvider>
</DialogContent>
</Dialog>
</template>

View file

@ -18,15 +18,15 @@ withDefaults(defineProps<Props>(), {
<Avatar
:src="user.photo_url"
:name="user.name"
:class="['h-8 w-8 rounded-lg', avatarClass]"
:class="['h-8 w-8 rounded-md border-2 border-foreground', avatarClass]"
:fallback-class="
fallbackClass ?? 'bg-sidebar-accent text-sidebar-accent-foreground'
fallbackClass ?? 'bg-violet-100 text-violet-700 font-bold'
"
/>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.name }}</span>
<span v-if="showEmail" class="truncate text-xs text-muted-foreground">{{
<span class="truncate font-bold text-foreground">{{ user.name }}</span>
<span v-if="showEmail" class="truncate text-xs font-medium text-foreground/60">{{
user.email
}}</span>
</div>

View file

@ -1,13 +1,10 @@
<script setup lang="ts">
import { Link, router, usePage } from '@inertiajs/vue3';
import {
IconBrightnessUp,
IconDeviceDesktop,
IconLanguage,
IconLifebuoy,
IconLogout,
IconMessageCircle,
IconMoon,
IconUser,
} from '@tabler/icons-vue';
import { loadLanguageAsync } from 'laravel-vue-i18n';
@ -25,7 +22,6 @@ import {
DropdownMenuSubTrigger,
} from '@/components/ui/dropdown-menu';
import UserInfo from '@/components/UserInfo.vue';
import { useAppearance } from '@/composables/useAppearance';
import dayjs from '@/dayjs';
import { logout } from '@/routes';
import { edit } from '@/routes/app/profile';
@ -46,8 +42,6 @@ const page = usePage();
const languages = computed<Language[]>(() => page.props.languages as Language[]);
const currentLanguage = computed(() => languages.value?.find((l: Language) => l.code === page.props.locale));
const { appearance, updateAppearance } = useAppearance();
const switchLanguage = (code: string) => {
const previousCode = currentLanguage.value?.code || 'en';
@ -70,12 +64,12 @@ const handleLogout = () => {
</script>
<template>
<DropdownMenuLabel class="p-0 font-normal">
<DropdownMenuLabel class="p-0 text-sm font-normal normal-case tracking-normal text-foreground">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<UserInfo
:user="user"
:show-email="true"
fallback-class="bg-neutral-200 text-neutral-600 dark:bg-neutral-700 dark:text-neutral-200"
fallback-class="bg-violet-100 text-violet-700"
/>
</div>
</DropdownMenuLabel>
@ -89,46 +83,10 @@ const handleLogout = () => {
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuSub>
<DropdownMenuSubTrigger class="gap-2">
<IconBrightnessUp
v-if="appearance === 'light'"
class="size-4 text-muted-foreground"
/>
<IconMoon
v-else-if="appearance === 'dark'"
class="size-4 text-muted-foreground"
/>
<IconDeviceDesktop
v-else
class="size-4 text-muted-foreground"
/>
{{ $t('sidebar.theme', { name: appearance === 'light' ? $t('sidebar.theme_light') : appearance === 'dark' ? $t('sidebar.theme_dark') : $t('sidebar.theme_system') }) }}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem @click="updateAppearance('light')">
<IconBrightnessUp class="size-4" />
{{ $t('sidebar.theme_light') }}
</DropdownMenuItem>
<DropdownMenuItem @click="updateAppearance('dark')">
<IconMoon class="size-4" />
{{ $t('sidebar.theme_dark') }}
</DropdownMenuItem>
<DropdownMenuItem @click="updateAppearance('system')">
<IconDeviceDesktop class="size-4" />
{{ $t('sidebar.theme_system') }}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuSub v-if="languages && languages.length > 1">
<DropdownMenuSubTrigger class="gap-2">
<IconLanguage class="size-4 text-muted-foreground" />
<DropdownMenuSubTrigger>
<IconLanguage />
{{ $t('sidebar.language', { name: currentLanguage?.name ?? 'English' }) }}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>

View file

@ -1,8 +1,10 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { IconPlus } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { onMounted, onUnmounted } from 'vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
@ -10,7 +12,6 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
export interface AvailablePlatform {
value: string;
@ -24,10 +25,31 @@ defineProps<{
const open = defineModel<boolean>('open', { default: false });
const getPlatformDescription = (platform: string): string => {
return trans(`accounts.descriptions.${platform}`);
const getPlatformDescription = (platform: string): string =>
trans(`accounts.descriptions.${platform}`);
// Mirrors `NetworksGrid.vue` from the marketing site — pastel tile bg
// + ink 2px border + slight rotation per platform, real PNG logo inside.
// `linkedin-page` / `instagram-facebook` fall back to the base brand
// image and same color since they're variants of the same network.
const platformTheme: Record<string, { bg: string; rotate: string; image: string }> = {
instagram: { bg: 'bg-pink-200', rotate: '-rotate-2', image: '/images/accounts/instagram.png' },
'instagram-facebook': { bg: 'bg-pink-200', rotate: '-rotate-2', image: '/images/accounts/instagram.png' },
facebook: { bg: 'bg-sky-200', rotate: 'rotate-1', image: '/images/accounts/facebook.png' },
linkedin: { bg: 'bg-blue-200', rotate: '-rotate-1', image: '/images/accounts/linkedin.png' },
'linkedin-page': { bg: 'bg-blue-200', rotate: '-rotate-1', image: '/images/accounts/linkedin.png' },
x: { bg: 'bg-amber-200', rotate: 'rotate-2', image: '/images/accounts/x.png' },
tiktok: { bg: 'bg-fuchsia-200', rotate: '-rotate-1', image: '/images/accounts/tiktok.png' },
youtube: { bg: 'bg-red-200', rotate: 'rotate-1', image: '/images/accounts/youtube.png' },
pinterest: { bg: 'bg-rose-200', rotate: '-rotate-2', image: '/images/accounts/pinterest.png' },
threads: { bg: 'bg-emerald-200', rotate: 'rotate-2', image: '/images/accounts/threads.png' },
bluesky: { bg: 'bg-cyan-200', rotate: '-rotate-1', image: '/images/accounts/bluesky.png' },
mastodon: { bg: 'bg-violet-200', rotate: 'rotate-1', image: '/images/accounts/mastodon.png' },
};
const themeFor = (value: string) =>
platformTheme[value] ?? { bg: 'bg-muted', rotate: '', image: '' };
const openOAuthPopup = (platformValue: string) => {
const url = `/connect/${platformValue}`;
const width = 600;
@ -70,24 +92,55 @@ onUnmounted(() => {
{{ $t('accounts.add_social_description') }}
</DialogDescription>
</DialogHeader>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<button
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
<div
v-for="platform in platforms"
:key="platform.value"
class="flex flex-col items-center gap-2 rounded-lg border border-border p-4 text-center transition-colors hover:bg-accent"
@click="openOAuthPopup(platform.value)"
class="group relative flex flex-col items-center gap-3 rounded-xl border-2 border-foreground bg-card p-4 text-center shadow-xs transition-shadow hover:shadow-md"
>
<img :src="getPlatformLogo(platform.value)" :alt="platform.label" class="h-10 w-10 rounded-full object-contain" />
<span class="text-sm font-medium">
<template v-if="platform.label.includes('(')">
{{ platform.label.split('(')[0].trim() }}
</template>
<template v-else>{{ platform.label }}</template>
<!-- "+" sticker badge appears only on hover so the grid doesn't feel cluttered. -->
<span
class="pointer-events-none absolute -top-2 -right-2 inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground bg-violet-200 text-foreground opacity-0 shadow-2xs transition-all group-hover:rotate-90 group-hover:scale-110 group-hover:opacity-100"
aria-hidden="true"
>
<IconPlus class="size-3.5" stroke-width="3" />
</span>
<p class="line-clamp-2 text-xs leading-tight text-muted-foreground">
{{ getPlatformDescription(platform.value) }}
</p>
</button>
<div
:class="[
themeFor(platform.value).bg,
themeFor(platform.value).rotate,
'inline-flex size-16 items-center justify-center rounded-2xl border-2 border-foreground shadow-sm transition-transform group-hover:!rotate-0',
]"
>
<img
:src="themeFor(platform.value).image"
:alt="platform.label"
class="size-9 rounded-lg"
loading="lazy"
/>
</div>
<div class="flex-1">
<span class="block text-sm font-semibold text-foreground">
<template v-if="platform.label.includes('(')">
{{ platform.label.split('(')[0].trim() }}
</template>
<template v-else>{{ platform.label }}</template>
</span>
<p class="mt-0.5 line-clamp-2 text-xs leading-tight text-foreground/60">
{{ getPlatformDescription(platform.value) }}
</p>
</div>
<Button
size="sm"
class="mt-auto w-full"
@click="openOAuthPopup(platform.value)"
>
{{ $t('accounts.connect_cta') }}
</Button>
</div>
</div>
</DialogContent>
</Dialog>

View file

@ -0,0 +1,124 @@
<script setup lang="ts">
import { IconChevronDown } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import type { AnalyticsAccount } from './types';
const props = defineProps<{
accounts: AnalyticsAccount[];
selectedId: string | null;
}>();
const emit = defineEmits<{
select: [accountId: string];
}>();
const open = ref(false);
const selected = computed(() =>
props.accounts.find((a) => a.id === props.selectedId) ?? null,
);
const select = (account: AnalyticsAccount) => {
emit('select', account.id);
open.value = false;
};
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
type="button"
variant="outline"
role="combobox"
:aria-expanded="open"
:disabled="accounts.length === 0"
class="h-auto min-w-[240px] justify-start gap-3 px-3 py-2 text-left font-medium"
>
<template v-if="selected">
<div class="relative shrink-0">
<Avatar class="size-7 rounded-full border-2 border-foreground shadow-2xs">
<AvatarImage v-if="selected.avatar_url" :src="selected.avatar_url" :alt="selected.display_name" />
<AvatarFallback class="rounded-full bg-violet-100 text-xs font-bold text-foreground">
{{ selected.display_name?.charAt(0) }}
</AvatarFallback>
</Avatar>
<span class="absolute -bottom-1 -right-1 inline-flex size-4 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
:src="getPlatformLogo(selected.platform)"
:alt="selected.platform"
class="size-full object-cover"
/>
</span>
</div>
<span class="min-w-0 flex-1 truncate">
<span class="font-bold text-foreground">{{ selected.display_name }}</span>
<span v-if="selected.username" class="ml-1.5 text-xs font-medium text-foreground/60">
@{{ selected.username }}
</span>
</span>
</template>
<template v-else>
<span class="text-foreground/60">{{ $t('analytics.select_account') }}</span>
</template>
<IconChevronDown class="ml-auto size-4 shrink-0 text-foreground/60" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-[320px] overflow-hidden p-0" align="start">
<Command class="rounded-[10px]">
<CommandInput :placeholder="trans('analytics.search_account')" />
<CommandList>
<CommandEmpty>{{ $t('analytics.no_accounts_match') }}</CommandEmpty>
<CommandGroup>
<CommandItem
v-for="account in accounts"
:key="account.id"
:value="account.id"
class="gap-3"
@select="select(account)"
>
<div class="relative shrink-0">
<Avatar class="size-9 rounded-full border-2 border-foreground shadow-2xs">
<AvatarImage v-if="account.avatar_url" :src="account.avatar_url" :alt="account.display_name" />
<AvatarFallback class="rounded-full bg-violet-100 font-bold text-foreground">
{{ account.display_name?.charAt(0) }}
</AvatarFallback>
</Avatar>
<span class="absolute -bottom-1 -right-1 inline-flex size-5 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
:src="getPlatformLogo(account.platform)"
:alt="account.platform"
class="size-full object-cover"
/>
</span>
</div>
<div class="min-w-0 flex-1">
<p class="truncate font-bold text-foreground">{{ account.display_name }}</p>
<p v-if="account.username" class="truncate text-xs font-medium text-foreground/60">
@{{ account.username }}
</p>
</div>
<span class="sr-only">{{ getPlatformLabel(account.platform) }}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>

View file

@ -1,60 +0,0 @@
<script setup lang="ts">
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
export interface AnalyticsAccount {
id: string;
platform: string;
display_name: string;
username: string | null;
avatar_url: string | null;
}
defineProps<{
accounts: AnalyticsAccount[];
selectedId: string | null;
}>();
const emit = defineEmits<{
select: [accountId: string];
}>();
</script>
<template>
<div class="flex h-full w-64 shrink-0 flex-col border-r">
<div class="border-b px-4 py-3">
<h2 class="text-sm font-semibold">{{ $t('analytics.channels') }}</h2>
</div>
<div class="flex-1 overflow-y-auto p-2">
<div v-if="accounts.length === 0" class="px-2 py-8 text-center text-sm text-muted-foreground">
{{ $t('analytics.no_accounts') }}
</div>
<button
v-for="account in accounts"
:key="account.id"
type="button"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors"
:class="selectedId === account.id ? 'bg-accent text-accent-foreground' : 'hover:bg-muted'"
@click="emit('select', account.id)"
>
<div class="relative">
<Avatar class="h-8 w-8">
<AvatarImage v-if="account.avatar_url" :src="account.avatar_url" :alt="account.display_name" />
<AvatarFallback>{{ account.display_name?.charAt(0) }}</AvatarFallback>
</Avatar>
<img
:src="getPlatformLogo(account.platform)"
:alt="account.platform"
class="absolute -bottom-0.5 -right-0.5 h-4 w-4 rounded-full border border-background"
/>
</div>
<div class="min-w-0 flex-1">
<p class="truncate font-medium">{{ account.display_name }}</p>
<p v-if="account.username" class="truncate text-xs text-muted-foreground">
@{{ account.username }}
</p>
</div>
</button>
</div>
</div>
</template>

View file

@ -1,11 +1,10 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { onMounted, ref, watch } from 'vue';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import MetricsGrid from '@/components/analytics/MetricsGrid.vue';
import dayjs from '@/dayjs';
import { formatNumber } from '@/lib/utils';
import { show as showAnalytics } from '@/routes/app/analytics';
interface MetricItem {
@ -58,30 +57,5 @@ defineExpose({ supportsDateRange: true });
</script>
<template>
<!-- Loading -->
<div v-if="isLoading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="i in 8" :key="i">
<CardContent class="p-6">
<Skeleton class="mb-3 h-4 w-24" />
<Skeleton class="h-8 w-32" />
</CardContent>
</Card>
</div>
<!-- Metrics -->
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
</Card>
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
{{ $t('analytics.no_data') }}
</div>
<MetricsGrid :metrics="metrics" :loading="isLoading" :empty-label="trans('analytics.no_data')" />
</template>

View file

@ -1,11 +1,10 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { onMounted, ref, watch } from 'vue';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import MetricsGrid from '@/components/analytics/MetricsGrid.vue';
import dayjs from '@/dayjs';
import { formatNumber } from '@/lib/utils';
import { show as showAnalytics } from '@/routes/app/analytics';
interface MetricItem {
@ -58,30 +57,5 @@ defineExpose({ supportsDateRange: true });
</script>
<template>
<!-- Loading -->
<div v-if="isLoading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="i in 8" :key="i">
<CardContent class="p-6">
<Skeleton class="mb-3 h-4 w-24" />
<Skeleton class="h-8 w-32" />
</CardContent>
</Card>
</div>
<!-- Metrics -->
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
</Card>
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
{{ $t('analytics.no_data') }}
</div>
<MetricsGrid :metrics="metrics" :loading="isLoading" :empty-label="trans('analytics.no_data')" />
</template>

View file

@ -1,11 +1,10 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { onMounted, ref, watch } from 'vue';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import MetricsGrid from '@/components/analytics/MetricsGrid.vue';
import dayjs from '@/dayjs';
import { formatNumber } from '@/lib/utils';
import { show as showAnalytics } from '@/routes/app/analytics';
interface MetricItem {
@ -58,30 +57,5 @@ defineExpose({ supportsDateRange: true });
</script>
<template>
<!-- Loading -->
<div v-if="isLoading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="i in 8" :key="i">
<CardContent class="p-6">
<Skeleton class="mb-3 h-4 w-24" />
<Skeleton class="h-8 w-32" />
</CardContent>
</Card>
</div>
<!-- Metrics -->
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
</Card>
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
{{ $t('analytics.no_data') }}
</div>
<MetricsGrid :metrics="metrics" :loading="isLoading" :empty-label="trans('analytics.no_data')" />
</template>

View file

@ -0,0 +1,138 @@
<script setup lang="ts">
import {
IconActivity,
IconBookmark,
IconChartBar,
IconClick,
IconClock,
IconEye,
IconHeart,
IconMessage,
IconPlayerPlay,
IconPointer,
IconQuote,
IconRepeat,
IconShare,
IconThumbUp,
IconUserPlus,
IconUsers,
} from '@tabler/icons-vue';
import type { FunctionalComponent } from 'vue';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { formatNumber } from '@/lib/utils';
interface MetricItem {
label: string;
value: number;
}
withDefaults(
defineProps<{
metrics: MetricItem[];
loading?: boolean;
emptyLabel?: string;
skeletonCount?: number;
}>(),
{
loading: false,
emptyLabel: '',
skeletonCount: 8,
},
);
// Loose keyword match. Backend metric labels are derived from raw API names
// (English regardless of UI locale), so substring matching covers things
// like "Profile views", "Video views", "Total likes", etc. without a
// per-platform mapping.
type StickerTone = { bg: string; rotate: string };
const TONES = {
eye: { bg: 'bg-violet-200', rotate: '-rotate-2' },
heart: { bg: 'bg-rose-200', rotate: 'rotate-2' },
chat: { bg: 'bg-sky-200', rotate: '-rotate-1' },
share: { bg: 'bg-emerald-200', rotate: 'rotate-1' },
star: { bg: 'bg-amber-200', rotate: '-rotate-2' },
play: { bg: 'bg-fuchsia-200', rotate: 'rotate-1' },
users: { bg: 'bg-cyan-200', rotate: '-rotate-1' },
default: { bg: 'bg-foreground/10', rotate: 'rotate-1' },
} satisfies Record<string, StickerTone>;
interface Mapping {
icon: FunctionalComponent;
tone: StickerTone;
}
// Multi-locale keyword matching. Keys are lowercased substrings of metric labels
// across English, Portuguese, and Spanish so icons render correctly regardless
// of the active locale. Order matters — earlier rules win.
const MATCHES: Array<{ keys: string[]; mapping: Mapping }> = [
{ keys: ['view', 'visualiz', 'vista', 'impress', 'impres', 'reach', 'alcanc'], mapping: { icon: IconEye, tone: TONES.eye } },
{ keys: ['like', 'curtid', 'me gust', 'favour', 'favorit', 'reaction', 'love'], mapping: { icon: IconHeart, tone: TONES.heart } },
{ keys: ['reply', 'replies', 'respost', 'respue', 'comment', 'coment'], mapping: { icon: IconMessage, tone: TONES.chat } },
{ keys: ['repost', 'reblog', 'retweet'], mapping: { icon: IconRepeat, tone: TONES.share } },
{ keys: ['quote', 'cita'], mapping: { icon: IconQuote, tone: TONES.share } },
{ keys: ['save', 'salv', 'guardad', 'bookmark'], mapping: { icon: IconBookmark, tone: TONES.star } },
{ keys: ['watch', 'duration', 'duraç', 'duración', 'minut', 'tempo', 'time'], mapping: { icon: IconClock, tone: TONES.play } },
{ keys: ['video', 'vídeo', 'play'], mapping: { icon: IconPlayerPlay, tone: TONES.play } },
{ keys: ['follower', 'seguidor', 'subscriber', 'inscrit', 'suscrip', 'audience'], mapping: { icon: IconUserPlus, tone: TONES.users } },
{ keys: ['profile', 'perfil', 'visit'], mapping: { icon: IconUsers, tone: TONES.users } },
{ keys: ['click', 'cliqu', 'clic'], mapping: { icon: IconClick, tone: TONES.share } },
{ keys: ['tap', 'pointer'], mapping: { icon: IconPointer, tone: TONES.share } },
{ keys: ['engage', 'engaj', 'engagement', 'interacti', 'interaç'], mapping: { icon: IconActivity, tone: TONES.heart } },
{ keys: ['thumb'], mapping: { icon: IconThumbUp, tone: TONES.share } },
{ keys: ['shar', 'compart'], mapping: { icon: IconShare, tone: TONES.share } },
];
const matchMetric = (label: string): Mapping => {
const haystack = label.toLowerCase();
for (const { keys, mapping } of MATCHES) {
if (keys.some((k) => haystack.includes(k))) return mapping;
}
return { icon: IconChartBar, tone: TONES.default };
};
</script>
<template>
<!-- Loading -->
<div v-if="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="i in skeletonCount" :key="i">
<CardContent class="p-6">
<div class="flex items-start justify-between gap-3">
<Skeleton class="h-4 w-24" />
<Skeleton class="size-10 rounded-2xl" />
</div>
<Skeleton class="mt-3 h-8 w-32" />
</CardContent>
</Card>
</div>
<!-- Metrics -->
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<div class="flex items-start justify-between gap-3">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ metric.label }}</p>
<span
:class="[
'inline-flex size-10 shrink-0 items-center justify-center rounded-2xl border-2 border-foreground shadow-2xs',
matchMetric(metric.label).tone.bg,
matchMetric(metric.label).tone.rotate,
]"
>
<component :is="matchMetric(metric.label).icon" class="size-5 text-foreground" stroke-width="2" />
</span>
</div>
<p class="mt-3 text-3xl font-bold tabular-nums tracking-tight text-foreground">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
</Card>
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-sm font-medium text-foreground/60">
{{ emptyLabel }}
</div>
</template>

View file

@ -1,11 +1,10 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { onMounted, ref, watch } from 'vue';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import MetricsGrid from '@/components/analytics/MetricsGrid.vue';
import dayjs from '@/dayjs';
import { formatNumber } from '@/lib/utils';
import { show as showAnalytics } from '@/routes/app/analytics';
interface MetricItem {
@ -58,30 +57,5 @@ defineExpose({ supportsDateRange: true });
</script>
<template>
<!-- Loading -->
<div v-if="isLoading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="i in 8" :key="i">
<CardContent class="p-6">
<Skeleton class="mb-3 h-4 w-24" />
<Skeleton class="h-8 w-32" />
</CardContent>
</Card>
</div>
<!-- Metrics -->
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
</Card>
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
{{ $t('analytics.no_data') }}
</div>
<MetricsGrid :metrics="metrics" :loading="isLoading" :empty-label="trans('analytics.no_data')" />
</template>

View file

@ -1,11 +1,10 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { onMounted, ref, watch } from 'vue';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import MetricsGrid from '@/components/analytics/MetricsGrid.vue';
import dayjs from '@/dayjs';
import { formatNumber } from '@/lib/utils';
import { show as showAnalytics } from '@/routes/app/analytics';
interface MetricItem {
@ -58,30 +57,5 @@ defineExpose({ supportsDateRange: true });
</script>
<template>
<!-- Loading -->
<div v-if="isLoading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="i in 8" :key="i">
<CardContent class="p-6">
<Skeleton class="mb-3 h-4 w-24" />
<Skeleton class="h-8 w-32" />
</CardContent>
</Card>
</div>
<!-- Metrics -->
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
</Card>
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
{{ $t('analytics.no_data') }}
</div>
<MetricsGrid :metrics="metrics" :loading="isLoading" :empty-label="trans('analytics.no_data')" />
</template>

View file

@ -1,10 +1,9 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { onMounted, ref, watch } from 'vue';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { formatNumber } from '@/lib/utils';
import MetricsGrid from '@/components/analytics/MetricsGrid.vue';
import { show as showAnalytics } from '@/routes/app/analytics';
interface MetricItem {
@ -47,30 +46,5 @@ defineExpose({ supportsDateRange: false });
</script>
<template>
<!-- Loading -->
<div v-if="isLoading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="i in 8" :key="i">
<CardContent class="p-6">
<Skeleton class="mb-3 h-4 w-24" />
<Skeleton class="h-8 w-32" />
</CardContent>
</Card>
</div>
<!-- Metrics -->
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
</Card>
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
{{ $t('analytics.no_data') }}
</div>
<MetricsGrid :metrics="metrics" :loading="isLoading" :empty-label="trans('analytics.no_data')" />
</template>

View file

@ -1,11 +1,10 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { onMounted, ref, watch } from 'vue';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import MetricsGrid from '@/components/analytics/MetricsGrid.vue';
import dayjs from '@/dayjs';
import { formatNumber } from '@/lib/utils';
import { show as showAnalytics } from '@/routes/app/analytics';
interface MetricItem {
@ -58,30 +57,5 @@ defineExpose({ supportsDateRange: true });
</script>
<template>
<!-- Loading -->
<div v-if="isLoading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="i in 8" :key="i">
<CardContent class="p-6">
<Skeleton class="mb-3 h-4 w-24" />
<Skeleton class="h-8 w-32" />
</CardContent>
</Card>
</div>
<!-- Metrics -->
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
</Card>
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
{{ $t('analytics.no_data') }}
</div>
<MetricsGrid :metrics="metrics" :loading="isLoading" :empty-label="trans('analytics.no_data')" />
</template>

View file

@ -1,11 +1,10 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { onMounted, ref, watch } from 'vue';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import MetricsGrid from '@/components/analytics/MetricsGrid.vue';
import dayjs from '@/dayjs';
import { formatNumber } from '@/lib/utils';
import { show as showAnalytics } from '@/routes/app/analytics';
interface MetricItem {
@ -58,30 +57,5 @@ defineExpose({ supportsDateRange: true });
</script>
<template>
<!-- Loading -->
<div v-if="isLoading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="i in 8" :key="i">
<CardContent class="p-6">
<Skeleton class="mb-3 h-4 w-24" />
<Skeleton class="h-8 w-32" />
</CardContent>
</Card>
</div>
<!-- Metrics -->
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
</Card>
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
{{ $t('analytics.no_data') }}
</div>
<MetricsGrid :metrics="metrics" :loading="isLoading" :empty-label="trans('analytics.no_data')" />
</template>

View file

@ -0,0 +1,7 @@
export interface AnalyticsAccount {
id: string;
platform: string;
display_name: string;
username: string | null;
avatar_url: string | null;
}

View file

@ -553,18 +553,20 @@ onUnmounted(() => {
</TabsList>
<!-- ───── My Uploads ───── -->
<TabsContent value="uploads" class="mt-4">
<TabsContent value="uploads" class="mt-6">
<div
class="relative mb-4 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors"
:class="isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'"
class="relative mb-4 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed p-8 text-center transition-colors"
:class="isDragging ? 'border-foreground bg-violet-100' : 'border-foreground/25 bg-card hover:bg-foreground/5'"
@click="triggerFileInput"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
>
<IconCloudUpload class="mb-2 size-8 text-muted-foreground" />
<p class="text-sm font-medium">{{ trans('assets.upload.drag_drop') }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ trans('assets.upload.formats') }}</p>
<div class="inline-flex size-12 -rotate-3 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-200 shadow-2xs">
<IconCloudUpload class="size-6 text-foreground" stroke-width="2" />
</div>
<p class="text-sm font-semibold text-foreground">{{ trans('assets.upload.drag_drop') }}</p>
<p class="text-xs text-foreground/60">{{ trans('assets.upload.formats') }}</p>
<input
ref="fileInput"
type="file"
@ -573,8 +575,8 @@ onUnmounted(() => {
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4"
@change="handleFileSelect"
/>
<div v-if="uploading" class="absolute inset-0 flex items-center justify-center rounded-lg bg-background/80">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<div v-if="uploading" class="absolute inset-0 flex items-center justify-center rounded-2xl bg-card/85">
<div class="flex items-center gap-2 text-sm font-semibold text-foreground">
<IconLoader2 class="size-4 animate-spin" />
{{ trans('assets.upload.uploading') }}
</div>
@ -582,34 +584,34 @@ onUnmounted(() => {
</div>
<div class="relative mb-4">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<IconSearch class="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-foreground/60" />
<Input
v-model="uploadsSearch"
type="search"
:placeholder="trans('assets.search_placeholder')"
class="pl-9"
class="h-12 pl-11 text-base"
/>
</div>
<div v-if="uploadsLoading" class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<Skeleton v-for="i in 8" :key="i" class="aspect-square rounded-lg" />
<Skeleton v-for="i in 8" :key="i" class="aspect-square rounded-xl" />
</div>
<div v-else-if="uploads.length === 0" class="flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground">
<IconPhoto class="size-10" />
<p class="text-sm">{{ trans('assets.empty.title') }}</p>
</div>
<EmptyState
v-else-if="uploads.length === 0"
:icon="IconPhoto"
:title="trans('assets.empty.title')"
:description="trans('assets.empty.description')"
/>
<div v-else class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<div
v-for="asset in uploads"
:key="asset.id"
class="group relative overflow-hidden rounded-lg border-2 bg-muted transition-all"
class="group relative overflow-hidden rounded-xl border-2 border-foreground bg-muted shadow-2xs transition-all hover:-translate-y-0.5 hover:shadow-md"
:class="[
(isPicker || asset.type !== 'video') ? 'cursor-pointer' : '',
isPicker && isSelected(asset.id)
? 'border-primary ring-2 ring-primary/30'
: 'border-transparent',
isPicker && isSelected(asset.id) ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : '',
]"
@click="handleAssetClick(asset)"
>
@ -632,32 +634,37 @@ onUnmounted(() => {
<div
v-if="isPicker && isSelected(asset.id)"
class="absolute right-2 top-2 flex size-6 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground"
class="absolute right-2 top-2 inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground bg-primary text-xs font-bold text-primary-foreground shadow-2xs"
>
{{ selectionIndex(asset.id) }}
</div>
<div
v-if="!isPicker"
class="absolute inset-0 flex flex-col justify-between bg-black/60 p-2 opacity-0 transition-opacity group-hover:opacity-100"
class="absolute inset-0 flex flex-col justify-between bg-foreground/60 p-2 opacity-0 transition-opacity group-hover:opacity-100"
>
<div class="flex justify-end gap-1">
<div class="flex justify-end gap-1.5">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="secondary" size="icon" class="size-7" @click.stop="createPostFromAsset(asset)">
<IconPencilPlus class="size-3.5" />
<Button variant="outline" size="icon" class="size-8" @click.stop="createPostFromAsset(asset)">
<IconPencilPlus class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ trans('assets.create_post') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button variant="destructive" size="icon" class="size-7" @click.stop="handleDelete(asset.id)">
<IconTrash class="size-3.5" />
<Button
variant="outline"
size="icon"
class="size-8 bg-rose-100 hover:bg-rose-200"
@click.stop="handleDelete(asset.id)"
>
<IconTrash class="size-4 text-rose-700" />
</Button>
</div>
<div class="space-y-0.5">
<p class="truncate text-xs font-medium text-white">{{ asset.original_filename }}</p>
<p class="truncate text-xs font-semibold text-white">{{ asset.original_filename }}</p>
<p class="text-xs text-white/70">{{ formatFileSize(asset.size) }}</p>
</div>
</div>
@ -665,24 +672,27 @@ onUnmounted(() => {
</div>
<div v-if="uploadsHasMore" ref="uploadsSentinel" class="mt-4 flex justify-center">
<IconLoader2 v-if="uploadsLoadingMore" class="size-5 animate-spin text-muted-foreground" />
<IconLoader2 v-if="uploadsLoadingMore" class="size-5 animate-spin text-foreground/60" />
</div>
</TabsContent>
<!-- ───── Stock Photos (Unsplash) ───── -->
<TabsContent value="stock" class="mt-4" @vue:mounted="onUnsplashTabMounted">
<TabsContent value="stock" class="mt-6" @vue:mounted="onUnsplashTabMounted">
<div class="relative mb-4">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<IconSearch class="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-foreground/60" />
<Input
v-model="unsplashQuery"
:placeholder="trans('assets.unsplash.search_placeholder')"
class="pl-9"
class="h-12 pl-11 text-base"
@input="searchUnsplashFn"
/>
</div>
<div v-if="displayedPhotos.length > 0" class="space-y-3">
<p v-if="!unsplashQuery && trendingPhotos.length > 0" class="text-sm font-medium text-muted-foreground">
<p
v-if="!unsplashQuery && trendingPhotos.length > 0"
class="text-[11px] font-black uppercase tracking-widest text-foreground/60"
>
{{ trans('assets.unsplash.trending') }}
</p>
@ -690,7 +700,7 @@ onUnmounted(() => {
<div
v-for="photo in displayedPhotos"
:key="photo.id"
class="group relative cursor-pointer overflow-hidden rounded-lg bg-muted"
class="group relative cursor-pointer overflow-hidden rounded-xl border-2 border-foreground bg-muted shadow-2xs transition-all hover:-translate-y-0.5 hover:shadow-md"
@click="previewImage = photo.url_regular"
>
<div class="aspect-[4/3]">
@ -702,19 +712,19 @@ onUnmounted(() => {
/>
</div>
<div class="absolute inset-0 flex flex-col justify-between bg-black/60 p-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="flex justify-end gap-1">
<div class="absolute inset-0 flex flex-col justify-between bg-foreground/60 p-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="flex justify-end gap-1.5">
<TooltipProvider v-if="!isPicker">
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
variant="outline"
size="icon"
class="size-7"
class="size-8"
:disabled="savingPhotoId === photo.id"
@click.stop="createPostFromUnsplash(photo)"
>
<IconPencilPlus class="size-3.5" />
<IconPencilPlus class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ trans('assets.create_post') }}</TooltipContent>
@ -724,14 +734,14 @@ onUnmounted(() => {
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
variant="outline"
size="icon"
class="size-7"
class="size-8 bg-violet-100 hover:bg-violet-200"
:disabled="savingPhotoId === photo.id"
@click.stop="saveAndPickUnsplash(photo)"
>
<IconLoader2 v-if="savingPhotoId === photo.id" class="size-3.5 animate-spin" />
<IconPlus v-else class="size-3.5" />
<IconLoader2 v-if="savingPhotoId === photo.id" class="size-4 animate-spin" />
<IconPlus v-else class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
@ -762,26 +772,29 @@ onUnmounted(() => {
/>
<div v-if="unsplashLoading" class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
<Skeleton v-for="i in 8" :key="i" class="aspect-[4/3] rounded-lg" />
<Skeleton v-for="i in 8" :key="i" class="aspect-[4/3] rounded-xl" />
</div>
<div v-if="hasMorePhotos" ref="unsplashSentinel" class="h-1" />
</TabsContent>
<!-- ───── GIFs (Giphy) ───── -->
<TabsContent value="gifs" class="mt-4" @vue:mounted="onGiphyTabMounted">
<TabsContent value="gifs" class="mt-6" @vue:mounted="onGiphyTabMounted">
<div class="relative mb-4">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<IconSearch class="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-foreground/60" />
<Input
v-model="giphyQuery"
:placeholder="trans('assets.giphy.search_placeholder')"
class="pl-9"
class="h-12 pl-11 text-base"
@input="searchGiphyFn"
/>
</div>
<div v-if="displayedGifs.length > 0" class="space-y-3">
<p v-if="!giphyQuery && giphyTrendingItems.length > 0" class="text-sm font-medium text-muted-foreground">
<p
v-if="!giphyQuery && giphyTrendingItems.length > 0"
class="text-[11px] font-black uppercase tracking-widest text-foreground/60"
>
{{ trans('assets.giphy.trending') }}
</p>
@ -789,26 +802,26 @@ onUnmounted(() => {
<div
v-for="gif in displayedGifs"
:key="gif.id"
class="group relative cursor-pointer overflow-hidden rounded-lg bg-muted"
class="group relative cursor-pointer overflow-hidden rounded-xl border-2 border-foreground bg-muted shadow-2xs transition-all hover:-translate-y-0.5 hover:shadow-md"
@click="previewImage = gif.url_original"
>
<div class="aspect-[4/3]">
<img :src="gif.url_preview" :alt="gif.title || 'GIF'" class="size-full object-cover" loading="lazy" />
</div>
<div class="absolute inset-0 flex flex-col justify-between bg-black/60 p-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="flex justify-end gap-1">
<div class="absolute inset-0 flex flex-col justify-between bg-foreground/60 p-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="flex justify-end gap-1.5">
<TooltipProvider v-if="!isPicker">
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
variant="outline"
size="icon"
class="size-7"
class="size-8"
:disabled="savingGifId === gif.id"
@click.stop="createPostFromGiphy(gif)"
>
<IconPencilPlus class="size-3.5" />
<IconPencilPlus class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ trans('assets.create_post') }}</TooltipContent>
@ -818,14 +831,14 @@ onUnmounted(() => {
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
variant="outline"
size="icon"
class="size-7"
class="size-8 bg-violet-100 hover:bg-violet-200"
:disabled="savingGifId === gif.id"
@click.stop="saveAndPickGiphy(gif)"
>
<IconLoader2 v-if="savingGifId === gif.id" class="size-3.5 animate-spin" />
<IconPlus v-else class="size-3.5" />
<IconLoader2 v-if="savingGifId === gif.id" class="size-4 animate-spin" />
<IconPlus v-else class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
@ -848,13 +861,13 @@ onUnmounted(() => {
/>
<div v-if="giphyLoading" class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
<Skeleton v-for="i in 8" :key="i" class="aspect-[4/3] rounded-lg" />
<Skeleton v-for="i in 8" :key="i" class="aspect-[4/3] rounded-xl" />
</div>
<div v-if="hasMoreGifs" ref="giphySentinel" class="h-1" />
<div v-if="displayedGifs.length > 0" class="mt-4 text-center">
<a href="https://giphy.com" target="_blank" rel="noopener noreferrer" class="text-xs text-muted-foreground hover:text-foreground">
<a href="https://giphy.com" target="_blank" rel="noopener noreferrer" class="text-xs font-medium text-foreground/60 hover:text-foreground">
{{ trans('assets.giphy.powered_by') }}
</a>
</div>

View file

@ -2,6 +2,7 @@
import { useForm } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import HexColorInput from '@/components/HexColorInput.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -17,21 +18,11 @@ import { store as labelsStore } from '@/routes/app/labels';
const open = defineModel<boolean>('open', { default: false });
const colors = [
'#FDFD96',
'#FFD580',
'#FFB3BA',
'#FF69B4',
'#DDA0DD',
'#89CFF0',
'#90EE90',
'#D2B48C',
'#D3D3D3',
];
const DEFAULT_COLOR = '#7c3aed';
const form = useForm({
name: '',
color: colors[0],
color: DEFAULT_COLOR,
});
const submit = () => {
@ -46,7 +37,7 @@ const submit = () => {
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset();
form.color = colors[0];
form.color = DEFAULT_COLOR;
form.clearErrors();
}
open.value = value;
@ -77,22 +68,8 @@ const handleOpenChange = (value: boolean) => {
</div>
<div class="space-y-2">
<Label>{{ $t('labels.create.color') }}</Label>
<div class="flex flex-wrap gap-3">
<button
v-for="color in colors"
:key="color"
type="button"
class="size-8 rounded-full transition-all"
:class="[
form.color === color
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
: 'hover:scale-110'
]"
:style="{ backgroundColor: color }"
@click="form.color = color"
/>
</div>
<Label for="create-color">{{ $t('labels.create.color') }}</Label>
<HexColorInput v-model="form.color" name="color" />
<p v-if="form.errors.color" class="text-sm text-destructive">
{{ form.errors.color }}
</p>

View file

@ -3,6 +3,7 @@ import { useForm } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { watch } from 'vue';
import HexColorInput from '@/components/HexColorInput.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -28,18 +29,6 @@ const props = defineProps<{
const open = defineModel<boolean>('open', { default: false });
const colors = [
'#FDFD96',
'#FFD580',
'#FFB3BA',
'#FF69B4',
'#DDA0DD',
'#89CFF0',
'#90EE90',
'#D2B48C',
'#D3D3D3',
];
const form = useForm({
name: '',
color: '',
@ -87,22 +76,8 @@ const submit = () => {
</div>
<div class="space-y-2">
<Label>{{ $t('labels.edit.color') }}</Label>
<div class="flex flex-wrap gap-3">
<button
v-for="color in colors"
:key="color"
type="button"
class="size-8 rounded-full transition-all"
:class="[
form.color === color
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
: 'hover:scale-110'
]"
:style="{ backgroundColor: color }"
@click="form.color = color"
/>
</div>
<Label for="edit-color">{{ $t('labels.edit.color') }}</Label>
<HexColorInput v-model="form.color" name="color" />
<p v-if="form.errors.color" class="text-sm text-destructive">
{{ form.errors.color }}
</p>

View file

@ -0,0 +1,41 @@
<script setup lang="ts">
interface Label {
id: string;
name: string;
color: string;
}
withDefaults(
defineProps<{
label: Label;
interactive?: boolean;
selected?: boolean;
disabled?: boolean;
}>(),
{
interactive: false,
selected: false,
disabled: false,
},
);
defineEmits<{ click: [] }>();
</script>
<template>
<component
:is="interactive ? 'button' : 'span'"
:type="interactive ? 'button' : undefined"
:disabled="interactive && disabled"
class="inline-flex items-center gap-1.5 rounded-full border-2 border-foreground px-2 py-0.5 text-[10px] font-bold uppercase tracking-widest text-foreground shadow-2xs"
:class="[
selected ? 'bg-violet-100 shadow-md' : 'bg-card',
interactive ? 'cursor-pointer transition-transform hover:-translate-y-0.5 disabled:cursor-not-allowed' : '',
interactive && !selected && !disabled ? 'opacity-80 hover:opacity-100' : '',
]"
@click="interactive && !disabled ? $emit('click') : undefined"
>
<span class="size-2 shrink-0 rounded-full" :style="{ backgroundColor: label.color }" />
<span class="truncate">{{ label.name }}</span>
</component>
</template>

View file

@ -121,8 +121,8 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="flex w-[340px] flex-col overflow-hidden rounded-md bg-popover text-popover-foreground">
<div class="border-b p-2">
<div class="flex w-[340px] flex-col overflow-hidden rounded-[10px] bg-card text-foreground">
<div class="border-b-2 border-foreground/10 p-2">
<Input
v-model="search"
type="search"
@ -139,7 +139,7 @@ onBeforeUnmount(() => {
<template v-if="isSearching">
<div
v-if="searchResults.length === 0"
class="flex h-full items-center justify-center px-4 text-center text-xs text-muted-foreground"
class="flex h-full items-center justify-center px-4 text-center text-xs font-medium text-foreground/60"
>
{{ trans('posts.edit.emoji_picker.empty') }}
</div>
@ -148,7 +148,7 @@ onBeforeUnmount(() => {
v-for="emoji in searchResults"
:key="emoji.c"
type="button"
class="flex h-9 w-9 items-center justify-center rounded text-xl transition-colors hover:bg-muted focus:bg-muted focus:outline-none"
class="flex size-9 cursor-pointer items-center justify-center rounded-md text-xl transition-colors hover:bg-foreground/5 focus:bg-foreground/5 focus:outline-none"
:title="emoji.n"
:aria-label="emoji.n"
@click="onPick(emoji)"
@ -162,7 +162,7 @@ onBeforeUnmount(() => {
<section v-if="recentEmojis.length > 0">
<h3
:ref="setHeaderRef('recent')"
class="sticky top-0 z-10 bg-popover/95 px-1 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground backdrop-blur"
class="sticky top-0 z-10 bg-card/95 px-1 py-1.5 text-[11px] font-black uppercase tracking-widest text-foreground/60 backdrop-blur"
>
{{ categoryLabel('recent') }}
</h3>
@ -171,7 +171,7 @@ onBeforeUnmount(() => {
v-for="emoji in recentEmojis"
:key="`recent-${emoji.c}`"
type="button"
class="flex h-9 w-9 items-center justify-center rounded text-xl transition-colors hover:bg-muted focus:bg-muted focus:outline-none"
class="flex size-9 cursor-pointer items-center justify-center rounded-md text-xl transition-colors hover:bg-foreground/5 focus:bg-foreground/5 focus:outline-none"
:title="emoji.n"
:aria-label="emoji.n"
@click="onPick(emoji)"
@ -184,7 +184,7 @@ onBeforeUnmount(() => {
<section v-for="category in EMOJI_CATEGORIES" :key="category">
<h3
:ref="setHeaderRef(category)"
class="sticky top-0 z-10 bg-popover/95 px-1 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground backdrop-blur"
class="sticky top-0 z-10 bg-card/95 px-1 py-1.5 text-[11px] font-black uppercase tracking-widest text-foreground/60 backdrop-blur"
>
{{ categoryLabel(category) }}
</h3>
@ -193,7 +193,7 @@ onBeforeUnmount(() => {
v-for="emoji in grouped[category]"
:key="emoji.c"
type="button"
class="flex h-9 w-9 items-center justify-center rounded text-xl transition-colors hover:bg-muted focus:bg-muted focus:outline-none"
class="flex size-9 cursor-pointer items-center justify-center rounded-md text-xl transition-colors hover:bg-foreground/5 focus:bg-foreground/5 focus:outline-none"
:title="emoji.n"
:aria-label="emoji.n"
@click="onPick(emoji)"
@ -205,12 +205,12 @@ onBeforeUnmount(() => {
</template>
</div>
<div class="flex items-center justify-between border-t px-1 py-1">
<div class="flex items-center justify-between border-t-2 border-foreground/10 px-1 py-1">
<button
v-if="recentEmojis.length > 0"
type="button"
class="flex h-8 w-8 items-center justify-center rounded text-base transition-colors hover:bg-muted focus:bg-muted focus:outline-none"
:class="activeCategory === 'recent' && !isSearching ? 'bg-muted' : ''"
class="flex size-8 cursor-pointer items-center justify-center rounded-md text-base transition-colors hover:bg-foreground/5 focus:bg-foreground/5 focus:outline-none"
:class="activeCategory === 'recent' && !isSearching ? 'bg-violet-100 ring-2 ring-foreground' : ''"
:title="categoryLabel('recent')"
:aria-label="categoryLabel('recent')"
@click="scrollToCategory('recent')"
@ -221,8 +221,8 @@ onBeforeUnmount(() => {
v-for="category in EMOJI_CATEGORIES"
:key="category"
type="button"
class="flex h-8 w-8 items-center justify-center rounded text-base transition-colors hover:bg-muted focus:bg-muted focus:outline-none"
:class="activeCategory === category && !isSearching ? 'bg-muted' : ''"
class="flex size-8 cursor-pointer items-center justify-center rounded-md text-base transition-colors hover:bg-foreground/5 focus:bg-foreground/5 focus:outline-none"
:class="activeCategory === category && !isSearching ? 'bg-violet-100 ring-2 ring-foreground' : ''"
:title="categoryLabel(category)"
:aria-label="categoryLabel(category)"
@click="scrollToCategory(category)"

View file

@ -11,6 +11,7 @@ import dayjs from '@/dayjs';
const props = defineProps<{
modelValue: string;
disabled?: boolean;
showRemove?: boolean;
}>();
const timezoneAbbr = computed(() => dayjs().format('z'));
@ -18,6 +19,7 @@ const timezoneAbbr = computed(() => dayjs().format('z'));
const emit = defineEmits<{
'update:modelValue': [value: string];
confirm: [value: string];
remove: [];
}>();
const open = ref(false);
@ -73,6 +75,11 @@ const confirm = () => {
emit('confirm', value);
open.value = false;
};
const remove = () => {
emit('remove');
open.value = false;
};
</script>
<template>
@ -91,14 +98,14 @@ const confirm = () => {
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">{{ $t('posts.edit.time') }}</span>
<Select v-model="selectedHour">
<SelectTrigger class="w-[70px]"><SelectValue placeholder="HH" /></SelectTrigger>
<SelectTrigger class="w-[84px]"><SelectValue placeholder="HH" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="h in hours" :key="h" :value="h">{{ h }}</SelectItem>
</SelectContent>
</Select>
<span class="text-muted-foreground">:</span>
<Select v-model="selectedMinute">
<SelectTrigger class="w-[70px]"><SelectValue placeholder="MM" /></SelectTrigger>
<SelectTrigger class="w-[84px]"><SelectValue placeholder="MM" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="m in minutes" :key="m" :value="m">{{ m }}</SelectItem>
</SelectContent>
@ -108,7 +115,17 @@ const confirm = () => {
</div>
<div class="flex items-center justify-between gap-2 border-t p-3">
<Button type="button" variant="ghost" size="sm" @click="cancel">{{ $t('posts.edit.cancel') }}</Button>
<Button
v-if="showRemove"
type="button"
variant="outline"
size="sm"
class="bg-rose-100 text-rose-700 hover:bg-rose-200"
@click="remove"
>
{{ $t('posts.edit.unschedule') }}
</Button>
<Button v-else type="button" variant="ghost" size="sm" @click="cancel">{{ $t('posts.edit.cancel') }}</Button>
<Button type="button" size="sm" @click="confirm">{{ $t('posts.edit.pick_time') }}</Button>
</div>
</DialogContent>

View file

@ -10,6 +10,8 @@ interface Metric {
value: number;
}
type MetricsResponse = Metric[] | { unsupported: true; reason: string };
interface Props {
postId: string;
postPlatformId: string;
@ -28,15 +30,16 @@ const formatNumber = (n: number): string => {
return n.toString();
};
const http = useHttp<Record<string, never>, MetricsResponse>({});
onMounted(async () => {
try {
const response = await useHttp().get(
const response = await http.get(
metricsRoute.url({ post: props.postId, postPlatform: props.postPlatformId }),
) as { data: unknown };
const data = response.data;
);
if (Array.isArray(data)) {
metrics.value = data;
if (Array.isArray(response)) {
metrics.value = response;
}
} catch {
// Swallow — empty state hides itself.

View file

@ -87,9 +87,9 @@ watch(open, () => {
</div>
<div v-if="status !== 'idle'" class="grid gap-2">
<Label>{{ $t('posts.ai.generate.preview_label') }}</Label>
<div class="min-h-[120px] whitespace-pre-wrap rounded-md border bg-muted/30 px-3 py-2 text-sm">{{ text || '...' }}</div>
<p v-if="status === 'failed'" class="text-xs text-destructive">{{ errorMessage }}</p>
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.ai.generate.preview_label') }}</Label>
<div class="min-h-[120px] whitespace-pre-wrap rounded-lg border-2 border-foreground bg-card px-3 py-2 text-sm font-medium text-foreground shadow-2xs">{{ text || '...' }}</div>
<p v-if="status === 'failed'" class="text-xs font-semibold text-rose-700">{{ errorMessage }}</p>
</div>
</div>
@ -104,7 +104,7 @@ watch(open, () => {
<Button v-if="canApply" @click="apply">
{{ $t('posts.ai.generate.apply') }}
</Button>
<Button v-if="canRetry" variant="secondary" @click="retry">
<Button v-if="canRetry" variant="outline" @click="retry">
{{ $t('posts.ai.generate.retry') }}
</Button>
<Button variant="outline" @click="open = false">

View file

@ -93,39 +93,39 @@ watch(open, (isOpen) => {
<DialogDescription>{{ $t('posts.ai.review.description') }}</DialogDescription>
</DialogHeader>
<p v-if="status === 'loading'" class="py-8 text-center text-sm text-muted-foreground">
<p v-if="status === 'loading'" class="py-8 text-center text-sm font-medium text-foreground/60">
{{ $t('posts.ai.review.loading') }}
</p>
<p v-else-if="status === 'failed'" class="py-4 text-sm text-destructive">{{ errorMessage }}</p>
<p v-else-if="status === 'failed'" class="py-4 text-sm font-semibold text-rose-700">{{ errorMessage }}</p>
<p v-else-if="noIssues" class="py-8 text-center text-sm text-muted-foreground">
<p v-else-if="noIssues" class="py-8 text-center text-sm font-medium text-foreground/60">
{{ $t('posts.ai.review.no_issues') }}
</p>
<div v-else-if="suggestions.length > 0" class="max-h-[400px] space-y-2 overflow-y-auto pr-1">
<div v-else-if="suggestions.length > 0" class="max-h-[400px] space-y-3 overflow-y-auto pr-1">
<article
v-for="(s, idx) in suggestions"
:key="idx"
class="rounded-md border bg-card px-4 py-3 transition-opacity"
class="rounded-xl border-2 border-foreground bg-card px-4 py-3 shadow-2xs transition-opacity"
:class="appliedSet.has(idx) ? 'opacity-50' : ''"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1 space-y-1.5">
<p class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm leading-relaxed">
<span class="rounded bg-destructive/15 px-1.5 py-0.5 text-destructive line-through decoration-destructive/50">
<span class="rounded-md border-2 border-foreground bg-rose-100 px-1.5 py-0.5 font-bold text-rose-700 line-through decoration-rose-700/60 shadow-2xs">
{{ s.original }}
</span>
<span class="text-muted-foreground">→</span>
<span class="rounded bg-emerald-500/15 px-1.5 py-0.5 font-medium text-emerald-700 dark:text-emerald-400">
<span class="font-bold text-foreground/40">→</span>
<span class="rounded-md border-2 border-foreground bg-emerald-100 px-1.5 py-0.5 font-bold text-emerald-700 shadow-2xs">
{{ s.suggestion }}
</span>
</p>
<p class="text-xs text-muted-foreground">{{ s.reason }}</p>
<p class="text-xs font-medium text-foreground/60">{{ s.reason }}</p>
</div>
<Button
size="sm"
variant="secondary"
variant="outline"
:disabled="appliedSet.has(idx)"
@click="applySuggestion(idx, s)"
>

View file

@ -28,9 +28,13 @@ interface SocialAccount {
interface Props {
socialAccounts: SocialAccount[];
/** ISO date (YYYY-MM-DD) carried over from the calendar's per-day "+" button. */
date?: string | null;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
date: null,
});
type WizardStep = 'configure' | 'preview';
@ -67,7 +71,8 @@ const httpStart = useHttp<{
social_account_id: string | null;
image_count: number;
prompt: string;
}>({ format: null, social_account_id: null, image_count: 0, prompt: '' });
date: string | null;
}>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null });
const httpFinalize = useHttp<{ content: string; image_title: string; image_body: string }>({
content: '',
@ -253,6 +258,7 @@ const startGeneration = async () => {
httpStart.social_account_id = selectedAccountId.value;
httpStart.image_count = submittedImageCount.value;
httpStart.prompt = promptText.value.trim();
httpStart.date = props.date;
try {
const data = await httpStart.post(startRoute.url()) as { creation_id: string; channel: string };
@ -292,74 +298,80 @@ onUnmounted(() => unsubscribeEcho());
<template>
<div class="space-y-6">
<!-- Back button — consistent across both steps -->
<div class="flex items-center">
<Button variant="ghost" size="sm" class="-ml-2 text-muted-foreground" @click="goBack">
<IconArrowLeft class="mr-1 size-4" />
{{ $t('posts.create.steps.back') }}
</Button>
</div>
<!-- Back button — sticker arrow + ink label, mirrors the marketing site -->
<button
type="button"
class="group inline-flex cursor-pointer items-center gap-1.5 text-sm font-semibold text-foreground/70 transition-colors hover:text-foreground"
@click="goBack"
>
<span class="inline-flex size-7 items-center justify-center rounded-md border-2 border-foreground bg-card shadow-2xs transition-transform group-hover:-translate-x-0.5">
<IconArrowLeft class="size-3.5 text-foreground" stroke-width="2.5" />
</span>
{{ $t('posts.create.steps.back') }}
</button>
<!-- ====== Step 1: Configure (everything in one screen) ====== -->
<template v-if="step === 'configure'">
<!-- Format -->
<div class="space-y-2">
<Label class="text-sm font-medium">{{ $t('posts.create.steps.format_title') }}</Label>
<Label class="text-sm font-bold">{{ $t('posts.create.steps.format_title') }}</Label>
<div class="grid gap-2 sm:grid-cols-2">
<button
v-for="format in availableFormats"
:key="format.value"
type="button"
class="flex items-center gap-3 rounded-xl border bg-card p-3.5 text-left text-sm transition-all hover:border-primary/50 hover:bg-primary/5 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border disabled:hover:bg-card"
:class="{ 'border-primary bg-primary/5 ring-1 ring-primary/30': selectedFormat === format.value }"
class="flex cursor-pointer items-center gap-3 rounded-xl border-2 border-foreground bg-card p-3.5 text-left text-sm shadow-2xs transition-all hover:bg-foreground/5 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-card"
:class="{ '!bg-violet-100 shadow-md': selectedFormat === format.value }"
:disabled="!isFormatConnected(format)"
:title="!isFormatConnected(format) ? $t('posts.create.steps.connect_first') : ''"
@click="selectFormat(format.value)"
>
<img
:src="getPlatformLogo(format.platforms[0])"
:alt="format.platforms[0]"
class="size-6 rounded-full ring-1 ring-background"
/>
<span class="flex-1 font-medium">{{ $t(`posts.create.steps.format.${format.value}`) }}</span>
<IconCheck v-if="selectedFormat === format.value" class="size-4 text-primary" />
<span class="inline-flex size-7 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
:src="getPlatformLogo(format.platforms[0])"
:alt="format.platforms[0]"
class="size-full object-cover"
/>
</span>
<span class="flex-1 font-semibold text-foreground">{{ $t(`posts.create.steps.format.${format.value}`) }}</span>
<IconCheck v-if="selectedFormat === format.value" class="size-4 text-foreground" stroke-width="3" />
</button>
</div>
</div>
<!-- Account (only when there's a choice to make) -->
<div v-if="selectedFormat && showsAccountPicker" class="space-y-2">
<Label class="text-sm font-medium">{{ $t('posts.create.steps.account_title') }}</Label>
<Label class="text-sm font-bold">{{ $t('posts.create.steps.account_title') }}</Label>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
<button
v-for="account in accountsForFormat"
:key="account.id"
type="button"
class="relative flex items-center gap-2 rounded-xl border bg-card p-2.5 text-left text-sm transition-all hover:border-primary/50 hover:bg-primary/5"
:class="{ 'border-primary bg-primary/5 ring-1 ring-primary/30': selectedAccountId === account.id }"
class="relative flex cursor-pointer items-center gap-2 rounded-xl border-2 border-foreground bg-card p-2.5 text-left text-sm shadow-2xs transition-all hover:bg-foreground/5"
:class="{ '!bg-violet-100 shadow-md': selectedAccountId === account.id }"
@click="selectedAccountId = account.id"
>
<img
v-if="account.avatar_url"
:src="account.avatar_url"
:alt="account.display_name"
class="size-8 shrink-0 rounded-full"
/>
<div v-else class="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
<img :src="getPlatformLogo(account.platform)" :alt="account.platform" class="size-4" />
</div>
<span class="inline-flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
v-if="account.avatar_url"
:src="account.avatar_url"
:alt="account.display_name"
class="size-full object-cover"
/>
<img v-else :src="getPlatformLogo(account.platform)" :alt="account.platform" class="size-4" />
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium leading-tight">{{ account.display_name }}</p>
<p v-if="account.username" class="truncate text-xs text-muted-foreground">@{{ account.username }}</p>
<p class="truncate text-xs font-bold leading-tight text-foreground">{{ account.display_name }}</p>
<p v-if="account.username" class="truncate text-xs font-medium text-foreground/60">@{{ account.username }}</p>
</div>
<IconCheck v-if="selectedAccountId === account.id" class="absolute right-2 top-2 size-3.5 text-primary" />
<IconCheck v-if="selectedAccountId === account.id" class="absolute right-2 top-2 size-3.5 text-foreground" stroke-width="3" />
</button>
</div>
</div>
<!-- Media — inline, only when format actually has options -->
<div v-if="selectedFormat && isCarousel" class="space-y-2">
<Label class="text-sm font-medium">{{ $t('posts.create.steps.media_carousel') }}</Label>
<Label class="text-sm font-bold">{{ $t('posts.create.steps.media_carousel') }}</Label>
<div class="flex flex-wrap gap-2">
<Button
v-for="n in [2, 3, 4, 5, 6, 7, 8, 9, 10]"
@ -375,7 +387,7 @@ onUnmounted(() => unsubscribeEcho());
</div>
<div v-if="selectedFormat && supportsOptionalImages" class="space-y-2">
<Label class="text-sm font-medium">{{ $t('posts.create.steps.media_optional_label') }}</Label>
<Label class="text-sm font-bold">{{ $t('posts.create.steps.media_optional_label') }}</Label>
<div class="flex flex-wrap gap-2">
<Button
type="button"
@ -399,7 +411,7 @@ onUnmounted(() => unsubscribeEcho());
<!-- Prompt -->
<div v-if="selectedFormat" class="space-y-2">
<Label for="ai-prompt" class="text-sm font-medium">{{ $t('posts.create.steps.prompt_label') }}</Label>
<Label for="ai-prompt" class="text-sm font-bold">{{ $t('posts.create.steps.prompt_label') }}</Label>
<Textarea
id="ai-prompt"
v-model="promptText"
@ -418,19 +430,21 @@ onUnmounted(() => unsubscribeEcho());
<!-- ====== Step 2: Preview ====== -->
<template v-else-if="step === 'preview'">
<div v-if="previewStatus === 'loading'" class="flex flex-col items-center gap-4 rounded-xl border bg-muted/20 py-16 text-center">
<IconLoader2 class="size-10 animate-spin text-primary" />
<p class="text-sm text-muted-foreground">{{ $t('posts.create.steps.preview_loading') }}</p>
<div v-if="previewStatus === 'loading'" class="flex flex-col items-center gap-4 rounded-2xl border-2 border-foreground bg-card py-16 text-center shadow-2xs">
<div class="inline-flex size-12 -rotate-2 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-200 shadow-2xs">
<IconLoader2 class="size-6 animate-spin text-foreground" stroke-width="2" />
</div>
<p class="text-sm font-semibold text-foreground/70">{{ $t('posts.create.steps.preview_loading') }}</p>
</div>
<div v-else-if="previewStatus === 'error'" class="space-y-4">
<div class="rounded-xl border border-destructive/30 bg-destructive/5 p-4">
<p class="text-sm text-destructive">{{ previewError || $t('posts.create.steps.preview_error') }}</p>
<div class="rounded-xl border-2 border-foreground bg-rose-50 p-4 shadow-2xs">
<p class="text-sm font-semibold text-rose-700">{{ previewError || $t('posts.create.steps.preview_error') }}</p>
</div>
<div class="flex justify-end">
<Button variant="outline" @click="retryGeneration">
<IconRefresh class="mr-1 size-4" />
<IconRefresh class="size-4" />
{{ $t('posts.create.steps.retry') }}
</Button>
</div>
@ -438,14 +452,14 @@ onUnmounted(() => unsubscribeEcho());
<div v-else-if="previewStatus === 'done'" class="space-y-4">
<!-- Caption-less formats (Stories): edit title + body separately. -->
<div v-if="!supportsCaption" class="space-y-3 rounded-xl border bg-muted/20 p-5">
<div class="space-y-1">
<Label class="text-xs font-medium text-muted-foreground">{{ $t('posts.create.preview.image_title') }}</Label>
<Input v-model="previewImageTitle" class="bg-background" />
<div v-if="!supportsCaption" class="space-y-3 rounded-2xl border-2 border-foreground bg-card p-5 shadow-2xs">
<div class="space-y-1.5">
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.create.preview.image_title') }}</Label>
<Input v-model="previewImageTitle" />
</div>
<div class="space-y-1">
<Label class="text-xs font-medium text-muted-foreground">{{ $t('posts.create.preview.image_body') }}</Label>
<Textarea v-model="previewImageBody" class="min-h-[120px] resize-none bg-background" />
<div class="space-y-1.5">
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.create.preview.image_body') }}</Label>
<Textarea v-model="previewImageBody" class="min-h-[120px] resize-none" />
</div>
</div>
@ -453,15 +467,16 @@ onUnmounted(() => unsubscribeEcho());
<Textarea
v-else
v-model="previewContent"
class="min-h-[200px] resize-none rounded-xl border bg-muted/20 p-5 text-sm leading-relaxed"
class="min-h-[200px] resize-none p-5 text-sm leading-relaxed"
/>
<div class="flex justify-end gap-2">
<Button variant="outline" @click="retryGeneration">
<IconRefresh class="size-4" />
{{ $t('posts.create.steps.retry') }}
</Button>
<Button :disabled="finalizing" @click="createPost">
<IconLoader2 v-if="finalizing" class="mr-1 size-4 animate-spin" />
<IconLoader2 v-if="finalizing" class="size-4 animate-spin" />
{{ $t('posts.create.steps.create') }}
</Button>
</div>

View file

@ -8,6 +8,7 @@ import {
IconTrash,
IconX,
} from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import CommentBody from '@/components/CommentBody.vue';
@ -103,7 +104,11 @@ const commentsByDay = computed((): DayGroup[] => {
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD');
return Array.from(groups.entries()).map(([day, items]) => ({
label: day === today ? 'Today' : day === yesterday ? 'Yesterday' : dayjs(day).format('MMM D, YYYY'),
label: day === today
? trans('comments.today')
: day === yesterday
? trans('comments.yesterday')
: dayjs(day).format('D MMM YYYY'),
comments: items,
}));
});
@ -475,20 +480,20 @@ watch(() => props.postId, () => {
<!-- Empty state -->
<div v-if="!loading && comments.length === 0" class="flex flex-col items-center justify-center py-12 text-center">
<p class="text-sm text-muted-foreground">{{ $t('comments.empty') }}</p>
<p class="text-sm font-medium text-foreground/60">{{ $t('comments.empty') }}</p>
</div>
<!-- Comments grouped by day -->
<div class="px-2 py-1">
<template v-for="group in commentsByDay" :key="group.label">
<div class="mt-2 mb-4 text-center text-xs text-muted-foreground">{{ group.label }}</div>
<div class="mb-4 mt-2 text-center text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ group.label }}</div>
<template v-for="comment in group.comments" :key="comment.id">
<!-- Top-level comment -->
<div
:data-comment-id="comment.id"
class="group relative rounded-lg py-1.5 px-2 transition-colors"
:class="highlightedId === comment.id ? 'bg-primary/10 ring-1 ring-primary/30' : 'hover:bg-muted/50'"
:class="highlightedId === comment.id ? 'bg-violet-100 ring-2 ring-foreground' : 'hover:bg-foreground/5'"
@mouseenter="hoveredCommentId = comment.id"
@mouseleave="hoveredCommentId = null; emojiPickerCommentId = null"
>
@ -509,39 +514,41 @@ watch(() => props.postId, () => {
<!-- Display mode -->
<template v-else>
<div class="flex items-start gap-2">
<Avatar class="h-6 w-6 shrink-0">
<div class="flex items-start gap-3">
<Avatar class="size-8 shrink-0 rounded-full border-2 border-foreground shadow-2xs">
<AvatarImage v-if="getAvatarUrl(comment.user)" :src="getAvatarUrl(comment.user)!" />
<AvatarFallback class="text-[10px]">{{ getInitials(comment.user.name) }}</AvatarFallback>
<AvatarFallback class="rounded-full bg-violet-100 text-[10px] font-bold text-violet-700">{{ getInitials(comment.user.name) }}</AvatarFallback>
</Avatar>
<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-1.5">
<span class="text-xs font-medium">{{ comment.user.name }}</span>
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span class="text-sm font-bold text-foreground">{{ comment.user.name }}</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<span class="text-[10px] text-muted-foreground">{{ date.diffForHumans(comment.created_at) }}</span>
<span class="text-xs font-medium text-foreground/60">{{ date.diffForHumans(comment.created_at) }}</span>
</TooltipTrigger>
<TooltipContent side="top">
<span class="text-xs">{{ date.formatDateTime(comment.created_at) }}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span v-if="comment.updated_at !== comment.created_at" class="text-[10px] text-muted-foreground italic">({{ $t('comments.edited') }})</span>
<span v-if="comment.updated_at !== comment.created_at" class="text-xs font-medium italic text-foreground/60">({{ $t('comments.edited') }})</span>
</div>
<div class="mt-0.5 text-sm leading-relaxed text-foreground">
<CommentBody :body="comment.body" :members="memberNames" />
</div>
<CommentBody :body="comment.body" :members="memberNames" />
<!-- Reactions -->
<div v-if="groupedReactions(comment.reactions).length > 0" class="mt-1.5 flex flex-wrap gap-1">
<button
v-for="r in groupedReactions(comment.reactions)"
:key="r.emoji"
class="inline-flex items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-xs transition-colors"
:class="r.hasReacted ? 'border-primary/40 bg-primary/10' : 'border-border hover:border-primary/30'"
class="inline-flex items-center gap-1 rounded-full border-2 px-2 py-0.5 text-xs font-bold cursor-pointer transition-colors"
:class="r.hasReacted ? 'border-foreground bg-violet-100' : 'border-foreground/30 hover:border-foreground'"
@click="toggleReaction(comment, r.emoji)"
>
<span>{{ r.emoji }}</span>
<span class="text-[10px] text-muted-foreground">{{ r.count }}</span>
<span class="text-[10px] font-medium text-foreground/60">{{ r.count }}</span>
</button>
</div>
@ -550,31 +557,31 @@ watch(() => props.postId, () => {
<!-- Floating toolbar -->
<div
v-if="hoveredCommentId === comment.id && editingComment?.id !== comment.id"
class="absolute -top-3 right-2 z-50 flex items-center gap-0.5 rounded-md border bg-background px-1 py-0.5 shadow-sm"
class="absolute -top-3 right-2 z-50 flex items-center gap-0.5 rounded-md border-2 border-foreground bg-card px-1 py-0.5 shadow-2xs"
>
<TooltipProvider :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" @mouseenter="showEmojiPicker(comment.id)"><IconMoodSmile class="h-3.5 w-3.5" /></button>
<button class="rounded p-1 text-muted-foreground hover:bg-foreground/5 hover:text-foreground" @mouseenter="showEmojiPicker(comment.id)"><IconMoodSmile class="h-3.5 w-3.5" /></button>
</TooltipTrigger>
<TooltipContent side="top" class="text-xs">React</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" @click="startReply(comment)"><IconArrowBackUp class="h-3.5 w-3.5" /></button>
<button class="rounded p-1 text-muted-foreground hover:bg-foreground/5 hover:text-foreground" @click="startReply(comment)"><IconArrowBackUp class="h-3.5 w-3.5" /></button>
</TooltipTrigger>
<TooltipContent side="top" class="text-xs">{{ $t('comments.reply') }}</TooltipContent>
</Tooltip>
<template v-if="comment.user_id === currentUserId">
<Tooltip>
<TooltipTrigger as-child>
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" @click="startEdit(comment)"><IconEdit class="h-3.5 w-3.5" /></button>
<button class="rounded p-1 text-muted-foreground hover:bg-foreground/5 hover:text-foreground" @click="startEdit(comment)"><IconEdit class="h-3.5 w-3.5" /></button>
</TooltipTrigger>
<TooltipContent side="top" class="text-xs">{{ $t('comments.edit') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive" @click="deleteComment(comment)"><IconTrash class="h-3.5 w-3.5" /></button>
<button class="rounded p-1 text-muted-foreground hover:bg-rose-100 hover:text-rose-700" @click="deleteComment(comment)"><IconTrash class="h-3.5 w-3.5" /></button>
</TooltipTrigger>
<TooltipContent side="top" class="text-xs">{{ $t('comments.delete') }}</TooltipContent>
</Tooltip>
@ -585,7 +592,7 @@ watch(() => props.postId, () => {
<!-- Emoji picker -->
<div
v-if="emojiPickerCommentId === comment.id"
class="absolute -top-10 right-2 z-50 flex items-center gap-0.5 rounded-lg border bg-popover p-1.5 shadow-md" >
class="absolute -top-10 right-2 z-50 flex items-center gap-0.5 rounded-lg border-2 border-foreground bg-card p-1.5 shadow-md" >
<button v-for="emoji in EMOJIS" :key="emoji" class="rounded p-0.5 text-sm transition-transform hover:scale-125 hover:bg-muted" @click="toggleReaction(comment, emoji)">{{ emoji }}</button>
</div>
</div>
@ -599,7 +606,7 @@ watch(() => props.postId, () => {
:key="reply.id"
:data-comment-id="reply.id"
class="group relative ml-8 rounded-lg py-1.5 px-2 transition-colors"
:class="highlightedId === reply.id ? 'bg-primary/10 ring-1 ring-primary/30' : 'hover:bg-muted/50'"
:class="highlightedId === reply.id ? 'bg-violet-100 ring-2 ring-foreground' : 'hover:bg-foreground/5'"
@mouseenter="hoveredCommentId = reply.id"
@mouseleave="hoveredCommentId = null; emojiPickerCommentId = null"
>
@ -620,39 +627,41 @@ watch(() => props.postId, () => {
<!-- Display reply -->
<template v-else>
<div class="flex items-start gap-2">
<Avatar class="h-5 w-5 shrink-0">
<div class="flex items-start gap-2.5">
<Avatar class="size-7 shrink-0 rounded-full border-2 border-foreground shadow-2xs">
<AvatarImage v-if="getAvatarUrl(reply.user)" :src="getAvatarUrl(reply.user)!" />
<AvatarFallback class="text-[9px]">{{ getInitials(reply.user.name) }}</AvatarFallback>
<AvatarFallback class="rounded-full bg-violet-100 text-[10px] font-bold text-violet-700">{{ getInitials(reply.user.name) }}</AvatarFallback>
</Avatar>
<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-1.5">
<span class="text-xs font-medium">{{ reply.user.name }}</span>
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span class="text-sm font-bold text-foreground">{{ reply.user.name }}</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<span class="text-[10px] text-muted-foreground">{{ date.diffForHumans(reply.created_at) }}</span>
<span class="text-xs font-medium text-foreground/60">{{ date.diffForHumans(reply.created_at) }}</span>
</TooltipTrigger>
<TooltipContent side="top">
<span class="text-xs">{{ date.formatDateTime(reply.created_at) }}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span v-if="reply.updated_at !== reply.created_at" class="text-[10px] text-muted-foreground italic">({{ $t('comments.edited') }})</span>
<span v-if="reply.updated_at !== reply.created_at" class="text-xs font-medium italic text-foreground/60">({{ $t('comments.edited') }})</span>
</div>
<div class="mt-0.5 text-sm leading-relaxed text-foreground">
<CommentBody :body="reply.body" :members="memberNames" />
</div>
<CommentBody :body="reply.body" :members="memberNames" />
<!-- Reply reactions -->
<div v-if="groupedReactions(reply.reactions).length > 0" class="mt-1.5 flex flex-wrap gap-1">
<button
v-for="r in groupedReactions(reply.reactions)"
:key="r.emoji"
class="inline-flex items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-xs transition-colors"
:class="r.hasReacted ? 'border-primary/40 bg-primary/10' : 'border-border hover:border-primary/30'"
class="inline-flex items-center gap-1 rounded-full border-2 px-2 py-0.5 text-xs font-bold cursor-pointer transition-colors"
:class="r.hasReacted ? 'border-foreground bg-violet-100' : 'border-foreground/30 hover:border-foreground'"
@click="toggleReaction(reply, r.emoji)"
>
<span>{{ r.emoji }}</span>
<span class="text-[10px] text-muted-foreground">{{ r.count }}</span>
<span class="text-[10px] font-medium text-foreground/60">{{ r.count }}</span>
</button>
</div>
@ -661,25 +670,25 @@ watch(() => props.postId, () => {
<!-- Reply floating toolbar -->
<div
v-if="hoveredCommentId === reply.id && editingComment?.id !== reply.id"
class="absolute -top-3 right-2 z-50 flex items-center gap-0.5 rounded-md border bg-background px-1 py-0.5 shadow-sm"
class="absolute -top-3 right-2 z-50 flex items-center gap-0.5 rounded-md border-2 border-foreground bg-card px-1 py-0.5 shadow-2xs"
>
<TooltipProvider :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" @mouseenter="showEmojiPicker(reply.id)"><IconMoodSmile class="h-3.5 w-3.5" /></button>
<button class="rounded p-1 text-muted-foreground hover:bg-foreground/5 hover:text-foreground" @mouseenter="showEmojiPicker(reply.id)"><IconMoodSmile class="h-3.5 w-3.5" /></button>
</TooltipTrigger>
<TooltipContent side="top" class="text-xs">React</TooltipContent>
</Tooltip>
<template v-if="reply.user_id === currentUserId">
<Tooltip>
<TooltipTrigger as-child>
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" @click="startEdit(reply)"><IconEdit class="h-3.5 w-3.5" /></button>
<button class="rounded p-1 text-muted-foreground hover:bg-foreground/5 hover:text-foreground" @click="startEdit(reply)"><IconEdit class="h-3.5 w-3.5" /></button>
</TooltipTrigger>
<TooltipContent side="top" class="text-xs">{{ $t('comments.edit') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive" @click="deleteComment(reply)"><IconTrash class="h-3.5 w-3.5" /></button>
<button class="rounded p-1 text-muted-foreground hover:bg-rose-100 hover:text-rose-700" @click="deleteComment(reply)"><IconTrash class="h-3.5 w-3.5" /></button>
</TooltipTrigger>
<TooltipContent side="top" class="text-xs">{{ $t('comments.delete') }}</TooltipContent>
</Tooltip>
@ -690,7 +699,7 @@ watch(() => props.postId, () => {
<!-- Reply emoji picker -->
<div
v-if="emojiPickerCommentId === reply.id"
class="absolute -top-10 right-2 z-50 flex items-center gap-0.5 rounded-lg border bg-popover p-1.5 shadow-md" >
class="absolute -top-10 right-2 z-50 flex items-center gap-0.5 rounded-lg border-2 border-foreground bg-card p-1.5 shadow-md" >
<button v-for="emoji in EMOJIS" :key="emoji" class="rounded p-0.5 text-sm transition-transform hover:scale-125 hover:bg-muted" @click="toggleReaction(reply, emoji)">{{ emoji }}</button>
</div>
</div>
@ -708,13 +717,13 @@ watch(() => props.postId, () => {
</div>
<!-- Input area -->
<div class="shrink-0 border-t p-2">
<div class="shrink-0 border-t-2 border-foreground/10 p-2">
<!-- Replying to indicator -->
<div v-if="replyingTo" class="mb-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<IconArrowBackUp class="h-3 w-3" />
<div v-if="replyingTo" class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-foreground/70">
<IconArrowBackUp class="size-3" />
<span>{{ $t('comments.replying_to', { name: replyingTo.user.name }) }}</span>
<button class="ml-auto rounded p-0.5 hover:bg-muted" @click="cancelReply">
<IconX class="h-3 w-3" />
<button class="ml-auto cursor-pointer rounded p-0.5 hover:bg-foreground/5" @click="cancelReply">
<IconX class="size-3" />
</button>
</div>
@ -731,13 +740,12 @@ watch(() => props.postId, () => {
/>
<Button
size="icon"
variant="ghost"
class="h-9 w-9 shrink-0"
class="size-9 shrink-0"
:disabled="!newBody.trim() || sending"
@click="sendComment"
>
<IconLoader2 v-if="sending" class="h-4 w-4 animate-spin" />
<IconSend v-else class="h-4 w-4" />
<IconLoader2 v-if="sending" class="size-4 animate-spin" />
<IconSend v-else class="size-4" />
</Button>
</div>
</div>

View file

@ -1,9 +1,10 @@
<script setup lang="ts">
import { IconAlertTriangle, IconBrandFacebook, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
interface SocialAccount {
id: string;
@ -45,48 +46,50 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
</script>
<template>
<div class="rounded-lg border">
<div class="rounded-xl border-2 border-foreground bg-card shadow-2xs">
<button
type="button"
class="flex w-full items-center justify-between p-4 text-sm font-medium"
class="flex w-full cursor-pointer items-center justify-between gap-3 p-4 text-sm"
@click="open = !open"
>
<span class="flex items-center gap-2">
<IconBrandFacebook class="size-5" />
<span>{{ $t('posts.form.facebook.settings') }}</span>
<span v-if="socialAccount" class="text-muted-foreground">·&nbsp;@{{ socialAccount.username }}</span>
<span class="flex min-w-0 items-center gap-2">
<span class="inline-flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img :src="getPlatformLogo('facebook')" alt="Facebook" class="size-full object-cover" />
</span>
<span class="truncate font-bold text-foreground">{{ $t('posts.form.facebook.settings') }}</span>
<span v-if="socialAccount?.username" class="truncate font-medium text-foreground/60">·&nbsp;@{{ socialAccount.username }}</span>
</span>
<IconChevronUp v-if="open" class="h-4 w-4 text-muted-foreground" />
<IconChevronDown v-else class="h-4 w-4 text-muted-foreground" />
<IconChevronUp v-if="open" class="size-4 shrink-0 text-foreground/60" />
<IconChevronDown v-else class="size-4 shrink-0 text-foreground/60" />
</button>
<div v-if="open" class="space-y-5 border-t px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-muted/50 p-3">
<div v-if="open" class="space-y-5 border-t-2 border-foreground/10 px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3">
<Avatar
:src="socialAccount.avatar_url"
:name="socialAccount.display_name"
class="h-9 w-9 shrink-0 rounded-full"
class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs"
/>
<div class="min-w-0 flex-1">
<p class="text-xs text-muted-foreground">{{ $t('posts.form.facebook.posting_to') }}</p>
<p class="truncate text-sm font-medium">
{{ socialAccount.display_name }}
<span class="text-muted-foreground">@{{ socialAccount.username }}</span>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.facebook.posting_to') }}</p>
<p class="truncate text-sm">
<span class="font-bold text-foreground">{{ socialAccount.display_name }}</span>
<span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span>
</p>
</div>
</div>
<div class="space-y-2">
<p class="text-sm font-medium">{{ $t('posts.form.facebook.variant_label') }}</p>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.facebook.variant_label') }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="variant in variants"
:key="variant.value"
type="button"
class="rounded-full border px-3 py-1.5 text-xs transition-colors"
class="cursor-pointer rounded-full border-2 px-3 py-1 text-xs font-bold uppercase tracking-widest transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="contentType === variant.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'"
? 'border-foreground bg-violet-100 text-foreground shadow-2xs'
: 'border-foreground/30 text-foreground/70 hover:border-foreground hover:text-foreground'"
:disabled="disabled"
@click="pickVariant(variant.value)"
>
@ -97,9 +100,9 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
<p
v-if="warning"
class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive"
class="flex items-start gap-2 rounded-lg border-2 border-foreground bg-rose-50 p-2 text-xs font-semibold text-rose-700"
>
<IconAlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<IconAlertTriangle class="mt-0.5 size-3.5 shrink-0" />
{{ $t(`posts.form.warnings.${warning.key}`, warning.params) }}
</p>
</div>

View file

@ -1,9 +1,10 @@
<script setup lang="ts">
import { IconAlertTriangle, IconBrandInstagram, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType } from '@/enums/content-type';
interface SocialAccount {
@ -64,48 +65,50 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
</script>
<template>
<div class="rounded-lg border">
<div class="rounded-xl border-2 border-foreground bg-card shadow-2xs">
<button
type="button"
class="flex w-full items-center justify-between p-4 text-sm font-medium"
class="flex w-full cursor-pointer items-center justify-between gap-3 p-4 text-sm"
@click="open = !open"
>
<span class="flex items-center gap-2">
<IconBrandInstagram class="size-5" />
<span>{{ $t('posts.form.instagram.settings') }}</span>
<span v-if="socialAccount" class="text-muted-foreground">·&nbsp;@{{ socialAccount.username }}</span>
<span class="flex min-w-0 items-center gap-2">
<span class="inline-flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img :src="getPlatformLogo(socialAccount?.platform ?? 'instagram')" alt="Instagram" class="size-full object-cover" />
</span>
<span class="truncate font-bold text-foreground">{{ $t('posts.form.instagram.settings') }}</span>
<span v-if="socialAccount?.username" class="truncate font-medium text-foreground/60">·&nbsp;@{{ socialAccount.username }}</span>
</span>
<IconChevronUp v-if="open" class="h-4 w-4 text-muted-foreground" />
<IconChevronDown v-else class="h-4 w-4 text-muted-foreground" />
<IconChevronUp v-if="open" class="size-4 shrink-0 text-foreground/60" />
<IconChevronDown v-else class="size-4 shrink-0 text-foreground/60" />
</button>
<div v-if="open" class="space-y-5 border-t px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-muted/50 p-3">
<div v-if="open" class="space-y-5 border-t-2 border-foreground/10 px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3">
<Avatar
:src="socialAccount.avatar_url"
:name="socialAccount.display_name"
class="h-9 w-9 shrink-0 rounded-full"
class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs"
/>
<div class="min-w-0 flex-1">
<p class="text-xs text-muted-foreground">{{ $t('posts.form.instagram.posting_to') }}</p>
<p class="truncate text-sm font-medium">
{{ socialAccount.display_name }}
<span class="text-muted-foreground">@{{ socialAccount.username }}</span>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.instagram.posting_to') }}</p>
<p class="truncate text-sm">
<span class="font-bold text-foreground">{{ socialAccount.display_name }}</span>
<span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span>
</p>
</div>
</div>
<div class="space-y-2">
<p class="text-sm font-medium">{{ $t('posts.form.instagram.variant_label') }}</p>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.instagram.variant_label') }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="variant in variants"
:key="variant.value"
type="button"
class="rounded-full border px-3 py-1.5 text-xs transition-colors"
class="cursor-pointer rounded-full border-2 px-3 py-1 text-xs font-bold uppercase tracking-widest transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="contentType === variant.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'"
? 'border-foreground bg-violet-100 text-foreground shadow-2xs'
: 'border-foreground/30 text-foreground/70 hover:border-foreground hover:text-foreground'"
:disabled="disabled"
@click="pickVariant(variant.value)"
>
@ -115,16 +118,16 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
</div>
<div v-if="isFeed" class="space-y-2">
<p class="text-sm font-medium">{{ $t('posts.form.instagram.aspect_label') }}</p>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.instagram.aspect_label') }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="ratio in aspectRatios"
:key="ratio.value"
type="button"
class="rounded-full border px-3 py-1.5 text-xs transition-colors"
class="cursor-pointer rounded-full border-2 px-3 py-1 text-xs font-bold uppercase tracking-widest transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="selectedAspectRatio === ratio.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'"
? 'border-foreground bg-violet-100 text-foreground shadow-2xs'
: 'border-foreground/30 text-foreground/70 hover:border-foreground hover:text-foreground'"
:disabled="disabled"
@click="pickAspectRatio(ratio.value)"
>
@ -135,9 +138,9 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
<p
v-if="warning"
class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive"
class="flex items-start gap-2 rounded-lg border-2 border-foreground bg-rose-50 p-2 text-xs font-semibold text-rose-700"
>
<IconAlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<IconAlertTriangle class="mt-0.5 size-3.5 shrink-0" />
{{ $t(`posts.form.warnings.${warning.key}`, warning.params) }}
</p>
</div>

View file

@ -1,9 +1,10 @@
<script setup lang="ts">
import { IconAlertTriangle, IconBrandLinkedin, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType } from '@/enums/content-type';
interface SocialAccount {
@ -55,48 +56,50 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
</script>
<template>
<div class="rounded-lg border">
<div class="rounded-xl border-2 border-foreground bg-card shadow-2xs">
<button
type="button"
class="flex w-full items-center justify-between p-4 text-sm font-medium"
class="flex w-full cursor-pointer items-center justify-between gap-3 p-4 text-sm"
@click="open = !open"
>
<span class="flex items-center gap-2">
<IconBrandLinkedin class="size-5" />
<span>{{ isPage ? $t('posts.form.linkedin.settings_page') : $t('posts.form.linkedin.settings') }}</span>
<span v-if="socialAccount" class="text-muted-foreground">·&nbsp;@{{ socialAccount.username }}</span>
<span class="flex min-w-0 items-center gap-2">
<span class="inline-flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img :src="getPlatformLogo(platform)" alt="LinkedIn" class="size-full object-cover" />
</span>
<span class="truncate font-bold text-foreground">{{ isPage ? $t('posts.form.linkedin.settings_page') : $t('posts.form.linkedin.settings') }}</span>
<span v-if="socialAccount?.username" class="truncate font-medium text-foreground/60">·&nbsp;@{{ socialAccount.username }}</span>
</span>
<IconChevronUp v-if="open" class="h-4 w-4 text-muted-foreground" />
<IconChevronDown v-else class="h-4 w-4 text-muted-foreground" />
<IconChevronUp v-if="open" class="size-4 shrink-0 text-foreground/60" />
<IconChevronDown v-else class="size-4 shrink-0 text-foreground/60" />
</button>
<div v-if="open" class="space-y-5 border-t px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-muted/50 p-3">
<div v-if="open" class="space-y-5 border-t-2 border-foreground/10 px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3">
<Avatar
:src="socialAccount.avatar_url"
:name="socialAccount.display_name"
class="h-9 w-9 shrink-0 rounded-full"
class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs"
/>
<div class="min-w-0 flex-1">
<p class="text-xs text-muted-foreground">{{ $t('posts.form.linkedin.posting_to') }}</p>
<p class="truncate text-sm font-medium">
{{ socialAccount.display_name }}
<span class="text-muted-foreground">@{{ socialAccount.username }}</span>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.linkedin.posting_to') }}</p>
<p class="truncate text-sm">
<span class="font-bold text-foreground">{{ socialAccount.display_name }}</span>
<span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span>
</p>
</div>
</div>
<div class="space-y-2">
<p class="text-sm font-medium">{{ $t('posts.form.linkedin.variant_label') }}</p>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.linkedin.variant_label') }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="variant in variants"
:key="variant.value"
type="button"
class="rounded-full border px-3 py-1.5 text-xs transition-colors"
class="cursor-pointer rounded-full border-2 px-3 py-1 text-xs font-bold uppercase tracking-widest transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="contentType === variant.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'"
? 'border-foreground bg-violet-100 text-foreground shadow-2xs'
: 'border-foreground/30 text-foreground/70 hover:border-foreground hover:text-foreground'"
:disabled="disabled"
@click="pickVariant(variant.value)"
>
@ -107,9 +110,9 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
<p
v-if="warning"
class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive"
class="flex items-start gap-2 rounded-lg border-2 border-foreground bg-rose-50 p-2 text-xs font-semibold text-rose-700"
>
<IconAlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<IconAlertTriangle class="mt-0.5 size-3.5 shrink-0" />
{{ $t(`posts.form.warnings.${warning.key}`, warning.params) }}
</p>
</div>

View file

@ -1,9 +1,10 @@
<script setup lang="ts">
import { IconAlertTriangle, IconBrandPinterest, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType } from '@/enums/content-type';
interface SocialAccount {
@ -46,48 +47,50 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
</script>
<template>
<div class="rounded-lg border">
<div class="rounded-xl border-2 border-foreground bg-card shadow-2xs">
<button
type="button"
class="flex w-full items-center justify-between p-4 text-sm font-medium"
class="flex w-full cursor-pointer items-center justify-between gap-3 p-4 text-sm"
@click="open = !open"
>
<span class="flex items-center gap-2">
<IconBrandPinterest class="size-5" />
<span>{{ $t('posts.form.pinterest.settings') }}</span>
<span v-if="socialAccount" class="text-muted-foreground">·&nbsp;@{{ socialAccount.username }}</span>
<span class="flex min-w-0 items-center gap-2">
<span class="inline-flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img :src="getPlatformLogo('pinterest')" alt="Pinterest" class="size-full object-cover" />
</span>
<span class="truncate font-bold text-foreground">{{ $t('posts.form.pinterest.settings') }}</span>
<span v-if="socialAccount?.username" class="truncate font-medium text-foreground/60">·&nbsp;@{{ socialAccount.username }}</span>
</span>
<IconChevronUp v-if="open" class="h-4 w-4 text-muted-foreground" />
<IconChevronDown v-else class="h-4 w-4 text-muted-foreground" />
<IconChevronUp v-if="open" class="size-4 shrink-0 text-foreground/60" />
<IconChevronDown v-else class="size-4 shrink-0 text-foreground/60" />
</button>
<div v-if="open" class="space-y-5 border-t px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-muted/50 p-3">
<div v-if="open" class="space-y-5 border-t-2 border-foreground/10 px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3">
<Avatar
:src="socialAccount.avatar_url"
:name="socialAccount.display_name"
class="h-9 w-9 shrink-0 rounded-full"
class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs"
/>
<div class="min-w-0 flex-1">
<p class="text-xs text-muted-foreground">{{ $t('posts.form.pinterest.posting_to') }}</p>
<p class="truncate text-sm font-medium">
{{ socialAccount.display_name }}
<span class="text-muted-foreground">@{{ socialAccount.username }}</span>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.pinterest.posting_to') }}</p>
<p class="truncate text-sm">
<span class="font-bold text-foreground">{{ socialAccount.display_name }}</span>
<span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span>
</p>
</div>
</div>
<div class="space-y-2">
<p class="text-sm font-medium">{{ $t('posts.form.pinterest.variant_label') }}</p>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.pinterest.variant_label') }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="variant in variants"
:key="variant.value"
type="button"
class="rounded-full border px-3 py-1.5 text-xs transition-colors"
class="cursor-pointer rounded-full border-2 px-3 py-1 text-xs font-bold uppercase tracking-widest transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="contentType === variant.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'"
? 'border-foreground bg-violet-100 text-foreground shadow-2xs'
: 'border-foreground/30 text-foreground/70 hover:border-foreground hover:text-foreground'"
:disabled="disabled"
@click="pickVariant(variant.value)"
>
@ -98,9 +101,9 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
<p
v-if="warning"
class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive"
class="flex items-start gap-2 rounded-lg border-2 border-foreground bg-rose-50 p-2 text-xs font-semibold text-rose-700"
>
<IconAlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<IconAlertTriangle class="mt-0.5 size-3.5 shrink-0" />
{{ $t(`posts.form.warnings.${warning.key}`, warning.params) }}
</p>
</div>

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