Signed-off-by: Sam Therapy <sam@samtherapy.net>
This commit is contained in:
parent
e7ef7e4d7d
commit
61463ff239
50 changed files with 2782 additions and 5613 deletions
28
.drone.yml
28
.drone.yml
|
@ -3,19 +3,19 @@ type: docker
|
|||
name: default
|
||||
|
||||
steps:
|
||||
- name: dependencies
|
||||
image: node
|
||||
commands:
|
||||
- yarn
|
||||
- name: dependencies
|
||||
image: node
|
||||
commands:
|
||||
- yarn
|
||||
|
||||
- name: lint
|
||||
image: node
|
||||
commands:
|
||||
- yarn lint
|
||||
depends: [dependencies]
|
||||
- name: lint
|
||||
image: node
|
||||
commands:
|
||||
- yarn lint:ci
|
||||
depends: [dependencies]
|
||||
|
||||
- name: test
|
||||
image: node
|
||||
commands:
|
||||
- yarn test
|
||||
depends: [lint]
|
||||
- name: test
|
||||
image: node
|
||||
commands:
|
||||
- yarn test
|
||||
depends: [lint]
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
src/public/infinite-scroll.js
|
||||
src/public/lib/infinite-scroll.pkgd.min.js
|
||||
.yarn/
|
|
@ -1,30 +1,19 @@
|
|||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"indent": [
|
||||
"error",
|
||||
4
|
||||
],
|
||||
"linebreak-style": [
|
||||
"error",
|
||||
"unix"
|
||||
],
|
||||
"quotes": [
|
||||
"error",
|
||||
"double"
|
||||
],
|
||||
"semi": [
|
||||
"error",
|
||||
"always"
|
||||
]
|
||||
}
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"node": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["prettier"],
|
||||
"rules": {
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"quotes": ["error", "double"],
|
||||
"semi": ["error", "always"],
|
||||
"prettier/prettier": ["error", { "singleQuote": false }]
|
||||
},
|
||||
"extends": ["eslint:recommended", "plugin:prettier/recommended"]
|
||||
}
|
||||
|
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -127,4 +127,6 @@ dist
|
|||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
.pnp.*
|
||||
|
||||
.dccache
|
2
.prettierignore
Normal file
2
.prettierignore
Normal file
|
@ -0,0 +1,2 @@
|
|||
src/public/lib/infinite-scroll.pkgd.min.js
|
||||
.yarn/
|
27
README.md
27
README.md
|
@ -9,7 +9,7 @@ https://www.fedifeed.com
|
|||
|
||||
## User guide
|
||||
|
||||
The homepage has a tool for generating iframe code for you, with a sensible `sandbox` attribute. The feeds fit comfortably in a 400px wide area.
|
||||
The homepage has a tool for generating iframe code for you, with a sensible `sandbox` attribute. The feeds fit comfortably in a 400px wide area.
|
||||
|
||||
## API
|
||||
|
||||
|
@ -19,28 +19,29 @@ The homepage has a tool for generating iframe code for you, with a sensible `san
|
|||
|
||||
> example: `/api/v1/feed?userurl=https%3A%2F%2Foctodon.social%2Fusers%2Ffenwick67&scale=90&theme=masto-light`
|
||||
|
||||
Returns a html page which displays a feed for a user URL. Note that URLs must be URI encoded (i.e. `encodeURIComponent('https://octodon.social/users/fenwick67')` ).
|
||||
Returns a html page which displays a feed for a user URL. Note that URLs must be URI encoded (i.e. `encodeURIComponent('https://octodon.social/users/fenwick67')` ).
|
||||
|
||||
Querystring options:
|
||||
|
||||
| option | required | description |
|
||||
| ------ | -------- | ----------- |
|
||||
| `userurl` | **\*** | Mastodon/Pleroma/Misskey account URL (usually `https://${instance}/users/${username}` for MastoAPI or `https://${instance}/@${username}` for Misskey) |
|
||||
| `instance` | **\*\***| Mastodon/Pleroma/Misskey instance URL (usually `https://${instance}`) |
|
||||
| `user` | **\*\*** | Mastodon/Pleroma/Misskey user ID (usually `${username}`) |
|
||||
| `feedurl` | no | a URL to a page of an ActivityPub post collection. Only used for pages beyond the first. |
|
||||
| `theme` | no | either `masto-dark`, `masto-light` or `masto-auto`, to select the UI theme (default is `masto-dark`). `auto` will appear masto-light unless the user sets up masto-dark mode on their device. |
|
||||
| `boosts` | no | whether to show boosts or not |
|
||||
| `replies` | no | whether to show replies or not |
|
||||
| `size` | no | the scale of the UI in percent. |
|
||||
| option | required | description |
|
||||
| ---------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `userurl` | **\*** | Mastodon/Pleroma/Misskey account URL (usually `https://${instance}/users/${username}` for MastoAPI or `https://${instance}/@${username}` for Misskey) |
|
||||
| `instance` | **\*\*** | Mastodon/Pleroma/Misskey instance URL (usually `https://${instance}`) |
|
||||
| `user` | **\*\*** | Mastodon/Pleroma/Misskey user ID (usually `${username}`) |
|
||||
| `feedurl` | no | a URL to a page of an ActivityPub post collection. Only used for pages beyond the first. |
|
||||
| `theme` | no | either `masto-dark`, `masto-light` or `masto-auto`, to select the UI theme (default is `masto-dark`). `auto` will appear masto-light unless the user sets up masto-dark mode on their device. |
|
||||
| `boosts` | no | whether to show boosts or not |
|
||||
| `replies` | no | whether to show replies or not |
|
||||
| `size` | no | the scale of the UI in percent. |
|
||||
|
||||
\* `userurl` is required if `instance` and `user` are not specified.\*\*\* \
|
||||
\*\* `instance` **and** `user` are required if `userurl` is not specified.\*\*\*
|
||||
|
||||
\*\*\* **`userurl` and `instance`/`user` are mutually exclusive.**
|
||||
|
||||
## Server Installation
|
||||
|
||||
This is a straightforward node project with zero databases or anything, you should just be able to run `yarn install` and then `yarn start` to get up and running. Set your `PORT` environment variable to change the port it listens on.
|
||||
This is a straightforward node project with zero databases or anything, you should just be able to run `yarn install` and then `yarn start` to get up and running. Set your `PORT` environment variable to change the port it listens on.
|
||||
|
||||
## Improve me
|
||||
|
||||
|
|
160
index.js
Normal file
160
index.js
Normal file
|
@ -0,0 +1,160 @@
|
|||
import Express from "express";
|
||||
// v2 api
|
||||
import convertv2 from "./lib/convert.js";
|
||||
import serveStatic from "serve-static";
|
||||
import cors from "cors";
|
||||
import errorPage from "./lib/errorPage.js";
|
||||
import morgan from "morgan";
|
||||
import { detector } from "megalodon";
|
||||
import helmet from "helmet";
|
||||
|
||||
const app = Express();
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: false,
|
||||
crossOriginResourcePolicy: false,
|
||||
})
|
||||
);
|
||||
|
||||
const logger = morgan(":method :url :status via :referrer - :response-time ms");
|
||||
|
||||
app.use(
|
||||
serveStatic("public", {
|
||||
maxAge: "1d",
|
||||
})
|
||||
);
|
||||
|
||||
function doCache(res, durationSecs) {
|
||||
res.set({
|
||||
"Cache-Control": "max-age=" + durationSecs,
|
||||
});
|
||||
}
|
||||
|
||||
// this just redirects to the
|
||||
app.options("/api/feed", cors());
|
||||
app.get("/api/feed", cors(), logger, function (req, res) {
|
||||
// get feed url
|
||||
const feedUrl = req.query.url;
|
||||
if (!feedUrl) {
|
||||
res
|
||||
.status(400)
|
||||
.send(errorPage(400, "You need to specify a feed URL", null));
|
||||
return;
|
||||
}
|
||||
|
||||
const userUrl = feedUrl.replace(/\.atom.*/i, "");
|
||||
|
||||
const redirectUrl = "/api/v1/feed?";
|
||||
const qs = ["userurl=" + encodeURIComponent(userUrl), "api=v1"];
|
||||
|
||||
["size", "theme", "boosts", "replies"].forEach((key) => {
|
||||
if (typeof req.query[key] != "undefined") {
|
||||
qs.push(key + "=" + encodeURIComponent(req.query[key]));
|
||||
}
|
||||
});
|
||||
|
||||
res.redirect(redirectUrl + qs.join("&"));
|
||||
});
|
||||
|
||||
app.options("/api/v1/feed", cors());
|
||||
app.get("/api/v1/feed", cors(), logger, async function (req, res) {
|
||||
// get feed url
|
||||
// userUrl
|
||||
let type = req.query.instance_type;
|
||||
let userUrl = req.query.userurl;
|
||||
if (userUrl === "" || userUrl === undefined) {
|
||||
const user = req.query.user;
|
||||
const instance = req.query.instance;
|
||||
if (type === "" || type === undefined) {
|
||||
type = await detector(instance).catch(() => "");
|
||||
}
|
||||
if (type === "mastodon" || type === "pleroma")
|
||||
userUrl = instance + "/users/" + user;
|
||||
else if (type === "misskey") userUrl = instance + "/@" + user;
|
||||
else {
|
||||
res
|
||||
.status(400)
|
||||
.send(errorPage(400, "You need to specify a user URL", null));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const feedUrl = req.query.feedurl;
|
||||
|
||||
const opts = {};
|
||||
if (req.query.size) {
|
||||
opts.size = req.query.size;
|
||||
}
|
||||
if (req.query.theme) {
|
||||
opts.theme = req.query.theme;
|
||||
if (opts.theme === "auto-auto") {
|
||||
switch (type) {
|
||||
case "mastodon":
|
||||
opts.theme = "masto-auto";
|
||||
break;
|
||||
case "pleroma":
|
||||
opts.theme = "pleroma";
|
||||
break;
|
||||
case "misskey":
|
||||
opts.theme = "misskey-auto";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else if (opts.theme === "auto-light") {
|
||||
switch (type) {
|
||||
case "mastodon":
|
||||
opts.theme = "masto-light";
|
||||
break;
|
||||
case "misskey":
|
||||
opts.theme = "misskey-light";
|
||||
break;
|
||||
case "pleroma":
|
||||
opts.theme = "pleroma-light";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else if (opts.theme === "auto-dark") {
|
||||
switch (type) {
|
||||
case "mastodon":
|
||||
opts.theme = "masto-dark";
|
||||
break;
|
||||
case "misskey":
|
||||
opts.theme = "misskey-dark";
|
||||
break;
|
||||
case "pleroma":
|
||||
opts.theme = "pleroma-dark";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (req.query.header) opts.header = req.query.header.toLowerCase() === "true";
|
||||
if (req.query.boosts) opts.boosts = req.query.boosts.toLowerCase() === "true";
|
||||
if (req.query.replies)
|
||||
opts.replies = req.query.replies.toLowerCase() === "true";
|
||||
opts.instance_type = type;
|
||||
opts.userUrl = userUrl;
|
||||
opts.feedUrl = feedUrl;
|
||||
opts.mastofeedUrl = req.url;
|
||||
|
||||
convertv2(opts)
|
||||
.then((data) => {
|
||||
doCache(res, 60 * 60);
|
||||
res.status(200).send(data);
|
||||
})
|
||||
.catch((er) => {
|
||||
res
|
||||
.status(500)
|
||||
.send(errorPage(500, null, { theme: opts.theme, size: opts.size }));
|
||||
// TODO log the error
|
||||
console.error(er, er.stack);
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(process.env.PORT || 8000, "127.0.0.1", function () {
|
||||
console.log("Server started, listening on " + (process.env.PORT || 8000));
|
||||
});
|
400
lib/convert.js
400
lib/convert.js
|
@ -12,238 +12,258 @@ const hour = 3600000;
|
|||
|
||||
// note: rejects on HTTP 4xx or 5xx
|
||||
async function apGet(url) {
|
||||
|
||||
return new Promise(function(resolve,reject){
|
||||
return new Promise(function (resolve, reject) {
|
||||
// fail early
|
||||
if (!url) {
|
||||
reject(new Error("URL is invalid"));
|
||||
}
|
||||
|
||||
// fail early
|
||||
if (!url){
|
||||
reject(new Error("URL is invalid"));
|
||||
}
|
||||
|
||||
got( {
|
||||
url:url,
|
||||
cache:map,
|
||||
headers: {
|
||||
"accept": "application/activity+json"
|
||||
}
|
||||
})
|
||||
.then(response=>JSON.parse(response.body))
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
|
||||
});
|
||||
|
||||
got({
|
||||
url: url,
|
||||
cache: map,
|
||||
headers: {
|
||||
accept: "application/activity+json",
|
||||
},
|
||||
})
|
||||
.then((response) => JSON.parse(response.body))
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
// like Promise.all except returns null on error instead of failing all the promises
|
||||
async function promiseSome(proms){
|
||||
|
||||
function noRejectWrap(prom){
|
||||
return new Promise(function(resolve){
|
||||
|
||||
prom // it's already been called
|
||||
.then(resolve)
|
||||
.catch( ()=>{
|
||||
// console.warn(e);// for debugging
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
async function promiseSome(proms) {
|
||||
function noRejectWrap(prom) {
|
||||
return new Promise(function (resolve) {
|
||||
prom // it's already been called
|
||||
.then(resolve)
|
||||
.catch(() => {
|
||||
// console.warn(e);// for debugging
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
|
||||
return await Promise.all(proms.map(noRejectWrap));
|
||||
});
|
||||
}
|
||||
|
||||
return await Promise.all(proms.map(noRejectWrap));
|
||||
}
|
||||
|
||||
export default async function (opts) {
|
||||
// let opts = opts;
|
||||
// let opts = opts;
|
||||
|
||||
let feedUrl = opts.feedUrl;
|
||||
let userUrl = opts.userUrl;
|
||||
let isIndex = false;
|
||||
let feedUrl = opts.feedUrl;
|
||||
let userUrl = opts.userUrl;
|
||||
let isIndex = false;
|
||||
|
||||
if (!userUrl) {
|
||||
throw new Error("need user URL");
|
||||
if (!userUrl) {
|
||||
throw new Error("need user URL");
|
||||
}
|
||||
|
||||
let user, feed;
|
||||
|
||||
// get user and feed in parallel if I have both URLs.
|
||||
// can cache feed aggressively since it is a specific start and end.
|
||||
if (userUrl && feedUrl) {
|
||||
[user, feed] = await Promise.all([apGet(userUrl), apGet(feedUrl)]);
|
||||
} else {
|
||||
// get user, then outbox, then feed
|
||||
|
||||
user = await apGet(userUrl);
|
||||
isIndex = true;
|
||||
let outbox = await apGet(user.outbox);
|
||||
|
||||
// outbox.first can be a string for a URL, or an object with stuffs in it
|
||||
if (typeof outbox.first == "object") {
|
||||
feed = outbox.first;
|
||||
} else {
|
||||
feed = await apGet(outbox.first);
|
||||
}
|
||||
}
|
||||
|
||||
let user, feed;
|
||||
|
||||
// get user and feed in parallel if I have both URLs.
|
||||
// can cache feed aggressively since it is a specific start and end.
|
||||
if (userUrl && feedUrl){
|
||||
[user, feed] = await Promise.all([ apGet(userUrl), apGet(feedUrl) ]);
|
||||
}else{
|
||||
// get user, then outbox, then feed
|
||||
|
||||
user = await apGet(userUrl,24 * hour);
|
||||
isIndex = true;
|
||||
let outbox = await apGet(user.outbox, 1 * hour);
|
||||
|
||||
// outbox.first can be a string for a URL, or an object with stuffs in it
|
||||
if (typeof outbox.first == "object"){
|
||||
feed = outbox.first;
|
||||
} else {
|
||||
feed = await apGet(outbox.first, hour/6);// 10 mins. Because the base feed URL can get new toots quickly.
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
let templateData = {
|
||||
opts: opts,// from the request
|
||||
meta: metaForUser(user),
|
||||
items: await itemsForFeed(opts,user,feed),
|
||||
nextPageLink: getNextPage(opts,user,feed),
|
||||
isIndex: isIndex
|
||||
};
|
||||
|
||||
return template(templateData);
|
||||
let templateData = {
|
||||
opts: opts, // from the request
|
||||
meta: metaForUser(user),
|
||||
items: await itemsForFeed(opts, user, feed),
|
||||
nextPageLink: getNextPage(opts, user, feed),
|
||||
isIndex: isIndex,
|
||||
};
|
||||
|
||||
return template(templateData);
|
||||
}
|
||||
|
||||
function metaForUser(user) {
|
||||
return {
|
||||
avatar: user.icon && user.icon.url?user.icon.url:null,
|
||||
headerImage:user.image && user.image.url?user.image.url:null,
|
||||
title: user.name||user.preferredUsername||null,
|
||||
description: user.summary||null,
|
||||
link:user.url||"#"
|
||||
};
|
||||
return {
|
||||
avatar: user.icon && user.icon.url ? user.icon.url : null,
|
||||
headerImage: user.image && user.image.url ? user.image.url : null,
|
||||
title: user.name || user.preferredUsername || null,
|
||||
description: user.summary || null,
|
||||
link: user.url || "#",
|
||||
};
|
||||
}
|
||||
|
||||
async function itemsForFeed(opts,user,feed) {
|
||||
async function itemsForFeed(opts, user, feed) {
|
||||
let items = feed.orderedItems;
|
||||
|
||||
let items = feed.orderedItems;
|
||||
|
||||
if (opts.boosts){
|
||||
// yes, I have to fetch all the fucking boosts for this whole feed apparently >:/
|
||||
let boostData = [];
|
||||
let boostUrls = feed.orderedItems.filter(i=>i.type=="Announce").map(i=>i.object);
|
||||
// console.log(boostUrls);
|
||||
boostData = await promiseSome(boostUrls.map(apGet));
|
||||
if (opts.boosts) {
|
||||
// yes, I have to fetch all the fucking boosts for this whole feed apparently >:/
|
||||
let boostData = [];
|
||||
let boostUrls = feed.orderedItems
|
||||
.filter((i) => i.type == "Announce")
|
||||
.map((i) => i.object);
|
||||
// console.log(boostUrls);
|
||||
boostData = await promiseSome(boostUrls.map(apGet));
|
||||
|
||||
// now get user data for each of those
|
||||
let userData = await promiseSome(boostData.map(d=>d?d.attributedTo||"":null).map(apGet));
|
||||
// now get user data for each of those
|
||||
let userData = await promiseSome(
|
||||
boostData.map((d) => (d ? d.attributedTo || "" : null)).map(apGet)
|
||||
);
|
||||
|
||||
// put a ._userdata key on the item object if this is a boost
|
||||
for (let i = 0; i < boostData.length; i ++){
|
||||
if (userData[i] && boostData[i]){
|
||||
boostData[i]._userdata = userData[i];
|
||||
}
|
||||
}
|
||||
|
||||
// some URLs may have failed but IDGAF
|
||||
|
||||
// console.log(boostData[0]);
|
||||
|
||||
boostData.forEach((boostToot)=>{
|
||||
|
||||
if (!boostToot){// failed request
|
||||
return;
|
||||
}
|
||||
|
||||
// inject in-place into items
|
||||
|
||||
let index = -1;
|
||||
for (var i = 0; i < items.length; i ++){
|
||||
if (items[i].object == boostToot.id){
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (index == -1){
|
||||
console.warn("warning: couldn't match boost to item: ",boostToot);
|
||||
return;
|
||||
}
|
||||
|
||||
boostToot.object = boostToot;// this lets the later stage parser access object without errors :)
|
||||
items[i] = boostToot;
|
||||
});
|
||||
// put a ._userdata key on the item object if this is a boost
|
||||
for (let i = 0; i < boostData.length; i++) {
|
||||
if (userData[i] && boostData[i]) {
|
||||
boostData[i]._userdata = userData[i];
|
||||
}
|
||||
}
|
||||
|
||||
// some URLs may have failed but IDGAF
|
||||
|
||||
return items.filter((item)=>{
|
||||
return typeof item.object == "object";// handle weird cases
|
||||
}).map((item)=>{
|
||||
// console.log(boostData[0]);
|
||||
|
||||
let enclosures = (item.object.attachment||[]).filter((a)=>{
|
||||
return a.type == "Document";
|
||||
}).map((a)=>{
|
||||
return {
|
||||
name:a.name,
|
||||
type:a.mediaType,
|
||||
url:a.url
|
||||
};
|
||||
boostData.forEach((boostToot) => {
|
||||
if (!boostToot) {
|
||||
// failed request
|
||||
return;
|
||||
}
|
||||
|
||||
// inject in-place into items
|
||||
|
||||
let index = -1;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].object == boostToot.id) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (index == -1) {
|
||||
console.warn(`warning: couldn't match boost to item: ${boostToot}`);
|
||||
return;
|
||||
}
|
||||
|
||||
boostToot.object = boostToot; // this lets the later stage parser access object without errors :)
|
||||
items[i] = boostToot;
|
||||
});
|
||||
}
|
||||
|
||||
return items
|
||||
.filter((item) => {
|
||||
return typeof item.object == "object"; // handle weird cases
|
||||
})
|
||||
.map((item) => {
|
||||
let enclosures = (item.object.attachment || [])
|
||||
.filter((a) => {
|
||||
return a.type == "Document";
|
||||
})
|
||||
.map((a) => {
|
||||
return {
|
||||
name: a.name,
|
||||
type: a.mediaType,
|
||||
url: a.url,
|
||||
};
|
||||
});
|
||||
|
||||
let op = item._userdata?item._userdata:user;
|
||||
let op = item._userdata ? item._userdata : user;
|
||||
|
||||
return {
|
||||
isBoost:!!item._userdata,
|
||||
title:item._userdata?user.preferredUsername+" shared a status by "+op.preferredUsername:"",
|
||||
isReply:!!(item.object && item.object.inReplyTo),
|
||||
hasCw:item.object.sensitive||false,
|
||||
cw:item.object.summary,
|
||||
content: item.object&&item.object.content?item.object.content:"",//TODO sanitize then render without entity escapes
|
||||
atomHref:item.published?item.published.replace(/\W+/g,""):Math.random().toString().replace(".",""),// used for IDs
|
||||
enclosures:enclosures,
|
||||
stringDate:item.published?getTimeDisplay(Date.parse(item.published)):"",
|
||||
permalink:item.object.id?item.object.id:"#",
|
||||
author:{
|
||||
uri:op.url,// link to author page
|
||||
avatar:op.icon&&op.icon.url?op.icon.url:"",
|
||||
displayName:op.name || op.preferredUsername,
|
||||
fullName:op.preferredUsername+"@"+(new URL(op.url).hostname),
|
||||
}
|
||||
};
|
||||
return {
|
||||
isBoost: !!item._userdata,
|
||||
title: item._userdata
|
||||
? user.preferredUsername +
|
||||
" shared a status by " +
|
||||
op.preferredUsername
|
||||
: "",
|
||||
isReply: !!(item.object && item.object.inReplyTo),
|
||||
hasCw: item.object.sensitive || false,
|
||||
cw: item.object.summary,
|
||||
content: item.object && item.object.content ? item.object.content : "", //TODO sanitize then render without entity escapes
|
||||
atomHref: item.published
|
||||
? item.published.replace(/\W+/g, "")
|
||||
: Math.random().toString().replace("./g", ""), // used for IDs
|
||||
enclosures: enclosures,
|
||||
stringDate: item.published
|
||||
? getTimeDisplay(Date.parse(item.published))
|
||||
: "",
|
||||
permalink: item.object.id ? item.object.id : "#",
|
||||
author: {
|
||||
uri: op.url, // link to author page
|
||||
avatar: op.icon && op.icon.url ? op.icon.url : "",
|
||||
displayName: op.name || op.preferredUsername,
|
||||
fullName: op.preferredUsername + "@" + new URL(op.url).hostname,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getNextPage(opts,user,feed){
|
||||
//based on feed.next
|
||||
if (!feed.next){return null;}
|
||||
// take feed.next, uriencode it, then take user url, then take options.mastofeedUrl
|
||||
//let base = opts.mastofeedUrl.slice(0,opts.mastofeedUrl.indexOf("?"));
|
||||
function getNextPage(opts, user, feed) {
|
||||
//based on feed.next
|
||||
if (!feed.next) {
|
||||
return null;
|
||||
}
|
||||
// take feed.next, uriencode it, then take user url, then take options.mastofeedUrl
|
||||
//let base = opts.mastofeedUrl.slice(0,opts.mastofeedUrl.indexOf("?"));
|
||||
|
||||
let ret = "/api/v1/feed?userurl=" + encodeURIComponent(opts.userUrl) + "&feedurl=" +encodeURIComponent(feed.next) + "&instance_type=" + opts.instance_type;
|
||||
|
||||
// add other params to the end
|
||||
(["theme","header","size","boosts","replies"]).forEach((k)=>{
|
||||
if (typeof opts[k] != "undefined"){
|
||||
ret+=`&${k}=${ opts[k].toString() }`;
|
||||
}
|
||||
});
|
||||
return ret;
|
||||
let ret =
|
||||
"/api/v1/feed?userurl=" +
|
||||
encodeURIComponent(opts.userUrl) +
|
||||
"&feedurl=" +
|
||||
encodeURIComponent(feed.next) +
|
||||
"&instance_type=" +
|
||||
opts.instance_type;
|
||||
|
||||
// add other params to the end
|
||||
["theme", "header", "size", "boosts", "replies"].forEach((k) => {
|
||||
if (typeof opts[k] != "undefined") {
|
||||
ret += `&${k}=${opts[k].toString()}`;
|
||||
}
|
||||
});
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
// utilities below
|
||||
|
||||
function getTimeDisplay(d) {
|
||||
// let d = d;
|
||||
if (typeof d !== "object") {
|
||||
d = new Date(d);
|
||||
}
|
||||
// convert to number
|
||||
let dt = d.getTime();
|
||||
let now = Date.now();
|
||||
// let d = d;
|
||||
if (typeof d !== "object") {
|
||||
d = new Date(d);
|
||||
}
|
||||
// convert to number
|
||||
let dt = d.getTime();
|
||||
let now = Date.now();
|
||||
|
||||
let delta = now - dt;
|
||||
|
||||
// over 6 days ago
|
||||
if (delta > 1000 * 60 * 60 * 24 * 6) {
|
||||
return isoDateToEnglish(d.toISOString());
|
||||
} else {
|
||||
return format(dt);
|
||||
}
|
||||
let delta = now - dt;
|
||||
|
||||
// over 6 days ago
|
||||
if (delta > 1000 * 60 * 60 * 24 * 6) {
|
||||
return isoDateToEnglish(d.toISOString());
|
||||
} else {
|
||||
return format(dt);
|
||||
}
|
||||
}
|
||||
|
||||
function isoDateToEnglish(d) {
|
||||
let dt = d.split(/[t-]/gi);
|
||||
let months = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
let dt = d.split(/[t-]/ig);
|
||||
let months = ["January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"];
|
||||
|
||||
return months[Number(dt[1]) - 1] + " " + dt[2] + ", " + dt[0];
|
||||
return months[Number(dt[1]) - 1] + " " + dt[2] + ", " + dt[0];
|
||||
}
|
||||
|
|
|
@ -2,35 +2,34 @@ import { compile } from "ejs";
|
|||
import { readFileSync } from "fs";
|
||||
let template = compile(readFileSync("./lib/template.ejs", "utf8"));
|
||||
|
||||
export default function(code,message,displayOptions){
|
||||
export default function (code, message, displayOptions) {
|
||||
let msg;
|
||||
// const displayOptions = displayOptions || {};
|
||||
|
||||
let msg;
|
||||
// const displayOptions = displayOptions || {};
|
||||
if (code == 500 && !message) {
|
||||
msg =
|
||||
"<p>Sorry, we are having trouble fetching posts for this user. Please try again later.</p><br><p>If the issue persists, <a href=`https://git.froth.zone/Sam/fedifeed/issues`>please open an issue on Gitea</a>, or message sam@froth.zone</p>";
|
||||
} else {
|
||||
msg = message || "";
|
||||
}
|
||||
|
||||
if (code == 500 && !message){
|
||||
msg = "<p>Sorry, we are having trouble fetching posts for this user. Please try again later.</p><br><p>If the issue persists, <a href=\"https://git.froth.zone/Sam/fedifeed/issues\">please open an issue on Gitea</a>, or message sam@froth.zone</p>";
|
||||
}else{
|
||||
msg = message||"";
|
||||
}
|
||||
let options = {
|
||||
opts: {
|
||||
header: true,
|
||||
theme: displayOptions?.theme || null,
|
||||
size: displayOptions?.size || null,
|
||||
},
|
||||
meta: {
|
||||
title: code.toString(),
|
||||
description: msg,
|
||||
link: "#",
|
||||
// avatar:'',
|
||||
// headerImage:''
|
||||
},
|
||||
items: [],
|
||||
nextPageLink: null,
|
||||
isIndex: true,
|
||||
};
|
||||
|
||||
|
||||
let options = {
|
||||
opts:{
|
||||
header:true,
|
||||
theme:displayOptions.theme||null,
|
||||
size:displayOptions.size||null
|
||||
},
|
||||
meta:{
|
||||
title:code.toString(),
|
||||
description:msg,
|
||||
link:"#"
|
||||
// avatar:'',
|
||||
// headerImage:''
|
||||
},
|
||||
items:[],
|
||||
nextPageLink:null,
|
||||
isIndex:true
|
||||
};
|
||||
|
||||
return template(options);
|
||||
}
|
||||
return template(options);
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8"></meta>
|
||||
|
@ -6,7 +7,7 @@
|
|||
|
||||
<% if (opts.theme && opts.theme.toLowerCase() == 'masto-light'){ %>
|
||||
<link rel="stylesheet" href="/css/masto-light.css"></link>
|
||||
<% } else if (opts.theme && opts.theme.toLowerCase() == 'auto'){ %>
|
||||
<% } else if (opts.theme && opts.theme.toLowerCase() == 'masto-auto'){ %>
|
||||
<link rel="stylesheet" href="/css/masto-auto.css"></link>
|
||||
<% } else if (opts.theme && opts.theme.toLowerCase() == 'misskey-dark'){ %>
|
||||
<link rel="stylesheet" href="/css/misskey-dark.css"></link>
|
||||
|
@ -113,7 +114,7 @@
|
|||
<% } %>
|
||||
|
||||
<% if ( isIndex ){ %>
|
||||
<script src="/infinite-scroll.js"></script>
|
||||
<script src="/lib/infinite-scroll.pkgd.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
|
||||
let lastPageLoaded = null;
|
||||
|
|
57
package.json
57
package.json
|
@ -1,26 +1,31 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"ejs": "^3.1.6",
|
||||
"express": "^4.17.2",
|
||||
"feedparser": "^2.2.10",
|
||||
"got": "^12.0.1",
|
||||
"megalodon": "^4.0.0",
|
||||
"morgan": "^1.10.0",
|
||||
"serve-static": "^1.14.2",
|
||||
"timeago.js": "^4.0.2"
|
||||
},
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "yarn build-styles && node src/index.js",
|
||||
"build-styles": "node src/build-styles.js",
|
||||
"lint": "eslint --ext .js src lib",
|
||||
"test": "echo \"Error: no test specified\" && exit 0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "8.15.0",
|
||||
"node-sass": "7.0.1"
|
||||
}
|
||||
}
|
||||
{
|
||||
"dependencies": {
|
||||
"cors": "2.8.5",
|
||||
"ejs": "3.1.7",
|
||||
"express": "4.18.1",
|
||||
"feedparser": "2.2.10",
|
||||
"got": "12.0.4",
|
||||
"helmet": "5.0.2",
|
||||
"megalodon": "4.0.1",
|
||||
"morgan": "1.10.0",
|
||||
"serve-static": "1.15.0",
|
||||
"timeago.js": "4.0.2"
|
||||
},
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "yarn node index.js",
|
||||
"lint": "eslint --fix . && prettier --write .",
|
||||
"lint:ci": "eslint . && prettier . --check",
|
||||
"test": "echo \"Error: no test specified\" && exit 0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "8.15.0",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
"eslint-plugin-prettier": "4.0.0",
|
||||
"prettier": "2.6.2",
|
||||
"sass": "1.51.0"
|
||||
},
|
||||
"packageManager": "yarn@3.2.0"
|
||||
}
|
||||
|
|
|
@ -1,132 +1,162 @@
|
|||
html,
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
font-family: 'Roboto', roboto, Arial, sans-serif;
|
||||
font-family: "Roboto", roboto, Arial, sans-serif;
|
||||
color: #282c37;
|
||||
font-weight: lighter;
|
||||
overflow-x: hidden;
|
||||
font-size: 100%;
|
||||
word-break: break-word; }
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0; }
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a,
|
||||
a * {
|
||||
color: #2b90d9; }
|
||||
color: #2b90d9;
|
||||
}
|
||||
|
||||
.meta {
|
||||
background-color: #ecf0f4; }
|
||||
background-color: #ecf0f4;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
background-size: cover;
|
||||
min-height: 8rem;
|
||||
color: #282c37; }
|
||||
.header .header-left, .header .header-right {
|
||||
margin: 0; }
|
||||
.header .header-left {
|
||||
min-width: 8rem;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.3); }
|
||||
.header .header-left .avatar {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
position: relative;
|
||||
top: calc(50% - 3rem); }
|
||||
.header .header-right {
|
||||
flex-grow: 1;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.85); }
|
||||
.header .header-title {
|
||||
font-size: 1.3rem; }
|
||||
color: #282c37;
|
||||
}
|
||||
.header .header-left,
|
||||
.header .header-right {
|
||||
margin: 0;
|
||||
}
|
||||
.header .header-left {
|
||||
min-width: 8rem;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
.header .header-left .avatar {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
position: relative;
|
||||
top: calc(50% - 3rem);
|
||||
}
|
||||
.header .header-right {
|
||||
flex-grow: 1;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.header .header-title {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 1rem;
|
||||
border-top: solid 1px #8494ab; }
|
||||
border-top: solid 1px #8494ab;
|
||||
}
|
||||
|
||||
.item-content,
|
||||
.cw,
|
||||
.title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: lighter; }
|
||||
font-weight: lighter;
|
||||
}
|
||||
|
||||
.item-content *, .cw {
|
||||
.item-content *,
|
||||
.cw {
|
||||
margin: 1rem 0;
|
||||
line-height: 1.4rem; }
|
||||
line-height: 1.4rem;
|
||||
}
|
||||
|
||||
.item-title,
|
||||
.date,
|
||||
.author-fullname {
|
||||
color: #90a1ba;
|
||||
font-size: 0.9rem; }
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin: 1rem 0 0 0;
|
||||
text-decoration: none;
|
||||
display: block; }
|
||||
display: block;
|
||||
}
|
||||
|
||||
.date:hover {
|
||||
text-decoration: underline; }
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
margin-bottom: 0.7rem; }
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
margin-bottom: 1rem; }
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.author-info {
|
||||
margin: 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around; }
|
||||
.author-info .author-displayname {
|
||||
font-size: 1.2rem;
|
||||
color: #282c37;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
font-weight: bolder; }
|
||||
justify-content: space-around;
|
||||
}
|
||||
.author-info .author-displayname {
|
||||
font-size: 1.2rem;
|
||||
color: #282c37;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: none;
|
||||
border-radius: 10%; }
|
||||
.avatar.circular {
|
||||
border-radius: 100%; }
|
||||
border-radius: 10%;
|
||||
}
|
||||
.avatar.circular {
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.enclosures {
|
||||
padding: 0.5em 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
overflow: hidden; }
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.enclosure {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
width: 50%;
|
||||
border: none;
|
||||
max-height: 12rem; }
|
||||
max-height: 12rem;
|
||||
}
|
||||
|
||||
a.enclosure {
|
||||
cursor: zoom-in; }
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.enclosure > * {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover; }
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.meta .title {
|
||||
font-weight: bold; }
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none; }
|
||||
display: none;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.5rem 1rem;
|
||||
|
@ -140,145 +170,182 @@ a.enclosure {
|
|||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
user-select: none; }
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
label.button {
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin: 0.4rem;
|
||||
background: #8494ab;
|
||||
color: #ffffff;
|
||||
font-size: 0.8rem; }
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
input[type=checkbox] {
|
||||
input[type="checkbox"] {
|
||||
position: absolute;
|
||||
left: -9999px; }
|
||||
left: -9999px;
|
||||
}
|
||||
|
||||
input[type=checkbox]:checked ~ label::after {
|
||||
content: " Less"; }
|
||||
input[type="checkbox"]:checked ~ label::after {
|
||||
content: " Less";
|
||||
}
|
||||
|
||||
input[type=checkbox]:not(:checked) ~ label::after {
|
||||
content: " More"; }
|
||||
input[type="checkbox"]:not(:checked) ~ label::after {
|
||||
content: " More";
|
||||
}
|
||||
|
||||
input[type=checkbox]:not(:checked) ~ div {
|
||||
display: none; }
|
||||
input[type="checkbox"]:not(:checked) ~ div {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item-content,
|
||||
.description,
|
||||
.title,
|
||||
html,
|
||||
body {
|
||||
font-weight: normal; }
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: masto-dark) {
|
||||
html,
|
||||
body {
|
||||
background-color: #282c37;
|
||||
font-family: 'Roboto', roboto, Arial, sans-serif;
|
||||
font-family: "Roboto", roboto, Arial, sans-serif;
|
||||
color: #ffffff;
|
||||
font-weight: lighter;
|
||||
overflow-x: hidden;
|
||||
font-size: 100%;
|
||||
word-break: break-word; }
|
||||
word-break: break-word;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0; }
|
||||
padding: 0;
|
||||
}
|
||||
a,
|
||||
a * {
|
||||
color: #2b90d9; }
|
||||
color: #2b90d9;
|
||||
}
|
||||
.meta {
|
||||
background-color: #39404d; }
|
||||
background-color: #39404d;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
background-size: cover;
|
||||
min-height: 8rem;
|
||||
color: #ffffff; }
|
||||
.header .header-left, .header .header-right {
|
||||
margin: 0; }
|
||||
.header .header-left {
|
||||
min-width: 8rem;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
background: rgba(40, 44, 55, 0.3); }
|
||||
.header .header-left .avatar {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
position: relative;
|
||||
top: calc(50% - 3rem); }
|
||||
.header .header-right {
|
||||
flex-grow: 1;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.9rem;
|
||||
background: rgba(40, 44, 55, 0.85); }
|
||||
.header .header-title {
|
||||
font-size: 1.3rem; }
|
||||
color: #ffffff;
|
||||
}
|
||||
.header .header-left,
|
||||
.header .header-right {
|
||||
margin: 0;
|
||||
}
|
||||
.header .header-left {
|
||||
min-width: 8rem;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
background: rgba(40, 44, 55, 0.3);
|
||||
}
|
||||
.header .header-left .avatar {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
position: relative;
|
||||
top: calc(50% - 3rem);
|
||||
}
|
||||
.header .header-right {
|
||||
flex-grow: 1;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.9rem;
|
||||
background: rgba(40, 44, 55, 0.85);
|
||||
}
|
||||
.header .header-title {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
.item {
|
||||
padding: 1rem;
|
||||
border-top: solid 1px #626d80; }
|
||||
border-top: solid 1px #626d80;
|
||||
}
|
||||
.item-content,
|
||||
.cw,
|
||||
.title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: lighter; }
|
||||
.item-content *, .cw {
|
||||
font-weight: lighter;
|
||||
}
|
||||
.item-content *,
|
||||
.cw {
|
||||
margin: 1rem 0;
|
||||
line-height: 1.4rem; }
|
||||
line-height: 1.4rem;
|
||||
}
|
||||
.item-title,
|
||||
.date,
|
||||
.author-fullname {
|
||||
color: #9baec8;
|
||||
font-size: 0.9rem; }
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.date {
|
||||
margin: 1rem 0 0 0;
|
||||
text-decoration: none;
|
||||
display: block; }
|
||||
display: block;
|
||||
}
|
||||
.date:hover {
|
||||
text-decoration: underline; }
|
||||
text-decoration: underline;
|
||||
}
|
||||
.item-title {
|
||||
margin-bottom: 0.7rem; }
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
.author {
|
||||
display: flex;
|
||||
margin-bottom: 1rem; }
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.author-info {
|
||||
margin: 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around; }
|
||||
.author-info .author-displayname {
|
||||
font-size: 1.2rem;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
font-weight: bolder; }
|
||||
justify-content: space-around;
|
||||
}
|
||||
.author-info .author-displayname {
|
||||
font-size: 1.2rem;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
font-weight: bolder;
|
||||
}
|
||||
.avatar {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: none;
|
||||
border-radius: 10%; }
|
||||
.avatar.circular {
|
||||
border-radius: 100%; }
|
||||
border-radius: 10%;
|
||||
}
|
||||
.avatar.circular {
|
||||
border-radius: 100%;
|
||||
}
|
||||
.enclosures {
|
||||
padding: 0.5em 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
overflow: hidden; }
|
||||
overflow: hidden;
|
||||
}
|
||||
.enclosure {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
width: 50%;
|
||||
border: none;
|
||||
max-height: 12rem; }
|
||||
max-height: 12rem;
|
||||
}
|
||||
a.enclosure {
|
||||
cursor: zoom-in; }
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.enclosure > * {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover; }
|
||||
object-fit: cover;
|
||||
}
|
||||
.meta .title {
|
||||
font-weight: bold; }
|
||||
font-weight: bold;
|
||||
}
|
||||
.hidden {
|
||||
display: none; }
|
||||
display: none;
|
||||
}
|
||||
.button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
|
@ -291,19 +358,26 @@ body {
|
|||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
user-select: none; }
|
||||
user-select: none;
|
||||
}
|
||||
label.button {
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin: 0.4rem;
|
||||
background: #626d80;
|
||||
color: #282c37;
|
||||
font-size: 0.8rem; }
|
||||
input[type=checkbox] {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
position: absolute;
|
||||
left: -9999px; }
|
||||
input[type=checkbox]:checked ~ label::after {
|
||||
content: " Less"; }
|
||||
input[type=checkbox]:not(:checked) ~ label::after {
|
||||
content: " More"; }
|
||||
input[type=checkbox]:not(:checked) ~ div {
|
||||
display: none; } }
|
||||
left: -9999px;
|
||||
}
|
||||
input[type="checkbox"]:checked ~ label::after {
|
||||
content: " Less";
|
||||
}
|
||||
input[type="checkbox"]:not(:checked) ~ label::after {
|
||||
content: " More";
|
||||
}
|
||||
input[type="checkbox"]:not(:checked) ~ div {
|
||||
display: none;
|
||||
}
|
||||
}
|
199
public/css/masto-dark.css
Normal file
199
public/css/masto-dark.css
Normal file
|
@ -0,0 +1,199 @@
|
|||
html,
|
||||
body {
|
||||
background-color: #282c37;
|
||||
font-family: "Roboto", roboto, Arial, sans-serif;
|
||||
color: #ffffff;
|
||||
font-weight: lighter;
|
||||
overflow-x: hidden;
|
||||
font-size: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a,
|
||||
a * {
|
||||
color: #2b90d9;
|
||||
}
|
||||
|
||||
.meta {
|
||||
background-color: #39404d;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
background-size: cover;
|
||||
min-height: 8rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
.header .header-left,
|
||||
.header .header-right {
|
||||
margin: 0;
|
||||
}
|
||||
.header .header-left {
|
||||
min-width: 8rem;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
background: rgba(40, 44, 55, 0.3);
|
||||
}
|
||||
.header .header-left .avatar {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
position: relative;
|
||||
top: calc(50% - 3rem);
|
||||
}
|
||||
.header .header-right {
|
||||
flex-grow: 1;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.9rem;
|
||||
background: rgba(40, 44, 55, 0.85);
|
||||
}
|
||||
.header .header-title {
|
||||
font-size: 1.3rem;
|