Bring caravel hosting in-app
This commit is contained in:
parent
37e6dd6b7d
commit
cf938c6397
54 changed files with 3271 additions and 293 deletions
3
.env
3
.env
|
|
@ -22,3 +22,6 @@ VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
|
||||||
VITE_THUMBNAIL_URL=https://vthumbs.coracle.social
|
VITE_THUMBNAIL_URL=https://vthumbs.coracle.social
|
||||||
VITE_GLITCHTIP_API_KEY=
|
VITE_GLITCHTIP_API_KEY=
|
||||||
GLITCHTIP_AUTH_TOKEN=
|
GLITCHTIP_AUTH_TOKEN=
|
||||||
|
VITE_HOSTING_BACKEND_URL=https://api.hosting.coracle.social
|
||||||
|
VITE_HOSTING_BACKEND_URL=http://127.0.0.1:2892
|
||||||
|
VITE_HOSTING_RELAY_DOMAIN=spaces.flotilla.social
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -75,6 +75,7 @@ ic_stat_notify.png
|
||||||
.vscode
|
.vscode
|
||||||
.claude
|
.claude
|
||||||
.local
|
.local
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
# OS generated
|
# OS generated
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
||||||
91
AGENTS.md
91
AGENTS.md
|
|
@ -26,7 +26,7 @@ routes/ (top layer - can depend on anything)
|
||||||
↓
|
↓
|
||||||
app/components/ (can depend on app/* and lib/*)
|
app/components/ (can depend on app/* and lib/*)
|
||||||
↓
|
↓
|
||||||
app/core/ & app/util/ (can only depend on lib/*)
|
app/* (can only depend on lib/*)
|
||||||
↓
|
↓
|
||||||
lib/ (can only depend on external libraries)
|
lib/ (can only depend on external libraries)
|
||||||
↓
|
↓
|
||||||
|
|
@ -49,63 +49,18 @@ import {Dialog} from "$lib/components"
|
||||||
import {repository} from "$app/core/state"
|
import {repository} from "$app/core/state"
|
||||||
```
|
```
|
||||||
|
|
||||||
## File Structure
|
## Cleanup Pass
|
||||||
|
|
||||||
```
|
These are the things that most often get fixed by hand after the fact. Go through
|
||||||
src/
|
them before handing work back. The full style reference is under Development
|
||||||
├── lib/ # Generic reusable code
|
Conventions below.
|
||||||
│ ├── components/ # 38 UI components (Button, Dialog, etc.)
|
|
||||||
│ ├── html.ts # DOM utilities
|
- **Comments** — only for genuinely surprising things: a workaround, a constraint imposed by a backend or platform, an invariant that isn't visible from the code in front of you. Never comment props, never restate what the next line does, never justify an ordinary decision. If a small refactor would make the comment stale, don't write it.
|
||||||
│ ├── indexeddb.ts # IndexedDB helpers
|
- **Used once, inlined** — a derived value, helper, type, or named constant with a single use is indirection. Write `setTimeout(pollOnce, 3500)`, not a `POLL_INTERVAL` referenced twice in one file. Name something only when the name is what makes the code readable.
|
||||||
│ └── util.ts # Generic utilities
|
- **Positive conditionals** — prefer `if (ready) { ... }` over `if (!ready) return`. Nesting is fine; when it gets deep that's the signal the function is doing too much, so split it. Many early returns belong in validation or pipeline functions, not everywhere else.
|
||||||
│
|
- **Truthiness** — test values directly (`if (invoice.paid_at)`) instead of comparing against `null`/`undefined`, and put the truthy branch first in a ternary.
|
||||||
├── app/
|
- **Standard names** — `loading` for in-flight state, `on*` for handlers bound to an event, a plain verb (`submit`, `save`) for the action itself. Use `{prop}` shorthand when the names match.
|
||||||
│ ├── core/
|
- **Errors** — toast a human-readable message and `console.error` anything unexpected (e.g. a non-`HostingError`). Never swallow an error you didn't anticipate.
|
||||||
│ │ ├── state.ts # State management, stores, constants (687 lines)
|
|
||||||
│ │ ├── commands.ts # Publishing events and other write operations (440+ lines)
|
|
||||||
│ │ ├── requests.ts # Loading data from network (191 lines)
|
|
||||||
│ │ ├── sync.ts # Data synchronization (296 lines)
|
|
||||||
│ │ └── storage.ts # IndexedDB setup
|
|
||||||
│ │
|
|
||||||
│ ├── util/
|
|
||||||
│ │ ├── notifications.ts # Push notifications (731 lines)
|
|
||||||
│ │ ├── policies.ts # Relay policies
|
|
||||||
│ │ ├── routes.ts # Routing helpers
|
|
||||||
│ │ ├── modal.ts # Modal management
|
|
||||||
│ │ ├── toast.ts # Toast notifications
|
|
||||||
│ │ ├── theme.ts # Theme switching
|
|
||||||
│ │ └── keyboard.ts # Keyboard handling
|
|
||||||
│ │
|
|
||||||
│ ├── editor/ # Rich text editor config
|
|
||||||
│ │ ├── index.ts # TipTap setup with Nostr integration
|
|
||||||
│ │ ├── EditorContent.svelte
|
|
||||||
│ │ └── MentionNodeView.ts
|
|
||||||
│ │
|
|
||||||
│ └── components/ # 188 app-specific components
|
|
||||||
│ ├── Space*.svelte # Space/relay management
|
|
||||||
│ ├── Room*.svelte # Room/channel management
|
|
||||||
│ ├── Chat*.svelte # Direct messaging
|
|
||||||
│ ├── Profile*.svelte # User profiles
|
|
||||||
│ ├── Thread*.svelte # Threaded posts
|
|
||||||
│ └── ...
|
|
||||||
│
|
|
||||||
├── routes/ # SvelteKit file-based routing
|
|
||||||
│ ├── +layout.svelte # Root layout (sync logic here)
|
|
||||||
│ ├── spaces/ # Space management
|
|
||||||
│ │ └── [relay]/ # Specific space
|
|
||||||
│ │ ├── chat/ # Space chat
|
|
||||||
│ │ ├── threads/ # Thread posts
|
|
||||||
│ │ ├── calendar/ # Events
|
|
||||||
│ │ └── [h]/ # Specific room (h = room id)
|
|
||||||
│ ├── chat/ # Direct messages
|
|
||||||
│ ├── settings/ # User settings
|
|
||||||
│ └── [bech32]/ # Bech32 entity viewer
|
|
||||||
│
|
|
||||||
├── assets/icons/ # ~1,277 SVG icons
|
|
||||||
├── app.html # HTML template
|
|
||||||
├── app.css # Global styles
|
|
||||||
└── types.d.ts # Type definitions
|
|
||||||
```
|
|
||||||
|
|
||||||
## State Management
|
## State Management
|
||||||
|
|
||||||
|
|
@ -161,24 +116,27 @@ src/
|
||||||
|
|
||||||
- **No `null`** - only use `undefined`
|
- **No `null`** - only use `undefined`
|
||||||
- Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components
|
- Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components
|
||||||
- TailwindCSS and DaisyUI styling
|
- TailwindCSS styling with css components customized by theme. See lib/components for examples.
|
||||||
- Only add comments for really weird stuff
|
- Comments, naming, conditionals and single-use indirection are covered by the Cleanup Pass above.
|
||||||
- Do not call functions in components unless a parameter is reactive. Instead, use a svelte store or rune to make it reactive.
|
|
||||||
- Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect.
|
- Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect.
|
||||||
- When dynamically building classes, use `cx` from `classnames` rather than embedded ternaries or svelte 4's old `class:` syntax.
|
- When dynamically building classes, use `cx` from `classnames` rather than embedded ternaries or svelte 4's old `class:` syntax.
|
||||||
- When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss
|
- When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss
|
||||||
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates
|
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates
|
||||||
|
- Write a `{#if}`/`{:else if}` chain rather than hoisting display strings into a lookup `Record` in the script section.
|
||||||
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
|
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
|
||||||
- Instead of `getTag(tagName, event.tags)?.[1] || ""`, use `getTagValue(tagName, event.tags)`
|
- Instead of `getTag(tagName, event.tags)?.[1] || ""`, use `getTagValue(tagName, event.tags)`
|
||||||
- Do not render a profile's `about` directly (e.g. `profile.about`); use the `ProfileAbout` component instead.
|
- Do not render a profile's `about` directly (e.g. `profile.about`); use the `ProfileAbout` component instead.
|
||||||
- Use `type Props` instead of interface when defining props for svelte components.
|
- Use `type Props` instead of interface when defining props for svelte components.
|
||||||
- When a component's value/prop shape mirrors a subset of an existing type, derive it with `Pick`/`Partial` and `export` that type from the component's `<script module>` (e.g. a `Values` type) for callers to import, instead of re-enumerating its sub-properties.
|
- When a component's value/prop shape mirrors a subset of an existing type, derive it with `Pick`/`Partial` and `export` that type from the component's `<script module>` (e.g. a `Values` type) for callers to import, instead of re-enumerating its sub-properties.
|
||||||
|
- Avoid pass-through functions except when the wrapper is part of an abstraction. `x => y()` is not ok, but `x => this.impl.y()` is ok, for example.
|
||||||
|
- When declaring variables in a svelte component, the order should generally be: props, constants derived from props/state, functions declared with `const`, mutable variables declared with `let`, effects, onMount. This order may vary due to dependencies, but should generally be adhered to.
|
||||||
|
|
||||||
**Human-First Simplicity (Jon Staab Style):**
|
**Human-First Simplicity:**
|
||||||
|
|
||||||
- Prefer direct, readable code over layered abstractions.
|
- Prefer direct, readable code over layered abstractions.
|
||||||
- Do not add indirection (extra helpers, wrappers, stores, or derived state) unless it removes real repeated complexity.
|
- Do not add indirection (extra helpers, wrappers, stores, or derived state) unless it removes real repeated complexity.
|
||||||
- Reuse existing Welshman and Flotilla primitives before introducing new utilities or dependencies.
|
- Abstractions must be either: elegant and self-explanatory (bottom-up design), or used in at least 3 locations (ugly glue code).
|
||||||
|
- Reuse existing Welshman and Flotilla primitives before introducing new utilities or dependencies. See /welshman-* skills for details.
|
||||||
- Favor linear control flow and explicit naming over clever patterns.
|
- Favor linear control flow and explicit naming over clever patterns.
|
||||||
- Remove defensive checks that do not apply in this runtime model.
|
- Remove defensive checks that do not apply in this runtime model.
|
||||||
- When two approaches work, pick the one that feels more human and easier to maintain.
|
- When two approaches work, pick the one that feels more human and easier to maintain.
|
||||||
|
|
@ -244,15 +202,6 @@ pnpm run check # Type check
|
||||||
|
|
||||||
See `.env.template` for all options.
|
See `.env.template` for all options.
|
||||||
|
|
||||||
## Important Files to Reference
|
|
||||||
|
|
||||||
- **src/app/core/state.ts** - All stores and constants
|
|
||||||
- **src/app/core/sync.ts** - Data synchronization
|
|
||||||
- **src/app/core/requests.ts** - Utilities for requesting data
|
|
||||||
- **src/app/core/commands.ts** - Publishing patterns
|
|
||||||
- **src/app/util/notifications.ts** - Notification badges and push notifications
|
|
||||||
- **src/routes/+layout.svelte** - Top-level sync logic
|
|
||||||
|
|
||||||
## Mobile Development
|
## Mobile Development
|
||||||
|
|
||||||
**Capacitor Integration:**
|
**Capacitor Integration:**
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,15 @@ export default [
|
||||||
"no-extra-semi": "off",
|
"no-extra-semi": "off",
|
||||||
"no-async-promise-executor": "off",
|
"no-async-promise-executor": "off",
|
||||||
"prefer-const": ["error", {destructuring: "all"}],
|
"prefer-const": ["error", {destructuring: "all"}],
|
||||||
|
"no-eq-null": "error",
|
||||||
|
"no-restricted-syntax": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
selector:
|
||||||
|
"Literal[raw='null']:not(CallExpression[callee.property.name='stringify'] > Literal)",
|
||||||
|
message: "Use undefined instead of null.",
|
||||||
|
},
|
||||||
|
],
|
||||||
"svelte/valid-compile": "off",
|
"svelte/valid-compile": "off",
|
||||||
"svelte/no-at-html-tags": "off",
|
"svelte/no-at-html-tags": "off",
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
const isQuote = (p: Parsed) => isEvent(p) || isAddress(p)
|
const isQuote = (p: Parsed) => isEvent(p) || isAddress(p)
|
||||||
|
|
||||||
const ignoreWarning = () => {
|
const ignoreWarning = () => {
|
||||||
warning = null
|
warning = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
let warning = $state(
|
let warning = $state(
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@
|
||||||
target: element,
|
target: element,
|
||||||
props: {
|
props: {
|
||||||
onClose: closeModal,
|
onClose: closeModal,
|
||||||
|
size: options.size,
|
||||||
noEscape: options.noEscape,
|
noEscape: options.noEscape,
|
||||||
fullscreen: options.fullscreen,
|
fullscreen: options.fullscreen,
|
||||||
children: {component, props},
|
children: {component, props},
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,13 @@
|
||||||
import type {RoomMeta} from "@welshman/util"
|
import type {RoomMeta} from "@welshman/util"
|
||||||
import {makeRoomMeta} from "@welshman/util"
|
import {makeRoomMeta} from "@welshman/util"
|
||||||
import {waitForThunkError, createRoom, editRoom, joinRoom} from "@welshman/app"
|
import {waitForThunkError, createRoom, editRoom, joinRoom} from "@welshman/app"
|
||||||
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
|
|
||||||
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
|
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
|
||||||
import Volume from "@assets/icons/volume.svg?dataurl"
|
import Volume from "@assets/icons/volume.svg?dataurl"
|
||||||
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
|
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||||
import IconPickerButton from "@lib/components/IconPickerButton.svelte"
|
import IconInput from "@lib/components/IconInput.svelte"
|
||||||
import Modal from "@lib/components/Modal.svelte"
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
@ -90,35 +88,6 @@
|
||||||
let imageFile = $state<File | undefined>()
|
let imageFile = $state<File | undefined>()
|
||||||
let imagePreview = $state(initialValues.picture)
|
let imagePreview = $state(initialValues.picture)
|
||||||
let roomType = $state(getRoomType(initialValues))
|
let roomType = $state(getRoomType(initialValues))
|
||||||
|
|
||||||
const handleImageUpload = async (event: Event) => {
|
|
||||||
const file = (event.target as HTMLInputElement).files?.[0]
|
|
||||||
|
|
||||||
if (file && file.type.startsWith("image/")) {
|
|
||||||
const reader = new FileReader()
|
|
||||||
|
|
||||||
reader.onload = e => {
|
|
||||||
imageFile = file
|
|
||||||
imagePreview = e.target?.result as string
|
|
||||||
}
|
|
||||||
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleIconSelect = (iconUrl: string) => {
|
|
||||||
imagePreview = iconUrl
|
|
||||||
|
|
||||||
const parts = iconUrl.split(",")
|
|
||||||
const imageData = atob(parts[1])
|
|
||||||
const result = new Uint8Array(imageData.length)
|
|
||||||
|
|
||||||
for (let n = 0; n < imageData.length; n++) {
|
|
||||||
result[n] = imageData.charCodeAt(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
imageFile = new File([result], `icon.svg`, {type: "image/svg+xml"})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal tag="form" onsubmit={preventDefault(trySubmit)}>
|
<Modal tag="form" onsubmit={preventDefault(trySubmit)}>
|
||||||
|
|
@ -129,25 +98,7 @@
|
||||||
<p>Icon</p>
|
<p>Icon</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="flex grow items-center justify-between gap-4">
|
<IconInput bind:file={imageFile} bind:preview={imagePreview} previewClass="rounded-xl" />
|
||||||
{#if imagePreview}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-sm opacity-75">Selected:</span>
|
|
||||||
<ImageIcon src={imagePreview} alt="" class="rounded-lg" />
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<span class="text-sm opacity-75">No icon selected</span>
|
|
||||||
{/if}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<IconPickerButton onSelect={handleIconSelect} class="button button-primary button-sm">
|
|
||||||
<Icon icon={StickerSmileSquare} size={4} />
|
|
||||||
</IconPickerButton>
|
|
||||||
<label class="button button-neutral button-sm">
|
|
||||||
<Icon icon={UploadMinimalistic} size={4} />
|
|
||||||
<input type="file" accept="image/*" class="hidden" onchange={handleImageUpload} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
<FieldInline>
|
<FieldInline>
|
||||||
|
|
@ -157,7 +108,7 @@
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<label class="input input-group flex w-full items-center gap-2">
|
<label class="input input-group flex w-full items-center gap-2">
|
||||||
{#if imagePreview}
|
{#if imagePreview}
|
||||||
<ImageIcon src={imagePreview} alt="" class="rounded-lg" />
|
<ImageIcon src={imagePreview} alt="" class="rounded-xl" />
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon={roomType === RoomType.Voice ? Volume : Hashtag} />
|
<Icon icon={roomType === RoomType.Voice ? Volume : Hashtag} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,11 @@
|
||||||
<Icon size={size + 1} icon={Volume} />
|
<Icon size={size + 1} icon={Volume} />
|
||||||
{#if $room.picture}
|
{#if $room.picture}
|
||||||
<span class="text-base">/</span>
|
<span class="text-base">/</span>
|
||||||
<ImageIcon src={$room.picture} {size} alt="" class="rounded-lg" />
|
<ImageIcon src={$room.picture} {size} alt="" class="rounded-xl" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if $room.picture}
|
{:else if $room.picture}
|
||||||
<ImageIcon src={$room.picture} {size} alt="" class="rounded-lg" />
|
<ImageIcon src={$room.picture} {size} alt="" class="rounded-xl" />
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon={fallbackIcon} {size} />
|
<Icon icon={fallbackIcon} {size} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
class="button button-sm rounded-lg border-0 bg-primary-content font-medium text-primary"
|
class="button button-sm border-0 bg-primary-content font-medium text-primary"
|
||||||
onclick={jumpToMessage}>
|
onclick={jumpToMessage}>
|
||||||
Jump to message
|
Jump to message
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,8 @@
|
||||||
import type {RelayProfile} from "@welshman/util"
|
import type {RelayProfile} from "@welshman/util"
|
||||||
import {displayRelayUrl, ManagementMethod} from "@welshman/util"
|
import {displayRelayUrl, ManagementMethod} from "@welshman/util"
|
||||||
import {manageRelay, forceLoadRelay} from "@welshman/app"
|
import {manageRelay, forceLoadRelay} from "@welshman/app"
|
||||||
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
|
|
||||||
import Widget from "@assets/icons/widget-4.svg?dataurl"
|
import Widget from "@assets/icons/widget-4.svg?dataurl"
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
|
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
@ -18,7 +16,7 @@
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||||
import IconPickerButton from "@lib/components/IconPickerButton.svelte"
|
import IconInput from "@lib/components/IconInput.svelte"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {clearModals} from "@app/modal"
|
import {clearModals} from "@app/modal"
|
||||||
import {compressFileForUpload, uploadFileOrFallback} from "@app/uploads"
|
import {compressFileForUpload, uploadFileOrFallback} from "@app/uploads"
|
||||||
|
|
@ -89,35 +87,6 @@
|
||||||
let loading = $state(false)
|
let loading = $state(false)
|
||||||
let imageFile = $state<File | undefined>()
|
let imageFile = $state<File | undefined>()
|
||||||
let imagePreview = $state(initialValues.icon)
|
let imagePreview = $state(initialValues.icon)
|
||||||
|
|
||||||
const handleImageUpload = async (event: Event) => {
|
|
||||||
const file = (event.target as HTMLInputElement).files?.[0]
|
|
||||||
|
|
||||||
if (file && file.type.startsWith("image/")) {
|
|
||||||
const reader = new FileReader()
|
|
||||||
|
|
||||||
reader.onload = e => {
|
|
||||||
imageFile = file
|
|
||||||
imagePreview = e.target?.result as string
|
|
||||||
}
|
|
||||||
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleIconSelect = (iconUrl: string) => {
|
|
||||||
imagePreview = iconUrl
|
|
||||||
|
|
||||||
const parts = iconUrl.split(",")
|
|
||||||
const imageData = atob(parts[1])
|
|
||||||
const result = new Uint8Array(imageData.length)
|
|
||||||
|
|
||||||
for (let n = 0; n < imageData.length; n++) {
|
|
||||||
result[n] = imageData.charCodeAt(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
imageFile = new File([result], `icon.svg`, {type: "image/svg+xml"})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal tag="form" onsubmit={preventDefault(trySubmit)}>
|
<Modal tag="form" onsubmit={preventDefault(trySubmit)}>
|
||||||
|
|
@ -131,25 +100,7 @@
|
||||||
<p>Icon</p>
|
<p>Icon</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<div class="flex items-center gap-4 justify-between grow">
|
<IconInput bind:file={imageFile} bind:preview={imagePreview} />
|
||||||
{#if imagePreview}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-sm opacity-75">Selected:</span>
|
|
||||||
<ImageIcon src={imagePreview} alt="" />
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<span class="text-sm opacity-75">No icon selected</span>
|
|
||||||
{/if}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<IconPickerButton onSelect={handleIconSelect} class="button button-primary button-sm">
|
|
||||||
<Icon icon={StickerSmileSquare} size={4} />
|
|
||||||
</IconPickerButton>
|
|
||||||
<label class="button button-neutral button-sm cursor-pointer">
|
|
||||||
<Icon icon={UploadMinimalistic} size={4} />
|
|
||||||
<input type="file" accept="image/*" class="hidden" onchange={handleImageUpload} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
<FieldInline>
|
<FieldInline>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@
|
||||||
import Home from "@assets/icons/home.svg?dataurl"
|
import Home from "@assets/icons/home.svg?dataurl"
|
||||||
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
|
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
|
||||||
import Danger from "@assets/icons/danger.svg?dataurl"
|
import Danger from "@assets/icons/danger.svg?dataurl"
|
||||||
|
import ServerPath from "@assets/icons/server-path.svg?dataurl"
|
||||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
|
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||||
import Exit from "@assets/icons/logout-3.svg?dataurl"
|
import Exit from "@assets/icons/logout-3.svg?dataurl"
|
||||||
import Letter from "@assets/icons/letter.svg?dataurl"
|
import Letter from "@assets/icons/letter.svg?dataurl"
|
||||||
import Login from "@assets/icons/login-3.svg?dataurl"
|
import Login from "@assets/icons/login-3.svg?dataurl"
|
||||||
|
|
@ -33,6 +35,7 @@
|
||||||
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
|
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
|
||||||
import SpaceInvite from "@app/components/SpaceInvite.svelte"
|
import SpaceInvite from "@app/components/SpaceInvite.svelte"
|
||||||
import SpaceExit from "@app/components/SpaceExit.svelte"
|
import SpaceExit from "@app/components/SpaceExit.svelte"
|
||||||
|
import SpaceEdit from "@app/components/SpaceEdit.svelte"
|
||||||
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
||||||
import RelayName from "@app/components/RelayName.svelte"
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
|
import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
|
||||||
|
|
@ -43,6 +46,7 @@
|
||||||
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
|
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
|
||||||
import {ENABLE_ZAPS} from "@app/env"
|
import {ENABLE_ZAPS} from "@app/env"
|
||||||
import {CONTENT_KINDS} from "@app/content"
|
import {CONTENT_KINDS} from "@app/content"
|
||||||
|
import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting"
|
||||||
import {deriveUserCanCreateRoom, deriveUserIsSpaceAdmin} from "@app/members"
|
import {deriveUserCanCreateRoom, deriveUserIsSpaceAdmin} from "@app/members"
|
||||||
import {
|
import {
|
||||||
deriveUserRooms,
|
deriveUserRooms,
|
||||||
|
|
@ -78,6 +82,7 @@
|
||||||
const otherRooms = deriveOtherRooms(url)
|
const otherRooms = deriveOtherRooms(url)
|
||||||
const otherVoiceRooms = deriveOtherVoiceRooms(url)
|
const otherVoiceRooms = deriveOtherVoiceRooms(url)
|
||||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||||
|
const hostedRelay = deriveHostedRelay(url)
|
||||||
const actionItems = deriveSpaceActionItems(url)
|
const actionItems = deriveSpaceActionItems(url)
|
||||||
|
|
||||||
const spaceKinds = derived(
|
const spaceKinds = derived(
|
||||||
|
|
@ -109,6 +114,8 @@
|
||||||
showMenu = !showMenu
|
showMenu = !showMenu
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startEdit = () => pushModal(SpaceEdit, {url, initialValues: $relay || {url}})
|
||||||
|
|
||||||
const showActionItems = () => pushModal(SpaceActionItems, {url})
|
const showActionItems = () => pushModal(SpaceActionItems, {url})
|
||||||
|
|
||||||
const openSearch = () => pushModal(SpaceSearch, {url})
|
const openSearch = () => pushModal(SpaceSearch, {url})
|
||||||
|
|
@ -208,6 +215,21 @@
|
||||||
</Link>
|
</Link>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
|
{#if HOSTING_ENABLED && $hostedRelay.relay}
|
||||||
|
<li>
|
||||||
|
<Link href={makeSpacePath(url, "admin")}>
|
||||||
|
<Icon icon={ServerPath} />
|
||||||
|
Hosting settings
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
{:else if $userIsAdmin}
|
||||||
|
<li>
|
||||||
|
<Button onclick={startEdit}>
|
||||||
|
<Icon icon={Pen} />
|
||||||
|
Edit Space
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
<li>
|
<li>
|
||||||
{#if $userSpaceUrls.includes(url)}
|
{#if $userSpaceUrls.includes(url)}
|
||||||
<Button onclick={leaveSpace} class="text-error">
|
<Button onclick={leaveSpace} class="text-error">
|
||||||
|
|
|
||||||
67
src/app/components/SpaceRedirect.svelte
Normal file
67
src/app/components/SpaceRedirect.svelte
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
|
import {preventDefault} from "@lib/html"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import ServerPath from "@assets/icons/server-path.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import {moveSpace} from "@app/groups"
|
||||||
|
import {goToMovedSpace} from "@app/routes"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
newUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, newUrl}: Props = $props()
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const confirm = async () => {
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await moveSpace(url, newUrl)
|
||||||
|
await goToMovedSpace(url, newUrl)
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let loading = $state(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal tag="form" onsubmit={preventDefault(confirm)}>
|
||||||
|
<ModalBody>
|
||||||
|
<ModalHeader>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<Icon icon={ServerPath} size={8} class="text-primary" />
|
||||||
|
</div>
|
||||||
|
<ModalTitle>This space has moved</ModalTitle>
|
||||||
|
<ModalSubtitle>
|
||||||
|
<span class="text-primary">{displayRelayUrl(url)}</span>
|
||||||
|
is now located at
|
||||||
|
<span class="text-primary">{displayRelayUrl(newUrl)}</span>.
|
||||||
|
</ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
<p class="text-center">
|
||||||
|
Would you like to update your spaces list to point at the new address?
|
||||||
|
</p>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="button button-link" onclick={back} disabled={loading}>
|
||||||
|
<Icon icon={AltArrowLeft} />
|
||||||
|
Not now
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" class="button button-primary" disabled={loading}>
|
||||||
|
<Spinner {loading}>Update and go</Spinner>
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
57
src/app/components/hosting/ActivityFeed.svelte
Normal file
57
src/app/components/hosting/ActivityFeed.svelte
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {formatTimestampRelative} from "@welshman/lib"
|
||||||
|
import History from "@assets/icons/history.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import type {Activity} from "@app/hosting"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
activity: Activity[]
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const {activity, loading = false}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card card-sm flex flex-col gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon icon={History} size={5} />
|
||||||
|
<strong>Activity</strong>
|
||||||
|
</div>
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-sm opacity-75">Loading activity...</p>
|
||||||
|
{:else if activity.length === 0}
|
||||||
|
<p class="text-sm opacity-75">No activity yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ol class="flex flex-col gap-4">
|
||||||
|
{#each activity as item (item.id)}
|
||||||
|
<li class="flex items-start gap-3">
|
||||||
|
<span class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-content-muted"></span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium">
|
||||||
|
{#if item.activity_type === "create_relay"}
|
||||||
|
Relay created
|
||||||
|
{:else if item.activity_type === "update_relay"}
|
||||||
|
Relay updated
|
||||||
|
{:else if item.activity_type === "deactivate_relay"}
|
||||||
|
Relay deactivated
|
||||||
|
{:else if item.activity_type === "activate_relay"}
|
||||||
|
Relay activated
|
||||||
|
{:else if item.activity_type === "fail_relay_sync"}
|
||||||
|
Relay sync failed
|
||||||
|
{:else if item.activity_type === "complete_relay_sync"}
|
||||||
|
Relay sync completed
|
||||||
|
{:else if item.activity_type === "create_tenant"}
|
||||||
|
Account created
|
||||||
|
{:else if item.activity_type === "update_tenant"}
|
||||||
|
Account updated
|
||||||
|
{:else}
|
||||||
|
{item.activity_type.replace(/_/g, " ")}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<time class="text-xs opacity-75">{formatTimestampRelative(item.created_at)}</time>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
157
src/app/components/hosting/BillingPrompts.svelte
Normal file
157
src/app/components/hosting/BillingPrompts.svelte
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
|
import type {Snippet} from "svelte"
|
||||||
|
import {page} from "$app/stores"
|
||||||
|
import DangerTriangle from "@assets/icons/danger-triangle.svg?dataurl"
|
||||||
|
import DangerCircle from "@assets/icons/danger-circle.svg?dataurl"
|
||||||
|
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
|
||||||
|
import Close from "@assets/icons/close.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import {errorMessage} from "@lib/util"
|
||||||
|
import PaymentDialog from "@app/components/hosting/PaymentDialog.svelte"
|
||||||
|
import PaymentSetup from "@app/components/hosting/PaymentSetup.svelte"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
import {autopayConfigured, getInvoice, type Invoice, type Tenant} from "@app/hosting"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tenant: Tenant | undefined
|
||||||
|
openInvoice: Invoice | undefined
|
||||||
|
hasPaidSubscription: boolean
|
||||||
|
suppressInline?: boolean
|
||||||
|
onRefresh?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
tenant,
|
||||||
|
openInvoice,
|
||||||
|
hasPaidSubscription,
|
||||||
|
suppressInline = false,
|
||||||
|
onRefresh,
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
let dismissedAutopay = $state(false)
|
||||||
|
|
||||||
|
const openPayInvoice = (invoice: Invoice) =>
|
||||||
|
pushModal(PaymentDialog, {invoice, onPaid: () => onRefresh?.()})
|
||||||
|
|
||||||
|
const openSetup = (initialTab: "nwc" | "card") =>
|
||||||
|
pushModal(PaymentSetup, {
|
||||||
|
initialTab,
|
||||||
|
nwcConnected: Boolean(tenant?.nwc_is_set),
|
||||||
|
onSaved: () => onRefresh?.(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const setUpNwc = () => openSetup("nwc")
|
||||||
|
|
||||||
|
const updateMethod = () => openSetup(tenant?.nwc_error ? "nwc" : "card")
|
||||||
|
|
||||||
|
const payOpenInvoice = () => {
|
||||||
|
if (openInvoice) openPayInvoice(openInvoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismissAutopay = () => {
|
||||||
|
dismissedAutopay = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep link (?invoice=<id>, e.g. from the billing DM) opens the pay dialog,
|
||||||
|
// once per id.
|
||||||
|
let handledInvoiceId: string | undefined
|
||||||
|
$effect(() => {
|
||||||
|
const id = $page.url.searchParams.get("invoice") ?? undefined
|
||||||
|
if (!id || id === handledInvoiceId) return
|
||||||
|
|
||||||
|
handledInvoiceId = id
|
||||||
|
|
||||||
|
getInvoice(id)
|
||||||
|
.then(openPayInvoice)
|
||||||
|
.catch(e => pushToast({theme: "error", message: errorMessage(e) || "Failed to load invoice"}))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet card(className: string, icon: string, message: string, actions: Snippet)}
|
||||||
|
<div
|
||||||
|
class={cx(
|
||||||
|
"card card-sm flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4",
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
|
<div class="flex min-w-0 items-start gap-3">
|
||||||
|
<Icon {icon} size={5} class="mt-0.5 shrink-0" />
|
||||||
|
<p class="min-w-0 text-sm">{message}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
|
{@render actions()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet churnedActions()}
|
||||||
|
{#if openInvoice}
|
||||||
|
<Button class="button button-sm button-primary" onclick={payOpenInvoice}>Pay now</Button>
|
||||||
|
{/if}
|
||||||
|
<Button
|
||||||
|
class={cx("button button-sm", {
|
||||||
|
"button-neutral": openInvoice,
|
||||||
|
"button-primary": !openInvoice,
|
||||||
|
})}
|
||||||
|
onclick={setUpNwc}>
|
||||||
|
Update payment method
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet payInvoiceActions()}
|
||||||
|
<Button class="button button-sm button-primary" onclick={payOpenInvoice}>Pay now</Button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet updateMethodActions()}
|
||||||
|
<Button class="button button-sm button-primary" onclick={updateMethod}>
|
||||||
|
Update payment method
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet autopayActions()}
|
||||||
|
<Button class="button button-sm button-primary" onclick={setUpNwc}>Set up autopay</Button>
|
||||||
|
<Button class="button button-link button-sm" aria-label="Dismiss" onclick={dismissAutopay}>
|
||||||
|
<Icon icon={Close} size={4} />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if tenant}
|
||||||
|
{#if tenant.churned_at}
|
||||||
|
{@render card(
|
||||||
|
"card-secondary",
|
||||||
|
DangerTriangle,
|
||||||
|
"Your account is past due and some relays are paused. Pay your balance or update your payment method to restore service.",
|
||||||
|
churnedActions,
|
||||||
|
)}
|
||||||
|
{:else if openInvoice && !suppressInline}
|
||||||
|
{@render card(
|
||||||
|
"card-secondary",
|
||||||
|
DangerCircle,
|
||||||
|
"You have an unpaid invoice. Pay it now to keep your relays running.",
|
||||||
|
payInvoiceActions,
|
||||||
|
)}
|
||||||
|
{:else if tenant.nwc_is_set && tenant.nwc_error}
|
||||||
|
{@render card(
|
||||||
|
"card-secondary",
|
||||||
|
DangerCircle,
|
||||||
|
"Your Lightning wallet couldn't be charged. Update your payment method.",
|
||||||
|
updateMethodActions,
|
||||||
|
)}
|
||||||
|
{:else if tenant.stripe_payment_method_id && tenant.stripe_error}
|
||||||
|
{@render card(
|
||||||
|
"card-secondary",
|
||||||
|
DangerCircle,
|
||||||
|
"Your card couldn't be charged. Update your payment method.",
|
||||||
|
updateMethodActions,
|
||||||
|
)}
|
||||||
|
{:else if hasPaidSubscription && !autopayConfigured(tenant) && !suppressInline && !dismissedAutopay}
|
||||||
|
{@render card(
|
||||||
|
"card-primary",
|
||||||
|
InfoCircle,
|
||||||
|
"Set up automatic payments so your subscription renews without interruption.",
|
||||||
|
autopayActions,
|
||||||
|
)}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
126
src/app/components/hosting/CustomDomainModal.svelte
Normal file
126
src/app/components/hosting/CustomDomainModal.svelte
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {preventDefault} from "@lib/html"
|
||||||
|
import Global from "@assets/icons/global.svg?dataurl"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import VerifiedCheck from "@assets/icons/verified-check.svg?dataurl"
|
||||||
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Badge from "@lib/components/Badge.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Input from "@lib/components/Input.svelte"
|
||||||
|
import Field from "@lib/components/Field.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
import {canonicalRelayHost, flagToBool, updateRelay, HostingError, type Relay} from "@app/hosting"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
relay: Relay
|
||||||
|
onUpdate?: (relay: Relay) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const {relay, onUpdate}: Props = $props()
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const clearDomain = () => {
|
||||||
|
domain = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async (value: string) => {
|
||||||
|
if (!loading) {
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await updateRelay(current.id, {custom_domain: value})
|
||||||
|
|
||||||
|
current = updated
|
||||||
|
domain = updated.custom_domain ?? ""
|
||||||
|
onUpdate?.(updated)
|
||||||
|
pushToast({message: value ? "Custom domain saved." : "Custom domain removed."})
|
||||||
|
back()
|
||||||
|
} catch (e) {
|
||||||
|
const isHostingError = e instanceof HostingError
|
||||||
|
|
||||||
|
pushToast({theme: "error", message: isHostingError ? e.message : "Failed to save domain."})
|
||||||
|
|
||||||
|
if (!isHostingError) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = () => submit(domain.trim())
|
||||||
|
|
||||||
|
// Local copy so the verified state updates in place after save/verify.
|
||||||
|
let current = $state(relay)
|
||||||
|
let domain = $state(relay.custom_domain ?? "")
|
||||||
|
let loading = $state(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal tag="form" onsubmit={preventDefault(onSubmit)}>
|
||||||
|
<ModalBody>
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>Custom domain</ModalTitle>
|
||||||
|
<ModalSubtitle>
|
||||||
|
Point a domain you control at this relay instead of the default
|
||||||
|
<span class="text-primary">{canonicalRelayHost(current)}</span> address.
|
||||||
|
</ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<Icon icon={Global} size={4} />
|
||||||
|
<p>Domain</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet secondary()}
|
||||||
|
{#if current.custom_domain && flagToBool(current.custom_domain_verified, false)}
|
||||||
|
<Badge variant="primary">
|
||||||
|
<Icon icon={VerifiedCheck} size={3} />
|
||||||
|
Verified
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<Input
|
||||||
|
bind:value={domain}
|
||||||
|
type="text"
|
||||||
|
placeholder="relay.example.com"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="none"
|
||||||
|
spellcheck="false">
|
||||||
|
{#snippet after()}
|
||||||
|
{#if domain}
|
||||||
|
<Button
|
||||||
|
onclick={clearDomain}
|
||||||
|
aria-label="Clear domain"
|
||||||
|
class="shrink-0 text-content-subtle hover:text-content">
|
||||||
|
<Icon icon={CloseCircle} size={4} />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Input>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet info()}
|
||||||
|
<p>Must be a domain you control, e.g. relay.example.com.</p>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="button button-link" onclick={back} disabled={loading}>
|
||||||
|
<Icon icon={AltArrowLeft} />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" class="button button-primary" disabled={loading}>
|
||||||
|
<Spinner {loading}>Save</Spinner>
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
21
src/app/components/hosting/InvoiceItemsList.svelte
Normal file
21
src/app/components/hosting/InvoiceItemsList.svelte
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {formatUsd, type InvoiceItem} from "@app/hosting"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
items: InvoiceItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const {items}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card card-sm">
|
||||||
|
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-content-muted">On this invoice</p>
|
||||||
|
<ul class="flex flex-col gap-1.5">
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<li class="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span class="truncate">{item.description}</span>
|
||||||
|
<span class="shrink-0 text-xs text-content-muted">{formatUsd(item.amount)}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
53
src/app/components/hosting/InvoiceListItem.svelte
Normal file
53
src/app/components/hosting/InvoiceListItem.svelte
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
|
import Badge from "@lib/components/Badge.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import {formatUsd, formatPeriod, type Invoice} from "@app/hosting"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
invoice: Invoice
|
||||||
|
isDraft?: boolean
|
||||||
|
onPay?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const {invoice, isDraft = false, onPay}: Props = $props()
|
||||||
|
|
||||||
|
// The backend models the invoice lifecycle as timestamps, not a status field.
|
||||||
|
const status = $derived.by(() => {
|
||||||
|
if (isDraft) return "draft"
|
||||||
|
if (invoice.paid_at) return "paid"
|
||||||
|
if (invoice.voided_at) return "void"
|
||||||
|
return "open"
|
||||||
|
})
|
||||||
|
const isOpen = $derived(status === "open")
|
||||||
|
const variant = $derived(
|
||||||
|
status === "open" ? "warning" : status === "paid" ? "primary" : "neutral",
|
||||||
|
)
|
||||||
|
const periodLabel = $derived(formatPeriod(invoice.period_start, invoice.period_end))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li class={cx("card card-sm flex items-center justify-between gap-3", {"border-dashed": isDraft})}>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium">{formatUsd(invoice.amount)}</span>
|
||||||
|
<Badge {variant} class="badge-sm capitalize">{status}</Badge>
|
||||||
|
{#if invoice.method}
|
||||||
|
<span class="text-xs text-content-muted"
|
||||||
|
>· paid via {invoice.method === "stripe" ? "Card" : "Lightning"}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if periodLabel}
|
||||||
|
<p class="mt-0.5 text-xs text-content-muted">{periodLabel}</p>
|
||||||
|
{/if}
|
||||||
|
{#if isDraft}
|
||||||
|
<p class="mt-1 text-xs text-content-subtle">
|
||||||
|
Charges accruing this period. You'll be invoiced once a balance is due.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if isOpen && onPay}
|
||||||
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
|
<Button class="button button-primary button-sm" onclick={onPay}>Pay now</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
307
src/app/components/hosting/PaymentDialog.svelte
Normal file
307
src/app/components/hosting/PaymentDialog.svelte
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {onMount, onDestroy} from "svelte"
|
||||||
|
import cx from "classnames"
|
||||||
|
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||||
|
import Card from "@assets/icons/card.svg?dataurl"
|
||||||
|
import Copy from "@assets/icons/copy.svg?dataurl"
|
||||||
|
import Refresh from "@assets/icons/refresh.svg?dataurl"
|
||||||
|
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import {errorMessage} from "@lib/util"
|
||||||
|
import QRCode from "@app/components/QRCode.svelte"
|
||||||
|
import PaymentSetup from "@app/components/hosting/PaymentSetup.svelte"
|
||||||
|
import InvoiceItemsList from "@app/components/hosting/InvoiceItemsList.svelte"
|
||||||
|
import {clip, pushToast} from "@app/toast"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
import {
|
||||||
|
ensureInvoiceBolt11,
|
||||||
|
reconcileInvoice,
|
||||||
|
createInvoiceCheckout,
|
||||||
|
listInvoiceItems,
|
||||||
|
formatUsd,
|
||||||
|
formatPeriod,
|
||||||
|
type Invoice,
|
||||||
|
type InvoiceItem,
|
||||||
|
} from "@app/hosting"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
invoice: Invoice
|
||||||
|
onPaid?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const {invoice, onPaid}: Props = $props()
|
||||||
|
|
||||||
|
type Bolt11Status = "idle" | "loading" | "ready" | "error"
|
||||||
|
|
||||||
|
let method = $state<"lightning" | "card">("lightning")
|
||||||
|
let bolt11 = $state("")
|
||||||
|
let bolt11Status = $state<Bolt11Status>("idle")
|
||||||
|
let bolt11Error = $state("")
|
||||||
|
let items = $state<InvoiceItem[]>([])
|
||||||
|
let paid = $state(false)
|
||||||
|
let checking = $state(false)
|
||||||
|
let redirecting = $state(false)
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let destroyed = false
|
||||||
|
|
||||||
|
const amountLabel = $derived(formatUsd(invoice.amount))
|
||||||
|
const periodLabel = $derived(formatPeriod(invoice.period_start, invoice.period_end))
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const showLightning = () => {
|
||||||
|
method = "lightning"
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCard = () => {
|
||||||
|
method = "card"
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyBolt11 = () => clip(bolt11)
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer)
|
||||||
|
pollTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const markPaid = () => {
|
||||||
|
if (paid) return
|
||||||
|
|
||||||
|
paid = true
|
||||||
|
stopPolling()
|
||||||
|
onPaid?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadBolt11 = async () => {
|
||||||
|
bolt11Status = "loading"
|
||||||
|
bolt11Error = ""
|
||||||
|
bolt11 = ""
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {lnbc} = await ensureInvoiceBolt11(invoice.id)
|
||||||
|
|
||||||
|
bolt11 = lnbc
|
||||||
|
bolt11Status = "ready"
|
||||||
|
} catch (e) {
|
||||||
|
bolt11Status = "error"
|
||||||
|
bolt11Error = errorMessage(e) || "Failed to generate Lightning invoice"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadItems = async () => {
|
||||||
|
try {
|
||||||
|
items = await listInvoiceItems(invoice.id)
|
||||||
|
} catch {
|
||||||
|
// line items are cosmetic; don't block payment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconcile = async () => {
|
||||||
|
const updated = await reconcileInvoice(invoice.id)
|
||||||
|
|
||||||
|
if (updated.paid_at) {
|
||||||
|
markPaid()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silent poll until paid or the modal closes.
|
||||||
|
const pollOnce = async () => {
|
||||||
|
if (destroyed || paid) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (await reconcile()) return
|
||||||
|
} catch {
|
||||||
|
// keep polling
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!destroyed && !paid) {
|
||||||
|
pollTimer = setTimeout(pollOnce, 3500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkPayment = async () => {
|
||||||
|
if (checking) return
|
||||||
|
|
||||||
|
checking = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(await reconcile())) {
|
||||||
|
pushToast({message: "Payment not yet confirmed. Please try again after sending."})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({theme: "error", message: errorMessage(e) || "Failed to check payment status"})
|
||||||
|
} finally {
|
||||||
|
checking = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up the tenant's server-side wallet (nested so this dialog stays mounted);
|
||||||
|
// onSaved collects right away and the poll is the backstop.
|
||||||
|
const connectWallet = () => {
|
||||||
|
pushModal(
|
||||||
|
PaymentSetup,
|
||||||
|
{initialTab: "nwc", onSaved: () => void reconcile().catch(() => {})},
|
||||||
|
{nested: true},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payWithCard = async () => {
|
||||||
|
if (redirecting) return
|
||||||
|
|
||||||
|
redirecting = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {url} = await createInvoiceCheckout(invoice.id)
|
||||||
|
|
||||||
|
window.location.href = url
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({theme: "error", message: errorMessage(e) || "Failed to open checkout"})
|
||||||
|
} finally {
|
||||||
|
redirecting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void loadItems()
|
||||||
|
void loadBolt11()
|
||||||
|
|
||||||
|
pollTimer = setTimeout(pollOnce, 3500)
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
destroyed = true
|
||||||
|
stopPolling()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal>
|
||||||
|
<ModalBody>
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>Pay invoice</ModalTitle>
|
||||||
|
<ModalSubtitle>
|
||||||
|
{#if periodLabel}
|
||||||
|
Billing period {periodLabel}
|
||||||
|
{:else}
|
||||||
|
Complete your payment to keep your relays running.
|
||||||
|
{/if}
|
||||||
|
</ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<p class="text-center text-3xl font-bold">{amountLabel}</p>
|
||||||
|
|
||||||
|
{#if paid}
|
||||||
|
<div class="flex flex-col items-center gap-3 py-6 text-center">
|
||||||
|
<Icon icon={CheckCircle} size={12} class="text-primary" />
|
||||||
|
<p class="text-sm font-semibold text-content">Payment confirmed!</p>
|
||||||
|
<p class="text-sm text-content-muted">Thank you. Your account is up to date.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if items.length > 0}
|
||||||
|
<InvoiceItemsList {items} />
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
class={cx("button grow", {
|
||||||
|
"button-primary": method === "lightning",
|
||||||
|
"button-neutral": method !== "lightning",
|
||||||
|
})}
|
||||||
|
onclick={showLightning}>
|
||||||
|
<Icon icon={Bolt} size={4} />
|
||||||
|
Lightning
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
class={cx("button grow", {
|
||||||
|
"button-primary": method === "card",
|
||||||
|
"button-neutral": method !== "card",
|
||||||
|
})}
|
||||||
|
onclick={showCard}>
|
||||||
|
<Icon icon={Card} size={4} />
|
||||||
|
Card
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#if method === "lightning"}
|
||||||
|
{#if bolt11Status === "idle" || bolt11Status === "loading"}
|
||||||
|
<div class="flex items-center justify-center py-12 text-sm text-content-muted">
|
||||||
|
<Spinner>Generating invoice…</Spinner>
|
||||||
|
</div>
|
||||||
|
{:else if bolt11Status === "error"}
|
||||||
|
<div class="flex flex-col gap-3 rounded-2xl border border-error bg-surface-less p-4">
|
||||||
|
<p class="text-sm font-semibold text-error">Unable to generate invoice</p>
|
||||||
|
<p class="break-words text-xs text-content-muted">{bolt11Error}</p>
|
||||||
|
<Button class="button button-neutral button-sm self-start" onclick={loadBolt11}>
|
||||||
|
<Icon icon={Refresh} size={4} />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col items-center gap-6 pt-2">
|
||||||
|
<QRCode code={bolt11} class="w-full max-w-64" />
|
||||||
|
<p class="text-center text-sm text-content-muted">
|
||||||
|
Scan with a Lightning wallet, or copy the invoice manually.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label class="input flex w-full items-center justify-between gap-2">
|
||||||
|
<input readonly class="min-w-0 grow truncate" value={bolt11} />
|
||||||
|
<Button class="flex items-center" onclick={copyBolt11}>
|
||||||
|
<Icon icon={Copy} />
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
<Button class="button button-neutral button-block" onclick={connectWallet}>
|
||||||
|
<Icon icon={Bolt} size={4} />
|
||||||
|
Connect a wallet to pay
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col items-center gap-4 text-center">
|
||||||
|
<div class="flex size-12 items-center justify-center rounded-full bg-surface-less">
|
||||||
|
<Icon icon={Card} size={6} class="text-content-muted" />
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-content-muted">
|
||||||
|
Pay this invoice on Stripe's secure checkout. You'll be redirected and brought back here
|
||||||
|
once it's done.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
class="button button-primary button-block"
|
||||||
|
onclick={payWithCard}
|
||||||
|
disabled={redirecting}>
|
||||||
|
<Spinner loading={redirecting}>Pay {amountLabel} by card</Spinner>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
{#if paid}
|
||||||
|
<div></div>
|
||||||
|
<Button class="button button-primary" onclick={back}>Done</Button>
|
||||||
|
{:else}
|
||||||
|
<Button class="button button-link" onclick={back}>
|
||||||
|
<Icon icon={AltArrowLeft} />
|
||||||
|
Pay later
|
||||||
|
</Button>
|
||||||
|
{#if method === "lightning"}
|
||||||
|
<Button
|
||||||
|
class="button button-primary"
|
||||||
|
onclick={checkPayment}
|
||||||
|
disabled={checking || bolt11Status !== "ready"}>
|
||||||
|
<Spinner loading={checking}>Check payment</Spinner>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
217
src/app/components/hosting/PaymentSetup.svelte
Normal file
217
src/app/components/hosting/PaymentSetup.svelte
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
|
import {pubkey, session} from "@welshman/app"
|
||||||
|
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||||
|
import Card from "@assets/icons/card.svg?dataurl"
|
||||||
|
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import Field from "@lib/components/Field.svelte"
|
||||||
|
import Input from "@lib/components/Input.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
import {getNwcClient} from "@app/lightning"
|
||||||
|
import {updateTenant, createPortalSession} from "@app/hosting"
|
||||||
|
|
||||||
|
type Tab = "nwc" | "card"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onSaved?: () => void
|
||||||
|
initialTab?: Tab
|
||||||
|
nwcConnected?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const {onSaved, initialTab, nwcConnected = false}: Props = $props()
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
let tab = $state<Tab>(initialTab ?? "nwc")
|
||||||
|
|
||||||
|
const showNwc = () => {
|
||||||
|
tab = "nwc"
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCard = () => {
|
||||||
|
tab = "card"
|
||||||
|
}
|
||||||
|
|
||||||
|
let nwcUrl = $state("")
|
||||||
|
let saving = $state(false)
|
||||||
|
let saved = $state(false)
|
||||||
|
let redirecting = $state(false)
|
||||||
|
|
||||||
|
// Offer to reuse the app's spending wallet instead of re-pasting its NWC url.
|
||||||
|
// The two wallets stay separate records, this just reuses the same connection.
|
||||||
|
const spendingWalletUrl =
|
||||||
|
$session?.wallet?.type === "nwc" ? getNwcClient().nostrWalletConnectUrl : undefined
|
||||||
|
|
||||||
|
// The tenant's autopay wallet (a backend NWC, write-only), distinct from the
|
||||||
|
// app's own wallet.
|
||||||
|
const persistNwc = async (url?: string) => {
|
||||||
|
if (!url || saving) return
|
||||||
|
|
||||||
|
saving = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateTenant($pubkey!, {nwc_url: url})
|
||||||
|
saved = true
|
||||||
|
onSaved?.()
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: e instanceof Error ? e.message : "Failed to save wallet connection",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveNwc = () => persistNwc(nwcUrl.trim())
|
||||||
|
|
||||||
|
const useSpendingWallet = () => persistNwc(spendingWalletUrl)
|
||||||
|
|
||||||
|
const disconnectNwc = async () => {
|
||||||
|
if (saving) return
|
||||||
|
|
||||||
|
saving = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateTenant($pubkey!, {nwc_url: ""})
|
||||||
|
onSaved?.()
|
||||||
|
pushToast({message: "Lightning wallet disconnected."})
|
||||||
|
back()
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: e instanceof Error ? e.message : "Failed to disconnect wallet",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPortal = async () => {
|
||||||
|
redirecting = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {url} = await createPortalSession($pubkey!)
|
||||||
|
window.open(url)
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: e instanceof Error ? e.message : "Failed to open billing portal",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
redirecting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal>
|
||||||
|
<ModalBody>
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>Set Up Payments</ModalTitle>
|
||||||
|
<ModalSubtitle>
|
||||||
|
Choose how you'd like to pay once invoices are issued for your relay.
|
||||||
|
</ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
class={cx("button flex-1", {
|
||||||
|
"button-primary": tab === "nwc",
|
||||||
|
"button-neutral": tab !== "nwc",
|
||||||
|
})}
|
||||||
|
onclick={showNwc}>
|
||||||
|
<Icon icon={Bolt} size={4} />
|
||||||
|
Lightning
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
class={cx("button flex-1", {
|
||||||
|
"button-primary": tab === "card",
|
||||||
|
"button-neutral": tab !== "card",
|
||||||
|
})}
|
||||||
|
onclick={showCard}>
|
||||||
|
<Icon icon={Card} size={4} />
|
||||||
|
Card
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-h-44 flex-col justify-center gap-4">
|
||||||
|
{#if tab === "nwc"}
|
||||||
|
{#if saved}
|
||||||
|
<div class="flex flex-col items-center gap-2 text-center">
|
||||||
|
<Icon icon={CheckCircle} size={8} class="text-primary" />
|
||||||
|
<p class="font-bold">Wallet connected!</p>
|
||||||
|
<p class="text-sm text-content-muted">Automatic payments are now enabled.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if nwcConnected}
|
||||||
|
<p class="text-sm text-content-muted">
|
||||||
|
A Lightning wallet is connected. Paste a new URL to replace it, or disconnect.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
Nostr Wallet Connect URL
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<Input
|
||||||
|
bind:value={nwcUrl}
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="nostr+walletconnect://..." />
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
{#if nwcConnected}
|
||||||
|
<Button
|
||||||
|
class="button button-link button-sm self-center text-error"
|
||||||
|
disabled={saving}
|
||||||
|
onclick={disconnectNwc}>
|
||||||
|
Disconnect wallet
|
||||||
|
</Button>
|
||||||
|
{:else if spendingWalletUrl}
|
||||||
|
<Button
|
||||||
|
class="button button-link button-sm self-center"
|
||||||
|
disabled={saving}
|
||||||
|
onclick={useSpendingWallet}>
|
||||||
|
Re-use your spending wallet
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col items-center gap-4 text-center">
|
||||||
|
<Icon icon={Card} size={8} class="text-content-muted" />
|
||||||
|
<p class="text-sm text-content-muted">
|
||||||
|
Add a payment card via Stripe to enable automatic billing. If an invoice is currently
|
||||||
|
due, we will retry collection after card setup.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
{#if saved}
|
||||||
|
<div></div>
|
||||||
|
<Button class="button button-primary" onclick={back}>Done</Button>
|
||||||
|
{:else}
|
||||||
|
<Button class="button button-link" onclick={back}>
|
||||||
|
<Icon icon={AltArrowLeft} />
|
||||||
|
Set up later
|
||||||
|
</Button>
|
||||||
|
{#if tab === "nwc"}
|
||||||
|
<Button class="button button-primary" disabled={saving || !nwcUrl.trim()} onclick={saveNwc}>
|
||||||
|
<Spinner loading={saving}>Save</Spinner>
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button class="button button-primary" disabled={redirecting} onclick={openPortal}>
|
||||||
|
<Spinner loading={redirecting}>Add a payment card</Spinner>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
116
src/app/components/hosting/PlanModal.svelte
Normal file
116
src/app/components/hosting/PlanModal.svelte
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {preventDefault} from "@lib/html"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import PricingTable from "@app/components/hosting/PricingTable.svelte"
|
||||||
|
import PaymentDialog from "@app/components/hosting/PaymentDialog.svelte"
|
||||||
|
import PaymentSetup from "@app/components/hosting/PaymentSetup.svelte"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
import {pubkey} from "@welshman/app"
|
||||||
|
import {
|
||||||
|
autopayConfigured,
|
||||||
|
derivePlans,
|
||||||
|
getTenant,
|
||||||
|
HostingError,
|
||||||
|
listTenantInvoices,
|
||||||
|
reconcileTenant,
|
||||||
|
selectPayableInvoice,
|
||||||
|
updateRelay,
|
||||||
|
type Relay,
|
||||||
|
} from "@app/hosting"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
relay: Relay
|
||||||
|
onUpdate?: (relay: Relay) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const {relay, onUpdate}: Props = $props()
|
||||||
|
|
||||||
|
const plans = derivePlans()
|
||||||
|
|
||||||
|
let selected = $state(relay.plan_id)
|
||||||
|
let saving = $state(false)
|
||||||
|
|
||||||
|
const canSave = $derived(!saving && selected !== relay.plan_id)
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const selectPlan = (id: string) => {
|
||||||
|
selected = id
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!canSave) return
|
||||||
|
|
||||||
|
saving = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Downgrading to free turns off the features that plan doesn't include.
|
||||||
|
const updated = await updateRelay(
|
||||||
|
relay.id,
|
||||||
|
selected === "free"
|
||||||
|
? {plan_id: selected, blossom_enabled: 0, livekit_enabled: 0}
|
||||||
|
: {plan_id: selected},
|
||||||
|
)
|
||||||
|
|
||||||
|
onUpdate?.(updated)
|
||||||
|
pushToast({message: "Plan updated."})
|
||||||
|
|
||||||
|
if (selected === "free") {
|
||||||
|
back()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await reconcileTenant($pubkey!)
|
||||||
|
|
||||||
|
const tenant = await getTenant($pubkey!)
|
||||||
|
|
||||||
|
if (autopayConfigured(tenant)) {
|
||||||
|
back()
|
||||||
|
} else {
|
||||||
|
const invoice = selectPayableInvoice(await listTenantInvoices($pubkey!))
|
||||||
|
|
||||||
|
if (invoice) {
|
||||||
|
pushModal(PaymentDialog, {invoice}, {replaceState: true})
|
||||||
|
} else {
|
||||||
|
pushModal(PaymentSetup, {}, {replaceState: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: e instanceof HostingError ? e.message : "Failed to update plan.",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal tag="form" onsubmit={preventDefault(save)}>
|
||||||
|
<ModalBody>
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>Change plan</ModalTitle>
|
||||||
|
<ModalSubtitle>Pick a plan for this space.</ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
<PricingTable plans={$plans} selectable value={selected} onSelect={selectPlan} />
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="button button-link" onclick={back} disabled={saving}>
|
||||||
|
<Icon icon={AltArrowLeft} />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" class="button button-primary" disabled={!canSave}>
|
||||||
|
<Spinner loading={saving}>Save</Spinner>
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
126
src/app/components/hosting/PricingTable.svelte
Normal file
126
src/app/components/hosting/PricingTable.svelte
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
|
import Check from "@assets/icons/check.svg?dataurl"
|
||||||
|
import Close from "@assets/icons/close.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import type {Plan} from "@app/hosting"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
plans: Plan[]
|
||||||
|
selectable?: boolean
|
||||||
|
value?: string
|
||||||
|
onSelect?: (planId: string) => void
|
||||||
|
onCta?: (planId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const {plans, selectable = false, value, onSelect, onCta}: Props = $props()
|
||||||
|
|
||||||
|
const priceLabel = (amount: number) => (amount === 0 ? "Free" : `$${amount / 100}`)
|
||||||
|
|
||||||
|
const memberLabel = (members: Plan["members"]) =>
|
||||||
|
members ? `Up to ${members} members` : "Unlimited members"
|
||||||
|
|
||||||
|
const visiblePlans = $derived(plans.filter(plan => !plan.hidden))
|
||||||
|
|
||||||
|
const gridCols: Record<number, string> = {
|
||||||
|
1: "",
|
||||||
|
2: "@md:grid-cols-2",
|
||||||
|
3: "@md:grid-cols-2 @3xl:grid-cols-3",
|
||||||
|
4: "@md:grid-cols-2 @3xl:grid-cols-4",
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardCount = $derived(visiblePlans.length + (selectable ? 0 : 1))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet feature(present: boolean, label: string)}
|
||||||
|
<li class={cx("flex items-start gap-2", {"text-content-subtle": !present})}>
|
||||||
|
<Icon
|
||||||
|
icon={present ? Check : Close}
|
||||||
|
size={4}
|
||||||
|
class={cx("mt-0.5 shrink-0", {"text-primary": present})} />
|
||||||
|
{label}
|
||||||
|
</li>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet cardBody(plan: Plan, isPopular: boolean)}
|
||||||
|
{#if isPopular && !selectable}
|
||||||
|
<span
|
||||||
|
class="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-primary px-3 py-1 text-xs font-bold tracking-wide text-primary-content">
|
||||||
|
POPULAR
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<h3 class="mb-1 text-lg font-bold">{plan.name}</h3>
|
||||||
|
<div class="mb-8">
|
||||||
|
<span class="text-4xl font-extrabold">{priceLabel(plan.amount)}</span>
|
||||||
|
<span class="ml-1 text-sm text-content-muted">/ mo</span>
|
||||||
|
</div>
|
||||||
|
<ul class="mb-8 flex flex-col gap-3 text-sm">
|
||||||
|
<li class="flex items-start gap-2">
|
||||||
|
<Icon icon={Check} size={4} class="mt-0.5 shrink-0 text-primary" />
|
||||||
|
{memberLabel(plan.members)}
|
||||||
|
</li>
|
||||||
|
{@render feature(plan.blossom, "Blossom storage")}
|
||||||
|
{@render feature(plan.livekit, "LiveKit video")}
|
||||||
|
</ul>
|
||||||
|
{#if !selectable}
|
||||||
|
<Button
|
||||||
|
class={cx("button button-block mt-auto", {
|
||||||
|
"button-primary": isPopular,
|
||||||
|
"button-neutral": !isPopular,
|
||||||
|
})}
|
||||||
|
onclick={() => onCta?.(plan.id)}>
|
||||||
|
Get started
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="@container">
|
||||||
|
<div class={cx("grid grid-cols-1 items-start gap-6", gridCols[cardCount] ?? "@3xl:grid-cols-4")}>
|
||||||
|
{#each visiblePlans as plan (plan.id)}
|
||||||
|
{@const isPopular = plan.id === "basic"}
|
||||||
|
{#if selectable}
|
||||||
|
<Button
|
||||||
|
class={cx("card card-interactive relative flex flex-col text-left", {
|
||||||
|
"border-primary": value === plan.id,
|
||||||
|
})}
|
||||||
|
onclick={() => onSelect?.(plan.id)}>
|
||||||
|
{@render cardBody(plan, isPopular)}
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<div class={cx("card relative flex flex-col", {"border-primary": isPopular})}>
|
||||||
|
{@render cardBody(plan, isPopular)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#if !selectable}
|
||||||
|
<div class="card relative flex flex-col">
|
||||||
|
<h3 class="mb-1 text-lg font-bold">Custom</h3>
|
||||||
|
<div class="mb-8">
|
||||||
|
<span class="text-4xl font-extrabold">Let's talk</span>
|
||||||
|
</div>
|
||||||
|
<ul class="mb-8 flex flex-col gap-3 text-sm">
|
||||||
|
<li class="flex items-start gap-2">
|
||||||
|
<Icon icon={Check} size={4} class="mt-0.5 shrink-0 text-primary" />
|
||||||
|
White-labeled app
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-2">
|
||||||
|
<Icon icon={Check} size={4} class="mt-0.5 shrink-0 text-primary" />
|
||||||
|
Dedicated support
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-2">
|
||||||
|
<Icon icon={Check} size={4} class="mt-0.5 shrink-0 text-primary" />
|
||||||
|
Custom feature development
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<Link
|
||||||
|
external
|
||||||
|
href="https://cal.com/coracle.social/30min"
|
||||||
|
class="button button-neutral button-block mt-auto text-center">
|
||||||
|
Contact us
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
73
src/app/components/hosting/RelayCreate.svelte
Normal file
73
src/app/components/hosting/RelayCreate.svelte
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {goto} from "$app/navigation"
|
||||||
|
import {pubkey} from "@welshman/app"
|
||||||
|
import RelayForm, {type RelayFormValues} from "@app/components/hosting/RelayForm.svelte"
|
||||||
|
import PaymentDialog from "@app/components/hosting/PaymentDialog.svelte"
|
||||||
|
import PaymentSetup from "@app/components/hosting/PaymentSetup.svelte"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
import {addSpace} from "@app/groups"
|
||||||
|
import {makeSpacePath} from "@app/routes"
|
||||||
|
import {HOSTING_RELAY_DOMAIN} from "@app/env"
|
||||||
|
import {
|
||||||
|
autopayConfigured,
|
||||||
|
createRelay,
|
||||||
|
ensureSessionTenant,
|
||||||
|
getHostedRelayUrl,
|
||||||
|
getTenant,
|
||||||
|
listTenantInvoices,
|
||||||
|
reconcileTenant,
|
||||||
|
selectPayableInvoice,
|
||||||
|
} from "@app/hosting"
|
||||||
|
|
||||||
|
const createRelayForActiveTenant = (values: RelayFormValues) => {
|
||||||
|
const defaults = {
|
||||||
|
info_name: "",
|
||||||
|
info_icon: "",
|
||||||
|
info_description: "",
|
||||||
|
policy_public_read: 0,
|
||||||
|
policy_public_write: 0,
|
||||||
|
policy_public_join: 0,
|
||||||
|
policy_strip_signatures: 0,
|
||||||
|
groups_enabled: 1,
|
||||||
|
management_enabled: 1,
|
||||||
|
push_enabled: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrides = {
|
||||||
|
tenant_pubkey: $pubkey!,
|
||||||
|
zooid_domain: HOSTING_RELAY_DOMAIN,
|
||||||
|
blossom_enabled: values.plan_id === "free" ? 0 : 1,
|
||||||
|
livekit_enabled: values.plan_id === "free" ? 0 : 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
return createRelay({...defaults, ...values, ...overrides})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (values: RelayFormValues) => {
|
||||||
|
await ensureSessionTenant()
|
||||||
|
|
||||||
|
const relay = await createRelayForActiveTenant(values)
|
||||||
|
const url = getHostedRelayUrl(relay)
|
||||||
|
|
||||||
|
// Join the space we just made, otherwise the space layout prompts us to join
|
||||||
|
// it, displacing the payment modals below.
|
||||||
|
await addSpace(url)
|
||||||
|
await reconcileTenant($pubkey!)
|
||||||
|
|
||||||
|
const tenant = await getTenant($pubkey!)
|
||||||
|
|
||||||
|
await goto(makeSpacePath(url, "admin"), {replaceState: true})
|
||||||
|
|
||||||
|
if (values.plan_id !== "free" && !autopayConfigured(tenant)) {
|
||||||
|
const invoice = selectPayableInvoice(await listTenantInvoices($pubkey!))
|
||||||
|
|
||||||
|
if (invoice) {
|
||||||
|
pushModal(PaymentDialog, {invoice})
|
||||||
|
} else {
|
||||||
|
pushModal(PaymentSetup, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<RelayForm mode="create" submitLabel="Create Space" onSubmit={handleSubmit} />
|
||||||
515
src/app/components/hosting/RelayDetailCard.svelte
Normal file
515
src/app/components/hosting/RelayDetailCard.svelte
Normal file
|
|
@ -0,0 +1,515 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
|
import {spec} from "@welshman/lib"
|
||||||
|
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||||
|
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||||
|
import Power from "@assets/icons/power.svg?dataurl"
|
||||||
|
import Play from "@assets/icons/play.svg?dataurl"
|
||||||
|
import Server from "@assets/icons/server.svg?dataurl"
|
||||||
|
import Danger from "@assets/icons/danger.svg?dataurl"
|
||||||
|
import VerifiedCheck from "@assets/icons/verified-check.svg?dataurl"
|
||||||
|
import ShieldWarning from "@assets/icons/shield-warning.svg?dataurl"
|
||||||
|
import Copy from "@assets/icons/copy.svg?dataurl"
|
||||||
|
import Refresh from "@assets/icons/refresh.svg?dataurl"
|
||||||
|
import BillList from "@assets/icons/bill-list.svg?dataurl"
|
||||||
|
import {fly} from "@lib/transition"
|
||||||
|
import {ucFirst} from "@lib/util"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Badge from "@lib/components/Badge.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||||
|
import Popover from "@lib/components/Popover.svelte"
|
||||||
|
import Divider from "@lib/components/Divider.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
|
import ToggleInput from "@lib/components/ToggleInput.svelte"
|
||||||
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
|
import CustomDomainModal from "@app/components/hosting/CustomDomainModal.svelte"
|
||||||
|
import PlanModal from "@app/components/hosting/PlanModal.svelte"
|
||||||
|
import RelayForm from "@app/components/hosting/RelayForm.svelte"
|
||||||
|
import type {RelayFormValues} from "@app/components/hosting/RelayForm.svelte"
|
||||||
|
import {pushModal, clearModals} from "@app/modal"
|
||||||
|
import {clip, pushToast} from "@app/toast"
|
||||||
|
import {moveSpace} from "@app/groups"
|
||||||
|
import {goToMovedSpace} from "@app/routes"
|
||||||
|
import {
|
||||||
|
HostingError,
|
||||||
|
flagToBool,
|
||||||
|
boolToFlag,
|
||||||
|
canonicalRelayHost,
|
||||||
|
relayHost,
|
||||||
|
getHostedRelayUrl,
|
||||||
|
updateRelay,
|
||||||
|
getRelay,
|
||||||
|
deactivateRelay,
|
||||||
|
reactivateRelay,
|
||||||
|
derivePlans,
|
||||||
|
deriveRelayMembers,
|
||||||
|
type Relay,
|
||||||
|
type RelayFlag,
|
||||||
|
type UpdateRelayInput,
|
||||||
|
} from "@app/hosting"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
relay: Relay
|
||||||
|
}
|
||||||
|
|
||||||
|
const {relay}: Props = $props()
|
||||||
|
|
||||||
|
const PLAN_GATED_TOOLTIP = "Not available on your current plan"
|
||||||
|
|
||||||
|
const plans = derivePlans()
|
||||||
|
const members = deriveRelayMembers(relay.id)
|
||||||
|
|
||||||
|
let current = $state(relay)
|
||||||
|
let showMenu = $state(false)
|
||||||
|
let loading = $state(false)
|
||||||
|
|
||||||
|
const host = $derived(relayHost(current))
|
||||||
|
const relayLabel = $derived(current.info_name || current.subdomain)
|
||||||
|
const domainVerified = $derived(flagToBool(current.custom_domain_verified, false))
|
||||||
|
const cnameTarget = $derived(canonicalRelayHost(current))
|
||||||
|
const isPaidPlan = $derived(current.plan_id !== "free")
|
||||||
|
|
||||||
|
// Adding, removing or verifying a custom domain moves the relay's host, so
|
||||||
|
// migrate the admin's groups list and follow the space to its new url.
|
||||||
|
const setCurrentAndFollowHost = async (next: Relay) => {
|
||||||
|
const previousUrl = getHostedRelayUrl(current)
|
||||||
|
const nextUrl = getHostedRelayUrl(next)
|
||||||
|
|
||||||
|
current = next
|
||||||
|
|
||||||
|
if (previousUrl !== nextUrl) {
|
||||||
|
await moveSpace(previousUrl, nextUrl)
|
||||||
|
await goToMovedSpace(previousUrl, nextUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const persist = async (next: Relay, input: UpdateRelayInput) => {
|
||||||
|
const previous = current
|
||||||
|
|
||||||
|
current = next
|
||||||
|
|
||||||
|
try {
|
||||||
|
current = await updateRelay(previous.id, input)
|
||||||
|
} catch (e) {
|
||||||
|
current = previous
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: e instanceof HostingError ? e.message : "Failed to save changes.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setFlag = (field: RelayFlag) => (checked: boolean) => {
|
||||||
|
const input: UpdateRelayInput = {[field]: boolToFlag(checked)}
|
||||||
|
|
||||||
|
persist({...current, ...input}, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCurrent = (next: Relay) => {
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPlan = () =>
|
||||||
|
pushModal(PlanModal, {relay: current, onUpdate: setCurrent}, {size: "large"})
|
||||||
|
|
||||||
|
const openEdit = () => {
|
||||||
|
const initialValues: RelayFormValues = {
|
||||||
|
info_name: current.info_name,
|
||||||
|
subdomain: current.subdomain,
|
||||||
|
info_icon: current.info_icon,
|
||||||
|
info_description: current.info_description,
|
||||||
|
plan_id: current.plan_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
pushModal(RelayForm, {
|
||||||
|
mode: "edit",
|
||||||
|
initialValues,
|
||||||
|
zooidDomain: current.zooid_domain,
|
||||||
|
onSubmit: async (values: RelayFormValues) => {
|
||||||
|
const updated = await updateRelay(current.id, values)
|
||||||
|
|
||||||
|
clearModals()
|
||||||
|
|
||||||
|
// The subdomain is editable here, so the host may have moved.
|
||||||
|
await setCurrentAndFollowHost(updated)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCustomDomain = () => {
|
||||||
|
pushModal(CustomDomainModal, {relay: current, onUpdate: setCurrentAndFollowHost})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMenu = () => {
|
||||||
|
showMenu = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
showMenu = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyCname = () => clip(cnameTarget)
|
||||||
|
|
||||||
|
// Verification runs in a backend poller; reload to pick up the result.
|
||||||
|
const verify = async () => {
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await getRelay(current.id)
|
||||||
|
|
||||||
|
pushToast({
|
||||||
|
message: flagToBool(updated.custom_domain_verified, false)
|
||||||
|
? "Custom domain verified."
|
||||||
|
: "Not verified yet. DNS changes can take a while to propagate.",
|
||||||
|
})
|
||||||
|
|
||||||
|
await setCurrentAndFollowHost(updated)
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: e instanceof HostingError ? e.message : "Verification failed.",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestDeactivate = () => {
|
||||||
|
pushModal(Confirm, {
|
||||||
|
title: "Deactivate relay?",
|
||||||
|
subtitle: `${relayLabel} will be taken offline immediately.`,
|
||||||
|
message:
|
||||||
|
"All client connections are dropped and members can't read or write, but all data and settings are preserved. You can reactivate at any time.",
|
||||||
|
confirm: async () => {
|
||||||
|
try {
|
||||||
|
await deactivateRelay(current.id)
|
||||||
|
current = await getRelay(current.id)
|
||||||
|
clearModals()
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: e instanceof HostingError ? e.message : "Failed to deactivate relay.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestReactivate = () => {
|
||||||
|
pushModal(Confirm, {
|
||||||
|
title: "Reactivate relay?",
|
||||||
|
subtitle: `${relayLabel} will come back online.`,
|
||||||
|
message: "The relay will start accepting connections again.",
|
||||||
|
confirm: async () => {
|
||||||
|
try {
|
||||||
|
await reactivateRelay(current.id)
|
||||||
|
current = await getRelay(current.id)
|
||||||
|
clearModals()
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: e instanceof HostingError ? e.message : "Failed to reactivate relay.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card flex flex-col gap-6">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex min-w-0 items-start gap-4">
|
||||||
|
<div class="rounded-full border border-line aspect-square px-2 pt-2">
|
||||||
|
<ImageIcon size={8} alt="" src={current.info_icon || Server} />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 class="text-xl font-bold">{relayLabel}</h2>
|
||||||
|
<Badge variant={current.status === "active" ? "primary" : "neutral"}>
|
||||||
|
{ucFirst(current.status.replace(/_/g, " "))}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={current.plan_id === "free" ? "neutral" : "primary"}>
|
||||||
|
{ucFirst(current.plan_id)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Link external href={`https://${host}`} class="break-all text-sm text-primary">
|
||||||
|
wss://{host}
|
||||||
|
</Link>
|
||||||
|
{#if current.info_description.trim()}
|
||||||
|
<p class="mt-2 text-sm text-content-muted">{current.info_description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative shrink-0">
|
||||||
|
<Button
|
||||||
|
class="button button-neutral button-circle"
|
||||||
|
aria-label="Relay actions"
|
||||||
|
onclick={openMenu}>
|
||||||
|
<Icon icon={MenuDots} />
|
||||||
|
</Button>
|
||||||
|
{#if showMenu}
|
||||||
|
<Popover hideOnClick onClose={closeMenu}>
|
||||||
|
<ul
|
||||||
|
transition:fly
|
||||||
|
class="menu absolute right-0 z-popover mt-2 w-52 gap-1 rounded-2xl border border-line bg-surface p-2 shadow-xl">
|
||||||
|
<li>
|
||||||
|
<Button onclick={openEdit}>
|
||||||
|
<Icon icon={Pen} />
|
||||||
|
Edit details
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Button onclick={openPlan}>
|
||||||
|
<Icon icon={BillList} />
|
||||||
|
Change plan
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
{#if current.status === "active"}
|
||||||
|
<Button class="text-error" onclick={requestDeactivate}>
|
||||||
|
<Icon icon={Power} />
|
||||||
|
Deactivate
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button class="text-primary" onclick={requestReactivate}>
|
||||||
|
<Icon icon={Play} />
|
||||||
|
Reactivate
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Popover>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if current.sync_error}
|
||||||
|
<div class="flex flex-col gap-1 rounded-2xl border border-error bg-surface-less p-4">
|
||||||
|
<p class="flex items-center gap-2 text-sm font-semibold text-error">
|
||||||
|
<Icon icon={Danger} size={4} />
|
||||||
|
Provisioning error
|
||||||
|
</p>
|
||||||
|
<p class="break-all font-mono text-sm text-error">{current.sync_error}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Custom Domain</h3>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
{#if current.custom_domain}
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<span class="truncate font-medium">{current.custom_domain}</span>
|
||||||
|
{#if domainVerified}
|
||||||
|
<Badge variant="primary">
|
||||||
|
<Icon icon={VerifiedCheck} size={3} />
|
||||||
|
Verified
|
||||||
|
</Badge>
|
||||||
|
{:else}
|
||||||
|
<Badge variant="warning">
|
||||||
|
<Icon icon={ShieldWarning} size={3} />
|
||||||
|
Pending
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Button class="button button-neutral button-sm shrink-0" onclick={openCustomDomain}>
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<span class="text-sm text-content-muted">Not configured</span>
|
||||||
|
<Button class="button button-neutral button-sm shrink-0" onclick={openCustomDomain}>
|
||||||
|
Add domain
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if current.custom_domain && !domainVerified}
|
||||||
|
<div class="flex flex-col gap-3 rounded-2xl border border-warning bg-surface-less p-4">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<Icon icon={ShieldWarning} size={4} class="mt-0.5 shrink-0 text-warning" />
|
||||||
|
<p class="text-sm font-semibold text-warning">
|
||||||
|
Not yet verified — add this DNS record, then verify.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 rounded-xl border border-line bg-surface px-3 py-2">
|
||||||
|
<code class="min-w-0 flex-1 break-all font-mono text-xs text-content">
|
||||||
|
{current.custom_domain} CNAME {cnameTarget}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
class="button button-neutral button-sm shrink-0"
|
||||||
|
data-tip="Copy CNAME target"
|
||||||
|
onclick={copyCname}>
|
||||||
|
<Icon icon={Copy} size={4} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-content-muted">
|
||||||
|
For apex domains (e.g. example.com), use an ALIAS or ANAME record instead.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
class="button button-neutral button-sm self-start"
|
||||||
|
onclick={verify}
|
||||||
|
disabled={loading}>
|
||||||
|
{#if !loading}
|
||||||
|
<Icon icon={Refresh} size={4} />
|
||||||
|
{/if}
|
||||||
|
<Spinner {loading} size="sm">Verify DNS record</Spinner>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Policy</h3>
|
||||||
|
<div class="grid gap-x-8 gap-y-4 sm:grid-cols-2">
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<span>Public read</span>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<ToggleInput
|
||||||
|
checked={flagToBool(current.policy_public_read, false)}
|
||||||
|
onchange={setFlag("policy_public_read")} />
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<span>Public write</span>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<ToggleInput
|
||||||
|
checked={flagToBool(current.policy_public_write, false)}
|
||||||
|
onchange={setFlag("policy_public_write")} />
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<span>Public join</span>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<ToggleInput
|
||||||
|
checked={flagToBool(current.policy_public_join, false)}
|
||||||
|
onchange={setFlag("policy_public_join")} />
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<span>Strip signatures</span>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<ToggleInput
|
||||||
|
checked={flagToBool(current.policy_strip_signatures, false)}
|
||||||
|
onchange={setFlag("policy_strip_signatures")} />
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Features</h3>
|
||||||
|
<div class="grid gap-x-8 gap-y-4 sm:grid-cols-2">
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<span>Rooms</span>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<ToggleInput
|
||||||
|
checked={flagToBool(current.groups_enabled, true)}
|
||||||
|
onchange={setFlag("groups_enabled")} />
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<span>Management API</span>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<ToggleInput
|
||||||
|
checked={flagToBool(current.management_enabled, true)}
|
||||||
|
onchange={setFlag("management_enabled")} />
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<span>Push notifications</span>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<ToggleInput
|
||||||
|
checked={flagToBool(current.push_enabled, true)}
|
||||||
|
onchange={setFlag("push_enabled")} />
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
<FieldInline class={cx({"opacity-50": !isPaidPlan})}>
|
||||||
|
{#snippet label()}
|
||||||
|
<span>Media storage</span>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<ToggleInput
|
||||||
|
checked={flagToBool(current.blossom_enabled, false)}
|
||||||
|
disabled={!isPaidPlan}
|
||||||
|
tooltip={isPaidPlan ? undefined : PLAN_GATED_TOOLTIP}
|
||||||
|
onchange={setFlag("blossom_enabled")} />
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
<FieldInline class={cx({"opacity-50": !isPaidPlan})}>
|
||||||
|
{#snippet label()}
|
||||||
|
<span>LiveKit support</span>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<ToggleInput
|
||||||
|
checked={flagToBool(current.livekit_enabled, false)}
|
||||||
|
disabled={!isPaidPlan}
|
||||||
|
tooltip={isPaidPlan ? undefined : PLAN_GATED_TOOLTIP}
|
||||||
|
onchange={setFlag("livekit_enabled")} />
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Membership</h3>
|
||||||
|
<div class="grid gap-x-8 gap-y-4 sm:grid-cols-2">
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<span>Current members</span>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<span class="font-medium">{$members.length}</span>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<span>Member limit</span>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
{@const plan = $plans.find(spec({id: current.plan_id}))}
|
||||||
|
<span class="font-medium">
|
||||||
|
{typeof plan?.members === "number" ? plan.members : "∞"}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Plan</h3>
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<span>Current plan</span>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<span class="font-medium">{ucFirst(current.plan_id)}</span>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
233
src/app/components/hosting/RelayForm.svelte
Normal file
233
src/app/components/hosting/RelayForm.svelte
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
<script module lang="ts">
|
||||||
|
import type {Relay} from "@app/hosting"
|
||||||
|
|
||||||
|
export type RelayFormValues = Pick<
|
||||||
|
Relay,
|
||||||
|
"info_name" | "subdomain" | "info_icon" | "info_description" | "plan_id"
|
||||||
|
>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type {Snippet} from "svelte"
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
|
import {preventDefault} from "@lib/html"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
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"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import PricingTable from "@app/components/hosting/PricingTable.svelte"
|
||||||
|
import {derivePlans} from "@app/hosting"
|
||||||
|
import {HOSTING_RELAY_DOMAIN} from "@app/env"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
import {compressFileForUpload, uploadFileOrFallback} from "@app/uploads"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mode: "create" | "edit"
|
||||||
|
initialValues?: Partial<RelayFormValues>
|
||||||
|
zooidDomain?: string
|
||||||
|
submitLabel?: string
|
||||||
|
header?: Snippet
|
||||||
|
onSubmit: (values: RelayFormValues) => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
mode,
|
||||||
|
initialValues = {},
|
||||||
|
zooidDomain = HOSTING_RELAY_DOMAIN,
|
||||||
|
submitLabel,
|
||||||
|
header,
|
||||||
|
onSubmit,
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
const slugify = (value: string) =>
|
||||||
|
value
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
|
||||||
|
const plans = derivePlans()
|
||||||
|
|
||||||
|
let loading = $state(false)
|
||||||
|
let name = $state(initialValues.info_name ?? "")
|
||||||
|
let subdomain = $state(initialValues.subdomain ?? "")
|
||||||
|
let icon = $state(initialValues.info_icon ?? "")
|
||||||
|
let imageFile = $state<File | undefined>()
|
||||||
|
let imagePreview = $state(initialValues.info_icon ?? "")
|
||||||
|
let description = $state(initialValues.info_description ?? "")
|
||||||
|
let planId = $state(initialValues.plan_id ?? "free")
|
||||||
|
|
||||||
|
const validateSubdomain = (subdomain: string): Maybe<string> => {
|
||||||
|
if (subdomain.length === 0) {
|
||||||
|
return "subdomain is required"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subdomain.length > 63) {
|
||||||
|
return "subdomain must be 63 characters or fewer"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subdomain.startsWith("-") || subdomain.endsWith("-")) {
|
||||||
|
return "subdomain cannot start or end with a hyphen"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["api", "admin"].includes(subdomain)) {
|
||||||
|
return "subdomain is reserved"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-z0-9-]+$/.test(subdomain)) {
|
||||||
|
return "subdomain may only contain lowercase letters, numbers, and hyphens"
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const subdomainError = $derived(validateSubdomain(subdomain))
|
||||||
|
|
||||||
|
const saveLabel = $derived(submitLabel ?? (mode === "create" ? "Create Space" : "Save Changes"))
|
||||||
|
|
||||||
|
// In create mode the subdomain tracks the name.
|
||||||
|
$effect(() => {
|
||||||
|
if (mode === "create") {
|
||||||
|
subdomain = slugify(name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const selectPlan = (id: string) => {
|
||||||
|
planId = id
|
||||||
|
}
|
||||||
|
|
||||||
|
const trySubmit = async () => {
|
||||||
|
if (mode === "create" && !planId) {
|
||||||
|
return pushToast({theme: "error", message: "Please select a plan"})
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = validateSubdomain(subdomain)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return pushToast({theme: "error", message: error})
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (imageFile) {
|
||||||
|
const compressedFile = await compressFileForUpload(imageFile, {
|
||||||
|
maxWidth: 128,
|
||||||
|
maxHeight: 128,
|
||||||
|
})
|
||||||
|
const result = await uploadFileOrFallback(compressedFile)
|
||||||
|
|
||||||
|
icon = result.url
|
||||||
|
imagePreview = result.url
|
||||||
|
imageFile = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubmit({
|
||||||
|
plan_id: planId,
|
||||||
|
info_name: name,
|
||||||
|
subdomain,
|
||||||
|
info_icon: icon,
|
||||||
|
info_description: description,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
theme: "error",
|
||||||
|
message: e instanceof Error ? e.message : "Failed to save space",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal tag="form" onsubmit={preventDefault(trySubmit)}>
|
||||||
|
<ModalBody>
|
||||||
|
{#if header}
|
||||||
|
{@render header()}
|
||||||
|
{:else}
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>{mode === "create" ? "New Space" : "Edit Space"}</ModalTitle>
|
||||||
|
<ModalSubtitle>
|
||||||
|
{#if mode === "create"}
|
||||||
|
Configure your hosted space and choose a plan.
|
||||||
|
{:else}
|
||||||
|
Update your space's details.
|
||||||
|
{/if}
|
||||||
|
</ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Name</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<Input bind:value={name} type="text" placeholder="My Space" required />
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Subdomain</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<Input bind:value={subdomain} type="text" placeholder="my-space" required>
|
||||||
|
{#snippet after()}
|
||||||
|
<span class="whitespace-nowrap text-sm opacity-50">.{zooidDomain}</span>
|
||||||
|
{/snippet}
|
||||||
|
</Input>
|
||||||
|
{#if subdomain && subdomainError}
|
||||||
|
<p class="text-sm text-error">{subdomainError}</p>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Icon</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<IconInput bind:file={imageFile} bind:preview={imagePreview} />
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Description</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<textarea bind:value={description} class="textarea input w-full" rows="3"></textarea>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
{#if mode === "create"}
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Plan</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<PricingTable plans={$plans} selectable value={planId} onSelect={selectPlan} />
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="button button-link" onclick={back} disabled={loading}>
|
||||||
|
<Icon icon={AltArrowLeft} />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" class="button button-primary" disabled={loading}>
|
||||||
|
<Spinner {loading}>{saveLabel}</Spinner>
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
40
src/app/components/hosting/RelayListItem.svelte
Normal file
40
src/app/components/hosting/RelayListItem.svelte
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Server from "@assets/icons/server.svg?dataurl"
|
||||||
|
import {ucFirst} from "@lib/util"
|
||||||
|
import Badge from "@lib/components/Badge.svelte"
|
||||||
|
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import {canonicalRelayHost, getHostedRelayUrl, type Relay} from "@app/hosting"
|
||||||
|
import {makeSpacePath} from "@app/routes"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
relay: Relay
|
||||||
|
}
|
||||||
|
|
||||||
|
const {relay}: Props = $props()
|
||||||
|
|
||||||
|
const name = $derived(relay.info_name || relay.subdomain)
|
||||||
|
const host = $derived(canonicalRelayHost(relay))
|
||||||
|
const href = $derived(makeSpacePath(getHostedRelayUrl(relay), "admin"))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card flex flex-row items-center justify-between gap-3 p-3 sm:p-4">
|
||||||
|
<ImageIcon size={8} alt="" class="rounded-xl" src={relay.info_icon || Server} />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="truncate text-sm font-medium">{name}</p>
|
||||||
|
<p class="truncate text-xs opacity-75">{host}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
|
{#if relay.sync_error}
|
||||||
|
<Badge variant="warning" title={relay.sync_error}>Failed to sync</Badge>
|
||||||
|
{:else}
|
||||||
|
<Badge variant={relay.status === "active" ? "primary" : "neutral"}>
|
||||||
|
{ucFirst(relay.status.replace(/_/g, " "))}
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
<Badge variant={relay.plan_id === "free" ? "neutral" : "primary"}>
|
||||||
|
{ucFirst(relay.plan_id)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Link class="button button-neutral button-sm shrink-0" {href}>Manage</Link>
|
||||||
|
</div>
|
||||||
|
|
@ -55,6 +55,10 @@ export const DEFAULT_SPACES = fromCsv(import.meta.env.VITE_DEFAULT_SPACES).map(n
|
||||||
|
|
||||||
export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS
|
export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS
|
||||||
|
|
||||||
|
export const HOSTING_BACKEND_URL = import.meta.env.VITE_HOSTING_BACKEND_URL
|
||||||
|
|
||||||
|
export const HOSTING_RELAY_DOMAIN = import.meta.env.VITE_HOSTING_RELAY_DOMAIN
|
||||||
|
|
||||||
export const DUFFLEPUD_URL = "https://dufflepud.coracle.social"
|
export const DUFFLEPUD_URL = "https://dufflepud.coracle.social"
|
||||||
|
|
||||||
export const THUMBNAIL_URL = import.meta.env.VITE_THUMBNAIL_URL
|
export const THUMBNAIL_URL = import.meta.env.VITE_THUMBNAIL_URL
|
||||||
|
|
|
||||||
|
|
@ -325,6 +325,41 @@ export const setSpaces = async (urls: string[]) => {
|
||||||
return publishThunk({event, relays})
|
return publishThunk({event, relays})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const moveSpace = async (oldUrl: string, newUrl: string) => {
|
||||||
|
const from = normalizeRelayUrl(oldUrl)
|
||||||
|
const to = normalizeRelayUrl(newUrl)
|
||||||
|
const list = get(userGroupList)
|
||||||
|
|
||||||
|
if (!list || from === to) return
|
||||||
|
|
||||||
|
const rewrite = (tags: string[][]) =>
|
||||||
|
tags.map(t => {
|
||||||
|
if (t[0] === "r" && normalizeRelayUrl(t[1]) === from) return ["r", to]
|
||||||
|
if (t[0] === "group" && normalizeRelayUrl(t[2] || "") === from) return ["group", t[1], to]
|
||||||
|
return t
|
||||||
|
})
|
||||||
|
|
||||||
|
const publicTags = rewrite(list.publicTags)
|
||||||
|
const privateTags = rewrite(list.privateTags)
|
||||||
|
const publicChanged = !equals(publicTags, list.publicTags)
|
||||||
|
const privateChanged = !equals(privateTags, list.privateTags)
|
||||||
|
|
||||||
|
if (!publicChanged && !privateChanged) return
|
||||||
|
|
||||||
|
const event = await updateList(list, {
|
||||||
|
publicTags: publicChanged ? publicTags : undefined,
|
||||||
|
privateTags: privateChanged ? privateTags : undefined,
|
||||||
|
}).reconcile(nip44EncryptToSelf)
|
||||||
|
const relays = uniq([
|
||||||
|
oldUrl,
|
||||||
|
newUrl,
|
||||||
|
...Router.get().FromUser().getUrls(),
|
||||||
|
...getRelayTagValues(event.tags),
|
||||||
|
])
|
||||||
|
|
||||||
|
return publishThunk({event, relays})
|
||||||
|
}
|
||||||
|
|
||||||
export const addRoom = async (url: string, h: string) => {
|
export const addRoom = async (url: string, h: string) => {
|
||||||
const list = get(userGroupList) || makeList({kind: ROOMS})
|
const list = get(userGroupList) || makeList({kind: ROOMS})
|
||||||
const newTags = [
|
const newTags = [
|
||||||
|
|
|
||||||
418
src/app/hosting.ts
Normal file
418
src/app/hosting.ts
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
import {derived, readable} from "svelte/store"
|
||||||
|
import type {Readable} from "svelte/store"
|
||||||
|
import {Capacitor} from "@capacitor/core"
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
|
import {sortBy, int, MINUTE, ms} from "@welshman/lib"
|
||||||
|
import {makeHttpAuth, makeHttpAuthHeader, normalizeRelayUrl} from "@welshman/util"
|
||||||
|
import {pubkey, signer} from "@welshman/app"
|
||||||
|
import {HOSTING_BACKEND_URL, PLATFORM_URL} from "@app/env"
|
||||||
|
|
||||||
|
// Apple doesn't allow selling hosting outside their payment system, so on iOS we
|
||||||
|
// point people at the platform's website instead.
|
||||||
|
export const HOSTING_ENABLED = Capacitor.getPlatform() !== "ios"
|
||||||
|
|
||||||
|
export type Plan = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
amount: number
|
||||||
|
hidden: boolean
|
||||||
|
members: number | null
|
||||||
|
blossom: boolean
|
||||||
|
livekit: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Relay = {
|
||||||
|
id: string
|
||||||
|
tenant_pubkey: string
|
||||||
|
subdomain: string
|
||||||
|
zooid_domain: string
|
||||||
|
plan_id: string
|
||||||
|
status: string
|
||||||
|
sync_error: string
|
||||||
|
synced: number
|
||||||
|
custom_domain: string
|
||||||
|
custom_domain_verified: number
|
||||||
|
info_name: string
|
||||||
|
info_icon: string
|
||||||
|
info_description: string
|
||||||
|
policy_public_read: number
|
||||||
|
policy_public_write: number
|
||||||
|
policy_public_join: number
|
||||||
|
policy_strip_signatures: number
|
||||||
|
groups_enabled: number
|
||||||
|
management_enabled: number
|
||||||
|
blossom_enabled: number
|
||||||
|
livekit_enabled: number
|
||||||
|
push_enabled: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// The relay's boolean settings, which the backend models as 0/1 columns.
|
||||||
|
export type RelayFlag =
|
||||||
|
| "policy_public_read"
|
||||||
|
| "policy_public_write"
|
||||||
|
| "policy_public_join"
|
||||||
|
| "policy_strip_signatures"
|
||||||
|
| "groups_enabled"
|
||||||
|
| "management_enabled"
|
||||||
|
| "blossom_enabled"
|
||||||
|
| "livekit_enabled"
|
||||||
|
| "push_enabled"
|
||||||
|
|
||||||
|
export type CreateRelayInput = {
|
||||||
|
tenant_pubkey?: string
|
||||||
|
subdomain: string
|
||||||
|
zooid_domain: string
|
||||||
|
plan_id: string
|
||||||
|
info_name?: string
|
||||||
|
info_icon?: string
|
||||||
|
info_description?: string
|
||||||
|
policy_public_read?: number
|
||||||
|
policy_public_write?: number
|
||||||
|
policy_public_join?: number
|
||||||
|
policy_strip_signatures?: number
|
||||||
|
groups_enabled?: number
|
||||||
|
management_enabled?: number
|
||||||
|
blossom_enabled?: number
|
||||||
|
livekit_enabled?: number
|
||||||
|
push_enabled?: number
|
||||||
|
custom_domain?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateRelayInput = {
|
||||||
|
subdomain?: string
|
||||||
|
zooid_domain?: string
|
||||||
|
plan_id?: string
|
||||||
|
info_name?: string
|
||||||
|
info_icon?: string
|
||||||
|
info_description?: string
|
||||||
|
policy_public_read?: number
|
||||||
|
policy_public_write?: number
|
||||||
|
policy_public_join?: number
|
||||||
|
policy_strip_signatures?: number
|
||||||
|
groups_enabled?: number
|
||||||
|
management_enabled?: number
|
||||||
|
blossom_enabled?: number
|
||||||
|
livekit_enabled?: number
|
||||||
|
push_enabled?: number
|
||||||
|
custom_domain?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Tenant = {
|
||||||
|
pubkey: string
|
||||||
|
return_url: string
|
||||||
|
nwc_is_set: boolean
|
||||||
|
created_at: number
|
||||||
|
billing_anchor: number | null
|
||||||
|
stripe_customer_id: string
|
||||||
|
stripe_payment_method_id: string | null
|
||||||
|
nwc_error: string | null
|
||||||
|
stripe_error: string | null
|
||||||
|
churned_at: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateTenantInput = {
|
||||||
|
nwc_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceMethod = "nwc" | "stripe" | "oob"
|
||||||
|
|
||||||
|
export type Invoice = {
|
||||||
|
id: string
|
||||||
|
tenant_pubkey: string
|
||||||
|
amount: number
|
||||||
|
period_start: number
|
||||||
|
period_end: number
|
||||||
|
created_at: number
|
||||||
|
paid_at: number | null
|
||||||
|
voided_at: number | null
|
||||||
|
method: InvoiceMethod | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceItem = {
|
||||||
|
id: string
|
||||||
|
invoice_id: string | null
|
||||||
|
activity_id: string | null
|
||||||
|
tenant_pubkey: string
|
||||||
|
relay_id: string
|
||||||
|
plan_id: string
|
||||||
|
amount: number
|
||||||
|
description: string
|
||||||
|
created_at: number
|
||||||
|
voided_at: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Bolt11 = {
|
||||||
|
id: string
|
||||||
|
invoice_id: string
|
||||||
|
lnbc: string
|
||||||
|
msats: number
|
||||||
|
created_at: number
|
||||||
|
expires_at: number
|
||||||
|
settled_at: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Activity = {
|
||||||
|
id: string
|
||||||
|
tenant_pubkey: string
|
||||||
|
created_at: number
|
||||||
|
activity_type: string
|
||||||
|
resource_type: string
|
||||||
|
resource_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HostingError extends Error {
|
||||||
|
status: number
|
||||||
|
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = "HostingError"
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requests
|
||||||
|
|
||||||
|
const AUTH_TTL = ms(int(10, MINUTE))
|
||||||
|
|
||||||
|
// The backend checks the signature and `u` tag but not freshness, so one token
|
||||||
|
// per pubkey is reusable. Cache the promise rather than the value so a burst of
|
||||||
|
// concurrent requests shares a single signature, and key it on the pubkey so
|
||||||
|
// switching accounts doesn't keep signing as the old one.
|
||||||
|
let cachedAuth: Maybe<{pubkey: string; expiresAt: number; header: Promise<string>}>
|
||||||
|
|
||||||
|
const getAuthHeader = (): Maybe<Promise<string>> => {
|
||||||
|
const $pubkey = pubkey.get()
|
||||||
|
const $signer = signer.get()
|
||||||
|
|
||||||
|
if (!$pubkey || !$signer) return undefined
|
||||||
|
|
||||||
|
if (cachedAuth?.pubkey !== $pubkey || cachedAuth.expiresAt < Date.now()) {
|
||||||
|
const header = makeHttpAuth(HOSTING_BACKEND_URL, "GET")
|
||||||
|
.then(template => $signer.sign(template))
|
||||||
|
.then(makeHttpAuthHeader)
|
||||||
|
|
||||||
|
// A declined signature shouldn't be cached, or the user can't retry.
|
||||||
|
header.catch(() => {
|
||||||
|
if (cachedAuth?.header === header) cachedAuth = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
cachedAuth = {pubkey: $pubkey, expiresAt: Date.now() + AUTH_TTL, header}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedAuth.header
|
||||||
|
}
|
||||||
|
|
||||||
|
type HostingResponse<T> = {data: T; error?: string}
|
||||||
|
|
||||||
|
export const hostingFetch = async <T>(method: string, path: string, body?: unknown): Promise<T> => {
|
||||||
|
const auth = await getAuthHeader()
|
||||||
|
const headers: Record<string, string> = {Accept: "application/json"}
|
||||||
|
|
||||||
|
if (auth) headers["Authorization"] = auth
|
||||||
|
if (body !== undefined) headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
const response = await fetch(new URL(path, HOSTING_BACKEND_URL).toString(), {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload: Maybe<HostingResponse<T>> = await response.json().catch(() => undefined)
|
||||||
|
|
||||||
|
if (!response.ok || !payload) {
|
||||||
|
throw new HostingError(payload?.error || `Request failed (${response.status})`, response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listPlans = () => hostingFetch<Plan[]>("GET", "/plans")
|
||||||
|
|
||||||
|
export const createTenant = (returnUrl: string) =>
|
||||||
|
hostingFetch<Tenant>("POST", "/tenants", {return_url: returnUrl})
|
||||||
|
|
||||||
|
export const getTenant = (pubkey: string) => hostingFetch<Tenant>("GET", `/tenants/${pubkey}`)
|
||||||
|
|
||||||
|
export const updateTenant = (pubkey: string, input: UpdateTenantInput) =>
|
||||||
|
hostingFetch<Tenant>("PUT", `/tenants/${pubkey}`, input)
|
||||||
|
|
||||||
|
export const listTenantRelays = (pubkey: string) =>
|
||||||
|
hostingFetch<Relay[]>("GET", `/tenants/${pubkey}/relays`)
|
||||||
|
|
||||||
|
export const listTenantInvoices = (pubkey: string) =>
|
||||||
|
hostingFetch<Invoice[]>("GET", `/tenants/${pubkey}/invoices`)
|
||||||
|
|
||||||
|
export const reconcileTenant = (pubkey: string) =>
|
||||||
|
hostingFetch<Tenant>("POST", `/tenants/${pubkey}/reconcile`)
|
||||||
|
|
||||||
|
// The backend answers with null when nothing has accrued this period.
|
||||||
|
export const getDraftInvoice = async (pubkey: string): Promise<Maybe<Invoice>> =>
|
||||||
|
(await hostingFetch<Invoice | null>("GET", `/tenants/${pubkey}/invoices/draft`)) ?? undefined
|
||||||
|
|
||||||
|
export const createPortalSession = (pubkey: string, returnUrl: string = hostingReturnUrl()) =>
|
||||||
|
hostingFetch<{url: string}>(
|
||||||
|
"GET",
|
||||||
|
`/tenants/${pubkey}/stripe/session?return_url=${encodeURIComponent(returnUrl)}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
export const createRelay = (input: CreateRelayInput) =>
|
||||||
|
hostingFetch<Relay>("POST", "/relays", input)
|
||||||
|
|
||||||
|
export const getRelay = (id: string) => hostingFetch<Relay>("GET", `/relays/${id}`)
|
||||||
|
|
||||||
|
export const updateRelay = (id: string, input: UpdateRelayInput) =>
|
||||||
|
hostingFetch<Relay>("PUT", `/relays/${id}`, input)
|
||||||
|
|
||||||
|
export const deactivateRelay = (id: string) =>
|
||||||
|
hostingFetch<void>("POST", `/relays/${id}/deactivate`)
|
||||||
|
|
||||||
|
export const reactivateRelay = (id: string) =>
|
||||||
|
hostingFetch<void>("POST", `/relays/${id}/reactivate`)
|
||||||
|
|
||||||
|
export const listRelayMembers = (id: string) =>
|
||||||
|
hostingFetch<{members: string[]}>("GET", `/relays/${id}/members`)
|
||||||
|
|
||||||
|
export const listRelayActivity = (id: string) =>
|
||||||
|
hostingFetch<{activity: Activity[]}>("GET", `/relays/${id}/activity`)
|
||||||
|
|
||||||
|
export const getInvoice = (id: string) => hostingFetch<Invoice>("GET", `/invoices/${id}`)
|
||||||
|
|
||||||
|
export const listInvoiceItems = (invoiceId: string) =>
|
||||||
|
hostingFetch<InvoiceItem[]>("GET", `/invoices/${invoiceId}/items`)
|
||||||
|
|
||||||
|
export const ensureInvoiceBolt11 = (invoiceId: string) =>
|
||||||
|
hostingFetch<Bolt11>("POST", `/invoices/${invoiceId}/bolt11`)
|
||||||
|
|
||||||
|
export const createInvoiceCheckout = (invoiceId: string) => {
|
||||||
|
const returnUrl = hostingReturnUrl()
|
||||||
|
const queryString = `?return_url=${encodeURIComponent(returnUrl)}`
|
||||||
|
|
||||||
|
return hostingFetch<{url: string}>("POST", `/invoices/${invoiceId}/checkout${queryString}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reconcileInvoice = (invoiceId: string) =>
|
||||||
|
hostingFetch<Invoice>("POST", `/invoices/${invoiceId}/reconcile`)
|
||||||
|
|
||||||
|
// API utils
|
||||||
|
|
||||||
|
export const flagToBool = (value: number | undefined, fallback: boolean): boolean => {
|
||||||
|
if (value === 0) return false
|
||||||
|
if (value === 1) return true
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export const boolToFlag = (value: boolean): 0 | 1 => (value ? 1 : 0)
|
||||||
|
|
||||||
|
export const autopayConfigured = (
|
||||||
|
t: Pick<Tenant, "nwc_is_set" | "stripe_payment_method_id">,
|
||||||
|
): boolean => t.nwc_is_set || Boolean(t.stripe_payment_method_id)
|
||||||
|
|
||||||
|
// The oldest open positive invoice, matching the backend's dunning order. The
|
||||||
|
// backend models the lifecycle as timestamps, not a status field.
|
||||||
|
export const selectPayableInvoice = (invoices: Invoice[]): Invoice | undefined =>
|
||||||
|
sortBy(invoice => invoice.created_at, invoices).find(
|
||||||
|
invoice => !invoice.paid_at && !invoice.voided_at && invoice.amount > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
export const formatUsd = (cents: number) => `$${(cents / 100).toFixed(2)}`
|
||||||
|
|
||||||
|
export const formatPeriod = (startSecs?: number, endSecs?: number) =>
|
||||||
|
!startSecs || !endSecs
|
||||||
|
? ""
|
||||||
|
: `${new Date(ms(startSecs)).toLocaleDateString()} – ${new Date(ms(endSecs)).toLocaleDateString()}`
|
||||||
|
|
||||||
|
// A verified custom domain when present, otherwise the platform subdomain.
|
||||||
|
export const relayHost = (relay: Relay): string =>
|
||||||
|
relay.custom_domain_verified && relay.custom_domain
|
||||||
|
? relay.custom_domain
|
||||||
|
: canonicalRelayHost(relay)
|
||||||
|
|
||||||
|
// The host a custom domain must CNAME to.
|
||||||
|
export const canonicalRelayHost = (relay: Relay): string =>
|
||||||
|
`${relay.subdomain}.${relay.zooid_domain}`
|
||||||
|
|
||||||
|
export const getHostedRelayUrl = (relay: Relay): string =>
|
||||||
|
normalizeRelayUrl("wss://" + relayHost(relay))
|
||||||
|
|
||||||
|
// On native, redirects can't round-trip through location.origin; use the
|
||||||
|
// platform's hosted url.
|
||||||
|
export const hostingReturnUrl = (path = "/settings/hosting"): string =>
|
||||||
|
Capacitor.isNativePlatform() ? PLATFORM_URL + path : location.origin + path
|
||||||
|
|
||||||
|
// Provisioning is idempotent, so this runs once per login. The shared promise
|
||||||
|
// dedupes concurrent callers, and a failure drops the cache so the next call
|
||||||
|
// retries.
|
||||||
|
let tenantPromise: Maybe<{pubkey: string; promise: Promise<Tenant>}>
|
||||||
|
|
||||||
|
export const ensureSessionTenant = async () => {
|
||||||
|
const $pubkey = pubkey.get()
|
||||||
|
|
||||||
|
if (!$pubkey) return
|
||||||
|
|
||||||
|
if (tenantPromise?.pubkey !== $pubkey) {
|
||||||
|
const promise = createTenant(hostingReturnUrl())
|
||||||
|
|
||||||
|
promise.catch(() => {
|
||||||
|
if (tenantPromise?.promise === promise) tenantPromise = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
tenantPromise = {pubkey: $pubkey, promise}
|
||||||
|
}
|
||||||
|
|
||||||
|
await tenantPromise.promise
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stores
|
||||||
|
|
||||||
|
export const derivePlans = () =>
|
||||||
|
readable<Plan[]>([], set => {
|
||||||
|
listPlans().then(set, () => set([]))
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deriveRelayMembers = (id: string) =>
|
||||||
|
readable<string[]>([], set => {
|
||||||
|
listRelayMembers(id).then(
|
||||||
|
({members}) => set(members),
|
||||||
|
() => set([]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export type RelayActivityState = {loading: boolean; activity: Activity[]}
|
||||||
|
|
||||||
|
// Takes a store because the id comes from the hosted relay lookup, which hasn't
|
||||||
|
// resolved when the page mounts.
|
||||||
|
export const deriveRelayActivity = (id: Readable<Maybe<string>>) =>
|
||||||
|
derived<Readable<Maybe<string>>, RelayActivityState>(
|
||||||
|
id,
|
||||||
|
($id, set) => {
|
||||||
|
if (!$id) return set({loading: false, activity: []})
|
||||||
|
|
||||||
|
set({loading: true, activity: []})
|
||||||
|
|
||||||
|
listRelayActivity($id).then(
|
||||||
|
({activity}) => set({loading: false, activity}),
|
||||||
|
() => set({loading: false, activity: []}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{loading: false, activity: []},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Loading is distinct from "not hosted here" — both leave `relay` undefined, but
|
||||||
|
// only one of them should keep the caller waiting.
|
||||||
|
export type HostedRelayState = {loading: boolean; relay: Maybe<Relay>}
|
||||||
|
|
||||||
|
export const deriveHostedRelay = (url: string) =>
|
||||||
|
derived<typeof pubkey, HostedRelayState>(
|
||||||
|
pubkey,
|
||||||
|
($pubkey, set) => {
|
||||||
|
if (!$pubkey) return set({loading: false, relay: undefined})
|
||||||
|
|
||||||
|
set({loading: true, relay: undefined})
|
||||||
|
|
||||||
|
listTenantRelays($pubkey).then(
|
||||||
|
relays => set({loading: false, relay: relays.find(r => getHostedRelayUrl(r) === url)}),
|
||||||
|
() => set({loading: false, relay: undefined}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{loading: true, relay: undefined},
|
||||||
|
)
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import {derived, writable} from "svelte/store"
|
import {derived, readable, writable} from "svelte/store"
|
||||||
import {
|
import {
|
||||||
ManagementMethod,
|
ManagementMethod,
|
||||||
RELAY_ADD_MEMBER,
|
RELAY_ADD_MEMBER,
|
||||||
|
|
@ -20,7 +20,7 @@ import {
|
||||||
sortEventsAsc,
|
sortEventsAsc,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import type {Filter, PublishedRoomMeta, TrustedEvent} from "@welshman/util"
|
import type {Filter, PublishedRoomMeta, TrustedEvent} from "@welshman/util"
|
||||||
import {first, memoize, sortBy, spec, uniq} from "@welshman/lib"
|
import {first, sortBy, spec, uniq} from "@welshman/lib"
|
||||||
import {addRoomMember, manageRelay, pubkey, waitForThunkError} from "@welshman/app"
|
import {addRoomMember, manageRelay, pubkey, waitForThunkError} from "@welshman/app"
|
||||||
import {load} from "@welshman/net"
|
import {load} from "@welshman/net"
|
||||||
import {get} from "svelte/store"
|
import {get} from "svelte/store"
|
||||||
|
|
@ -266,16 +266,13 @@ export enum MembershipStatus {
|
||||||
Granted,
|
Granted,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deriveUserIsSpaceAdmin = memoize((url?: string) => {
|
export const deriveUserIsSpaceAdmin = (url?: string) =>
|
||||||
const store = writable(false)
|
readable(false, set => {
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []}).then(res =>
|
manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []}).then(res =>
|
||||||
store.set(Boolean(res.result?.length)),
|
set(Boolean(res.result?.length)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return store
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const deriveUserSpaceMembershipStatus = (url: string) => {
|
export const deriveUserSpaceMembershipStatus = (url: string) => {
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,14 @@ import {randomId, always, assoc, Emitter} from "@welshman/lib"
|
||||||
import {deriveDeduplicated} from "@welshman/store"
|
import {deriveDeduplicated} from "@welshman/store"
|
||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
|
import type {DialogSize} from "@lib/components/Dialog.svelte"
|
||||||
|
|
||||||
export type ModalOptions = {
|
export type ModalOptions = {
|
||||||
drawer?: boolean
|
drawer?: boolean
|
||||||
nested?: boolean
|
nested?: boolean
|
||||||
noEscape?: boolean
|
noEscape?: boolean
|
||||||
fullscreen?: boolean
|
fullscreen?: boolean
|
||||||
|
size?: DialogSize
|
||||||
replaceState?: boolean
|
replaceState?: boolean
|
||||||
path?: string
|
path?: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
import type {List, RelayProfile} from "@welshman/util"
|
import type {List, RelayProfile} from "@welshman/util"
|
||||||
import {AuthStateEvent, AuthStatus, Pool, SocketEvent, SocketStatus} from "@welshman/net"
|
import {AuthStateEvent, AuthStatus, Pool, SocketEvent, SocketStatus} from "@welshman/net"
|
||||||
import {derived, readable} from "svelte/store"
|
import {derived, readable} from "svelte/store"
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
import {call, on, simpleCache, uniq} from "@welshman/lib"
|
import {call, on, simpleCache, uniq} from "@welshman/lib"
|
||||||
import {throttled} from "@welshman/store"
|
import {throttled} from "@welshman/store"
|
||||||
import {getRelay, loadRelay, manageRelay, publishThunk, waitForThunkError} from "@welshman/app"
|
import {getRelay, loadRelay, manageRelay, publishThunk, waitForThunkError} from "@welshman/app"
|
||||||
|
|
@ -33,6 +34,25 @@ export const encodeRelay = (url: string) =>
|
||||||
|
|
||||||
export const decodeRelay = (url: string) => normalizeRelayUrl(decodeURIComponent(url))
|
export const decodeRelay = (url: string) => normalizeRelayUrl(decodeURIComponent(url))
|
||||||
|
|
||||||
|
// A hosted relay whose custom domain changed keeps 301-ing its old host to the
|
||||||
|
// new one; the NIP-11 fetch surfaces that (the socket can't). Returns the new
|
||||||
|
// wss:// url, or undefined when there's no redirect or the fetch fails.
|
||||||
|
export const fetchRelayRedirect = async (url: string): Promise<Maybe<string>> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url.replace(/^ws/, "http"), {
|
||||||
|
headers: {Accept: "application/nostr+json"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.redirected) return undefined
|
||||||
|
|
||||||
|
const next = normalizeRelayUrl(response.url.replace(/^http/, "ws"))
|
||||||
|
|
||||||
|
return next === normalizeRelayUrl(url) ? undefined : next
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const deriveSocket = (url: string) => {
|
export const deriveSocket = (url: string) => {
|
||||||
const socket = Pool.get().get(url)
|
const socket = Pool.get().get(url)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,9 @@ export const goToSpace = (url: string, hash = "") => {
|
||||||
return goto(makeSpacePath(url) + hash, {replaceState: true})
|
return goto(makeSpacePath(url) + hash, {replaceState: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const goToMovedSpace = (oldUrl: string, newUrl: string) =>
|
||||||
|
goto(get(page).url.pathname.replace(encodeRelay(oldUrl), encodeRelay(newUrl)))
|
||||||
|
|
||||||
export const goToHome = () => {
|
export const goToHome = () => {
|
||||||
if (PLATFORM_RELAYS.length > 0) {
|
if (PLATFORM_RELAYS.length > 0) {
|
||||||
goToSpace(PLATFORM_RELAYS[0], get(page).url.hash)
|
goToSpace(PLATFORM_RELAYS[0], get(page).url.hash)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export const isEventMuted = withGetter(
|
||||||
const regex =
|
const regex =
|
||||||
mutedWords.length > 0
|
mutedWords.length > 0
|
||||||
? new RegExp(`\\b(${mutedWords.map(w => w.toLowerCase().trim()).join("|")})\\b`)
|
? new RegExp(`\\b(${mutedWords.map(w => w.toLowerCase().trim()).join("|")})\\b`)
|
||||||
: null
|
: undefined
|
||||||
|
|
||||||
return (e: TrustedEvent) => {
|
return (e: TrustedEvent) => {
|
||||||
if (!pubkey) return false
|
if (!pubkey) return false
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ const staticTitles = new Map<string, string>([
|
||||||
["/spaces/[relay]/calendar", "Calendar"],
|
["/spaces/[relay]/calendar", "Calendar"],
|
||||||
["/spaces/[relay]/goals", "Goals"],
|
["/spaces/[relay]/goals", "Goals"],
|
||||||
["/spaces/[relay]/polls", "Polls"],
|
["/spaces/[relay]/polls", "Polls"],
|
||||||
|
["/spaces/[relay]/admin", "Space Administration"],
|
||||||
["/chat", "Messages"],
|
["/chat", "Messages"],
|
||||||
["/join", "Join Space"],
|
["/join", "Join Space"],
|
||||||
["/people", "Find People"],
|
["/people", "Find People"],
|
||||||
|
|
@ -30,6 +31,7 @@ const staticTitles = new Map<string, string>([
|
||||||
["/settings/content", "Content Settings"],
|
["/settings/content", "Content Settings"],
|
||||||
["/settings/privacy", "Privacy Settings"],
|
["/settings/privacy", "Privacy Settings"],
|
||||||
["/settings/relays", "Relay Settings"],
|
["/settings/relays", "Relay Settings"],
|
||||||
|
["/settings/hosting", "Hosting"],
|
||||||
["/settings/alerts", "Alert Settings"],
|
["/settings/alerts", "Alert Settings"],
|
||||||
["/settings/wallet", "Wallet Settings"],
|
["/settings/wallet", "Wallet Settings"],
|
||||||
["/[bech32]", "Opening Link"],
|
["/[bech32]", "Opening Link"],
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type {Component} from "svelte"
|
import type {Component} from "svelte"
|
||||||
import {writable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
import {randomId} from "@welshman/lib"
|
import {randomId} from "@welshman/lib"
|
||||||
import {copyToClipboard} from "@lib/html"
|
import {copyToClipboard} from "@lib/html"
|
||||||
|
|
||||||
|
|
@ -21,7 +22,7 @@ export type Toast = ToastParams & {
|
||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const toast = writable<Toast | null>(null)
|
export const toast = writable<Maybe<Toast>>()
|
||||||
|
|
||||||
export const pushToast = (params: ToastParams) => {
|
export const pushToast = (params: ToastParams) => {
|
||||||
const id = randomId()
|
const id = randomId()
|
||||||
|
|
@ -33,7 +34,7 @@ export const pushToast = (params: ToastParams) => {
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
export const popToast = (id: string) => toast.update($t => ($t?.id === id ? null : $t))
|
export const popToast = (id: string) => toast.update($t => ($t?.id === id ? undefined : $t))
|
||||||
|
|
||||||
export const clip = (value: string) => {
|
export const clip = (value: string) => {
|
||||||
copyToClipboard(value)
|
copyToClipboard(value)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
// selected via its time input; we expose unix seconds
|
// selected via its time input; we expose unix seconds
|
||||||
let element: HTMLElement
|
let element: HTMLElement
|
||||||
let isOpen = $state(false)
|
let isOpen = $state(false)
|
||||||
let startDate: number | null = $state(value ? value * 1000 : null)
|
let startDate: number | undefined = $state(value ? value * 1000 : undefined)
|
||||||
let startDateTime = $state("")
|
let startDateTime = $state("")
|
||||||
|
|
||||||
// When no date is provided the library initializes its time input from the
|
// When no date is provided the library initializes its time input from the
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const clear = () => {
|
const clear = () => {
|
||||||
startDate = null
|
startDate = undefined
|
||||||
isOpen = false
|
isOpen = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@
|
||||||
// selected via its time inputs; we expose unix seconds
|
// selected via its time inputs; we expose unix seconds
|
||||||
let element: HTMLElement
|
let element: HTMLElement
|
||||||
let isOpen = $state(false)
|
let isOpen = $state(false)
|
||||||
let startDate: number | null = $state(start ? start * 1000 : null)
|
let startDate: number | undefined = $state(start ? start * 1000 : undefined)
|
||||||
let endDate: number | null = $state(end ? end * 1000 : null)
|
let endDate: number | undefined = $state(end ? end * 1000 : undefined)
|
||||||
let startDateTime = $state("")
|
let startDateTime = $state("")
|
||||||
let endDateTime = $state("")
|
let endDateTime = $state("")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
<script module lang="ts">
|
||||||
|
export type DialogSize = "default" | "large"
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {Component} from "svelte"
|
import type {Component} from "svelte"
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
|
|
@ -11,18 +15,27 @@
|
||||||
onClose?: any
|
onClose?: any
|
||||||
noEscape?: boolean
|
noEscape?: boolean
|
||||||
fullscreen?: boolean
|
fullscreen?: boolean
|
||||||
|
size?: DialogSize
|
||||||
children: {
|
children: {
|
||||||
component: Component<any>
|
component: Component<any>
|
||||||
props: Record<string, any>
|
props: Record<string, any>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const {onClose = noop, noEscape = false, fullscreen = false, children}: Props = $props()
|
const {
|
||||||
|
onClose = noop,
|
||||||
|
noEscape = false,
|
||||||
|
fullscreen = false,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
const wrapperClass = $derived(
|
const wrapperClass = $derived(
|
||||||
cx("absolute inset-0 flex sm:relative pointer-events-none", {
|
cx("absolute inset-0 flex sm:relative pointer-events-none", {
|
||||||
"items-center justify-center": fullscreen,
|
"items-center justify-center": fullscreen,
|
||||||
"items-end sm:w-[520px] sm:items-center": !fullscreen,
|
"items-end sm:items-center": !fullscreen,
|
||||||
|
"sm:w-[520px]": !fullscreen && size === "default",
|
||||||
|
"sm:w-[90%]": !fullscreen && size === "large",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
68
src/lib/components/IconInput.svelte
Normal file
68
src/lib/components/IconInput.svelte
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
|
||||||
|
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"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
// The parent uploads this on submit — never `preview`, which may be a data: URL.
|
||||||
|
file?: File | undefined
|
||||||
|
// An existing hosted URL, or the data: URL of a freshly picked image.
|
||||||
|
preview?: string | undefined
|
||||||
|
previewClass?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let {file = $bindable(), preview = $bindable(), previewClass = ""}: Props = $props()
|
||||||
|
|
||||||
|
// Decode a built-in icon's base64 SVG data: URL into a File so it uploads on
|
||||||
|
// submit like any image.
|
||||||
|
const handleIconSelect = (iconUrl: string) => {
|
||||||
|
preview = iconUrl
|
||||||
|
|
||||||
|
const parts = iconUrl.split(",")
|
||||||
|
const imageData = atob(parts[1])
|
||||||
|
const bytes = new Uint8Array(imageData.length)
|
||||||
|
|
||||||
|
for (let n = 0; n < imageData.length; n++) {
|
||||||
|
bytes[n] = imageData.charCodeAt(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
file = new File([bytes], "icon.svg", {type: "image/svg+xml"})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageUpload = (event: Event) => {
|
||||||
|
const selected = (event.target as HTMLInputElement).files?.[0]
|
||||||
|
|
||||||
|
if (selected && selected.type.startsWith("image/")) {
|
||||||
|
const reader = new FileReader()
|
||||||
|
|
||||||
|
reader.onload = e => {
|
||||||
|
file = selected
|
||||||
|
preview = e.target?.result as string
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.readAsDataURL(selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex grow items-center justify-between gap-4">
|
||||||
|
{#if preview}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm opacity-75">Selected:</span>
|
||||||
|
<ImageIcon src={preview} alt="" class={previewClass} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="text-sm opacity-75">No icon selected</span>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<IconPickerButton onSelect={handleIconSelect} class="button button-primary button-sm">
|
||||||
|
<Icon icon={StickerSmileSquare} size={4} />
|
||||||
|
</IconPickerButton>
|
||||||
|
<label class="button button-neutral button-sm cursor-pointer">
|
||||||
|
<Icon icon={UploadMinimalistic} size={4} />
|
||||||
|
<input type="file" accept="image/*" class="hidden" onchange={handleImageUpload} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
import {randomId} from "@welshman/lib"
|
import {randomId} from "@welshman/lib"
|
||||||
import {removeAt, insertAt} from "@welshman/lib"
|
import {removeAt, insertAt} from "@welshman/lib"
|
||||||
import {preventDefault, stopPropagation} from "@lib/html"
|
import {preventDefault, stopPropagation} from "@lib/html"
|
||||||
|
|
@ -58,7 +59,7 @@
|
||||||
dropActive = false
|
dropActive = false
|
||||||
}
|
}
|
||||||
|
|
||||||
let draggedIndex: number | null = $state(null)
|
let draggedIndex: Maybe<number> = $state()
|
||||||
let dropActive = $state(false)
|
let dropActive = $state(false)
|
||||||
|
|
||||||
const handleDragStart = (e: DragEvent, index: number) => {
|
const handleDragStart = (e: DragEvent, index: number) => {
|
||||||
|
|
@ -72,14 +73,14 @@
|
||||||
const handleDragOver = (e: DragEvent, index: number) => {
|
const handleDragOver = (e: DragEvent, index: number) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
if (draggedIndex !== null && draggedIndex !== index) {
|
if (draggedIndex !== undefined && draggedIndex !== index) {
|
||||||
value = insertAt(index, value[draggedIndex], removeAt(draggedIndex, value))
|
value = insertAt(index, value[draggedIndex], removeAt(draggedIndex, value))
|
||||||
draggedIndex = index
|
draggedIndex = index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
const handleDragEnd = () => {
|
||||||
draggedIndex = null
|
draggedIndex = undefined
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
import {append, removeAt, replaceAt, insertAt} from "@welshman/lib"
|
import {append, removeAt, replaceAt, insertAt} from "@welshman/lib"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||||
|
|
@ -19,7 +20,7 @@
|
||||||
placeholder = "Enter text...",
|
placeholder = "Enter text...",
|
||||||
allowAdd = true,
|
allowAdd = true,
|
||||||
}: Props & {allowAdd?: boolean} = $props()
|
}: Props & {allowAdd?: boolean} = $props()
|
||||||
let draggedIndex: number | null = $state(null)
|
let draggedIndex: Maybe<number> = $state()
|
||||||
|
|
||||||
const onChange = (newValue: string[]) => {
|
const onChange = (newValue: string[]) => {
|
||||||
value = newValue
|
value = newValue
|
||||||
|
|
@ -42,14 +43,14 @@
|
||||||
const handleDragOver = (e: DragEvent, index: number) => {
|
const handleDragOver = (e: DragEvent, index: number) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
if (draggedIndex !== null && draggedIndex !== index) {
|
if (draggedIndex !== undefined && draggedIndex !== index) {
|
||||||
onChange(insertAt(index, value[draggedIndex], removeAt(draggedIndex, value)))
|
onChange(insertAt(index, value[draggedIndex], removeAt(draggedIndex, value)))
|
||||||
draggedIndex = index
|
draggedIndex = index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
const handleDragEnd = () => {
|
||||||
draggedIndex = null
|
draggedIndex = undefined
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
29
src/lib/components/ToggleInput.svelte
Normal file
29
src/lib/components/ToggleInput.svelte
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Tooltip from "@lib/components/Tooltip.svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
checked?: boolean
|
||||||
|
tooltip?: string
|
||||||
|
onchange?: (checked: boolean) => void
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
let {checked = $bindable(), tooltip, onchange, ...restProps}: Props = $props()
|
||||||
|
|
||||||
|
const onInput = (event: Event & {currentTarget: HTMLInputElement}) =>
|
||||||
|
onchange?.(event.currentTarget.checked)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet toggle()}
|
||||||
|
<input {...restProps} type="checkbox" class="toggle" bind:checked onchange={onInput} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<!-- The tooltip hangs off a wrapper rather than the input, so it still shows
|
||||||
|
while the toggle is disabled. -->
|
||||||
|
{#if tooltip}
|
||||||
|
<Tooltip content={tooltip}>
|
||||||
|
{@render toggle()}
|
||||||
|
</Tooltip>
|
||||||
|
{:else}
|
||||||
|
{@render toggle()}
|
||||||
|
{/if}
|
||||||
|
|
@ -2,8 +2,6 @@
|
||||||
.card {
|
.card {
|
||||||
@apply text-content bg-surface border-line rounded-3xl border-solid p-6;
|
@apply text-content bg-surface border-line rounded-3xl border-solid p-6;
|
||||||
border-width: var(--border-thick);
|
border-width: var(--border-thick);
|
||||||
/* clip fill to the padding box so the opaque border owns the rounded
|
|
||||||
corners — otherwise the bg bleeds past the arc and specks white */
|
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
transition:
|
transition:
|
||||||
|
|
@ -12,7 +10,7 @@
|
||||||
border-color 0.2s ease;
|
border-color 0.2s ease;
|
||||||
|
|
||||||
&.card-sm {
|
&.card-sm {
|
||||||
@apply rounded-2xl p-4;
|
@apply p-4;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,6 +25,20 @@
|
||||||
&.card-flat {
|
&.card-flat {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.card-primary {
|
||||||
|
--accent: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.card-secondary {
|
||||||
|
--accent: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.card-primary,
|
||||||
|
&.card-secondary {
|
||||||
|
background-color: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 30%, var(--line));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* nested cards & cards in dialogs lose their shadow */
|
/* nested cards & cards in dialogs lose their shadow */
|
||||||
|
|
@ -44,8 +56,22 @@
|
||||||
box-shadow: var(--shadow), var(--shadow-inset);
|
box-shadow: var(--shadow), var(--shadow-inset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-fl-theme="clay"] .card-primary,
|
||||||
|
[data-fl-theme="clay"] .card-secondary {
|
||||||
|
box-shadow:
|
||||||
|
8px 8px 0 color-mix(in srgb, var(--accent) 28%, transparent),
|
||||||
|
var(--shadow-inset);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-fl-theme="clay"] .card-sm.card-primary,
|
||||||
|
[data-fl-theme="clay"] .card-sm.card-secondary {
|
||||||
|
box-shadow:
|
||||||
|
6px 6px 0 color-mix(in srgb, var(--accent) 28%, transparent),
|
||||||
|
var(--shadow-inset);
|
||||||
|
}
|
||||||
|
|
||||||
/* cards nested in another card (or a dialog) drop the raised clay treatment and
|
/* cards nested in another card (or a dialog) drop the raised clay treatment and
|
||||||
read flat — no offset shadow / inset, hairline border */
|
read flat — no offset shadow / inset, hairline border. */
|
||||||
[data-fl-theme="clay"] .card .card,
|
[data-fl-theme="clay"] .card .card,
|
||||||
[data-fl-theme="clay"] .dialog .card {
|
[data-fl-theme="clay"] .dialog .card {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,7 @@ export const createScroller = ({
|
||||||
: element.closest(".scroll-container")
|
: element.closest(".scroll-container")
|
||||||
|
|
||||||
const check = async () => {
|
const check = async () => {
|
||||||
const isHidden = (el: Element) =>
|
const isHidden = (el: Element) => !(el as HTMLElement).offsetParent || el.clientHeight === 0
|
||||||
(el as HTMLElement).offsetParent === null || el.clientHeight === 0
|
|
||||||
|
|
||||||
if (container && !isHidden(container)) {
|
if (container && !isHidden(container)) {
|
||||||
// While we have empty space, fill it
|
// While we have empty space, fill it
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,3 @@ export const scale = (
|
||||||
return `transform: scale(${s}); opacity: ${t}`
|
return `transform: scale(${s}); opacity: ${t}`
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const conditionalTransition =
|
|
||||||
(condition: any, transition: any) => (node: any, args?: any) =>
|
|
||||||
condition ? transition(node, args) : null
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
import {Capacitor} from "@capacitor/core"
|
import {Capacitor} from "@capacitor/core"
|
||||||
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||||
import Wallet from "@assets/icons/wallet.svg?dataurl"
|
import Wallet from "@assets/icons/wallet.svg?dataurl"
|
||||||
import Server from "@assets/icons/server.svg?dataurl"
|
import ServerPath from "@assets/icons/server-path.svg?dataurl"
|
||||||
|
import RemoteControlMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl"
|
||||||
import Moon from "@assets/icons/moon.svg?dataurl"
|
import Moon from "@assets/icons/moon.svg?dataurl"
|
||||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||||
import Exit from "@assets/icons/logout-3.svg?dataurl"
|
import Exit from "@assets/icons/logout-3.svg?dataurl"
|
||||||
|
|
@ -16,6 +17,7 @@
|
||||||
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
||||||
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
|
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
|
||||||
import LogOut from "@app/components/LogOut.svelte"
|
import LogOut from "@app/components/LogOut.svelte"
|
||||||
|
import {HOSTING_ENABLED} from "@app/hosting"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {theme} from "@app/theme"
|
import {theme} from "@app/theme"
|
||||||
|
|
||||||
|
|
@ -47,8 +49,13 @@
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
{/if}
|
{/if}
|
||||||
<SecondaryNavItem href="/settings/relays">
|
<SecondaryNavItem href="/settings/relays">
|
||||||
<Icon icon={Server} /> Relays
|
<Icon icon={RemoteControlMinimalistic} /> Relays
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
|
{#if HOSTING_ENABLED}
|
||||||
|
<SecondaryNavItem href="/settings/hosting">
|
||||||
|
<Icon icon={ServerPath} /> Hosting
|
||||||
|
</SecondaryNavItem>
|
||||||
|
{/if}
|
||||||
<SecondaryNavItem href="/settings/content">
|
<SecondaryNavItem href="/settings/content">
|
||||||
<Icon icon={GalleryMinimalistic} /> Content
|
<Icon icon={GalleryMinimalistic} /> Content
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
|
import ToggleInput from "@lib/components/ToggleInput.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
@ -72,7 +73,7 @@
|
||||||
<p>Show badge for unread alerts</p>
|
<p>Show badge for unread alerts</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<input type="checkbox" class="toggle" bind:checked={settings.badge} />
|
<ToggleInput bind:checked={settings.badge} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -83,7 +84,7 @@
|
||||||
<p>Play sound for new activity</p>
|
<p>Play sound for new activity</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<input type="checkbox" class="toggle" bind:checked={settings.sound} />
|
<ToggleInput bind:checked={settings.sound} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -92,7 +93,7 @@
|
||||||
<p>Enable push notifications</p>
|
<p>Enable push notifications</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<input type="checkbox" class="toggle" bind:checked={settings.push} />
|
<ToggleInput bind:checked={settings.push} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -106,7 +107,7 @@
|
||||||
<p>Notify me about new activity</p>
|
<p>Notify me about new activity</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<input type="checkbox" class="toggle" bind:checked={settings.spaces} />
|
<ToggleInput bind:checked={settings.spaces} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
<FieldInline>
|
<FieldInline>
|
||||||
|
|
@ -114,7 +115,7 @@
|
||||||
<p>Always notify me when mentioned</p>
|
<p>Always notify me when mentioned</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<input type="checkbox" class="toggle" checked={settings.mentions} />
|
<ToggleInput checked={settings.mentions} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
<FieldInline>
|
<FieldInline>
|
||||||
|
|
@ -122,7 +123,7 @@
|
||||||
<p>Notify me about new messages</p>
|
<p>Notify me about new messages</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<input type="checkbox" class="toggle" bind:checked={settings.messages} />
|
<ToggleInput bind:checked={settings.messages} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import Field from "@lib/components/Field.svelte"
|
import Field from "@lib/components/Field.svelte"
|
||||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
|
import ToggleInput from "@lib/components/ToggleInput.svelte"
|
||||||
import InputList from "@lib/components/InputList.svelte"
|
import InputList from "@lib/components/InputList.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Card from "@lib/components/Card.svelte"
|
import Card from "@lib/components/Card.svelte"
|
||||||
|
|
@ -68,7 +69,7 @@
|
||||||
<p>Hide sensitive content?</p>
|
<p>Hide sensitive content?</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<input type="checkbox" class="toggle" bind:checked={settings.hide_sensitive} />
|
<ToggleInput bind:checked={settings.hide_sensitive} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet info()}
|
{#snippet info()}
|
||||||
<p>
|
<p>
|
||||||
|
|
@ -81,7 +82,7 @@
|
||||||
<p>Show media?</p>
|
<p>Show media?</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<input type="checkbox" class="toggle" bind:checked={settings.show_media} />
|
<ToggleInput bind:checked={settings.show_media} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet info()}
|
{#snippet info()}
|
||||||
<p>Use this to disable link previews and image rendering.</p>
|
<p>Use this to disable link previews and image rendering.</p>
|
||||||
|
|
|
||||||
294
src/routes/settings/hosting/+page.svelte
Normal file
294
src/routes/settings/hosting/+page.svelte
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from "svelte"
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
|
import {indexBy} from "@welshman/lib"
|
||||||
|
import {pubkey} from "@welshman/app"
|
||||||
|
import {App} from "@capacitor/app"
|
||||||
|
import Add from "@assets/icons/add.svg?dataurl"
|
||||||
|
import Server from "@assets/icons/server.svg?dataurl"
|
||||||
|
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||||
|
import Card from "@assets/icons/card.svg?dataurl"
|
||||||
|
import Wallet from "@assets/icons/wallet.svg?dataurl"
|
||||||
|
import Bill from "@assets/icons/bill.svg?dataurl"
|
||||||
|
import DangerCircle from "@assets/icons/danger-circle.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Badge from "@lib/components/Badge.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
|
import {errorMessage} from "@lib/util"
|
||||||
|
import RelayListItem from "@app/components/hosting/RelayListItem.svelte"
|
||||||
|
import RelayCreate from "@app/components/hosting/RelayCreate.svelte"
|
||||||
|
import BillingPrompts from "@app/components/hosting/BillingPrompts.svelte"
|
||||||
|
import InvoiceListItem from "@app/components/hosting/InvoiceListItem.svelte"
|
||||||
|
import PaymentDialog from "@app/components/hosting/PaymentDialog.svelte"
|
||||||
|
import PaymentSetup from "@app/components/hosting/PaymentSetup.svelte"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
import {
|
||||||
|
autopayConfigured,
|
||||||
|
ensureSessionTenant,
|
||||||
|
getDraftInvoice,
|
||||||
|
getTenant,
|
||||||
|
listTenantInvoices,
|
||||||
|
listTenantRelays,
|
||||||
|
derivePlans,
|
||||||
|
reconcileTenant,
|
||||||
|
selectPayableInvoice,
|
||||||
|
type Invoice,
|
||||||
|
type Relay,
|
||||||
|
type Tenant,
|
||||||
|
} from "@app/hosting"
|
||||||
|
|
||||||
|
type PaymentMethodState = {kind: "not_set_up"} | {kind: "ok"} | {kind: "error"; message: string}
|
||||||
|
|
||||||
|
const INVOICE_PAGE_SIZE = 10
|
||||||
|
|
||||||
|
const plans = derivePlans()
|
||||||
|
|
||||||
|
let tenant = $state<Maybe<Tenant>>()
|
||||||
|
let invoices = $state<Invoice[]>([])
|
||||||
|
let relays = $state<Relay[]>([])
|
||||||
|
let draftInvoice = $state<Maybe<Invoice>>()
|
||||||
|
let loading = $state(true)
|
||||||
|
let error = $state("")
|
||||||
|
let showAllInvoices = $state(false)
|
||||||
|
|
||||||
|
const openInvoice = $derived(selectPayableInvoice(invoices))
|
||||||
|
|
||||||
|
// Whether any active relay is on a paid plan.
|
||||||
|
const hasPaidSubscription = $derived.by(() => {
|
||||||
|
const planById = indexBy(p => p.id, $plans)
|
||||||
|
return relays.some(relay => {
|
||||||
|
const plan = planById.get(relay.plan_id)
|
||||||
|
return Boolean(plan && plan.amount > 0 && relay.status === "active")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = $derived.by(() => {
|
||||||
|
if (!tenant) return "inactive"
|
||||||
|
if (tenant.churned_at) return "delinquent"
|
||||||
|
if (hasPaidSubscription || openInvoice || autopayConfigured(tenant)) return "active"
|
||||||
|
return "inactive"
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusVariant = $derived(
|
||||||
|
status === "active" ? "primary" : status === "delinquent" ? "warning" : "neutral",
|
||||||
|
)
|
||||||
|
|
||||||
|
const nwc = $derived.by<PaymentMethodState>(() => {
|
||||||
|
if (!tenant?.nwc_is_set) return {kind: "not_set_up"}
|
||||||
|
if (tenant.nwc_error) return {kind: "error", message: tenant.nwc_error}
|
||||||
|
return {kind: "ok"}
|
||||||
|
})
|
||||||
|
|
||||||
|
const card = $derived.by<PaymentMethodState>(() => {
|
||||||
|
if (!tenant?.stripe_payment_method_id) return {kind: "not_set_up"}
|
||||||
|
// Don't surface Stripe's raw decline text to the tenant.
|
||||||
|
if (tenant.stripe_error) return {kind: "error", message: "Payment failed"}
|
||||||
|
return {kind: "ok"}
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleInvoices = $derived(
|
||||||
|
showAllInvoices ? invoices : invoices.slice(0, INVOICE_PAGE_SIZE),
|
||||||
|
)
|
||||||
|
|
||||||
|
const refetch = async () => {
|
||||||
|
const pk = pubkey.get()
|
||||||
|
if (!pk) return
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
getTenant(pk),
|
||||||
|
listTenantInvoices(pk),
|
||||||
|
listTenantRelays(pk),
|
||||||
|
getDraftInvoice(pk),
|
||||||
|
])
|
||||||
|
|
||||||
|
const [t, inv, rel, draft] = results
|
||||||
|
|
||||||
|
if (t.status === "fulfilled") tenant = t.value
|
||||||
|
if (inv.status === "fulfilled") invoices = inv.value
|
||||||
|
if (rel.status === "fulfilled") relays = rel.value
|
||||||
|
if (draft.status === "fulfilled") draftInvoice = draft.value
|
||||||
|
|
||||||
|
if (results.some(r => r.status === "rejected")) {
|
||||||
|
pushToast({theme: "error", message: "Failed to refresh billing data"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard so the mount effect and the foreground listener don't race two reconciles.
|
||||||
|
let reconciling = false
|
||||||
|
|
||||||
|
// Pre-init failures render the error card instead of a toast over an empty page.
|
||||||
|
let initialized = false
|
||||||
|
|
||||||
|
// This page is the Stripe portal/checkout return target, so reconcile on landing
|
||||||
|
// to settle a completed checkout and pick up a portal-added card. Charging open
|
||||||
|
// invoices is left to the backend's dunning poll, which collects all of them.
|
||||||
|
const reconcile = async () => {
|
||||||
|
const pk = pubkey.get()
|
||||||
|
if (!pk || reconciling) return
|
||||||
|
|
||||||
|
reconciling = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureSessionTenant()
|
||||||
|
await reconcileTenant(pk)
|
||||||
|
} catch (e) {
|
||||||
|
const message = errorMessage(e) || "Failed to update billing"
|
||||||
|
|
||||||
|
if (initialized) {
|
||||||
|
pushToast({theme: "error", message})
|
||||||
|
} else {
|
||||||
|
error = message
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await refetch()
|
||||||
|
reconciling = false
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPayInvoice = (invoice: Invoice) =>
|
||||||
|
pushModal(PaymentDialog, {invoice, onPaid: () => void refetch()})
|
||||||
|
|
||||||
|
const openSetup = (initialTab: "nwc" | "card") =>
|
||||||
|
pushModal(PaymentSetup, {
|
||||||
|
initialTab,
|
||||||
|
nwcConnected: Boolean(tenant?.nwc_is_set),
|
||||||
|
onSaved: () => void refetch(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const openNwcSetup = () => openSetup("nwc")
|
||||||
|
|
||||||
|
const openCardSetup = () => openSetup("card")
|
||||||
|
|
||||||
|
const openCreateRelay = () => pushModal(RelayCreate)
|
||||||
|
|
||||||
|
const refresh = () => void refetch()
|
||||||
|
|
||||||
|
const toggleAllInvoices = () => {
|
||||||
|
showAllInvoices = !showAllInvoices
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
reconcile().finally(() => (loading = false))
|
||||||
|
|
||||||
|
// Re-run on foreground return (native browser round-trip) to pick up a
|
||||||
|
// completed checkout or a portal-added card.
|
||||||
|
const resumeListener = App.addListener("appStateChange", ({isActive}) => {
|
||||||
|
if (isActive) void reconcile()
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resumeListener.then(listener => listener.remove())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet methodRow(title: string, icon: string, state: PaymentMethodState, onAction: () => void)}
|
||||||
|
<li class="card card-sm flex items-center justify-between gap-3">
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<Icon {icon} size={5} class="text-content-muted" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="font-medium">{title}</p>
|
||||||
|
{#if state.kind === "ok"}
|
||||||
|
<p class="text-xs text-success">Connected</p>
|
||||||
|
{:else if state.kind === "error"}
|
||||||
|
<p class="text-xs text-error">{state.message}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-content-muted">Not set up</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button class="button button-neutral button-sm shrink-0" onclick={onAction}>
|
||||||
|
{state.kind === "not_set_up" ? "Set up" : "Update"}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<PageContent class="flex flex-col gap-4 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<strong class="flex items-center gap-3 text-lg">
|
||||||
|
<Icon icon={Server} />
|
||||||
|
Your Spaces
|
||||||
|
</strong>
|
||||||
|
<Button class="button button-primary button-sm" onclick={openCreateRelay}>
|
||||||
|
<Icon icon={Add} size={4} />
|
||||||
|
Create a Space
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex justify-center py-20">
|
||||||
|
<Spinner {loading}>Loading your spaces…</Spinner>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="card flex items-center gap-3 text-error">
|
||||||
|
<Icon icon={DangerCircle} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if tenant}
|
||||||
|
<BillingPrompts {tenant} {openInvoice} {hasPaidSubscription} onRefresh={refresh} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if relays.length === 0}
|
||||||
|
<div class="card flex flex-col items-center gap-1 py-12 text-center">
|
||||||
|
<p class="text-content opacity-75">You don't host any spaces yet.</p>
|
||||||
|
<p class="text-sm text-content-muted">Create a space to get started.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#each relays as relay (relay.id)}
|
||||||
|
<RelayListItem {relay} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="card flex flex-col gap-6">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<strong class="flex items-center gap-3 text-lg">
|
||||||
|
<Icon icon={Wallet} />
|
||||||
|
Payment Methods
|
||||||
|
</strong>
|
||||||
|
{#if tenant}
|
||||||
|
<Badge variant={statusVariant} class="badge-sm capitalize">
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<ul class="flex flex-col gap-3">
|
||||||
|
{@render methodRow("Lightning (NWC)", Bolt, nwc, openNwcSetup)}
|
||||||
|
{@render methodRow("Card", Card, card, openCardSetup)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card flex flex-col gap-6">
|
||||||
|
<strong class="flex items-center gap-3 text-lg">
|
||||||
|
<Icon icon={Bill} />
|
||||||
|
Payment History
|
||||||
|
</strong>
|
||||||
|
{#if invoices.length === 0 && !draftInvoice}
|
||||||
|
<p class="py-4 text-center text-sm text-content-muted">No invoices yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="flex flex-col gap-3">
|
||||||
|
{#if draftInvoice}
|
||||||
|
<InvoiceListItem invoice={draftInvoice} isDraft />
|
||||||
|
{/if}
|
||||||
|
{#each visibleInvoices as invoice (invoice.id)}
|
||||||
|
<InvoiceListItem {invoice} onPay={() => openPayInvoice(invoice)} />
|
||||||
|
{/each}
|
||||||
|
{#if invoices.length > INVOICE_PAGE_SIZE}
|
||||||
|
<li>
|
||||||
|
<Button
|
||||||
|
class="button button-link button-sm px-0 font-medium"
|
||||||
|
onclick={toggleAllInvoices}>
|
||||||
|
{showAllInvoices ? "Show less" : `Show all (${invoices.length})`}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</PageContent>
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import ShieldMinimalistic from "@assets/icons/shield-minimalistic.svg?dataurl"
|
import ShieldMinimalistic from "@assets/icons/shield-minimalistic.svg?dataurl"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
|
import ToggleInput from "@lib/components/ToggleInput.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Card from "@lib/components/Card.svelte"
|
import Card from "@lib/components/Card.svelte"
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
|
|
@ -14,10 +15,8 @@
|
||||||
settings = {...$userSettingsValues}
|
settings = {...$userSettingsValues}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onAuthModeChange = (e: Event) => {
|
const onAuthModeChange = (checked: boolean) => {
|
||||||
const target = e.currentTarget as HTMLInputElement
|
settings.relay_auth = checked ? RelayAuthMode.Aggressive : RelayAuthMode.Conservative
|
||||||
|
|
||||||
settings.relay_auth = target.checked ? RelayAuthMode.Aggressive : RelayAuthMode.Conservative
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onsubmit = preventDefault(async () => {
|
const onsubmit = preventDefault(async () => {
|
||||||
|
|
@ -41,11 +40,9 @@
|
||||||
<p>Authenticate with unknown relays?</p>
|
<p>Authenticate with unknown relays?</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<input
|
<ToggleInput
|
||||||
type="checkbox"
|
checked={settings.relay_auth === RelayAuthMode.Aggressive}
|
||||||
class="toggle"
|
onchange={onAuthModeChange} />
|
||||||
onchange={onAuthModeChange}
|
|
||||||
checked={settings.relay_auth === RelayAuthMode.Aggressive} />
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet info()}
|
{#snippet info()}
|
||||||
<p>Controls whether {PLATFORM_NAME} will identify you to relays not in your lists.</p>
|
<p>Controls whether {PLATFORM_NAME} will identify you to relays not in your lists.</p>
|
||||||
|
|
@ -56,7 +53,7 @@
|
||||||
<p>Report errors?</p>
|
<p>Report errors?</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<input type="checkbox" class="toggle" bind:checked={settings.report_errors} />
|
<ToggleInput bind:checked={settings.report_errors} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet info()}
|
{#snippet info()}
|
||||||
<p>Allow {PLATFORM_NAME} to send error reports to help improve the app.</p>
|
<p>Allow {PLATFORM_NAME} to send error reports to help improve the app.</p>
|
||||||
|
|
@ -67,7 +64,7 @@
|
||||||
<p>Report usage?</p>
|
<p>Report usage?</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<input type="checkbox" class="toggle" bind:checked={settings.report_usage} />
|
<ToggleInput bind:checked={settings.report_usage} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet info()}
|
{#snippet info()}
|
||||||
<p>Allow {PLATFORM_NAME} to collect anonymous usage data.</p>
|
<p>Allow {PLATFORM_NAME} to collect anonymous usage data.</p>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
<script module lang="ts">
|
<script module lang="ts">
|
||||||
const joinPrompted = new Set<string>()
|
const joinPrompted = new Set<string>()
|
||||||
|
const redirectPrompted = new Set<string>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
import {once} from "@welshman/lib"
|
import {once} from "@welshman/lib"
|
||||||
import Page from "@lib/components/Page.svelte"
|
import Page from "@lib/components/Page.svelte"
|
||||||
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
|
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
|
||||||
|
|
@ -12,11 +14,12 @@
|
||||||
import SpaceAuthError from "@app/components/SpaceAuthError.svelte"
|
import SpaceAuthError from "@app/components/SpaceAuthError.svelte"
|
||||||
import SpaceTrustRelay from "@app/components/SpaceTrustRelay.svelte"
|
import SpaceTrustRelay from "@app/components/SpaceTrustRelay.svelte"
|
||||||
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
||||||
|
import SpaceRedirect from "@app/components/SpaceRedirect.svelte"
|
||||||
import {deriveRelayAuthError} from "@app/access"
|
import {deriveRelayAuthError} from "@app/access"
|
||||||
import {loadUserGroupList, userSpaceUrls} from "@app/groups"
|
import {loadUserGroupList, userSpaceUrls} from "@app/groups"
|
||||||
import {modal, pushModal} from "@app/modal"
|
import {modal, pushModal} from "@app/modal"
|
||||||
import {relaysPendingTrust} from "@app/policies"
|
import {relaysPendingTrust} from "@app/policies"
|
||||||
import {decodeRelay} from "@app/relays"
|
import {decodeRelay, fetchRelayRedirect} from "@app/relays"
|
||||||
import {makeSpacePath} from "@app/routes"
|
import {makeSpacePath} from "@app/routes"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -35,15 +38,29 @@
|
||||||
|
|
||||||
const showPendingTrust = once(() => pushModal(SpaceTrustRelay, {url}, {noEscape: true}))
|
const showPendingTrust = once(() => pushModal(SpaceTrustRelay, {url}, {noEscape: true}))
|
||||||
|
|
||||||
|
// Detect a moved custom domain (see fetchRelayRedirect)
|
||||||
|
const checkRedirect = once(() => {
|
||||||
|
fetchRelayRedirect(url).then(next => {
|
||||||
|
redirectUrl = next
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Track this manually since we want to avoid race conditions in which we show this prompt before we load
|
// Track this manually since we want to avoid race conditions in which we show this prompt before we load
|
||||||
let spacesLoaded = $state(false)
|
let spacesLoaded = $state(false)
|
||||||
|
|
||||||
|
let redirectUrl = $state<Maybe<string>>()
|
||||||
|
|
||||||
|
$effect(checkRedirect)
|
||||||
|
|
||||||
// Watch for relay errors and notify the user
|
// Watch for relay errors and notify the user
|
||||||
// Direct links skip Discover — prompt to join when relay is not in the user's space list.
|
// Direct links skip Discover — prompt to join when relay is not in the user's space list.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($modal) return
|
if ($modal) return
|
||||||
|
|
||||||
if (!$userSpaceUrls.includes(url) && !joinPrompted.has(url)) {
|
if (redirectUrl && !redirectPrompted.has(url)) {
|
||||||
|
redirectPrompted.add(url)
|
||||||
|
pushModal(SpaceRedirect, {url, newUrl: redirectUrl})
|
||||||
|
} else if (!$userSpaceUrls.includes(url) && !joinPrompted.has(url)) {
|
||||||
if (spacesLoaded) {
|
if (spacesLoaded) {
|
||||||
joinPrompted.add(url)
|
joinPrompted.add(url)
|
||||||
pushModal(SpaceJoin, {url})
|
pushModal(SpaceJoin, {url})
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
import {deriveRelay} from "@welshman/app"
|
import {deriveRelay} from "@welshman/app"
|
||||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
|
||||||
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
|
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
|
||||||
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
|
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
|
||||||
import BillList from "@assets/icons/bill-list.svg?dataurl"
|
import BillList from "@assets/icons/bill-list.svg?dataurl"
|
||||||
|
|
@ -15,20 +14,13 @@
|
||||||
import RelayName from "@app/components/RelayName.svelte"
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
import RelayDescription from "@app/components/RelayDescription.svelte"
|
import RelayDescription from "@app/components/RelayDescription.svelte"
|
||||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
import SpaceEdit from "@app/components/SpaceEdit.svelte"
|
|
||||||
import SpaceMembersSummary from "@app/components/SpaceMembersSummary.svelte"
|
import SpaceMembersSummary from "@app/components/SpaceMembersSummary.svelte"
|
||||||
import SpaceFeaturedContent from "@app/components/SpaceFeaturedContent.svelte"
|
import SpaceFeaturedContent from "@app/components/SpaceFeaturedContent.svelte"
|
||||||
import {deriveUserIsSpaceAdmin} from "@app/members"
|
|
||||||
import {makeSpacePath} from "@app/routes"
|
import {makeSpacePath} from "@app/routes"
|
||||||
import {decodeRelay} from "@app/relays"
|
import {decodeRelay} from "@app/relays"
|
||||||
import {pushModal} from "@app/modal"
|
|
||||||
|
|
||||||
const url = decodeRelay($page.params.relay!)
|
const url = decodeRelay($page.params.relay!)
|
||||||
const relay = deriveRelay(url)
|
const relay = deriveRelay(url)
|
||||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
|
||||||
|
|
||||||
const startEdit = () => pushModal(SpaceEdit, {url, initialValues: $relay || {url}})
|
|
||||||
|
|
||||||
const showMenu = () => goto(makeSpacePath(url))
|
const showMenu = () => goto(makeSpacePath(url))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -105,12 +97,6 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $userIsAdmin}
|
|
||||||
<Button class="button button-primary" onclick={startEdit}>
|
|
||||||
<Icon icon={Pen} />
|
|
||||||
Edit Space
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
<div class="lg:col-span-2 flex flex-col gap-4">
|
<div class="lg:col-span-2 flex flex-col gap-4">
|
||||||
|
|
|
||||||
49
src/routes/spaces/[relay]/admin/+page.svelte
Normal file
49
src/routes/spaces/[relay]/admin/+page.svelte
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {derived} from "svelte/store"
|
||||||
|
import {page} from "$app/stores"
|
||||||
|
import {goto} from "$app/navigation"
|
||||||
|
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
|
||||||
|
import Server from "@assets/icons/server.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
|
import RelayDetailCard from "@app/components/hosting/RelayDetailCard.svelte"
|
||||||
|
import ActivityFeed from "@app/components/hosting/ActivityFeed.svelte"
|
||||||
|
import {deriveHostedRelay, deriveRelayActivity} from "@app/hosting"
|
||||||
|
import {decodeRelay} from "@app/relays"
|
||||||
|
import {makeSpacePath} from "@app/routes"
|
||||||
|
|
||||||
|
const url = decodeRelay($page.params.relay!)
|
||||||
|
const hostedRelay = deriveHostedRelay(url)
|
||||||
|
const relayActivity = deriveRelayActivity(derived(hostedRelay, $h => $h.relay?.id))
|
||||||
|
|
||||||
|
const showMenu = () => goto(makeSpacePath(url))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageContent class="flex flex-col gap-4 p-4">
|
||||||
|
<Button onclick={showMenu} class="button button-neutral md:hidden place-self-start">
|
||||||
|
<Icon icon={ArrowLeft} size={7} /> Go Back
|
||||||
|
</Button>
|
||||||
|
{#if $hostedRelay.relay}
|
||||||
|
<RelayDetailCard relay={$hostedRelay.relay} />
|
||||||
|
<ActivityFeed activity={$relayActivity.activity} loading={$relayActivity.loading} />
|
||||||
|
{:else if $hostedRelay.loading}
|
||||||
|
<div class="flex justify-center p-12">
|
||||||
|
<Spinner>Loading hosting details...</Spinner>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card flex flex-col items-center gap-4 p-8 text-center">
|
||||||
|
<Icon icon={Server} size={10} class="text-content-muted" />
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="text-lg font-bold">Not a Coracle-hosted space</h2>
|
||||||
|
<p class="text-sm text-content-muted">
|
||||||
|
This space isn't hosted by Coracle Hosting, or you don't administer it. Only the owner of
|
||||||
|
a hosted relay can manage its plan, policies, and billing here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link class="button button-neutral" href="/settings/hosting">Go to Hosting settings</Link>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</PageContent>
|
||||||
|
|
@ -3,11 +3,18 @@
|
||||||
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||||
import ArrowRight from "@assets/icons/arrow-right.svg?dataurl"
|
import ArrowRight from "@assets/icons/arrow-right.svg?dataurl"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Badge from "@lib/components/Badge.svelte"
|
import Badge from "@lib/components/Badge.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Page from "@lib/components/Page.svelte"
|
import Page from "@lib/components/Page.svelte"
|
||||||
import PageHeader from "@lib/components/PageHeader.svelte"
|
import PageHeader from "@lib/components/PageHeader.svelte"
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
|
import RelayCreate from "@app/components/hosting/RelayCreate.svelte"
|
||||||
|
import {PLATFORM_LOGO, PLATFORM_NAME, PLATFORM_URL} from "@app/env"
|
||||||
|
import {HOSTING_ENABLED} from "@app/hosting"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
|
const openCreate = () => pushModal(RelayCreate, {}, {size: 'large'})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
|
|
@ -24,9 +31,55 @@
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<div class="flex w-full max-w-lg flex-col gap-4 lg:max-w-4xl">
|
<div class="flex w-full max-w-lg flex-col gap-4 lg:max-w-4xl">
|
||||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-2">
|
<div class="grid grid-cols-1 gap-2 lg:grid-cols-2">
|
||||||
|
<div class="card flex flex-col gap-5 border" style="border-color: var(--primary)">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<img alt="{PLATFORM_NAME} Logo" src={PLATFORM_LOGO} class="h-10 w-10" />
|
||||||
|
<Badge variant="primary">Recommended</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-lg font-bold">
|
||||||
|
{PLATFORM_NAME} Hosting
|
||||||
|
</h3>
|
||||||
|
<div class="text-xs font-semibold tracking-wider opacity-60">FULLY MANAGED</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm opacity-70">
|
||||||
|
The premium experience. We handle the infrastructure, security updates, and scaling so
|
||||||
|
you can focus on your community.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul class="flex flex-col gap-2 text-sm">
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<Icon icon={CheckCircle} class="text-primary" />
|
||||||
|
One-click deployment
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<Icon icon={CheckCircle} class="text-primary" />
|
||||||
|
Automated backups & scaling
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<Icon icon={CheckCircle} class="text-primary" />
|
||||||
|
Priority support
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{#if HOSTING_ENABLED}
|
||||||
|
<Button class="button button-primary mt-auto" onclick={openCreate}>
|
||||||
|
Start for free
|
||||||
|
<Icon icon={ArrowRight} />
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Link
|
||||||
|
external
|
||||||
|
class="button button-primary mt-auto"
|
||||||
|
href="{PLATFORM_URL}/spaces/create">
|
||||||
|
Start for free
|
||||||
|
<Icon icon={ArrowRight} />
|
||||||
|
</Link>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<div class="card flex flex-col gap-5">
|
<div class="card flex flex-col gap-5">
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-md text-primary">
|
<div class="flex h-10 w-10 items-center justify-center text-primary">
|
||||||
<Icon icon={CloudCheck} size={10} />
|
<Icon icon={CloudCheck} size={10} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
|
|
@ -60,51 +113,14 @@
|
||||||
<Icon icon={ArrowRight} />
|
<Icon icon={ArrowRight} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div class="card flex flex-col gap-5 border" style="border-color: var(--primary)">
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<img alt="Coracle Logo" src="/coracle.png" class="h-10 w-10" />
|
|
||||||
<Badge variant="primary">Recommended</Badge>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-lg font-bold">Coracle Hosting</h3>
|
|
||||||
<div class="text-xs font-semibold tracking-wider opacity-60">FULLY MANAGED</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm opacity-70">
|
|
||||||
The premium experience. We handle the infrastructure, security updates, and scaling so
|
|
||||||
you can focus on your community.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ul class="flex flex-col gap-2 text-sm">
|
|
||||||
<li class="flex items-center gap-2">
|
|
||||||
<Icon icon={CheckCircle} class="text-primary" />
|
|
||||||
One-click deployment
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center gap-2">
|
|
||||||
<Icon icon={CheckCircle} class="text-primary" />
|
|
||||||
Automated backups & scaling
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center gap-2">
|
|
||||||
<Icon icon={CheckCircle} class="text-primary" />
|
|
||||||
Priority support
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<Link
|
|
||||||
external
|
|
||||||
class="button button-primary mt-auto"
|
|
||||||
href="https://hosting.coracle.social">
|
|
||||||
Start for free
|
|
||||||
<Icon icon={ArrowRight} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-center justify-center gap-2 py-2 text-sm opacity-70">
|
<div class="flex flex-col items-center justify-center gap-2 py-2 text-sm opacity-70">
|
||||||
<span>Want to host on other servers?</span>
|
<span>Looking for third party managed hosting?</span>
|
||||||
<Link
|
<Link
|
||||||
external
|
external
|
||||||
class="link flex justify-center items-center gap-1"
|
class="link flex justify-center items-center gap-1"
|
||||||
href="https://relay.tools/signup">
|
href="https://relay.tools/signup">
|
||||||
Other hosting options
|
Try relay.tools
|
||||||
<Icon icon={ArrowRight} />
|
<Icon icon={ArrowRight} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue