14 Commits

28 changed files with 670 additions and 470 deletions

View File

@ -1,9 +1,12 @@
HASHTAG_FILTER = ichlausche,music,musik,nowplaying,tunetuesday,nowlistening HASHTAG_FILTER = ichlausche,music,musik,nowplaying,tunetuesday,nowlistening
URL_FILTER = song.link,album.link,spotify.com,music.apple.com,bandcamp.com URL_FILTER = song.link,album.link,spotify.com,music.apple.com,bandcamp.com
YOUTUBE_API_KEY = CHANGE_ME YOUTUBE_API_KEY = CHANGE_ME
YOUTUBE_DISABLE = false
MASTODON_INSTANCE = 'metalhead.club' MASTODON_INSTANCE = 'metalhead.club'
BASE_URL = 'https://moshingmammut.phlaym.net' BASE_URL = 'https://moshingmammut.phlaym.net'
VERBOSE = false VERBOSE = false
IGNORE_USERS = @moshhead@metalhead.club
WEBSUB_HUB = 'http://pubsubhubbub.superfeedr.com'
PUBLIC_REFRESH_INTERVAL = 10000 PUBLIC_REFRESH_INTERVAL = 10000
PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME = 'Metalhead.club' PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME = 'Metalhead.club'

View File

@ -1,7 +1,7 @@
{ {
"apexskier.typescript.config.formatDocumentOnSave": "true", "apexskier.typescript.config.formatDocumentOnSave" : "true",
"apexskier.typescript.config.isEnabledForJavascript": "Enable", "apexskier.typescript.config.isEnabledForJavascript" : "Enable",
"apexskier.typescript.config.organizeImportsOnSave": "true", "apexskier.typescript.config.organizeImportsOnSave" : "true",
"apexskier.typescript.config.userPreferences.quotePreference": "single", "apexskier.typescript.config.userPreferences.quotePreference" : "single",
"apexskier.typescript.config.userPreferences.useLabelDetailsInCompletionEntries": true "apexskier.typescript.config.userPreferences.useLabelDetailsInCompletionEntries" : true
} }

View File

@ -33,12 +33,12 @@ I can see that there are plenty of posts using only descriptions and links witho
be missed. This isn't a great solution. be missed. This isn't a great solution.
Another idea was to store only URLs of posts and resolve the content and account information live. Another idea was to store only URLs of posts and resolve the content and account information live.
This would be better, but I'm *still* storing post information while also slowing the app down and introduce more code This would be better, but I'm _still_ storing post information while also slowing the app down and introduce more code
complexity. I'm willing to make this change if people prefer this though. complexity. I'm willing to make this change if people prefer this though.
Additionally, I ended up adding a few things which turned out to be not needed: Additionally, I ended up adding a few things which turned out to be not needed:
The `tags` table (tags are included in the post's content and I don't do anything separately with tags) and The `tags` table (tags are included in the post's content and I don't do anything separately with tags) and
`accounts.username` and `accounts.avatar_static`. I will keep these in until the initial wave of feedback arrives, and ~~`accounts.username`~~ (s being used for #18) ~~and `accounts.avatar_static`~~ (has been removed). I will keep these in until the initial wave of feedback arrives, and
remove it if no new features required them. remove it if no new features required them.
I'll gladly accept any help in coming up with a good solution which doesn't need to store anything at all! I'll gladly accept any help in coming up with a good solution which doesn't need to store anything at all!
@ -51,7 +51,6 @@ By default, NVM is used to install NodeJS, but you can install it any way you wa
This is based on [SvelteKit's instructions](https://kit.svelte.dev/docs/adapter-node#deploying) and [How To Deploy Node.js Applications Using Systemd and Nginx](https://www.digitalocean.com/community/tutorials/how-to-deploy-node-js-applications-using-systemd-and-nginx) This is based on [SvelteKit's instructions](https://kit.svelte.dev/docs/adapter-node#deploying) and [How To Deploy Node.js Applications Using Systemd and Nginx](https://www.digitalocean.com/community/tutorials/how-to-deploy-node-js-applications-using-systemd-and-nginx)
#### On your server #### On your server
Install Apache2 if not already installed. Install Apache2 if not already installed.
@ -78,7 +77,6 @@ Enter `$APP_DIR`.
Place `package-lock.json` and `start.sh.EXAMPLE` in this directory. Place `package-lock.json` and `start.sh.EXAMPLE` in this directory.
Run `npm ci --omit dev` to install the dependencies. Run `npm ci --omit dev` to install the dependencies.
Rename `start.sh.EXAMPLE` to `start.sh` and set the path to your NVM. Rename `start.sh.EXAMPLE` to `start.sh` and set the path to your NVM.
If you do not have NVM installed, simply remove the line and make sure your node executable can be found either by If you do not have NVM installed, simply remove the line and make sure your node executable can be found either by
@ -93,12 +91,13 @@ If you do, add the path to your SSLCertificateFile and SSLCertificateKeyFile.
Copy `moshing-mammut.service.EXAMPLE` to `/etc/systemd/system/moshing-mammut.service` Copy `moshing-mammut.service.EXAMPLE` to `/etc/systemd/system/moshing-mammut.service`
and set your `User`, `Group`, `ExecStart` and `WorkingDirectory` accordingly. and set your `User`, `Group`, `ExecStart` and `WorkingDirectory` accordingly.
#### On your development machine #### On your development machine
Copy `.env.EXAMPLE` to `.env` and add your `YOUTUBE_API_KEY`. Copy `.env.EXAMPLE` to `.env` and add your `YOUTUBE_API_KEY`.
To obtain one follow [YouTube's guide](https://developers.google.com/youtube/registering_an_application) to create an To obtain one follow [YouTube's guide](https://developers.google.com/youtube/registering_an_application) to create an
*API key*. As soon as #13 is implemented, this will be optional! _API key_.
If `YOUTUBE_API_KEY` is unset, all YouTube videos will be assumed to contain music links.
If this is unwanted, set `YOUTUBE_DISABLE` to `true`).
Run `npm run build` and copy the output folder, usually `build` to `$APP_DIR` on your server. Run `npm run build` and copy the output folder, usually `build` to `$APP_DIR` on your server.

View File

@ -1,4 +1,3 @@
{ {
"masterPicture": "./icon.svg", "masterPicture": "./icon.svg",
"iconsPath": "/static", "iconsPath": "/static",
@ -61,4 +60,4 @@
"htmlCodeFile": false, "htmlCodeFile": false,
"usePathAsIs": false "usePathAsIs": false
} }
} }

View File

@ -5,6 +5,7 @@
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"devn": "vite dev --host",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",

View File

@ -1,53 +1,53 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" />
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" /> <link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
<link rel="mask-icon" href="%sveltekit.assets%/safari-pinned-tab.svg" color="#2e0b78" /> <link rel="mask-icon" href="%sveltekit.assets%/safari-pinned-tab.svg" color="#2e0b78" />
<meta name="apple-mobile-web-app-title" content="Moshing Mammut" /> <meta name="apple-mobile-web-app-title" content="Moshing Mammut" />
<meta name="application-name" content="Moshing Mammut" /> <meta name="application-name" content="Moshing Mammut" />
<meta name="msapplication-TileColor" content="#2e0b78" /> <meta name="msapplication-TileColor" content="#2e0b78" />
<meta name="theme-color" content="#2e0b78" /> <link rel="stylesheet" href="%sveltekit.assets%/style.css" />
<link rel="stylesheet" href="%sveltekit.assets%/style.css" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="viewport" content="width=device-width" /> <meta name="theme-color" content="#17063b" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#2E0B78" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#BCB9B2" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#BCB9B2" media="(prefers-color-scheme: light)" /> <link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Atom Feed" />
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Atom Feed" /> <link rel="hub" href="https://pubsubhubbub.superfeedr.com" />
%sveltekit.head% %sveltekit.head%
<style> <style>
body { body {
--color-text: #2F0C7A; --color-text: #2f0c7a;
--color-bg: white; --color-bg: white;
--color-border: #17063B; --color-border: #17063b;
--color-link: #563ACC; --color-link: #563acc;
--color-link-visited: #858AFA; --color-link-visited: #858afa;
color: var(--color-text); color: var(--color-text);
background-color: var(--color-bg); background-color: var(--color-bg);
} }
a { a {
color: var(--color-link); color: var(--color-link);
} }
a:visited { a:visited {
color: var(--color-link-visited); color: var(--color-link-visited);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { body {
--color-text: white; --color-text: white;
--color-bg: #17063B; --color-bg: #17063b;
--color-border: white; --color-border: white;
--color-link: #8A9BF0; --color-link: #8a9bf0;
--color-link-visited: #C384FB; --color-link-visited: #c384fb;
} }
} }
</style> </style>
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>
</html> </html>

View File

@ -24,12 +24,9 @@ export const handle = (async ({ event, resolve }) => {
} }
if (event.url.pathname === '/feed.xml') { if (event.url.pathname === '/feed.xml') {
const f = await fs.readFile('feed.xml', { encoding: 'utf8' }); const f = await fs.readFile('feed.xml', { encoding: 'utf8' });
return new Response( return new Response(f, { headers: [['Content-Type', 'application/atom+xml']] });
f,
{ headers: [['Content-Type', 'application/atom+xml']] }
);
} }
const response = await resolve(event); const response = await resolve(event);
return response; return response;
}) satisfies Handle; }) satisfies Handle;

View File

@ -4,4 +4,4 @@
export let account: Account; export let account: Account;
</script> </script>
<a href="{account.url}" target="_blank">{account.display_name} @{account.acct}</a> <a href={account.url} target="_blank">{account.display_name} @{account.acct}</a>

View File

@ -3,18 +3,18 @@
export let account: Account; export let account: Account;
let avatarDescription: string; let avatarDescription: string;
$: avatarDescription = `Avatar for ${account.acct}` $: avatarDescription = `Avatar for ${account.acct}`;
</script> </script>
<img src="{account.avatar}" alt={avatarDescription}/> <img src={account.avatar} alt={avatarDescription} />
<style> <style>
img { img {
max-width: 50px; max-width: 50px;
max-height: 50px; max-height: 50px;
width: auto; width: auto;
height: auto; height: auto;
object-fit: contain; object-fit: contain;
border-radius: 3px;; border-radius: 3px;
} }
</style> </style>

View File

@ -4,24 +4,28 @@
</script> </script>
<div class="footer"> <div class="footer">
<div> <div>
<span>Made with &#x1F918; by&nbsp;</span> <span class="label"
<a href="https://metalhead.club/@aymm" rel="me">@aymm@metalhead.club</a> >Made<span class="secretIngredient">&nbsp;with &#x1F918;</span>&nbsp;by&nbsp;</span
</div> >
| <a href="https://metalhead.club/@aymm" rel="me"
<div> >@aymm<span class="mastodonInstance">@metalhead.club</span></a
<a href="https://phlaym.net/git/phlaym/moshing-mammut"> >
<img alt="Git branch" src={git} class="icon" /> </div>
Source Code |
</a> <div>
</div> <a href="https://phlaym.net/git/phlaym/moshing-mammut">
| <img alt="Git branch" src={git} class="icon" />
<div> <span class="label">Source Code</span>
<a href="/feed.xml"> </a>
<img alt="RSS" src={rss} class="icon" /> </div>
RSS Feed |
</a> <div>
</div> <a href="/feed.xml">
<img alt="RSS" src={rss} class="icon" />
<span class="label">RSS<span class="feedSuffix">&nbsp;Feed</span></span>
</a>
</div>
</div> </div>
<style> <style>
@ -38,6 +42,7 @@
padding: 0.3em 1em; padding: 0.3em 1em;
margin: 0 -8px; margin: 0 -8px;
border-radius: 3px; border-radius: 3px;
padding-bottom: env(safe-area-inset-bottom);
} }
.icon { .icon {
position: relative; position: relative;
@ -53,4 +58,27 @@
background-color: var(--color-grey-translucent); background-color: var(--color-grey-translucent);
} }
} }
</style> @media only screen and (max-width: 620px) {
.mastodonInstance,
.feedSuffix {
display: none;
}
.footer {
justify-content: center;
}
}
@media only screen and (max-width: 430px) {
.mastodonInstance,
.feedSuffix,
.secretIngredient {
display: none;
}
}
@media only screen and (max-width: 370px) {
.label {
display: none;
}
}
</style>

View File

@ -2,19 +2,19 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import LoadingSpinnerComponent from '$lib/components/LoadingSpinnerComponent.svelte'; import LoadingSpinnerComponent from '$lib/components/LoadingSpinnerComponent.svelte';
export let moreAvailable: boolean = false; export let moreAvailable = false;
export let isLoading: boolean = false; export let isLoading = false;
let displayText = ''; let displayText = '';
let title = ''; let title = '';
let disabled: boolean; let disabled: boolean;
$: if (isLoading) { $: if (isLoading) {
displayText = 'Loading...'; displayText = 'Loading...';
} else if (!moreAvailable) { } else if (!moreAvailable) {
displayText = 'You reached the end'; displayText = 'You reached the end';
} else { } else {
displayText = 'Load More'; displayText = 'Load More';
}; }
$: disabled = !moreAvailable || isLoading; $: disabled = !moreAvailable || isLoading;
$: title = moreAvailable ? 'Load More' : 'There be dragons!'; $: title = moreAvailable ? 'Load More' : 'There be dragons!';
@ -26,10 +26,10 @@
</script> </script>
<button on:click={loadOlderPosts} {disabled} {title}> <button on:click={loadOlderPosts} {disabled} {title}>
<div class="loading" class:collapsed={!isLoading}> <div class="loading" class:collapsed={!isLoading}>
<LoadingSpinnerComponent size='0.5em' thickness='6px' /> <LoadingSpinnerComponent size="0.5em" thickness="6px" />
</div> </div>
<span>{displayText}</span> <span>{displayText}</span>
</button> </button>
<style> <style>
@ -78,4 +78,4 @@
max-width: 0; max-width: 0;
margin-right: 0; margin-right: 0;
} }
</style> </style>

View File

@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
export let size: string = '64px'; export let size = '64px';
export let thickness: string = '6px'; export let thickness = '6px';
</script> </script>
<div class="lds-dual-ring" style="--size: {size}; --thickness: {thickness}"></div> <div class="lds-dual-ring" style="--size: {size}; --thickness: {thickness}" />
<style> <style>
.lds-dual-ring { .lds-dual-ring {
display: inline-block; display: inline-block;
@ -11,7 +12,7 @@
height: 100%; height: 100%;
} }
.lds-dual-ring:after { .lds-dual-ring:after {
content: " "; content: ' ';
display: block; display: block;
width: var(--size); width: var(--size);
height: var(--size); height: var(--size);
@ -34,5 +35,4 @@
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
</style>
</style>

View File

@ -3,14 +3,14 @@
import AvatarComponent from '$lib/components/AvatarComponent.svelte'; import AvatarComponent from '$lib/components/AvatarComponent.svelte';
import AccountComponent from '$lib/components/AccountComponent.svelte'; import AccountComponent from '$lib/components/AccountComponent.svelte';
import { secondsSince, relativeTime } from '$lib/relativeTime'; import { secondsSince, relativeTime } from '$lib/relativeTime';
import { onMount } from "svelte"; import { onMount } from 'svelte';
export let post: Post; export let post: Post;
let displayRelativeTime = false; let displayRelativeTime = false;
const absoluteDate = new Date(post.created_at).toLocaleString(); const absoluteDate = new Date(post.created_at).toLocaleString();
let dateCreated = absoluteDate; let dateCreated = absoluteDate;
const timePassed = secondsSince(new Date(post.created_at)); const timePassed = secondsSince(new Date(post.created_at));
$: if(displayRelativeTime) { $: if (displayRelativeTime) {
dateCreated = relativeTime($timePassed) ?? absoluteDate; dateCreated = relativeTime($timePassed) ?? absoluteDate;
} }
@ -19,16 +19,15 @@
// When JS is disabled the server-side rendered absolute date will be shown, // When JS is disabled the server-side rendered absolute date will be shown,
// otherwise the relative date would be outdated very quickly // otherwise the relative date would be outdated very quickly
displayRelativeTime = true; displayRelativeTime = true;
}) });
</script> </script>
<div class="wrapper"> <div class="wrapper">
<div class="avatar"><AvatarComponent account={post.account} /></div> <div class="avatar"><AvatarComponent account={post.account} /></div>
<div class="post"> <div class="post">
<div class="meta"> <div class="meta">
<AccountComponent account={post.account} /> <AccountComponent account={post.account} />
<small><a href={post.url} target="_blank" title="{absoluteDate}">{dateCreated}</a></small> <small><a href={post.url} target="_blank" title={absoluteDate}>{dateCreated}</a></small>
</div> </div>
<div class="content">{@html post.content}</div> <div class="content">{@html post.content}</div>
</div> </div>
@ -42,6 +41,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 2; flex-grow: 2;
word-break: break-word;
} }
.meta { .meta {
display: flex; display: flex;
@ -54,4 +54,4 @@
max-width: calc(600px - 1em - 50px); max-width: calc(600px - 1em - 50px);
overflow-x: auto; overflow-x: auto;
} }
</style> </style>

View File

@ -5,4 +5,4 @@ export function errorToast(message: string): number {
return toast.push(`<img src="${errorIcon}" />${message}`, { return toast.push(`<img src="${errorIcon}" />${message}`, {
classes: ['error'] classes: ['error']
}); });
} }

View File

@ -1,29 +1,27 @@
export interface TimelineEvent { export interface TimelineEvent {
event: string, event: string;
payload: string payload: string;
} }
export interface Post { export interface Post {
id: string, id: string;
created_at: string, created_at: string;
tags: Tag[], tags: Tag[];
url: string, url: string;
content: string, content: string;
account: Account account: Account;
} }
export interface Tag { export interface Tag {
name: string, name: string;
url: string url: string;
} }
export interface Account { export interface Account {
id: string, id: string;
acct: string, acct: string;
username: string, username: string;
display_name: string, display_name: string;
url: string, url: string;
avatar: string, avatar: string;
avatar_static: string }
}

View File

@ -11,10 +11,7 @@ export const time = readable(new Date(), function start(set) {
}); });
export function secondsSince(date: Date): Readable<number> { export function secondsSince(date: Date): Readable<number> {
return derived( return derived(time, ($time) => Math.round(($time.getTime() - date.getTime()) / 1000));
time,
$time => Math.round(($time.getTime() - date.getTime()) / 1000)
);
} }
export function relativeTime(seconds: number): string | null { export function relativeTime(seconds: number): string | null {
@ -30,11 +27,11 @@ export function relativeTime(seconds: number): string | null {
const day = hour * 24; const day = hour * 24;
if (seconds < day) { if (seconds < day) {
return `${(Math.floor(seconds / hour))}h`; return `${Math.floor(seconds / hour)}h`;
} }
const maxRelative = day * 31; const maxRelative = day * 31;
if (seconds < maxRelative) { if (seconds < maxRelative) {
return `${Math.floor(seconds / day)}d`; return `${Math.floor(seconds / day)}d`;
} }
return null; return null;
} }

View File

@ -1,15 +1,30 @@
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { IGNORE_USERS, MASTODON_INSTANCE } from '$env/static/private';
import type { Account, Post, Tag } from '$lib/mastodon/response'; import type { Account, Post, Tag } from '$lib/mastodon/response';
import { isTruthy } from '$lib/truthyString';
import sqlite3 from 'sqlite3'; import sqlite3 from 'sqlite3';
const { DEV } = import.meta.env; const { DEV } = import.meta.env;
const db: sqlite3.Database = new sqlite3.Database('moshingmammut.db'); const db: sqlite3.Database = new sqlite3.Database('moshingmammut.db');
// for the local masto instance, the instance name is *not* saved
// as part of the username or acct, so it needs to be stripped
const ignoredUsers: string[] =
IGNORE_USERS === undefined
? []
: IGNORE_USERS.split(',')
.map((u) => (u.startsWith('@') ? u.substring(1) : u))
.map((u) =>
u.endsWith('@' + MASTODON_INSTANCE)
? u.substring(0, u.length - ('@' + MASTODON_INSTANCE).length)
: u
);
let databaseReady = false;
if (DEV && env.VERBOSE === 'true') { if (DEV && isTruthy(env.VERBOSE)) {
sqlite3.verbose(); sqlite3.verbose();
db.on('change', (t, d, table, rowid) => { db.on('change', (t, d, table, rowid) => {
console.debug('DB change event', t, d, table, rowid); console.debug('DB change event', t, d, table, rowid);
}) });
db.on('trace', (sql) => { db.on('trace', (sql) => {
console.debug('Running', sql); console.debug('Running', sql);
@ -21,9 +36,9 @@ if (DEV && env.VERBOSE === 'true') {
} }
interface Migration { interface Migration {
id: number, id: number;
name: string, name: string;
statement: string statement: string;
} }
db.on('open', () => { db.on('open', () => {
@ -33,24 +48,40 @@ db.on('open', () => {
db.all('SELECT id FROM migrations', (err, rows) => { db.all('SELECT id FROM migrations', (err, rows) => {
if (err !== null) { if (err !== null) {
console.error('Could not fetch existing migrations', err); console.error('Could not fetch existing migrations', err);
databaseReady = true;
return; return;
} }
console.debug('Already applied migrations', rows); console.debug('Already applied migrations', rows);
const appliedMigrations: Set<number> = new Set(rows.map((row: any) => row['id'])); const appliedMigrations: Set<number> = new Set(rows.map((row: any) => row['id']));
const toApply = getMigrations().filter(m => !appliedMigrations.has(m.id)); const toApply = getMigrations().filter((m) => !appliedMigrations.has(m.id));
for (let migration of toApply) { let remaining = toApply.length;
if (remaining === 0) {
databaseReady = true;
return;
}
for (const migration of toApply) {
db.exec(migration.statement, (err) => { db.exec(migration.statement, (err) => {
remaining--;
// This will set databaseReady to true before the migration has been inserted as applies,
// but that doesn't matter. It's only important that is has been applied
if (remaining === 0) {
databaseReady = true;
}
if (err !== null) { if (err !== null) {
console.error(`Failed to apply migration ${migration.name}`, err); console.error(`Failed to apply migration ${migration.name}`, err);
return; return;
} }
db.run('INSERT INTO migrations (id, name) VALUES(?, ?)', [migration.id, migration.name], (e: Error) => { db.run(
if (e !== null) { 'INSERT INTO migrations (id, name) VALUES(?, ?)',
console.error(`Failed to mark migration ${migration.name} as applied`, e); [migration.id, migration.name],
return; (e: Error) => {
if (e !== null) {
console.error(`Failed to mark migration ${migration.name} as applied`, e);
return;
}
console.info(`Applied migration ${migration.name}`);
} }
console.info(`Applied migration ${migration.name}`); );
});
}); });
} }
}); });
@ -60,10 +91,11 @@ db.on('error', (err) => {
}); });
function getMigrations(): Migration[] { function getMigrations(): Migration[] {
return [{ return [
id: 1, {
name: 'initial', id: 1,
statement: ` name: 'initial',
statement: `
CREATE TABLE accounts ( CREATE TABLE accounts (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
acct TEXT, acct TEXT,
@ -89,32 +121,102 @@ function getMigrations(): Migration[] {
FOREIGN KEY (post_id) REFERENCES posts(id), FOREIGN KEY (post_id) REFERENCES posts(id),
FOREIGN KEY (tag_url) REFERENCES tags(url) FOREIGN KEY (tag_url) REFERENCES tags(url)
)` )`
}]; },
{
id: 2,
name: 'urls as keys',
statement: `
CREATE TABLE accounts_new (
id TEXT NOT NULL,
acct TEXT,
username TEXT,
display_name TEXT,
url TEXT NOT NULL PRIMARY KEY,
avatar TEXT
);
INSERT INTO accounts_new (id, acct, username, display_name, url, avatar)
SELECT id, acct, username, display_name, url, avatar
FROM accounts;
DROP TABLE accounts;
ALTER TABLE accounts_new RENAME TO accounts;
CREATE TABLE posts_new (
id TEXT NOT NULL,
content TEXT,
created_at TEXT,
url TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL,
FOREIGN KEY (account_id) REFERENCES accounts(url)
);
INSERT INTO posts_new (id, content, created_at, url, account_id)
SELECT p.id, p.content, p.created_at, p.url, accounts.url
FROM posts as p
JOIN accounts ON accounts.id = p.account_id;
DROP TABLE posts;
ALTER TABLE posts_new RENAME TO posts;
CREATE TABLE poststags_new (
id integer PRIMARY KEY,
post_id TEXT NOT NULL,
tag_url TEXT NOT NULL,
FOREIGN KEY (post_id) REFERENCES posts(url),
FOREIGN KEY (tag_url) REFERENCES tags(url)
);
INSERT INTO poststags_new (id, post_id, tag_url)
SELECT pt.id, posts.url, pt.tag_url
FROM poststags as pt
JOIN posts ON posts.id = pt.post_id;
DROP TABLE poststags;
ALTER TABLE poststags_new RENAME TO poststags;
`
}
];
}
async function waitReady(): Promise<undefined> {
// Simpler than a semaphore and is really only needed on startup
return new Promise((resolve) => {
const interval = setInterval(() => {
if (DEV) {
console.debug('Waiting for database to be ready');
}
if (databaseReady) {
if (DEV) {
console.debug('DB is ready');
}
clearInterval(interval);
resolve(undefined);
}
}, 100);
});
} }
export async function savePost(post: Post): Promise<undefined> { export async function savePost(post: Post): Promise<undefined> {
return new Promise((resolve, reject) => { if (!databaseReady) {
await waitReady();
}
return await new Promise<undefined>((resolve, reject) => {
console.debug(`Saving post ${post.url}`); console.debug(`Saving post ${post.url}`);
const account = post.account; const account = post.account;
db.run(` db.run(
INSERT INTO accounts (id, acct, username, display_name, url, avatar, avatar_static) `
VALUES(?, ?, ?, ?, ?, ?, ?) INSERT INTO accounts (id, acct, username, display_name, url, avatar)
ON CONFLICT(id) VALUES(?, ?, ?, ?, ?, ?)
ON CONFLICT(url)
DO UPDATE SET DO UPDATE SET
acct=excluded.acct, acct=excluded.acct,
username=excluded.username, username=excluded.username,
display_name=excluded.display_name, display_name=excluded.display_name,
url=excluded.url, id=excluded.id,
avatar=excluded.avatar, avatar=excluded.avatar;`,
avatar_static=excluded.avatar_static;`,
[ [
account.id, account.id,
account.acct, account.acct,
account.username, account.username,
account.display_name, account.display_name,
account.url, account.url,
account.avatar, account.avatar
account.avatar_static
], ],
(err) => { (err) => {
if (err !== null) { if (err !== null) {
@ -122,20 +224,15 @@ export async function savePost(post: Post): Promise<undefined> {
reject(err); reject(err);
return; return;
} }
db.run(` db.run(
`
INSERT INTO posts (id, content, created_at, url, account_id) INSERT INTO posts (id, content, created_at, url, account_id)
VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET VALUES (?, ?, ?, ?, ?) ON CONFLICT(url) DO UPDATE SET
content=excluded.content, content=excluded.content,
created_at=excluded.created_at, created_at=excluded.created_at,
url=excluded.url, id=excluded.id,
account_id=excluded.account_id;`, account_id=excluded.account_id;`,
[ [post.id, post.content, post.created_at, post.url, post.account.url],
post.id,
post.content,
post.created_at,
post.url,
post.account.id
],
(postErr) => { (postErr) => {
if (postErr !== null) { if (postErr !== null) {
console.error(`Could not insert post ${post.url}`, postErr); console.error(`Could not insert post ${post.url}`, postErr);
@ -145,26 +242,28 @@ export async function savePost(post: Post): Promise<undefined> {
db.parallelize(() => { db.parallelize(() => {
let remaining = post.tags.length; let remaining = post.tags.length;
for (let tag of post.tags) { for (const tag of post.tags) {
db.run(` db.run(
`
INSERT INTO tags (url, tag) VALUES (?, ?) INSERT INTO tags (url, tag) VALUES (?, ?)
ON CONFLICT(url) DO UPDATE SET ON CONFLICT(url) DO UPDATE SET
tag=excluded.tag;`, tag=excluded.tag;`,
[ [tag.url, tag.name],
tag.url,
tag.name
],
(tagErr) => { (tagErr) => {
if (tagErr !== null) { if (tagErr !== null) {
console.error(`Could not insert/update tag ${tag.url}`, tagErr); console.error(`Could not insert/update tag ${tag.url}`, tagErr);
reject(tagErr); reject(tagErr);
return; return;
} }
db.run('INSERT INTO poststags (post_id, tag_url) VALUES (?, ?)', db.run(
[post.id, tag.url], 'INSERT INTO poststags (post_id, tag_url) VALUES (?, ?)',
[post.url, tag.url],
(posttagserr) => { (posttagserr) => {
if (posttagserr !== null) { if (posttagserr !== null) {
console.error(`Could not insert poststags ${tag.url}, ${post.url}`, posttagserr); console.error(
`Could not insert poststags ${tag.url}, ${post.url}`,
posttagserr
);
reject(posttagserr); reject(posttagserr);
return; return;
} }
@ -180,15 +279,20 @@ export async function savePost(post: Post): Promise<undefined> {
); );
} }
}); });
}); }
}); );
}
);
}); });
} }
export async function getPosts(since: string | null, before: string | null, limit: number) { export async function getPosts(since: string | null, before: string | null, limit: number) {
let promise = await new Promise<Post[]>((resolve, reject) => { if (!databaseReady) {
let filter_query; await waitReady();
let params: any = { $limit: limit }; }
const promise = await new Promise<Post[]>((resolve, reject) => {
let filter_query = '';
const params: any = { $limit: limit };
if (since === null && before === null) { if (since === null && before === null) {
filter_query = ''; filter_query = '';
} else if (since !== null) { } else if (since !== null) {
@ -199,74 +303,78 @@ export async function getPosts(since: string | null, before: string | null, limi
filter_query = 'WHERE posts.created_at < $before'; filter_query = 'WHERE posts.created_at < $before';
params.$before = before; params.$before = before;
} }
ignoredUsers.forEach((ignoredUser, index) => {
const userParam = `$user_${index}`;
const acctParam = userParam + 'a';
const usernameParam = userParam + 'u';
const prefix = filter_query === '' ? ' WHERE' : ' AND';
filter_query += `${prefix} acct != ${acctParam} AND username != ${usernameParam} `;
params[acctParam] = ignoredUser;
params[usernameParam] = ignoredUser;
});
const sql = `SELECT posts.id, posts.content, posts.created_at, posts.url, const sql = `SELECT posts.id, posts.content, posts.created_at, posts.url,
accounts.id AS account_id, accounts.acct, accounts.username, accounts.display_name, accounts.id AS account_id, accounts.acct, accounts.username, accounts.display_name,
accounts.url AS account_url, accounts.avatar accounts.url AS account_url, accounts.avatar
FROM posts FROM posts
JOIN accounts ON posts.account_id = accounts.id JOIN accounts ON posts.account_id = accounts.url
${filter_query} ${filter_query}
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $limit`; LIMIT $limit`;
db.all( db.all(sql, params, (err, rows: any[]) => {
sql, if (err != null) {
params, console.error('Error loading posts', err);
(err, rows: any[]) => { reject(err);
if (err != null) { return;
console.error('Error loading posts', err); }
reject(err); if (rows.length === 0) {
return; // No need to check for tags
} resolve([]);
if (rows.length === 0) { return;
// No need to check for tags }
resolve([]); const postIdsParams = rows.map(() => '?').join(', ');
return; db.all(
} `SELECT post_id, tags.url, tags.tag
const postIdsParams = rows.map(() => '?').join(', ');
db.all(
`SELECT post_id, tags.url, tags.tag
FROM poststags FROM poststags
JOIN tags ON poststags.tag_url = tags.url JOIN tags ON poststags.tag_url = tags.url
WHERE post_id IN (${postIdsParams});`, WHERE post_id IN (${postIdsParams});`,
rows.map((r: any) => r.id), rows.map((r: any) => r.url),
(tagErr, tagRows: any[]) => { (tagErr, tagRows: any[]) => {
if (tagErr != null) { if (tagErr != null) {
console.error('Error loading post tags', tagErr); console.error('Error loading post tags', tagErr);
reject(tagErr); reject(tagErr);
return; return;
}
const tagMap: Map<string, Tag[]> = tagRows.reduce(
(result: Map<string, Tag[]>, item) => {
const tag: Tag = {
url: item.url,
name: item.tag
};
result.set(item.post_id, [...result.get(item.post_id) || [], tag]);
return result;
}, new Map());
const posts = rows.map(row => {
return {
id: row.id,
content: row.content,
created_at: row.created_at,
url: row.url,
tags: tagMap.get(row.id) || [],
account: {
id: row.account_id,
acct: row.acct,
username: row.username,
display_name: row.display_name,
url: row.account_url,
avatar: row.avatar,
avatar_static: ''
} as Account
} as Post
});
resolve(posts);
} }
); const tagMap: Map<string, Tag[]> = tagRows.reduce((result: Map<string, Tag[]>, item) => {
} const tag: Tag = {
); url: item.url,
name: item.tag
};
result.set(item.post_id, [...(result.get(item.post_id) || []), tag]);
return result;
}, new Map());
const posts = rows.map((row) => {
return {
id: row.id,
content: row.content,
created_at: row.created_at,
url: row.url,
tags: tagMap.get(row.id) || [],
account: {
id: row.account_id,
acct: row.acct,
username: row.username,
display_name: row.display_name,
url: row.account_url,
avatar: row.avatar
} as Account
} as Post;
});
resolve(posts);
}
);
});
}); });
return promise; return promise;
} }

View File

@ -1,4 +1,4 @@
import { BASE_URL } from '$env/static/private'; import { BASE_URL, WEBSUB_HUB } from '$env/static/private';
import { PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME } from '$env/static/public'; import { PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME } from '$env/static/public';
import type { Post } from '$lib//mastodon/response'; import type { Post } from '$lib//mastodon/response';
import { Feed } from 'feed'; import { Feed } from 'feed';
@ -6,6 +6,7 @@ import fs from 'fs/promises';
export function createFeed(posts: Post[]): Feed { export function createFeed(posts: Post[]): Feed {
const baseUrl = BASE_URL.endsWith('/') ? BASE_URL : BASE_URL + '/'; const baseUrl = BASE_URL.endsWith('/') ? BASE_URL : BASE_URL + '/';
const hub = WEBSUB_HUB ? WEBSUB_HUB : undefined;
const feed = new Feed({ const feed = new Feed({
title: `${PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music feed`, title: `${PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music feed`,
description: `Posts about music on ${PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME}`, description: `Posts about music on ${PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME}`,
@ -19,27 +20,45 @@ export function createFeed(posts: Post[]): Feed {
feedLinks: { feedLinks: {
atom: `${BASE_URL}/feed.xml` atom: `${BASE_URL}/feed.xml`
}, },
hub: hub,
author: { author: {
name: '@aymm', name: '@aymm',
link: 'https://metalhead.club/@aymm' link: 'https://metalhead.club/@aymm'
}, }
}); });
posts.forEach(p => { posts.forEach((p) => {
feed.addItem({ feed.addItem({
title: p.content, title: p.content,
id: p.url, id: p.url,
link: p.url, link: p.url,
content: p.content, content: p.content,
author: [{ author: [
name: p.account.acct, {
link: p.account.url name: p.account.acct,
}], link: p.account.url
}
],
date: new Date(p.created_at) date: new Date(p.created_at)
}) });
}); });
feed.addCategory('Music'); feed.addCategory('Music');
return feed; return feed;
} }
export async function saveAtomFeed(feed: Feed) { export async function saveAtomFeed(feed: Feed) {
await fs.writeFile('feed.xml', feed.atom1(), { encoding: 'utf8' }); await fs.writeFile('feed.xml', feed.atom1(), { encoding: 'utf8' });
} if (!WEBSUB_HUB) {
return;
}
try {
const params = new URLSearchParams();
params.append('hub.mode', 'publish');
params.append('hub.url', `${BASE_URL}/feed.xml`);
await fetch(WEBSUB_HUB, {
method: 'POST',
body: params
});
} catch (e) {
console.error('Failed to update WebSub hub', e);
}
}

View File

@ -1,19 +1,34 @@
import { HASHTAG_FILTER, MASTODON_INSTANCE, URL_FILTER, YOUTUBE_API_KEY } from '$env/static/private'; import {
HASHTAG_FILTER,
MASTODON_INSTANCE,
URL_FILTER,
YOUTUBE_API_KEY,
YOUTUBE_DISABLE
} from '$env/static/private';
import type { Post, Tag, TimelineEvent } from '$lib/mastodon/response'; import type { Post, Tag, TimelineEvent } from '$lib/mastodon/response';
import { getPosts, savePost } from '$lib/server/db'; import { getPosts, savePost } from '$lib/server/db';
import { createFeed, saveAtomFeed } from '$lib/server/rss'; import { createFeed, saveAtomFeed } from '$lib/server/rss';
import { WebSocket } from "ws"; import { isTruthy } from '$lib/truthyString';
import { WebSocket } from 'ws';
const YOUTUBE_REGEX = new RegExp(/https?:\/\/(www\.)?youtu((be.com\/.*?v=)|(\.be\/))(?<videoId>[a-zA-Z_0-9-]+)/gm); const YOUTUBE_REGEX = new RegExp(
/https?:\/\/(www\.)?youtu((be.com\/.*?v=)|(\.be\/))(?<videoId>[a-zA-Z_0-9-]+)/gm
);
export class TimelineReader { export class TimelineReader {
private static _instance: TimelineReader; private static _instance: TimelineReader;
private static async isMusicVideo(videoId: string) { private static async isMusicVideo(videoId: string) {
if (YOUTUBE_API_KEY === undefined) {
// Assume that it *is* a music link when no YT API key is provided
// If it should assumed to not be YOUTUBE_DISABLE needs to be set to something truthy
return true;
}
const searchParams = new URLSearchParams([ const searchParams = new URLSearchParams([
['part', 'snippet'], ['part', 'snippet'],
['id', videoId], ['id', videoId],
['key', YOUTUBE_API_KEY]]); ['key', YOUTUBE_API_KEY]
]);
const youtubeVideoUrl = new URL(`https://www.googleapis.com/youtube/v3/videos?${searchParams}`); const youtubeVideoUrl = new URL(`https://www.googleapis.com/youtube/v3/videos?${searchParams}`);
const resp = await fetch(youtubeVideoUrl); const resp = await fetch(youtubeVideoUrl);
const respObj = await resp.json(); const respObj = await resp.json();
@ -30,15 +45,23 @@ export class TimelineReader {
const categorySearchParams = new URLSearchParams([ const categorySearchParams = new URLSearchParams([
['part', 'snippet'], ['part', 'snippet'],
['id', item.categoryId], ['id', item.categoryId],
['key', YOUTUBE_API_KEY]]); ['key', YOUTUBE_API_KEY]
const youtubeCategoryUrl = new URL(`https://www.googleapis.com/youtube/v3/videoCategories?${categorySearchParams}`); ]);
const categoryTitle: string = await fetch(youtubeCategoryUrl).then(r => r.json()).then(r => r.items[0]?.title); const youtubeCategoryUrl = new URL(
`https://www.googleapis.com/youtube/v3/videoCategories?${categorySearchParams}`
);
const categoryTitle: string = await fetch(youtubeCategoryUrl)
.then((r) => r.json())
.then((r) => r.items[0]?.title);
return categoryTitle === 'Music'; return categoryTitle === 'Music';
} }
private static async checkYoutubeMatches(postContent: string): Promise<boolean> { private static async checkYoutubeMatches(postContent: string): Promise<boolean> {
if (isTruthy(YOUTUBE_DISABLE)) {
return false;
}
const matches = postContent.matchAll(YOUTUBE_REGEX); const matches = postContent.matchAll(YOUTUBE_REGEX);
for (let match of matches) { for (const match of matches) {
if (match === undefined || match.groups === undefined) { if (match === undefined || match.groups === undefined) {
continue; continue;
} }
@ -57,10 +80,10 @@ export class TimelineReader {
private constructor() { private constructor() {
const socket = new WebSocket(`wss://${MASTODON_INSTANCE}/api/v1/streaming`); const socket = new WebSocket(`wss://${MASTODON_INSTANCE}/api/v1/streaming`);
socket.onopen = (_event) => { socket.onopen = () => {
socket.send('{ "type": "subscribe", "stream": "public:local"}'); socket.send('{ "type": "subscribe", "stream": "public:local"}');
}; };
socket.onmessage = (async (event) => { socket.onmessage = async (event) => {
try { try {
const data: TimelineEvent = JSON.parse(event.data.toString()); const data: TimelineEvent = JSON.parse(event.data.toString());
if (data.event !== 'update') { if (data.event !== 'update') {
@ -71,31 +94,30 @@ export class TimelineReader {
const found_tags: Tag[] = post.tags.filter((t: Tag) => hashttags.includes(t.name)); const found_tags: Tag[] = post.tags.filter((t: Tag) => hashttags.includes(t.name));
const urls: string[] = URL_FILTER.split(','); const urls: string[] = URL_FILTER.split(',');
const found_urls = urls.filter(t => post.content.includes(t)); const found_urls = urls.filter((t) => post.content.includes(t));
// If we don't have any tags or non-youtube urls, check youtube // If we don't have any tags or non-youtube urls, check youtube
// YT is handled separately, because it requires an API call and therefore is slower // YT is handled separately, because it requires an API call and therefore is slower
if (found_urls.length === 0 && if (
found_urls.length === 0 &&
found_tags.length === 0 && found_tags.length === 0 &&
!await TimelineReader.checkYoutubeMatches(post.content)) { !(await TimelineReader.checkYoutubeMatches(post.content))
) {
return; return;
} }
await savePost(post); await savePost(post);
const posts = await getPosts(null, null, 100); const posts = await getPosts(null, null, 100);
await saveAtomFeed(createFeed(posts)); await saveAtomFeed(createFeed(posts));
} catch (e) { } catch (e) {
console.error("error message", event, event.data, e) console.error('error message', event, event.data, e);
} }
};
});
socket.onclose = (event) => { socket.onclose = (event) => {
console.log("Closed", event, event.code, event.reason) console.log('Closed', event, event.code, event.reason);
}; };
socket.onerror = (event) => { socket.onerror = (event) => {
console.log("error", event, event.message, event.error) console.log('error', event, event.message, event.error);
}; };
} }
public static init() { public static init() {
@ -108,4 +130,4 @@ export class TimelineReader {
TimelineReader.init(); TimelineReader.init();
return this._instance; return this._instance;
} }
} }

7
src/lib/truthyString.ts Normal file
View File

@ -0,0 +1,7 @@
export function isTruthy(value: string | number | boolean | null | undefined): boolean {
if (typeof value === 'string') {
return value.toLowerCase() === 'true' || !!+value; // here we parse to number first
}
return !!value;
}

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import FooterComponent from '$lib/components/FooterComponent.svelte' import FooterComponent from '$lib/components/FooterComponent.svelte';
import { SvelteToast } from '@zerodevx/svelte-toast'; import { SvelteToast } from '@zerodevx/svelte-toast';
const options = { const options = {
@ -7,6 +7,7 @@
classes: ['toast'] classes: ['toast']
}; };
</script> </script>
<slot /> <slot />
<SvelteToast {options} /> <SvelteToast {options} />
<div class="footer"> <div class="footer">
@ -33,4 +34,9 @@
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
</style> @media only screen and (max-width: 620px) {
.footer {
width: 100%;
}
}
</style>

View File

@ -1,174 +1,184 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from 'svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { Post } from '$lib/mastodon/response'; import type { Post } from '$lib/mastodon/response';
import { PUBLIC_REFRESH_INTERVAL, PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME } from '$env/static/public'; import {
import PostComponent from '$lib/components/PostComponent.svelte'; PUBLIC_REFRESH_INTERVAL,
import LoadMoreComponent from '$lib/components/LoadMoreComponent.svelte'; PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME
import { fly, type FlyParams } from 'svelte/transition'; } from '$env/static/public';
import { cubicInOut } from 'svelte/easing'; import PostComponent from '$lib/components/PostComponent.svelte';
import { errorToast } from '$lib/errorToast' import LoadMoreComponent from '$lib/components/LoadMoreComponent.svelte';
import { fly, type FlyParams } from 'svelte/transition';
import { cubicInOut } from 'svelte/easing';
import { errorToast } from '$lib/errorToast';
export let data: PageData;
export let data: PageData; interface FetchOptions {
since?: string;
interface FetchOptions { before?: string;
since?: string, count?: number;
before?: string,
count?: number
}
interface EdgeFlyParams extends FlyParams {
created_at: string
}
const refreshInterval = parseInt(PUBLIC_REFRESH_INTERVAL);
let interval: NodeJS.Timer | null = null;
let moreOlderPostsAvailable = true;
let loadingOlderPosts = false;
// Needed, so that edgeFly() can do its thing:
// To determine whether a newly loaded post is older than the existing ones, is required to know what the oldest
// post was, before the fetch happened.
let oldestBeforeLastFetch: number | null = null;
/**
* Animate either from the top, or the bottom of the window, depending if the post is
* newer than the existing ones or older.
*/
function edgeFly(node: Element, opts: EdgeFlyParams) {
const createdAt = new Date(opts.created_at).getTime();
const diffNewest = Math.abs(new Date(data.posts[0].created_at).getTime() - createdAt);
const oldest = oldestBeforeLastFetch !== null
? oldestBeforeLastFetch
: new Date(data.posts[data.posts.length - 1].created_at).getTime();
const diffOldest = Math.abs(oldest - createdAt);
const fromTop = diffNewest <= diffOldest;
const rect = node.getBoundingClientRect();
const paramY = +`${opts.y}`;
let offset = isNaN(paramY) ? 0 : paramY + rect.height;
opts.y = fromTop ? -offset : window.innerHeight + offset;
return fly(node, opts);
}
async function fetchPosts(options: FetchOptions): Promise<Post[]> {
const params = new URLSearchParams();
if (options?.since !== undefined) {
params.set('since', options.since);
}
if (options?.before !== undefined) {
params.set('before', options.before);
}
if (options?.count !== undefined) {
params.set('count', options.count.toFixed(0));
} }
const response = await fetch(`/api/posts?${params}`); interface EdgeFlyParams extends FlyParams {
return await response.json(); created_at: string;
}
function filterDuplicates(posts: Post[]): Post[] {
return posts.filter((obj, index, arr) => {
return arr.map(mapObj => mapObj.url).indexOf(obj.url) === index;
});
}
function refresh() {
let filter: FetchOptions = {};
if (data.posts.length > 0) {
filter = { since: data.posts[0].created_at };
} }
fetchPosts(filter).then(resp => {
if (resp.length > 0) { const refreshInterval = parseInt(PUBLIC_REFRESH_INTERVAL);
// Prepend new posts, filter dupes let interval: NodeJS.Timer | null = null;
// There shouldn't be any duplicates, but better be safe than sorry let moreOlderPostsAvailable = true;
data.posts = filterDuplicates(resp.concat(data.posts)); let loadingOlderPosts = false;
// Needed, so that edgeFly() can do its thing:
// To determine whether a newly loaded post is older than the existing ones, is required to know what the oldest
// post was, before the fetch happened.
let oldestBeforeLastFetch: number | null = null;
/**
* Animate either from the top, or the bottom of the window, depending if the post is
* newer than the existing ones or older.
*/
function edgeFly(node: Element, opts: EdgeFlyParams) {
const createdAt = new Date(opts.created_at).getTime();
const diffNewest = Math.abs(new Date(data.posts[0].created_at).getTime() - createdAt);
const oldest =
oldestBeforeLastFetch !== null
? oldestBeforeLastFetch
: new Date(data.posts[data.posts.length - 1].created_at).getTime();
const diffOldest = Math.abs(oldest - createdAt);
const fromTop = diffNewest <= diffOldest;
const rect = node.getBoundingClientRect();
const paramY = +`${opts.y}`;
let offset = isNaN(paramY) ? 0 : paramY + rect.height;
opts.y = fromTop ? -offset : window.innerHeight + offset;
return fly(node, opts);
}
async function fetchPosts(options: FetchOptions): Promise<Post[]> {
const params = new URLSearchParams();
if (options?.since !== undefined) {
params.set('since', options.since);
} }
}) if (options?.before !== undefined) {
.catch((e: Error) => { params.set('before', options.before);
errorToast('Error loading newest posts: ' + e.message);
});
}
onMount(async () => {
if (data.posts.length > 0) {
oldestBeforeLastFetch = new Date(data.posts[data.posts.length - 1].created_at).getTime();
}
interval = setInterval(refresh, refreshInterval);
// - If the page is hidden, slow down refresh rate
// - If the page is shown, bump up refresh rate
document.addEventListener('visibilitychange', () => {
const delay = document.hidden ? refreshInterval * 10 : refreshInterval;
if (interval) {
clearInterval(interval);
} }
interval = setInterval(refresh, delay); if (options?.count !== undefined) {
params.set('count', options.count.toFixed(0));
}
const response = await fetch(`/api/posts?${params}`);
return await response.json();
}
function filterDuplicates(posts: Post[]): Post[] {
return posts.filter((obj, index, arr) => {
return arr.map((mapObj) => mapObj.url).indexOf(obj.url) === index;
});
}
function refresh() {
let filter: FetchOptions = {};
if (data.posts.length > 0) {
filter = { since: data.posts[0].created_at };
}
fetchPosts(filter)
.then((resp) => {
if (resp.length > 0) {
// Prepend new posts, filter dupes
// There shouldn't be any duplicates, but better be safe than sorry
data.posts = filterDuplicates(resp.concat(data.posts));
}
})
.catch((e: Error) => {
errorToast('Error loading newest posts: ' + e.message);
});
}
onMount(async () => {
if (data.posts.length > 0) {
oldestBeforeLastFetch = new Date(data.posts[data.posts.length - 1].created_at).getTime();
}
interval = setInterval(refresh, refreshInterval);
// - If the page is hidden, slow down refresh rate
// - If the page is shown, bump up refresh rate
document.addEventListener('visibilitychange', () => {
const delay = document.hidden ? refreshInterval * 10 : refreshInterval;
if (interval) {
clearInterval(interval);
}
interval = setInterval(refresh, delay);
});
return () => {
if (interval !== null) {
clearInterval(interval);
}
};
}); });
return () => { function loadOlderPosts() {
if (interval !== null) { loadingOlderPosts = true;
clearInterval(interval) const filter: FetchOptions = { count: 20 };
if (data.posts.length > 0) {
const before = data.posts[data.posts.length - 1].created_at;
filter.before = before;
oldestBeforeLastFetch = new Date(before).getTime();
} }
fetchPosts(filter)
.then((resp) => {
if (resp.length > 0) {
// Append old posts, filter dupes
// There shouldn't be any duplicates, but better be safe than sorry
data.posts = filterDuplicates(data.posts.concat(resp));
// If we got less than we expected, there are no older posts available
moreOlderPostsAvailable = resp.length >= (filter.count ?? 20);
} else {
moreOlderPostsAvailable = false;
}
loadingOlderPosts = false;
})
.catch((e) => {
loadingOlderPosts = false;
errorToast('Error loading older posts: ' + e.message);
});
} }
});
function loadOlderPosts() {
loadingOlderPosts = true;
const filter: FetchOptions = { count: 20 };
if (data.posts.length > 0) {
const before = data.posts[data.posts.length - 1].created_at;
filter.before = before;
oldestBeforeLastFetch = new Date(before).getTime();
}
fetchPosts(filter).then(resp => {
if (resp.length > 0) {
// Append old posts, filter dupes
// There shouldn't be any duplicates, but better be safe than sorry
data.posts = filterDuplicates(data.posts.concat(resp));
// If we got less than we expected, there are no older posts available
moreOlderPostsAvailable = resp.length >= (filter.count ?? 20);
} else {
moreOlderPostsAvailable = false;
}
loadingOlderPosts = false;
})
.catch(e => {
loadingOlderPosts = false;
errorToast('Error loading older posts: ' + e.message);
});
}
</script> </script>
<svelte:head> <svelte:head>
<title>{PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music list</title> <title>{PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music list</title>
</svelte:head> </svelte:head>
<h2>{PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music list</h2> <h2>{PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME} music list</h2>
<div class="wrapper"> <div class="wrapper">
<div></div> <div />
<div class="posts"> <div class="posts">
{#if data.posts.length === 0} {#if data.posts.length === 0}
Sorry, no posts recommending music aave been found yet Sorry, no posts recommending music have been found yet
{/if} {/if}
{#each data.posts as post (post.url)} {#each data.posts as post (post.url)}
<div <div
class="post" class="post"
transition:edgeFly="{{ y: 10, created_at: post.created_at, duration: 300, easing: cubicInOut }}" transition:edgeFly={{
y: 10,
created_at: post.created_at,
duration: 300,
easing: cubicInOut
}}
> >
<PostComponent {post} /> <PostComponent {post} />
</div> </div>
{/each} {/each}
<LoadMoreComponent <LoadMoreComponent
on:loadOlderPosts={loadOlderPosts} on:loadOlderPosts={loadOlderPosts}
moreAvailable={moreOlderPostsAvailable} moreAvailable={moreOlderPostsAvailable}
isLoading={loadingOlderPosts}/> isLoading={loadingOlderPosts}
</div> />
<div></div>
</div> </div>
<div />
</div>
<style> <style>
.posts { .posts {
display: flex; display: flex;
@ -191,4 +201,11 @@ function loadOlderPosts() {
text-align: center; text-align: center;
z-index: 100; z-index: 100;
} }
</style>
@media only screen and (max-width: 650px) {
.post {
max-width: calc(100vw - 16px);
padding: 1em 0;
}
}
</style>

View File

@ -2,8 +2,8 @@ import type { Post } from '$lib/mastodon/response';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load = (async ({ fetch }) => { export const load = (async ({ fetch }) => {
const p = await fetch('/'); const p = await fetch('/');
return { return {
posts: await p.json() as Post[] posts: (await p.json()) as Post[]
}; };
}) satisfies PageLoad; }) satisfies PageLoad;

View File

@ -2,4 +2,4 @@ import type { RequestHandler } from './$types';
export const GET = (async ({ fetch }) => { export const GET = (async ({ fetch }) => {
return await fetch('api/posts'); return await fetch('api/posts');
}) satisfies RequestHandler; }) satisfies RequestHandler;

View File

@ -13,4 +13,4 @@ export const GET = (async ({ url }) => {
count = Math.min(count, 100); count = Math.min(count, 100);
const posts = await getPosts(since, before, count); const posts = await getPosts(since, before, count);
return json(posts); return json(posts);
}) satisfies RequestHandler; }) satisfies RequestHandler;

View File

@ -1,19 +1,19 @@
{ {
"name": "Moshing Mammut", "name": "Moshing Mammut",
"short_name": "Moshing Mammut", "short_name": "Moshing Mammut",
"icons": [ "icons": [
{ {
"src": "/android-chrome-192x192.png", "src": "/android-chrome-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "/android-chrome-512x512.png", "src": "/android-chrome-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
} }
], ],
"theme_color": "#2e0b78", "theme_color": "#2e0b78",
"background_color": "#2e0b78", "background_color": "#2e0b78",
"display": "standalone" "display": "standalone"
} }

View File

@ -16,7 +16,6 @@ body {
--color-red-desat-dark: hsl(7, 20%, 30%); --color-red-desat-dark: hsl(7, 20%, 30%);
--color-red-desat-desat: hsl(7, 8%, 56%); --color-red-desat-desat: hsl(7, 8%, 56%);
--color-text: var(--color-blue); --color-text: var(--color-blue);
--color-border: var(--color-grey); --color-border: var(--color-grey);
--color-link: var(--color-mauve); --color-link: var(--color-mauve);
@ -30,9 +29,9 @@ body {
color: var(--color-text); color: var(--color-text);
background-color: var(--color-bg); background-color: var(--color-bg);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu,
Ubuntu, Cantarell, "Helvetica Neue", Helvetica, Arial, sans-serif, Cantarell, 'Helvetica Neue', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 'Segoe UI Symbol';
} }
a { a {
@ -55,4 +54,4 @@ a:visited {
--color-button-deactivated: var(--color-red-desat-desat); --color-button-deactivated: var(--color-red-desat-desat);
--color-button-text: var(--color-blue-dark); --color-button-text: var(--color-blue-dark);
} }
} }

View File

@ -11,8 +11,8 @@ const config = {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter. // If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters. // See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter(), adapter: adapter()
}, }
}; };
export default config; export default config;