From 9f970808cac48a1eeaf492089874f6315832f92c Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 1 Oct 2026 11:01:18 +0200 Subject: [PATCH] Tag blog posts and sign them with a member Tags are named in every language and made from the page form; the byline is the member who signs a post on the site. The public API sends both, filters posts by tag and lists the tags in use. Co-Authored-By: Claude Opus 5.5 --- README.md | 16 ++ .../Resources/Pages/Schemas/PageForm.php | 34 +++++ .../Resources/Tags/Pages/ManageTags.php | 21 +++ app/Filament/Resources/Tags/TagResource.php | 108 ++++++++++++++ .../Api/PublicApi/V1/PageController.php | 36 ++++- .../Controllers/Api/V1/PageController.php | 25 +++- app/Http/Controllers/Api/V1/TagController.php | 82 ++++++++++ app/Http/Requests/Api/V1/PageRequest.php | 4 + app/Http/Resources/PageResource.php | 2 + .../PublicApi/PublicPageResource.php | 24 +++ app/Models/Page.php | 22 ++- app/Models/Tag.php | 37 +++++ app/Policies/TagPolicy.php | 17 +++ app/Providers/AuthServiceProvider.php | 3 + app/Support/Audit/Auditor.php | 2 + app/Support/Duplicator.php | 2 +- database/factories/TagFactory.php | 25 ++++ ...1_130000_add_tags_and_bylines_to_pages.php | 44 ++++++ lang/ca/panel.php | 12 ++ lang/en/panel.php | 12 ++ lang/es/panel.php | 12 ++ public/api-types.d.ts | 9 ++ public/openapi.yaml | 141 ++++++++++++++++++ routes/api.php | 3 + tests/Feature/Api/AdminApiContractTest.php | 3 + tests/Feature/Api/BlogTagsTest.php | 120 +++++++++++++++ tests/Feature/Api/PublicApiContractTest.php | 5 +- 27 files changed, 809 insertions(+), 12 deletions(-) create mode 100644 app/Filament/Resources/Tags/Pages/ManageTags.php create mode 100644 app/Filament/Resources/Tags/TagResource.php create mode 100644 app/Http/Controllers/Api/V1/TagController.php create mode 100644 app/Models/Tag.php create mode 100644 app/Policies/TagPolicy.php create mode 100644 database/factories/TagFactory.php create mode 100644 database/migrations/2026_10_01_130000_add_tags_and_bylines_to_pages.php create mode 100644 tests/Feature/Api/BlogTagsTest.php diff --git a/README.md b/README.md index 795835e..273a6fd 100644 --- a/README.md +++ b/README.md @@ -663,6 +663,22 @@ secreto viaje sin acabar en el navegador de cualquiera. Un frontend que renderizara en cliente necesitaría otra cosa —un token de preview por sesión y con caducidad—, no este secreto compartido. +## Etiquetas y firma del blog + +Las entradas del blog (páginas de la categoría `blog`) llevan **etiquetas** +—«angular», «accesibilidad»— con nombre y slug en cada idioma, que se eligen en +el formulario de la página y se crean desde ahí mismo si no existen (Contenido → +Etiquetas para ordenarlas; las gestiona quien gestiona las categorías). Y una +**firma**: el miembro que la firma en la web, con enlace a su perfil, que no +tiene por qué ser la cuenta que la escribió en el panel (`author_id`, que se +queda dentro). + +La API pública manda `tags` (solo las que tienen nombre en ese idioma) y +`byline` (solo si el miembro tiene perfil público), acepta `?tag=` en +`/public/v1/pages` y tiene `/public/v1/tags` con cuántas entradas publicadas +lleva cada una. La de administración, `tag_ids` y `byline_member_id` en las +páginas y `/api/v1/tags`. + ## La vitrina: servicios, clientes, testimonios y charlas Cuatro tipos de contenido con los que se compone una landing o un portfolio. diff --git a/app/Filament/Resources/Pages/Schemas/PageForm.php b/app/Filament/Resources/Pages/Schemas/PageForm.php index 357801b..7115857 100644 --- a/app/Filament/Resources/Pages/Schemas/PageForm.php +++ b/app/Filament/Resources/Pages/Schemas/PageForm.php @@ -9,6 +9,7 @@ use App\Filament\Resources\Pages\PageResource; use App\Filament\Support\TranslatableTabs; use App\Filament\Support\TranslatedOptionLabel; use App\Models\Page; +use App\Models\Tag; use App\Rules\UniqueTranslatedSlug; use App\Support\DuplicateTitles; use Filament\Forms\Components\DateTimePicker; @@ -126,6 +127,39 @@ class PageForm ->searchable() ->preload(), + // What the post is about, for the blog to be read by + // subject. A missing one is made from here. + Select::make('tags') + ->label(__('panel.tags.plural')) + ->relationship('tags', 'name->'.config('locales.default')) + ->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('name')) + ->multiple() + ->searchable() + ->preload() + ->createOptionForm([ + TextInput::make('name') + ->label(__('panel.tags.fields.name')) + ->required() + ->maxLength(80), + ]) + ->createOptionUsing(function (array $data): int { + $locale = (string) config('locales.default'); + + return Tag::query()->create([ + 'name' => [$locale => $data['name']], + 'slug' => [$locale => Str::slug($data['name'])], + ])->getKey(); + }) + ->createOptionAction(fn ($action) => $action->authorize('create', Tag::class)), + + // Who signs it on the site, with a link to their profile. + Select::make('byline_member_id') + ->label(__('panel.pages.fields.byline')) + ->helperText(__('panel.pages.fields.byline_hint')) + ->relationship('byline', 'name') + ->searchable() + ->preload(), + Select::make('author_id') ->label(__('panel.pages.fields.author')) ->relationship('author', 'name') diff --git a/app/Filament/Resources/Tags/Pages/ManageTags.php b/app/Filament/Resources/Tags/Pages/ManageTags.php new file mode 100644 index 0000000..d696b6a --- /dev/null +++ b/app/Filament/Resources/Tags/Pages/ManageTags.php @@ -0,0 +1,21 @@ +getTranslation('name', app()->getLocale()); + } + + public static function form(Schema $schema): Schema + { + return $schema->components([ + TranslatableTabs::make(fn (string $locale, bool $isDefault) => [ + TextInput::make("name.{$locale}") + ->label(__('panel.tags.fields.name')) + ->required($isDefault) + ->maxLength(80) + ->live(onBlur: true) + ->afterStateUpdated(function (?string $state, callable $get, callable $set) use ($locale): void { + if (blank($get("slug.{$locale}"))) { + $set("slug.{$locale}", Str::slug((string) $state)); + } + }), + + TextInput::make("slug.{$locale}") + ->label(__('panel.tags.fields.slug')) + ->required($isDefault) + ->maxLength(80) + ->rules(fn ($record) => [new UniqueTranslatedSlug(Tag::class, $record?->id)]), + ])->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->modifyQueryUsing(fn ($query) => $query->withCount('pages')) + ->columns([ + TextColumn::make('name') + ->label(__('panel.tags.fields.name')) + ->searchable(), + + TextColumn::make('slug') + ->label(__('panel.tags.fields.slug')) + ->color('gray'), + + TextColumn::make('pages_count') + ->label(__('panel.tags.fields.pages')) + ->sortable(), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make(), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => ManageTags::route('/'), + ]; + } +} diff --git a/app/Http/Controllers/Api/PublicApi/V1/PageController.php b/app/Http/Controllers/Api/PublicApi/V1/PageController.php index 8524b8e..aaab7fc 100644 --- a/app/Http/Controllers/Api/PublicApi/V1/PageController.php +++ b/app/Http/Controllers/Api/PublicApi/V1/PageController.php @@ -6,7 +6,9 @@ namespace App\Http\Controllers\Api\PublicApi\V1; use App\Http\Resources\PublicApi\PublicPageResource; use App\Models\Page; +use App\Models\Tag; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -15,12 +17,15 @@ class PageController extends PublicController public function index(Request $request): AnonymousResourceCollection { $pages = Page::query() - ->with(['category', 'featuredMedia']) + ->with(['category', 'featuredMedia', 'tags', 'byline.avatarMedia']) ->unless($this->isPreview($request), fn (Builder $q) => $q->published()) ->when($request->filled('category'), fn (Builder $q) => $q ->whereHas('category', fn (Builder $c) => $c ->where('slug->'.app()->getLocale(), $request->string('category')))) ->when($request->boolean('featured'), fn (Builder $q) => $q->where('is_featured', true)) + ->when($request->filled('tag'), fn (Builder $q) => $q + ->whereHas('tags', fn (Builder $t) => $t + ->where('slug->'.app()->getLocale(), $request->string('tag')))) ->orderByDesc('published_at') ->paginate($this->perPage($request)) ->withQueryString(); @@ -31,11 +36,38 @@ class PageController extends PublicController public function show(Request $request, string $slug): PublicPageResource { $page = Page::query() - ->with(['category', 'featuredMedia']) + ->with(['category', 'featuredMedia', 'tags', 'byline.avatarMedia']) ->whereSlug($slug) ->unless($this->isPreview($request), fn (Builder $q) => $q->published()) ->firstOrFail(); return PublicPageResource::make($page); } + + /** + * The tags used by something published, with how many posts each has in + * the request's language — for a blog to offer them as a way in. + */ + public function tags(Request $request): JsonResponse + { + $locale = app()->getLocale(); + $published = fn (Builder $q) => $q + ->unless($this->isPreview($request), fn (Builder $q) => $q->published()) + ->whereNotNull("slug->{$locale}"); + + $tags = Tag::query() + ->whereHas('pages', $published) + ->withCount(['pages' => $published]) + ->get() + ->filter(fn (Tag $tag) => filled($tag->getTranslation('slug', $locale, false))) + ->sortByDesc('pages_count') + ->map(fn (Tag $tag) => [ + 'name' => $tag->getTranslation('name', $locale), + 'slug' => $tag->getTranslation('slug', $locale, false), + 'count' => (int) $tag->pages_count, + ]) + ->values(); + + return response()->json(['data' => $tags]); + } } diff --git a/app/Http/Controllers/Api/V1/PageController.php b/app/Http/Controllers/Api/V1/PageController.php index 7f42958..a9a4cd6 100644 --- a/app/Http/Controllers/Api/V1/PageController.php +++ b/app/Http/Controllers/Api/V1/PageController.php @@ -17,6 +17,8 @@ use Illuminate\Http\Resources\Json\AnonymousResourceCollection; class PageController extends Controller { + protected const WITH = ['category', 'author', 'featuredMedia', 'tags']; + /** * GET /api/v1/pages * @@ -30,7 +32,7 @@ class PageController extends Controller $locale = app()->getLocale(); $pages = Page::query() - ->with(['category', 'author', 'featuredMedia']) + ->with(self::WITH) ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) ->when($request->filled('category_id'), fn ($q) => $q->where('category_id', $request->integer('category_id'))) ->when($request->has('is_featured'), fn ($q) => $q->where('is_featured', $request->boolean('is_featured'))) @@ -57,7 +59,7 @@ class PageController extends Controller $this->authorize('create', Page::class); $page = new Page; - $page->fill(TranslatablePayload::merge($page, $request->validated(), $page->translatable)); + $page->fill(TranslatablePayload::merge($page, collect($request->validated())->except('tag_ids')->all(), $page->translatable)); $page->author_id ??= $request->user()->id; // A page created as published with no explicit date goes live now. @@ -66,8 +68,9 @@ class PageController extends Controller } $page->save(); + $this->syncTags($page, $request); - return PageResource::make($page->load(['category', 'author', 'featuredMedia'])) + return PageResource::make($page->load(self::WITH)) ->response() ->setStatusCode(201); } @@ -76,22 +79,23 @@ class PageController extends Controller { $this->authorize('view', $page); - return PageResource::make($page->load(['category', 'author', 'featuredMedia'])); + return PageResource::make($page->load(self::WITH)); } public function update(PageRequest $request, Page $page): PageResource { $this->authorize('update', $page); - $page->fill(TranslatablePayload::merge($page, $request->validated(), $page->translatable)); + $page->fill(TranslatablePayload::merge($page, collect($request->validated())->except('tag_ids')->all(), $page->translatable)); if ($page->status === PageStatus::Published && $page->published_at === null) { $page->published_at = now(); } $page->save(); + $this->syncTags($page, $request); - return PageResource::make($page->load(['category', 'author', 'featuredMedia'])); + return PageResource::make($page->load(self::WITH)); } public function destroy(Page $page): JsonResponse @@ -109,7 +113,7 @@ class PageController extends Controller $page->restore(); - return PageResource::make($page->load(['category', 'author', 'featuredMedia'])); + return PageResource::make($page->load(self::WITH)); } /** @@ -135,4 +139,11 @@ class PageController extends Controller $query->orderBy(in_array($column, $sortable, true) ? $column : 'created_at', $direction); } + + protected function syncTags(Page $page, PageRequest $request): void + { + if ($request->has('tag_ids')) { + $page->tags()->sync($request->validated('tag_ids') ?? []); + } + } } diff --git a/app/Http/Controllers/Api/V1/TagController.php b/app/Http/Controllers/Api/V1/TagController.php new file mode 100644 index 0000000..b08a85f --- /dev/null +++ b/app/Http/Controllers/Api/V1/TagController.php @@ -0,0 +1,82 @@ +authorize('viewAny', Tag::class); + + return response()->json([ + 'data' => Tag::query()->withCount('pages')->orderBy('id')->get()->map(fn (Tag $tag) => $this->present($tag))->values(), + ]); + } + + public function store(Request $request): JsonResponse + { + $this->authorize('create', Tag::class); + + $tag = new Tag; + $tag->fill(TranslatablePayload::merge($tag, $this->validated($request, null), $tag->translatable))->save(); + + return response()->json(['data' => $this->present($tag->loadCount('pages'))], 201); + } + + public function update(Request $request, Tag $tag): JsonResponse + { + $this->authorize('update', $tag); + + $tag->fill(TranslatablePayload::merge($tag, $this->validated($request, $tag), $tag->translatable))->save(); + + return response()->json(['data' => $this->present($tag->loadCount('pages'))]); + } + + public function destroy(Tag $tag): JsonResponse + { + $this->authorize('delete', $tag); + + $tag->delete(); + + return response()->json(status: 204); + } + + /** + * @return array + */ + protected function validated(Request $request, ?Tag $tag): array + { + $sometimes = $tag === null ? 'required' : 'sometimes'; + + return $request->validate([ + 'name' => [$sometimes, new Translatable(requireDefaultLocale: $tag === null, max: 80)], + 'slug' => [$sometimes, new Translatable(requireDefaultLocale: $tag === null, max: 80), new UniqueTranslatedSlug(Tag::class, $tag?->id)], + ]); + } + + /** + * @return array + */ + protected function present(Tag $tag): array + { + return [ + 'id' => $tag->id, + 'name' => (object) $tag->getTranslations('name'), + 'slug' => (object) $tag->getTranslations('slug'), + 'pages_count' => (int) ($tag->pages_count ?? 0), + ]; + } +} diff --git a/app/Http/Requests/Api/V1/PageRequest.php b/app/Http/Requests/Api/V1/PageRequest.php index 396be37..c725669 100644 --- a/app/Http/Requests/Api/V1/PageRequest.php +++ b/app/Http/Requests/Api/V1/PageRequest.php @@ -49,6 +49,10 @@ class PageRequest extends FormRequest 'meta_description' => ['sometimes', 'nullable', new Translatable(max: 160)], 'category_id' => ['sometimes', 'nullable', 'integer', Rule::exists('categories', 'id')->whereNull('deleted_at')], + // Who signs it on the site; replaced whole when sent, like the tags. + 'byline_member_id' => ['sometimes', 'nullable', 'integer', Rule::exists('members', 'id')->whereNull('deleted_at')], + 'tag_ids' => ['sometimes', 'array'], + 'tag_ids.*' => ['integer', 'distinct', Rule::exists('tags', 'id')], 'status' => ['sometimes', Rule::enum(PageStatus::class)], 'featured_image' => ['sometimes', 'nullable', 'string', 'max:2048'], 'featured_image_alt' => ['sometimes', 'nullable', new Translatable(max: 255)], diff --git a/app/Http/Resources/PageResource.php b/app/Http/Resources/PageResource.php index 932c6bf..379021d 100644 --- a/app/Http/Resources/PageResource.php +++ b/app/Http/Resources/PageResource.php @@ -49,6 +49,8 @@ class PageResource extends JsonResource 'category' => CategoryResource::make($this->whenLoaded('category')), 'author' => UserResource::make($this->whenLoaded('author')), + 'byline_member_id' => $this->byline_member_id, + 'tag_ids' => $this->whenLoaded('tags', fn () => $this->tags->pluck('id')->values()), // Opt-in: `?with=translations` returns every language at once, // which is what a translation UI needs and a page renderer does not. diff --git a/app/Http/Resources/PublicApi/PublicPageResource.php b/app/Http/Resources/PublicApi/PublicPageResource.php index 976ce8d..f8b1437 100644 --- a/app/Http/Resources/PublicApi/PublicPageResource.php +++ b/app/Http/Resources/PublicApi/PublicPageResource.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Resources\PublicApi; use App\Models\Page; +use App\Models\Tag; use App\Support\ImagePayload; use App\Support\SiteUrls; use App\Support\TranslatablePayload; @@ -58,6 +59,29 @@ class PublicPageResource extends JsonResource 'category' => PublicCategoryResource::make($this->whenLoaded('category')), + // Only the tags named in this language: one without a slug here + // has no listing to link to. + 'tags' => $this->whenLoaded('tags', fn () => $this->tags + ->filter(fn (Tag $tag) => filled($tag->getTranslation('slug', $locale, false))) + ->map(fn (Tag $tag) => [ + 'name' => $tag->getTranslation('name', $locale), + 'slug' => $tag->getTranslation('slug', $locale, false), + ]) + ->values()), + + // Who signs it — only someone with a public profile to link to. + 'byline' => $this->whenLoaded('byline', fn () => $this->byline?->is_public ? [ + 'name' => $this->byline->name, + 'slug' => $this->byline->slug, + 'headline' => $this->byline->getTranslation('headline', $locale), + 'avatar' => ImagePayload::make( + $this->byline->avatar, + $this->byline->relationLoaded('avatarMedia') ? $this->byline->getRelation('avatarMedia') : null, + $this->byline->name, + ), + 'path' => SiteUrls::path('member', $locale, $this->byline->slug), + ] : null), + // Lets the frontend emit correct hreflang tags. Only locales that // actually have a translation appear. 'alternates' => SiteUrls::alternates($this->resource, 'page'), diff --git a/app/Models/Page.php b/app/Models/Page.php index cff57b6..d728ef5 100644 --- a/app/Models/Page.php +++ b/app/Models/Page.php @@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\Translatable\HasTranslations; @@ -26,7 +27,7 @@ use Spatie\Translatable\HasTranslations; * @property array $slug */ #[Fillable([ - 'category_id', 'author_id', 'title', 'slug', 'excerpt', 'body', + 'category_id', 'author_id', 'byline_member_id', 'title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'status', 'featured_image', 'featured_image_alt', 'is_featured', 'published_at', 'unpublish_at', ])] @@ -146,6 +147,25 @@ class Page extends Model return $this->belongsTo(User::class, 'author_id'); } + /** + * Who signs it on the site: a member, with a profile to link to. Not the + * account that typed it in, which is `author` and stays in the panel. + * + * @return BelongsTo + */ + public function byline(): BelongsTo + { + return $this->belongsTo(Member::class, 'byline_member_id'); + } + + /** + * @return BelongsToMany + */ + public function tags(): BelongsToMany + { + return $this->belongsToMany(Tag::class); + } + /** * Publicly visible pages: published and not scheduled for the future. * diff --git a/app/Models/Tag.php b/app/Models/Tag.php new file mode 100644 index 0000000..51b8ff8 --- /dev/null +++ b/app/Models/Tag.php @@ -0,0 +1,37 @@ + */ + use BumpsContentVersion, HasFactory, HasTranslations; + + /** + * @var array + */ + public array $translatable = ['name', 'slug']; + + /** + * @return BelongsToMany + */ + public function pages(): BelongsToMany + { + return $this->belongsToMany(Page::class); + } +} diff --git a/app/Policies/TagPolicy.php b/app/Policies/TagPolicy.php new file mode 100644 index 0000000..b771e60 --- /dev/null +++ b/app/Policies/TagPolicy.php @@ -0,0 +1,17 @@ + 'pages', Category::class => 'categories', + Tag::class => 'tags', Project::class => 'projects', Member::class => 'members', Service::class => 'services', diff --git a/app/Support/Duplicator.php b/app/Support/Duplicator.php index e852e8b..67889ac 100644 --- a/app/Support/Duplicator.php +++ b/app/Support/Duplicator.php @@ -25,7 +25,7 @@ final class Duplicator * @var array, array> */ public const RELATIONS = [ - Page::class => [], + Page::class => ['tags'], Project::class => ['technologies', 'services', 'members'], Service::class => ['technologies', 'projects'], Call::class => ['technologies'], diff --git a/database/factories/TagFactory.php b/database/factories/TagFactory.php new file mode 100644 index 0000000..83b173e --- /dev/null +++ b/database/factories/TagFactory.php @@ -0,0 +1,25 @@ + + */ +class TagFactory extends Factory +{ + public function definition(): array + { + $name = $this->faker->unique()->word(); + + return [ + 'name' => ['es' => $name, 'en' => $name, 'ca' => $name], + 'slug' => ['es' => Str::slug($name), 'en' => Str::slug($name), 'ca' => Str::slug($name)], + ]; + } +} diff --git a/database/migrations/2026_10_01_130000_add_tags_and_bylines_to_pages.php b/database/migrations/2026_10_01_130000_add_tags_and_bylines_to_pages.php new file mode 100644 index 0000000..e6146ff --- /dev/null +++ b/database/migrations/2026_10_01_130000_add_tags_and_bylines_to_pages.php @@ -0,0 +1,44 @@ +id(); + $table->jsonb('name'); + $table->jsonb('slug'); + $table->timestamps(); + }); + + Schema::create('page_tag', function (Blueprint $table): void { + $table->foreignId('page_id')->constrained()->cascadeOnDelete(); + $table->foreignId('tag_id')->constrained()->cascadeOnDelete(); + $table->primary(['page_id', 'tag_id']); + }); + + Schema::table('pages', function (Blueprint $table): void { + $table->foreignId('byline_member_id')->nullable()->after('author_id')->constrained('members')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('pages', function (Blueprint $table): void { + $table->dropConstrainedForeignId('byline_member_id'); + }); + + Schema::dropIfExists('page_tag'); + Schema::dropIfExists('tags'); + } +}; diff --git a/lang/ca/panel.php b/lang/ca/panel.php index a0a476f..4b3f832 100644 --- a/lang/ca/panel.php +++ b/lang/ca/panel.php @@ -63,6 +63,8 @@ return [ 'seo_hint' => 'Metadades per a cercadors i xarxes socials.', ], 'fields' => [ + 'byline' => 'Signatura', + 'byline_hint' => 'Qui signa l\'entrada al web, amb enllaç al seu perfil. Si es deixa buit no es mostra autoria.', 'title' => 'Títol', 'slug' => 'Slug', 'excerpt' => 'Extracte', @@ -144,6 +146,16 @@ return [ 'select_all' => 'Selecciona-ho tot', ], + 'tags' => [ + 'label' => 'Etiqueta', + 'plural' => 'Etiquetes', + 'fields' => [ + 'name' => 'Nom', + 'slug' => 'Slug', + 'pages' => 'Entrades', + ], + ], + 'translate' => [ 'title' => 'Traduccions', 'title_for' => ':title en :language', diff --git a/lang/en/panel.php b/lang/en/panel.php index 3e052f0..b9c7a57 100644 --- a/lang/en/panel.php +++ b/lang/en/panel.php @@ -63,6 +63,8 @@ return [ 'seo_hint' => 'Metadata for search engines and social networks.', ], 'fields' => [ + 'byline' => 'Byline', + 'byline_hint' => 'Who signs the post on the site, linked to their profile. Left empty, no author is shown.', 'title' => 'Title', 'slug' => 'Slug', 'excerpt' => 'Excerpt', @@ -144,6 +146,16 @@ return [ 'select_all' => 'Select all', ], + 'tags' => [ + 'label' => 'Tag', + 'plural' => 'Tags', + 'fields' => [ + 'name' => 'Name', + 'slug' => 'Slug', + 'pages' => 'Posts', + ], + ], + 'translate' => [ 'title' => 'Translations', 'title_for' => ':title in :language', diff --git a/lang/es/panel.php b/lang/es/panel.php index 05b8202..cd5cc27 100644 --- a/lang/es/panel.php +++ b/lang/es/panel.php @@ -63,6 +63,8 @@ return [ 'seo_hint' => 'Metadatos para buscadores y redes sociales.', ], 'fields' => [ + 'byline' => 'Firma', + 'byline_hint' => 'Quién firma la entrada en la web, con enlace a su perfil. Si se deja vacío no se muestra autoría.', 'title' => 'Título', 'slug' => 'Slug', 'excerpt' => 'Extracto', @@ -144,6 +146,16 @@ return [ 'select_all' => 'Seleccionar todos', ], + 'tags' => [ + 'label' => 'Etiqueta', + 'plural' => 'Etiquetas', + 'fields' => [ + 'name' => 'Nombre', + 'slug' => 'Slug', + 'pages' => 'Entradas', + ], + ], + 'translate' => [ 'title' => 'Traducciones', 'title_for' => ':title en :language', diff --git a/public/api-types.d.ts b/public/api-types.d.ts index 0e0f866..749e830 100644 --- a/public/api-types.d.ts +++ b/public/api-types.d.ts @@ -165,6 +165,14 @@ export interface PublicPage { unpublish_at?: string; updated_at: string; category: PublicCategory; + /** + * The ones named in this language, to link to the blog filtered by each. + */ + tags: ({ name: string; slug: string })[]; + /** + * The member who signs it, when they have a public profile. + */ + byline?: { name: string; slug: string; headline?: string; avatar?: Image; path: string }; alternates: Alternates; path?: string; } @@ -398,6 +406,7 @@ export interface PublicApiPayloads { '/public/v1/slugs': Record })[]>; '/public/v1/redirects': ({ from: string; to: string; status: 301 | 302; locale?: string })[]; '/public/v1/search': PublicSearchResult[]; + '/public/v1/tags': ({ name: string; slug: string; count: number })[]; '/public/v1/projects': PublicProject[]; '/public/v1/projects/{slug}': PublicProject; '/public/v1/members': PublicMember[]; diff --git a/public/openapi.yaml b/public/openapi.yaml index 7737b56..a7a81dc 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -328,6 +328,32 @@ paths: "422": { $ref: "#/components/responses/ValidationError" } "503": { $ref: "#/components/responses/Maintenance" } + /public/v1/tags: + get: + tags: [Public] + summary: Blog tags in use + description: Tags on something published, with how many posts each has in the request's language. Most used first. + security: [] + parameters: + - $ref: "#/components/parameters/Lang" + responses: + "200": + description: The tags + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + name: { type: string } + slug: { type: string } + count: { type: integer } + "503": { $ref: "#/components/responses/Maintenance" } + /public/v1/projects: get: tags: [Public] @@ -871,6 +897,10 @@ paths: - name: category in: query schema: { type: string } + - name: tag + in: query + description: A tag's slug in the active language. + schema: { type: string } - name: featured in: query schema: { type: boolean } @@ -1330,6 +1360,76 @@ paths: "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } + /v1/tags: + get: + tags: [Categories] + summary: List blog tags + description: Every tag, unpaginated, with how many pages carry it. Kept by whoever keeps the categories. + responses: + "200": + description: The tags + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Tag" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [Categories] + summary: Create a blog tag + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TagWrite" } + responses: + "201": + description: Tag created + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Tag" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + + /v1/tags/{tag}: + parameters: + - name: tag + in: path + required: true + schema: { type: integer } + patch: + tags: [Categories] + summary: Update a blog tag + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TagWrite" } + responses: + "200": + description: Tag updated + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Tag" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Categories] + summary: Delete a blog tag + description: The pages keep existing; they just stop carrying it. + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + /v1/technologies: get: tags: [Technologies] @@ -5221,6 +5321,26 @@ components: cache downstream at the same moment. updated_at: { type: string, format: date-time } category: { $ref: "#/components/schemas/PublicCategory" } + tags: + type: array + description: The ones named in this language, to link to the blog filtered by each. + items: + type: object + properties: + name: { type: string } + slug: { type: string } + byline: + type: object + nullable: true + description: The member who signs it, when they have a public profile. + properties: + name: { type: string } + slug: { type: string } + headline: { type: string, nullable: true } + avatar: + allOf: [{ $ref: "#/components/schemas/Image" }] + nullable: true + path: { type: string } alternates: { $ref: "#/components/schemas/Alternates" } path: { type: string, nullable: true } @@ -5297,6 +5417,8 @@ components: deleted_at: { type: string, format: date-time, nullable: true } category: { $ref: "#/components/schemas/Category" } author: { $ref: "#/components/schemas/User" } + byline_member_id: { type: integer, nullable: true, description: The member who signs it on the site. } + tag_ids: { type: array, items: { type: integer } } featured_image_alt: type: string nullable: true @@ -5325,6 +5447,8 @@ components: meta_title: { $ref: "#/components/schemas/TranslatedField" } meta_description: { $ref: "#/components/schemas/TranslatedField" } category_id: { type: integer, nullable: true } + byline_member_id: { type: integer, nullable: true, description: The member who signs it on the site. } + tag_ids: { type: array, items: { type: integer }, description: Replaced whole when sent. } status: { type: string, enum: [draft, review, published, archived] } featured_image: type: string @@ -6173,6 +6297,23 @@ components: subject: { type: object, additionalProperties: { type: string }, description: "Language → text. `{name}`, `{subject}` and `{sender}` are filled in." } body: { type: object, additionalProperties: { type: string } } + Tag: + type: object + properties: + id: { type: integer } + name: { type: object, additionalProperties: { type: string }, description: Language → name. } + slug: { type: object, additionalProperties: { type: string } } + pages_count: { type: integer } + + TagWrite: + type: object + required: [name, slug] + properties: + name: { $ref: "#/components/schemas/TranslatedField" } + slug: + allOf: [{ $ref: "#/components/schemas/TranslatedField" }] + description: Unique per language. + MediaFor: type: string enum: [pages, technologies, projects, members, clients, testimonials] diff --git a/routes/api.php b/routes/api.php index f381cea..d8306ea 100644 --- a/routes/api.php +++ b/routes/api.php @@ -36,6 +36,7 @@ use App\Http\Controllers\Api\V1\RevisionController; use App\Http\Controllers\Api\V1\RoleController; use App\Http\Controllers\Api\V1\ServiceController; use App\Http\Controllers\Api\V1\SettingController; +use App\Http\Controllers\Api\V1\TagController; use App\Http\Controllers\Api\V1\TalkController; use App\Http\Controllers\Api\V1\TechnologyController; use App\Http\Controllers\Api\V1\TestimonialController; @@ -94,6 +95,7 @@ Route::prefix('public/v1') Route::get('search', SearchController::class)->name('search'); Route::get('pages', [PublicPageController::class, 'index'])->name('pages.index'); + Route::get('tags', [PublicPageController::class, 'tags'])->name('tags'); Route::get('pages/{slug}', [PublicPageController::class, 'show'])->name('pages.show'); Route::get('projects', [PublicProjectController::class, 'index'])->name('projects.index'); @@ -197,6 +199,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () { ->name('pages.restore'); Route::apiResource('categories', CategoryController::class); + Route::apiResource('tags', TagController::class)->except('show'); Route::apiResource('technologies', TechnologyController::class); diff --git a/tests/Feature/Api/AdminApiContractTest.php b/tests/Feature/Api/AdminApiContractTest.php index 962f930..ba7cd60 100644 --- a/tests/Feature/Api/AdminApiContractTest.php +++ b/tests/Feature/Api/AdminApiContractTest.php @@ -19,6 +19,7 @@ use App\Models\ReplyTemplate; use App\Models\Repository; use App\Models\Service; use App\Models\Setting; +use App\Models\Tag; use App\Models\Talk; use App\Models\Technology; use App\Models\Testimonial; @@ -118,6 +119,7 @@ class AdminApiContractTest extends TestCase $application = Application::factory()->for($call)->create(); $application->replies()->create(['user_id' => $this->caller->id, 'user_name' => $this->caller->name, 'subject' => 'Re', 'body' => 'Hola', 'channel' => 'mail']); ReplyTemplate::factory()->create(); + $page->tags()->attach(Tag::factory()->create()); $redirect = Redirect::factory()->create(); $missingUrl = MissingUrl::factory()->create(); $repository = Repository::factory()->create(); @@ -158,6 +160,7 @@ class AdminApiContractTest extends TestCase 'application' => ["/v1/applications/{$application->id}", '/v1/applications/{application}'], 'replies' => ["/v1/applications/{$application->id}/replies", '/v1/applications/{application}/replies'], 'reply templates' => ['/v1/reply-templates', '/v1/reply-templates'], + 'tags' => ['/v1/tags', '/v1/tags'], 'redirects' => ['/v1/redirects', '/v1/redirects'], 'redirect' => ["/v1/redirects/{$redirect->id}", '/v1/redirects/{redirect}'], 'missing-urls' => ['/v1/missing-urls', '/v1/missing-urls'], diff --git a/tests/Feature/Api/BlogTagsTest.php b/tests/Feature/Api/BlogTagsTest.php new file mode 100644 index 0000000..79500d1 --- /dev/null +++ b/tests/Feature/Api/BlogTagsTest.php @@ -0,0 +1,120 @@ +seed(RolePermissionSeeder::class); + Queue::fake(); + } + + protected function tag(string $es, string $en): Tag + { + return Tag::factory()->create([ + 'name' => ['es' => ucfirst($es), 'en' => ucfirst($en)], + 'slug' => ['es' => $es, 'en' => $en], + ]); + } + + #[Test] + public function a_post_carries_its_tags_and_who_signs_it(): void + { + $ada = Member::factory()->create(['name' => 'Ada', 'slug' => 'ada', 'is_public' => true]); + $page = Page::factory()->published()->create([ + 'slug' => ['es' => 'angular-nativo'], + 'byline_member_id' => $ada->id, + ]); + $page->tags()->attach([$this->tag('angular', 'angular')->id, $this->tag('rendimiento', 'performance')->id]); + + $this->getJson('/api/public/v1/pages/angular-nativo?lang=es') + ->assertOk() + ->assertJsonPath('data.tags.1.slug', 'rendimiento') + ->assertJsonPath('data.byline.name', 'Ada') + ->assertJsonPath('data.byline.path', '/equipo/ada'); + } + + #[Test] + public function a_private_member_does_not_sign_in_public(): void + { + $page = Page::factory()->published()->create([ + 'slug' => ['es' => 'secreto'], + 'byline_member_id' => Member::factory()->create(['is_public' => false])->id, + ]); + + $this->getJson('/api/public/v1/pages/secreto?lang=es')->assertJsonPath('data.byline', null); + } + + #[Test] + public function the_blog_is_read_by_tag(): void + { + $angular = $this->tag('angular', 'angular'); + Page::factory()->published()->create()->tags()->attach($angular); + Page::factory()->published()->create()->tags()->attach($angular); + Page::factory()->draft()->create()->tags()->attach($angular); + Page::factory()->published()->create(); + + $this->getJson('/api/public/v1/pages?lang=es&tag=angular')->assertJsonCount(2, 'data'); + + $this->getJson('/api/public/v1/tags?lang=es') + ->assertOk() + ->assertJsonPath('data.0.slug', 'angular') + ->assertJsonPath('data.0.count', 2); + } + + #[Test] + public function the_admin_api_writes_tags_and_bylines(): void + { + $this->withHeaders($this->apiHeadersFor($this->userWithRoles('editor'))); + + $tag = $this->postJson('/api/v1/tags', ['name' => ['es' => 'Accesibilidad'], 'slug' => ['es' => 'accesibilidad']]) + ->assertCreated() + ->json('data.id'); + + $member = Member::factory()->create(); + $page = Page::factory()->create(); + + $this->patchJson("/api/v1/pages/{$page->id}", ['tag_ids' => [$tag], 'byline_member_id' => $member->id]) + ->assertOk() + ->assertJsonPath('data.tag_ids', [$tag]) + ->assertJsonPath('data.byline_member_id', $member->id); + + // Untouched when not sent. + $this->patchJson("/api/v1/pages/{$page->id}", ['is_featured' => true])->assertJsonPath('data.tag_ids', [$tag]); + } + + #[Test] + public function a_tag_is_made_from_the_page_form(): void + { + $this->actingAs($this->userWithRoles('editor')); + + $page = Page::factory()->draft()->create(); + + Livewire::test(EditPage::class, ['record' => $page->getRouteKey()]) + ->assertFormFieldExists('tags') + ->assertFormFieldExists('byline_member_id'); + } +} diff --git a/tests/Feature/Api/PublicApiContractTest.php b/tests/Feature/Api/PublicApiContractTest.php index 2e615ad..74adef4 100644 --- a/tests/Feature/Api/PublicApiContractTest.php +++ b/tests/Feature/Api/PublicApiContractTest.php @@ -10,6 +10,7 @@ use App\Models\Member; use App\Models\Page; use App\Models\Project; use App\Models\Service; +use App\Models\Tag; use App\Models\Talk; use App\Models\Technology; use App\Models\Testimonial; @@ -59,10 +60,12 @@ class PublicApiContractTest extends TestCase 'slug' => ['es' => 'mi-proyecto'], ]); - Page::factory()->published()->create([ + $page = Page::factory()->published()->create([ 'title' => ['es' => 'Quiénes somos'], 'slug' => ['es' => 'quienes-somos'], + 'byline_member_id' => Member::factory()->create(['is_public' => true])->id, ]); + $page->tags()->attach(Tag::factory()->create()); Member::factory()->create([ 'name' => 'Ada Lovelace',