Initial version
commit
b38b400e04
|
@ -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
|
||||
<div class="comtodon" data-domain="mastodon.social" data-status="100745593232538751"></div>
|
||||
<script src="comtodon.min.js" defer></script>
|
||||
```
|
||||
|
||||
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 }}
|
||||
<div class="comtodon" data-domain="{{ .Site.Params.comtodon.domain }}" data-status="{{ .Params.comtodon }}" {{ with .Site.Params.comtodon.moderator }}data-moderator="{{ . }}"{{ end }}></div>
|
||||
<script src="//comtodon.min.js" defer></script>
|
||||
{{- 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
|
||||
<div class="comtodon" data-domain="mastodon.social" data-status="100745593232538751">
|
||||
<p class="no-js sad">Enable JavaScript to see comments</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
## TODO
|
||||
|
||||
- Use personal status as sample
|
||||
- Create proxy backend for cache and moderating with fav
|
||||
- Auto-magically create statuses from hugo ???
|
|
@ -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}</${tag}>`
|
||||
}
|
||||
|
||||
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(
|
||||
`<img class="emoji" alt="${emoji.shortcode}" title="${emoji.shortcode}" src="${emoji.static_url}">`
|
||||
)
|
||||
}
|
||||
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, `<img class="avatar" src="${account.avatar_static}" />` +
|
||||
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 = '<div class="loading">Loading...</div>'
|
||||
|
||||
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'))
|
||||
}
|
||||
})()
|
|
@ -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}</${c}>`}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<f?"s":""} ago`;h=b[0],f=a}return`${d}a long time ago`}function e(a,b){for(const c of b)a=a.split(`:${c.shortcode}:`).join(`<img class="emoji" alt="${c.shortcode}" title="${c.shortcode}" src="${c.static_url}">`);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,`<img class="avatar" src="${a.avatar_static}" />`+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="<div class=\"loading\">Loading...</div>";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"))}})();
|
|
@ -0,0 +1,33 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Comtodon samples</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Comtodon samples</h1>
|
||||
<main>
|
||||
<section>
|
||||
<h2>Simple</h2>
|
||||
<div class="comtodon" data-domain="mastodon.social" data-status="100745593232538751"></div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Tree</h2>
|
||||
<div class="comtodon" data-domain="mastodon.social" data-status="100745593232538751" data-deep></div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Direct</h2>
|
||||
<div class="comtodon" data-domain="mastodon.social" data-status="100745593232538751" data-deep="1"></div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Moderation</h2>
|
||||
<div class="comtodon" data-domain="mastodon.social" data-status="100745593232538751" data-deep="1" data-moderator="358957"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="comtodon.js" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -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)
|
|
@ -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 */
|
|
@ -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"
|
||||
}
|
|
@ -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
|
Loading…
Reference in New Issue