Compare commits

...

2 Commits

Author SHA1 Message Date
6c9546b74a
Improve formatting 2023-04-11 16:02:54 +02:00
268128c2f4
Improve layout for smaller devices 2023-04-11 15:49:50 +02:00
25 changed files with 497 additions and 447 deletions

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,7 +33,7 @@ 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:
@ -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,11 @@ 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_. As soon as #13 is implemented, this will be optional!
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",

View File

@ -12,18 +12,18 @@
<meta name="msapplication-TileColor" content="#2e0b78" /> <meta name="msapplication-TileColor" content="#2e0b78" />
<meta name="theme-color" 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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2E0B78" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#17063b" 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" />
%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);
@ -39,10 +39,10 @@
@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>

View File

@ -24,10 +24,7 @@ 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);

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

@ -5,21 +5,25 @@
<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
>
<a href="https://metalhead.club/@aymm" rel="me"
>@aymm<span class="mastodonInstance">@metalhead.club</span></a
>
</div> </div>
| |
<div> <div>
<a href="https://phlaym.net/git/phlaym/moshing-mammut"> <a href="https://phlaym.net/git/phlaym/moshing-mammut">
<img alt="Git branch" src={git} class="icon" /> <img alt="Git branch" src={git} class="icon" />
Source Code <span class="label">Source Code</span>
</a> </a>
</div> </div>
| |
<div> <div>
<a href="/feed.xml"> <a href="/feed.xml">
<img alt="RSS" src={rss} class="icon" /> <img alt="RSS" src={rss} class="icon" />
RSS Feed <span class="label">RSS<span class="feedSuffix">&nbsp;Feed</span></span>
</a> </a>
</div> </div>
</div> </div>
@ -53,4 +57,27 @@
background-color: var(--color-grey-translucent); background-color: var(--color-grey-translucent);
} }
} }
@media only screen and (max-device-width: 620px) {
.mastodonInstance,
.feedSuffix {
display: none;
}
.footer {
justify-content: center;
}
}
@media only screen and (max-device-width: 430px) {
.mastodonInstance,
.feedSuffix,
.secretIngredient {
display: none;
}
}
@media only screen and (max-device-width: 370px) {
.label {
display: none;
}
}
</style> </style>

View File

@ -2,8 +2,8 @@
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;
@ -14,7 +14,7 @@
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!';
@ -27,7 +27,7 @@
<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>

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>

View File

@ -1,29 +1,28 @@
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 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,7 +27,7 @@ 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) {

View File

@ -9,7 +9,7 @@ if (DEV && env.VERBOSE === 'true') {
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 +21,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', () => {
@ -37,20 +37,24 @@ db.on('open', () => {
} }
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) { for (const migration of toApply) {
db.exec(migration.statement, (err) => { db.exec(migration.statement, (err) => {
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(
'INSERT INTO migrations (id, name) VALUES(?, ?)',
[migration.id, migration.name],
(e: Error) => {
if (e !== null) { if (e !== null) {
console.error(`Failed to mark migration ${migration.name} as applied`, e); console.error(`Failed to mark migration ${migration.name} as applied`, e);
return; return;
} }
console.info(`Applied migration ${migration.name}`); console.info(`Applied migration ${migration.name}`);
}); }
);
}); });
} }
}); });
@ -60,7 +64,8 @@ db.on('error', (err) => {
}); });
function getMigrations(): Migration[] { function getMigrations(): Migration[] {
return [{ return [
{
id: 1, id: 1,
name: 'initial', name: 'initial',
statement: ` statement: `
@ -89,14 +94,16 @@ 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)
)` )`
}]; }
];
} }
export async function savePost(post: Post): Promise<undefined> { export async function savePost(post: Post): Promise<undefined> {
return new Promise((resolve, reject) => { return new Promise((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) INSERT INTO accounts (id, acct, username, display_name, url, avatar, avatar_static)
VALUES(?, ?, ?, ?, ?, ?, ?) VALUES(?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) ON CONFLICT(id)
@ -122,20 +129,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(id) DO UPDATE SET
content=excluded.content, content=excluded.content,
created_at=excluded.created_at, created_at=excluded.created_at,
url=excluded.url, url=excluded.url,
account_id=excluded.account_id;`, account_id=excluded.account_id;`,
[ [post.id, post.content, post.created_at, post.url, post.account.id],
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 +147,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(
'INSERT INTO poststags (post_id, tag_url) VALUES (?, ?)',
[post.id, tag.url], [post.id, 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 +184,17 @@ 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) => { const promise = await new Promise<Post[]>((resolve, reject) => {
let filter_query; let filter_query;
let params: any = { $limit: limit }; 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) {
@ -207,10 +213,7 @@ export async function getPosts(since: string | null, before: string | null, limi
${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,
params,
(err, rows: any[]) => {
if (err != null) { if (err != null) {
console.error('Error loading posts', err); console.error('Error loading posts', err);
reject(err); reject(err);
@ -234,16 +237,15 @@ export async function getPosts(since: string | null, before: string | null, limi
reject(tagErr); reject(tagErr);
return; return;
} }
const tagMap: Map<string, Tag[]> = tagRows.reduce( const tagMap: Map<string, Tag[]> = tagRows.reduce((result: Map<string, Tag[]>, item) => {
(result: Map<string, Tag[]>, item) => {
const tag: Tag = { const tag: Tag = {
url: item.url, url: item.url,
name: item.tag name: item.tag
}; };
result.set(item.post_id, [...result.get(item.post_id) || [], tag]); result.set(item.post_id, [...(result.get(item.post_id) || []), tag]);
return result; return result;
}, new Map()); }, new Map());
const posts = rows.map(row => { const posts = rows.map((row) => {
return { return {
id: row.id, id: row.id,
content: row.content, content: row.content,
@ -259,14 +261,12 @@ export async function getPosts(since: string | null, before: string | null, limi
avatar: row.avatar, avatar: row.avatar,
avatar_static: '' avatar_static: ''
} as Account } as Account
} as Post } as Post;
}); });
resolve(posts); resolve(posts);
} }
); );
} });
);
}); });
return promise; return promise;
} }

View File

@ -22,20 +22,22 @@ export function createFeed(posts: Post[]): Feed {
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, name: p.account.acct,
link: p.account.url 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;

View File

@ -1,10 +1,17 @@
import { HASHTAG_FILTER, MASTODON_INSTANCE, URL_FILTER, YOUTUBE_API_KEY } from '$env/static/private'; import {
HASHTAG_FILTER,
MASTODON_INSTANCE,
URL_FILTER,
YOUTUBE_API_KEY
} 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 { 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;
@ -13,7 +20,8 @@ export class TimelineReader {
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 +38,20 @@ 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> {
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 +70,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 +84,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() {

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;
} }
@media only screen and (max-device-width: 620px) {
.footer {
width: calc(100% + 16px);
}
}
</style> </style>

View File

@ -1,45 +1,48 @@
<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;
before?: string;
count?: number;
}
interface FetchOptions { interface EdgeFlyParams extends FlyParams {
since?: string, created_at: string;
before?: string, }
count?: number
}
interface EdgeFlyParams extends FlyParams { const refreshInterval = parseInt(PUBLIC_REFRESH_INTERVAL);
created_at: string let interval: NodeJS.Timer | null = null;
} let moreOlderPostsAvailable = true;
let loadingOlderPosts = false;
const refreshInterval = parseInt(PUBLIC_REFRESH_INTERVAL); // Needed, so that edgeFly() can do its thing:
let interval: NodeJS.Timer | null = null; // To determine whether a newly loaded post is older than the existing ones, is required to know what the oldest
let moreOlderPostsAvailable = true; // post was, before the fetch happened.
let loadingOlderPosts = false; let oldestBeforeLastFetch: number | null = null;
// 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 * Animate either from the top, or the bottom of the window, depending if the post is
* newer than the existing ones or older. * newer than the existing ones or older.
*/ */
function edgeFly(node: Element, opts: EdgeFlyParams) { function edgeFly(node: Element, opts: EdgeFlyParams) {
const createdAt = new Date(opts.created_at).getTime(); const createdAt = new Date(opts.created_at).getTime();
const diffNewest = Math.abs(new Date(data.posts[0].created_at).getTime() - createdAt); const diffNewest = Math.abs(new Date(data.posts[0].created_at).getTime() - createdAt);
const oldest = oldestBeforeLastFetch !== null const oldest =
oldestBeforeLastFetch !== null
? oldestBeforeLastFetch ? oldestBeforeLastFetch
: new Date(data.posts[data.posts.length - 1].created_at).getTime(); : new Date(data.posts[data.posts.length - 1].created_at).getTime();
const diffOldest = Math.abs(oldest - createdAt); const diffOldest = Math.abs(oldest - createdAt);
@ -50,9 +53,9 @@ function edgeFly(node: Element, opts: EdgeFlyParams) {
let offset = isNaN(paramY) ? 0 : paramY + rect.height; let offset = isNaN(paramY) ? 0 : paramY + rect.height;
opts.y = fromTop ? -offset : window.innerHeight + offset; opts.y = fromTop ? -offset : window.innerHeight + offset;
return fly(node, opts); return fly(node, opts);
} }
async function fetchPosts(options: FetchOptions): Promise<Post[]> { async function fetchPosts(options: FetchOptions): Promise<Post[]> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (options?.since !== undefined) { if (options?.since !== undefined) {
params.set('since', options.since); params.set('since', options.since);
@ -66,20 +69,21 @@ async function fetchPosts(options: FetchOptions): Promise<Post[]> {
const response = await fetch(`/api/posts?${params}`); const response = await fetch(`/api/posts?${params}`);
return await response.json(); return await response.json();
} }
function filterDuplicates(posts: Post[]): Post[] { function filterDuplicates(posts: Post[]): Post[] {
return posts.filter((obj, index, arr) => { return posts.filter((obj, index, arr) => {
return arr.map(mapObj => mapObj.url).indexOf(obj.url) === index; return arr.map((mapObj) => mapObj.url).indexOf(obj.url) === index;
}); });
} }
function refresh() { function refresh() {
let filter: FetchOptions = {}; let filter: FetchOptions = {};
if (data.posts.length > 0) { if (data.posts.length > 0) {
filter = { since: data.posts[0].created_at }; filter = { since: data.posts[0].created_at };
} }
fetchPosts(filter).then(resp => { fetchPosts(filter)
.then((resp) => {
if (resp.length > 0) { if (resp.length > 0) {
// Prepend new posts, filter dupes // Prepend new posts, filter dupes
// There shouldn't be any duplicates, but better be safe than sorry // There shouldn't be any duplicates, but better be safe than sorry
@ -89,9 +93,9 @@ function refresh() {
.catch((e: Error) => { .catch((e: Error) => {
errorToast('Error loading newest posts: ' + e.message); errorToast('Error loading newest posts: ' + e.message);
}); });
} }
onMount(async () => { onMount(async () => {
if (data.posts.length > 0) { if (data.posts.length > 0) {
oldestBeforeLastFetch = new Date(data.posts[data.posts.length - 1].created_at).getTime(); oldestBeforeLastFetch = new Date(data.posts[data.posts.length - 1].created_at).getTime();
} }
@ -109,12 +113,12 @@ onMount(async () => {
return () => { return () => {
if (interval !== null) { if (interval !== null) {
clearInterval(interval) clearInterval(interval);
} }
} };
}); });
function loadOlderPosts() { function loadOlderPosts() {
loadingOlderPosts = true; loadingOlderPosts = true;
const filter: FetchOptions = { count: 20 }; const filter: FetchOptions = { count: 20 };
if (data.posts.length > 0) { if (data.posts.length > 0) {
@ -123,8 +127,8 @@ function loadOlderPosts() {
oldestBeforeLastFetch = new Date(before).getTime(); oldestBeforeLastFetch = new Date(before).getTime();
} }
fetchPosts(filter)
fetchPosts(filter).then(resp => { .then((resp) => {
if (resp.length > 0) { if (resp.length > 0) {
// Append old posts, filter dupes // Append old posts, filter dupes
// There shouldn't be any duplicates, but better be safe than sorry // There shouldn't be any duplicates, but better be safe than sorry
@ -136,20 +140,19 @@ function loadOlderPosts() {
} }
loadingOlderPosts = false; loadingOlderPosts = false;
}) })
.catch(e => { .catch((e) => {
loadingOlderPosts = false; loadingOlderPosts = false;
errorToast('Error loading older posts: ' + e.message); 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 aave been found yet
@ -157,7 +160,12 @@ function loadOlderPosts() {
{#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>
@ -165,10 +173,12 @@ function loadOlderPosts() {
<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,10 @@ function loadOlderPosts() {
text-align: center; text-align: center;
z-index: 100; z-index: 100;
} }
@media only screen and (max-device-width: 650px) {
.post {
max-width: 100vw;
}
}
</style> </style>

View File

@ -4,6 +4,6 @@ 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

@ -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 {

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;