feat: Implement soft deletes for workspace hashtags and labels, and integrate content_type into the initial post platforms table creation.

This commit is contained in:
Paulo Castellano 2026-01-17 14:44:37 -03:00
parent a7d88b8c47
commit 7042bcb5ae
81 changed files with 1894 additions and 1096 deletions

View file

@ -1,8 +1,8 @@
<?php
namespace App\Enums;
namespace App\Enums\Media;
enum MediaType: string
enum Type: string
{
case Image = 'image';
case Video = 'video';

View file

@ -1,8 +1,8 @@
<?php
namespace App\Enums;
namespace App\Enums\Post;
enum PostStatus: string
enum Status: string
{
case Draft = 'draft';
case Scheduled = 'scheduled';

View file

@ -2,7 +2,7 @@
namespace App\Enums\PostPlatform;
use App\Enums\SocialPlatform;
use App\Enums\SocialAccount\Platform as SocialPlatform;
enum ContentType: string
{

View file

@ -1,8 +1,10 @@
<?php
namespace App\Enums;
namespace App\Enums\SocialAccount;
enum SocialPlatform: string
use App\Enums\Media\Type as MediaType;
enum Platform: string
{
case LinkedIn = 'linkedin';
case LinkedInPage = 'linkedin-page';

View file

@ -1,6 +1,6 @@
<?php
namespace App\Enums;
namespace App\Enums\SocialAccount;
enum Status: string
{

View file

@ -1,8 +1,8 @@
<?php
namespace App\Enums;
namespace App\Enums\UserWorkspace;
enum WorkspaceRole: string
enum Role: string
{
case Owner = 'owner';
case Admin = 'admin';

View file

@ -1,8 +1,8 @@
<?php
namespace App\Enums;
namespace App\Enums\WorkspaceInvite;
enum InviteStatus: string
enum Status: string
{
case Pending = 'pending';
case Accepted = 'accepted';

View file

@ -2,8 +2,8 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialPlatform;
use App\Enums\Status;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

View file

@ -2,8 +2,8 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialPlatform;
use App\Enums\Status;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

View file

@ -2,8 +2,8 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialPlatform;
use App\Enums\Status;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

View file

@ -2,8 +2,8 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialPlatform;
use App\Enums\Status;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

View file

@ -2,8 +2,8 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialPlatform;
use App\Enums\Status;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Http\Controllers\Controller;
use App\Models\SocialAccount;
use App\Models\Workspace;

View file

@ -2,8 +2,8 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialPlatform;
use App\Enums\Status;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

View file

@ -2,8 +2,8 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialPlatform;
use App\Enums\Status;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

View file

@ -2,7 +2,7 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialPlatform;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

View file

@ -2,8 +2,8 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SocialPlatform;
use App\Enums\Status;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

View file

@ -2,32 +2,25 @@
namespace App\Http\Controllers;
use App\Enums\MediaType;
use App\Http\Requests\StoreMediaRequest;
use App\Models\PostMedia;
use App\Models\Media;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MediaController extends Controller
{
public function store(StoreMediaRequest $request): JsonResponse
{
$file = $request->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;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Laravel\Cashier\Subscription as CashierSubscription;
class Subscription extends CashierSubscription
{
use HasUuids;
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Laravel\Cashier\SubscriptionItem as CashierSubscriptionItem;
class SubscriptionItem extends CashierSubscriptionItem
{
use HasUuids;
}

View file

@ -0,0 +1,128 @@
<?php
namespace App\Models\Traits;
use App\Models\Media;
use App\Models\PostPlatform;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Http\UploadedFile;
trait HasMedia
{
/**
* Media collections configuration.
* 'single' = only one media per collection (replaces existing)
* 'multiple' = unlimited media per collection
*/
protected static array $mediaCollections = [
Workspace::class => [
'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;
}
}

View file

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

View file

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

View file

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

View file

@ -1,17 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Relations\Pivot;
class WorkspaceMember extends Pivot
{
use HasUuids;
protected $table = 'workspace_members';
public $incrementing = false;
protected $keyType = 'string';
}

View file

@ -2,7 +2,7 @@
namespace App\Policies;
use App\Enums\WorkspaceRole;
use App\Enums\UserWorkspace\Role as WorkspaceRole;
use App\Models\User;
use App\Models\Workspace;

View file

@ -3,6 +3,8 @@
namespace App\Providers;
use App\Listeners\StripeEventListener;
use App\Models\Subscription;
use App\Models\SubscriptionItem;
use App\Socialite\InstagramProvider;
use App\Socialite\LinkedInPageExtendSocialite;
use Carbon\CarbonImmutable;
@ -11,6 +13,7 @@
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Events\WebhookReceived;
use Laravel\Socialite\Facades\Socialite;
use SocialiteProviders\Facebook\FacebookExtendSocialite;
@ -36,6 +39,9 @@ public function boot(): void
$this->configureDefaults();
$this->configureSocialite();
$this->configureStripeWebhooks();
Cashier::useSubscriptionModel(Subscription::class);
Cashier::useSubscriptionItemModel(SubscriptionItem::class);
}
protected function configureStripeWebhooks(): void

View file

@ -0,0 +1,64 @@
<?php
namespace Database\Factories;
use App\Enums\Media\Type as MediaType;
use App\Models\Media;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Media>
*/
class MediaFactory extends Factory
{
protected $model = Media::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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',
]);
}
}

View file

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

View file

@ -1,23 +0,0 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\PostMedia>
*/
class PostMediaFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
//
];
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

1
lang/php_en.json Normal file

File diff suppressed because one or more lines are too long

18
package-lock.json generated
View file

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

View file

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

View file

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

View file

@ -150,7 +150,7 @@ const mainNavItems: NavItem[] = [
<Button variant="ghost" size="icon"
class="relative size-10 w-auto rounded-full p-1 focus-within:ring-2 focus-within:ring-primary">
<Avatar class="size-8 overflow-hidden rounded-full">
<AvatarImage v-if="auth.user.avatar" :src="auth.user.avatar"
<AvatarImage v-if="auth.user.avatar?.url" :src="auth.user.avatar?.url"
:alt="auth.user.name" />
<AvatarFallback
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white">

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, router, usePage } from '@inertiajs/vue3';
import { Calendar, Check, ChevronsUpDown, CreditCard, Hash, LogOut, Plus, Settings, Share2, Sparkles, Tag, Users } from 'lucide-vue-next';
import { Calendar, Check, ChevronsUpDown, Hash, LogOut, Plus, Settings, Share2, Tag } from 'lucide-vue-next';
import { computed } from 'vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@ -27,17 +27,21 @@ import {
SidebarRail,
} from '@/components/ui/sidebar';
import { useInitials } from '@/composables/useInitials';
import { accounts, calendar, logout, members, settings } from '@/routes';
import { accounts, calendar, logout } from '@/routes';
import { index as hashtags } from '@/routes/hashtags';
import { index as labels } from '@/routes/labels';
import { index as billing } from '@/routes/billing';
import { edit as editProfile } from '@/routes/profile';
import { settings as workspaceSettings } from '@/routes/workspace';
import { create as createWorkspaceRoute, switchMethod } from '@/routes/workspaces';
import type { NavItem } from '@/types';
interface Workspace {
id: string;
name: string;
logo: {
url: string;
media_id: string | null;
};
}
const page = usePage();
@ -68,14 +72,9 @@ const mainNavItems: NavItem[] = [
href: labels.url(),
icon: Tag,
},
{
title: 'Members',
href: members.url(),
icon: Users,
},
{
title: 'Settings',
href: settings.url(),
href: workspaceSettings.url(),
icon: Settings,
},
];
@ -110,9 +109,11 @@ function handleLogout() {
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div class="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Sparkles class="size-4" />
</div>
<img
:src="currentWorkspace?.logo.url"
:alt="currentWorkspace?.name"
class="size-8 rounded-lg object-cover"
/>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ currentWorkspace?.name || 'Select workspace' }}</span>
<span class="truncate text-xs text-muted-foreground">Workspace</span>
@ -135,9 +136,11 @@ function handleLogout() {
class="cursor-pointer gap-2 p-2"
@click="switchWorkspace(workspace)"
>
<div class="flex size-6 items-center justify-center rounded-sm border bg-background">
<Sparkles class="size-4 shrink-0" />
</div>
<img
:src="workspace.logo.url"
:alt="workspace.name"
class="size-6 rounded-sm object-cover"
/>
<span class="truncate">{{ workspace.name }}</span>
<Check v-if="currentWorkspace?.id === workspace.id" class="ml-auto size-4" />
</DropdownMenuItem>
@ -186,7 +189,7 @@ function handleLogout() {
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage v-if="auth.user.avatar" :src="auth.user.avatar" :alt="auth.user.name" />
<AvatarImage v-if="auth.user.avatar?.url" :src="auth.user.avatar?.url" :alt="auth.user.name" />
<AvatarFallback class="rounded-lg">
{{ getInitials(auth.user.name) }}
</AvatarFallback>
@ -207,7 +210,7 @@ function handleLogout() {
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage v-if="auth.user.avatar" :src="auth.user.avatar" :alt="auth.user.name" />
<AvatarImage v-if="auth.user.avatar?.url" :src="auth.user.avatar?.url" :alt="auth.user.name" />
<AvatarFallback class="rounded-lg">
{{ getInitials(auth.user.name) }}
</AvatarFallback>
@ -223,13 +226,7 @@ function handleLogout() {
<DropdownMenuItem as-child>
<Link class="cursor-pointer" :href="editProfile()">
<Settings class="mr-2 size-4" />
Account Settings
</Link>
</DropdownMenuItem>
<DropdownMenuItem as-child>
<Link class="cursor-pointer" :href="billing.url()">
<CreditCard class="mr-2 size-4" />
Billing
Settings
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>

View file

@ -0,0 +1,259 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { store as storeMedia, destroy as destroyMedia } from '@/routes/medias';
import { router } from '@inertiajs/vue3';
import axios from 'axios';
import { Trash2, Upload, User } from 'lucide-vue-next';
import { ref, watch } from 'vue';
interface Photo {
url: string;
media_id: string | null;
}
interface Props {
modelId: string;
modelType: string;
photo: Photo;
collection?: string;
reloadOnly?: string[];
size?: 'sm' | 'md' | 'lg';
rounded?: 'full' | 'lg';
}
const props = withDefaults(defineProps<Props>(), {
collection: 'logo',
reloadOnly: () => [],
size: 'md',
rounded: 'lg',
});
const photoPreview = ref<string | null>(props.photo.url);
const uploading = ref(false);
const fileInputRef = ref<HTMLInputElement | null>(null);
const dialogOpen = ref(false);
const lightboxOpen = ref(false);
watch(
() => props.photo.url,
(newUrl) => {
photoPreview.value = newUrl;
},
);
const sizeClasses = {
sm: 'h-16 w-16',
md: 'h-20 w-20',
lg: 'h-24 w-24',
};
const roundedClasses = {
full: 'rounded-full',
lg: 'rounded-lg',
};
const handlePhotoChange = async (event: Event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) {
return;
}
uploading.value = true;
try {
const formData = new FormData();
formData.append('media', file);
formData.append('collection', props.collection);
formData.append('model', props.modelType);
formData.append('model_id', props.modelId);
await axios.post(storeMedia.url(), formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const reader = new FileReader();
reader.onload = (e) => {
photoPreview.value = e.target?.result as string;
};
reader.readAsDataURL(file);
if (props.reloadOnly.length > 0) {
router.reload({ only: props.reloadOnly, preserveScroll: true });
} else {
router.reload({ preserveScroll: true });
}
closeDialog();
} catch (error) {
console.error('Error uploading photo:', error);
} finally {
uploading.value = false;
if (fileInputRef.value) {
fileInputRef.value.value = '';
}
}
};
const removePhoto = async () => {
if (!props.photo.media_id) {
return;
}
try {
await axios.delete(
destroyMedia.url({ modelId: props.modelId, media: props.photo.media_id! }),
);
if (props.reloadOnly.length > 0) {
router.reload({ only: props.reloadOnly, preserveScroll: true });
} else {
router.reload({ preserveScroll: true });
}
} catch (error) {
console.error('Error removing photo:', error);
}
};
const triggerFileInput = () => {
fileInputRef.value?.click();
};
const openDialog = () => {
dialogOpen.value = true;
};
const closeDialog = () => {
dialogOpen.value = false;
};
</script>
<template>
<div class="flex items-center gap-4">
<div class="relative">
<img
v-if="photoPreview"
:src="photoPreview"
alt="Preview"
:class="[
'cursor-pointer border-2 border-muted object-cover transition-opacity hover:opacity-80',
sizeClasses[size],
roundedClasses[rounded],
]"
@click="lightboxOpen = true"
/>
<div
v-else
:class="[
'flex items-center justify-center bg-muted',
sizeClasses[size],
roundedClasses[rounded],
]"
>
<User class="h-8 w-8 text-muted-foreground" />
</div>
</div>
<TooltipProvider>
<div class="flex items-center gap-2">
<input
ref="fileInputRef"
type="file"
accept="image/*"
class="hidden"
@change="handlePhotoChange"
/>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="outline"
size="sm"
class="px-2"
:disabled="uploading"
@click="openDialog"
>
<Upload class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{{
uploading
? 'Uploading...'
: photo.media_id
? 'Change photo'
: 'Add photo'
}}
</p>
</TooltipContent>
</Tooltip>
<Tooltip v-if="photo.media_id">
<TooltipTrigger as-child>
<Button
type="button"
variant="outline"
size="sm"
class="px-2"
@click="removePhoto"
>
<Trash2 class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Remove photo</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<Dialog :open="dialogOpen" @update:open="(open) => !open && closeDialog()">
<DialogContent class="sm:max-w-md">
<DialogTitle>Upload Photo</DialogTitle>
<DialogDescription class="sr-only">
Select an image file to upload
</DialogDescription>
<div class="flex flex-col items-center gap-4 py-8">
<div class="rounded-full bg-muted p-6">
<Upload class="h-12 w-12 text-muted-foreground" />
</div>
<p class="text-center text-sm text-muted-foreground">
Select an image from your computer
</p>
<Button type="button" :disabled="uploading" @click="triggerFileInput">
{{ uploading ? 'Uploading...' : 'Choose file' }}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog v-model:open="lightboxOpen">
<DialogContent class="max-w-[95vw] overflow-hidden bg-black p-0 sm:max-w-3xl">
<DialogTitle class="sr-only">View Photo</DialogTitle>
<DialogDescription class="sr-only">Photo in full size</DialogDescription>
<img
v-if="photoPreview"
:src="photoPreview"
alt="Preview"
class="h-auto max-h-[90vh] w-full object-contain"
/>
</DialogContent>
</Dialog>
</div>
</template>

View file

@ -0,0 +1,91 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { store as hashtagsStore } from '@/routes/hashtags';
const open = defineModel<boolean>('open', { default: false });
const form = useForm({
name: '',
hashtags: '',
});
const submit = () => {
form.post(hashtagsStore.url(), {
onSuccess: () => {
open.value = false;
form.reset();
},
});
};
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset();
form.clearErrors();
}
open.value = value;
};
</script>
<template>
<Dialog :open="open" @update:open="handleOpenChange">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create Hashtag Group</DialogTitle>
<DialogDescription>
Give your group a name and add hashtags separated by spaces or commas
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submit" class="space-y-4">
<div class="space-y-2">
<Label for="create-name">Group Name</Label>
<Input
id="create-name"
v-model="form.name"
placeholder="e.g. Marketing, Travel, Food"
:class="{ 'border-destructive': form.errors.name }"
/>
<p v-if="form.errors.name" class="text-sm text-destructive">
{{ form.errors.name }}
</p>
</div>
<div class="space-y-2">
<Label for="create-hashtags">Hashtags</Label>
<Textarea
id="create-hashtags"
v-model="form.hashtags"
placeholder="#marketing #socialmedia #business #growth"
rows="4"
:class="{ 'border-destructive': form.errors.hashtags }"
/>
<p class="text-sm text-muted-foreground">
Enter hashtags separated by spaces or commas. Include the # symbol.
</p>
<p v-if="form.errors.hashtags" class="text-sm text-destructive">
{{ form.errors.hashtags }}
</p>
</div>
<DialogFooter>
<Button type="submit" :disabled="form.processing">
{{ form.processing ? 'Creating...' : 'Create Group' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,102 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { watch } from 'vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { update as hashtagsUpdate } from '@/routes/hashtags';
interface Hashtag {
id: string;
name: string;
hashtags: string;
}
const props = defineProps<{
hashtag: Hashtag | null;
}>();
const open = defineModel<boolean>('open', { default: false });
const form = useForm({
name: '',
hashtags: '',
});
watch(() => props.hashtag, (hashtag) => {
if (hashtag) {
form.name = hashtag.name;
form.hashtags = hashtag.hashtags;
form.clearErrors();
}
}, { immediate: true });
const submit = () => {
if (!props.hashtag) return;
form.put(hashtagsUpdate.url(props.hashtag.id), {
onSuccess: () => {
open.value = false;
},
});
};
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit Hashtag Group</DialogTitle>
<DialogDescription>
Update the name and hashtags for this group
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submit" class="space-y-4">
<div class="space-y-2">
<Label for="edit-name">Group Name</Label>
<Input
id="edit-name"
v-model="form.name"
placeholder="e.g. Marketing, Travel, Food"
:class="{ 'border-destructive': form.errors.name }"
/>
<p v-if="form.errors.name" class="text-sm text-destructive">
{{ form.errors.name }}
</p>
</div>
<div class="space-y-2">
<Label for="edit-hashtags">Hashtags</Label>
<Textarea
id="edit-hashtags"
v-model="form.hashtags"
placeholder="#marketing #socialmedia #business #growth"
rows="4"
:class="{ 'border-destructive': form.errors.hashtags }"
/>
<p class="text-sm text-muted-foreground">
Enter hashtags separated by spaces or commas. Include the # symbol.
</p>
<p v-if="form.errors.hashtags" class="text-sm text-destructive">
{{ form.errors.hashtags }}
</p>
</div>
<DialogFooter>
<Button type="submit" :disabled="form.processing">
{{ form.processing ? 'Saving...' : 'Save Changes' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,108 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { store as labelsStore } from '@/routes/labels';
const open = defineModel<boolean>('open', { default: false });
const colors = [
'#FDFD96',
'#FFD580',
'#FFB3BA',
'#FF69B4',
'#DDA0DD',
'#89CFF0',
'#90EE90',
'#D2B48C',
'#D3D3D3',
];
const form = useForm({
name: '',
color: colors[0],
});
const submit = () => {
form.post(labelsStore.url(), {
onSuccess: () => {
open.value = false;
form.reset();
},
});
};
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset();
form.color = colors[0];
form.clearErrors();
}
open.value = value;
};
</script>
<template>
<Dialog :open="open" @update:open="handleOpenChange">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create Label</DialogTitle>
<DialogDescription>
Give your label a name and pick a color
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submit" class="space-y-4">
<div class="space-y-2">
<Label for="create-name">Name</Label>
<Input
id="create-name"
v-model="form.name"
placeholder="Enter label name..."
:class="{ 'border-destructive': form.errors.name }"
/>
<p v-if="form.errors.name" class="text-sm text-destructive">
{{ form.errors.name }}
</p>
</div>
<div class="space-y-2">
<Label>Color</Label>
<div class="flex flex-wrap gap-2">
<button
v-for="color in colors"
:key="color"
type="button"
class="h-10 w-10 rounded-lg transition-all"
:class="[
form.color === color
? 'ring-2 ring-primary ring-offset-2'
: 'hover:scale-110'
]"
:style="{ backgroundColor: color }"
@click="form.color = color"
/>
</div>
<p v-if="form.errors.color" class="text-sm text-destructive">
{{ form.errors.color }}
</p>
</div>
<DialogFooter>
<Button type="submit" :disabled="form.processing">
{{ form.processing ? 'Creating...' : 'Create Label' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,118 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { watch } from 'vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { update as labelsUpdate } from '@/routes/labels';
interface LabelType {
id: string;
name: string;
color: string;
}
const props = defineProps<{
label: LabelType | null;
}>();
const open = defineModel<boolean>('open', { default: false });
const colors = [
'#FDFD96',
'#FFD580',
'#FFB3BA',
'#FF69B4',
'#DDA0DD',
'#89CFF0',
'#90EE90',
'#D2B48C',
'#D3D3D3',
];
const form = useForm({
name: '',
color: '',
});
watch(() => props.label, (label) => {
if (label) {
form.name = label.name;
form.color = label.color;
form.clearErrors();
}
}, { immediate: true });
const submit = () => {
if (!props.label) return;
form.put(labelsUpdate.url(props.label.id), {
onSuccess: () => {
open.value = false;
},
});
};
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit Label</DialogTitle>
<DialogDescription>
Update the name and color for this label
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submit" class="space-y-4">
<div class="space-y-2">
<Label for="edit-name">Name</Label>
<Input
id="edit-name"
v-model="form.name"
placeholder="Enter label name..."
:class="{ 'border-destructive': form.errors.name }"
/>
<p v-if="form.errors.name" class="text-sm text-destructive">
{{ form.errors.name }}
</p>
</div>
<div class="space-y-2">
<Label>Color</Label>
<div class="flex flex-wrap gap-2">
<button
v-for="color in colors"
:key="color"
type="button"
class="h-10 w-10 rounded-lg transition-all"
:class="[
form.color === color
? 'ring-2 ring-primary ring-offset-2'
: 'hover:scale-110'
]"
:style="{ backgroundColor: color }"
@click="form.color = color"
/>
</div>
<p v-if="form.errors.color" class="text-sm text-destructive">
{{ form.errors.color }}
</p>
</div>
<DialogFooter>
<Button type="submit" :disabled="form.processing">
{{ form.processing ? 'Saving...' : 'Save Changes' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View file

@ -2,17 +2,18 @@
import { Link } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useActiveUrl } from '@/composables/useActiveUrl';
import { toUrl } from '@/lib/utils';
import { members } from '@/routes';
import { edit as editAppearance } from '@/routes/appearance';
import { index as billing } from '@/routes/billing';
import { edit as editProfile } from '@/routes/profile';
import { show } from '@/routes/two-factor';
import { edit as editPassword } from '@/routes/user-password';
import { settings as workspaceSettings } from '@/routes/workspace';
import { type NavItem } from '@/types';
const sidebarNavItems: NavItem[] = [
const navItems: NavItem[] = [
{
title: 'Profile',
href: editProfile(),
@ -29,6 +30,18 @@ const sidebarNavItems: NavItem[] = [
title: 'Appearance',
href: editAppearance(),
},
{
title: 'Workspace',
href: workspaceSettings(),
},
{
title: 'Members',
href: members(),
},
{
title: 'Billing',
href: billing(),
},
];
const { urlIsActive } = useActiveUrl();
@ -36,41 +49,34 @@ const { urlIsActive } = useActiveUrl();
<template>
<div class="px-4 py-6">
<Heading
title="Settings"
description="Manage your profile and account settings"
/>
<div class="flex flex-col items-center gap-6">
<Heading
title="Settings"
description="Manage your profile and account settings"
class="w-full max-w-2xl"
/>
<div class="flex flex-col lg:flex-row lg:space-x-12">
<aside class="w-full max-w-xl lg:w-48">
<nav
class="flex flex-col space-y-1 space-x-0"
aria-label="Settings"
<nav
class="inline-flex h-9 w-full max-w-2xl items-center justify-start rounded-lg bg-muted p-1 text-muted-foreground"
aria-label="Settings"
>
<Link
v-for="item in navItems"
:key="toUrl(item.href)"
:href="item.href"
:class="[
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
urlIsActive(item.href)
? 'bg-background text-foreground shadow'
: 'hover:bg-background/50 hover:text-foreground',
]"
>
<Button
v-for="item in sidebarNavItems"
:key="toUrl(item.href)"
variant="ghost"
:class="[
'w-full justify-start',
{ 'bg-muted': urlIsActive(item.href) },
]"
as-child
>
<Link :href="item.href">
<component :is="item.icon" class="h-4 w-4" />
{{ item.title }}
</Link>
</Button>
</nav>
</aside>
{{ item.title }}
</Link>
</nav>
<Separator class="my-6 lg:hidden" />
<div class="flex-1 md:max-w-2xl">
<section class="max-w-xl space-y-12">
<slot />
</section>
<div class="w-full max-w-2xl">
<slot />
</div>
</div>
</div>

View file

@ -1,100 +0,0 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { index as hashtagsIndex, store as hashtagsStore } from '@/routes/hashtags';
import { type BreadcrumbItemType } from '@/types';
interface Workspace {
id: string;
name: string;
}
interface Props {
workspace: Workspace;
}
defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
{ title: 'Hashtags', href: hashtagsIndex.url() },
{ title: 'Create', href: '#' },
];
const form = useForm({
name: '',
hashtags: '',
});
const submit = () => {
form.post(hashtagsStore.url());
};
</script>
<template>
<Head title="Create Hashtag Group" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex flex-col gap-6 p-6">
<div>
<h1 class="text-2xl font-bold tracking-tight">Create Hashtag Group</h1>
<p class="text-muted-foreground">
Create a new group of hashtags to use in your posts
</p>
</div>
<Card class="max-w-2xl">
<CardHeader>
<CardTitle>Hashtag Group Details</CardTitle>
<CardDescription>
Give your group a name and add hashtags separated by spaces or commas
</CardDescription>
</CardHeader>
<CardContent>
<form @submit.prevent="submit" class="space-y-4">
<div class="space-y-2">
<Label for="name">Group Name</Label>
<Input
id="name"
v-model="form.name"
placeholder="e.g. Marketing, Travel, Food"
:class="{ 'border-destructive': form.errors.name }"
/>
<p v-if="form.errors.name" class="text-sm text-destructive">
{{ form.errors.name }}
</p>
</div>
<div class="space-y-2">
<Label for="hashtags">Hashtags</Label>
<Textarea
id="hashtags"
v-model="form.hashtags"
placeholder="#marketing #socialmedia #business #growth"
rows="6"
:class="{ 'border-destructive': form.errors.hashtags }"
/>
<p class="text-sm text-muted-foreground">
Enter hashtags separated by spaces or commas. Include the # symbol.
</p>
<p v-if="form.errors.hashtags" class="text-sm text-destructive">
{{ form.errors.hashtags }}
</p>
</div>
<div class="flex gap-2">
<Button type="submit" :disabled="form.processing">
{{ form.processing ? 'Creating...' : 'Create Group' }}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
</template>

View file

@ -1,107 +0,0 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { index as hashtagsIndex, update as hashtagsUpdate } from '@/routes/hashtags';
import { type BreadcrumbItemType } from '@/types';
interface Workspace {
id: string;
name: string;
}
interface Hashtag {
id: string;
name: string;
hashtags: string;
}
interface Props {
workspace: Workspace;
hashtag: Hashtag;
}
const props = defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
{ title: 'Hashtags', href: hashtagsIndex.url() },
{ title: 'Edit', href: '#' },
];
const form = useForm({
name: props.hashtag.name,
hashtags: props.hashtag.hashtags,
});
const submit = () => {
form.put(hashtagsUpdate.url(props.hashtag.id));
};
</script>
<template>
<Head title="Edit Hashtag Group" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex flex-col gap-6 p-6">
<div>
<h1 class="text-2xl font-bold tracking-tight">Edit Hashtag Group</h1>
<p class="text-muted-foreground">
Update your hashtag group details
</p>
</div>
<Card class="max-w-2xl">
<CardHeader>
<CardTitle>Hashtag Group Details</CardTitle>
<CardDescription>
Update the name and hashtags for this group
</CardDescription>
</CardHeader>
<CardContent>
<form @submit.prevent="submit" class="space-y-4">
<div class="space-y-2">
<Label for="name">Group Name</Label>
<Input
id="name"
v-model="form.name"
placeholder="e.g. Marketing, Travel, Food"
:class="{ 'border-destructive': form.errors.name }"
/>
<p v-if="form.errors.name" class="text-sm text-destructive">
{{ form.errors.name }}
</p>
</div>
<div class="space-y-2">
<Label for="hashtags">Hashtags</Label>
<Textarea
id="hashtags"
v-model="form.hashtags"
placeholder="#marketing #socialmedia #business #growth"
rows="6"
:class="{ 'border-destructive': form.errors.hashtags }"
/>
<p class="text-sm text-muted-foreground">
Enter hashtags separated by spaces or commas. Include the # symbol.
</p>
<p v-if="form.errors.hashtags" class="text-sm text-destructive">
{{ form.errors.hashtags }}
</p>
</div>
<div class="flex gap-2">
<Button type="submit" :disabled="form.processing">
{{ form.processing ? 'Saving...' : 'Save Changes' }}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
</template>

View file

@ -1,13 +1,15 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { Head } from '@inertiajs/vue3';
import { ref } from 'vue';
import { Plus, Hash, Pencil, Trash2 } from 'lucide-vue-next';
import AppLayout from '@/layouts/AppLayout.vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.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 { index as hashtagsIndex, create as hashtagsCreate, edit as hashtagsEdit, destroy as hashtagsDestroy } from '@/routes/hashtags';
import { index as hashtagsIndex, destroy as hashtagsDestroy } from '@/routes/hashtags';
import { type BreadcrumbItemType } from '@/types';
interface Workspace {
@ -27,14 +29,22 @@ interface Props {
hashtags: Hashtag[];
}
const props = defineProps<Props>();
defineProps<Props>();
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const isCreateDialogOpen = ref(false);
const isEditDialogOpen = ref(false);
const editingHashtag = ref<Hashtag | null>(null);
const breadcrumbs: BreadcrumbItemType[] = [
{ title: 'Hashtags', href: hashtagsIndex.url() },
];
const openEditDialog = (hashtag: Hashtag) => {
editingHashtag.value = hashtag;
isEditDialogOpen.value = true;
};
const handleDelete = (hashtagId: string) => {
deleteModal.value?.open({
url: hashtagsDestroy.url(hashtagId),
@ -58,12 +68,10 @@ const getHashtagCount = (hashtags: string): number => {
Create hashtag groups to quickly add to your posts
</p>
</div>
<Link :href="hashtagsCreate.url()">
<Button>
<Plus class="mr-2 h-4 w-4" />
New Group
</Button>
</Link>
<Button @click="isCreateDialogOpen = true">
<Plus class="mr-2 h-4 w-4" />
New Group
</Button>
</div>
<div v-if="hashtags.length === 0" class="flex flex-col items-center justify-center py-16">
@ -74,12 +82,10 @@ const getHashtagCount = (hashtags: string): number => {
<p class="text-muted-foreground mb-4 text-center max-w-sm">
Create hashtag groups to quickly add popular hashtags to your posts
</p>
<Link :href="hashtagsCreate.url()">
<Button>
<Plus class="mr-2 h-4 w-4" />
Create your first group
</Button>
</Link>
<Button @click="isCreateDialogOpen = true">
<Plus class="mr-2 h-4 w-4" />
Create your first group
</Button>
</div>
<div v-else class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
@ -88,11 +94,14 @@ const getHashtagCount = (hashtags: string): number => {
<div class="flex items-center justify-between">
<CardTitle class="text-lg">{{ hashtag.name }}</CardTitle>
<div class="flex items-center gap-1">
<Link :href="hashtagsEdit.url(hashtag.id)">
<Button variant="ghost" size="icon" class="h-8 w-8">
<Pencil class="h-4 w-4" />
</Button>
</Link>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="openEditDialog(hashtag)"
>
<Pencil class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@ -117,6 +126,9 @@ const getHashtagCount = (hashtags: string): number => {
</div>
</AppLayout>
<CreateDialog v-model:open="isCreateDialogOpen" />
<EditDialog v-model:open="isEditDialogOpen" :hashtag="editingHashtag" />
<ConfirmDeleteModal
ref="deleteModal"
title="Delete Hashtag Group"

View file

@ -1,116 +0,0 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { index as labelsIndex, store as labelsStore } from '@/routes/labels';
import { type BreadcrumbItemType } from '@/types';
interface Workspace {
id: string;
name: string;
}
interface Props {
workspace: Workspace;
}
defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
{ title: 'Labels', href: labelsIndex.url() },
{ title: 'Create', href: '#' },
];
const colors = [
'#FDFD96',
'#FFD580',
'#FFB3BA',
'#FF69B4',
'#DDA0DD',
'#89CFF0',
'#90EE90',
'#D2B48C',
'#D3D3D3',
];
const form = useForm({
name: '',
color: colors[0],
});
const submit = () => {
form.post(labelsStore.url());
};
</script>
<template>
<Head title="Create Label" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex flex-col gap-6 p-6">
<div>
<h1 class="text-2xl font-bold tracking-tight">Create Label</h1>
<p class="text-muted-foreground">
Create a new label to organize your posts
</p>
</div>
<Card class="max-w-md">
<CardHeader>
<CardTitle>Label Details</CardTitle>
<CardDescription>
Give your label a name and pick a color
</CardDescription>
</CardHeader>
<CardContent>
<form @submit.prevent="submit" class="space-y-4">
<div class="space-y-2">
<Label for="name">Name</Label>
<Input
id="name"
v-model="form.name"
placeholder="Enter label name..."
:class="{ 'border-destructive': form.errors.name }"
/>
<p v-if="form.errors.name" class="text-sm text-destructive">
{{ form.errors.name }}
</p>
</div>
<div class="space-y-2">
<Label>Color</Label>
<div class="flex flex-wrap gap-2">
<button
v-for="color in colors"
:key="color"
type="button"
class="h-10 w-10 rounded-lg transition-all"
:class="[
form.color === color
? 'ring-2 ring-primary ring-offset-2'
: 'hover:scale-110'
]"
:style="{ backgroundColor: color }"
@click="form.color = color"
/>
</div>
<p v-if="form.errors.color" class="text-sm text-destructive">
{{ form.errors.color }}
</p>
</div>
<div class="flex gap-2 pt-2">
<Button type="submit" :disabled="form.processing">
{{ form.processing ? 'Creating...' : 'Save' }}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
</template>

View file

@ -1,123 +0,0 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { index as labelsIndex, update as labelsUpdate } from '@/routes/labels';
import { type BreadcrumbItemType } from '@/types';
interface Workspace {
id: string;
name: string;
}
interface LabelType {
id: string;
name: string;
color: string;
}
interface Props {
workspace: Workspace;
label: LabelType;
}
const props = defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
{ title: 'Labels', href: labelsIndex.url() },
{ title: 'Edit', href: '#' },
];
const colors = [
'#FDFD96',
'#FFD580',
'#FFB3BA',
'#FF69B4',
'#DDA0DD',
'#89CFF0',
'#90EE90',
'#D2B48C',
'#D3D3D3',
];
const form = useForm({
name: props.label.name,
color: props.label.color,
});
const submit = () => {
form.put(labelsUpdate.url(props.label.id));
};
</script>
<template>
<Head title="Edit Label" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex flex-col gap-6 p-6">
<div>
<h1 class="text-2xl font-bold tracking-tight">Edit Label</h1>
<p class="text-muted-foreground">
Update your label details
</p>
</div>
<Card class="max-w-md">
<CardHeader>
<CardTitle>Label Details</CardTitle>
<CardDescription>
Update the name and color for this label
</CardDescription>
</CardHeader>
<CardContent>
<form @submit.prevent="submit" class="space-y-4">
<div class="space-y-2">
<Label for="name">Name</Label>
<Input
id="name"
v-model="form.name"
placeholder="Enter label name..."
:class="{ 'border-destructive': form.errors.name }"
/>
<p v-if="form.errors.name" class="text-sm text-destructive">
{{ form.errors.name }}
</p>
</div>
<div class="space-y-2">
<Label>Color</Label>
<div class="flex flex-wrap gap-2">
<button
v-for="color in colors"
:key="color"
type="button"
class="h-10 w-10 rounded-lg transition-all"
:class="[
form.color === color
? 'ring-2 ring-primary ring-offset-2'
: 'hover:scale-110'
]"
:style="{ backgroundColor: color }"
@click="form.color = color"
/>
</div>
<p v-if="form.errors.color" class="text-sm text-destructive">
{{ form.errors.color }}
</p>
</div>
<div class="flex gap-2 pt-2">
<Button type="submit" :disabled="form.processing">
{{ form.processing ? 'Saving...' : 'Save Changes' }}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
</template>

View file

@ -1,13 +1,15 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { Head } from '@inertiajs/vue3';
import { ref } from 'vue';
import { Plus, Tag, Pencil, Trash2 } from 'lucide-vue-next';
import AppLayout from '@/layouts/AppLayout.vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import CreateDialog from '@/components/labels/CreateDialog.vue';
import EditDialog from '@/components/labels/EditDialog.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { index as labelsIndex, create as labelsCreate, edit as labelsEdit, destroy as labelsDestroy } from '@/routes/labels';
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
import { index as labelsIndex, destroy as labelsDestroy } from '@/routes/labels';
import { type BreadcrumbItemType } from '@/types';
interface Workspace {
@ -27,14 +29,22 @@ interface Props {
labels: Label[];
}
const props = defineProps<Props>();
defineProps<Props>();
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const isCreateDialogOpen = ref(false);
const isEditDialogOpen = ref(false);
const editingLabel = ref<Label | null>(null);
const breadcrumbs: BreadcrumbItemType[] = [
{ title: 'Labels', href: labelsIndex.url() },
];
const openEditDialog = (label: Label) => {
editingLabel.value = label;
isEditDialogOpen.value = true;
};
const handleDelete = (labelId: string) => {
deleteModal.value?.open({
url: labelsDestroy.url(labelId),
@ -54,12 +64,10 @@ const handleDelete = (labelId: string) => {
Create labels to organize and categorize your posts
</p>
</div>
<Link :href="labelsCreate.url()">
<Button>
<Plus class="mr-2 h-4 w-4" />
New Label
</Button>
</Link>
<Button @click="isCreateDialogOpen = true">
<Plus class="mr-2 h-4 w-4" />
New Label
</Button>
</div>
<div v-if="labels.length === 0" class="flex flex-col items-center justify-center py-16">
@ -70,12 +78,10 @@ const handleDelete = (labelId: string) => {
<p class="text-muted-foreground mb-4 text-center max-w-sm">
Create labels to organize and categorize your posts
</p>
<Link :href="labelsCreate.url()">
<Button>
<Plus class="mr-2 h-4 w-4" />
Create your first label
</Button>
</Link>
<Button @click="isCreateDialogOpen = true">
<Plus class="mr-2 h-4 w-4" />
Create your first label
</Button>
</div>
<div v-else class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
@ -90,11 +96,14 @@ const handleDelete = (labelId: string) => {
<CardTitle class="text-lg">{{ label.name }}</CardTitle>
</div>
<div class="flex items-center gap-1">
<Link :href="labelsEdit.url(label.id)">
<Button variant="ghost" size="icon" class="h-8 w-8">
<Pencil class="h-4 w-4" />
</Button>
</Link>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="openEditDialog(label)"
>
<Pencil class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@ -111,6 +120,9 @@ const handleDelete = (labelId: string) => {
</div>
</AppLayout>
<CreateDialog v-model:open="isCreateDialogOpen" />
<EditDialog v-model:open="isEditDialogOpen" :label="editingLabel" />
<ConfirmDeleteModal
ref="deleteModal"
title="Delete Label"

View file

@ -6,6 +6,7 @@ import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { calendar } from '@/routes';
import { create as createPost, edit as editPost, show as showPost } from '@/routes/posts';
import { type BreadcrumbItemType } from '@/types';
@ -39,6 +40,8 @@ interface Props {
workspace: Workspace;
posts: Record<string, Post[]>;
currentWeekStart: string;
currentMonth: string;
view: 'week' | 'month';
}
const props = defineProps<Props>();
@ -47,6 +50,7 @@ const breadcrumbs: BreadcrumbItemType[] = [
{ title: 'Calendar', href: calendar.url() },
];
// Week view computed
const weekStart = computed(() => dayjs(props.currentWeekStart));
const weekDays = computed(() => {
@ -57,7 +61,7 @@ const weekDays = computed(() => {
return days;
});
const headerTitle = computed(() => {
const weekHeaderTitle = computed(() => {
const start = weekStart.value;
const end = weekStart.value.add(6, 'day');
@ -67,6 +71,40 @@ const headerTitle = computed(() => {
return `${start.format('MMM D')} - ${end.format('MMM D, YYYY')}`;
});
// Month view computed
const monthDate = computed(() => dayjs(props.currentMonth));
const monthHeaderTitle = computed(() => {
return monthDate.value.format('MMMM YYYY');
});
const calendarDays = computed(() => {
const start = monthDate.value.startOf('month').startOf('week');
const end = monthDate.value.endOf('month').endOf('week');
const days = [];
let current = start;
while (current.isBefore(end) || current.isSame(end, 'day')) {
days.push(current);
current = current.add(1, 'day');
}
return days;
});
const calendarWeeks = computed(() => {
const weeks = [];
for (let i = 0; i < calendarDays.value.length; i += 7) {
weeks.push(calendarDays.value.slice(i, i + 7));
}
return weeks;
});
// Header title based on view
const headerTitle = computed(() => {
return props.view === 'month' ? monthHeaderTitle.value : weekHeaderTitle.value;
});
const getPostsForDay = (day: dayjs.Dayjs): Post[] => {
const dateKey = day.format('YYYY-MM-DD');
return props.posts[dateKey] || [];
@ -74,13 +112,34 @@ const getPostsForDay = (day: dayjs.Dayjs): Post[] => {
const navigateWeek = (direction: number) => {
const newStart = weekStart.value.add(direction * 7, 'day');
router.get(calendar.url({ query: { week: newStart.format('YYYY-MM-DD') } }), {}, {
router.get(calendar.url({ query: { view: 'week', week: newStart.format('YYYY-MM-DD') } }), {}, {
preserveState: true,
});
};
const navigateMonth = (direction: number) => {
const newMonth = monthDate.value.add(direction, 'month');
router.get(calendar.url({ query: { view: 'month', month: newMonth.format('YYYY-MM-DD') } }), {}, {
preserveState: true,
});
};
const navigate = (direction: number) => {
if (props.view === 'month') {
navigateMonth(direction);
} else {
navigateWeek(direction);
}
};
const goToToday = () => {
router.get(calendar.url(), {}, {
router.get(calendar.url({ query: { view: props.view } }), {}, {
preserveState: true,
});
};
const switchView = (view: string) => {
router.get(calendar.url({ query: { view } }), {}, {
preserveState: true,
});
};
@ -89,6 +148,10 @@ const isToday = (day: dayjs.Dayjs): boolean => {
return day.isSame(dayjs(), 'day');
};
const isCurrentMonth = (day: dayjs.Dayjs): boolean => {
return day.month() === monthDate.value.month();
};
const getStatusColor = (status: string): string => {
const colors: Record<string, string> = {
draft: 'bg-gray-100 border-gray-300 text-gray-700',
@ -135,10 +198,10 @@ const formatTime = (scheduledAt: string): string => {
<div class="flex items-center justify-between p-4 border-b">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<Button variant="outline" size="icon" @click="navigateWeek(-1)">
<Button variant="outline" size="icon" @click="navigate(-1)">
<ChevronLeft class="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" @click="navigateWeek(1)">
<Button variant="outline" size="icon" @click="navigate(1)">
<ChevronRight class="h-4 w-4" />
</Button>
</div>
@ -149,16 +212,24 @@ const formatTime = (scheduledAt: string): string => {
{{ headerTitle }}
</h1>
</div>
<Link :href="createPost.url()">
<Button>
<Plus class="mr-2 h-4 w-4" />
New Post
</Button>
</Link>
<div class="flex items-center gap-4">
<Tabs :default-value="view" @update:model-value="switchView">
<TabsList>
<TabsTrigger value="week">Week</TabsTrigger>
<TabsTrigger value="month">Month</TabsTrigger>
</TabsList>
</Tabs>
<Link :href="createPost.url()">
<Button>
<Plus class="mr-2 h-4 w-4" />
New Post
</Button>
</Link>
</div>
</div>
<!-- Week Grid -->
<div class="flex-1 grid grid-cols-7 divide-x overflow-hidden">
<!-- Week View -->
<div v-if="view === 'week'" class="flex-1 grid grid-cols-7 divide-x overflow-hidden">
<div
v-for="day in weekDays"
:key="day.format('YYYY-MM-DD')"
@ -233,6 +304,100 @@ const formatTime = (scheduledAt: string): string => {
</div>
</div>
</div>
<!-- Month View -->
<div v-else class="flex-1 flex flex-col overflow-hidden border-l">
<!-- Weekday Headers -->
<div class="grid grid-cols-7 divide-x border-b bg-muted/30">
<div
v-for="day in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
:key="day"
class="py-3 text-center text-xs font-medium text-muted-foreground uppercase"
>
{{ day }}
</div>
</div>
<!-- Calendar Grid -->
<div
class="flex-1 grid divide-y"
:style="{ gridTemplateRows: `repeat(${calendarWeeks.length}, minmax(0, 1fr))` }"
>
<div
v-for="(week, weekIndex) in calendarWeeks"
:key="weekIndex"
class="grid grid-cols-7 divide-x min-h-0"
>
<div
v-for="day in week"
:key="day.format('YYYY-MM-DD')"
class="flex flex-col p-2 min-h-0 overflow-hidden group"
:class="{
'bg-primary/5': isToday(day),
'bg-muted/30': !isCurrentMonth(day),
}"
>
<!-- Day Header -->
<div class="flex items-center justify-between mb-2">
<span
class="flex items-center justify-center w-7 h-7 text-sm font-medium rounded-full"
:class="{
'bg-primary text-primary-foreground': isToday(day),
'text-muted-foreground': !isCurrentMonth(day),
'text-foreground': isCurrentMonth(day) && !isToday(day),
}"
>
{{ day.format('D') }}
</span>
<Link
:href="createPost.url({ query: { date: day.format('YYYY-MM-DD') } })"
class="opacity-0 group-hover:opacity-100 focus:opacity-100 p-1 rounded text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all"
>
<Plus class="h-4 w-4" />
</Link>
</div>
<!-- Posts -->
<div class="flex-1 overflow-y-auto space-y-1 min-h-0">
<Link
v-for="post in getPostsForDay(day).slice(0, 3)"
:key="post.id"
:href="getPostUrl(post)"
class="block"
>
<div
class="flex items-center justify-between gap-1.5 px-2 py-1 rounded border text-xs transition-colors hover:brightness-95"
:class="getStatusColor(post.status)"
>
<span class="font-medium shrink-0">{{ formatTime(post.scheduled_at) }}</span>
<div class="flex -space-x-1 shrink-0">
<img
v-for="pp in post.post_platforms.slice(0, post.post_platforms.length > 4 ? 3 : 4)"
:key="pp.id"
:src="getPlatformLogo(pp.platform)"
:alt="pp.platform"
class="h-4 w-4 rounded-full ring-1 ring-white"
/>
<span
v-if="post.post_platforms.length > 4"
class="flex items-center justify-center h-4 w-4 rounded-full bg-muted text-[9px] font-medium ring-1 ring-white"
>
+{{ post.post_platforms.length - 3 }}
</span>
</div>
</div>
</Link>
<div
v-if="getPostsForDay(day).length > 3"
class="text-xs text-muted-foreground px-2 py-0.5"
>
+{{ getPostsForDay(day).length - 3 }} more
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</AppLayout>
</template>

View file

@ -2,6 +2,7 @@
import { Head, router } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import { Clock, AlertCircle, Trash2, Send, Link2, MoreHorizontal, Plus } from 'lucide-vue-next';
import axios from 'axios';
import dayjs from '@/dayjs';
import { PlatformPreview } from '@/components/posts/previews';
@ -41,6 +42,7 @@ import DatePicker from '@/components/DatePicker.vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import { calendar } from '@/routes';
import { destroy as destroyPost, update as updatePost } from '@/routes/posts';
import { store as storeMedia, destroy as destroyMedia, duplicate as duplicateMedia } from '@/routes/medias';
import { type BreadcrumbItemType } from '@/types';
interface SocialAccount {
@ -162,6 +164,10 @@ const { date: initialDate, time: initialTime } = getLocalSchedule();
const scheduledDate = ref(initialDate);
const scheduledTime = ref(initialTime);
const timezoneAbbr = computed(() => {
return dayjs().tz(props.workspace.timezone).format('z');
});
// Helpers
const getPlatformLogo = (platform: string): string => {
const logos: Record<string, string> = {
@ -441,14 +447,10 @@ const isSingleMediaPlatform = (platformId: string): boolean => {
};
const clearPlatformMedia = async (platformId: string) => {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
const media = platformMedia.value[platformId] || [];
for (const m of media) {
await fetch(`/media/${m.id}`, {
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': csrfToken },
});
await axios.delete(destroyMedia.url({ modelId: platformId, media: m.id }));
}
platformMedia.value[platformId] = [];
@ -460,8 +462,6 @@ const handleFileUpload = async (event: Event, postPlatformId: string) => {
if (!files || files.length === 0) return;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
// Get other platforms to duplicate to (if synced)
const otherPlatformIds = synced.value
? selectedPlatformIds.value.filter(id => id !== postPlatformId)
@ -482,44 +482,37 @@ const handleFileUpload = async (event: Event, postPlatformId: string) => {
try {
// 1. Upload once to the current platform
const formData = new FormData();
formData.append('file', file);
formData.append('post_platform_id', postPlatformId);
formData.append('media', file);
formData.append('model', 'App\\Models\\PostPlatform');
formData.append('model_id', postPlatformId);
const response = await fetch('/media', {
method: 'POST',
body: formData,
headers: { 'X-CSRF-TOKEN': csrfToken },
});
const response = await axios.post(storeMedia.url(), formData);
const data = response.data;
if (response.ok) {
const data = await response.json();
// Add to current platform (use spread for reactivity)
const currentMedia = platformMedia.value[postPlatformId] || [];
platformMedia.value[postPlatformId] = [...currentMedia, data];
// Add to current platform (use spread for reactivity)
const currentMedia = platformMedia.value[postPlatformId] || [];
platformMedia.value[postPlatformId] = [...currentMedia, data];
// 2. If synced, duplicate to other platforms
if (otherPlatformIds.length > 0) {
const targets = otherPlatformIds.map(id => ({
model: 'App\\Models\\PostPlatform',
model_id: id,
}));
// 2. If synced, duplicate to other platforms
if (otherPlatformIds.length > 0) {
const duplicateResponse = await fetch(`/media/${data.id}/duplicate`, {
method: 'POST',
body: JSON.stringify({ post_platform_ids: otherPlatformIds }),
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
},
});
const duplicateResponse = await axios.post(
duplicateMedia.url({ media: data.id }),
{ targets }
);
if (duplicateResponse.ok) {
const duplicates = await duplicateResponse.json();
for (const dup of duplicates) {
// For single-media platforms, clear first
if (isSingleMediaPlatform(dup.post_platform_id)) {
await clearPlatformMedia(dup.post_platform_id);
}
const existingMedia = platformMedia.value[dup.post_platform_id] || [];
platformMedia.value[dup.post_platform_id] = [...existingMedia, dup];
}
const duplicates = duplicateResponse.data;
for (const dup of duplicates) {
// For single-media platforms, clear first
if (isSingleMediaPlatform(dup.mediable_id)) {
await clearPlatformMedia(dup.mediable_id);
}
const existingMedia = platformMedia.value[dup.mediable_id] || [];
platformMedia.value[dup.mediable_id] = [...existingMedia, dup];
}
}
} catch (error) {
@ -557,12 +550,7 @@ const removeMedia = async (postPlatformId: string, mediaId: string) => {
);
// Delete from server
await fetch(`/media/${mediaInPlatform.id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
},
});
await axios.delete(destroyMedia.url({ modelId: targetId, media: mediaInPlatform.id }));
}
}
};
@ -709,6 +697,9 @@ const deletePost = () => {
v-model="scheduledTime"
class="w-[100px] h-9"
/>
<span class="text-xs text-muted-foreground">
{{ timezoneAbbr }}
</span>
</div>
<span class="text-muted-foreground">|</span>

View file

@ -2,18 +2,19 @@
import { Head, useForm, router } from '@inertiajs/vue3';
import { UserPlus, Users, Mail, Clock, Trash2, Crown, User, Shield } from 'lucide-vue-next';
import AppLayout from '@/layouts/AppLayout.vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { members } from '@/routes';
import { destroy as destroyInvite, store as storeInvite } from '@/routes/invites';
import { remove as removeMember } from '@/routes/members';
import { type BreadcrumbItemType } from '@/types';
import { type BreadcrumbItem } from '@/types';
interface Workspace {
id: string;
@ -51,7 +52,7 @@ interface Props {
const props = defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
const breadcrumbItems: BreadcrumbItem[] = [
{ title: 'Members', href: members.url() },
];
@ -110,20 +111,18 @@ function getRoleIcon(role: string) {
</script>
<template>
<Head :title="`Team - ${workspace.name}`" />
<AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Members" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex flex-col gap-6 p-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">Team</h1>
<p class="text-muted-foreground">
Manage members and invites for this workspace
</p>
</div>
</div>
<h1 class="sr-only">Members</h1>
<SettingsLayout>
<div class="flex flex-col space-y-6">
<HeadingSmall
title="Team members"
description="Manage members and invites for this workspace"
/>
<div class="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
@ -222,65 +221,65 @@ function getRoleIcon(role: string) {
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Users class="h-5 w-5" />
Members
</CardTitle>
<CardDescription>
People with access to this workspace
</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-3">
<div class="flex items-center justify-between p-3 rounded-lg border bg-muted/50">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Crown class="h-5 w-5" />
</div>
<div>
<p class="font-medium">{{ owner.name }}</p>
<p class="text-sm text-muted-foreground">{{ owner.email }}</p>
</div>
</div>
<Badge>Owner</Badge>
</div>
<div
v-for="member in members"
:key="member.id"
class="flex items-center justify-between p-3 rounded-lg border"
>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
<component :is="getRoleIcon(member.role)" class="h-5 w-5" />
</div>
<div>
<p class="font-medium">{{ member.name }}</p>
<p class="text-sm text-muted-foreground">{{ member.email }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<Badge variant="outline" class="capitalize">{{ member.role }}</Badge>
<Button
variant="ghost"
size="icon"
@click="handleRemoveMember(member.id)"
>
<Trash2 class="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
<div v-if="members.length === 0" class="text-center py-6 text-muted-foreground">
No members besides the owner
</div>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Users class="h-5 w-5" />
Members
</CardTitle>
<CardDescription>
People with access to this workspace
</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-3">
<div class="flex items-center justify-between p-3 rounded-lg border bg-muted/50">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Crown class="h-5 w-5" />
</div>
<div>
<p class="font-medium">{{ owner.name }}</p>
<p class="text-sm text-muted-foreground">{{ owner.email }}</p>
</div>
</div>
<Badge>Owner</Badge>
</div>
<div
v-for="member in members"
:key="member.id"
class="flex items-center justify-between p-3 rounded-lg border"
>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
<component :is="getRoleIcon(member.role)" class="h-5 w-5" />
</div>
<div>
<p class="font-medium">{{ member.name }}</p>
<p class="text-sm text-muted-foreground">{{ member.email }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<Badge variant="outline" class="capitalize">{{ member.role }}</Badge>
<Button
variant="ghost"
size="icon"
@click="handleRemoveMember(member.id)"
>
<Trash2 class="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
<div v-if="members.length === 0" class="text-center py-6 text-muted-foreground">
No members besides the owner
</div>
</div>
</CardContent>
</Card>
</div>
</SettingsLayout>
</AppLayout>
</template>

View file

@ -5,6 +5,7 @@ import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileCo
import DeleteUser from '@/components/DeleteUser.vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
import InputError from '@/components/InputError.vue';
import PhotoUpload from '@/components/PhotoUpload.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@ -45,6 +46,18 @@ const user = page.props.auth.user;
description="Update your name and email address"
/>
<div class="grid gap-2">
<Label>Avatar</Label>
<PhotoUpload
:model-id="user.id"
model-type="App\Models\User"
:photo="user.avatar"
collection="avatar"
:reload-only="['auth']"
rounded="full"
/>
</div>
<Form
v-bind="ProfileController.update.form()"
class="space-y-6"

View file

@ -0,0 +1,118 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import HeadingSmall from '@/components/HeadingSmall.vue';
import InputError from '@/components/InputError.vue';
import PhotoUpload from '@/components/PhotoUpload.vue';
import TimezoneCombobox from '@/components/TimezoneCombobox.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import { settings } from '@/routes/workspace';
import { update } from '@/routes/workspace/settings';
import { type BreadcrumbItem } from '@/types';
interface Workspace {
id: string;
name: string;
timezone: string;
logo: {
url: string;
media_id: string | null;
};
}
interface Props {
workspace: Workspace;
timezones: Record<string, string>;
}
const props = defineProps<Props>();
const breadcrumbItems: BreadcrumbItem[] = [
{
title: 'Workspace settings',
href: settings().url,
},
];
const form = useForm({
name: props.workspace.name,
timezone: props.workspace.timezone,
});
const submit = () => {
form.put(update().url);
};
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Workspace settings" />
<h1 class="sr-only">Workspace Settings</h1>
<SettingsLayout>
<div class="flex flex-col space-y-6">
<HeadingSmall
title="Workspace settings"
description="Update your workspace name, logo, and timezone"
/>
<div class="grid gap-2">
<Label>Logo</Label>
<PhotoUpload
:model-id="workspace.id"
model-type="App\Models\Workspace"
:photo="workspace.logo"
collection="logo"
:reload-only="['workspace']"
/>
</div>
<form @submit.prevent="submit" class="space-y-6">
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input
id="name"
v-model="form.name"
class="mt-1 block w-full"
required
placeholder="My Workspace"
/>
<InputError class="mt-2" :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="timezone">Timezone</Label>
<TimezoneCombobox
v-model="form.timezone"
:timezones="timezones"
/>
<InputError class="mt-2" :message="form.errors.timezone" />
</div>
<div class="flex items-center gap-4">
<Button :disabled="form.processing">Save</Button>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-show="form.recentlySuccessful"
class="text-sm text-neutral-600"
>
Saved.
</p>
</Transition>
</div>
</form>
</div>
</SettingsLayout>
</AppLayout>
</template>

View file

@ -5,7 +5,8 @@ import { Plus, Calendar, Users, Settings } from 'lucide-vue-next';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { accounts, calendar, settings } from '@/routes';
import { accounts, calendar } from '@/routes';
import { settings } from '@/routes/workspace';
import { create as createWorkspace, index as workspacesIndex, switchMethod } from '@/routes/workspaces';
import { type BreadcrumbItemType } from '@/types';

View file

@ -1,98 +0,0 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { Settings } from 'lucide-vue-next';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import TimezoneCombobox from '@/components/TimezoneCombobox.vue';
import { type BreadcrumbItemType } from '@/types';
interface Workspace {
id: string;
name: string;
timezone: string;
}
interface Props {
workspace: Workspace;
timezones: Record<string, string>;
}
const props = defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [
{ title: 'Settings', href: '/settings' },
];
const form = useForm({
name: props.workspace.name,
timezone: props.workspace.timezone,
});
const submit = () => {
form.put('/settings');
};
</script>
<template>
<Head title="Settings" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex flex-col gap-6 p-6">
<div>
<h1 class="text-2xl font-bold tracking-tight">Settings</h1>
<p class="text-muted-foreground">
Manage your workspace settings
</p>
</div>
<Card class="max-w-lg">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Settings class="h-5 w-5" />
Workspace Settings
</CardTitle>
<CardDescription>
Update your workspace name and timezone
</CardDescription>
</CardHeader>
<CardContent>
<form @submit.prevent="submit" class="space-y-4">
<div class="space-y-2">
<Label for="name">Name</Label>
<Input
id="name"
v-model="form.name"
placeholder="My Workspace"
:class="{ 'border-destructive': form.errors.name }"
/>
<p v-if="form.errors.name" class="text-sm text-destructive">
{{ form.errors.name }}
</p>
</div>
<div class="space-y-2">
<Label for="timezone">Timezone</Label>
<TimezoneCombobox
v-model="form.timezone"
:timezones="timezones"
/>
<p v-if="form.errors.timezone" class="text-sm text-destructive">
{{ form.errors.timezone }}
</p>
</div>
<div class="flex gap-2 pt-2">
<Button type="submit" :disabled="form.processing">
{{ form.processing ? 'Saving...' : 'Save Changes' }}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
</template>

View file

@ -1,11 +1,12 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { Calendar, Users, FileText, CheckCircle, Clock, AlertCircle, UserPlus, Settings } from 'lucide-vue-next';
import { Calendar, Users, FileText, CheckCircle, Clock, AlertCircle, Settings } from 'lucide-vue-next';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { accounts, calendar, members, settings } from '@/routes';
import { accounts, calendar } from '@/routes';
import { settings } from '@/routes/workspace';
import { type BreadcrumbItemType } from '@/types';
interface SocialAccount {
@ -59,12 +60,6 @@ const breadcrumbs: BreadcrumbItemType[] = [
<Settings class="h-4 w-4" />
</Button>
</Link>
<Link :href="members.url()">
<Button variant="outline">
<UserPlus class="mr-2 h-4 w-4" />
Team
</Button>
</Link>
<Link :href="accounts.url()">
<Button variant="outline">
<Users class="mr-2 h-4 w-4" />

View file

@ -7,8 +7,6 @@
use Inertia\Inertia;
Route::middleware(['auth'])->group(function () {
Route::redirect('settings', '/settings/profile');
Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
});

View file

@ -96,9 +96,9 @@
Route::post('workspaces/{workspace}/switch', [WorkspaceController::class, 'switch'])->name('workspaces.switch');
Route::delete('workspaces/{workspace}', [WorkspaceController::class, 'destroy'])->name('workspaces.destroy');
// Current workspace routes (no {workspace} in URL)
Route::get('settings', [WorkspaceController::class, 'settings'])->name('settings');
Route::put('settings', [WorkspaceController::class, 'updateSettings'])->name('settings.update');
// Current workspace settings
Route::get('settings/workspace', [WorkspaceController::class, 'settings'])->name('workspace.settings');
Route::put('settings/workspace', [WorkspaceController::class, 'updateSettings'])->name('workspace.settings.update');
// Social Accounts
Route::get('accounts', [SocialController::class, 'index'])->name('accounts');
@ -117,29 +117,25 @@
Route::delete('posts/{post}', [PostController::class, 'destroy'])->name('posts.destroy');
// Media
Route::post('media', [MediaController::class, 'store'])->name('media.store');
Route::post('media/{media}/duplicate', [MediaController::class, 'duplicate'])->name('media.duplicate');
Route::delete('media/{media}', [MediaController::class, 'destroy'])->name('media.destroy');
Route::post('medias', [MediaController::class, 'store'])->name('medias.store');
Route::post('medias/{media}/duplicate', [MediaController::class, 'duplicate'])->name('medias.duplicate');
Route::delete('medias/{modelId}/{media}', [MediaController::class, 'destroy'])->name('medias.destroy');
// Members
Route::get('members', [WorkspaceInviteController::class, 'index'])->name('members');
Route::post('invites', [WorkspaceInviteController::class, 'store'])->name('invites.store');
Route::delete('invites/{invite}', [WorkspaceInviteController::class, 'destroy'])->name('invites.destroy');
Route::delete('members/{user}', [WorkspaceInviteController::class, 'removeMember'])->name('members.remove');
// Members (Settings)
Route::get('settings/members', [WorkspaceInviteController::class, 'index'])->name('members');
Route::post('settings/members/invites', [WorkspaceInviteController::class, 'store'])->name('invites.store');
Route::delete('settings/members/invites/{invite}', [WorkspaceInviteController::class, 'destroy'])->name('invites.destroy');
Route::delete('settings/members/{user}', [WorkspaceInviteController::class, 'removeMember'])->name('members.remove');
// Hashtags
Route::get('hashtags', [WorkspaceHashtagController::class, 'index'])->name('hashtags.index');
Route::get('hashtags/create', [WorkspaceHashtagController::class, 'create'])->name('hashtags.create');
Route::post('hashtags', [WorkspaceHashtagController::class, 'store'])->name('hashtags.store');
Route::get('hashtags/{hashtag}/edit', [WorkspaceHashtagController::class, 'edit'])->name('hashtags.edit');
Route::put('hashtags/{hashtag}', [WorkspaceHashtagController::class, 'update'])->name('hashtags.update');
Route::delete('hashtags/{hashtag}', [WorkspaceHashtagController::class, 'destroy'])->name('hashtags.destroy');
// Labels
Route::get('labels', [WorkspaceLabelController::class, 'index'])->name('labels.index');
Route::get('labels/create', [WorkspaceLabelController::class, 'create'])->name('labels.create');
Route::post('labels', [WorkspaceLabelController::class, 'store'])->name('labels.store');
Route::get('labels/{label}/edit', [WorkspaceLabelController::class, 'edit'])->name('labels.edit');
Route::put('labels/{label}', [WorkspaceLabelController::class, 'update'])->name('labels.update');
Route::delete('labels/{label}', [WorkspaceLabelController::class, 'destroy'])->name('labels.destroy');

View file

@ -2,6 +2,7 @@ import { wayfinder } from '@laravel/vite-plugin-wayfinder';
import tailwindcss from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
import laravel from 'laravel-vite-plugin';
import i18n from 'laravel-vue-i18n/vite';
import { defineConfig } from 'vite';
export default defineConfig({
@ -23,5 +24,6 @@ export default defineConfig({
},
},
}),
i18n(),
],
});