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": {
"TypeScript": {
"language_servers": ["deno", "!typescript-language-server", "!vtsls", "!eslint"],
"formatter": "language_server"
"language_servers": [
"deno",
"!typescript-language-server",
"!vtsls",
"!eslint",
"..."
]
},
"TSX": {
"language_servers": ["deno", "!typescript-language-server", "!vtsls", "!eslint"],
"formatter": "language_server"
"language_servers": [
"deno",
"!typescript-language-server",
"!vtsls",
"!eslint",
"..."
]
},
"JavaScript": {
"language_servers": [
"deno",
"!typescript-language-server",
"!vtsls",
"!eslint",
"..."
]
}
}
},
"formatter": "language_server"
}

View file

@ -32,7 +32,7 @@
}
},
"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/serverus": "jsr:@andyburke/serverus@^0.13.0",
"@da/bcrypt": "jsr:@da/bcrypt@^1.0.1",
@ -40,6 +40,7 @@
"@std/encoding": "jsr:@std/encoding@^1.0.10",
"@std/fs": "jsr:@std/fs@^1.0.19",
"@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"
}
}

9
deno.lock generated
View file

@ -1,7 +1,7 @@
{
"version": "5",
"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/serverus@0.13": "0.13.0",
"jsr:@da/bcrypt@*": "1.0.1",
@ -31,8 +31,8 @@
"npm:@types/node@*": "22.15.15"
},
"jsr": {
"@andyburke/fsdb@1.0.4": {
"integrity": "ce4bf858e6af25bf257726d08b2901c7409f82aa409f435795d5381caffffad4",
"@andyburke/fsdb@1.1.0": {
"integrity": "ad2d062672137ca96df19df032b51f1c7aa3133c973a0b86eb8eaab3b4c2d47b",
"dependencies": [
"jsr:@std/cli@^1.0.20",
"jsr:@std/fs@^1.0.18",
@ -133,7 +133,7 @@
},
"workspace": {
"dependencies": [
"jsr:@andyburke/fsdb@^1.0.4",
"jsr:@andyburke/fsdb@^1.1.0",
"jsr:@andyburke/lurid@0.2",
"jsr:@andyburke/serverus@0.13",
"jsr:@da/bcrypt@^1.0.1",
@ -141,6 +141,7 @@
"jsr:@std/encoding@^1.0.10",
"jsr:@std/fs@^1.0.19",
"jsr:@std/http@^1.0.21",
"jsr:@std/media-types@^1.1.0",
"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 parse_body from '../../../../../utils/bodyparser.ts';
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 = {};
@ -58,11 +58,9 @@ export async function GET(request: Request, meta: Record<string, any>): Promise<
sort,
filter: (entry: WALK_ENTRY<EVENT>) => {
const {
groups: {
event_type,
event_id
}
} = /^.*\/events\/(?<event_type>.*?)\/.*\/(?<event_id>[A-Za-z-]+)\.json$/.exec(entry.path) ?? { groups: {} };
event_type,
event_id
} = /^.*\/events\/(?<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;
@ -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
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() ?? '';

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.write',
'topics.posts.read',
'users.read'
'users.read',
'watches.create.own',
'watches.read.own',
'watches.write.own'
];
export const PRECHECKS: PRECHECK_TABLE = {};

View file

@ -6,6 +6,8 @@
--bg-darker: hsl(from var(--base-color) h 20% 5%);
--bg-lighter: hsl(from var(--base-color) h 20% 10%);
--blur-radius: 8px;
--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));
@ -98,7 +100,7 @@ select {
font-size: inherit;
}
details > summary {
details>summary {
display: block;
position: relative;
padding-left: 2rem;
@ -106,7 +108,7 @@ details > summary {
user-select: none;
}
details > summary:before {
details>summary:before {
content: "";
border-width: 0.6rem;
border-style: solid;
@ -119,11 +121,11 @@ details > summary:before {
transition: 0.25s transform ease;
}
details[open] > summary:before {
details[open]>summary:before {
transform: rotate(90deg);
}
details > summary::-webkit-details-marker {
details>summary::-webkit-details-marker {
display: none;
}
@ -163,7 +165,22 @@ body {
background-color: var(--bg);
display: flex;
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,
@ -240,13 +257,11 @@ textarea:focus {
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
-55deg,
rgba(0, 0, 0, 0.25) 0px,
rgba(0, 0, 0, 0.25) 20px,
rgba(255, 177, 1, 0.25) 20px,
rgba(255, 177, 1, 0.25) 40px
);
background: repeating-linear-gradient(-55deg,
rgba(0, 0, 0, 0.25) 0px,
rgba(0, 0, 0, 0.25) 20px,
rgba(255, 177, 1, 0.25) 20px,
rgba(255, 177, 1, 0.25) 40px);
}
.collapsed {
@ -268,15 +283,15 @@ label:has(input[collapse-toggle]) {
margin-bottom: 2rem;
}
input[collapse-toggle] + .collapsible,
label:has(input[collapse-toggle]) + .collapsible {
input[collapse-toggle]+.collapsible,
label:has(input[collapse-toggle])+.collapsible {
transition: all 0.33s;
height: 0;
overflow: hidden;
}
input[collapse-toggle]:checked + .collapsible,
label:has(input[collapse-toggle]:checked) + .collapsible {
input[collapse-toggle]:checked+.collapsible,
label:has(input[collapse-toggle]:checked)+.collapsible {
height: 100%;
}
@ -300,8 +315,8 @@ form label.placeholder {
font-size 0.2s ease-in-out;
}
form input:focus ~ label.placeholder,
form input:valid ~ label.placeholder {
form input:focus~label.placeholder,
form input:valid~label.placeholder {
top: -1.6rem;
font-size: small;
border: 1px solid rgba(128, 128, 128, 0.2);
@ -444,11 +459,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
max-width: 800px;
}
.audio-container
.audio-controls-container
.progress-container
.slider-container
input[name="progress"] {
.audio-container .audio-controls-container .progress-container .slider-container input[name="progress"] {
width: 100%;
}
@ -469,6 +480,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
max-width: 100px;
overflow: hidden;
}
@media screen and (max-width: 480px) {
.audio-container .audio-controls-container .blank {
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"] {
--c: var(--accent); /* active color */
--g: 4px; /* the gap */
--l: 2px; /* line thickness*/
--s: 15px; /* thumb size*/
--c: var(--accent);
/* active color */
--g: 4px;
/* the gap */
--l: 2px;
/* line thickness*/
--s: 15px;
/* thumb size*/
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%));
-webkit-appearance: none;
-moz-appearance: none;
@ -495,26 +512,29 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
cursor: pointer;
overflow: hidden;
}
.audio-container .audio-controls-container input[type="range"]:focus-visible,
.audio-container .audio-controls-container input[type="range"]:hover {
--p: 25%;
}
.audio-container .audio-controls-container input[type="range"]:active,
.audio-container .audio-controls-container input[type="range"]:focus-visible {
--_b: var(--s);
}
/* chromium */
.audio-container .audio-controls-container input[type="range"]::-webkit-slider-thumb {
height: var(--s);
aspect-ratio: 1;
border-radius: 50%;
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)
100vw/0 calc(100vw + var(--g));
border-image: linear-gradient(90deg, var(--_c) 50%, #ababab 0) 0 1 / calc(50% - var(--l) / 2) 100vw/0 calc(100vw + var(--g));
-webkit-appearance: none;
appearance: none;
transition: 0.2s;
}
/* Firefox */
.audio-container .audio-controls-container input[type="range"]::-moz-range-thumb {
height: var(--s);
@ -522,12 +542,12 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
background: none;
border-radius: 50%;
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)
100vw/0 calc(100vw + var(--g));
border-image: linear-gradient(90deg, var(--_c) 50%, #ababab 0) 0 1 / calc(50% - var(--l) / 2) 100vw/0 calc(100vw + var(--g));
-moz-appearance: none;
appearance: none;
transition: 0.2s;
}
@supports not (color: color-mix(in srgb, red, red)) {
.audio-container .audio-controls-container input[type="range"] {
--_c: var(--c);
@ -547,6 +567,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
opacity: 1;
display: block;
}
.audio-container .audio-controls-container .audio-control .icon.pause {
opacity: 0;
display: none;
@ -556,11 +577,24 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
opacity: 0;
display: none;
}
.audio-container[data-playing] .audio-controls-container .audio-control .icon.pause {
opacity: 1;
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 {
padding: 2em;
}
@ -597,6 +631,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
transform: scale(var(--icon-scale, 1));
border-radius: 4px;
}
.icon.add::after,
.icon.add::before {
content: "";
@ -610,6 +645,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: 8px;
left: 4px;
}
.icon.add::after {
width: 2px;
height: 10px;
@ -631,6 +667,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
transform: scale(var(--icon-scale, 1));
margin-top: 11px;
}
.icon.attachment::after,
.icon.attachment::before {
content: "";
@ -640,6 +677,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border-radius: 3px;
border: 2px solid;
}
.icon.attachment::after {
border-bottom: 0;
border-top-left-radius: 100px;
@ -649,6 +687,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 14px;
bottom: 8px;
}
.icon.attachment::before {
width: 6px;
height: 12px;
@ -670,6 +709,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
width: 22px;
height: 16px;
}
.icon.blurb::after,
.icon.blurb::before {
content: "";
@ -681,11 +721,13 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
background: currentColor;
bottom: 2px;
}
.icon.blurb::before {
width: 10px;
left: 2px;
box-shadow: 4px -4px 0;
}
.icon.blurb::after {
width: 3px;
right: 2px;
@ -698,6 +740,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
display: block;
box-sizing: border-box;
}
.icon.calendar {
position: relative;
transform: scale(var(--icon-scale, 1));
@ -731,6 +774,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 12px;
perspective: 24px;
}
.icon.camera::after,
.icon.camera::before {
content: "";
@ -738,6 +782,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
box-sizing: border-box;
position: absolute;
}
.icon.camera::before {
border: 2px solid;
border-left-color: transparent;
@ -747,6 +792,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
right: -7px;
top: 0;
}
.icon.camera::after {
width: 10px;
height: 5px;
@ -766,6 +812,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
width: 14px;
height: 10px;
}
.icon.chat::after,
.icon.chat::before {
content: "";
@ -776,11 +823,13 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 2px;
background: currentColor;
}
.icon.chat::before {
width: 10px;
opacity: 0.5;
box-shadow: 0 4px 0;
}
.icon.chat::after {
width: 14px;
bottom: 0;
@ -809,6 +858,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid;
border-radius: var(--border-radius);
}
.icon.close::after,
.icon.close::before {
content: "";
@ -823,6 +873,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: 8px;
left: 3px;
}
.icon.close::after {
transform: rotate(-45deg);
}
@ -838,6 +889,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid;
border-radius: 100px;
}
.icon.controller::before {
content: "";
display: block;
@ -869,6 +921,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border-bottom-right-radius: 2px;
margin-top: 8px;
}
.icon.download::after {
content: "";
display: block;
@ -882,6 +935,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
left: 2px;
bottom: 4px;
}
.icon.download::before {
content: "";
display: block;
@ -907,6 +961,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border-radius: 3px;
box-shadow: 0 -1px 0;
}
.icon.essay::after,
.icon.essay::before {
content: "";
@ -916,6 +971,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
width: 6px;
top: 2px;
}
.icon.essay::before {
background: currentColor;
left: 2px;
@ -925,6 +981,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border-radius: 3px;
height: 2px;
}
.icon.essay::after {
height: 10px;
border: 2px solid;
@ -941,6 +998,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
width: 8px;
height: 8px;
}
.icon.exchange {
position: relative;
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;
}
.icon.exchange::after,
.icon.exchange::before {
content: "";
position: absolute;
border: 2px solid;
}
.icon.exchange::before {
top: -5px;
left: -5px;
}
.icon.exchange::after {
bottom: -5px;
right: -5px;
@ -973,6 +1034,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 14px;
border-bottom: 2px solid;
}
.icon.forum::after,
.icon.forum::before {
content: "";
@ -981,6 +1043,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
position: absolute;
top: 2px;
}
.icon.forum::before {
border-left: 4px solid;
left: 1px;
@ -989,6 +1052,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border-top: 3px solid transparent;
border-bottom: 3px solid transparent;
}
.icon.forum::after {
width: 8px;
height: 6px;
@ -1007,6 +1071,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 16px;
box-shadow: 6px -6px 0 -4px;
}
.icon.forward-copy::before {
content: "";
display: block;
@ -1019,6 +1084,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
left: 0;
bottom: 0;
}
.icon.forward-copy::after {
content: "";
display: block;
@ -1053,6 +1119,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border-bottom-left-radius: 0;
margin-bottom: -2px;
}
.icon.home::after,
.icon.home::before {
content: "";
@ -1060,6 +1127,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
box-sizing: border-box;
position: absolute;
}
.icon.home::before {
border-top: 2px solid;
border-left: 2px solid;
@ -1071,6 +1139,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 14px;
left: 0;
}
.icon.home::after {
width: 8px;
height: 10px;
@ -1087,6 +1156,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
.icon.live {
transform: scale(var(--icon-scale, 1));
}
.icon.live,
.icon.live::after,
.icon.live::before {
@ -1099,6 +1169,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border-bottom-color: transparent;
border-radius: 50%;
}
.icon.live::after,
.icon.live::before {
content: "";
@ -1108,6 +1179,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: 2px;
left: 2px;
}
.icon.live::after {
width: 22px;
height: 22px;
@ -1138,6 +1210,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid;
border-radius: 3px;
}
.icon.more::before {
content: "";
position: absolute;
@ -1158,6 +1231,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
.icon.more-borderless {
transform: scale(var(--icon-scale, 1));
}
.icon.more-borderless,
.icon.more-borderless::after,
.icon.more-borderless::before {
@ -1169,15 +1243,18 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
background: currentColor;
border-radius: 100%;
}
.icon.more-borderless::after,
.icon.more-borderless::before {
content: "";
position: absolute;
top: 0;
}
.icon.more-borderless::after {
left: -6px;
}
.icon.more-borderless::before {
right: -6px;
}
@ -1193,6 +1270,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid;
border-radius: 24px;
}
.icon.more-circle::before {
content: "";
position: absolute;
@ -1218,6 +1296,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 22px;
transform: scale(var(--icon-scale, 1));
}
.icon.phone::after,
.icon.phone::before {
content: "";
@ -1225,6 +1304,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
box-sizing: border-box;
position: absolute;
}
.icon.phone::after {
width: 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 -1px 0/4px 6px;
}
.icon.phone::before {
width: 20px;
height: 20px;
@ -1259,17 +1340,20 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
display: block;
box-sizing: border-box;
}
.icon.plus::after,
.icon.plus::before {
border-radius: 10px;
background: currentColor;
}
.icon.plus {
position: relative;
transform: scale(var(--icon-scale, 1));
width: 16px;
height: 16px;
}
.icon.plus::after {
content: "";
position: absolute;
@ -1278,6 +1362,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: 0;
left: 7px;
}
.icon.plus::before {
content: "";
position: absolute;
@ -1297,6 +1382,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 16px;
box-shadow: -6px -6px 0 -4px;
}
.icon.reply::before {
content: "";
display: block;
@ -1309,6 +1395,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
right: 0;
bottom: 0;
}
.icon.reply::after {
content: "";
display: block;
@ -1330,6 +1417,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
box-sizing: border-box;
border-radius: 22px;
}
.icon.resources {
position: relative;
transform: scale(var(--icon-scale, 1));
@ -1337,6 +1425,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 20px;
border: 2px solid transparent;
}
.icon.resources::after {
content: "";
position: absolute;
@ -1363,6 +1452,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid;
border-radius: 4px;
}
.icon.send::after,
.icon.send::before {
content: "";
@ -1375,6 +1465,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: 5px;
right: 5px;
}
.icon.send::after {
width: 6px;
height: 6px;
@ -1388,6 +1479,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
.icon.talk {
transform: scale(var(--icon-scale, 1));
}
.icon.talk,
.icon.talk::after {
box-sizing: border-box;
@ -1398,6 +1490,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border-radius: 100px;
border: 2px dotted currentColor;
}
.icon.talk::after {
content: "";
position: absolute;
@ -1428,6 +1521,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border-bottom-right-radius: 1px;
margin-top: 4px;
}
.icon.trash::after,
.icon.trash::before {
content: "";
@ -1435,6 +1529,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
box-sizing: border-box;
position: absolute;
}
.icon.trash::after {
background: currentColor;
border-radius: 3px;
@ -1443,6 +1538,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: -4px;
left: -5px;
}
.icon.trash::before {
width: 10px;
height: 4px;
@ -1463,6 +1559,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid;
border-radius: 100px;
}
.icon.user {
overflow: hidden;
transform: scale(var(--icon-scale, 1));
@ -1470,6 +1567,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 22px;
position: relative;
}
.icon.user::after,
.icon.user::before {
content: "";
@ -1479,6 +1577,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
width: 8px;
height: 8px;
}
.icon.user::after {
border-radius: 200px;
top: 11px;
@ -1498,6 +1597,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid;
border-radius: 22px;
}
.icon.work::after,
.icon.work::before {
content: "";
@ -1505,6 +1605,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
box-sizing: border-box;
position: absolute;
}
.icon.work::before {
width: 12px;
height: 6px;
@ -1515,6 +1616,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
left: 2px;
border-bottom: 0;
}
.icon.work::after {
width: 18px;
height: 2px;
@ -1532,6 +1634,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
width: 22px;
height: 22px;
}
.icon.right::after,
.icon.right::before {
content: "";
@ -1546,6 +1649,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: 7px;
right: 6px;
}
.icon.right::after {
right: 11px;
}
@ -1558,6 +1662,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
width: 22px;
height: 22px;
}
.icon.left::after,
.icon.left::before {
content: "";
@ -1572,6 +1677,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: 7px;
left: 6px;
}
.icon.left::after {
left: 11px;
}
@ -1587,6 +1693,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid;
border-radius: 4px;
}
.icon.skip-back::after,
.icon.skip-back::before {
content: "";
@ -1596,12 +1703,14 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 8px;
top: 5px;
}
.icon.skip-back::before {
width: 2px;
border-radius: 2px;
right: 11px;
background: currentColor;
}
.icon.skip-back::after {
width: 0;
border-top: 4px solid transparent;
@ -1620,6 +1729,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
width: 22px;
height: 22px;
}
.icon.rewind::after,
.icon.rewind::before {
content: "";
@ -1634,6 +1744,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: 6px;
left: 5px;
}
.icon.rewind::after {
left: 9px;
}
@ -1648,6 +1759,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid;
border-radius: 4px;
}
.icon.play::before {
content: "";
display: block;
@ -1672,6 +1784,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid;
border-radius: 4px;
}
.icon.pause::before {
content: "";
display: block;
@ -1695,6 +1808,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
width: 22px;
height: 22px;
}
.icon.fastforward::after,
.icon.fastforward::before {
content: "";
@ -1709,6 +1823,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
top: 6px;
right: 5px;
}
.icon.fastforward::after {
right: 9px;
}
@ -1723,6 +1838,7 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
border: 2px solid;
border-radius: 4px;
}
.icon.skip-forward::after,
.icon.skip-forward::before {
content: "";
@ -1732,12 +1848,14 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
height: 8px;
top: 5px;
}
.icon.skip-forward::before {
width: 2px;
border-radius: 2px;
left: 11px;
background: currentColor;
}
.icon.skip-forward::after {
width: 0;
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>
<script src="./js/_utils.js" type="text/javascript"></script>
<script src="./js/api.js" type="text/javascript"></script>
<!-- inlining these to force them to be scoped for everything else -->
<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/datetimeutils.js" type="text/javascript"></script>
<script src="./js/debounce.js" type="text/javascript"></script>
@ -49,191 +53,4 @@
<!-- #include file="./tabs/tabs.html" -->
</main>
</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>

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;
border: 1px solid var(--border-normal);
padding: 0.5rem;
visibility: hidden;
display: none;
opacity: 0;
}
#eventactionspopup[data-shown] {
visibility: visible;
display: block;
opacity: 1;
}
#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.top = position.y + "px";
event_actions_popup.style.visibility = "visible";
event_actions_popup.style.opacity = "1";
event_actions_popup.style.display = "block";
event_actions_popup.dataset.shown = true;
}
function clear_event_actions_popup() {
@ -71,9 +78,7 @@ function clear_event_actions_popup() {
return;
}
event_actions_popup.style.visibility = "hidden";
event_actions_popup.style.opacity = "0";
event_actions_popup.style.display = "none";
delete event_actions_popup.dataset.shown;
}
document.addEventListener("DOMContentLoaded", () => {

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<script>
document.addEventListener("topics_updated", ({ detail: { topics } }) => {
APP.on("topics_updated", ({ topics }) => {
const topic_list = document.getElementById("topic-list");
topic_list.innerHTML = "";
for (const topic of topics.sort((lhs, rhs) => lhs.name.localeCompare(rhs.name))) {
@ -16,19 +16,22 @@
.forEach((element) => element.classList.remove("active"));
const new_topic_id = event?.detail?.topic_id ?? document.body.dataset.topic;
if (!new_topic_id) {
return;
if (new_topic_id) {
document
.querySelectorAll(`[data-topic-selector-for="${new_topic_id}"]`)
.forEach((element) => element.classList.add("active"));
}
document
.querySelectorAll(`[data-topic-selector-for="${new_topic_id}"]`)
.forEach((element) => element.classList.add("active"));
for ( const watch of APP.user_watches ) {
// find the topic indicator for this watch
// if there is new stuff - TODO implement a HEAD for getting latest event id?
// add a class of 'new-content'
}
}
document.addEventListener("topics_updated", update_topic_indicators);
document.addEventListener("topic_changed", update_topic_indicators);
document.addEventListener("user_logged_in", update_topic_indicators);
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() {
document.body.querySelectorAll(".invitepopover").forEach((element) => element.remove());
@ -64,7 +67,7 @@
return;
}
const user = document.body.dataset.user && JSON.parse(document.body.dataset.user);
const user = APP.user;
if (!user) {
alert("You must be logged in.");
return;
@ -103,22 +106,53 @@
<button onclick="( () => document.querySelectorAll( '.invitepopover' ).forEach( (element) => element.remove() ) )()">Done</button>
</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>
<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 {
z-index: 100;
background: var(--bg);
@ -126,15 +160,86 @@
width: auto;
left: 0;
max-width: 32rem;
padding: 0.5rem;
padding-left: 6rem;
transition: all ease-in-out 0.33s;
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-icon {
opacity: 0;
display: none;
z-index: 1000;
}
@media screen and (max-width: 1200px) {
@ -285,324 +390,437 @@
</style>
<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" />
<label id="sidebar-toggle-icon" for="sidebar-toggle">
<div class="icon right"></div>
</label>
<script>
const DEFAULT_AVATAR_URL = `//${window.location.host}/images/default_avatar.gif`;
async function update_servers_list() {
const template = document.getElementById( 'server-list-entry-template');
new MutationObserver((mutations, observer) => {
mutations.forEach((mutation) => {
const user = document.body.dataset.user
? JSON.parse(document.body.dataset.user)
: null;
try {
const server = APP.server;
const entry = eval("`" + template.innerHTML.trim() + "`");
document.getElementById('this-server-container').innerHTML = entry;
}
catch( error ) {
console.error( error );
}
const user_bound_elements = document.querySelectorAll("[data-bind-to-user-field]");
for (const user_bound_element of user_bound_elements) {
const key =
user_bound_element.dataset
.bindToUserField; /* I hate that it converts the name */
const key_elements = key.split(".");
try {
document.getElementById( 'suggested-servers-container').innerHTML = '';
let value = undefined;
if (user) {
let current = user;
for (const key_element of key_elements) {
current = current[key_element];
if (!current) {
break;
}
}
value = current;
}
const target =
typeof user_bound_element.dataset.userFieldTarget === "string" &&
user_bound_element.dataset.userFieldTarget.length > 0
? user_bound_element.dataset.userFieldTarget
: "innerHTML";
const default_value =
typeof user_bound_element.dataset.userFieldDefault === "string" &&
user_bound_element.dataset.userFieldDefault.length > 0
? user_bound_element.dataset.userFieldDefault
: "";
user_bound_element[target] = value ?? default_value;
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 );
}
const primary_color_setting = user?.meta?.primary_color;
if (primary_color_setting) {
const root = document.querySelector(":root");
root.style.setProperty("--base-color", primary_color_setting);
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 );
}
});
}).observe(document.body, {
attributes: true,
attributeFilter: ["data-user"],
});
}
catch( error ) {
console.error( error );
}
}
APP.on( 'load', update_servers_list );
</script>
<style type="text/css">
.profile-container {
max-width: 1024px;
padding: 1rem;
padding-bottom: 0;
}
.profile-container .avatar-container {
position: relative;
width: 100%;
max-width: 200px;
}
.profile-container .avatar-container input[type="file"] {
<style>
#server-list-container {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0;
cursor: pointer;
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>
<form class="profile-container">
<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>
const profile_form = document.currentScript.closest("form");
const DEFAULT_AVATAR_URL = `//${window.location.host}/images/default_avatar.gif`;
document.addEventListener("DOMContentLoaded", () => {
const inputs = profile_form.querySelectorAll("input");
new MutationObserver((mutations, observer) => {
mutations.forEach((mutation) => {
const user = APP.user;
async function update_from_input(input) {
delete input.__debounce_timeout;
const user_bound_elements = document.querySelectorAll("[data-bind-to-user-field]");
for (const user_bound_element of user_bound_elements) {
const key =
user_bound_element.dataset
.bindToUserField; /* I hate that it converts the name */
const key_elements = key.split(".");
if (!document.body.dataset.user) {
return;
}
const user = JSON.parse(document.body.dataset.user);
const updated_user = { ...user };
switch (input.name) {
case "meta.avatar":
const avatar = input.files[0];
if (!avatar || !avatar.type || !avatar.type.includes("image")) {
return alert(
"You must select a valid image to upload as your avatar.",
);
let value = undefined;
if (user) {
let current = user;
for (const key_element of key_elements) {
current = current[key_element];
if (!current) {
break;
}
}
// TODO: actually enforce this on the upload in serverus somehow
if (avatar.size > 512_000) {
return alert("512K is the largest allowed avatar size.");
}
const avatar_upload_body = new FormData();
avatar_upload_body.append(
"file",
avatar,
encodeURIComponent(avatar.name),
);
const avatar_path = `/files/users/${user.id}/avatars/${encodeURIComponent(avatar.name)}`;
const avatar_upload_response = await api.fetch(avatar_path, {
method: "PUT",
body: avatar_upload_body,
});
if (!avatar_upload_response.ok) {
const error = await avatar_upload_response.json();
return alert(error?.error?.message ?? "Unknown error.");
}
updated_user.meta = updated_user.meta ?? {};
updated_user.meta.avatar = `//${window.location.host}${avatar_path}`;
break;
default:
const elements = input.name.split(".");
let current = updated_user;
for (const element of elements.slice(0, elements.length - 1)) {
current[element] = current[element] ?? {};
current = current[element];
}
current[elements.slice(elements.length - 1).shift()] =
input.value.trim();
break;
}
const saved_user_response = await api.fetch(`/api/users/${user.id}`, {
method: "PUT",
json: updated_user,
});
if (!saved_user_response.ok) {
const error = await avatar_upload_response.json();
return alert(error?.error?.message ?? "Unknown error.");
}
const saved_user = await saved_user_response.json();
document.body.dataset.user = JSON.stringify(saved_user);
document.body.dataset.perms = saved_user.permissions.join(":");
}
for (const input of inputs) {
function on_updated(event) {
if (input.__debounce_timeout) {
clearTimeout(input.__debounce_timeout);
value = current;
}
input.__debounce_timeout = setTimeout(() => {
update_from_input(input);
}, 250);
const target =
typeof user_bound_element.dataset.userFieldTarget === "string" &&
user_bound_element.dataset.userFieldTarget.length > 0
? user_bound_element.dataset.userFieldTarget
: "innerHTML";
const default_value =
typeof user_bound_element.dataset.userFieldDefault === "string" &&
user_bound_element.dataset.userFieldDefault.length > 0
? user_bound_element.dataset.userFieldDefault
: "";
user_bound_element[target] = value ?? default_value;
}
input.addEventListener("input", on_updated);
input.addEventListener("change", on_updated);
}
const primary_color_setting = user?.meta?.primary_color;
if (primary_color_setting) {
const root = document.querySelector(":root");
root.style.setProperty("--base-color", primary_color_setting);
}
});
}).observe(document.body, {
attributes: true,
attributeFilter: ["data-user"],
});
</script>
<div class="avatar-container xx-large">
<img
id="user-avatar"
src="/images/default_avatar.gif"
alt="User Avatar"
data-bind-to-user-field="meta.avatar"
data-user-field-target="src"
data-user-field-default="/images/default_avatar.gif"
/>
<input type="file" accept="image/*" name="meta.avatar" />
</div>
<details class="additional-profile">
<summary>
<div class="username-container">
<span class="username" data-bind-to-user-field="username"></span>
</div>
</summary>
<div class="notifications-settings-container">
<button class="mockup" onclick="NOTIFICATIONS.request_permission()">
Enable Notifications
</button>
</div>
<div class="color-settings-container">
<input
type="text"
id="user-color-setting-primary"
name="meta.primary_color"
value=""
data-bind-to-user-field="meta.primary_color"
data-user-field-target="value"
data-user-field-default=""
/>
<label class="placeholder" for="user-color-setting-primary">Primary Color</label>
</div>
</details>
</form>
<button
style="text-transform: uppercase; width: 100%; padding: 1.1rem 0"
onclick="generate_invite(event)"
>
Invite Another Human
</button>
<form
data-smart="true"
data-method="DELETE"
action="/api/auth"
style="position: absolute; left: 1rem; right: 1rem; bottom: 1rem"
>
<script>
{
const form = document.currentScript.closest("form");
form.on_reply = (response) => {
if (!response.deleted) {
alert("error logging out? please reload.");
return;
}
delete document.body.dataset.user;
delete document.body.dataset.perms;
window.location = "/";
document.dispatchEvent(new CustomEvent("user_logged_out", { detail: {} }));
};
<style type="text/css">
.profile-container {
max-width: 1024px;
padding: 1rem;
padding-bottom: 0;
}
</script>
<button class="primary">Log Out</button>
</form>
<div class="topics-container">
<div style="margin-bottom: 1rem">
<span class="title">topics</span>
</div>
<ul id="topic-list" class="topic-list"></ul>
.profile-container .avatar-container {
position: relative;
width: 100%;
max-width: 200px;
}
<div id="topic-creation-container" data-requires-permission="topics.create">
<button
id="toggle-topic-creation-form-button"
onclick="((event) => {
event.preventDefault();
const topic_create_form = document.getElementById( 'topic-create' );
topic_create_form.style[ 'height' ] = topic_create_form.style[ 'height' ] === '5rem' ? '0' : '5rem';
})(event)"
>
<div class="icon plus"></div>
</button>
<form
id="topic-create"
data-smart="true"
action="/api/topics"
method="POST"
style="
margin-top: 1rem;
width: 100%;
overflow: hidden;
height: 0;
overflow: hidden;
transition: all 0.5s;
"
>
<input
id="new-topic-name-input"
type="text"
name="name"
value=""
placeholder="new topic"
/>
.profile-container .avatar-container input[type="file"] {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0;
cursor: pointer;
}
</style>
<form class="profile-container">
<script>
const profile_form = document.currentScript.closest("form");
<input type="submit" hidden />
<script>
{
const form = document.currentScript.closest("form");
const topic_create_form = document.getElementById("topic-create");
const new_topic_name_input =
document.getElementById("new-topic-name-input");
document.addEventListener("DOMContentLoaded", () => {
const inputs = profile_form.querySelectorAll("input");
form.on_reply = (new_topic) => {
const topic_list = document.getElementById("topic-list");
topic_list.insertAdjacentHTML(
"beforeend",
`<li id="topic-selector-${new_topic.id}" class="topic"><a href="#/topic/${new_topic.id}">${new_topic.name}</a></li>`,
);
async function update_from_input(input) {
delete input.__debounce_timeout;
new_topic_name_input.value = "";
window.location.hash = `/topic/${new_topic.id}/chat`;
topic_create_form.style["height"] = "0";
};
if (!document.body.dataset.user) {
return;
}
const user = APP.user;
const updated_user = { ...user };
switch (input.name) {
case "meta.avatar":
const avatar = input.files[0];
if (!avatar || !avatar.type || !avatar.type.includes("image")) {
return alert(
"You must select a valid image to upload as your avatar.",
);
}
// TODO: actually enforce this on the upload in serverus somehow
if (avatar.size > 512_000) {
return alert("512K is the largest allowed avatar size.");
}
const avatar_upload_body = new FormData();
avatar_upload_body.append(
"file",
avatar,
encodeURIComponent(avatar.name),
);
const avatar_path = `/files/users/${user.id}/avatars/${encodeURIComponent(avatar.name)}`;
const avatar_upload_response = await api.fetch(avatar_path, {
method: "PUT",
body: avatar_upload_body,
});
if (!avatar_upload_response.ok) {
const error = await avatar_upload_response.json();
return alert(error?.error?.message ?? "Unknown error.");
}
updated_user.meta = updated_user.meta ?? {};
updated_user.meta.avatar = `//${window.location.host}${avatar_path}`;
break;
default:
const elements = input.name.split(".");
let current = updated_user;
for (const element of elements.slice(0, elements.length - 1)) {
current[element] = current[element] ?? {};
current = current[element];
}
current[elements.slice(elements.length - 1).shift()] =
input.value.trim();
break;
}
const saved_user_response = await api.fetch(`/api/users/${user.id}`, {
method: "PUT",
json: updated_user,
});
if (!saved_user_response.ok) {
const error = await avatar_upload_response.json();
return alert(error?.error?.message ?? "Unknown error.");
}
const saved_user = await saved_user_response.json();
APP.update_user( saved_user );
}
</script>
</form>
for (const input of inputs) {
function on_updated(event) {
if (input.__debounce_timeout) {
clearTimeout(input.__debounce_timeout);
}
input.__debounce_timeout = setTimeout(() => {
update_from_input(input);
}, 250);
}
input.addEventListener("input", on_updated);
input.addEventListener("change", on_updated);
}
});
</script>
<div class="avatar-container xx-large">
<img
id="user-avatar"
src="/images/default_avatar.gif"
alt="User Avatar"
data-bind-to-user-field="meta.avatar"
data-user-field-target="src"
data-user-field-default="/images/default_avatar.gif"
/>
<input type="file" accept="image/*" name="meta.avatar" />
</div>
<details class="additional-profile">
<summary>
<div class="username-container">
<span class="username" data-bind-to-user-field="username"></span>
</div>
</summary>
<div class="notifications-settings-container">
<button class="mockup" onclick="NOTIFICATIONS.request_permission()">
Enable Notifications
</button>
</div>
<div class="color-settings-container">
<input
type="text"
id="user-color-setting-primary"
name="meta.primary_color"
value=""
data-bind-to-user-field="meta.primary_color"
data-user-field-target="value"
data-user-field-default=""
/>
<label class="placeholder" for="user-color-setting-primary">Primary Color</label>
</div>
</details>
</form>
<button
style="text-transform: uppercase; width: 100%; padding: 1.1rem 0"
onclick="generate_invite(event)"
>
Invite Another Human
</button>
<form
data-smart="true"
data-method="DELETE"
action="/api/auth"
style="position: absolute; left: 1rem; right: 1rem; bottom: 1rem"
>
<script>
{
const form = document.currentScript.closest("form");
form.on_reply = (response) => {
if (!response.deleted) {
alert("error logging out? please reload.");
return;
}
APP.logout();
};
}
</script>
<button class="primary">Log Out</button>
</form>
<div class="topics-container">
<div style="margin-bottom: 1rem">
<span class="title">topics</span>
</div>
<ul id="topic-list" class="topic-list"></ul>
<div id="topic-creation-container" data-requires-permission="topics.create">
<button
id="toggle-topic-creation-form-button"
onclick="((event) => {
event.preventDefault();
const topic_create_form = document.getElementById( 'topic-create' );
topic_create_form.style[ 'height' ] = topic_create_form.style[ 'height' ] === '5rem' ? '0' : '5rem';
})(event)"
>
<div class="icon plus"></div>
</button>
<form
id="topic-create"
data-smart="true"
action="/api/topics"
method="POST"
style="
margin-top: 1rem;
width: 100%;
overflow: hidden;
height: 0;
overflow: hidden;
transition: all 0.5s;
"
>
<input
id="new-topic-name-input"
type="text"
name="name"
value=""
placeholder="new topic"
/>
<input type="submit" hidden />
<script>
{
const form = document.currentScript.closest("form");
const topic_create_form = document.getElementById("topic-create");
const new_topic_name_input =
document.getElementById("new-topic-name-input");
form.on_reply = (new_topic) => {
const topic_list = document.getElementById("topic-list");
topic_list.insertAdjacentHTML(
"beforeend",
`<li id="topic-selector-${new_topic.id}" class="topic"><a href="#/topic/${new_topic.id}">${new_topic.name}</a></li>`,
);
new_topic_name_input.value = "";
window.location.hash = `/topic/${new_topic.id}/chat`;
topic_create_form.style["height"] = "0";
};
}
</script>
</form>
</div>
</div>
</div>
</div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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