feature: watches on the backend, need frontend implementation for

notifications and unread indicators
This commit is contained in:
Andy Burke 2025-10-25 14:57:28 -07:00
parent 7046bb0389
commit 6293374bb7
28 changed files with 1405 additions and 608 deletions

View file

@ -10,12 +10,32 @@
}, },
"languages": { "languages": {
"TypeScript": { "TypeScript": {
"language_servers": ["deno", "!typescript-language-server", "!vtsls", "!eslint"], "language_servers": [
"formatter": "language_server" "deno",
"!typescript-language-server",
"!vtsls",
"!eslint",
"..."
]
}, },
"TSX": { "TSX": {
"language_servers": ["deno", "!typescript-language-server", "!vtsls", "!eslint"], "language_servers": [
"deno",
"!typescript-language-server",
"!vtsls",
"!eslint",
"..."
]
},
"JavaScript": {
"language_servers": [
"deno",
"!typescript-language-server",
"!vtsls",
"!eslint",
"..."
]
}
},
"formatter": "language_server" "formatter": "language_server"
} }
}
}

View file

@ -32,7 +32,7 @@
} }
}, },
"imports": { "imports": {
"@andyburke/fsdb": "jsr:@andyburke/fsdb@^1.0.4", "@andyburke/fsdb": "jsr:@andyburke/fsdb@^1.1.0",
"@andyburke/lurid": "jsr:@andyburke/lurid@^0.2.0", "@andyburke/lurid": "jsr:@andyburke/lurid@^0.2.0",
"@andyburke/serverus": "jsr:@andyburke/serverus@^0.13.0", "@andyburke/serverus": "jsr:@andyburke/serverus@^0.13.0",
"@da/bcrypt": "jsr:@da/bcrypt@^1.0.1", "@da/bcrypt": "jsr:@da/bcrypt@^1.0.1",
@ -40,6 +40,7 @@
"@std/encoding": "jsr:@std/encoding@^1.0.10", "@std/encoding": "jsr:@std/encoding@^1.0.10",
"@std/fs": "jsr:@std/fs@^1.0.19", "@std/fs": "jsr:@std/fs@^1.0.19",
"@std/http": "jsr:@std/http@^1.0.21", "@std/http": "jsr:@std/http@^1.0.21",
"@std/media-types": "jsr:@std/media-types@^1.1.0",
"@std/path": "jsr:@std/path@^1.1.2" "@std/path": "jsr:@std/path@^1.1.2"
} }
} }

9
deno.lock generated
View file

@ -1,7 +1,7 @@
{ {
"version": "5", "version": "5",
"specifiers": { "specifiers": {
"jsr:@andyburke/fsdb@^1.0.4": "1.0.4", "jsr:@andyburke/fsdb@^1.1.0": "1.1.0",
"jsr:@andyburke/lurid@0.2": "0.2.0", "jsr:@andyburke/lurid@0.2": "0.2.0",
"jsr:@andyburke/serverus@0.13": "0.13.0", "jsr:@andyburke/serverus@0.13": "0.13.0",
"jsr:@da/bcrypt@*": "1.0.1", "jsr:@da/bcrypt@*": "1.0.1",
@ -31,8 +31,8 @@
"npm:@types/node@*": "22.15.15" "npm:@types/node@*": "22.15.15"
}, },
"jsr": { "jsr": {
"@andyburke/fsdb@1.0.4": { "@andyburke/fsdb@1.1.0": {
"integrity": "ce4bf858e6af25bf257726d08b2901c7409f82aa409f435795d5381caffffad4", "integrity": "ad2d062672137ca96df19df032b51f1c7aa3133c973a0b86eb8eaab3b4c2d47b",
"dependencies": [ "dependencies": [
"jsr:@std/cli@^1.0.20", "jsr:@std/cli@^1.0.20",
"jsr:@std/fs@^1.0.18", "jsr:@std/fs@^1.0.18",
@ -133,7 +133,7 @@
}, },
"workspace": { "workspace": {
"dependencies": [ "dependencies": [
"jsr:@andyburke/fsdb@^1.0.4", "jsr:@andyburke/fsdb@^1.1.0",
"jsr:@andyburke/lurid@0.2", "jsr:@andyburke/lurid@0.2",
"jsr:@andyburke/serverus@0.13", "jsr:@andyburke/serverus@0.13",
"jsr:@da/bcrypt@^1.0.1", "jsr:@da/bcrypt@^1.0.1",
@ -141,6 +141,7 @@
"jsr:@std/encoding@^1.0.10", "jsr:@std/encoding@^1.0.10",
"jsr:@std/fs@^1.0.19", "jsr:@std/fs@^1.0.19",
"jsr:@std/http@^1.0.21", "jsr:@std/http@^1.0.21",
"jsr:@std/media-types@^1.1.0",
"jsr:@std/path@^1.1.2" "jsr:@std/path@^1.1.2"
] ]
} }

66
models/watch.ts Normal file
View file

@ -0,0 +1,66 @@
import { FSDB_COLLECTION } from '@andyburke/fsdb';
import { by_lurid } from '@andyburke/fsdb/organizers';
import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers';
/**
* @typedef {object} WATCH_TYPE_INFO
* @property {boolean} ignored if true, this type should NOT produce any indications or notifications
* @property {string} last_id_seen the last event id the user has seen for this event type
* @property {string} last_id_notified the last event id the user was notified about for this type
*/
export type WATCH_TYPE_INFO = {
ignored: boolean;
last_id_seen: string;
last_id_notified: string;
};
/**
* @typedef {object} WATCH_TIMESTAMPS
* @property {string} created the created date of the watch
* @property {string} updated the last updated date, usually coinciding with the last seen id being changed
*/
/**
* WATCH
*
* @property {string} id - lurid (stable)
* @property {string} creator_id - user id of the watch creator
* @property {string} topic_id - the topic_id being watched
* @property {[WATCH_TYPE_INFO]} types - information for types being watched within this topic
* @property {Record<string,any>} [meta] - optional metadata about the watch
* @property {WATCH_TIMESTAMPS} timestamps - timestamps for the watch
*/
export type WATCH = {
id: string;
creator_id: string;
topic_id: string;
types: [WATCH_TYPE_INFO];
meta?: Record<string, any>;
timestamps: {
created: string;
updated: string;
};
};
export const WATCHES = new FSDB_COLLECTION<WATCH>({
name: 'watches',
id_field: 'id',
organize: by_lurid,
indexers: {
creator_id: new FSDB_INDEXER_SYMLINKS<WATCH>({
name: 'creator_id',
field: 'creator_id',
to_many: true,
organize: by_lurid
}),
topic_id: new FSDB_INDEXER_SYMLINKS<WATCH>({
name: 'topic_id',
field: 'topic_id',
to_many: true,
organize: by_lurid
})
}
});

View file

@ -5,7 +5,7 @@ import * as CANNED_RESPONSES from '../../../../../utils/canned_responses.ts';
import { EVENT, get_events_collection_for_topic, VALIDATE_EVENT } from '../../../../../models/event.ts'; import { EVENT, get_events_collection_for_topic, VALIDATE_EVENT } from '../../../../../models/event.ts';
import parse_body from '../../../../../utils/bodyparser.ts'; import parse_body from '../../../../../utils/bodyparser.ts';
import { FSDB_COLLECTION, FSDB_SEARCH_OPTIONS, WALK_ENTRY } from '@andyburke/fsdb'; import { FSDB_COLLECTION, FSDB_SEARCH_OPTIONS, WALK_ENTRY } from '@andyburke/fsdb';
import * as path from '@std/path'; import { WATCH, WATCHES } from '../../../../../models/watch.ts';
export const PRECHECKS: PRECHECK_TABLE = {}; export const PRECHECKS: PRECHECK_TABLE = {};
@ -58,11 +58,9 @@ export async function GET(request: Request, meta: Record<string, any>): Promise<
sort, sort,
filter: (entry: WALK_ENTRY<EVENT>) => { filter: (entry: WALK_ENTRY<EVENT>) => {
const { const {
groups: {
event_type, event_type,
event_id event_id
} } = /^.*\/events\/(?<event_type>.*?)\/.*\/(?<event_id>[A-Za-z-]+)\.json$/.exec(entry.path)?.groups ?? {};
} = /^.*\/events\/(?<event_type>.*?)\/.*\/(?<event_id>[A-Za-z-]+)\.json$/.exec(entry.path) ?? { groups: {} };
if (meta.query.after_id && event_id <= meta.query.after_id) { if (meta.query.after_id && event_id <= meta.query.after_id) {
return false; return false;
@ -136,6 +134,26 @@ export async function GET(request: Request, meta: Record<string, any>): Promise<
}); });
} }
async function update_watches(topic: TOPIC, event: EVENT) {
const limit = 100;
let more_to_process;
let offset = 0;
do {
const watches: WATCH[] = (await WATCHES.find({
topic_id: topic.id
}, {
limit,
offset
})).map((entry) => entry.load());
// TODO: look at the watch .types[] and send notifications
offset += watches.length;
more_to_process = watches.length === limit;
} while (more_to_process);
}
// POST /api/topics/:topic_id/events - Create an event // POST /api/topics/:topic_id/events - Create an event
PRECHECKS.POST = [get_session, get_user, require_user, async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => { PRECHECKS.POST = [get_session, get_user, require_user, async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? ''; const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? '';

View file

@ -0,0 +1,88 @@
import * as CANNED_RESPONSES from '../../../../../../utils/canned_responses.ts';
import { WATCH, WATCHES } from '../../../../../../models/watch.ts';
import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../../utils/prechecks.ts';
import parse_body from '../../../../../../utils/bodyparser.ts';
export const PRECHECKS: PRECHECK_TABLE = {};
// PUT /api/users/:user_id/watches/:watch_id - Update topic
PRECHECKS.PUT = [get_session, get_user, require_user, async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
const watch_id: string = meta.params?.watch_id?.toLowerCase().trim() ?? '';
// lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same"
const watch: WATCH | null = watch_id.length === 49 ? await WATCHES.get(watch_id) : null;
if (!watch) {
return CANNED_RESPONSES.not_found();
}
meta.watch = watch;
const user_owns_watch = watch.creator_id === meta.user.id;
if (!user_owns_watch) {
return CANNED_RESPONSES.permission_denied();
}
}];
export async function PUT(req: Request, meta: Record<string, any>): Promise<Response> {
const now = new Date().toISOString();
try {
const body = await parse_body(req);
const updated = {
...meta.watch,
...body,
id: meta.watch.id,
timestamps: {
created: meta.watch.timestamps.created,
updated: now
}
};
await WATCHES.update(updated);
return Response.json(updated, {
status: 200
});
} catch (err) {
return Response.json({
error: {
message: (err as Error)?.message ?? 'Unknown error due to invalid data.',
cause: (err as Error)?.cause ?? 'invalid_data'
}
}, {
status: 400
});
}
}
// DELETE /api/users/:user_id/watches/:watch_id - Delete watch
PRECHECKS.DELETE = [
get_session,
get_user,
require_user,
async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
const watch_id: string = meta.params?.watch_id?.toLowerCase().trim() ?? '';
// lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same"
const watch: WATCH | null = watch_id.length === 49 ? await WATCHES.get(watch_id) : null;
if (!watch) {
return CANNED_RESPONSES.not_found();
}
meta.topic = watch;
const user_owns_watch = watch.creator_id === meta.user.id;
if (!user_owns_watch) {
return CANNED_RESPONSES.permission_denied();
}
}
];
export async function DELETE(_req: Request, meta: Record<string, any>): Promise<Response> {
await WATCHES.delete(meta.watch);
return Response.json({
deleted: true
}, {
status: 200
});
}

View file

@ -0,0 +1,145 @@
import { FSDB_SEARCH_OPTIONS, WALK_ENTRY } from '@andyburke/fsdb';
import { WATCH, WATCHES } from '../../../../../models/watch.ts';
import * as CANNED_RESPONSES from '../../../../../utils/canned_responses.ts';
import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../utils/prechecks.ts';
import parse_body from '../../../../../utils/bodyparser.ts';
import lurid from '@andyburke/lurid';
import { TOPICS } from '../../../../../models/topic.ts';
export const PRECHECKS: PRECHECK_TABLE = {};
// GET /api/users/:user_id/watches - get watches this user has created
// query parameters:
// partial_id: the partial id subset you would like to match (remember, lurids are lexigraphically sorted)
PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Record<string, any>): Response | undefined => {
const user_has_read_own_watches_permission = meta.user.permissions.includes('watches.read.own');
const user_has_read_all_watches_permission = meta.user.permissions.includes('watches.read.all');
if (!(user_has_read_all_watches_permission || (user_has_read_own_watches_permission && meta.user.id === meta.params.user_id))) {
return CANNED_RESPONSES.permission_denied();
}
}];
export async function GET(_request: Request, meta: Record<string, any>): Promise<Response> {
const sorts = WATCHES.sorts;
const sort_name: string = meta.query.sort ?? 'newest';
const key = sort_name as keyof typeof sorts;
const sort: any = sorts[key];
if (!sort) {
return Response.json({
error: {
message: 'You must specify a sort: newest, oldest, latest, stalest',
cause: 'invalid_sort'
}
}, {
status: 400
});
}
const options: FSDB_SEARCH_OPTIONS<WATCH> = {
...(meta.query ?? {}),
limit: Math.min(parseInt(meta.query?.limit ?? '100', 10), 1_000),
sort,
filter: (entry: WALK_ENTRY<WATCH>) => {
const {
event_type,
event_id
} = /^.*\/watches\/(?<event_type>.*?)\/.*\/(?<event_id>[A-Za-z-]+)\.json$/.exec(entry.path)?.groups ?? {};
if (meta.query.after_id && event_id <= meta.query.after_id) {
return false;
}
if (meta.query.before_id && event_id >= meta.query.before_id) {
return false;
}
if (meta.query.type && !meta.query.type.split(',').includes(event_type)) {
return false;
}
return true;
}
};
const headers = {
'Cache-Control': 'no-cache, must-revalidate'
};
const results = (await WATCHES.all(options))
.map((entry: WALK_ENTRY<WATCH>) => entry.load())
.sort((lhs_item: WATCH, rhs_item: WATCH) => rhs_item.timestamps.created.localeCompare(lhs_item.timestamps.created));
return Response.json(results, {
status: 200,
headers
});
}
// POST /api/users/:user_id/watches - Create a watch
PRECHECKS.POST = [get_session, get_user, require_user, (_request: Request, meta: Record<string, any>): Response | undefined => {
const user_has_create_own_watches_permission = meta.user.permissions.includes('watches.create.own');
const user_has_create_all_watches_permission = meta.user.permissions.includes('watches.create.all');
if (!(user_has_create_all_watches_permission || (user_has_create_own_watches_permission && meta.user.id === meta.params.user_id))) {
return CANNED_RESPONSES.permission_denied();
}
}];
export async function POST(req: Request, meta: Record<string, any>): Promise<Response> {
try {
const now = new Date().toISOString();
const body = await parse_body(req);
const watch: WATCH = {
...body,
id: lurid(),
creator_id: meta.user.id,
timestamps: {
created: now,
updated: now
}
};
const topic = await TOPICS.get(watch.topic_id);
if (!topic) {
return Response.json({
errors: [{
cause: 'invalid_topic_id',
message: 'Could not find a topic with id: ' + watch.topic_id
}]
}, {
status: 400
});
}
const existing_watch: WATCH | undefined = (await WATCHES.find({
creator_id: meta.user.id,
topic_id: topic.id
}, {
limit: 1
})).shift()?.load();
if (existing_watch) {
return Response.json({
errors: [{
cause: 'existing_watch',
message: 'You already have a watch for this topic.'
}]
}, {
status: 400
});
}
await WATCHES.create(watch);
return Response.json(watch, {
status: 201
});
} catch (error) {
return Response.json({
error: {
message: (error as Error).message ?? 'Unknown Error!',
cause: (error as Error).cause ?? 'unknown'
}
}, { status: 500 });
}
}

View file

@ -30,7 +30,10 @@ const DEFAULT_USER_PERMISSIONS: string[] = [
'topics.posts.create', 'topics.posts.create',
'topics.posts.write', 'topics.posts.write',
'topics.posts.read', 'topics.posts.read',
'users.read' 'users.read',
'watches.create.own',
'watches.read.own',
'watches.write.own'
]; ];
export const PRECHECKS: PRECHECK_TABLE = {}; export const PRECHECKS: PRECHECK_TABLE = {};

View file

@ -6,6 +6,8 @@
--bg-darker: hsl(from var(--base-color) h 20% 5%); --bg-darker: hsl(from var(--base-color) h 20% 5%);
--bg-lighter: hsl(from var(--base-color) h 20% 10%); --bg-lighter: hsl(from var(--base-color) h 20% 10%);
--blur-radius: 8px;
--text: hsl(from var(--base-color) h 5% 100%); --text: hsl(from var(--base-color) h 5% 100%);
--accent: hsl(from var(--base-color) h clamp(0, calc(s + 10), 100) clamp(0, calc(l + 20), 100)); --accent: hsl(from var(--base-color) h clamp(0, calc(s + 10), 100) clamp(0, calc(l + 20), 100));
@ -163,7 +165,22 @@ body {
background-color: var(--bg); background-color: var(--bg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; /* fixed height? */ height: 100vh;
/* fixed height? */
}
main {
position: relative;
width: 100%;
height: 100%;
display: grid;
grid-template-columns: auto 1fr;
}
@media screen and (max-width: 1200px) {
main {
grid-template-columns: auto;
}
} }
input[type="text"]:focus, input[type="text"]:focus,
@ -240,13 +257,11 @@ textarea:focus {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: repeating-linear-gradient( background: repeating-linear-gradient(-55deg,
-55deg,
rgba(0, 0, 0, 0.25) 0px, rgba(0, 0, 0, 0.25) 0px,
rgba(0, 0, 0, 0.25) 20px, rgba(0, 0, 0, 0.25) 20px,
rgba(255, 177, 1, 0.25) 20px, rgba(255, 177, 1, 0.25) 20px,
rgba(255, 177, 1, 0.25) 40px rgba(255, 177, 1, 0.25) 40px);
);
} }
.collapsed { .collapsed {
@ -444,11 +459,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
max-width: 800px; max-width: 800px;
} }
.audio-container .audio-container .audio-controls-container .progress-container .slider-container input[name="progress"] {
.audio-controls-container
.progress-container
.slider-container
input[name="progress"] {
width: 100%; width: 100%;
} }
@ -469,6 +480,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
max-width: 100px; max-width: 100px;
overflow: hidden; overflow: hidden;
} }
@media screen and (max-width: 480px) { @media screen and (max-width: 480px) {
.audio-container .audio-controls-container .blank { .audio-container .audio-controls-container .blank {
width: auto; width: auto;
@ -480,13 +492,18 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
} }
.audio-container .audio-controls-container input[type="range"] { .audio-container .audio-controls-container input[type="range"] {
--c: var(--accent); /* active color */ --c: var(--accent);
--g: 4px; /* the gap */ /* active color */
--l: 2px; /* line thickness*/ --g: 4px;
--s: 15px; /* thumb size*/ /* the gap */
--l: 2px;
/* line thickness*/
--s: 15px;
/* thumb size*/
width: 100%; width: 100%;
height: var(--s); /* needed for Firefox*/ height: var(--s);
/* needed for Firefox*/
--_c: color-mix(in srgb, var(--c), #000 var(--p, 0%)); --_c: color-mix(in srgb, var(--c), #000 var(--p, 0%));
-webkit-appearance: none; -webkit-appearance: none;
-moz-appearance: none; -moz-appearance: none;
@ -495,26 +512,29 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
} }
.audio-container .audio-controls-container input[type="range"]:focus-visible, .audio-container .audio-controls-container input[type="range"]:focus-visible,
.audio-container .audio-controls-container input[type="range"]:hover { .audio-container .audio-controls-container input[type="range"]:hover {
--p: 25%; --p: 25%;
} }
.audio-container .audio-controls-container input[type="range"]:active, .audio-container .audio-controls-container input[type="range"]:active,
.audio-container .audio-controls-container input[type="range"]:focus-visible { .audio-container .audio-controls-container input[type="range"]:focus-visible {
--_b: var(--s); --_b: var(--s);
} }
/* chromium */ /* chromium */
.audio-container .audio-controls-container input[type="range"]::-webkit-slider-thumb { .audio-container .audio-controls-container input[type="range"]::-webkit-slider-thumb {
height: var(--s); height: var(--s);
aspect-ratio: 1; aspect-ratio: 1;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 0 var(--_b, var(--l)) inset var(--_c); box-shadow: 0 0 0 var(--_b, var(--l)) inset var(--_c);
border-image: linear-gradient(90deg, var(--_c) 50%, #ababab 0) 0 1 / calc(50% - var(--l) / 2) border-image: linear-gradient(90deg, var(--_c) 50%, #ababab 0) 0 1 / calc(50% - var(--l) / 2) 100vw/0 calc(100vw + var(--g));
100vw/0 calc(100vw + var(--g));
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
transition: 0.2s; transition: 0.2s;
} }
/* Firefox */ /* Firefox */
.audio-container .audio-controls-container input[type="range"]::-moz-range-thumb { .audio-container .audio-controls-container input[type="range"]::-moz-range-thumb {
height: var(--s); height: var(--s);
@ -522,12 +542,12 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
background: none; background: none;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 0 var(--_b, var(--l)) inset var(--_c); box-shadow: 0 0 0 var(--_b, var(--l)) inset var(--_c);
border-image: linear-gradient(90deg, var(--_c) 50%, #ababab 0) 0 1 / calc(50% - var(--l) / 2) border-image: linear-gradient(90deg, var(--_c) 50%, #ababab 0) 0 1 / calc(50% - var(--l) / 2) 100vw/0 calc(100vw + var(--g));
100vw/0 calc(100vw + var(--g));
-moz-appearance: none; -moz-appearance: none;
appearance: none; appearance: none;
transition: 0.2s; transition: 0.2s;
} }
@supports not (color: color-mix(in srgb, red, red)) { @supports not (color: color-mix(in srgb, red, red)) {
.audio-container .audio-controls-container input[type="range"] { .audio-container .audio-controls-container input[type="range"] {
--_c: var(--c); --_c: var(--c);
@ -547,6 +567,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
opacity: 1; opacity: 1;
display: block; display: block;
} }
.audio-container .audio-controls-container .audio-control .icon.pause { .audio-container .audio-controls-container .audio-control .icon.pause {
opacity: 0; opacity: 0;
display: none; display: none;
@ -556,11 +577,24 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
opacity: 0; opacity: 0;
display: none; display: none;
} }
.audio-container[data-playing] .audio-controls-container .audio-control .icon.pause { .audio-container[data-playing] .audio-controls-container .audio-control .icon.pause {
opacity: 1; opacity: 1;
display: block; display: block;
} }
/* === GLOW EFECTO === from: https://codepen.io/andrewuru/pen/Byjdgrb */
.glow {
position: absolute;
inset: 0;
background: radial-gradient(circle at var(--x, 50%) var(--y, 50%),
hsla(var(--accent), 100%, 60%, 0.4),
transparent 70%);
mix-blend-mode: screen;
pointer-events: none;
filter: blur(calc(2 * var(--blur-radius)));
}
.html-from-markdown { .html-from-markdown {
padding: 2em; padding: 2em;
} }
@ -597,6 +631,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
transform: scale(var(--icon-scale, 1)); transform: scale(var(--icon-scale, 1));
border-radius: 4px; border-radius: 4px;
} }
.icon.add::after, .icon.add::after,
.icon.add::before { .icon.add::before {
content: ""; content: "";
@ -610,6 +645,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: 8px; top: 8px;
left: 4px; left: 4px;
} }
.icon.add::after { .icon.add::after {
width: 2px; width: 2px;
height: 10px; height: 10px;
@ -631,6 +667,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
transform: scale(var(--icon-scale, 1)); transform: scale(var(--icon-scale, 1));
margin-top: 11px; margin-top: 11px;
} }
.icon.attachment::after, .icon.attachment::after,
.icon.attachment::before { .icon.attachment::before {
content: ""; content: "";
@ -640,6 +677,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border-radius: 3px; border-radius: 3px;
border: 2px solid; border: 2px solid;
} }
.icon.attachment::after { .icon.attachment::after {
border-bottom: 0; border-bottom: 0;
border-top-left-radius: 100px; border-top-left-radius: 100px;
@ -649,6 +687,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 14px; height: 14px;
bottom: 8px; bottom: 8px;
} }
.icon.attachment::before { .icon.attachment::before {
width: 6px; width: 6px;
height: 12px; height: 12px;
@ -670,6 +709,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
width: 22px; width: 22px;
height: 16px; height: 16px;
} }
.icon.blurb::after, .icon.blurb::after,
.icon.blurb::before { .icon.blurb::before {
content: ""; content: "";
@ -681,11 +721,13 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
background: currentColor; background: currentColor;
bottom: 2px; bottom: 2px;
} }
.icon.blurb::before { .icon.blurb::before {
width: 10px; width: 10px;
left: 2px; left: 2px;
box-shadow: 4px -4px 0; box-shadow: 4px -4px 0;
} }
.icon.blurb::after { .icon.blurb::after {
width: 3px; width: 3px;
right: 2px; right: 2px;
@ -698,6 +740,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
display: block; display: block;
box-sizing: border-box; box-sizing: border-box;
} }
.icon.calendar { .icon.calendar {
position: relative; position: relative;
transform: scale(var(--icon-scale, 1)); transform: scale(var(--icon-scale, 1));
@ -731,6 +774,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 12px; height: 12px;
perspective: 24px; perspective: 24px;
} }
.icon.camera::after, .icon.camera::after,
.icon.camera::before { .icon.camera::before {
content: ""; content: "";
@ -738,6 +782,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
box-sizing: border-box; box-sizing: border-box;
position: absolute; position: absolute;
} }
.icon.camera::before { .icon.camera::before {
border: 2px solid; border: 2px solid;
border-left-color: transparent; border-left-color: transparent;
@ -747,6 +792,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
right: -7px; right: -7px;
top: 0; top: 0;
} }
.icon.camera::after { .icon.camera::after {
width: 10px; width: 10px;
height: 5px; height: 5px;
@ -766,6 +812,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
width: 14px; width: 14px;
height: 10px; height: 10px;
} }
.icon.chat::after, .icon.chat::after,
.icon.chat::before { .icon.chat::before {
content: ""; content: "";
@ -776,11 +823,13 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 2px; height: 2px;
background: currentColor; background: currentColor;
} }
.icon.chat::before { .icon.chat::before {
width: 10px; width: 10px;
opacity: 0.5; opacity: 0.5;
box-shadow: 0 4px 0; box-shadow: 0 4px 0;
} }
.icon.chat::after { .icon.chat::after {
width: 14px; width: 14px;
bottom: 0; bottom: 0;
@ -809,6 +858,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid; border: 2px solid;
border-radius: var(--border-radius); border-radius: var(--border-radius);
} }
.icon.close::after, .icon.close::after,
.icon.close::before { .icon.close::before {
content: ""; content: "";
@ -823,6 +873,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: 8px; top: 8px;
left: 3px; left: 3px;
} }
.icon.close::after { .icon.close::after {
transform: rotate(-45deg); transform: rotate(-45deg);
} }
@ -838,6 +889,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid; border: 2px solid;
border-radius: 100px; border-radius: 100px;
} }
.icon.controller::before { .icon.controller::before {
content: ""; content: "";
display: block; display: block;
@ -869,6 +921,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border-bottom-right-radius: 2px; border-bottom-right-radius: 2px;
margin-top: 8px; margin-top: 8px;
} }
.icon.download::after { .icon.download::after {
content: ""; content: "";
display: block; display: block;
@ -882,6 +935,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
left: 2px; left: 2px;
bottom: 4px; bottom: 4px;
} }
.icon.download::before { .icon.download::before {
content: ""; content: "";
display: block; display: block;
@ -907,6 +961,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border-radius: 3px; border-radius: 3px;
box-shadow: 0 -1px 0; box-shadow: 0 -1px 0;
} }
.icon.essay::after, .icon.essay::after,
.icon.essay::before { .icon.essay::before {
content: ""; content: "";
@ -916,6 +971,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
width: 6px; width: 6px;
top: 2px; top: 2px;
} }
.icon.essay::before { .icon.essay::before {
background: currentColor; background: currentColor;
left: 2px; left: 2px;
@ -925,6 +981,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border-radius: 3px; border-radius: 3px;
height: 2px; height: 2px;
} }
.icon.essay::after { .icon.essay::after {
height: 10px; height: 10px;
border: 2px solid; border: 2px solid;
@ -941,6 +998,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
width: 8px; width: 8px;
height: 8px; height: 8px;
} }
.icon.exchange { .icon.exchange {
position: relative; position: relative;
transform: scale(var(--icon-scale, 1)); transform: scale(var(--icon-scale, 1));
@ -948,16 +1006,19 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
-3px 3px 0 -1px, -3px 3px 0 -1px,
3px -3px 0 -1px; 3px -3px 0 -1px;
} }
.icon.exchange::after, .icon.exchange::after,
.icon.exchange::before { .icon.exchange::before {
content: ""; content: "";
position: absolute; position: absolute;
border: 2px solid; border: 2px solid;
} }
.icon.exchange::before { .icon.exchange::before {
top: -5px; top: -5px;
left: -5px; left: -5px;
} }
.icon.exchange::after { .icon.exchange::after {
bottom: -5px; bottom: -5px;
right: -5px; right: -5px;
@ -973,6 +1034,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 14px; height: 14px;
border-bottom: 2px solid; border-bottom: 2px solid;
} }
.icon.forum::after, .icon.forum::after,
.icon.forum::before { .icon.forum::before {
content: ""; content: "";
@ -981,6 +1043,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
position: absolute; position: absolute;
top: 2px; top: 2px;
} }
.icon.forum::before { .icon.forum::before {
border-left: 4px solid; border-left: 4px solid;
left: 1px; left: 1px;
@ -989,6 +1052,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border-top: 3px solid transparent; border-top: 3px solid transparent;
border-bottom: 3px solid transparent; border-bottom: 3px solid transparent;
} }
.icon.forum::after { .icon.forum::after {
width: 8px; width: 8px;
height: 6px; height: 6px;
@ -1007,6 +1071,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 16px; height: 16px;
box-shadow: 6px -6px 0 -4px; box-shadow: 6px -6px 0 -4px;
} }
.icon.forward-copy::before { .icon.forward-copy::before {
content: ""; content: "";
display: block; display: block;
@ -1019,6 +1084,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
left: 0; left: 0;
bottom: 0; bottom: 0;
} }
.icon.forward-copy::after { .icon.forward-copy::after {
content: ""; content: "";
display: block; display: block;
@ -1053,6 +1119,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
margin-bottom: -2px; margin-bottom: -2px;
} }
.icon.home::after, .icon.home::after,
.icon.home::before { .icon.home::before {
content: ""; content: "";
@ -1060,6 +1127,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
box-sizing: border-box; box-sizing: border-box;
position: absolute; position: absolute;
} }
.icon.home::before { .icon.home::before {
border-top: 2px solid; border-top: 2px solid;
border-left: 2px solid; border-left: 2px solid;
@ -1071,6 +1139,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 14px; height: 14px;
left: 0; left: 0;
} }
.icon.home::after { .icon.home::after {
width: 8px; width: 8px;
height: 10px; height: 10px;
@ -1087,6 +1156,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
.icon.live { .icon.live {
transform: scale(var(--icon-scale, 1)); transform: scale(var(--icon-scale, 1));
} }
.icon.live, .icon.live,
.icon.live::after, .icon.live::after,
.icon.live::before { .icon.live::before {
@ -1099,6 +1169,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border-bottom-color: transparent; border-bottom-color: transparent;
border-radius: 50%; border-radius: 50%;
} }
.icon.live::after, .icon.live::after,
.icon.live::before { .icon.live::before {
content: ""; content: "";
@ -1108,6 +1179,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: 2px; top: 2px;
left: 2px; left: 2px;
} }
.icon.live::after { .icon.live::after {
width: 22px; width: 22px;
height: 22px; height: 22px;
@ -1138,6 +1210,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid; border: 2px solid;
border-radius: 3px; border-radius: 3px;
} }
.icon.more::before { .icon.more::before {
content: ""; content: "";
position: absolute; position: absolute;
@ -1158,6 +1231,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
.icon.more-borderless { .icon.more-borderless {
transform: scale(var(--icon-scale, 1)); transform: scale(var(--icon-scale, 1));
} }
.icon.more-borderless, .icon.more-borderless,
.icon.more-borderless::after, .icon.more-borderless::after,
.icon.more-borderless::before { .icon.more-borderless::before {
@ -1169,15 +1243,18 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
background: currentColor; background: currentColor;
border-radius: 100%; border-radius: 100%;
} }
.icon.more-borderless::after, .icon.more-borderless::after,
.icon.more-borderless::before { .icon.more-borderless::before {
content: ""; content: "";
position: absolute; position: absolute;
top: 0; top: 0;
} }
.icon.more-borderless::after { .icon.more-borderless::after {
left: -6px; left: -6px;
} }
.icon.more-borderless::before { .icon.more-borderless::before {
right: -6px; right: -6px;
} }
@ -1193,6 +1270,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid; border: 2px solid;
border-radius: 24px; border-radius: 24px;
} }
.icon.more-circle::before { .icon.more-circle::before {
content: ""; content: "";
position: absolute; position: absolute;
@ -1218,6 +1296,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 22px; height: 22px;
transform: scale(var(--icon-scale, 1)); transform: scale(var(--icon-scale, 1));
} }
.icon.phone::after, .icon.phone::after,
.icon.phone::before { .icon.phone::before {
content: ""; content: "";
@ -1225,6 +1304,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
box-sizing: border-box; box-sizing: border-box;
position: absolute; position: absolute;
} }
.icon.phone::after { .icon.phone::after {
width: 18px; width: 18px;
height: 18px; height: 18px;
@ -1239,6 +1319,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
linear-gradient(to left, currentColor 10px, transparent 0) no-repeat right 11px/6px 4px, linear-gradient(to left, currentColor 10px, transparent 0) no-repeat right 11px/6px 4px,
linear-gradient(to left, currentColor 10px, transparent 0) no-repeat -1px 0/4px 6px; linear-gradient(to left, currentColor 10px, transparent 0) no-repeat -1px 0/4px 6px;
} }
.icon.phone::before { .icon.phone::before {
width: 20px; width: 20px;
height: 20px; height: 20px;
@ -1259,17 +1340,20 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
display: block; display: block;
box-sizing: border-box; box-sizing: border-box;
} }
.icon.plus::after, .icon.plus::after,
.icon.plus::before { .icon.plus::before {
border-radius: 10px; border-radius: 10px;
background: currentColor; background: currentColor;
} }
.icon.plus { .icon.plus {
position: relative; position: relative;
transform: scale(var(--icon-scale, 1)); transform: scale(var(--icon-scale, 1));
width: 16px; width: 16px;
height: 16px; height: 16px;
} }
.icon.plus::after { .icon.plus::after {
content: ""; content: "";
position: absolute; position: absolute;
@ -1278,6 +1362,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: 0; top: 0;
left: 7px; left: 7px;
} }
.icon.plus::before { .icon.plus::before {
content: ""; content: "";
position: absolute; position: absolute;
@ -1297,6 +1382,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 16px; height: 16px;
box-shadow: -6px -6px 0 -4px; box-shadow: -6px -6px 0 -4px;
} }
.icon.reply::before { .icon.reply::before {
content: ""; content: "";
display: block; display: block;
@ -1309,6 +1395,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
right: 0; right: 0;
bottom: 0; bottom: 0;
} }
.icon.reply::after { .icon.reply::after {
content: ""; content: "";
display: block; display: block;
@ -1330,6 +1417,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
box-sizing: border-box; box-sizing: border-box;
border-radius: 22px; border-radius: 22px;
} }
.icon.resources { .icon.resources {
position: relative; position: relative;
transform: scale(var(--icon-scale, 1)); transform: scale(var(--icon-scale, 1));
@ -1337,6 +1425,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 20px; height: 20px;
border: 2px solid transparent; border: 2px solid transparent;
} }
.icon.resources::after { .icon.resources::after {
content: ""; content: "";
position: absolute; position: absolute;
@ -1363,6 +1452,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid; border: 2px solid;
border-radius: 4px; border-radius: 4px;
} }
.icon.send::after, .icon.send::after,
.icon.send::before { .icon.send::before {
content: ""; content: "";
@ -1375,6 +1465,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: 5px; top: 5px;
right: 5px; right: 5px;
} }
.icon.send::after { .icon.send::after {
width: 6px; width: 6px;
height: 6px; height: 6px;
@ -1388,6 +1479,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
.icon.talk { .icon.talk {
transform: scale(var(--icon-scale, 1)); transform: scale(var(--icon-scale, 1));
} }
.icon.talk, .icon.talk,
.icon.talk::after { .icon.talk::after {
box-sizing: border-box; box-sizing: border-box;
@ -1398,6 +1490,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border-radius: 100px; border-radius: 100px;
border: 2px dotted currentColor; border: 2px dotted currentColor;
} }
.icon.talk::after { .icon.talk::after {
content: ""; content: "";
position: absolute; position: absolute;
@ -1428,6 +1521,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border-bottom-right-radius: 1px; border-bottom-right-radius: 1px;
margin-top: 4px; margin-top: 4px;
} }
.icon.trash::after, .icon.trash::after,
.icon.trash::before { .icon.trash::before {
content: ""; content: "";
@ -1435,6 +1529,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
box-sizing: border-box; box-sizing: border-box;
position: absolute; position: absolute;
} }
.icon.trash::after { .icon.trash::after {
background: currentColor; background: currentColor;
border-radius: 3px; border-radius: 3px;
@ -1443,6 +1538,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: -4px; top: -4px;
left: -5px; left: -5px;
} }
.icon.trash::before { .icon.trash::before {
width: 10px; width: 10px;
height: 4px; height: 4px;
@ -1463,6 +1559,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid; border: 2px solid;
border-radius: 100px; border-radius: 100px;
} }
.icon.user { .icon.user {
overflow: hidden; overflow: hidden;
transform: scale(var(--icon-scale, 1)); transform: scale(var(--icon-scale, 1));
@ -1470,6 +1567,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 22px; height: 22px;
position: relative; position: relative;
} }
.icon.user::after, .icon.user::after,
.icon.user::before { .icon.user::before {
content: ""; content: "";
@ -1479,6 +1577,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
width: 8px; width: 8px;
height: 8px; height: 8px;
} }
.icon.user::after { .icon.user::after {
border-radius: 200px; border-radius: 200px;
top: 11px; top: 11px;
@ -1498,6 +1597,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid; border: 2px solid;
border-radius: 22px; border-radius: 22px;
} }
.icon.work::after, .icon.work::after,
.icon.work::before { .icon.work::before {
content: ""; content: "";
@ -1505,6 +1605,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
box-sizing: border-box; box-sizing: border-box;
position: absolute; position: absolute;
} }
.icon.work::before { .icon.work::before {
width: 12px; width: 12px;
height: 6px; height: 6px;
@ -1515,6 +1616,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
left: 2px; left: 2px;
border-bottom: 0; border-bottom: 0;
} }
.icon.work::after { .icon.work::after {
width: 18px; width: 18px;
height: 2px; height: 2px;
@ -1532,6 +1634,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
width: 22px; width: 22px;
height: 22px; height: 22px;
} }
.icon.right::after, .icon.right::after,
.icon.right::before { .icon.right::before {
content: ""; content: "";
@ -1546,6 +1649,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: 7px; top: 7px;
right: 6px; right: 6px;
} }
.icon.right::after { .icon.right::after {
right: 11px; right: 11px;
} }
@ -1558,6 +1662,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
width: 22px; width: 22px;
height: 22px; height: 22px;
} }
.icon.left::after, .icon.left::after,
.icon.left::before { .icon.left::before {
content: ""; content: "";
@ -1572,6 +1677,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: 7px; top: 7px;
left: 6px; left: 6px;
} }
.icon.left::after { .icon.left::after {
left: 11px; left: 11px;
} }
@ -1587,6 +1693,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid; border: 2px solid;
border-radius: 4px; border-radius: 4px;
} }
.icon.skip-back::after, .icon.skip-back::after,
.icon.skip-back::before { .icon.skip-back::before {
content: ""; content: "";
@ -1596,12 +1703,14 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 8px; height: 8px;
top: 5px; top: 5px;
} }
.icon.skip-back::before { .icon.skip-back::before {
width: 2px; width: 2px;
border-radius: 2px; border-radius: 2px;
right: 11px; right: 11px;
background: currentColor; background: currentColor;
} }
.icon.skip-back::after { .icon.skip-back::after {
width: 0; width: 0;
border-top: 4px solid transparent; border-top: 4px solid transparent;
@ -1620,6 +1729,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
width: 22px; width: 22px;
height: 22px; height: 22px;
} }
.icon.rewind::after, .icon.rewind::after,
.icon.rewind::before { .icon.rewind::before {
content: ""; content: "";
@ -1634,6 +1744,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: 6px; top: 6px;
left: 5px; left: 5px;
} }
.icon.rewind::after { .icon.rewind::after {
left: 9px; left: 9px;
} }
@ -1648,6 +1759,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid; border: 2px solid;
border-radius: 4px; border-radius: 4px;
} }
.icon.play::before { .icon.play::before {
content: ""; content: "";
display: block; display: block;
@ -1672,6 +1784,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid; border: 2px solid;
border-radius: 4px; border-radius: 4px;
} }
.icon.pause::before { .icon.pause::before {
content: ""; content: "";
display: block; display: block;
@ -1695,6 +1808,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
width: 22px; width: 22px;
height: 22px; height: 22px;
} }
.icon.fastforward::after, .icon.fastforward::after,
.icon.fastforward::before { .icon.fastforward::before {
content: ""; content: "";
@ -1709,6 +1823,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: 6px; top: 6px;
right: 5px; right: 5px;
} }
.icon.fastforward::after { .icon.fastforward::after {
right: 9px; right: 9px;
} }
@ -1723,6 +1838,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid; border: 2px solid;
border-radius: 4px; border-radius: 4px;
} }
.icon.skip-forward::after, .icon.skip-forward::after,
.icon.skip-forward::before { .icon.skip-forward::before {
content: ""; content: "";
@ -1732,12 +1848,14 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 8px; height: 8px;
top: 5px; top: 5px;
} }
.icon.skip-forward::before { .icon.skip-forward::before {
width: 2px; width: 2px;
border-radius: 2px; border-radius: 2px;
left: 11px; left: 11px;
background: currentColor; background: currentColor;
} }
.icon.skip-forward::after { .icon.skip-forward::after {
width: 0; width: 0;
border-top: 4px solid transparent; border-top: 4px solid transparent;

View file

@ -0,0 +1,36 @@
import * as CANNED_RESPONSES from '../../../utils/canned_responses.ts';
import * as fs from '@std/fs';
import * as path from '@std/path';
import * as media_types from '@std/media-types';
// GET /icons/:icon_path - get an icon from settings or from defaults
export async function GET(_request: Request, meta: Record<string, any>): Promise<Response> {
const filename = meta.params.icon_path;
if (!filename || filename.indexOf('..') !== -1) {
return CANNED_RESPONSES.not_found();
}
const settings_version_exists = fs.existsSync(`./files/settings/icons/${filename}`);
if (settings_version_exists) {
return new Response(null, {
status: 301,
headers: {
Location: `/files/settings/icons/${filename}`
}
});
}
const default_version_exists = fs.existsSync(`./icons/${filename}`);
if (default_version_exists) {
const extension = path.extname(filename)?.slice(1)?.toLowerCase() ?? '';
const content_type = media_types.contentType(extension) ?? 'application/octet-stream';
return new Response(await Deno.readFile(`./icons/${filename}`), {
headers: {
'Content-Type': content_type
}
});
}
return CANNED_RESPONSES.not_found();
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View file

@ -11,8 +11,12 @@
<link rel="stylesheet" href="./base.css"></link> <link rel="stylesheet" href="./base.css"></link>
<script src="./js/_utils.js" type="text/javascript"></script> <!-- inlining these to force them to be scoped for everything else -->
<script src="./js/api.js" type="text/javascript"></script> <script type="text/javascript"><!-- #include file="./js/_utils.js" --></script>
<script type="text/javascript"><!-- #include file="./js/api.js" --></script>
<script type="text/javascript"><!-- #include file="./js/app.js" --></script>
<!-- everything else -->
<script src="./js/audioplayer.js" type="text/javascript"></script> <script src="./js/audioplayer.js" type="text/javascript"></script>
<script src="./js/datetimeutils.js" type="text/javascript"></script> <script src="./js/datetimeutils.js" type="text/javascript"></script>
<script src="./js/debounce.js" type="text/javascript"></script> <script src="./js/debounce.js" type="text/javascript"></script>
@ -49,191 +53,4 @@
<!-- #include file="./tabs/tabs.html" --> <!-- #include file="./tabs/tabs.html" -->
</main> </main>
</body> </body>
<script>
/* globals - sue me */
const USERS = {
_evict_timeouts: {},
_update_timeouts: {},
get: async (id, force) => {
if ( force || !USERS[ id ] ) {
USERS[ id ] = (await (await api.fetch(`/api/users/${id}`)).json());
}
if ( !USERS._update_timeouts[ id ] ) {
USERS._update_timeouts[ id ] = setInterval( () => {
USERS.get( id, true );
}, 1 * 60_000 );
}
if ( !force ) {
if ( USERS._evict_timeouts[ id ] ) {
clearTimeout( USERS._evict_timeouts[ id ] );
}
USERS._evict_timeouts[id] = setTimeout( () => {
if ( USERS._update_timeouts[ id ] ) {
clearTimeout( USERS._update_timeouts[ id ] );
delete USERS._update_timeouts[ id ];
}
delete USERS[ id ];
}, 10 * 60_000 );
}
return USERS[ id ];
}
};
const HASH_EXTRACTOR = /^\#\/topic\/(?<topic_id>[A-Za-z\-]+)\/?(?<view>\w+)?/gm;
function extract_url_hash_info() {
HASH_EXTRACTOR.lastIndex = 0; // ugh, need this to have this work on multiple exec calls
const {
groups: { topic_id, view },
} = HASH_EXTRACTOR.exec(window.location.hash ?? "") ?? {
groups: {},
};
console.dir({
url: window.location.href,
hash: window.location.hash,
topic_id,
view,
});
if ( !document.body.dataset.topic || document.body.dataset.topic !== topic_id ) {
const previous = document.body.dataset.topic;
console.dir( {
topic_changed: {
detail: {
previous,
topic_id
}
}
});
document.dispatchEvent(new CustomEvent( "topic_changed", {
detail: {
previous,
topic_id
}
}));
}
if ( !document.body.dataset.view || document.body.dataset.view !== view ) {
const previous = document.body.dataset.view;
document.body.dataset.view = view;
console.dir( {
view_changed: {
detail: {
previous,
view
}
}
});
document.dispatchEvent(new CustomEvent( "view_changed", {
detail: {
previous,
view
}
}));
}
}
window.addEventListener("locationchange", extract_url_hash_info);
document.addEventListener( 'topic_changed', ( {detail: { topic_id }}) => {
if ( !topic_id ) {
const first_topic_id = TOPICS?.[0]?.id;
if ( first_topic_id ) {
window.location.hash = `/topic/${ first_topic_id }/chat`;
}
return;
}
document.body.dataset.topic = topic_id;
});
let TOPICS = [];
let last_topic_update = undefined;
let update_topics_timeout = undefined;
const UPDATE_TOPICS_FREQUENCY = 60_000;
async function update_topics() {
const now = new Date();
const time_since_last_update = now - (last_topic_update ?? 0);
if (time_since_last_update < UPDATE_TOPICS_FREQUENCY / 2) {
return;
}
if ( update_topics_timeout ) {
clearTimeout( update_topics_timeout );
update_topics_timeout = undefined;
}
try {
const topics_response = await api.fetch("/api/topics");
if (topics_response.ok) {
const new_topics = await topics_response.json();
const has_differences = TOPICS.length !== new_topics.length || new_topics.some( (topic, index) => {
return ( TOPICS[ index ]?.id !== topic.id || TOPICS[ index ]?.name !== topic.name );
});
if ( has_differences ) {
TOPICS = new_topics;
document.dispatchEvent(new CustomEvent("topics_updated", { detail: { topics: TOPICS } }));
}
last_topic_update = now;
}
}
catch( error ) {
console.error( error );
}
update_topics_timeout = setTimeout( update_topics, UPDATE_TOPICS_FREQUENCY);
// now that we have topics, make sure our url is all good
extract_url_hash_info();
}
document.addEventListener("DOMContentLoaded", async () => {
window.addEventListener( 'locationchange', update_topics);
document.addEventListener( 'user_logged_in', update_topics );
/* check if we are logged in */
(async () => {
try {
const session_response = await api.fetch("/api/users/me");
if (!session_response.ok) {
const error_body = await session_response.json();
const error = error_body?.error;
console.dir({
error_body,
error,
});
return;
}
const user = await session_response.json();
document.body.dataset.user = JSON.stringify( user );
document.body.dataset.perms = user.permissions.join(":");
document.dispatchEvent(new CustomEvent("user_logged_in", { detail: { user } }));
} catch (error) {
console.dir({
error,
});
}
})();
extract_url_hash_info();
});
</script>
</html> </html>

275
public/js/app.js Normal file
View file

@ -0,0 +1,275 @@
const HASH_EXTRACTOR = /^\#\/topic\/(?<topic_id>[A-Za-z\-]+)\/?(?<view>\w+)?/gm;
const UPDATE_TOPICS_FREQUENCY = 60_000;
const APP = {
user_servers: [],
user_watches: [],
_event_callbacks: {},
on: function( event_name, callback ) {
this._event_callbacks[ event_name ] = this._event_callbacks[ event_name ] ?? new Set();
this._event_callbacks[event_name ].add( callback );
return true;
},
off: function( event_name, callback ) {
return this._event_callbacks[ event_name ]?.delete( callback );
},
_emit: function( event_name, event_data ) {
const event_callbacks = this._event_callbacks[ event_name ];
event_callbacks?.forEach( ( callback ) => {
callback( event_data );
});
},
check_if_logged_in: async function () {
try {
const session_response = await api.fetch("/api/users/me");
if (!session_response.ok) {
const error_body = await session_response.json();
const error = error_body?.error;
console.dir({
error_body,
error,
});
return;
}
const user = await session_response.json();
this.login( user );
} catch (error) {
console.dir({
error,
});
}
},
extract_url_hash_info: async function () {
HASH_EXTRACTOR.lastIndex = 0; // ugh, need this to have this work on multiple exec calls
const {
groups: { topic_id, view },
} = HASH_EXTRACTOR.exec(window.location.hash ?? "") ?? {
groups: {},
};
console.dir({
url: window.location.href,
hash: window.location.hash,
topic_id,
view,
});
if (!document.body.dataset.topic || document.body.dataset.topic !== topic_id) {
const previous = document.body.dataset.topic;
console.dir({
topic_changed: {
detail: {
previous,
topic_id,
},
},
});
document.body.dataset.topic = topic_id;
this._emit( 'topic_changed', {
previous,
topic_id
});
if (!topic_id) {
const first_topic_id = this.TOPICS.TOPIC_LIST[0]?.id;
if (first_topic_id) {
window.location.hash = `/topic/${first_topic_id}/chat`; // TODO: allow a different default than chat
}
}
}
if (!document.body.dataset.view || document.body.dataset.view !== view) {
const previous = document.body.dataset.view;
document.body.dataset.view = view;
console.dir({
view_changed: {
detail: {
previous,
view,
},
},
});
this._emit( 'view_changed', {
previous,
view
});
}
},
load: async function() {
this.server = {};
this.suggested_servers = [];
try {
const server_info_response = await api.fetch( '/files/settings/settings.json' );
if ( !server_info_response.ok ) {
throw new Error( 'Could not get server info.' );
}
const this_server_info = await server_info_response.json();
this.server = {
name: this_server_info?.name ?? document.title,
url: this_server_info?.url ?? window.location.origin ?? window.location.href,
icon: this_server_info?.icon ?? '/icons/favicon-128x128.png',
icon_background: this_server_info?.icon_background ?? undefined
};
const suggested_servers = await (await api.fetch( '/files/settings/suggested_servers.json' )).json();
}
catch( error ) {
console.error( error );
}
window.addEventListener("locationchange", this.extract_url_hash_info.bind( this ));
window.addEventListener("locationchange", this.TOPICS.update );
this.check_if_logged_in();
this.extract_url_hash_info();
this._emit( 'load', this );
},
update_user: async function( updated_user ) {
const user = this.user = updated_user;
document.body.dataset.user = JSON.stringify(user);
document.body.dataset.perms = user.permissions.join(":");
this.TOPICS.update();
this.user_servers = [];
try {
const user_server_response = await api.fetch( `/files/users/${ user.id }/settings/servers.json` );
this.user_servers = user_server_response.ok ? await user_server_response.json() : [];
}
catch( error ) {
console.error( error );
}
this.user_watches = [];
try {
const user_watches_response = await api.fetch( `/api/users/${ user.id }/watches` );
this.user_watches = user_watches_response.ok ? await user_watches_response.json() : [];
}
catch( error ) {
console.error( error );
}
// TODO: show unread indicators based on watches
},
login: async function( user ) {
await this.update_user( user );
this._emit( 'user_logged_in', { user } );
},
logout: function() {
delete document.body.dataset.user;
delete document.body.dataset.perms;
window.location = "/";
this._emit( "user_logged_out", {});
},
USERS: {
_evict_timeouts: {},
_update_timeouts: {},
get: async (id, force) => {
if (force || !APP.USERS[id]) {
APP.USERS[id] = await (await api.fetch(`/api/users/${id}`)).json();
}
if (!APP.USERS._update_timeouts[id]) {
APP.USERS._update_timeouts[id] = setInterval(() => {
APP.USERS.get(id, true);
}, 1 * 60_000);
}
if (!force) {
if (APP.USERS._evict_timeouts[id]) {
clearTimeout(APP.USERS._evict_timeouts[id]);
}
APP.USERS._evict_timeouts[id] = setTimeout(() => {
if (APP.USERS._update_timeouts[id]) {
clearTimeout(APP.USERS._update_timeouts[id]);
delete APP.USERS._update_timeouts[id];
}
delete APP.USERS[id];
}, 10 * 60_000);
}
return APP.USERS[id];
},
},
TOPICS: {
_last_topic_update: undefined,
_update_topics_timeout: undefined,
TOPIC_LIST: [],
update: async () => {
const now = new Date();
const time_since_last_update = now - (APP.TOPICS._last_topic_update ?? 0);
if (time_since_last_update < UPDATE_TOPICS_FREQUENCY / 2) {
return;
}
if (APP.TOPICS._update_topics_timeout) {
clearTimeout(APP.TOPICS._update_topics_timeout);
APP.TOPICS._update_topics_timeout = undefined;
}
try {
const topics_response = await api.fetch("/api/topics");
if (topics_response.ok) {
const new_topics = await topics_response.json();
const has_differences =
APP.TOPICS.TOPIC_LIST.length !== new_topics.length ||
new_topics.some((topic, index) => {
return (
APP.TOPICS.TOPIC_LIST[index]?.id !== topic.id ||
APP.TOPICS.TOPIC_LIST[index]?.name !== topic.name
);
});
if (has_differences) {
APP.TOPICS.TOPIC_LIST = [...new_topics];
APP._emit( 'topics_updated', {
topics: APP.TOPICS.TOPIC_LIST
});
}
APP.TOPICS._last_topic_update = now;
}
} catch (error) {
console.error(error);
}
APP.TOPICS._update_topics_timeout = setTimeout(
APP.TOPICS.update,
UPDATE_TOPICS_FREQUENCY,
);
// now that we have topics, make sure our url is all good
APP.extract_url_hash_info();
},
},
};
document.addEventListener("DOMContentLoaded", APP.load.bind( APP ));

View file

@ -11,6 +11,15 @@ const event_actions_popup_styling = `
overflow: hidden; overflow: hidden;
border: 1px solid var(--border-normal); border: 1px solid var(--border-normal);
padding: 0.5rem; padding: 0.5rem;
visibility: hidden;
display: none;
opacity: 0;
}
#eventactionspopup[data-shown] {
visibility: visible;
display: block;
opacity: 1;
} }
#eventactionspopup .icon.close { #eventactionspopup .icon.close {
@ -61,9 +70,7 @@ function open_event_actions_popup(event) {
event_actions_popup.style.left = position.x + "px"; event_actions_popup.style.left = position.x + "px";
event_actions_popup.style.top = position.y + "px"; event_actions_popup.style.top = position.y + "px";
event_actions_popup.style.visibility = "visible"; event_actions_popup.dataset.shown = true;
event_actions_popup.style.opacity = "1";
event_actions_popup.style.display = "block";
} }
function clear_event_actions_popup() { function clear_event_actions_popup() {
@ -71,9 +78,7 @@ function clear_event_actions_popup() {
return; return;
} }
event_actions_popup.style.visibility = "hidden"; delete event_actions_popup.dataset.shown;
event_actions_popup.style.opacity = "0";
event_actions_popup.style.display = "none";
} }
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {

View file

@ -12,6 +12,15 @@ const reactions_popup_styling = `
border: 1px solid var(--border-normal); border: 1px solid var(--border-normal);
padding: 0.5rem; padding: 0.5rem;
text-align: center; text-align: center;
visibility: hidden;
display: none;
opacity: 0;
}
#reactionspopup[data-shown] {
visibility: visible;
display: block;
opacity: 1;
} }
#reactionspopup .icon.close { #reactionspopup .icon.close {
@ -103,10 +112,7 @@ function open_reactions_popup(event) {
reactions_popup.style.left = position.x + "px"; reactions_popup.style.left = position.x + "px";
reactions_popup.style.top = position.y + "px"; reactions_popup.style.top = position.y + "px";
reactions_popup.style.visibility = "visible"; reactions_popup.dataset.shown = true;
reactions_popup.style.opacity = "1";
reactions_popup.style.display = "block";
reactions_popup_search_input.focus(); reactions_popup_search_input.focus();
} }
@ -115,9 +121,7 @@ function clear_reactions_popup() {
return; return;
} }
reactions_popup.style.visibility = "hidden"; delete reactions_popup.dataset.shown;
reactions_popup.style.opacity = "0";
reactions_popup.style.display = "none";
} }
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
@ -160,7 +164,7 @@ document.addEventListener("DOMContentLoaded", () => {
<input <input
type="hidden" type="hidden"
name="creator_id" name="creator_id"
generator="() => { return JSON.parse( document.body.dataset.user ?? '{}' ).id; }" generator="() => { return APP.user?.id; }"
/> />
<input <input
@ -194,7 +198,7 @@ document.addEventListener("DOMContentLoaded", () => {
document.body.appendChild(reactions_popup); document.body.appendChild(reactions_popup);
reactions_popup_form = document.getElementById("reactions-selection-form"); reactions_popup_form = document.getElementById("reactions-selection-form");
document.addEventListener("topic_changed", ({ detail: { topic_id } }) => { APP.on("topic_changed", ({ topic_id }) => {
const reaction_topic_id = topic_id ?? document.body.dataset.topic; const reaction_topic_id = topic_id ?? document.body.dataset.topic;
reactions_popup_form.action = reaction_topic_id reactions_popup_form.action = reaction_topic_id
? `/api/topics/${reaction_topic_id}/events` ? `/api/topics/${reaction_topic_id}/events`

View file

@ -228,8 +228,12 @@ function smarten_feeds() {
return; return;
} }
if ( error.name === 'TypeError' && error.message === 'NetworkError when attempting to fetch resource.' ) {
console.log( error.message );
return;
}
feed.dataset.error = JSON.stringify(error); feed.dataset.error = JSON.stringify(error);
console.trace(error);
}) })
.finally(() => { .finally(() => {
if (feed.__started && feed.dataset.longpolling) { if (feed.__started && feed.dataset.longpolling) {

View file

@ -41,9 +41,7 @@ function smarten_forms() {
form.uploaded = []; form.uploaded = [];
form.errors = []; form.errors = [];
const user = document.body.dataset.user const user = APP.user;
? JSON.parse(document.body.dataset.user)
: undefined;
if (!user) { if (!user) {
throw new Error("You must be logged in to upload files here."); throw new Error("You must be logged in to upload files here.");
} }

View file

@ -1,5 +1,5 @@
<script> <script>
document.addEventListener("topics_updated", ({ detail: { topics } }) => { APP.on("topics_updated", ({ topics }) => {
const topic_list = document.getElementById("topic-list"); const topic_list = document.getElementById("topic-list");
topic_list.innerHTML = ""; topic_list.innerHTML = "";
for (const topic of topics.sort((lhs, rhs) => lhs.name.localeCompare(rhs.name))) { for (const topic of topics.sort((lhs, rhs) => lhs.name.localeCompare(rhs.name))) {
@ -16,19 +16,22 @@
.forEach((element) => element.classList.remove("active")); .forEach((element) => element.classList.remove("active"));
const new_topic_id = event?.detail?.topic_id ?? document.body.dataset.topic; const new_topic_id = event?.detail?.topic_id ?? document.body.dataset.topic;
if (new_topic_id) {
if (!new_topic_id) {
return;
}
document document
.querySelectorAll(`[data-topic-selector-for="${new_topic_id}"]`) .querySelectorAll(`[data-topic-selector-for="${new_topic_id}"]`)
.forEach((element) => element.classList.add("active")); .forEach((element) => element.classList.add("active"));
} }
document.addEventListener("topics_updated", update_topic_indicators); for ( const watch of APP.user_watches ) {
document.addEventListener("topic_changed", update_topic_indicators); // find the topic indicator for this watch
document.addEventListener("user_logged_in", update_topic_indicators); // if there is new stuff - TODO implement a HEAD for getting latest event id?
// add a class of 'new-content'
}
}
APP.on("topics_updated", update_topic_indicators);
APP.on("topic_changed", update_topic_indicators);
APP.on("user_logged_in", update_topic_indicators);
function clear_invite_popup() { function clear_invite_popup() {
document.body.querySelectorAll(".invitepopover").forEach((element) => element.remove()); document.body.querySelectorAll(".invitepopover").forEach((element) => element.remove());
@ -64,7 +67,7 @@
return; return;
} }
const user = document.body.dataset.user && JSON.parse(document.body.dataset.user); const user = APP.user;
if (!user) { if (!user) {
alert("You must be logged in."); alert("You must be logged in.");
return; return;
@ -103,22 +106,53 @@
<button onclick="( () => document.querySelectorAll( '.invitepopover' ).forEach( (element) => element.remove() ) )()">Done</button> <button onclick="( () => document.querySelectorAll( '.invitepopover' ).forEach( (element) => element.remove() ) )()">Done</button>
</div>`; </div>`;
} }
document.addEventListener(
"contextmenu",
(event) => {
if (!event.target?.closest("#sidebar")) {
return;
}
const topic_selector = event.target.closest("li.topic");
if (!topic_selector) {
return;
}
event.preventDefault();
const context_menu = document.getElementById("sidebar-context-menu");
context_menu.dataset.prepare = true;
const position = get_best_coords_for_popup({
target_element: topic_selector,
popup: {
width: context_menu.getBoundingClientRect().width,
height: context_menu.getBoundingClientRect().height,
},
offset: {
x: 4,
y: 4,
},
});
context_menu.style.left = position.x + "px";
context_menu.style.top = position.y + "px";
context_menu.dataset.show = true;
},
false,
);
document.addEventListener("click", (event) => {
if (!event.target?.closest("#sidebar-context-menu")) {
const context_menu = document.getElementById("sidebar-context-menu");
delete context_menu.dataset.show;
delete context_menu.dataset.prepare;
}
});
</script> </script>
<style type="text/css"> <style type="text/css">
main {
position: relative;
width: 100%;
height: 100%;
display: grid;
grid-template-columns: auto 1fr;
}
@media screen and (max-width: 1200px) {
main {
grid-template-columns: auto;
}
}
#sidebar { #sidebar {
z-index: 100; z-index: 100;
background: var(--bg); background: var(--bg);
@ -126,15 +160,86 @@
width: auto; width: auto;
left: 0; left: 0;
max-width: 32rem; max-width: 32rem;
padding: 0.5rem; padding-left: 6rem;
transition: all ease-in-out 0.33s; transition: all ease-in-out 0.33s;
border-right: 1px solid var(--border-subtle); border-right: 1px solid var(--border-subtle);
} }
#sidebar #sidebar-context-menu {
display: none;
visibility: hidden;
opacity: 0;
position: fixed;
z-index: 100;
min-width: 220px;
overflow: hidden;
flex-direction: column;
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--border-highlight);
border-radius: var(--border-radius);
box-shadow:
0 0 25px hsla(var(--accent), 100%, 70%, 0.4),
inset 0 0 10px hsla(var(--accent), 100%, 60%, 0.2);
backdrop-filter: blur(var(--blur-radius));
}
#sidebar #sidebar-context-menu[data-prepare] {
display: block;
visibility: visible;
}
#sidebar #sidebar-context-menu[data-show] {
display: block;
visibility: visible;
opacity: 1;
}
#sidebar #sidebar-context-menu button {
padding: 1rem;
display: flex;
align-items: center;
color: var(--text);
letter-spacing: 0.5px;
transition:
background 0.25s,
color 0.25s;
position: relative;
margin: 0;
border-bottom-width: 0;
}
#sidebar #sidebar-context-menu button:last-of-type {
border-bottom-width: 1px;
}
#sidebar #sidebar-context-menu button::before {
content: "";
position: absolute;
left: 0;
width: 3px;
height: 0;
top: 50%;
transform: translateY(-50%);
background: hsla(var(--accent), 100%, 75%, 0.8);
transition: height 0.25s;
border-radius: var(--border-radius);
}
#sidebar #sidebar-context-menu button:hover {
color: var(--accent);
}
#sidebar #sidebar-context-menu button:hover::before {
height: 70%;
}
#sidebar #sidebar-toggle, #sidebar #sidebar-toggle,
#sidebar #sidebar-toggle-icon { #sidebar #sidebar-toggle-icon {
opacity: 0; opacity: 0;
display: none; display: none;
z-index: 1000;
} }
@media screen and (max-width: 1200px) { @media screen and (max-width: 1200px) {
@ -285,19 +390,137 @@
</style> </style>
<div id="sidebar"> <div id="sidebar">
<div id="sidebar-context-menu">
<form
data-smart="true"
data-method="POST"
action="/api/users/${ APP.user?.id }/watches"
>
<input type="hidden" name="target" value="" />
<button data-sidebar-context-menu-item="true">👁️‍🗨️ Watch</button>
<button data-sidebar-context-menu-item="true">🕳️ Hide</button>
</form>
</div>
<input type="checkbox" id="sidebar-toggle" /> <input type="checkbox" id="sidebar-toggle" />
<label id="sidebar-toggle-icon" for="sidebar-toggle"> <label id="sidebar-toggle-icon" for="sidebar-toggle">
<div class="icon right"></div> <div class="icon right"></div>
</label> </label>
<script>
async function update_servers_list() {
const template = document.getElementById( 'server-list-entry-template');
try {
const server = APP.server;
const entry = eval("`" + template.innerHTML.trim() + "`");
document.getElementById('this-server-container').innerHTML = entry;
}
catch( error ) {
console.error( error );
}
try {
document.getElementById( 'suggested-servers-container').innerHTML = '';
for ( const server of APP.suggested_servers ) {
const entry = eval( "`" + template.innerHTML.trim() + "`");
document.getElementById( 'suggested-servers-container').insertAdjacentHTML( 'beforeend', entry );
}
}
catch( error ) {
console.error( error );
}
try {
document.getElementById( 'user-servers-container').innerHTML = '';
for ( const server of APP.user_servers ) {
const entry = eval( "`" + template.innerHTML.trim() + "`");
document.getElementById( 'user-servers-container').insertAdjacentHTML( 'beforeend', entry );
}
}
catch( error ) {
console.error( error );
}
}
APP.on( 'load', update_servers_list );
</script>
<style>
#server-list-container {
position: absolute;
top: 0;
left: 0;
bottom: 0;
padding: 0.75rem;
width: 6rem;
border-right: 1px solid var(--border-subtle);
overflow-y: auto;
}
.server-list-entry {
width: 100%;
padding: 0.5rem;
margin: 0.5rem auto;
overflow: hidden;
text-align: center;
margin-bottom: 1rem;
}
.server-list-entry a {
text-decoration: none;
}
.server-list-entry img {
width: 3rem;
height: 3rem;
margin: 0 auto 0.75rem;
padding: 0.2rem;
border-radius: var(--border-radius);
object-fit: scale-down;
align-content: center;
border: 1px solid var(--border-subtle);
background: var(--icon-background);
}
.server-list-entry .server-name {
font-size: x-small;
word-wrap: break-word;
font-weight: bold;
max-height: 3rem;
}
</style>
<div id="server-list-container">
<template id="server-list-entry-template">
<div class="server-list-entry">
<a href="${ server.url }">
<img class="server-icon" src="${ server.icon ?? ( server.url + '/favicon.ico' ) }" alt="${ server.name ?? server.url } icon" style="${ server.icon_background ? `--icon-background: ${ server.icon_background };` : '' }" />
<div class="server-name">${ server.name ?? server.url }</div>
</a>
</div>
</template>
<div id="this-server-container"></div>
<div id="suggested-servers-container"></div>
<div id="user-servers-container"></div>
</div>
<style>
#server-info {
position: relative;
height: 100%;
padding: 0.75rem;
}
</style>
<div id="server-info">
<script> <script>
const DEFAULT_AVATAR_URL = `//${window.location.host}/images/default_avatar.gif`; const DEFAULT_AVATAR_URL = `//${window.location.host}/images/default_avatar.gif`;
new MutationObserver((mutations, observer) => { new MutationObserver((mutations, observer) => {
mutations.forEach((mutation) => { mutations.forEach((mutation) => {
const user = document.body.dataset.user const user = APP.user;
? JSON.parse(document.body.dataset.user)
: null;
const user_bound_elements = document.querySelectorAll("[data-bind-to-user-field]"); const user_bound_elements = document.querySelectorAll("[data-bind-to-user-field]");
for (const user_bound_element of user_bound_elements) { for (const user_bound_element of user_bound_elements) {
@ -382,7 +605,7 @@
return; return;
} }
const user = JSON.parse(document.body.dataset.user); const user = APP.user;
const updated_user = { ...user }; const updated_user = { ...user };
@ -448,9 +671,7 @@
} }
const saved_user = await saved_user_response.json(); const saved_user = await saved_user_response.json();
APP.update_user( saved_user );
document.body.dataset.user = JSON.stringify(saved_user);
document.body.dataset.perms = saved_user.permissions.join(":");
} }
for (const input of inputs) { for (const input of inputs) {
@ -531,11 +752,7 @@
return; return;
} }
delete document.body.dataset.user; APP.logout();
delete document.body.dataset.perms;
window.location = "/";
document.dispatchEvent(new CustomEvent("user_logged_out", { detail: {} }));
}; };
} }
</script> </script>
@ -606,3 +823,4 @@
</div> </div>
</div> </div>
</div> </div>
</div>

View file

@ -68,12 +68,7 @@
const form = document.currentScript.closest("form"); const form = document.currentScript.closest("form");
form.on_reply = (response) => { form.on_reply = (response) => {
const user = response.user; const user = response.user;
document.body.dataset.user = JSON.stringify(user); APP.login( user );
document.body.dataset.perms = user.permissions.join(":");
document.dispatchEvent(
new CustomEvent("user_logged_in", { detail: { user } }),
);
}; };
} }
</script> </script>
@ -127,7 +122,7 @@
</div> </div>
<div> <div>
<script> <script>
document.addEventListener("DOMContentLoaded", () => { APP.on( 'load', () => {
const query = new URL(document.location.toString()) const query = new URL(document.location.toString())
.searchParams; .searchParams;
const invite_code = query.get("invite_code"); const invite_code = query.get("invite_code");

View file

@ -133,7 +133,7 @@
name="top-level-tabs" name="top-level-tabs"
id="blurb-tab-input" id="blurb-tab-input"
class="tab-switch" class="tab-switch"
data-view="blurb" data-view="blurbs"
/> />
<label for="blurb-tab-input" class="tab-label" <label for="blurb-tab-input" class="tab-label"
><div class="icon blurb"></div> ><div class="icon blurb"></div>
@ -159,8 +159,8 @@
{ {
const feed = document.currentScript.closest("[data-feed]"); const feed = document.currentScript.closest("[data-feed]");
document.addEventListener("topic_changed", () => { feed.__reset && feed.__reset(); }); APP.on("topic_changed", () => { feed.__reset && feed.__reset(); });
document.addEventListener("user_logged_in", () => { feed.__reset && feed.__reset(); }); APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); });
feed.__target_element = (item) => { feed.__target_element = (item) => {
return ( return (
@ -195,7 +195,7 @@
return { return {
event: item, event: item,
blurb: item, blurb: item,
creator: await USERS.get(item.creator_id), creator: await APP.USERS.get(item.creator_id),
blurb_datetime blurb_datetime
}; };
}; };

View file

@ -62,7 +62,7 @@
<input <input
type="hidden" type="hidden"
name="creator_id" name="creator_id"
generator="() => { return JSON.parse( document.body.dataset.user ?? '{}' ).id; }" generator="() => { return APP.user?.id; }"
/> />
<input <input

View file

@ -32,12 +32,8 @@
{ {
const feed = document.currentScript.closest("[data-feed]"); const feed = document.currentScript.closest("[data-feed]");
document.addEventListener("topic_changed", () => { APP.on("topic_changed", () => { feed.__reset && feed.__reset(); });
feed.__reset && feed.__reset(); APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); });
});
document.addEventListener("user_logged_in", () => {
feed.__reset && feed.__reset();
});
const time_tick_tock_timeout = 60_000; const time_tick_tock_timeout = 60_000;
@ -71,7 +67,7 @@
return { return {
event: item, event: item,
creator: await USERS.get(item.creator_id), creator: await APP.USERS.get(item.creator_id),
event_datetime, event_datetime,
time_tick_tock_class, time_tick_tock_class,
user_tick_tock_class, user_tick_tock_class,
@ -165,12 +161,9 @@
<script> <script>
{ {
const form = document.currentScript.closest("form"); const form = document.currentScript.closest("form");
document.addEventListener( APP.on( "topic_changed", ({ topic_id }) => {
"topic_changed",
({ detail: { topic_id } }) => {
form.action = topic_id ? `/api/topics/${topic_id}/events` : ""; form.action = topic_id ? `/api/topics/${topic_id}/events` : "";
}, });
);
} }
</script> </script>
@ -192,7 +185,7 @@
<input <input
type="hidden" type="hidden"
name="creator_id" name="creator_id"
generator="() => { return JSON.parse( document.body.dataset.user ?? '{}' ).id; }" generator="() => { return APP.user?.id; }"
/> />
<input <input

View file

@ -101,7 +101,7 @@
name="top-level-tabs" name="top-level-tabs"
id="essay-tab-input" id="essay-tab-input"
class="tab-switch" class="tab-switch"
data-view="essay" data-view="essays"
/> />
<label for="essay-tab-input" class="tab-label" <label for="essay-tab-input" class="tab-label"
><div class="icon essay"></div> ><div class="icon essay"></div>
@ -126,12 +126,8 @@
{ {
const feed = document.currentScript.closest("[data-feed]"); const feed = document.currentScript.closest("[data-feed]");
document.addEventListener("topic_changed", () => { APP.on("topic_changed", () => { feed.__reset && feed.__reset(); });
feed.__reset && feed.__reset(); APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); });
});
document.addEventListener("user_logged_in", () => {
feed.__reset && feed.__reset();
});
feed.__target_element = (item) => { feed.__target_element = (item) => {
let target = feed; let target = feed;
@ -156,7 +152,7 @@
return { return {
event: item, event: item,
essay: item, essay: item,
creator: await USERS.get(item.creator_id), creator: await APP.USERS.get(item.creator_id),
essay_datetime, essay_datetime,
}; };
}; };

View file

@ -59,7 +59,7 @@
<input <input
type="hidden" type="hidden"
name="creator_id" name="creator_id"
generator="() => { return JSON.parse( document.body.dataset.user ?? '{}' ).id; }" generator="() => { return APP.user?.id; }"
/> />
<input <input

View file

@ -156,12 +156,8 @@
{ {
const feed = document.currentScript.closest("[data-feed]"); const feed = document.currentScript.closest("[data-feed]");
document.addEventListener("topic_changed", () => { APP.on("topic_changed", () => { feed.__reset && feed.__reset(); });
feed.__reset && feed.__reset(); APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); });
});
document.addEventListener("user_logged_in", () => {
feed.__reset && feed.__reset();
});
feed.__target_element = (item) => { feed.__target_element = (item) => {
let target = feed; let target = feed;
@ -189,7 +185,7 @@
return { return {
event: item, event: item,
post: item, post: item,
creator: await USERS.get(item.creator_id), creator: await APP.USERS.get(item.creator_id),
post_datetime, post_datetime,
}; };
}; };

View file

@ -36,7 +36,7 @@
<input <input
type="hidden" type="hidden"
name="creator_id" name="creator_id"
generator="() => { return JSON.parse( document.body.dataset.user ?? '{}' ).id; }" generator="() => { return APP.user?.id; }"
/> />
<input <input

View file

@ -1,5 +1,5 @@
<script> <script>
document.addEventListener("view_changed", ({ detail: { view } }) => { APP.on( "view_changed", ({ view }) => {
const target_tab = document.querySelector(`.tab-switch[data-view="${view}"]`); const target_tab = document.querySelector(`.tab-switch[data-view="${view}"]`);
if (target_tab) { if (target_tab) {
@ -7,7 +7,7 @@
} }
}); });
document.addEventListener("DOMContentLoaded", () => { APP.on( 'load', () => {
const tab_switchers = document.querySelectorAll(".tab-switch"); const tab_switchers = document.querySelectorAll(".tab-switch");
for (const tab_switch of tab_switchers) { for (const tab_switch of tab_switchers) {
tab_switch.addEventListener("input", (event) => { tab_switch.addEventListener("input", (event) => {