Added actors admin panel with bulk merge. Fixed merge failing if source and target actor have conflicting network profiles.

This commit is contained in:
2026-06-17 00:05:42 +02:00
parent 994908ef6a
commit 721eaa5d07
28 changed files with 548 additions and 52 deletions

View File

@@ -384,7 +384,7 @@
<Merge
v-if="showMergeDialog"
:actor="actor"
:actors="[actor]"
@close="showMergeDialog = false"
/>

View File

@@ -1,6 +1,6 @@
<template>
<Dialog
:title="`Merge '${actor.name}'`"
:title="`Merge ${actors.length === 1 ? `'${actors[0].name}'` : `${actors.length} actors`}`"
@close="emit('close')"
>
<div
@@ -12,9 +12,9 @@
<ul class="options">
<li class="option">
<a
:href="`/actor/${actor?.id}/${actor?.slug}`"
href=""
class="link"
>Reload #{{ actor.id }} {{ actor.name }} ({{ actor.entity.name }})</a>
>Reload page</a>
</li>
<li class="option">
@@ -30,7 +30,18 @@
v-else
class="dialog-body"
>
<strong class="source">#{{ actor.id }} {{ actor.name }}<span v-if="actor.entity"> ({{ actor.entity.name }})</span></strong>
<ul class="actors nolist">
<li
v-for="actor in actors"
:key="`actor-${actor.id}`"
class="actor"
>
<span class="source">
<strong class="source-name">{{ actor.name }}<template v-if="actor.entity"> ({{ actor.entity.name }})</template></strong>
<span class="source-id">#{{ actor.id }}</span>
</span>
</li>
</ul>
<span class="path">merging into</span>
@@ -88,9 +99,10 @@
<button
v-else
v-tooltip="sourceTargetConflict && 'Cannot merge actor profile into itself'"
type="submit"
class="button button-primary"
:disabled="!targetActor"
:disabled="!targetActor || sourceTargetConflict"
@click="merge"
>Merge</button>
</div>
@@ -99,7 +111,7 @@
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import Dialog from '#/components/dialog/dialog.vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
@@ -107,9 +119,9 @@ import Ellipsis from '#/components/loading/ellipsis.vue';
import { get, post } from '#/src/api.js';
const props = defineProps({
actor: {
type: Object,
default: null,
actors: {
type: Array,
default: () => [],
},
});
@@ -122,9 +134,11 @@ const actorResults = ref([]);
const submitted = ref(false);
const merged = ref(false);
const sourceTargetConflict = computed(() => props.actors.some((actor) => actor.id === targetActor.value?.id));
async function searchActors() {
const res = await get('/actors', {
q: actorQuery.value.charAt(0) === '#' ? actorQuery.value : `${actorQuery.value}*`, // return partial matches
q: actorQuery.value, // return partial matches
limit: 10,
global: true,
});
@@ -132,12 +146,24 @@ async function searchActors() {
actorResults.value = res.actors;
}
function getActorNames() {
if (props.actors.length > 1) {
return `${props.actors.length} actors`;
}
if (props.actors[0].entity) {
return `${props.actors[0].name} (${props.actors[0].entity.name})`;
}
return props.actors[0].name;
}
async function merge() {
submitted.value = true;
await post(`/actors/${targetActor.value.id}/merge/${props.actor.id}`, null, {
successFeedback: `Merged ${props.actor.entity ? `${props.actor.name} (${props.actor.entity.name})` : props.actor.name} into ${targetActor.value.name}`,
errorFeedback: `Failed to merge ${props.actor.entity ? `${props.actor.name} (${props.actor.entity.name})` : props.actor.name} into ${targetActor.value.name}`,
await post(`/actors/${targetActor.value.id}/merge/${props.actors.map((actor) => actor.id).join(',')}`, null, {
successFeedback: `Merged ${getActorNames()} into ${targetActor.value.name}`,
errorFeedback: `Failed to merge ${getActorNames()} into ${targetActor.value.name}`,
appendErrorMessage: true,
});
@@ -167,6 +193,7 @@ onMounted(() => {
box-sizing: border-box;
padding: 1rem;
gap: 1rem;
overflow: hidden;
}
.dialog-actions {
@@ -175,6 +202,15 @@ onMounted(() => {
}
}
.actors {
max-height: 15rem;
overflow-y: auto;
}
.actor {
display: block;
}
.input {
width: 100%;
}
@@ -199,11 +235,20 @@ onMounted(() => {
}
}
.source-id,
.target-id {
font-family: monospace;
font-size: 1rem;
}
.source {
display: flex;
}
.source-name {
flex-grow: 1;
}
.results {
padding: .25rem 0;
}

View File

@@ -128,6 +128,10 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
:deep(.bookmarks) .icon:not(.favorited):not(:hover) {
fill: var(--text-light);
}
.menu {
fill: var(--text-light);
}
}
&.unstashed {
@@ -135,6 +139,14 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
}
}
.menu {
padding: .5rem .75rem;
position: absolute;
top: 0;
left: 0;
fill: var(--highlight-strong-20);
}
.label {
display: flex;
justify-content: space-between;

View File

@@ -2,6 +2,14 @@
<div class="page">
<nav class="nav">
<ul class="nav-items nolist">
<li class="nav-item">
<a
href="/admin/actors"
class="nav-link nolink"
:class="{ active: pageContext.routeParams.section === 'actors' }"
>Actors</a>
</li>
<li class="nav-item">
<a
href="/admin/revisions/scenes"