pads_view.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. 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")
  2. const { template, i18n } = require("./main_views")
  3. const moment = require("../server/node_modules/moment")
  4. const { config } = require("../server/SSB_server.js")
  5. const userId = config.keys.id
  6. 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"]
  7. const memberColorClass = (members, feedId) => {
  8. const idx = members.indexOf(feedId)
  9. return idx >= 0 ? PAD_COLOR_CLASSES[idx % PAD_COLOR_CLASSES.length] : "pad-author-color-none"
  10. }
  11. const sliceChunksByOffset = (chunks, from, to) => {
  12. const out = []
  13. let pos = 0
  14. for (const c of chunks) {
  15. const cStart = pos
  16. const cEnd = pos + c.text.length
  17. if (cEnd <= from) { pos = cEnd; continue }
  18. if (cStart >= to) break
  19. const sliceStart = Math.max(0, from - cStart)
  20. const sliceEnd = Math.min(c.text.length, to - cStart)
  21. if (sliceEnd > sliceStart) out.push({ text: c.text.slice(sliceStart, sliceEnd), author: c.author })
  22. pos = cEnd
  23. }
  24. return out
  25. }
  26. const mergeAdjacent = (chunks) => {
  27. const out = []
  28. for (const c of chunks) {
  29. if (!c.text) continue
  30. if (out.length > 0 && out[out.length - 1].author === c.author) {
  31. out[out.length - 1].text += c.text
  32. } else {
  33. out.push({ ...c })
  34. }
  35. }
  36. return out
  37. }
  38. const computeAttributedChunks = (entries) => {
  39. if (!entries || entries.length === 0) return []
  40. let chunks = [{ text: entries[0].text || "", author: entries[0].author }]
  41. for (let i = 1; i < entries.length; i++) {
  42. const prev = entries[i - 1].text || ""
  43. const curr = entries[i].text || ""
  44. const author = entries[i].author
  45. let start = 0
  46. const maxStart = Math.min(prev.length, curr.length)
  47. while (start < maxStart && prev.charCodeAt(start) === curr.charCodeAt(start)) start++
  48. let endPrev = prev.length
  49. let endCurr = curr.length
  50. while (endPrev > start && endCurr > start && prev.charCodeAt(endPrev - 1) === curr.charCodeAt(endCurr - 1)) {
  51. endPrev--
  52. endCurr--
  53. }
  54. const inserted = curr.slice(start, endCurr)
  55. const headChunks = sliceChunksByOffset(chunks, 0, start)
  56. const tailChunks = sliceChunksByOffset(chunks, endPrev, prev.length)
  57. const middle = inserted ? [{ text: inserted, author }] : []
  58. chunks = mergeAdjacent([...headChunks, ...middle, ...tailChunks])
  59. }
  60. return chunks
  61. }
  62. const renderStatus = (status, isClosed) => {
  63. if (isClosed) return span({ class: "pad-status-closed" }, i18n.padStatusClosed || "CLOSED")
  64. if (status === "INVITE-ONLY") return span({ class: "pad-status-invite" }, i18n.padStatusInviteOnly || "INVITE-ONLY")
  65. return span({ class: "pad-status-open" }, i18n.padStatusOpen || "OPEN")
  66. }
  67. const renderModeButtons = (currentFilter) =>
  68. div({ class: "tribe-mode-buttons" },
  69. ["all", "mine", "recent", "open", "closed"].map(f =>
  70. form({ method: "GET", action: "/pads" },
  71. input({ type: "hidden", name: "filter", value: f }),
  72. button({ type: "submit", class: currentFilter === f ? "filter-btn active" : "filter-btn" },
  73. i18n[`padFilter${f.charAt(0).toUpperCase() + f.slice(1)}`] || f.toUpperCase())
  74. )
  75. ),
  76. form({ method: "GET", action: "/pads" },
  77. input({ type: "hidden", name: "filter", value: "create" }),
  78. button({ type: "submit", class: "create-button" }, i18n.padCreate || "Create Pad")
  79. )
  80. )
  81. const renderPadCard = (pad, filter) => {
  82. const returnTo = `/pads?filter=${encodeURIComponent(filter || "all")}`
  83. return div({ class: "tribe-card" },
  84. div({ class: "tribe-card-body" },
  85. h2({ class: "tribe-card-title" },
  86. span(null, "\uD83D\uDD12 "),
  87. a({ href: `/pads/${encodeURIComponent(pad.rootId)}` }, pad.title || "\u2014")
  88. ),
  89. table({ class: "tribe-info-table" },
  90. tr(td(i18n.padStatusLabel || "Status"), td(renderStatus(pad.status, pad.isClosed))),
  91. pad.deadline ? tr(td(i18n.padDeadlineLabel || "Deadline"), td(moment(pad.deadline).format("YYYY-MM-DD HH:mm"))) : null
  92. ),
  93. div({ class: "tribe-card-members" },
  94. span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`)
  95. ),
  96. div({ class: "visit-btn-centered" },
  97. a({ href: `/pads/${encodeURIComponent(pad.rootId)}`, class: "filter-btn" }, i18n.padVisitPad || "Visit Pad")
  98. )
  99. )
  100. )
  101. }
  102. const renderCreateForm = (padToEdit, params) => {
  103. const tribeId = (params && params.tribeId) || ""
  104. return div({ class: "div-center audio-form" },
  105. h2(padToEdit ? (i18n.padUpdateSectionTitle || "Update Pad") : (i18n.padCreateSectionTitle || "Create New Pad")),
  106. form({
  107. method: "POST",
  108. action: padToEdit ? `/pads/update/${encodeURIComponent(padToEdit.rootId)}` : "/pads/create"
  109. },
  110. tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
  111. span(i18n.padTitleLabel || "Title"), require("../server/node_modules/hyperaxe").br(),
  112. input({ type: "text", name: "title", value: padToEdit ? padToEdit.title : "", placeholder: i18n.padTitlePlaceholder || "Enter pad title...", required: true }),
  113. require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(),
  114. span(i18n.padStatusLabel || "Status"), require("../server/node_modules/hyperaxe").br(),
  115. select({ name: "status" },
  116. ["OPEN", "INVITE-ONLY"].map(s =>
  117. option({ value: s, ...(padToEdit && padToEdit.status === s ? { selected: true } : {}) }, s)
  118. )
  119. ),
  120. require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(),
  121. span(i18n.padDeadlineLabel || "Deadline"), require("../server/node_modules/hyperaxe").br(),
  122. input({
  123. type: "datetime-local",
  124. name: "deadline",
  125. value: padToEdit && padToEdit.deadline ? moment(padToEdit.deadline).format("YYYY-MM-DDTHH:mm") : "",
  126. min: moment().format("YYYY-MM-DDTHH:mm")
  127. }),
  128. require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(),
  129. span(i18n.padTagsLabel || "Tags"), require("../server/node_modules/hyperaxe").br(),
  130. input({ type: "text", name: "tags", value: padToEdit ? padToEdit.tags.join(", ") : "", placeholder: i18n.padTagsPlaceholder || "tag1, tag2, ..." }),
  131. require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(),
  132. button({ type: "submit", class: "create-button" }, padToEdit ? (i18n.padUpdate || "Update Pad") : (i18n.padCreate || "Create Pad"))
  133. )
  134. )
  135. }
  136. exports.renderPadInvitePage = (code) => {
  137. const pageContent = div({ class: "invite-page" },
  138. h2(i18n.tribeInviteCodeText, code),
  139. form({ method: "GET", action: "/pads" },
  140. input({ type: "hidden", name: "filter", value: "all" }),
  141. button({ type: "submit", class: "filter-btn" }, i18n.walletBack)
  142. )
  143. )
  144. return template(i18n.padInviteMode || "Invite", section(pageContent))
  145. }
  146. exports.padsView = async (pads, filter, padToEdit, params) => {
  147. const q = String((params && params.q) || "").trim()
  148. const isForm = filter === "create" || filter === "edit"
  149. const headerMap = {
  150. all: i18n.padAllSectionTitle || "Pads",
  151. mine: i18n.padMineSectionTitle || "Your Pads",
  152. recent: i18n.padRecentSectionTitle || "Recent Pads",
  153. open: i18n.padOpenSectionTitle || "Open Pads",
  154. closed: i18n.padClosedSectionTitle || "Closed Pads"
  155. }
  156. const headerText = headerMap[filter] || headerMap.all
  157. const filteredPads = q
  158. ? pads.filter(pd => String(pd.title || "").toLowerCase().includes(q.toLowerCase()))
  159. : pads
  160. const body = div({ class: "main-column" },
  161. div({ class: "tags-header" },
  162. h2(headerText),
  163. p(i18n.padsDescription || "Manage collaborative encrypted text editors in your network.")
  164. ),
  165. renderModeButtons(filter),
  166. !isForm
  167. ? div({ class: "filters" },
  168. form({ method: "GET", action: "/pads" },
  169. input({ type: "hidden", name: "filter", value: filter }),
  170. input({ type: "text", name: "q", placeholder: i18n.padSearchPlaceholder || "Search pads...", value: q }),
  171. br(),
  172. button({ type: "submit" }, i18n.search),
  173. br()
  174. )
  175. )
  176. : null,
  177. isForm
  178. ? renderCreateForm(padToEdit, params)
  179. : div(
  180. filteredPads.length === 0
  181. ? p({ class: "no-content" }, i18n.padsNoItems || "No pads found.")
  182. : div({ class: "tribe-grid" }, ...filteredPads.map(pd => renderPadCard(pd, filter)))
  183. )
  184. )
  185. return template(i18n.padsTitle || "Pads", body)
  186. }
  187. exports.singlePadView = async (pad, entries, params) => {
  188. const isAuthor = String(pad.author) === String(userId)
  189. const isMember = pad.members.includes(userId)
  190. const padClosed = pad.isClosed
  191. const returnTo = `/pads/${encodeURIComponent(pad.rootId)}`
  192. const shareUrl = `/pads/${encodeURIComponent(pad.rootId)}`
  193. const isRestrictedInviteOnly = !isMember && !isAuthor && pad.status === "INVITE-ONLY"
  194. const tags = !isRestrictedInviteOnly && Array.isArray(pad.tags) && pad.tags.length > 0
  195. ? div({ class: "tribe-side-tags" }, ...pad.tags.map(t => a({ href: `/search?query=%23${encodeURIComponent(t)}` }, `#${t}`)))
  196. : null
  197. const padSide = div({ class: "tribe-side" },
  198. h2(null,
  199. span(null, "\uD83D\uDD12 "),
  200. pad.title || "\u2014"
  201. ),
  202. div({ class: "shop-share" },
  203. span({ class: "tribe-info-label" }, i18n.padShareUrl || "Share URL"),
  204. input({ type: "text", readonly: true, value: shareUrl, class: "shop-share-input" })
  205. ),
  206. div({ class: "tribe-card-members" },
  207. span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`)
  208. ),
  209. table({ class: "tribe-info-table" },
  210. tr(td({ class: "tribe-info-label" }, i18n.padCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(pad.createdAt).format("YYYY-MM-DD"))),
  211. isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-value", colspan: "4" }, a({ href: `/author/${encodeURIComponent(pad.author)}`, class: "user-link" }, pad.author))),
  212. tr(td({ class: "tribe-info-label" }, i18n.padStatusLabel || "Status"), td({ class: "tribe-info-value", colspan: "3" }, renderStatus(pad.status, padClosed))),
  213. 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"))
  214. ),
  215. isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" },
  216. isAuthor
  217. ? form({ method: "POST", action: `/pads/generate-invite/${encodeURIComponent(pad.rootId)}` },
  218. button({ type: "submit", class: "tribe-action-btn" }, i18n.padGenerateCode || "Generate Code")
  219. )
  220. : null,
  221. form(
  222. { method: "POST", action: pad.isFavorite ? `/pads/favorites/remove/${encodeURIComponent(pad.key)}` : `/pads/favorites/add/${encodeURIComponent(pad.key)}` },
  223. returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
  224. button({ type: "submit", class: "tribe-action-btn" }, pad.isFavorite ? (i18n.padRemoveFavorite || "Remove Favorite") : (i18n.padAddFavorite || "Add Favorite"))
  225. ),
  226. !isAuthor
  227. ? a({ href: `/pm?to=${encodeURIComponent(pad.author)}`, class: "tribe-action-btn" }, "PM")
  228. : null,
  229. isAuthor
  230. ? form({ method: "GET", action: "/pads" },
  231. input({ type: "hidden", name: "filter", value: "edit" }),
  232. input({ type: "hidden", name: "id", value: pad.rootId }),
  233. button({ type: "submit", class: "tribe-action-btn" }, i18n.padUpdate || "Update")
  234. )
  235. : null,
  236. isAuthor && pad.status !== "CLOSED" && !padClosed
  237. ? form({ method: "POST", action: `/pads/close/${encodeURIComponent(pad.rootId)}` },
  238. button({ type: "submit", class: "tribe-action-btn" }, i18n.padClose || "Close Pad")
  239. )
  240. : null,
  241. isAuthor
  242. ? form({ method: "POST", action: `/pads/delete/${encodeURIComponent(pad.rootId)}` },
  243. button({ type: "submit", class: "tribe-action-btn" }, i18n.padDelete || "Delete")
  244. )
  245. : null
  246. ),
  247. !isAuthor && pad.status === "INVITE-ONLY" && !isMember
  248. ? div({ class: "pad-invite-section" },
  249. form({ method: "POST", action: "/pads/join-code" },
  250. label(i18n.padInviteCodeLabel || "Invite Code"),
  251. input({ type: "text", name: "code", placeholder: i18n.padInviteCodePlaceholder || "Enter invite code..." }),
  252. button({ type: "submit", class: "filter-btn" }, i18n.padValidateInvite || "Validate")
  253. )
  254. )
  255. : null,
  256. !isRestrictedInviteOnly && (!isAuthor && (pad.status === "OPEN" || isMember) && !padClosed)
  257. ? form({ method: "POST", action: `/pads/join/${encodeURIComponent(pad.rootId)}` },
  258. button({ type: "submit", class: "create-button" }, i18n.padStartEditing || "START EDITING!")
  259. )
  260. : null,
  261. tags
  262. )
  263. let canonicalEntries = entries
  264. if (params.selectedVersion) {
  265. const idx = entries.findIndex(e => e.key === params.selectedVersion.key)
  266. if (idx >= 0) canonicalEntries = entries.slice(0, idx + 1)
  267. }
  268. const chunks = computeAttributedChunks(canonicalEntries)
  269. const lastEntry = canonicalEntries.length > 0 ? canonicalEntries[canonicalEntries.length - 1] : null
  270. const currentText = lastEntry ? lastEntry.text : ""
  271. const coloredView = chunks.length > 0
  272. ? div({ class: "pad-readonly-colored" },
  273. ...chunks.map(c =>
  274. span({ class: "pad-author-span " + memberColorClass(pad.members, c.author) }, c.text)
  275. )
  276. )
  277. : p(i18n.padNoEntries || "No entries yet.")
  278. const versionList = entries.length > 0
  279. ? div({ class: "pad-version-list" },
  280. h4(i18n.padVersionHistory || "Version History"),
  281. ...entries.slice().reverse().map((e, idx) =>
  282. div({ class: "pad-version-item" },
  283. span({ class: "pad-version-date" }, moment(e.createdAt).format("YYYY-MM-DD HH:mm")),
  284. span({ class: "pad-version-author" },
  285. span({ class: "pad-author-swatch " + memberColorClass(pad.members, e.author) }),
  286. a({ href: `/author/${encodeURIComponent(e.author)}`, class: "user-link" }, "@" + e.author.slice(1, 9) + "\u2026")
  287. ),
  288. a({ href: `/pads/${encodeURIComponent(pad.rootId)}?version=${encodeURIComponent(e.key || idx)}`, class: "pad-version-link" }, i18n.padVersionView || "View")
  289. )
  290. )
  291. )
  292. : null
  293. const editorArea = isMember && !padClosed && !params.selectedVersion
  294. ? div({ class: "pad-editor-area" },
  295. coloredView,
  296. form({ method: "POST", action: `/pads/entry/${encodeURIComponent(pad.rootId)}` },
  297. textarea({ name: "text", rows: "12", class: "pad-editor-white", placeholder: i18n.padEditorPlaceholder || "Start writing..." }, currentText),
  298. button({ type: "submit", class: "create-button" }, i18n.padSubmitEntry || "Submit")
  299. ),
  300. versionList ? div({ class: "pad-version-section" }, versionList) : null
  301. )
  302. : div({ class: "pad-editor-area" },
  303. params.selectedVersion
  304. ? div({ class: "pad-viewer-back" },
  305. a({ href: `/pads/${encodeURIComponent(pad.rootId)}`, class: "filter-btn" },
  306. "\u2190 " + (i18n.padBackToEditor || "Back to editor"))
  307. )
  308. : null,
  309. coloredView,
  310. versionList ? div({ class: "pad-version-section" }, versionList) : null
  311. )
  312. const padMain = isRestrictedInviteOnly
  313. ? div({ class: "tribe-main" }, p({ class: "access-denied-msg" }, i18n.padAccessDenied))
  314. : div({ class: "tribe-main" }, editorArea)
  315. return template(
  316. pad.title || i18n.padsTitle || "Pad",
  317. section(
  318. div({ class: "tags-header" },
  319. h2(i18n.padsTitle || "Pads"),
  320. p(i18n.padsDescription || "Manage collaborative encrypted text editors in your network.")
  321. ),
  322. renderModeButtons("all")
  323. ),
  324. section(div({ class: "tribe-details" }, padSide, padMain))
  325. )
  326. }