Improve formatting

This commit is contained in:
Max Nuding 2023-04-11 16:02:54 +02:00
parent 268128c2f4
commit 6c9546b74a
Signed by: phlaym
GPG Key ID: A06651BAB6777237
22 changed files with 280 additions and 279 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",
@ -61,4 +60,4 @@
"htmlCodeFile": false, "htmlCodeFile": false,
"usePathAsIs": false "usePathAsIs": false
} }
} }

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" /> <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);
} }
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

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

@ -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(
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 +64,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,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,66 +213,60 @@ 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, 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.id),
(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,
avatar_static: ''
} as Account
} as Post;
});
resolve(posts);
}
);
});
}); });
return promise; return promise;
} }

View File

@ -22,24 +22,26 @@ 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, {
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' });
} }

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() {
@ -108,4 +120,4 @@ export class TimelineReader {
TimelineReader.init(); TimelineReader.init();
return this._instance; return this._instance;
} }
} }

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;