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/admin/Admin.tsx b/admin/Admin.tsx index 2211a4e..43c96be 100644 --- a/admin/Admin.tsx +++ b/admin/Admin.tsx @@ -4,8 +4,7 @@ import * as actions from '~/admin/rpc/admin.ts' 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 +82,7 @@ function Table]>({ } export function Admin() { - if (!state.user) whoami().then(user => { - if (!user) location.href = '/' - else state.user = user - }) - + maybeLogin('/') return
@@ -97,7 +92,7 @@ export function Admin() {
{state.user?.nick} - location.href = '/'} /> +
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 db71bff..aabbb02 100644 --- a/api/models.ts +++ b/api/models.ts @@ -31,6 +31,17 @@ export interface ChannelUser { nick: string; } +export const Favorites = z.object({ + profileNick: z.string(), + soundId: z.string(), + createdAt: z.coerce.date(), +}) +export interface Favorites { + createdAt: Generated; + profileNick: string; + soundId: string; +} + export const Messages = z.object({ id: z.string(), channel: z.string(), @@ -48,6 +59,46 @@ export interface Messages { type: string; } +export const Profiles = z.object({ + ownerNick: 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; + 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(), + remixOf: z.string().nullish(), +}) +export interface Sounds { + code: string; + createdAt: Generated; + id: Generated; + ownerProfileNick: string; + remixOf: string | null; + title: string; + updatedAt: Generated; +} + export const Users = z.object({ nick: z.string(), email: z.string(), @@ -56,9 +107,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; @@ -70,12 +123,18 @@ export interface Users { export const DB = z.object({ channels: Channels, channelUser: ChannelUser, + favorites: Favorites, messages: Messages, + profiles: Profiles, + sounds: Sounds, users: Users, }) export interface DB { channels: Channels; channelUser: ChannelUser; + favorites: Favorites; 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..dd6b5f0 --- /dev/null +++ b/api/sounds/actions.ts @@ -0,0 +1,144 @@ +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' +import { Sounds, Profiles } from '~/api/models.ts' +import type { z } from 'zod' + +actions.post.publishSound = publishSound +export async function publishSound(ctx: Context, title: string, code: string, remixOf?: 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, remixOf }) + .returning('id') + .executeTakeFirstOrThrow() +} + +actions.post.overwriteSound = overwriteSound +export async function overwriteSound(ctx: Context, id: string, title: string, code: string) { + const session = getSession(ctx) + const { defaultProfile } = session + if (!defaultProfile) throw new RouteError(401, 'No default profile set') + + await db + .updateTable('sounds') + .set({ title, code }) + .where('id', '=', id) + .where('ownerProfileNick', '=', defaultProfile) + .executeTakeFirstOrThrow() +} + +actions.post.deleteSound = deleteSound +export async function deleteSound(ctx: Context, id: string) { + const session = getSession(ctx) + const { defaultProfile } = session + if (!defaultProfile) throw new RouteError(401, 'No default profile set') + + await db + .deleteFrom('sounds') + .where('id', '=', id) + .where('ownerProfileNick', '=', defaultProfile) + .executeTakeFirstOrThrow() +} + +actions.get.listSounds = listSounds +export async function listSounds(_ctx: Context, nick: string) { + return await db + .selectFrom('sounds') + .innerJoin('profiles', 'sounds.ownerProfileNick', 'profiles.nick') + .select(['sounds.id', 'sounds.title', 'sounds.remixOf', 'sounds.ownerProfileNick as profileNick', 'profiles.displayName as profileDisplayName']) + .where('sounds.ownerProfileNick', '=', nick) + .orderBy('sounds.createdAt', 'desc') + .execute() +} + +actions.get.listRecentSounds = listRecentSounds +export async function listRecentSounds(_ctx: Context) { + return await db + .selectFrom('sounds') + .innerJoin('profiles', 'sounds.ownerProfileNick', 'profiles.nick') + .select(['sounds.id', 'sounds.title', 'sounds.remixOf', 'sounds.ownerProfileNick as profileNick', 'profiles.displayName as profileDisplayName']) + .orderBy('sounds.createdAt', 'desc') + .limit(20) + .execute() +} + +export type GetSoundResult = { + sound: z.infer + creator: z.infer + remixOf: null | GetSoundResult +} + +actions.get.getSound = getSound +export async function getSound(ctx: Context, id: string): Promise { + const sound = await db + .selectFrom('sounds') + .selectAll() + .where('id', '=', id) + .executeTakeFirstOrThrow() + + const creator = await db + .selectFrom('profiles') + .selectAll() + .where('nick', '=', sound.ownerProfileNick) + .executeTakeFirstOrThrow() + + const remixOf = sound.remixOf + ? await getSound(ctx, sound.remixOf) + : null + + return { sound, creator, remixOf } +} + +actions.post.addSoundToFavorites = addSoundToFavorites +export async function addSoundToFavorites(ctx: Context, soundId: string) { + const session = getSession(ctx) + const { defaultProfile } = session + if (!defaultProfile) throw new RouteError(401, 'No default profile set') + + await db + .insertInto('favorites') + .values({ profileNick: defaultProfile, soundId }) + .execute() +} + +actions.post.removeSoundFromFavorites = removeSoundFromFavorites +export async function removeSoundFromFavorites(ctx: Context, soundId: string) { + const session = getSession(ctx) + const { defaultProfile } = session + if (!defaultProfile) throw new RouteError(401, 'No default profile set') + + await db + .deleteFrom('favorites') + .where('profileNick', '=', defaultProfile) + .where('soundId', '=', soundId) + .execute() +} + +actions.get.listFavorites = listFavorites +export async function listFavorites(ctx: Context, nick?: string) { + if (nick == null) { + const session = getSession(ctx) + const { defaultProfile } = session + if (!defaultProfile) throw new RouteError(401, 'No default profile set') + nick = defaultProfile + } + + return await db + .selectFrom('favorites') + .innerJoin('sounds', 'favorites.soundId', 'sounds.id') + .innerJoin('profiles', 'sounds.ownerProfileNick', 'profiles.nick') + .select([ + 'sounds.id', + 'sounds.title', + 'sounds.ownerProfileNick as profileNick', + 'profiles.displayName as profileDisplayName' + ]) + .where('favorites.profileNick', '=', nick) + .orderBy('favorites.createdAt', 'desc') + .execute() +} diff --git a/as/assembly/dsp/gen/bno.ts b/as/assembly/dsp/gen/bno.ts index f5df701..66d232b 100644 --- a/as/assembly/dsp/gen/bno.ts +++ b/as/assembly/dsp/gen/bno.ts @@ -1,6 +1,7 @@ import { Biquad } from './biquad' export class Bno extends Biquad { + _name: string = 'Bno' cut: f32 = 500 q: f32 = 0.5 diff --git a/as/assembly/dsp/gen/bpk.ts b/as/assembly/dsp/gen/bpk.ts index 5b44060..3b1e179 100644 --- a/as/assembly/dsp/gen/bpk.ts +++ b/as/assembly/dsp/gen/bpk.ts @@ -1,6 +1,7 @@ import { Biquad } from './biquad' export class Bpk extends Biquad { + _name: string = 'Bpk' cut: f32 = 500 q: f32 = 0.5 amt: f32 = 1 diff --git a/as/assembly/dsp/gen/clamp.ts b/as/assembly/dsp/gen/clamp.ts index 3ed554f..3e8fca6 100644 --- a/as/assembly/dsp/gen/clamp.ts +++ b/as/assembly/dsp/gen/clamp.ts @@ -1,6 +1,7 @@ import { Gen } from './gen' export class Clamp extends Gen { + _name: string = 'Clamp' min: f32 = -0.5; max: f32 = 0.5; in: u32 = 0 diff --git a/as/assembly/dsp/gen/comp.ts b/as/assembly/dsp/gen/comp.ts index e2696e6..953f60f 100644 --- a/as/assembly/dsp/gen/comp.ts +++ b/as/assembly/dsp/gen/comp.ts @@ -1,6 +1,7 @@ import { Gen } from './gen' export class Comp extends Gen { + _name: string = 'Comp' threshold: f32 = 0.7 ratio: f32 = 1 / 3 attack: f32 = 0.01 diff --git a/as/assembly/dsp/gen/daverb.ts b/as/assembly/dsp/gen/daverb.ts index 1be79fe..f444f2d 100644 --- a/as/assembly/dsp/gen/daverb.ts +++ b/as/assembly/dsp/gen/daverb.ts @@ -74,6 +74,7 @@ const ro5: u32 = u32(0.011256342 * 48000) const ro6: u32 = u32(0.004065724 * 48000) export class Daverb extends Gen { + _name: string = 'Daverb'; in: u32 = 0 pd: f32 = 0.03 diff --git a/as/assembly/dsp/gen/dcc.ts b/as/assembly/dsp/gen/dcc.ts index 30035f8..f41c0c5 100644 --- a/as/assembly/dsp/gen/dcc.ts +++ b/as/assembly/dsp/gen/dcc.ts @@ -1,6 +1,7 @@ import { Gen } from './gen' export class Dcc extends Gen { + _name: string = 'Dcc' ceil: f32 = 0.2; in: u32 = 0 diff --git a/as/assembly/dsp/gen/dclip.ts b/as/assembly/dsp/gen/dclip.ts index fd335f3..55e8cba 100644 --- a/as/assembly/dsp/gen/dclip.ts +++ b/as/assembly/dsp/gen/dclip.ts @@ -1,6 +1,7 @@ import { Gen } from './gen' export class Dclip extends Gen { + _name: string = 'Dclip'; in: u32 = 0 _audio(begin: u32, end: u32, out: usize): void { diff --git a/as/assembly/dsp/gen/dcliplin.ts b/as/assembly/dsp/gen/dcliplin.ts index 5a80fc5..abfe9fe 100644 --- a/as/assembly/dsp/gen/dcliplin.ts +++ b/as/assembly/dsp/gen/dcliplin.ts @@ -1,6 +1,7 @@ import { Gen } from './gen' export class Dcliplin extends Gen { + _name: string = 'Dcliplin'; threshold: f32 = 0.5; factor: f32 = 0.5; in: u32 = 0 diff --git a/as/assembly/dsp/gen/delay.ts b/as/assembly/dsp/gen/delay.ts index a7febee..003bad3 100644 --- a/as/assembly/dsp/gen/delay.ts +++ b/as/assembly/dsp/gen/delay.ts @@ -3,6 +3,7 @@ import { DELAY_MAX_SIZE } from '../core/constants' import { Gen } from './gen' export class Delay extends Gen { + _name: string = 'Delay' ms: f32 = 200; fb: f32 = 0.5; diff --git a/as/assembly/dsp/gen/diode.ts b/as/assembly/dsp/gen/diode.ts index 8607fa8..2dc6987 100644 --- a/as/assembly/dsp/gen/diode.ts +++ b/as/assembly/dsp/gen/diode.ts @@ -14,6 +14,7 @@ function clamp(min: f32, max: f32, value: f32): f32 { @inline const PI2 = Mathf.PI * 2.0 export class Diode extends Gen { + _name: string = 'Diode' cut: f32 = 500; hpf: f32 = 1000; sat: f32 = 1.0; diff --git a/as/assembly/dsp/gen/freesound.ts b/as/assembly/dsp/gen/freesound.ts index bb4a41b..fe6bd8a 100644 --- a/as/assembly/dsp/gen/freesound.ts +++ b/as/assembly/dsp/gen/freesound.ts @@ -1,6 +1,7 @@ import { Smp } from './smp' export class Freesound extends Smp { + _name: string = 'Freesound'; id: i32 = 0 _update(): void { diff --git a/as/assembly/dsp/gen/gendy.ts b/as/assembly/dsp/gen/gendy.ts index 9d04bc7..0920c87 100644 --- a/as/assembly/dsp/gen/gendy.ts +++ b/as/assembly/dsp/gen/gendy.ts @@ -2,6 +2,7 @@ import { rnd } from '../../util' import { Gen } from './gen' export class Gendy extends Gen { + _name: string = 'Gendy' step: f32 = 0.00001 _audio(begin: u32, end: u32, out: usize): void { diff --git a/as/assembly/dsp/gen/grain.ts b/as/assembly/dsp/gen/grain.ts index f275322..eb7a33b 100644 --- a/as/assembly/dsp/gen/grain.ts +++ b/as/assembly/dsp/gen/grain.ts @@ -2,6 +2,7 @@ import { Gen } from './gen' import { rnd } from '../../util' export class Grain extends Gen { + _name: string = 'Grain' amt: f32 = 1.0; _audio(begin: u32, end: u32, out: usize): void { diff --git a/as/assembly/dsp/gen/inc.ts b/as/assembly/dsp/gen/inc.ts index 81964f6..883960e 100644 --- a/as/assembly/dsp/gen/inc.ts +++ b/as/assembly/dsp/gen/inc.ts @@ -1,6 +1,7 @@ import { Gen } from './gen' export class Inc extends Gen { + _name: string = 'Inc' amt: f32 = 1.0; /** Trigger phase sync when set to 0. */ diff --git a/as/assembly/dsp/gen/mhp.ts b/as/assembly/dsp/gen/mhp.ts index d0ee8d6..2766064 100644 --- a/as/assembly/dsp/gen/mhp.ts +++ b/as/assembly/dsp/gen/mhp.ts @@ -1,6 +1,7 @@ import { Moog } from './moog' export class Mhp extends Moog { + _name: string = 'Mhp' cut: f32 = 500 q: f32 = 0.5 diff --git a/as/assembly/dsp/gen/mlp.ts b/as/assembly/dsp/gen/mlp.ts index 2b5a6c2..20cb77e 100644 --- a/as/assembly/dsp/gen/mlp.ts +++ b/as/assembly/dsp/gen/mlp.ts @@ -1,6 +1,7 @@ import { Moog } from './moog' export class Mlp extends Moog { + _name: string = 'Mlp' cut: f32 = 500 q: f32 = 0.5 diff --git a/as/assembly/dsp/gen/moog.ts b/as/assembly/dsp/gen/moog.ts index 67af059..53b9b76 100644 --- a/as/assembly/dsp/gen/moog.ts +++ b/as/assembly/dsp/gen/moog.ts @@ -9,6 +9,7 @@ function tanha(x: f32): f32 { // https://github.com/mixxxdj/mixxx/blob/main/src/engine/filters/enginefiltermoogladder4.h export class Moog extends Gen { + _name: string = 'Moog'; in: u32 = 0 _m_azt1: f32 = 0 @@ -65,25 +66,25 @@ export class Moog extends Gen { } @inline _process(x0: f32): void { - this._x1 = x0 - this._m_amf * this._m_kacr; - const az1: f32 = this._m_azt1 + this._m_k2vg * tanha(this._x1 / this._v2); - const at1: f32 = this._m_k2vg * tanha(az1 / this._v2); + this._x1 = x0 - this._m_amf * this._m_kacr + const az1: f32 = this._m_azt1 + this._m_k2vg * tanha(this._x1 / this._v2) + const at1: f32 = this._m_k2vg * tanha(az1 / this._v2) this._m_azt1 = az1 - at1 const az2 = this._m_azt2 + at1 const at2 = this._m_k2vg * tanha(az2 / this._v2) this._m_azt2 = az2 - at2 - this._az3 = this._m_azt3 + at2; - const at3 = this._m_k2vg * tanha(this._az3 / this._v2); + this._az3 = this._m_azt3 + at2 + const at3 = this._m_k2vg * tanha(this._az3 / this._v2) this._m_azt3 = this._az3 - at3 - this._az4 = this._m_azt4 + at3; - const at4 = this._m_k2vg * tanha(this._az4 / this._v2); - this._m_azt4 = this._az4 - at4; + this._az4 = this._m_azt4 + at3 + const at4 = this._m_k2vg * tanha(this._az4 / this._v2) + this._m_azt4 = this._az4 - at4 // this is for oversampling but we're not doing it here yet, see link - this._m_amf = this._az4; + this._m_amf = this._az4 } @inline _lowpass(): f32 { diff --git a/as/assembly/dsp/gen/ramp.ts b/as/assembly/dsp/gen/ramp.ts index 95a6aa3..013154d 100644 --- a/as/assembly/dsp/gen/ramp.ts +++ b/as/assembly/dsp/gen/ramp.ts @@ -1,6 +1,7 @@ import { Aosc } from './aosc' export class Ramp extends Aosc { + _name: string = 'Ramp' get _tables(): StaticArray> { return this._engine.wavetable.antialias.ramp } diff --git a/as/assembly/dsp/gen/rate.ts b/as/assembly/dsp/gen/rate.ts index fc06c35..777c22a 100644 --- a/as/assembly/dsp/gen/rate.ts +++ b/as/assembly/dsp/gen/rate.ts @@ -3,6 +3,7 @@ import { Engine } from '../core/engine' import { Gen } from './gen' export class Rate extends Gen { + _name: string = 'Rate' samples: f32 constructor(public _engine: Engine) { super(_engine) diff --git a/as/assembly/dsp/gen/sap.ts b/as/assembly/dsp/gen/sap.ts index a5d6925..eae7294 100644 --- a/as/assembly/dsp/gen/sap.ts +++ b/as/assembly/dsp/gen/sap.ts @@ -1,6 +1,7 @@ import { Svf } from './svf' export class Sap extends Svf { + _name: string = 'Sap' cut: f32 = 500 q: f32 = 0.5 diff --git a/as/assembly/dsp/gen/say.ts b/as/assembly/dsp/gen/say.ts index a64385a..4d24c70 100644 --- a/as/assembly/dsp/gen/say.ts +++ b/as/assembly/dsp/gen/say.ts @@ -1,6 +1,7 @@ import { Smp } from './smp' export class Say extends Smp { + _name: string = 'Say' text: i32 = 0 _update(): void { diff --git a/as/assembly/dsp/gen/smp.ts b/as/assembly/dsp/gen/smp.ts index 0e6fb17..269f126 100644 --- a/as/assembly/dsp/gen/smp.ts +++ b/as/assembly/dsp/gen/smp.ts @@ -2,6 +2,7 @@ import { clamp, clamp64, cubicMod } from '../../util' import { Gen } from './gen' export class Smp extends Gen { + _name: string = 'Smp' offset: f32 = 0 length: f32 = 1 @@ -44,7 +45,7 @@ export class Smp extends Gen { const floats: StaticArray | null = this._floats if (!floats) return - const length: u32 = u32(Math.floor(f64(clamp(0, 1, 1, this.length) ) * f64(floats.length))) + const length: u32 = u32(Math.floor(f64(clamp(0, 1, 1, this.length)) * f64(floats.length))) let offsetCurrent: f64 = f64(clamp64(0, 1, 0, this._offsetCurrent)) const offsetTarget: f64 = f64(clamp64(0, 1, 0, this._offsetTarget)) diff --git a/as/assembly/dsp/gen/spk.ts b/as/assembly/dsp/gen/spk.ts index b7565c9..863ecd9 100644 --- a/as/assembly/dsp/gen/spk.ts +++ b/as/assembly/dsp/gen/spk.ts @@ -1,6 +1,7 @@ import { Svf } from './svf' export class Spk extends Svf { + _name: string = 'Spk' cut: f32 = 500 q: f32 = 0.5 diff --git a/as/assembly/dsp/gen/sqr.ts b/as/assembly/dsp/gen/sqr.ts index fa6d0f9..58d1ac0 100644 --- a/as/assembly/dsp/gen/sqr.ts +++ b/as/assembly/dsp/gen/sqr.ts @@ -1,6 +1,7 @@ import { Aosc } from './aosc' export class Sqr extends Aosc { + _name: string = 'Sqr' get _tables(): StaticArray> { return this._engine.wavetable.antialias.sqr } diff --git a/as/assembly/dsp/gen/svf.ts b/as/assembly/dsp/gen/svf.ts index 20fa872..a85e386 100644 --- a/as/assembly/dsp/gen/svf.ts +++ b/as/assembly/dsp/gen/svf.ts @@ -2,6 +2,7 @@ import { paramClamp } from '../../util' import { Gen } from './gen' export class Svf extends Gen { + _name: string = 'Svf'; in: u32 = 0 _c1: f64 = 0 diff --git a/as/assembly/dsp/gen/tanh.ts b/as/assembly/dsp/gen/tanh.ts index af85d34..48b22aa 100644 --- a/as/assembly/dsp/gen/tanh.ts +++ b/as/assembly/dsp/gen/tanh.ts @@ -1,6 +1,7 @@ import { Gen } from './gen' export class Tanh extends Gen { + _name: string = 'Tanh'; in: u32 = 0 _audio(begin: u32, end: u32, out: usize): void { diff --git a/as/assembly/dsp/gen/tanha.ts b/as/assembly/dsp/gen/tanha.ts index e368f04..40fc429 100644 --- a/as/assembly/dsp/gen/tanha.ts +++ b/as/assembly/dsp/gen/tanha.ts @@ -1,6 +1,7 @@ import { Gen } from './gen' export class Tanha extends Gen { + _name: string = 'Tanha'; in: u32 = 0 _gainv: v128 = f32x4.splat(1.0) diff --git a/as/assembly/dsp/gen/tap.ts b/as/assembly/dsp/gen/tap.ts index 98bbe29..7262b62 100644 --- a/as/assembly/dsp/gen/tap.ts +++ b/as/assembly/dsp/gen/tap.ts @@ -3,6 +3,7 @@ import { DELAY_MAX_SIZE } from '../core/constants' import { Gen } from './gen' export class Tap extends Gen { + _name: string = 'Tap' ms: f32 = 200; in: u32 = 0; diff --git a/as/assembly/dsp/gen/tri.ts b/as/assembly/dsp/gen/tri.ts index b6af520..14185e9 100644 --- a/as/assembly/dsp/gen/tri.ts +++ b/as/assembly/dsp/gen/tri.ts @@ -1,6 +1,7 @@ import { Aosc } from './aosc' export class Tri extends Aosc { + _name: string = 'Tri' get _tables(): StaticArray> { return this._engine.wavetable.antialias.tri } diff --git a/as/assembly/dsp/gen/zero.ts b/as/assembly/dsp/gen/zero.ts index 67e7e23..062e2e2 100644 --- a/as/assembly/dsp/gen/zero.ts +++ b/as/assembly/dsp/gen/zero.ts @@ -1,6 +1,7 @@ import { Gen } from './gen' export class Zero extends Gen { + _name: string = 'Zero' _audio(begin: u32, end: u32, out: usize): void { const zerov: v128 = f32x4.splat(0) diff --git a/as/assembly/dsp/vm/dsp.ts b/as/assembly/dsp/vm/dsp.ts index 6f90f8f..2b9c3fc 100644 --- a/as/assembly/dsp/vm/dsp.ts +++ b/as/assembly/dsp/vm/dsp.ts @@ -1,4 +1,4 @@ -import { Factory } from '../../../../generated/assembly/dsp-factory' +import { Ctors, Factory } from '../../../../generated/assembly/dsp-factory' import { Offsets } from '../../../../generated/assembly/dsp-offsets' import { modWrap } from '../../util' import { Gen } from '../gen/gen' @@ -14,17 +14,25 @@ export class Dsp { @inline CreateGen(snd: Sound, kind_index: i32): void { + // TODO: we need to generate code for Gen pools const Gen = Factory[kind_index] - let gen = Gen(snd.engine) + const genName = Ctors[kind_index] + // TODO: use pool to get gen + let gen: Gen | null = null //Gen(snd.engine) + for (let i = 0; i < snd.prevGens.length; i++) { const prevGen: Gen = snd.prevGens[i] - const isSameClass: boolean = prevGen._name === gen._name + const isSameClass: boolean = prevGen._name === genName if (isSameClass) { gen = prevGen + // TODO: put prev gens in pool snd.prevGens.splice(i, 1) break } } + if (gen === null) { + gen = Gen(snd.engine) + } snd.gens.push(gen) snd.offsets.push(Offsets[kind_index]) } diff --git a/as/assembly/dsp/vm/sound.ts b/as/assembly/dsp/vm/sound.ts index da526df..6684089 100644 --- a/as/assembly/dsp/vm/sound.ts +++ b/as/assembly/dsp/vm/sound.ts @@ -13,20 +13,6 @@ const enum Globals { co, } -export function ntof(n: f32): f32 { - return 440 * 2 ** ((n - 69) / 12) -} - -// { n= 2 n 69 - 12 / ^ 440 * } ntof= -// export class SoundValue { -// constructor( -// public kind: SoundValueKind, -// public ptr: i32, -// ) { } -// scalar$: i32 = 0 -// audio$: i32 = 0 -// } - export class Sound { constructor(public engine: Engine) { } @@ -61,11 +47,10 @@ export class Sound { @inline clear(): void { - this.prevGens = this.gens - this.gens = [] + while (this.gens.length) { + this.prevGens.push(this.gens.shift()) + } this.offsets = [] - // this.values = [] - // this.audios = [] } @inline diff --git a/as/assembly/util.ts b/as/assembly/util.ts index fba15a3..6548849 100644 --- a/as/assembly/util.ts +++ b/as/assembly/util.ts @@ -1,5 +1,9 @@ export type Floats = StaticArray +export function ntof(n: f32): f32 { + return 440 * 2 ** ((n - 69) / 12) +} + export function clamp255(x: f32): i32 { if (x > 255) x = 255 else if (x < 0) x = 0 diff --git a/asconfig-rms.json b/asconfig-rms.json index ab7db41..d152769 100644 --- a/asconfig-rms.json +++ b/asconfig-rms.json @@ -26,8 +26,8 @@ ], "sharedMemory": true, "importMemory": false, - "initialMemory": 1, - "maximumMemory": 1, + "initialMemory": 10, + "maximumMemory": 10, "bindings": "raw", "runtime": false, "exportRuntime": false 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/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/index.html b/index.html index bbe1d8f..e28ad4c 100644 --- a/index.html +++ b/index.html @@ -18,7 +18,7 @@ /> - Vasi + ravescript - make music with code diff --git a/lib/cn.ts b/lib/cn.ts index fa7a272..e4481aa 100644 --- a/lib/cn.ts +++ b/lib/cn.ts @@ -9,7 +9,10 @@ export function cn(...args: any[]) { classes.push(arg()) } else if (arg) { - classes.push(Object.keys(arg).filter(k => arg[k]).join(' ')) + classes.push(Object.keys(arg).filter(k => { + const fnOrBooley = arg[k] + return typeof fnOrBooley === 'function' ? fnOrBooley() : fnOrBooley + }).join(' ')) } } return classes.join(' ') diff --git a/migrations/1729663196543_profiles-table.ts b/migrations/1729663196543_profiles-table.ts new file mode 100644 index 0000000..b64750d --- /dev/null +++ b/migrations/1729663196543_profiles-table.ts @@ -0,0 +1,54 @@ +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('ownerNick', '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_ownerNick_index') + .ifNotExists() + .on('profiles') + .column('ownerNick') + .execute() + + await db.schema + .createIndex('profiles_displayName_index') + .ifNotExists() + .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 { + // 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() + + 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/migrations/1729758260210_favorites-table.ts b/migrations/1729758260210_favorites-table.ts new file mode 100644 index 0000000..ca92b76 --- /dev/null +++ b/migrations/1729758260210_favorites-table.ts @@ -0,0 +1,36 @@ +import { sql, type Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('favorites') + .ifNotExists() + .addColumn('profileNick', 'text', col => col.references('profiles.nick').onDelete('cascade').notNull()) + .addColumn('soundId', 'uuid', col => col.references('sounds.id').onDelete('cascade').notNull()) + .addColumn('createdAt', 'timestamp', col => col.defaultTo(sql`now()`).notNull()) + .addPrimaryKeyConstraint('favorites_pkey', ['profileNick', 'soundId']) + .execute() + + // Index for faster lookups when querying a profile's favorites + await db.schema + .createIndex('favorites_profileId_idx') + .ifNotExists() + .on('favorites') + .column('profileNick') + .execute() + + // Index for faster lookups when querying who favorited a sound + await db.schema + .createIndex('favorites_soundId_idx') + .ifNotExists() + .on('favorites') + .column('soundId') + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema + .dropTable('favorites') + .ifExists() + .cascade() + .execute() +} diff --git a/migrations/1729771213713_sounds-remixOf-column.ts b/migrations/1729771213713_sounds-remixOf-column.ts new file mode 100644 index 0000000..fb32700 --- /dev/null +++ b/migrations/1729771213713_sounds-remixOf-column.ts @@ -0,0 +1,23 @@ +import 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 + + // Add remixOf column to sounds table + await db.schema + .alterTable('sounds') + .addColumn('remixOf', 'uuid', col => col.references('sounds.id')) + .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 + .alterTable('sounds') + .dropColumn('remixOf') + .execute() +} diff --git a/package.json b/package.json index 523135d..186b87a 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "autoprefixer": "^10.4.20", "kysely": "^0.27.4", "kysely-ctl": "^0.9.0", + "node-conditions": "^1.2.0", "open-in-editor": "^2.2.0", "postcss": "^8.4.47", "qrcode-terminal": "^0.12.0", diff --git a/src/as/dsp/dsp.ts b/src/as/dsp/dsp.ts index 64d9638..93a0060 100644 --- a/src/as/dsp/dsp.ts +++ b/src/as/dsp/dsp.ts @@ -1,6 +1,6 @@ import { getMemoryView } from 'utils' import { BUFFER_SIZE, MAX_AUDIOS, MAX_SCALARS, MAX_TRACKS, MAX_VALUES } from '~/as/assembly/dsp/constants.ts' -import type { __AdaptedExports as WasmExports } from '~/as/build/dsp-nort.d.ts' +import type { __AdaptedExports as WasmExports } from '~/as/build/dsp.d.ts' import { Out } from '~/src/as/dsp/shared.ts' export function createDsp(sampleRate: number, wasm: typeof WasmExports, memory: WebAssembly.Memory) { diff --git a/src/as/dsp/preview-worker.ts b/src/as/dsp/preview-worker.ts index 14334f0..44145f9 100644 --- a/src/as/dsp/preview-worker.ts +++ b/src/as/dsp/preview-worker.ts @@ -6,7 +6,6 @@ self.document = { import { assign, getMemoryView, rpc, type MemoryView } from 'utils' import { BUFFER_SIZE } from '~/as/assembly/dsp/constants.ts' -import type { __AdaptedExports as WasmExports } from '~/as/build/dsp-nort.d.ts' import { builds, getTokens, setupTracks } from '~/src/as/dsp/build.ts' import { createDsp } from '~/src/as/dsp/dsp.ts' import { Clock, type Track } from '~/src/as/dsp/shared.ts' @@ -41,8 +40,10 @@ const worker = { track: null as null | Track, error: null as null | Error, async createDsp(sampleRate: number) { - const dsp = this.dsp = createDsp(sampleRate, wasm as unknown as typeof WasmExports, wasm.memory) + const dsp = this.dsp = createDsp(sampleRate, wasm, 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, @@ -53,6 +54,7 @@ const worker = { dsp.lists$$, ) this.track = this.tracks[0] + wasm.__pin(this.track.ptr) return { memory: wasm.memory, L: dsp.L, diff --git a/src/as/dsp/worklet.ts b/src/as/dsp/worklet.ts index 8451931..b9b3979 100644 --- a/src/as/dsp/worklet.ts +++ b/src/as/dsp/worklet.ts @@ -62,7 +62,7 @@ async function setup({ sourcemapUrl }: SetupOptions) { const wasm: typeof WasmExports = instance.exports as any - const dsp = createDsp(sampleRate, wasm, memory) + const dsp = createDsp(sampleRate, wasm as any, memory) return { wasm, diff --git a/src/as/gfx/anim.ts b/src/as/gfx/anim.ts index 63dc2de..109e8bc 100644 --- a/src/as/gfx/anim.ts +++ b/src/as/gfx/anim.ts @@ -1,22 +1,17 @@ import { Sigui } from 'sigui' +import { AnimMode } from '~/src/constants.ts' import { state } from '~/src/state.ts' const DEBUG = false //true -export enum AnimMode { - Auto = 'auto', - On = 'on', - Off = 'off', -} - -const Modes = Object.values(AnimMode) - export type Anim = ReturnType export function Anim() { DEBUG && console.log('[anim] create') using $ = Sigui() + const Modes = Object.values(AnimMode) + const info = $({ isRunning: false, mode: state.$.animMode, diff --git a/src/comp/AuthModal.tsx b/src/comp/AuthModal.tsx new file mode 100644 index 0000000..267ae32 --- /dev/null +++ b/src/comp/AuthModal.tsx @@ -0,0 +1,47 @@ +import { $ } from 'sigui' +import { LoginOrRegister } from '~/src/comp/LoginOrRegister.tsx' +import { state } from '~/src/state.ts' + +export function showAuthModal() { + state.modalIsCancelled = false + + const off = $.fx(() => { + const { modalIsCancelled } = state + if (modalIsCancelled) { + off() + return + } + const { user } = state + $() + if (user == null) { + state.modal = + state.modalIsOpen = true + state.modalIsCancelled = false + } + else { + state.modal = null + state.modalIsOpen = false + queueMicrotask(() => off()) + } + }) +} + +export function wrapActionAuth(fn: () => void) { + return () => { + if (state.user) return fn() + + showAuthModal() + + const off = $.fx(() => { + const { modalIsCancelled } = state + if (modalIsCancelled) { + off() + return + } + const { user, favorites } = $.of(state) + $() + fn() + off() + }) + } +} diff --git a/src/comp/DspEditor.tsx b/src/comp/DspEditor.tsx index 1ee97d2..e51cb15 100644 --- a/src/comp/DspEditor.tsx +++ b/src/comp/DspEditor.tsx @@ -273,7 +273,13 @@ export function DspEditor({ code, width, height }: { inputHandlers, }) - const hoverMark = HoverMarkWidget(editor.info.pane.draw.shapes) + let hoverMark: HoverMarkWidget + $.fx(() => { + const { pane } = editor.info + $() + hoverMark?.dispose() + hoverMark = HoverMarkWidget(pane.draw.shapes) + }) const errorSub = ErrorSubWidget() $.fx(() => { diff --git a/src/pages/DspNodeDemo.tsx b/src/comp/DspEditorUi.tsx similarity index 74% rename from src/pages/DspNodeDemo.tsx rename to src/comp/DspEditorUi.tsx index 6742f33..605911b 100644 --- a/src/pages/DspNodeDemo.tsx +++ b/src/comp/DspEditorUi.tsx @@ -1,9 +1,11 @@ import { BUFFER_SIZE, createDspNode, PreviewService, SoundValue } from 'dsp' +import type { Pane } from 'editor' import { Gfx, Matrix, Rect, wasm as wasmGfx } from 'gfx' import type { Token } from 'lang' -import { Sigui } from 'sigui' -import { Button, Canvas } from 'ui' -import { assign, Lru, throttle } from 'utils' +import { $, dispose, Sigui } from 'sigui' +import { Canvas } from 'ui' +import { assign, dom, Lru, throttle } from 'utils' +import { cn } from '~/lib/cn.ts' import { DspEditor } from '~/src/comp/DspEditor.tsx' import { screen } from '~/src/screen.ts' import { state } from '~/src/state.ts' @@ -48,18 +50,50 @@ 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] */ + +const demo = `t 4* x= [sin 100.00 352 [exp 1.00 x] 31.88^ * + x] [exp 1.00 x] 6.26^ * [sno 83 .9] [dclipexp 1.088] [clip .40] +[saw (92 353 50 218 50 50 50 50) t 1* ? [sin 1 x] 9^ 61* + x] [clip .4] .7* [slp 156 22k [exp 8 x [sin .24 x] .15* +] 4.7^ * + .86] [exp 8 x] .5^ * [sno 516 2181 [sin .2 co * t .5 -] * + ] [delay 15 .73] .59* +[noi 4.23] [adsr .03 100 .3 48 x 3* on= x 3* .012 - off=] 2 [sin .3] 1.0 + .9^ * ^ [sin 2 x] * * [shp 7090 .7] .21* +[noi 14.23] [adsr .03 10 .3 248 x 4* on= x 4* .012 - off=] 2 [sin .3] 1.0 + .9^ * ^ [sin 8 x] * * [sbp 3790 .17 .60 [sin .5 co* t 2 /]*+ ] .16* +` + const getFloatsGfx = Lru(1024, (key: string, length: number) => wasmGfx.alloc(Float32Array, length), item => item.fill(0), item => item.free()) -export function DspNodeDemo() { +let _dspEditorUi: ReturnType +export function dspEditorUi() { + _dspEditorUi ??= DspEditorUi() + return _dspEditorUi +} + +export function DspEditorUi() { using $ = Sigui() const info = $({ - 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] -`, + el: null as null | HTMLDivElement, + resized: 0, + get editorWidth() { + info.resized + return info.el?.clientWidth ? info.el.clientWidth * (screen.lg ? 0.5 : 1) : 100 + }, + get editorHeight() { + info.resized + return info.el?.clientHeight ? info.el.clientHeight * (screen.lg ? 1 : 0.7) : 100 + }, + get canvasWidth() { + info.resized + return info.el?.clientWidth ? info.el.clientWidth * (screen.lg ? 0.5 : 1) : 100 + }, + get canvasHeight() { + info.resized + return info.el?.clientHeight ? info.el.clientHeight * (screen.lg ? 1 : 0.3) : 100 + }, + code: demo, codeWorking: null as null | string, + lastBuildPane: null as null | Pane, + get didBuildPane() { + const { pane } = dspEditor.editor.info + return info.lastBuildPane === pane + }, audios: [] as Float32Array[], values: [] as SoundValue[], floats: new Float32Array(), @@ -95,9 +129,10 @@ export function DspNodeDemo() { $.fx(() => { const { audios, values } = $.of(info) const { isPlaying, clock, dsp: { scalars } } = $.of(dspNode.info) + const { pane } = dspEditor.editor.info $() if (isPlaying) { - const { pane } = dspEditor.editor.info + const { wave: waveWidgets, rms: rmsWidgets, list: listWidgets } = getPaneWidgets(pane) let animFrame: any const tick = () => { for (const wave of [...waveWidgets, plot]) { @@ -106,7 +141,7 @@ export function DspNodeDemo() { audios[wave.info.index], clock.ringPos, wave.info.stabilizerTemp.length, - 15 + BUFFER_SIZE / 128 - 1 ) const startIndex = wave.info.stabilizer.findStartingPoint(wave.info.stabilizerTemp) wave.info.floats.set(wave.info.stabilizerTemp.subarray(startIndex)) @@ -135,23 +170,31 @@ export function DspNodeDemo() { } }) - const canvas = as HTMLCanvasElement + const canvas = as HTMLCanvasElement const gfx = Gfx({ canvas }) - const view = Rect(0, 0, info.$.width, info.$.height) + const view = Rect(0, 0, info.$.canvasWidth, info.$.canvasHeight) const matrix = Matrix() const c = gfx.createContext(view, matrix) const shapes = c.createShapes() c.sketch.scene.add(shapes) - const widgetRect = Rect(0, 0, info.$.width, info.$.height) + const widgetRect = Rect(0, 0, info.$.canvasWidth, info.$.canvasHeight) const plot = WaveGlDecoWidget(shapes, widgetRect) plot.info.stabilizerTemp = getFloatsGfx('s:LR', BUFFER_SIZE) plot.info.previewFloats = getFloatsGfx('p:LR', BUFFER_SIZE) plot.info.floats = getFloatsGfx(`LR`, BUFFER_SIZE) - const waveWidgets: WaveGlDecoWidget[] = [] - const rmsWidgets: RmsDecoWidget[] = [] - const listWidgets: ListMarkWidget[] = [] + const paneWidgets = new Map() + + function getPaneWidgets(pane: Pane) { + let widgets = paneWidgets.get(pane) + if (!widgets) paneWidgets.set(pane, widgets = { + wave: [], + rms: [], + list: [], + }) + return widgets + } $.fx(() => { const { isReady, dsp, view: previewView } = $.of(preview.info) @@ -168,6 +211,7 @@ export function DspNodeDemo() { const { pane } = dspEditor.editor.info const { code } = pane.buffer.info + const { wave: waveWidgets, rms: rmsWidgets, list: listWidgets } = getPaneWidgets(pane) function fixBounds(bounds: Token.Bounds) { let newBounds = { ...bounds } @@ -284,24 +328,28 @@ export function DspNodeDemo() { pane.view.anim.ticks.add(c.meshes.draw) pane.view.anim.info.epoch++ pane.draw.widgets.update() + requestAnimationFrame(() => { + info.lastBuildPane = pane + }) } const buildThrottled = throttle(16, build) - queueMicrotask(() => { - $.fx(() => { - const { previewSound$ } = $.of(info) - const { pane } = dspEditor.editor.info - const { codeVisual } = pane.buffer.info - const { isPlaying } = dspNode.info - $() - queueMicrotask(isPlaying ? buildThrottled : build) - }) + // queueMicrotask(() => { + $.fx(() => { + const { previewSound$ } = $.of(info) + const { pane } = dspEditor.editor.info + const { codeVisual } = pane.buffer.info + const { isPlaying } = dspNode.info + $() + queueMicrotask(buildThrottled) + // queueMicrotask(isPlaying ? buildThrottled : build) }) + // }) const dspEditor = DspEditor({ - width: info.$.width, - height: info.$.height, + width: info.$.editorWidth, + height: info.$.editorHeight, code: info.$.code, }) @@ -313,11 +361,22 @@ export function DspNodeDemo() { return () => dspEditor.info.error = null }) - return
- - {dspEditor} - {canvas} -
+ info.el =
+
+
+ {() => info.didBuildPane &&
+ {dspEditor} + {canvas} +
} +
+
+
as HTMLDivElement + + dom.observe.resize(info.el, () => { + requestAnimationFrame(() => { + info.resized++ + }) + }) + + return { el: info.el, info, dspEditor, dspNode } } diff --git a/src/comp/Header.tsx b/src/comp/Header.tsx index 70d12d6..1bc7b37 100644 --- a/src/comp/Header.tsx +++ b/src/comp/Header.tsx @@ -1,5 +1,11 @@ +import { Layout } from 'ui' + export function Header({ children }: { children?: any }) { - return
- {children} + return
+ +
+ {children} +
+
} 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/LoginOrRegister.tsx b/src/comp/LoginOrRegister.tsx new file mode 100644 index 0000000..dc4fcbd --- /dev/null +++ b/src/comp/LoginOrRegister.tsx @@ -0,0 +1,15 @@ +import { Login } from '~/src/comp/Login.tsx' +import { OAuthLogin } from '~/src/comp/OAuthLogin.tsx' +import { Register } from '~/src/comp/Register.tsx' + +export function LoginOrRegister() { + return
+
+ + or + +
+ + +
+} diff --git a/src/comp/Logout.tsx b/src/comp/Logout.tsx deleted file mode 100644 index 4c652c6..0000000 --- a/src/comp/Logout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { logout } from '~/src/rpc/auth.ts' -import { state } from '~/src/state.ts' -import { Button } from '~/src/ui/index.ts' - -export function Logout({ then }: { then?: () => void }) { - return -} 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/constants.ts b/src/constants.ts new file mode 100644 index 0000000..c8027ef --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,10 @@ +export const ICON_16 = { size: 16, 'stroke-width': 2.05 } +export const ICON_24 = { size: 24, 'stroke-width': 1.3 } +export const ICON_32 = { size: 32, 'stroke-width': 1.2 } +export const ICON_48 = { size: 48, 'stroke-width': 0.8 } + +export enum AnimMode { + Auto = 'auto', + On = 'on', + Off = 'off', +} diff --git a/src/pages/App copy.tsx b/src/pages/App copy.tsx new file mode 100644 index 0000000..5b044b5 --- /dev/null +++ b/src/pages/App copy.tsx @@ -0,0 +1,195 @@ +import { AudioWaveform, Plus, UserCircle } from 'lucide' +import { dispose, Sigui } from 'sigui' +import { DropDown, Layout } from 'ui' +import { dom } from 'utils' +import { CachingRouter } from '~/lib/caching-router.ts' +import { icon } from '~/lib/icon.ts' +import { Header } from '~/src/comp/Header.tsx' +import { ResetPassword } from '~/src/comp/ResetPassword.tsx' +import { Toast } from '~/src/comp/Toast.tsx' +import { VerifyEmail } from '~/src/comp/VerifyEmail.tsx' +import { ICON_24, ICON_32 } from '~/src/constants.ts' +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 { CreateSound } from '~/src/pages/CreateSound.tsx' +import { getDspControls } from '~/src/pages/DspControls.tsx' +import { EditorDemo } from '~/src/pages/EditorDemo.tsx' +import { Home } from '~/src/pages/Home.tsx' +import { Logout } from '~/src/pages/Logout' +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 { Showcase } from '~/src/pages/Showcase' +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 { maybeLogin } from '~/src/rpc/auth.ts' +import { getProfile } from '~/src/rpc/profiles.ts' +import { listFavorites } from '~/src/rpc/sounds.ts' +import { state, triggers } from '~/src/state.ts' +import { go, Link } from '~/src/ui/Link.tsx' + +export function App() { + using $ = Sigui() + + maybeLogin() + + $.fx(() => { + const { url } = state + $() + state.onNavigate.forEach(fn => fn()) + state.onNavigate.clear() + }) + + $.fx(() => { + const { user } = $.of(state) + const { defaultProfile } = $.of(user) + $().then(async () => { + state.profile = $(await getProfile(defaultProfile)) + }) + }) + + $.fx(() => { + const { user } = $.of(state) + const { defaultProfile } = $.of(user) + $().then(async () => { + state.favorites = new Set((await listFavorites()).map(({ id }) => id)) + }) + }) + + const info = $({ + bg: 'transparent', + canvasWidth: state.$.containerWidth, + canvasHeight: state.$.containerHeight, + }) + + const router = CachingRouter({ + '!/': () => , + '!/canvas': () => , + '/settings': () => , + '!/ws': () => , + '/about': () => , + '/asc': () => , + '/chat': () => , + '/create-profile': () => , + '!/create-sound': () => , + '/editor': () => , + '/logout': () => go('/')} />, + '/qrcode': () => , + '/reset-password': () => , + '/showcase': () => , + '/ui': () => , + '/verify-email': () => , + '/webgl': () => , + '/worker-worklet': () => , + '/oauth/popup': () => { + const provider = state.url.searchParams.get('provider')! + const url = new URL(`${state.apiUrl}oauth/start`) + url.searchParams.set('provider', provider) + location.href = url.href + return
+ }, + '/oauth/register': () => , + '/oauth/cancel': () => { + localStorage.oauth = 'cancel' + Math.random() + window.close() + return
OAuth login cancelled
+ }, + '/oauth/complete': () => { + // hack: triggering a localStorage write is how we communicate + // to the parent window that we're done. + localStorage.oauth = 'complete' + Math.random() + window.close() + return
+ Successfully logged in. + You may now . +
+ } + }) + + $.fx(() => [ + dom.on(window, 'error', ev => { + console.warn(ev) + state.toastMessages = [...state.toastMessages, (ev as unknown as ErrorEvent)] + }), + dom.on(window, 'unhandledrejection', ev => { + console.warn(ev) + state.toastMessages = [...state.toastMessages, (ev as unknown as PromiseRejectionEvent).reason] + }), + ]) + + $.fx(() => { + const { user } = state + $() + $.flush() + triggers.resize++ + requestAnimationFrame(() => triggers.resize++) + }) + + $.fx(() => { + $() + state.heading2 = () =>
{() => getDspControls().el}
+ }) + + return
info.bg = '#433'} + onmouseleave={() => info.bg = 'transparent'} + > + + +
+
+
+ {icon(AudioWaveform, ICON_32)} +
ravescript
+
+
+
{() => state.heading}
+
{() => state.heading2()}
+
+
+ +
+
+ + {icon(Plus, ICON_32)} Create new sound + +
+
+ `/${state.user?.defaultProfile}`}>{() => state.profile?.displayName} + [ + [Settings, () => { }], + [state.user ? Logout :
, () => { }], + ]} /> + +
+
+
+ + +
+ {() => { + const { user, pathname } = state + $() + if (user === undefined) return
Loading...
+ + const el = router(pathname) + if (el) return el + + return + }} +
+
+
+} + +/* +home + {() => state.url.pathname} +*/ diff --git a/src/pages/App.tsx b/src/pages/App.tsx index 7de746b..58fefbd 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -1,83 +1,220 @@ +import { AudioWaveform, Plus, UserCircle, UserCircle2, X } from 'lucide' import { Sigui } from 'sigui' +import { Button, DropDown } from 'ui' import { dom } from 'utils' -import { CachingRouter } from '~/lib/caching-router.ts' -import { Header } from '~/src/comp/Header.tsx' -import { Logout } from '~/src/comp/Logout.tsx' -import { ResetPassword } from '~/src/comp/ResetPassword.tsx' +import type { z } from 'zod' +import type { Profiles } from '~/api/models.ts' +import { cn } from '~/lib/cn.ts' +import { icon } from '~/lib/icon.ts' +import { dspEditorUi } from '~/src/comp/DspEditorUi.tsx' import { Toast } from '~/src/comp/Toast.tsx' -import { VerifyEmail } from '~/src/comp/VerifyEmail.tsx' -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 { DspNodeDemo } from '~/src/pages/DspNodeDemo.tsx' -import { EditorDemo } from '~/src/pages/EditorDemo.tsx' -import { Home } from '~/src/pages/Home.tsx' +import { ICON_24, ICON_32, ICON_48 } from '~/src/constants.ts' +import { CreateProfile } from '~/src/pages/CreateProfile.tsx' +import { getDspControls } from '~/src/pages/DspControls.tsx' import { OAuthRegister } from '~/src/pages/OAuthRegister.tsx' -import { QrCode } from '~/src/pages/QrCode.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 { logoutUser, maybeLogin } from '~/src/rpc/auth.ts' +import { getProfile, listProfilesForNick, makeDefaultProfile } from '~/src/rpc/profiles.ts' +import { listFavorites, listRecentSounds, listSounds } from '~/src/rpc/sounds.ts' import { state, triggers } from '~/src/state.ts' import { go, Link } from '~/src/ui/Link.tsx' +type SoundsKind = 'recent' | 'sounds' | 'remixes' | 'favorites' + export function App() { using $ = Sigui() - if (!state.user) whoami().then(user => state.user = user) + maybeLogin() + + $.fx(() => { + const { url } = state + $() + state.onNavigate.forEach(fn => fn()) + state.onNavigate.clear() + }) + + $.fx(() => { + const { user } = $.of(state) + const { defaultProfile } = $.of(user) + $().then(async () => { + state.profile = $(await getProfile(defaultProfile)) + }) + }) + + $.fx(() => { + const { user } = $.of(state) + const { defaultProfile } = $.of(user) + $().then(async () => { + state.favorites = new Set((await listFavorites()).map(({ id }) => id)) + }) + }) + + $.fx(() => { + const { user } = $.of(state) + const { nick } = user + $().then(async () => { + state.profiles = await listProfilesForNick(nick) + }) + }) const info = $({ - bg: 'transparent', + code: '[sin 300] [exp 1] *', canvasWidth: state.$.containerWidth, canvasHeight: state.$.containerHeight, - }) - - const router = CachingRouter({ - '/': () => , - '/ui': () => , - '/chat': () => , - '!/ws': () => , - '!/canvas': () => , - '/webgl': () => , - '/editor': () => , - '/dsp': () => , - '/worker-worklet': () => , - '/asc': () => , - '/qrcode': () => , - '/about': () => , - '/verify-email': () => , - '/reset-password': () => , - '/oauth/popup': () => { - const provider = state.url.searchParams.get('provider')! - const url = new URL(`${state.apiUrl}oauth/start`) - url.searchParams.set('provider', provider) - location.href = url.href - return
+ sounds: null as null | false | Awaited>, + favorites: null as null | false | Awaited>, + profile: null as null | false | z.infer, + get profileNick() { + return state.pathname.slice(1) }, - '/oauth/register': () => , - '/oauth/cancel': () => { - localStorage.oauth = 'cancel' + Math.random() - return
OAuth login cancelled
+ get soundsKind(): null | SoundsKind { + return state.searchParams.get('kind') as null | SoundsKind }, - '/oauth/complete': () => { - // hack: triggering a localStorage write is how we communicate - // to the parent window that we're done. - localStorage.oauth = 'complete' + Math.random() - window.close() - return
- Successfully logged in. - You may now . + }) + + $.fx(() => { + const { profile } = info + if (profile) return + $().then(async () => { + try { + info.sounds = await listRecentSounds() + } + catch (error) { + console.warn(error) + info.sounds = false + } + }) + }) + + $.fx(() => { + const { profileNick } = info + $().then(async () => { + try { + info.profile = await getProfile(profileNick) + } + catch (error) { + console.warn(error) + info.profile = false + } + }) + }) + + $.fx(() => { + const { triggerReloadProfileSounds } = state + 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 + } + }) + }) + + $.fx(() => { + const { triggerReloadProfileFavorites } = state + const { profile } = $.of(info) + if (profile === false) return + $().then(async () => { + try { + info.favorites = await listFavorites(profile.nick) + } + catch (error) { + console.warn(error) + info.sounds = false + } + }) + }) + + function SoundsList({ sounds, soundsKind, showCreator }: { + sounds: null | false | Awaited> + soundsKind: string + showCreator: boolean + }) { + if (sounds === null) return
Loading...
+ else if (sounds === false) return
Failed to load sounds
+ else { + return
+
+ {!sounds.length ?
No {soundsKind} yet.
: sounds.map(sound => { + const isSelected = soundsKind === info.soundsKind && sound.id === state.loadedSound + const el = isSelected }, + 'overflow-hidden whitespace-nowrap overflow-ellipsis px-2' + )} + href={info.profile + ? `/${info.profile.nick}?sound=${encodeURIComponent(sound.id)}` + + `&kind=${soundsKind}` + + (info.profile.nick === sound.profileNick ? '' : '&creator=' + encodeURIComponent(sound.profileNick)) + : `/?sound=${sound.id}` + } + >{() => showCreator ? {sound.profileDisplayName} - : }{sound.title} + + if (isSelected) { + requestAnimationFrame(() => { + // @ts-ignore + (el as HTMLElement).scrollIntoViewIfNeeded({ block: 'center' }) + }) + } + + return el + })} +
} - }) + } + + function SoundsListOfKind({ kind }: { kind: SoundsKind }) { + return
+
+ {kind === 'recent' ? 'Recent sounds' : kind} + {() => kind === 'recent' && + {icon(Plus, ICON_24)} + } +
+ !sound.remixOf) + : kind === 'remixes' + ? info.sounds && info.sounds.filter(sound => sound.remixOf) + : info.favorites) as any + } + soundsKind={kind} + showCreator={kind === 'recent' || kind === 'favorites'} + /> +
+ } + + function createSound() { + state.url.search = '' + go(state.url.href) + info.code = '[sin 300] [exp 1] *' + const { info: controlsInfo, dspEditorUi } = getDspControls() + controlsInfo.loadedSound = null + controlsInfo.isLoadingSound = false + const { dspEditor: { editor } } = dspEditorUi() + const { pane } = editor.info + const newPane = editor.createPane({ rect: pane.rect, code: info.$.code }) + dspEditorUi().info.code = info.code + editor.addPane(newPane) + editor.removePane(pane) + editor.info.pane = newPane + setTimeout(editor.focus, 500) + } $.fx(() => [ dom.on(window, 'error', ev => { - state.toastMessages = [...state.toastMessages, (ev as unknown as ErrorEvent).error] + console.warn(ev) + state.toastMessages = [...state.toastMessages, (ev as unknown as ErrorEvent)] }), dom.on(window, 'unhandledrejection', ev => { + console.warn(ev) state.toastMessages = [...state.toastMessages, (ev as unknown as PromiseRejectionEvent).reason] }), ]) @@ -90,34 +227,112 @@ export function App() { requestAnimationFrame(() => triggers.resize++) }) - return
info.bg = '#433'} - onmouseleave={() => info.bg = 'transparent'} - > - - -
-
- home - {() => state.url.pathname} + const Home = () =>
+
+
+ {icon(AudioWaveform, ICON_32)} +
ravescript
- -
- `/${state.user?.nick ?? ''}`}>{() => state.user?.nick} - {() => state.user ? go('/')} /> :
} +
+
{() => info.profile ? [ +
+ info.profile && `/${info.profile.nick}` || ''}>{icon(UserCircle2, ICON_48)} {() => info.profile && info.profile.displayName} +
, + SoundsListOfKind({ kind: 'sounds' }), + SoundsListOfKind({ kind: 'remixes' }), + SoundsListOfKind({ kind: 'favorites' }), + ] : [SoundsListOfKind({ kind: 'recent' })]}
-
+
+
+
+
+
{() => state.heading}
+
{() => getDspControls().el}
+
+
{() => [ + + {icon(Plus, ICON_32)} New sound + , + ...(() => !state.user ? [] : [ + `/${state.user?.defaultProfile}`}>{() => state.profile?.displayName}, + [ + // [Settings, () => { }], + [state.user ? Logout :
, () => { }], + [New profile, () => { }], + ...state.profiles + .filter(p => p.nick !== state.user?.defaultProfile) + .map(p => + [ { + const { user } = state + if (!user) return + await makeDefaultProfile(p.nick) + user.defaultProfile = p.nick + }} + >{p.displayName} {icon(UserCircle, ICON_24)}, () => { }], + ) as any, + ]} /> + ])() + ]}
+
+
{() => dspEditorUi().el}
+
+
-
- {() => { - if (state.user === undefined) return
Loading...
+ const Modal = () => state.modalIsOpen &&
+
+ + {state.modal} +
+
- const el = router(state.pathname) - if (el) return el + return
{() => [ + , + Modal(), + (() => { + const { pathname } = state + $() + switch (pathname) { + case '/oauth/popup': { + const provider = state.url.searchParams.get('provider')! + const url = new URL(`${state.apiUrl}oauth/start`) + url.searchParams.set('provider', provider) + location.href = url.href + return
+ } + case '/oauth/register': return + case '/oauth/cancel': { + localStorage.oauth = 'cancel' + Math.random() + window.close() + return
OAuth login cancelled
+ } + case '/oauth/complete': { + // hack: triggering a localStorage write is how we communicate + // to the parent window that we're done. + localStorage.oauth = 'complete' + Math.random() + window.close() + return
+ Successfully logged in. + You may now . +
+ } + case '/new-profile': + return - return
404 Not found
- }} -
- + default: + return + } + })(), + ]} } + +/* +home + {() => state.url.pathname} +*/ 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/CreateSound.tsx b/src/pages/CreateSound.tsx new file mode 100644 index 0000000..2be2663 --- /dev/null +++ b/src/pages/CreateSound.tsx @@ -0,0 +1,35 @@ +import { Sigui } from 'sigui' +import { H2 } from 'ui' +import { dspEditorUi } from '~/src/comp/DspEditorUi.tsx' +import { getDspControls } from '~/src/pages/DspControls.tsx' + +export function CreateSound() { + using $ = Sigui() + + const info = $({ + code: '[sin 300] [exp 1] *' + }) + + $.fx(() => { + $() + const { info: controlsInfo, dspEditorUi } = getDspControls() + controlsInfo.loadedSound = null + controlsInfo.isLoadingSound = false + const { pane } = dspEditorUi().dspEditor.editor.info + const newPane = dspEditorUi().dspEditor.editor.createPane({ rect: pane.rect, code: info.$.code }) + dspEditorUi().info.code = info.code + dspEditorUi().dspEditor.editor.addPane(newPane) + dspEditorUi().dspEditor.editor.removePane(pane) + dspEditorUi().dspEditor.editor.info.pane = newPane + }) + + return
+

+ Create Sound +
+
{() => getDspControls().el}
+
+

+
{() => dspEditorUi().el}
+
as HTMLDivElement +} diff --git a/src/pages/DspControls.tsx b/src/pages/DspControls.tsx new file mode 100644 index 0000000..b421b4c --- /dev/null +++ b/src/pages/DspControls.tsx @@ -0,0 +1,159 @@ +import { Heart, Play, Save, SaveAll, Square, Trash2 } from 'lucide' +import { Sigui } from 'sigui' +import { Button, go, Link } from 'ui' +import type { GetSoundResult } from '~/api/sounds/actions.ts' +import { icon } from '~/lib/icon.ts' +import { showAuthModal, wrapActionAuth } from '~/src/comp/AuthModal.tsx' +import { dspEditorUi } from '~/src/comp/DspEditorUi.tsx' +import { ICON_16, ICON_24, ICON_32 } from '~/src/constants.ts' +import { addSoundToFavorites, deleteSound, getSound, overwriteSound, publishSound, removeSoundFromFavorites } from '~/src/rpc/sounds.ts' +import { state } from '~/src/state.ts' +import { theme } from '~/src/theme.ts' + +let dspControls: ReturnType + +export function getDspControls() { + dspControls ??= DspControls() + return dspControls +} + +export function DspControls() { + using $ = Sigui() + + const info = $({ + el: null as null | HTMLDivElement, + get title() { + return info.isPublished ? info.loadedSound!.sound.title : '' + }, + get isPublished() { + return !!info.loadedSound + }, + get isEdited() { + return !info.loadedSound || (info.loadedSound?.sound.code !== dspEditorUi().info.code) + }, + isLoadingSound: false, + loadedSound: null as null | Awaited> + }) + + $.fx(() => { + const { searchParams } = state + $().then(async () => { + if (searchParams.has('sound')) { + $.batch(() => { + info.isLoadingSound = true + // dspEditorUi().info.codeWorking = null + }) + const { sound: soundId } = Object.fromEntries(searchParams) + const loadedSound = await getSound(soundId) + $.batch(() => { + info.isLoadingSound = false + info.loadedSound = { + sound: $(loadedSound.sound), + creator: $(loadedSound.creator), + remixOf: loadedSound.remixOf ? $(loadedSound.remixOf) : loadedSound.remixOf + } + const { pane } = dspEditorUi().dspEditor.editor.info + const newPane = dspEditorUi().dspEditor.editor.createPane({ rect: pane.rect, code: $(loadedSound.sound).$.code }) + dspEditorUi().info.code = loadedSound.sound.code + dspEditorUi().dspEditor.editor.addPane(newPane) + dspEditorUi().dspEditor.editor.removePane(pane) + dspEditorUi().dspEditor.editor.info.pane = newPane + }) + } + else { + $.batch(() => { + // info.loadedSound = null + }) + } + }) + }) + + const overwriteBtn = + + const publishBtn = + + const likeBtn = + + const unlikeBtn = + + const deleteBtn = + + const playStopBtn = + + state.heading = playStopBtn + + const titleDiv = ({ sound, creator, remixOf }: GetSoundResult) =>
+
+ {creator.displayName} - {sound.title} +
{remixOf &&
remix of + {remixOf.creator.displayName} - {remixOf.sound.title} +
} +
+ + info.el =
+
+ {() => info.isPublished && titleDiv(info.loadedSound!)} +
+
{ + () => info.isLoadingSound ? [] : [ + (!info.isPublished || info.isEdited) && state.user && state.user.defaultProfile === info.loadedSound?.creator?.nick && overwriteBtn, + (!info.isPublished || info.isEdited) && publishBtn, + !info.isEdited && info.loadedSound && (state.favorites?.has(info.loadedSound.sound.id) ? unlikeBtn : likeBtn), + (info.isPublished && !info.isEdited) && state.user?.defaultProfile === info.loadedSound?.creator?.nick && deleteBtn, + ]} +
+
as HTMLDivElement + + return { el: info.el, info, dspEditorUi } +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 254abad..9c1491b 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,10 +1,73 @@ +import { Sigui } from 'sigui' +import { H2 } from 'ui' +import { cn } from '~/lib/cn.ts' +import { dspEditorUi } from '~/src/comp/DspEditorUi.tsx' import { Login } from '~/src/comp/Login.tsx' import { OAuthLogin } from '~/src/comp/OAuthLogin.tsx' import { Register } from '~/src/comp/Register.tsx' +import { listRecentSounds } from '~/src/rpc/sounds.ts' import { state } from '~/src/state.ts' import { Link } from '~/src/ui/Link.tsx' +let _home: ReturnType +export function home() { + _home ??= Home() + return _home +} + export function Home() { + using $ = Sigui() + + const info = $({ + sounds: null as null | false | Awaited>, + }) + + $.fx(() => { + $().then(async () => { + try { + info.sounds = await listRecentSounds() + } + catch (error) { + console.warn(error) + info.sounds = false + } + }) + }) + + function SoundsList() { + const { sounds } = info + if (sounds === null) return
Loading...
+ else if (sounds === false) return
Failed to load sounds
+ else { + return
!state.isLoadedSound }, + { 'flex flex-col': () => state.isLoadedSound }, + )}> +
!state.isLoadedSound, + "flex flex-col": () => state.isLoadedSound, + })}> + {sounds.map(sound => { + const isSelected = sound.id === state.loadedSound + const el = isSelected }, + "overflow-hidden whitespace-nowrap overflow-ellipsis")} href={`/?sound=${sound.id}`}>{sound.profileDisplayName} - {sound.title} + + if (isSelected) { + requestAnimationFrame(() => { + // @ts-ignore + (el as HTMLElement).scrollIntoViewIfNeeded({ block: 'center' }) + }) + } + + return el + })} +
+
+ } + } + return
{() => state.user === undefined ?
Loading...
@@ -20,19 +83,15 @@ export function Home() {
: -
- {state.user.isAdmin && Admin} - UI Showcase - Chat - WebSockets - Canvas - WebGL - Editor - Dsp - Worker-Worklet - AssemblyScript - QrCode - About +
+
!state.isLoadedSound })}> +

+ {() => state.isLoadedSound ? Recent
sounds
: Recent sounds}
+

+ +
+ +
{() => dspEditorUi().el}
}
diff --git a/src/pages/Logout.tsx b/src/pages/Logout.tsx new file mode 100644 index 0000000..3a4f31e --- /dev/null +++ b/src/pages/Logout.tsx @@ -0,0 +1,12 @@ +import { logout } from '~/src/rpc/auth.ts' +import { state } from '~/src/state.ts' + +export function logoutAction() { + logout() + .then(() => { + state.user = + state.profile = + state.favorites = + null + }) +} diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx new file mode 100644 index 0000000..3837d9b --- /dev/null +++ b/src/pages/Profile.tsx @@ -0,0 +1,177 @@ +import { Settings, UserCircle2 } from 'lucide' +import { dispose, Sigui } from 'sigui' +import { Button, DropDown, go, H2, H3, Link } from 'ui' +import type { z } from 'zod' +import type { Profiles } from '~/api/models.ts' +import { cn } from '~/lib/cn.ts' +import { icon } from '~/lib/icon.ts' +import { DspEditorEl } from '~/src/comp/DspEditorUi.tsx' +import { ICON_24, ICON_32 } from '~/src/constants.ts' +import { maybeLogin } from '~/src/rpc/auth.ts' +import { deleteProfile, getProfile, makeDefaultProfile } from '~/src/rpc/profiles.ts' +import { listFavorites, listSounds } from '~/src/rpc/sounds.ts' +import { state } from '~/src/state.ts' + +type SoundsKind = 'sounds' | 'remixes' | 'favorites' + +export function Profile() { + using $ = Sigui() + + const info = $({ + profile: null as null | false | z.infer, + sounds: null as null | false | Awaited>, + favorites: null as null | false | Awaited>, + get soundsKind(): null | SoundsKind { + return state.searchParams.get('kind') as null | SoundsKind + }, + get profileNick() { + return state.pathname.slice(1) + } + }) + + $.fx(() => { + const { profileNick } = info + $().then(async () => { + try { + info.profile = await getProfile(profileNick) + } + catch (error) { + console.warn(error) + info.profile = false + } + }) + }) + + $.fx(() => { + const { triggerReloadProfileSounds } = state + 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 + } + }) + }) + + $.fx(() => { + const { triggerReloadProfileFavorites } = state + const { profile } = $.of(info) + if (profile === false) return + $().then(async () => { + try { + info.favorites = await listFavorites(profile.nick) + } + catch (error) { + console.warn(error) + info.sounds = false + } + }) + }) + + function SoundsList({ sounds, soundsKind, showCreator }: { + sounds: null | false | Awaited> + soundsKind: string + showCreator: boolean + }) { + if (sounds == null || !info.profile) return
Loading {soundsKind}...
+ else if (sounds === false) return
Failed to load {soundsKind}
+ return
!state.isLoadedSound })}>{ + () => !sounds.length ?
No {soundsKind} yet.
: sounds.map(sound => { + const isSelected = soundsKind === info.soundsKind && sound.id === state.loadedSound + const el = info.profile &&
+ + } + + function SoundsListOfKind({ kind }: { kind: SoundsKind }) { + return
+

{kind}

+ !sound.remixOf) + : kind === 'remixes' + ? info.sounds && info.sounds.filter(sound => sound.remixOf) + : info.favorites) as any + } + soundsKind={kind} + showCreator={kind === 'favorites'} + /> +
+ } + + function Inner() { + const { user } = state + const { profile } = info + $() + if (profile == null) return
Loading profile...
+ else if (profile === false) return
404 Not Found
+ return
+
!state.isLoadedSound })}> +

+
+ +
+ +
{() => user && user.nick === profile.ownerNick && [ + [user.defaultProfile !== profile.nick && + ], + [], + ]} />}
+

+ +
+ + +
+
+ +
{() => DspEditorEl()}
+
+ } + + 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/pages/Showcase.tsx b/src/pages/Showcase.tsx new file mode 100644 index 0000000..eeadeee --- /dev/null +++ b/src/pages/Showcase.tsx @@ -0,0 +1,40 @@ +import { Login } from '~/src/comp/Login.tsx' +import { OAuthLogin } from '~/src/comp/OAuthLogin.tsx' +import { Register } from '~/src/comp/Register.tsx' +import { state } from '~/src/state.ts' +import { Link } from '~/src/ui/Link.tsx' + +export function Showcase() { + return
+ {() => state.user === undefined + ?
Loading...
+ : state.user === null + ? +
+
+ + or + +
+ + +
+ : +
+ {state.user.isAdmin && Admin} + UI Showcase + Chat + WebSockets + Canvas + WebGL + Editor + Dsp + Worker-Worklet + AssemblyScript + QrCode + Create Profile + About +
+ } +
+} diff --git a/src/pages/UiShowcase.tsx b/src/pages/UiShowcase.tsx index 5e82027..23d31f3 100644 --- a/src/pages/UiShowcase.tsx +++ b/src/pages/UiShowcase.tsx @@ -1,4 +1,6 @@ -import { Button, Fieldset, H1, H2, H3, Input, Label, Link } from '~/src/ui/index.ts' +import { Menu } from 'lucide' +import { Button, DropDown, Fieldset, H1, H2, H3, Input, Label, Link } from 'ui' +import { icon } from '~/lib/icon.ts' function UiGroup({ name, children }: { name: string, children?: any }) { return
@@ -30,6 +32,21 @@ export function UiShowcase() { + + console.log('Item 1')], + ['Item 2', () => console.log('Item 2')], + ['Item 3', () => console.log('Item 3')], + ]} /> +
+ console.log('Item 1')], + ['Item 2', () => console.log('Item 2')], + ['Item 3', () => console.log('Item 3')], + ]} /> +
+
+ @@ -38,6 +55,7 @@ export function UiShowcase() { About +
) } diff --git a/src/rpc/auth.ts b/src/rpc/auth.ts index da3bec8..e2198ba 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,28 @@ 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() + loginUserSession(userSession) + } + catch (error) { + console.warn(error) + state.user = null + if (orRedirect) location.href = orRedirect + } +} + +export async function logoutUser() { + await logout() + state.user = + state.profile = + state.favorites = + null } 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..0d5e487 --- /dev/null +++ b/src/rpc/sounds.ts @@ -0,0 +1,12 @@ +import type * as actions from '~/api/sounds/actions.ts' +import { rpc } from '~/lib/rpc.ts' + +export const publishSound = rpc('POST', 'publishSound') +export const overwriteSound = rpc('POST', 'overwriteSound') +export const deleteSound = rpc('POST', 'deleteSound') +export const listSounds = rpc('GET', 'listSounds') +export const listRecentSounds = rpc('GET', 'listRecentSounds') +export const getSound = rpc('GET', 'getSound') +export const addSoundToFavorites = rpc('POST', 'addSoundToFavorites') +export const removeSoundFromFavorites = rpc('POST', 'removeSoundFromFavorites') +export const listFavorites = rpc('GET', 'listFavorites') diff --git a/src/state.ts b/src/state.ts index 962fdf7..77c8567 100644 --- a/src/state.ts +++ b/src/state.ts @@ -2,10 +2,12 @@ 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 type { DspEditorUi } from '~/src/comp/DspEditorUi.tsx' +import { AnimMode } from '~/src/constants.ts' import { env } from '~/src/env.ts' +import type { getProfile } from '~/src/rpc/profiles.ts' import { screen } from '~/src/screen.ts' import { link } from '~/src/ui/Link.tsx' @@ -61,9 +63,32 @@ class State { } return url.href } + onNavigate = new Set<() => void>() // app - user?: UserSession | null + user?: $ | null + + profiles: z.infer[] = [] + + profile?: $>> | null + triggerReloadProfileSounds = 0 + triggerReloadProfileFavorites = 0 + + favorites: Set | null = null + + heading: JSX.Element | null = null + heading2: () => JSX.Element | null = () => null + + modal: JSX.Element | null = null + modalIsOpen = false + modalIsCancelled = false + + get loadedSound() { + return this.searchParams.get('sound') + } + get isLoadedSound() { + return !!this.loadedSound + } channelsList: Pick, 'name'>[] = [] channels: UiChannel[] = [] diff --git a/src/ui/Button.tsx b/src/ui/Button.tsx index 2b2c106..da61f01 100644 --- a/src/ui/Button.tsx +++ b/src/ui/Button.tsx @@ -1,9 +1,18 @@ import { cn } from '~/lib/cn.ts' -export function Button(props: Record) { +export function Button(props: Record & { bare?: boolean }) { return diff --git a/src/ui/DropDown.tsx b/src/ui/DropDown.tsx new file mode 100644 index 0000000..d555156 --- /dev/null +++ b/src/ui/DropDown.tsx @@ -0,0 +1,61 @@ +import { Sigui } from 'sigui' +import { dom } from 'utils' +import { cn } from '~/lib/cn.ts' + +type Items = ([any, () => void] | [any])[] + +export function DropDown({ right, handle, items }: { right?: boolean, handle: JSX.Element | string, items: Items | (() => Items) }) { + using $ = Sigui() + + const info = $({ isOpen: false }) + + const dropDown =
+
info.isOpen })} onpointerdown={e => { + e.preventDefault() + info.isOpen = !info.isOpen + }}> + {handle} +
+ {() =>
!info.isOpen, 'right-0': () => right })}> + {(typeof items === 'function' ? items() : items).map(([item, fn], i) =>
{ + fn?.() + info.isOpen = false + }}> + {item} +
)} +
} +
as HTMLDivElement + + $.fx(() => { + const { isOpen } = info + $() + if (isOpen) { + return [ + dom.on(window, 'keydown', e => { + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + e.stopImmediatePropagation() + info.isOpen = false + } + }), + dom.on(window, 'pointerdown', e => { + if (e.composedPath().includes(dropDown)) { + return + } + e.preventDefault() + e.stopPropagation() + e.stopImmediatePropagation() + info.isOpen = false + dom.on(window, 'click', e => { + e.preventDefault() + e.stopPropagation() + e.stopImmediatePropagation() + }, { once: true, capture: true }) + }, { capture: true }), + ] + } + }) + + return dropDown +} diff --git a/src/ui/Editor.tsx b/src/ui/Editor.tsx index 115e27f..a691aa7 100644 --- a/src/ui/Editor.tsx +++ b/src/ui/Editor.tsx @@ -38,7 +38,7 @@ export function Editor({ code, width, height, colorize, tokenize, wordWrapProces function createPane({ rect, code }: { rect: $ code: Signal - }) { + }): Pane { return Pane({ misc, view, diff --git a/src/ui/Heading.tsx b/src/ui/Heading.tsx index 15d996c..d7f7ecf 100644 --- a/src/ui/Heading.tsx +++ b/src/ui/Heading.tsx @@ -1,11 +1,13 @@ +import { cn } from '~/lib/cn.ts' + export function H1({ children }: { children?: any }) { return

{children}

} -export function H2({ children }: { children?: any }) { - return

+export function H2({ class: _class = '', children }: { class?: string, children?: any }) { + return

{children}

} 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