diff --git a/app/Enums/MediaType.php b/app/Enums/Media/Type.php similarity index 94% rename from app/Enums/MediaType.php rename to app/Enums/Media/Type.php index 7549acb7..4d60d086 100644 --- a/app/Enums/MediaType.php +++ b/app/Enums/Media/Type.php @@ -1,8 +1,8 @@ file('file'); - $mimeType = $file->getMimeType(); + $modelClass = $request->input('model'); + $modelId = $request->input('model_id'); + $collection = $request->input('collection', 'default'); - $type = $this->getMediaType($mimeType); + $model = $modelClass::findOrFail($modelId); - $path = $file->store('media/'.now()->format('Y-m')); - - $media = PostMedia::create([ - 'post_platform_id' => $request->input('post_platform_id'), - 'type' => $type, - 'path' => $path, - 'original_filename' => $file->getClientOriginalName(), - 'mime_type' => $mimeType, - 'size' => $file->getSize(), - 'order' => 0, - 'meta' => $this->getMediaMeta($file, $type), - ]); + $media = $model->addMedia( + $request->file('media'), + $collection + ); return response()->json([ 'id' => $media->id, @@ -37,22 +30,32 @@ public function store(StoreMediaRequest $request): JsonResponse ]); } - public function destroy(PostMedia $media): JsonResponse + public function destroy(string $modelId, Media $media): JsonResponse { + if ($media->mediable_id !== $modelId) { + abort(403); + } + $media->delete(); return response()->json(['success' => true]); } - public function duplicate(PostMedia $media): JsonResponse + public function duplicate(Media $media, Request $request): JsonResponse { - $postPlatformIds = request()->input('post_platform_ids', []); + $targets = $request->input('targets', []); $duplicates = []; - foreach ($postPlatformIds as $postPlatformId) { - $duplicate = PostMedia::create([ - 'post_platform_id' => $postPlatformId, + foreach ($targets as $target) { + $modelClass = $target['model']; + $modelId = $target['model_id']; + $collection = $target['collection'] ?? $media->collection; + + $model = $modelClass::findOrFail($modelId); + + $duplicate = $model->media()->create([ + 'collection' => $collection, 'type' => $media->type, 'path' => $media->path, 'original_filename' => $media->original_filename, @@ -64,7 +67,8 @@ public function duplicate(PostMedia $media): JsonResponse $duplicates[] = [ 'id' => $duplicate->id, - 'post_platform_id' => $duplicate->post_platform_id, + 'mediable_id' => $duplicate->mediable_id, + 'mediable_type' => $duplicate->mediable_type, 'url' => $duplicate->url, 'type' => $duplicate->type->value, 'original_filename' => $duplicate->original_filename, @@ -73,32 +77,4 @@ public function duplicate(PostMedia $media): JsonResponse return response()->json($duplicates); } - - private function getMediaType(string $mimeType): MediaType - { - if (str_starts_with($mimeType, 'image/')) { - return MediaType::Image; - } - - if (str_starts_with($mimeType, 'video/')) { - return MediaType::Video; - } - - return MediaType::Document; - } - - private function getMediaMeta($file, MediaType $type): array - { - $meta = []; - - if ($type === MediaType::Image) { - $imageInfo = @getimagesize($file->getPathname()); - if ($imageInfo) { - $meta['width'] = $imageInfo[0]; - $meta['height'] = $imageInfo[1]; - } - } - - return $meta; - } } diff --git a/app/Http/Controllers/OnboardingController.php b/app/Http/Controllers/OnboardingController.php index 9a8320a5..28e3c9f0 100644 --- a/app/Http/Controllers/OnboardingController.php +++ b/app/Http/Controllers/OnboardingController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers; -use App\Enums\SocialPlatform; +use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\User\Persona; use App\Enums\User\Setup; use Illuminate\Http\RedirectResponse; diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php index 00039bd8..165c2128 100644 --- a/app/Http/Controllers/PostController.php +++ b/app/Http/Controllers/PostController.php @@ -2,8 +2,8 @@ namespace App\Http\Controllers; +use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostStatus; use App\Http\Requests\StorePostRequest; use App\Http\Requests\UpdatePostRequest; use App\Jobs\PublishPost; @@ -48,20 +48,28 @@ public function calendar(Request $request): Response|RedirectResponse $this->authorize('view', $workspace); $tz = $workspace->timezone; + $view = $request->input('view', 'week'); + // Week view $weekStart = $request->input('week') ? Carbon::parse($request->input('week'), $tz)->startOfWeek() : Carbon::now($tz)->startOfWeek(); - $weekEnd = $weekStart->copy()->endOfWeek(); - // Convert to UTC for database query - $weekStartUtc = $weekStart->copy()->utc(); - $weekEndUtc = $weekEnd->copy()->utc(); + // Month view + $monthDate = $request->input('month') + ? Carbon::parse($request->input('month'), $tz)->startOfMonth() + : Carbon::now($tz)->startOfMonth(); + $monthStart = $monthDate->copy()->startOfMonth()->startOfWeek(); + $monthEnd = $monthDate->copy()->endOfMonth()->endOfWeek(); + + // Get posts for both views (we query the larger range to cover both) + $rangeStart = $view === 'month' ? $monthStart : $weekStart; + $rangeEnd = $view === 'month' ? $monthEnd : $weekEnd; $posts = $workspace->posts() ->with(['postPlatforms.socialAccount']) - ->whereBetween('scheduled_at', [$weekStartUtc, $weekEndUtc]) + ->whereBetween('scheduled_at', [$rangeStart->copy()->utc(), $rangeEnd->copy()->utc()]) ->orderBy('scheduled_at') ->get() ->groupBy(fn ($post) => $post->scheduled_at?->setTimezone($tz)->format('Y-m-d')); @@ -70,6 +78,8 @@ public function calendar(Request $request): Response|RedirectResponse 'workspace' => $workspace, 'posts' => $posts, 'currentWeekStart' => $weekStart->format('Y-m-d'), + 'currentMonth' => $monthDate->format('Y-m-d'), + 'view' => $view, ]); } diff --git a/app/Http/Controllers/WorkspaceController.php b/app/Http/Controllers/WorkspaceController.php index f455e70a..9a2aef7b 100644 --- a/app/Http/Controllers/WorkspaceController.php +++ b/app/Http/Controllers/WorkspaceController.php @@ -107,7 +107,7 @@ public function settings(Request $request): Response|RedirectResponse ->mapWithKeys(fn ($tz) => [$tz => $tz]) ->toArray(); - return Inertia::render('workspaces/Settings', [ + return Inertia::render('settings/Workspace', [ 'workspace' => $workspace, 'timezones' => $timezones, ]); @@ -132,7 +132,7 @@ public function updateSettings(UpdateWorkspaceRequest $request): RedirectRespons session()->flash('flash.banner', 'Settings updated successfully!'); session()->flash('flash.bannerStyle', 'success'); - return redirect()->route('settings'); + return redirect()->route('workspace.settings'); } /** diff --git a/app/Http/Controllers/WorkspaceHashtagController.php b/app/Http/Controllers/WorkspaceHashtagController.php index 392efb22..cb8d2607 100644 --- a/app/Http/Controllers/WorkspaceHashtagController.php +++ b/app/Http/Controllers/WorkspaceHashtagController.php @@ -26,21 +26,6 @@ public function index(Request $request): Response|RedirectResponse ]); } - public function create(Request $request): Response|RedirectResponse - { - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('view', $workspace); - - return Inertia::render('hashtags/Create', [ - 'workspace' => $workspace, - ]); - } - public function store(Request $request): RedirectResponse { $workspace = $request->user()->currentWorkspace; @@ -64,26 +49,6 @@ public function store(Request $request): RedirectResponse return redirect()->route('hashtags.index'); } - public function edit(Request $request, WorkspaceHashtag $hashtag): Response|RedirectResponse - { - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('view', $workspace); - - if ($hashtag->workspace_id !== $workspace->id) { - abort(404); - } - - return Inertia::render('hashtags/Edit', [ - 'workspace' => $workspace, - 'hashtag' => $hashtag, - ]); - } - public function update(Request $request, WorkspaceHashtag $hashtag): RedirectResponse { $workspace = $request->user()->currentWorkspace; diff --git a/app/Http/Controllers/WorkspaceInviteController.php b/app/Http/Controllers/WorkspaceInviteController.php index c898921c..fb39a00b 100644 --- a/app/Http/Controllers/WorkspaceInviteController.php +++ b/app/Http/Controllers/WorkspaceInviteController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers; -use App\Enums\WorkspaceRole; +use App\Enums\UserWorkspace\Role as WorkspaceRole; use App\Http\Requests\StoreWorkspaceInviteRequest; use App\Models\WorkspaceInvite; use App\Notifications\WorkspaceInviteNotification; @@ -23,7 +23,7 @@ public function index(Request $request): Response|RedirectResponse $this->authorize('manageTeam', $workspace); - return Inertia::render('workspaces/Invites', [ + return Inertia::render('settings/Members', [ 'workspace' => $workspace, 'invites' => $workspace->invites() ->with('inviter') diff --git a/app/Http/Controllers/WorkspaceLabelController.php b/app/Http/Controllers/WorkspaceLabelController.php index c7a38957..08ecac2c 100644 --- a/app/Http/Controllers/WorkspaceLabelController.php +++ b/app/Http/Controllers/WorkspaceLabelController.php @@ -26,21 +26,6 @@ public function index(Request $request): Response|RedirectResponse ]); } - public function create(Request $request): Response|RedirectResponse - { - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('view', $workspace); - - return Inertia::render('labels/Create', [ - 'workspace' => $workspace, - ]); - } - public function store(Request $request): RedirectResponse { $workspace = $request->user()->currentWorkspace; @@ -64,26 +49,6 @@ public function store(Request $request): RedirectResponse return redirect()->route('labels.index'); } - public function edit(Request $request, WorkspaceLabel $label): Response|RedirectResponse - { - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('workspaces.create'); - } - - $this->authorize('view', $workspace); - - if ($label->workspace_id !== $workspace->id) { - abort(404); - } - - return Inertia::render('labels/Edit', [ - 'workspace' => $workspace, - 'label' => $label, - ]); - } - public function update(Request $request, WorkspaceLabel $label): RedirectResponse { $workspace = $request->user()->currentWorkspace; diff --git a/app/Http/Requests/StoreMediaRequest.php b/app/Http/Requests/StoreMediaRequest.php index 60456d23..70494dba 100644 --- a/app/Http/Requests/StoreMediaRequest.php +++ b/app/Http/Requests/StoreMediaRequest.php @@ -3,9 +3,16 @@ namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class StoreMediaRequest extends FormRequest { + protected array $allowedModels = [ + 'App\Models\PostPlatform', + 'App\Models\Workspace', + 'App\Models\User', + ]; + public function authorize(): bool { return true; @@ -14,21 +21,38 @@ public function authorize(): bool public function rules(): array { return [ - 'file' => [ + 'media' => [ 'required', 'file', 'max:512000', // 500MB 'mimetypes:image/jpeg,image/png,image/gif,image/webp,video/mp4,video/quicktime,video/webm,application/pdf', ], + 'model' => [ + 'required', + 'string', + Rule::in($this->allowedModels), + ], + 'model_id' => [ + 'required', + 'string', + ], + 'collection' => [ + 'sometimes', + 'string', + 'max:255', + ], ]; } public function messages(): array { return [ - 'file.required' => 'O arquivo é obrigatório.', - 'file.max' => 'O arquivo deve ter no máximo 500MB.', - 'file.mimetypes' => 'Tipo de arquivo não suportado.', + 'media.required' => 'The file is required.', + 'media.max' => 'The file must be at most 500MB.', + 'media.mimetypes' => 'File type not supported.', + 'model.required' => 'The model is required.', + 'model.in' => 'Invalid model type.', + 'model_id.required' => 'The model ID is required.', ]; } } diff --git a/app/Http/Requests/StorePostRequest.php b/app/Http/Requests/StorePostRequest.php index 75df4845..1e8f721b 100644 --- a/app/Http/Requests/StorePostRequest.php +++ b/app/Http/Requests/StorePostRequest.php @@ -2,7 +2,7 @@ namespace App\Http\Requests; -use App\Enums\PostStatus; +use App\Enums\Post\Status as PostStatus; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -17,7 +17,7 @@ public function rules(): array { return [ 'status' => ['required', Rule::enum(PostStatus::class)], - 'scheduled_at' => ['required_if:status,' . PostStatus::Scheduled->value, 'nullable', 'date', 'after:now'], + 'scheduled_at' => ['required_if:status,'.PostStatus::Scheduled->value, 'nullable', 'date', 'after:now'], 'platforms' => ['required', 'array', 'min:1'], 'platforms.*.social_account_id' => ['required', 'uuid', 'exists:social_accounts,id'], 'platforms.*.platform' => ['required', 'string'], diff --git a/app/Http/Requests/StoreWorkspaceInviteRequest.php b/app/Http/Requests/StoreWorkspaceInviteRequest.php index 77ab9b8f..73d549aa 100644 --- a/app/Http/Requests/StoreWorkspaceInviteRequest.php +++ b/app/Http/Requests/StoreWorkspaceInviteRequest.php @@ -2,7 +2,7 @@ namespace App\Http\Requests; -use App\Enums\WorkspaceRole; +use App\Enums\UserWorkspace\Role as WorkspaceRole; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 0366e258..15b9c317 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -2,7 +2,7 @@ namespace App\Jobs; -use App\Enums\SocialPlatform; +use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Events\PostPlatformStatusUpdated; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; diff --git a/app/Models/PostMedia.php b/app/Models/Media.php similarity index 77% rename from app/Models/PostMedia.php rename to app/Models/Media.php index 70f71ceb..bd3f2ece 100644 --- a/app/Models/PostMedia.php +++ b/app/Models/Media.php @@ -2,25 +2,27 @@ namespace App\Models; -use App\Enums\MediaType; +use App\Enums\Media\Type as MediaType; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Support\Facades\Storage; -class PostMedia extends Model +class Media extends Model { - /** @use HasFactory<\Database\Factories\PostMediaFactory> */ + /** @use HasFactory<\Database\Factories\MediaFactory> */ use HasFactory, HasUuids; - protected $table = 'post_medias'; + protected $table = 'medias'; protected $appends = ['url']; protected $fillable = [ - 'post_platform_id', + 'mediable_id', + 'mediable_type', + 'collection', 'type', 'path', 'original_filename', @@ -40,9 +42,9 @@ protected function casts(): array ]; } - public function postPlatform(): BelongsTo + public function mediable(): MorphTo { - return $this->belongsTo(PostPlatform::class); + return $this->morphTo(); } protected function url(): Attribute diff --git a/app/Models/Post.php b/app/Models/Post.php index 5e7cb9c5..d7a9fac8 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -2,7 +2,7 @@ namespace App\Models; -use App\Enums\PostStatus; +use App\Enums\Post\Status as PostStatus; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; diff --git a/app/Models/PostPlatform.php b/app/Models/PostPlatform.php index 8be22cfa..95ccee0a 100644 --- a/app/Models/PostPlatform.php +++ b/app/Models/PostPlatform.php @@ -3,17 +3,17 @@ namespace App\Models; use App\Enums\PostPlatform\ContentType; -use App\Enums\SocialPlatform; +use App\Enums\SocialAccount\Platform as SocialPlatform; +use App\Models\Traits\HasMedia; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\HasMany; class PostPlatform extends Model { /** @use HasFactory<\Database\Factories\PostPlatformFactory> */ - use HasFactory, HasUuids; + use HasFactory, HasMedia, HasUuids; protected $fillable = [ 'post_id', @@ -51,11 +51,6 @@ public function socialAccount(): BelongsTo return $this->belongsTo(SocialAccount::class); } - public function media(): HasMany - { - return $this->hasMany(PostMedia::class)->orderBy('order'); - } - public function markAsPublishing(): void { $this->update(['status' => 'publishing']); diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index 411f81ff..94dc255a 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -2,8 +2,8 @@ namespace App\Models; -use App\Enums\SocialPlatform; -use App\Enums\Status; +use App\Enums\SocialAccount\Platform as SocialPlatform; +use App\Enums\SocialAccount\Status; use App\Notifications\AccountDisconnectedNotification; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUuids; diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php new file mode 100644 index 00000000..8a5ac056 --- /dev/null +++ b/app/Models/Subscription.php @@ -0,0 +1,13 @@ + [ + 'logo' => 'single', + ], + User::class => [ + 'avatar' => 'single', + ], + PostPlatform::class => [ + 'default' => 'multiple', + ], + ]; + + public function media(): MorphMany + { + return $this->morphMany(Media::class, 'mediable')->orderBy('order'); + } + + public function getMedia(string $collection = 'default'): MorphMany + { + return $this->media()->where('collection', $collection); + } + + public function getFirstMedia(string $collection = 'default'): ?Media + { + return $this->getMedia($collection)->first(); + } + + public function getFirstMediaUrl(string $collection = 'default', ?string $default = null): ?string + { + $media = $this->getFirstMedia($collection); + + return $media?->url ?? $default; + } + + /** + * Generate a DiceBear fallback URL using initials. + */ + public function getFallbackAvatarUrl(string $seed): string + { + return 'https://api.dicebear.com/9.x/initials/svg?backgroundColor=777777&fontFamily=Verdana&fontSize=40&seed='.urlencode($seed); + } + + /** + * Add media to a collection. + * If the collection is configured as 'single', it will clear existing media first. + */ + public function addMedia(UploadedFile $file, string $collection = 'default', array $meta = []): Media + { + if ($this->isSingleMediaCollection($collection)) { + $this->clearMediaCollection($collection); + } + + $mimeType = $file->getMimeType(); + $type = $this->getMediaType($mimeType); + + $path = $file->store('media/'.now()->format('Y-m')); + + return $this->media()->create([ + 'collection' => $collection, + 'type' => $type, + 'path' => $path, + 'original_filename' => $file->getClientOriginalName(), + 'mime_type' => $mimeType, + 'size' => $file->getSize(), + 'order' => 0, + 'meta' => array_merge($this->getMediaMeta($file, $type), $meta), + ]); + } + + public function clearMediaCollection(string $collection = 'default'): void + { + $this->getMedia($collection)->each(fn (Media $media) => $media->delete()); + } + + public function isSingleMediaCollection(string $collection): bool + { + $modelClass = static::class; + $config = self::$mediaCollections[$modelClass][$collection] ?? 'multiple'; + + return $config === 'single'; + } + + private function getMediaType(string $mimeType): string + { + if (str_starts_with($mimeType, 'image/')) { + return 'image'; + } + + if (str_starts_with($mimeType, 'video/')) { + return 'video'; + } + + return 'document'; + } + + private function getMediaMeta(UploadedFile $file, string $type): array + { + $meta = []; + + if ($type === 'image') { + $imageInfo = @getimagesize($file->getPathname()); + if ($imageInfo) { + $meta['width'] = $imageInfo[0]; + $meta['height'] = $imageInfo[1]; + } + } + + return $meta; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index e897e0ef..b8864246 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,9 +2,10 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; use App\Enums\User\Persona; use App\Enums\User\Setup; +use App\Models\Traits\HasMedia; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -15,10 +16,10 @@ use Laravel\Cashier\Billable; use Laravel\Fortify\TwoFactorAuthenticatable; -class User extends Authenticatable +class User extends Authenticatable implements MustVerifyEmail { /** @use HasFactory<\Database\Factories\UserFactory> */ - use Billable, HasFactory, HasUuids, Notifiable, TwoFactorAuthenticatable; + use Billable, HasFactory, HasMedia, HasUuids, Notifiable, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. @@ -46,6 +47,21 @@ class User extends Authenticatable 'remember_token', ]; + protected $appends = ['avatar']; + + /** + * @return array{url: string, media_id: string|null} + */ + public function getAvatarAttribute(): array + { + $media = $this->getFirstMedia('avatar'); + + return [ + 'url' => $media?->url ?? $this->getFallbackAvatarUrl($this->name), + 'media_id' => $media?->id, + ]; + } + /** * Get the attributes that should be cast. * @@ -75,8 +91,7 @@ public function workspaces(): HasMany */ public function memberWorkspaces(): BelongsToMany { - return $this->belongsToMany(Workspace::class, 'workspace_members') - ->using(WorkspaceMember::class) + return $this->belongsToMany(Workspace::class) ->withPivot('role') ->withTimestamps(); } diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index 6ebd0bfe..93e75476 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Traits\HasMedia; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -12,7 +13,7 @@ class Workspace extends Model { /** @use HasFactory<\Database\Factories\WorkspaceFactory> */ - use HasFactory, HasUuids; + use HasFactory, HasMedia, HasUuids; protected $fillable = [ 'user_id', @@ -20,6 +21,21 @@ class Workspace extends Model 'timezone', ]; + protected $appends = ['logo']; + + /** + * @return array{url: string, media_id: string|null} + */ + public function getLogoAttribute(): array + { + $media = $this->getFirstMedia('logo'); + + return [ + 'url' => $media?->url ?? $this->getFallbackAvatarUrl($this->name), + 'media_id' => $media?->id, + ]; + } + public function owner(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); @@ -27,8 +43,7 @@ public function owner(): BelongsTo public function members(): BelongsToMany { - return $this->belongsToMany(User::class, 'workspace_members') - ->using(WorkspaceMember::class) + return $this->belongsToMany(User::class) ->withPivot('role') ->withTimestamps(); } diff --git a/app/Models/WorkspaceInvite.php b/app/Models/WorkspaceInvite.php index 5e786538..c96e3cf5 100644 --- a/app/Models/WorkspaceInvite.php +++ b/app/Models/WorkspaceInvite.php @@ -2,8 +2,8 @@ namespace App\Models; -use App\Enums\InviteStatus; -use App\Enums\WorkspaceRole; +use App\Enums\UserWorkspace\Role; +use App\Enums\WorkspaceInvite\Status as InviteStatus; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -37,7 +37,7 @@ class WorkspaceInvite extends Model protected function casts(): array { return [ - 'role' => WorkspaceRole::class, + 'role' => Role::class, 'status' => InviteStatus::class, 'expires_at' => 'datetime', 'accepted_at' => 'datetime', diff --git a/app/Models/WorkspaceMember.php b/app/Models/WorkspaceMember.php deleted file mode 100644 index 028fb953..00000000 --- a/app/Models/WorkspaceMember.php +++ /dev/null @@ -1,17 +0,0 @@ -configureDefaults(); $this->configureSocialite(); $this->configureStripeWebhooks(); + + Cashier::useSubscriptionModel(Subscription::class); + Cashier::useSubscriptionItemModel(SubscriptionItem::class); } protected function configureStripeWebhooks(): void diff --git a/database/factories/MediaFactory.php b/database/factories/MediaFactory.php new file mode 100644 index 00000000..94c14315 --- /dev/null +++ b/database/factories/MediaFactory.php @@ -0,0 +1,64 @@ + + */ +class MediaFactory extends Factory +{ + protected $model = Media::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'collection' => 'default', + 'type' => MediaType::Image, + 'path' => 'media/'.now()->format('Y-m').'/'.$this->faker->uuid().'.jpg', + 'original_filename' => $this->faker->word().'.jpg', + 'mime_type' => 'image/jpeg', + 'size' => $this->faker->numberBetween(10000, 5000000), + 'order' => 0, + 'meta' => [ + 'width' => 1920, + 'height' => 1080, + ], + ]; + } + + public function video(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => MediaType::Video, + 'path' => 'media/'.now()->format('Y-m').'/'.$this->faker->uuid().'.mp4', + 'original_filename' => $this->faker->word().'.mp4', + 'mime_type' => 'video/mp4', + 'meta' => [ + 'duration' => $this->faker->numberBetween(10, 300), + ], + ]); + } + + public function logo(): static + { + return $this->state(fn (array $attributes) => [ + 'collection' => 'logo', + ]); + } + + public function avatar(): static + { + return $this->state(fn (array $attributes) => [ + 'collection' => 'avatar', + ]); + } +} diff --git a/database/factories/PostFactory.php b/database/factories/PostFactory.php index 55f16330..c5188ec0 100644 --- a/database/factories/PostFactory.php +++ b/database/factories/PostFactory.php @@ -2,6 +2,9 @@ namespace Database\Factories; +use App\Enums\Post\Status as PostStatus; +use App\Models\User; +use App\Models\Workspace; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -17,7 +20,40 @@ class PostFactory extends Factory public function definition(): array { return [ - // + 'workspace_id' => Workspace::factory(), + 'user_id' => User::factory(), + 'status' => PostStatus::Draft, + 'synced' => true, ]; } + + public function draft(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PostStatus::Draft, + ]); + } + + public function scheduled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PostStatus::Scheduled, + 'scheduled_at' => now()->addDay(), + ]); + } + + public function published(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PostStatus::Published, + 'published_at' => now(), + ]); + } + + public function failed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PostStatus::Failed, + ]); + } } diff --git a/database/factories/PostMediaFactory.php b/database/factories/PostMediaFactory.php deleted file mode 100644 index e0cad8fe..00000000 --- a/database/factories/PostMediaFactory.php +++ /dev/null @@ -1,23 +0,0 @@ - - */ -class PostMediaFactory extends Factory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - // - ]; - } -} diff --git a/database/factories/PostPlatformFactory.php b/database/factories/PostPlatformFactory.php index 5c7d3249..d3d0f248 100644 --- a/database/factories/PostPlatformFactory.php +++ b/database/factories/PostPlatformFactory.php @@ -2,6 +2,10 @@ namespace Database\Factories; +use App\Enums\PostPlatform\ContentType; +use App\Enums\SocialAccount\Platform; +use App\Models\Post; +use App\Models\SocialAccount; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -17,7 +21,60 @@ class PostPlatformFactory extends Factory public function definition(): array { return [ - // + 'post_id' => Post::factory(), + 'social_account_id' => SocialAccount::factory(), + 'enabled' => true, + 'platform' => Platform::LinkedIn, + 'content' => $this->faker->paragraph(), + 'content_type' => ContentType::Text, + 'status' => 'pending', + 'meta' => [], ]; } + + public function disabled(): static + { + return $this->state(fn (array $attributes) => [ + 'enabled' => false, + ]); + } + + public function published(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'published', + 'platform_post_id' => $this->faker->uuid(), + 'platform_url' => $this->faker->url(), + 'published_at' => now(), + ]); + } + + public function failed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'failed', + 'error_message' => 'Failed to publish', + ]); + } + + public function linkedin(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::LinkedIn, + ]); + } + + public function x(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::X, + ]); + } + + public function instagram(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::Instagram, + ]); + } } diff --git a/database/factories/SocialAccountFactory.php b/database/factories/SocialAccountFactory.php index f69d8e44..11a76694 100644 --- a/database/factories/SocialAccountFactory.php +++ b/database/factories/SocialAccountFactory.php @@ -2,6 +2,9 @@ namespace Database\Factories; +use App\Enums\SocialAccount\Platform; +use App\Enums\SocialAccount\Status; +use App\Models\Workspace; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -17,7 +20,90 @@ class SocialAccountFactory extends Factory public function definition(): array { return [ - // + 'workspace_id' => Workspace::factory(), + 'platform' => Platform::LinkedIn, + 'platform_user_id' => $this->faker->uuid(), + 'username' => $this->faker->userName(), + 'display_name' => $this->faker->name(), + 'access_token' => $this->faker->sha256(), + 'refresh_token' => $this->faker->sha256(), + 'token_expires_at' => now()->addDays(60), + 'scopes' => [], + 'meta' => [], + 'status' => Status::Connected, ]; } + + public function linkedin(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::LinkedIn, + ]); + } + + public function linkedinPage(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::LinkedInPage, + ]); + } + + public function x(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::X, + ]); + } + + public function tiktok(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::TikTok, + ]); + } + + public function youtube(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::YouTube, + ]); + } + + public function facebook(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::Facebook, + ]); + } + + public function instagram(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::Instagram, + ]); + } + + public function threads(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::Threads, + ]); + } + + public function disconnected(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => Status::Disconnected, + 'error_message' => 'Token expired', + 'disconnected_at' => now(), + ]); + } + + public function tokenExpired(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => Status::TokenExpired, + 'token_expires_at' => now()->subDay(), + ]); + } } diff --git a/database/factories/WorkspaceFactory.php b/database/factories/WorkspaceFactory.php index fdf6192c..6de65ad6 100644 --- a/database/factories/WorkspaceFactory.php +++ b/database/factories/WorkspaceFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -17,7 +18,9 @@ class WorkspaceFactory extends Factory public function definition(): array { return [ - // + 'user_id' => User::factory(), + 'name' => fake()->company(), + 'timezone' => fake()->timezone(), ]; } } diff --git a/database/factories/WorkspaceInviteFactory.php b/database/factories/WorkspaceInviteFactory.php index 01532a9e..e39c3b10 100644 --- a/database/factories/WorkspaceInviteFactory.php +++ b/database/factories/WorkspaceInviteFactory.php @@ -2,8 +2,8 @@ namespace Database\Factories; -use App\Enums\InviteStatus; -use App\Enums\WorkspaceRole; +use App\Enums\UserWorkspace\Role; +use App\Enums\WorkspaceInvite\Status as InviteStatus; use App\Models\User; use App\Models\Workspace; use Illuminate\Database\Eloquent\Factories\Factory; @@ -26,7 +26,7 @@ public function definition(): array 'invited_by' => User::factory(), 'email' => fake()->unique()->safeEmail(), 'token' => Str::random(64), - 'role' => WorkspaceRole::Member, + 'role' => Role::Member, 'status' => InviteStatus::Pending, 'expires_at' => now()->addDays(7), ]; diff --git a/database/migrations/2026_01_14_231040_create_subscriptions_table.php b/database/migrations/2026_01_14_231040_create_subscriptions_table.php index cb384ebb..7b2423b2 100644 --- a/database/migrations/2026_01_14_231040_create_subscriptions_table.php +++ b/database/migrations/2026_01_14_231040_create_subscriptions_table.php @@ -12,7 +12,7 @@ public function up(): void { Schema::create('subscriptions', function (Blueprint $table) { - $table->id(); + $table->uuid('id')->primary(); $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); $table->string('type'); $table->string('stripe_id')->unique(); diff --git a/database/migrations/2026_01_14_231041_create_subscription_items_table.php b/database/migrations/2026_01_14_231041_create_subscription_items_table.php index 80ada616..f9852e57 100644 --- a/database/migrations/2026_01_14_231041_create_subscription_items_table.php +++ b/database/migrations/2026_01_14_231041_create_subscription_items_table.php @@ -12,8 +12,8 @@ public function up(): void { Schema::create('subscription_items', function (Blueprint $table) { - $table->id(); - $table->foreignId('subscription_id'); + $table->uuid('id')->primary(); + $table->foreignUuid('subscription_id')->constrained()->cascadeOnDelete(); $table->string('stripe_id')->unique(); $table->string('meter_id')->nullable(); $table->string('stripe_product'); diff --git a/database/migrations/2026_01_14_232316_create_user_workspace_table.php b/database/migrations/2026_01_14_232316_create_user_workspace_table.php new file mode 100644 index 00000000..bd49330a --- /dev/null +++ b/database/migrations/2026_01_14_232316_create_user_workspace_table.php @@ -0,0 +1,31 @@ +foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('role'); + $table->timestamps(); + + $table->primary(['user_id', 'workspace_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_workspace'); + } +}; diff --git a/database/migrations/2026_01_14_232316_create_workspace_members_table.php b/database/migrations/2026_01_14_232316_create_workspace_members_table.php deleted file mode 100644 index fdeaeb58..00000000 --- a/database/migrations/2026_01_14_232316_create_workspace_members_table.php +++ /dev/null @@ -1,35 +0,0 @@ -uuid('id')->primary(); - $table->uuid('workspace_id'); - $table->uuid('user_id'); - $table->string('role')->default('member'); - $table->timestamps(); - - $table->foreign('workspace_id')->references('id')->on('workspaces')->cascadeOnDelete(); - $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); - - $table->unique(['workspace_id', 'user_id']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('workspace_members'); - } -}; diff --git a/database/migrations/2026_01_14_232320_create_post_media_table.php b/database/migrations/2026_01_14_232320_create_medias_table.php similarity index 72% rename from database/migrations/2026_01_14_232320_create_post_media_table.php rename to database/migrations/2026_01_14_232320_create_medias_table.php index 928e64af..5a9bd388 100644 --- a/database/migrations/2026_01_14_232320_create_post_media_table.php +++ b/database/migrations/2026_01_14_232320_create_medias_table.php @@ -11,9 +11,10 @@ */ public function up(): void { - Schema::create('post_medias', function (Blueprint $table) { + Schema::create('medias', function (Blueprint $table) { $table->uuid('id')->primary(); - $table->uuid('post_platform_id')->nullable(); + $table->uuidMorphs('mediable'); + $table->string('collection')->default('default'); $table->string('type'); $table->string('path'); $table->string('original_filename'); @@ -23,7 +24,7 @@ public function up(): void $table->json('meta')->nullable(); $table->timestamps(); - $table->foreign('post_platform_id')->references('id')->on('post_platforms')->cascadeOnDelete(); + $table->index(['mediable_type', 'mediable_id', 'collection']); }); } @@ -32,6 +33,6 @@ public function up(): void */ public function down(): void { - Schema::dropIfExists('post_medias'); + Schema::dropIfExists('medias'); } }; diff --git a/lang/php_en.json b/lang/php_en.json new file mode 100644 index 00000000..0ef12bda --- /dev/null +++ b/lang/php_en.json @@ -0,0 +1 @@ +{"auth.failed":"These credentials do not match our records.","auth.password":"The provided password is incorrect.","auth.throttle":"Too many login attempts. Please try again in :seconds seconds.","pagination.previous":"« Previous","pagination.next":"Next »","passwords.reset":"Your password has been reset.","passwords.sent":"We have emailed your password reset link.","passwords.throttled":"Please wait before retrying.","passwords.token":"This password reset token is invalid.","passwords.user":"We can't find a user with that email address.","validation.accepted":"The :attribute field must be accepted.","validation.accepted_if":"The :attribute field must be accepted when :other is :value.","validation.active_url":"The :attribute field must be a valid URL.","validation.after":"The :attribute field must be a date after :date.","validation.after_or_equal":"The :attribute field must be a date after or equal to :date.","validation.alpha":"The :attribute field must only contain letters.","validation.alpha_dash":"The :attribute field must only contain letters, numbers, dashes, and underscores.","validation.alpha_num":"The :attribute field must only contain letters and numbers.","validation.any_of":"The :attribute field is invalid.","validation.array":"The :attribute field must be an array.","validation.ascii":"The :attribute field must only contain single-byte alphanumeric characters and symbols.","validation.before":"The :attribute field must be a date before :date.","validation.before_or_equal":"The :attribute field must be a date before or equal to :date.","validation.between.array":"The :attribute field must have between :min and :max items.","validation.between.file":"The :attribute field must be between :min and :max kilobytes.","validation.between.numeric":"The :attribute field must be between :min and :max.","validation.between.string":"The :attribute field must be between :min and :max characters.","validation.boolean":"The :attribute field must be true or false.","validation.can":"The :attribute field contains an unauthorized value.","validation.confirmed":"The :attribute field confirmation does not match.","validation.contains":"The :attribute field is missing a required value.","validation.current_password":"The password is incorrect.","validation.date":"The :attribute field must be a valid date.","validation.date_equals":"The :attribute field must be a date equal to :date.","validation.date_format":"The :attribute field must match the format :format.","validation.decimal":"The :attribute field must have :decimal decimal places.","validation.declined":"The :attribute field must be declined.","validation.declined_if":"The :attribute field must be declined when :other is :value.","validation.different":"The :attribute field and :other must be different.","validation.digits":"The :attribute field must be :digits digits.","validation.digits_between":"The :attribute field must be between :min and :max digits.","validation.dimensions":"The :attribute field has invalid image dimensions.","validation.distinct":"The :attribute field has a duplicate value.","validation.doesnt_contain":"The :attribute field must not contain any of the following: :values.","validation.doesnt_end_with":"The :attribute field must not end with one of the following: :values.","validation.doesnt_start_with":"The :attribute field must not start with one of the following: :values.","validation.email":"The :attribute field must be a valid email address.","validation.encoding":"The :attribute field must be encoded in :encoding.","validation.ends_with":"The :attribute field must end with one of the following: :values.","validation.enum":"The selected :attribute is invalid.","validation.exists":"The selected :attribute is invalid.","validation.extensions":"The :attribute field must have one of the following extensions: :values.","validation.file":"The :attribute field must be a file.","validation.filled":"The :attribute field must have a value.","validation.gt.array":"The :attribute field must have more than :value items.","validation.gt.file":"The :attribute field must be greater than :value kilobytes.","validation.gt.numeric":"The :attribute field must be greater than :value.","validation.gt.string":"The :attribute field must be greater than :value characters.","validation.gte.array":"The :attribute field must have :value items or more.","validation.gte.file":"The :attribute field must be greater than or equal to :value kilobytes.","validation.gte.numeric":"The :attribute field must be greater than or equal to :value.","validation.gte.string":"The :attribute field must be greater than or equal to :value characters.","validation.hex_color":"The :attribute field must be a valid hexadecimal color.","validation.image":"The :attribute field must be an image.","validation.in":"The selected :attribute is invalid.","validation.in_array":"The :attribute field must exist in :other.","validation.in_array_keys":"The :attribute field must contain at least one of the following keys: :values.","validation.integer":"The :attribute field must be an integer.","validation.ip":"The :attribute field must be a valid IP address.","validation.ipv4":"The :attribute field must be a valid IPv4 address.","validation.ipv6":"The :attribute field must be a valid IPv6 address.","validation.json":"The :attribute field must be a valid JSON string.","validation.list":"The :attribute field must be a list.","validation.lowercase":"The :attribute field must be lowercase.","validation.lt.array":"The :attribute field must have less than :value items.","validation.lt.file":"The :attribute field must be less than :value kilobytes.","validation.lt.numeric":"The :attribute field must be less than :value.","validation.lt.string":"The :attribute field must be less than :value characters.","validation.lte.array":"The :attribute field must not have more than :value items.","validation.lte.file":"The :attribute field must be less than or equal to :value kilobytes.","validation.lte.numeric":"The :attribute field must be less than or equal to :value.","validation.lte.string":"The :attribute field must be less than or equal to :value characters.","validation.mac_address":"The :attribute field must be a valid MAC address.","validation.max.array":"The :attribute field must not have more than :max items.","validation.max.file":"The :attribute field must not be greater than :max kilobytes.","validation.max.numeric":"The :attribute field must not be greater than :max.","validation.max.string":"The :attribute field must not be greater than :max characters.","validation.max_digits":"The :attribute field must not have more than :max digits.","validation.mimes":"The :attribute field must be a file of type: :values.","validation.mimetypes":"The :attribute field must be a file of type: :values.","validation.min.array":"The :attribute field must have at least :min items.","validation.min.file":"The :attribute field must be at least :min kilobytes.","validation.min.numeric":"The :attribute field must be at least :min.","validation.min.string":"The :attribute field must be at least :min characters.","validation.min_digits":"The :attribute field must have at least :min digits.","validation.missing":"The :attribute field must be missing.","validation.missing_if":"The :attribute field must be missing when :other is :value.","validation.missing_unless":"The :attribute field must be missing unless :other is :value.","validation.missing_with":"The :attribute field must be missing when :values is present.","validation.missing_with_all":"The :attribute field must be missing when :values are present.","validation.multiple_of":"The :attribute field must be a multiple of :value.","validation.not_in":"The selected :attribute is invalid.","validation.not_regex":"The :attribute field format is invalid.","validation.numeric":"The :attribute field must be a number.","validation.password.letters":"The :attribute field must contain at least one letter.","validation.password.mixed":"The :attribute field must contain at least one uppercase and one lowercase letter.","validation.password.numbers":"The :attribute field must contain at least one number.","validation.password.symbols":"The :attribute field must contain at least one symbol.","validation.password.uncompromised":"The given :attribute has appeared in a data leak. Please choose a different :attribute.","validation.present":"The :attribute field must be present.","validation.present_if":"The :attribute field must be present when :other is :value.","validation.present_unless":"The :attribute field must be present unless :other is :value.","validation.present_with":"The :attribute field must be present when :values is present.","validation.present_with_all":"The :attribute field must be present when :values are present.","validation.prohibited":"The :attribute field is prohibited.","validation.prohibited_if":"The :attribute field is prohibited when :other is :value.","validation.prohibited_if_accepted":"The :attribute field is prohibited when :other is accepted.","validation.prohibited_if_declined":"The :attribute field is prohibited when :other is declined.","validation.prohibited_unless":"The :attribute field is prohibited unless :other is in :values.","validation.prohibits":"The :attribute field prohibits :other from being present.","validation.regex":"The :attribute field format is invalid.","validation.required":"The :attribute field is required.","validation.required_array_keys":"The :attribute field must contain entries for: :values.","validation.required_if":"The :attribute field is required when :other is :value.","validation.required_if_accepted":"The :attribute field is required when :other is accepted.","validation.required_if_declined":"The :attribute field is required when :other is declined.","validation.required_unless":"The :attribute field is required unless :other is in :values.","validation.required_with":"The :attribute field is required when :values is present.","validation.required_with_all":"The :attribute field is required when :values are present.","validation.required_without":"The :attribute field is required when :values is not present.","validation.required_without_all":"The :attribute field is required when none of :values are present.","validation.same":"The :attribute field must match :other.","validation.size.array":"The :attribute field must contain :size items.","validation.size.file":"The :attribute field must be :size kilobytes.","validation.size.numeric":"The :attribute field must be :size.","validation.size.string":"The :attribute field must be :size characters.","validation.starts_with":"The :attribute field must start with one of the following: :values.","validation.string":"The :attribute field must be a string.","validation.timezone":"The :attribute field must be a valid timezone.","validation.unique":"The :attribute has already been taken.","validation.uploaded":"The :attribute failed to upload.","validation.uppercase":"The :attribute field must be uppercase.","validation.url":"The :attribute field must be a valid URL.","validation.ulid":"The :attribute field must be a valid ULID.","validation.uuid":"The :attribute field must be a valid UUID.","validation.custom.attribute-name.rule-name":"custom-message"} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 51deede8..663b0b58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,13 @@ "@inertiajs/vue3": "^2.3.7", "@tabler/icons-vue": "^3.36.1", "@vueuse/core": "^12.8.2", + "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.19", "embla-carousel-vue": "^8.6.0", "laravel-vite-plugin": "^2.0.0", + "laravel-vue-i18n": "^2.8.0", "lucide-vue-next": "^0.468.0", "maska": "^3.2.0", "reka-ui": "^2.7.0", @@ -5102,6 +5104,16 @@ "vite": "^7.0.0" } }, + "node_modules/laravel-vue-i18n": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/laravel-vue-i18n/-/laravel-vue-i18n-2.8.0.tgz", + "integrity": "sha512-5D4GvITMZaLVwnirMU8NeQBCSfhEqDyRHiFshfP9ycdczB6e2oYZl5ow95M2sLOViPCuroYzMXqybfG9yFhoCw==", + "license": "MIT", + "dependencies": { + "php-parser": "3.2.2", + "vue": "^3.5.13" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5791,6 +5803,12 @@ "dev": true, "license": "MIT" }, + "node_modules/php-parser": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/php-parser/-/php-parser-3.2.2.tgz", + "integrity": "sha512-voj3rzCJmEbwHwH3QteON28wA6K+JbcaJEofyUZkUXmcViiXofjbSbcE5PtqtjX6nstnnAEYCFoRq0mkjP5/cg==", + "license": "BSD-3-Clause" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index f061612b..3048ea6f 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,13 @@ "@inertiajs/vue3": "^2.3.7", "@tabler/icons-vue": "^3.36.1", "@vueuse/core": "^12.8.2", + "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.19", "embla-carousel-vue": "^8.6.0", "laravel-vite-plugin": "^2.0.0", + "laravel-vue-i18n": "^2.8.0", "lucide-vue-next": "^0.468.0", "maska": "^3.2.0", "reka-ui": "^2.7.0", diff --git a/resources/js/app.ts b/resources/js/app.ts index fa4ef5cf..b8e25684 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -4,7 +4,7 @@ import { createInertiaApp } from '@inertiajs/vue3'; import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import type { DefineComponent } from 'vue'; import { createApp, h } from 'vue'; - +import { i18nVue } from 'laravel-vue-i18n'; import { initializeTheme } from './composables/useAppearance'; import { configureEcho } from '@laravel/echo-vue'; @@ -23,6 +23,12 @@ createInertiaApp({ ), setup({ el, App, props, plugin }) { createApp({ render: () => h(App, props) }) + .use(i18nVue, { + resolve: async (lang: string) => { + const langs = import.meta.glob('../../lang/*.json'); + return await langs[`../../lang/php_${lang}.json`](); + }, + }) .use(plugin) .mount(el); }, diff --git a/resources/js/components/AppHeader.vue b/resources/js/components/AppHeader.vue index 52a4d1a6..2562d7a0 100644 --- a/resources/js/components/AppHeader.vue +++ b/resources/js/components/AppHeader.vue @@ -150,7 +150,7 @@ const mainNavItems: NavItem[] = [