tag-builder: add + delete subjects (characters) via a 'Mine' tab

User-created characters now live in a Subjects -> Mine view with an 'Add
subject' tile; the character menu gains Delete (hide base) / Remove (drop
add). apply_overlay gains include_adds=False (used by the paginated
character browser + detail) so adds never leak into a LIMIT/OFFSET page.
This commit is contained in:
chrmc40
2026-06-29 23:50:07 -07:00
parent b710afef7e
commit dcc31e4eae
5 changed files with 1494 additions and 1215 deletions
+8 -4
View File
@@ -3501,9 +3501,11 @@ async def _api_characters(request):
params + [per_page, offset],
).fetchall()
# Merge user edits onto this page. 2b is edit-only, so no overlay adds
# exist to break LIMIT/OFFSET; adds/deletes (which would) are deferred.
merged = tag_overlay.apply_overlay("characters", [dict(r) for r in rows])
# Merge user edits + tombstone-deletes onto this page. Adds are suppressed
# here (include_adds=False): user-added characters live in the separate
# "Mine" view, and injecting them into a LIMIT/OFFSET page would duplicate
# them across pages and skew the total count.
merged = tag_overlay.apply_overlay("characters", [dict(r) for r in rows], include_adds=False)
return web.json_response({
"characters": merged,
@@ -3578,7 +3580,9 @@ async def _api_character_detail(request):
if not char:
return web.json_response({"error": "Character not found"}, status=404)
# Apply user edits (dict — downstream uses char["..."] / dict(char), both fine).
char = (tag_overlay.apply_overlay("characters", [dict(char)]) or [dict(char)])[0]
# include_adds=False: this row already exists in the base, and the Mine view
# owns added characters, so adds must never tag along on a single-row detail.
char = (tag_overlay.apply_overlay("characters", [dict(char)], include_adds=False) or [dict(char)])[0]
outfits = db.execute(
"SELECT * FROM outfits WHERE character_tag = ? ORDER BY sort_order, outfit_name",
+17 -10
View File
@@ -193,13 +193,19 @@ def _coerce_types(spec: dict, data: dict) -> None:
# ── the merge ────────────────────────────────────────────────────────
def apply_overlay(table: str, base_rows: list[dict], add_filter: dict | None = None) -> list[dict]:
def apply_overlay(table: str, base_rows: list[dict], add_filter: dict | None = None,
include_adds: bool = True) -> list[dict]:
"""Merge the user delta over freshly-read base rows. Unregistered tables and
tables with no overlay pass through unchanged, so every read site can call
this unconditionally and Phase-2 breadth is just registry lines.
add_filter (e.g. {"item_group": group}) scopes user-added rows to a filtered
query so an add in one group doesn't leak into another group's view.
include_adds=False applies only edits + deletes, never user-added rows. The
paginated character browser uses this: its adds live in a separate "Mine"
view, so injecting them here would duplicate them across every page (adds
aren't bounded by the SQL LIMIT/OFFSET) and skew the total count.
"""
spec = ENTITY_REGISTRY.get(table)
if spec is None:
@@ -240,15 +246,16 @@ def apply_overlay(table: str, base_rows: list[dict], add_filter: dict | None = N
out.append(merged)
seen.add(key)
for key, row in adds.items():
if key in deletes or key in seen:
continue
if add_filter and any(str(row.get(c, "")) != str(v) for c, v in add_filter.items()):
continue
merged = dict(row)
merged["_overlay"] = "add"
out.append(merged)
seen.add(key)
if include_adds:
for key, row in adds.items():
if key in deletes or key in seen:
continue
if add_filter and any(str(row.get(c, "")) != str(v) for c, v in add_filter.items()):
continue
merged = dict(row)
merged["_overlay"] = "add"
out.append(merged)
seen.add(key)
# Only re-sort tables that declare a sort column (the *_items buckets).
# Tables without one (e.g. characters, ordered server-side by post_count)
@@ -5994,6 +5994,25 @@
line-height: 1.3;
}
/* "Add subject" tile in Subjects → Mine. Dashed accent box that reads as an
action, not a content card. */
.pcr-atb2-card-add.svelte-ibd6yj {
align-items: center;
justify-content: center;
min-height: 72px;
border: 1px dashed #5b4b8a;
background: rgba(124, 58, 237, 0.07);
}
.pcr-atb2-card-add.svelte-ibd6yj:hover {
background: rgba(124, 58, 237, 0.16);
border-color: #8b5cf6;
}
.pcr-atb2-card-add-plus.svelte-ibd6yj {
font-size: 26px;
line-height: 1;
color: #b59cff;
}
/* ---- LIST VIEW ---- compact rows reusing the same card markup: tiny left
thumb + name (left) + series (right). Toggled via .pcr-atb2-list-mode on
the browser; works in every view (All / category / NSFW / search / recent). */
File diff suppressed because it is too large Load Diff
@@ -823,7 +823,8 @@
if (cat.enabled && cat.bucket) loadBucket(cat.bucket);
}
loadOverlayCount(); // show the "Your edits" button if any deltas exist
loadOverlayCount(); // show the "Your edits" button if any deltas exist
loadMyCharacters(); // populate the Subjects → Mine tab
// Preload normalized characters (tag + base_natlang etc.) so the
// round-trip parser can recognize both tag-form ("cammy_white") and
@@ -1358,8 +1359,12 @@
// blocks the grid/list, so the user can't click a stale (pre-edit) row
// until fresh data is in place — otherwise a quick insert emits the old
// value.
if (ed.kind === "character") await refreshCharacterRow(ed.initial?.tag);
else await reloadBucketItems(ed.bucket);
if (ed.kind === "character") {
// An add — or an edit of an already-added char — belongs to the Mine
// list; an edit of a base char is patched into the paginated list.
await loadMyCharacters();
if (ed.mode === "edit" && ed.initial?._overlay !== "add") await refreshCharacterRow(ed.initial?.tag);
} else await reloadBucketItems(ed.bucket);
overlayEditorOpen = null;
loadOverlayCount();
}
@@ -1473,6 +1478,56 @@
}
}
// --- User-added subjects ("Mine") -------------------------------------
// Added characters are kept OUT of the paginated browser (the server applies
// them with include_adds=False) — injecting them into a LIMIT/OFFSET page
// would duplicate them across pages and skew the count. They live here
// instead, read straight from the overlay file via the per-table summary.
const MINE_KEY = "__mine__";
let myCharacters = $state([]);
let myCharactersLoading = $state(false);
async function loadMyCharacters() {
myCharactersLoading = true;
try {
const res = await fetch("/promptchain/tag-builder/overlay/characters", { cache: "no-store" });
const adds = res.ok ? (await res.json()).adds || {} : {};
myCharacters = Object.values(adds).map(row => ({ ...row, _overlay: "add" }));
} catch (e) {
console.error("[TagBuilder2] load my characters failed", e);
myCharacters = [];
}
myCharactersLoading = false;
}
function openNewCharacterEditor() {
overlayEditorOpen = {
bucket: "character", table: "characters", mode: "add",
fields: CHARACTER_FIELDS, initial: {}, groups: [], kind: "character",
};
}
// Hide a base character (tombstone) or drop a user-added one — both route
// through the same overlay DELETE. The base case is reversible from the
// "Your edits" view; the added case is gone for good, so confirm either way.
async function deleteCharacter(char) {
closeCharEditMenu();
const label = char.display || char.tag;
const msg = char._overlay === "add"
? `Remove your added subject “${label}”?`
: `Hide “${label}” from the library? You can bring it back from your edits list.`;
if (typeof window !== "undefined" && !window.confirm(msg)) return;
try {
const res = await fetch(
`/promptchain/tag-builder/overlay/characters/${encodeURIComponent(char.tag)}`,
{ method: "DELETE" });
if (!res.ok) { console.error(`[TagBuilder2] character delete failed: ${res.status}`); return; }
} catch (e) { console.error("[TagBuilder2] character delete error", e); return; }
characters = characters.filter(c => c.tag !== char.tag);
await loadMyCharacters();
loadOverlayCount();
}
// --- "Your edits" management view -------------------------------------
const OVERLAY_TABLE_LABELS = {
cast_items: "Cast", appearance_items: "Appearance", clothing_items: "Clothing",
@@ -1667,6 +1722,7 @@
activeSubjectSubitem = activeSubjectSubitem === key ? null : key;
searchQuery = "";
debouncedQuery = "";
if (key === MINE_KEY) loadMyCharacters(); // refresh in case edited elsewhere
scrollBrowserTop();
}
@@ -5380,6 +5436,18 @@
{/if}
</div>
{/each}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="pcr-atb2-rail-sub pcr-atb2-rail-sub-mine"
class:active={activeSubjectSubitem === MINE_KEY}
onclick={() => selectSubjectSubitem(MINE_KEY)}
>
<span class="pcr-atb2-rail-sub-label">⭐ Mine</span>
{#if myCharacters.length}
<span class="pcr-atb2-rail-count">{myCharacters.length}</span>
{/if}
</div>
</div>
{:else if isActive && cat.key === "styles" && styleGroups.length}
<div class="pcr-atb2-rail-drilldown">
@@ -5624,8 +5692,38 @@
{/each}
{/if}
{:else if activeCategory === "subjects"}
{@const sub = activeSubjectSubitem ? SUBJECT_SUBITEMS.find(s => s.key === activeSubjectSubitem) : null}
{#if !sub || sub.source === "characters"}
{@const sub = activeSubjectSubitem && activeSubjectSubitem !== MINE_KEY ? SUBJECT_SUBITEMS.find(s => s.key === activeSubjectSubitem) : null}
{#if activeSubjectSubitem === MINE_KEY}
<div class="pcr-atb2-grid">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pcr-atb2-card pcr-atb2-card-add" onclick={openNewCharacterEditor} title="Create a new subject">
<div class="pcr-atb2-card-add-plus">+</div>
<div class="pcr-atb2-card-name">Add subject</div>
</div>
{#each myCharacters as char (char.tag)}
{@const isActiveIdentity = activeSubject?.character?.tag === char.tag}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="pcr-atb2-card"
class:selected={isActiveIdentity}
onclick={() => isActiveIdentity ? unbindIdentity(activeSubject.id) : pickIdentityFromBrowser(characterToOption(char))}
oncontextmenu={(e) => openCharEditMenu(e, char)}
title={char.base_natlang || `${char.display || char.tag}${char.series ? " — " + char.series : ""}`}
>
<div class="pcr-atb2-card-thumb"></div>
<div class="pcr-atb2-card-name">{char.display || char.tag}</div>
{#if char.series}
<div class="pcr-atb2-card-group">{char.series}</div>
{/if}
</div>
{/each}
</div>
{#if !myCharacters.length}
<div class="pcr-atb2-empty">No added subjects yet. Click “Add subject” to create one — it survives updates.</div>
{/if}
{:else if !sub || sub.source === "characters"}
{#if charactersLoading}
<div class="pcr-atb2-empty">Searching characters…</div>
{:else if characters.length === 0}
@@ -6572,6 +6670,9 @@
↩️ Restore default
</button>
{/if}
<button class="pcr-atb2-qa-menu-item pcr-atb2-qa-menu-danger" onclick={() => deleteCharacter(charEditMenu.char)}>
{charEditMenu.char._overlay === "add" ? "🗑️ Remove" : "🗑️ Delete (hide)"}
</button>
</div>
</div>
{/if}
@@ -6880,6 +6981,25 @@
line-height: 1.3;
}
/* "Add subject" tile in Subjects → Mine. Dashed accent box that reads as an
action, not a content card. */
.pcr-atb2-card-add {
align-items: center;
justify-content: center;
min-height: 72px;
border: 1px dashed #5b4b8a;
background: rgba(124, 58, 237, 0.07);
}
.pcr-atb2-card-add:hover {
background: rgba(124, 58, 237, 0.16);
border-color: #8b5cf6;
}
.pcr-atb2-card-add-plus {
font-size: 26px;
line-height: 1;
color: #b59cff;
}
/* ---- LIST VIEW ---- compact rows reusing the same card markup: tiny left
thumb + name (left) + series (right). Toggled via .pcr-atb2-list-mode on
the browser; works in every view (All / category / NSFW / search / recent). */