commit b38b400e04e1654fd539c7dc888de9df479529a9 Author: Clement Bois Date: Fri Jun 7 15:05:12 2019 +0200 Initial version diff --git a/README.md b/README.md new file mode 100644 index 0000000..9c60f25 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# Comtodon + +A minimal commenting system for static blogs using external [Mastodon](https://joinmastodon.org) or API [compatible](https://pleroma.social) server. + +- **painless:** use already existing fediverse accounts +- **serverless:** hosted on static server or cdn +- **lightweight:** any useless features *(IMO)* +- **no dependencies:** pure native js +- **personal:** no style, no tracking + +```html +
+ +``` + +See [index.html](index.html) + +## Deep + +Create a tree by adding `data-deep=N` + - 0: Full tree + - 1: Only "direct" replies + - 2: With replies to replies + - And so on + +## Moderation ? + +Add `data-moderator="{moderator_id}"` to display only approved *(replied)* comments + +Note: Can't use fav, it requires authentication + +## Hugo +Put `comtodon.min.js` in `static` folder + +In your site config +```yaml +params: + comtodon: + domain: mastodon.social + # moderator: 358957 +``` + +In your single page layout +```html +{{ if .Params.comtodon }} +
+ +{{- end }} +``` + +In your content header +```yaml +comtodon: 100745593232538751 +``` + +**Style it** + +## Mastodon + +Only use `/api/v1/statuses/:id/context`. See [doc](https://docs.joinmastodon.org/api/rest/statuses/#get-api-v1-statuses-id-context) + +> No authentication required + +``` +{ + descendants: [{ + account: { + acct, + avatar_static, + display_name, + id, + emojis: [{shortcode, static_url}], + url + }, + created_at, + content, + emojis: [{shortcode, static_url}], + id, + in_reply_to_id, + sensitive, + spoiler_text + }] +} +``` + +## No js ? + +Simple placeholder +```html +
+

Enable JavaScript to see comments

+
+``` + +## TODO + +- Use personal status as sample +- Create proxy backend for cache and moderating with fav +- Auto-magically create statuses from hugo ??? \ No newline at end of file diff --git a/comtodon.js b/comtodon.js new file mode 100644 index 0000000..f921b42 --- /dev/null +++ b/comtodon.js @@ -0,0 +1,108 @@ +(function () { + function fail(el, text) { + el.innerHTML = 'Loading fail =(' + console.error(text, el) + } + + function required(el, field) { + const val = el.dataset[field] + if (val) { + return val + } + fail(el, `Missing data-${field} attribut`) + } + + function h(classes, content, tag = 'div') { + return `<${tag} class="${classes}">${content}` + } + + const TIMES = new Map([ + ['second', 1000], + ['minute', 60], + ['hour', 60], + ['day', 24], + ['month', 30.5], + ['year', 12], + ['century', 100] + ]) + + function ago(date) { + const now = Date.now() + const target = Number(new Date(date)) + + const prefix = target > now ? 'in ' : '' + const milliseconds = Math.floor(Math.abs(target - now)) + + let cur = 0 + let divider = 1 + let name = 'millisecond' + for (const time of TIMES) { + divider *= time[1] + const next = Math.floor(milliseconds / divider) + if (next <= 0) { + return `${prefix}${cur} ${name}${cur > 1 ? 's' : ''} ago` + } + name = time[0] + cur = next + } + return `${prefix}a long time ago` + } + + function moji(text, emojis) { + for (const emoji of emojis) { + text = text.split(`:${emoji.shortcode}:`).join( + `${emoji.shortcode}` + ) + } + return text + } + + function tree(statuses, parent, limit) { + const [replies, others] = statuses.reduce(([c, o], s) => (s.in_reply_to_id == parent.id ? [[...c, s], o] : [c, [...o, s]]), [[], []]) + parent.replies = limit ? replies.map(r => tree(others, r, limit - 1)) : [] + return parent + } + + function html(statuses, domain) { + return statuses.map(({ account, created_at, content, id, emojis, sensitive, spoiler_text, replies }) => + h('status', h('date', ago(created_at), 'p') + + h('author" href="' + account.url, `` + + h('name', moji(account.display_name, account.emojis), 'span') + + h('acct', account.acct, 'span'), 'a') + + (spoiler_text || sensitive ? h('spoiler', spoiler_text || h('spoiler-empty', 'Sensitive', 'span')) : '') + + h('content' + (spoiler_text || sensitive ? ' sensitive' : ''), moji(content, emojis)) + + (replies ? h('replies', html(replies, domain)) : '') + + h(`reply" href="https://${domain}/interact/${id}?type=reply`, 'Reply', 'a'))).join('') + } + + function moderate(statuses, id) { + if (!id) { + return statuses + } + const valids = statuses.filter(s => s.account.id == id).map(s => s.in_reply_to_id) + return statuses.filter(s => valids.includes(s.id)) + } + + for (const el of document.getElementsByClassName('comtodon')) { + el.innerHTML = '
Loading...
' + + const domain = required(el, 'domain') + const status = required(el, 'status') + if (!domain || !status) { + return + } + + fetch(`https://${domain}/api/v1/statuses/${status}/context`) + .then(res => res.json()) + .then(res => { + el.innerHTML = h(`reply-main" href="https://${domain}/interact/${status}?type=reply`, 'Comment', 'a') + const statuses = moderate(res.descendants, el.dataset.moderator) + if (statuses) { + el.innerHTML += html('deep' in el.dataset ? tree(statuses, { id: status }, el.dataset.deep || -1).replies : statuses, domain) + } else { + el.innerHTML += h('empty', 'Any comment') + } + }) + .catch(() => fail(el, 'Request fail')) + } +})() \ No newline at end of file diff --git a/comtodon.min.js b/comtodon.min.js new file mode 100644 index 0000000..c7be8b4 --- /dev/null +++ b/comtodon.min.js @@ -0,0 +1 @@ +(function(){function a(a,b){a.innerHTML="Loading fail =(",console.error(b,a)}function b(b,c){const d=b.dataset[c];return d?d:void a(b,`Missing data-${c} attribut`)}function c(a,b,c="div"){return`<${c} class="${a}">${b}`}function d(a){const b=Date.now(),c=+new Date(a),d=c>b?"in ":"",e=Math.floor(Math.abs(c-b));let f=0,g=1,h="millisecond";for(const b of i){g*=b[1];const a=Math.floor(e/g);if(0>=a)return`${d}${f} ${h}${1`);return a}function f(a,b,c){const[d,e]=a.reduce(([a,c],d)=>d.in_reply_to_id==b.id?[[...a,d],c]:[a,[...c,d]],[[],[]]);return b.replies=c?d.map(a=>f(e,a,c-1)):[],b}function g(a,b){return a.map(({account:a,created_at:f,content:h,id:i,emojis:j,sensitive:k,spoiler_text:l,replies:m})=>c("status",c("date",d(f),"p")+c("author\" href=\""+a.url,``+c("name",e(a.display_name,a.emojis),"span")+c("acct",a.acct,"span"),"a")+(l||k?c("spoiler",l||c("spoiler-empty","Sensitive","span")):"")+c("content"+(l||k?" sensitive":""),e(h,j))+(m?c("replies",g(m,b)):"")+c(`reply" href="https://${b}/interact/${i}?type=reply`,"Reply","a"))).join("")}function h(a,b){if(!b)return a;const c=a.filter(a=>a.account.id==b).map(a=>a.in_reply_to_id);return a.filter(a=>c.includes(a.id))}const i=new Map([["second",1e3],["minute",60],["hour",60],["day",24],["month",30.5],["year",12],["century",100]]);for(const d of document.getElementsByClassName("comtodon")){d.innerHTML="
Loading...
";const e=b(d,"domain"),i=b(d,"status");if(!e||!i)return;fetch(`https://${e}/api/v1/statuses/${i}/context`).then(a=>a.json()).then(a=>{d.innerHTML=c(`reply-main" href="https://${e}/interact/${i}?type=reply`,"Comment","a");const b=h(a.descendants,d.dataset.moderator);d.innerHTML+=b?g("deep"in d.dataset?f(b,{id:i},d.dataset.deep||-1).replies:b,e):c("empty","Any comment")}).catch(()=>a(d,"Request fail"))}})(); \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..a3edff9 --- /dev/null +++ b/index.html @@ -0,0 +1,33 @@ + + + + + + Comtodon samples + + + + +

Comtodon samples

+
+
+

Simple

+
+
+
+

Tree

+
+
+
+

Direct

+
+
+
+

Moderation

+
+
+
+ + + + \ No newline at end of file diff --git a/index.pug b/index.pug new file mode 100644 index 0000000..0a07938 --- /dev/null +++ b/index.pug @@ -0,0 +1,26 @@ +doctype html +html(lang="en") + head + meta(charset="UTF-8") + title Comtodon samples + link(rel="stylesheet" href="style.css") + body + h1 Comtodon samples + main + section + h2 Simple + .comtodon(data-domain="mastodon.social" data-status="100745593232538751") + + section + h2 Tree + .comtodon(data-domain="mastodon.social" data-status="100745593232538751" data-deep) + + section + h2 Direct + .comtodon(data-domain="mastodon.social" data-status="100745593232538751" data-deep="1") + + section + h2 Moderation + .comtodon(data-domain="mastodon.social" data-status="100745593232538751" data-deep="1" data-moderator="358957") + + script(src="comtodon.js" defer) \ No newline at end of file diff --git a/style.css b/style.css new file mode 100644 index 0000000..f561b54 --- /dev/null +++ b/style.css @@ -0,0 +1,117 @@ +@charset "UTF-8"; +* { + font-family: sans-serif; +} + +h1, h2 { + text-align: center; +} + +main { + display: -ms-grid; + display: grid; + -ms-grid-columns: (1fr)[2]; + grid-template-columns: repeat(2, 1fr); + grid-gap: 2em; + padding: 0 2em; +} + +.comtodon .status { + position: relative; +} + +.comtodon .content, .comtodon .reply-main { + margin-bottom: .5em; +} + +.comtodon .content { + border-left: 2px solid #777; +} + +.comtodon .content.sensitive { + content: 'Sensitive'; +} + +.comtodon .replies { + margin-left: 1em; +} + +.comtodon p { + margin: .1em; +} + +.comtodon a { + text-decoration: none; + color: #777; +} + +.comtodon .emoji { + height: 1em; +} + +.comtodon .author { + display: -ms-grid; + display: grid; + -ms-grid-columns: 3.3em auto; + grid-template-columns: 3.3em auto; + -ms-grid-rows: (1em)[3]; + grid-template-rows: repeat(3, 1em); + grid-gap: .1em; +} + +.comtodon .author .avatar { + border-radius: 20%; + -ms-grid-row: 1; + -ms-grid-row-span: 3; + -ms-grid-column: 1; + grid-area: 1 / 1 / 4 / 1; + height: 100%; +} + +.comtodon .author .name { + color: black; + font-weight: bold; +} + +.comtodon .author .acct:before { + content: '@'; +} + +.comtodon .date { + float: right; +} + +.comtodon .reply-main { + display: inline-block; + color: white; + background-color: #777; + border-radius: .5em; + padding: .1em .3em; +} + +.comtodon .reply-main:after { + content: '✉'; + margin-left: .5em; +} + +.comtodon .reply { + visibility: hidden; + position: absolute; + right: .1em; + top: 2em; +} + +.comtodon .reply:after { + content: '✉'; + visibility: visible; + display: block; + position: absolute; + right: 0; + bottom: 0; + color: white; + background-color: #777; + border-radius: .5em; + font-size: .8em; + padding: .1em .3em; +} +/*# sourceMappingURL=style.css.map */ \ No newline at end of file diff --git a/style.css.map b/style.css.map new file mode 100644 index 0000000..5e6306a --- /dev/null +++ b/style.css.map @@ -0,0 +1,9 @@ +{ + "version": 3, + "mappings": ";AAAA,AAAA,CAAC,CAAC;EACA,WAAW,EAAE,UAAU;CAAG;;AAE5B,AAAA,EAAE,EAAE,EAAE,CAAC;EACL,UAAU,EAAE,MAAM;CAAG;;AAEvB,AAAA,IAAI,CAAC;EACH,OAAO,EAAE,IAAI;EACb,qBAAqB,EAAE,cAAc;EACrC,QAAQ,EAAE,GAAG;EACb,OAAO,EAAE,KAAK;CAAG;;AAEnB,AACE,SADO,CACP,OAAO,CAAC;EACN,QAAQ,EAAE,QAAQ;CAAG;;AAFzB,AAGE,SAHO,CAGP,QAAQ,EAHV,SAAS,CAGG,WAAW,CAAC;EACpB,aAAa,EAAE,IAAI;CAAG;;AAJ1B,AAKE,SALO,CAKP,QAAQ,CAAC;EACP,WAAW,EAAE,cAAc;CAEA;;AAR/B,AAOI,SAPK,CAKP,QAAQ,AAEL,UAAU,CAAC;EACV,OAAO,EAAE,WAAW;CAAG;;AAR7B,AASE,SATO,CASP,QAAQ,CAAC;EACP,WAAW,EAAE,GAAG;CAAG;;AAVvB,AAWE,SAXO,CAWP,CAAC,CAAC;EACA,MAAM,EAAE,IAAI;CAAG;;AAZnB,AAaE,SAbO,CAaP,CAAC,CAAC;EACA,eAAe,EAAE,IAAI;EACrB,KAAK,EAAE,IAAI;CAAG;;AAflB,AAgBE,SAhBO,CAgBP,MAAM,CAAC;EACL,MAAM,EAAE,GAAG;CAAG;;AAjBlB,AAkBE,SAlBO,CAkBP,OAAO,CAAC;EACN,OAAO,EAAE,IAAI;EACb,qBAAqB,EAAE,UAAU;EACjC,kBAAkB,EAAE,cAAc;EAClC,QAAQ,EAAE,IAAI;CASK;;AA/BvB,AAuBI,SAvBK,CAkBP,OAAO,CAKL,OAAO,CAAC;EACN,aAAa,EAAE,GAAG;EAClB,SAAS,EAAE,aAAa;EACxB,MAAM,EAAE,IAAI;CAAG;;AA1BrB,AA2BI,SA3BK,CAkBP,OAAO,CASL,KAAK,CAAC;EACJ,KAAK,EAAE,KAAK;EACZ,WAAW,EAAE,IAAI;CAAG;;AA7B1B,AA8BI,SA9BK,CAkBP,OAAO,CAYL,KAAK,AAAA,OAAO,CAAC;EACX,OAAO,EAAE,GAAG;CAAG;;AA/BrB,AAgCE,SAhCO,CAgCP,KAAK,CAAC;EACJ,KAAK,EAAE,KAAK;CAAG;;AAjCnB,AAkCE,SAlCO,CAkCP,WAAW,CAAC;EACV,OAAO,EAAE,YAAY;EACrB,KAAK,EAAE,KAAK;EACZ,gBAAgB,EAAE,IAAI;EACtB,aAAa,EAAE,IAAI;EACnB,OAAO,EAAE,SAAS;CAGM;;AA1C5B,AAwCI,SAxCK,CAkCP,WAAW,AAMR,MAAM,CAAC;EACN,OAAO,EAAE,IAAI;EACb,WAAW,EAAE,IAAI;CAAG;;AA1C1B,AA2CE,SA3CO,CA2CP,MAAM,CAAC;EACL,UAAU,EAAE,MAAM;EAClB,QAAQ,EAAE,QAAQ;EAClB,KAAK,EAAE,IAAI;EACX,GAAG,EAAE,GAAG;CAYiB;;AA3D7B,AAgDI,SAhDK,CA2CP,MAAM,AAKH,MAAM,CAAC;EACN,OAAO,EAAE,IAAI;EACb,UAAU,EAAE,OAAO;EACnB,OAAO,EAAE,KAAK;EACd,QAAQ,EAAE,QAAQ;EAClB,KAAK,EAAE,CAAC;EACR,MAAM,EAAE,CAAC;EACT,KAAK,EAAE,KAAK;EACZ,gBAAgB,EAAE,IAAI;EACtB,aAAa,EAAE,IAAI;EACnB,SAAS,EAAE,IAAI;EACf,OAAO,EAAE,SAAS;CAAG", + "sources": [ + "style.sass" + ], + "names": [], + "file": "style.css" +} \ No newline at end of file diff --git a/style.sass b/style.sass new file mode 100644 index 0000000..9f7736f --- /dev/null +++ b/style.sass @@ -0,0 +1,72 @@ +* + font-family: sans-serif + +h1, h2 + text-align: center + +main + display: grid + grid-template-columns: repeat(2, 1fr) + grid-gap: 2em + padding: 0 2em + +.comtodon + .status + position: relative + .content, .reply-main + margin-bottom: .5em + .content + border-left: 2px solid #777 + &.sensitive + content: 'Sensitive' + .replies + margin-left: 1em + p + margin: .1em + a + text-decoration: none + color: #777 + .emoji + height: 1em + .author + display: grid + grid-template-columns: 3.3em auto + grid-template-rows: repeat(3, 1em) + grid-gap: .1em + .avatar + border-radius: 20% + grid-area: 1 / 1 / 4 / 1 + height: 100% + .name + color: black + font-weight: bold + .acct:before + content: '@' + .date + float: right + .reply-main + display: inline-block + color: white + background-color: #777 + border-radius: .5em + padding: .1em .3em + &:after + content: '✉' + margin-left: .5em + .reply + visibility: hidden + position: absolute + right: .1em + top: 2em + &:after + content: '✉' + visibility: visible + display: block + position: absolute + right: 0 + bottom: 0 + color: white + background-color: #777 + border-radius: .5em + font-size: .8em + padding: .1em .3em