feat: @mentions in comments, AI Action layer + MCP tools, settings tabs
Mentions in post comments
- @mention autocomplete (workspace members, current user excluded) with
marker syntax @[uuid] persisted, display names rendered via CommentBody
chips; live edit replaces markers with names and converts back on save.
- NotifyMentions action with workspace-scoped membership check, dedupes
same user, only newly-added mentions on update.
- Email + in-app via SendNotification job, respecting per-user
notification_preferences.mentioned_in_comment.
- Heartbeat-based presence (Cache, 60s TTL, 30s ping) so online recipients
get only the in-app notification — no email noise.
- Real-time bell on workspace.{id}.user.{id} private channel
(NotificationCreated event), scoped channel name avoids client-side
filtering and lays out a convention for future workspace channels.
- Mailable localized via lang/{en,es,pt-BR}/mail.php; Maizzle source
template for the email is committed and built into resources/views/mail.
AI generation refactor (Action layer + MCP)
- Extracted Actions/Ai/Generate{Image,Video} with QuotaExhaustedException
so agent tools and MCP tools share a single domain entry point.
- Mcp/Tools/Ai/Generate{Image,Video}Tool registered in TryPostServer; both
return MediaResource payloads.
- Orientation::imageApiSize maps non-OpenAI ratios to 1:1/2:3/3:2.
- config/ai.php is now the single source of truth driven by env, removing
the trypost.ai shim. Default text/image providers flipped to OpenAI.
Settings/UX
- /settings/workspace split into shadcn Tabs (Workspace / Brand / Users)
with three components.
- /assets and the in-editor MediaPicker open the ImagePreviewDialog
lightbox on image click while preserving action button behaviour.
- Comments tab landed via ?tab=comments&comment=<id> from notification
click (scroll-to + temporary highlight).
- Mention autocomplete popover flips above when near the viewport bottom.
- Real social platform PNGs replace Tabler brand glyphs in schedule
pills and post list, with hover tooltip carrying display_name + handle.
Bug fixes
- AcceptInvite: controller now passes workspace + role payload that the
Vue page expects; login/register CTAs preselect the invite email.
- WorkspaceInvite mailable: stopped referencing nonexistent
$invite->workspace and $invite->role; column added to the migration,
Invite model casts role to WorkspaceRole, CreateInvite persists it.
- PostCommentCreated: added broadcastAs so .PostCommentCreated actually
matches the Echo listener; payload now includes mentioned_users so
receivers render the chip correctly without a refetch.
- Preview components for X/Pinterest/Threads/Bluesky/LinkedIn/Mastodon/
TikTok/YouTube switched from item.type === 'image' to
!isVideoMedia(item) so media without a persisted type still renders.
- UpdatePostRequest now accepts media.*.{type,mime_type,size,...} so the
posts.media JSON keeps the metadata that the previews need.
- Removed throttle:6,1 from social connect routes (was 429ing legitimate
OAuth retries).
- Used MediaType enum cases instead of literal 'image'/'video' strings
when creating media rows.
Tests
- MentionParser unit tests, NotifyMentions feature tests including
online/offline channel selection and preference gating, MCP AI tool
happy paths, MentionedInComment mailable rendering, AcceptInvite +
search-members + index mentioned_users path. 1229 passing.
This commit is contained in:
parent
dafdd5da43
commit
3c3b170b21
85 changed files with 2607 additions and 581 deletions
10
.env.example
10
.env.example
|
|
@ -140,11 +140,13 @@ GEMINI_API_KEY=
|
|||
ELEVENLABS_API_KEY=
|
||||
ELEVENLABS_DEFAULT_VOICE=EXAVITQu4vr4xnSDxMaL
|
||||
|
||||
# AI Provider Selection (text: gemini/openai, image: gemini, audio: elevenlabs, video: gemini)
|
||||
AI_TEXT_PROVIDER=gemini
|
||||
AI_IMAGE_PROVIDER=gemini
|
||||
# AI Provider Selection
|
||||
# text: openai | anthropic | gemini | xai | groq | mistral | deepseek | ...
|
||||
# image: openai | gemini | xai
|
||||
# audio: openai | elevenlabs
|
||||
AI_TEXT_PROVIDER=openai
|
||||
AI_IMAGE_PROVIDER=openai
|
||||
AI_AUDIO_PROVIDER=elevenlabs
|
||||
AI_VIDEO_PROVIDER=gemini
|
||||
|
||||
# Media Services
|
||||
UNSPLASH_ACCESS_KEY=
|
||||
|
|
|
|||
82
app/Actions/Ai/GenerateImage.php
Normal file
82
app/Actions/Ai/GenerateImage.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Ai;
|
||||
|
||||
use App\Enums\Ai\Orientation;
|
||||
use App\Enums\Ai\UsageType;
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use App\Exceptions\Ai\QuotaExhaustedException;
|
||||
use App\Features\AiImagesLimit;
|
||||
use App\Models\AiUsageLog;
|
||||
use App\Models\Media;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Ai\Image;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
final class GenerateImage
|
||||
{
|
||||
/**
|
||||
* Generate an AI image, store it, register it in the workspace media library,
|
||||
* and log the usage. Returns the persisted Media model.
|
||||
*
|
||||
* Throws QuotaExhaustedException when the workspace's monthly image quota
|
||||
* is exhausted, leaving the caller free to translate that into a tool message,
|
||||
* an HTTP error, etc.
|
||||
*/
|
||||
public static function execute(
|
||||
Workspace $workspace,
|
||||
string $prompt,
|
||||
Orientation $orientation,
|
||||
?string $userId = null,
|
||||
?string $postId = null,
|
||||
): Media {
|
||||
$limit = (int) Feature::for($workspace->account)->value(AiImagesLimit::class);
|
||||
$used = AiUsageLog::monthlyCount($workspace->account_id, UsageType::Image);
|
||||
|
||||
if ($used >= $limit) {
|
||||
throw new QuotaExhaustedException(UsageType::Image, $used, $limit);
|
||||
}
|
||||
|
||||
$renderedPrompt = view('prompts.assistant.image', [
|
||||
'prompt' => $prompt,
|
||||
'brand_name' => $workspace->name ?? '',
|
||||
'tone' => $workspace->brand_tone ?? 'professional',
|
||||
'aspect_ratio' => $orientation->aspectRatio(),
|
||||
'content_language' => $workspace->content_language ?? 'en',
|
||||
])->render();
|
||||
|
||||
$response = Image::of($renderedPrompt)
|
||||
->size($orientation->imageApiSize())
|
||||
->quality('high')
|
||||
->generate();
|
||||
|
||||
$storedPath = $response->store('medias');
|
||||
|
||||
$media = $workspace->media()->create([
|
||||
'group_id' => Str::uuid()->toString(),
|
||||
'collection' => 'assets',
|
||||
'type' => MediaType::Image,
|
||||
'path' => $storedPath,
|
||||
'original_filename' => 'ai-generated.png',
|
||||
'mime_type' => 'image/png',
|
||||
'size' => Storage::size($storedPath),
|
||||
'order' => 0,
|
||||
'meta' => ['ai_generated' => true, 'prompt' => Str::limit($prompt, 200)],
|
||||
]);
|
||||
|
||||
AiUsageLog::create([
|
||||
'account_id' => $workspace->account_id,
|
||||
'workspace_id' => $workspace->id,
|
||||
'user_id' => $userId,
|
||||
'post_id' => $postId,
|
||||
'type' => UsageType::Image,
|
||||
'provider' => (string) config('ai.default_for_images'),
|
||||
]);
|
||||
|
||||
return $media;
|
||||
}
|
||||
}
|
||||
46
app/Actions/Ai/GenerateVideo.php
Normal file
46
app/Actions/Ai/GenerateVideo.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Ai;
|
||||
|
||||
use App\Enums\Ai\Orientation;
|
||||
use App\Enums\Ai\UsageType;
|
||||
use App\Exceptions\Ai\QuotaExhaustedException;
|
||||
use App\Features\AiVideosLimit;
|
||||
use App\Models\AiUsageLog;
|
||||
use App\Models\Media;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Ai\VideoGenerationService;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
final class GenerateVideo
|
||||
{
|
||||
/**
|
||||
* Generate an AI video, store it, register it, and log the usage.
|
||||
*
|
||||
* @throws QuotaExhaustedException When the workspace's monthly video quota is exhausted.
|
||||
*/
|
||||
public static function execute(
|
||||
Workspace $workspace,
|
||||
string $prompt,
|
||||
Orientation $orientation,
|
||||
?string $userId = null,
|
||||
?string $postId = null,
|
||||
): Media {
|
||||
$limit = (int) Feature::for($workspace->account)->value(AiVideosLimit::class);
|
||||
$used = AiUsageLog::monthlyCount($workspace->account_id, UsageType::Video);
|
||||
|
||||
if ($used >= $limit) {
|
||||
throw new QuotaExhaustedException(UsageType::Video, $used, $limit);
|
||||
}
|
||||
|
||||
return app(VideoGenerationService::class)->generate(
|
||||
prompt: $prompt,
|
||||
workspace: $workspace,
|
||||
userId: $userId,
|
||||
postId: $postId,
|
||||
orientation: $orientation,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Actions\Invite;
|
||||
|
||||
use App\Enums\UserWorkspace\Role as WorkspaceRole;
|
||||
use App\Mail\WorkspaceInvite as WorkspaceInviteMail;
|
||||
use App\Models\Invite;
|
||||
use App\Models\Workspace;
|
||||
|
|
@ -13,10 +14,14 @@ class CreateInvite
|
|||
{
|
||||
public static function execute(Workspace $workspace, array $data): Invite
|
||||
{
|
||||
$role = WorkspaceRole::tryFrom((string) data_get($data, 'role', WorkspaceRole::Member->value))
|
||||
?? WorkspaceRole::Member;
|
||||
|
||||
$invite = Invite::create([
|
||||
'account_id' => $workspace->account_id,
|
||||
'invited_by' => auth()->id(),
|
||||
'email' => data_get($data, 'email'),
|
||||
'role' => $role,
|
||||
'workspaces' => [$workspace->id],
|
||||
]);
|
||||
|
||||
|
|
|
|||
95
app/Actions/PostComment/NotifyMentions.php
Normal file
95
app/Actions/PostComment/NotifyMentions.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\PostComment;
|
||||
|
||||
use App\Enums\Notification\Channel;
|
||||
use App\Enums\Notification\Type;
|
||||
use App\Jobs\SendNotification;
|
||||
use App\Mail\MentionedInComment;
|
||||
use App\Models\PostComment;
|
||||
use App\Models\User;
|
||||
use App\Support\MentionParser;
|
||||
use App\Support\WorkspacePresence;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class NotifyMentions
|
||||
{
|
||||
/**
|
||||
* Dispatch in-app + email notifications for every workspace member that has
|
||||
* been mentioned in `$comment->body` but was not mentioned in the previous
|
||||
* body. The comment author is never notified about their own mention.
|
||||
*/
|
||||
public static function execute(PostComment $comment, ?string $previousBody = null): void
|
||||
{
|
||||
$newIds = MentionParser::extractUserIds($comment->body ?? '');
|
||||
|
||||
if (empty($newIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$previousIds = $previousBody !== null ? MentionParser::extractUserIds($previousBody) : [];
|
||||
$diff = array_values(array_diff($newIds, $previousIds, [$comment->user_id]));
|
||||
|
||||
if (empty($diff)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$post = $comment->post()->with('workspace')->first();
|
||||
if (! $post || ! $post->workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
$workspace = $post->workspace;
|
||||
|
||||
$eligibleUsers = $workspace->members()
|
||||
->whereIn('users.id', $diff)
|
||||
->get();
|
||||
|
||||
if ($eligibleUsers->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$author = $comment->user()->first();
|
||||
if (! $author instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$excerpt = Str::limit(self::stripMarkers($comment->body ?? ''), 140);
|
||||
|
||||
foreach ($eligibleUsers as $member) {
|
||||
// If the recipient is currently active in the workspace (heartbeat
|
||||
// within the last minute), they will see the in-app notification
|
||||
// immediately — skip the email to avoid noise.
|
||||
$online = WorkspacePresence::isOnline($workspace->id, $member->id);
|
||||
$channel = $online ? Channel::InApp : Channel::Both;
|
||||
|
||||
SendNotification::dispatch(
|
||||
user: $member,
|
||||
workspaceId: $workspace->id,
|
||||
type: Type::MentionedInComment,
|
||||
channel: $channel,
|
||||
title: "{$author->name} mentioned you",
|
||||
body: $excerpt,
|
||||
data: [
|
||||
'post_id' => $post->id,
|
||||
'comment_id' => $comment->id,
|
||||
'parent_id' => $comment->parent_id,
|
||||
'author_id' => $comment->user_id,
|
||||
'author_name' => $author->name,
|
||||
],
|
||||
mailable: $online ? null : new MentionedInComment(
|
||||
comment: $comment,
|
||||
author: $author,
|
||||
excerpt: $excerpt,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static function stripMarkers(string $body): string
|
||||
{
|
||||
return trim((string) preg_replace('/@\[[0-9a-fA-F-]{36}\]/', '@…', $body));
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ public function instructions(): string
|
|||
|
||||
public function provider(): Lab
|
||||
{
|
||||
return match (config('trypost.ai.text_provider')) {
|
||||
return match (config('ai.default')) {
|
||||
'openai' => Lab::OpenAI,
|
||||
default => Lab::Gemini,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ public function instructions(): string
|
|||
|
||||
public function provider(): Lab
|
||||
{
|
||||
return match (config('trypost.ai.text_provider')) {
|
||||
return match (config('ai.default')) {
|
||||
'openai' => Lab::OpenAI,
|
||||
default => Lab::Gemini,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
use Laravel\Ai\Promptable;
|
||||
|
||||
#[Temperature(0.3)]
|
||||
#[MaxSteps(1)]
|
||||
#[MaxSteps(3)]
|
||||
class SocialMediaAssistant implements Agent, Conversational, HasMiddleware, HasStructuredOutput, HasTools
|
||||
{
|
||||
use Promptable;
|
||||
|
|
@ -118,7 +118,7 @@ public function messages(): iterable
|
|||
|
||||
public function provider(): Lab
|
||||
{
|
||||
return match (config('trypost.ai.text_provider')) {
|
||||
return match (config('ai.default')) {
|
||||
'openai' => Lab::OpenAI,
|
||||
default => Lab::Gemini,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,19 +4,15 @@
|
|||
|
||||
namespace App\Ai\Tools;
|
||||
|
||||
use App\Actions\Ai\GenerateImage as GenerateImageAction;
|
||||
use App\Enums\Ai\Orientation;
|
||||
use App\Enums\Ai\UsageType;
|
||||
use App\Features\AiImagesLimit;
|
||||
use App\Models\AiUsageLog;
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use App\Exceptions\Ai\QuotaExhaustedException;
|
||||
use App\Models\Post;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Ai\Contracts\Tool;
|
||||
use Laravel\Ai\Image;
|
||||
use Laravel\Ai\Tools\Request;
|
||||
use Laravel\Pennant\Feature;
|
||||
use Stringable;
|
||||
|
||||
class GenerateImage implements Tool
|
||||
|
|
@ -51,60 +47,28 @@ public function description(): Stringable|string
|
|||
|
||||
public function handle(Request $request): Stringable|string
|
||||
{
|
||||
$limit = (int) Feature::for($this->workspace->account)->value(AiImagesLimit::class);
|
||||
$used = AiUsageLog::monthlyCount($this->workspace->account_id, UsageType::Image);
|
||||
|
||||
if ($used >= $limit) {
|
||||
return "Image quota exhausted this month ({$used} of {$limit} used). Ask the user to upgrade their plan or wait until next month.";
|
||||
}
|
||||
|
||||
$prompt = (string) data_get($request, 'prompt', '');
|
||||
$orientationString = (string) data_get($request, 'orientation', 'vertical');
|
||||
$orientationEnum = Orientation::tryFrom($orientationString) ?? Orientation::Portrait;
|
||||
$aspectRatio = $orientationEnum->aspectRatio();
|
||||
$orientation = Orientation::tryFrom($orientationString) ?? Orientation::Portrait;
|
||||
|
||||
$renderedPrompt = view('prompts.assistant.image', [
|
||||
'prompt' => $prompt,
|
||||
'brand_name' => $this->workspace->name ?? '',
|
||||
'tone' => $this->workspace->brand_tone ?? 'professional',
|
||||
'aspect_ratio' => $aspectRatio,
|
||||
'content_language' => $this->workspace->content_language ?? 'en',
|
||||
])->render();
|
||||
|
||||
$response = Image::of($renderedPrompt)
|
||||
->size($aspectRatio)
|
||||
->quality('high')
|
||||
->generate();
|
||||
|
||||
$storedPath = $response->store('medias');
|
||||
|
||||
$media = $this->workspace->media()->create([
|
||||
'group_id' => Str::uuid()->toString(),
|
||||
'collection' => 'assets',
|
||||
'type' => 'image',
|
||||
'path' => $storedPath,
|
||||
'original_filename' => 'ai-generated.png',
|
||||
'mime_type' => 'image/png',
|
||||
'size' => Storage::size($storedPath),
|
||||
'order' => 0,
|
||||
'meta' => ['ai_generated' => true, 'prompt' => Str::limit($prompt, 200)],
|
||||
]);
|
||||
|
||||
AiUsageLog::create([
|
||||
'account_id' => $this->workspace->account_id,
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->userId,
|
||||
'post_id' => $this->post?->id,
|
||||
'type' => UsageType::Image,
|
||||
'provider' => 'gemini',
|
||||
]);
|
||||
try {
|
||||
$media = GenerateImageAction::execute(
|
||||
workspace: $this->workspace,
|
||||
prompt: $prompt,
|
||||
orientation: $orientation,
|
||||
userId: $this->userId,
|
||||
postId: $this->post?->id,
|
||||
);
|
||||
} catch (QuotaExhaustedException $e) {
|
||||
return "Image quota exhausted this month ({$e->used} of {$e->limit} used). Ask the user to upgrade their plan or wait until next month.";
|
||||
}
|
||||
|
||||
$this->collector->push([
|
||||
'id' => $media->id,
|
||||
'path' => $media->path,
|
||||
'url' => $media->url,
|
||||
'mime_type' => 'image/png',
|
||||
'type' => 'image',
|
||||
'type' => MediaType::Image->value,
|
||||
]);
|
||||
|
||||
return "Generated a {$orientationString} image (id: {$media->id}) and attached it to the post.";
|
||||
|
|
|
|||
|
|
@ -4,17 +4,15 @@
|
|||
|
||||
namespace App\Ai\Tools;
|
||||
|
||||
use App\Actions\Ai\GenerateVideo as GenerateVideoAction;
|
||||
use App\Enums\Ai\Orientation;
|
||||
use App\Enums\Ai\UsageType;
|
||||
use App\Features\AiVideosLimit;
|
||||
use App\Models\AiUsageLog;
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use App\Exceptions\Ai\QuotaExhaustedException;
|
||||
use App\Models\Post;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Ai\VideoGenerationService;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Ai\Contracts\Tool;
|
||||
use Laravel\Ai\Tools\Request;
|
||||
use Laravel\Pennant\Feature;
|
||||
use Stringable;
|
||||
|
||||
class GenerateVideo implements Tool
|
||||
|
|
@ -46,30 +44,31 @@ public function description(): Stringable|string
|
|||
|
||||
public function handle(Request $request): Stringable|string
|
||||
{
|
||||
$limit = (int) Feature::for($this->workspace->account)->value(AiVideosLimit::class);
|
||||
$used = AiUsageLog::monthlyCount($this->workspace->account_id, UsageType::Video);
|
||||
|
||||
if ($used >= $limit) {
|
||||
return "Video quota exhausted this month ({$used} of {$limit} used). Ask the user to upgrade their plan or wait until next month.";
|
||||
}
|
||||
|
||||
$prompt = (string) data_get($request, 'prompt', '');
|
||||
$orientationString = (string) data_get($request, 'orientation', 'vertical');
|
||||
$orientationEnum = Orientation::tryFrom($orientationString) ?? Orientation::Vertical;
|
||||
$orientation = Orientation::tryFrom($orientationString) ?? Orientation::Vertical;
|
||||
|
||||
$service = app(VideoGenerationService::class);
|
||||
try {
|
||||
$media = GenerateVideoAction::execute(
|
||||
workspace: $this->workspace,
|
||||
prompt: $prompt,
|
||||
orientation: $orientation,
|
||||
userId: $this->userId,
|
||||
postId: $this->post?->id,
|
||||
);
|
||||
} catch (QuotaExhaustedException $e) {
|
||||
return "Video quota exhausted this month ({$e->used} of {$e->limit} used). Ask the user to upgrade their plan or wait until next month.";
|
||||
}
|
||||
|
||||
$attachment = $service->generate(
|
||||
prompt: $prompt,
|
||||
workspace: $this->workspace,
|
||||
userId: $this->userId,
|
||||
postId: $this->post?->id,
|
||||
orientation: $orientationEnum,
|
||||
);
|
||||
$this->collector->push([
|
||||
'id' => $media->id,
|
||||
'path' => $media->path,
|
||||
'url' => $media->url,
|
||||
'mime_type' => 'video/mp4',
|
||||
'type' => MediaType::Video->value,
|
||||
]);
|
||||
|
||||
$this->collector->push($attachment);
|
||||
|
||||
return "Generated a {$orientationString} video (id: {$attachment['id']}) and attached it to the post.";
|
||||
return "Generated a {$orientationString} video (id: {$media->id}) and attached it to the post.";
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
|
|
|
|||
16
app/Broadcasting/WorkspaceUserChannel.php
Normal file
16
app/Broadcasting/WorkspaceUserChannel.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Broadcasting;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
class WorkspaceUserChannel
|
||||
{
|
||||
public function join(User $user, Workspace $workspace, User $owner): bool
|
||||
{
|
||||
return $user->id === $owner->id && $workspace->hasMember($user);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,9 @@ enum Orientation: string
|
|||
case Vertical = 'vertical';
|
||||
case Horizontal = 'horizontal';
|
||||
|
||||
/**
|
||||
* Aspect ratio used in prompts to describe the desired framing to the model.
|
||||
*/
|
||||
public function aspectRatio(): string
|
||||
{
|
||||
return match ($this) {
|
||||
|
|
@ -20,4 +23,19 @@ public function aspectRatio(): string
|
|||
self::Horizontal => '16:9',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Size string supported by the AI image SDK (1:1 / 2:3 / 3:2).
|
||||
*
|
||||
* OpenAI's image API only accepts these three ratios, so we map every
|
||||
* orientation to the closest supported one. Gemini accepts the same set.
|
||||
*/
|
||||
public function imageApiSize(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Square => '1:1',
|
||||
self::Portrait, self::Vertical => '2:3',
|
||||
self::Horizontal => '3:2',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,4 +13,5 @@ enum Type: string
|
|||
case InviteReceived = 'invite_received';
|
||||
case MemberJoined = 'member_joined';
|
||||
case MemberRemoved = 'member_removed';
|
||||
case MentionedInComment = 'mentioned_in_comment';
|
||||
}
|
||||
|
|
|
|||
50
app/Events/NotificationCreated.php
Normal file
50
app/Events/NotificationCreated.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class NotificationCreated implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets;
|
||||
|
||||
public function __construct(public Notification $notification) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel('workspace.'.$this->notification->workspace_id.'.user.'.$this->notification->user_id),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'NotificationCreated';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'notification' => [
|
||||
'id' => $this->notification->id,
|
||||
'user_id' => $this->notification->user_id,
|
||||
'workspace_id' => $this->notification->workspace_id,
|
||||
'type' => $this->notification->type->value,
|
||||
'channel' => $this->notification->channel->value,
|
||||
'title' => $this->notification->title,
|
||||
'body' => $this->notification->body,
|
||||
'data' => $this->notification->data,
|
||||
'read_at' => $this->notification->read_at?->toISOString(),
|
||||
'archived_at' => $this->notification->archived_at?->toISOString(),
|
||||
'created_at' => $this->notification->created_at->toISOString(),
|
||||
'updated_at' => $this->notification->updated_at->toISOString(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@
|
|||
namespace App\Events;
|
||||
|
||||
use App\Models\PostComment;
|
||||
use App\Models\User;
|
||||
use App\Support\MentionParser;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
|
|
@ -23,8 +25,22 @@ public function broadcastOn(): array
|
|||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'PostCommentCreated';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
$mentionedIds = MentionParser::extractUserIds($this->comment->body ?? '');
|
||||
$mentionedUsers = empty($mentionedIds)
|
||||
? []
|
||||
: User::query()
|
||||
->whereIn('id', $mentionedIds)
|
||||
->get(['id', 'name'])
|
||||
->mapWithKeys(fn ($u) => [$u->id => $u->name])
|
||||
->all();
|
||||
|
||||
return [
|
||||
'comment' => [
|
||||
'id' => $this->comment->id,
|
||||
|
|
@ -40,6 +56,7 @@ public function broadcastWith(): array
|
|||
'photo_url' => $this->comment->user->photo_url,
|
||||
],
|
||||
],
|
||||
'mentioned_users' => $mentionedUsers,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
app/Exceptions/Ai/QuotaExhaustedException.php
Normal file
21
app/Exceptions/Ai/QuotaExhaustedException.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Ai;
|
||||
|
||||
use App\Enums\Ai\UsageType;
|
||||
use RuntimeException;
|
||||
|
||||
class QuotaExhaustedException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly UsageType $type,
|
||||
public readonly int $used,
|
||||
public readonly int $limit,
|
||||
) {
|
||||
parent::__construct(
|
||||
sprintf('%s quota exhausted this month (%d of %d used).', ucfirst($type->value), $used, $limit),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,12 +4,15 @@
|
|||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Actions\PostComment\NotifyMentions;
|
||||
use App\Events\PostCommentCreated;
|
||||
use App\Http\Requests\App\PostComment\ReactPostCommentRequest;
|
||||
use App\Http\Requests\App\PostComment\StorePostCommentRequest;
|
||||
use App\Http\Requests\App\PostComment\UpdatePostCommentRequest;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use App\Models\User;
|
||||
use App\Support\MentionParser;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
|
@ -30,7 +33,31 @@ public function index(Request $request, Post $post): JsonResponse
|
|||
->latest()
|
||||
->paginate(config('app.pagination.default'));
|
||||
|
||||
return response()->json($comments);
|
||||
$mentionedIds = $comments->getCollection()
|
||||
->flatMap(function ($comment) {
|
||||
$ids = MentionParser::extractUserIds($comment->body ?? '');
|
||||
foreach ($comment->replies as $reply) {
|
||||
$ids = array_merge($ids, MentionParser::extractUserIds($reply->body ?? ''));
|
||||
}
|
||||
|
||||
return $ids;
|
||||
})
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$mentionedUsers = empty($mentionedIds)
|
||||
? []
|
||||
: User::query()
|
||||
->whereIn('id', $mentionedIds)
|
||||
->get(['id', 'name'])
|
||||
->mapWithKeys(fn ($u) => [$u->id => $u->name])
|
||||
->all();
|
||||
|
||||
return response()->json([
|
||||
...$comments->toArray(),
|
||||
'mentioned_users' => $mentionedUsers,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StorePostCommentRequest $request, Post $post): JsonResponse
|
||||
|
|
@ -65,6 +92,7 @@ public function store(StorePostCommentRequest $request, Post $post): JsonRespons
|
|||
|
||||
$comment->load('user');
|
||||
|
||||
NotifyMentions::execute($comment);
|
||||
PostCommentCreated::dispatch($comment);
|
||||
|
||||
return response()->json($comment, Response::HTTP_CREATED);
|
||||
|
|
@ -87,8 +115,11 @@ public function update(UpdatePostCommentRequest $request, Post $post, PostCommen
|
|||
|
||||
$validated = $request->validated();
|
||||
|
||||
$previousBody = $comment->body;
|
||||
$comment->update(['body' => data_get($validated, 'body')]);
|
||||
|
||||
NotifyMentions::execute($comment, $previousBody);
|
||||
|
||||
return response()->json($comment);
|
||||
}
|
||||
|
||||
|
|
|
|||
26
app/Http/Controllers/App/PresenceController.php
Normal file
26
app/Http/Controllers/App/PresenceController.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Support\WorkspacePresence;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PresenceController extends Controller
|
||||
{
|
||||
public function heartbeat(Request $request): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['ok' => false], Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
WorkspacePresence::markOnline($workspace->id, $request->user()->id);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,11 +9,13 @@
|
|||
use App\Actions\Workspace\DeleteWorkspace;
|
||||
use App\Http\Requests\App\Workspace\StoreWorkspaceRequest;
|
||||
use App\Http\Requests\App\Workspace\UpdateWorkspaceRequest;
|
||||
use App\Http\Resources\App\WorkspaceMemberResource;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Brand\LogoAttacher;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
|
@ -23,6 +25,26 @@
|
|||
|
||||
class WorkspaceController extends Controller
|
||||
{
|
||||
public function searchMembers(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
abort_if(! $workspace, SymfonyResponse::HTTP_FORBIDDEN);
|
||||
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
$term = trim((string) $request->input('q', ''));
|
||||
|
||||
$members = $workspace->members()
|
||||
->where('users.id', '!=', $request->user()->id)
|
||||
->when($term !== '', fn ($query) => $query->where('users.name', 'ilike', '%'.$term.'%'))
|
||||
->orderBy('users.name')
|
||||
->limit(50)
|
||||
->get(['users.id', 'users.name', 'users.email']);
|
||||
|
||||
return WorkspaceMemberResource::collection($members);
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ public function show(Invite $invite): Response
|
|||
{
|
||||
$invite->load('account');
|
||||
|
||||
$firstWorkspaceId = collect($invite->workspaces ?? [])->first();
|
||||
$workspace = $firstWorkspaceId ? Workspace::find($firstWorkspaceId) : null;
|
||||
|
||||
$role = $invite->role ?? Role::Member;
|
||||
|
||||
return Inertia::render('auth/AcceptInvite', [
|
||||
'invite' => [
|
||||
'id' => $invite->id,
|
||||
|
|
@ -30,7 +35,14 @@ public function show(Invite $invite): Response
|
|||
'id' => $invite->account->id,
|
||||
'name' => $invite->account->name,
|
||||
],
|
||||
'workspaces' => $invite->workspaces,
|
||||
'workspace' => $workspace ? [
|
||||
'id' => $workspace->id,
|
||||
'name' => $workspace->name,
|
||||
] : null,
|
||||
'role' => [
|
||||
'value' => $role->value,
|
||||
'label' => $role->label(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ public function rules(): array
|
|||
'media.*.id' => ['required', 'string'],
|
||||
'media.*.path' => ['required', 'string', 'max:500'],
|
||||
'media.*.url' => ['required', 'string', 'max:2048'],
|
||||
'media.*.type' => ['sometimes', 'nullable', 'string', 'max:32'],
|
||||
'media.*.mime_type' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
'media.*.size' => ['sometimes', 'nullable', 'integer'],
|
||||
'media.*.meta' => ['sometimes', 'nullable', 'array'],
|
||||
'scheduled_at' => [
|
||||
'sometimes',
|
||||
'nullable',
|
||||
|
|
|
|||
29
app/Http/Resources/App/WorkspaceMemberResource.php
Normal file
29
app/Http/Resources/App/WorkspaceMemberResource.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\App;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
*/
|
||||
class WorkspaceMemberResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'avatar_url' => $this->profile_photo_url ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use App\Enums\Notification\Channel;
|
||||
use App\Enums\Notification\Type;
|
||||
use App\Events\NotificationCreated;
|
||||
use App\Models\Notification;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
|
@ -40,7 +41,7 @@ public function handle(): void
|
|||
{
|
||||
// Save in-app notification
|
||||
if ($this->channel !== Channel::Email) {
|
||||
Notification::create([
|
||||
$notification = Notification::create([
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $this->workspaceId,
|
||||
'type' => $this->type,
|
||||
|
|
@ -49,6 +50,8 @@ public function handle(): void
|
|||
'body' => $this->body,
|
||||
'data' => $this->data,
|
||||
]);
|
||||
|
||||
NotificationCreated::dispatch($notification);
|
||||
}
|
||||
|
||||
// Send email (respects user preferences)
|
||||
|
|
|
|||
56
app/Mail/MentionedInComment.php
Normal file
56
app/Mail/MentionedInComment.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\PostComment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MentionedInComment extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public PostComment $comment,
|
||||
public User $author,
|
||||
public string $excerpt,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: __('mail.mentioned.subject', ['name' => $this->author->name]),
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'mail.mentioned-in-comment',
|
||||
with: [
|
||||
'title' => __('mail.mentioned.title', ['name' => $this->author->name]),
|
||||
'previewText' => Str::limit($this->excerpt, 100),
|
||||
'authorName' => $this->author->name,
|
||||
'excerpt' => $this->excerpt,
|
||||
'url' => route('app.posts.edit', [
|
||||
'post' => $this->comment->post_id,
|
||||
'tab' => 'comments',
|
||||
'comment' => $this->comment->id,
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -29,12 +29,16 @@ public function envelope(): Envelope
|
|||
|
||||
public function content(): Content
|
||||
{
|
||||
$accountName = $this->invite->account->name;
|
||||
$roleLabel = $this->invite->role?->label() ?? '';
|
||||
|
||||
return new Content(
|
||||
view: 'mail.workspace-invite',
|
||||
with: [
|
||||
'title' => "You've been invited to join {$this->invite->account->name}",
|
||||
'previewText' => "You've been invited to join {$this->invite->account->name}",
|
||||
'invite' => $this->invite,
|
||||
'title' => "You've been invited to join {$accountName}",
|
||||
'previewText' => "You've been invited to join {$accountName}",
|
||||
'accountName' => $accountName,
|
||||
'roleLabel' => $roleLabel,
|
||||
'url' => route('app.invites.show', $this->invite),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
namespace App\Mcp\Servers;
|
||||
|
||||
use App\Mcp\Tools\Ai\GenerateImageTool;
|
||||
use App\Mcp\Tools\Ai\GenerateVideoTool;
|
||||
use App\Mcp\Tools\ApiKey\CreateApiKeyTool;
|
||||
use App\Mcp\Tools\ApiKey\DeleteApiKeyTool;
|
||||
use App\Mcp\Tools\ApiKey\ListApiKeysTool;
|
||||
|
|
@ -64,6 +66,10 @@ class TryPostServer extends Server
|
|||
ListApiKeysTool::class,
|
||||
CreateApiKeyTool::class,
|
||||
DeleteApiKeyTool::class,
|
||||
|
||||
// AI generation
|
||||
GenerateImageTool::class,
|
||||
GenerateVideoTool::class,
|
||||
];
|
||||
|
||||
protected array $resources = [];
|
||||
|
|
|
|||
61
app/Mcp/Tools/Ai/GenerateImageTool.php
Normal file
61
app/Mcp/Tools/Ai/GenerateImageTool.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tools\Ai;
|
||||
|
||||
use App\Actions\Ai\GenerateImage;
|
||||
use App\Enums\Ai\Orientation;
|
||||
use App\Exceptions\Ai\QuotaExhaustedException;
|
||||
use App\Http\Resources\App\MediaResource;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Generate an AI image and store it in the current workspace media library. Returns the created Media record (id, url, path).')]
|
||||
class GenerateImageTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): ResponseFactory
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$validated = $request->validate([
|
||||
'prompt' => ['required', 'string', 'max:4000'],
|
||||
'orientation' => ['required', 'string', 'in:square,portrait,vertical,horizontal'],
|
||||
]);
|
||||
|
||||
$orientation = Orientation::tryFrom((string) data_get($validated, 'orientation')) ?? Orientation::Portrait;
|
||||
|
||||
try {
|
||||
$media = GenerateImage::execute(
|
||||
workspace: $workspace,
|
||||
prompt: (string) data_get($validated, 'prompt'),
|
||||
orientation: $orientation,
|
||||
userId: $request->user()->id,
|
||||
);
|
||||
} catch (QuotaExhaustedException $e) {
|
||||
return Response::error($e->getMessage());
|
||||
}
|
||||
|
||||
return Response::structured([
|
||||
...(new MediaResource($media))->resolve(),
|
||||
'orientation' => $orientation->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'prompt' => $schema->string()
|
||||
->description('Detailed visual description: subject, style, composition, mood, and any text to render.')
|
||||
->required(),
|
||||
'orientation' => $schema->string()
|
||||
->enum(['square', 'portrait', 'vertical', 'horizontal'])
|
||||
->description('"square" (1:1) | "portrait" (4:5) | "vertical" (9:16) | "horizontal" (16:9)')
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
61
app/Mcp/Tools/Ai/GenerateVideoTool.php
Normal file
61
app/Mcp/Tools/Ai/GenerateVideoTool.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tools\Ai;
|
||||
|
||||
use App\Actions\Ai\GenerateVideo;
|
||||
use App\Enums\Ai\Orientation;
|
||||
use App\Exceptions\Ai\QuotaExhaustedException;
|
||||
use App\Http\Resources\App\MediaResource;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('Generate a short AI video (Veo 3.1) and store it in the current workspace media library. Returns the created Media record.')]
|
||||
class GenerateVideoTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): ResponseFactory
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$validated = $request->validate([
|
||||
'prompt' => ['required', 'string', 'max:4000'],
|
||||
'orientation' => ['required', 'string', 'in:vertical,horizontal'],
|
||||
]);
|
||||
|
||||
$orientation = Orientation::tryFrom((string) data_get($validated, 'orientation')) ?? Orientation::Vertical;
|
||||
|
||||
try {
|
||||
$media = GenerateVideo::execute(
|
||||
workspace: $workspace,
|
||||
prompt: (string) data_get($validated, 'prompt'),
|
||||
orientation: $orientation,
|
||||
userId: $request->user()->id,
|
||||
);
|
||||
} catch (QuotaExhaustedException $e) {
|
||||
return Response::error($e->getMessage());
|
||||
}
|
||||
|
||||
return Response::structured([
|
||||
...(new MediaResource($media))->resolve(),
|
||||
'orientation' => $orientation->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'prompt' => $schema->string()
|
||||
->description('Detailed visual description of the video: subject, motion, style, mood, key moments.')
|
||||
->required(),
|
||||
'orientation' => $schema->string()
|
||||
->enum(['vertical', 'horizontal'])
|
||||
->description('"vertical" (9:16) for TikTok, Reels, Shorts, Stories. "horizontal" (16:9) for X, LinkedIn, Facebook.')
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\UserWorkspace\Role as WorkspaceRole;
|
||||
use Database\Factories\InviteFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
|
@ -19,6 +20,7 @@ class Invite extends Model
|
|||
'account_id',
|
||||
'invited_by',
|
||||
'email',
|
||||
'role',
|
||||
'workspaces',
|
||||
'accepted_at',
|
||||
];
|
||||
|
|
@ -26,6 +28,7 @@ class Invite extends Model
|
|||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'role' => WorkspaceRole::class,
|
||||
'workspaces' => 'array',
|
||||
'accepted_at' => 'datetime',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class NotificationPreference extends Model
|
|||
'post_published',
|
||||
'post_failed',
|
||||
'account_disconnected',
|
||||
'mentioned_in_comment',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
|
|
@ -25,6 +26,7 @@ protected function casts(): array
|
|||
'post_published' => 'boolean',
|
||||
'post_failed' => 'boolean',
|
||||
'account_disconnected' => 'boolean',
|
||||
'mentioned_in_comment' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ public function wantsEmailFor(NotificationType $type): bool
|
|||
NotificationType::PostPublished => $preference->post_published,
|
||||
NotificationType::PostFailed, NotificationType::PostPartiallyPublished => $preference->post_failed,
|
||||
NotificationType::AccountDisconnected => $preference->account_disconnected,
|
||||
NotificationType::MentionedInComment => $preference->mentioned_in_comment ?? true,
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,16 @@
|
|||
|
||||
use App\Enums\Ai\Orientation;
|
||||
use App\Enums\Ai\UsageType;
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use App\Models\AiUsageLog;
|
||||
use App\Models\Media;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class VideoGenerationService
|
||||
{
|
||||
|
|
@ -31,13 +34,10 @@ public function __construct()
|
|||
$this->apiKey = config('services.gemini.api_key') ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, path: string, url: string, mime_type: string, type: string}
|
||||
*/
|
||||
public function generate(string $prompt, Workspace $workspace, ?string $userId = null, ?string $postId = null, Orientation $orientation = Orientation::Vertical): array
|
||||
public function generate(string $prompt, Workspace $workspace, ?string $userId = null, ?string $postId = null, Orientation $orientation = Orientation::Vertical): Media
|
||||
{
|
||||
if (empty($this->apiKey)) {
|
||||
throw new \RuntimeException('Gemini API key is not configured. Please set GEMINI_API_KEY in your .env file.');
|
||||
throw new RuntimeException('Gemini API key is not configured. Please set GEMINI_API_KEY in your .env file.');
|
||||
}
|
||||
|
||||
$aspectRatio = $orientation->aspectRatio();
|
||||
|
|
@ -63,7 +63,7 @@ public function generate(string $prompt, Workspace $workspace, ?string $userId =
|
|||
$media = $workspace->media()->create([
|
||||
'group_id' => Str::uuid()->toString(),
|
||||
'collection' => 'assets',
|
||||
'type' => 'video',
|
||||
'type' => MediaType::Video,
|
||||
'path' => $path,
|
||||
'original_filename' => 'ai-generated.mp4',
|
||||
'mime_type' => 'video/mp4',
|
||||
|
|
@ -81,13 +81,7 @@ public function generate(string $prompt, Workspace $workspace, ?string $userId =
|
|||
'provider' => 'veo',
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $media->id,
|
||||
'path' => $media->path,
|
||||
'url' => $media->url,
|
||||
'mime_type' => 'video/mp4',
|
||||
'type' => 'video',
|
||||
];
|
||||
return $media;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -108,13 +102,13 @@ private function startGeneration(string $prompt, string $aspectRatio = '9:16'):
|
|||
if ($response->failed()) {
|
||||
Log::error('VideoGenerationService start failed', ['body' => $response->body()]);
|
||||
|
||||
throw new \RuntimeException('Failed to start video generation. Please try again.');
|
||||
throw new RuntimeException('Failed to start video generation. Please try again.');
|
||||
}
|
||||
|
||||
$operationName = data_get($response->json(), 'name');
|
||||
|
||||
if (! $operationName) {
|
||||
throw new \RuntimeException('No operation returned from video generation API.');
|
||||
throw new RuntimeException('No operation returned from video generation API.');
|
||||
}
|
||||
|
||||
return $operationName;
|
||||
|
|
@ -144,6 +138,6 @@ private function pollForCompletion(string $operationName): string
|
|||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Video generation timed out. Please try again.');
|
||||
throw new RuntimeException('Video generation timed out. Please try again.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ final class BrandAnalyzerRunner
|
|||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return match (config('trypost.ai.text_provider')) {
|
||||
return match (config('ai.default')) {
|
||||
'openai' => ! empty(config('services.openai.api_key')),
|
||||
'gemini' => ! empty(config('services.gemini.api_key')),
|
||||
default => false,
|
||||
|
|
|
|||
33
app/Support/MentionParser.php
Normal file
33
app/Support/MentionParser.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class MentionParser
|
||||
{
|
||||
/**
|
||||
* Marker syntax for mentions inside a comment body.
|
||||
*
|
||||
* "Hey @[019dabc...] could you review?"
|
||||
*
|
||||
* The frontend stores the user_id wrapped in `@[ ]` so that downstream
|
||||
* rendering and notification dispatch never depend on a free-form display
|
||||
* name (which can change or be ambiguous within a workspace).
|
||||
*/
|
||||
private const PATTERN = '/@\[([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\]/';
|
||||
|
||||
/**
|
||||
* Extract the unique user ids referenced in the given comment body.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function extractUserIds(string $body): array
|
||||
{
|
||||
if (! preg_match_all(self::PATTERN, $body, $matches)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_unique($matches[1]));
|
||||
}
|
||||
}
|
||||
36
app/Support/WorkspacePresence.php
Normal file
36
app/Support/WorkspacePresence.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Tracks which workspace members are actively using the app in real time.
|
||||
*
|
||||
* Frontend pings `markOnline()` every ~30s while any authenticated workspace
|
||||
* page is mounted; the entry lives in the application's default cache store
|
||||
* for 60 seconds. Presence is consulted by NotifyMentions to skip the email
|
||||
* channel for users that are already online in the workspace (the in-app
|
||||
* bell already covers them).
|
||||
*/
|
||||
final class WorkspacePresence
|
||||
{
|
||||
private const TTL_SECONDS = 60;
|
||||
|
||||
public static function markOnline(string $workspaceId, string $userId): void
|
||||
{
|
||||
Cache::put(self::key($workspaceId, $userId), true, self::TTL_SECONDS);
|
||||
}
|
||||
|
||||
public static function isOnline(string $workspaceId, string $userId): bool
|
||||
{
|
||||
return (bool) Cache::get(self::key($workspaceId, $userId), false);
|
||||
}
|
||||
|
||||
private static function key(string $workspaceId, string $userId): string
|
||||
{
|
||||
return "presence:workspace:{$workspaceId}:user:{$userId}";
|
||||
}
|
||||
}
|
||||
|
|
@ -13,12 +13,12 @@
|
|||
|
|
||||
*/
|
||||
|
||||
'default' => 'openai',
|
||||
'default_for_images' => 'gemini',
|
||||
'default_for_audio' => 'openai',
|
||||
'default_for_transcription' => 'openai',
|
||||
'default_for_embeddings' => 'openai',
|
||||
'default_for_reranking' => 'cohere',
|
||||
'default' => env('AI_TEXT_PROVIDER', 'openai'),
|
||||
'default_for_images' => env('AI_IMAGE_PROVIDER', 'openai'),
|
||||
'default_for_audio' => env('AI_AUDIO_PROVIDER', 'openai'),
|
||||
'default_for_transcription' => env('AI_TRANSCRIPTION_PROVIDER', 'openai'),
|
||||
'default_for_embeddings' => env('AI_EMBEDDINGS_PROVIDER', 'openai'),
|
||||
'default_for_reranking' => env('AI_RERANKING_PROVIDER', 'cohere'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -78,21 +78,4 @@
|
|||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| AI Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure which AI providers to use for content generation.
|
||||
| Supported text providers: "gemini", "openai"
|
||||
|
|
||||
*/
|
||||
|
||||
'ai' => [
|
||||
'text_provider' => env('AI_TEXT_PROVIDER', 'gemini'),
|
||||
'image_provider' => env('AI_IMAGE_PROVIDER', 'gemini'),
|
||||
'audio_provider' => env('AI_AUDIO_PROVIDER', 'elevenlabs'),
|
||||
'video_provider' => env('AI_VIDEO_PROVIDER', 'gemini'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role as WorkspaceRole;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
|
@ -15,6 +16,7 @@ public function up(): void
|
|||
$table->foreignUuid('account_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUuid('invited_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('email');
|
||||
$table->string('role')->default(WorkspaceRole::Member->value);
|
||||
$table->json('workspaces');
|
||||
$table->timestamp('accepted_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ public function up(): void
|
|||
$table->boolean('post_published')->default(true);
|
||||
$table->boolean('post_failed')->default(true);
|
||||
$table->boolean('account_disconnected')->default(true);
|
||||
$table->boolean('mentioned_in_comment')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'mentioned' => [
|
||||
'subject' => ':name mentioned you on TryPost',
|
||||
'title' => ':name mentioned you',
|
||||
'intro' => ':name mentioned you in a post comment.',
|
||||
'cta' => 'View comment',
|
||||
],
|
||||
|
||||
'workspace_connections_disconnected' => [
|
||||
'subject' => '{1} :count account needs to be reconnected in :workspace|[2,*] :count accounts need to be reconnected in :workspace',
|
||||
'title' => 'Accounts Need Reconnection',
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@
|
|||
],
|
||||
|
||||
'workspace' => [
|
||||
'tabs' => [
|
||||
'workspace' => 'Workspace',
|
||||
'brand' => 'Brand',
|
||||
'users' => 'Users',
|
||||
],
|
||||
'title' => 'Workspace settings',
|
||||
'logo_heading' => 'Workspace logo',
|
||||
'logo_description' => 'Upload a logo for your workspace',
|
||||
|
|
@ -152,6 +157,7 @@
|
|||
'owner' => 'Owner',
|
||||
'admin' => 'Admin',
|
||||
'member' => 'Member',
|
||||
'viewer' => 'Viewer',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'mentioned' => [
|
||||
'subject' => ':name te mencionó en TryPost',
|
||||
'title' => ':name te mencionó',
|
||||
'intro' => ':name te mencionó en un comentario.',
|
||||
'cta' => 'Ver comentario',
|
||||
],
|
||||
|
||||
'workspace_connections_disconnected' => [
|
||||
'subject' => '{1} :count cuenta necesita ser reconectada en :workspace|[2,*] :count cuentas necesitan ser reconectadas en :workspace',
|
||||
'title' => 'Cuentas necesitan reconexión',
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@
|
|||
],
|
||||
|
||||
'workspace' => [
|
||||
'tabs' => [
|
||||
'workspace' => 'Workspace',
|
||||
'brand' => 'Marca',
|
||||
'users' => 'Usuarios',
|
||||
],
|
||||
'title' => 'Configuración del workspace',
|
||||
'logo_heading' => 'Logo del workspace',
|
||||
'logo_description' => 'Sube un logo para tu workspace',
|
||||
|
|
@ -152,6 +157,7 @@
|
|||
'owner' => 'Propietario',
|
||||
'admin' => 'Administrador',
|
||||
'member' => 'Miembro',
|
||||
'viewer' => 'Espectador',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,13 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'mentioned' => [
|
||||
'subject' => ':name mencionou você no TryPost',
|
||||
'title' => ':name mencionou você',
|
||||
'intro' => ':name mencionou você num comentário.',
|
||||
'cta' => 'Ver comentário',
|
||||
],
|
||||
|
||||
'workspace_connections_disconnected' => [
|
||||
'subject' => '{1} :count conta precisa ser reconectada em :workspace|[2,*] :count contas precisam ser reconectadas em :workspace',
|
||||
'title' => 'Contas Precisam ser Reconectadas',
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@
|
|||
],
|
||||
|
||||
'workspace' => [
|
||||
'tabs' => [
|
||||
'workspace' => 'Workspace',
|
||||
'brand' => 'Marca',
|
||||
'users' => 'Usuários',
|
||||
],
|
||||
'title' => 'Configurações do workspace',
|
||||
'logo_heading' => 'Logo do workspace',
|
||||
'logo_description' => 'Envie um logo para o workspace',
|
||||
|
|
@ -152,6 +157,7 @@
|
|||
'owner' => 'Proprietário',
|
||||
'admin' => 'Administrador',
|
||||
'member' => 'Membro',
|
||||
'viewer' => 'Visualizador',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
|
|
|
|||
38
maizzle/templates/mentioned-in-comment.html
Normal file
38
maizzle/templates/mentioned-in-comment.html
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<x-main>
|
||||
<div class="bg-zinc-50 sm:px-4 font-sans">
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td class="w-[552px] max-w-full">
|
||||
<x-header />
|
||||
|
||||
<table class="w-full">
|
||||
<tr>
|
||||
<td class="p-12 sm:px-6 text-base text-zinc-700 bg-white rounded shadow-sm">
|
||||
<h1 class="m-0 mb-6 text-2xl sm:leading-8 text-black font-semibold">
|
||||
@{{ $title }}
|
||||
</h1>
|
||||
|
||||
<p class="m-0 mb-4 leading-6">
|
||||
{!! __('mail.mentioned.intro', ['name' => '<strong>'.e($authorName).'</strong>']) !!}
|
||||
</p>
|
||||
|
||||
<div class="border-l-[3px] border-zinc-200 px-4 py-3 bg-zinc-50 rounded text-[15px] leading-[22px] text-zinc-700">
|
||||
@{{ $excerpt }}
|
||||
</div>
|
||||
|
||||
<x-spacer height="24px" />
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<x-button href="@{{ $url }}">
|
||||
@{{ __('mail.mentioned.cta') }} →
|
||||
</x-button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<x-footer />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</x-main>
|
||||
|
|
@ -13,11 +13,11 @@ <h1 class="m-0 mb-6 text-2xl sm:leading-8 text-black font-semibold">
|
|||
</h1>
|
||||
|
||||
<p class="m-0 leading-6">
|
||||
You've been invited to collaborate on the <strong>@{{ $invite->workspace->name }}</strong> workspace.
|
||||
You've been invited to collaborate on the <strong>@{{ $accountName }}</strong> workspace.
|
||||
</p>
|
||||
|
||||
<p class="m-0 mt-4 leading-6">
|
||||
You've been invited as <strong>@{{ $invite->role->label() }}</strong>.
|
||||
You've been invited as <strong>@{{ $roleLabel }}</strong>.
|
||||
</p>
|
||||
|
||||
<x-spacer height="24px" />
|
||||
|
|
|
|||
54
resources/js/components/CommentBody.vue
Normal file
54
resources/js/components/CommentBody.vue
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface MemberMap {
|
||||
[id: string]: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
body: string;
|
||||
members?: MemberMap;
|
||||
}>();
|
||||
|
||||
interface Segment {
|
||||
type: 'text' | 'mention';
|
||||
value: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
const segments = computed<Segment[]>(() => {
|
||||
const re = /@\[([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\]/g;
|
||||
const result: Segment[] = [];
|
||||
const text = props.body ?? '';
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
result.push({ type: 'text', value: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
const userId = match[1];
|
||||
const name = props.members?.[userId] ?? 'someone';
|
||||
result.push({ type: 'mention', value: `@${name}`, userId });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
result.push({ type: 'text', value: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="mt-0.5 whitespace-pre-wrap text-sm">
|
||||
<template v-for="(seg, i) in segments" :key="i">
|
||||
<span
|
||||
v-if="seg.type === 'mention'"
|
||||
class="inline-block rounded-md bg-primary/10 px-1.5 text-primary font-medium"
|
||||
>{{ seg.value }}</span>
|
||||
<template v-else>{{ seg.value }}</template>
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
307
resources/js/components/MentionTextarea.vue
Normal file
307
resources/js/components/MentionTextarea.vue
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
<script setup lang="ts">
|
||||
import { useHttp } from '@inertiajs/vue3';
|
||||
import { IconAt } from '@tabler/icons-vue';
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import debounce from '@/debounce';
|
||||
import { search as searchMembers } from '@/routes/app/workspace/members';
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
memberNames?: Record<string, string>;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
autofocus?: boolean;
|
||||
class?: string;
|
||||
}>(),
|
||||
{
|
||||
memberNames: () => ({}),
|
||||
placeholder: '',
|
||||
rows: 2,
|
||||
autofocus: false,
|
||||
class: '',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
enter: [event: KeyboardEvent];
|
||||
keydown: [event: KeyboardEvent];
|
||||
mention: [member: { id: string; name: string }];
|
||||
}>();
|
||||
|
||||
const textareaWrapper = useTemplateRef<InstanceType<typeof Textarea>>('textareaWrapper');
|
||||
|
||||
const MARKER_RE = /@\[([0-9a-fA-F-]{36})\]/g;
|
||||
|
||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
/**
|
||||
* `displayText` is what the textarea shows (with names like "@João Silva").
|
||||
* `nameToId` maps each inserted display name back to the user_id so the marker
|
||||
* can be reconstructed when emitting modelValue. We only need to remember names
|
||||
* the component is responsible for — names already present in the incoming
|
||||
* modelValue (markers) are added on init from `memberNames`.
|
||||
*/
|
||||
const displayText = ref('');
|
||||
const nameToId = ref<Record<string, string>>({});
|
||||
|
||||
let lastEmitted = '';
|
||||
|
||||
const buildMarkerValue = (text: string): string => {
|
||||
let out = text;
|
||||
for (const [name, id] of Object.entries(nameToId.value)) {
|
||||
if (!name) continue;
|
||||
const re = new RegExp(`@${escapeRegex(name)}(?!\\w)`, 'g');
|
||||
out = out.replace(re, `@[${id}]`);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const emitConverted = () => {
|
||||
const next = buildMarkerValue(displayText.value);
|
||||
lastEmitted = next;
|
||||
emit('update:modelValue', next);
|
||||
};
|
||||
|
||||
const initFromMarkers = (raw: string) => {
|
||||
let text = raw;
|
||||
const nameMap = props.memberNames ?? {};
|
||||
const matches = [...raw.matchAll(MARKER_RE)];
|
||||
|
||||
for (const match of matches) {
|
||||
const id = match[1];
|
||||
const name = nameMap[id];
|
||||
if (!name) continue;
|
||||
nameToId.value[name] = id;
|
||||
text = text.split(match[0]).join(`@${name}`);
|
||||
}
|
||||
|
||||
displayText.value = text;
|
||||
lastEmitted = raw;
|
||||
};
|
||||
|
||||
initFromMarkers(props.modelValue ?? '');
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(next) => {
|
||||
if (next === lastEmitted) return;
|
||||
initFromMarkers(next ?? '');
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.memberNames,
|
||||
() => {
|
||||
if (displayText.value === '' && (props.modelValue ?? '') !== '') {
|
||||
initFromMarkers(props.modelValue ?? '');
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const getRawTextarea = (): HTMLTextAreaElement | null => {
|
||||
const inst = textareaWrapper.value;
|
||||
if (!inst) return null;
|
||||
const root = (inst as unknown as { $el?: HTMLElement }).$el;
|
||||
if (root instanceof HTMLTextAreaElement) return root;
|
||||
return root?.querySelector('textarea') ?? null;
|
||||
};
|
||||
|
||||
const open = ref(false);
|
||||
const query = ref('');
|
||||
const triggerStart = ref<number | null>(null);
|
||||
const members = ref<Member[]>([]);
|
||||
const loading = ref(false);
|
||||
const activeIndex = ref(0);
|
||||
const flipUp = ref(false);
|
||||
|
||||
const recomputeFlip = () => {
|
||||
const ta = getRawTextarea();
|
||||
if (!ta) return;
|
||||
const rect = ta.getBoundingClientRect();
|
||||
const viewportH = window.innerHeight || document.documentElement.clientHeight;
|
||||
flipUp.value = rect.bottom + 280 > viewportH;
|
||||
};
|
||||
|
||||
const httpMembers = useHttp<Record<string, never>, Member[]>({});
|
||||
|
||||
const fetchMembers = async (term: string) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await httpMembers.get(searchMembers.url({ query: { q: term } }));
|
||||
members.value = Array.isArray(result) ? result : [];
|
||||
activeIndex.value = 0;
|
||||
} catch {
|
||||
members.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedFetch = debounce((term: string) => {
|
||||
void fetchMembers(term);
|
||||
}, 150);
|
||||
|
||||
const closePopover = () => {
|
||||
open.value = false;
|
||||
triggerStart.value = null;
|
||||
query.value = '';
|
||||
};
|
||||
|
||||
const onInput = () => {
|
||||
const ta = getRawTextarea();
|
||||
if (!ta) return;
|
||||
|
||||
emitConverted();
|
||||
|
||||
const pos = ta.selectionStart ?? 0;
|
||||
const before = displayText.value.slice(0, pos);
|
||||
const match = before.match(/(?:^|\s)@([\w-]*)$/);
|
||||
|
||||
if (!match) {
|
||||
if (open.value) closePopover();
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedQuery = match[1];
|
||||
triggerStart.value = pos - matchedQuery.length - 1;
|
||||
query.value = matchedQuery;
|
||||
|
||||
if (!open.value) {
|
||||
recomputeFlip();
|
||||
open.value = true;
|
||||
void fetchMembers(matchedQuery);
|
||||
} else {
|
||||
debouncedFetch(matchedQuery);
|
||||
}
|
||||
};
|
||||
|
||||
const insertMention = async (member: Member) => {
|
||||
const ta = getRawTextarea();
|
||||
if (!ta || triggerStart.value === null) return;
|
||||
|
||||
const start = triggerStart.value;
|
||||
const pos = ta.selectionStart ?? displayText.value.length;
|
||||
const visible = `@${member.name} `;
|
||||
|
||||
nameToId.value[member.name] = member.id;
|
||||
displayText.value =
|
||||
displayText.value.slice(0, start) + visible + displayText.value.slice(pos);
|
||||
closePopover();
|
||||
|
||||
emit('mention', { id: member.id, name: member.name });
|
||||
|
||||
await nextTick();
|
||||
emitConverted();
|
||||
|
||||
const newPos = start + visible.length;
|
||||
ta.focus();
|
||||
ta.setSelectionRange(newPos, newPos);
|
||||
};
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (open.value && members.value.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
activeIndex.value = (activeIndex.value + 1) % members.value.length;
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
activeIndex.value = (activeIndex.value - 1 + members.value.length) % members.value.length;
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
void insertMention(members.value[activeIndex.value]);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closePopover();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
emit('enter', event);
|
||||
}
|
||||
|
||||
emit('keydown', event);
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
setTimeout(() => closePopover(), 120);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autofocus) {
|
||||
getRawTextarea()?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => closePopover());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<Textarea
|
||||
ref="textareaWrapper"
|
||||
v-model="displayText"
|
||||
:placeholder="placeholder"
|
||||
:rows="rows"
|
||||
:class="props.class"
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="open"
|
||||
class="absolute z-50 w-full max-w-xs rounded-md border bg-popover text-popover-foreground shadow-md"
|
||||
:class="flipUp ? 'bottom-full mb-1' : 'top-full mt-1'"
|
||||
>
|
||||
<div v-if="loading" class="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground">
|
||||
<IconAt class="h-3.5 w-3.5" />
|
||||
Searching members…
|
||||
</div>
|
||||
|
||||
<div v-else-if="members.length === 0" class="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground">
|
||||
<IconAt class="h-3.5 w-3.5" />
|
||||
No member matches "{{ query }}"
|
||||
</div>
|
||||
|
||||
<ul v-else class="max-h-64 overflow-y-auto py-1">
|
||||
<li
|
||||
v-for="(member, index) in members"
|
||||
:key="member.id"
|
||||
class="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm transition-colors"
|
||||
:class="index === activeIndex ? 'bg-muted' : 'hover:bg-muted/60'"
|
||||
@mousedown.prevent="insertMention(member)"
|
||||
@mouseenter="activeIndex = index"
|
||||
>
|
||||
<Avatar class="h-6 w-6 shrink-0">
|
||||
<AvatarImage v-if="member.avatar_url" :src="member.avatar_url" :alt="member.name" />
|
||||
<AvatarFallback class="text-[10px]">{{ member.name.charAt(0).toUpperCase() }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">{{ member.name }}</p>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ member.email }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { router, usePage } from '@inertiajs/vue3';
|
||||
import { useEcho } from '@laravel/echo-vue';
|
||||
import { IconArchive, IconBell, IconCheck, IconChecks, IconInbox, IconX } from '@tabler/icons-vue';
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
|
|
@ -13,6 +14,7 @@ import dayjs from '@/dayjs';
|
|||
import { accounts } from '@/routes/app';
|
||||
import { index, read, readAll, archiveAll } from '@/routes/app/notifications';
|
||||
import { edit as editPost } from '@/routes/app/posts';
|
||||
import type { SharedData } from '@/types';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
|
|
@ -31,6 +33,28 @@ const loading = ref(false);
|
|||
const show = ref(false);
|
||||
const panel = ref<HTMLElement | null>(null);
|
||||
|
||||
const page = usePage<SharedData>();
|
||||
const currentUserId = computed(() => page.props.auth?.user?.id ?? null);
|
||||
const currentWorkspaceId = computed(() => page.props.auth?.currentWorkspace?.id ?? null);
|
||||
|
||||
const channelName = computed(() =>
|
||||
currentUserId.value && currentWorkspaceId.value
|
||||
? `workspace.${currentWorkspaceId.value}.user.${currentUserId.value}`
|
||||
: null,
|
||||
);
|
||||
|
||||
if (channelName.value) {
|
||||
useEcho(channelName.value, '.NotificationCreated', (e: { notification: Notification }) => {
|
||||
const exists = notifications.value.some((n) => n.id === e.notification.id);
|
||||
if (exists) return;
|
||||
|
||||
notifications.value = [e.notification, ...notifications.value];
|
||||
if (! e.notification.read_at) {
|
||||
unreadCount.value += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const csrfToken = () =>
|
||||
document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
|
||||
|
|
@ -95,6 +119,16 @@ const handleNotificationClick = (notification: Notification) => {
|
|||
|
||||
close();
|
||||
|
||||
if (notification.type === 'mentioned_in_comment' && notification.data?.post_id) {
|
||||
const url = new URL(editPost.url(notification.data.post_id), window.location.origin);
|
||||
url.searchParams.set('tab', 'comments');
|
||||
if (notification.data?.comment_id) {
|
||||
url.searchParams.set('comment', notification.data.comment_id);
|
||||
}
|
||||
router.visit(url.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
if (notification.data?.post_id) {
|
||||
router.visit(editPost.url(notification.data.post_id));
|
||||
} else if (notification.data?.social_account_id || notification.data?.workspace_id) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { toast } from 'vue-sonner';
|
|||
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import EmptyState from '@/components/EmptyState.vue';
|
||||
import ImagePreviewDialog from '@/components/ImagePreviewDialog.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
|
@ -80,6 +81,18 @@ const selected = defineModel<PickedMedia[]>('selected', { default: () => [] });
|
|||
|
||||
const isPicker = computed(() => props.mode === 'picker');
|
||||
|
||||
const previewImage = ref<string | null>(null);
|
||||
|
||||
const handleAssetClick = (asset: { id: string; url: string; type?: string }) => {
|
||||
if (isPicker.value) {
|
||||
toggleSelect(asset as AssetMedia);
|
||||
return;
|
||||
}
|
||||
if (asset.type !== 'video') {
|
||||
previewImage.value = asset.url;
|
||||
}
|
||||
};
|
||||
|
||||
const selectedIds = computed(() => new Set(selected.value.map((m) => m.id)));
|
||||
const isSelected = (id: string) => selectedIds.value.has(id);
|
||||
const selectionIndex = (id: string) => selected.value.findIndex((m) => m.id === id) + 1;
|
||||
|
|
@ -593,12 +606,12 @@ onUnmounted(() => {
|
|||
:key="asset.id"
|
||||
class="group relative overflow-hidden rounded-lg border-2 bg-muted transition-all"
|
||||
:class="[
|
||||
isPicker ? 'cursor-pointer' : '',
|
||||
(isPicker || asset.type !== 'video') ? 'cursor-pointer' : '',
|
||||
isPicker && isSelected(asset.id)
|
||||
? 'border-primary ring-2 ring-primary/30'
|
||||
: 'border-transparent',
|
||||
]"
|
||||
@click="isPicker ? toggleSelect(asset) : null"
|
||||
@click="handleAssetClick(asset)"
|
||||
>
|
||||
<div class="aspect-square">
|
||||
<video
|
||||
|
|
@ -632,14 +645,14 @@ onUnmounted(() => {
|
|||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="secondary" size="icon" class="size-7" @click="createPostFromAsset(asset)">
|
||||
<Button variant="secondary" size="icon" class="size-7" @click.stop="createPostFromAsset(asset)">
|
||||
<IconPencilPlus class="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ trans('assets.create_post') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button variant="destructive" size="icon" class="size-7" @click="handleDelete(asset.id)">
|
||||
<Button variant="destructive" size="icon" class="size-7" @click.stop="handleDelete(asset.id)">
|
||||
<IconTrash class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -677,7 +690,8 @@ onUnmounted(() => {
|
|||
<div
|
||||
v-for="photo in displayedPhotos"
|
||||
:key="photo.id"
|
||||
class="group relative overflow-hidden rounded-lg bg-muted"
|
||||
class="group relative cursor-pointer overflow-hidden rounded-lg bg-muted"
|
||||
@click="previewImage = photo.url_regular"
|
||||
>
|
||||
<div class="aspect-[4/3]">
|
||||
<img
|
||||
|
|
@ -698,7 +712,7 @@ onUnmounted(() => {
|
|||
size="icon"
|
||||
class="size-7"
|
||||
:disabled="savingPhotoId === photo.id"
|
||||
@click="createPostFromUnsplash(photo)"
|
||||
@click.stop="createPostFromUnsplash(photo)"
|
||||
>
|
||||
<IconPencilPlus class="size-3.5" />
|
||||
</Button>
|
||||
|
|
@ -714,7 +728,7 @@ onUnmounted(() => {
|
|||
size="icon"
|
||||
class="size-7"
|
||||
:disabled="savingPhotoId === photo.id"
|
||||
@click="saveAndPickUnsplash(photo)"
|
||||
@click.stop="saveAndPickUnsplash(photo)"
|
||||
>
|
||||
<IconLoader2 v-if="savingPhotoId === photo.id" class="size-3.5 animate-spin" />
|
||||
<IconPlus v-else class="size-3.5" />
|
||||
|
|
@ -727,11 +741,11 @@ onUnmounted(() => {
|
|||
</TooltipProvider>
|
||||
</div>
|
||||
<p class="text-xs text-white/80">
|
||||
<a :href="photo.author.url + '?utm_source=trypost&utm_medium=referral'" target="_blank" rel="noopener noreferrer" class="hover:text-white">
|
||||
<a :href="photo.author.url + '?utm_source=trypost&utm_medium=referral'" target="_blank" rel="noopener noreferrer" class="hover:text-white" @click.stop>
|
||||
{{ photo.author.name }}
|
||||
</a>
|
||||
<span class="text-white/50"> / </span>
|
||||
<a href="https://unsplash.com/?utm_source=trypost&utm_medium=referral" target="_blank" rel="noopener noreferrer" class="hover:text-white">
|
||||
<a href="https://unsplash.com/?utm_source=trypost&utm_medium=referral" target="_blank" rel="noopener noreferrer" class="hover:text-white" @click.stop>
|
||||
Unsplash
|
||||
</a>
|
||||
</p>
|
||||
|
|
@ -772,7 +786,12 @@ onUnmounted(() => {
|
|||
</p>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||
<div v-for="gif in displayedGifs" :key="gif.id" class="group relative overflow-hidden rounded-lg bg-muted">
|
||||
<div
|
||||
v-for="gif in displayedGifs"
|
||||
:key="gif.id"
|
||||
class="group relative cursor-pointer overflow-hidden rounded-lg bg-muted"
|
||||
@click="previewImage = gif.url_original"
|
||||
>
|
||||
<div class="aspect-[4/3]">
|
||||
<img :src="gif.url_preview" :alt="gif.title || 'GIF'" class="size-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
|
|
@ -787,7 +806,7 @@ onUnmounted(() => {
|
|||
size="icon"
|
||||
class="size-7"
|
||||
:disabled="savingGifId === gif.id"
|
||||
@click="createPostFromGiphy(gif)"
|
||||
@click.stop="createPostFromGiphy(gif)"
|
||||
>
|
||||
<IconPencilPlus class="size-3.5" />
|
||||
</Button>
|
||||
|
|
@ -803,7 +822,7 @@ onUnmounted(() => {
|
|||
size="icon"
|
||||
class="size-7"
|
||||
:disabled="savingGifId === gif.id"
|
||||
@click="saveAndPickGiphy(gif)"
|
||||
@click.stop="saveAndPickGiphy(gif)"
|
||||
>
|
||||
<IconLoader2 v-if="savingGifId === gif.id" class="size-3.5 animate-spin" />
|
||||
<IconPlus v-else class="size-3.5" />
|
||||
|
|
@ -849,5 +868,7 @@ onUnmounted(() => {
|
|||
:action="trans('assets.delete.confirm')"
|
||||
:cancel="trans('assets.delete.cancel')"
|
||||
/>
|
||||
|
||||
<ImagePreviewDialog :src="previewImage" @close="previewImage = null" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ import {
|
|||
} from '@tabler/icons-vue';
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import CommentBody from '@/components/CommentBody.vue';
|
||||
import MentionTextarea from '@/components/MentionTextarea.vue';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import date from '@/date';
|
||||
import dayjs from '@/dayjs';
|
||||
|
|
@ -53,11 +54,13 @@ interface PaginatedResponse {
|
|||
current_page: number;
|
||||
last_page: number;
|
||||
next_page_url: string | null;
|
||||
mentioned_users?: Record<string, string>;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
postId: string;
|
||||
currentUserId: string;
|
||||
highlightCommentId?: string | null;
|
||||
}>();
|
||||
|
||||
const EMOJIS = ['👍', '❤️', '😂', '🎉', '🔥', '👏', '😍', '🤔', '👀', '💯'];
|
||||
|
|
@ -78,7 +81,7 @@ const hoveredCommentId = ref<string | null>(null);
|
|||
const emojiPickerCommentId = ref<string | null>(null);
|
||||
|
||||
const scrollContainer = ref<HTMLDivElement | null>(null);
|
||||
const textareaRef = ref<InstanceType<typeof Textarea> | null>(null);
|
||||
const textareaRef = ref<InstanceType<typeof MentionTextarea> | null>(null);
|
||||
|
||||
const hasOlderComments = computed(() => currentPage.value < lastPage.value);
|
||||
|
||||
|
|
@ -146,6 +149,10 @@ const loadComments = async (page = 1) => {
|
|||
currentPage.value = data.current_page;
|
||||
lastPage.value = data.last_page;
|
||||
|
||||
if (data.mentioned_users) {
|
||||
memberNames.value = { ...memberNames.value, ...data.mentioned_users };
|
||||
}
|
||||
|
||||
if (page === 1) {
|
||||
// Reverse so newest is at bottom
|
||||
comments.value = [...data.data].reverse();
|
||||
|
|
@ -412,12 +419,45 @@ const addCommentFromBroadcast = (comment: Comment) => {
|
|||
}
|
||||
};
|
||||
|
||||
defineExpose({ addCommentFromBroadcast });
|
||||
const memberNames = ref<Record<string, string>>({});
|
||||
|
||||
onMounted(() => {
|
||||
loadComments(1);
|
||||
const registerMention = (member: { id: string; name: string }) => {
|
||||
memberNames.value = { ...memberNames.value, [member.id]: member.name };
|
||||
};
|
||||
|
||||
const registerMentionedUsers = (users: Record<string, string>) => {
|
||||
memberNames.value = { ...memberNames.value, ...users };
|
||||
};
|
||||
|
||||
defineExpose({ addCommentFromBroadcast, registerMentionedUsers });
|
||||
|
||||
const highlightedId = ref<string | null>(null);
|
||||
|
||||
const focusComment = async (commentId: string) => {
|
||||
await nextTick();
|
||||
const el = document.querySelector<HTMLElement>(`[data-comment-id="${commentId}"]`);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
highlightedId.value = commentId;
|
||||
setTimeout(() => {
|
||||
if (highlightedId.value === commentId) highlightedId.value = null;
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadComments(1);
|
||||
if (props.highlightCommentId) {
|
||||
await focusComment(props.highlightCommentId);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.highlightCommentId,
|
||||
(id) => {
|
||||
if (id) void focusComment(id);
|
||||
},
|
||||
);
|
||||
|
||||
watch(() => props.postId, () => {
|
||||
comments.value = [];
|
||||
currentPage.value = 1;
|
||||
|
|
@ -450,16 +490,20 @@ watch(() => props.postId, () => {
|
|||
<template v-for="comment in group.comments" :key="comment.id">
|
||||
<!-- Top-level comment -->
|
||||
<div
|
||||
class="group relative rounded-lg py-1.5 px-2 hover:bg-muted/50"
|
||||
:data-comment-id="comment.id"
|
||||
class="group relative rounded-lg py-1.5 px-2 transition-colors"
|
||||
:class="highlightedId === comment.id ? 'bg-primary/10 ring-1 ring-primary/30' : 'hover:bg-muted/50'"
|
||||
@mouseenter="hoveredCommentId = comment.id"
|
||||
@mouseleave="hoveredCommentId = null; emojiPickerCommentId = null"
|
||||
>
|
||||
<!-- Editing mode -->
|
||||
<div v-if="editingComment?.id === comment.id" class="space-y-2">
|
||||
<Textarea
|
||||
<MentionTextarea
|
||||
v-model="editBody"
|
||||
:member-names="memberNames"
|
||||
class="min-h-[60px] resize-none text-sm"
|
||||
@keydown="handleEditKeydown"
|
||||
@mention="registerMention"
|
||||
/>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button size="sm" variant="default" @click="saveEdit">{{ $t('comments.save') }}</Button>
|
||||
|
|
@ -489,7 +533,7 @@ watch(() => props.postId, () => {
|
|||
</TooltipProvider>
|
||||
<span v-if="comment.updated_at !== comment.created_at" class="text-[10px] text-muted-foreground italic">({{ $t('comments.edited') }})</span>
|
||||
</div>
|
||||
<p class="mt-0.5 whitespace-pre-wrap text-sm">{{ comment.body }}</p>
|
||||
<CommentBody :body="comment.body" :members="memberNames" />
|
||||
|
||||
<!-- Reactions -->
|
||||
<div v-if="groupedReactions(comment.reactions).length > 0" class="mt-1.5 flex flex-wrap gap-1">
|
||||
|
|
@ -557,16 +601,20 @@ watch(() => props.postId, () => {
|
|||
<div
|
||||
v-for="reply in comment.replies"
|
||||
:key="reply.id"
|
||||
class="group relative ml-8 rounded-lg py-1.5 px-2 hover:bg-muted/50"
|
||||
:data-comment-id="reply.id"
|
||||
class="group relative ml-8 rounded-lg py-1.5 px-2 transition-colors"
|
||||
:class="highlightedId === reply.id ? 'bg-primary/10 ring-1 ring-primary/30' : 'hover:bg-muted/50'"
|
||||
@mouseenter="hoveredCommentId = reply.id"
|
||||
@mouseleave="hoveredCommentId = null; emojiPickerCommentId = null"
|
||||
>
|
||||
<!-- Editing reply -->
|
||||
<div v-if="editingComment?.id === reply.id" class="space-y-2">
|
||||
<Textarea
|
||||
<MentionTextarea
|
||||
v-model="editBody"
|
||||
:member-names="memberNames"
|
||||
class="min-h-[60px] resize-none text-sm"
|
||||
@keydown="handleEditKeydown"
|
||||
@mention="registerMention"
|
||||
/>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button size="sm" variant="default" @click="saveEdit">{{ $t('comments.save') }}</Button>
|
||||
|
|
@ -596,7 +644,7 @@ watch(() => props.postId, () => {
|
|||
</TooltipProvider>
|
||||
<span v-if="reply.updated_at !== reply.created_at" class="text-[10px] text-muted-foreground italic">({{ $t('comments.edited') }})</span>
|
||||
</div>
|
||||
<p class="mt-0.5 whitespace-pre-wrap text-sm">{{ reply.body }}</p>
|
||||
<CommentBody :body="reply.body" :members="memberNames" />
|
||||
|
||||
<!-- Reply reactions -->
|
||||
<div v-if="groupedReactions(reply.reactions).length > 0" class="mt-1.5 flex flex-wrap gap-1">
|
||||
|
|
@ -675,13 +723,15 @@ watch(() => props.postId, () => {
|
|||
</div>
|
||||
|
||||
<div class="flex items-end gap-1.5">
|
||||
<Textarea
|
||||
<MentionTextarea
|
||||
ref="textareaRef"
|
||||
v-model="newBody"
|
||||
:member-names="memberNames"
|
||||
:placeholder="replyingTo ? $t('comments.reply_placeholder') : $t('comments.placeholder')"
|
||||
class="min-h-[36px] max-h-[120px] flex-1 resize-none text-sm"
|
||||
rows="1"
|
||||
:rows="1"
|
||||
@keydown="handleKeydown"
|
||||
@mention="registerMention"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
|
|
@ -7,13 +9,6 @@ interface SocialAccount {
|
|||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
original_filename: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
|
|
@ -91,7 +86,7 @@ defineProps<Props>();
|
|||
'aspect-square': media.length > 1,
|
||||
'col-span-2': media.length === 3 && index === 0,
|
||||
}">
|
||||
<img v-if="item.type === 'image'" :src="item.url" :alt="item.original_filename"
|
||||
<img v-if="!isVideoMedia(item)" :src="item.url" :alt="item.original_filename"
|
||||
class="w-full h-full object-cover" />
|
||||
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
|
||||
playsinline />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
|
|
@ -7,13 +9,6 @@ interface SocialAccount {
|
|||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
original_filename: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
|
|
@ -80,7 +75,7 @@ defineProps<Props>();
|
|||
'row-span-2': media.length === 3 && index === 0,
|
||||
'aspect-square': media.length > 1,
|
||||
}">
|
||||
<img v-if="item.type === 'image'" :src="item.url" :alt="item.original_filename"
|
||||
<img v-if="!isVideoMedia(item)" :src="item.url" :alt="item.original_filename"
|
||||
class="w-full h-full object-cover" />
|
||||
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
|
||||
playsinline />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
|
|
@ -7,13 +9,6 @@ interface SocialAccount {
|
|||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
original_filename: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
|
|
@ -63,7 +58,7 @@ defineProps<Props>();
|
|||
'aspect-[4/3]': media.length === 1,
|
||||
'aspect-square': media.length > 1,
|
||||
}">
|
||||
<img v-if="item.type === 'image'" :src="item.url" :alt="item.original_filename"
|
||||
<img v-if="!isVideoMedia(item)" :src="item.url" :alt="item.original_filename"
|
||||
class="w-full h-full object-cover" />
|
||||
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
|
||||
playsinline />
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
import { IconPhoto, IconStack2 } from '@tabler/icons-vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
|
|
@ -10,13 +12,6 @@ interface SocialAccount {
|
|||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
original_filename: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
|
|
@ -51,7 +46,7 @@ const isCarousel = computed(() => props.contentType === 'pinterest_carousel');
|
|||
<!-- Single Image or Video -->
|
||||
<div v-if="!isCarousel || media.length === 1" class="relative">
|
||||
<img
|
||||
v-if="media[0].type === 'image'"
|
||||
v-if="!isVideoMedia(media[0])"
|
||||
:src="media[0].url"
|
||||
:alt="media[0].original_filename"
|
||||
class="w-full aspect-[2/3] object-cover"
|
||||
|
|
@ -65,7 +60,7 @@ const isCarousel = computed(() => props.contentType === 'pinterest_carousel');
|
|||
playsinline
|
||||
/>
|
||||
<!-- Video indicator -->
|
||||
<div v-if="media[0].type === 'video'" class="absolute bottom-2 left-2 bg-black/60 text-white text-[10px] px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<div v-if="isVideoMedia(media[0])" class="absolute bottom-2 left-2 bg-black/60 text-white text-[10px] px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<svg class="h-2.5 w-2.5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="5,3 19,12 5,21"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
|
|
@ -9,13 +11,6 @@ interface SocialAccount {
|
|||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
original_filename: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
|
|
@ -100,7 +95,7 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
|
|||
'aspect-[4/3]': media.length === 1,
|
||||
'aspect-square': media.length > 1,
|
||||
}">
|
||||
<img v-if="item.type === 'image'" :src="item.url" :alt="item.original_filename"
|
||||
<img v-if="!isVideoMedia(item)" :src="item.url" :alt="item.original_filename"
|
||||
class="w-full h-full object-cover" />
|
||||
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
|
||||
playsinline />
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
import { IconPlus } from '@tabler/icons-vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
|
|
@ -10,13 +12,6 @@ interface SocialAccount {
|
|||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
original_filename: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
|
|
@ -44,10 +39,10 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
|
|||
<!-- Video/Media Area - Full screen -->
|
||||
<div class="absolute inset-0">
|
||||
<!-- Video content -->
|
||||
<div v-if="media.length > 0 && media[0].type === 'video'" class="w-full h-full">
|
||||
<div v-if="media.length > 0 && isVideoMedia(media[0])" class="w-full h-full">
|
||||
<video :src="media[0].url" class="w-full h-full object-cover" muted loop playsinline />
|
||||
</div>
|
||||
<div v-else-if="media.length > 0 && media[0].type === 'image'" class="w-full h-full">
|
||||
<div v-else-if="media.length > 0" class="w-full h-full">
|
||||
<img :src="media[0].url" :alt="media[0].original_filename" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center bg-[#161823]">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
|
|
@ -9,13 +11,6 @@ interface SocialAccount {
|
|||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
original_filename: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
|
|
@ -98,12 +93,12 @@ const username = computed(() => props.socialAccount.username || 'username');
|
|||
'aspect-[16/9]': media.length === 1,
|
||||
'aspect-square': media.length > 1,
|
||||
}">
|
||||
<img v-if="item.type === 'image'" :src="item.url" :alt="item.original_filename"
|
||||
<img v-if="!isVideoMedia(item)" :src="item.url" :alt="item.original_filename"
|
||||
class="w-full h-full object-cover" />
|
||||
<video v-else :src="item.url" class="w-full h-full object-cover bg-black" muted loop
|
||||
playsinline />
|
||||
<!-- Video duration badge -->
|
||||
<div v-if="item.type === 'video'"
|
||||
<div v-if="isVideoMedia(item)"
|
||||
class="absolute bottom-2 left-2 bg-black/70 text-white text-[13px] px-1.5 py-0.5 rounded">
|
||||
0:27
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
|
|
@ -9,13 +11,6 @@ interface SocialAccount {
|
|||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
original_filename: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
|
|
@ -43,10 +38,10 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
|
|||
<!-- Video/Media Area - Full screen -->
|
||||
<div class="absolute inset-0">
|
||||
<!-- Video content -->
|
||||
<div v-if="media.length > 0 && media[0].type === 'video'" class="w-full h-full">
|
||||
<div v-if="media.length > 0 && isVideoMedia(media[0])" class="w-full h-full">
|
||||
<video :src="media[0].url" class="w-full h-full object-cover" muted loop playsinline />
|
||||
</div>
|
||||
<div v-else-if="media.length > 0 && media[0].type === 'image'" class="w-full h-full">
|
||||
<div v-else-if="media.length > 0" class="w-full h-full">
|
||||
<img :src="media[0].url" :alt="media[0].original_filename" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center bg-[#0f0f0f]">
|
||||
|
|
|
|||
151
resources/js/components/settings/BrandTab.vue
Normal file
151
resources/js/components/settings/BrandTab.vue
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<script setup lang="ts">
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import WorkspaceController from '@/actions/App/Http/Controllers/App/WorkspaceController';
|
||||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
brand_website: string | null;
|
||||
brand_description: string | null;
|
||||
brand_tone: string;
|
||||
brand_voice_notes: string | null;
|
||||
content_language: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
workspace: Workspace;
|
||||
}>();
|
||||
|
||||
const brandTone = ref(props.workspace.brand_tone ?? 'professional');
|
||||
const contentLanguage = ref(props.workspace.content_language ?? 'en');
|
||||
|
||||
const toneLabel = computed(() =>
|
||||
brandTone.value ? trans(`settings.brand.tone_${brandTone.value}`) : '',
|
||||
);
|
||||
|
||||
const languageLabel = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
en: 'English',
|
||||
'pt-BR': 'Português (Brasil)',
|
||||
es: 'Español',
|
||||
};
|
||||
return map[contentLanguage.value] ?? '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.brand.title')"
|
||||
:description="$t('settings.brand.description')"
|
||||
/>
|
||||
|
||||
<Form
|
||||
v-bind="WorkspaceController.updateSettings.form()"
|
||||
v-slot="{ errors, processing }"
|
||||
class="space-y-6"
|
||||
>
|
||||
<input type="hidden" name="name" :value="workspace.name" />
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_website">{{ $t('settings.brand.website') }}</Label>
|
||||
<Input
|
||||
id="brand_website"
|
||||
name="brand_website"
|
||||
type="url"
|
||||
:default-value="workspace.brand_website ?? ''"
|
||||
:placeholder="$t('settings.brand.website_placeholder')"
|
||||
/>
|
||||
<InputError :message="errors.brand_website" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_description">{{ $t('settings.brand.brand_description') }}</Label>
|
||||
<Textarea
|
||||
id="brand_description"
|
||||
name="brand_description"
|
||||
:default-value="workspace.brand_description ?? ''"
|
||||
:placeholder="$t('settings.brand.brand_description_placeholder')"
|
||||
rows="3"
|
||||
/>
|
||||
<InputError :message="errors.brand_description" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_tone">{{ $t('settings.brand.tone') }}</Label>
|
||||
<Select v-model="brandTone" name="brand_tone">
|
||||
<SelectTrigger id="brand_tone" class="w-full">
|
||||
<SelectValue :placeholder="$t('settings.brand.tone')">
|
||||
{{ toneLabel }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="professional">{{ $t('settings.brand.tone_professional') }}</SelectItem>
|
||||
<SelectItem value="casual">{{ $t('settings.brand.tone_casual') }}</SelectItem>
|
||||
<SelectItem value="friendly">{{ $t('settings.brand.tone_friendly') }}</SelectItem>
|
||||
<SelectItem value="bold">{{ $t('settings.brand.tone_bold') }}</SelectItem>
|
||||
<SelectItem value="inspirational">{{ $t('settings.brand.tone_inspirational') }}</SelectItem>
|
||||
<SelectItem value="humorous">{{ $t('settings.brand.tone_humorous') }}</SelectItem>
|
||||
<SelectItem value="educational">{{ $t('settings.brand.tone_educational') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="brand_tone" :value="brandTone" />
|
||||
<InputError :message="errors.brand_tone" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="content_language">{{ $t('settings.brand.content_language') }}</Label>
|
||||
<Select v-model="contentLanguage" name="content_language">
|
||||
<SelectTrigger id="content_language" class="w-full">
|
||||
<SelectValue :placeholder="$t('settings.brand.content_language')">
|
||||
{{ languageLabel }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="pt-BR">Português (Brasil)</SelectItem>
|
||||
<SelectItem value="es">Español</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="content_language" :value="contentLanguage" />
|
||||
<InputError :message="errors.content_language" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="-mt-4 text-xs text-muted-foreground">
|
||||
{{ $t('settings.brand.content_language_description') }}
|
||||
</p>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_voice_notes">{{ $t('settings.brand.voice_notes') }}</Label>
|
||||
<Textarea
|
||||
id="brand_voice_notes"
|
||||
name="brand_voice_notes"
|
||||
:default-value="workspace.brand_voice_notes ?? ''"
|
||||
:placeholder="$t('settings.brand.voice_notes_placeholder')"
|
||||
rows="3"
|
||||
/>
|
||||
<InputError :message="errors.brand_voice_notes" />
|
||||
</div>
|
||||
|
||||
<Button :disabled="processing">{{ $t('settings.workspace.save') }}</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
175
resources/js/components/settings/UsersTab.vue
Normal file
175
resources/js/components/settings/UsersTab.vue
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { IconClock, IconDots, IconShield, IconTrash, IconUser } from '@tabler/icons-vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
import InviteMemberDialog from '@/components/members/InviteMemberDialog.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { WorkspaceRole } from '@/enums/workspace-role';
|
||||
import { destroy as destroyInvite } from '@/routes/app/invites';
|
||||
import { remove as removeMemberRoute, updateRole } from '@/routes/app/members';
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface Invitation {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
members: Member[];
|
||||
invitations: Invitation[];
|
||||
}>();
|
||||
|
||||
const inviteDialogOpen = ref(false);
|
||||
const removeMemberModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
const cancelInvitationModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
|
||||
const changeRole = (member: Member, role: string) => {
|
||||
router.put(updateRole.url(member.id), { role });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.workspace.members_heading')"
|
||||
:description="$t('settings.workspace.members_description')"
|
||||
/>
|
||||
|
||||
<Button variant="secondary" @click="inviteDialogOpen = true">
|
||||
{{ $t('settings.members.invite.submit') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{{ $t('settings.workspace.name') }}</TableHead>
|
||||
<TableHead>{{ $t('settings.members.invite.email') }}</TableHead>
|
||||
<TableHead>{{ $t('settings.members.invite.role') }}</TableHead>
|
||||
<TableHead class="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="member in members" :key="member.id">
|
||||
<TableCell class="font-medium">
|
||||
{{ member.name }}
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
{{ member.email }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="member.role === WorkspaceRole.Admin ? 'default' : 'secondary'">
|
||||
{{ member.role }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8">
|
||||
<IconDots class="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
v-if="member.role === WorkspaceRole.Member"
|
||||
@click="changeRole(member, WorkspaceRole.Admin)"
|
||||
>
|
||||
<IconShield class="size-3.5" />
|
||||
{{ $t('settings.members.make_admin') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="member.role === WorkspaceRole.Admin"
|
||||
@click="changeRole(member, WorkspaceRole.Member)"
|
||||
>
|
||||
<IconUser class="size-3.5" />
|
||||
{{ $t('settings.members.make_member') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
@click="removeMemberModal?.open({ url: removeMemberRoute.url(member.id) })"
|
||||
>
|
||||
<IconTrash class="size-3.5" />
|
||||
{{ $t('settings.members.remove') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-for="invitation in invitations"
|
||||
:key="`inv-${invitation.id}`"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconClock class="size-3.5" />
|
||||
<span class="italic">{{ $t('settings.members.pending.title') }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{{ invitation.email }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{{ invitation.role }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-destructive"
|
||||
@click="cancelInvitationModal?.open({ url: destroyInvite.url(invitation.id) })"
|
||||
>
|
||||
<IconTrash class="size-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<InviteMemberDialog v-model:open="inviteDialogOpen" />
|
||||
|
||||
<ConfirmDeleteModal
|
||||
ref="removeMemberModal"
|
||||
:title="$t('settings.members.remove_modal.title')"
|
||||
:description="$t('settings.members.remove_modal.description')"
|
||||
:action="$t('settings.members.remove_modal.action')"
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
ref="cancelInvitationModal"
|
||||
:title="$t('settings.members.cancel_invite_modal.title')"
|
||||
:description="$t('settings.members.cancel_invite_modal.description')"
|
||||
:action="$t('settings.members.cancel_invite_modal.action')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
71
resources/js/components/settings/WorkspaceTab.vue
Normal file
71
resources/js/components/settings/WorkspaceTab.vue
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<script setup lang="ts">
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
|
||||
import WorkspaceController from '@/actions/App/Http/Controllers/App/WorkspaceController';
|
||||
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';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { uploadLogo, deleteLogo } from '@/routes/app/workspace';
|
||||
|
||||
interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
has_logo: boolean;
|
||||
logo_url: string | null;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
workspace: Workspace;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-12">
|
||||
<div class="flex flex-col space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.workspace.logo_heading')"
|
||||
:description="$t('settings.workspace.logo_description')"
|
||||
/>
|
||||
|
||||
<PhotoUpload
|
||||
:photo-url="workspace.logo_url"
|
||||
:has-photo="workspace.has_logo"
|
||||
:name="workspace.name"
|
||||
:upload-url="uploadLogo().url"
|
||||
:delete-url="deleteLogo().url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="flex flex-col space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.workspace.heading')"
|
||||
:description="$t('settings.workspace.description')"
|
||||
/>
|
||||
|
||||
<Form
|
||||
v-bind="WorkspaceController.updateSettings.form()"
|
||||
v-slot="{ errors, processing }"
|
||||
class="space-y-6"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">{{ $t('settings.workspace.name') }}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
:default-value="workspace.name"
|
||||
:placeholder="$t('settings.workspace.name_placeholder')"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<Button :disabled="processing">{{ $t('settings.workspace.save') }}</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { useHttp, usePage } from '@inertiajs/vue3';
|
||||
import { onBeforeUnmount, onMounted } from 'vue';
|
||||
|
||||
import AppHeader from '@/components/AppHeader.vue';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import Toast from '@/components/Toast.vue';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { heartbeat as heartbeatRoute } from '@/routes/app/presence';
|
||||
|
||||
const page = usePage();
|
||||
const isOpen = page.props.sidebarOpen;
|
||||
|
|
@ -18,6 +20,24 @@ withDefaults(defineProps<Props>(), {
|
|||
title: '',
|
||||
fullWidth: false,
|
||||
});
|
||||
|
||||
const heartbeatHttp = useHttp<Record<string, never>, { ok: boolean }>({});
|
||||
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const sendHeartbeat = () => {
|
||||
if (typeof document === 'undefined' || document.hidden) return;
|
||||
void heartbeatHttp.post(heartbeatRoute.url()).catch(() => undefined);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
sendHeartbeat();
|
||||
heartbeatTimer = setInterval(sendHeartbeat, 30_000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ const props = defineProps<{
|
|||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
account: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}>();
|
||||
|
|
@ -48,12 +52,12 @@ const inviteUrl = computed(() => `/invites/${props.invite.id}`);
|
|||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-xl">{{ $t('auth.accept_invite.title') }}</CardTitle>
|
||||
<CardDescription>
|
||||
{{ $t('auth.accept_invite.description', { workspace: invite.workspace.name }) }}
|
||||
{{ $t('auth.accept_invite.description', { workspace: invite.workspace?.name ?? invite.account.name }) }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div class="rounded-lg bg-muted p-4 space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<div v-if="invite.workspace" class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ $t('auth.accept_invite.workspace') }}</span>
|
||||
<span class="font-medium">{{ invite.workspace.name }}</span>
|
||||
</div>
|
||||
|
|
@ -87,12 +91,12 @@ const inviteUrl = computed(() => `/invites/${props.invite.id}`);
|
|||
{{ $t('auth.accept_invite.login_prompt') }}
|
||||
</p>
|
||||
<Button as-child size="lg" class="w-full">
|
||||
<Link :href="login({ query: { redirect: inviteUrl } })">
|
||||
<Link :href="login({ query: { redirect: inviteUrl, email: invite.email } })">
|
||||
{{ $t('auth.accept_invite.log_in') }}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button as-child variant="outline" size="lg" class="w-full">
|
||||
<Link :href="register({ query: { redirect: inviteUrl } })">
|
||||
<Link :href="register({ query: { redirect: inviteUrl, email: invite.email } })">
|
||||
{{ $t('auth.accept_invite.create_account') }}
|
||||
</Link>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -238,7 +238,13 @@ const selectedLabelIds = ref<string[]>(post.value.labels?.map((l) => l.id) || []
|
|||
const isSubmitting = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const showSaved = ref(false);
|
||||
const activeTab = ref('schedule');
|
||||
const queryParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
|
||||
const initialTabFromQuery = (() => {
|
||||
const tab = queryParams?.get('tab');
|
||||
return ['preview', 'schedule', 'comments', 'assistant'].includes(tab ?? '') ? (tab as string) : 'schedule';
|
||||
})();
|
||||
const initialHighlightCommentId = queryParams?.get('comment') ?? null;
|
||||
const activeTab = ref(initialTabFromQuery);
|
||||
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
const hashtagsModal = ref<InstanceType<typeof HashtagsModal> | null>(null);
|
||||
const mediaPickerDialog = ref<InstanceType<typeof MediaPickerDialog> | null>(null);
|
||||
|
|
@ -461,12 +467,15 @@ const deletePost = () => {
|
|||
// Full reload of the post prop so the new status + post_platforms propagate and
|
||||
// the overlay dismisses.
|
||||
useEcho(`post.${post.value.id}`, '.PostPlatformStatusUpdated', () => {
|
||||
router.reload({ only: ['post'], preserveScroll: true });
|
||||
router.reload({ only: ['post'] });
|
||||
});
|
||||
|
||||
|
||||
// Echo: listen for real-time comments
|
||||
useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
|
||||
if (e.mentioned_users) {
|
||||
commentsTabRef.value?.registerMentionedUsers(e.mentioned_users);
|
||||
}
|
||||
commentsTabRef.value?.addCommentFromBroadcast(e.comment);
|
||||
});
|
||||
</script>
|
||||
|
|
@ -767,7 +776,12 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="comments" class="flex-1 overflow-hidden">
|
||||
<CommentsTab ref="commentsTabRef" :post-id="post.id" :current-user-id="authUserId" />
|
||||
<CommentsTab
|
||||
ref="commentsTabRef"
|
||||
:post-id="post.id"
|
||||
:current-user-id="authUserId"
|
||||
:highlight-comment-id="initialHighlightCommentId"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="assistant" class="flex-1 overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -1,47 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import { Form, Head, router } from '@inertiajs/vue3';
|
||||
import { IconClock, IconDots, IconShield, IconTrash, IconUser } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
|
||||
import WorkspaceController from '@/actions/App/Http/Controllers/App/WorkspaceController';
|
||||
import { WorkspaceRole } from '@/enums/workspace-role';
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import InviteMemberDialog from '@/components/members/InviteMemberDialog.vue';
|
||||
import PhotoUpload from '@/components/PhotoUpload.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import BrandTab from '@/components/settings/BrandTab.vue';
|
||||
import UsersTab from '@/components/settings/UsersTab.vue';
|
||||
import WorkspaceTab from '@/components/settings/WorkspaceTab.vue';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { destroy as destroyInvite } from '@/routes/app/invites';
|
||||
import { remove as removeMemberRoute, updateRole } from '@/routes/app/members';
|
||||
import { uploadLogo, deleteLogo } from '@/routes/app/workspace';
|
||||
|
||||
interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -67,35 +32,11 @@ interface Invitation {
|
|||
role: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
workspace: Workspace;
|
||||
members: Member[];
|
||||
invitations: Invitation[];
|
||||
}>();
|
||||
|
||||
const brandTone = ref(props.workspace.brand_tone ?? 'professional');
|
||||
const contentLanguage = ref(props.workspace.content_language ?? 'en');
|
||||
|
||||
const toneLabel = computed(() =>
|
||||
brandTone.value ? trans(`settings.brand.tone_${brandTone.value}`) : '',
|
||||
);
|
||||
|
||||
const languageLabel = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
en: 'English',
|
||||
'pt-BR': 'Português (Brasil)',
|
||||
es: 'Español',
|
||||
};
|
||||
return map[contentLanguage.value] ?? '';
|
||||
});
|
||||
const inviteDialogOpen = ref(false);
|
||||
|
||||
const removeMemberModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
const cancelInvitationModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
|
||||
const changeRole = (member: Member, role: string) => {
|
||||
router.put(updateRole.url(member.id), { role });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -104,271 +45,26 @@ const changeRole = (member: Member, role: string) => {
|
|||
|
||||
<h1 class="sr-only">{{ $t('settings.workspace.title') }}</h1>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-6 space-y-12">
|
||||
<div class="flex flex-col space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.workspace.logo_heading')"
|
||||
:description="$t('settings.workspace.logo_description')"
|
||||
/>
|
||||
<div class="mx-auto max-w-4xl px-4 py-6">
|
||||
<Tabs default-value="workspace">
|
||||
<TabsList>
|
||||
<TabsTrigger value="workspace">{{ $t('settings.workspace.tabs.workspace') }}</TabsTrigger>
|
||||
<TabsTrigger value="brand">{{ $t('settings.workspace.tabs.brand') }}</TabsTrigger>
|
||||
<TabsTrigger value="users">{{ $t('settings.workspace.tabs.users') }}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<PhotoUpload
|
||||
:photo-url="workspace.logo_url"
|
||||
:has-photo="workspace.has_logo"
|
||||
:name="workspace.name"
|
||||
:upload-url="uploadLogo().url"
|
||||
:delete-url="deleteLogo().url"
|
||||
/>
|
||||
</div>
|
||||
<TabsContent value="workspace" class="mt-6">
|
||||
<WorkspaceTab :workspace="workspace" />
|
||||
</TabsContent>
|
||||
|
||||
<Separator />
|
||||
<TabsContent value="brand" class="mt-6">
|
||||
<BrandTab :workspace="workspace" />
|
||||
</TabsContent>
|
||||
|
||||
<div class="flex flex-col space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.workspace.heading')"
|
||||
:description="$t('settings.workspace.description')"
|
||||
/>
|
||||
|
||||
<Form
|
||||
v-bind="WorkspaceController.updateSettings.form()"
|
||||
class="space-y-6"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">{{ $t('settings.workspace.name') }}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
:default-value="workspace.name"
|
||||
:placeholder="$t('settings.workspace.name_placeholder')"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<Button :disabled="processing">{{ $t('settings.workspace.save') }}</Button>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="flex flex-col space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.brand.title')"
|
||||
:description="$t('settings.brand.description')"
|
||||
/>
|
||||
|
||||
<Form
|
||||
v-bind="WorkspaceController.updateSettings.form()"
|
||||
class="space-y-6"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<input type="hidden" name="name" :value="workspace.name" />
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_website">{{ $t('settings.brand.website') }}</Label>
|
||||
<Input
|
||||
id="brand_website"
|
||||
name="brand_website"
|
||||
type="url"
|
||||
:default-value="workspace.brand_website ?? ''"
|
||||
:placeholder="$t('settings.brand.website_placeholder')"
|
||||
/>
|
||||
<InputError :message="errors.brand_website" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_description">{{ $t('settings.brand.brand_description') }}</Label>
|
||||
<Textarea
|
||||
id="brand_description"
|
||||
name="brand_description"
|
||||
:default-value="workspace.brand_description ?? ''"
|
||||
:placeholder="$t('settings.brand.brand_description_placeholder')"
|
||||
rows="3"
|
||||
/>
|
||||
<InputError :message="errors.brand_description" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_tone">{{ $t('settings.brand.tone') }}</Label>
|
||||
<Select v-model="brandTone" name="brand_tone">
|
||||
<SelectTrigger id="brand_tone" class="w-full">
|
||||
<SelectValue :placeholder="$t('settings.brand.tone')">
|
||||
{{ toneLabel }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="professional">{{ $t('settings.brand.tone_professional') }}</SelectItem>
|
||||
<SelectItem value="casual">{{ $t('settings.brand.tone_casual') }}</SelectItem>
|
||||
<SelectItem value="friendly">{{ $t('settings.brand.tone_friendly') }}</SelectItem>
|
||||
<SelectItem value="bold">{{ $t('settings.brand.tone_bold') }}</SelectItem>
|
||||
<SelectItem value="inspirational">{{ $t('settings.brand.tone_inspirational') }}</SelectItem>
|
||||
<SelectItem value="humorous">{{ $t('settings.brand.tone_humorous') }}</SelectItem>
|
||||
<SelectItem value="educational">{{ $t('settings.brand.tone_educational') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="brand_tone" :value="brandTone" />
|
||||
<InputError :message="errors.brand_tone" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="content_language">{{ $t('settings.brand.content_language') }}</Label>
|
||||
<Select v-model="contentLanguage" name="content_language">
|
||||
<SelectTrigger id="content_language" class="w-full">
|
||||
<SelectValue :placeholder="$t('settings.brand.content_language')">
|
||||
{{ languageLabel }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="pt-BR">Português (Brasil)</SelectItem>
|
||||
<SelectItem value="es">Español</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="content_language" :value="contentLanguage" />
|
||||
<InputError :message="errors.content_language" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="-mt-4 text-xs text-muted-foreground">
|
||||
{{ $t('settings.brand.content_language_description') }}
|
||||
</p>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_voice_notes">{{ $t('settings.brand.voice_notes') }}</Label>
|
||||
<Textarea
|
||||
id="brand_voice_notes"
|
||||
name="brand_voice_notes"
|
||||
:default-value="workspace.brand_voice_notes ?? ''"
|
||||
:placeholder="$t('settings.brand.voice_notes_placeholder')"
|
||||
rows="3"
|
||||
/>
|
||||
<InputError :message="errors.brand_voice_notes" />
|
||||
</div>
|
||||
|
||||
<Button :disabled="processing">{{ $t('settings.workspace.save') }}</Button>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="flex flex-col space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.workspace.members_heading')"
|
||||
:description="$t('settings.workspace.members_description')"
|
||||
/>
|
||||
|
||||
<Button variant="secondary" @click="inviteDialogOpen = true">
|
||||
{{ $t('settings.members.invite.submit') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{{ $t('settings.workspace.name') }}</TableHead>
|
||||
<TableHead>{{ $t('settings.members.invite.email') }}</TableHead>
|
||||
<TableHead>{{ $t('settings.members.invite.role') }}</TableHead>
|
||||
<TableHead class="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="member in members" :key="member.id">
|
||||
<TableCell class="font-medium">
|
||||
{{ member.name }}
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
{{ member.email }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="member.role === WorkspaceRole.Admin ? 'default' : 'secondary'">
|
||||
{{ member.role }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8">
|
||||
<IconDots class="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
v-if="member.role === WorkspaceRole.Member"
|
||||
@click="changeRole(member, WorkspaceRole.Admin)"
|
||||
>
|
||||
<IconShield class="size-3.5" />
|
||||
{{ $t('settings.members.make_admin') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="member.role === WorkspaceRole.Admin"
|
||||
@click="changeRole(member, WorkspaceRole.Member)"
|
||||
>
|
||||
<IconUser class="size-3.5" />
|
||||
{{ $t('settings.members.make_member') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
@click="removeMemberModal?.open({ url: removeMemberRoute.url(member.id) })"
|
||||
>
|
||||
<IconTrash class="size-3.5" />
|
||||
{{ $t('settings.members.remove') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-for="invitation in invitations"
|
||||
:key="`inv-${invitation.id}`"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconClock class="size-3.5" />
|
||||
<span class="italic">{{ $t('settings.members.pending.title') }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{{ invitation.email }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{{ invitation.role }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-destructive"
|
||||
@click="cancelInvitationModal?.open({ url: destroyInvite.url(invitation.id) })"
|
||||
>
|
||||
<IconTrash class="size-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InviteMemberDialog v-model:open="inviteDialogOpen" />
|
||||
|
||||
<ConfirmDeleteModal
|
||||
ref="removeMemberModal"
|
||||
:title="$t('settings.members.remove_modal.title')"
|
||||
:description="$t('settings.members.remove_modal.description')"
|
||||
:action="$t('settings.members.remove_modal.action')"
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
ref="cancelInvitationModal"
|
||||
:title="$t('settings.members.cancel_invite_modal.title')"
|
||||
:description="$t('settings.members.cancel_invite_modal.description')"
|
||||
:action="$t('settings.members.cancel_invite_modal.action')"
|
||||
/>
|
||||
<TabsContent value="users" class="mt-6">
|
||||
<UsersTab :members="members" :invitations="invitations" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
|
|||
113
resources/views/mail/mentioned-in-comment.blade.php
Normal file
113
resources/views/mail/mentioned-in-comment.blade.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="x-apple-disable-message-reformatting">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<style>
|
||||
td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: "Segoe UI", sans-serif; mso-line-height-rule: exactly;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
@if(isset($title))
|
||||
<title>{{ $title }}</title>
|
||||
@endif
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet" media="screen">
|
||||
<style>
|
||||
.hover-i-text-decoration-underline:hover {
|
||||
text-decoration: underline !important
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.sm-my-8 {
|
||||
margin-top: 32px !important;
|
||||
margin-bottom: 32px !important
|
||||
}
|
||||
.sm-px-4 {
|
||||
padding-left: 16px !important;
|
||||
padding-right: 16px !important
|
||||
}
|
||||
.sm-px-6 {
|
||||
padding-left: 24px !important;
|
||||
padding-right: 24px !important
|
||||
}
|
||||
.sm-leading-8 {
|
||||
line-height: 32px !important
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0; width: 100%; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word">
|
||||
@if(isset($previewText))
|
||||
<div style="display: none">
|
||||
{{ $previewText }}
|
||||
 ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏
|
||||
</div>
|
||||
@endif
|
||||
<div role="article" aria-roledescription="email" aria-label="{{ $title }}" lang="en">
|
||||
<div class="sm-px-4" style="background-color: #fafafa; font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
|
||||
<table align="center" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td style="width: 552px; max-width: 100%">
|
||||
<div class="sm-my-8" style="margin-top: 48px; margin-bottom: 48px; text-align: center">
|
||||
<a href="https://trypost.it" target="_blank">
|
||||
<img src="{{ asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
|
||||
</a>
|
||||
</div>
|
||||
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td class="sm-px-6" style="border-radius: 4px; background-color: #fffffe; padding: 48px; font-size: 16px; color: #3f3f46; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05)">
|
||||
<h1 class="sm-leading-8" style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #000001">
|
||||
{{ $title }}
|
||||
</h1>
|
||||
<p style="margin: 0 0 16px; line-height: 24px">
|
||||
{!! __('mail.mentioned.intro', ['name' => '<strong>'.e($authorName).'</strong>']) !!}
|
||||
</p>
|
||||
<div style="border-radius: 4px; border-left-width: 3px; border-color: #e4e4e7; background-color: #fafafa; padding: 12px 16px; font-size: 15px; line-height: 22px; color: #3f3f46">
|
||||
{{ $excerpt }}
|
||||
</div>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<div style="display: flex; align-items: center; justify-content: center">
|
||||
<div>
|
||||
<a href="{{ $url }}" style="display: inline-block; text-decoration: none; padding: 16px 24px; font-size: 16px; line-height: 1; border-radius: 8px; background-color: #262626; color: #ffffff">
|
||||
<!--[if mso]><i style="mso-font-width: 150%; mso-text-raise: 31px" hidden> </i><![endif]-->
|
||||
<span style="mso-text-raise: 16px">{{ __('mail.mentioned.cta') }} →</span>
|
||||
<!--[if mso]><i hidden style="mso-font-width: 150%"> ​</i><![endif]-->
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 24px; text-align: center; font-size: 12px; color: #52525b">
|
||||
<p style="margin: 0 0 8px">
|
||||
Open-source social media scheduling tool
|
||||
</p>
|
||||
@if(isset($unsubscribe_url))
|
||||
<p style="margin: 8px 0 0">
|
||||
<a href="{{ unsubscribe_url }}" target="_blank" class="hover-i-text-decoration-underline" style="color: #52525b; text-decoration: none">
|
||||
Unsubscribe
|
||||
</a>
|
||||
</p>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -72,10 +72,10 @@
|
|||
You've been invited!
|
||||
</h1>
|
||||
<p style="margin: 0; line-height: 24px">
|
||||
You've been invited to collaborate on the <strong>{{ $invite->workspace->name }}</strong> workspace.
|
||||
You've been invited to collaborate on the <strong>{{ $accountName }}</strong> workspace.
|
||||
</p>
|
||||
<p style="margin: 16px 0 0; line-height: 24px">
|
||||
You've been invited as <strong>{{ $invite->role->label() }}</strong>.
|
||||
You've been invited as <strong>{{ $roleLabel }}</strong>.
|
||||
</p>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<div style="display: flex; align-items: center; justify-content: center">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
use App\Http\Controllers\App\PostAssistantController;
|
||||
use App\Http\Controllers\App\PostCommentController;
|
||||
use App\Http\Controllers\App\PostController;
|
||||
use App\Http\Controllers\App\PresenceController;
|
||||
use App\Http\Controllers\App\Settings\AccountController;
|
||||
use App\Http\Controllers\App\Settings\NotificationPreferenceController;
|
||||
use App\Http\Controllers\App\Settings\PasswordController;
|
||||
|
|
@ -53,6 +54,13 @@
|
|||
Route::post('workspaces/autofill', [WorkspaceController::class, 'autofillBrand'])
|
||||
->middleware('throttle:10,1')
|
||||
->name('app.workspaces.autofill');
|
||||
|
||||
Route::get('workspace/members/search', [WorkspaceController::class, 'searchMembers'])
|
||||
->middleware('throttle:60,1')
|
||||
->name('app.workspace.members.search');
|
||||
|
||||
Route::post('presence/heartbeat', [PresenceController::class, 'heartbeat'])
|
||||
->name('app.presence.heartbeat');
|
||||
});
|
||||
|
||||
// Social Connect routes
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use App\Broadcasting\PostChannel;
|
||||
use App\Broadcasting\WorkspaceUserChannel;
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
|
||||
Broadcast::channel('post.{post}', PostChannel::class);
|
||||
|
||||
Broadcast::channel('workspace.{workspace}.user.{owner}', WorkspaceUserChannel::class);
|
||||
|
|
|
|||
210
tests/Feature/Actions/PostComment/NotifyMentionsTest.php
Normal file
210
tests/Feature/Actions/PostComment/NotifyMentionsTest.php
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\PostComment\NotifyMentions;
|
||||
use App\Enums\Notification\Channel;
|
||||
use App\Enums\Notification\Type;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Jobs\SendNotification;
|
||||
use App\Mail\MentionedInComment;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\WorkspacePresence;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
Queue::fake();
|
||||
|
||||
$this->author = User::factory()->create();
|
||||
$this->mentioned = User::factory()->create();
|
||||
$this->stranger = User::factory()->create();
|
||||
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->author->id]);
|
||||
$this->workspace->members()->attach($this->author->id, ['role' => Role::Member->value]);
|
||||
$this->workspace->members()->attach($this->mentioned->id, ['role' => Role::Member->value]);
|
||||
|
||||
$this->author->update(['current_workspace_id' => $this->workspace->id]);
|
||||
|
||||
$this->post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->author->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('notifies a workspace member mentioned in the comment body', function () {
|
||||
$comment = PostComment::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'user_id' => $this->author->id,
|
||||
'body' => "Hey @[{$this->mentioned->id}] could you take a look?",
|
||||
]);
|
||||
|
||||
NotifyMentions::execute($comment);
|
||||
|
||||
Queue::assertPushed(SendNotification::class, fn ($job) => $job->user->id === $this->mentioned->id
|
||||
&& $job->type === Type::MentionedInComment
|
||||
);
|
||||
});
|
||||
|
||||
test('does not notify the comment author when self-mentioning', function () {
|
||||
$comment = PostComment::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'user_id' => $this->author->id,
|
||||
'body' => "Note for myself @[{$this->author->id}]",
|
||||
]);
|
||||
|
||||
NotifyMentions::execute($comment);
|
||||
|
||||
Queue::assertNotPushed(SendNotification::class);
|
||||
});
|
||||
|
||||
test('does not notify users that are not workspace members', function () {
|
||||
$comment = PostComment::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'user_id' => $this->author->id,
|
||||
'body' => "FYI @[{$this->stranger->id}]",
|
||||
]);
|
||||
|
||||
NotifyMentions::execute($comment);
|
||||
|
||||
Queue::assertNotPushed(SendNotification::class);
|
||||
});
|
||||
|
||||
test('on update only notifies newly added mentions', function () {
|
||||
$comment = PostComment::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'user_id' => $this->author->id,
|
||||
'body' => "Hi @[{$this->mentioned->id}]",
|
||||
]);
|
||||
|
||||
$secondMember = User::factory()->create();
|
||||
$this->workspace->members()->attach($secondMember->id, ['role' => Role::Member->value]);
|
||||
|
||||
$previousBody = $comment->body;
|
||||
$comment->update(['body' => "Hi @[{$this->mentioned->id}] and @[{$secondMember->id}]"]);
|
||||
|
||||
NotifyMentions::execute($comment, $previousBody);
|
||||
|
||||
Queue::assertPushed(SendNotification::class, 1);
|
||||
Queue::assertPushed(
|
||||
SendNotification::class,
|
||||
fn ($job) => $job->user->id === $secondMember->id
|
||||
);
|
||||
});
|
||||
|
||||
test('dedupes repeated mentions of the same user', function () {
|
||||
$comment = PostComment::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'user_id' => $this->author->id,
|
||||
'body' => "@[{$this->mentioned->id}] @[{$this->mentioned->id}] @[{$this->mentioned->id}]",
|
||||
]);
|
||||
|
||||
NotifyMentions::execute($comment);
|
||||
|
||||
Queue::assertPushed(SendNotification::class, 1);
|
||||
});
|
||||
|
||||
test('with no mention markers no jobs are queued', function () {
|
||||
$comment = PostComment::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'user_id' => $this->author->id,
|
||||
'body' => 'Plain comment with no mention',
|
||||
]);
|
||||
|
||||
NotifyMentions::execute($comment);
|
||||
|
||||
Queue::assertNotPushed(SendNotification::class);
|
||||
});
|
||||
|
||||
test('online recipient (workspace presence) gets InApp only — no mailable', function () {
|
||||
WorkspacePresence::markOnline($this->workspace->id, $this->mentioned->id);
|
||||
|
||||
$comment = PostComment::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'user_id' => $this->author->id,
|
||||
'body' => "Hey @[{$this->mentioned->id}]",
|
||||
]);
|
||||
|
||||
NotifyMentions::execute($comment);
|
||||
|
||||
Queue::assertPushed(SendNotification::class, function ($job) {
|
||||
expect($job->channel)->toBe(Channel::InApp);
|
||||
expect($job->mailable)->toBeNull();
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('offline recipient gets Both (in-app + email)', function () {
|
||||
$comment = PostComment::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'user_id' => $this->author->id,
|
||||
'body' => "Hey @[{$this->mentioned->id}]",
|
||||
]);
|
||||
|
||||
NotifyMentions::execute($comment);
|
||||
|
||||
Queue::assertPushed(SendNotification::class, function ($job) {
|
||||
expect($job->channel)->toBe(Channel::Both);
|
||||
expect($job->mailable)->toBeInstanceOf(MentionedInComment::class);
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('respects mentioned_in_comment preference: when disabled, no email is queued', function () {
|
||||
$this->mentioned->notificationPreference()->create([
|
||||
'post_published' => true,
|
||||
'post_failed' => true,
|
||||
'account_disconnected' => true,
|
||||
'mentioned_in_comment' => false,
|
||||
]);
|
||||
|
||||
$comment = PostComment::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'user_id' => $this->author->id,
|
||||
'body' => "Hey @[{$this->mentioned->id}]",
|
||||
]);
|
||||
|
||||
NotifyMentions::execute($comment);
|
||||
|
||||
Queue::assertPushed(SendNotification::class, function ($job) {
|
||||
$job->handle();
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// In-app notification still saved (Channel::Both, but email path is gated by user preference)
|
||||
expect(Notification::where('user_id', $this->mentioned->id)
|
||||
->where('type', Type::MentionedInComment)
|
||||
->count())->toBe(1);
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
test('processed job persists a Notification row + sends the mailable', function () {
|
||||
$comment = PostComment::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'user_id' => $this->author->id,
|
||||
'body' => "Hey @[{$this->mentioned->id}]",
|
||||
]);
|
||||
|
||||
NotifyMentions::execute($comment);
|
||||
|
||||
Queue::assertPushed(SendNotification::class, function ($job) {
|
||||
$job->handle();
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
expect(Notification::where('user_id', $this->mentioned->id)
|
||||
->where('type', Type::MentionedInComment)
|
||||
->count())->toBe(1);
|
||||
|
||||
Mail::assertQueued(MentionedInComment::class);
|
||||
});
|
||||
|
|
@ -172,6 +172,7 @@
|
|||
|
||||
test('when llm is configured, polishes description/tone/language/voice_notes via BrandAnalyzer', function () {
|
||||
config()->set('services.gemini.api_key', 'fake-key');
|
||||
config()->set('ai.default', 'gemini');
|
||||
|
||||
Http::fake([
|
||||
'example.com' => Http::response(<<<'HTML'
|
||||
|
|
@ -234,6 +235,7 @@
|
|||
|
||||
test('falls back to meta tags when BrandAnalyzer throws', function () {
|
||||
config()->set('services.gemini.api_key', 'fake-key');
|
||||
config()->set('ai.default', 'gemini');
|
||||
|
||||
Http::fake([
|
||||
'example.com' => Http::response(<<<'HTML'
|
||||
|
|
|
|||
|
|
@ -127,12 +127,12 @@
|
|||
expect($instructions)->not->toContain('ACTIVE PLATFORMS FOR THIS POST');
|
||||
});
|
||||
|
||||
test('provider honors trypost.ai.text_provider config', function () {
|
||||
config()->set('trypost.ai.text_provider', 'openai');
|
||||
test('provider honors ai.default config', function () {
|
||||
config()->set('ai.default', 'openai');
|
||||
|
||||
expect((new SocialMediaAssistant($this->workspace))->provider())->toBe(Lab::OpenAI);
|
||||
|
||||
config()->set('trypost.ai.text_provider', 'gemini');
|
||||
config()->set('ai.default', 'gemini');
|
||||
|
||||
expect((new SocialMediaAssistant($this->workspace))->provider())->toBe(Lab::Gemini);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@
|
|||
$this->assertDatabaseHas('workspace_ai_usages', [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'type' => UsageType::Image->value,
|
||||
'provider' => 'gemini',
|
||||
'provider' => config('ai.default_for_images'),
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
use App\Models\Workspace;
|
||||
use App\Services\Ai\VideoGenerationService;
|
||||
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Ai\Tools\Request as ToolRequest;
|
||||
|
||||
beforeEach(function () {
|
||||
|
|
@ -25,16 +26,19 @@
|
|||
});
|
||||
|
||||
test('tool delegates to VideoGenerationService and pushes attachment to collector', function () {
|
||||
$expected = [
|
||||
'id' => 'video-uuid',
|
||||
'path' => 'medias/video.mp4',
|
||||
'url' => 'https://example.com/medias/video.mp4',
|
||||
'mime_type' => 'video/mp4',
|
||||
$media = $this->workspace->media()->create([
|
||||
'group_id' => Str::uuid()->toString(),
|
||||
'collection' => 'assets',
|
||||
'type' => 'video',
|
||||
];
|
||||
'path' => 'medias/video.mp4',
|
||||
'original_filename' => 'ai-generated.mp4',
|
||||
'mime_type' => 'video/mp4',
|
||||
'size' => 1024,
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$mock = $this->mock(VideoGenerationService::class);
|
||||
$mock->shouldReceive('generate')->once()->andReturn($expected);
|
||||
$mock->shouldReceive('generate')->once()->andReturn($media);
|
||||
|
||||
$tool = new GenerateVideo(
|
||||
workspace: $this->workspace,
|
||||
|
|
@ -49,7 +53,8 @@
|
|||
|
||||
expect((string) $summary)->toContain('video');
|
||||
expect(app(AttachmentCollector::class)->all())->toHaveCount(1);
|
||||
expect(app(AttachmentCollector::class)->all()[0])->toBe($expected);
|
||||
expect(app(AttachmentCollector::class)->all()[0]['id'])->toBe($media->id);
|
||||
expect(app(AttachmentCollector::class)->all()[0]['type'])->toBe('video');
|
||||
});
|
||||
|
||||
test('tool refuses to generate when monthly video quota is exhausted', function () {
|
||||
|
|
|
|||
130
tests/Feature/Mcp/AiToolTest.php
Normal file
130
tests/Feature/Mcp/AiToolTest.php
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Ai\UsageType;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Mcp\Servers\TryPostServer;
|
||||
use App\Mcp\Tools\Ai\GenerateImageTool;
|
||||
use App\Mcp\Tools\Ai\GenerateVideoTool;
|
||||
use App\Models\Account;
|
||||
use App\Models\AiUsageLog;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Ai\VideoGenerationService;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Ai\Image;
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake();
|
||||
|
||||
$this->account = Account::factory()->create();
|
||||
$this->user = User::factory()->create(['account_id' => $this->account->id]);
|
||||
$this->account->update(['owner_id' => $this->user->id]);
|
||||
|
||||
$this->workspace = Workspace::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
|
||||
$this->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
]);
|
||||
});
|
||||
|
||||
test('generate-image tool creates a media in the workspace gallery', function () {
|
||||
Image::fake();
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(GenerateImageTool::class, [
|
||||
'prompt' => 'A purple sunset over the desert',
|
||||
'orientation' => 'square',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
expect($this->workspace->getMedia('assets')->count())->toBe(1);
|
||||
|
||||
$media = $this->workspace->getMedia('assets')->first();
|
||||
expect($media->mime_type)->toBe('image/png');
|
||||
expect($media->collection)->toBe('assets');
|
||||
});
|
||||
|
||||
test('generate-image tool logs ai usage', function () {
|
||||
Image::fake();
|
||||
|
||||
TryPostServer::actingAs($this->user)
|
||||
->tool(GenerateImageTool::class, [
|
||||
'prompt' => 'test',
|
||||
'orientation' => 'square',
|
||||
]);
|
||||
|
||||
expect(AiUsageLog::where('workspace_id', $this->workspace->id)
|
||||
->where('type', UsageType::Image)
|
||||
->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('generate-image tool requires prompt and orientation', function () {
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(GenerateImageTool::class, []);
|
||||
|
||||
$response->assertHasErrors();
|
||||
});
|
||||
|
||||
test('generate-image tool rejects invalid orientation', function () {
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(GenerateImageTool::class, [
|
||||
'prompt' => 'test',
|
||||
'orientation' => 'diagonal',
|
||||
]);
|
||||
|
||||
$response->assertHasErrors();
|
||||
});
|
||||
|
||||
test('generate-video tool delegates to VideoGenerationService and returns Media payload', function () {
|
||||
$media = $this->workspace->media()->create([
|
||||
'group_id' => Str::uuid()->toString(),
|
||||
'collection' => 'assets',
|
||||
'type' => 'video',
|
||||
'path' => 'medias/v.mp4',
|
||||
'original_filename' => 'ai-generated.mp4',
|
||||
'mime_type' => 'video/mp4',
|
||||
'size' => 4096,
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$mock = $this->mock(VideoGenerationService::class);
|
||||
$mock->shouldReceive('generate')->once()->andReturn($media);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(GenerateVideoTool::class, [
|
||||
'prompt' => 'A cat dancing',
|
||||
'orientation' => 'vertical',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee($media->id);
|
||||
});
|
||||
|
||||
test('generate-video tool rejects invalid orientation', function () {
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(GenerateVideoTool::class, [
|
||||
'prompt' => 'A cat dancing',
|
||||
'orientation' => 'square',
|
||||
]);
|
||||
|
||||
$response->assertHasErrors();
|
||||
});
|
||||
|
||||
test('generate-video tool requires prompt', function () {
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(GenerateVideoTool::class, ['orientation' => 'vertical']);
|
||||
|
||||
$response->assertHasErrors();
|
||||
});
|
||||
|
|
@ -3,10 +3,12 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Jobs\SendNotification;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create([]);
|
||||
|
|
@ -79,6 +81,67 @@
|
|||
]);
|
||||
});
|
||||
|
||||
test('index returns mentioned_users map for chip rendering', function () {
|
||||
$other = User::factory()->create(['name' => 'Other Member']);
|
||||
$this->workspace->members()->attach($other->id, ['role' => Role::Member->value]);
|
||||
|
||||
PostComment::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'user_id' => $this->user->id,
|
||||
'body' => "Ping @[{$other->id}] please",
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson(route('app.posts.comments.index', $this->post));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath("mentioned_users.{$other->id}", 'Other Member');
|
||||
});
|
||||
|
||||
test('store dispatches a mention notification to a workspace member', function () {
|
||||
Queue::fake();
|
||||
|
||||
$other = User::factory()->create();
|
||||
$this->workspace->members()->attach($other->id, ['role' => Role::Member->value]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson(route('app.posts.comments.store', $this->post), [
|
||||
'body' => "Hey @[{$other->id}] please review",
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
Queue::assertPushed(
|
||||
SendNotification::class,
|
||||
fn ($job) => $job->user->id === $other->id
|
||||
);
|
||||
});
|
||||
|
||||
test('update with newly added mention dispatches a notification', function () {
|
||||
Queue::fake();
|
||||
|
||||
$other = User::factory()->create();
|
||||
$this->workspace->members()->attach($other->id, ['role' => Role::Member->value]);
|
||||
|
||||
$comment = PostComment::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'user_id' => $this->user->id,
|
||||
'body' => 'Plain body, no mention.',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->putJson(route('app.posts.comments.update', [$this->post, $comment]), [
|
||||
'body' => "Updated to mention @[{$other->id}]",
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
Queue::assertPushed(
|
||||
SendNotification::class,
|
||||
fn ($job) => $job->user->id === $other->id
|
||||
);
|
||||
});
|
||||
|
||||
test('store rejects reply to a reply', function () {
|
||||
$parent = PostComment::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
|
|
|
|||
55
tests/Feature/Workspace/SearchMembersTest.php
Normal file
55
tests/Feature/Workspace/SearchMembersTest.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create(['name' => 'Alice Owner']);
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
test('returns workspace members excluding the current user', function () {
|
||||
$bob = User::factory()->create(['name' => 'Bob Builder']);
|
||||
$this->workspace->members()->attach($bob->id, ['role' => Role::Member->value]);
|
||||
|
||||
$response = $this->actingAs($this->user)->getJson(route('app.workspace.members.search'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonPath('0.id', $bob->id);
|
||||
$names = collect($response->json())->pluck('name')->all();
|
||||
expect($names)->not->toContain('Alice Owner');
|
||||
expect($names)->toContain('Bob Builder');
|
||||
});
|
||||
|
||||
test('filters by query case-insensitively', function () {
|
||||
$bob = User::factory()->create(['name' => 'Bob Builder']);
|
||||
$this->workspace->members()->attach($bob->id, ['role' => Role::Member->value]);
|
||||
|
||||
$response = $this->actingAs($this->user)->getJson(route('app.workspace.members.search', ['q' => 'bob']));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonPath('0.id', $bob->id);
|
||||
});
|
||||
|
||||
test('does not include users from other workspaces', function () {
|
||||
$other = User::factory()->create(['name' => 'Eve External']);
|
||||
$otherWorkspace = Workspace::factory()->create(['user_id' => $other->id]);
|
||||
$otherWorkspace->members()->attach($other->id, ['role' => Role::Member->value]);
|
||||
|
||||
$response = $this->actingAs($this->user)->getJson(route('app.workspace.members.search'));
|
||||
|
||||
$response->assertOk();
|
||||
$names = collect($response->json())->pluck('name')->all();
|
||||
expect($names)->not->toContain('Eve External');
|
||||
});
|
||||
|
||||
test('requires authentication', function () {
|
||||
$this->getJson(route('app.workspace.members.search'))->assertStatus(401);
|
||||
});
|
||||
67
tests/Unit/Mail/MentionedInCommentTest.php
Normal file
67
tests/Unit/Mail/MentionedInCommentTest.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Mail\MentionedInComment;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->author = User::factory()->create(['name' => 'Alice Author']);
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->author->id]);
|
||||
$this->workspace->members()->attach($this->author->id, ['role' => Role::Member->value]);
|
||||
|
||||
$this->post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->author->id,
|
||||
]);
|
||||
|
||||
$this->comment = PostComment::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'user_id' => $this->author->id,
|
||||
'body' => 'Hey, please review this asap',
|
||||
]);
|
||||
});
|
||||
|
||||
test('subject is localized with author name', function () {
|
||||
$mail = new MentionedInComment($this->comment, $this->author, 'short excerpt');
|
||||
|
||||
expect($mail->envelope()->subject)->toBe('Alice Author mentioned you on TryPost');
|
||||
});
|
||||
|
||||
test('content view + payload + url include the comment context', function () {
|
||||
$mail = new MentionedInComment($this->comment, $this->author, 'short excerpt');
|
||||
$content = $mail->content();
|
||||
|
||||
expect($content->view)->toBe('mail.mentioned-in-comment');
|
||||
expect($content->with['title'])->toBe('Alice Author mentioned you');
|
||||
expect($content->with['authorName'])->toBe('Alice Author');
|
||||
expect($content->with['excerpt'])->toBe('short excerpt');
|
||||
expect($content->with['url'])->toContain((string) $this->post->id);
|
||||
expect($content->with['url'])->toContain('tab=comments');
|
||||
expect($content->with['url'])->toContain('comment='.$this->comment->id);
|
||||
});
|
||||
|
||||
test('mailable has no attachments', function () {
|
||||
$mail = new MentionedInComment($this->comment, $this->author, 'x');
|
||||
|
||||
expect($mail->attachments())->toBeEmpty();
|
||||
});
|
||||
|
||||
test('mailable is queueable', function () {
|
||||
$mail = new MentionedInComment($this->comment, $this->author, 'x');
|
||||
|
||||
expect($mail)->toBeInstanceOf(ShouldQueue::class);
|
||||
});
|
||||
|
||||
test('blade view renders without error', function () {
|
||||
$mail = new MentionedInComment($this->comment, $this->author, 'short excerpt');
|
||||
$rendered = $mail->render();
|
||||
|
||||
expect($rendered)->toContain('Alice Author');
|
||||
expect($rendered)->toContain('short excerpt');
|
||||
});
|
||||
|
|
@ -30,7 +30,8 @@
|
|||
expect($content->view)->toBe('mail.workspace-invite');
|
||||
expect($content->with['title'])->toBe("You've been invited to join My Team");
|
||||
expect($content->with['previewText'])->toBe("You've been invited to join My Team");
|
||||
expect($content->with['invite'])->toBe($invite);
|
||||
expect($content->with['accountName'])->toBe('My Team');
|
||||
expect($content->with['roleLabel'])->toBeString();
|
||||
expect($content->with['url'])->toBe(route('app.invites.show', $invite->id));
|
||||
});
|
||||
|
||||
|
|
|
|||
48
tests/Unit/Support/MentionParserTest.php
Normal file
48
tests/Unit/Support/MentionParserTest.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\MentionParser;
|
||||
|
||||
test('extracts a single uuid mention', function () {
|
||||
$body = 'Hey @[019dabc1-2345-6789-abcd-ef0123456789] take a look';
|
||||
|
||||
expect(MentionParser::extractUserIds($body))
|
||||
->toEqual(['019dabc1-2345-6789-abcd-ef0123456789']);
|
||||
});
|
||||
|
||||
test('extracts multiple distinct mentions in order of first appearance', function () {
|
||||
$body = '@[aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa] and @[bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb]';
|
||||
|
||||
expect(MentionParser::extractUserIds($body))
|
||||
->toEqual(['aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb']);
|
||||
});
|
||||
|
||||
test('dedupes repeated mentions of the same uuid', function () {
|
||||
$id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||
$body = "@[$id] @[$id] middle @[$id]";
|
||||
|
||||
expect(MentionParser::extractUserIds($body))->toEqual([$id]);
|
||||
});
|
||||
|
||||
test('returns empty array for body with no markers', function () {
|
||||
expect(MentionParser::extractUserIds('No mentions in this text @notuuid'))->toEqual([]);
|
||||
});
|
||||
|
||||
test('ignores malformed uuids', function () {
|
||||
$body = '@[not-a-uuid] @[019d] @[XX] real -> @[12345678-1234-1234-1234-123456789abc]';
|
||||
|
||||
expect(MentionParser::extractUserIds($body))
|
||||
->toEqual(['12345678-1234-1234-1234-123456789abc']);
|
||||
});
|
||||
|
||||
test('matches uuids regardless of hex case', function () {
|
||||
$body = '@[ABCDEFAB-cdef-CDEF-1234-1234567890ab]';
|
||||
|
||||
expect(MentionParser::extractUserIds($body))
|
||||
->toEqual(['ABCDEFAB-cdef-CDEF-1234-1234567890ab']);
|
||||
});
|
||||
|
||||
test('returns empty for empty body', function () {
|
||||
expect(MentionParser::extractUserIds(''))->toEqual([]);
|
||||
});
|
||||
Loading…
Reference in a new issue