| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351 |
- const { div, h2, h3, h4, p, section, button, form, a, span, br, textarea, input, label, select, option, table, tr, td } = require("../server/node_modules/hyperaxe")
- const { template, i18n } = require("./main_views")
- const moment = require("../server/node_modules/moment")
- const { config } = require("../server/SSB_server.js")
- const userId = config.keys.id
- const PAD_COLOR_CLASSES = ["pad-author-color-0","pad-author-color-1","pad-author-color-2","pad-author-color-3","pad-author-color-4","pad-author-color-5","pad-author-color-6","pad-author-color-7","pad-author-color-8","pad-author-color-9"]
- const memberColorClass = (members, feedId) => {
- const idx = members.indexOf(feedId)
- return idx >= 0 ? PAD_COLOR_CLASSES[idx % PAD_COLOR_CLASSES.length] : "pad-author-color-none"
- }
- const sliceChunksByOffset = (chunks, from, to) => {
- const out = []
- let pos = 0
- for (const c of chunks) {
- const cStart = pos
- const cEnd = pos + c.text.length
- if (cEnd <= from) { pos = cEnd; continue }
- if (cStart >= to) break
- const sliceStart = Math.max(0, from - cStart)
- const sliceEnd = Math.min(c.text.length, to - cStart)
- if (sliceEnd > sliceStart) out.push({ text: c.text.slice(sliceStart, sliceEnd), author: c.author })
- pos = cEnd
- }
- return out
- }
- const mergeAdjacent = (chunks) => {
- const out = []
- for (const c of chunks) {
- if (!c.text) continue
- if (out.length > 0 && out[out.length - 1].author === c.author) {
- out[out.length - 1].text += c.text
- } else {
- out.push({ ...c })
- }
- }
- return out
- }
- const computeAttributedChunks = (entries) => {
- if (!entries || entries.length === 0) return []
- let chunks = [{ text: entries[0].text || "", author: entries[0].author }]
- for (let i = 1; i < entries.length; i++) {
- const prev = entries[i - 1].text || ""
- const curr = entries[i].text || ""
- const author = entries[i].author
- let start = 0
- const maxStart = Math.min(prev.length, curr.length)
- while (start < maxStart && prev.charCodeAt(start) === curr.charCodeAt(start)) start++
- let endPrev = prev.length
- let endCurr = curr.length
- while (endPrev > start && endCurr > start && prev.charCodeAt(endPrev - 1) === curr.charCodeAt(endCurr - 1)) {
- endPrev--
- endCurr--
- }
- const inserted = curr.slice(start, endCurr)
- const headChunks = sliceChunksByOffset(chunks, 0, start)
- const tailChunks = sliceChunksByOffset(chunks, endPrev, prev.length)
- const middle = inserted ? [{ text: inserted, author }] : []
- chunks = mergeAdjacent([...headChunks, ...middle, ...tailChunks])
- }
- return chunks
- }
- const renderStatus = (status, isClosed) => {
- if (isClosed) return span({ class: "pad-status-closed" }, i18n.padStatusClosed || "CLOSED")
- if (status === "INVITE-ONLY") return span({ class: "pad-status-invite" }, i18n.padStatusInviteOnly || "INVITE-ONLY")
- return span({ class: "pad-status-open" }, i18n.padStatusOpen || "OPEN")
- }
- const renderModeButtons = (currentFilter) =>
- div({ class: "tribe-mode-buttons" },
- ["all", "mine", "recent", "open", "closed"].map(f =>
- form({ method: "GET", action: "/pads" },
- input({ type: "hidden", name: "filter", value: f }),
- button({ type: "submit", class: currentFilter === f ? "filter-btn active" : "filter-btn" },
- i18n[`padFilter${f.charAt(0).toUpperCase() + f.slice(1)}`] || f.toUpperCase())
- )
- ),
- form({ method: "GET", action: "/pads" },
- input({ type: "hidden", name: "filter", value: "create" }),
- button({ type: "submit", class: "create-button" }, i18n.padCreate || "Create Pad")
- )
- )
- const renderPadCard = (pad, filter) => {
- const returnTo = `/pads?filter=${encodeURIComponent(filter || "all")}`
- return div({ class: "tribe-card" },
- div({ class: "tribe-card-body" },
- h2({ class: "tribe-card-title" },
- span(null, "\uD83D\uDD12 "),
- a({ href: `/pads/${encodeURIComponent(pad.rootId)}` }, pad.title || "\u2014")
- ),
- table({ class: "tribe-info-table" },
- tr(td(i18n.padStatusLabel || "Status"), td(renderStatus(pad.status, pad.isClosed))),
- pad.deadline ? tr(td(i18n.padDeadlineLabel || "Deadline"), td(moment(pad.deadline).format("YYYY-MM-DD HH:mm"))) : null
- ),
- div({ class: "tribe-card-members" },
- span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`)
- ),
- div({ class: "visit-btn-centered" },
- a({ href: `/pads/${encodeURIComponent(pad.rootId)}`, class: "filter-btn" }, i18n.padVisitPad || "Visit Pad")
- )
- )
- )
- }
- const renderCreateForm = (padToEdit, params) => {
- const tribeId = (params && params.tribeId) || ""
- return div({ class: "div-center audio-form" },
- h2(padToEdit ? (i18n.padUpdateSectionTitle || "Update Pad") : (i18n.padCreateSectionTitle || "Create New Pad")),
- form({
- method: "POST",
- action: padToEdit ? `/pads/update/${encodeURIComponent(padToEdit.rootId)}` : "/pads/create"
- },
- tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
- span(i18n.padTitleLabel || "Title"), require("../server/node_modules/hyperaxe").br(),
- input({ type: "text", name: "title", value: padToEdit ? padToEdit.title : "", placeholder: i18n.padTitlePlaceholder || "Enter pad title...", required: true }),
- require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(),
- span(i18n.padStatusLabel || "Status"), require("../server/node_modules/hyperaxe").br(),
- select({ name: "status" },
- ["OPEN", "INVITE-ONLY"].map(s =>
- option({ value: s, ...(padToEdit && padToEdit.status === s ? { selected: true } : {}) }, s)
- )
- ),
- require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(),
- span(i18n.padDeadlineLabel || "Deadline"), require("../server/node_modules/hyperaxe").br(),
- input({
- type: "datetime-local",
- name: "deadline",
- value: padToEdit && padToEdit.deadline ? moment(padToEdit.deadline).format("YYYY-MM-DDTHH:mm") : "",
- min: moment().format("YYYY-MM-DDTHH:mm")
- }),
- require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(),
- span(i18n.padTagsLabel || "Tags"), require("../server/node_modules/hyperaxe").br(),
- input({ type: "text", name: "tags", value: padToEdit ? padToEdit.tags.join(", ") : "", placeholder: i18n.padTagsPlaceholder || "tag1, tag2, ..." }),
- require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(),
- button({ type: "submit", class: "create-button" }, padToEdit ? (i18n.padUpdate || "Update Pad") : (i18n.padCreate || "Create Pad"))
- )
- )
- }
- exports.renderPadInvitePage = (code) => {
- const pageContent = div({ class: "invite-page" },
- h2(i18n.tribeInviteCodeText, code),
- form({ method: "GET", action: "/pads" },
- input({ type: "hidden", name: "filter", value: "all" }),
- button({ type: "submit", class: "filter-btn" }, i18n.walletBack)
- )
- )
- return template(i18n.padInviteMode || "Invite", section(pageContent))
- }
- exports.padsView = async (pads, filter, padToEdit, params) => {
- const q = String((params && params.q) || "").trim()
- const isForm = filter === "create" || filter === "edit"
- const headerMap = {
- all: i18n.padAllSectionTitle || "Pads",
- mine: i18n.padMineSectionTitle || "Your Pads",
- recent: i18n.padRecentSectionTitle || "Recent Pads",
- open: i18n.padOpenSectionTitle || "Open Pads",
- closed: i18n.padClosedSectionTitle || "Closed Pads"
- }
- const headerText = headerMap[filter] || headerMap.all
- const filteredPads = q
- ? pads.filter(pd => String(pd.title || "").toLowerCase().includes(q.toLowerCase()))
- : pads
- const body = div({ class: "main-column" },
- div({ class: "tags-header" },
- h2(headerText),
- p(i18n.padsDescription || "Manage collaborative encrypted text editors in your network.")
- ),
- renderModeButtons(filter),
- !isForm
- ? div({ class: "filters" },
- form({ method: "GET", action: "/pads" },
- input({ type: "hidden", name: "filter", value: filter }),
- input({ type: "text", name: "q", placeholder: i18n.padSearchPlaceholder || "Search pads...", value: q }),
- br(),
- button({ type: "submit" }, i18n.search),
- br()
- )
- )
- : null,
- isForm
- ? renderCreateForm(padToEdit, params)
- : div(
- filteredPads.length === 0
- ? p({ class: "no-content" }, i18n.padsNoItems || "No pads found.")
- : div({ class: "tribe-grid" }, ...filteredPads.map(pd => renderPadCard(pd, filter)))
- )
- )
- return template(i18n.padsTitle || "Pads", body)
- }
- exports.singlePadView = async (pad, entries, params) => {
- const isAuthor = String(pad.author) === String(userId)
- const isMember = pad.members.includes(userId)
- const padClosed = pad.isClosed
- const returnTo = `/pads/${encodeURIComponent(pad.rootId)}`
- const shareUrl = `/pads/${encodeURIComponent(pad.rootId)}`
- const isRestrictedInviteOnly = !isMember && !isAuthor && pad.status === "INVITE-ONLY"
- const tags = !isRestrictedInviteOnly && Array.isArray(pad.tags) && pad.tags.length > 0
- ? div({ class: "tribe-side-tags" }, ...pad.tags.map(t => a({ href: `/search?query=%23${encodeURIComponent(t)}` }, `#${t}`)))
- : null
- const padSide = div({ class: "tribe-side" },
- h2(null,
- span(null, "\uD83D\uDD12 "),
- pad.title || "\u2014"
- ),
- div({ class: "shop-share" },
- span({ class: "tribe-info-label" }, i18n.padShareUrl || "Share URL"),
- input({ type: "text", readonly: true, value: shareUrl, class: "shop-share-input" })
- ),
- div({ class: "tribe-card-members" },
- span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`)
- ),
- table({ class: "tribe-info-table" },
- tr(td({ class: "tribe-info-label" }, i18n.padCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(pad.createdAt).format("YYYY-MM-DD"))),
- isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-value", colspan: "4" }, a({ href: `/author/${encodeURIComponent(pad.author)}`, class: "user-link" }, pad.author))),
- tr(td({ class: "tribe-info-label" }, i18n.padStatusLabel || "Status"), td({ class: "tribe-info-value", colspan: "3" }, renderStatus(pad.status, padClosed))),
- isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-label" }, i18n.padDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014"))
- ),
- isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" },
- isAuthor
- ? form({ method: "POST", action: `/pads/generate-invite/${encodeURIComponent(pad.rootId)}` },
- button({ type: "submit", class: "tribe-action-btn" }, i18n.padGenerateCode || "Generate Code")
- )
- : null,
- form(
- { method: "POST", action: pad.isFavorite ? `/pads/favorites/remove/${encodeURIComponent(pad.key)}` : `/pads/favorites/add/${encodeURIComponent(pad.key)}` },
- returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
- button({ type: "submit", class: "tribe-action-btn" }, pad.isFavorite ? (i18n.padRemoveFavorite || "Remove Favorite") : (i18n.padAddFavorite || "Add Favorite"))
- ),
- !isAuthor
- ? a({ href: `/pm?to=${encodeURIComponent(pad.author)}`, class: "tribe-action-btn" }, "PM")
- : null,
- isAuthor
- ? form({ method: "GET", action: "/pads" },
- input({ type: "hidden", name: "filter", value: "edit" }),
- input({ type: "hidden", name: "id", value: pad.rootId }),
- button({ type: "submit", class: "tribe-action-btn" }, i18n.padUpdate || "Update")
- )
- : null,
- isAuthor && pad.status !== "CLOSED" && !padClosed
- ? form({ method: "POST", action: `/pads/close/${encodeURIComponent(pad.rootId)}` },
- button({ type: "submit", class: "tribe-action-btn" }, i18n.padClose || "Close Pad")
- )
- : null,
- isAuthor
- ? form({ method: "POST", action: `/pads/delete/${encodeURIComponent(pad.rootId)}` },
- button({ type: "submit", class: "tribe-action-btn" }, i18n.padDelete || "Delete")
- )
- : null
- ),
- !isAuthor && pad.status === "INVITE-ONLY" && !isMember
- ? div({ class: "pad-invite-section" },
- form({ method: "POST", action: "/pads/join-code" },
- label(i18n.padInviteCodeLabel || "Invite Code"),
- input({ type: "text", name: "code", placeholder: i18n.padInviteCodePlaceholder || "Enter invite code..." }),
- button({ type: "submit", class: "filter-btn" }, i18n.padValidateInvite || "Validate")
- )
- )
- : null,
- !isRestrictedInviteOnly && (!isAuthor && (pad.status === "OPEN" || isMember) && !padClosed)
- ? form({ method: "POST", action: `/pads/join/${encodeURIComponent(pad.rootId)}` },
- button({ type: "submit", class: "create-button" }, i18n.padStartEditing || "START EDITING!")
- )
- : null,
- tags
- )
- let canonicalEntries = entries
- if (params.selectedVersion) {
- const idx = entries.findIndex(e => e.key === params.selectedVersion.key)
- if (idx >= 0) canonicalEntries = entries.slice(0, idx + 1)
- }
- const chunks = computeAttributedChunks(canonicalEntries)
- const lastEntry = canonicalEntries.length > 0 ? canonicalEntries[canonicalEntries.length - 1] : null
- const currentText = lastEntry ? lastEntry.text : ""
- const coloredView = chunks.length > 0
- ? div({ class: "pad-readonly-colored" },
- ...chunks.map(c =>
- span({ class: "pad-author-span " + memberColorClass(pad.members, c.author) }, c.text)
- )
- )
- : p(i18n.padNoEntries || "No entries yet.")
- const versionList = entries.length > 0
- ? div({ class: "pad-version-list" },
- h4(i18n.padVersionHistory || "Version History"),
- ...entries.slice().reverse().map((e, idx) =>
- div({ class: "pad-version-item" },
- span({ class: "pad-version-date" }, moment(e.createdAt).format("YYYY-MM-DD HH:mm")),
- span({ class: "pad-version-author" },
- span({ class: "pad-author-swatch " + memberColorClass(pad.members, e.author) }),
- a({ href: `/author/${encodeURIComponent(e.author)}`, class: "user-link" }, "@" + e.author.slice(1, 9) + "\u2026")
- ),
- a({ href: `/pads/${encodeURIComponent(pad.rootId)}?version=${encodeURIComponent(e.key || idx)}`, class: "pad-version-link" }, i18n.padVersionView || "View")
- )
- )
- )
- : null
- const editorArea = isMember && !padClosed && !params.selectedVersion
- ? div({ class: "pad-editor-area" },
- coloredView,
- form({ method: "POST", action: `/pads/entry/${encodeURIComponent(pad.rootId)}` },
- textarea({ name: "text", rows: "12", class: "pad-editor-white", placeholder: i18n.padEditorPlaceholder || "Start writing..." }, currentText),
- button({ type: "submit", class: "create-button" }, i18n.padSubmitEntry || "Submit")
- ),
- versionList ? div({ class: "pad-version-section" }, versionList) : null
- )
- : div({ class: "pad-editor-area" },
- params.selectedVersion
- ? div({ class: "pad-viewer-back" },
- a({ href: `/pads/${encodeURIComponent(pad.rootId)}`, class: "filter-btn" },
- "\u2190 " + (i18n.padBackToEditor || "Back to editor"))
- )
- : null,
- coloredView,
- versionList ? div({ class: "pad-version-section" }, versionList) : null
- )
- const padMain = isRestrictedInviteOnly
- ? div({ class: "tribe-main" }, p({ class: "access-denied-msg" }, i18n.padAccessDenied))
- : div({ class: "tribe-main" }, editorArea)
- return template(
- pad.title || i18n.padsTitle || "Pad",
- section(
- div({ class: "tags-header" },
- h2(i18n.padsTitle || "Pads"),
- p(i18n.padsDescription || "Manage collaborative encrypted text editors in your network.")
- ),
- renderModeButtons("all")
- ),
- section(div({ class: "tribe-details" }, padSide, padMain))
- )
- }
|