diff --git a/PlotLine/Views/WordCompanionHost/Index.cshtml b/PlotLine/Views/WordCompanionHost/Index.cshtml index 62b0ec3..a1e65e3 100644 --- a/PlotLine/Views/WordCompanionHost/Index.cshtml +++ b/PlotLine/Views/WordCompanionHost/Index.cshtml @@ -110,17 +110,22 @@ diff --git a/PlotLine/wwwroot/css/word-companion.css b/PlotLine/wwwroot/css/word-companion.css index 22a3383..de91089 100644 --- a/PlotLine/wwwroot/css/word-companion.css +++ b/PlotLine/wwwroot/css/word-companion.css @@ -284,27 +284,48 @@ body { white-space: pre-wrap; } -.word-companion-chip-list { - display: flex; - flex-wrap: wrap; +.word-companion-check-list { + display: grid; gap: 5px; } -.word-companion-chip, -.word-companion-empty-chip { - border: 1px solid rgba(47, 111, 99, 0.25); - border-radius: 999px; - padding: 3px 7px; - background: var(--companion-soft); - color: var(--companion-accent-dark); - font-size: 0.82rem; - font-weight: 700; +.word-companion-check-item { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 7px; + min-height: 30px; + border: 1px solid rgba(47, 111, 99, 0.18); + border-radius: 6px; + padding: 5px 7px; + background: #ffffff; + color: var(--companion-ink); + font-size: 0.9rem; } -.word-companion-empty-chip { - border-color: var(--companion-line); - color: var(--companion-muted); +.word-companion-check-item input { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--companion-accent); +} + +.word-companion-check-item span { + color: var(--companion-ink); + font-size: 0.9rem; font-weight: 600; + text-transform: none; +} + +.word-companion-check-item:has(input:disabled) { + background: var(--companion-soft); + color: var(--companion-muted); +} + +.word-companion-empty-list { + margin: 0; + color: var(--companion-muted); + font-size: 0.88rem; } .word-companion-outline { diff --git a/PlotLine/wwwroot/js/word-companion-host.js b/PlotLine/wwwroot/js/word-companion-host.js index cabc68e..a38d916 100644 --- a/PlotLine/wwwroot/js/word-companion-host.js +++ b/PlotLine/wwwroot/js/word-companion-host.js @@ -43,6 +43,8 @@ const characterChips = document.querySelector("[data-character-chips]"); const assetChips = document.querySelector("[data-asset-chips]"); const locationChips = document.querySelector("[data-location-chips]"); + const saveSceneLinksButton = document.querySelector("[data-save-scene-links]"); + const sceneLinksStatus = document.querySelector("[data-scene-links-status]"); const resolveProjectId = document.querySelector("[data-resolve-project-id]"); const resolveProjectTitle = document.querySelector("[data-resolve-project-title]"); const resolveBookId = document.querySelector("[data-resolve-book-id]"); @@ -75,6 +77,11 @@ let detectedSceneTitle = ""; let detectedSceneWordCount = null; let resolvedSceneId = null; + let currentSceneLinks = { + characters: [], + assets: [], + locations: [] + }; const setOfficeStatus = (message) => { if (officeStatus) { @@ -147,7 +154,11 @@ if (syncSceneProgressButton) { syncSceneProgressButton.disabled = !resolvedSceneId; } + if (saveSceneLinksButton) { + saveSceneLinksButton.disabled = !resolvedSceneId; + } setSyncStatus(resolvedSceneId ? "Ready to sync." : "No resolved PlotDirector scene."); + setSceneLinksStatus(resolvedSceneId ? "Ready to save." : "No resolved PlotDirector scene."); }; const setRefreshCurrentSceneBusy = (isBusy) => { @@ -167,11 +178,19 @@ return String(value); }; + const setSceneLinksStatus = (message) => { + setText(sceneLinksStatus, message); + }; + const resetPlotDirectorScene = (message) => { setPlotDirectorSceneStatus(message); setHidden(plotDirectorSceneDetails, true); setHidden(writingBriefBlock, true); setHidden(plotDirectorLinkGroups, true); + currentSceneLinks = { characters: [], assets: [], locations: [] }; + renderSceneLinkList(characterChips, [], "character"); + renderSceneLinkList(assetChips, [], "asset"); + renderSceneLinkList(locationChips, [], "location"); setResolvedScene(null); setText(syncPlotDirectorCount, "-"); setText(plotDirectorSceneTitle, "-"); @@ -183,26 +202,53 @@ setHidden(plotDirectorBlockedReasonRow, true); }; - const renderLinkedItems = (container, items) => { + const linkItemId = (item, type) => { + if (type === "asset") { + return item?.assetId || item?.AssetId || item?.storyAssetId || item?.StoryAssetId || 0; + } + + if (type === "location") { + return item?.locationId || item?.LocationId || 0; + } + + return item?.characterId || item?.CharacterId || 0; + }; + + const renderSceneLinkList = (container, items, type, enabled = false) => { if (!container) { return; } container.innerHTML = ""; - const linkedItems = Array.isArray(items) ? items.filter((item) => item.linked) : []; - if (linkedItems.length === 0) { - const empty = document.createElement("span"); - empty.className = "word-companion-empty-chip"; + const rows = Array.isArray(items) ? items : []; + if (rows.length === 0) { + const empty = document.createElement("p"); + empty.className = "word-companion-empty-list"; empty.textContent = "None"; container.append(empty); return; } - for (const item of linkedItems) { - const chip = document.createElement("span"); - chip.className = "word-companion-chip"; - chip.textContent = item.name || "Unnamed"; - container.append(chip); + for (const item of rows) { + const id = Number.parseInt(linkItemId(item, type), 10); + if (!Number.isInteger(id) || id <= 0) { + continue; + } + + const label = document.createElement("label"); + label.className = "word-companion-check-item"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.value = String(id); + checkbox.checked = !!item.linked; + checkbox.disabled = !enabled; + + const text = document.createElement("span"); + text.textContent = item.name || "Unnamed"; + + label.append(checkbox, text); + container.append(label); } }; @@ -226,9 +272,15 @@ writingBrief.textContent = data?.writingBrief || "No writing brief has been added for this scene."; } - renderLinkedItems(characterChips, data?.characters); - renderLinkedItems(assetChips, data?.assets); - renderLinkedItems(locationChips, data?.locations); + currentSceneLinks = { + characters: Array.isArray(data?.characters) ? data.characters : [], + assets: Array.isArray(data?.assets) ? data.assets : [], + locations: Array.isArray(data?.locations) ? data.locations : [] + }; + renderSceneLinkList(characterChips, currentSceneLinks.characters, "character", !!resolvedSceneId); + renderSceneLinkList(assetChips, currentSceneLinks.assets, "asset", !!resolvedSceneId); + renderSceneLinkList(locationChips, currentSceneLinks.locations, "location", !!resolvedSceneId); + setSceneLinksStatus(resolvedSceneId ? "Ready to save." : "No resolved PlotDirector scene."); setHidden(plotDirectorSceneDetails, false); setHidden(writingBriefBlock, false); setHidden(plotDirectorLinkGroups, false); @@ -711,6 +763,63 @@ body: JSON.stringify(body) }); + const putJson = (url, body) => fetchJson(url, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + + const selectedLinkIds = (container) => { + if (!container) { + return []; + } + + return [...container.querySelectorAll("input[type='checkbox']:checked")] + .map((checkbox) => Number.parseInt(checkbox.value || "", 10)) + .filter((id) => Number.isInteger(id) && id > 0); + }; + + const updateCurrentSceneLinkState = () => { + const selectedCharacters = new Set(selectedLinkIds(characterChips)); + const selectedAssets = new Set(selectedLinkIds(assetChips)); + const selectedLocations = new Set(selectedLinkIds(locationChips)); + + currentSceneLinks = { + characters: currentSceneLinks.characters.map((item) => ({ ...item, linked: selectedCharacters.has(Number.parseInt(linkItemId(item, "character"), 10)) })), + assets: currentSceneLinks.assets.map((item) => ({ ...item, linked: selectedAssets.has(Number.parseInt(linkItemId(item, "asset"), 10)) })), + locations: currentSceneLinks.locations.map((item) => ({ ...item, linked: selectedLocations.has(Number.parseInt(linkItemId(item, "location"), 10)) })) + }; + }; + + const saveSceneLinks = async () => { + if (!resolvedSceneId) { + setSceneLinksStatus("No resolved PlotDirector scene."); + return; + } + + if (saveSceneLinksButton) { + saveSceneLinksButton.disabled = true; + saveSceneLinksButton.textContent = "Saving..."; + } + + try { + await putJson(`/api/word-companion/scenes/${resolvedSceneId}/links`, { + characterIds: selectedLinkIds(characterChips), + assetIds: selectedLinkIds(assetChips), + locationIds: selectedLinkIds(locationChips) + }); + updateCurrentSceneLinkState(); + setSceneLinksStatus("Scene links saved successfully."); + } catch { + setSceneLinksStatus("Unable to save scene links."); + } finally { + if (saveSceneLinksButton) { + saveSceneLinksButton.disabled = !resolvedSceneId; + saveSceneLinksButton.textContent = "Save Scene Links"; + } + } + }; + const syncSceneProgress = async () => { if (!resolvedSceneId) { setSyncStatus("No resolved PlotDirector scene."); @@ -836,8 +945,8 @@ } setPlotDirectorSceneStatus("Linked to PlotDirector"); setDocumentMessage(""); - renderCompanionData(companionData); setResolvedScene(resolvedSceneId); + renderCompanionData(companionData); return true; } catch { resetPlotDirectorScene("Not found in PlotDirector"); @@ -985,6 +1094,7 @@ refreshPlotDirectorSceneButton?.addEventListener("click", refreshPlotDirectorScene); refreshCurrentSceneButton?.addEventListener("click", refreshCurrentScene); syncSceneProgressButton?.addEventListener("click", syncSceneProgress); + saveSceneLinksButton?.addEventListener("click", saveSceneLinks); runDiagnosticsButton?.addEventListener("click", runDiagnostics); if (isAuthenticated) {