mirror of
https://github.com/Blah-IM/Weblah.git
synced 2025-05-01 00:31:08 +00:00
refactor: continue to migrate components
This commit is contained in:
parent
1e95dc0830
commit
4129cac511
24 changed files with 147 additions and 262 deletions
|
@ -38,7 +38,7 @@
|
||||||
"vitest": "^3.0.8"
|
"vitest": "^3.0.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blah-im/core": "^0.4.2",
|
"@blah-im/core": "^0.4.3",
|
||||||
"@melt-ui/svelte": "^0.86.4",
|
"@melt-ui/svelte": "^0.86.4",
|
||||||
"@zeabur/svelte-adapter": "^1.0.0",
|
"@zeabur/svelte-adapter": "^1.0.0",
|
||||||
"bits-ui": "^1.3.12",
|
"bits-ui": "^1.3.12",
|
||||||
|
|
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
|
@ -9,8 +9,8 @@ importers:
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@blah-im/core':
|
'@blah-im/core':
|
||||||
specifier: ^0.4.2
|
specifier: ^0.4.3
|
||||||
version: 0.4.2
|
version: 0.4.3
|
||||||
'@melt-ui/svelte':
|
'@melt-ui/svelte':
|
||||||
specifier: ^0.86.4
|
specifier: ^0.86.4
|
||||||
version: 0.86.4(svelte@5.23.0)
|
version: 0.86.4(svelte@5.23.0)
|
||||||
|
@ -122,8 +122,8 @@ packages:
|
||||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
'@blah-im/core@0.4.2':
|
'@blah-im/core@0.4.3':
|
||||||
resolution: {integrity: sha512-/3OsqGzDjAS5qTcizBGCR6QZmlCYSj7BYdS+ySK0GltRr3wOftmeoo7W6dWataZPFhIIcIvyZAQ3pMbL2707FQ==}
|
resolution: {integrity: sha512-qrVybHBFctonxAo/TYmyQ6X+7JPzu8jDMCxcfQvlI+SnZBWcYjqEVb3+A9Oqws0nivNlRvue7D0QdXxaEO8AyA==}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.19.12':
|
'@esbuild/aix-ppc64@0.19.12':
|
||||||
resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==}
|
resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==}
|
||||||
|
@ -2075,7 +2075,7 @@ snapshots:
|
||||||
'@jridgewell/gen-mapping': 0.3.5
|
'@jridgewell/gen-mapping': 0.3.5
|
||||||
'@jridgewell/trace-mapping': 0.3.25
|
'@jridgewell/trace-mapping': 0.3.25
|
||||||
|
|
||||||
'@blah-im/core@0.4.2':
|
'@blah-im/core@0.4.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
zod: 3.24.2
|
zod: 3.24.2
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import {
|
||||||
type BlahIdentityDescription,
|
type BlahIdentityDescription,
|
||||||
type BlahProfile
|
type BlahProfile
|
||||||
} from '@blah-im/core/identity';
|
} from '@blah-im/core/identity';
|
||||||
import { type IdentityFileDB, openIdentityFileDB } from '$lib/identityFiles/identityFileDB';
|
import { type IdentityDB, openIdentityDB } from './identityFileDB';
|
||||||
import { BlahKeyPair } from '@blah-im/core/crypto';
|
import { BlahKeyPair } from '@blah-im/core/crypto';
|
||||||
import { persisted } from 'svelte-persisted-store';
|
import { persisted } from 'svelte-persisted-store';
|
||||||
|
|
||||||
|
@ -16,18 +16,18 @@ export type Account = BlahIdentityDescription & {
|
||||||
|
|
||||||
class AccountStore implements Readable<Account[]> {
|
class AccountStore implements Readable<Account[]> {
|
||||||
private keyDB: AccountKeyDB;
|
private keyDB: AccountKeyDB;
|
||||||
private identityFileDB: IdentityFileDB;
|
private identityDB: IdentityDB;
|
||||||
private internalStore = writable<Account[]>([]);
|
private internalStore = writable<Account[]>([]);
|
||||||
subscribe = this.internalStore.subscribe;
|
subscribe = this.internalStore.subscribe;
|
||||||
|
|
||||||
private constructor(keyDB: AccountKeyDB, identityFileDB: IdentityFileDB) {
|
private constructor(keyDB: AccountKeyDB, identityDB: IdentityDB) {
|
||||||
this.keyDB = keyDB;
|
this.keyDB = keyDB;
|
||||||
this.identityFileDB = identityFileDB;
|
this.identityDB = identityDB;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async open(): Promise<AccountStore> {
|
static async open(): Promise<AccountStore> {
|
||||||
const keyDB = await openAccountKeyDB();
|
const keyDB = await openAccountKeyDB();
|
||||||
const identityFileDB = await openIdentityFileDB();
|
const identityFileDB = await openIdentityDB();
|
||||||
const store = new AccountStore(keyDB, identityFileDB);
|
const store = new AccountStore(keyDB, identityFileDB);
|
||||||
await store.loadAccounts();
|
await store.loadAccounts();
|
||||||
return store;
|
return store;
|
||||||
|
@ -35,7 +35,7 @@ class AccountStore implements Readable<Account[]> {
|
||||||
|
|
||||||
async loadAccounts() {
|
async loadAccounts() {
|
||||||
const accountCreds = await this.keyDB.fetchAllAccounts();
|
const accountCreds = await this.keyDB.fetchAllAccounts();
|
||||||
const identityFileMap = await this.identityFileDB.fetchIdentityFiles(
|
const identityFileMap = await this.identityDB.fetchIdentities(
|
||||||
accountCreds.map((x) => x.idKeyId)
|
accountCreds.map((x) => x.idKeyId)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -61,7 +61,7 @@ class AccountStore implements Readable<Account[]> {
|
||||||
const idKeyId =
|
const idKeyId =
|
||||||
typeof accountOrIdKeyId === 'string' ? accountOrIdKeyId : accountOrIdKeyId.id_key;
|
typeof accountOrIdKeyId === 'string' ? accountOrIdKeyId : accountOrIdKeyId.id_key;
|
||||||
|
|
||||||
const identityFile = await this.identityFileDB.fetchIdentityFile(idKeyId);
|
const identityFile = await this.identityDB.fetchIdentity(idKeyId);
|
||||||
if (!identityFile) throw new Error('Identity file not found');
|
if (!identityFile) throw new Error('Identity file not found');
|
||||||
|
|
||||||
const accountCreds = await this.keyDB.fetchAccount(idKeyId);
|
const accountCreds = await this.keyDB.fetchAccount(idKeyId);
|
||||||
|
@ -76,7 +76,7 @@ class AccountStore implements Readable<Account[]> {
|
||||||
|
|
||||||
async saveIdentityDescription(identity: BlahIdentity) {
|
async saveIdentityDescription(identity: BlahIdentity) {
|
||||||
const identityDesc = identity.generateIdentityDescription();
|
const identityDesc = identity.generateIdentityDescription();
|
||||||
await this.identityFileDB.updateIdentityFile(identityDesc);
|
await this.identityDB.updateIdentity(identityDesc);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAccount(profile: BlahProfile, password: string): Promise<string> {
|
async createAccount(profile: BlahProfile, password: string): Promise<string> {
|
||||||
|
|
81
src/lib/accounts/identityFileDB.ts
Normal file
81
src/lib/accounts/identityFileDB.ts
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import type { BlahIdentityDescription } from '@blah-im/core/identity';
|
||||||
|
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
|
||||||
|
|
||||||
|
const IDB_NAME = 'weblah-identities';
|
||||||
|
const IDB_OBJECT_STORE_NAME = 'identities';
|
||||||
|
|
||||||
|
const IDENTITY_FILE_MAX_AGE = 1000 * 60 * 60 * 24 * 30; // 30 days
|
||||||
|
|
||||||
|
interface IdentityDBSchema extends DBSchema {
|
||||||
|
[IDB_OBJECT_STORE_NAME]: {
|
||||||
|
key: string;
|
||||||
|
value: BlahIdentityDescription & { lastUpdatedAt: Date };
|
||||||
|
indexes: { id_urls: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class IdentityDB {
|
||||||
|
private db: IDBPDatabase<IdentityDBSchema>;
|
||||||
|
|
||||||
|
private constructor(db: IDBPDatabase<IdentityDBSchema>) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async open(): Promise<IdentityDB> {
|
||||||
|
const db = await openDB<IdentityDBSchema>(IDB_NAME, 1, {
|
||||||
|
upgrade(db) {
|
||||||
|
if (!db.objectStoreNames.contains(IDB_OBJECT_STORE_NAME)) {
|
||||||
|
const store = db.createObjectStore(IDB_OBJECT_STORE_NAME, { keyPath: 'id_key' });
|
||||||
|
store.createIndex('id_urls', 'profile.signee.payload.id_urls', {
|
||||||
|
multiEntry: true,
|
||||||
|
unique: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = new IdentityDB(db);
|
||||||
|
await store.removeExpiredIdentities();
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateIdentity(identityDescription: BlahIdentityDescription): Promise<void> {
|
||||||
|
await this.db.put(IDB_OBJECT_STORE_NAME, { ...identityDescription, lastUpdatedAt: new Date() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchIdentity(idKeyId: string): Promise<BlahIdentityDescription | undefined> {
|
||||||
|
return await this.db.get(IDB_OBJECT_STORE_NAME, idKeyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchIdentities(idKeyIds: string[]): Promise<Map<string, BlahIdentityDescription>> {
|
||||||
|
return new Map(
|
||||||
|
(
|
||||||
|
await Promise.all(
|
||||||
|
idKeyIds.map(async (idKeyId): Promise<[string, BlahIdentityDescription] | null> => {
|
||||||
|
const profile = await this.fetchIdentity(idKeyId);
|
||||||
|
return profile ? [idKeyId, profile] : null;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).filter((x): x is [string, BlahIdentityDescription] => !!x)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIdentityByIdUrl(idUrl: string): Promise<BlahIdentityDescription | undefined> {
|
||||||
|
return await this.db.getFromIndex(IDB_OBJECT_STORE_NAME, 'id_urls', idUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeExpiredIdentities(): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
const cutoff = new Date(now.getTime() - IDENTITY_FILE_MAX_AGE);
|
||||||
|
await this.db.delete(IDB_OBJECT_STORE_NAME, IDBKeyRange.upperBound(cutoff));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let identityFileDB: IdentityDB | null = null;
|
||||||
|
export async function openIdentityDB(): Promise<IdentityDB> {
|
||||||
|
if (!identityFileDB) {
|
||||||
|
identityFileDB = await IdentityDB.open();
|
||||||
|
}
|
||||||
|
return identityFileDB;
|
||||||
|
}
|
||||||
|
export type { IdentityDB };
|
|
@ -1,6 +1,6 @@
|
||||||
import { version } from '$app/environment';
|
import { version } from '$app/environment';
|
||||||
import type { BlahRichText } from '$lib/richText';
|
import type { BlahRichText } from '@blah-im/core/richText';
|
||||||
import type { BlahKeyPair, BlahSignedPayload } from '../crypto';
|
import type { BlahKeyPair, BlahSignedPayload } from '@blah-im/core/crypto';
|
||||||
import type { BlahAuth, BlahMessage, BlahRoomInfo, BlahUserJoinMessage } from '../structures';
|
import type { BlahAuth, BlahMessage, BlahRoomInfo, BlahUserJoinMessage } from '../structures';
|
||||||
import { BlahError } from './error';
|
import { BlahError } from './error';
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { BlahSignedPayload } from '../crypto';
|
import type { BlahSignedPayload } from '@blah-im/core/crypto';
|
||||||
|
|
||||||
export type BlahActKeyEntry = {
|
export type BlahActKeyEntry = {
|
||||||
exp: number;
|
exp: number;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { BlahRichText } from '$lib/richText';
|
import type { BlahRichText } from '@blah-im/core/richText';
|
||||||
|
|
||||||
export type BlahMessage = {
|
export type BlahMessage = {
|
||||||
rich_text: BlahRichText;
|
rich_text: BlahRichText;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { BlahSignedPayload } from '../crypto';
|
import type { BlahSignedPayload } from '@blah-im/core/crypto';
|
||||||
import type { BlahMessage } from './message';
|
import type { BlahMessage } from './message';
|
||||||
|
|
||||||
export type BlahRoomInfo = {
|
export type BlahRoomInfo = {
|
||||||
|
|
|
@ -1,7 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createBubbler } from 'svelte/legacy';
|
|
||||||
|
|
||||||
const bubble = createBubbler();
|
|
||||||
import { tw } from '$lib/tw';
|
import { tw } from '$lib/tw';
|
||||||
import { Icon, type IconSource } from 'svelte-hero-icons';
|
import { Icon, type IconSource } from 'svelte-hero-icons';
|
||||||
|
|
||||||
|
@ -10,31 +7,27 @@
|
||||||
icon?: IconSource | undefined;
|
icon?: IconSource | undefined;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
children?: import('svelte').Snippet;
|
children?: import('svelte').Snippet;
|
||||||
|
onclick?: (e: MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { href = undefined, icon = undefined, selected = false, children, onclick }: Props = $props();
|
||||||
href = undefined,
|
|
||||||
icon = undefined,
|
|
||||||
selected = false,
|
|
||||||
children
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:element
|
<svelte:element
|
||||||
this={href ? 'a' : 'button'}
|
this={href ? 'a' : 'button'}
|
||||||
{href}
|
{href}
|
||||||
class={tw(
|
class={tw(
|
||||||
'flex w-full cursor-default items-center gap-2 px-4 py-3 font-medium text-sf-primary first:rounded-t-lg last:rounded-b-lg',
|
'text-sf-primary flex w-full cursor-default items-center gap-2 px-4 py-3 font-medium first:rounded-t-lg last:rounded-b-lg',
|
||||||
selected && 'bg-accent-500 text-white shadow-inner dark:bg-accent-900 dark:text-sf-primary'
|
selected && 'bg-accent-500 dark:bg-accent-900 dark:text-sf-primary text-white shadow-inner'
|
||||||
)}
|
)}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
onclick={bubble('click')}
|
{onclick}
|
||||||
>
|
>
|
||||||
{#if icon}
|
{#if icon}
|
||||||
<Icon
|
<Icon
|
||||||
src={icon}
|
src={icon}
|
||||||
class={tw('size-5 text-sf-secondary', selected && 'text-white dark:text-sf-primary')}
|
class={tw('text-sf-secondary size-5', selected && 'dark:text-sf-primary text-white')}
|
||||||
mini
|
mini
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { BlahRichText } from '$lib/richText';
|
import type { BlahRichText } from '@blah-im/core/richText';
|
||||||
import { tw } from '$lib/tw';
|
import { tw } from '$lib/tw';
|
||||||
import RichTextSpan from './RichTextRenderer/RichTextSpan.svelte';
|
import RichTextSpan from './RichTextRenderer/RichTextSpan.svelte';
|
||||||
import PlainTextRenderer from './RichTextRenderer/PlainTextRenderer.svelte';
|
import PlainTextRenderer from './RichTextRenderer/PlainTextRenderer.svelte';
|
||||||
|
@ -10,7 +10,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
let { content, class: className = '' }: Props = $props();
|
let { content, class: className = '' }: Props = $props();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={tw('rich-text', className)}>
|
<div class={tw('rich-text', className)}>
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
<!-- @migration-task Error while migrating Svelte code: $$props is used together with named props in a way that cannot be automatically migrated. -->
|
<!-- @migration-task Error while migrating Svelte code: $$props is used together with named props in a way that cannot be automatically migrated. -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { BlahRichTextSpanAttributes } from '$lib/richText';
|
import type { BlahRichTextSpanAttributes } from '@blah-im/core/richText';
|
||||||
import PlainTextRenderer from './PlainTextRenderer.svelte';
|
import PlainTextRenderer from './PlainTextRenderer.svelte';
|
||||||
|
|
||||||
// From outside to inside, better align this with the RichTextInput
|
// From outside to inside, better align this with the RichTextInput
|
||||||
const renderOrder: (keyof BlahRichTextSpanAttributes)[] = [
|
const renderOrder: (keyof BlahRichTextSpanAttributes)[] = [
|
||||||
|
'spoiler',
|
||||||
'link',
|
'link',
|
||||||
'hashtag',
|
'tag',
|
||||||
'b',
|
'b',
|
||||||
'i',
|
'i',
|
||||||
'm',
|
'm',
|
||||||
|
@ -40,7 +41,7 @@
|
||||||
<a href={attributes.link} target="_blank">
|
<a href={attributes.link} target="_blank">
|
||||||
<svelte:self {...$$props} attribute={nextAttribute} />
|
<svelte:self {...$$props} attribute={nextAttribute} />
|
||||||
</a>
|
</a>
|
||||||
{:else if attribute === 'hashtag'}
|
{:else if attribute === 'tag'}
|
||||||
<a href={`/search?q=${encodeURIComponent(text)}`}>
|
<a href={`/search?q=${encodeURIComponent(text)}`}>
|
||||||
<svelte:self {...$$props} attribute={nextAttribute} />
|
<svelte:self {...$$props} attribute={nextAttribute} />
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -1,81 +0,0 @@
|
||||||
import type { BlahIdentityFile } from '@blah-im/core/identity';
|
|
||||||
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
|
|
||||||
|
|
||||||
const IDB_NAME = 'weblah-identities';
|
|
||||||
const IDB_OBJECT_STORE_NAME = 'identities';
|
|
||||||
|
|
||||||
const IDENTITY_FILE_MAX_AGE = 1000 * 60 * 60 * 24 * 30; // 30 days
|
|
||||||
|
|
||||||
interface IdentityFileDBSchema extends DBSchema {
|
|
||||||
[IDB_OBJECT_STORE_NAME]: {
|
|
||||||
key: string;
|
|
||||||
value: BlahIdentityFile & { lastUpdatedAt: Date };
|
|
||||||
indexes: { id_urls: string };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class IdentityFileDB {
|
|
||||||
private db: IDBPDatabase<IdentityFileDBSchema>;
|
|
||||||
|
|
||||||
private constructor(db: IDBPDatabase<IdentityFileDBSchema>) {
|
|
||||||
this.db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async open(): Promise<IdentityFileDB> {
|
|
||||||
const db = await openDB<IdentityFileDBSchema>(IDB_NAME, 1, {
|
|
||||||
upgrade(db) {
|
|
||||||
if (!db.objectStoreNames.contains(IDB_OBJECT_STORE_NAME)) {
|
|
||||||
const store = db.createObjectStore(IDB_OBJECT_STORE_NAME, { keyPath: 'id_key' });
|
|
||||||
store.createIndex('id_urls', 'profile.signee.payload.id_urls', {
|
|
||||||
multiEntry: true,
|
|
||||||
unique: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const store = new IdentityFileDB(db);
|
|
||||||
await store.removeExpiredIdentityFiles();
|
|
||||||
return store;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateIdentityFile(identityFile: BlahIdentityFile): Promise<void> {
|
|
||||||
await this.db.put(IDB_OBJECT_STORE_NAME, { ...identityFile, lastUpdatedAt: new Date() });
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchIdentityFile(idKeyId: string): Promise<BlahIdentityFile | undefined> {
|
|
||||||
return await this.db.get(IDB_OBJECT_STORE_NAME, idKeyId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchIdentityFiles(idKeyIds: string[]): Promise<Map<string, BlahIdentityFile>> {
|
|
||||||
return new Map(
|
|
||||||
(
|
|
||||||
await Promise.all(
|
|
||||||
idKeyIds.map(async (idKeyId): Promise<[string, BlahIdentityFile] | null> => {
|
|
||||||
const profile = await this.fetchIdentityFile(idKeyId);
|
|
||||||
return profile ? [idKeyId, profile] : null;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
).filter((x): x is [string, BlahIdentityFile] => !!x)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getIdentityFileByIdUrl(idUrl: string): Promise<BlahIdentityFile | undefined> {
|
|
||||||
return await this.db.getFromIndex(IDB_OBJECT_STORE_NAME, 'id_urls', idUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeExpiredIdentityFiles(): Promise<void> {
|
|
||||||
const now = new Date();
|
|
||||||
const cutoff = new Date(now.getTime() - IDENTITY_FILE_MAX_AGE);
|
|
||||||
await this.db.delete(IDB_OBJECT_STORE_NAME, IDBKeyRange.upperBound(cutoff));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let identityFileDB: IdentityFileDB | null = null;
|
|
||||||
export async function openIdentityFileDB(): Promise<IdentityFileDB> {
|
|
||||||
if (!identityFileDB) {
|
|
||||||
identityFileDB = await IdentityFileDB.open();
|
|
||||||
}
|
|
||||||
return identityFileDB;
|
|
||||||
}
|
|
||||||
export type { IdentityFileDB };
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { BlahRichText } from '$lib/richText';
|
import type { BlahRichText } from '@blah-im/core/richText';
|
||||||
import type { Message, User } from '$lib/types';
|
import type { Message, User } from '$lib/types';
|
||||||
import { getRandomUser } from './users';
|
import { getRandomUser } from './users';
|
||||||
|
|
||||||
|
|
|
@ -1,103 +0,0 @@
|
||||||
import canonicalize from 'canonicalize';
|
|
||||||
import type { AttributeMap, Delta } from 'typewriter-editor';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const blahRichTextSpanAttributesSchema = z.object({
|
|
||||||
b: z.boolean().default(false),
|
|
||||||
i: z.boolean().default(false),
|
|
||||||
u: z.boolean().default(false),
|
|
||||||
s: z.boolean().default(false),
|
|
||||||
m: z.boolean().default(false),
|
|
||||||
hashtag: z.boolean().default(false),
|
|
||||||
link: z.string().url().optional()
|
|
||||||
});
|
|
||||||
export type BlahRichTextSpanAttributes = z.input<typeof blahRichTextSpanAttributesSchema>;
|
|
||||||
|
|
||||||
export const blahRichTextSpanSchema = z.union([
|
|
||||||
z.string(),
|
|
||||||
z.tuple([z.string(), blahRichTextSpanAttributesSchema])
|
|
||||||
]);
|
|
||||||
export type BlahRichTextSpan = z.input<typeof blahRichTextSpanSchema>;
|
|
||||||
|
|
||||||
export const blahRichTextSchema = z.array(blahRichTextSpanSchema);
|
|
||||||
export type BlahRichText = z.input<typeof blahRichTextSchema>;
|
|
||||||
|
|
||||||
function isObjectEmpty(obj: object) {
|
|
||||||
for (const _ in obj) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deltaAttributesToBlahRichTextSpanAttributes(
|
|
||||||
attributes?: AttributeMap
|
|
||||||
): BlahRichTextSpanAttributes | null {
|
|
||||||
if (!attributes) return null;
|
|
||||||
|
|
||||||
const blahRichTextSpanAttributes: BlahRichTextSpanAttributes = {};
|
|
||||||
|
|
||||||
if (attributes.bold) blahRichTextSpanAttributes.b = true;
|
|
||||||
if (attributes.italic) blahRichTextSpanAttributes.i = true;
|
|
||||||
if (attributes.code) blahRichTextSpanAttributes.m = true;
|
|
||||||
if (attributes.link) blahRichTextSpanAttributes.link = attributes.link;
|
|
||||||
|
|
||||||
if (attributes.underline) blahRichTextSpanAttributes.u = true;
|
|
||||||
if (attributes.strikethrough) blahRichTextSpanAttributes.s = true;
|
|
||||||
|
|
||||||
return isObjectEmpty(blahRichTextSpanAttributes) ? null : blahRichTextSpanAttributes;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deltaToBlahRichText(delta: Delta, trim: boolean = true): BlahRichText {
|
|
||||||
const spans: BlahRichText = [];
|
|
||||||
|
|
||||||
let lastText = '';
|
|
||||||
let lastAttributes: BlahRichTextSpanAttributes | null = null;
|
|
||||||
let canonicalizedLastAttributes: string = 'null';
|
|
||||||
|
|
||||||
function commitSpan(trim?: 'start' | 'end'): boolean {
|
|
||||||
const trimmedLastText =
|
|
||||||
trim === 'start' ? lastText.trimStart() : trim === 'end' ? lastText.trimEnd() : lastText;
|
|
||||||
if (trimmedLastText === '') return false;
|
|
||||||
spans.push(lastAttributes === null ? trimmedLastText : [trimmedLastText, lastAttributes]);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let isFirstSpan = true;
|
|
||||||
for (const op of delta.ops) {
|
|
||||||
// Not sure in what cases op.insert would not be a string, but let's be safe
|
|
||||||
if (typeof op.insert !== 'string') continue;
|
|
||||||
|
|
||||||
const attributes = deltaAttributesToBlahRichTextSpanAttributes(op.attributes);
|
|
||||||
const canonicalizedAttributes = canonicalize(attributes) ?? 'null';
|
|
||||||
|
|
||||||
if (canonicalizedAttributes === canonicalizedLastAttributes) {
|
|
||||||
lastText += op.insert;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const commited = commitSpan(trim && isFirstSpan ? 'start' : undefined);
|
|
||||||
if (commited) isFirstSpan = false;
|
|
||||||
|
|
||||||
lastText = op.insert;
|
|
||||||
lastAttributes = attributes;
|
|
||||||
canonicalizedLastAttributes = canonicalizedAttributes;
|
|
||||||
}
|
|
||||||
const lastCommited = commitSpan(trim ? 'end' : undefined);
|
|
||||||
if (trim && !lastCommited) {
|
|
||||||
// The last segment is empty, so we need to trim the one before it
|
|
||||||
let lastSpan = spans.pop();
|
|
||||||
if (!lastSpan) return spans;
|
|
||||||
|
|
||||||
if (typeof lastSpan === 'string') {
|
|
||||||
lastSpan = lastSpan.trimEnd();
|
|
||||||
if (lastSpan !== '') spans.push(lastSpan);
|
|
||||||
} else {
|
|
||||||
lastSpan[0] = lastSpan[0].trimEnd();
|
|
||||||
if (lastSpan[0] !== '') spans.push(lastSpan);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return spans;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function blahRichTextToPlainText(richText: BlahRichText): string {
|
|
||||||
return richText.map((span) => (typeof span === 'string' ? span : span[0])).join('');
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
import type { BlahSignedPayload } from '@blah-im/core/crypto';
|
import type { BlahSignedPayload } from '@blah-im/core/crypto';
|
||||||
import type { BlahMessage } from '$lib/blah/structures';
|
import type { BlahMessage } from '$lib/blah/structures';
|
||||||
import type { BlahRichText } from '$lib/richText';
|
import type { BlahRichText } from '@blah-im/core/richText';
|
||||||
|
|
||||||
export type Message = {
|
export type Message = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
@ -12,8 +12,6 @@
|
||||||
|
|
||||||
let { searchQuery = $bindable(''), isSearchFocused = $bindable() }: Props = $props();
|
let { searchQuery = $bindable(''), isSearchFocused = $bindable() }: Props = $props();
|
||||||
|
|
||||||
let inputElement: HTMLInputElement = $state();
|
|
||||||
|
|
||||||
function onTapClear(e: MouseEvent) {
|
function onTapClear(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
@ -21,7 +19,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="flex items-center justify-stretch gap-2 border-b border-ss-secondary p-2 shadow-xs">
|
<header class="border-ss-secondary flex items-center justify-stretch gap-2 border-b p-2 shadow-xs">
|
||||||
<a
|
<a
|
||||||
class={tw(
|
class={tw(
|
||||||
'transition-[opacity,transform] duration-200',
|
'transition-[opacity,transform] duration-200',
|
||||||
|
@ -38,9 +36,8 @@
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
class="w-full flex-1 bg-transparent text-sm leading-4 text-sf-primary focus:outline-hidden"
|
class="text-sf-primary w-full flex-1 bg-transparent text-sm leading-4 focus:outline-hidden"
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
bind:this={inputElement}
|
|
||||||
onfocus={() => {
|
onfocus={() => {
|
||||||
isSearchFocused = true;
|
isSearchFocused = true;
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { run } from 'svelte/legacy';
|
|
||||||
|
|
||||||
import { AvatarBeam } from 'svelte-boring-avatars';
|
import { AvatarBeam } from 'svelte-boring-avatars';
|
||||||
|
|
||||||
import { formatMessageDate, formatUnreadCount } from '$lib/formatters';
|
import { formatMessageDate, formatFullMessageDate, formatUnreadCount } from '$lib/formatters';
|
||||||
import type { Chat } from '$lib/types';
|
import type { Chat } from '$lib/types';
|
||||||
import { currentKeyPair } from '$lib/keystore';
|
import { currentKeyPair } from '$lib/keystore';
|
||||||
import { blahRichTextToPlainText } from '$lib/richText';
|
import { toPlainText } from '@blah-im/core/richText';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
import { tw } from '$lib/tw';
|
import { tw } from '$lib/tw';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -16,19 +14,20 @@
|
||||||
|
|
||||||
let { chat }: Props = $props();
|
let { chat }: Props = $props();
|
||||||
|
|
||||||
let urlSafeEndpoint: string = $state();
|
function urlSafeEndpointForChat(chat: Chat) {
|
||||||
run(() => {
|
|
||||||
const url = new URL(chat.server);
|
const url = new URL(chat.server);
|
||||||
urlSafeEndpoint = encodeURIComponent(url.hostname + url.pathname);
|
return encodeURIComponent(url.hostname + url.pathname);
|
||||||
});
|
}
|
||||||
|
|
||||||
let isSelected = $derived($page.params.chatId === chat.id);
|
let urlSafeEndpoint = $derived(urlSafeEndpointForChat(chat));
|
||||||
|
|
||||||
|
let isSelected = $derived(page.params.chatId === chat.id);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<li
|
<li
|
||||||
class={tw(
|
class={tw(
|
||||||
'relative after:absolute after:bottom-0 after:end-0 after:start-14 after:border-t-[0.5px] after:border-ss-secondary',
|
'after:border-ss-secondary relative after:absolute after:start-14 after:end-0 after:bottom-0 after:border-t-[0.5px]',
|
||||||
isSelected && 'bg-accent-100 shadow-inner dark:bg-accent-950'
|
isSelected && 'bg-accent-100 dark:bg-accent-950 shadow-inner'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
|
@ -44,16 +43,16 @@
|
||||||
<h3 class="flex-1 truncate text-sm font-semibold">{chat.name}</h3>
|
<h3 class="flex-1 truncate text-sm font-semibold">{chat.name}</h3>
|
||||||
{#if chat.lastMessage}
|
{#if chat.lastMessage}
|
||||||
<time
|
<time
|
||||||
class="truncate text-xs text-sf-tertiary"
|
class="text-sf-tertiary truncate text-xs"
|
||||||
datetime={chat.lastMessage.date.toISOString()}
|
datetime={chat.lastMessage.date.toISOString()}
|
||||||
title={formatMessageDate(chat.lastMessage.date, true)}
|
title={formatFullMessageDate(chat.lastMessage.date)}
|
||||||
>
|
>
|
||||||
{formatMessageDate(chat.lastMessage.date)}
|
{formatMessageDate(chat.lastMessage.date)}
|
||||||
</time>
|
</time>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-end gap-1">
|
<div class="flex items-end gap-1">
|
||||||
<p class="line-clamp-2 h-[2.5em] flex-1 text-sm leading-tight text-sf-secondary">
|
<p class="text-sf-secondary line-clamp-2 h-[2.5em] flex-1 text-sm leading-tight">
|
||||||
{#if chat.lastMessage}
|
{#if chat.lastMessage}
|
||||||
{#if chat.id !== chat.lastMessage.sender.id}
|
{#if chat.id !== chat.lastMessage.sender.id}
|
||||||
<span class="text-sf-primary">
|
<span class="text-sf-primary">
|
||||||
|
@ -62,12 +61,12 @@
|
||||||
: chat.lastMessage.sender.name}:
|
: chat.lastMessage.sender.name}:
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{blahRichTextToPlainText(chat.lastMessage.content)}
|
{toPlainText(chat.lastMessage.content)}
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{#if chat.unreadCount}
|
{#if chat.unreadCount}
|
||||||
<span
|
<span
|
||||||
class="whitespace-nowrap rounded-full bg-slate-400 px-1.5 py-0.5 text-xs text-slate-50 dark:bg-slate-500 dark:text-slate-950"
|
class="rounded-full bg-slate-400 px-1.5 py-0.5 text-xs whitespace-nowrap text-slate-50 dark:bg-slate-500 dark:text-slate-950"
|
||||||
>
|
>
|
||||||
{formatUnreadCount(chat.unreadCount)}
|
{formatUnreadCount(chat.unreadCount)}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
let { size = 32 }: Props = $props();
|
let { size = 32 }: Props = $props();
|
||||||
|
|
||||||
let accountStore: AccountStore = $state();
|
let accountStore: AccountStore | undefined = $state();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
openAccountStore().then((store) => {
|
openAccountStore().then((store) => {
|
||||||
|
@ -22,7 +22,7 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if accountStore}
|
{#if accountStore && $accountStore}
|
||||||
{@const currentAccount = $accountStore.find((account) => account.id_key === $currentAccountStore)}
|
{@const currentAccount = $accountStore.find((account) => account.id_key === $currentAccountStore)}
|
||||||
<ProfilePicture account={currentAccount} {size} />
|
<ProfilePicture account={currentAccount} {size} />
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
@ -13,12 +13,12 @@
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<h3
|
<h3
|
||||||
class="ms-2 border-b-[0.5px] border-ss-secondary pb-1 pe-2 pt-2 text-xs font-semibold uppercase text-sf-secondary"
|
class="border-ss-secondary text-sf-secondary ms-2 border-b-[0.5px] pe-2 pt-2 pb-1 text-xs font-semibold uppercase"
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</h3>
|
</h3>
|
||||||
<ul>
|
<ul>
|
||||||
{#each results as chat}
|
{#each results as chat (chat.id)}
|
||||||
<ChatListItem {chat} />
|
<ChatListItem {chat} />
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { chatServerConnectionPool } from '$lib/chatServers';
|
import { chatServerConnectionPool } from '$lib/chatServers';
|
||||||
import { ChatBubbleLeftRight, Icon } from 'svelte-hero-icons';
|
import { ChatBubbleLeftRight, Icon } from 'svelte-hero-icons';
|
||||||
import ChatListItem from './ChatListItem.svelte';
|
|
||||||
import SearchChatResultSection from './SearchChatResultSection.svelte';
|
import SearchChatResultSection from './SearchChatResultSection.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -18,7 +17,7 @@
|
||||||
|
|
||||||
{#await search(searchQuery)}
|
{#await search(searchQuery)}
|
||||||
<div class="flex size-full items-center justify-center">
|
<div class="flex size-full items-center justify-center">
|
||||||
<Icon src={ChatBubbleLeftRight} solid class="w-1/3 animate-pulse fill-sf-tertiary" />
|
<Icon src={ChatBubbleLeftRight} solid class="fill-sf-tertiary w-1/3 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
{:then results}
|
{:then results}
|
||||||
{#if results.joined.length === 0 && results.public.length === 0}
|
{#if results.joined.length === 0 && results.public.length === 0}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
import RichTextInput from '$lib/components/RichTextInput.svelte';
|
import RichTextInput from '$lib/components/RichTextInput.svelte';
|
||||||
import { deltaToBlahRichText, type BlahRichText } from '$lib/richText';
|
import { deltaToBlahRichText, type BlahRichText } from '@blah-im/core/richText';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import type { Delta, Editor } from 'typewriter-editor';
|
import type { Delta, Editor } from 'typewriter-editor';
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
import ChatHeader from './ChatHeader.svelte';
|
import ChatHeader from './ChatHeader.svelte';
|
||||||
import ChatHistory from './ChatHistory.svelte';
|
import ChatHistory from './ChatHistory.svelte';
|
||||||
import ChatInput from './ChatInput.svelte';
|
import ChatInput from './ChatInput.svelte';
|
||||||
import type { BlahRichText } from '$lib/richText';
|
import type { BlahRichText } from '@blah-im/core/richText';
|
||||||
import type { MessageSection } from '$lib/chat';
|
import type { MessageSection } from '$lib/chat';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
@ -11,9 +11,9 @@
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate';
|
||||||
import { blur, scale } from 'svelte/transition';
|
import { blur } from 'svelte/transition';
|
||||||
|
|
||||||
let accountStore: AccountStore = $state();
|
let accountStore: AccountStore | undefined = $state();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
openAccountStore().then((store) => {
|
openAccountStore().then((store) => {
|
||||||
|
@ -26,7 +26,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if accountStore}
|
{#if accountStore && $accountStore}
|
||||||
{@const currentAccount = $accountStore.find((acc) => acc.id_key === $currentAccountStore)}
|
{@const currentAccount = $accountStore.find((acc) => acc.id_key === $currentAccountStore)}
|
||||||
{@const remainingAccounts = $accountStore
|
{@const remainingAccounts = $accountStore
|
||||||
.filter((acc) => acc.id_key !== $currentAccountStore)
|
.filter((acc) => acc.id_key !== $currentAccountStore)
|
||||||
|
@ -38,12 +38,12 @@
|
||||||
<ProfilePicture account={currentAccount} size={68} />
|
<ProfilePicture account={currentAccount} size={68} />
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<span class="text-xl font-semibold text-sf-primary">
|
<span class="text-sf-primary text-xl font-semibold">
|
||||||
{currentAccount.profile.signee.payload.name}
|
{currentAccount.profile.signee.payload.name}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<code class="text-sm text-sf-secondary">
|
<code class="text-sf-secondary text-sm">
|
||||||
{currentAccount.profile.signee.id_key.slice(0, 6) +
|
{currentAccount.profile.signee.id_key.slice(0, 6) +
|
||||||
'...' +
|
'...' +
|
||||||
currentAccount.profile.signee.id_key.slice(-6)}
|
currentAccount.profile.signee.id_key.slice(-6)}
|
||||||
|
@ -57,7 +57,7 @@
|
||||||
<GroupedListSection>
|
<GroupedListSection>
|
||||||
{#each remainingAccounts as account (account.id_key)}
|
{#each remainingAccounts as account (account.id_key)}
|
||||||
<div animate:flip={{ duration: 250 }} transition:blur>
|
<div animate:flip={{ duration: 250 }} transition:blur>
|
||||||
<GroupedListItem on:click={() => switchToAccount(account)}>
|
<GroupedListItem onclick={() => switchToAccount(account)}>
|
||||||
<div class="-mx-0.5"><ProfilePicture {account} size={24} /></div>
|
<div class="-mx-0.5"><ProfilePicture {account} size={24} /></div>
|
||||||
{account.profile.signee.payload.name}
|
{account.profile.signee.payload.name}
|
||||||
</GroupedListItem>
|
</GroupedListItem>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import RichTextInput from '$lib/components/RichTextInput.svelte';
|
import RichTextInput from '$lib/components/RichTextInput.svelte';
|
||||||
import { deltaToBlahRichText } from '$lib/richText';
|
import { deltaToBlahRichText } from '@blah-im/core/richText';
|
||||||
import type { Delta } from 'typewriter-editor';
|
import type { Delta } from 'typewriter-editor';
|
||||||
|
|
||||||
let delta: Delta = $state();
|
let delta: Delta = $state();
|
||||||
|
@ -16,14 +16,14 @@
|
||||||
<div class="flex min-h-0 flex-1 gap-4 p-4">
|
<div class="flex min-h-0 flex-1 gap-4 p-4">
|
||||||
<div class="flex min-h-0 flex-1 flex-col">
|
<div class="flex min-h-0 flex-1 flex-col">
|
||||||
<h2 class="text-lg">Delta (Editor's internal representation)</h2>
|
<h2 class="text-lg">Delta (Editor's internal representation)</h2>
|
||||||
<div class="min-h-0 flex-1 select-text overflow-auto">
|
<div class="min-h-0 flex-1 overflow-auto select-text">
|
||||||
<pre><code>{JSON.stringify(delta, null, 2)}</code></pre>
|
<pre><code>{JSON.stringify(delta, null, 2)}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-1 flex-col">
|
<div class="flex min-h-0 flex-1 flex-col">
|
||||||
<h2 class="text-lg">Blah Rich Text</h2>
|
<h2 class="text-lg">Blah Rich Text</h2>
|
||||||
<div class="min-h-0 flex-1 select-text overflow-auto">
|
<div class="min-h-0 flex-1 overflow-auto select-text">
|
||||||
<pre><code>{JSON.stringify(brt, null, 2)}</code></pre>
|
<pre><code>{JSON.stringify(brt, null, 2)}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Add table
Reference in a new issue