refactor: replace hashtags functionality with reusable signatures feature
This commit is contained in:
parent
8a2f853fd5
commit
b47f2488d0
49 changed files with 72 additions and 1799 deletions
|
|
@ -1,19 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Hashtag;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceHashtag;
|
||||
|
||||
class CreateHashtag
|
||||
{
|
||||
public static function execute(Workspace $workspace, array $data): WorkspaceHashtag
|
||||
{
|
||||
return $workspace->hashtags()->create([
|
||||
'name' => data_get($data, 'name'),
|
||||
'hashtags' => data_get($data, 'hashtags'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Hashtag;
|
||||
|
||||
use App\Models\WorkspaceHashtag;
|
||||
|
||||
class DeleteHashtag
|
||||
{
|
||||
public static function execute(WorkspaceHashtag $hashtag): void
|
||||
{
|
||||
$hashtag->delete();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Hashtag;
|
||||
|
||||
use App\Models\WorkspaceHashtag;
|
||||
|
||||
class UpdateHashtag
|
||||
{
|
||||
public static function execute(WorkspaceHashtag $hashtag, array $data): WorkspaceHashtag
|
||||
{
|
||||
$hashtag->update([
|
||||
'name' => data_get($data, 'name'),
|
||||
'hashtags' => data_get($data, 'hashtags'),
|
||||
]);
|
||||
|
||||
return $hashtag;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Hashtag\CreateHashtag;
|
||||
use App\Actions\Hashtag\DeleteHashtag;
|
||||
use App\Actions\Hashtag\UpdateHashtag;
|
||||
use App\Http\Requests\Api\Hashtag\StoreHashtagRequest;
|
||||
use App\Http\Requests\Api\Hashtag\UpdateHashtagRequest;
|
||||
use App\Http\Resources\Api\HashtagResource;
|
||||
use App\Models\WorkspaceHashtag;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class HashtagController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$hashtags = $request->workspace->hashtags()->latest()->get();
|
||||
|
||||
return HashtagResource::collection($hashtags);
|
||||
}
|
||||
|
||||
public function store(StoreHashtagRequest $request): JsonResponse
|
||||
{
|
||||
$hashtag = CreateHashtag::execute($request->workspace, $request->validated());
|
||||
|
||||
return (new HashtagResource($hashtag))
|
||||
->response()
|
||||
->setStatusCode(Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
public function update(UpdateHashtagRequest $request, WorkspaceHashtag $hashtag): HashtagResource
|
||||
{
|
||||
if ($hashtag->workspace_id !== $request->workspace->id) {
|
||||
abort(Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$hashtag = UpdateHashtag::execute($hashtag, $request->validated());
|
||||
|
||||
return new HashtagResource($hashtag);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, WorkspaceHashtag $hashtag): JsonResponse
|
||||
{
|
||||
if ($hashtag->workspace_id !== $request->workspace->id) {
|
||||
abort(Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
DeleteHashtag::execute($hashtag);
|
||||
|
||||
return response()->json(null, Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
|
|
@ -255,7 +255,7 @@ public function edit(Request $request, Post $post): Response|RedirectResponse
|
|||
$post->load(['postPlatforms.socialAccount', 'labels']);
|
||||
$socialAccounts = $workspace->socialAccounts()->active()->get();
|
||||
$labels = $workspace->labels;
|
||||
$hashtags = $workspace->hashtags;
|
||||
$signatures = $workspace->signatures;
|
||||
|
||||
$platformConfigs = $socialAccounts->mapWithKeys(fn ($account) => [
|
||||
$account->id => new PlatformConfigResource($account),
|
||||
|
|
@ -287,7 +287,7 @@ public function edit(Request $request, Post $post): Response|RedirectResponse
|
|||
),
|
||||
])->filter()),
|
||||
'labels' => $labels,
|
||||
'hashtags' => $hashtags,
|
||||
'signatures' => $signatures,
|
||||
'authUserId' => $request->user()->id,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Actions\Hashtag\CreateHashtag;
|
||||
use App\Actions\Hashtag\DeleteHashtag;
|
||||
use App\Actions\Hashtag\UpdateHashtag;
|
||||
use App\Models\WorkspaceHashtag;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class WorkspaceHashtagController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response|RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if (! $workspace) {
|
||||
return redirect()->route('app.workspaces.create');
|
||||
}
|
||||
|
||||
$this->authorize('createPost', $workspace);
|
||||
|
||||
$hashtags = $workspace->hashtags()
|
||||
->when($request->input('search'), fn ($query, $search) => $query->where('name', 'ilike', "%{$search}%"))
|
||||
->latest()
|
||||
->paginate(config('app.pagination.default'));
|
||||
|
||||
return Inertia::render('hashtags/Index', [
|
||||
'workspace' => $workspace,
|
||||
'hashtags' => Inertia::scroll(fn () => $hashtags),
|
||||
'filters' => [
|
||||
'search' => $request->input('search', ''),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if (! $workspace) {
|
||||
return redirect()->route('app.workspaces.create');
|
||||
}
|
||||
|
||||
$this->authorize('createPost', $workspace);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'hashtags' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
CreateHashtag::execute($workspace, $validated);
|
||||
|
||||
session()->flash('flash.banner', __('hashtags.flash.created'));
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return redirect()->route('app.hashtags.index');
|
||||
}
|
||||
|
||||
public function update(Request $request, WorkspaceHashtag $hashtag): RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if (! $workspace) {
|
||||
return redirect()->route('app.workspaces.create');
|
||||
}
|
||||
|
||||
$this->authorize('createPost', $workspace);
|
||||
|
||||
if ($hashtag->workspace_id !== $workspace->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'hashtags' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
UpdateHashtag::execute($hashtag, $validated);
|
||||
|
||||
session()->flash('flash.banner', __('hashtags.flash.updated'));
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return redirect()->route('app.hashtags.index');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, WorkspaceHashtag $hashtag): RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if (! $workspace) {
|
||||
return redirect()->route('app.workspaces.create');
|
||||
}
|
||||
|
||||
$this->authorize('createPost', $workspace);
|
||||
|
||||
if ($hashtag->workspace_id !== $workspace->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
DeleteHashtag::execute($hashtag);
|
||||
|
||||
session()->flash('flash.banner', __('hashtags.flash.deleted'));
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return redirect()->route('app.hashtags.index');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\Hashtag;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreHashtagRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'hashtags' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\Hashtag;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateHashtagRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'hashtags' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Hashtag;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreHashtagRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'hashtags' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class HashtagResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'hashtags' => $this->hashtags,
|
||||
'created_at' => $this->created_at->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->updated_at->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -8,10 +8,6 @@
|
|||
use App\Mcp\Tools\ApiKey\CreateApiKeyTool;
|
||||
use App\Mcp\Tools\ApiKey\DeleteApiKeyTool;
|
||||
use App\Mcp\Tools\ApiKey\ListApiKeysTool;
|
||||
use App\Mcp\Tools\Hashtag\CreateHashtagTool;
|
||||
use App\Mcp\Tools\Hashtag\DeleteHashtagTool;
|
||||
use App\Mcp\Tools\Hashtag\ListHashtagsTool;
|
||||
use App\Mcp\Tools\Hashtag\UpdateHashtagTool;
|
||||
use App\Mcp\Tools\Label\CreateLabelTool;
|
||||
use App\Mcp\Tools\Label\DeleteLabelTool;
|
||||
use App\Mcp\Tools\Label\ListLabelsTool;
|
||||
|
|
@ -20,6 +16,10 @@
|
|||
use App\Mcp\Tools\Post\DeletePostTool;
|
||||
use App\Mcp\Tools\Post\GetPostTool;
|
||||
use App\Mcp\Tools\Post\ListPostsTool;
|
||||
use App\Mcp\Tools\Signature\CreateSignatureTool;
|
||||
use App\Mcp\Tools\Signature\DeleteSignatureTool;
|
||||
use App\Mcp\Tools\Signature\ListSignaturesTool;
|
||||
use App\Mcp\Tools\Signature\UpdateSignatureTool;
|
||||
use App\Mcp\Tools\SocialAccount\ListSocialAccountsTool;
|
||||
use App\Mcp\Tools\SocialAccount\ToggleSocialAccountTool;
|
||||
use App\Mcp\Tools\Workspace\GetWorkspaceTool;
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
|
||||
#[Name('TryPost')]
|
||||
#[Version('1.0.0')]
|
||||
#[Instructions('TryPost is a social media scheduling platform. Use this server to manage posts, hashtag groups, labels, workspaces, and API keys.')]
|
||||
#[Instructions('TryPost is a social media scheduling platform. Use this server to manage posts, signatures, labels, workspaces, and API keys.')]
|
||||
class TryPostServer extends Server
|
||||
{
|
||||
public int $defaultPaginationLength = 100;
|
||||
|
|
@ -42,11 +42,11 @@ class TryPostServer extends Server
|
|||
CreatePostTool::class,
|
||||
DeletePostTool::class,
|
||||
|
||||
// Hashtags
|
||||
ListHashtagsTool::class,
|
||||
CreateHashtagTool::class,
|
||||
UpdateHashtagTool::class,
|
||||
DeleteHashtagTool::class,
|
||||
// Signatures
|
||||
ListSignaturesTool::class,
|
||||
CreateSignatureTool::class,
|
||||
UpdateSignatureTool::class,
|
||||
DeleteSignatureTool::class,
|
||||
|
||||
// Labels
|
||||
ListLabelsTool::class,
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tools\Hashtag;
|
||||
|
||||
use App\Actions\Hashtag\CreateHashtag;
|
||||
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('Create a new hashtag group with a name and hashtag string.')]
|
||||
class CreateHashtagTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): ResponseFactory
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'hashtags' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$hashtag = CreateHashtag::execute($request->user()->currentWorkspace, $validated);
|
||||
|
||||
return Response::structured($hashtag->toArray());
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'name' => $schema->string()->required()->description('The hashtag group name.'),
|
||||
'hashtags' => $schema->string()->required()->description('The hashtags string (e.g. "#tech #ai #startup").'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tools\Hashtag;
|
||||
|
||||
use App\Actions\Hashtag\DeleteHashtag;
|
||||
use App\Models\WorkspaceHashtag;
|
||||
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('Delete a hashtag group by ID.')]
|
||||
class DeleteHashtagTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$hashtag = WorkspaceHashtag::where('workspace_id', $request->user()->current_workspace_id)
|
||||
->find(data_get($request->validate(['hashtag_id' => ['required', 'string']]), 'hashtag_id'));
|
||||
|
||||
if (! $hashtag) {
|
||||
return Response::error('Hashtag not found.');
|
||||
}
|
||||
|
||||
DeleteHashtag::execute($hashtag);
|
||||
|
||||
return Response::structured(['deleted' => true]);
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'hashtag_id' => $schema->string()->required()->description('The hashtag group ID to delete.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tools\Hashtag;
|
||||
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('List all hashtag groups for the current workspace.')]
|
||||
class ListHashtagsTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): ResponseFactory
|
||||
{
|
||||
$hashtags = $request->user()->currentWorkspace->hashtags()->latest()->get();
|
||||
|
||||
return Response::structured($hashtags->toArray());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tools\Hashtag;
|
||||
|
||||
use App\Actions\Hashtag\UpdateHashtag;
|
||||
use App\Models\WorkspaceHashtag;
|
||||
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('Update a hashtag group name or hashtags.')]
|
||||
class UpdateHashtagTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'hashtag_id' => ['required', 'string'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'hashtags' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$hashtag = WorkspaceHashtag::where('workspace_id', $request->user()->current_workspace_id)
|
||||
->find(data_get($validated, 'hashtag_id'));
|
||||
|
||||
if (! $hashtag) {
|
||||
return Response::error('Hashtag not found.');
|
||||
}
|
||||
|
||||
$hashtag = UpdateHashtag::execute($hashtag, $validated);
|
||||
|
||||
return Response::structured($hashtag->toArray());
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'hashtag_id' => $schema->string()->required()->description('The hashtag group ID.'),
|
||||
'name' => $schema->string()->required()->description('The new name.'),
|
||||
'hashtags' => $schema->string()->required()->description('The new hashtags string.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -73,9 +73,9 @@ public function posts(): HasMany
|
|||
return $this->hasMany(Post::class);
|
||||
}
|
||||
|
||||
public function hashtags(): HasMany
|
||||
public function signatures(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkspaceHashtag::class);
|
||||
return $this->hasMany(WorkspaceSignature::class);
|
||||
}
|
||||
|
||||
public function labels(): HasMany
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\WorkspaceHashtagFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class WorkspaceHashtag extends Model
|
||||
{
|
||||
/** @use HasFactory<WorkspaceHashtagFactory> */
|
||||
use HasFactory, HasUuids, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'name',
|
||||
'hashtags',
|
||||
];
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -33,8 +33,8 @@
|
|||
use App\Models\SubscriptionItem;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceHashtag;
|
||||
use App\Models\WorkspaceLabel;
|
||||
use App\Models\WorkspaceSignature;
|
||||
use App\Services\PostTemplate\Registry as PostTemplateRegistry;
|
||||
use App\Socialite\InstagramProvider;
|
||||
use App\Socialite\LinkedInPageExtendSocialite;
|
||||
|
|
@ -162,7 +162,7 @@ protected function configureMorphMap(): void
|
|||
'subscriptionItem' => SubscriptionItem::class,
|
||||
'user' => User::class,
|
||||
'workspace' => Workspace::class,
|
||||
'workspaceHashtag' => WorkspaceHashtag::class,
|
||||
'workspaceSignature' => WorkspaceSignature::class,
|
||||
'workspaceLabel' => WorkspaceLabel::class,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceHashtag;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<WorkspaceHashtag>
|
||||
*/
|
||||
class WorkspaceHashtagFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'name' => fake()->words(2, true),
|
||||
'hashtags' => '#'.implode(' #', fake()->words(5)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('workspace_hashtags', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->text('hashtags');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('workspace_hashtags');
|
||||
}
|
||||
};
|
||||
|
|
@ -45,9 +45,9 @@
|
|||
'title' => 'Team Workspaces',
|
||||
'description' => 'Invite your team, assign roles, and manage multiple brands from separate workspaces.',
|
||||
],
|
||||
'hashtags' => [
|
||||
'title' => 'Hashtag Groups',
|
||||
'description' => 'Save hashtag collections and add them to posts with one click. Organize with labels and filters.',
|
||||
'signatures' => [
|
||||
'title' => 'Signatures',
|
||||
'description' => 'Save reusable signatures (hashtags, links, signoffs) and append them to posts with one click.',
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Hashtags',
|
||||
'description' => 'Create hashtag groups to quickly add to your posts',
|
||||
'search' => 'Search hashtags...',
|
||||
'new_group' => 'New Group',
|
||||
'no_groups_yet' => 'No hashtag groups yet',
|
||||
'no_groups_description' => 'Create hashtag groups to quickly add popular hashtags to your posts',
|
||||
'no_search_results' => 'No groups match your search',
|
||||
'try_different_search' => 'Try a different keyword or clear the search.',
|
||||
'create_first_group' => 'Create your first group',
|
||||
'hashtags_count' => ':count hashtags',
|
||||
'table' => [
|
||||
'name' => 'Name',
|
||||
'tags' => 'Tags',
|
||||
'count' => 'Count',
|
||||
'created_at' => 'Created',
|
||||
],
|
||||
|
||||
'create' => [
|
||||
'title' => 'Create Hashtag Group',
|
||||
'description' => 'Give your group a name and add hashtags separated by spaces or commas',
|
||||
'name' => 'Group Name',
|
||||
'name_placeholder' => 'e.g. Marketing, Travel, Food',
|
||||
'hashtags' => 'Hashtags',
|
||||
'hashtags_placeholder' => '#marketing #socialmedia #business #growth',
|
||||
'hashtags_hint' => 'Enter hashtags separated by spaces or commas. Include the # symbol.',
|
||||
'submit' => 'Create Group',
|
||||
'submitting' => 'Creating...',
|
||||
],
|
||||
|
||||
'edit' => [
|
||||
'title' => 'Edit Hashtag Group',
|
||||
'description' => 'Update the name and hashtags for this group',
|
||||
'name' => 'Group Name',
|
||||
'name_placeholder' => 'e.g. Marketing, Travel, Food',
|
||||
'hashtags' => 'Hashtags',
|
||||
'hashtags_placeholder' => '#marketing #socialmedia #business #growth',
|
||||
'hashtags_hint' => 'Enter hashtags separated by spaces or commas. Include the # symbol.',
|
||||
'submit' => 'Save Changes',
|
||||
'submitting' => 'Saving...',
|
||||
],
|
||||
|
||||
'delete' => [
|
||||
'title' => 'Delete Hashtag Group',
|
||||
'description' => 'Are you sure you want to delete this hashtag group? This action cannot be undone.',
|
||||
'confirm' => 'Delete',
|
||||
'cancel' => 'Cancel',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'created' => 'Hashtag group created successfully!',
|
||||
'updated' => 'Hashtag group updated successfully!',
|
||||
'deleted' => 'Hashtag group deleted successfully!',
|
||||
],
|
||||
];
|
||||
|
|
@ -249,7 +249,7 @@
|
|||
'add' => 'Add',
|
||||
'publish_to' => 'Publish to',
|
||||
'organize' => 'Organize',
|
||||
'hashtags' => 'Hashtags',
|
||||
'signatures' => 'Signatures',
|
||||
'view_on_platform' => 'View on platform',
|
||||
'platform_status' => 'Platform status',
|
||||
'compliance_incomplete' => 'Some platform settings are incomplete or incompatible with the attached media.',
|
||||
|
|
@ -334,9 +334,9 @@
|
|||
'description' => 'Choose which platforms to publish this post to.',
|
||||
],
|
||||
|
||||
'hashtags_modal' => [
|
||||
'search' => 'Search hashtags...',
|
||||
'no_results' => 'No hashtags found.',
|
||||
'signatures_modal' => [
|
||||
'search' => 'Search signatures...',
|
||||
'no_results' => 'No signatures found.',
|
||||
],
|
||||
|
||||
'validation' => [
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
|
||||
'workspace' => [
|
||||
'connections' => 'Connections',
|
||||
'hashtags' => 'Hashtags',
|
||||
'signatures' => 'Signatures',
|
||||
'labels' => 'Labels',
|
||||
'assets' => 'Assets',
|
||||
'api_keys' => 'API Keys',
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@
|
|||
'title' => 'Workspaces en Equipo',
|
||||
'description' => 'Invita a tu equipo, asigna roles y gestiona múltiples marcas en workspaces separados.',
|
||||
],
|
||||
'hashtags' => [
|
||||
'title' => 'Grupos de Hashtags',
|
||||
'description' => 'Guarda colecciones de hashtags y agrégalos a tus posts con un clic. Organiza con etiquetas y filtros.',
|
||||
'signatures' => [
|
||||
'title' => 'Firmas',
|
||||
'description' => 'Guarda firmas reutilizables (hashtags, links, despedidas) y añádelas a tus posts con un clic.',
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Hashtags',
|
||||
'description' => 'Crea grupos de hashtags para agregarlos rápidamente a tus posts',
|
||||
'search' => 'Buscar hashtags...',
|
||||
'new_group' => 'Nuevo grupo',
|
||||
'no_groups_yet' => 'Aún no hay grupos de hashtags',
|
||||
'no_groups_description' => 'Crea grupos de hashtags para agregar rápidamente hashtags populares a tus posts',
|
||||
'no_search_results' => 'Ningún grupo coincide con tu búsqueda',
|
||||
'try_different_search' => 'Prueba otra palabra clave o limpia la búsqueda.',
|
||||
'create_first_group' => 'Crea tu primer grupo',
|
||||
'hashtags_count' => ':count hashtags',
|
||||
'table' => [
|
||||
'name' => 'Nombre',
|
||||
'tags' => 'Hashtags',
|
||||
'count' => 'Cantidad',
|
||||
'created_at' => 'Creado',
|
||||
],
|
||||
|
||||
'create' => [
|
||||
'title' => 'Crear grupo de hashtags',
|
||||
'description' => 'Dale un nombre a tu grupo y agrega hashtags separados por espacios o comas',
|
||||
'name' => 'Nombre del grupo',
|
||||
'name_placeholder' => 'ej: Marketing, Viajes, Comida',
|
||||
'hashtags' => 'Hashtags',
|
||||
'hashtags_placeholder' => '#marketing #redessociales #negocios #crecimiento',
|
||||
'hashtags_hint' => 'Ingresa hashtags separados por espacios o comas. Incluye el símbolo #.',
|
||||
'submit' => 'Crear grupo',
|
||||
'submitting' => 'Creando...',
|
||||
],
|
||||
|
||||
'edit' => [
|
||||
'title' => 'Editar grupo de hashtags',
|
||||
'description' => 'Actualiza el nombre y los hashtags de este grupo',
|
||||
'name' => 'Nombre del grupo',
|
||||
'name_placeholder' => 'ej: Marketing, Viajes, Comida',
|
||||
'hashtags' => 'Hashtags',
|
||||
'hashtags_placeholder' => '#marketing #redessociales #negocios #crecimiento',
|
||||
'hashtags_hint' => 'Ingresa hashtags separados por espacios o comas. Incluye el símbolo #.',
|
||||
'submit' => 'Guardar cambios',
|
||||
'submitting' => 'Guardando...',
|
||||
],
|
||||
|
||||
'delete' => [
|
||||
'title' => 'Eliminar grupo de hashtags',
|
||||
'description' => '¿Estás seguro de que deseas eliminar este grupo de hashtags? Esta acción no se puede deshacer.',
|
||||
'confirm' => 'Eliminar',
|
||||
'cancel' => 'Cancelar',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'created' => '¡Grupo de hashtags creado correctamente!',
|
||||
'updated' => '¡Grupo de hashtags actualizado correctamente!',
|
||||
'deleted' => '¡Grupo de hashtags eliminado correctamente!',
|
||||
],
|
||||
];
|
||||
|
|
@ -221,7 +221,7 @@
|
|||
'manage_platforms' => 'Administrar plataformas',
|
||||
'sync' => 'Sincronizar',
|
||||
'labels' => 'Etiquetas',
|
||||
'hashtags' => 'Hashtags',
|
||||
'signatures' => 'Firmas',
|
||||
'schedule' => 'Programar',
|
||||
'publish' => 'Publicar',
|
||||
'delete' => 'Eliminar',
|
||||
|
|
@ -347,9 +347,9 @@
|
|||
'description' => 'Elige en qué plataformas publicar este post.',
|
||||
],
|
||||
|
||||
'hashtags_modal' => [
|
||||
'search' => 'Buscar hashtags...',
|
||||
'no_results' => 'No se encontraron hashtags.',
|
||||
'signatures_modal' => [
|
||||
'search' => 'Buscar firmas...',
|
||||
'no_results' => 'No se encontraron firmas.',
|
||||
],
|
||||
|
||||
'validation' => [
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
|
||||
'workspace' => [
|
||||
'connections' => 'Conexiones',
|
||||
'hashtags' => 'Hashtags',
|
||||
'signatures' => 'Firmas',
|
||||
'labels' => 'Etiquetas',
|
||||
'assets' => 'Medios',
|
||||
'api_keys' => 'API Keys',
|
||||
|
|
|
|||
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
|
|
@ -45,9 +45,9 @@
|
|||
'title' => 'Workspaces em Equipe',
|
||||
'description' => 'Convide sua equipe, atribua funções e gerencie múltiplas marcas em workspaces separados.',
|
||||
],
|
||||
'hashtags' => [
|
||||
'title' => 'Grupos de Hashtags',
|
||||
'description' => 'Salve coleções de hashtags e adicione aos posts com um clique. Organize com etiquetas e filtros.',
|
||||
'signatures' => [
|
||||
'title' => 'Assinaturas',
|
||||
'description' => 'Salve assinaturas reutilizáveis (hashtags, links, encerramentos) e anexe nos posts com um clique.',
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Hashtags',
|
||||
'description' => 'Crie grupos de hashtags para adicionar rapidamente aos seus posts',
|
||||
'search' => 'Buscar hashtags...',
|
||||
'new_group' => 'Novo Grupo',
|
||||
'no_groups_yet' => 'Nenhum grupo de hashtags ainda',
|
||||
'no_groups_description' => 'Crie grupos de hashtags para adicionar rapidamente hashtags populares aos seus posts',
|
||||
'no_search_results' => 'Nenhum grupo corresponde à sua busca',
|
||||
'try_different_search' => 'Tente outra palavra-chave ou limpe a busca.',
|
||||
'create_first_group' => 'Crie seu primeiro grupo',
|
||||
'hashtags_count' => ':count hashtags',
|
||||
'table' => [
|
||||
'name' => 'Nome',
|
||||
'tags' => 'Hashtags',
|
||||
'count' => 'Quantidade',
|
||||
'created_at' => 'Criado',
|
||||
],
|
||||
|
||||
'create' => [
|
||||
'title' => 'Criar Grupo de Hashtags',
|
||||
'description' => 'Dê um nome ao grupo e adicione hashtags separadas por espaços ou vírgulas',
|
||||
'name' => 'Nome do Grupo',
|
||||
'name_placeholder' => 'ex: Marketing, Viagem, Comida',
|
||||
'hashtags' => 'Hashtags',
|
||||
'hashtags_placeholder' => '#marketing #redessociais #negocios #crescimento',
|
||||
'hashtags_hint' => 'Digite as hashtags separadas por espaços ou vírgulas. Inclua o símbolo #.',
|
||||
'submit' => 'Criar Grupo',
|
||||
'submitting' => 'Criando...',
|
||||
],
|
||||
|
||||
'edit' => [
|
||||
'title' => 'Editar Grupo de Hashtags',
|
||||
'description' => 'Atualize o nome e as hashtags deste grupo',
|
||||
'name' => 'Nome do Grupo',
|
||||
'name_placeholder' => 'ex: Marketing, Viagem, Comida',
|
||||
'hashtags' => 'Hashtags',
|
||||
'hashtags_placeholder' => '#marketing #redessociais #negocios #crescimento',
|
||||
'hashtags_hint' => 'Digite as hashtags separadas por espaços ou vírgulas. Inclua o símbolo #.',
|
||||
'submit' => 'Salvar Alterações',
|
||||
'submitting' => 'Salvando...',
|
||||
],
|
||||
|
||||
'delete' => [
|
||||
'title' => 'Excluir Grupo de Hashtags',
|
||||
'description' => 'Tem certeza que deseja excluir este grupo de hashtags? Esta ação não pode ser desfeita.',
|
||||
'confirm' => 'Excluir',
|
||||
'cancel' => 'Cancelar',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'created' => 'Grupo de hashtags criado com sucesso!',
|
||||
'updated' => 'Grupo de hashtags atualizado com sucesso!',
|
||||
'deleted' => 'Grupo de hashtags excluído com sucesso!',
|
||||
],
|
||||
];
|
||||
|
|
@ -221,7 +221,7 @@
|
|||
'manage_platforms' => 'Gerenciar plataformas',
|
||||
'sync' => 'Sincronizar',
|
||||
'labels' => 'Etiqueta',
|
||||
'hashtags' => 'Hashtags',
|
||||
'signatures' => 'Assinaturas',
|
||||
'schedule' => 'Agendar',
|
||||
'publish' => 'Publicar',
|
||||
'delete' => 'Excluir',
|
||||
|
|
@ -347,9 +347,9 @@
|
|||
'description' => 'Escolha em quais plataformas publicar este post.',
|
||||
],
|
||||
|
||||
'hashtags_modal' => [
|
||||
'search' => 'Buscar hashtags...',
|
||||
'no_results' => 'Nenhuma hashtag encontrada.',
|
||||
'signatures_modal' => [
|
||||
'search' => 'Buscar assinaturas...',
|
||||
'no_results' => 'Nenhuma assinatura encontrada.',
|
||||
],
|
||||
|
||||
'validation' => [
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
|
||||
'workspace' => [
|
||||
'connections' => 'Conexões',
|
||||
'hashtags' => 'Hashtags',
|
||||
'signatures' => 'Assinaturas',
|
||||
'labels' => 'Etiquetas',
|
||||
'assets' => 'Mídias',
|
||||
'api_keys' => 'API Keys',
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ import { edit as accountSettings } from '@/routes/app/account';
|
|||
import { index as billing } from '@/routes/app/billing';
|
||||
import { index as usage } from '@/routes/app/usage';
|
||||
import { index as assets } from '@/routes/app/assets';
|
||||
import { index as hashtags } from '@/routes/app/hashtags';
|
||||
import { index as signatures } from '@/routes/app/signatures';
|
||||
import { index as labels } from '@/routes/app/labels';
|
||||
import { settings as workspaceSettings } from '@/routes/app/workspace';
|
||||
import { create as createWorkspaceRoute, switchMethod } from '@/routes/app/workspaces';
|
||||
|
|
@ -126,8 +126,8 @@ const workspaceNavItems = computed<NavItem[]>(() => {
|
|||
icon: IconAffiliate,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.workspace.hashtags'),
|
||||
href: hashtags.url(),
|
||||
title: trans('sidebar.workspace.signatures'),
|
||||
href: signatures.url(),
|
||||
icon: IconHash,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { store as hashtagsStore } from '@/routes/app/hashtags';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
hashtags: '',
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
form.post(hashtagsStore.url(), {
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (value) {
|
||||
form.reset();
|
||||
form.clearErrors();
|
||||
}
|
||||
open.value = value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="handleOpenChange">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('hashtags.create.title') }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ $t('hashtags.create.description') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form @submit.prevent="submit" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="create-name">{{ $t('hashtags.create.name') }}</Label>
|
||||
<Input
|
||||
id="create-name"
|
||||
v-model="form.name"
|
||||
:placeholder="trans('hashtags.create.name_placeholder')"
|
||||
:class="{ 'border-destructive': form.errors.name }"
|
||||
/>
|
||||
<p v-if="form.errors.name" class="text-sm text-destructive">
|
||||
{{ form.errors.name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="create-hashtags">{{ $t('hashtags.create.hashtags') }}</Label>
|
||||
<Textarea
|
||||
id="create-hashtags"
|
||||
v-model="form.hashtags"
|
||||
:placeholder="trans('hashtags.create.hashtags_placeholder')"
|
||||
rows="4"
|
||||
:class="{ 'border-destructive': form.errors.hashtags }"
|
||||
/>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('hashtags.create.hashtags_hint') }}
|
||||
</p>
|
||||
<p v-if="form.errors.hashtags" class="text-sm text-destructive">
|
||||
{{ form.errors.hashtags }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
{{ form.processing ? $t('hashtags.create.submitting') : $t('hashtags.create.submit') }}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" @click="open = false">
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { watch } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { update as hashtagsUpdate } from '@/routes/app/hashtags';
|
||||
|
||||
interface Hashtag {
|
||||
id: string;
|
||||
name: string;
|
||||
hashtags: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
hashtag: Hashtag | null;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
hashtags: '',
|
||||
});
|
||||
|
||||
watch(() => props.hashtag, (hashtag) => {
|
||||
if (hashtag) {
|
||||
form.name = hashtag.name;
|
||||
form.hashtags = hashtag.hashtags;
|
||||
form.clearErrors();
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const submit = () => {
|
||||
if (!props.hashtag) return;
|
||||
form.put(hashtagsUpdate.url(props.hashtag.id), {
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('hashtags.edit.title') }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ $t('hashtags.edit.description') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form @submit.prevent="submit" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="edit-name">{{ $t('hashtags.edit.name') }}</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
v-model="form.name"
|
||||
:placeholder="trans('hashtags.edit.name_placeholder')"
|
||||
:class="{ 'border-destructive': form.errors.name }"
|
||||
/>
|
||||
<p v-if="form.errors.name" class="text-sm text-destructive">
|
||||
{{ form.errors.name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="edit-hashtags">{{ $t('hashtags.edit.hashtags') }}</Label>
|
||||
<Textarea
|
||||
id="edit-hashtags"
|
||||
v-model="form.hashtags"
|
||||
:placeholder="trans('hashtags.edit.hashtags_placeholder')"
|
||||
rows="4"
|
||||
:class="{ 'border-destructive': form.errors.hashtags }"
|
||||
/>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('hashtags.edit.hashtags_hint') }}
|
||||
</p>
|
||||
<p v-if="form.errors.hashtags" class="text-sm text-destructive">
|
||||
{{ form.errors.hashtags }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
{{ form.processing ? $t('hashtags.edit.submitting') : $t('hashtags.edit.submit') }}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" @click="open = false">
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
|
||||
interface Hashtag {
|
||||
id: string;
|
||||
name: string;
|
||||
hashtags: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
hashtags: Hashtag[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', hashtag: Hashtag): void;
|
||||
}>();
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
const handleSelect = (hashtag: Hashtag) => {
|
||||
emit('select', hashtag);
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
isOpen.value = true;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CommandDialog v-model:open="isOpen">
|
||||
<CommandInput :placeholder="$t('posts.edit.hashtags_modal.search')" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{{ $t('posts.edit.hashtags_modal.no_results') }}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem v-for="hashtag in hashtags" :key="hashtag.id" :value="hashtag.name"
|
||||
class="flex flex-col items-start gap-1 py-3" @select="handleSelect(hashtag)">
|
||||
<div class="font-medium text-sm">
|
||||
{{ hashtag.name }}
|
||||
</div>
|
||||
<p class="text-xs line-clamp-2 w-full">
|
||||
{{ hashtag.hashtags }}
|
||||
</p>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</template>
|
||||
|
|
@ -17,7 +17,7 @@ import { computed, nextTick, ref } from 'vue';
|
|||
|
||||
import ImagePreviewDialog from '@/components/ImagePreviewDialog.vue';
|
||||
import EmojiPicker from '@/components/posts/EmojiPicker.vue';
|
||||
import HashtagsModal from '@/components/posts/HashtagsModal.vue';
|
||||
import SignaturesModal from '@/components/posts/SignaturesModal.vue';
|
||||
import MediaPickerDialog from '@/components/posts/MediaPickerDialog.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
|
||||
|
|
@ -37,10 +37,10 @@ interface MediaItem {
|
|||
meta?: { width?: number; height?: number; duration?: number };
|
||||
}
|
||||
|
||||
interface Hashtag {
|
||||
interface Signature {
|
||||
id: string;
|
||||
name: string;
|
||||
hashtags: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface PlatformLimit {
|
||||
|
|
@ -54,7 +54,7 @@ interface MediaIssue {
|
|||
}
|
||||
|
||||
const props = defineProps<{
|
||||
hashtags: Hashtag[];
|
||||
signatures: Signature[];
|
||||
platformLimits: PlatformLimit[];
|
||||
mediaIssues: Record<string, MediaIssue[]>;
|
||||
}>();
|
||||
|
|
@ -71,7 +71,7 @@ const isDragging = ref(false);
|
|||
const uploading = ref(false);
|
||||
const emojiOpen = ref(false);
|
||||
const mediaPickerDialog = ref<InstanceType<typeof MediaPickerDialog> | null>(null);
|
||||
const hashtagsModal = ref<InstanceType<typeof HashtagsModal> | null>(null);
|
||||
const signaturesModal = ref<InstanceType<typeof SignaturesModal> | null>(null);
|
||||
|
||||
const dragMediaIndex = ref<number | null>(null);
|
||||
const dragOverIndex = ref<number | null>(null);
|
||||
|
|
@ -198,9 +198,9 @@ const addMediaFromGallery = (picked: MediaItem[]) => {
|
|||
media.value = [...media.value, ...additions];
|
||||
};
|
||||
|
||||
const appendHashtags = (hashtag: Hashtag) => {
|
||||
const appendSignature = (signature: Signature) => {
|
||||
const separator = content.value.trim() ? '\n\n' : '';
|
||||
content.value += separator + hashtag.hashtags;
|
||||
content.value += separator + signature.content;
|
||||
};
|
||||
|
||||
const appendEmoji = (emoji: string) => {
|
||||
|
|
@ -417,12 +417,12 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
|
|||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="size-8 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
@click="hashtagsModal?.open()"
|
||||
@click="signaturesModal?.open()"
|
||||
>
|
||||
<IconHash class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('posts.edit.hashtags') }}</TooltipContent>
|
||||
<TooltipContent>{{ $t('posts.edit.signatures') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
|
|
@ -515,7 +515,7 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<HashtagsModal ref="hashtagsModal" :hashtags="hashtags" @select="appendHashtags" />
|
||||
<SignaturesModal ref="signaturesModal" :signatures="signatures" @select="appendSignature" />
|
||||
<MediaPickerDialog ref="mediaPickerDialog" @select="addMediaFromGallery" />
|
||||
<ImagePreviewDialog
|
||||
:images="previewImages"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ withDefaults(defineProps<{
|
|||
showLegal: false,
|
||||
});
|
||||
|
||||
const slideKeys = ['calendar', 'scheduling', 'media', 'video', 'team', 'hashtags'] as const;
|
||||
const slideKeys = ['calendar', 'scheduling', 'media', 'video', 'team', 'signatures'] as const;
|
||||
|
||||
const slideIcons = {
|
||||
calendar: IconCalendar,
|
||||
|
|
@ -26,7 +26,7 @@ const slideIcons = {
|
|||
media: IconPhoto,
|
||||
video: IconVideo,
|
||||
team: IconUsers,
|
||||
hashtags: IconHash,
|
||||
signatures: IconHash,
|
||||
};
|
||||
|
||||
const slides = computed(() =>
|
||||
|
|
|
|||
|
|
@ -1,188 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, InfiniteScroll, router } from '@inertiajs/vue3';
|
||||
import { IconHash, IconPencil, IconSearch, IconTrash } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import EmptyState from '@/components/EmptyState.vue';
|
||||
import CreateDialog from '@/components/hashtags/CreateDialog.vue';
|
||||
import EditDialog from '@/components/hashtags/EditDialog.vue';
|
||||
import PageHeader from '@/components/PageHeader.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableLoadMore,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import dayjs from '@/dayjs';
|
||||
import debounce from '@/debounce';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { destroy as hashtagsDestroy, index as hashtagsIndex } from '@/routes/app/hashtags';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Hashtag {
|
||||
id: string;
|
||||
name: string;
|
||||
hashtags: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ScrollHashtags {
|
||||
data: Hashtag[];
|
||||
meta: { hasNextPage: boolean };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workspace: Workspace;
|
||||
hashtags: ScrollHashtags;
|
||||
filters: { search: string };
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const searchQuery = ref(props.filters.search);
|
||||
|
||||
const search = debounce(() => {
|
||||
router.get(
|
||||
hashtagsIndex.url(),
|
||||
{ search: searchQuery.value || undefined },
|
||||
{ preserveState: true, preserveScroll: true, reset: ['hashtags'] },
|
||||
);
|
||||
}, 300);
|
||||
|
||||
watch(searchQuery, () => search());
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: trans('hashtags.title') },
|
||||
]);
|
||||
|
||||
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
const isCreateDialogOpen = ref(false);
|
||||
const isEditDialogOpen = ref(false);
|
||||
const editingHashtag = ref<Hashtag | null>(null);
|
||||
|
||||
const openEditDialog = (hashtag: Hashtag) => {
|
||||
editingHashtag.value = hashtag;
|
||||
isEditDialogOpen.value = true;
|
||||
};
|
||||
|
||||
const handleDelete = (hashtagId: string) => {
|
||||
deleteModal.value?.open({ url: hashtagsDestroy.url(hashtagId) });
|
||||
};
|
||||
|
||||
const getHashtagCount = (hashtags: string): number =>
|
||||
hashtags.split(/[\s,]+/).filter((tag) => tag.startsWith('#') || tag.length > 0).length;
|
||||
|
||||
const formatDate = (date: string): string => dayjs.utc(date).local().format('D MMM YYYY');
|
||||
|
||||
const hasActiveSearch = computed(() => Boolean(searchQuery.value?.trim()));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head :title="$t('hashtags.title')" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div class="flex h-full flex-1 flex-col gap-4 p-4">
|
||||
<PageHeader :title="$t('hashtags.title')" />
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="relative">
|
||||
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="trans('hashtags.search')"
|
||||
class="w-64 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button @click="isCreateDialogOpen = true">{{ $t('hashtags.new_group') }}</Button>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-if="hashtags.data.length === 0"
|
||||
:icon="IconHash"
|
||||
:title="hasActiveSearch ? $t('hashtags.no_search_results') : $t('hashtags.no_groups_yet')"
|
||||
:description="hasActiveSearch ? $t('hashtags.try_different_search') : $t('hashtags.no_groups_description')"
|
||||
/>
|
||||
|
||||
<div v-else class="rounded-md border">
|
||||
<InfiniteScroll data="hashtags" items-element="#hashtags-body" preserve-url>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{{ $t('hashtags.table.name') }}</TableHead>
|
||||
<TableHead>{{ $t('hashtags.table.tags') }}</TableHead>
|
||||
<TableHead>{{ $t('hashtags.table.count') }}</TableHead>
|
||||
<TableHead>{{ $t('hashtags.table.created_at') }}</TableHead>
|
||||
<TableHead class="text-right" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody id="hashtags-body">
|
||||
<TableRow
|
||||
v-for="hashtag in hashtags.data"
|
||||
:key="hashtag.id"
|
||||
class="cursor-pointer"
|
||||
@click="openEditDialog(hashtag)"
|
||||
>
|
||||
<TableCell class="font-medium">{{ hashtag.name }}</TableCell>
|
||||
<TableCell class="max-w-md">
|
||||
<p class="truncate text-sm text-muted-foreground">{{ hashtag.hashtags }}</p>
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground tabular-nums">
|
||||
{{ getHashtagCount(hashtag.hashtags) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">{{ formatDate(hashtag.created_at) }}</TableCell>
|
||||
<TableCell class="text-right" @click.stop>
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
@click="openEditDialog(hashtag)"
|
||||
>
|
||||
<IconPencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
@click="handleDelete(hashtag.id)"
|
||||
>
|
||||
<IconTrash class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<template #next="{ loading }">
|
||||
<TableLoadMore v-if="loading" />
|
||||
</template>
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<CreateDialog v-model:open="isCreateDialogOpen" />
|
||||
<EditDialog v-model:open="isEditDialogOpen" :hashtag="editingHashtag" />
|
||||
|
||||
<ConfirmDeleteModal
|
||||
ref="deleteModal"
|
||||
:title="$t('hashtags.delete.title')"
|
||||
:description="$t('hashtags.delete.description')"
|
||||
:action="$t('hashtags.delete.confirm')"
|
||||
:cancel="$t('hashtags.delete.cancel')"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -97,7 +97,7 @@ const props = defineProps<{
|
|||
pinterestBoards: any[];
|
||||
tiktokCreatorInfos?: Record<string, TikTokCreatorInfo> | null;
|
||||
labels: { id: string; name: string; color: string }[];
|
||||
hashtags: { id: string; name: string; hashtags: string }[];
|
||||
signatures: { id: string; name: string; content: string }[];
|
||||
authUserId: string;
|
||||
}>();
|
||||
|
||||
|
|
@ -522,7 +522,7 @@ useEcho(`post.${post.value.id}`, '.PostCommentCreated', (e: any) => {
|
|||
<PostEditorComposer
|
||||
v-model:content="content"
|
||||
v-model:media="media"
|
||||
:hashtags="hashtags"
|
||||
:signatures="signatures"
|
||||
:platform-limits="platformLimits"
|
||||
:media-issues="mediaIssues"
|
||||
@open-ai-generate="isAiGenerateOpen = true"
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\Api\ApiKeyController;
|
||||
use App\Http\Controllers\Api\HashtagController;
|
||||
use App\Http\Controllers\Api\LabelController;
|
||||
use App\Http\Controllers\Api\PostController;
|
||||
use App\Http\Controllers\Api\SignatureController;
|
||||
use App\Http\Controllers\Api\SocialAccountController;
|
||||
use App\Http\Controllers\Api\WorkspaceController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
|
@ -21,11 +21,11 @@
|
|||
// Workspace
|
||||
Route::get('/workspace', [WorkspaceController::class, 'show'])->name('api.workspace.show');
|
||||
|
||||
// Hashtags
|
||||
Route::get('/hashtags', [HashtagController::class, 'index'])->name('api.hashtags.index');
|
||||
Route::post('/hashtags', [HashtagController::class, 'store'])->name('api.hashtags.store');
|
||||
Route::put('/hashtags/{hashtag}', [HashtagController::class, 'update'])->name('api.hashtags.update');
|
||||
Route::delete('/hashtags/{hashtag}', [HashtagController::class, 'destroy'])->name('api.hashtags.destroy');
|
||||
// Signatures
|
||||
Route::get('/signatures', [SignatureController::class, 'index'])->name('api.signatures.index');
|
||||
Route::post('/signatures', [SignatureController::class, 'store'])->name('api.signatures.store');
|
||||
Route::put('/signatures/{signature}', [SignatureController::class, 'update'])->name('api.signatures.update');
|
||||
Route::delete('/signatures/{signature}', [SignatureController::class, 'destroy'])->name('api.signatures.destroy');
|
||||
|
||||
// Labels
|
||||
Route::get('/labels', [LabelController::class, 'index'])->name('api.labels.index');
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@
|
|||
use App\Http\Controllers\App\Settings\UsageController;
|
||||
use App\Http\Controllers\App\UnsplashController;
|
||||
use App\Http\Controllers\App\WorkspaceController;
|
||||
use App\Http\Controllers\App\WorkspaceHashtagController;
|
||||
use App\Http\Controllers\App\WorkspaceInviteController;
|
||||
use App\Http\Controllers\App\WorkspaceLabelController;
|
||||
use App\Http\Controllers\App\WorkspaceSignatureController;
|
||||
use App\Http\Controllers\Auth\BlueskyController;
|
||||
use App\Http\Controllers\Auth\FacebookController;
|
||||
use App\Http\Controllers\Auth\InstagramController;
|
||||
|
|
@ -178,11 +178,11 @@
|
|||
Route::delete('settings/workspace/members/{user}', [WorkspaceInviteController::class, 'removeMember'])->name('app.members.remove');
|
||||
Route::put('settings/workspace/members/{user}/role', [WorkspaceInviteController::class, 'updateRole'])->name('app.members.update-role');
|
||||
|
||||
// Hashtags
|
||||
Route::get('hashtags', [WorkspaceHashtagController::class, 'index'])->name('app.hashtags.index');
|
||||
Route::post('hashtags', [WorkspaceHashtagController::class, 'store'])->name('app.hashtags.store');
|
||||
Route::put('hashtags/{hashtag}', [WorkspaceHashtagController::class, 'update'])->name('app.hashtags.update');
|
||||
Route::delete('hashtags/{hashtag}', [WorkspaceHashtagController::class, 'destroy'])->name('app.hashtags.destroy');
|
||||
// Signatures
|
||||
Route::get('signatures', [WorkspaceSignatureController::class, 'index'])->name('app.signatures.index');
|
||||
Route::post('signatures', [WorkspaceSignatureController::class, 'store'])->name('app.signatures.store');
|
||||
Route::put('signatures/{signature}', [WorkspaceSignatureController::class, 'update'])->name('app.signatures.update');
|
||||
Route::delete('signatures/{signature}', [WorkspaceSignatureController::class, 'destroy'])->name('app.signatures.destroy');
|
||||
|
||||
// Assets
|
||||
Route::get('assets', [AssetController::class, 'index'])->name('app.assets.index');
|
||||
|
|
|
|||
|
|
@ -1,195 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ApiToken;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceHashtag;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @return array{token: ApiToken, plain_token: string, workspace: Workspace}
|
||||
*/
|
||||
function createHashtagApiToken(array $overrides = []): array
|
||||
{
|
||||
$plainToken = 'tp_'.Str::random(48);
|
||||
|
||||
$workspace = data_get($overrides, 'workspace') ?? Workspace::factory()->create();
|
||||
|
||||
$factoryOverrides = collect($overrides)->except('workspace')->toArray();
|
||||
|
||||
$apiToken = ApiToken::factory()->create(array_merge([
|
||||
'workspace_id' => $workspace->id,
|
||||
'token_lookup' => substr($plainToken, 3, 16),
|
||||
'token_hash' => Hash::make($plainToken),
|
||||
], $factoryOverrides));
|
||||
|
||||
return [
|
||||
'token' => $apiToken,
|
||||
'plain_token' => $plainToken,
|
||||
'workspace' => $workspace,
|
||||
];
|
||||
}
|
||||
|
||||
test('list hashtags', function () {
|
||||
$result = createHashtagApiToken();
|
||||
|
||||
WorkspaceHashtag::factory()->count(3)->create([
|
||||
'workspace_id' => $result['workspace']->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$result['plain_token'],
|
||||
])->getJson(
|
||||
route('api.hashtags.index'),
|
||||
['HTTP_HOST' => 'api.trypost.test']
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(3);
|
||||
});
|
||||
|
||||
test('create hashtag', function () {
|
||||
$result = createHashtagApiToken();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$result['plain_token'],
|
||||
])->postJson(
|
||||
route('api.hashtags.store'),
|
||||
[
|
||||
'name' => 'Marketing Tags',
|
||||
'hashtags' => '#marketing #growth #saas',
|
||||
],
|
||||
['HTTP_HOST' => 'api.trypost.test']
|
||||
);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonPath('name', 'Marketing Tags');
|
||||
|
||||
expect($result['workspace']->hashtags()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('create hashtag validation errors', function () {
|
||||
$result = createHashtagApiToken();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$result['plain_token'],
|
||||
])->postJson(
|
||||
route('api.hashtags.store'),
|
||||
[],
|
||||
['HTTP_HOST' => 'api.trypost.test']
|
||||
);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['name', 'hashtags']);
|
||||
});
|
||||
|
||||
test('update hashtag', function () {
|
||||
$result = createHashtagApiToken();
|
||||
|
||||
$hashtag = WorkspaceHashtag::factory()->create([
|
||||
'workspace_id' => $result['workspace']->id,
|
||||
'name' => 'Old Name',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$result['plain_token'],
|
||||
])->putJson(
|
||||
route('api.hashtags.update', $hashtag),
|
||||
[
|
||||
'name' => 'Updated Name',
|
||||
'hashtags' => '#updated #tags',
|
||||
],
|
||||
['HTTP_HOST' => 'api.trypost.test']
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('name', 'Updated Name');
|
||||
});
|
||||
|
||||
test('delete hashtag', function () {
|
||||
$result = createHashtagApiToken();
|
||||
|
||||
$hashtag = WorkspaceHashtag::factory()->create([
|
||||
'workspace_id' => $result['workspace']->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$result['plain_token'],
|
||||
])->deleteJson(
|
||||
route('api.hashtags.destroy', $hashtag),
|
||||
[],
|
||||
['HTTP_HOST' => 'api.trypost.test']
|
||||
);
|
||||
|
||||
$response->assertNoContent();
|
||||
|
||||
expect(WorkspaceHashtag::find($hashtag->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('cannot access hashtags from another workspace', function () {
|
||||
$result = createHashtagApiToken();
|
||||
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$hashtag = WorkspaceHashtag::factory()->create([
|
||||
'workspace_id' => $otherWorkspace->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$result['plain_token'],
|
||||
])->putJson(
|
||||
route('api.hashtags.update', $hashtag),
|
||||
[
|
||||
'name' => 'Hacked Name',
|
||||
'hashtags' => '#hacked',
|
||||
],
|
||||
['HTTP_HOST' => 'api.trypost.test']
|
||||
);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('cannot update hashtag from another workspace', function () {
|
||||
$result = createHashtagApiToken();
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.data_get($result, 'plain_token')])
|
||||
->putJson(route('api.hashtags.update', $hashtag), [
|
||||
'name' => 'Hacked',
|
||||
'hashtags' => '#hacked',
|
||||
])
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
test('update hashtag validation errors', function () {
|
||||
$result = createHashtagApiToken();
|
||||
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => data_get($result, 'workspace')->id]);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.data_get($result, 'plain_token')])
|
||||
->putJson(route('api.hashtags.update', $hashtag), [])
|
||||
->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('list hashtags returns correct structure', function () {
|
||||
$result = createHashtagApiToken();
|
||||
WorkspaceHashtag::factory()->create(['workspace_id' => data_get($result, 'workspace')->id]);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.data_get($result, 'plain_token')])
|
||||
->getJson(route('api.hashtags.index'))
|
||||
->assertOk()
|
||||
->assertJsonStructure([
|
||||
'*' => ['id', 'name', 'hashtags', 'created_at', 'updated_at'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('cannot delete hashtag from another workspace', function () {
|
||||
$result = createHashtagApiToken();
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.data_get($result, 'plain_token')])
|
||||
->deleteJson(route('api.hashtags.destroy', $hashtag))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Mcp\Servers\TryPostServer;
|
||||
use App\Mcp\Tools\Hashtag\CreateHashtagTool;
|
||||
use App\Mcp\Tools\Hashtag\DeleteHashtagTool;
|
||||
use App\Mcp\Tools\Hashtag\ListHashtagsTool;
|
||||
use App\Mcp\Tools\Hashtag\UpdateHashtagTool;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceHashtag;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$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('can list hashtags', function () {
|
||||
WorkspaceHashtag::factory()->count(2)->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(ListHashtagsTool::class, []);
|
||||
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('can create hashtag', function () {
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(CreateHashtagTool::class, [
|
||||
'name' => 'Marketing',
|
||||
'hashtags' => '#marketing #social',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Marketing');
|
||||
expect($this->workspace->hashtags()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('create hashtag validates required fields', function () {
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(CreateHashtagTool::class, []);
|
||||
|
||||
$response->assertHasErrors();
|
||||
});
|
||||
|
||||
test('can update hashtag', function () {
|
||||
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(UpdateHashtagTool::class, [
|
||||
'hashtag_id' => $hashtag->id,
|
||||
'name' => 'Updated',
|
||||
'hashtags' => '#updated',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Updated');
|
||||
});
|
||||
|
||||
test('cannot update hashtag from another workspace', function () {
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(UpdateHashtagTool::class, [
|
||||
'hashtag_id' => $hashtag->id,
|
||||
'name' => 'Hacked',
|
||||
'hashtags' => '#hacked',
|
||||
]);
|
||||
|
||||
$response->assertHasErrors(['Hashtag not found.']);
|
||||
});
|
||||
|
||||
test('can delete hashtag', function () {
|
||||
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(DeleteHashtagTool::class, ['hashtag_id' => $hashtag->id]);
|
||||
|
||||
$response->assertOk();
|
||||
expect(WorkspaceHashtag::find($hashtag->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('cannot delete hashtag from another workspace', function () {
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(DeleteHashtagTool::class, ['hashtag_id' => $hashtag->id]);
|
||||
|
||||
$response->assertHasErrors(['Hashtag not found.']);
|
||||
});
|
||||
|
||||
test('update hashtag validates required fields', function () {
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(UpdateHashtagTool::class, []);
|
||||
|
||||
$response->assertHasErrors();
|
||||
});
|
||||
|
||||
test('delete hashtag validates hashtag_id required', function () {
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(DeleteHashtagTool::class, []);
|
||||
|
||||
$response->assertHasErrors();
|
||||
});
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceHashtag;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create([]);
|
||||
$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]);
|
||||
});
|
||||
|
||||
// Index tests
|
||||
test('hashtags index requires authentication', function () {
|
||||
$response = $this->get(route('app.hashtags.index'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('hashtags index shows hashtags for workspace', function () {
|
||||
WorkspaceHashtag::factory()->count(3)->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.hashtags.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('hashtags/Index', false)
|
||||
->has('workspace')
|
||||
->has('hashtags.data', 3)
|
||||
);
|
||||
});
|
||||
|
||||
test('hashtags index redirects if no workspace', function () {
|
||||
$this->user->update(['current_workspace_id' => null]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.hashtags.index'));
|
||||
|
||||
$response->assertRedirect(route('app.workspaces.create'));
|
||||
});
|
||||
|
||||
// Store tests
|
||||
test('store hashtag requires authentication', function () {
|
||||
$response = $this->post(route('app.hashtags.store'), [
|
||||
'name' => 'Marketing',
|
||||
'hashtags' => '#marketing #digital #growth',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('store hashtag creates hashtag group', function () {
|
||||
$response = $this->actingAs($this->user)->post(route('app.hashtags.store'), [
|
||||
'name' => 'Marketing',
|
||||
'hashtags' => '#marketing #digital #growth',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('app.hashtags.index'));
|
||||
|
||||
$this->assertDatabaseHas('workspace_hashtags', [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'name' => 'Marketing',
|
||||
'hashtags' => '#marketing #digital #growth',
|
||||
]);
|
||||
});
|
||||
|
||||
test('store hashtag validates required fields', function () {
|
||||
$response = $this->actingAs($this->user)->post(route('app.hashtags.store'), [
|
||||
'name' => '',
|
||||
'hashtags' => '',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors(['name', 'hashtags']);
|
||||
});
|
||||
|
||||
// Update tests
|
||||
test('update hashtag requires authentication', function () {
|
||||
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = $this->put(route('app.hashtags.update', $hashtag), [
|
||||
'name' => 'Updated Name',
|
||||
'hashtags' => '#updated #hashtags',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('update hashtag updates the hashtag group', function () {
|
||||
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = $this->actingAs($this->user)->put(route('app.hashtags.update', $hashtag), [
|
||||
'name' => 'Updated Name',
|
||||
'hashtags' => '#updated #hashtags',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('app.hashtags.index'));
|
||||
|
||||
$hashtag->refresh();
|
||||
expect($hashtag->name)->toBe('Updated Name');
|
||||
expect($hashtag->hashtags)->toBe('#updated #hashtags');
|
||||
});
|
||||
|
||||
test('update hashtag returns 404 for other workspace hashtag', function () {
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$response = $this->actingAs($this->user)->put(route('app.hashtags.update', $hashtag), [
|
||||
'name' => 'Updated Name',
|
||||
'hashtags' => '#updated #hashtags',
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// Destroy tests
|
||||
test('destroy hashtag requires authentication', function () {
|
||||
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = $this->delete(route('app.hashtags.destroy', $hashtag));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('destroy hashtag deletes the hashtag group', function () {
|
||||
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = $this->actingAs($this->user)->delete(route('app.hashtags.destroy', $hashtag));
|
||||
|
||||
$response->assertRedirect(route('app.hashtags.index'));
|
||||
expect(WorkspaceHashtag::find($hashtag->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('destroy hashtag returns 404 for other workspace hashtag', function () {
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$response = $this->actingAs($this->user)->delete(route('app.hashtags.destroy', $hashtag));
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('hashtags index filters by search query', function () {
|
||||
WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Marketing']);
|
||||
WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Travel']);
|
||||
WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Food']);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.hashtags.index', ['search' => 'market']));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('hashtags.data', 1)
|
||||
->has('filters')
|
||||
->where('filters.search', 'market')
|
||||
);
|
||||
});
|
||||
|
||||
test('hashtags index returns all when no search query', function () {
|
||||
WorkspaceHashtag::factory()->count(3)->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.hashtags.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('hashtags.data', 3)
|
||||
->where('filters.search', '')
|
||||
);
|
||||
});
|
||||
|
||||
// Member authorization tests
|
||||
test('member can create hashtag', function () {
|
||||
$member = User::factory()->create(['account_id' => $this->workspace->account_id]);
|
||||
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
|
||||
$member->update(['current_workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = $this->actingAs($member)->post(route('app.hashtags.store'), [
|
||||
'name' => 'Test Group',
|
||||
'hashtags' => '#test #hashtag',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
expect($this->workspace->hashtags()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('update hashtag validates required fields', function () {
|
||||
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
$response = $this->actingAs($this->user)->put(route('app.hashtags.update', $hashtag), []);
|
||||
|
||||
$response->assertSessionHasErrors(['name', 'hashtags']);
|
||||
});
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceHashtag;
|
||||
|
||||
test('workspace hashtag belongs to workspace', function () {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $workspace->id]);
|
||||
|
||||
expect($hashtag->workspace->id)->toBe($workspace->id);
|
||||
});
|
||||
|
||||
test('workspace hashtag has fillable attributes', function () {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$hashtag = WorkspaceHashtag::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'name' => 'Marketing',
|
||||
'hashtags' => '#marketing #digital #social',
|
||||
]);
|
||||
|
||||
expect($hashtag->name)->toBe('Marketing');
|
||||
expect($hashtag->hashtags)->toBe('#marketing #digital #social');
|
||||
});
|
||||
|
||||
test('workspace hashtag uses soft deletes', function () {
|
||||
$hashtag = WorkspaceHashtag::factory()->create();
|
||||
$hashtagId = $hashtag->id;
|
||||
|
||||
$hashtag->delete();
|
||||
|
||||
expect(WorkspaceHashtag::find($hashtagId))->toBeNull();
|
||||
expect(WorkspaceHashtag::withTrashed()->find($hashtagId))->not->toBeNull();
|
||||
});
|
||||
Loading…
Reference in a new issue