diff --git a/src/site/api/routes.js b/src/site/api/routes.js index d7fb391..d081cf5 100644 --- a/src/site/api/routes.js +++ b/src/site/api/routes.js @@ -114,6 +114,34 @@ module.exports = [ }) } }, + { + route: `/fragment/post/(${constants.external.shortcode_regex})`, methods: ["GET"], code: ({fill}) => { + return getOrFetchShortcode(fill[0]).then(async post => { + await post.fetchChildren() + await post.fetchExtendedOwnerP() // serial await is okay since intermediate fetch result is cached + if (post.isVideo()) await post.fetchVideoURL() + return { + statusCode: 200, + contentType: "application/json", + content: { + title: post.getCaptionIntroduction(), + html: pugCache.get("pug/fragments/post.pug").web({post}) + } + } + }).catch(error => { + if (error === constants.symbols.NOT_FOUND) { + return render(404, "pug/friendlyerror.pug", { + statusCode: 404, + title: "Not found", + message: "Somehow, you reached a post that doesn't exist.", + withInstancesLink: false + }) + } else { + throw error + } + }) + } + }, { route: "/p", methods: ["GET"], code: async ({url}) => { if (url.searchParams.has("p")) { @@ -135,7 +163,7 @@ module.exports = [ route: `/p/(${constants.external.shortcode_regex})`, methods: ["GET"], code: ({fill}) => { return getOrFetchShortcode(fill[0]).then(async post => { await post.fetchChildren() - await post.fetchExtendedOwnerP() // parallel await is okay since intermediate fetch result is cached + await post.fetchExtendedOwnerP() // serial await is okay since intermediate fetch result is cached if (post.isVideo()) await post.fetchVideoURL() return render(200, "pug/post.pug", {post}) }).catch(error => { diff --git a/src/site/html/static/js/pagination.js b/src/site/html/static/js/pagination.js index 59745dd..d942170 100644 --- a/src/site/html/static/js/pagination.js +++ b/src/site/html/static/js/pagination.js @@ -15,9 +15,29 @@ class FreezeWidth extends ElemJS { const intersectionThreshold = 0 +class NextPageController { + constructor() { + this.instance = null + } + + add() { + const nextPage = q("#next-page") + if (nextPage) { + this.instance = new NextPage(nextPage, this) + } else { + this.instance = null + } + } + + activate() { + if (this.instance) this.instance.activate() + } +} + class NextPage extends FreezeWidth { - constructor(container) { + constructor(container, controller) { super(container) + this.controller = controller this.clicked = false this.nextPageNumber = +this.element.getAttribute("data-page") this.attribute("href", "javascript:void(0)") @@ -54,14 +74,17 @@ class NextPage extends FreezeWidth { q("#next-page-container").remove() this.observer.disconnect() q("#timeline").insertAdjacentHTML("beforeend", text) - addNextPageControl() + this.controller.add() }) } + + activate() { + if (this.fetching) return + this.class("disabled") + this.fetch() + } } -function addNextPageControl() { - const nextPage = q("#next-page") - if (nextPage) new NextPage(nextPage) -} - -addNextPageControl() +const controller = new NextPageController() +controller.add() +export {controller} diff --git a/src/site/html/static/js/post_overlay.js b/src/site/html/static/js/post_overlay.js new file mode 100644 index 0000000..99299e8 --- /dev/null +++ b/src/site/html/static/js/post_overlay.js @@ -0,0 +1,125 @@ +import {q, ElemJS} from "./elemjs/elemjs.js" + +/** @type {PostOverlay[]} */ +const postOverlays = [] +const titleHistory = [] +titleHistory.push(document.title) +const shortcodeDataMap = new Map() + +window.addEventListener("popstate", event => { + // console.log(event.state, postOverlays.length) + if (event.state) { + if (event.state.view === "post_overlay") { + loadPostOverlay(event.state.shortcode, false) + } + } else { // event.state === null which means back to originally loaded page, so pop overlay + setTimeout(() => { // make sure document is entirely loaded + if (titleHistory.length === 1) { + document.title = titleHistory[0] + } else if (titleHistory.length >= 2) { + titleHistory.pop() + document.title = titleHistory.slice(-1)[0] + } + if (postOverlays.length) { + popOverlay() + } else { + window.location.reload() + } + }) + } +}) + +function pushOverlay(overlay) { + postOverlays.push(overlay) + document.body.style.overflowY = "hidden" +} + +function popOverlay() { + const top = postOverlays.pop() + if (top) { + top.pop() + } + if (postOverlays.length === 0) document.body.style.overflowY = "auto" +} + +class PostOverlay extends ElemJS { + constructor() { + super("div") + this.class("post-overlay") + this.event("click", event => { + if (event.target === event.currentTarget) history.back() + }) + this.loaded = false + this.available = true + setTimeout(() => { + if (!this.loaded) { + this.class("loading") + this.child( + new ElemJS("div").class("loading-inner").text("Loading...") + ) + } + }, 0) + } + + setContent(html) { + this.html(html) + this.loaded = true + this.removeClass("loading") + } + + showError() { + this.loaded = true + this.class("loading") + this.clearChildren() + this.child( + new ElemJS("div").class("loading-inner").text("Request failed.") + ) + } + + pop() { + this.element.remove() + this.available = false + } +} + +const timeline = q("#timeline") +if (timeline) { + timeline.addEventListener("click", event => { + /** @type {HTMLElement[]} */ + //@ts-ignore + const path = event.composedPath() + const postLink = path.find(element => element.classList && element.classList.contains("sized-link") && element.hasAttribute("data-shortcode")) + if (postLink) { + event.preventDefault() + const shortcode = postLink.getAttribute("data-shortcode") + loadPostOverlay(shortcode, true) + } + }) +} + +function fetchShortcodeFragment(shortcode) { + if (shortcodeDataMap.has(shortcode)) return Promise.resolve(shortcodeDataMap.get(shortcode)) + else return fetch(`/fragment/post/${shortcode}`).then(res => res.json()) +} + +function loadPostOverlay(shortcode, shouldPushState) { + const overlay = new PostOverlay() + document.body.appendChild(overlay.element) + pushOverlay(overlay) + if (shouldPushState) history.pushState({view: "post_overlay", shortcode: shortcode}, "", `/p/${shortcode}`) + const fetcher = fetchShortcodeFragment(shortcode) + fetcher.then(root => { + shortcodeDataMap.set(shortcode, root) + if (overlay.available) { + const {title, html} = root + overlay.setContent(html) + if (overlay.available) { + document.title = title + } + } + }) + fetcher.catch(error => { + console.error(error) + overlay.showError() + }) +} diff --git a/src/site/pug/fragments/post.pug b/src/site/pug/fragments/post.pug new file mode 100644 index 0000000..2c2799c --- /dev/null +++ b/src/site/pug/fragments/post.pug @@ -0,0 +1,5 @@ +//- Needs post + +include ../includes/post.pug + ++post(post) diff --git a/src/site/pug/includes/post.pug b/src/site/pug/includes/post.pug new file mode 100644 index 0000000..371ef97 --- /dev/null +++ b/src/site/pug/includes/post.pug @@ -0,0 +1,17 @@ +include ./display_structured + +mixin post(post) + .post-page-divider + section.description-section + header.user-header + img(src=post.ownerPfpCacheP width=150 height=150 alt="").pfp + a.name(href=`/u/${post.getBasicOwner().username}`)= `${post.data.owner.full_name} (@${post.getBasicOwner().username})` + if post.getCaption() + p.structured-text.description + +display_structured(post.getStructuredCaption()) + section.images-gallery + for entry in post.children + if entry.isVideo() + video(src=entry.getVideoUrlP() controls preload="auto" width=entry.data.dimensions.width height=entry.data.dimensions.height).sized-video + else + img(src=entry.getDisplayUrlP() alt=entry.getAlt() width=entry.data.dimensions.width height=entry.data.dimensions.height).sized-image diff --git a/src/site/pug/includes/timeline_page.pug b/src/site/pug/includes/timeline_page.pug index e0e7c51..03f23be 100644 --- a/src/site/pug/includes/timeline_page.pug +++ b/src/site/pug/includes/timeline_page.pug @@ -11,5 +11,5 @@ mixin timeline_page(page, pageIndex) - const suggestedSize = 260 //- from css :( each image in page - const thumbnail = image.getSuggestedThumbnailP(suggestedSize) //- use this as the src in case there are problems with srcset - a(href=`/p/${image.data.shortcode}`).sized-link + a(href=`/p/${image.data.shortcode}` data-shortcode=image.data.shortcode).sized-link img(src=thumbnail.src alt=image.getAlt() width=thumbnail.config_width height=thumbnail.config_height srcset=image.getThumbnailSrcsetP() sizes=image.getThumbnailSizes()).sized-image diff --git a/src/site/pug/post.pug b/src/site/pug/post.pug index a4862ac..b106e93 100644 --- a/src/site/pug/post.pug +++ b/src/site/pug/post.pug @@ -1,6 +1,4 @@ -include includes/display_structured - -- const numberFormat = new Intl.NumberFormat().format +include includes/post doctype html html @@ -12,18 +10,7 @@ html =`Post from @${post.getBasicOwner().username}` =` | Bibliogram` include includes/head + script(type="module" src="/static/js/post_overlay.js") body.post-page - main.post-page-divider - section.description-section - header.user-header - img(src=post.ownerPfpCacheP width=150 height=150 alt="").pfp - a.name(href=`/u/${post.getBasicOwner().username}`)= `${post.data.owner.full_name} (@${post.getBasicOwner().username})` - if post.getCaption() - p.structured-text.description - +display_structured(post.getStructuredCaption()) - section.images-gallery - for entry in post.children - if entry.isVideo() - video(src=entry.getVideoUrlP() controls preload="auto" width=entry.data.dimensions.width height=entry.data.dimensions.height).sized-video - else - img(src=entry.getDisplayUrlP() alt=entry.getAlt() width=entry.data.dimensions.width height=entry.data.dimensions.height).sized-image + main + +post(post) diff --git a/src/site/pug/user.pug b/src/site/pug/user.pug index 75bbc98..ac846d3 100644 --- a/src/site/pug/user.pug +++ b/src/site/pug/user.pug @@ -16,6 +16,7 @@ html title= `@${user.data.username} | Bibliogram` include includes/head script(src="/static/js/pagination.js" type="module") + script(src="/static/js/post_overlay.js" type="module") body .main-divider header.profile-overview @@ -46,7 +47,7 @@ html if constants.settings.rss_enabled +feed_link("RSS", "rss", user.data.username, "application/rss+xml", display_feed_validation_buttons) +feed_link("Atom", "atom", user.data.username, "application/atom+xml", display_feed_validation_buttons) - a(rel="noreferrer noopener" href=`https://www.instagram.com/${user.data.username}`) instagram.com + a(rel="noreferrer noopener" href=`https://www.instagram.com/${user.data.username}` target="_blank") instagram.com - const hasPosts = !user.data.is_private && user.timeline.pages.length && user.timeline.pages[0].length main(class=hasPosts ? "" : "no-posts")#timeline.timeline diff --git a/src/site/sass/main.sass b/src/site/sass/main.sass index 100a87e..ef0e10d 100644 --- a/src/site/sass/main.sass +++ b/src/site/sass/main.sass @@ -215,95 +215,95 @@ body @include sized .post-page - background-color: #6a6b71 + background-color: #505156 - .post-page-divider +.post-page-divider + display: grid + grid-template-columns: 360px auto + max-width: 1200px + margin: 0 auto + min-height: 100vh + + @media screen and (max-width: $layout-a-max) + display: flex + flex-direction: column + + .description-section display: grid - grid-template-columns: 360px auto - max-width: 1200px - margin: 0 auto - min-height: 100vh + align-items: start + align-content: normal + background-color: #eee + position: sticky + top: 0 + height: 100vh + overflow-y: auto + box-sizing: border-box @media screen and (max-width: $layout-a-max) + position: inherit + top: inherit + height: inherit + + .user-header display: flex - flex-direction: column - - .description-section - display: grid - align-items: start - align-content: normal - background-color: #eee - position: sticky - top: 0 - height: 100vh - overflow-y: auto - box-sizing: border-box - - @media screen and (max-width: $layout-a-max) - position: inherit - top: inherit - height: inherit - - .user-header - display: flex - align-items: center - justify-content: center - background-color: #b3b3b3 - padding: 10px - - .pfp - $size: 40px - - width: $size - height: $size - margin-right: 10px - background-color: rgba(40, 40, 40, 0.25) - - .name - font-size: 20px - color: black - text-decoration: none - - &:hover - text-decoration: underline - - .description - margin: 12px - white-space: pre-line - overflow-wrap: anywhere - font-size: 20px - line-height: 1.4 - - @media screen and (max-width: $layout-a-max) - font-size: 18px - - .images-gallery - display: flex - flex-direction: column align-items: center justify-content: center - background-color: #262728 + background-color: #b3b3b3 padding: 10px + .pfp + $size: 40px + + width: $size + height: $size + margin-right: 10px + background-color: rgba(40, 40, 40, 0.25) + + .name + font-size: 20px + color: black + text-decoration: none + + &:hover + text-decoration: underline + + .description + margin: 12px + white-space: pre-line + overflow-wrap: anywhere + font-size: 20px + line-height: 1.4 + @media screen and (max-width: $layout-a-max) - flex: 1 + font-size: 18px - .sized-image, .sized-video - color: #eee - background-color: #3b3c3d - max-height: 94vh - max-width: 100% + .images-gallery + display: flex + flex-direction: column + align-items: center + justify-content: center + background-color: #262728 + padding: 10px - &:not(:last-child) - margin-bottom: 10px + @media screen and (max-width: $layout-a-max) + flex: 1 - .sized-image - width: auto - height: auto + .sized-image, .sized-video + color: #eee + background-color: #3b3c3d + max-height: 94vh + max-width: 100% - .sized-video - width: 100% - height: 100% + &:not(:last-child) + margin-bottom: 10px + + .sized-image + width: auto + height: auto + + .sized-video + width: 100% + height: 100% .error-page box-sizing: border-box @@ -490,3 +490,31 @@ body margin-top: 45px padding-top: 15px border-top: 1px solid #714141 + +.post-overlay + position: fixed + top: 0 + left: 0 + right: 0 + bottom: 0 + background: rgba(0, 0, 0, 0.7) + z-index: 10 + overflow-y: auto + + &:not(.loading) > * + min-height: 100vh + + &.loading + display: flex + justify-content: center + align-items: center + + .loading-inner + color: white + font-size: 30px + line-height: 1 + padding: 26px + border-radius: 20px + border: 2px solid #aaa + font-weight: bold + background-color: #282828