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:
+8
-4
@@ -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
@@ -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). */
|
||||
|
||||
+1325
-1196
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). */
|
||||
|
||||
Reference in New Issue
Block a user