chore: remove deprecated localization files

This commit is contained in:
Paulo Castellano 2026-05-03 15:23:25 -03:00
parent d58af18e82
commit 8a2f853fd5
26 changed files with 1685 additions and 0 deletions

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Actions\Signature;
use App\Models\Workspace;
use App\Models\WorkspaceSignature;
class CreateSignature
{
public static function execute(Workspace $workspace, array $data): WorkspaceSignature
{
return $workspace->signatures()->create([
'name' => data_get($data, 'name'),
'content' => data_get($data, 'content'),
]);
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Actions\Signature;
use App\Models\WorkspaceSignature;
class DeleteSignature
{
public static function execute(WorkspaceSignature $signature): void
{
$signature->delete();
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Actions\Signature;
use App\Models\WorkspaceSignature;
class UpdateSignature
{
public static function execute(WorkspaceSignature $signature, array $data): WorkspaceSignature
{
$signature->update([
'name' => data_get($data, 'name'),
'content' => data_get($data, 'content'),
]);
return $signature;
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Actions\Signature\CreateSignature;
use App\Actions\Signature\DeleteSignature;
use App\Actions\Signature\UpdateSignature;
use App\Http\Requests\Api\Signature\StoreSignatureRequest;
use App\Http\Requests\Api\Signature\UpdateSignatureRequest;
use App\Http\Resources\Api\SignatureResource;
use App\Models\WorkspaceSignature;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Symfony\Component\HttpFoundation\Response;
class SignatureController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$signatures = $request->workspace->signatures()->latest()->get();
return SignatureResource::collection($signatures);
}
public function store(StoreSignatureRequest $request): JsonResponse
{
$signature = CreateSignature::execute($request->workspace, $request->validated());
return (new SignatureResource($signature))
->response()
->setStatusCode(Response::HTTP_CREATED);
}
public function update(UpdateSignatureRequest $request, WorkspaceSignature $signature): SignatureResource
{
if ($signature->workspace_id !== $request->workspace->id) {
abort(Response::HTTP_NOT_FOUND);
}
$signature = UpdateSignature::execute($signature, $request->validated());
return new SignatureResource($signature);
}
public function destroy(Request $request, WorkspaceSignature $signature): JsonResponse
{
if ($signature->workspace_id !== $request->workspace->id) {
abort(Response::HTTP_NOT_FOUND);
}
DeleteSignature::execute($signature);
return response()->json(null, Response::HTTP_NO_CONTENT);
}
}

View file

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Actions\Signature\CreateSignature;
use App\Actions\Signature\DeleteSignature;
use App\Actions\Signature\UpdateSignature;
use App\Models\WorkspaceSignature;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class WorkspaceSignatureController 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);
$signatures = $workspace->signatures()
->when($request->input('search'), fn ($query, $search) => $query->where('name', 'ilike', "%{$search}%"))
->latest()
->paginate(config('app.pagination.default'));
return Inertia::render('signatures/Index', [
'workspace' => $workspace,
'signatures' => Inertia::scroll(fn () => $signatures),
'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'],
'content' => ['required', 'string'],
]);
CreateSignature::execute($workspace, $validated);
session()->flash('flash.banner', __('signatures.flash.created'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.signatures.index');
}
public function update(Request $request, WorkspaceSignature $signature): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('createPost', $workspace);
if ($signature->workspace_id !== $workspace->id) {
abort(404);
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
]);
UpdateSignature::execute($signature, $validated);
session()->flash('flash.banner', __('signatures.flash.updated'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.signatures.index');
}
public function destroy(Request $request, WorkspaceSignature $signature): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('createPost', $workspace);
if ($signature->workspace_id !== $workspace->id) {
abort(404);
}
DeleteSignature::execute($signature);
session()->flash('flash.banner', __('signatures.flash.deleted'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.signatures.index');
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\Signature;
use Illuminate\Foundation\Http\FormRequest;
class StoreSignatureRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
];
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\Signature;
use Illuminate\Foundation\Http\FormRequest;
class UpdateSignatureRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
];
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class SignatureResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'content' => $this->content,
'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

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Signature;
use App\Actions\Signature\CreateSignature;
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 signature with a name and content (hashtags, links, custom text, etc.).')]
class CreateSignatureTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
]);
$signature = CreateSignature::execute($request->user()->currentWorkspace, $validated);
return Response::structured($signature->toArray());
}
public function schema(JsonSchema $schema): array
{
return [
'name' => $schema->string()->required()->description('The signature name.'),
'content' => $schema->string()->required()->description('The signature content (hashtags, links, custom text — anything you want to append to posts).'),
];
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Signature;
use App\Actions\Signature\DeleteSignature;
use App\Models\WorkspaceSignature;
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 signature by ID.')]
class DeleteSignatureTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
{
$signature = WorkspaceSignature::where('workspace_id', $request->user()->current_workspace_id)
->find(data_get($request->validate(['signature_id' => ['required', 'string']]), 'signature_id'));
if (! $signature) {
return Response::error('Signature not found.');
}
DeleteSignature::execute($signature);
return Response::structured(['deleted' => true]);
}
public function schema(JsonSchema $schema): array
{
return [
'signature_id' => $schema->string()->required()->description('The signature ID to delete.'),
];
}
}

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Signature;
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 signatures for the current workspace.')]
class ListSignaturesTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$signatures = $request->user()->currentWorkspace->signatures()->latest()->get();
return Response::structured($signatures->toArray());
}
}

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Signature;
use App\Actions\Signature\UpdateSignature;
use App\Models\WorkspaceSignature;
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 signature name or content.')]
class UpdateSignatureTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->validate([
'signature_id' => ['required', 'string'],
'name' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
]);
$signature = WorkspaceSignature::where('workspace_id', $request->user()->current_workspace_id)
->find(data_get($validated, 'signature_id'));
if (! $signature) {
return Response::error('Signature not found.');
}
$signature = UpdateSignature::execute($signature, $validated);
return Response::structured($signature->toArray());
}
public function schema(JsonSchema $schema): array
{
return [
'signature_id' => $schema->string()->required()->description('The signature ID.'),
'name' => $schema->string()->required()->description('The new name.'),
'content' => $schema->string()->required()->description('The new content (hashtags, links, custom text).'),
];
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\WorkspaceSignatureFactory;
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 WorkspaceSignature extends Model
{
/** @use HasFactory<WorkspaceSignatureFactory> */
use HasFactory, HasUuids, SoftDeletes;
protected $fillable = [
'workspace_id',
'name',
'content',
];
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Workspace;
use App\Models\WorkspaceSignature;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<WorkspaceSignature>
*/
class WorkspaceSignatureFactory 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),
'content' => '#'.implode(' #', fake()->words(5)),
];
}
}

View file

@ -0,0 +1,33 @@
<?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_signatures', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->text('content');
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('workspace_signatures');
}
};

54
lang/en/signatures.php Normal file
View file

@ -0,0 +1,54 @@
<?php
return [
'title' => 'Signatures',
'description' => 'Create reusable signatures to quickly append to your posts',
'search' => 'Search signatures...',
'new' => 'New signature',
'empty_title' => 'No signatures yet',
'empty_description' => 'Create signatures to quickly append hashtags, links, or any reusable text to your posts',
'no_search_results' => 'No signatures match your search',
'try_different_search' => 'Try a different keyword or clear the search.',
'table' => [
'name' => 'Name',
'content' => 'Content',
'created_at' => 'Created',
],
'create' => [
'title' => 'Create signature',
'description' => 'Give your signature a name and the content to append (hashtags, links, custom text — anything you reuse).',
'name' => 'Name',
'name_placeholder' => 'e.g. Marketing, Travel, Brand sign-off',
'content' => 'Content',
'content_placeholder' => "#marketing #socialmedia\nLearn more: https://yourbrand.com",
'content_hint' => 'Hashtags, links, custom intros, signoffs — anything you append to posts.',
'submit' => 'Create signature',
'submitting' => 'Creating...',
],
'edit' => [
'title' => 'Edit signature',
'description' => 'Update the name and content for this signature.',
'name' => 'Name',
'name_placeholder' => 'e.g. Marketing, Travel, Brand sign-off',
'content' => 'Content',
'content_placeholder' => "#marketing #socialmedia\nLearn more: https://yourbrand.com",
'content_hint' => 'Hashtags, links, custom intros, signoffs — anything you append to posts.',
'submit' => 'Save changes',
'submitting' => 'Saving...',
],
'delete' => [
'title' => 'Delete signature',
'description' => 'Are you sure you want to delete this signature? This action cannot be undone.',
'confirm' => 'Delete',
'cancel' => 'Cancel',
],
'flash' => [
'created' => 'Signature created.',
'updated' => 'Signature updated.',
'deleted' => 'Signature deleted.',
],
];

54
lang/es/signatures.php Normal file
View file

@ -0,0 +1,54 @@
<?php
return [
'title' => 'Firmas',
'description' => 'Crea firmas reutilizables para añadir rápidamente a tus posts',
'search' => 'Buscar firmas...',
'new' => 'Nueva firma',
'empty_title' => 'Aún no hay firmas',
'empty_description' => 'Crea firmas para añadir hashtags, links o cualquier texto reutilizable a tus posts',
'no_search_results' => 'Ninguna firma coincide con tu búsqueda',
'try_different_search' => 'Prueba otra palabra clave o limpia la búsqueda.',
'table' => [
'name' => 'Nombre',
'content' => 'Contenido',
'created_at' => 'Creado',
],
'create' => [
'title' => 'Crear firma',
'description' => 'Dale un nombre a tu firma y el contenido para añadir (hashtags, links, texto libre — lo que reutilizas).',
'name' => 'Nombre',
'name_placeholder' => 'ej: Marketing, Viaje, Cierre de marca',
'content' => 'Contenido',
'content_placeholder' => "#marketing #socialmedia\nMás info: https://tumarca.com",
'content_hint' => 'Hashtags, links, intros, cierres — cualquier cosa que añades a los posts.',
'submit' => 'Crear firma',
'submitting' => 'Creando...',
],
'edit' => [
'title' => 'Editar firma',
'description' => 'Actualiza el nombre y el contenido de esta firma.',
'name' => 'Nombre',
'name_placeholder' => 'ej: Marketing, Viaje, Cierre de marca',
'content' => 'Contenido',
'content_placeholder' => "#marketing #socialmedia\nMás info: https://tumarca.com",
'content_hint' => 'Hashtags, links, intros, cierres — cualquier cosa que añades a los posts.',
'submit' => 'Guardar cambios',
'submitting' => 'Guardando...',
],
'delete' => [
'title' => 'Eliminar firma',
'description' => '¿Seguro que quieres eliminar esta firma? Esta acción no se puede deshacer.',
'confirm' => 'Eliminar',
'cancel' => 'Cancelar',
],
'flash' => [
'created' => 'Firma creada.',
'updated' => 'Firma actualizada.',
'deleted' => 'Firma eliminada.',
],
];

54
lang/pt-BR/signatures.php Normal file
View file

@ -0,0 +1,54 @@
<?php
return [
'title' => 'Assinaturas',
'description' => 'Crie assinaturas reutilizáveis pra anexar rapidamente nos seus posts',
'search' => 'Buscar assinaturas...',
'new' => 'Nova assinatura',
'empty_title' => 'Nenhuma assinatura ainda',
'empty_description' => 'Crie assinaturas pra anexar hashtags, links ou qualquer texto reutilizável nos seus posts',
'no_search_results' => 'Nenhuma assinatura corresponde à busca',
'try_different_search' => 'Tente outra palavra-chave ou limpe a busca.',
'table' => [
'name' => 'Nome',
'content' => 'Conteúdo',
'created_at' => 'Criado em',
],
'create' => [
'title' => 'Criar assinatura',
'description' => 'Dê um nome à sua assinatura e o conteúdo pra anexar (hashtags, links, texto livre — o que você reutiliza).',
'name' => 'Nome',
'name_placeholder' => 'ex: Marketing, Viagem, Encerramento da marca',
'content' => 'Conteúdo',
'content_placeholder' => "#marketing #socialmedia\nSaiba mais: https://suamarca.com",
'content_hint' => 'Hashtags, links, intros, assinaturas — qualquer coisa que você anexa nos posts.',
'submit' => 'Criar assinatura',
'submitting' => 'Criando...',
],
'edit' => [
'title' => 'Editar assinatura',
'description' => 'Atualize o nome e o conteúdo desta assinatura.',
'name' => 'Nome',
'name_placeholder' => 'ex: Marketing, Viagem, Encerramento da marca',
'content' => 'Conteúdo',
'content_placeholder' => "#marketing #socialmedia\nSaiba mais: https://suamarca.com",
'content_hint' => 'Hashtags, links, intros, assinaturas — qualquer coisa que você anexa nos posts.',
'submit' => 'Salvar alterações',
'submitting' => 'Salvando...',
],
'delete' => [
'title' => 'Deletar assinatura',
'description' => 'Tem certeza que quer deletar esta assinatura? Esta ação não pode ser desfeita.',
'confirm' => 'Deletar',
'cancel' => 'Cancelar',
],
'flash' => [
'created' => 'Assinatura criada.',
'updated' => 'Assinatura atualizada.',
'deleted' => 'Assinatura deletada.',
],
];

View file

@ -0,0 +1,68 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
interface Signature {
id: string;
name: string;
content: string;
}
defineProps<{
signatures: Signature[];
}>();
const emit = defineEmits<{
(e: 'select', signature: Signature): void;
}>();
const isOpen = ref(false);
const handleSelect = (signature: Signature) => {
emit('select', signature);
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.signatures_modal.search')" />
<CommandList>
<CommandEmpty>
{{ $t('posts.edit.signatures_modal.no_results') }}
</CommandEmpty>
<CommandGroup>
<CommandItem v-for="signature in signatures" :key="signature.id" :value="signature.name"
class="flex flex-col items-start gap-1 py-3" @select="handleSelect(signature)">
<div class="font-medium text-sm">
{{ signature.name }}
</div>
<p class="text-xs line-clamp-2 w-full">
{{ signature.content }}
</p>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
</template>

View file

@ -0,0 +1,95 @@
<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 signaturesStore } from '@/routes/app/signatures';
const open = defineModel<boolean>('open', { default: false });
const form = useForm({
name: '',
content: '',
});
const submit = () => {
form.post(signaturesStore.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('signatures.create.title') }}</DialogTitle>
<DialogDescription>
{{ $t('signatures.create.description') }}
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submit" class="space-y-4">
<div class="grid gap-2">
<Label for="create-name">{{ $t('signatures.create.name') }}</Label>
<Input
id="create-name"
v-model="form.name"
:placeholder="trans('signatures.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="grid gap-2">
<Label for="create-content">{{ $t('signatures.create.content') }}</Label>
<Textarea
id="create-content"
v-model="form.content"
:placeholder="trans('signatures.create.content_placeholder')"
rows="4"
:class="{ 'border-destructive': form.errors.content }"
/>
<p class="text-sm text-muted-foreground">
{{ $t('signatures.create.content_hint') }}
</p>
<p v-if="form.errors.content" class="text-sm text-destructive">
{{ form.errors.content }}
</p>
</div>
<DialogFooter>
<Button type="submit" :disabled="form.processing">
{{ form.processing ? $t('signatures.create.submitting') : $t('signatures.create.submit') }}
</Button>
<Button type="button" variant="outline" @click="open = false">
{{ $t('common.cancel') }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,106 @@
<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 signaturesUpdate } from '@/routes/app/signatures';
interface Signature {
id: string;
name: string;
content: string;
}
const props = defineProps<{
signature: Signature | null;
}>();
const open = defineModel<boolean>('open', { default: false });
const form = useForm({
name: '',
content: '',
});
watch(() => props.signature, (signature) => {
if (signature) {
form.name = signature.name;
form.content = signature.content;
form.clearErrors();
}
}, { immediate: true });
const submit = () => {
if (!props.signature) return;
form.put(signaturesUpdate.url(props.signature.id), {
onSuccess: () => {
open.value = false;
},
});
};
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ $t('signatures.edit.title') }}</DialogTitle>
<DialogDescription>
{{ $t('signatures.edit.description') }}
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submit" class="space-y-4">
<div class="grid gap-2">
<Label for="edit-name">{{ $t('signatures.edit.name') }}</Label>
<Input
id="edit-name"
v-model="form.name"
:placeholder="trans('signatures.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="grid gap-2">
<Label for="edit-content">{{ $t('signatures.edit.content') }}</Label>
<Textarea
id="edit-content"
v-model="form.content"
:placeholder="trans('signatures.edit.content_placeholder')"
rows="4"
:class="{ 'border-destructive': form.errors.content }"
/>
<p class="text-sm text-muted-foreground">
{{ $t('signatures.edit.content_hint') }}
</p>
<p v-if="form.errors.content" class="text-sm text-destructive">
{{ form.errors.content }}
</p>
</div>
<DialogFooter>
<Button type="submit" :disabled="form.processing">
{{ form.processing ? $t('signatures.edit.submitting') : $t('signatures.edit.submit') }}
</Button>
<Button type="button" variant="outline" @click="open = false">
{{ $t('common.cancel') }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,181 @@
<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 PageHeader from '@/components/PageHeader.vue';
import CreateDialog from '@/components/signatures/CreateDialog.vue';
import EditDialog from '@/components/signatures/EditDialog.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 signaturesDestroy, index as signaturesIndex } from '@/routes/app/signatures';
import type { BreadcrumbItem } from '@/types';
interface Workspace {
id: string;
name: string;
}
interface Signature {
id: string;
name: string;
content: string;
created_at: string;
}
interface ScrollSignatures {
data: Signature[];
meta: { hasNextPage: boolean };
}
interface Props {
workspace: Workspace;
signatures: ScrollSignatures;
filters: { search: string };
}
const props = defineProps<Props>();
const searchQuery = ref(props.filters.search);
const search = debounce(() => {
router.get(
signaturesIndex.url(),
{ search: searchQuery.value || undefined },
{ preserveState: true, preserveScroll: true, reset: ['signatures'] },
);
}, 300);
watch(searchQuery, () => search());
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: trans('signatures.title') },
]);
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const isCreateDialogOpen = ref(false);
const isEditDialogOpen = ref(false);
const editingSignature = ref<Signature | null>(null);
const openEditDialog = (signature: Signature) => {
editingSignature.value = signature;
isEditDialogOpen.value = true;
};
const handleDelete = (signatureId: string) => {
deleteModal.value?.open({ url: signaturesDestroy.url(signatureId) });
};
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('signatures.title')" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex h-full flex-1 flex-col gap-4 p-4">
<PageHeader :title="$t('signatures.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('signatures.search')"
class="w-64 pl-9"
/>
</div>
<Button @click="isCreateDialogOpen = true">{{ $t('signatures.new') }}</Button>
</div>
<EmptyState
v-if="signatures.data.length === 0"
:icon="IconHash"
:title="hasActiveSearch ? $t('signatures.no_search_results') : $t('signatures.empty_title')"
:description="hasActiveSearch ? $t('signatures.try_different_search') : $t('signatures.empty_description')"
/>
<div v-else class="rounded-md border">
<InfiniteScroll data="signatures" items-element="#signatures-body" preserve-url>
<Table>
<TableHeader>
<TableRow>
<TableHead>{{ $t('signatures.table.name') }}</TableHead>
<TableHead>{{ $t('signatures.table.content') }}</TableHead>
<TableHead>{{ $t('signatures.table.created_at') }}</TableHead>
<TableHead class="text-right" />
</TableRow>
</TableHeader>
<TableBody id="signatures-body">
<TableRow
v-for="signature in signatures.data"
:key="signature.id"
class="cursor-pointer"
@click="openEditDialog(signature)"
>
<TableCell class="font-medium">{{ signature.name }}</TableCell>
<TableCell class="max-w-md">
<p class="truncate text-sm text-muted-foreground">{{ signature.content }}</p>
</TableCell>
<TableCell class="text-muted-foreground">{{ formatDate(signature.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(signature)"
>
<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(signature.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" :signature="editingSignature" />
<ConfirmDeleteModal
ref="deleteModal"
:title="$t('signatures.delete.title')"
:description="$t('signatures.delete.description')"
:action="$t('signatures.delete.confirm')"
:cancel="$t('signatures.delete.cancel')"
/>
</template>

View file

@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
use App\Models\ApiToken;
use App\Models\Workspace;
use App\Models\WorkspaceSignature;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @return array{token: ApiToken, plain_token: string, workspace: Workspace}
*/
function createSignatureApiToken(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 signatures', function () {
$result = createSignatureApiToken();
WorkspaceSignature::factory()->count(3)->create([
'workspace_id' => $result['workspace']->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$result['plain_token'],
])->getJson(
route('api.signatures.index'),
['HTTP_HOST' => 'api.trypost.test']
);
$response->assertOk();
$response->assertJsonCount(3);
});
test('create signature', function () {
$result = createSignatureApiToken();
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$result['plain_token'],
])->postJson(
route('api.signatures.store'),
[
'name' => 'Marketing Tags',
'content' => '#marketing #growth #saas',
],
['HTTP_HOST' => 'api.trypost.test']
);
$response->assertCreated();
$response->assertJsonPath('name', 'Marketing Tags');
expect($result['workspace']->signatures()->count())->toBe(1);
});
test('create signature validation errors', function () {
$result = createSignatureApiToken();
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$result['plain_token'],
])->postJson(
route('api.signatures.store'),
[],
['HTTP_HOST' => 'api.trypost.test']
);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['name', 'content']);
});
test('update signature', function () {
$result = createSignatureApiToken();
$signature = WorkspaceSignature::factory()->create([
'workspace_id' => $result['workspace']->id,
'name' => 'Old Name',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$result['plain_token'],
])->putJson(
route('api.signatures.update', $signature),
[
'name' => 'Updated Name',
'content' => '#updated #tags',
],
['HTTP_HOST' => 'api.trypost.test']
);
$response->assertOk();
$response->assertJsonPath('name', 'Updated Name');
});
test('delete signature', function () {
$result = createSignatureApiToken();
$signature = WorkspaceSignature::factory()->create([
'workspace_id' => $result['workspace']->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$result['plain_token'],
])->deleteJson(
route('api.signatures.destroy', $signature),
[],
['HTTP_HOST' => 'api.trypost.test']
);
$response->assertNoContent();
expect(WorkspaceSignature::find($signature->id))->toBeNull();
});
test('cannot access signatures from another workspace', function () {
$result = createSignatureApiToken();
$otherWorkspace = Workspace::factory()->create();
$signature = WorkspaceSignature::factory()->create([
'workspace_id' => $otherWorkspace->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$result['plain_token'],
])->putJson(
route('api.signatures.update', $signature),
[
'name' => 'Hacked Name',
'content' => '#hacked',
],
['HTTP_HOST' => 'api.trypost.test']
);
$response->assertNotFound();
});
test('cannot update signature from another workspace', function () {
$result = createSignatureApiToken();
$otherWorkspace = Workspace::factory()->create();
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $otherWorkspace->id]);
$this->withHeaders(['Authorization' => 'Bearer '.data_get($result, 'plain_token')])
->putJson(route('api.signatures.update', $signature), [
'name' => 'Hacked',
'content' => '#hacked',
])
->assertNotFound();
});
test('update signature validation errors', function () {
$result = createSignatureApiToken();
$signature = WorkspaceSignature::factory()->create(['workspace_id' => data_get($result, 'workspace')->id]);
$this->withHeaders(['Authorization' => 'Bearer '.data_get($result, 'plain_token')])
->putJson(route('api.signatures.update', $signature), [])
->assertUnprocessable();
});
test('list signatures returns correct structure', function () {
$result = createSignatureApiToken();
WorkspaceSignature::factory()->create(['workspace_id' => data_get($result, 'workspace')->id]);
$this->withHeaders(['Authorization' => 'Bearer '.data_get($result, 'plain_token')])
->getJson(route('api.signatures.index'))
->assertOk()
->assertJsonStructure([
'*' => ['id', 'name', 'content', 'created_at', 'updated_at'],
]);
});
test('cannot delete signature from another workspace', function () {
$result = createSignatureApiToken();
$otherWorkspace = Workspace::factory()->create();
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $otherWorkspace->id]);
$this->withHeaders(['Authorization' => 'Bearer '.data_get($result, 'plain_token')])
->deleteJson(route('api.signatures.destroy', $signature))
->assertNotFound();
});

View file

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Mcp\Servers\TryPostServer;
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\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceSignature;
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 signatures', function () {
WorkspaceSignature::factory()->count(2)->create(['workspace_id' => $this->workspace->id]);
$response = TryPostServer::actingAs($this->user)
->tool(ListSignaturesTool::class, []);
$response->assertOk();
});
test('can create signature', function () {
$response = TryPostServer::actingAs($this->user)
->tool(CreateSignatureTool::class, [
'name' => 'Marketing',
'content' => '#marketing #social',
]);
$response->assertOk();
$response->assertSee('Marketing');
expect($this->workspace->signatures()->count())->toBe(1);
});
test('create signature validates required fields', function () {
$response = TryPostServer::actingAs($this->user)
->tool(CreateSignatureTool::class, []);
$response->assertHasErrors();
});
test('can update signature', function () {
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id]);
$response = TryPostServer::actingAs($this->user)
->tool(UpdateSignatureTool::class, [
'signature_id' => $signature->id,
'name' => 'Updated',
'content' => '#updated',
]);
$response->assertOk();
$response->assertSee('Updated');
});
test('cannot update signature from another workspace', function () {
$otherWorkspace = Workspace::factory()->create();
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $otherWorkspace->id]);
$response = TryPostServer::actingAs($this->user)
->tool(UpdateSignatureTool::class, [
'signature_id' => $signature->id,
'name' => 'Hacked',
'content' => '#hacked',
]);
$response->assertHasErrors(['Signature not found.']);
});
test('can delete signature', function () {
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id]);
$response = TryPostServer::actingAs($this->user)
->tool(DeleteSignatureTool::class, ['signature_id' => $signature->id]);
$response->assertOk();
expect(WorkspaceSignature::find($signature->id))->toBeNull();
});
test('cannot delete signature from another workspace', function () {
$otherWorkspace = Workspace::factory()->create();
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $otherWorkspace->id]);
$response = TryPostServer::actingAs($this->user)
->tool(DeleteSignatureTool::class, ['signature_id' => $signature->id]);
$response->assertHasErrors(['Signature not found.']);
});
test('update signature validates required fields', function () {
$response = TryPostServer::actingAs($this->user)
->tool(UpdateSignatureTool::class, []);
$response->assertHasErrors();
});
test('delete signature validates signature_id required', function () {
$response = TryPostServer::actingAs($this->user)
->tool(DeleteSignatureTool::class, []);
$response->assertHasErrors();
});

View file

@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceSignature;
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('signatures index requires authentication', function () {
$response = $this->get(route('app.signatures.index'));
$response->assertRedirect(route('login'));
});
test('signatures index shows signatures for workspace', function () {
WorkspaceSignature::factory()->count(3)->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->get(route('app.signatures.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('signatures/Index', false)
->has('workspace')
->has('signatures.data', 3)
);
});
test('signatures index redirects if no workspace', function () {
$this->user->update(['current_workspace_id' => null]);
$response = $this->actingAs($this->user)->get(route('app.signatures.index'));
$response->assertRedirect(route('app.workspaces.create'));
});
// Store tests
test('store signature requires authentication', function () {
$response = $this->post(route('app.signatures.store'), [
'name' => 'Marketing',
'content' => '#marketing #digital #growth',
]);
$response->assertRedirect(route('login'));
});
test('store signature creates signature', function () {
$response = $this->actingAs($this->user)->post(route('app.signatures.store'), [
'name' => 'Marketing',
'content' => '#marketing #digital #growth',
]);
$response->assertRedirect(route('app.signatures.index'));
$this->assertDatabaseHas('workspace_signatures', [
'workspace_id' => $this->workspace->id,
'name' => 'Marketing',
'content' => '#marketing #digital #growth',
]);
});
test('store signature validates required fields', function () {
$response = $this->actingAs($this->user)->post(route('app.signatures.store'), [
'name' => '',
'content' => '',
]);
$response->assertSessionHasErrors(['name', 'content']);
});
// Update tests
test('update signature requires authentication', function () {
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id]);
$response = $this->put(route('app.signatures.update', $signature), [
'name' => 'Updated Name',
'content' => '#updated #content',
]);
$response->assertRedirect(route('login'));
});
test('update signature updates the signature', function () {
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->put(route('app.signatures.update', $signature), [
'name' => 'Updated Name',
'content' => '#updated #content',
]);
$response->assertRedirect(route('app.signatures.index'));
$signature->refresh();
expect($signature->name)->toBe('Updated Name');
expect($signature->content)->toBe('#updated #content');
});
test('update signature returns 404 for other workspace signature', function () {
$otherWorkspace = Workspace::factory()->create();
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $otherWorkspace->id]);
$response = $this->actingAs($this->user)->put(route('app.signatures.update', $signature), [
'name' => 'Updated Name',
'content' => '#updated #content',
]);
$response->assertNotFound();
});
// Destroy tests
test('destroy signature requires authentication', function () {
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id]);
$response = $this->delete(route('app.signatures.destroy', $signature));
$response->assertRedirect(route('login'));
});
test('destroy signature deletes the signature', function () {
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->delete(route('app.signatures.destroy', $signature));
$response->assertRedirect(route('app.signatures.index'));
expect(WorkspaceSignature::find($signature->id))->toBeNull();
});
test('destroy signature returns 404 for other workspace signature', function () {
$otherWorkspace = Workspace::factory()->create();
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $otherWorkspace->id]);
$response = $this->actingAs($this->user)->delete(route('app.signatures.destroy', $signature));
$response->assertNotFound();
});
test('signatures index filters by search query', function () {
WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Marketing']);
WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Travel']);
WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Food']);
$response = $this->actingAs($this->user)->get(route('app.signatures.index', ['search' => 'market']));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('signatures.data', 1)
->has('filters')
->where('filters.search', 'market')
);
});
test('signatures index returns all when no search query', function () {
WorkspaceSignature::factory()->count(3)->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->get(route('app.signatures.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('signatures.data', 3)
->where('filters.search', '')
);
});
// Member authorization tests
test('member can create signature', 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.signatures.store'), [
'name' => 'Test Signature',
'content' => '#test #signature',
]);
$response->assertRedirect();
expect($this->workspace->signatures()->count())->toBe(1);
});
test('update signature validates required fields', function () {
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->put(route('app.signatures.update', $signature), []);
$response->assertSessionHasErrors(['name', 'content']);
});

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Models\Workspace;
use App\Models\WorkspaceSignature;
test('workspace signature belongs to workspace', function () {
$workspace = Workspace::factory()->create();
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $workspace->id]);
expect($signature->workspace->id)->toBe($workspace->id);
});
test('workspace signature has fillable attributes', function () {
$workspace = Workspace::factory()->create();
$signature = WorkspaceSignature::factory()->create([
'workspace_id' => $workspace->id,
'name' => 'Marketing',
'content' => '#marketing #digital #social',
]);
expect($signature->name)->toBe('Marketing');
expect($signature->content)->toBe('#marketing #digital #social');
});
test('workspace signature uses soft deletes', function () {
$signature = WorkspaceSignature::factory()->create();
$signatureId = $signature->id;
$signature->delete();
expect(WorkspaceSignature::find($signatureId))->toBeNull();
expect(WorkspaceSignature::withTrashed()->find($signatureId))->not->toBeNull();
});