feat: increase gallery grid from 4 to 5 columns per row on desktopfeat: increase gallery grid from 4 to 5 columns per row on desktop

This commit is contained in:
2026-02-25 19:11:23 +01:00
parent 5c97488e80
commit 0032aec02f
131 changed files with 15674 additions and 597 deletions

210
docs/tags-system.md Normal file
View File

@@ -0,0 +1,210 @@
# Skinbase Tag System
Architecture reference for the Skinbase unified tag system.
---
## Architecture Overview
```
TagInput (React)
├─ GET /api/tags/search?q= → TagController@search
└─ GET /api/tags/popular → TagController@popular
ArtworkTagController
├─ GET /api/artworks/{id}/tags
├─ POST /api/artworks/{id}/tags → TagService::attachUserTags()
├─ PUT /api/artworks/{id}/tags → TagService::syncTags()
└─ DELETE /api/artworks/{id}/tags/{tag} → TagService::detachTags()
TagService → TagNormalizer → Tag (model) → artwork_tag (pivot)
ArtworkObserver / TagService → IndexArtworkJob → Meilisearch
```
---
## Database Schema
### `tags`
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| name | varchar(64) | unique |
| slug | varchar(64) | unique, normalized |
| usage_count | bigint | maintained by TagService |
| is_active | boolean | false = hidden from search |
| created_at / updated_at | timestamps | |
### `artwork_tag` (pivot)
| Column | Type | Notes |
|--------|------|-------|
| artwork_id | bigint FK | |
| tag_id | bigint FK | |
| source | enum(user,ai,system) | |
| confidence | float NULL | AI only |
| created_at | timestamp | |
PK: `(artwork_id, tag_id)` — one row per pair, user source takes precedence over ai.
### `tag_synonyms`
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| tag_id | bigint FK | cascade delete |
| synonym | varchar(64) | |
Unique: `(tag_id, synonym)`.
---
## Services
### `TagNormalizer`
`App\Services\TagNormalizer`
```php
$n->normalize(' Café Night!! '); // → 'cafe-night'
$n->normalize('🚀 Rocket'); // → 'rocket'
```
Rules applied in order:
1. Trim + lowercase (UTF-8)
2. Unicode → ASCII transliteration (Transliterator / iconv)
3. Strip everything except `[a-z0-9 -]`
4. Collapse whitespace → hyphens
5. Strip leading/trailing hyphens
6. Clamp to `config('tags.max_length', 32)` characters
### `TagService`
`App\Services\TagService`
| Method | Description |
|--------|-------------|
| `attachUserTags(Artwork, string[])` | Normalize → findOrCreate → attach with `source=user`. Skips duplicates. Max 15. |
| `attachAiTags(Artwork, array{tag,confidence}[])` | Normalize → findOrCreate → syncWithoutDetaching `source=ai`. Existing user pivot is never overwritten. |
| `detachTags(Artwork, string[])` | Detach by slug, decrement usage_count. |
| `syncTags(Artwork, string[])` | Replace full user-tag set. New tags increment, removed tags decrement. |
| `updateUsageCount(Tag, int)` | Clamp-safe increment/decrement. |
---
## API Endpoints
### Public (no auth)
```
GET /api/tags/search?q={query}&limit={n}
GET /api/tags/popular?limit={n}
```
Response shape:
```json
{ "data": [{ "id": 1, "name": "city", "slug": "city", "usage_count": 412 }] }
```
### Authenticated (artwork owner or admin)
```
GET /api/artworks/{id}/tags
POST /api/artworks/{id}/tags body: { "tags": ["city", "night"] }
PUT /api/artworks/{id}/tags body: { "tags": ["city", "night", "rain"] }
DELETE /api/artworks/{id}/tags/{tag}
```
All tag mutations dispatch `IndexArtworkJob` to keep Meilisearch in sync.
---
## Meilisearch Integration
Index name: `skinbase_prod_artworks` (prefix from `MEILI_PREFIX` env var).
Tags are stored in the `tags` field as an array of slugs:
```json
{ "id": 42, "tags": ["city", "night", "cyberpunk"], ... }
```
Filterable: `tags`
Searchable: `tags` (full-text match on tag slugs)
Sync triggered by:
- `ArtworkObserver` (created/updated/deleted/restored)
- `TagService` — all mutation methods dispatch `IndexArtworkJob`
- `ArtworkAwardService::syncToSearch()`
Rebuild all: `php artisan artworks:search-rebuild`
---
## UI Component
`resources/js/components/tags/TagInput.jsx`
```jsx
<TagInput
value={tags} // string[]
onChange={setTags} // (string[]) => void
suggestedTags={aiTags} // [{ tag, confidence }]
maxTags={15}
searchEndpoint="/api/tags/search"
popularEndpoint="/api/tags/popular"
/>
```
**Keyboard shortcuts:**
| Key | Action |
|-----|--------|
| Enter / Comma | Add current input as tag |
| Tab | Accept highlighted suggestion or add input |
| Backspace (empty input) | Remove last tag |
| Arrow Up/Down | Navigate suggestions |
| Escape | Close suggestions |
Paste splits on commas automatically.
---
## Tag Pages (SEO)
Route: `GET /tag/{slug}`
Controller: `TagController@show` (`App\Http\Controllers\Web\TagController`)
SEO output per page:
- `<title>``{Tag} Artworks | Skinbase`
- `<meta name="description">``Browse {count}+ artworks tagged with {tag}.`
- `<link rel="canonical">``https://skinbase.org/tag/{slug}`
- JSON-LD `CollectionPage` schema
- Prev/next pagination links
- `?sort=popular|latest|liked|downloads` supported
---
## Configuration
`config/tags.php`
```php
'max_length' => 32, // max chars per tag slug
'max_per_upload'=> 15, // max tags per artwork
'banned' => [], // blocked slugs (add to env-driven list)
```
---
## Testing
PHP: `tests/Feature/TagSystemTest.php`
Covers: normalization, duplicate prevention, AI attach, sync, usage counts, force-delete cleanup.
JS: `resources/js/components/tags/TagInput.test.jsx`
Covers: add/remove, keyboard accept, paste, API failure, max-tags limit.
Run:
```bash
php artisan test --filter=TagSystem
npm test -- TagInput
```