diff --git a/Dockerfile b/Dockerfile index e69de29..db5e253 100644 --- a/Dockerfile +++ b/Dockerfile @@ -0,0 +1,3 @@ +FROM nginx:latest +WORKDIR /usr/share/nginx/html +ADD index.html ./ diff --git a/Makefile b/Makefile index 5c9a323..5a8a0aa 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := default -IMAGE ?= rg.fr-par.scw.cloud/averagemarcus-private/bsky-screenshot:latest +IMAGE ?= rg.fr-par.scw.cloud/averagemarcus/bsky-screenshot:latest .PHONY: test # Run all tests, linting and format checks test: lint check-format run-tests @@ -67,7 +67,7 @@ ci: .PHONY: release # Release the latest version of the application release: - @echo "⚠️ 'release' unimplemented" + @kubectl --namespace bsky-screenshot set image deployment bsky-screenshot web=docker.cluster.fun/averagemarcus/bsky-screenshot:$(SHA) .PHONY: help # Show this list of commands help: diff --git a/index.html b/index.html new file mode 100644 index 0000000..1d57f41 --- /dev/null +++ b/index.html @@ -0,0 +1,158 @@ + + + + + + + Bsky Screenshot + + + + + + + + + + + + + + + + +
+

+ Bsky Screenshot +

+ +
+ Web app to generate screenshots of Bluesky posts +
+ +
+
+
+ + +
+ + +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+ Source code available on GitHub, GitLab, Bitbucket & my own Gitea server. +
+
+ +
+
+
+ +
+
+
+ + + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..528982d --- /dev/null +++ b/script.js @@ -0,0 +1,192 @@ +function imageSrc(value) { + if (value) { + this.setAttribute('src', value); + } else { + this.setAttribute('src', "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="); + } +} + +function asHTML(value) { + this.innerHTML = value; +} + +function shouldShow(value) { + if (value == false || value == "") { + this.style.display = 'none'; + } else { + this.style.display = ''; + } +} + +function setWidth(value) { + this.style.width = `${value}px`; +} + +(() => { + let {bskyPost, config} = bindIt({ + config: { + corsProxy: "https://corsproxy.io/?url=", + width: 600, + windowDecoration: true, + showInteractions: true + }, + bskyPost: { + url: 'https://bsky.app/profile/averagemarcus.bsky.social/post/3lujs2efm5s2f', + handle: '', + displayName: '', + avatar: '', + text: '', + createdAt: '', + likes: 0, + reposts: 0, + replies: 0, + externalLink: { + exists: false, + title: "", + description: "", + image: "", + domain: "" + }, + images: { + exists: false, + image1: "", + image2: "", + image3: "", + image4: "", + } + } + }); + + + function isValidHttpUrl(string) { + let url; + try { + url = new URL(string); + } catch (_) { + return false; + } + return url.protocol === "http:" || url.protocol === "https:"; + } + + function formatDate(d) { + const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + var d = new Date(d); + let month = months[d.getMonth()]; + let ampm = "AM"; + let hour = d.getHours(); + if (hour > 12) { + ampm = "PM"; + hour = hour - 12; + } else if (hour == 12) { + ampm = "PM"; + } + + return `${month} ${d.getDate()}, ${d.getFullYear()} at ${hour}:${d.getMinutes()} ${ampm}`; + } + + async function loadPost() { + bskyPost.url = document.getElementById('post-url').value + if (!isValidHttpUrl(bskyPost.url)) { + return; + } + let urlParts = bskyPost.url.split('/'); + let rKey = urlParts[6]; + let handle = urlParts[4]; + + bskyPost.handle = `@${handle}`; + + let profile = await (await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${handle}`)).json(); + let did = profile.did; + bskyPost.displayName = profile.displayName; + bskyPost.avatar = config.corsProxy + profile.avatar; + + let record = await (await fetch(`https://public.api.bsky.app/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.feed.post&rkey=${rKey}`)).json(); + console.log(record); + let parts = []; + let body = unescape(encodeURIComponent(record.value.text)); + let offset = 0; + if (record.value.facets) { + for (const facet of record.value.facets) { + parts.push(body.substring(0, facet.index.byteStart - offset)); + body = body.substring(facet.index.byteStart - offset, body.length) + offset = facet.index.byteStart; + parts.push(body.substring(0, facet.index.byteEnd - offset)); + body = body.substring(facet.index.byteEnd - offset, body.length) + offset = facet.index.byteEnd; + } + } + parts.push(body); + + + let postBody = ""; + for (let index = 0; index < parts.length; index++) { + postBody += parts[index]; + if (index == parts.length-1) { + + } else if (index % 2 == 0) { + postBody += '' + } else { + postBody += '' + } + } + bskyPost.text = decodeURIComponent(escape(postBody)).replaceAll("\n", "
"); + + bskyPost.createdAt = formatDate(record.value.createdAt); + + let uri = record.uri; + let postLikes = await (await fetch(`https://public.api.bsky.app/xrpc/app.bsky.feed.getLikes?uri=${uri}&limit=100`)).json(); + bskyPost.likes = postLikes.likes.length; + + let postReposts = await (await fetch(`https://public.api.bsky.app/xrpc/app.bsky.feed.getRepostedBy?uri=${uri}&limit=100`)).json(); + bskyPost.reposts = postReposts.repostedBy.length; + + let thread = await (await fetch(`https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${uri}&parentHeight=1&depth=1000`)).json(); + bskyPost.replies = thread.thread.replies.length + + bskyPost.externalLink.exists = false; + bskyPost.images.exists = false; + bskyPost.images.image1 = ""; + bskyPost.images.image2 = ""; + bskyPost.images.image3 = ""; + bskyPost.images.image4 = ""; + + if (record.value.embed && record.value.embed["$type"] == "app.bsky.embed.images") { + bskyPost.images.exists = true; + if (record.value.embed.images.length > 0) bskyPost.images.image1 = `${config.corsProxy}https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${record.value.embed.images[0].image.ref.$link}@jpeg`; + if (record.value.embed.images.length > 1) bskyPost.images.image2 = `${config.corsProxy}https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${record.value.embed.images[1].image.ref.$link}@jpeg`; + if (record.value.embed.images.length > 2) bskyPost.images.image3 = `${config.corsProxy}https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${record.value.embed.images[2].image.ref.$link}@jpeg`; + if (record.value.embed.images.length > 3) bskyPost.images.image4 = `${config.corsProxy}https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${record.value.embed.images[3].image.ref.$link}@jpeg`; + } else if (record.value.embed && record.value.embed["$type"] == "app.bsky.embed.external") { + bskyPost.externalLink.exists = true; + bskyPost.externalLink.title = record.value.embed.external.title; + bskyPost.externalLink.description = record.value.embed.external.description; + bskyPost.externalLink.domain = record.value.embed.external.uri.replaceAll("https://", "").split("/")[0]; + bskyPost.externalLink.image = `${config.corsProxy}https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${record.value.embed.external.thumb.ref["$link"]}@jpeg`; + } + + updateImage(); + } + + function updateImage() { + window.requestAnimationFrame(function() { + domtoimage.toPng(document.getElementById('post'), { cacheBust: false }) + .then(function (dataUrl) { + document.getElementById('result-image').src = dataUrl; + }); + }); + } + [...document.querySelectorAll('img')].forEach(el => el.addEventListener("load", updateImage)); + + document.getElementById('submit').addEventListener('click', loadPost); + document.getElementById('post-url').addEventListener('change', loadPost); + document.getElementById('post-url').addEventListener('keyup', loadPost); + window.requestAnimationFrame(loadPost); + + document.getElementById('download').addEventListener('click', function() { + let link = document.createElement('a'); + link.download = 'post.png'; + link.href = document.getElementById('result-image').src; + link.click(); + }); + +})(); diff --git a/style.css b/style.css new file mode 100644 index 0000000..0ca83e9 --- /dev/null +++ b/style.css @@ -0,0 +1,263 @@ + +body { + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: space-between; +} +.form { + display: flex; +} +.form * { + margin: 2px; +} + +.post-wrapper { + overflow: hidden; + position: relative; +} +#post { + background: transparent; + position: absolute; + left: -9999; + top: -9999; + width: 600px; + margin-left: -10px; +} + +.link { + color: rgb(16, 131, 254); +} + +.border { + background: #fff; + border-radius: 22px; + border-color: rgba(180, 180, 178, 0.56); + border-style: double; + border-width: 2px; +} +.border .windowDecoration { + height: 34px; + display: grid; + grid-template-columns: 1fr 2fr 1fr; + justify-content: space-around; +} +.border .windowDecoration .buttons { + display: flex; + gap: 6px; + padding: 7px; +} +.border .windowDecoration .buttons .roundButton { + border-radius: 9999px; + border-width: 1px; + border-style: solid; + background-color: rgba(38, 34, 23, 0.9); + opacity: 0.9; + width: 10px; + height: 10px; +} + +.border .windowDecoration .title { + text-align: center; + font-size: 16px; + letter-spacing: 0px; + color: rgb(66, 87, 108); + line-height: 20px; + font-family: InterVariable, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + font-variant: no-contextual; +} + +.post { + width: 600px; + padding: 8px; +} +.post .header { + width: 100%; + display: flex; + gap: 12px; + flex-direction: row; + padding-bottom: 12px; +} +.post .header .avatar { + width: 42px; + height: 42px; + border-radius: 21px; +} +.post .header .displayName { + font-size: 16.875px; + letter-spacing: 0px; + color: rgb(11, 15, 20); + flex-shrink: 1; + font-weight: 600; + line-height: 22px; + font-family: InterVariable, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + font-variant: no-contextual; +} +.post .header .handle { + font-size: 15px; + letter-spacing: 0px; + color: rgb(66, 87, 108); + line-height: 20px; + font-family: InterVariable, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + font-variant: no-contextual; +} + +.post .body { + width: 100%; + font-size: 18.75px; + letter-spacing: 0px; + color: rgb(11, 15, 20); + line-height: 24px; + flex: 1 1 0%; + font-family: InterVariable, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + font-variant: no-contextual; +} +.post .embeds { + display: flex; +} +.post .images { + gap: 6px; + display: flex; + border-radius: 12px; + overflow: hidden; + flex-direction: row; + align-content: flex-start; + align-items: stretch; + flex-wrap: wrap; +} +.post .images > div { + aspect-ratio: 1 / 1; + flex: 1 0 48%; +} +.post .images img { + vertical-align: middle; + width: 100%; + height: 100%; + object-fit: cover; +} +.post .images > div:nth-child(2) { + grid-column: 2; +} +.post .images > div:nth-child(3) { + grid-row: 2; + grid-column: 1; +} +.post .images > div:nth-child(4) { + grid-row: 2; + grid-column: 2; +} + +.post .externalLink { + flex-direction: column; + border-radius: 12px; + overflow: hidden; + width: 100%; + border-width: 1px; + border-style: solid; + margin-top: 8px; + border-color: rgb(212, 219, 226); +} +.post .externalLink .linkMeta { + gap: 3px; + padding-bottom: 4px; + padding-left: 12px; + padding-right: 12px; + padding-top: 8px; + border-top-style: solid; + border-top-width: 1px; + border-color: rgb(212, 219, 226); +} +.post .externalLink .linkTitle { + font-size: 15px; + letter-spacing: 0px; + color: rgb(11, 15, 20); + font-weight: 600; + line-height: 20px; + font-family: InterVariable, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + font-variant: no-contextual; +} +.post .externalLink .linkDescription { + padding-top: 6px; + padding-bottom: 6px; + font-size: 13.125px; + letter-spacing: 0px; + color: rgb(11, 15, 20); + line-height: 17px; + font-family: InterVariable, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + font-variant: no-contextual; +} +.post .externalLink .linkDomain { + padding-bottom: 8px; + padding-top: 6px; + border-top-style: solid; + border-top-width: 1px; + border-color: rgb(212, 219, 226); + font-size: 11.25px; + letter-spacing: 0px; + color: rgb(66, 87, 108); + line-height: 15px; + font-family: InterVariable, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + font-variant: no-contextual; +} + +.post .footer { + width: 100%; + padding-top: 12px; + display: flex; + justify-content: space-between; + border-color: rgb(212, 219, 226); + border-top-style: solid; + border-top-width: 1px; + margin-top: 12px; + padding-left: 4px; + padding-right: 8px; + padding-top: 12px; + padding-bottom: 12px; +} + +.post .footer .createdAt { + font-size: 15px; + letter-spacing: 0px; + color: rgb(119, 129, 140); + line-height: 15px; + font-family: InterVariable, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + font-variant: no-contextual; +} +.post .footer .interactions { + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; +} +.post .footer .replies, +.post .footer .reposts, +.post .footer .likes { + font-size: 15px; + letter-spacing: 0px; + color: rgb(119, 129, 140); + stroke: rgb(119, 129, 140); + line-height: 15px; + font-family: InterVariable, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + font-variant: no-contextual; +} +.post .footer .replies>svg, +.post .footer .reposts>svg, +.post .footer .likes>svg { + height: 20px; + margin-bottom: -5px; +} +.post .footer .replies>span, +.post .footer .reposts>span, +.post .footer .likes>span { + font-size: 15px; + letter-spacing: 0px; + font-weight: 600; + line-height: 15px; + font-family: InterVariable, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + font-variant: no-contextual; +} + +#download, #result-image { + display: block; + margin: 30px auto; +}