audio_view.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. const {
  2. form,
  3. button,
  4. div,
  5. h2,
  6. p,
  7. section,
  8. input,
  9. br,
  10. a,
  11. audio: audioHyperaxe,
  12. span,
  13. textarea,
  14. select,
  15. label,
  16. option
  17. } = require("../server/node_modules/hyperaxe");
  18. const { template, i18n, userLink, renderSpreadButton } = require("./main_views");
  19. const moment = require("../server/node_modules/moment");
  20. const { config } = require("../server/SSB_server.js");
  21. const { renderUrl } = require("../backend/renderUrl")
  22. const { renderMapLocationVisitLabel } = require("./maps_view");
  23. const opinionCategories = require("../backend/opinion_categories");
  24. const userId = config.keys.id;
  25. const safeArr = (v) => (Array.isArray(v) ? v : []);
  26. const safeText = (v) => String(v || "").trim();
  27. const buildReturnTo = (filter, params = {}) => {
  28. const f = safeText(filter || "all");
  29. const q = safeText(params.q || "");
  30. const sort = safeText(params.sort || "recent");
  31. const parts = [`filter=${encodeURIComponent(f)}`];
  32. if (q) parts.push(`q=${encodeURIComponent(q)}`);
  33. if (sort) parts.push(`sort=${encodeURIComponent(sort)}`);
  34. return `/audios?${parts.join("&")}`;
  35. };
  36. const renderTags = (tags) => {
  37. const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
  38. return list.length
  39. ? div(
  40. { class: "card-tags" },
  41. list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
  42. )
  43. : null;
  44. };
  45. const renderAudioFavoriteToggle = (audioObj, returnTo = "") =>
  46. form(
  47. {
  48. method: "POST",
  49. action: audioObj.isFavorite
  50. ? `/audios/favorites/remove/${encodeURIComponent(audioObj.key)}`
  51. : `/audios/favorites/add/${encodeURIComponent(audioObj.key)}`
  52. },
  53. returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
  54. button(
  55. { type: "submit", class: "filter-btn" },
  56. audioObj.isFavorite ? i18n.audioRemoveFavoriteButton : i18n.audioAddFavoriteButton
  57. )
  58. );
  59. const renderAudioPlayer = (audioObj) =>
  60. audioObj?.url
  61. ? div(
  62. { class: "audio-container", style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
  63. audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(audioObj.url)}`, preload: "metadata" })
  64. )
  65. : p(i18n.audioNoFile);
  66. const renderAudioOwnerActions = (filter, audioObj, params = {}) => {
  67. const returnTo = buildReturnTo(filter, params);
  68. const isAuthor = String(audioObj.author) === String(userId);
  69. const hasOpinions = Object.keys(audioObj.opinions || {}).length > 0;
  70. if (!isAuthor) return [];
  71. const items = [];
  72. if (!hasOpinions) {
  73. items.push(
  74. form(
  75. { method: "GET", action: `/audios/edit/${encodeURIComponent(audioObj.key)}` },
  76. input({ type: "hidden", name: "returnTo", value: returnTo }),
  77. button({ class: "update-btn", type: "submit" }, i18n.audioUpdateButton)
  78. )
  79. );
  80. }
  81. items.push(
  82. form(
  83. { method: "POST", action: `/audios/delete/${encodeURIComponent(audioObj.key)}` },
  84. input({ type: "hidden", name: "returnTo", value: returnTo }),
  85. button({ class: "delete-btn", type: "submit" }, i18n.audioDeleteButton)
  86. )
  87. );
  88. return items;
  89. };
  90. const renderAudioCommentsSection = (audioId, comments = [], returnTo = null) => {
  91. const list = safeArr(comments).filter(c => {
  92. const t = c && c.value && c.value.content && c.value.content.text;
  93. return t && String(t).trim();
  94. });
  95. const commentsCount = list.length;
  96. return div(
  97. { class: "vote-comments-section" },
  98. div(
  99. { class: "comments-count" },
  100. span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
  101. span({ class: "card-value" }, String(commentsCount))
  102. ),
  103. div(
  104. { class: "comment-form-wrapper" },
  105. h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
  106. form(
  107. { method: "POST", action: `/audios/${encodeURIComponent(audioId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
  108. returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
  109. textarea({
  110. id: "comment-text",
  111. name: "text",
  112. rows: 4,
  113. class: "comment-textarea",
  114. placeholder: i18n.voteNewCommentPlaceholder
  115. }),
  116. div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
  117. br(),
  118. button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
  119. )
  120. ),
  121. list.length
  122. ? div(
  123. { class: "comments-list" },
  124. list.map((c) => {
  125. const author = c?.value?.author || "";
  126. const ts = c?.value?.timestamp || c?.timestamp;
  127. const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
  128. const relDate = ts ? moment(ts).fromNow() : "";
  129. const userName = author && author.includes("@") ? author.split("@")[1] : author;
  130. const content = c?.value?.content || {};
  131. const rootId = content.fork || content.root || null;
  132. const text = content.text || "";
  133. return div(
  134. { class: "votations-comment-card" },
  135. span(
  136. { class: "created-at" },
  137. span(i18n.createdBy),
  138. author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
  139. absDate ? span(" | ") : "",
  140. absDate ? span({ class: "votations-comment-date" }, absDate) : "",
  141. relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
  142. relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
  143. ),
  144. p({ class: "votations-comment-text" }, ...renderUrl(text))
  145. );
  146. })
  147. )
  148. : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
  149. );
  150. };
  151. const renderAudioList = exports.renderAudioList = (audios, filter, params = {}) => {
  152. const returnTo = buildReturnTo(filter, params);
  153. return audios.length
  154. ? audios.map((audioObj) => {
  155. const commentCount = typeof audioObj.commentCount === "number" ? audioObj.commentCount : 0;
  156. const title = safeText(audioObj.title);
  157. const ownerActions = renderAudioOwnerActions(filter, audioObj, params);
  158. return div(
  159. { class: "tags-header audio-card" },
  160. div(
  161. { class: "bookmark-topbar" },
  162. div(
  163. { class: "bookmark-topbar-left" },
  164. form(
  165. { method: "GET", action: `/audios/${encodeURIComponent(audioObj.key)}` },
  166. input({ type: "hidden", name: "returnTo", value: returnTo }),
  167. input({ type: "hidden", name: "filter", value: filter || "all" }),
  168. params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
  169. params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
  170. button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
  171. ),
  172. renderAudioFavoriteToggle(audioObj, returnTo),
  173. audioObj.author && String(audioObj.author) !== String(userId)
  174. ? form(
  175. { method: "GET", action: "/pm" },
  176. input({ type: "hidden", name: "recipients", value: audioObj.author }),
  177. button({ type: "submit", class: "filter-btn" }, i18n.audioMessageAuthorButton)
  178. )
  179. : null
  180. ),
  181. ownerActions.length ? div({ class: "bookmark-actions" }, ...ownerActions) : null
  182. ),
  183. title ? h2(title) : null,
  184. renderAudioPlayer(audioObj),
  185. div(
  186. { class: "card-comments-summary" },
  187. span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
  188. span({ class: "card-value" }, String(commentCount)),
  189. br(),
  190. br(),
  191. form(
  192. { method: "GET", action: `/audios/${encodeURIComponent(audioObj.key)}` },
  193. input({ type: "hidden", name: "returnTo", value: returnTo }),
  194. input({ type: "hidden", name: "filter", value: filter || "all" }),
  195. params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
  196. params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
  197. button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
  198. )
  199. ),
  200. renderMapLocationVisitLabel(audioObj.mapUrl),
  201. br(),
  202. (() => {
  203. const createdTs = audioObj.createdAt ? new Date(audioObj.createdAt).getTime() : NaN;
  204. const updatedTs = audioObj.updatedAt ? new Date(audioObj.updatedAt).getTime() : NaN;
  205. const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
  206. return p(
  207. { class: "card-footer" },
  208. span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
  209. userLink(audioObj.author),
  210. showUpdated
  211. ? span(
  212. { class: "votations-comment-date" },
  213. ` | ${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
  214. )
  215. : null
  216. );
  217. })()
  218. );
  219. })
  220. : p(params.q ? i18n.audioNoMatch : i18n.noAudios);
  221. };
  222. const renderAudioForm = (filter, audioId, audioToEdit, params = {}) => {
  223. const returnTo = safeText(params.returnTo) || buildReturnTo("all", params);
  224. return div(
  225. { class: "div-center audio-form" },
  226. form(
  227. {
  228. action: filter === "edit" ? `/audios/update/${encodeURIComponent(audioId)}` : "/audios/create",
  229. method: "POST",
  230. enctype: "multipart/form-data"
  231. },
  232. input({ type: "hidden", name: "returnTo", value: returnTo }),
  233. span(i18n.audioFileLabel),
  234. br(),
  235. input({ type: "file", name: "audio", required: filter !== "edit" }),
  236. br(),
  237. br(),
  238. span(i18n.audioTitleLabel),
  239. br(),
  240. input({ type: "text", name: "title", placeholder: i18n.audioTitlePlaceholder, value: audioToEdit?.title || "" }),
  241. br(),
  242. span(i18n.audioDescriptionLabel),
  243. br(),
  244. textarea({ name: "description", placeholder: i18n.audioDescriptionPlaceholder, rows: "4" }, audioToEdit?.description || ""),
  245. br(),
  246. span(i18n.mapLocationTitle || "Map Location"),
  247. br(),
  248. input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: audioToEdit?.mapUrl || "" }),
  249. br(),
  250. span(i18n.audioTagsLabel),
  251. br(),
  252. input({
  253. type: "text",
  254. name: "tags",
  255. placeholder: i18n.audioTagsPlaceholder,
  256. value: safeArr(audioToEdit?.tags).join(", ")
  257. }),
  258. br(),
  259. br(),
  260. button({ type: "submit" }, filter === "edit" ? i18n.audioUpdateButton : i18n.audioCreateButton)
  261. )
  262. );
  263. };
  264. exports.audioView = async (audios, filter = "all", audioId = null, params = {}) => {
  265. const title =
  266. filter === "mine"
  267. ? i18n.audioMineSectionTitle
  268. : filter === "create"
  269. ? i18n.audioCreateSectionTitle
  270. : filter === "edit"
  271. ? i18n.audioUpdateSectionTitle
  272. : filter === "recent"
  273. ? i18n.audioRecentSectionTitle
  274. : filter === "top"
  275. ? i18n.audioTopSectionTitle
  276. : filter === "favorites"
  277. ? i18n.audioFavoritesSectionTitle
  278. : i18n.audioAllSectionTitle;
  279. const q = safeText(params.q || "");
  280. const sort = safeText(params.sort || "recent");
  281. const list = safeArr(audios);
  282. const audioToEdit = audioId ? list.find((a) => a.key === audioId) : null;
  283. return template(
  284. title,
  285. section(
  286. div({ class: "tags-header" },
  287. h2(title),
  288. p(i18n.audioDescription),
  289. (() => {
  290. const { renderReachChip } = require('./clearnet_view');
  291. const isClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetAudios);
  292. return div({ class: "shop-title-row" }, renderReachChip(isClearnet, i18n));
  293. })()
  294. ),
  295. div(
  296. { class: "filters" },
  297. form(
  298. { method: "GET", action: "/audios", class: "ui-toolbar ui-toolbar--filters" },
  299. input({ type: "hidden", name: "q", value: q }),
  300. input({ type: "hidden", name: "sort", value: sort }),
  301. button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterAll),
  302. button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterMine),
  303. button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterRecent),
  304. button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterTop),
  305. button({ type: "submit", name: "filter", value: "blockchain", class: filter === "blockchain" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterBlockchain || "BLOCKCHAIN"),
  306. button(
  307. { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
  308. i18n.audioFilterFavorites
  309. ),
  310. button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.audioCreateButton)
  311. )
  312. )
  313. ),
  314. section(
  315. filter === "create" || filter === "edit"
  316. ? renderAudioForm(filter, audioId, audioToEdit, { ...params, filter })
  317. : section(
  318. div(
  319. { class: "audios-search" },
  320. form(
  321. { method: "GET", action: "/audios", class: "filter-box" },
  322. input({ type: "hidden", name: "filter", value: filter }),
  323. input({
  324. type: "text",
  325. name: "q",
  326. value: q,
  327. placeholder: i18n.audioSearchPlaceholder,
  328. class: "filter-box__input"
  329. }),
  330. div(
  331. { class: "filter-box__controls" },
  332. select(
  333. { name: "sort", class: "filter-box__select" },
  334. option({ value: "recent", selected: sort === "recent" }, i18n.audioSortRecent),
  335. option({ value: "oldest", selected: sort === "oldest" }, i18n.audioSortOldest),
  336. option({ value: "top", selected: sort === "top" }, i18n.audioSortTop)
  337. ),
  338. button({ type: "submit", class: "filter-box__button" }, i18n.audioSearchButton)
  339. )
  340. )
  341. ),
  342. div({ class: "audios-list" }, renderAudioList(list, filter, { q, sort }))
  343. )
  344. )
  345. );
  346. };
  347. exports.singleAudioView = async (audioObj, filter = "all", comments = [], params = {}) => {
  348. const q = safeText(params.q || "");
  349. const sort = safeText(params.sort || "recent");
  350. const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort });
  351. const title = safeText(audioObj.title);
  352. const ownerActions = renderAudioOwnerActions(filter, audioObj, { q, sort });
  353. const topbarLeft =
  354. audioObj.author && String(audioObj.author) !== String(userId)
  355. ? form(
  356. { method: "GET", action: "/pm" },
  357. input({ type: "hidden", name: "recipients", value: audioObj.author }),
  358. button({ type: "submit", class: "filter-btn" }, i18n.audioMessageAuthorButton)
  359. )
  360. : null;
  361. const topbar = div(
  362. { class: "bookmark-topbar" },
  363. div({ class: "bookmark-actions" }, renderAudioFavoriteToggle(audioObj, returnTo), ...ownerActions)
  364. );
  365. return template(
  366. i18n.audioTitle,
  367. section(
  368. div(
  369. { class: "filters" },
  370. form(
  371. { method: "GET", action: "/audios", class: "ui-toolbar ui-toolbar--filters" },
  372. input({ type: "hidden", name: "q", value: q }),
  373. input({ type: "hidden", name: "sort", value: sort }),
  374. button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterAll),
  375. button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterMine),
  376. button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterRecent),
  377. button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterTop),
  378. button({ type: "submit", name: "filter", value: "blockchain", class: filter === "blockchain" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterBlockchain || "BLOCKCHAIN"),
  379. button(
  380. { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
  381. i18n.audioFilterFavorites
  382. ),
  383. button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.audioCreateButton)
  384. )
  385. ),
  386. div(
  387. { class: "bookmark-item card" },
  388. topbar,
  389. title ? h2(title) : null,
  390. renderAudioPlayer(audioObj),
  391. safeText(audioObj.description) ? p(...renderUrl(audioObj.description)) : null,
  392. (() => {
  393. const { renderReachChip } = require('./clearnet_view');
  394. const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetAudios);
  395. return div({ class: 'shop-title-row' }, renderReachChip(isClearnet, i18n));
  396. })(),
  397. renderTags(audioObj.tags),
  398. br(),
  399. renderMapLocationVisitLabel(audioObj.mapUrl),
  400. br(),
  401. (() => {
  402. const createdTs = audioObj.createdAt ? new Date(audioObj.createdAt).getTime() : NaN;
  403. const updatedTs = audioObj.updatedAt ? new Date(audioObj.updatedAt).getTime() : NaN;
  404. const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
  405. return p(
  406. { class: "card-footer" },
  407. span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
  408. userLink(audioObj.author),
  409. showUpdated
  410. ? span(
  411. { class: "votations-comment-date" },
  412. ` | ${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
  413. )
  414. : null
  415. );
  416. })(),
  417. div({ class: "spread-row" }, renderSpreadButton(audioObj.key, params.spreads)),
  418. div(
  419. { class: "voting-buttons" },
  420. opinionCategories.map((category) =>
  421. form(
  422. { method: "POST", action: `/audios/opinions/${encodeURIComponent(audioObj.key)}/${category}` },
  423. input({ type: "hidden", name: "returnTo", value: returnTo }),
  424. button(
  425. { class: "vote-btn" },
  426. `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${
  427. audioObj.opinions?.[category] || 0
  428. }]`
  429. )
  430. )
  431. )
  432. )
  433. ),
  434. div({ id: "comments" }, renderAudioCommentsSection(audioObj.key, comments, returnTo))
  435. )
  436. );
  437. };