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:
parent
a7d88b8c47
commit
7042bcb5ae
81 changed files with 1894 additions and 1096 deletions
|
|
@ -1,8 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
namespace App\Enums\Media;
|
||||
|
||||
enum MediaType: string
|
||||
enum Type: string
|
||||
{
|
||||
case Image = 'image';
|
||||
case Video = 'video';
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
namespace App\Enums\Post;
|
||||
|
||||
enum PostStatus: string
|
||||
enum Status: string
|
||||
{
|
||||
case Draft = 'draft';
|
||||
case Scheduled = 'scheduled';
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace App\Enums\PostPlatform;
|
||||
|
||||
use App\Enums\SocialPlatform;
|
||||
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
||||
|
||||
enum ContentType: string
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
namespace App\Enums\SocialAccount;
|
||||
|
||||
enum Status: string
|
||||
{
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
namespace App\Enums\UserWorkspace;
|
||||
|
||||
enum WorkspaceRole: string
|
||||
enum Role: string
|
||||
{
|
||||
case Owner = 'owner';
|
||||
case Admin = 'admin';
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
namespace App\Enums\WorkspaceInvite;
|
||||
|
||||
enum InviteStatus: string
|
||||
enum Status: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Accepted = 'accepted';
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
13
app/Models/Subscription.php
Normal file
13
app/Models/Subscription.php
Normal 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;
|
||||
}
|
||||
13
app/Models/SubscriptionItem.php
Normal file
13
app/Models/SubscriptionItem.php
Normal 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;
|
||||
}
|
||||
128
app/Models/Traits/HasMedia.php
Normal file
128
app/Models/Traits/HasMedia.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
64
database/factories/MediaFactory.php
Normal file
64
database/factories/MediaFactory.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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
1
lang/php_en.json
Normal file
File diff suppressed because one or more lines are too long
18
package-lock.json
generated
18
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
259
resources/js/components/PhotoUpload.vue
Normal file
259
resources/js/components/PhotoUpload.vue
Normal 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>
|
||||
91
resources/js/components/hashtags/CreateDialog.vue
Normal file
91
resources/js/components/hashtags/CreateDialog.vue
Normal 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>
|
||||
102
resources/js/components/hashtags/EditDialog.vue
Normal file
102
resources/js/components/hashtags/EditDialog.vue
Normal 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>
|
||||
108
resources/js/components/labels/CreateDialog.vue
Normal file
108
resources/js/components/labels/CreateDialog.vue
Normal 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>
|
||||
118
resources/js/components/labels/EditDialog.vue
Normal file
118
resources/js/components/labels/EditDialog.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
118
resources/js/pages/settings/Workspace.vue
Normal file
118
resources/js/pages/settings/Workspace.vue
Normal 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>
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue