This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

web/settings: wire repo site config to the appview xrpc

Wires the per-repo sites page to org.tangled.temp.repo.{getSiteConfig,
updateSiteConfig,disableSite}, gated on the owner having a domain claim.

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>

author
Anirudh Oppiliappan
date (Aug 4, 2026, 5:09 PM +0300) commit 540a9c8c parent 69d176f5 change-id mkvswqvv
+247 -104
+44
web/src/lib/api/sites.ts
··· 31 31 domain: string, 32 32 init?: XrpcRequestInit 33 33 ): Promise<void> => authedPost(ctx, RELEASE_DOMAIN, { domain }, init).then(() => undefined); 34 + 35 + // --- per-repo static site config (org.tangled.temp.repo.*) --- 36 + 37 + // mirrors org.tangled.temp.repo.getSiteConfig#siteConfig. an index site is 38 + // served at the root of the owner's sites domain; otherwise under the repo name. 39 + export interface RepoSiteConfig { 40 + branch: string; 41 + dir: string; 42 + isIndex: boolean; 43 + } 44 + 45 + const GET_SITE_CONFIG = "org.tangled.temp.repo.getSiteConfig"; 46 + const UPDATE_SITE_CONFIG = "org.tangled.temp.repo.updateSiteConfig"; 47 + const DISABLE_SITE = "org.tangled.temp.repo.disableSite"; 48 + 49 + // returns the repo's site config, or null when no site is configured. 50 + export const getRepoSiteConfig = async ( 51 + ctx: AppviewContext, 52 + repoDid: string, 53 + init?: XrpcRequestInit 54 + ): Promise<RepoSiteConfig | null> => { 55 + const res = await authedGet<{ config?: RepoSiteConfig }>( 56 + ctx, 57 + GET_SITE_CONFIG, 58 + { repoDid }, 59 + init 60 + ); 61 + return res.config ?? null; 62 + }; 63 + 64 + // requires the owner to have an active sites domain claim. 65 + export const updateRepoSiteConfig = ( 66 + ctx: AppviewContext, 67 + repoDid: string, 68 + input: { branch: string; dir: string; isIndex?: boolean }, 69 + init?: XrpcRequestInit 70 + ): Promise<void> => 71 + authedPost(ctx, UPDATE_SITE_CONFIG, { repoDid, ...input }, init).then(() => undefined); 72 + 73 + export const disableRepoSite = ( 74 + ctx: AppviewContext, 75 + repoDid: string, 76 + init?: XrpcRequestInit 77 + ): Promise<void> => authedPost(ctx, DISABLE_SITE, { repoDid }, init).then(() => undefined);
+203 -104
web/src/routes/[handle]/[repo]/settings/sites/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { untrack } from "svelte"; 3 - import Button from "$lib/components/ui/Button.svelte"; 4 - import ButtonGroup from "$lib/components/ui/ButtonGroup.svelte"; 2 + import { resolve } from "$app/paths"; 3 + import { page } from "$app/state"; 4 + import { getAuth } from "$lib/auth.svelte"; 5 + import { createAppviewClient } from "$lib/api/appview"; 6 + import { ClientResponseError } from "$lib/api/client"; 7 + import { 8 + getDomainClaim, 9 + getRepoSiteConfig, 10 + updateRepoSiteConfig, 11 + disableRepoSite, 12 + type RepoSiteConfig 13 + } from "$lib/api/sites"; 14 + import { createLoad, createAction } from "$lib/action.svelte"; 15 + import Button, { button } from "$lib/components/ui/Button.svelte"; 5 16 import Input from "$lib/components/ui/Input.svelte"; 6 17 import Radio from "$lib/components/ui/Radio.svelte"; 7 18 import Select from "$lib/components/ui/Select.svelte"; 8 19 import Toggle from "$lib/components/ui/Toggle.svelte"; 9 20 import Tag from "$lib/components/ui/Tag.svelte"; 21 + import Skeleton from "$lib/components/ui/Skeleton.svelte"; 22 + import ErrorAlert from "$lib/components/ui/Error.svelte"; 10 23 import SettingsEmpty from "$lib/components/settings/SettingsEmpty.svelte"; 11 24 import SettingsSection from "$lib/components/settings/SettingsSection.svelte"; 12 25 import SettingsList from "$lib/components/settings/SettingsList.svelte"; ··· 15 28 import CodeChip from "$lib/components/settings/CodeChip.svelte"; 16 29 import Check from "$icon/check"; 17 30 import ExternalLink from "$icon/external-link"; 18 - import Settings2 from "$icon/settings-2"; 19 31 20 32 let { data } = $props(); 21 33 22 - const domain = "user.tngl.sh"; 23 - const subPath = $derived(`${domain}/${data.repo.name}`); 34 + const auth = getAuth(); 35 + const apiUrl = $derived(page.data.publicConfig?.apiUrl as string | undefined); 36 + const repoDid = $derived(data.repo.repoDid); 37 + 38 + const errMessage = (cause: unknown, fallback: string): string => { 39 + if (cause instanceof ClientResponseError) return cause.description || cause.error || fallback; 40 + return cause instanceof Error ? cause.message : fallback; 41 + }; 42 + 43 + // load the repo's site config alongside the owner's domain claim: a claim is 44 + // required before a site can be configured, and it forms the live URL. 45 + type SiteState = { config: RepoSiteConfig | null; domain: string | null }; 46 + const loaded = createLoad(async (): Promise<SiteState> => { 47 + const agent = auth.agent; 48 + const url = apiUrl; 49 + const did = repoDid; 50 + if (!agent || !url || !did) return { config: null, domain: null }; 51 + const ctx = createAppviewClient({ apiUrl: url, agent }); 52 + const [config, domain] = await Promise.all([ 53 + getRepoSiteConfig(ctx, did), 54 + getDomainClaim(ctx) 55 + ]); 56 + return { config, domain }; 57 + }, (cause) => errMessage(cause, "Failed to load site configuration.")); 24 58 25 - // mocked — sites config is backed by G7 and has no write path here. Rendering 26 - // the deployed state so the live-status pair and Disable row are visible; an 27 - // undeployed repo drops both, as in the first Figma frame. 28 - const deployed = true; 59 + const domain = $derived(loaded.data?.domain ?? null); 60 + const config = $derived(loaded.data?.config ?? null); 61 + const deployed = $derived(config !== null); 29 62 30 - const initial = { 31 - branch: untrack(() => data.repo.defaultBranch), 32 - directory: "/", 33 - type: "index" as "index" | "subpath", 34 - disabled: false 63 + // the live URL reflects the saved config, not the in-progress form 64 + const liveLabel = $derived( 65 + domain && config ? (config.isIndex ? domain : `${domain}/${data.repo.name}`) : "" 66 + ); 67 + const liveUrl = $derived(liveLabel ? `https://${liveLabel}` : ""); 68 + 69 + type SiteForm = { branch: string; directory: string; type: "index" | "subpath" }; 70 + const snapshot = (c: RepoSiteConfig | null): SiteForm => 71 + c 72 + ? { branch: c.branch, directory: c.dir, type: c.isIndex ? "index" : "subpath" } 73 + : { branch: data.repo.defaultBranch, directory: "/", type: "index" }; 74 + 75 + let form = $state<SiteForm>(snapshot(null)); 76 + let baseline = $state(JSON.stringify(snapshot(null))); 77 + // resync the form whenever server state (re)loads, discarding stale edits 78 + $effect(() => { 79 + const next = snapshot(loaded.data?.config ?? null); 80 + form = next; 81 + baseline = JSON.stringify(next); 82 + }); 83 + 84 + const dirty = $derived(JSON.stringify(form) !== baseline); 85 + const reset = () => (form = JSON.parse(baseline)); 86 + 87 + // no branch-list endpoint is wired to settings yet; offer the default and any 88 + // currently configured branch, matching the sibling settings pages. 89 + const branchOptions = $derived.by(() => { 90 + const names = new Set<string>([data.repo.defaultBranch]); 91 + if (config?.branch) names.add(config.branch); 92 + if (form.branch) names.add(form.branch); 93 + return [...names].map((b) => ({ 94 + value: b, 95 + label: b === data.repo.defaultBranch ? `${b} (default)` : b 96 + })); 97 + }); 98 + 99 + const subPath = $derived(domain ? `${domain}/${data.repo.name}` : ""); 100 + 101 + const requireClient = () => { 102 + const agent = auth.agent; 103 + const url = apiUrl; 104 + const did = repoDid; 105 + if (!agent || !url || !did) throw new Error("You must own this repository to configure sites."); 106 + return { ctx: createAppviewClient({ apiUrl: url, agent }), did }; 35 107 }; 36 - let form = $state({ ...initial }); 37 - const dirty = $derived(JSON.stringify(form) !== JSON.stringify(initial)); 38 - const reset = () => (form = { ...initial }); 108 + 109 + const save = createAction(async () => { 110 + const { ctx, did } = requireClient(); 111 + await updateRepoSiteConfig(ctx, did, { 112 + branch: form.branch, 113 + dir: form.directory.trim() || "/", 114 + isIndex: form.type === "index" 115 + }); 116 + await loaded.run(); 117 + }, (cause) => errMessage(cause, "Failed to save site configuration.")); 118 + 119 + const disable = createAction(async () => { 120 + const { ctx, did } = requireClient(); 121 + await disableRepoSite(ctx, did); 122 + await loaded.run(); 123 + }, (cause) => errMessage(cause, "Failed to disable site.")); 39 124 40 - // the "(default)" marker is display-only; the value stays the plain branch name 41 - const branches = $derived([ 42 - { value: data.repo.defaultBranch, label: `${data.repo.defaultBranch} (default)` }, 43 - { value: "develop", label: "develop" } 44 - ]); 125 + const confirmDisable = () => { 126 + if (disable.loading) return; 127 + if (!confirm("Disable this repository's site? It will no longer be served.")) return; 128 + void disable.run(); 129 + }; 45 130 46 - const deploys = [ 47 - { branch: "main", status: "Success", reason: "Config change", when: "5 hours ago" } 48 - ]; 131 + const actionError = $derived(save.error ?? disable.error); 132 + const userSitesHref = resolve("/settings/sites" as "/"); 49 133 </script> 50 134 51 135 <div class="flex min-h-9 w-full items-center justify-between gap-4"> 52 136 <h1 class="typography-heading-2 text-foreground-default">Sites</h1> 53 - <SettingsSaveBar {dirty} onreset={reset} /> 137 + {#if !loaded.loading && domain} 138 + <SettingsSaveBar {dirty} onreset={reset} onsave={save.run} /> 139 + {/if} 54 140 </div> 55 141 56 142 <div class="flex w-full flex-col gap-4"> 57 143 <p class="typography-paragraph-regular text-foreground-muted"> 58 144 Serve a static site directly from this repository. Choose a branch and the directory containing 59 - your <code class="font-mono typography-monospace-regular">index.html</code>.<br /> 145 + your <code class="typography-monospace-regular font-mono">index.html</code>.<br /> 60 146 Only repository owners can configure sites. 61 147 </p> 62 148 63 - {#if deployed} 64 - <div class="flex gap-2"> 65 - <Tag color="success" icon={Check}>Live at {domain}</Tag> 66 - <Button icon={ExternalLink}>Open</Button> 67 - </div> 149 + {#if loaded.error} 150 + <ErrorAlert label={loaded.error} /> 151 + {/if} 152 + {#if actionError} 153 + <ErrorAlert label={actionError} /> 68 154 {/if} 69 - </div> 70 155 71 - <SettingsList> 72 - <SettingsRow title="Branch" description="The branch to build and deploy the site from." stack> 73 - <Select 74 - rich 75 - bind:value={form.branch} 76 - options={branches} 77 - label="Branch" 78 - searchPlaceholder="Find a branch…" 79 - emptyLabel="No branch matches" 80 - class="w-full sm:w-80" 156 + {#if loaded.loading} 157 + <SettingsList> 158 + {#each Array.from({ length: 3 }) as _, i (i)} 159 + <div class="flex w-full flex-col gap-2"> 160 + <Skeleton class="h-4 w-32 rounded" /> 161 + <Skeleton class="h-9 w-full max-w-80 rounded" /> 162 + </div> 163 + {/each} 164 + </SettingsList> 165 + {:else if !domain} 166 + <SettingsEmpty 167 + message="Claim a sites domain in your account settings before configuring repository sites." 81 168 /> 82 - </SettingsRow> 83 - 84 - <SettingsRow 85 - title="Deploy directory" 86 - description={[ 87 - "Path within the repository that contains your index.html.", 88 - "Use / for the root, or a subdirectory like /docs." 89 - ]} 90 - stack 91 - > 92 - <Input bind:value={form.directory} placeholder="/" class="w-full sm:w-80" /> 93 - </SettingsRow> 94 - 95 - <SettingsRow 96 - title="Type" 97 - description={[ 98 - "An index site is served at the root of your sites domain.", 99 - "A sub-path site is served under the repository name." 100 - ]} 101 - align="start" 102 - stack 103 - > 104 - <div class="flex w-full flex-col gap-2 sm:w-80"> 105 - <div class="flex flex-col gap-1"> 106 - <Radio value="index" bind:group={form.type} name="site-type">Index site</Radio> 107 - <CodeChip class="w-fit">{domain}</CodeChip> 169 + <div> 170 + <Button icon={ExternalLink} href={userSitesHref}>Sites settings</Button> 171 + </div> 172 + {:else} 173 + {#if deployed} 174 + <div class="flex gap-2"> 175 + <Tag color="success" icon={Check}>Live at {liveLabel}</Tag> 176 + <!-- plain anchor: the live site is outside the app, so it can't be a Button href --> 177 + <a href={liveUrl} rel="external noopener noreferrer" class={button()}> 178 + <span class="inline-flex min-w-0 items-center justify-center gap-[inherit]"> 179 + <ExternalLink class="size-4 shrink-0" aria-hidden="true" /> 180 + Open 181 + </span> 182 + </a> 108 183 </div> 109 - <div class="flex flex-col gap-1"> 110 - <Radio value="subpath" bind:group={form.type} name="site-type">Sub-path site</Radio> 111 - <CodeChip class="w-fit">{subPath}</CodeChip> 112 - </div> 113 - </div> 114 - </SettingsRow> 184 + {/if} 115 185 116 - {#if deployed} 117 - <SettingsRow 118 - title="Disable site" 119 - description="Disables the site for this repository. The site will no longer be served." 120 - > 121 - <Toggle bind:checked={form.disabled} aria-label="Disable site" /> 122 - </SettingsRow> 123 - {/if} 124 - </SettingsList> 186 + <SettingsList> 187 + <SettingsRow title="Branch" description="The branch to build and deploy the site from." stack> 188 + <Select 189 + rich 190 + bind:value={form.branch} 191 + options={branchOptions} 192 + label="Branch" 193 + searchPlaceholder="Find a branch…" 194 + emptyLabel="No branch matches" 195 + class="w-full sm:w-80" 196 + /> 197 + </SettingsRow> 125 198 126 - <hr class="w-full border-0 border-t border-border-default" /> 199 + <SettingsRow 200 + title="Deploy directory" 201 + description={[ 202 + "Path within the repository that contains your index.html.", 203 + "Use / for the root, or a subdirectory like /docs." 204 + ]} 205 + stack 206 + > 207 + <Input bind:value={form.directory} placeholder="/" class="w-full sm:w-80" /> 208 + </SettingsRow> 127 209 128 - <SettingsSection title="Recent deploys" headingGap="4" framed={deploys.length > 0}> 129 - {#if deploys.length === 0} 130 - <SettingsEmpty message="No deploys yet." /> 131 - {:else} 132 - {#each deploys as deploy (deploy.branch)} 133 - <SettingsRow> 134 - {#snippet label()} 135 - <span class="flex min-w-0 items-center gap-2"> 136 - <span class="font-mono typography-monospace-regular text-foreground-default"> 137 - {deploy.branch} 138 - </span> 139 - <Tag color="success" size="sm" icon={Check}>{deploy.status}</Tag> 140 - <Tag color="gray" size="sm" icon={Settings2}>{deploy.reason}</Tag> 141 - </span> 142 - {/snippet} 143 - <span class="typography-paragraph-regular text-foreground-subtle">{deploy.when}</span> 210 + <SettingsRow 211 + title="Type" 212 + description={[ 213 + "An index site is served at the root of your sites domain.", 214 + "A sub-path site is served under the repository name." 215 + ]} 216 + align="start" 217 + stack 218 + > 219 + <div class="flex w-full flex-col gap-2 sm:w-80"> 220 + <div class="flex flex-col gap-1"> 221 + <Radio value="index" bind:group={form.type} name="site-type">Index site</Radio> 222 + <CodeChip class="w-fit">{domain}</CodeChip> 223 + </div> 224 + <div class="flex flex-col gap-1"> 225 + <Radio value="subpath" bind:group={form.type} name="site-type">Sub-path site</Radio> 226 + <CodeChip class="w-fit">{subPath}</CodeChip> 227 + </div> 228 + </div> 144 229 </SettingsRow> 145 - {/each} 230 + 231 + {#if deployed} 232 + <SettingsRow 233 + title="Disable site" 234 + description="Disables the site for this repository. The site will no longer be served." 235 + > 236 + <Toggle 237 + checked={false} 238 + disabled={disable.loading} 239 + onchange={confirmDisable} 240 + aria-label="Disable site" 241 + /> 242 + </SettingsRow> 243 + {/if} 244 + </SettingsList> 146 245 {/if} 147 - </SettingsSection> 246 + </div>