calendars_view.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. const { div, h2, h3, h4, p, section, button, form, a, span, br, textarea, input, label, select, option, table, tr, td, ul, li } = 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 renderNoteText = (text) => {
  7. if (!text) return []
  8. const urlRegex = /(https?:\/\/[^\s]+)/g
  9. const result = []
  10. let last = 0
  11. text.replace(urlRegex, (match, _g, offset) => {
  12. if (offset > last) result.push(text.slice(last, offset))
  13. result.push(a({ href: match, target: "_blank" }, match))
  14. last = offset + match.length
  15. })
  16. if (last < text.length) result.push(text.slice(last))
  17. return result
  18. }
  19. const renderCalendarFavoriteToggle = (cal, returnTo) =>
  20. form(
  21. { method: "POST", action: cal.isFavorite ? `/calendars/favorites/remove/${encodeURIComponent(cal.rootId)}` : `/calendars/favorites/add/${encodeURIComponent(cal.rootId)}` },
  22. returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
  23. button({ type: "submit", class: "tribe-action-btn" }, cal.isFavorite ? (i18n.calendarRemoveFavorite || "Remove Favorite") : (i18n.calendarAddFavorite || "Add Favorite"))
  24. )
  25. const renderModeButtons = (currentFilter) =>
  26. div({ class: "tribe-mode-buttons" },
  27. ["all", "mine", "recent", "favorites", "open", "closed"].map(f =>
  28. form({ method: "GET", action: "/calendars" },
  29. input({ type: "hidden", name: "filter", value: f }),
  30. button({ type: "submit", class: currentFilter === f ? "filter-btn active" : "filter-btn" },
  31. i18n[`calendarFilter${f.charAt(0).toUpperCase() + f.slice(1)}`] || f.toUpperCase())
  32. )
  33. ),
  34. form({ method: "GET", action: "/calendars" },
  35. input({ type: "hidden", name: "filter", value: "create" }),
  36. button({ type: "submit", class: "create-button" }, i18n.calendarCreate || "Create Calendar")
  37. )
  38. )
  39. const renderStatus = (cal) => {
  40. if (cal.isClosed) return span({ class: "pad-status-closed" }, i18n.calendarStatusClosed || "CLOSED")
  41. return span({ class: "pad-status-open" }, i18n.calendarStatusOpen || "OPEN")
  42. }
  43. const renderCalendarCard = (cal) => {
  44. const href = `/calendars/${encodeURIComponent(cal.rootId)}`
  45. return div({ class: "tribe-card" },
  46. div({ class: "tribe-card-body" },
  47. div({ class: "tribe-card-title" },
  48. a({ href }, cal.title || "\u2014")
  49. ),
  50. table({ class: "tribe-info-table" },
  51. tr(td({ class: "tribe-info-label" }, i18n.calendarStatusLabel || "Status"), td({ class: "tribe-info-value" }, renderStatus(cal))),
  52. cal.deadline ? tr(td({ class: "tribe-info-label" }, i18n.calendarDeadlineLabel || "Deadline"), td({ class: "tribe-info-value" }, moment(cal.deadline).format("YYYY-MM-DD HH:mm"))) : null,
  53. ),
  54. div({ class: "tribe-card-members" },
  55. span({ class: "tribe-members-count calendar-participants-count" }, `${i18n.calendarParticipantsLabel || "Participants"}: ${cal.participants.length}`)
  56. ),
  57. div({ class: "visit-btn-centered" },
  58. a({ href, class: "filter-btn" }, i18n.calendarVisitCalendar || "Visit Calendar")
  59. )
  60. )
  61. )
  62. }
  63. const renderIntervalBlock = () =>
  64. div({ class: "calendar-interval-block" },
  65. span({ class: "calendar-interval-label" }, i18n.calendarIntervalLabel || "Interval"),
  66. div({ class: "calendar-interval-row" },
  67. input({ type: "hidden", name: "intervalWeekly", value: "0" }),
  68. label({ class: "calendar-interval-option" },
  69. input({ type: "checkbox", name: "intervalWeekly", value: "1" }),
  70. " ", i18n.calendarIntervalWeekly || "Weekly"
  71. ),
  72. input({ type: "hidden", name: "intervalMonthly", value: "0" }),
  73. label({ class: "calendar-interval-option" },
  74. input({ type: "checkbox", name: "intervalMonthly", value: "1" }),
  75. " ", i18n.calendarIntervalMonthly || "Monthly"
  76. ),
  77. input({ type: "hidden", name: "intervalYearly", value: "0" }),
  78. label({ class: "calendar-interval-option" },
  79. input({ type: "checkbox", name: "intervalYearly", value: "1" }),
  80. " ", i18n.calendarIntervalYearly || "Yearly"
  81. )
  82. ),
  83. span({ class: "calendar-interval-label calendar-interval-until" }, i18n.calendarIntervalUntil || "Until"),
  84. input({ type: "datetime-local", name: "intervalDeadline" }),
  85. br()
  86. )
  87. const renderCreateForm = (calendarToEdit, params) => {
  88. const isEdit = !!calendarToEdit
  89. const tribeId = (params && params.tribeId) || ""
  90. const now = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm")
  91. const action = isEdit ? `/calendars/update/${encodeURIComponent(calendarToEdit.rootId)}` : "/calendars/create"
  92. const sectionTitle = isEdit ? (i18n.calendarUpdateSectionTitle || "Update Calendar") : (i18n.calendarCreateSectionTitle || "Create New Calendar")
  93. return div({ class: "div-center audio-form" },
  94. h2(sectionTitle),
  95. form({ method: "POST", action },
  96. tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
  97. span(i18n.calendarTitleLabel || "Title"), br(),
  98. input({ type: "text", name: "title", required: true, placeholder: i18n.calendarTitlePlaceholder || "Calendar title...", value: calendarToEdit ? calendarToEdit.title : "" }),
  99. br(), br(),
  100. span(i18n.calendarStatusLabel || "Status"), br(),
  101. select({ name: "status", required: true },
  102. option({ value: "OPEN", ...((!calendarToEdit || calendarToEdit.status === "OPEN") ? { selected: true } : {}) }, i18n.calendarStatusOpen || "OPEN"),
  103. option({ value: "CLOSED", ...((calendarToEdit && calendarToEdit.status === "CLOSED") ? { selected: true } : {}) }, i18n.calendarStatusClosed || "CLOSED")
  104. ),
  105. br(), br(),
  106. span(i18n.calendarDeadlineLabel || "Deadline"), br(),
  107. input({ type: "datetime-local", name: "deadline", required: true, min: now, value: calendarToEdit && calendarToEdit.deadline ? moment(calendarToEdit.deadline).format("YYYY-MM-DDTHH:mm") : "" }),
  108. br(), br(),
  109. span(i18n.calendarTagsLabel || "Tags"), br(),
  110. input({ type: "text", name: "tags", placeholder: i18n.calendarTagsPlaceholder || "tag1, tag2...", value: calendarToEdit && Array.isArray(calendarToEdit.tags) ? calendarToEdit.tags.join(", ") : "" }),
  111. br(), br(),
  112. !isEdit
  113. ? [
  114. span(i18n.calendarFirstDateLabel || "Date"), br(),
  115. input({ type: "datetime-local", name: "firstDate", required: true, min: now }),
  116. br(), br(),
  117. span(i18n.calendarFormDescription || "Description"), br(),
  118. input({ type: "text", name: "firstDateLabel", placeholder: i18n.calendarDatePlaceholder || "Describe this date..." }),
  119. br(), br(),
  120. renderIntervalBlock(),
  121. span(i18n.calendarFirstNoteLabel || "Notes"), br(),
  122. textarea({ name: "firstNote", rows: "3", placeholder: i18n.calendarNotePlaceholder || "Add a note..." }),
  123. br(), br()
  124. ]
  125. : null,
  126. button({ type: "submit", class: "create-button" }, isEdit ? (i18n.calendarUpdate || "Update") : (i18n.calendarCreate || "Create Calendar"))
  127. )
  128. )
  129. }
  130. const renderMonthGrid = (year, month, datesMap, calendarId) => {
  131. const DAY_NAMES = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"]
  132. const firstDay = new Date(year, month, 1)
  133. const daysInMonth = new Date(year, month + 1, 0).getDate()
  134. const startPad = (firstDay.getDay() + 6) % 7
  135. const monthStr = `${year}-${String(month + 1).padStart(2, "0")}`
  136. const headerCells = DAY_NAMES.map(d => div({ class: "calendar-day-header" }, d))
  137. const cells = []
  138. for (let i = 0; i < startPad; i++) cells.push(div({ class: "calendar-day calendar-day-empty" }, " "))
  139. for (let day = 1; day <= daysInMonth; day++) {
  140. const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
  141. const marked = datesMap && datesMap[dateStr] && datesMap[dateStr].length > 0
  142. if (marked) {
  143. cells.push(
  144. div({ class: "calendar-day calendar-day-marked" },
  145. a({ href: `/calendars/${encodeURIComponent(calendarId)}?month=${monthStr}&day=${dateStr}` }, String(day))
  146. )
  147. )
  148. } else {
  149. cells.push(div({ class: "calendar-day" }, String(day)))
  150. }
  151. }
  152. return div({ class: "calendar-grid" }, ...headerCells, ...cells)
  153. }
  154. exports.calendarsView = async (calendars, filter, calendarToEdit, params) => {
  155. const q = (params && params.q) || ""
  156. const showForm = filter === "create" || filter === "edit" || !!calendarToEdit
  157. const headerMap = {
  158. all: i18n.calendarAllSectionTitle || "Calendars",
  159. mine: i18n.calendarMineSectionTitle || "Your Calendars",
  160. recent: i18n.calendarRecentSectionTitle || "Recent Calendars",
  161. favorites: i18n.calendarFavoritesSectionTitle || "Favorites",
  162. open: i18n.calendarOpenSectionTitle || "Open Calendars",
  163. closed: i18n.calendarClosedSectionTitle || "Closed Calendars"
  164. }
  165. const headerText = headerMap[filter] || headerMap.all
  166. return template(
  167. i18n.calendarsTitle || "Calendars",
  168. section(
  169. div({ class: "tags-header" },
  170. h2(headerText),
  171. p(i18n.calendarsDescription || "Discover and manage calendars.")
  172. ),
  173. renderModeButtons(filter),
  174. q
  175. ? div({ class: "filters" },
  176. form({ method: "GET", action: "/calendars" },
  177. input({ type: "text", name: "q", value: q, placeholder: i18n.calendarSearchPlaceholder || "Search calendars..." }),
  178. button({ type: "submit", class: "filter-btn" }, i18n.searchButton || "Search")
  179. )
  180. )
  181. : null
  182. ),
  183. section(
  184. showForm
  185. ? renderCreateForm(calendarToEdit, params)
  186. : (calendars.length > 0
  187. ? div({ class: "tribe-grid" }, ...calendars.map(c => renderCalendarCard(c)))
  188. : p({ class: "no-content" }, i18n.calendarsNoItems || "No calendars found."))
  189. )
  190. )
  191. }
  192. exports.singleCalendarView = async (calendar, dates, notesByDate, params) => {
  193. const { month: monthStr, day: selectedDay } = params || {}
  194. const isAuthor = calendar.author === userId
  195. const isParticipant = calendar.participants.includes(userId)
  196. const calClosed = calendar.isClosed
  197. const shareUrl = `/calendars/${encodeURIComponent(calendar.rootId)}`
  198. const now = moment()
  199. const currentMonth = monthStr ? moment(monthStr, "YYYY-MM") : now.clone().startOf("month")
  200. const prevMonth = currentMonth.clone().subtract(1, "month").format("YYYY-MM")
  201. const nextMonth = currentMonth.clone().add(1, "month").format("YYYY-MM")
  202. const year = currentMonth.year()
  203. const month = currentMonth.month()
  204. const datesMap = {}
  205. for (const d of dates) {
  206. const dayKey = moment(d.date).format("YYYY-MM-DD")
  207. if (!datesMap[dayKey]) datesMap[dayKey] = []
  208. datesMap[dayKey].push(d)
  209. }
  210. const tags = Array.isArray(calendar.tags) && calendar.tags.length > 0
  211. ? div({ class: "tribe-side-tags" }, ...calendar.tags.map(t => a({ href: `/search?query=%23${encodeURIComponent(t)}` }, `#${t} `)))
  212. : null
  213. const calSide = div({ class: "tribe-side" },
  214. h2(null, calendar.title || "\u2014"),
  215. div({ class: "shop-share" },
  216. span({ class: "tribe-info-label" }, i18n.calendarsShareUrl || "Share URL"),
  217. input({ type: "text", readonly: true, value: shareUrl, class: "shop-share-input" })
  218. ),
  219. div({ class: "tribe-card-members" },
  220. span({ class: "tribe-members-count calendar-participants-count" }, `${i18n.calendarParticipantsLabel || "Participants"}: ${calendar.participants.length}`)
  221. ),
  222. table({ class: "tribe-info-table" },
  223. tr(td({ class: "tribe-info-label" }, i18n.calendarCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(calendar.createdAt).format("YYYY-MM-DD"))),
  224. tr(td({ class: "tribe-info-value", colspan: "4" }, a({ href: `/author/${encodeURIComponent(calendar.author)}`, class: "user-link" }, calendar.author))),
  225. tr(td({ class: "tribe-info-label" }, i18n.calendarStatusLabel || "Status"), td({ class: "tribe-info-value", colspan: "3" }, renderStatus(calendar))),
  226. calendar.deadline ? tr(td({ class: "tribe-info-label" }, i18n.calendarDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, moment(calendar.deadline).format("YYYY-MM-DD HH:mm"))) : null
  227. ),
  228. div({ class: "tribe-side-actions" },
  229. renderCalendarFavoriteToggle(calendar, shareUrl),
  230. isAuthor
  231. ? form({ method: "GET", action: "/calendars" },
  232. input({ type: "hidden", name: "filter", value: "edit" }),
  233. input({ type: "hidden", name: "id", value: calendar.rootId }),
  234. button({ type: "submit", class: "tribe-action-btn" }, i18n.calendarUpdate || "Update")
  235. )
  236. : null,
  237. isAuthor
  238. ? form({ method: "POST", action: `/calendars/delete/${encodeURIComponent(calendar.rootId)}` },
  239. button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.calendarDelete || "Delete")
  240. )
  241. : null,
  242. !isAuthor
  243. ? a({ href: `/pm?to=${encodeURIComponent(calendar.author)}`, class: "tribe-action-btn" }, "PM")
  244. : null,
  245. !isAuthor && !isParticipant
  246. ? form({ method: "POST", action: `/calendars/join/${encodeURIComponent(calendar.rootId)}` },
  247. button({ type: "submit", class: "create-button" }, i18n.calendarJoin || "Join Calendar")
  248. )
  249. : null,
  250. !isAuthor && isParticipant
  251. ? form({ method: "POST", action: `/calendars/leave/${encodeURIComponent(calendar.rootId)}` },
  252. button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.calendarLeave || "Leave Calendar")
  253. )
  254. : null
  255. ),
  256. tags
  257. )
  258. const minDate = now.add(1, "minute").format("YYYY-MM-DDTHH:mm")
  259. const canAddDate = !calClosed && (calendar.status === "OPEN" || isAuthor)
  260. const unifiedForm = canAddDate
  261. ? div({ class: "div-center audio-form" },
  262. h4(i18n.calendarAddEntry || "Add Entry"),
  263. form({ method: "POST", action: `/calendars/add-date/${encodeURIComponent(calendar.rootId)}` },
  264. span(i18n.calendarDateLabel || "Date"), br(),
  265. input({ type: "datetime-local", name: "date", required: true, min: minDate }),
  266. br(), br(),
  267. span(i18n.calendarFormDescription || "Description"), br(),
  268. input({ type: "text", name: "label", placeholder: i18n.calendarDatePlaceholder || "Describe this date..." }),
  269. br(), br(),
  270. renderIntervalBlock(),
  271. br(),
  272. isParticipant
  273. ? [
  274. span(i18n.calendarNoteLabel || "Note (optional)"), br(),
  275. textarea({ name: "text", rows: "3", placeholder: i18n.calendarNotePlaceholder || "Add a note..." }),
  276. br(), br()
  277. ]
  278. : null,
  279. button({ type: "submit", class: "create-button" }, i18n.calendarAddEntry || "Add Entry")
  280. )
  281. )
  282. : null
  283. const monthLabel = currentMonth.format("MMMM YYYY")
  284. const calNav = div({ class: "calendar-nav" },
  285. a({ href: `${shareUrl}?month=${prevMonth}`, class: "filter-btn" }, i18n.calendarMonthPrev || "\u2190 Prev"),
  286. span({ class: "tribe-info-label" }, monthLabel),
  287. a({ href: `${shareUrl}?month=${nextMonth}`, class: "filter-btn" }, i18n.calendarMonthNext || "Next \u2192")
  288. )
  289. const grid = renderMonthGrid(year, month, datesMap, calendar.rootId)
  290. const dayEntries = selectedDay
  291. ? dates.filter(d => moment(d.date).format("YYYY-MM-DD") === selectedDay)
  292. : []
  293. const dayNotesSection = selectedDay
  294. ? div({ class: "calendar-day-notes" },
  295. h4(`${selectedDay}${dayEntries.length > 0 && dayEntries[0].label ? " \u2014 " + dayEntries[0].label : ""}`),
  296. dayEntries.length === 0
  297. ? p({ class: "no-content" }, i18n.calendarNoDates || "No dates added yet.")
  298. : div(null, ...dayEntries.map(d => {
  299. const notes = (notesByDate && notesByDate[d.key]) ? notesByDate[d.key] : []
  300. return div({ class: "calendar-date-item" },
  301. (isAuthor || String(d.author) === String(userId))
  302. ? form({ method: "POST", action: `/calendars/delete-date/${encodeURIComponent(d.key)}`, class: "calendar-date-delete" },
  303. input({ type: "hidden", name: "calendarId", value: calendar.rootId }),
  304. button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.calendarDeleteDate || "Delete Date")
  305. )
  306. : null,
  307. div({ class: "calendar-date-item-header" },
  308. `${moment(d.date).format("YYYY-MM-DD HH:mm")}${d.label ? " \u2014 " + d.label : ""}`
  309. ),
  310. notes.length === 0
  311. ? p({ class: "no-content" }, i18n.calendarNoNotes || "No notes.")
  312. : div(null, ...notes.map(n => {
  313. const isSelf = String(n.author) === String(userId)
  314. const dateStr = moment(n.createdAt).format("YYYY/MM/DD HH:mm")
  315. const shortId = n.author ? "@" + n.author.slice(1, 9) + "\u2026" : "?"
  316. return div({ class: (isSelf ? "chat-message chat-message-self" : "chat-message") + " calendar-note-card" },
  317. isSelf
  318. ? form({ method: "POST", action: `/calendars/delete-note/${encodeURIComponent(n.key)}`, class: "calendar-note-delete" },
  319. input({ type: "hidden", name: "calendarId", value: calendar.rootId }),
  320. button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.calendarDeleteNote || "Delete")
  321. )
  322. : null,
  323. div({ class: "chat-message-meta" },
  324. span({ class: "chat-message-sender" },
  325. a({ href: `/author/${encodeURIComponent(n.author)}`, class: "user-link" }, shortId)
  326. ),
  327. span({ class: "chat-message-date" }, ` [ ${dateStr} ]`)
  328. ),
  329. span({ class: "chat-message-text" }, ...renderNoteText(n.text || ""))
  330. )
  331. }))
  332. )
  333. }))
  334. )
  335. : null
  336. const calMain = div({ class: "tribe-main" },
  337. calNav,
  338. grid,
  339. dayNotesSection,
  340. unifiedForm
  341. )
  342. return template(
  343. calendar.title || i18n.calendarsTitle || "Calendar",
  344. section(
  345. div({ class: "tags-header" },
  346. h2(i18n.calendarsTitle || "Calendars"),
  347. p(i18n.calendarsDescription || "Discover and manage calendars.")
  348. ),
  349. renderModeButtons("all")
  350. ),
  351. section(div({ class: "tribe-details" }, calSide, calMain))
  352. )
  353. }