refactor: replace hashtags functionality with reusable signatures feature

This commit is contained in:
Paulo Castellano 2026-05-03 15:23:30 -03:00
parent 8a2f853fd5
commit b47f2488d0
49 changed files with 72 additions and 1799 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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' => [

View file

@ -38,7 +38,7 @@
'workspace' => [
'connections' => 'Connections',
'hashtags' => 'Hashtags',
'signatures' => 'Signatures',
'labels' => 'Labels',
'assets' => 'Assets',
'api_keys' => 'API Keys',

View file

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

View file

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

View file

@ -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' => [

View file

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

View file

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

View file

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

View file

@ -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' => [

View file

@ -38,7 +38,7 @@
'workspace' => [
'connections' => 'Conexões',
'hashtags' => 'Hashtags',
'signatures' => 'Assinaturas',
'labels' => 'Etiquetas',
'assets' => 'Mídias',
'api_keys' => 'API Keys',

View file

@ -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,
},
{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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