Move app-specific components, CSS families and single-caller helpers out of src/lib and document the app modules that still open components
This commit is contained in:
parent
25e11257aa
commit
0e01055855
25 changed files with 181 additions and 195 deletions
|
|
@ -20,9 +20,8 @@ and UI.
|
|||
| App modules | `src/app/*.ts`, `editor/`, `push/` | `@app/x` | `@lib`, each other |
|
||||
| Lib | `src/lib` | `@lib/x` | external packages only |
|
||||
|
||||
`svelte.config.js` defines the aliases `@src`, `@app`, `@lib` and `@assets`. Use `@lib`.
|
||||
SvelteKit's built-in `$lib` also resolves, but only four stray imports use it (in `relays.ts`,
|
||||
`callEngine.ts` and `VoiceRoomJoinDialog.svelte`). There is no barrel file, so import each
|
||||
`svelte.config.js` defines the aliases `@src`, `@app`, `@lib` and `@assets`. Use `@lib`, not
|
||||
SvelteKit's built-in `$lib`, which also resolves. There is no barrel file, so import each
|
||||
component by its path (`@lib/components/Button.svelte`), not from `$lib/components` as the
|
||||
AGENTS.md example has it. Icons come from `@assets/icons/<name>.svg?dataurl`.
|
||||
|
||||
|
|
@ -44,12 +43,14 @@ and `sync.ts`.
|
|||
No lint rule enforces the layers (`eslint.config.js` has no import restrictions), so review is the
|
||||
only gate.
|
||||
|
||||
- **lib → app.** `Link.svelte` imports `navigate` from `@app/modal`, and `ImageInputButton.svelte`
|
||||
and `IconPickerButton.svelte` open app modals. Don't copy them. A lib component that needs app
|
||||
behavior takes it as a prop, or moves to `src/app/components`.
|
||||
- **app → components.** `routes.ts` (`goToChat` opens `ChatEnable`), `share.ts` (`Share`,
|
||||
`ShareEvent`), `deepLinks.ts` (`Search`) and `speech.ts` (`OpenRouterEnable`) import a component
|
||||
so they can open a modal mid-flow. `editor/` holds `.svelte` files of its own (suggestion
|
||||
- **lib → app.** `Link.svelte` imports `navigate` from `@app/modal`. Don't copy it. A lib
|
||||
component that needs app behavior takes it as a prop, or moves to `src/app/components`, as
|
||||
`IconInput` and its picker buttons did.
|
||||
- **app → components.** `routes.ts` (`goToChat` opens `ChatEnable`, `goToEvent` opens
|
||||
`NoteDetail`), `share.ts` (`Share`, `ShareEvent`), `deepLinks.ts` (`Search`) and `speech.ts`
|
||||
(`OpenRouterEnable`) import a component so they can open a modal mid-flow. Push adapters and
|
||||
deep links reach those flows from outside any component, and the rest have several component
|
||||
callers, so each stays in one module. `editor/` holds `.svelte` files of its own (suggestion
|
||||
popovers), which `makeEditor` mounts.
|
||||
- Nothing under `src/app` or `src/lib` imports from `src/routes`.
|
||||
|
||||
|
|
@ -71,7 +72,9 @@ only gate.
|
|||
|
||||
## `src/app` by concern
|
||||
|
||||
`src/app/components` is flat except for `hosting/`. `flotilla-views` covers component conventions.
|
||||
`src/app/components` is flat except for `hosting/`. It also holds the CSS families for app
|
||||
concepts (`chat.css`, `room.css`, `role-badge.css`, `space-menu.css`), which `src/app/app.css`
|
||||
imports. `flotilla-views` covers component conventions.
|
||||
|
||||
**Core and session**
|
||||
- `core.ts`: the `App` store, plugin stores, `login`, and the `reader`/`writer`/`command` shortcuts
|
||||
|
|
@ -95,7 +98,7 @@ only gate.
|
|||
node views
|
||||
|
||||
**Spaces, rooms and administration**
|
||||
- `relays.ts`: relay URL encoding for routes, socket status, LiveKit detection
|
||||
- `relays.ts`: relay URL encoding for routes, socket status, the LiveKit endpoint and detection
|
||||
- `rooms.ts`: helpers over `rooms.get()`, and the user's rooms and spaces
|
||||
- `access.ts`: joining, invites, relay auth errors
|
||||
- `management.ts` (NIP-86 admin checks, bans), `roles.ts` (member roles)
|
||||
|
|
@ -110,11 +113,12 @@ only gate.
|
|||
- `feeds.ts`: `makeFeed`, `makeFeedContext`, `makeScrollLoader`, `makeCalendarFeed`
|
||||
- `classifieds.ts`, `articles.ts`, `pins.ts` (a person's pinned notes), `pinboards.ts`
|
||||
- `reactions.ts`, `social.ts` (display names, comment trees, muting), `render.ts` (events as
|
||||
text), `statuses.ts` (NIP-38), `uploads.ts` (Blossom)
|
||||
text), `statuses.ts` (NIP-38), `uploads.ts` (Blossom, image compression)
|
||||
- `notifications.ts` (unread state, badges), `inbox.ts` (the home inbox)
|
||||
|
||||
**Messaging and calls**
|
||||
- `chats.ts`, `call.ts` (call state), `callEngine.ts` (LiveKit join, leave, devices)
|
||||
- `chats.ts`, `call.ts` (call state), `callEngine.ts` (LiveKit join, leave, devices, and the
|
||||
`AbortError`/`TimeoutError` a join rejects with)
|
||||
|
||||
**Identity and payments**
|
||||
- `nip46.ts`, `pomade.ts` (email login), `lightning.ts` (wallet, invoices), `healthChecks.ts`
|
||||
|
|
@ -133,16 +137,15 @@ Lib code is app-agnostic. It may use svelte, SvelteKit, Capacitor and welshman,
|
|||
env, or the `App` instance. A good test is whether it would work unchanged in another nostr
|
||||
client.
|
||||
|
||||
- `util.ts`: small helpers (`errorMessage`, `AbortError`/`TimeoutError`, `buildUrl`,
|
||||
`normalizeTopic`)
|
||||
- `html.ts`: DOM helpers such as `isMobile`, `createScroller`, `copyToClipboard`, `compressFile`
|
||||
- `util.ts`: small helpers (`errorMessage`, `buildUrl`, `normalizeTopic`)
|
||||
- `html.ts`: DOM helpers such as `isMobile`, `createScroller`, `copyToClipboard`
|
||||
- `indexeddb.ts`: the `IDB` wrapper that `storage.ts` builds on
|
||||
- `feeds.ts`: saved feed definitions (kind `FEED`) over `@welshman/feeds`. It is unrelated to
|
||||
`@app/feeds`, which loads events.
|
||||
- `livekit.ts`: finds a relay's LiveKit endpoint
|
||||
- `currency.ts`, `transition.ts`, `implicit.ts` (hands state from one page to the next)
|
||||
- `test/`: the DEV-only hooks the e2e harness injects through
|
||||
- `components/`: the design system, entered through `theme.css` (see `flotilla-views`)
|
||||
- `components/`: the design system, entered through `theme.css` (see `flotilla-views`), which
|
||||
`src/app/app.css` imports ahead of the app's own CSS families
|
||||
|
||||
## Boot sequence
|
||||
|
||||
|
|
|
|||
|
|
@ -228,15 +228,15 @@ lives there:
|
|||
- modal chrome: `Dialog`, `Drawer`, `Modal`, `ModalBody`, `ModalHeader`, `ModalTitle`,
|
||||
`ModalSubtitle`, `ModalFooter`, `Confirm`
|
||||
- controls: `Button`, `Link`, `Field`, `FieldInline`, `Input`, `InputList`, `ToggleInput`,
|
||||
`DateTimeInput`, `ImagesInput`, `IconInput`, `EmojiPicker`, `MenuButton`, `SegmentedButtons`
|
||||
`DateTimeInput`, `ImagesInput`, `EmojiPicker`, `MenuButton`, `SegmentedButtons`
|
||||
- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `LoadingFallback`, `Tooltip`,
|
||||
`Tippy`, `Cv`, `VirtualList`, `Masonry`, `DragList`, `ScrollToTop`
|
||||
- the CSS component families (`button.css`, `card.css`, …) and the theme tokens
|
||||
|
||||
Anything that takes an identifier and reads a store, publishes, or knows a kind is an app
|
||||
component. A lib component that needs app behavior takes it as a prop, such as `MenuButton`'s
|
||||
`component`. Three lib components import `@app` anyway; flotilla-architecture lists them under
|
||||
its layer exceptions.
|
||||
`component`. `Link` imports `@app` anyway; flotilla-architecture lists it under its layer
|
||||
exceptions.
|
||||
|
||||
`src/app/components` is flat, and `hosting/` is the only subdirectory it has ever had, brought in
|
||||
whole by the caravel port (cf938c63) with names that would blur into the flat `Relay*` family.
|
||||
|
|
@ -435,7 +435,7 @@ wrap their contents in `ThunkStatusOrDeleted`, and chat pushes a `ThunkToast`.
|
|||
`Field` puts a label above its control, with optional `secondary` and `info` snippets.
|
||||
`FieldInline` puts the label left and the control right, as settings and detail rows do. Controls
|
||||
are plain elements styled by class (`<label class="input …">`, `<select class="select input">`),
|
||||
or bindable inputs from lib (`ImagesInput`, `IconInput`) and app (`TopicMultiSelect`).
|
||||
or bindable inputs from lib (`ImagesInput`) and app (`IconInput`, `TopicMultiSelect`).
|
||||
|
||||
Create/edit pairs come in three shapes:
|
||||
|
||||
|
|
@ -457,9 +457,11 @@ Rich text uses `makeEditor` from `src/app/editor` with `EditorContent`.
|
|||
## Styling
|
||||
|
||||
- Classes come from the component families in `src/lib/components/*.css`, which `theme.css`
|
||||
imports: `button` with `button-primary|neutral|link|ghost|error` and
|
||||
`button-sm|xs|circle|square`, `card`, `badge`, `input`, `select`, `textarea`, `menu`. These are
|
||||
flotilla's own; daisyUI is not installed.
|
||||
imports, and the app-concept families in `src/app/components/*.css`, which `src/app/app.css`
|
||||
imports after `theme.css`. A class goes in `@layer components` either way. The lib families
|
||||
include `button` with `button-primary|neutral|link|ghost|error` and `button-sm|xs|circle|square`,
|
||||
`card`, `badge`, `input`, `select`, `textarea` and `menu`. These are flotilla's own; daisyUI is
|
||||
not installed.
|
||||
- Colors are the semantic tokens registered in `base.css`: `bg-surface`, `bg-surface-more`,
|
||||
`text-content`, `text-content-muted`, `border-line`, `text-primary`, `text-error`. The clay,
|
||||
flat, navy and nature themes supply the values through `data-fl-theme`.
|
||||
|
|
|
|||
14
src/app/app.css
Normal file
14
src/app/app.css
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/* App stylesheet (imported once, in src/routes/+layout.svelte): the design
|
||||
system first, then the families for flotilla's own components. */
|
||||
|
||||
@import "../lib/components/theme.css";
|
||||
@import "./components/chat.css";
|
||||
@import "./components/role-badge.css";
|
||||
@import "./components/room.css";
|
||||
@import "./components/space-menu.css";
|
||||
|
||||
/* SocketStatusIndicator builds its class as bg-{status.theme}, which the source
|
||||
scanner can't see. Must stay below every @import to keep those valid. */
|
||||
@source inline("bg-success bg-warning bg-error bg-gray-500");
|
||||
|
||||
@config "../../tailwind.config.js";
|
||||
|
|
@ -16,9 +16,8 @@ import {get} from "svelte/store"
|
|||
import {not, nthEq, reject} from "@welshman/lib"
|
||||
import {makeHttpAuth, makeHttpAuthHeader} from "@welshman/util"
|
||||
import {makeRoomKey} from "@welshman/app"
|
||||
import {getLivekitEndpoint} from "$lib/livekit"
|
||||
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
|
||||
import {user} from "@app/core"
|
||||
import {getLivekitEndpoint} from "@app/relays"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {
|
||||
CallState,
|
||||
|
|
@ -39,6 +38,42 @@ import {
|
|||
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
|
||||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||
|
||||
export class AbortError extends Error {
|
||||
constructor() {
|
||||
super("Aborted")
|
||||
this.name = "AbortError"
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeoutError extends Error {
|
||||
constructor(message = "Timed out") {
|
||||
super(message)
|
||||
this.name = "TimeoutError"
|
||||
}
|
||||
}
|
||||
|
||||
const whenAborted = (signal?: AbortSignal) => {
|
||||
if (!signal) {
|
||||
return new Promise<never>(() => {})
|
||||
}
|
||||
|
||||
return new Promise<never>((_, reject) => {
|
||||
const onAborted = () => reject(new AbortError())
|
||||
if (signal.aborted) {
|
||||
onAborted()
|
||||
} else {
|
||||
signal.addEventListener("abort", onAborted, {once: true})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const whenTimeout = (ms: number, opts: {message?: string; signal?: AbortSignal} = {}) => {
|
||||
return new Promise<never>((_, reject) => {
|
||||
const timeout = setTimeout(() => reject(new TimeoutError(opts.message)), ms)
|
||||
opts.signal?.addEventListener("abort", () => clearTimeout(timeout), {once: true})
|
||||
})
|
||||
}
|
||||
|
||||
export const joinVoiceRoom = async (
|
||||
url: string,
|
||||
h: string,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
import IconPickerButton from "@lib/components/IconPickerButton.svelte"
|
||||
import ImageInputButton from "@lib/components/ImageInputButton.svelte"
|
||||
import IconPickerButton from "@app/components/IconPickerButton.svelte"
|
||||
import ImageInputButton from "@app/components/ImageInputButton.svelte"
|
||||
|
||||
type Props = {
|
||||
// The parent uploads this on submit — never `preview`, which may be a data: URL.
|
||||
|
|
@ -8,7 +8,6 @@
|
|||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
import IconInput from "@lib/components/IconInput.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import {rooms} from "@app/core"
|
||||
|
|
@ -17,6 +16,7 @@
|
|||
import {resolveImageInput} from "@app/uploads"
|
||||
import {deriveHasLivekit} from "@app/relays"
|
||||
import {RoomType} from "@app/rooms"
|
||||
import IconInput from "@app/components/IconInput.svelte"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@
|
|||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
import IconInput from "@lib/components/IconInput.svelte"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {clearModals} from "@app/modal"
|
||||
import {resolveImageInput} from "@app/uploads"
|
||||
import {relayManagement, relays} from "@app/core"
|
||||
import IconInput from "@app/components/IconInput.svelte"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
|
|
|
|||
|
|
@ -12,12 +12,11 @@
|
|||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import {AbortError, TimeoutError} from "$lib/util"
|
||||
import MicLevelMeter from "@app/components/MicLevelMeter.svelte"
|
||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||
import {displayRoom} from "@app/rooms"
|
||||
import {deriveCallParticipants, loadCallParticipants} from "@app/call"
|
||||
import {joinVoiceRoom} from "@app/callEngine"
|
||||
import {AbortError, TimeoutError, joinVoiceRoom} from "@app/callEngine"
|
||||
import {popModal} from "@app/modal"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@
|
|||
import Field from "@lib/components/Field.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Input from "@lib/components/Input.svelte"
|
||||
import IconInput from "@lib/components/IconInput.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
|
|
@ -29,6 +28,7 @@
|
|||
import {HOSTING_RELAY_DOMAIN} from "@app/env"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {resolveImageInput} from "@app/uploads"
|
||||
import IconInput from "@app/components/IconInput.svelte"
|
||||
|
||||
type Props = {
|
||||
mode: "create" | "edit"
|
||||
|
|
|
|||
44
src/app/components/role-badge.css
Normal file
44
src/app/components/role-badge.css
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
@layer components {
|
||||
.role-badge {
|
||||
@apply inline-flex min-w-0 items-center gap-1.5 rounded-full border-2 border-solid px-2 py-1 text-xs font-normal;
|
||||
background: color-mix(in srgb, var(--role-color) 12%, transparent);
|
||||
color: var(--role-color);
|
||||
border-color: color-mix(in srgb, var(--role-color) 22%, transparent);
|
||||
}
|
||||
|
||||
.role-badge__dot {
|
||||
@apply size-1.5 shrink-0 rounded-full;
|
||||
background-color: var(--role-color);
|
||||
}
|
||||
|
||||
.role-badge--md {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.role-badge--md .role-badge__dot {
|
||||
@apply size-2;
|
||||
}
|
||||
|
||||
[data-fl-theme="clay"] .role-badge {
|
||||
@apply bg-surface border-line rounded-xl;
|
||||
border-width: var(--border-thin, 2px);
|
||||
border-color: color-mix(in srgb, var(--role-color) 22%, var(--line));
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
box-shadow:
|
||||
4px 4px 0 color-mix(in srgb, var(--role-color) 28%, transparent),
|
||||
var(--shadow-inset);
|
||||
}
|
||||
|
||||
[data-fl-theme="clay"] .role-badge__dot {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--role-color) 18%, transparent);
|
||||
}
|
||||
|
||||
[data-fl-theme="clay"] .role-badge--md {
|
||||
@apply px-2.5 py-1.5;
|
||||
box-shadow:
|
||||
5px 5px 0 color-mix(in srgb, var(--role-color) 28%, transparent),
|
||||
var(--shadow-inset);
|
||||
}
|
||||
}
|
||||
|
|
@ -83,6 +83,10 @@
|
|||
@apply flex items-center gap-1 text-sm;
|
||||
}
|
||||
|
||||
body.keyboard-open .room {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
[data-fl-theme="clay"] .room {
|
||||
@apply bg-surface;
|
||||
|
||||
|
|
@ -3,7 +3,6 @@ import {call, on, simpleCache} from "@welshman/lib"
|
|||
import {normalizeRelayUrl} from "@welshman/util"
|
||||
import {AuthStateEvent, AuthStatus, SocketEvent, SocketStatus} from "@welshman/net"
|
||||
import {throttled} from "@welshman/store"
|
||||
import {checkRelayHasLivekit} from "$lib/livekit"
|
||||
import {app} from "@app/core"
|
||||
import {relaysMostlyRestricted} from "@app/policies"
|
||||
import {colorFor} from "@app/theme"
|
||||
|
|
@ -96,8 +95,16 @@ export const deriveSocketStatus = (url: string) =>
|
|||
}),
|
||||
)
|
||||
|
||||
const livekitUrl = (url: string) =>
|
||||
url.replace(/^ws/, "http").replace(/\/$/, "") + "/.well-known/nip29/livekit"
|
||||
|
||||
export const getLivekitEndpoint = (url: string, roomId: string) => `${livekitUrl(url)}/${roomId}`
|
||||
|
||||
export const deriveHasLivekit = simpleCache(([url]: [string]) =>
|
||||
readable<boolean | undefined>(undefined, set => {
|
||||
checkRelayHasLivekit(url).then(has => set(has))
|
||||
fetch(livekitUrl(url)).then(
|
||||
response => set(response.status === 204),
|
||||
() => set(false),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import type {Maybe} from "@welshman/lib"
|
||||
import {first, normalizeUrl, parseJson, sha256, simpleCache} from "@welshman/lib"
|
||||
import {first, normalizeUrl, parseJson, randomId, sha256, simpleCache} from "@welshman/lib"
|
||||
import {canUploadBlob, encryptFile, makeBlossomAuthEvent, uploadBlob} from "@welshman/util"
|
||||
import {Nip01Signer} from "@welshman/signer"
|
||||
import {compressFile} from "@lib/html"
|
||||
import {app, blossomServerLists, relays} from "@app/core"
|
||||
import {DEFAULT_BLOSSOM_SERVERS} from "@app/env"
|
||||
|
||||
|
|
@ -68,6 +67,39 @@ export type CompressFileOptions = {
|
|||
mimeType?: string
|
||||
}
|
||||
|
||||
const compressFile = async (file: File, options: CompressFileOptions) => {
|
||||
const {default: Compressor} = await import("compressorjs-next")
|
||||
|
||||
return new Promise<File>((resolve, reject) => {
|
||||
new Compressor(file, {
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
convertTypes: ["image/png"],
|
||||
...options,
|
||||
success: result => {
|
||||
// Capacitor's fetch interceptor checks instanceof File, and canvas.toBlob returns a Blob.
|
||||
if (result instanceof File) {
|
||||
resolve(result)
|
||||
} else {
|
||||
resolve(new File([result], file.name || "upload", {type: result.type}))
|
||||
}
|
||||
},
|
||||
error: e => {
|
||||
// Non-images break compressor, return the original file
|
||||
if (e.toString().includes("File or Blob")) {
|
||||
resolve(
|
||||
new File([file], `${randomId()}.${file.type.split("/")[1] || "bin"}`, {
|
||||
type: file.type,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
reject(e)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Webp/gif/svg either break the compressor or lose animation, pass them through untouched
|
||||
export const compressFileForUpload = async (file: File, options: CompressFileOptions = {}) =>
|
||||
file.type.match("image/(webp|gif|svg)") ? file : compressFile(file, options)
|
||||
|
|
|
|||
|
|
@ -35,24 +35,4 @@
|
|||
border-color: color-mix(in srgb, var(--warning) 25%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
@apply inline-flex min-w-0 items-center gap-1.5 rounded-full border-2 border-solid px-2 py-1 text-xs font-normal;
|
||||
background: color-mix(in srgb, var(--role-color) 12%, transparent);
|
||||
color: var(--role-color);
|
||||
border-color: color-mix(in srgb, var(--role-color) 22%, transparent);
|
||||
}
|
||||
|
||||
.role-badge__dot {
|
||||
@apply size-1.5 shrink-0 rounded-full;
|
||||
background-color: var(--role-color);
|
||||
}
|
||||
|
||||
.role-badge--md {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.role-badge--md .role-badge__dot {
|
||||
@apply size-2;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,9 +201,6 @@ body.keyboard-open {
|
|||
body.keyboard-open .hide-on-keyboard {
|
||||
@apply hidden;
|
||||
}
|
||||
body.keyboard-open .room {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
Fonts shared/default across all themes
|
||||
|
|
|
|||
|
|
@ -108,27 +108,4 @@
|
|||
.chat-editor .tiptap {
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
@apply bg-surface border-line rounded-xl;
|
||||
border-width: var(--border-thin, 2px);
|
||||
border-color: color-mix(in srgb, var(--role-color) 22%, var(--line));
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
box-shadow:
|
||||
4px 4px 0 color-mix(in srgb, var(--role-color) 28%, transparent),
|
||||
var(--shadow-inset);
|
||||
}
|
||||
|
||||
.role-badge__dot {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--role-color) 18%, transparent);
|
||||
}
|
||||
|
||||
.role-badge--md {
|
||||
@apply px-2.5 py-1.5;
|
||||
box-shadow:
|
||||
5px 5px 0 color-mix(in srgb, var(--role-color) 28%, transparent),
|
||||
var(--shadow-inset);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* ============================================================================
|
||||
Design-system entry point (imported once, in src/routes/+layout.svelte).
|
||||
Design-system entry point (imported once, by src/app/app.css).
|
||||
|
||||
tailwindcss — framework + our @theme token registration (via base.css)
|
||||
base.css — theme-independent structure (base, utilities, integrations)
|
||||
|
|
@ -24,7 +24,6 @@
|
|||
@import "./button.css";
|
||||
@import "./card.css";
|
||||
@import "./card-button.css";
|
||||
@import "./chat.css";
|
||||
@import "./content-markdown.css";
|
||||
@import "./content-search.css";
|
||||
@import "./controls.css";
|
||||
|
|
@ -36,14 +35,6 @@
|
|||
@import "./menu.css";
|
||||
@import "./page.css";
|
||||
@import "./primary-nav.css";
|
||||
@import "./room.css";
|
||||
@import "./spinner.css";
|
||||
@import "./secondary-nav.css";
|
||||
@import "./space-menu.css";
|
||||
@import "./tooltip.css";
|
||||
|
||||
/* SocketStatusIndicator builds its class as bg-{status.theme}, which the source
|
||||
scanner can't see. Must stay below every @import to keep those valid. */
|
||||
@source inline("bg-success bg-warning bg-error bg-gray-500");
|
||||
|
||||
@config "../../../tailwind.config.js";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import {readable} from "svelte/store"
|
||||
import {sleep, randomId} from "@welshman/lib"
|
||||
import {sleep} from "@welshman/lib"
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
export {preventDefault, stopPropagation} from "svelte/legacy"
|
||||
|
||||
|
|
@ -160,44 +160,6 @@ export const isIntersecting = async (element: Element) =>
|
|||
observer.observe(element)
|
||||
})
|
||||
|
||||
export const compressFile = async (
|
||||
file: File | Blob,
|
||||
options: Record<string, any> = {},
|
||||
): Promise<File> => {
|
||||
const {default: Compressor} = await import("compressorjs-next")
|
||||
|
||||
return new Promise<File>((resolve, _reject) => {
|
||||
new Compressor(file, {
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
convertTypes: ["image/png"],
|
||||
...options,
|
||||
success: result => {
|
||||
// Capacitor's fetch interceptor checks instanceof File, and canvas.toBlob returns a Blob.
|
||||
const f =
|
||||
result instanceof File
|
||||
? result
|
||||
: new File([result], (result as any).name || (file as any).name || "upload", {
|
||||
type: result.type,
|
||||
})
|
||||
resolve(f)
|
||||
},
|
||||
error: e => {
|
||||
// Non-images break compressor, return the original file
|
||||
if (e.toString().includes("File or Blob")) {
|
||||
if (file instanceof Blob) {
|
||||
file = new File([file], `${randomId()}.${file.type}`, {type: file.type})
|
||||
}
|
||||
|
||||
return resolve(file as File)
|
||||
}
|
||||
|
||||
_reject(e)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const escapeHtml = (html: string) => {
|
||||
const element = document.createElement("div")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
const toHttpUrl = (url: string) =>
|
||||
url
|
||||
.replace(/^wss:\/\//, "https://")
|
||||
.replace(/^ws:\/\//, "http://")
|
||||
.replace(/\/$/, "")
|
||||
|
||||
const livekitEndpoint = (url: string, roomId?: string) => {
|
||||
const base = `${toHttpUrl(url)}/.well-known/nip29/livekit`
|
||||
return roomId ? `${base}/${roomId}` : base
|
||||
}
|
||||
|
||||
export const checkRelayHasLivekit = async (url: string): Promise<boolean> => {
|
||||
const endpoint = livekitEndpoint(url)
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint)
|
||||
return response.status === 204
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const getLivekitEndpoint = (url: string, roomId: string) => livekitEndpoint(url, roomId)
|
||||
|
|
@ -44,44 +44,6 @@ export const ucFirst = (s: string) => s.slice(0, 1).toUpperCase() + s.slice(1)
|
|||
|
||||
export const errorMessage = (err: unknown) => String(err).replace(/^.*Error: /, "")
|
||||
|
||||
export class AbortError extends Error {
|
||||
constructor() {
|
||||
super("Aborted")
|
||||
this.name = "AbortError"
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeoutError extends Error {
|
||||
constructor(message = "Timed out") {
|
||||
super(message)
|
||||
this.name = "TimeoutError"
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a promise that rejects with AbortError when signal aborts. Use with Promise.race. */
|
||||
export const whenAborted = (signal?: AbortSignal) => {
|
||||
if (!signal) {
|
||||
return new Promise<never>(() => {})
|
||||
}
|
||||
|
||||
return new Promise<never>((_, reject) => {
|
||||
const onAborted = () => reject(new AbortError())
|
||||
if (signal.aborted) {
|
||||
onAborted()
|
||||
} else {
|
||||
signal.addEventListener("abort", onAborted, {once: true})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Rejects with TimeoutError after ms, clearing the timer when `signal` aborts. */
|
||||
export const whenTimeout = (ms: number, opts: {message?: string; signal?: AbortSignal} = {}) => {
|
||||
return new Promise<never>((_, reject) => {
|
||||
const timeout = setTimeout(() => reject(new TimeoutError(opts.message)), ms)
|
||||
opts.signal?.addEventListener("abort", () => clearTimeout(timeout), {once: true})
|
||||
})
|
||||
}
|
||||
|
||||
export const buildUrl = (base: string | URL, ...pathname: string[]) => {
|
||||
const url = new URL(base)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import "@lib/components/theme.css"
|
||||
import "@app/app.css"
|
||||
import "@welshman/editor/index.css"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import type {Unsubscriber} from "svelte/store"
|
||||
|
|
|
|||
Loading…
Reference in a new issue