# 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
```
