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:
commit
3953654f89
204 changed files with 3721 additions and 2957 deletions
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
|
|
@ -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'] }}
|
||||
|
|
|
|||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -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'`.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ public function index(Request $request): Response|RedirectResponse
|
|||
'filters' => [
|
||||
'search' => $request->input('search', ''),
|
||||
],
|
||||
'openDialog' => $request->boolean('openDialog'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
app/Http/Requests/App/Post/StorePostRequest.php
Normal file
26
app/Http/Requests/App/Post/StorePostRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? '';
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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...',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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...',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
|
|
|
|||
|
|
@ -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
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
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
1
lang/php_pt-BR.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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...',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
124
resources/js/components/analytics/AnalyticsAccountSelector.vue
Normal file
124
resources/js/components/analytics/AnalyticsAccountSelector.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
138
resources/js/components/analytics/MetricsGrid.vue
Normal file
138
resources/js/components/analytics/MetricsGrid.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
7
resources/js/components/analytics/types.ts
Normal file
7
resources/js/components/analytics/types.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export interface AnalyticsAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
display_name: string;
|
||||
username: string | null;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
41
resources/js/components/labels/LabelBadge.vue
Normal file
41
resources/js/components/labels/LabelBadge.vue
Normal 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>
|
||||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">· @{{ 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">· @{{ 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"> @{{ 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>
|
||||
|
|
|
|||
|
|
@ -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">· @{{ 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">· @{{ 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"> @{{ 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>
|
||||
|
|
|
|||
|
|
@ -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">· @{{ 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">· @{{ 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"> @{{ 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>
|
||||
|
|
|
|||
|
|
@ -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">· @{{ 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">· @{{ 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"> @{{ 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
Loading…
Reference in a new issue