diff --git a/public/market.html b/public/market.html index e3b7346..c38d05c 100644 --- a/public/market.html +++ b/public/market.html @@ -37,6 +37,42 @@ + diff --git a/public/market.js b/public/market.js index 584a505..80b4816 100644 --- a/public/market.js +++ b/public/market.js @@ -3,7 +3,18 @@ const marketUserPersonaList = document.getElementById("marketUserPersonaList"); const refreshMarketBtn = document.getElementById("refreshMarket"); const marketStatus = document.getElementById("marketStatus"); const marketSearchInput = document.getElementById("marketSearch"); +const marketPreviewModal = document.getElementById("marketPreviewModal"); +const marketPreviewName = document.getElementById("marketPreviewName"); +const marketPreviewMeta = document.getElementById("marketPreviewMeta"); +const marketPreviewPronouns = document.getElementById("marketPreviewPronouns"); +const marketPreviewAppearance = document.getElementById("marketPreviewAppearance"); +const marketPreviewBackground = document.getElementById("marketPreviewBackground"); +const marketPreviewDetails = document.getElementById("marketPreviewDetails"); +const marketPreviewConfirm = document.getElementById("marketPreviewConfirm"); +const marketPreviewCancel = document.getElementById("marketPreviewCancel"); +const marketPreviewClose = document.getElementById("marketPreviewClose"); let allMarketPersonas = []; +let pendingMarketPersona = null; async function request(url, data, method = "POST") { try { @@ -81,8 +92,8 @@ function renderMarketList(personaItems, listElement, personaType) { const getBtn = document.createElement("button"); getBtn.type = "button"; - getBtn.textContent = personaType === "assistant" ? "Get & equip as AI" : "Get & equip as You"; - getBtn.addEventListener("click", () => collectMarketPersona(persona.id, persona.persona_type)); + getBtn.textContent = "View details"; + getBtn.addEventListener("click", () => openMarketPreview(persona)); actions.appendChild(getBtn); item.appendChild(titleRow); @@ -92,6 +103,30 @@ function renderMarketList(personaItems, listElement, personaType) { }); } +function formatPersonaField(value) { + return value && value.trim() ? value.trim() : "Not provided."; +} + +function openMarketPreview(persona) { + if (!marketPreviewModal) return; + pendingMarketPersona = persona; + marketPreviewName.textContent = persona.name; + marketPreviewMeta.textContent = buildPersonaMeta(persona); + marketPreviewPronouns.textContent = formatPersonaField(persona.pronouns); + marketPreviewAppearance.textContent = formatPersonaField(persona.appearance); + marketPreviewBackground.textContent = formatPersonaField(persona.background); + marketPreviewDetails.textContent = formatPersonaField(persona.details); + marketPreviewConfirm.textContent = + persona.persona_type === "assistant" ? "Get & equip as AI" : "Get & equip as You"; + marketPreviewModal.classList.remove("hidden"); +} + +function closeMarketPreview() { + if (!marketPreviewModal) return; + marketPreviewModal.classList.add("hidden"); + pendingMarketPersona = null; +} + function filterMarketPersonas() { const query = marketSearchInput?.value.trim().toLowerCase() || ""; if (!query) return allMarketPersonas; @@ -160,6 +195,31 @@ if (marketSearchInput) { marketSearchInput.addEventListener("input", () => applyMarketFilter()); } +if (marketPreviewCancel) { + marketPreviewCancel.addEventListener("click", closeMarketPreview); +} + +if (marketPreviewClose) { + marketPreviewClose.addEventListener("click", closeMarketPreview); +} + +if (marketPreviewModal) { + marketPreviewModal.addEventListener("click", (event) => { + if (event.target === marketPreviewModal) { + closeMarketPreview(); + } + }); +} + +if (marketPreviewConfirm) { + marketPreviewConfirm.addEventListener("click", async () => { + if (!pendingMarketPersona) return; + const {id, persona_type} = pendingMarketPersona; + closeMarketPreview(); + await collectMarketPersona(id, persona_type); + }); +} + window.addEventListener("load", () => { loadMarketPersonas(); }); diff --git a/public/script.js b/public/script.js index f620ad5..be1f6da 100644 --- a/public/script.js +++ b/public/script.js @@ -306,6 +306,14 @@ function renderPersonaList(personaItems, activeId, listElement, personaType) { publishBtn.textContent = isPublished ? 'Update listing' : 'Publish'; publishBtn.addEventListener('click', () => publishPersona(persona.id)); + if (isPublished) { + const unpublishBtn = document.createElement('button'); + unpublishBtn.type = 'button'; + unpublishBtn.textContent = 'Unpublish'; + unpublishBtn.addEventListener('click', () => unpublishPersona(persona.id)); + actions.appendChild(unpublishBtn); + } + const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.textContent = 'Delete'; @@ -402,6 +410,12 @@ async function publishPersona(personaId) { await loadPersonas(); } +async function unpublishPersona(personaId) { + const res = await post(`/personas/${personaId}/unpublish`, {}); + if (res.error) return; + await loadPersonas(); +} + function setLoadingState(loading) { isProcessing = loading; sendBtn.disabled = loading; diff --git a/public/style.css b/public/style.css index a8c243f..a158d17 100644 --- a/public/style.css +++ b/public/style.css @@ -992,6 +992,16 @@ a.secondary-action { grid-template-columns: 1fr; } + .chat-header { + flex-direction: column; + align-items: flex-start; + } + + .chat-header-actions { + width: 100%; + justify-content: flex-start; + } + .chat-sidebar { order: 2; } @@ -1020,6 +1030,35 @@ a.secondary-action { h1 { font-size: 1.6rem; } + + .persona-popover { + width: 95vw; + right: auto; + left: 0; + } + + .persona-item-actions { + flex-direction: column; + align-items: stretch; + } + + .market-shell { + padding: 20px; + } + + .market-header { + flex-direction: column; + align-items: stretch; + } + + .market-header-actions { + width: 100%; + justify-content: flex-start; + } + + .market-search { + max-width: none; + } } @media (max-width: 480px) { @@ -1039,6 +1078,15 @@ a.secondary-action { #messages { padding: 14px; } + + .market-preview-actions { + flex-direction: column; + align-items: stretch; + } + + .market-preview-details { + grid-template-columns: 1fr; + } } .consent-container { @@ -1109,6 +1157,44 @@ html, body { padding: 16px; } +.market-preview-card { + width: min(640px, 95vw); + display: grid; + gap: 12px; +} + +.market-preview-body { + display: grid; + gap: 12px; +} + +.market-preview-details { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.market-preview-details h4 { + margin: 0 0 4px; + font-size: 0.85rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.market-preview-details p { + margin: 0; + font-size: 0.9rem; + color: var(--text-primary); +} + +.market-preview-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + justify-content: flex-end; +} + .modal-header { display: flex; align-items: center; diff --git a/server.js b/server.js index 7dc54b1..de3d9e9 100644 --- a/server.js +++ b/server.js @@ -529,6 +529,12 @@ const listPublishedPersonaIdsStmt = db.prepare(` WHERE creator_username = ? `); +const deleteMarketPersonaByPersonaIdStmt = db.prepare(` + DELETE FROM persona_market + WHERE persona_id = ? + AND creator_username = ? +`); + const incrementMarketUsageCountStmt = db.prepare(` UPDATE persona_market SET usage_count = usage_count + 1 @@ -835,6 +841,7 @@ app.delete("/personas/:id", requireLogin, (req, res) => { if (activePersonas.active_user_persona_id === personaId) { setActiveUserPersonaStmt.run(user, null); } + deleteMarketPersonaByPersonaIdStmt.run(personaId, user); res.json({message: "Persona deleted"}); }); @@ -901,6 +908,23 @@ app.post("/personas/:id/publish", requireLogin, (req, res) => { res.json({persona: marketPersona}); }); +app.post("/personas/:id/unpublish", requireLogin, (req, res) => { + const user = req.session.user; + const personaId = Number(req.params.id); + const persona = getPersonaForPublishStmt.get(personaId, user); + if (!persona) { + return res.status(404).json({error: "Persona not found"}); + } + if (persona.source_market_id) { + return res.status(403).json({error: "Collected personas cannot be unpublished."}); + } + const result = deleteMarketPersonaByPersonaIdStmt.run(personaId, user); + if (result.changes === 0) { + return res.status(404).json({error: "Persona is not published."}); + } + res.json({message: "Persona unpublished"}); +}); + app.post("/personas/market/:id/collect", requireLogin, (req, res) => { const user = req.session.user; const marketId = Number(req.params.id);