audio_view.js 18 KB

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