gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit1ed5457e1ed5457etracker#20 + #21 (mission 068): typed headings "<Type> | <name>" in type colours; TV titles split into Series (/series) and Shows (/shows) by TMDB type + Reality/Talk/News genres — kind stored by sync/import/adult backfill + new kind backfill (resumes), /movies/<slug>, /shows/<slug> of a series/movie → 301, /my/series + /my/shows, home 5 tiles + 3 rows, search/filmography labels; gates kinds 32 + browser 266, tests/realdata-068.mjs, tools/count-kinds.hl, docs/kinds.md, README + STATUSmre1ed5457e/components/show.hl

25.7 KB

  1. // components/show.hl — a title's own page (tracker.worldapi.org#4): since tracker#21 (mission 068) `/series/:slug`, `/movies/:slug`
  2. // and `/shows/:slug` (the last through project.hl showsRoute: a show is rendered there, a series or movie answers 301 to its
  3. // own address). tracker#20: the heading is "<Type> | <title>" — Series / Show / Movie in the type's colour (styles.hl
  4. // `type-*`), the title white. Creator's words:
  5. // "the poster on the left, right of it the title of the show, the genre pills, below the plot summary,
  6. // below that the artists just as normal links, separated with comma color red, genres are blue" (the
  7. // header) — "then all seasons in reverse order, latest on top, below them collapsable their episodes,
  8. // episodes first the round check icon, if the episode was watched by the user looking its solid, if
  9. // not its regular, then the episode number, then the title. the last season is not collapsed. click on
  10. // the check adds a watch. the season also has a check, if the season is checked or unchecked all its
  11. // episodes get checked or unchecked. artists link to /person/<artist-slug> the genre to /genre/<genre-slug>."
  12. //
  13. // THE ROWS ARE ONE FLAT LIST, not a nested for-loop over seasons > episodes: the vendored build may
  14. // still lack the fix for hybriel#86 (a nested for over a reassigned outer list keeps stale inner
  15. // cells) — a single-level `for (row of rows)` with a `kind` field, reassigned whole on every click,
  16. // is the same proven-safe shape components/main.hl's own header and ident's inbox.hl already use.
  17. // `rows` is rebuilt from scratch (seasons/episodes refetched by id, watch rows refetched) on every
  18. // toggle — cheap: a show has at most a few dozen episodes per season.
  19. //
  20. // Mission 047 (the creator, 2026-10-01): a caret at the season header's right edge (down = open, right = collapsed); episode
  21. // rows not indented; on a phone the title first, then the poster at full width, then genres/plot/cast; a Follow toggle
  22. // next to the title. SIGNED OUT the checks and Follow are visible too: a click opens the "sign in" modal (modal.hl, copied
  23. // from components.hybriel) and writes nothing.
  24. //
  25. // tracker.worldapi.org#17 (mission 057): the caret points DOWN while a season is closed and UP while it is open. Opening a
  26. // season shows SKELETON rows at once (min(episodes, 8), a CSS shimmer, styles.hl) and then its episodes from the face
  27. // `showSeasonEpisodes`, which takes NO session: a face with `session` makes hl:web re-mount the whole page on the server and
  28. // ship every session-derived member back as a `sync` (WebFramework.hl `inbound`) — that, not the episodes, was most of the
  29. // old 250–450 ms (real data). The watched state comes from `watchedEpisodes` (this show's watched episodes per season, rendered
  30. // with the page and re-synced after every watch face). Closing is client-only (no request). An ADULT show (only its
  31. // followers see it) keeps the session face `showRows`: the public face refuses adult seasons.
  32. //
  33. // tracker.worldapi.org#12: link icons under the title — the show on TMDB, IMDb, TheTVDB, TVmaze (a movie: TMDB + IMDb),
  34. // only for the ids it has (shows.hl externalLinksOf), each in a new tab.
  35. //
  36. // mission 058: #22 every episode row shows its air date (`release`, 'YYYY-MM-DD' like /my/unwatched; none → empty). #23 a
  37. // season's check is DERIVED: solid exactly when every one of its episodes is watched (a season without episodes: its own
  38. // season watch) — unchecking one episode turns the season off with the face's answer, checking it again turns it on; the
  39. // season click watches all when not all are watched, else unwatches all. #24 a MOVIE's page has its own watched check
  40. // (watch `targetType = 'movie'`, target = the title's id; face `showToggleMovie`), signed out → the sign-in modal.
  41. //
  42. parent './main.hl'
  43. import Modal from './modal.hl'
  44. import { appTitle } from '../project.hl'
  45. import { userIdOfSession, loginHref } from '../users.hl'
  46. import { isFollowing, setFollowed } from '../follows.hl'
  47. import { showBySlug, showById, genreById, personById, seasonsOfShow, episodesOfSeason, seasonById, posterName, externalLinksOf, titleKind } from '../shows.hl'
  48. import { watchesOfUser, isWatched, watchedSet, setWatched, setSeasonWatched, watchClassOf } from '../watches.hl'
  49. // ---- pure helpers ----------------------------------------------------------------------------
  50. // `static`, like calendar.hl's `soonOf` — a PLAIN instance member function that calls an
  51. // hl:mpackdb-backed import (seasonsOfShow, episodesOfSeason, watchesOfUser, …) comes back null
  52. // when called from an `on server` face (confirmed by running the gate: "'buildRows' is not
  53. // callable — it holds null" at the face's call site, even though the SAME call works fine as a
  54. // member's own initializer at the top of this file). `static` is the proven-safe shape (calendar's
  55. // `soonOf`, called from `calLoad`/`calSettingsLoad`, an mpackdb-backed helper exactly like this one).
  56. static initialCollapsed = (seasons) => {
  57. let m = {}
  58. let i = 0
  59. while (i < seasons.length) {
  60. if (i > 0) { m[seasons[i].id] = true }
  61. i = i + 1
  62. }
  63. return m
  64. }
  65. static genreRowsOf = (show) => {
  66. let out = []
  67. for (gid of show.genres) {
  68. let g = genreById(gid)
  69. if (g != null) { out.push({ id = g.id name = g.name href = '/genres/' + g.urlSegment }) }
  70. }
  71. return out
  72. }
  73. static castRowsOf = (show) => {
  74. let out = []
  75. for (c of show.cast) {
  76. let p = personById(c.person)
  77. if (p != null) { out.push({ name = c.actor href = '/person/' + p.urlSegment }) }
  78. }
  79. return out
  80. }
  81. // a View attribute must be a literal, a member or a field path — never an inline expression — so
  82. // every ternary (watched -> icon class, collapsed -> row class) is resolved HERE, once, into a
  83. // plain field the View just reads (row.watchClass, row.rowClass); `watchClassOf` itself lives in
  84. // watches.hl (shared with /unwatched and /schedule, "reuse, don't copy")
  85. // #22: an episode's air date as the lists show it ('YYYY-MM-DD'), '' when it has none
  86. static airDateOf = (e) => { return e.release != null && hlTypeName(e.release) == 'String' ? e.release : '' }
  87. // #23: a season is watched when ALL its episodes are (by the ids it lists); one without episodes keeps its own season watch
  88. static seasonAllWatched = (s, watched) => {
  89. if (s.episodes.length == 0) { return watched['season:' + s.id] == true }
  90. for (eid of s.episodes) { if (watched['episode:' + eid] != true) { return false } }
  91. return true
  92. }
  93. static buildRows = (show, uid, collapsed) => {
  94. let watched = watchedSet(uid != null ? watchesOfUser(uid) : [])
  95. let out = []
  96. for (s of seasonsOfShow(show)) {
  97. let isCollapsed = collapsed[s.id] == true
  98. let label = s.title != null && s.title != '' ? s.title : 'Season ' + s.seasonNumber
  99. let seasonWatched = seasonAllWatched(s, watched)
  100. out.push({ kind = 'season' isSeason = true isEpisode = false isSkeleton = false id = s.id skeletonCount = s.episodes.length < 8 ? s.episodes.length : 8 label = label episodeCountText = s.episodes.length == 1 ? '1 episode' : s.episodes.length + ' episodes' watched = seasonWatched watchClass = watchClassOf(seasonWatched) collapsed = isCollapsed rowClass = isCollapsed ? 'collapsed' : 'expanded' })
  101. if (!isCollapsed) {
  102. for (e of episodesOfSeason(s)) {
  103. let epWatched = watched['episode:' + e.id] == true
  104. out.push({ kind = 'episode' isSeason = false isEpisode = true isSkeleton = false id = e.id seasonId = s.id episodeNumber = e.episodeNumber title = e.title != null ? e.title : '' airDate = airDateOf(e) watched = epWatched watchClass = watchClassOf(epWatched) })
  105. }
  106. }
  107. }
  108. return out
  109. }
  110. // what the client marks the rows of a season it opens with (tracker#17) — only this show's, never the user's whole watch
  111. // list (every member is shipped to the browser). Per season with a watched episode: `{ all, except }` — `all` = most of
  112. // it is watched, then `except` holds the UNwatched ids, else the watched ones (a fully watched 142-episode season is
  113. // `{ all = true except = {} }`, not 142 ids). No entry = nothing watched.
  114. static watchedEpisodesOf = (show, uid) => {
  115. let m = {}
  116. if (uid == null) { return m }
  117. let watched = watchedSet(watchesOfUser(uid))
  118. for (s of seasonsOfShow(show)) {
  119. let yes = {}
  120. let no = {}
  121. let n = 0
  122. for (eid of s.episodes) {
  123. if (watched['episode:' + eid] == true) { yes[eid] = true n = n + 1 } else { no[eid] = true }
  124. }
  125. if (n > 0) { m[s.id] = n * 2 > s.episodes.length ? { all = true except = no } : { all = false except = yes } }
  126. }
  127. return m
  128. }
  129. // is this episode watched, by that per-season shape (client-side, pure)
  130. static watchedIn = (entry, episodeId) => {
  131. if (entry == null) { return false }
  132. let listed = entry.except[episodeId] == true
  133. return entry.all ? !listed : listed
  134. }
  135. // ---- the rows as the CLIENT changes them (tracker#17) — pure: no import is reached, so they run in the browser ----------
  136. // (`watchClassOf` is an import of the mpackdb-backed watches.hl, so `filledRows` writes its two classes itself)
  137. static seasonRowAs = (row, isCollapsed) => {
  138. return { kind = 'season' isSeason = true isEpisode = false isSkeleton = false id = row.id skeletonCount = row.skeletonCount label = row.label episodeCountText = row.episodeCountText watched = row.watched watchClass = row.watchClass collapsed = isCollapsed rowClass = isCollapsed ? 'collapsed' : 'expanded' }
  139. }
  140. // the season opened: its caret up at once, `skeletonCount` placeholder rows under it
  141. static openingRows = (rows, seasonId) => {
  142. let out = []
  143. for (row of rows) {
  144. if (row.isSeason && row.id == seasonId) {
  145. out.push(seasonRowAs(row, false))
  146. let i = 0
  147. while (i < row.skeletonCount) {
  148. out.push({ kind = 'skeleton' isSeason = false isEpisode = false isSkeleton = true id = seasonId + '-skeleton-' + i seasonId = seasonId })
  149. i = i + 1
  150. }
  151. } else { out.push(row) }
  152. }
  153. return out
  154. }
  155. // the season closed: its episode (and skeleton) rows gone, caret down
  156. static closedRows = (rows, seasonId) => {
  157. let out = []
  158. for (row of rows) {
  159. if (row.isSeason && row.id == seasonId) { out.push(seasonRowAs(row, true)) }
  160. else if (!row.isSeason && row.seasonId == seasonId) { }
  161. else { out.push(row) }
  162. }
  163. return out
  164. }
  165. // the episodes arrived: they replace the season's skeleton rows. No skeleton of it left (closed meanwhile, or a watch face
  166. // already rebuilt every row) → the rows stay as they are (no double rows)
  167. static filledRows = (rows, seasonId, episodes, watched) => {
  168. let out = []
  169. let done = false
  170. for (row of rows) {
  171. if (row.isSkeleton && row.seasonId == seasonId) {
  172. if (!done) {
  173. for (e of episodes) {
  174. let w = watchedIn(watched[seasonId], e.id)
  175. out.push({ kind = 'episode' isSeason = false isEpisode = true isSkeleton = false id = e.id seasonId = seasonId episodeNumber = e.episodeNumber title = e.title airDate = e.airDate watched = w watchClass = w ? 'checkicon solid' : 'checkicon regular' })
  176. }
  177. done = true
  178. }
  179. } else { out.push(row) }
  180. }
  181. return out
  182. }
  183. // a LOCAL static like buildRows (an imported mpackdb helper called straight from a member's initializer came back false
  184. // after a client-side navigation to the page — the gate's follow check)
  185. static followingOf = (show, uid) => { return uid != null && isFollowing(uid, show.id) }
  186. // #24: has this user watched this movie (a watch `movie:<title id>`)?
  187. static movieWatchedOf = (show, uid) => { return uid != null && isWatched(watchesOfUser(uid), 'movie', show.id) }
  188. // mission 056: an ADULT title's page (TMDB's flag `adult == true`, mission 054) is "Not found" for everyone who does not
  189. // follow it (signed out too); a follower sees it as before. Unknown (`null`) is not hidden here — only in the lists.
  190. // With hybriel ≥ 73267707 (#121/#122) `showRow`/`found` are server sites re-derived after every session face with the page's
  191. // slug, so an Unfollow on the page hides it at once (the gate proves it; the old build left the page up until a reload).
  192. // Mission 054's try (`found = … && !adultHiddenFor(showRow, me)`, `collapsed`/`rows` from `found`) broke the checks.
  193. static adultHiddenFor = (show, uid) => { return show.adult == true && !followingOf(show, uid) }
  194. // the page's show — null when there is none OR it is hidden for this user: every member is shipped to the browser, so the
  195. // row of a hidden title must never be one
  196. static visibleShow = (slug, uid) => {
  197. let s = showBySlug(slug)
  198. return s != null && adultHiddenFor(s, uid) ? null : s
  199. }
  200. // the initial open/closed seasons and the title's kind (tracker#20/#21: the heading's type word) from the SLUG alone (not from
  201. // `showRow`, which reads the session — see `collapsed`) — one slug lookup for both
  202. static slugInfoOf = (slug) => {
  203. let s = showBySlug(slug)
  204. return s != null ? { collapsed = initialCollapsed(seasonsOfShow(s)) kind = titleKind(s) } : { collapsed = {} kind = 'series' }
  205. }
  206. // ---- reactive members -----------------------------------------------------------------------
  207. slug = null
  208. session = null
  209. userId = userIdOfSession(session)
  210. signedIn = userId != null
  211. // SESSION SYNC (mission 047; since hybriel #121/#122, mission 056): a face taking `session` answers with a `sync` of every
  212. // member derived from session that reaches an import (here `showRow`, `found`, `following`, `rows`, …), re-derived on the
  213. // server with the page's route params but the MOUNT's other state — so client state (`collapsed`) must never derive from
  214. // them. `me` stays the plain copy mission 047 introduced.
  215. me = userId
  216. showRow = visibleShow(slug, me)
  217. found = showRow != null
  218. notFound = showRow == null
  219. // the Follow toggle: "Follow" = the inverted button (accent border), "Following" = the filled one
  220. following = found ? followingOf(showRow, me) : false
  221. followClass = following ? 'follow' : 'follow inverted'
  222. followText = following ? 'Following' : 'Follow'
  223. signInHref = loginHref
  224. // #24: a movie's watched check (same icon/classes as the episodes); the classes written out, not `watchClassOf` (an import of
  225. // the mpackdb-backed watches.hl cannot run in the browser when the member is re-derived there)
  226. isMovie = found && showRow.type == 'movie'
  227. movieWatched = isMovie ? movieWatchedOf(showRow, me) : false
  228. movieWatchClass = movieWatched ? 'checkicon solid' : 'checkicon regular'
  229. movieWatchText = movieWatched ? 'Watched' : 'Not watched'
  230. signInText = isMovie ? 'You need to sign in to follow movies and mark them watched.' : 'You need to sign in to follow shows and mark episodes.'
  231. title = found ? showRow.title : ''
  232. // tracker#20: "Series | Reacher" — the kind from the slug (pure strings here: they are re-derived in the browser too)
  233. slugInfo = slugInfoOf(slug)
  234. pageKind = slugInfo.kind
  235. typeText = pageKind == 'movie' ? 'Movie' : (pageKind == 'show' ? 'Show' : 'Series')
  236. typeClass = 'type-word type-' + pageKind
  237. summary = found && showRow.summary != null ? showRow.summary : ''
  238. poster = found ? posterName(showRow) : null
  239. hasPoster = poster != null
  240. posterUrl = hasPoster ? '/posters/' + poster : ''
  241. // mission 060 (tracker.worldapi.org#27): the short id under the poster (none yet while the backfill has not reached it)
  242. shortId = found && showRow.shortId != null ? '' + showRow.shortId : ''
  243. hasShortId = shortId != ''
  244. genreRows = found ? genreRowsOf(showRow) : []
  245. hasGenres = genreRows.length > 0
  246. castRows = found ? castRowsOf(showRow) : []
  247. hasCast = castRows.length > 0
  248. linkRows = found ? externalLinksOf(showRow) : []
  249. hasLinks = linkRows.length > 0
  250. // NOT from `showRow`/`found`: they read the session (via `me`), so a member derived from them is re-derived on the server
  251. // after every session face with the MOUNT's state — `collapsed` (client state) would snap back to the initial seasons (mission
  252. // 056: the 2nd episode click in a re-opened season did nothing). The season ids of a hidden page are no secret.
  253. collapsed = slugInfo.collapsed
  254. rows = found ? buildRows(showRow, me, collapsed) : []
  255. hasRows = rows.length > 0
  256. watchedEpisodes = found ? watchedEpisodesOf(showRow, me) : {}
  257. // an adult page (followers only) opens its seasons through the session face `showRows`
  258. adultShow = found && showRow.adult == true
  259. __title = found ? title + ' — ' + appTitle : appTitle
  260. View {
  261. showPage {
  262. if (notFound) {
  263. article { class = "main"
  264. h1 { "Not found" }
  265. p { "This show doesn't exist." }
  266. }
  267. }
  268. if (found) {
  269. showHeader {
  270. showPoster {
  271. if (hasPoster) { img { src = posterUrl alt = title } }
  272. if (hasShortId) { shortIdLabel { id = "shortid" title = "Short id" shortId } }
  273. }
  274. showMeta {
  275. showTitle {
  276. h1 { class = "typed" typeWord { class = typeClass typeText } typeSep { "|" } typeName { title } }
  277. button { id = "follow" type = "button" class = followClass followText on click(e) { emit toggleFollow(e) } }
  278. }
  279. if (isMovie) {
  280. movieWatch {
  281. button { id = "moviewatch" type = "button" class = movieWatchClass "aria-label" = "mark movie watched"
  282. svg { viewBox = "0 0 24 24" width = "22" height = "22" "aria-hidden" = "true"
  283. circle { cx = "12" cy = "12" r = "10" }
  284. path { d = "M7 12.5 L10.5 16 L17 8.5" }
  285. }
  286. on click(e) { emit toggleMovie(e) }
  287. }
  288. movieWatchLabel { movieWatchText }
  289. }
  290. }
  291. if (hasLinks) {
  292. showLinks {
  293. for (l of linkRows) { a { class = "extlink" href = l.href target = "_blank" rel = "noopener" title = l.title "aria-label" = l.title "data-site" = l.site l.label } }
  294. }
  295. }
  296. if (hasGenres) {
  297. showGenres {
  298. for (g of genreRows) { a { class = "genre-pill" href = g.href g.name } }
  299. }
  300. }
  301. showSummary { summary }
  302. if (hasCast) {
  303. showCast {
  304. for (c of castRows) { a { class = "artist-link" href = c.href c.name } }
  305. }
  306. }
  307. }
  308. }
  309. if (hasRows) {
  310. showSeasons {
  311. for (row of rows) {
  312. if (row.isSeason) {
  313. seasonRow { class = row.rowClass
  314. button { type = "button" class = row.watchClass value = row.id "aria-label" = "mark season watched"
  315. svg { viewBox = "0 0 24 24" width = "22" height = "22" "aria-hidden" = "true"
  316. circle { cx = "12" cy = "12" r = "10" }
  317. path { d = "M7 12.5 L10.5 16 L17 8.5" }
  318. }
  319. on click(e) { emit toggleSeason(row.id) }
  320. }
  321. button { type = "button" class = "season-toggle" value = row.id
  322. seasonLabel { row.label }
  323. seasonCount { row.episodeCountText }
  324. seasonCaret { "aria-hidden" = "true"
  325. svg { viewBox = "0 0 24 24" width = "20" height = "20"
  326. path { d = "M6 9 L12 15 L18 9" }
  327. }
  328. }
  329. on click(e) { emit toggleCollapse(row.id) }
  330. }
  331. }
  332. }
  333. if (row.isEpisode) {
  334. episodeRow {
  335. button { type = "button" class = row.watchClass value = row.id "aria-label" = "mark episode watched"
  336. svg { viewBox = "0 0 24 24" width = "22" height = "22" "aria-hidden" = "true"
  337. circle { cx = "12" cy = "12" r = "10" }
  338. path { d = "M7 12.5 L10.5 16 L17 8.5" }
  339. }
  340. on click(e) { emit toggleEpisode(row.id) }
  341. }
  342. episodeNumber { row.episodeNumber }
  343. episodeTitle { row.title }
  344. episodeDate { row.airDate }
  345. }
  346. }
  347. if (row.isSkeleton) {
  348. skeletonRow { "aria-hidden" = "true"
  349. skeletonCheck { }
  350. skeletonNumber { }
  351. skeletonTitle { }
  352. }
  353. }
  354. }
  355. }
  356. }
  357. Modal { id = "signin" title = "Sign in"
  358. p { signInText }
  359. footer {
  360. button { type = "button" value = "close" "Close" }
  361. a { id = "signin-link" class = "button" href = signInHref "Sign in with ident" }
  362. }
  363. }
  364. }
  365. }
  366. }
  367. // Every rebuild of `rows` happens SERVER-SIDE, never by calling `buildRows` from a client handler:
  368. // a member function whose body reaches an hl:mpackdb-backed import (seasonsOfShow, episodesOfSeason,
  369. // watchesOfUser, …) compiles to nothing on the client (no case ever assigns it — a genuine Hybriel
  370. // gap, filed under `issues`) even though the client bundle still calls it, so `this.buildRows(...)`
  371. // throws "not a function" in the browser. The client here only ever ASSIGNS the `rows` the server
  372. // already built (`rows = r.rows`) — never calls a tainted local helper itself. `slug` goes along so
  373. // the face can refetch the show without depending on implicit access to a sibling member.
  374. //
  375. // tracker#17: opening shows the skeleton rows BEFORE the request; the answer replaces them unless the season was closed
  376. // meanwhile (then nothing). An error closes the season again. Closing never asks the server.
  377. on toggleCollapse(seasonId) {
  378. let m = collapsed
  379. if (collapsed[seasonId] != true) {
  380. m[seasonId] = true
  381. collapsed = m
  382. rows = closedRows(rows, seasonId)
  383. return null
  384. }
  385. m[seasonId] = false
  386. collapsed = m
  387. rows = openingRows(rows, seasonId)
  388. if (adultShow) {
  389. let a = emit server showRows(slug, collapsed)
  390. if (collapsed[seasonId] == true) { return null }
  391. if (a == null || a.error != null) { m[seasonId] = true collapsed = m rows = closedRows(rows, seasonId) return null }
  392. rows = a.rows
  393. return null
  394. }
  395. let r = emit server showSeasonEpisodes('' + seasonId)
  396. if (collapsed[seasonId] == true) { return null }
  397. if (r == null || r.error != null) { m[seasonId] = true collapsed = m rows = closedRows(rows, seasonId) return null }
  398. rows = filledRows(rows, seasonId, r.episodes, watchedEpisodes)
  399. }
  400. // signed out: the modal, nothing is sent
  401. on toggleEpisode(episodeId) {
  402. if (!signedIn) { document.getElementById('signin').showModal() return null }
  403. let r = emit server showToggleEpisode(slug, '' + episodeId, collapsed)
  404. if (r == null || r.error != null) { return null }
  405. rows = r.rows
  406. }
  407. on toggleSeason(seasonId) {
  408. if (!signedIn) { document.getElementById('signin').showModal() return null }
  409. let r = emit server showToggleSeason(slug, '' + seasonId, collapsed)
  410. if (r == null || r.error != null) { return null }
  411. rows = r.rows
  412. }
  413. // #24: the movie's own check — signed out the modal, nothing sent
  414. on toggleMovie(e) {
  415. if (!signedIn) { document.getElementById('signin').showModal() return null }
  416. let r = emit server showToggleMovie(slug)
  417. if (r == null || r.error != null) { return null }
  418. movieWatched = r.watched
  419. movieWatchClass = r.watched ? 'checkicon solid' : 'checkicon regular'
  420. movieWatchText = r.watched ? 'Watched' : 'Not watched'
  421. }
  422. on toggleFollow(e) {
  423. if (!signedIn) { document.getElementById('signin').showModal() return null }
  424. let r = emit server showToggleFollow(slug)
  425. if (r == null || r.error != null) { return null }
  426. following = r.following
  427. followClass = r.following ? 'follow' : 'follow inverted'
  428. followText = r.following ? 'Following' : 'Follow'
  429. }
  430. // ---- the faces (the trailing `session` is always the server's: hybriel#16) ----
  431. // every row for these open/closed seasons — only an ADULT show's page still uses it (its followers; tracker#17)
  432. on server showRows(slug, collapsed, session) {
  433. let show = showBySlug(slug)
  434. let uid = userIdOfSession(session)
  435. if (show == null || adultHiddenFor(show, uid)) { return { error = 'no such show' } }
  436. return { ok = true rows = buildRows(show, uid, collapsed) }
  437. }
  438. // tracker#17: one season's episodes, in order — public data, so NO `session` (no page re-mount, no sync: the fast path).
  439. // Refuses a season of an adult title (those pages use showRows above).
  440. on server showSeasonEpisodes(seasonId) {
  441. if (seasonId == null || hlTypeName(seasonId) != 'String') { return { error = 'no such season' } }
  442. let season = seasonById(seasonId)
  443. if (season == null) { return { error = 'no such season' } }
  444. let show = showById(season.show)
  445. if (show == null || show.adult == true) { return { error = 'no such season' } }
  446. let out = []
  447. for (e of episodesOfSeason(season)) { out.push({ id = e.id episodeNumber = e.episodeNumber title = e.title != null ? e.title : '' airDate = airDateOf(e) }) }
  448. return { ok = true episodes = out }
  449. }
  450. on server showToggleEpisode(slug, episodeId, collapsed, session) {
  451. let show = showBySlug(slug)
  452. if (show == null) { return { error = 'no such show' } }
  453. let uid = userIdOfSession(session)
  454. if (uid == null) { return { error = 'you are not signed in' } }
  455. if (adultHiddenFor(show, uid)) { return { error = 'no such show' } }
  456. let rows = watchesOfUser(uid)
  457. let watched = isWatched(rows, 'episode', episodeId) == false
  458. let ok = setWatched(uid, 'episode', episodeId, rows, watched)
  459. if (!ok) { return { error = 'could not save the watch' } }
  460. return { ok = true rows = buildRows(show, uid, collapsed) }
  461. }
  462. on server showToggleSeason(slug, seasonId, collapsed, session) {
  463. let show = showBySlug(slug)
  464. if (show == null) { return { error = 'no such show' } }
  465. let uid = userIdOfSession(session)
  466. if (uid == null) { return { error = 'you are not signed in' } }
  467. if (adultHiddenFor(show, uid)) { return { error = 'no such show' } }
  468. let season = seasonById(seasonId)
  469. if (season == null) { return { error = 'no such season' } }
  470. let rows = watchesOfUser(uid)
  471. // #23: not all episodes watched → watch them all; all watched → unwatch them all
  472. let watched = !seasonAllWatched(season, watchedSet(rows))
  473. let ok = setSeasonWatched(uid, seasonId, season.episodes, rows, watched)
  474. if (!ok) { return { error = 'could not save the watch' } }
  475. return { ok = true rows = buildRows(show, uid, collapsed) }
  476. }
  477. // #24: a movie's watched check (only a movie: a series is watched by its episodes)
  478. on server showToggleMovie(slug, session) {
  479. let show = showBySlug(slug)
  480. if (show == null || show.type != 'movie') { return { error = 'no such movie' } }
  481. let uid = userIdOfSession(session)
  482. if (uid == null) { return { error = 'you are not signed in' } }
  483. if (adultHiddenFor(show, uid)) { return { error = 'no such movie' } }
  484. let rows = watchesOfUser(uid)
  485. let watched = isWatched(rows, 'movie', show.id) == false
  486. if (!setWatched(uid, 'movie', show.id, rows, watched)) { return { error = 'could not save the watch' } }
  487. return { ok = true watched = watched }
  488. }
  489. on server showToggleFollow(slug, session) {
  490. let show = showBySlug(slug)
  491. if (show == null) { return { error = 'no such show' } }
  492. let uid = userIdOfSession(session)
  493. if (uid == null) { return { error = 'you are not signed in' } }
  494. let follow = isFollowing(uid, show.id) == false
  495. if (!setFollowed(uid, show.id, follow)) { return { error = 'could not save the follow' } }
  496. return { ok = true following = follow }
  497. }

Branches

Latest commits

  • 1ed5457etracker#20 + #21 (mission 068): typed headings "<Type> | <name>" in type colours; TV titles split into Series (/series) and Shows (/shows) by TMDB type + Reality/Talk/News genres — kind stored by sync/import/adult backfill + new kind backfill (resumes), /movies/<slug>, /shows/<slug> of a series/movie → 301, /my/series + /my/shows, home 5 tiles + 3 rows, search/filmography labels; gates kinds 32 + browser 266, tests/realdata-068.mjs, tools/count-kinds.hl, docs/kinds.md, README + STATUSmre
  • fa1f9dfatracker#29 (mission 063): unwatched check muted grey outline + check (accent only on hover), watched stays solid — no code regression, the accent outline read as ticked; gate checks real checks visibly (computed style + screenshot pixel) on /my/unwatched, show, movie, /my/movies; gate 266, tests/realdata-063.mjs, README + STATUSmre
  • d8b12d67tracker#27 (mission 060): short ids for movies, series and persons — old 702 kept (data/old-short-ids.json), new random [a-z0-9]{5} unique across both, claimed at creation, background backfill (resumes), shown under poster/photo, /<shortId> → 301; gate 259, tests/realdata-060*, README + STATUSmre
  • f14db671tracker#22-#25 (mission 058): episode air dates, season check = all episodes watched, movie watched check (+ /my/movies count), /genres/<genre> pages (movies + series, newest first, paginated); gate 238, tests/realdata-058.mjs, README + STATUSmre
  • 1704ec45tracker#17 (mission 057): season caret down/up, skeleton rows while a season loads, sessionless showSeasonEpisodes face (no page re-mount), client-only close; gate 214, tests/realdata-057.mjs, README + STATUSmre
  • f2fe3e36mission 056: README + STATUS (merge, fixes, Hybriel 8590df63, real-data check), tests/realdata-056.mjs, tools/check-public-slugs.hlmre
  • f40c250emission 056: re-vendor hybriel master 8590df63 (#121, #122); an adult title's page is Not found for non-followers; gate: leave the page before stopping the servermre
  • 2b7fdd6cmission 056: signed-out header one row on phones ("Log in", nowrap), backfill skips adult titles' posters, gate checksmre
  • 2c53d5efMerge branch 't16-person' (tracker#16 person pages) into main; filmography shows only public titles (054 adult flag), gate race fix (backfill start line)mre
  • c171227emission 054: hide adult/unknown titles from the public lists and the search; in-app adult-flag backfill (TMDB details + poster per title, resumes), gate + real-data proofmre
  • 139fafd8tracker#16: short bio (4 lines, click = all), real-data check script, README + STATUSmre
  • 93be9476tracker#16: person pages /person/<slug> with the filmography fetched from TMDB on the first visit (step by step), gatemre
  • 47a3cae6STATUS: mission 053 merge commit idsmre
  • dcc5eecaMerge branch 't14-search'mre
  • 03edc783Merge branch 't15-tvmaze'mre
  • 71b46345tracker#15: numbering check by date or title, placeholder titles in other languages, docs + real-data proofmre
  • 6bb2daf1tracker#13: homepage (tiles, intro, latest movies/shows), /shows, /movies/page/N, /my/movies; lists cached in memorymre
  • b8bd1157tracker#14: README + STATUS (search, real-data numbers, gate, merge notes)mre
  • 65c694a8tracker#14: search — header magnifier, /search/<text> (in-memory word-prefix index over titles + people), Fetch from web (TMDB search/multi, ours left out), Add = import via syncShow; gate +25 checks, real-data scriptmre
  • 34f2c15btracker#15: TVmaze merge in the sync (gaps only: new episodes/seasons, empty titles/air dates; numbering check), fake TVmaze episodes + gatemre