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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
![]()
+
+
+
![]()
+
+
+
![]()
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+
+
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;
+}