feat(artist): move a source into another artist (#130 step 4)
Operator ask: a surface to merge new sources into existing artists (consolidate the singleton artist a fresh add spins up). Enabled by the #130 slug decoupling — the storage path is immutable, so re-attribution moves NO files. SourceService .reassign moves the source, re-points its posts (Post.source_id==S) and the images it contributed (ImageProvenance via S, scoped to the old artist so shared images aren't stolen), and deletes the old artist if it's left fully empty (else clears its subscription flag). POST /api/sources/<id>/reassign. Frontend: a 'Move…' action per source on the artist Management tab → artist-autocomplete picker → confirm → routes to the target (whose slug is stable). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
@@ -47,18 +47,60 @@
|
||||
</div>
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr><th>Platform</th><th>URL</th><th class="text-right">Images</th></tr>
|
||||
<tr>
|
||||
<th>Platform</th><th>URL</th>
|
||||
<th class="text-right">Images</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in overview.sources" :key="s.id">
|
||||
<td>{{ s.platform }}</td>
|
||||
<td class="fc-artist-mgmt__url">{{ s.url }}</td>
|
||||
<td class="text-right">{{ s.image_count }}</td>
|
||||
<td class="text-right">
|
||||
<v-btn
|
||||
size="x-small" variant="text" prepend-icon="mdi-account-arrow-right"
|
||||
@click="openMove(s)"
|
||||
>Move…</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</section>
|
||||
|
||||
<!-- #130: move a source into a different (existing) artist. The slug is
|
||||
immutable so no files move — just re-attribution. -->
|
||||
<v-dialog v-model="moveOpen" max-width="480">
|
||||
<v-card>
|
||||
<v-card-title class="fc-h2">Move source to another artist</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="mb-3 text-body-2">
|
||||
Moving <strong>{{ moveSource?.platform }}</strong> and the posts/images
|
||||
it brought in to another artist. Files aren't moved — only the
|
||||
attribution changes. If <strong>{{ overview.name }}</strong> is left
|
||||
empty it will be removed.
|
||||
</p>
|
||||
<v-autocomplete
|
||||
v-model="moveTarget"
|
||||
:items="artistItems"
|
||||
:loading="searching"
|
||||
item-title="name" item-value="id" return-object
|
||||
label="Target artist" density="compact" variant="outlined"
|
||||
no-filter hide-details autofocus clearable
|
||||
@update:search="onSearch"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="moveOpen = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="accent" :disabled="!moveTarget || moving" :loading="moving"
|
||||
@click="confirmMove"
|
||||
>Move</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<section class="fc-artist-mgmt__sec">
|
||||
<h2 class="fc-h2">Danger zone</h2>
|
||||
<ArtistDangerZone
|
||||
@@ -71,9 +113,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
|
||||
import { useSourcesStore } from '../../stores/sources.js'
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import ArtistDangerZone from './ArtistDangerZone.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -81,6 +125,53 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const sources = useSourcesStore()
|
||||
|
||||
// #130: move a source into another artist.
|
||||
const moveOpen = ref(false)
|
||||
const moveSource = ref(null)
|
||||
const moveTarget = ref(null)
|
||||
const artistItems = ref([])
|
||||
const searching = ref(false)
|
||||
const moving = ref(false)
|
||||
|
||||
function openMove (s) {
|
||||
moveSource.value = s
|
||||
moveTarget.value = null
|
||||
artistItems.value = []
|
||||
moveOpen.value = true
|
||||
}
|
||||
|
||||
let searchSeq = 0
|
||||
async function onSearch (q) {
|
||||
const mine = ++searchSeq
|
||||
if (!q || !q.trim()) { artistItems.value = []; return }
|
||||
searching.value = true
|
||||
try {
|
||||
const rows = await sources.autocompleteArtist(q)
|
||||
// Drop the current artist — you can't move a source onto itself.
|
||||
if (mine === searchSeq) artistItems.value = rows.filter(a => a.id !== props.overview.id)
|
||||
} finally {
|
||||
if (mine === searchSeq) searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmMove () {
|
||||
if (!moveTarget.value || !moveSource.value) return
|
||||
moving.value = true
|
||||
try {
|
||||
await sources.reassign(moveSource.value.id, moveTarget.value.id)
|
||||
moveOpen.value = false
|
||||
toast({ text: `Moved to ${moveTarget.value.name}`, type: 'success' })
|
||||
// The current artist may now be empty (deleted); route to the target,
|
||||
// whose slug is stable (immutable).
|
||||
router.push({ name: 'artist', params: { slug: moveTarget.value.slug } })
|
||||
} catch (e) {
|
||||
toast({ text: `Move failed: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
moving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const sparkW = 600
|
||||
const sparkH = 80
|
||||
|
||||
@@ -84,6 +84,15 @@ export const useSourcesStore = defineStore('sources', () => {
|
||||
return await api.get('/api/artists/autocomplete', { params: { q: query, limit } })
|
||||
}
|
||||
|
||||
// #130: move a source (+ its content) to another artist. Files don't move
|
||||
// (slug is immutable); the backend re-attributes source/posts/images and
|
||||
// deletes the old artist if it's left empty.
|
||||
async function reassign(id, targetArtistId) {
|
||||
return await api.post(`/api/sources/${id}/reassign`, {
|
||||
body: { target_artist_id: targetArtistId },
|
||||
})
|
||||
}
|
||||
|
||||
async function loadScheduleStatus() {
|
||||
scheduleStatus.value = await api.get('/api/sources/schedule-status')
|
||||
return scheduleStatus.value
|
||||
@@ -177,7 +186,7 @@ export const useSourcesStore = defineStore('sources', () => {
|
||||
recoverSource,
|
||||
recaptureSource,
|
||||
previewSource,
|
||||
findOrCreateArtist, autocompleteArtist,
|
||||
findOrCreateArtist, autocompleteArtist, reassign,
|
||||
loadScheduleStatus,
|
||||
sourcesByArtistGrouped,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user