From 5ff6c0e53fe5a28c692ea9ed5a776eb7e5aaaaf3 Mon Sep 17 00:00:00 2001 From: stagas Date: Wed, 23 Oct 2024 08:56:25 +0300 Subject: [PATCH 01/13] refactor: update dsp gens fixed header --- generated/typescript/dsp-gens.ts | 2 +- vendor/as-transform-update-dsp-gens.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/generated/typescript/dsp-gens.ts b/generated/typescript/dsp-gens.ts index 9145254..90cc8ce 100644 --- a/generated/typescript/dsp-gens.ts +++ b/generated/typescript/dsp-gens.ts @@ -1,5 +1,5 @@ // -// auto-generated Tue Oct 22 2024 23:08:20 GMT+0300 (Eastern European Summer Time) +// auto-generated import { Value } from '../../src/as/dsp/value.ts' diff --git a/vendor/as-transform-update-dsp-gens.js b/vendor/as-transform-update-dsp-gens.js index c9792a0..488dd1e 100644 --- a/vendor/as-transform-update-dsp-gens.js +++ b/vendor/as-transform-update-dsp-gens.js @@ -94,7 +94,7 @@ ${v.props.map((x, i) => ` ${x}?: ${audioProps.includes(x) ? 'Value.Audio' : t const formatted = util.format('%O', dspGens) const date = new Date() const text = /*ts*/`// -// auto-generated ${date.toDateString()} ${date.toTimeString()} +// auto-generated import { Value } from '../../src/as/dsp/value.ts' From eb4ec6980d6986d2f69fbb7457a90621a53dfc3d Mon Sep 17 00:00:00 2001 From: stagas Date: Wed, 23 Oct 2024 11:18:34 +0300 Subject: [PATCH 02/13] feat: local minio server --- .gitignore | 1 + docker-compose.yaml | 22 +++++++++++ migrations/1729663196543_profiles-table.ts | 44 ++++++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 migrations/1729663196543_profiles-table.ts diff --git a/.gitignore b/.gitignore index 32cb72a..47205a4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ coverage dist node_modules package-lock.json +storage diff --git a/docker-compose.yaml b/docker-compose.yaml index bfd309e..1feca63 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -17,3 +17,25 @@ services: - "5433:80" depends_on: - postgres + s3service: + image: quay.io/minio/minio:latest + volumes: + - ./storage/:/storage + command: server --console-address ":9001" /storage + ports: + - "9000:9000" + - "9001:9001" + env_file: .env.development + initialize-s3service: + image: quay.io/minio/mc + depends_on: + - s3service + entrypoint: > + /bin/sh -c ' + until (/usr/bin/mc config host add s3service http://s3service:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}") do echo '...waiting...' && sleep 1; done; + /usr/bin/mc mb s3service/"$${S3_BUCKET_NAME}"; + /usr/bin/mc admin user add s3service "$${S3_ACCESS_KEY}" "$${S3_SECRET_KEY}"; + /usr/bin/mc admin policy attach s3service readwrite --user "$${S3_ACCESS_KEY}"; + exit 0; + ' + env_file: .env.development diff --git a/migrations/1729663196543_profiles-table.ts b/migrations/1729663196543_profiles-table.ts new file mode 100644 index 0000000..f27ff77 --- /dev/null +++ b/migrations/1729663196543_profiles-table.ts @@ -0,0 +1,44 @@ +import { sql, type Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + // up migration code goes here... + // note: up migrations are mandatory. you must implement this function. + // For more info, see: https://kysely.dev/docs/migrations + await db.schema + .createTable('profiles') + .ifNotExists() + .addColumn('owner', 'text', col => col.references('users.nick').notNull()) + .addColumn('nick', 'text', col => col.primaryKey()) + .addColumn('displayName', 'text', col => col.notNull()) + .addColumn('bio', 'text') + .addColumn('avatar', 'text') + .addColumn('banner', 'text') + .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql`now()`).notNull()) + .addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql`now()`).notNull()) + .execute() + + await db.schema + .createIndex('profiles_owner_index') + .ifNotExists() + .on('profiles') + .column('owner') + .execute() + + await db.schema + .createIndex('profiles_displayName_index') + .ifNotExists() + .on('profiles') + .column('displayName') + .execute() +} + +export async function down(db: Kysely): Promise { + // down migration code goes here... + // note: down migrations are optional. you can safely delete this function. + // For more info, see: https://kysely.dev/docs/migrations + await db.schema + .dropTable('profiles') + .ifExists() + .cascade() + .execute() +} From 4ce473d198c3ef0c7722b2adebb423c07b46e2ea Mon Sep 17 00:00:00 2001 From: stagas Date: Wed, 23 Oct 2024 11:41:55 +0300 Subject: [PATCH 03/13] fix: asc vite plugin atomic --- vendor/vite-plugin-assemblyscript.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/vendor/vite-plugin-assemblyscript.ts b/vendor/vite-plugin-assemblyscript.ts index d53c444..33eeb90 100644 --- a/vendor/vite-plugin-assemblyscript.ts +++ b/vendor/vite-plugin-assemblyscript.ts @@ -3,6 +3,7 @@ import asc from 'assemblyscript/dist/asc' import fs from 'fs' import type { SourceMapPayload } from 'module' import { join, resolve } from 'path' +import { createConcurrentQueue } from 'utils' import type { Plugin } from 'vite' interface AssemblyScriptPluginOptions { @@ -54,6 +55,21 @@ async function compile(entryFile: string, mode: 'debug' | 'release', options: As } } +const queue: (() => Promise)[] = [] +let promise: Promise | null = null +async function flush() { + if (promise) return promise + async function run() { + let task: (() => Promise) | undefined + while (task = queue.pop()) { + await task() + } + } + promise = run() + await promise + promise = null +} + export function ViteAssemblyScript( userOptions: Partial = defaultOptions ): Plugin { @@ -74,13 +90,19 @@ export function ViteAssemblyScript( if (file.startsWith(matchPath)) { if (timestamp === handledTimestamp) return handledTimestamp = timestamp - await compile(entryFile, 'debug', options) + queue.push(async () => { + await compile(entryFile, 'debug', options) + }) + await flush() } }, async buildStart() { if (didBuild) return didBuild = true - await compile(entryFile, 'release', options) + queue.push(async () => { + await compile(entryFile, 'release', options) + }) + await flush() }, } } From 58902ef73df518c9d7fa495ca371c2c7093414e5 Mon Sep 17 00:00:00 2001 From: stagas Date: Wed, 23 Oct 2024 11:42:12 +0300 Subject: [PATCH 04/13] rebuild models --- api/models.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/api/models.ts b/api/models.ts index db71bff..a4110d8 100644 --- a/api/models.ts +++ b/api/models.ts @@ -48,6 +48,27 @@ export interface Messages { type: string; } +export const Profiles = z.object({ + owner: z.string(), + nick: z.string(), + displayName: z.string(), + bio: z.string().nullish(), + avatar: z.string().nullish(), + banner: z.string().nullish(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}) +export interface Profiles { + avatar: string | null; + banner: string | null; + bio: string | null; + createdAt: Generated; + displayName: string; + nick: string; + owner: string; + updatedAt: Generated; +} + export const Users = z.object({ nick: z.string(), email: z.string(), @@ -71,11 +92,13 @@ export const DB = z.object({ channels: Channels, channelUser: ChannelUser, messages: Messages, + profiles: Profiles, users: Users, }) export interface DB { channels: Channels; channelUser: ChannelUser; messages: Messages; + profiles: Profiles; users: Users; } From 683f5e13a22fbadf0d5f28d69015838f06be1b28 Mon Sep 17 00:00:00 2001 From: stagas Date: Wed, 23 Oct 2024 19:40:48 +0300 Subject: [PATCH 05/13] wip profile page --- admin/Admin.tsx | 8 +- api/auth/actions.ts | 16 +++- api/auth/types.ts | 1 + api/core/server.ts | 7 +- api/models.ts | 25 +++++- api/profiles/actions.ts | 78 ++++++++++++++++ api/profiles/types.ts | 8 ++ api/rpc/routes.ts | 7 +- api/sounds/actions.ts | 43 +++++++++ migrations/1729663196543_profiles-table.ts | 16 +++- migrations/1729687226871_sounds-table.ts | 42 +++++++++ src/as/dsp/preview-worker.ts | 2 + src/comp/Login.tsx | 2 +- src/comp/OAuthLogin.tsx | 5 +- src/comp/Register.tsx | 2 +- src/comp/ResetPassword.tsx | 2 +- src/pages/App.tsx | 35 +++++++- src/pages/CreateProfile.tsx | 68 ++++++++++++++ src/pages/DspNodeDemo.tsx | 78 ++++++++++++++-- src/pages/Home.tsx | 1 + src/pages/Profile.tsx | 100 +++++++++++++++++++++ src/pages/Settings.tsx | 42 +++++++++ src/rpc/auth.ts | 19 +++- src/rpc/profiles.ts | 8 ++ src/rpc/sounds.ts | 6 ++ src/state.ts | 4 +- src/ui/Label.tsx | 8 +- 27 files changed, 592 insertions(+), 41 deletions(-) create mode 100644 api/profiles/actions.ts create mode 100644 api/profiles/types.ts create mode 100644 api/sounds/actions.ts create mode 100644 migrations/1729687226871_sounds-table.ts create mode 100644 src/pages/CreateProfile.tsx create mode 100644 src/pages/Profile.tsx create mode 100644 src/pages/Settings.tsx create mode 100644 src/rpc/profiles.ts create mode 100644 src/rpc/sounds.ts diff --git a/admin/Admin.tsx b/admin/Admin.tsx index 2211a4e..0cbccc5 100644 --- a/admin/Admin.tsx +++ b/admin/Admin.tsx @@ -5,7 +5,7 @@ import { state } from '~/admin/state.ts' import { icon } from '~/lib/icon.ts' import { Header } from '~/src/comp/Header.tsx' import { Logout } from '~/src/comp/Logout.tsx' -import { whoami } from '~/src/rpc/auth.ts' +import { loginUserSession, maybeLogin, whoami } from '~/src/rpc/auth.ts' const EDITABLE = new Set(['nick', 'email']) @@ -83,11 +83,7 @@ function Table]>({ } export function Admin() { - if (!state.user) whoami().then(user => { - if (!user) location.href = '/' - else state.user = user - }) - + maybeLogin('/') return
diff --git a/api/auth/actions.ts b/api/auth/actions.ts index e7a62c5..312b2b2 100644 --- a/api/auth/actions.ts +++ b/api/auth/actions.ts @@ -45,6 +45,14 @@ export async function getUserByNick(nick: string) { .executeTakeFirst() } +export async function getUserByNickOrThrow(nick: string) { + return await db + .selectFrom('users') + .selectAll() + .where('nick', '=', nick) + .executeTakeFirstOrThrow() +} + export async function getUserByEmail(email: string) { return await db .selectFrom('users') @@ -69,7 +77,13 @@ export async function loginUser(ctx: Context, nick: string) { expires.setUTCFullYear(expires.getUTCFullYear() + 1) const isAdmin = ADMINS.includes(nick) - const session: UserSession = { nick, expires, isAdmin } + const user = await getUserByNickOrThrow(nick) + const session: UserSession = { + nick, + expires, + isAdmin, + defaultProfile: user.defaultProfile ?? '(unset)' + } await kv.set(sessionKey, session, { expireIn: expires.getTime() - now.getTime() }) diff --git a/api/auth/types.ts b/api/auth/types.ts index 9dbcf06..13af5e7 100644 --- a/api/auth/types.ts +++ b/api/auth/types.ts @@ -37,6 +37,7 @@ export type UserSession = z.infer export const UserSession = z.object({ nick: z.string(), expires: z.date(), + defaultProfile: z.string().optional(), isAdmin: z.boolean().optional(), }) diff --git a/api/core/server.ts b/api/core/server.ts index 01bfac3..c90ee6c 100644 --- a/api/core/server.ts +++ b/api/core/server.ts @@ -1,17 +1,20 @@ import os from 'https://deno.land/x/os_paths@v7.4.0/src/mod.deno.ts' import { parseArgs } from 'jsr:@std/cli/parse-args' import * as path from 'jsr:@std/path' -import '~/api/chat/actions.ts' import * as chat from '~/api/chat/routes.ts' import { app } from '~/api/core/app.ts' import { IS_DEV } from "~/api/core/constants.ts" import { cors, files, logger, session, watcher } from "~/api/core/middleware.ts" -import '~/api/oauth/actions.ts' import * as oauthCommon from '~/api/oauth/routes/common.ts' import * as oauthGitHub from '~/api/oauth/routes/github.ts' import * as rpc from '~/api/rpc/routes.ts' import * as ws from '~/api/ws/routes.ts' +import '~/api/chat/actions.ts' +import '~/api/oauth/actions.ts' +import '~/api/profiles/actions.ts' +import '~/api/sounds/actions.ts' + const dist = 'dist' const home = os.home() ?? '~' diff --git a/api/models.ts b/api/models.ts index a4110d8..a25d7ca 100644 --- a/api/models.ts +++ b/api/models.ts @@ -49,7 +49,7 @@ export interface Messages { } export const Profiles = z.object({ - owner: z.string(), + ownerNick: z.string(), nick: z.string(), displayName: z.string(), bio: z.string().nullish(), @@ -65,7 +65,24 @@ export interface Profiles { createdAt: Generated; displayName: string; nick: string; - owner: string; + ownerNick: string; + updatedAt: Generated; +} + +export const Sounds = z.object({ + id: z.string(), + ownerProfileNick: z.string(), + title: z.string(), + code: z.string(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}) +export interface Sounds { + code: string; + createdAt: Generated; + id: Generated; + ownerProfileNick: string; + title: string; updatedAt: Generated; } @@ -77,9 +94,11 @@ export const Users = z.object({ oauthGithub: z.boolean().nullish(), createdAt: z.coerce.date(), updatedAt: z.coerce.date(), + defaultProfile: z.string().nullish(), }) export interface Users { createdAt: Generated; + defaultProfile: string | null; email: string; emailVerified: Generated; nick: string; @@ -93,6 +112,7 @@ export const DB = z.object({ channelUser: ChannelUser, messages: Messages, profiles: Profiles, + sounds: Sounds, users: Users, }) export interface DB { @@ -100,5 +120,6 @@ export interface DB { channelUser: ChannelUser; messages: Messages; profiles: Profiles; + sounds: Sounds; users: Users; } diff --git a/api/profiles/actions.ts b/api/profiles/actions.ts new file mode 100644 index 0000000..9fd3cc2 --- /dev/null +++ b/api/profiles/actions.ts @@ -0,0 +1,78 @@ +import { db } from '~/api/db.ts' +import { actions } from '~/api/rpc/routes.ts' +import { RouteError, type Context } from '~/api/core/router.ts' +import { getSession } from '~/api/core/sessions.ts' +import { Profiles } from '~/api/models.ts' +import type { ProfileCreate } from '~/api/profiles/types.ts' +import { loginUser } from '~/api/auth/actions.ts' + +actions.post.createProfile = createProfile +export async function createProfile(ctx: Context, data: ProfileCreate) { + const session = getSession(ctx) + const { nick: ownerNick } = session + + const result = await db + .insertInto('profiles') + .values({ + ownerNick, + nick: data.nick, + displayName: data.displayName + }) + .returning('nick') + .executeTakeFirstOrThrow() + + if (data.isDefault || session.defaultProfile == null) { + await makeDefaultProfile(ctx, data.nick) + } + + return result +} + +actions.post.makeDefaultProfile = makeDefaultProfile +export async function makeDefaultProfile(ctx: Context, nick: string) { + const session = getSession(ctx) + const { nick: ownerNick } = session + + await db + .updateTable('users') + .set({ defaultProfile: nick }) + .where('nick', '=', ownerNick) + .returning('defaultProfile') + .executeTakeFirstOrThrow() + + return loginUser(ctx, ownerNick) +} + +actions.get.getProfile = getProfile +export async function getProfile(_ctx: Context, nick: string) { + return await db + .selectFrom('profiles') + .selectAll() + .where('nick', '=', nick) + .executeTakeFirstOrThrow() +} + +actions.get.listProfilesForNick = listProfilesForNick +export async function listProfilesForNick(_ctx: Context, nick: string) { + return (await db + .selectFrom('profiles') + .selectAll() + .where('ownerNick', '=', nick) + .execute() + ).map(entry => Profiles.parse(entry)) +} + +actions.post.deleteProfile = deleteProfile +export async function deleteProfile(ctx: Context, nick: string) { + const session = getSession(ctx) + const { nick: owner, defaultProfile } = session + + if (nick === defaultProfile) throw new RouteError(400, 'Cannot delete default profile') + + return await db + .deleteFrom('profiles') + .where('ownerNick', '=', owner) + .where('nick', '=', nick) + .returning('nick') + .executeTakeFirstOrThrow() +} diff --git a/api/profiles/types.ts b/api/profiles/types.ts new file mode 100644 index 0000000..1455bab --- /dev/null +++ b/api/profiles/types.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export type ProfileCreate = z.infer +export const ProfileCreate = z.object({ + nick: z.string(), + displayName: z.string(), + isDefault: z.coerce.boolean(), +}) diff --git a/api/rpc/routes.ts b/api/rpc/routes.ts index 3e2e448..c219f9b 100644 --- a/api/rpc/routes.ts +++ b/api/rpc/routes.ts @@ -2,6 +2,7 @@ import { defer } from 'utils' import { z } from 'zod' import { RouteError, type Router } from '~/api/core/router.ts' import { sessions } from '~/api/core/sessions.ts' +import { Kysely } from 'kysely' const DEBUG = false @@ -83,7 +84,11 @@ export function mount(app: Router) { }) } else { - return new Response(JSON.stringify({ error: error.message }), { + return new Response(JSON.stringify({ + error: (error instanceof Error) + ? (error as unknown as { detail?: string })?.detail ?? error.message + : 'Unknown error' + }), { status: 500, headers }) diff --git a/api/sounds/actions.ts b/api/sounds/actions.ts new file mode 100644 index 0000000..5c9e845 --- /dev/null +++ b/api/sounds/actions.ts @@ -0,0 +1,43 @@ +import { RouteError, type Context } from '~/api/core/router.ts' +import { getSession } from '~/api/core/sessions.ts' +import { db } from '~/api/db.ts' +import { actions } from '~/api/rpc/routes.ts' + +actions.post.publishSound = publishSound +export async function publishSound(ctx: Context, title: string, code: string) { + const session = getSession(ctx) + const { defaultProfile } = session + if (!defaultProfile) throw new RouteError(401, 'No default profile set') + + return await db + .insertInto('sounds') + .values({ ownerProfileNick: defaultProfile, title, code }) + .returning('id') + .executeTakeFirstOrThrow() +} + +actions.get.listSounds = listSounds +export async function listSounds(_ctx: Context, nick: string) { + return await db + .selectFrom('sounds') + .select(['id', 'title']) + .where('ownerProfileNick', '=', nick) + .execute() +} + +actions.get.getSound = getSound +export async function getSound(_ctx: Context, id: string) { + const sound = await db + .selectFrom('sounds') + .selectAll() + .where('id', '=', id) + .executeTakeFirstOrThrow() + + const creator = await db + .selectFrom('profiles') + .selectAll() + .where('nick', '=', sound.ownerProfileNick) + .executeTakeFirstOrThrow() + + return { sound, creator } +} diff --git a/migrations/1729663196543_profiles-table.ts b/migrations/1729663196543_profiles-table.ts index f27ff77..b64750d 100644 --- a/migrations/1729663196543_profiles-table.ts +++ b/migrations/1729663196543_profiles-table.ts @@ -7,7 +7,7 @@ export async function up(db: Kysely): Promise { await db.schema .createTable('profiles') .ifNotExists() - .addColumn('owner', 'text', col => col.references('users.nick').notNull()) + .addColumn('ownerNick', 'text', col => col.references('users.nick').notNull()) .addColumn('nick', 'text', col => col.primaryKey()) .addColumn('displayName', 'text', col => col.notNull()) .addColumn('bio', 'text') @@ -18,10 +18,10 @@ export async function up(db: Kysely): Promise { .execute() await db.schema - .createIndex('profiles_owner_index') + .createIndex('profiles_ownerNick_index') .ifNotExists() .on('profiles') - .column('owner') + .column('ownerNick') .execute() await db.schema @@ -30,6 +30,11 @@ export async function up(db: Kysely): Promise { .on('profiles') .column('displayName') .execute() + + await db.schema + .alterTable('users') + .addColumn('defaultProfile', 'text', col => col.references('profiles.nick')) + .execute() } export async function down(db: Kysely): Promise { @@ -41,4 +46,9 @@ export async function down(db: Kysely): Promise { .ifExists() .cascade() .execute() + + await db.schema + .alterTable('users') + .dropColumn('defaultProfile') + .execute() } diff --git a/migrations/1729687226871_sounds-table.ts b/migrations/1729687226871_sounds-table.ts new file mode 100644 index 0000000..819d74c --- /dev/null +++ b/migrations/1729687226871_sounds-table.ts @@ -0,0 +1,42 @@ +import { sql, type Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + // up migration code goes here... + // note: up migrations are mandatory. you must implement this function. + // For more info, see: https://kysely.dev/docs/migrations + await db.schema + .createTable('sounds') + .ifNotExists() + .addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql`gen_random_uuid()`)) + .addColumn('ownerProfileNick', 'text', col => col.references('profiles.nick').notNull()) + .addColumn('title', 'text', col => col.notNull()) + .addColumn('code', 'text', col => col.notNull()) + .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql`now()`).notNull()) + .addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql`now()`).notNull()) + .execute() + + await db.schema + .createIndex('sounds_ownerProfileNick_index') + .ifNotExists() + .on('sounds') + .column('ownerProfileNick') + .execute() + + await db.schema + .createIndex('sounds_title_index') + .ifNotExists() + .on('sounds') + .column('title') + .execute() +} + +export async function down(db: Kysely): Promise { + // down migration code goes here... + // note: down migrations are optional. you can safely delete this function. + // For more info, see: https://kysely.dev/docs/migrations + await db.schema + .dropTable('sounds') + .ifExists() + .cascade() + .execute() +} diff --git a/src/as/dsp/preview-worker.ts b/src/as/dsp/preview-worker.ts index 14334f0..1701205 100644 --- a/src/as/dsp/preview-worker.ts +++ b/src/as/dsp/preview-worker.ts @@ -43,6 +43,8 @@ const worker = { async createDsp(sampleRate: number) { const dsp = this.dsp = createDsp(sampleRate, wasm as unknown as typeof WasmExports, wasm.memory) const view = this.view = getMemoryView(wasm.memory) + wasm.__pin(dsp.sound$) + wasm.__pin(dsp.player$) this.clock = Clock(wasm.memory.buffer, dsp.clock$) this.tracks = setupTracks( view, diff --git a/src/comp/Login.tsx b/src/comp/Login.tsx index 99b31b4..9653ccb 100644 --- a/src/comp/Login.tsx +++ b/src/comp/Login.tsx @@ -18,7 +18,7 @@ export function Login() { const userLogin = parseForm(ev.target, UserLogin) actions .login(userLogin) - .then(actions.loginUser) + .then(actions.loginUserSession) .catch(err => info.error = err.message) return false } diff --git a/src/comp/OAuthLogin.tsx b/src/comp/OAuthLogin.tsx index 2df57ee..6d5b697 100644 --- a/src/comp/OAuthLogin.tsx +++ b/src/comp/OAuthLogin.tsx @@ -1,6 +1,5 @@ import { on } from 'utils' -import { whoami } from '~/src/rpc/auth.ts' -import { state } from '~/src/state.ts' +import { loginUserSession, maybeLogin, whoami } from '~/src/rpc/auth.ts' import { Button } from '~/src/ui/index.ts' export function OAuthLogin() { @@ -23,7 +22,7 @@ export function OAuthLogin() { on(window, 'storage', () => { popup!.close() if (localStorage.oauth?.startsWith('complete')) { - whoami().then(user => state.user = user) + maybeLogin() } else { alert('OAuth failed.\n\nTry logging in using a different method.') diff --git a/src/comp/Register.tsx b/src/comp/Register.tsx index 2bbebd2..e8e5588 100644 --- a/src/comp/Register.tsx +++ b/src/comp/Register.tsx @@ -15,7 +15,7 @@ export function Register() { ev.preventDefault() actions .register(parseForm(ev.target, UserRegister)) - .then(actions.loginUser) + .then(actions.loginUserSession) .catch(err => info.error = err.message) return false } diff --git a/src/comp/ResetPassword.tsx b/src/comp/ResetPassword.tsx index ff73e04..62f1adb 100644 --- a/src/comp/ResetPassword.tsx +++ b/src/comp/ResetPassword.tsx @@ -19,7 +19,7 @@ export function ResetPassword() { .changePassword(token, password) .then(session => { go('/') - actions.loginUser(session) + actions.loginUserSession(session) }) .catch(err => info.error = err.message) return false diff --git a/src/pages/App.tsx b/src/pages/App.tsx index 7de746b..3788767 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -10,23 +10,26 @@ import { About } from '~/src/pages/About.tsx' import { AssemblyScript } from '~/src/pages/AssemblyScript.tsx' import { CanvasDemo } from '~/src/pages/CanvasDemo' import { Chat } from '~/src/pages/Chat/Chat.tsx' +import { CreateProfile } from '~/src/pages/CreateProfile.tsx' import { DspNodeDemo } from '~/src/pages/DspNodeDemo.tsx' import { EditorDemo } from '~/src/pages/EditorDemo.tsx' import { Home } from '~/src/pages/Home.tsx' import { OAuthRegister } from '~/src/pages/OAuthRegister.tsx' +import { Profile } from '~/src/pages/Profile.tsx' import { QrCode } from '~/src/pages/QrCode.tsx' +import { Settings } from '~/src/pages/Settings.tsx' import { UiShowcase } from '~/src/pages/UiShowcase.tsx' import { WebGLDemo } from '~/src/pages/WebGLDemo.tsx' import { WebSockets } from '~/src/pages/WebSockets.tsx' import { WorkerWorkletDemo } from '~/src/pages/WorkerWorklet/WorkerWorkletDemo' -import { whoami } from '~/src/rpc/auth.ts' +import { maybeLogin } from '~/src/rpc/auth.ts' import { state, triggers } from '~/src/state.ts' import { go, Link } from '~/src/ui/Link.tsx' export function App() { using $ = Sigui() - if (!state.user) whoami().then(user => state.user = user) + maybeLogin() const info = $({ bg: 'transparent', @@ -47,6 +50,8 @@ export function App() { '/asc': () => , '/qrcode': () => , '/about': () => , + '!/settings': () => , + '/create-profile': () => , '/verify-email': () => , '/reset-password': () => , '/oauth/popup': () => { @@ -104,7 +109,8 @@ export function App() {
- `/${state.user?.nick ?? ''}`}>{() => state.user?.nick} + `/${state.user?.defaultProfile}`}>Profile + {() => state.user?.nick} {() => state.user ? go('/')} /> :
}
@@ -116,7 +122,28 @@ export function App() { const el = router(state.pathname) if (el) return el - return
404 Not found
+ return + + // const loading =
Loading...
as HTMLDivElement + // const notFound =
404 Not found
as HTMLDivElement + + // const profileNick = state.pathname.slice(1) + + // if (!state.profile || state.profile.nick !== profileNick) { + // state.isFetchingProfile = true + // getProfile(profileNick) + // .then(profile => { + // state.profile = profile + // }) + // .catch(error => { + // console.warn(error) + // state.isFetchingProfile = false + // }) + // return state.isFetchingProfile ? loading : notFound + // } + // else { + // return + // } }} diff --git a/src/pages/CreateProfile.tsx b/src/pages/CreateProfile.tsx new file mode 100644 index 0000000..4d5ed2d --- /dev/null +++ b/src/pages/CreateProfile.tsx @@ -0,0 +1,68 @@ +import { Sigui } from 'sigui' +import { ProfileCreate } from '~/api/profiles/types.ts' +import * as actions from '~/src/rpc/profiles.ts' +import { state } from '~/src/state.ts' +import { Button, Fieldset, go, Input, Label } from '~/src/ui/index.ts' +import { parseForm } from '~/src/util/parse-form.ts' + +export function CreateProfile() { + using $ = Sigui() + + const info = $({ + error: '' + }) + + function createProfile(ev: Event & { target: HTMLFormElement }) { + ev.preventDefault() + if (!state.user) return false + + const formData = parseForm(ev.target, ProfileCreate) + if (formData.isDefault) { + state.user.defaultProfile = formData.nick + } + + actions + .createProfile(formData) + .then(profile => go('/' + profile.nick)) + .catch(err => info.error = err.message) + return false + } + + return
+
+
+ + + + + + +
+ +
+ + {() => info.error} +
+
+
+ +} diff --git a/src/pages/DspNodeDemo.tsx b/src/pages/DspNodeDemo.tsx index 6742f33..9146975 100644 --- a/src/pages/DspNodeDemo.tsx +++ b/src/pages/DspNodeDemo.tsx @@ -2,9 +2,10 @@ import { BUFFER_SIZE, createDspNode, PreviewService, SoundValue } from 'dsp' import { Gfx, Matrix, Rect, wasm as wasmGfx } from 'gfx' import type { Token } from 'lang' import { Sigui } from 'sigui' -import { Button, Canvas } from 'ui' +import { Button, Canvas, go, Link } from 'ui' import { assign, Lru, throttle } from 'utils' import { DspEditor } from '~/src/comp/DspEditor.tsx' +import { getSound, publishSound } from '~/src/rpc/sounds.ts' import { screen } from '~/src/screen.ts' import { state } from '~/src/state.ts' import { ListMarkWidget, RmsDecoWidget, WaveGlDecoWidget } from '~/src/ui/editor/widgets/index.ts' @@ -54,8 +55,10 @@ export function DspNodeDemo() { using $ = Sigui() const info = $({ - get width() { return screen.lg ? state.containerWidth / 2 : state.containerWidth }, - get height() { return screen.lg ? state.containerHeight : state.containerHeight / 2 }, + width: 500, + height: 500, + // get width() { return screen.lg ? state.containerWidth / 2 : state.containerWidth }, + // get height() { return screen.lg ? state.containerHeight : state.containerHeight / 2 }, code: `t 4* y= [saw (35 38 42 40) 4 [sin 1 co* t 4/]* ? ntof] [exp .25 y 8 /] [lp 9.15] .5^ * .27 * [slp 616 9453 [exp .4 y 4/ ] [lp 88.91] 1.35^ * + .9] `, @@ -68,6 +71,42 @@ export function DspNodeDemo() { previewValues: [] as SoundValue[], previewScalars: new Float32Array(), error: null as null | Error, + creatorNick: null as null | string, + creatorName: '', + title: '(unsaved)', + isPublished: false, + isLoadingSound: false, + }) + + $.fx(() => { + const { searchParams } = state + $().then(async () => { + if (searchParams.has('sound')) { + $.batch(() => { + info.isPublished = false + info.isLoadingSound = true + info.title = '' + info.codeWorking = null + }) + const { sound: soundId } = Object.fromEntries(searchParams) + const { sound, creator } = await getSound(soundId) + $.batch(() => { + info.isLoadingSound = false + info.isPublished = true + info.title = sound.title + info.code = sound.code + info.creatorNick = creator.nick + info.creatorName = creator.displayName + }) + } + else { + $.batch(() => { + info.isPublished = false + info.creatorNick = null + info.title = '' + }) + } + }) }) const ctx = new AudioContext({ sampleRate: 48000, latencyHint: 0.00001 }) @@ -313,11 +352,32 @@ export function DspNodeDemo() { return () => dspEditor.info.error = null }) - return
- - {dspEditor} - {canvas} + return
+ {() => info.isLoadingSound && !info.codeWorking + ?
Loading...
+ :
+
{() => [ +
{ + () => info.creatorNick && + {info.creatorName} / + } { + () => info.isPublished && info.title + }
, + + !info.isPublished && , + + + ]}
+ {dspEditor} + {canvas} +
+ }
} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 254abad..a4723d2 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -32,6 +32,7 @@ export function Home() { Worker-Worklet AssemblyScript QrCode + Create Profile About
} diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx new file mode 100644 index 0000000..6a657f6 --- /dev/null +++ b/src/pages/Profile.tsx @@ -0,0 +1,100 @@ +import { Sigui } from 'sigui' +import { Button, go, H2, Link } from 'ui' +import type { z } from 'zod' +import type { Profiles } from '~/api/models.ts' +import { DspNodeDemo } from '~/src/pages/DspNodeDemo.tsx' +import { deleteProfile, getProfile, makeDefaultProfile } from '~/src/rpc/profiles.ts' +import { listSounds } from '~/src/rpc/sounds.ts' +import { state } from '~/src/state.ts' + +export function Profile() { + using $ = Sigui() + + const info = $({ + profile: null as null | false | z.infer, + sounds: null as null | false | Awaited>, + }) + + const profileNick = state.pathname.slice(1) + + $.fx(() => { + $().then(async () => { + try { + info.profile = await getProfile(profileNick) + } + catch (error) { + console.warn(error) + info.profile = false + } + }) + }) + + $.fx(() => { + const { profile } = $.of(info) + if (profile === false) return + $().then(async () => { + try { + info.sounds = await listSounds(profile.nick) + } + catch (error) { + console.warn(error) + info.sounds = false + } + }) + }) + + return
{ + () => { + const { user } = state + const { profile } = info + if (profile == null) return
Loading profile...
+ else if (profile === false) return
404 Not Found
+ return
+

+ {profile.displayName} + +
{() => user && user.nick === profile.ownerNick ? +
+
{() => user.defaultProfile !== profile.nick ? + :
}
+ + +
:
} +
+

+ + +
+
{ + () => { + const { sounds } = info + if (sounds == null) return
Loading sounds...
+ else if (sounds === false) return
Failed to load sounds
+ return
{ + () => !sounds.length ?
No sounds yet.
: sounds.map(sound => +
+ {sound.title} +
) + }
+ } + }
+ +
+ {() => { $(); return }} +
+
+ + +
+ } + } +
+} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx new file mode 100644 index 0000000..e9c0688 --- /dev/null +++ b/src/pages/Settings.tsx @@ -0,0 +1,42 @@ +import { Sigui } from 'sigui' +import { H2, H3, Link } from 'ui' +import type { z } from 'zod' +import type { Profiles } from '~/api/models.ts' +import { listProfilesForNick } from '~/src/rpc/profiles.ts' +import { state } from '~/src/state.ts' + +export function Settings() { + using $ = Sigui() + + const info = $({ + profiles: [] as z.infer[], + }) + + $.fx(() => { + const { user } = $.of(state) + const { nick } = user + $().then(async () => { + info.profiles = await listProfilesForNick(nick) + }) + }) + + return
+

Settings Page

+ +
+

+ Profiles +
+ Create New Profile +
+

+
{ + () => info.profiles.map(profile =>
+ + {() => profile.displayName} {() => state.user && profile.nick === state.user.defaultProfile ? '(default)' : ''} + +
) + }
+
+
+} diff --git a/src/rpc/auth.ts b/src/rpc/auth.ts index da3bec8..2eb542e 100644 --- a/src/rpc/auth.ts +++ b/src/rpc/auth.ts @@ -1,3 +1,4 @@ +import { $ } from 'sigui' import type * as actions from '~/api/auth/actions.ts' import type { UserSession } from '~/api/auth/types.ts' import { rpc } from '~/lib/rpc.ts' @@ -16,6 +17,20 @@ export const forgotPassword = rpc('POST', 'forgot export const getResetPasswordUserNick = rpc('GET', 'getResetPasswordUserNick') export const changePassword = rpc('POST', 'changePassword') -export function loginUser(session: UserSession) { - state.user = session +export function loginUserSession(userSession: UserSession | null) { + if (!userSession) throw new Error('No user session') + state.user = userSession ? $(userSession) : userSession +} + +export async function maybeLogin(orRedirect?: string) { + if (state.user) return + try { + const userSession = await whoami() + return loginUserSession(userSession) + } + catch (error) { + console.warn(error) + state.user = null + if (orRedirect) location.href = orRedirect + } } diff --git a/src/rpc/profiles.ts b/src/rpc/profiles.ts new file mode 100644 index 0000000..a227980 --- /dev/null +++ b/src/rpc/profiles.ts @@ -0,0 +1,8 @@ +import type * as actions from '~/api/profiles/actions.ts' +import { rpc } from '~/lib/rpc.ts' + +export const createProfile = rpc('POST', 'createProfile') +export const makeDefaultProfile = rpc('POST', 'makeDefaultProfile') +export const getProfile = rpc('GET', 'getProfile') +export const listProfilesForNick = rpc('GET', 'listProfilesForNick') +export const deleteProfile = rpc('POST', 'deleteProfile') diff --git a/src/rpc/sounds.ts b/src/rpc/sounds.ts new file mode 100644 index 0000000..6c98ceb --- /dev/null +++ b/src/rpc/sounds.ts @@ -0,0 +1,6 @@ +import type * as actions from '~/api/sounds/actions.ts' +import { rpc } from '~/lib/rpc.ts' + +export const publishSound = rpc('POST', 'publishSound') +export const listSounds = rpc('GET', 'listSounds') +export const getSound = rpc('GET', 'getSound') diff --git a/src/state.ts b/src/state.ts index 962fdf7..127e0ae 100644 --- a/src/state.ts +++ b/src/state.ts @@ -2,7 +2,7 @@ import { $, storage } from 'sigui' import type { z } from 'zod' import type { UserSession } from '~/api/auth/types.ts' import type { UiChannel } from '~/api/chat/types.ts' -import type { Channels } from '~/api/models.ts' +import type { Channels, Profiles } from '~/api/models.ts' import { lorem, loremRandomWord } from '~/lib/lorem.ts' import { AnimMode } from '~/src/as/gfx/anim.ts' import { env } from '~/src/env.ts' @@ -63,7 +63,7 @@ class State { } // app - user?: UserSession | null + user?: $ | null channelsList: Pick, 'name'>[] = [] channels: UiChannel[] = [] diff --git a/src/ui/Label.tsx b/src/ui/Label.tsx index d401ed3..19126a2 100644 --- a/src/ui/Label.tsx +++ b/src/ui/Label.tsx @@ -1,8 +1,10 @@ export function Label({ text, children }: { text: string, children?: any }) { - return