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:
210
docs/tags-system.md
Normal file
210
docs/tags-system.md
Normal 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
|
||||
```
|
||||
Reference in New Issue
Block a user