feat(tags): fandom-edit UI in tags directory + image modal
Adds the missing UI to change a character tag's fandom, in both places:
- FandomSetDialog (shared): pick an existing fandom, create a new one, or
clear it; on a name collision in the target fandom it surfaces a merge
confirmation and resolves via setFandom(merge:true). Reuses the tags
store's fandom cache.
- TagCard kebab gains "Set fandom…" for character tags (→ TagsView opens
the dialog, reloads on success).
- TagPanel chip kebab gains "Set fandom…" for character tags (→ reloads the
modal's tag list on success).
- tags store: setFandom(tagId, fandomId, {merge}) action + test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,12 @@
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-if="card.kind === 'character'"
|
||||
title="Set fandom…"
|
||||
prepend-icon="mdi-book-open-page-variant"
|
||||
@click="$emit('set-fandom', card)"
|
||||
/>
|
||||
<v-list-item
|
||||
title="Merge with…"
|
||||
prepend-icon="mdi-call-merge"
|
||||
@@ -78,7 +84,9 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({ card: { type: Object, required: true } })
|
||||
const emit = defineEmits(['open', 'rename', 'manage', 'read', 'merge-with', 'delete'])
|
||||
const emit = defineEmits([
|
||||
'open', 'rename', 'manage', 'read', 'merge-with', 'delete', 'set-fandom',
|
||||
])
|
||||
|
||||
const editing = ref(false)
|
||||
const draft = ref('')
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title class="text-body-1">Fandom for “{{ tag.name }}”</v-card-title>
|
||||
<v-card-text>
|
||||
<template v-if="!collision">
|
||||
<v-autocomplete
|
||||
v-model="selectedId"
|
||||
:items="store.fandomCache"
|
||||
:item-title="(f) => f.name" :item-value="(f) => f.id"
|
||||
label="Fandom" clearable density="compact"
|
||||
:hint="selectedId == null
|
||||
? 'No fandom — the character will be unassigned.' : ''"
|
||||
persistent-hint
|
||||
/>
|
||||
<v-divider class="my-3" />
|
||||
<p class="text-caption mb-2">Or create a new fandom:</p>
|
||||
<div class="d-flex" style="gap: 8px;">
|
||||
<v-text-field
|
||||
v-model="newName" placeholder="New fandom name"
|
||||
density="compact" hide-details
|
||||
@keydown.enter.prevent="onCreate"
|
||||
/>
|
||||
<v-btn
|
||||
:disabled="!newName.trim() || busy" rounded="pill"
|
||||
@click="onCreate"
|
||||
>Create</v-btn>
|
||||
</div>
|
||||
<v-alert
|
||||
v-if="error" type="error" variant="tonal" density="compact"
|
||||
class="mt-3"
|
||||
>{{ error }}</v-alert>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||||
A character named “{{ tag.name }}” already exists in that fandom.
|
||||
</v-alert>
|
||||
<p class="text-body-2">
|
||||
Merge this tag into “{{ collision.target.name }}”?
|
||||
{{ collision.source_image_count }} image
|
||||
association{{ collision.source_image_count === 1 ? '' : 's' }}
|
||||
will move over and this tag will be deleted{{
|
||||
collision.will_alias ? ' (its name kept as a tagger alias)' : '' }}.
|
||||
</p>
|
||||
</template>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<template v-if="!collision">
|
||||
<v-btn variant="text" :disabled="busy" @click="$emit('cancel')">
|
||||
Cancel
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary" rounded="pill" :loading="busy"
|
||||
:disabled="selectedId === (tag.fandom_id ?? null)"
|
||||
@click="onSave"
|
||||
>Save</v-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-btn variant="text" :disabled="busy" @click="collision = null">
|
||||
Back
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="warning" variant="flat" rounded="pill" :loading="busy"
|
||||
@click="onConfirmMerge"
|
||||
>Merge</v-btn>
|
||||
</template>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useTagStore } from '../../stores/tags.js'
|
||||
|
||||
const props = defineProps({ tag: { type: Object, required: true } })
|
||||
const emit = defineEmits(['updated', 'cancel'])
|
||||
|
||||
const store = useTagStore()
|
||||
|
||||
const selectedId = ref(props.tag.fandom_id ?? null)
|
||||
const newName = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref(null)
|
||||
const collision = ref(null)
|
||||
|
||||
onMounted(() => { if (store.fandomCache.length === 0) store.loadFandoms() })
|
||||
|
||||
async function onCreate() {
|
||||
const name = newName.value.trim()
|
||||
if (!name) return
|
||||
busy.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const f = await store.createFandom(name)
|
||||
selectedId.value = f.id
|
||||
newName.value = ''
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save(merge) {
|
||||
busy.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const body = await store.setFandom(props.tag.id, selectedId.value, { merge })
|
||||
emit('updated', body)
|
||||
} catch (e) {
|
||||
// 409 on first attempt → surface the merge confirmation; cross-fandom
|
||||
// collisions can't go through the regular /merge endpoint, so the
|
||||
// resolution is a second setFandom with merge: true.
|
||||
if (!merge && e.status === 409 && e.body && e.body.target) {
|
||||
collision.value = e.body
|
||||
} else {
|
||||
error.value = e.message || String(e)
|
||||
collision.value = null
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSave() { save(false) }
|
||||
function onConfirmMerge() { save(true) }
|
||||
</script>
|
||||
@@ -28,6 +28,11 @@
|
||||
<v-list-item @click="openRename(tag)">
|
||||
<v-list-item-title>Rename…</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="tag.kind === 'character'" @click="openSetFandom(tag)"
|
||||
>
|
||||
<v-list-item-title>Set fandom…</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</span>
|
||||
@@ -57,6 +62,13 @@
|
||||
@renamed="onRenamed" @cancel="renameDialog = false"
|
||||
/>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="fandomDialog" max-width="460">
|
||||
<FandomSetDialog
|
||||
v-if="fandomTarget" :tag="fandomTarget"
|
||||
@updated="onFandomUpdated" @cancel="fandomDialog = false"
|
||||
/>
|
||||
</v-dialog>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -67,6 +79,7 @@ import { useTagStore } from '../../stores/tags.js'
|
||||
import TagAutocomplete from './TagAutocomplete.vue'
|
||||
import SuggestionsPanel from './SuggestionsPanel.vue'
|
||||
import TagRenameDialog from './TagRenameDialog.vue'
|
||||
import FandomSetDialog from './FandomSetDialog.vue'
|
||||
|
||||
const modal = useModalStore()
|
||||
const store = useTagStore()
|
||||
@@ -107,6 +120,18 @@ async function onRenamed() {
|
||||
// Reflect the new name in the modal's current tag list without a full reload.
|
||||
await modal.reloadTags()
|
||||
}
|
||||
|
||||
const fandomDialog = ref(false)
|
||||
const fandomTarget = ref(null)
|
||||
function openSetFandom(tag) {
|
||||
fandomTarget.value = tag
|
||||
fandomDialog.value = true
|
||||
}
|
||||
async function onFandomUpdated() {
|
||||
fandomDialog.value = false
|
||||
// A fandom change can merge the tag away; reload to reflect the new state.
|
||||
await modal.reloadTags()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -49,8 +49,21 @@ export const useTagStore = defineStore('tags', () => {
|
||||
return fandom
|
||||
}
|
||||
|
||||
// Set / change / clear a character tag's fandom. fandomId null clears it.
|
||||
// Throws ApiError (status 409, body.target) on a name collision in the
|
||||
// target fandom; pass { merge: true } to resolve it by merging this tag
|
||||
// into the existing character. Returns the updated/surviving tag.
|
||||
async function setFandom(tagId, fandomId, { merge = false } = {}) {
|
||||
const body = { fandom_id: fandomId ?? null }
|
||||
if (merge) body.merge = true
|
||||
return await api.patch(`/api/tags/${tagId}`, { body })
|
||||
}
|
||||
|
||||
function kindOptions() { return KIND_OPTIONS }
|
||||
function colorFor(kind) { return KIND_COLOR[kind] || 'on-surface' }
|
||||
|
||||
return { fandomCache, autocomplete, loadFandoms, createFandom, kindOptions, colorFor }
|
||||
return {
|
||||
fandomCache, autocomplete, loadFandoms, createFandom, setFandom,
|
||||
kindOptions, colorFor
|
||||
}
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
v-for="c in store.cards" :key="c.id" :card="c"
|
||||
@open="openTag" @rename="onRename" @manage="onManage" @read="onRead"
|
||||
@merge-with="onMergeWith" @delete="onDeleteTag"
|
||||
@set-fandom="onSetFandom"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -85,6 +86,13 @@
|
||||
: ''"
|
||||
@confirm="onDeleteTagConfirm"
|
||||
/>
|
||||
|
||||
<v-dialog v-model="fandomDialogOpen" max-width="460">
|
||||
<FandomSetDialog
|
||||
v-if="fandomTarget" :tag="fandomTarget"
|
||||
@updated="onFandomUpdated" @cancel="fandomDialogOpen = false"
|
||||
/>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
@@ -98,6 +106,7 @@ import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
||||
import TagCard from '../components/discovery/TagCard.vue'
|
||||
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
|
||||
import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue'
|
||||
import FandomSetDialog from '../components/modal/FandomSetDialog.vue'
|
||||
|
||||
// Must stay a subset of the backend TagKind enum (character, fandom,
|
||||
// general, series, archive, post). 'fandom' is this model's
|
||||
@@ -140,6 +149,18 @@ function openTag(tagId) {
|
||||
router.push({ name: 'gallery', query: { tag_id: tagId } })
|
||||
}
|
||||
|
||||
// Character fandom editing (dots-menu → FandomSetDialog).
|
||||
const fandomDialogOpen = ref(false)
|
||||
const fandomTarget = ref(null)
|
||||
function onSetFandom(card) {
|
||||
fandomTarget.value = card
|
||||
fandomDialogOpen.value = true
|
||||
}
|
||||
function onFandomUpdated() {
|
||||
fandomDialogOpen.value = false
|
||||
store.reset() // reload so the card reflects the new fandom (or its removal)
|
||||
}
|
||||
|
||||
function onManage(id) {
|
||||
router.push({ name: 'series-manage', params: { tagId: id } })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useTagStore } from '../src/stores/tags.js'
|
||||
|
||||
function stubFetch(handler) {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
const { status, body } = handler(url, init)
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: String(status),
|
||||
text: async () => (body == null ? '' : JSON.stringify(body)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('tags store: setFandom', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('PATCHes fandom_id, and null to clear', async () => {
|
||||
const s = useTagStore()
|
||||
const calls = []
|
||||
stubFetch((url, init) => {
|
||||
calls.push({ url, init })
|
||||
return { status: 200, body: { id: 5, name: 'Ichigo', kind: 'character', fandom_id: 9 } }
|
||||
})
|
||||
const body = await s.setFandom(5, 9)
|
||||
expect(body.fandom_id).toBe(9)
|
||||
const last = calls.at(-1)
|
||||
expect(last.url).toContain('/api/tags/5')
|
||||
expect(last.init.method).toBe('PATCH')
|
||||
expect(JSON.parse(last.init.body)).toEqual({ fandom_id: 9 })
|
||||
|
||||
await s.setFandom(5, null)
|
||||
expect(JSON.parse(calls.at(-1).init.body)).toEqual({ fandom_id: null })
|
||||
})
|
||||
|
||||
it('sends merge: true when requested', async () => {
|
||||
const s = useTagStore()
|
||||
const calls = []
|
||||
stubFetch((url, init) => {
|
||||
calls.push({ url, init })
|
||||
return { status: 200, body: { id: 2 } }
|
||||
})
|
||||
await s.setFandom(7, 3, { merge: true })
|
||||
expect(JSON.parse(calls.at(-1).init.body)).toEqual({ fandom_id: 3, merge: true })
|
||||
})
|
||||
|
||||
it('throws ApiError carrying the 409 collision body', async () => {
|
||||
const s = useTagStore()
|
||||
stubFetch(() => ({
|
||||
status: 409,
|
||||
body: { error: 'exists', target: { id: 42, name: 'Renji' } },
|
||||
}))
|
||||
await expect(s.setFandom(1, 2)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
body: { target: { id: 42 } },
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user