Compare commits

..

No commits in common. "6c9546b74ac9a1748ef0992c95afe905c72c9aa1" and "e3c15be31cb7807614eb7ee45f8a6ff9187b7ecd" have entirely different histories.

25 changed files with 444 additions and 494 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,6 +51,7 @@ 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.
@ -77,6 +78,7 @@ 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
@ -91,11 +93,12 @@ 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,3 +1,4 @@
{ {
"masterPicture": "./icon.svg", "masterPicture": "./icon.svg",
"iconsPath": "/static", "iconsPath": "/static",
@ -60,4 +61,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, initial-scale=1" /> <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" />
%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,9 +24,12 @@ 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(f, { headers: [['Content-Type', 'application/atom+xml']] }); return new Response(
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,28 +4,24 @@
</script> </script>
<div class="footer"> <div class="footer">
<div> <div>
<span class="label" <span>Made with &#x1F918; by&nbsp;</span>
>Made<span class="secretIngredient">&nbsp;with &#x1F918;</span>&nbsp;by&nbsp;</span <a href="https://metalhead.club/@aymm" rel="me">@aymm@metalhead.club</a>
> </div>
<a href="https://metalhead.club/@aymm" rel="me" |
>@aymm<span class="mastodonInstance">@metalhead.club</span></a <div>
> <a href="https://phlaym.net/git/phlaym/moshing-mammut">
</div> <img alt="Git branch" src={git} class="icon" />
| Source Code
<div> </a>
<a href="https://phlaym.net/git/phlaym/moshing-mammut"> </div>
<img alt="Git branch" src={git} class="icon" /> |
<span class="label">Source Code</span> <div>
</a> <a href="/feed.xml">
</div> <img alt="RSS" src={rss} class="icon" />
| RSS Feed
<div> </a>
<a href="/feed.xml"> </div>
<img alt="RSS" src={rss} class="icon" />
<span class="label">RSS<span class="feedSuffix">&nbsp;Feed</span></span>
</a>
</div>
</div> </div>
<style> <style>
@ -57,27 +53,4 @@
background-color: var(--color-grey-translucent); background-color: var(--color-grey-translucent);
} }
} }
@media only screen and (max-device-width: 620px) { </style>
.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>

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 = false; export let moreAvailable: boolean = false;
export let isLoading = false; export let isLoading: boolean = 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,10 +1,9 @@
<script lang="ts"> <script lang="ts">
export let size = '64px'; export let size: string = '64px';
export let thickness = '6px'; export let thickness: string = '6px';
</script> </script>
<div class="lds-dual-ring" style="--size: {size}; --thickness: {thickness}" /> <div class="lds-dual-ring" style="--size: {size}; --thickness: {thickness}"></div>
<style> <style>
.lds-dual-ring { .lds-dual-ring {
display: inline-block; display: inline-block;
@ -12,7 +11,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);
@ -35,4 +34,5 @@
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,15 +19,16 @@
// 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>
@ -53,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,28 +1,29 @@
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,7 +11,10 @@ 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(time, ($time) => Math.round(($time.getTime() - date.getTime()) / 1000)); return derived(
time,
$time => Math.round(($time.getTime() - date.getTime()) / 1000)
);
} }
export function relativeTime(seconds: number): string | null { export function relativeTime(seconds: number): string | null {
@ -27,11 +30,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,24 +37,20 @@ 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 (const migration of toApply) { for (let 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( db.run('INSERT INTO migrations (id, name) VALUES(?, ?)', [migration.id, migration.name], (e: Error) => {
'INSERT INTO migrations (id, name) VALUES(?, ?)', if (e !== null) {
[migration.id, migration.name], console.error(`Failed to mark migration ${migration.name} as applied`, e);
(e: Error) => { return;
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}`);
});
}); });
} }
}); });
@ -64,11 +60,10 @@ db.on('error', (err) => {
}); });
function getMigrations(): Migration[] { function getMigrations(): Migration[] {
return [ return [{
{ id: 1,
id: 1, name: 'initial',
name: 'initial', statement: `
statement: `
CREATE TABLE accounts ( CREATE TABLE accounts (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
acct TEXT, acct TEXT,
@ -94,16 +89,14 @@ 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)
@ -129,15 +122,20 @@ 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);
@ -147,28 +145,26 @@ export async function savePost(post: Post): Promise<undefined> {
db.parallelize(() => { db.parallelize(() => {
let remaining = post.tags.length; let remaining = post.tags.length;
for (const tag of post.tags) { for (let 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( db.run('INSERT INTO poststags (post_id, tag_url) VALUES (?, ?)',
'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( console.error(`Could not insert poststags ${tag.url}, ${post.url}`, posttagserr);
`Could not insert poststags ${tag.url}, ${post.url}`,
posttagserr
);
reject(posttagserr); reject(posttagserr);
return; return;
} }
@ -184,17 +180,15 @@ 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) {
const promise = await new Promise<Post[]>((resolve, reject) => { let promise = await new Promise<Post[]>((resolve, reject) => {
let filter_query; let filter_query;
const params: any = { $limit: limit }; let 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) {
@ -213,60 +207,66 @@ 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(sql, params, (err, rows: any[]) => { db.all(
if (err != null) { sql,
console.error('Error loading posts', err); params,
reject(err); (err, rows: any[]) => {
return; if (err != null) {
} console.error('Error loading posts', err);
if (rows.length === 0) { reject(err);
// No need to check for tags return;
resolve([]); }
return; if (rows.length === 0) {
} // No need to check for tags
const postIdsParams = rows.map(() => '?').join(', '); resolve([]);
db.all( return;
`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,26 +22,24 @@ 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;
} }
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,17 +1,10 @@
import { import { HASHTAG_FILTER, MASTODON_INSTANCE, URL_FILTER, YOUTUBE_API_KEY } from '$env/static/private';
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( const YOUTUBE_REGEX = new RegExp(/https?:\/\/(www\.)?youtu((be.com\/.*?v=)|(\.be\/))(?<videoId>[a-zA-Z_0-9-]+)/gm);
/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;
@ -20,8 +13,7 @@ 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();
@ -38,20 +30,15 @@ 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 youtubeCategoryUrl = new URL( const categoryTitle: string = await fetch(youtubeCategoryUrl).then(r => r.json()).then(r => r.items[0]?.title);
`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 (const match of matches) { for (let match of matches) {
if (match === undefined || match.groups === undefined) { if (match === undefined || match.groups === undefined) {
continue; continue;
} }
@ -70,10 +57,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 = () => { socket.onopen = (_event) => {
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') {
@ -84,30 +71,31 @@ 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 ( if (found_urls.length === 0 &&
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() {
@ -120,4 +108,4 @@ export class TimelineReader {
TimelineReader.init(); TimelineReader.init();
return this._instance; return this._instance;
} }
} }

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,7 +7,6 @@
classes: ['toast'] classes: ['toast']
}; };
</script> </script>
<slot /> <slot />
<SvelteToast {options} /> <SvelteToast {options} />
<div class="footer"> <div class="footer">
@ -34,9 +33,4 @@
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
@media only screen and (max-device-width: 620px) { </style>
.footer {
width: calc(100% + 16px);
}
}
</style>

View File

@ -1,184 +1,174 @@
<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 { import { PUBLIC_REFRESH_INTERVAL, PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME } from '$env/static/public';
PUBLIC_REFRESH_INTERVAL, import PostComponent from '$lib/components/PostComponent.svelte';
PUBLIC_MASTODON_INSTANCE_DISPLAY_NAME import LoadMoreComponent from '$lib/components/LoadMoreComponent.svelte';
} from '$env/static/public'; import { fly, type FlyParams } from 'svelte/transition';
import PostComponent from '$lib/components/PostComponent.svelte'; import { cubicInOut } from 'svelte/easing';
import LoadMoreComponent from '$lib/components/LoadMoreComponent.svelte'; import { errorToast } from '$lib/errorToast'
import { fly, type FlyParams } from 'svelte/transition';
import { cubicInOut } from 'svelte/easing';
import { errorToast } from '$lib/errorToast';
export let data: PageData;
interface FetchOptions { export let data: PageData;
since?: string;
before?: string; interface FetchOptions {
count?: number; since?: string,
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));
} }
interface EdgeFlyParams extends FlyParams { const response = await fetch(`/api/posts?${params}`);
created_at: string; 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 => {
const refreshInterval = parseInt(PUBLIC_REFRESH_INTERVAL); if (resp.length > 0) {
let interval: NodeJS.Timer | null = null; // Prepend new posts, filter dupes
let moreOlderPostsAvailable = true; // There shouldn't be any duplicates, but better be safe than sorry
let loadingOlderPosts = false; data.posts = filterDuplicates(resp.concat(data.posts));
// 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));
} }
})
.catch((e: Error) => {
errorToast('Error loading newest posts: ' + e.message);
});
}
const response = await fetch(`/api/posts?${params}`); onMount(async () => {
return await response.json(); if (data.posts.length > 0) {
oldestBeforeLastFetch = new Date(data.posts[data.posts.length - 1].created_at).getTime();
} }
interval = setInterval(refresh, refreshInterval);
function filterDuplicates(posts: Post[]): Post[] { // - If the page is hidden, slow down refresh rate
return posts.filter((obj, index, arr) => { // - If the page is shown, bump up refresh rate
return arr.map((mapObj) => mapObj.url).indexOf(obj.url) === index; document.addEventListener('visibilitychange', () => {
}); const delay = document.hidden ? refreshInterval * 10 : refreshInterval;
} if (interval) {
clearInterval(interval);
function refresh() {
let filter: FetchOptions = {};
if (data.posts.length > 0) {
filter = { since: data.posts[0].created_at };
} }
fetchPosts(filter) interval = setInterval(refresh, delay);
.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);
}
};
}); });
function loadOlderPosts() { return () => {
loadingOlderPosts = true; if (interval !== null) {
const filter: FetchOptions = { count: 20 }; clearInterval(interval)
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> });
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>
<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
{/if} {/if}
{#each data.posts as post (post.url)} {#each data.posts as post (post.url)}
<div <div
class="post" class="post"
transition:edgeFly={{ transition:edgeFly="{{ y: 10, created_at: post.created_at, duration: 300, easing: cubicInOut }}"
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;
@ -201,10 +191,4 @@
text-align: center; text-align: center;
z-index: 100; z-index: 100;
} }
</style>
@media only screen and (max-device-width: 650px) {
.post {
max-width: 100vw;
}
}
</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,6 +16,7 @@ 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);
@ -29,9 +30,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, Ubuntu, font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans,
Cantarell, 'Helvetica Neue', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', Ubuntu, Cantarell, "Helvetica Neue", Helvetica, Arial, sans-serif,
'Segoe UI Symbol'; "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
} }
a { a {
@ -54,4 +55,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;