chore: posthog, ui, features and more

This commit is contained in:
Paulo Castellano 2026-03-30 21:18:07 -03:00
parent 91ad58cd01
commit 843b3991ec
69 changed files with 1697 additions and 264 deletions

View file

@ -120,19 +120,27 @@ THREADS_CLIENT_ID=
THREADS_CLIENT_SECRET=
THREADS_CLIENT_REDIRECT="${APP_URL}/accounts/threads/callback"
# YouTube / Google (https://console.cloud.google.com)
# Google (https://console.cloud.google.com)
# Used for YouTube social account connection AND Google login/signup
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CLIENT_REDIRECT="${APP_URL}/accounts/youtube/callback"
GOOGLE_AUTH_CALLBACK="${APP_URL}/auth/google/callback"
# Pinterest (https://developers.pinterest.com)
PINTEREST_CLIENT_ID=
PINTEREST_CLIENT_SECRET=
PINTEREST_CLIENT_REDIRECT="${APP_URL}/accounts/pinterest/callback"
# PostHog (optional - analytics)
POSTHOG_API_KEY=
POSTHOG_HOST=https://us.i.posthog.com
# Vite
VITE_APP_NAME="${APP_NAME}"
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
VITE_POSTHOG_API_KEY="${POSTHOG_API_KEY}"
VITE_POSTHOG_HOST="${POSTHOG_HOST}"

View file

@ -44,7 +44,7 @@ ## Skills Activation
- `cashier-stripe-development` — Handles Laravel Cashier Stripe integration including subscriptions, webhooks, Stripe Checkout, invoices, charges, refunds, trials, coupons, metered billing, and payment failure handling. Triggered when a user mentions Cashier, Billable, IncompletePayment, stripe_id, newSubscription, Stripe subscriptions, or billing. Also applies when setting up webhooks, handling SCA/3DS payment failures, testing with Stripe test cards, or troubleshooting incomplete subscriptions, CSRF webhook errors, or migration publish issues.
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
- `configuring-horizon` — Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching.
- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-\* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP.
- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP.
- `socialite-development` — Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication.
- `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task
- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.
@ -115,7 +115,7 @@ ## Tinker
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
- Always use single quotes to prevent shell expansion: `vendor/bin/sail artisan tinker --execute 'Your::code();'`
- Double quotes for PHP strings inside: `vendor/bin/sail artisan tinker --execute 'User::where("active", true)->count();'`
- Double quotes for PHP strings inside: `vendor/bin/sail artisan tinker --execute 'User::where("active", true)->count();'`
=== php rules ===
@ -228,7 +228,6 @@ ## Pest
# Inertia + Vue
Vue components must have a single root element.
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
</laravel-boost-guidelines>

View file

@ -28,7 +28,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P
'scheduled_at' => $scheduledAt,
]);
$socialAccounts = $workspace->socialAccounts;
$socialAccounts = $workspace->socialAccounts()->active()->get();
foreach ($socialAccounts as $account) {
$post->postPlatforms()->create([

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Actions\User;
use App\Enums\User\Setup;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;
class CreateUser
{
/**
* @param array{name: string, email: string, password?: string, timezone?: string, setup?: Setup, email_verified_at?: \DateTimeInterface|null} $data
*/
public static function execute(array $data): User
{
return DB::transaction(function () use ($data): User {
$isInviteRegistration = data_get($data, 'is_invite', false);
$user = User::create([
'name' => data_get($data, 'name'),
'email' => data_get($data, 'email'),
'password' => data_get($data, 'password'),
'setup' => data_get($data, 'setup', $isInviteRegistration ? Setup::Completed : Setup::Role),
'email_verified_at' => data_get($data, 'email_verified_at', $isInviteRegistration ? now() : null),
]);
$workspace = Workspace::create([
'user_id' => $user->id,
'name' => $user->name."'s Workspace",
'timezone' => data_get($data, 'timezone', 'UTC'),
]);
$workspace->members()->attach($user->id, ['role' => 'owner']);
$user->update(['current_workspace_id' => $workspace->id]);
return $user;
});
}
}

View file

@ -41,10 +41,17 @@ public function index(Request $request, ?string $status = null): Response|Redire
};
}
if ($search = $request->input('search')) {
$query->whereHas('postPlatforms', fn ($q) => $q->where('content', 'ilike', "%{$search}%"));
}
return Inertia::render('posts/Index', [
'workspace' => $workspace,
'posts' => Inertia::scroll(fn () => $query->latest('scheduled_at')->paginate(15)),
'posts' => Inertia::scroll(fn () => $query->latest('scheduled_at')->paginate(config('app.pagination.default'))),
'currentStatus' => $status,
'filters' => [
'search' => $request->input('search', ''),
],
]);
}
@ -114,7 +121,7 @@ public function store(Request $request): RedirectResponse|\Symfony\Component\Htt
$this->authorize('createPost', $workspace);
$socialAccounts = $workspace->socialAccounts;
$socialAccounts = $workspace->socialAccounts()->active()->get();
if ($socialAccounts->isEmpty()) {
session()->flash('flash.banner', __('posts.flash.connect_first'));
@ -145,7 +152,7 @@ public function edit(Request $request, Post $post): Response|RedirectResponse
}
$post->load(['postPlatforms.socialAccount', 'postPlatforms.media', 'labels']);
$socialAccounts = $workspace->socialAccounts;
$socialAccounts = $workspace->socialAccounts()->active()->get();
$labels = $workspace->labels;
$hashtags = $workspace->hashtags;

View file

@ -25,9 +25,17 @@ public function index(Request $request): Response|RedirectResponse
$this->authorize('view', $workspace);
$hashtags = $workspace->hashtags()
->when($request->input('search'), fn ($query, $search) => $query->where('name', 'ilike', "%{$search}%"))
->latest()
->paginate(config('app.pagination.default'));
return Inertia::render('hashtags/Index', [
'workspace' => $workspace,
'hashtags' => $workspace->hashtags()->latest()->get(),
'hashtags' => Inertia::scroll(fn () => $hashtags),
'filters' => [
'search' => $request->input('search', ''),
],
]);
}

View file

@ -25,9 +25,17 @@ public function index(Request $request): Response|RedirectResponse
$this->authorize('view', $workspace);
$labels = $workspace->labels()
->when($request->input('search'), fn ($query, $search) => $query->where('name', 'ilike', "%{$search}%"))
->latest()
->paginate(config('app.pagination.default'));
return Inertia::render('labels/Index', [
'workspace' => $workspace,
'labels' => $workspace->labels()->latest()->get(),
'labels' => Inertia::scroll(fn () => $labels),
'filters' => [
'search' => $request->input('search', ''),
],
]);
}

View file

@ -4,25 +4,20 @@
namespace App\Http\Controllers\Auth;
use App\Enums\User\Setup;
use App\Actions\User\CreateUser;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\Workspace;
use App\Rules\Timezone;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rules;
use Inertia\Inertia;
use Inertia\Response;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(Request $request): Response
{
return Inertia::render('auth/Register', [
@ -31,9 +26,6 @@ public function create(Request $request): Response
]);
}
/**
* Handle an incoming registration request.
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
@ -43,41 +35,21 @@ public function store(Request $request): RedirectResponse
'timezone' => ['nullable', 'string', new Timezone],
]);
// Check if registering via invite link (redirect contains /invites/)
$isInviteRegistration = str_contains($request->input('redirect', ''), '/invites/');
$user = DB::transaction(function () use ($request, $isInviteRegistration) {
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => $request->password,
'setup' => $isInviteRegistration ? Setup::Completed : Setup::Role,
'email_verified_at' => $isInviteRegistration ? now() : null,
]);
// Create default workspace for new user
$workspace = Workspace::create([
'user_id' => $user->id,
'name' => $user->name."'s Workspace",
'timezone' => $request->input('timezone', 'UTC'),
]);
// Add user as owner member
$workspace->members()->attach($user->id, ['role' => 'owner']);
// Set as current workspace
$user->update(['current_workspace_id' => $workspace->id]);
return $user;
});
$user = CreateUser::execute([
'name' => $request->name,
'email' => $request->email,
'password' => $request->password,
'timezone' => $request->input('timezone', 'UTC'),
'is_invite' => $isInviteRegistration,
]);
event(new Registered($user));
Auth::login($user);
// Check for redirect param
if ($redirect = $request->input('redirect')) {
// Only allow internal redirects (paths starting with /)
if (str_starts_with($redirect, '/') && ! str_starts_with($redirect, '//')) {
return redirect($redirect);
}

View file

@ -81,6 +81,25 @@ public function disconnect(Request $request, SocialAccount $account): RedirectRe
return back();
}
public function toggleActive(Request $request, SocialAccount $account): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
$this->authorize('manageAccounts', $workspace);
if ($account->workspace_id !== $workspace->id) {
abort(403);
}
$account->update(['is_active' => ! $account->is_active]);
$status = $account->is_active ? 'activated' : 'deactivated';
session()->flash('flash.banner', __("accounts.flash.{$status}"));
session()->flash('flash.bannerStyle', 'success');
return back();
}
protected function redirectToProvider(Request $request, string $driver, array $scopes): SymfonyResponse
{
$workspace = $request->user()->currentWorkspace;

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Actions\User\CreateUser;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
class SocialLoginController extends Controller
{
public function redirect(): RedirectResponse
{
return Socialite::driver('google-auth')->redirect();
}
public function callback(): RedirectResponse
{
$googleUser = Socialite::driver('google-auth')->user();
$user = User::where('email', $googleUser->getEmail())->first();
if ($user) {
return $this->loginExistingUser($user);
}
return $this->registerNewUser($googleUser);
}
private function loginExistingUser(User $user): RedirectResponse
{
if (! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
}
Auth::login($user, remember: true);
return redirect()->route('app.home');
}
private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser): RedirectResponse
{
$user = CreateUser::execute([
'name' => $googleUser->getName(),
'email' => $googleUser->getEmail(),
'email_verified_at' => now(),
]);
event(new Registered($user));
Auth::login($user, remember: true);
return redirect()->route('app.onboarding.role');
}
}

View file

@ -41,6 +41,14 @@ public function __construct(public PostPlatform $postPlatform) {}
public function handle(): void
{
if (! $this->postPlatform->socialAccount->is_active) {
$this->postPlatform->markAsFailed(__('posts.errors.account_inactive'));
$this->updatePostStatus();
$this->broadcastStatus();
return;
}
if ($this->postPlatform->socialAccount->isDisconnected()) {
$this->postPlatform->markAsFailed(__('posts.errors.account_disconnected'));
$this->updatePostStatus();

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use PostHog\PostHog;
class SendPostHogEvent implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 15;
/**
* @param array<array{method: string, payload: array<string, mixed>}> $calls
*/
public function __construct(
public array $calls,
) {
$this->onQueue('posthog');
}
public function handle(): void
{
if (! config('services.posthog.api_key')) {
return;
}
foreach ($this->calls as $call) {
match ($call['method']) {
'capture' => PostHog::capture($call['payload']),
'identify' => PostHog::identify($call['payload']),
'groupIdentify' => PostHog::groupIdentify($call['payload']),
default => Log::warning('SendPostHogEvent: unknown method', ['method' => $call['method']]),
};
}
PostHog::flush();
}
}

View file

@ -11,6 +11,7 @@
use App\Jobs\SendNotification;
use App\Mail\AccountDisconnected;
use Database\Factories\SocialAccountFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -38,6 +39,7 @@ class SocialAccount extends Model
'scopes',
'meta',
'status',
'is_active',
'error_message',
'disconnected_at',
];
@ -52,6 +54,7 @@ protected function casts(): array
return [
'platform' => SocialPlatform::class,
'status' => Status::class,
'is_active' => 'boolean',
'access_token' => 'encrypted',
'refresh_token' => 'encrypted',
'token_expires_at' => 'datetime',
@ -141,4 +144,9 @@ public function isDisconnected(): bool
{
return $this->status === Status::Disconnected || $this->status === Status::TokenExpired;
}
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true)->orderBy('platform');
}
}

View file

@ -40,6 +40,8 @@
use Laravel\Nightwatch\Facades\Nightwatch;
use Laravel\Nightwatch\Records\CacheEvent;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\GoogleProvider;
use PostHog\PostHog;
use SocialiteProviders\Facebook\FacebookExtendSocialite;
use SocialiteProviders\LinkedIn\LinkedInExtendSocialite;
use SocialiteProviders\Manager\SocialiteWasCalled;
@ -66,6 +68,7 @@ public function boot(): void
{
$this->configureDefaults();
$this->configureMorphMap();
$this->configurePostHog();
$this->configureRateLimiting();
$this->configureSocialite();
$this->configureStripeWebhooks();
@ -93,6 +96,17 @@ protected function configureMorphMap(): void
]);
}
protected function configurePostHog(): void
{
$apiKey = config('services.posthog.api_key');
if ($apiKey) {
PostHog::init($apiKey, [
'host' => config('services.posthog.host'),
]);
}
}
protected function configureRateLimiting(): void
{
RateLimiter::for('api', function (Request $request) {
@ -111,6 +125,13 @@ protected function configureStripeWebhooks(): void
protected function configureSocialite(): void
{
// Google Auth (login/signup) - separate from YouTube OAuth
Socialite::extend('google-auth', function ($app) {
$config = $app['config']['services.google-auth'];
return Socialite::buildProvider(GoogleProvider::class, $config);
});
// Instagram Business Login
Socialite::extend('instagram', function ($app) {
$config = $app['config']['services.instagram'];

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Jobs\SendPostHogEvent;
use Illuminate\Support\Facades\Log;
class PostHogService
{
/**
* @param array<string, mixed> $properties
*/
public function capture(string $distinctId, string $event, array $properties = []): void
{
if (! config('services.posthog.api_key')) {
return;
}
$this->dispatch('capture', [
'distinctId' => $distinctId,
'event' => $event,
'properties' => $properties,
]);
}
/**
* @param array<string, mixed> $properties
*/
public function identify(string $distinctId, array $properties = []): void
{
if (! config('services.posthog.api_key')) {
return;
}
$this->dispatch('identify', [
'distinctId' => $distinctId,
'properties' => $properties,
]);
}
/**
* @param array<string, mixed> $properties
*/
public function groupIdentify(string $groupType, string $groupKey, array $properties = []): void
{
if (! config('services.posthog.api_key')) {
return;
}
$this->dispatch('groupIdentify', [
'groupType' => $groupType,
'groupKey' => $groupKey,
'properties' => $properties,
]);
}
/**
* @param array<string, mixed> $payload
*/
private function dispatch(string $method, array $payload): void
{
try {
SendPostHogEvent::dispatch([
['method' => $method, 'payload' => $payload],
]);
} catch (\Throwable $e) {
Log::warning('PostHogService: failed to dispatch event', ['method' => $method, 'error' => $e->getMessage()]);
}
}
}

View file

@ -47,6 +47,7 @@
"laravel/tinker": "^3.0",
"laravel/wayfinder": "^0.1.9",
"league/flysystem-aws-s3-v3": "^3.0",
"posthog/posthog-php": "^4.1",
"predis/predis": "^3.3",
"sendkit/sendkit-laravel": "^1.1",
"socialiteproviders/facebook": "^4.1",

55
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "5408bdeb64bc5066a115e08ba7f378f3",
"content-hash": "f97b0298cdb7a80d1c5d1706165b6fe9",
"packages": [
{
"name": "aws/aws-crt-php",
@ -4534,6 +4534,59 @@
},
"time": "2026-01-25T14:56:51+00:00"
},
{
"name": "posthog/posthog-php",
"version": "4.1.1",
"source": {
"type": "git",
"url": "https://github.com/PostHog/posthog-php.git",
"reference": "6fe1346ea8b178a9b2c79777bc980892f0486cbb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PostHog/posthog-php/zipball/6fe1346ea8b178a9b2c79777bc980892f0486cbb",
"reference": "6fe1346ea8b178a9b2c79777bc980892f0486cbb",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": ">=8.2",
"symfony/clock": "^6.2|^7.0|^8.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"squizlabs/php_codesniffer": "^3.7"
},
"bin": [
"bin/posthog"
],
"type": "library",
"autoload": {
"psr-4": {
"PostHog\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PostHog <hey@posthog.com>",
"homepage": "https://posthog.com/"
}
],
"description": "PostHog PHP Library",
"homepage": "https://github.com/PostHog/posthog-php",
"keywords": [
"posthog"
],
"support": {
"issues": "https://github.com/PostHog/posthog-php/issues",
"source": "https://github.com/PostHog/posthog-php/tree/4.1.1"
},
"time": "2026-03-30T12:02:24+00:00"
},
{
"name": "predis/predis",
"version": "v3.4.2",

View file

@ -88,6 +88,18 @@
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Application Pagination
|--------------------------------------------------------------------------
|
| This is default pagination value for the application.
|
*/
'pagination' => [
'default' => 25,
],
/*
|--------------------------------------------------------------------------
| Encryption Key

View file

@ -69,6 +69,13 @@
'redirect' => env('GOOGLE_CLIENT_REDIRECT'),
],
// Google OAuth (used for login/signup)
'google-auth' => [
'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
'redirect' => env('GOOGLE_AUTH_CALLBACK'),
],
// Facebook Pages
'facebook' => [
'client_id' => env('FACEBOOK_CLIENT_ID'),
@ -97,4 +104,9 @@
'redirect' => env('PINTEREST_CLIENT_REDIRECT'),
],
'posthog' => [
'api_key' => env('POSTHOG_API_KEY'),
'host' => env('POSTHOG_HOST', 'https://us.i.posthog.com'),
],
];

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('social_accounts', function (Blueprint $table) {
$table->boolean('is_active')->default(true)->after('status');
});
}
public function down(): void
{
Schema::table('social_accounts', function (Blueprint $table) {
$table->dropColumn('is_active');
});
}
};

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('password')->nullable()->change();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('password')->nullable(false)->change();
});
}
};

View file

@ -62,6 +62,8 @@
'connected' => 'Account connected successfully!',
'session_expired' => 'Session expired. Please try again.',
'workspace_not_found' => 'Workspace not found.',
'activated' => 'Account activated!',
'deactivated' => 'Account deactivated!',
'already_connected' => 'This platform is already connected.',
'no_youtube_channels' => 'No YouTube channels found. Please create a channel first.',
],

View file

@ -51,6 +51,10 @@
],
],
'or_continue_with' => 'Or continue with',
'google_login' => 'Log in with Google',
'google_signup' => 'Sign up with Google',
'login' => [
'title' => 'Log in to your account',
'description' => 'Enter your email and password below to log in',

View file

@ -3,6 +3,7 @@
return [
'title' => 'Hashtags',
'description' => 'Create hashtag groups to quickly add to your posts',
'search' => 'Search hashtags...',
'new_group' => 'New Group',
'no_groups_yet' => 'No hashtag groups yet',
'no_groups_description' => 'Create hashtag groups to quickly add popular hashtags to your posts',

View file

@ -3,6 +3,7 @@
return [
'title' => 'Labels',
'description' => 'Create labels to organize and categorize your posts',
'search' => 'Search labels...',
'new_label' => 'New Label',
'no_labels_yet' => 'No labels yet',
'create_first_label' => 'Create your first label',

View file

@ -2,6 +2,7 @@
return [
'title' => 'Posts',
'search' => 'Search posts...',
'all_posts' => 'All Posts',
'new_post' => 'New Post',
'no_posts' => 'No posts found',
@ -224,5 +225,6 @@
'errors' => [
'account_disconnected' => 'Social account is disconnected',
'account_inactive' => 'Social account is deactivated',
],
];

View file

@ -43,6 +43,7 @@
'notifications' => 'Notifications',
'mark_all_read' => 'Mark all as read',
'mark_as_read' => 'Mark as read',
'archive_all' => 'Archive all',
'no_notifications' => 'No notifications',

View file

@ -62,6 +62,8 @@
'connected' => '¡Cuenta conectada correctamente!',
'session_expired' => 'Sesión expirada. Inténtalo de nuevo.',
'workspace_not_found' => 'Workspace no encontrado.',
'activated' => '¡Cuenta activada!',
'deactivated' => '¡Cuenta desactivada!',
'already_connected' => 'Esta plataforma ya está conectada.',
'no_youtube_channels' => 'No se encontraron canales de YouTube. Crea un canal primero.',
],

View file

@ -39,6 +39,10 @@
],
],
'or_continue_with' => 'O continuar con',
'google_login' => 'Iniciar sesión con Google',
'google_signup' => 'Registrarse con Google',
'login' => [
'title' => 'Inicia sesión en tu cuenta',
'description' => 'Introduce tu correo y contraseña para iniciar sesión',

View file

@ -3,6 +3,7 @@
return [
'title' => 'Hashtags',
'description' => 'Crea grupos de hashtags para agregarlos rápidamente a tus posts',
'search' => 'Buscar hashtags...',
'new_group' => 'Nuevo grupo',
'no_groups_yet' => 'Aún no hay grupos de hashtags',
'no_groups_description' => 'Crea grupos de hashtags para agregar rápidamente hashtags populares a tus posts',

View file

@ -3,6 +3,7 @@
return [
'title' => 'Etiquetas',
'description' => 'Crea etiquetas para organizar y categorizar tus posts',
'search' => 'Buscar etiquetas...',
'new_label' => 'Nueva etiqueta',
'no_labels_yet' => 'Aún no hay etiquetas',
'create_first_label' => 'Crea tu primera etiqueta',

View file

@ -2,6 +2,7 @@
return [
'title' => 'Posts',
'search' => 'Buscar posts...',
'all_posts' => 'Todos los posts',
'new_post' => 'Nuevo post',
'no_posts' => 'No se encontraron posts',
@ -224,5 +225,6 @@
'errors' => [
'account_disconnected' => 'Cuenta social desconectada',
'account_inactive' => 'Cuenta social desactivada',
],
];

View file

@ -43,6 +43,7 @@
'notifications' => 'Notificaciones',
'mark_all_read' => 'Marcar todo como leído',
'mark_as_read' => 'Marcar como leído',
'archive_all' => 'Archivar todo',
'no_notifications' => 'Sin notificaciones',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -62,6 +62,8 @@
'connected' => 'Conta conectada com sucesso!',
'session_expired' => 'Sessão expirada. Por favor, tente novamente.',
'workspace_not_found' => 'Workspace não encontrado.',
'activated' => 'Conta ativada!',
'deactivated' => 'Conta desativada!',
'already_connected' => 'Esta plataforma já está conectada.',
'no_youtube_channels' => 'Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.',
],

View file

@ -51,6 +51,10 @@
],
],
'or_continue_with' => 'Ou continue com',
'google_login' => 'Entrar com Google',
'google_signup' => 'Cadastrar com Google',
'login' => [
'title' => 'Entrar na sua conta',
'description' => 'Digite seu email e senha abaixo para entrar',

View file

@ -3,6 +3,7 @@
return [
'title' => 'Hashtags',
'description' => 'Crie grupos de hashtags para adicionar rapidamente aos seus posts',
'search' => 'Buscar hashtags...',
'new_group' => 'Novo Grupo',
'no_groups_yet' => 'Nenhum grupo de hashtags ainda',
'no_groups_description' => 'Crie grupos de hashtags para adicionar rapidamente hashtags populares aos seus posts',

View file

@ -3,6 +3,7 @@
return [
'title' => 'Etiquetas',
'description' => 'Crie etiquetas para organizar e categorizar seus posts',
'search' => 'Buscar etiquetas...',
'new_label' => 'Nova Etiqueta',
'no_labels_yet' => 'Nenhuma etiqueta ainda',
'create_first_label' => 'Crie sua primeira etiqueta',

View file

@ -2,6 +2,7 @@
return [
'title' => 'Posts',
'search' => 'Buscar posts...',
'all_posts' => 'Todos os Posts',
'new_post' => 'Novo Post',
'no_posts' => 'Nenhum post encontrado',
@ -224,5 +225,6 @@
'errors' => [
'account_disconnected' => 'Conta social está desconectada',
'account_inactive' => 'Conta social está desativada',
],
];

View file

@ -43,6 +43,7 @@
'notifications' => 'Notificações',
'mark_all_read' => 'Marcar tudo como lido',
'mark_as_read' => 'Marcar como lido',
'archive_all' => 'Arquivar tudo',
'no_notifications' => 'Sem notificações',

440
package-lock.json generated
View file

@ -17,6 +17,7 @@
"laravel-vite-plugin": "^2.0.0",
"laravel-vue-i18n": "^2.8.0",
"maska": "^3.2.0",
"posthog-js": "^1.364.2",
"reka-ui": "^2.7.0",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.1",
@ -1020,6 +1021,331 @@
"node": ">= 8"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/api-logs": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz",
"integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.3.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/core": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz",
"integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/exporter-logs-otlp-http": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz",
"integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.208.0",
"@opentelemetry/core": "2.2.0",
"@opentelemetry/otlp-exporter-base": "0.208.0",
"@opentelemetry/otlp-transformer": "0.208.0",
"@opentelemetry/sdk-logs": "0.208.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-exporter-base": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz",
"integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/otlp-transformer": "0.208.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-transformer": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz",
"integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.208.0",
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0",
"@opentelemetry/sdk-logs": "0.208.0",
"@opentelemetry/sdk-metrics": "2.2.0",
"@opentelemetry/sdk-trace-base": "2.2.0",
"protobufjs": "^7.3.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/resources": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz",
"integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.6.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz",
"integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-logs": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz",
"integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.208.0",
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.4.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-metrics": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz",
"integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.9.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz",
"integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz",
"integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/@posthog/core": {
"version": "1.24.4",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.24.4.tgz",
"integrity": "sha512-S+TolwBHSSJz7WWtgaELQWQqXviSm3uf1e+qorWUts0bZcgPwWzhnmhCUZAhvn0NVpTQHDJ3epv+hHbPLl5dHg==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.6"
}
},
"node_modules/@posthog/types": {
"version": "1.364.2",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.364.2.tgz",
"integrity": "sha512-SMTdaYanvRmatgheXtu2XkewhuhdXe8C3JCi7m/Hd2n+sa2DaJphcwg3nAkPtfV69JHMxJLe/gyOt7yFtbQSjQ==",
"license": "MIT"
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1",
"@protobufjs/inquire": "^1.1.0"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.53",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
@ -1758,12 +2084,18 @@
"version": "22.19.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz",
"integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
@ -3025,11 +3357,21 @@
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@ -3224,6 +3566,15 @@
"node": ">=0.10.0"
}
},
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -4118,6 +4469,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.4.8",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@ -5014,7 +5371,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/jiti": {
@ -5438,6 +5794,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -5806,7 +6168,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -5895,6 +6256,37 @@
"node": ">=4"
}
},
"node_modules/posthog-js": {
"version": "1.364.2",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.364.2.tgz",
"integrity": "sha512-ryeCFcaORLouVI5wsKxnraIDvKFM6RAxbbKlKuqo+A8VFZ9JvvRpwzfiMQR2trGsJUYcn6B3R4Rn0Xht9NrhAQ==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.208.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
"@opentelemetry/resources": "^2.2.0",
"@opentelemetry/sdk-logs": "^0.208.0",
"@posthog/core": "1.24.4",
"@posthog/types": "1.364.2",
"core-js": "^3.38.1",
"dompurify": "^3.3.2",
"fflate": "^0.4.8",
"preact": "^10.28.2",
"query-selector-shadow-dom": "^1.0.1",
"web-vitals": "^5.1.0"
}
},
"node_modules/preact": {
"version": "10.29.0",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz",
"integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -6025,6 +6417,30 @@
}
}
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@ -6051,6 +6467,12 @@
"tweetnacl": "^1.0.3"
}
},
"node_modules/query-selector-shadow-dom": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz",
"integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==",
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -6438,7 +6860,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@ -6451,7 +6872,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -7061,7 +7481,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
@ -7453,11 +7872,16 @@
"typescript": ">=5.0.0"
}
},
"node_modules/web-vitals": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz",
"integrity": "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==",
"license": "Apache-2.0"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"

View file

@ -48,6 +48,7 @@
"laravel-vite-plugin": "^2.0.0",
"laravel-vue-i18n": "^2.8.0",
"maska": "^3.2.0",
"posthog-js": "^1.364.2",
"reka-ui": "^2.7.0",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.1",

View file

@ -0,0 +1,22 @@
<svg
viewBox="0 0 25 25"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.54053 15.1078L4.67031 18.3565L1.4897 18.4237C0.53916 16.6607 0 14.6436 0 12.5C0 10.4272 0.504102 8.47252 1.39766 6.75137H1.39834L4.22998 7.27051L5.47041 10.0852C5.21079 10.842 5.06929 11.6545 5.06929 12.5C5.06938 13.4176 5.2356 14.2967 5.54053 15.1078Z"
fill="#FBBB00"
></path>
<path
d="M24.7816 10.1648C24.9251 10.921 25 11.7019 25 12.5C25 13.3949 24.9059 14.2679 24.7266 15.1099C24.1181 17.9753 22.5282 20.4773 20.3256 22.2479L20.3249 22.2473L16.7583 22.0653L16.2535 18.9142C17.715 18.057 18.8572 16.7157 19.4589 15.1099H12.7748V10.1648H19.5564H24.7816Z"
fill="#518EF8"
></path>
<path
d="M20.3249 22.2473L20.3256 22.248C18.1834 23.9698 15.4622 25 12.5 25C7.73972 25 3.601 22.3393 1.48972 18.4238L5.54055 15.1079C6.59616 17.9251 9.31389 19.9307 12.5 19.9307C13.8695 19.9307 15.1525 19.5605 16.2534 18.9142L20.3249 22.2473Z"
fill="#28B446"
></path>
<path
d="M20.4787 2.87773L16.4293 6.19297C15.2899 5.48076 13.943 5.06934 12.5 5.06934C9.24177 5.06934 6.47321 7.16685 5.47048 10.0852L1.39836 6.75137H1.39767C3.47805 2.74038 7.66896 0 12.5 0C15.533 0 18.3139 1.08037 20.4787 2.87773Z"
fill="#F14336"
></path>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1,6 +1,6 @@
import '../css/app.css';
import { createInertiaApp } from '@inertiajs/vue3';
import { createInertiaApp, router } from '@inertiajs/vue3';
import { configureEcho } from '@laravel/echo-vue';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { i18nVue } from 'laravel-vue-i18n';
@ -9,6 +9,7 @@ import { createApp, h } from 'vue';
import { initializeTheme } from './composables/useAppearance';
import dayjs from './dayjs';
import posthog from './posthog';
configureEcho({
broadcaster: 'reverb',
@ -30,6 +31,27 @@ createInertiaApp({
// Set dayjs locale based on user's language
dayjs.locale(locale.toLowerCase());
const auth = props.initialPage.props.auth as { user?: { id: string; email: string; name: string }; currentWorkspace?: { id: string; name: string } } | undefined;
if (auth?.user) {
posthog.identify(auth.user.id, {
$email: auth.user.email,
$name: auth.user.name,
});
if (auth.currentWorkspace) {
posthog.group('workspace', auth.currentWorkspace.id, {
name: auth.currentWorkspace.name,
});
}
}
router.on('navigate', () => {
posthog.capture('$pageview', {
$current_url: window.location.href,
});
});
createApp({ render: () => h(App, props) })
.use(i18nVue, {
lang: locale,

View file

@ -22,7 +22,6 @@ import { store as storePost } from '@/actions/App/Http/Controllers/App/PostContr
import { index as postsIndex } from '@/actions/App/Http/Controllers/App/PostController';
import NavMain from '@/components/NavMain.vue';
import NavUser from '@/components/NavUser.vue';
import NotificationBell from '@/components/NotificationBell.vue';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
@ -215,12 +214,7 @@ const switchWorkspace = (workspaceId: string) => {
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<div class="flex items-center gap-1">
<div class="flex-1">
<NavUser />
</div>
<NotificationBell v-if="currentWorkspace" />
</div>
<NavUser />
</SidebarFooter>
</Sidebar>
</template>

View file

@ -8,6 +8,7 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import NotificationBell from '@/components/NotificationBell.vue';
import {
SidebarMenu,
SidebarMenuButton,
@ -20,12 +21,15 @@ 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();
</script>
<template>
<SidebarMenu>
<SidebarMenuItem>
<div class="flex items-center gap-1">
<NotificationBell v-if="currentWorkspace && state === 'expanded'" />
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
@ -46,6 +50,7 @@ const { isMobile, state } = useSidebar();
<UserMenuContent :user="user" />
</DropdownMenuContent>
</DropdownMenu>
</div>
</SidebarMenuItem>
</SidebarMenu>
</template>

View file

@ -1,22 +1,15 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { IconArchive, IconBell, IconCheck, IconChecks } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { onMounted, ref } from 'vue';
import { IconArchive, IconBell, IconCheck, IconChecks, IconInbox, IconX } from '@tabler/icons-vue';
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import dayjs from '@/dayjs';
import { index, read, readAll, archiveAll } from '@/routes/app/notifications';
interface Notification {
@ -33,7 +26,11 @@ interface Notification {
const notifications = ref<Notification[]>([]);
const unreadCount = ref(0);
const loading = ref(false);
const dialogOpen = ref(false);
const show = ref(false);
const panel = ref<HTMLElement | null>(null);
const csrfToken = () =>
document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
const fetchNotifications = async () => {
loading.value = true;
@ -50,12 +47,9 @@ const fetchNotifications = async () => {
}
};
const csrfToken = () =>
document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
const handleMarkAsRead = async (notification: Notification) => {
await fetch(read.url(notification.id), {
method: 'PATCH',
method: 'PUT',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': csrfToken() },
credentials: 'same-origin',
});
@ -94,7 +88,7 @@ const handleNotificationClick = (notification: Notification) => {
handleMarkAsRead(notification);
}
dialogOpen.value = false;
close();
if (notification.data?.post_id) {
router.visit(`/posts/${notification.data.post_id}/edit`);
@ -103,18 +97,63 @@ const handleNotificationClick = (notification: Notification) => {
}
};
const openDialog = () => {
dialogOpen.value = true;
const open = () => {
show.value = true;
fetchNotifications();
};
const close = () => {
show.value = false;
};
const toggle = () => {
if (show.value) {
close();
} else {
open();
}
};
const onClickOutside = (event: MouseEvent) => {
if (panel.value && !panel.value.contains(event.target as Node)) {
close();
}
};
const onEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close();
}
};
const formatTime = (date: string) => {
return dayjs.utc(date).fromNow();
};
watch(show, (value) => {
if (value) {
setTimeout(() => {
document.addEventListener('click', onClickOutside);
document.addEventListener('keydown', onEscape);
}, 0);
} else {
document.removeEventListener('click', onClickOutside);
document.removeEventListener('keydown', onEscape);
}
});
onMounted(() => {
fetchNotifications();
});
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside);
document.removeEventListener('keydown', onEscape);
});
</script>
<template>
<Button variant="ghost" size="icon" class="relative size-8 shrink-0" :title="$t('sidebar.notifications')" @click="openDialog">
<Button variant="ghost" size="icon" class="relative size-8 shrink-0" @click.stop="toggle">
<IconBell class="size-4" />
<span
v-if="unreadCount > 0"
@ -124,68 +163,104 @@ onMounted(() => {
</span>
</Button>
<Dialog v-model:open="dialogOpen">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<div class="flex items-center justify-between">
<DialogTitle>{{ $t('sidebar.notifications') }}</DialogTitle>
<div v-if="notifications.length > 0" class="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-7" @click="handleMarkAllAsRead">
<IconChecks class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('sidebar.mark_all_read') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-7" @click="handleArchiveAll">
<IconArchive class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('sidebar.archive_all') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="-translate-x-2 opacity-0"
enter-to-class="translate-x-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-x-0 opacity-100"
leave-to-class="-translate-x-2 opacity-0"
>
<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"
>
<!-- 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">
<Tooltip v-if="notifications.length > 0">
<TooltipTrigger as-child>
<button
type="button"
class="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
@click="handleMarkAllAsRead"
>
<IconChecks class="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('sidebar.mark_all_read') }}</TooltipContent>
</Tooltip>
<Tooltip v-if="notifications.length > 0">
<TooltipTrigger as-child>
<button
type="button"
class="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
@click="handleArchiveAll"
>
<IconArchive class="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('sidebar.archive_all') }}</TooltipContent>
</Tooltip>
<button
type="button"
class="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
@click="close"
>
<IconX class="size-4" />
</button>
</div>
</div>
</DialogHeader>
<div v-if="notifications.length === 0" class="py-8 text-center text-sm text-muted-foreground">
{{ $t('sidebar.no_notifications') }}
</div>
<div v-else class="-mx-6 max-h-96 overflow-y-auto">
<div
v-for="notification in notifications"
:key="notification.id"
class="flex cursor-pointer items-start gap-3 border-b px-6 py-3 transition-colors last:border-0 hover:bg-accent/50"
:class="{ 'opacity-60': notification.read_at }"
@click="handleNotificationClick(notification)"
>
<span
v-if="!notification.read_at"
class="mt-1.5 size-2 shrink-0 rounded-full bg-primary"
/>
<span v-else class="mt-1.5 size-2 shrink-0" />
<div class="min-w-0 flex-1">
<p class="text-sm font-medium leading-tight">{{ notification.title }}</p>
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{{ notification.body }}</p>
<!-- Notification list -->
<div class="flex-1 overflow-y-auto">
<div v-if="notifications.length > 0" class="divide-y divide-border">
<div
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"
@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>
<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>
</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"
@click="handleMarkAsRead(notification)"
>
<IconCheck class="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('sidebar.mark_as_read') }}</TooltipContent>
</Tooltip>
</div>
</div>
</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>
<Button
v-if="!notification.read_at"
variant="ghost"
size="icon"
class="size-7 shrink-0"
@click.stop="handleMarkAsRead(notification)"
>
<IconCheck class="size-3.5" />
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</Transition>
</Teleport>
</template>

View file

@ -6,7 +6,9 @@ import { computed, onMounted, onUnmounted } from 'vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toggle as toggleAccount } from '@/routes/app/accounts';
export interface SocialAccount {
id: string;
@ -16,6 +18,7 @@ export interface SocialAccount {
display_name: string;
avatar_url: string;
status: 'connected' | 'disconnected' | 'token_expired' | null;
is_active: boolean;
error_message: string | null;
}
@ -42,6 +45,12 @@ const props = withDefaults(defineProps<Props>(), {
columns: 4,
});
const handleToggle = (accountId: string) => {
router.put(toggleAccount.url(accountId), {}, {
preserveScroll: true,
});
};
const getConnectUrl = (platformValue: string): string => {
return `/connect/${platformValue}`;
};
@ -141,14 +150,14 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
<div class="grid gap-4" :class="gridClass">
<div v-for="platform in platforms" :key="platform.value"
class="group relative overflow-hidden rounded-xl border bg-card transition-all hover:shadow-md" :class="{
'border-green-500/30 bg-green-50/50 dark:bg-green-950/20': platform.connected && !isDisconnected(platform.account),
'border-red-500/30 bg-red-50/50 dark:bg-red-950/20': platform.connected && isDisconnected(platform.account),
'border-green-500/30 bg-green-50/50 dark:bg-green-950/20': platform.connected && !isDisconnected(platform.account) && platform.account?.is_active,
'border-red-500/30 bg-red-50/50 dark:bg-red-950/20': platform.connected && (isDisconnected(platform.account) || !platform.account?.is_active),
}">
<!-- Platform Header -->
<div class="flex items-center gap-3 p-4">
<div class="relative">
<img :src="getPlatformLogo(platform.value)" :alt="platform.label"
class="h-12 w-12 rounded-lg object-contain" />
class="h-12 w-12 rounded-lg object-contain" :class="{ 'opacity-40': platform.connected && platform.account && !platform.account.is_active }" />
<div v-if="platform.connected && !isDisconnected(platform.account)"
class="absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-green-500 text-white ring-2 ring-white dark:ring-neutral-900">
<IconCheck class="h-3 w-3" />
@ -159,8 +168,13 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<div class="flex items-center justify-between gap-2">
<h3 class="font-semibold truncate">{{ platform.label }}</h3>
<Switch
v-if="platform.connected && platform.account"
:model-value="platform.account.is_active"
@update:model-value="handleToggle(platform.account.id)"
/>
</div>
<p v-if="platform.connected && platform.account" class="text-sm text-muted-foreground truncate">
@{{ platform.account.username || platform.account.display_name }}

View file

@ -52,7 +52,7 @@ const switchLanguage = (code: string) => {
loadLanguageAsync(code);
dayjs.locale(code.toLowerCase());
router.patch(updateLanguage.url(), { locale: code }, {
router.put(updateLanguage.url(), { locale: code }, {
preserveScroll: true,
preserveState: false,
onError: () => {

View file

@ -55,14 +55,14 @@ const handleOpenChange = (value: boolean) => {
<template>
<Dialog :open="open" @update:open="handleOpenChange">
<DialogContent class="sm:max-w-md">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{{ $t('labels.create.title') }}</DialogTitle>
<DialogDescription>
{{ $t('labels.create.description') }}
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submit" class="space-y-4">
<form @submit.prevent="submit" class="space-y-6">
<div class="space-y-2">
<Label for="create-name">{{ $t('labels.create.name') }}</Label>
<Input
@ -78,15 +78,15 @@ const handleOpenChange = (value: boolean) => {
<div class="space-y-2">
<Label>{{ $t('labels.create.color') }}</Label>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-3">
<button
v-for="color in colors"
:key="color"
type="button"
class="h-10 w-10 rounded-lg transition-all"
class="size-8 rounded-full transition-all"
:class="[
form.color === color
? 'ring-2 ring-primary ring-offset-2'
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
: 'hover:scale-110'
]"
:style="{ backgroundColor: color }"

View file

@ -65,14 +65,14 @@ const submit = () => {
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{{ $t('labels.edit.title') }}</DialogTitle>
<DialogDescription>
{{ $t('labels.edit.description') }}
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submit" class="space-y-4">
<form @submit.prevent="submit" class="space-y-6">
<div class="space-y-2">
<Label for="edit-name">{{ $t('labels.edit.name') }}</Label>
<Input
@ -88,15 +88,15 @@ const submit = () => {
<div class="space-y-2">
<Label>{{ $t('labels.edit.color') }}</Label>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-3">
<button
v-for="color in colors"
:key="color"
type="button"
class="h-10 w-10 rounded-lg transition-all"
class="size-8 rounded-full transition-all"
:class="[
form.color === color
? 'ring-2 ring-primary ring-offset-2'
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
: 'hover:scale-110'
]"
:style="{ backgroundColor: color }"

View file

@ -10,7 +10,7 @@ const props = defineProps<{
<template>
<main data-slot="sidebar-inset" :class="cn(
'bg-card text-card-foreground relative flex w-full flex-1 flex-col overflow-y-auto',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-lg md:peer-data-[variant=inset]:border md:peer-data-[variant=inset]:border-border md:peer-data-[variant=inset]:shadow-xs md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-lg md:peer-data-[variant=inset]:border md:peer-data-[variant=inset]:border-border md:peer-data-[variant=inset]:shadow-xs',
props.class,
)">
<slot />

View file

@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthBase from '@/layouts/AuthLayout.vue';
import { register } from '@/routes';
import { redirect as googleRedirect } from '@/routes/auth/google';
import { store } from '@/routes/login';
import { request } from '@/routes/password';
@ -65,6 +66,17 @@ defineProps<{
</Button>
</div>
<div
class="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"
>
<span class="relative z-10 bg-background px-2 text-muted-foreground">{{ $t('auth.or_continue_with') }}</span>
</div>
<Button variant="outline" class="w-full" as="a" :href="googleRedirect.url()">
<img src="/images/social/google.svg" alt="Google" class="size-4" />
{{ $t('auth.google_login') }}
</Button>
<div class="text-center text-sm text-muted-foreground">
{{ $t('auth.login.no_account') }}
<TextLink :href="register()" :tabindex="5">{{ $t('auth.login.sign_up') }}</TextLink>

View file

@ -17,6 +17,7 @@ import {
} from '@/components/ui/tooltip';
import AuthBase from '@/layouts/AuthLayout.vue';
import { login } from '@/routes';
import { redirect as googleRedirect } from '@/routes/auth/google';
import { store } from '@/routes/register';
defineProps<{
@ -124,6 +125,17 @@ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
</Button>
</div>
<div
class="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"
>
<span class="relative z-10 bg-background px-2 text-muted-foreground">{{ $t('auth.or_continue_with') }}</span>
</div>
<Button variant="outline" class="w-full" as="a" :href="googleRedirect.url()">
<img src="/images/social/google.svg" alt="Google" class="size-4" />
{{ $t('auth.google_signup') }}
</Button>
<div class="text-center text-sm text-muted-foreground">
{{ $t('auth.register.has_account') }}
<TextLink

View file

@ -1,14 +1,19 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { IconPlus, IconHash, IconPencil, IconTrash } from '@tabler/icons-vue';
import { Head, InfiniteScroll, router } from '@inertiajs/vue3';
import { IconHash, IconPencil, IconSearch, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import EmptyState from '@/components/EmptyState.vue';
import CreateDialog from '@/components/hashtags/CreateDialog.vue';
import EditDialog from '@/components/hashtags/EditDialog.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as hashtagsIndex, destroy as hashtagsDestroy } from '@/routes/app/hashtags';
import { type BreadcrumbItemType } from '@/types';
@ -25,12 +30,38 @@ interface Hashtag {
created_at: string;
}
interface Props {
workspace: Workspace;
hashtags: Hashtag[];
interface ScrollHashtags {
data: Hashtag[];
meta: {
hasNextPage: boolean;
};
}
defineProps<Props>();
interface Props {
workspace: Workspace;
hashtags: ScrollHashtags;
filters: {
search: string;
};
}
const props = defineProps<Props>();
const searchQuery = ref(props.filters.search);
const search = debounce(() => {
router.get(
hashtagsIndex.url(),
{ search: searchQuery.value || undefined },
{
preserveState: true,
preserveScroll: true,
reset: ['hashtags'],
},
);
}, 300);
watch(searchQuery, () => search());
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const isCreateDialogOpen = ref(false);
@ -63,6 +94,14 @@ const getHashtagCount = (hashtags: string): number => {
<AppLayout :breadcrumbs="breadcrumbs">
<template #header-right>
<div class="relative">
<IconSearch class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="searchQuery"
:placeholder="trans('hashtags.search')"
class="w-64 pl-9"
/>
</div>
<Button @click="isCreateDialogOpen = true">
{{ $t('hashtags.new_group') }}
</Button>
@ -70,46 +109,62 @@ const getHashtagCount = (hashtags: string): number => {
<div class="flex flex-col gap-6 p-6">
<div v-if="hashtags.length === 0" class="flex flex-col items-center justify-center py-16">
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center mb-4">
<IconHash class="h-8 w-8 text-muted-foreground" />
</div>
<h3 class="text-lg font-semibold mb-2">{{ $t('hashtags.no_groups_yet') }}</h3>
<p class="text-muted-foreground mb-4 text-center max-w-sm">
{{ $t('hashtags.no_groups_description') }}
</p>
<Button @click="isCreateDialogOpen = true">
<IconPlus class="mr-2 h-4 w-4" />
{{ $t('hashtags.create_first_group') }}
</Button>
</div>
<EmptyState
v-if="hashtags.data.length === 0"
:icon="IconHash"
:title="$t('hashtags.no_groups_yet')"
:description="$t('hashtags.no_groups_description')"
/>
<div v-else class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="hashtag in hashtags" :key="hashtag.id">
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<CardTitle class="text-lg">{{ hashtag.name }}</CardTitle>
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="openEditDialog(hashtag)">
<IconPencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
@click="handleDelete(hashtag.id)">
<IconTrash class="h-4 w-4" />
</Button>
<div v-else>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="hashtag in hashtags.data" :key="hashtag.id">
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<CardTitle class="text-lg">{{ hashtag.name }}</CardTitle>
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="openEditDialog(hashtag)">
<IconPencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
@click="handleDelete(hashtag.id)">
<IconTrash class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<CardDescription>
{{ $t('hashtags.hashtags_count', { count: getHashtagCount(hashtag.hashtags) }) }}
</CardDescription>
</CardHeader>
<CardContent>
<p class="text-sm text-muted-foreground line-clamp-3">
{{ hashtag.hashtags }}
</p>
</CardContent>
</Card>
<CardDescription>
{{ $t('hashtags.hashtags_count', { count: getHashtagCount(hashtag.hashtags) }) }}
</CardDescription>
</CardHeader>
<CardContent>
<p class="text-sm text-muted-foreground line-clamp-3">
{{ hashtag.hashtags }}
</p>
</CardContent>
</Card>
</div>
<InfiniteScroll data="hashtags" #default="{ loading }">
<div v-if="loading" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 mt-4">
<Card v-for="i in 3" :key="i">
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<Skeleton class="h-6 w-32" />
<div class="flex gap-1">
<Skeleton class="h-8 w-8" />
<Skeleton class="h-8 w-8" />
</div>
</div>
<Skeleton class="h-4 w-20" />
</CardHeader>
<CardContent>
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-3/4 mt-2" />
</CardContent>
</Card>
</div>
</InfiniteScroll>
</div>
</div>
</AppLayout>

View file

@ -1,23 +1,22 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { IconPlus, IconTag, IconPencil, IconTrash } from '@tabler/icons-vue';
import { Head, InfiniteScroll, router } from '@inertiajs/vue3';
import { IconPencil, IconSearch, IconTag, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import EmptyState from '@/components/EmptyState.vue';
import CreateDialog from '@/components/labels/CreateDialog.vue';
import EditDialog from '@/components/labels/EditDialog.vue';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as labelsIndex, destroy as labelsDestroy } from '@/routes/app/labels';
import { type BreadcrumbItemType } from '@/types';
interface Workspace {
id: string;
name: string;
}
interface Label {
id: string;
name: string;
@ -25,12 +24,21 @@ interface Label {
created_at: string;
}
interface Props {
workspace: Workspace;
labels: Label[];
interface ScrollLabels {
data: Label[];
meta: {
hasNextPage: boolean;
};
}
defineProps<Props>();
interface Props {
labels: ScrollLabels;
filters: {
search: string;
};
}
const props = defineProps<Props>();
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const isCreateDialogOpen = ref(false);
@ -41,6 +49,22 @@ const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
{ title: trans('labels.title'), href: labelsIndex.url() },
]);
const searchQuery = ref(props.filters.search);
const search = debounce(() => {
router.get(
labelsIndex.url(),
{ search: searchQuery.value || undefined },
{
preserveState: true,
preserveScroll: true,
reset: ['labels'],
},
);
}, 300);
watch(searchQuery, () => search());
const openEditDialog = (label: Label) => {
editingLabel.value = label;
isEditDialogOpen.value = true;
@ -59,48 +83,69 @@ const handleDelete = (labelId: string) => {
<AppLayout :breadcrumbs="breadcrumbs">
<template #header-right>
<div class="relative">
<IconSearch class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="searchQuery"
:placeholder="trans('labels.search')"
class="w-64 pl-9"
/>
</div>
<Button @click="isCreateDialogOpen = true">
{{ $t('labels.new_label') }}
</Button>
</template>
<div class="flex flex-col gap-6 p-6">
<EmptyState
v-if="labels.data.length === 0"
:icon="IconTag"
:title="$t('labels.no_labels_yet')"
:description="$t('labels.description')"
/>
<div v-if="labels.length === 0" class="flex flex-col items-center justify-center py-16">
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center mb-4">
<IconTag class="h-8 w-8 text-muted-foreground" />
<div v-else>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<Card v-for="label in labels.data" :key="label.id">
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="h-6 w-6 rounded-md" :style="{ backgroundColor: label.color }" />
<CardTitle class="text-lg">{{ label.name }}</CardTitle>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="openEditDialog(label)">
<IconPencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
@click="handleDelete(label.id)">
<IconTrash class="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
</Card>
</div>
<h3 class="text-lg font-semibold mb-2">{{ $t('labels.no_labels_yet') }}</h3>
<p class="text-muted-foreground mb-4 text-center max-w-sm">
{{ $t('labels.description') }}
</p>
<Button @click="isCreateDialogOpen = true">
<IconPlus class="mr-2 h-4 w-4" />
{{ $t('labels.create_first_label') }}
</Button>
</div>
<div v-else class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<Card v-for="label in labels" :key="label.id">
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="h-6 w-6 rounded-md" :style="{ backgroundColor: label.color }" />
<CardTitle class="text-lg">{{ label.name }}</CardTitle>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="openEditDialog(label)">
<IconPencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
@click="handleDelete(label.id)">
<IconTrash class="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
</Card>
<InfiniteScroll data="labels" #default="{ loading }">
<div v-if="loading" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mt-4">
<Card v-for="i in 4" :key="i">
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Skeleton class="h-6 w-6 rounded-md" />
<Skeleton class="h-6 w-24" />
</div>
<div class="flex gap-1">
<Skeleton class="h-8 w-8" />
<Skeleton class="h-8 w-8" />
</div>
</div>
</CardHeader>
</Card>
</div>
</InfiniteScroll>
</div>
</div>
</AppLayout>
@ -111,4 +156,4 @@ const handleDelete = (labelId: string) => {
<ConfirmDeleteModal ref="deleteModal" :title="$t('labels.delete.title')"
:description="$t('labels.delete.description')" :action="$t('labels.delete.confirm')"
:cancel="$t('labels.delete.cancel')" />
</template>
</template>

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
import { Head, Link, InfiniteScroll } from '@inertiajs/vue3';
import { IconClock, IconCircleCheck, IconAlertCircle, IconLoader2, IconFileText, IconEye, IconTrash } from '@tabler/icons-vue';
import { Head, Link, InfiniteScroll, router } from '@inertiajs/vue3';
import { IconClock, IconCircleCheck, IconAlertCircle, IconLoader2, IconFileText, IconEye, IconSearch, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { index as postsIndex, store as storePost, edit as editPost, destroy as destroyPost } from '@/actions/App/Http/Controllers/App/PostController';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
@ -10,9 +10,11 @@ import EmptyState from '@/components/EmptyState.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import dayjs from '@/dayjs';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { type BreadcrumbItemType } from '@/types';
@ -72,10 +74,30 @@ interface Props {
workspace: Workspace;
posts: ScrollPosts;
currentStatus: string | null;
filters: {
search: string;
};
}
const props = defineProps<Props>();
const searchQuery = ref(props.filters.search);
const search = debounce(() => {
const url = props.currentStatus ? postsIndex.url(props.currentStatus) : postsIndex.url();
router.get(
url,
{ search: searchQuery.value || undefined },
{
preserveState: true,
preserveScroll: true,
reset: ['posts'],
},
);
}, 300);
watch(searchQuery, () => search());
const pageTitle = computed(() => {
if (props.currentStatus) {
const statusLabel = trans(`posts.status.${props.currentStatus}`);
@ -178,6 +200,14 @@ const handleDelete = (post: Post) => {
<AppLayout :breadcrumbs="breadcrumbs">
<template #header-right>
<div class="relative">
<IconSearch class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="searchQuery"
:placeholder="trans('posts.search')"
class="w-64 pl-9"
/>
</div>
<Link :href="storePost.url()" method="post">
<Button>
{{ $t('posts.new_post') }}

21
resources/js/posthog.ts Normal file
View file

@ -0,0 +1,21 @@
import posthog from 'posthog-js';
const apiKey = import.meta.env.VITE_POSTHOG_API_KEY as string | undefined;
const host = import.meta.env.VITE_POSTHOG_HOST as string | undefined;
if (apiKey) {
posthog.init(apiKey, {
api_host: host || 'https://us.i.posthog.com',
ui_host: 'https://us.posthog.com',
capture_pageview: false,
capture_pageleave: true,
cross_subdomain_cookie: true,
enable_recording_console_log: true,
session_recording: {
maskAllInputs: true,
maskTextSelector: '.ph-no-capture',
},
});
}
export default posthog;

View file

@ -120,6 +120,7 @@ function () {
// Social Accounts
Route::get('accounts', [SocialController::class, 'index'])->name('app.accounts');
Route::delete('accounts/{account}', [SocialController::class, 'disconnect'])->name('app.accounts.disconnect');
Route::put('accounts/{account}/toggle', [SocialController::class, 'toggleActive'])->name('app.accounts.toggle');
// Calendar
Route::get('calendar', [PostController::class, 'calendar'])->name('app.calendar');
@ -168,7 +169,7 @@ function () {
// Notifications
Route::get('notifications', [NotificationController::class, 'index'])->name('app.notifications.index');
Route::patch('notifications/{notification}/read', [NotificationController::class, 'markAsRead'])->name('app.notifications.read');
Route::put('notifications/{notification}/read', [NotificationController::class, 'markAsRead'])->name('app.notifications.read');
Route::post('notifications/read-all', [NotificationController::class, 'markAllAsRead'])->name('app.notifications.read-all');
Route::post('notifications/archive-all', [NotificationController::class, 'archiveAll'])->name('app.notifications.archive-all');
});
@ -176,10 +177,10 @@ function () {
// Settings (auth required)
Route::middleware(['auth'])->group(function () {
Route::get('settings/profile', [ProfileController::class, 'edit'])->name('app.profile.edit');
Route::patch('settings/profile', [ProfileController::class, 'update'])->name('app.profile.update');
Route::put('settings/profile', [ProfileController::class, 'update'])->name('app.profile.update');
Route::post('settings/profile/photo', [ProfileController::class, 'uploadPhoto'])->name('app.profile.upload-photo');
Route::delete('settings/profile/photo', [ProfileController::class, 'deletePhoto'])->name('app.profile.delete-photo');
Route::patch('settings/language', [ProfileController::class, 'updateLanguage'])->name('app.profile.language');
Route::put('settings/language', [ProfileController::class, 'updateLanguage'])->name('app.profile.language');
});
Route::middleware(['auth', 'verified'])->group(function () {

View file

@ -9,6 +9,7 @@
use App\Http\Controllers\Auth\NewPasswordController;
use App\Http\Controllers\Auth\PasswordResetLinkController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\Auth\SocialLoginController;
use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route;
@ -38,6 +39,9 @@ function () {
Route::get('/reset-password/{token}', [NewPasswordController::class, 'create'])->name('password.reset');
Route::post('/reset-password', [NewPasswordController::class, 'store'])->name('password.store');
Route::get('/auth/google/redirect', [SocialLoginController::class, 'redirect'])->name('auth.google.redirect');
Route::get('/auth/google/callback', [SocialLoginController::class, 'callback'])->name('auth.google.callback');
}
);

View file

@ -79,7 +79,7 @@
'workspace_id' => $this->workspace->id,
]);
$response = $this->actingAs($this->user)->patchJson(route('app.notifications.read', $notification));
$response = $this->actingAs($this->user)->putJson(route('app.notifications.read', $notification));
$response->assertOk();
expect($notification->fresh()->read_at)->not->toBeNull();
@ -92,7 +92,7 @@
'workspace_id' => $this->workspace->id,
]);
$response = $this->actingAs($this->user)->patchJson(route('app.notifications.read', $notification));
$response = $this->actingAs($this->user)->putJson(route('app.notifications.read', $notification));
$response->assertForbidden();
});

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use App\Jobs\SendPostHogEvent;
test('job is queued on posthog queue', function () {
$job = new SendPostHogEvent([
['method' => 'capture', 'payload' => ['distinctId' => 'user-1', 'event' => 'test']],
]);
expect($job->queue)->toBe('posthog');
});
test('job skips execution when api key is missing', function () {
config(['services.posthog.api_key' => null]);
$job = new SendPostHogEvent([
['method' => 'capture', 'payload' => ['distinctId' => 'user-1', 'event' => 'test']],
]);
// Should not throw - silently skips
$job->handle();
expect(true)->toBeTrue();
});
test('job has correct retry and timeout settings', function () {
$job = new SendPostHogEvent([]);
expect($job->tries)->toBe(3);
expect($job->timeout)->toBe(15);
});

View file

@ -23,7 +23,7 @@
$response = $this
->actingAs($user)
->patch(route('app.profile.update'), [
->put(route('app.profile.update'), [
'name' => 'Test User',
'email' => 'test@example.com',
]);
@ -44,7 +44,7 @@
$response = $this
->actingAs($user)
->patch(route('app.profile.update'), [
->put(route('app.profile.update'), [
'name' => 'Test User',
'email' => $user->email,
]);
@ -62,7 +62,7 @@
$response = $this
->actingAs($user)
->from(route('app.posts.index'))
->patch(route('app.profile.language'), [
->put(route('app.profile.language'), [
'locale' => 'es',
]);
@ -78,7 +78,7 @@
$response = $this
->actingAs($user)
->patch(route('app.profile.language'), [
->put(route('app.profile.language'), [
'locale' => 'invalid',
]);

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
beforeEach(function () {
config([
'services.google-auth.client_id' => 'test-client-id',
'services.google-auth.client_secret' => 'test-client-secret',
'services.google-auth.redirect' => 'https://app.trypost.test/auth/google/callback',
]);
});
test('google redirect returns redirect response', function () {
$response = $this->get(route('auth.google.redirect'));
$response->assertRedirect();
expect($response->headers->get('Location'))->toContain('accounts.google.com');
});
test('google callback logs in existing user by email', function () {
$user = User::factory()->create([
'email' => 'existing@example.com',
]);
$socialiteUser = new SocialiteUser;
$socialiteUser->map([
'id' => '123456',
'name' => 'Existing User',
'email' => 'existing@example.com',
]);
Socialite::shouldReceive('driver')
->with('google-auth')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')->andReturn($socialiteUser);
$response = $this->get(route('auth.google.callback'));
$response->assertRedirect(route('app.home'));
$this->assertAuthenticatedAs($user);
});
test('google callback creates new user when email does not exist', function () {
$socialiteUser = new SocialiteUser;
$socialiteUser->map([
'id' => '789',
'name' => 'New User',
'email' => 'new@example.com',
]);
Socialite::shouldReceive('driver')
->with('google-auth')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')->andReturn($socialiteUser);
$response = $this->get(route('auth.google.callback'));
$response->assertRedirect(route('app.onboarding.role'));
$user = User::where('email', 'new@example.com')->first();
expect($user)->not->toBeNull();
expect($user->name)->toBe('New User');
expect($user->email_verified_at)->not->toBeNull();
expect($user->currentWorkspace)->not->toBeNull();
$this->assertAuthenticatedAs($user);
});
test('google callback marks unverified existing user as verified', function () {
$user = User::factory()->create([
'email' => 'unverified@example.com',
'email_verified_at' => null,
]);
$socialiteUser = new SocialiteUser;
$socialiteUser->map([
'id' => '456',
'name' => 'Unverified User',
'email' => 'unverified@example.com',
]);
Socialite::shouldReceive('driver')
->with('google-auth')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')->andReturn($socialiteUser);
$this->get(route('auth.google.callback'));
expect($user->fresh()->email_verified_at)->not->toBeNull();
});

View file

@ -29,7 +29,7 @@
$response->assertInertia(fn ($page) => $page
->component('hashtags/Index', false)
->has('workspace')
->has('hashtags', 3)
->has('hashtags.data', 3)
);
});
@ -140,3 +140,30 @@
$response->assertNotFound();
});
test('hashtags index filters by search query', function () {
WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Marketing']);
WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Travel']);
WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Food']);
$response = $this->actingAs($this->user)->get(route('app.hashtags.index', ['search' => 'market']));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('hashtags.data', 1)
->has('filters')
->where('filters.search', 'market')
);
});
test('hashtags index returns all when no search query', function () {
WorkspaceHashtag::factory()->count(3)->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->get(route('app.hashtags.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('hashtags.data', 3)
->where('filters.search', '')
);
});

View file

@ -29,7 +29,7 @@
$response->assertInertia(fn ($page) => $page
->component('labels/Index', false)
->has('workspace')
->has('labels', 3)
->has('labels.data', 3)
);
});
@ -149,3 +149,30 @@
$response->assertNotFound();
});
test('labels index filters by search query', function () {
WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Important']);
WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Urgent']);
WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Review']);
$response = $this->actingAs($this->user)->get(route('app.labels.index', ['search' => 'import']));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('labels.data', 1)
->has('filters')
->where('filters.search', 'import')
);
});
test('labels index returns all when no search query', function () {
WorkspaceLabel::factory()->count(3)->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->get(route('app.labels.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('labels.data', 3)
->where('filters.search', '')
);
});

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
use App\Jobs\SendPostHogEvent;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Queue;
test('capture dispatches job when api key is configured', function () {
Queue::fake();
config(['services.posthog.api_key' => 'phc_test_key']);
$service = new PostHogService;
$service->capture('user-123', 'test_event', ['foo' => 'bar']);
Queue::assertPushed(SendPostHogEvent::class, function ($job) {
return $job->calls[0]['method'] === 'capture'
&& $job->calls[0]['payload']['event'] === 'test_event'
&& $job->calls[0]['payload']['distinctId'] === 'user-123';
});
});
test('capture does not dispatch job when api key is missing', function () {
Queue::fake();
config(['services.posthog.api_key' => null]);
$service = new PostHogService;
$service->capture('user-123', 'test_event');
Queue::assertNothingPushed();
});
test('identify dispatches job when api key is configured', function () {
Queue::fake();
config(['services.posthog.api_key' => 'phc_test_key']);
$service = new PostHogService;
$service->identify('user-123', ['$email' => 'test@example.com']);
Queue::assertPushed(SendPostHogEvent::class, function ($job) {
return $job->calls[0]['method'] === 'identify'
&& $job->calls[0]['payload']['distinctId'] === 'user-123';
});
});
test('identify does not dispatch job when api key is missing', function () {
Queue::fake();
config(['services.posthog.api_key' => null]);
$service = new PostHogService;
$service->identify('user-123', ['$email' => 'test@example.com']);
Queue::assertNothingPushed();
});
test('group identify dispatches job when api key is configured', function () {
Queue::fake();
config(['services.posthog.api_key' => 'phc_test_key']);
$service = new PostHogService;
$service->groupIdentify('workspace', 'ws-123', ['name' => 'Test Workspace']);
Queue::assertPushed(SendPostHogEvent::class, function ($job) {
return $job->calls[0]['method'] === 'groupIdentify'
&& $job->calls[0]['payload']['groupType'] === 'workspace'
&& $job->calls[0]['payload']['groupKey'] === 'ws-123';
});
});
test('group identify does not dispatch job when api key is missing', function () {
Queue::fake();
config(['services.posthog.api_key' => null]);
$service = new PostHogService;
$service->groupIdentify('workspace', 'ws-123', ['name' => 'Test']);
Queue::assertNothingPushed();
});