diff --git a/deno.json b/deno.json index 80b6d15..6f8b356 100644 --- a/deno.json +++ b/deno.json @@ -32,9 +32,9 @@ } }, "imports": { - "@andyburke/fsdb": "jsr:@andyburke/fsdb@^0.6.0", + "@andyburke/fsdb": "jsr:@andyburke/fsdb@^0.6.1", "@andyburke/lurid": "jsr:@andyburke/lurid@^0.2.0", - "@andyburke/serverus": "jsr:@andyburke/serverus@^0.6.0", + "@andyburke/serverus": "jsr:@andyburke/serverus@^0.7.1", "@std/assert": "jsr:@std/assert@^1.0.13", "@std/encoding": "jsr:@std/encoding@^1.0.10", "@std/http": "jsr:@std/http@^1.0.18", diff --git a/deno.lock b/deno.lock index cba3125..2183d6a 100644 --- a/deno.lock +++ b/deno.lock @@ -1,12 +1,10 @@ { "version": "5", "specifiers": { - "jsr:@andyburke/fsdb@*": "0.6.0", - "jsr:@andyburke/fsdb@0.6": "0.6.0", + "jsr:@andyburke/fsdb@~0.6.1": "0.6.1", "jsr:@andyburke/lurid@*": "0.2.0", "jsr:@andyburke/lurid@0.2": "0.2.0", - "jsr:@andyburke/serverus@*": "0.6.0", - "jsr:@andyburke/serverus@0.6": "0.6.0", + "jsr:@andyburke/serverus@~0.7.1": "0.7.1", "jsr:@std/assert@*": "1.0.13", "jsr:@std/assert@^1.0.13": "1.0.13", "jsr:@std/async@^1.0.13": "1.0.13", @@ -33,8 +31,8 @@ "jsr:@stdext/crypto@0.1": "0.1.0" }, "jsr": { - "@andyburke/fsdb@0.6.0": { - "integrity": "6c58518e9de64c61f13acb0cb110ad3bd9c6277f016798ddee2eed26590dc848", + "@andyburke/fsdb@0.6.1": { + "integrity": "059ad6702e40a39a188e648a8ebf2547087782becae040af916aa843830328ea", "dependencies": [ "jsr:@std/cli@^1.0.20", "jsr:@std/fs@^1.0.18", @@ -47,8 +45,8 @@ "jsr:@std/cli@^1.0.19" ] }, - "@andyburke/serverus@0.6.0": { - "integrity": "89f013c1d77e3d5d2c4e0908b29cc4a1acd19ebf22fa2890a6c5aa777e7b0de3", + "@andyburke/serverus@0.7.1": { + "integrity": "750b5d425a3b147efb551fbbc564b0c1428e5a8b94959bd77e95936bab78040d", "dependencies": [ "jsr:@std/async", "jsr:@std/cli@^1.0.19", @@ -167,9 +165,9 @@ }, "workspace": { "dependencies": [ - "jsr:@andyburke/fsdb@0.6", + "jsr:@andyburke/fsdb@~0.6.1", "jsr:@andyburke/lurid@0.2", - "jsr:@andyburke/serverus@0.6", + "jsr:@andyburke/serverus@~0.7.1", "jsr:@std/assert@^1.0.13", "jsr:@std/encoding@^1.0.10", "jsr:@std/http@^1.0.18", diff --git a/models/event.ts b/models/event.ts index 92f95ab..5541ff3 100644 --- a/models/event.ts +++ b/models/event.ts @@ -1,6 +1,6 @@ -import { by_character, by_lurid } from '@andyburke/fsdb/organizers'; +import { by_character, by_lurid } from 'jsr:@andyburke/fsdb/organizers'; import { FSDB_COLLECTION } from 'jsr:@andyburke/fsdb'; -import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers'; +import { FSDB_INDEXER_SYMLINKS } from 'jsr:@andyburke/fsdb/indexers'; /** * @typedef {object} TIMESTAMPS @@ -34,7 +34,7 @@ export type EVENT = { export const EVENTS = new FSDB_COLLECTION({ name: 'events', id_field: 'id', - organize: (combined_id) => { + organize: (combined_id: string) => { const [room_id, event_id] = combined_id.split(':', 2); return ['rooms', room_id, event_id.substring(0, 14), `${event_id}.json`]; }, @@ -42,14 +42,16 @@ export const EVENTS = new FSDB_COLLECTION({ creator_id: new FSDB_INDEXER_SYMLINKS({ name: 'creator_id', field: 'creator_id', + to_many: true, organize: by_lurid }), tags: new FSDB_INDEXER_SYMLINKS({ name: 'tags', - get_values_to_index: (event): string[] => { - return (event.tags ?? []).map((tag) => tag.toLowerCase()); + get_values_to_index: (event: EVENT): string[] => { + return (event.tags ?? []).map((tag: string) => tag.toLowerCase()); }, + to_many: true, organize: by_character }) } diff --git a/models/room.ts b/models/room.ts index a13210a..09b6e03 100644 --- a/models/room.ts +++ b/models/room.ts @@ -1,6 +1,6 @@ -import { by_character, by_lurid } from '@andyburke/fsdb/organizers'; +import { by_character, by_lurid } from 'jsr:@andyburke/fsdb/organizers'; import { FSDB_COLLECTION } from 'jsr:@andyburke/fsdb'; -import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers'; +import { FSDB_INDEXER_SYMLINKS } from 'jsr:@andyburke/fsdb/indexers'; /** * @typedef {object} ROOM_PERMISSIONS diff --git a/public/api/rooms/:room_id/events/README.md b/public/api/rooms/:room_id/events/README.md index ddd37a6..ac03a50 100644 --- a/public/api/rooms/:room_id/events/README.md +++ b/public/api/rooms/:room_id/events/README.md @@ -1,10 +1,10 @@ -# /api/rooms/:room_id/events/:event_id +# /api/rooms/:room_id/events -Interact with a specific event. +Interact with a events for a room. -## GET /api/rooms/:room_id/events/:event_id +## GET /api/rooms/:room_id/events -Get the event specified by the tuple [ `:room_id`, `:event_id` ]. +Get events for the given room. ## PUT /api/rooms/:room_id/events/:event_id diff --git a/public/api/rooms/index.ts b/public/api/rooms/index.ts index 86183d1..56035fd 100644 --- a/public/api/rooms/index.ts +++ b/public/api/rooms/index.ts @@ -9,15 +9,14 @@ export const PRECHECKS: PRECHECK_TABLE = {}; // GET /api/rooms - get rooms PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Record): Response | undefined => { - const can_read_rooms = meta.user?.permissions?.includes('rooms.read'); + const can_read_rooms = meta.user.permissions.includes('rooms.read'); if (!can_read_rooms) { return CANNED_RESPONSES.permission_denied(); } }]; export async function GET(_req: Request, meta: Record): Promise { - const query: URLSearchParams = meta.query; - const limit = Math.min(parseInt(query.get('limit') ?? '100'), 100); + const limit = Math.min(parseInt(meta.query.limit ?? '100'), 100); const rooms = await ROOMS.all({ limit }); @@ -29,7 +28,7 @@ export async function GET(_req: Request, meta: Record): Promise): Response | undefined => { - const can_create_rooms = meta.user?.permissions?.includes('rooms.create'); + const can_create_rooms = meta.user.permissions.includes('rooms.create'); if (!can_create_rooms) { return CANNED_RESPONSES.permission_denied(); diff --git a/public/api/users/me/index.ts b/public/api/users/me/index.ts index 4b92fd1..70429d6 100644 --- a/public/api/users/me/index.ts +++ b/public/api/users/me/index.ts @@ -6,7 +6,7 @@ export const PRECHECKS: PRECHECK_TABLE = {}; // GET /api/users/me - Get the current user PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Record): Response | undefined => { - const can_read_self = meta.user?.permissions.includes('self.read'); + const can_read_self = meta.user.permissions.includes('self.read'); if (!can_read_self) { return CANNED_RESPONSES.permission_denied(); diff --git a/public/base.css b/public/base.css new file mode 100644 index 0000000..cd0a03e --- /dev/null +++ b/public/base.css @@ -0,0 +1,515 @@ +/* Dark mode default */ +:root { + --bg: #121212; + --text: #f0f0f0; + --accent: #4caf50; + --border-subtle: #555; + --border-normal: #888; + --border-highlight: #bbb; + --icon-scale: 1.25; +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #f0f0f0; + --text: #121212; + --accent: #4caf50; + --border-subtle: #bbb; + --border-normal: #888; + --border-highlight: #555; + } +} + +/* Box sizing rules */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* Remove default margin in favour of better control in authored CSS */ +body, +h1, +h2, +h3, +h4, +p, +figure, +blockquote, +dl, +dd { + margin-block-end: 0; +} + +/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */ +ul[role="list"], +ol[role="list"] { + list-style: none; +} + +/* Set core body defaults */ +body { + min-height: 100vh; + line-height: 1.5; +} + +/* Set shorter line heights on headings and interactive elements */ +h1, +h2, +h3, +h4, +button, +input, +label { + line-height: 1.1; +} + +/* Balance text wrapping on headings */ +h1, +h2, +h3, +h4 { + text-wrap: balance; +} + +/* A elements that don't have a class get default styles */ +a:not([class]) { + text-decoration-skip-ink: auto; + color: currentColor; +} + +/* Make images easier to work with */ +img, +picture { + max-width: 100%; + display: block; +} + +/* Inherit fonts for inputs and buttons */ +input, +button, +textarea, +select { + font-family: inherit; + font-size: inherit; +} + +/* Make sure textareas without a rows attribute are not tiny */ +textarea:not([rows]) { + min-height: 10em; +} + +/* Anything that has been anchored to should have extra scroll margin */ +:target { + scroll-margin-block: 5ex; +} + +* { + margin: 0; + padding: 0; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + box-sizing: border-box; +} + +body { + font-family: sans-serif; + color: var(--text); + background-color: var(--bg); + display: flex; + flex-direction: column; + height: 100vh; // fixed height? +} + +.clickable { + cursor: pointer; +} + +.resizable { + resize: horizontal; + overflow: hidden; + border-right: 4px solid #444; +} + +button { + background: inherit; + color: inherit; + padding: 0.5rem; + margin: 0 1rem; + border: 1px solid var(--text); + border-radius: 10%; +} + +button.primary { + border: 1px solid var(--accent); +} + +[data-requires-permission] { + visibility: hidden; + opacity: 0; +} + +body[data-perms*="users.write"] [data-requires-permission="users.write"], +body[data-perms*="rooms.create"] [data-requires-permission="rooms.create"] { + visibility: visible; + opacity: 1; +} + +/* ICONS */ +.icon { + width: 24px; + height: 24px; + transform: scale(var(--icon-scale, 1)); + + stroke: white; + fill: transparent; + stroke-width: 1pt; + stroke-miterlimit: 10; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 400; +} + +/* ICON - ATTACHMENT */ +.icon.attachment { + box-sizing: border-box; + position: relative; + display: block; + width: 14px; + height: 14px; + border: 2px solid; + border-top: 0; + border-bottom-left-radius: 100px; + border-bottom-right-radius: 100px; + transform: scale(var(--icon-scale, 1)); + margin-top: 11px; +} +.icon.attachment::after, +.icon.attachment::before { + content: ""; + display: block; + box-sizing: border-box; + position: absolute; + border-radius: 3px; + border: 2px solid; +} +.icon.attachment::after { + border-bottom: 0; + border-top-left-radius: 100px; + border-top-right-radius: 100px; + right: -2px; + width: 10px; + height: 14px; + bottom: 8px; +} +.icon.attachment::before { + width: 6px; + height: 12px; + border-top: 0; + border-bottom-left-radius: 100px; + border-bottom-right-radius: 100px; + left: 2px; + bottom: 4px; +} + + +/* ICON - CALENDAR */ +.icon.calendar, +.icon.calendar::before { + display: block; + box-sizing: border-box; +} +.icon.calendar { + position: relative; + transform: scale(var(--icon-scale, 1)); + width: 18px; + height: 18px; + border: 2px solid; + border-top: 4px solid; + border-radius: 3px; +} +.icon.calendar::before { + content: ""; + position: absolute; + width: 10px; + border-radius: 3px; + left: 2px; + background: currentColor; + height: 2px; + top: 2px; +} + +/* ICON - EXCHANGE */ +.icon.exchange, +.icon.exchange::after, +.icon.exchange::before { + display: block; + box-sizing: border-box; + width: 8px; + height: 8px; +} +.icon.exchange { + position: relative; + transform: scale(var(--icon-scale, 1)); + box-shadow: + -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; +} + +/* ICON - HOME */ +.icon.home { + background: + linear-gradient(to left, currentColor 5px, transparent 0) no-repeat 0 bottom/4px + 2px, + linear-gradient(to left, currentColor 5px, transparent 0) no-repeat right + bottom/4px 2px; + box-sizing: border-box; + position: relative; + display: block; + transform: scale(var(--icon-scale, 1)); + width: 18px; + height: 14px; + border: 2px solid; + border-top: 0; + border-bottom: 0; + border-top-right-radius: 3px; + border-top-left-radius: 3px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + margin-bottom: -2px; +} +.icon.home::after, +.icon.home::before { + content: ""; + display: block; + box-sizing: border-box; + position: absolute; +} +.icon.home::before { + border-top: 2px solid; + border-left: 2px solid; + border-top-left-radius: 4px; + transform: rotate(45deg); + top: -5px; + border-radius: 3px; + width: 14px; + height: 14px; + left: 0; +} +.icon.home::after { + width: 8px; + height: 10px; + border: 2px solid; + border-radius: 100px; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom: 0; + left: 3px; + bottom: 0; +} + +/* ICON - PLUS */ +.icon.plus, +.icon.plus::after, +.icon.plus::before { + 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; + width: 2px; + height: 16px; + top: 0; + left: 7px; +} +.icon.plus::before { + content: ""; + position: absolute; + width: 16px; + height: 2px; + top: 7px; + left: 0; +} + +/* ICON - RESOURCES */ +.icon.resources, +.icon.resources::after { + display: block; + box-sizing: border-box; + border-radius: 22px; +} +.icon.resources { + position: relative; + transform: scale(var(--icon-scale, 1)); + width: 20px; + height: 20px; + border: 2px solid transparent; +} +.icon.resources::after { + content: ""; + position: absolute; + width: 4px; + height: 4px; + background: currentColor; + top: 6px; + left: 6px; + box-shadow: + 0 7px 0 1px, + 0 -7px 0 1px, + -7px 0 0 1px, + 7px 0 0 1px; +} + +/* ICON - SEND */ +.icon.send { + box-sizing: border-box; + position: relative; + display: block; + transform: scale(var(--icon-scale, 1)); + width: 22px; + height: 22px; + border: 2px solid; + border-radius: 4px; +} +.icon.send::after, +.icon.send::before { + content: ""; + display: block; + box-sizing: border-box; + position: absolute; + width: 2px; + height: 8px; + border-right: 2px solid; + top: 5px; + right: 5px; +} +.icon.send::after { + width: 6px; + height: 6px; + border-bottom: 2px solid; + transform: rotate(-45deg); + right: 9px; + top: 6px; +} + +/* ICON - TALK */ +.icon.talk { + transform: scale(var(--icon-scale, 1)); +} +.icon.talk, +.icon.talk::after { + box-sizing: border-box; + position: relative; + display: block; + width: 20px; + height: 20px; + border-radius: 100px; + border: 2px dotted currentColor; +} +.icon.talk::after { + content: ""; + position: absolute; + width: 8px; + height: 8px; + border: 1px solid transparent; + top: 4px; + left: 4px; + box-shadow: + 0 0 0 2px, + inset 0 0 0 2px currentColor; +} + +/* ICON - USER */ +.icon.user, +.icon.user::after, +.icon.user::before { + display: block; + box-sizing: border-box; + border: 2px solid; + border-radius: 100px; +} +.icon.user { + overflow: hidden; + transform: scale(var(--icon-scale, 1)); + width: 22px; + height: 22px; + position: relative; +} +.icon.user::after, +.icon.user::before { + content: ""; + position: absolute; + top: 2px; + left: 5px; + width: 8px; + height: 8px; +} +.icon.user::after { + border-radius: 200px; + top: 11px; + left: 0px; + width: 18px; + height: 18px; +} + +/* ICON - WORK */ +.icon.work { + box-sizing: border-box; + position: relative; + display: block; + transform: scale(var(--icon-scale, 1)); + width: 20px; + height: 20px; + border: 2px solid; + border-radius: 22px; +} +.icon.work::after, +.icon.work::before { + content: ""; + display: block; + box-sizing: border-box; + position: absolute; +} +.icon.work::before { + width: 12px; + height: 6px; + border: 2px solid; + border-top-left-radius: 100px; + border-top-right-radius: 100px; + top: 2px; + left: 2px; + border-bottom: 0; +} +.icon.work::after { + width: 18px; + height: 2px; + background: currentColor; + left: -1px; + top: 8px; +} diff --git a/public/images/default_avatar.gif b/public/images/default_avatar.gif new file mode 100644 index 0000000..6d8cbf8 Binary files /dev/null and b/public/images/default_avatar.gif differ diff --git a/public/index.html b/public/index.html index a6180cf..eeab84e 100644 --- a/public/index.html +++ b/public/index.html @@ -4,915 +4,46 @@ Social UX - - - + + + + + + -
- - -
-
-
- - -
-
- -
- - -
-
- - -
-
- -
-
-
-
-
- - -
-
- -
- - -
-
- - -
- -
-
-
-
-
-
- - -
-
- - -
This is the home tab.
-
- -
- - -
- - -
This is a talk room.
-
-
- -
- - -
This is the exchange tab.
-
- -
- - -
This is the work tab.
-
- -
- - -
This is the resources tab.
-
- -
- - -
This is the calendar tab.
-
- -
- - -
This is the profile tab.
-
-
+ diff --git a/public/js/api.js b/public/js/api.js new file mode 100644 index 0000000..3c6b4ce --- /dev/null +++ b/public/js/api.js @@ -0,0 +1,33 @@ +const api = { + fetch: async function (url, options = { method: "GET" }) { + const session_id = (document.cookie.match( + /^(?:.*;)?\s*session_id\s*=\s*([^;]+)(?:.*)?$/, + ) || [, null])[1]; + + // TODO: this wasn't really intended to be persisted in a cookie + const session_secret = (document.cookie.match( + /^(?:.*;)?\s*session_secret\s*=\s*([^;]+)(?:.*)?$/, + ) || [, null])[1]; + + const headers = { + Accept: "application/json", + "x-session_id": session_id, + "x-totp": await otp_totp(session_secret), + ...(options.headers ?? {}), + }; + + const fetch_options = { + method: options.method, + headers, + }; + + if (options.json) { + headers["Content-Type"] = "application/json"; + fetch_options.body = JSON.stringify(options.json); + } + + const response = await fetch(`/api${url}`, fetch_options); + + return response; + }, +}; diff --git a/public/js/datetimeutils.js b/public/js/datetimeutils.js new file mode 100644 index 0000000..560899f --- /dev/null +++ b/public/js/datetimeutils.js @@ -0,0 +1,19 @@ +function datetime_to_local(input) { + const local_datetime = new Date(input); + + return { + long: local_datetime.toLocaleString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: true, + }), + + short: local_datetime.toLocaleString("en-US", { + timeStyle: "short", + hour12: true, + }), + }; +} diff --git a/public/js/locationchange.js b/public/js/locationchange.js new file mode 100644 index 0000000..156683f --- /dev/null +++ b/public/js/locationchange.js @@ -0,0 +1,22 @@ +// https://stackoverflow.com/questions/6390341/how-to-detect-if-url-has-changed-after-hash-in-javascript +(() => { + let oldPushState = history.pushState; + history.pushState = function pushState() { + let ret = oldPushState.apply(this, arguments); + window.dispatchEvent(new Event("pushstate")); + window.dispatchEvent(new Event("locationchange")); + return ret; + }; + + let oldReplaceState = history.replaceState; + history.replaceState = function replaceState() { + let ret = oldReplaceState.apply(this, arguments); + window.dispatchEvent(new Event("replacestate")); + window.dispatchEvent(new Event("locationchange")); + return ret; + }; + + window.addEventListener("popstate", () => { + window.dispatchEvent(new Event("locationchange")); + }); +})(); diff --git a/public/js/smartforms.js b/public/js/smartforms.js new file mode 100644 index 0000000..a170f3c --- /dev/null +++ b/public/js/smartforms.js @@ -0,0 +1,65 @@ +document.addEventListener("DOMContentLoaded", () => { + /* make all forms semi-smart */ + const forms = document.querySelectorAll("form"); + for (const form of forms) { + const script = form.querySelector("script"); + + form.onsubmit = async (event) => { + event.preventDefault(); + + const form_data = new FormData(form); + const body = {}; + for (const [key, value] of form_data.entries()) { + const elements = key.split("."); + let current = body; + for (const element of elements.slice(0, elements.length - 1)) { + current[element] = current[element] ?? {}; + current = current[element]; + } + + current[elements.slice(elements.length - 1).shift()] = value; + } + + const url = form.action; + + try { + // TODO: send session header + const response = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error_body = await response.json(); + const error = error_body?.error; + + if (form.on_error) { + return form.on_error(error); + } + + alert(error.message ?? "Unknown error!"); + return; + } + + const response_body = await response.json(); + if (form.on_response) { + return form.on_response(response_body); + } + } catch (error) { + console.dir({ + error, + }); + + if (form.onerror) { + return form.onerror(error); + } + + alert(error); + } + }; + } +}); diff --git a/public/js/totp.js b/public/js/totp.js new file mode 100644 index 0000000..6f4582d --- /dev/null +++ b/public/js/totp.js @@ -0,0 +1,42 @@ +/* https://github.com/turistu/totp-in-javascript/blob/main/totp.js */ + +async function otp_totp(key, secs = 30, digits = 6) { + return otp_hotp(otp_unbase32(key), otp_pack64bu(Date.now() / 1000 / secs), digits); +} +async function otp_hotp(key, counter, digits) { + let y = self.crypto.subtle; + if (!y) throw Error("no self.crypto.subtle object available"); + let k = await y.importKey("raw", key, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]); + return otp_hotp_truncate(await y.sign("HMAC", k, counter), digits); +} +function otp_hotp_truncate(buf, digits) { + let a = new Uint8Array(buf), + i = a[19] & 0xf; + return otp_fmt( + 10, + digits, + (((a[i] & 0x7f) << 24) | (a[i + 1] << 16) | (a[i + 2] << 8) | a[i + 3]) % 10 ** digits, + ); +} + +function otp_fmt(base, width, num) { + return num.toString(base).padStart(width, "0"); +} +function otp_unbase32(s) { + let t = (s.toLowerCase().match(/\S/g) || []) + .map((c) => { + let i = "abcdefghijklmnopqrstuvwxyz234567".indexOf(c); + if (i < 0) throw Error(`bad char '${c}' in key`); + return otp_fmt(2, 5, i); + }) + .join(""); + if (t.length < 8) throw Error("key too short"); + return new Uint8Array(t.match(/.{8}/g).map((d) => parseInt(d, 2))); +} +function otp_pack64bu(v) { + let b = new ArrayBuffer(8), + d = new DataView(b); + d.setUint32(0, v / 2 ** 32); + d.setUint32(4, v); + return b; +} diff --git a/public/signup_login_wall.html b/public/signup_login_wall.html new file mode 100644 index 0000000..c0b20d7 --- /dev/null +++ b/public/signup_login_wall.html @@ -0,0 +1,171 @@ +
+ + +
+
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+ +
+ + +
+
+ + +
+ +
+
+
+
+
+
diff --git a/public/tabs/calendar.html b/public/tabs/calendar.html new file mode 100644 index 0000000..104da9b --- /dev/null +++ b/public/tabs/calendar.html @@ -0,0 +1,14 @@ +
+ + +
This is the calendar tab.
+
diff --git a/public/tabs/exchange.html b/public/tabs/exchange.html new file mode 100644 index 0000000..1ac4c81 --- /dev/null +++ b/public/tabs/exchange.html @@ -0,0 +1,14 @@ +
+ + +
This is the exchange tab.
+
diff --git a/public/tabs/home.html b/public/tabs/home.html new file mode 100644 index 0000000..d5c960f --- /dev/null +++ b/public/tabs/home.html @@ -0,0 +1,15 @@ +
+ + +
This is the home tab.
+
diff --git a/public/tabs/resources.html b/public/tabs/resources.html new file mode 100644 index 0000000..fe05b70 --- /dev/null +++ b/public/tabs/resources.html @@ -0,0 +1,14 @@ +
+ + +
This is the resources tab.
+
diff --git a/public/tabs/tabs.html b/public/tabs/tabs.html new file mode 100644 index 0000000..1af63c9 --- /dev/null +++ b/public/tabs/tabs.html @@ -0,0 +1,131 @@ + + + +
+ + + + + + + +
diff --git a/public/tabs/talk.html b/public/tabs/talk.html new file mode 100644 index 0000000..486d93f --- /dev/null +++ b/public/tabs/talk.html @@ -0,0 +1,381 @@ +
+ + +
+ + +
+
+
+
+ + + +
+
+
+
+
diff --git a/public/tabs/user.html b/public/tabs/user.html new file mode 100644 index 0000000..5f1a41a --- /dev/null +++ b/public/tabs/user.html @@ -0,0 +1,57 @@ + +
+ + +
+
+ User Avatar +
+ +
+ +
+
+
diff --git a/public/tabs/work.html b/public/tabs/work.html new file mode 100644 index 0000000..5b97336 --- /dev/null +++ b/public/tabs/work.html @@ -0,0 +1,14 @@ +
+ + +
This is the work tab.
+