This repository has no description
0

Configure Feed

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

web/settings: add the repository settings screens from the redesign

Completes the desktop half of the Settings Redesign Figma
(TFhrx7FBh2Ue5SGH8CUeBU) that the user-settings pass left out: General,
Access, Pipelines, Hooks and Sites, plus the rename, custom-label,
collaborator, secret and webhook drill-downs.

There was no repository settings surface in the rewrite at all, so this
adds the route tree under [handle]/[repo]/settings and a Settings tab on
RepoTabs. The tab is unconditional for now -- gating it needs the
viewer's push access, which the repo layout load doesn't resolve yet.

The sidebar card, drill-down shell and row primitives are reused from
the user settings. Three of them grew to cover patterns that only appear
on the repo pages: SettingsList's gap is now named in spacing units
because the designs use all of 8/12/16px, SettingsRow takes a muted
description, and SettingsBlock is the new title+description+action
header that Access and Pipelines put above each card. Multi-line copy is
passed as an array of lines rather than a string with escapes, which
reads better and keeps the templates free of useless mustaches.

SettingsEmpty replaces EmptyState inside settings: Figma's empty state
there is a single 52px row, not the tall italic box the rest of the app
uses. SettingsSaveBar factors out the Reset / "Unsaved changes" / Save
trio now that General, Sites and Profile all carry it.

Verified light and dark against the Figma renders; navigation, card and
surface tokens plus nav width and heading size all match.

Refs TAN-575.

Signed-off-by: eti <eti@eti.tf>

author
eti
committer
dawn
date (Jul 31, 2026, 10:57 PM +0300) commit 45e0e3d5 parent 3365804b change-id qyvovruv
+1155 -53
+5 -1
web/src/lib/components/repo/RepoTabs.svelte
··· 3 3 import CircleDot from "$icon/circle-dot"; 4 4 import GitPullRequest from "$icon/git-pull-request"; 5 5 import Layers2 from "$icon/layers-2"; 6 + import Settings from "$icon/settings"; 6 7 import Tabs, { type TabDef } from "$lib/components/ui/Tabs.svelte"; 7 8 import type { RepoCounts, RepoInfo } from "./types"; 8 9 ··· 32 33 count: counts.pulls, 33 34 href: `${base}/pulls` 34 35 }, 35 - { id: "pipelines", label: "Pipelines", icon: Layers2, href: `${base}/pipelines` } 36 + { id: "pipelines", label: "Pipelines", icon: Layers2, href: `${base}/pipelines` }, 37 + // todo: only show this to collaborators — needs the viewer's push access on 38 + // RepoInfo, which the layout load doesn't resolve yet 39 + { id: "settings", label: "Settings", icon: Settings, href: `${base}/settings` } 36 40 ]); 37 41 </script> 38 42
+9 -4
web/src/lib/components/settings/DrillDown.svelte
··· 8 8 backLabel: string; 9 9 backHref: string; 10 10 title: string; 11 - description?: string; 11 + /** pass an array to get one line each */ 12 + description?: string | string[]; 12 13 children: Snippet; 13 14 } 14 15 15 16 let { backLabel, backHref, title, description, children }: Props = $props(); 17 + 18 + const lines = $derived( 19 + description === undefined ? [] : Array.isArray(description) ? description : [description] 20 + ); 16 21 </script> 17 22 18 23 <!-- Drill-downs are in-place content swaps: the sidebar stays put and the parent ··· 30 35 <div class="flex w-full flex-col gap-4"> 31 36 <div class="flex flex-col gap-1"> 32 37 <h1 class="typography-paragraph-large text-foreground-default">{title}</h1> 33 - {#if description} 34 - <p class="typography-paragraph-regular text-foreground-muted">{description}</p> 35 - {/if} 38 + {#each lines as line (line)} 39 + <p class="typography-paragraph-regular text-foreground-muted">{line}</p> 40 + {/each} 36 41 </div> 37 42 {@render children()} 38 43 </div>
+32 -8
web/src/lib/components/settings/FormRow.svelte
··· 3 3 4 4 interface Props { 5 5 label: string; 6 - /** helper line under the label, e.g. "Confirm your identity to proceed." */ 7 - description?: string; 6 + /** renders a muted "(optional)" after the label, as on the webhook secret */ 7 + optional?: boolean; 8 + /** helper copy under the label; pass an array to get one line each */ 9 + description?: string | string[]; 8 10 for?: string; 11 + /** top-align the control when it is taller than the label, e.g. a checkbox list */ 12 + align?: "center" | "start"; 9 13 /** the control — Figma sizes these to 320px on the right of the row */ 10 14 children: Snippet; 11 15 } 12 16 13 - let { label, description, for: forId, children }: Props = $props(); 17 + let { 18 + label, 19 + optional = false, 20 + description, 21 + for: forId, 22 + align = "center", 23 + children 24 + }: Props = $props(); 25 + 26 + const lines = $derived( 27 + description === undefined ? [] : Array.isArray(description) ? description : [description] 28 + ); 14 29 </script> 15 30 16 - <div class="flex min-h-8 w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> 31 + <div 32 + class="flex min-h-8 w-full flex-col gap-2 sm:flex-row sm:justify-between {align === 'start' 33 + ? 'sm:items-start' 34 + : 'sm:items-center'}" 35 + > 17 36 <div class="flex min-w-0 flex-col"> 18 - <label class="typography-paragraph-regular text-foreground-default" for={forId}>{label}</label> 19 - {#if description} 20 - <span class="typography-paragraph-regular text-foreground-muted">{description}</span> 21 - {/if} 37 + <label class="typography-paragraph-regular text-foreground-default" for={forId}> 38 + {label} 39 + {#if optional} 40 + <span class="text-foreground-muted">(optional)</span> 41 + {/if} 42 + </label> 43 + {#each lines as line (line)} 44 + <span class="typography-paragraph-regular text-foreground-muted">{line}</span> 45 + {/each} 22 46 </div> 23 47 <div class="w-full shrink-0 sm:w-80">{@render children()}</div> 24 48 </div>
+60
web/src/lib/components/settings/SettingsBlock.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from "svelte"; 3 + 4 + interface Props { 5 + /** Figma uses 16px on General's label blocks and 14px on Access/Pipelines */ 6 + title?: string; 7 + titleSize?: "regular" | "large"; 8 + /** muted copy under the title; pass an array to get one line each */ 9 + description?: string | string[]; 10 + /** trailing action, bottom-aligned against the description */ 11 + action?: Snippet; 12 + /** the hairline between the header and the content */ 13 + separator?: boolean; 14 + children?: Snippet; 15 + } 16 + 17 + let { 18 + title, 19 + titleSize = "regular", 20 + description, 21 + action, 22 + separator = false, 23 + children 24 + }: Props = $props(); 25 + 26 + const lines = $derived( 27 + description === undefined ? [] : Array.isArray(description) ? description : [description] 28 + ); 29 + </script> 30 + 31 + <div class="flex w-full flex-col gap-4"> 32 + {#if title || lines.length > 0 || action} 33 + <!-- the action sits on the last line of the description, not its middle --> 34 + <div class="flex w-full items-end justify-between gap-16"> 35 + <div class="flex min-w-0 flex-col"> 36 + {#if title} 37 + <span 38 + class="{titleSize === 'large' 39 + ? 'typography-paragraph-large' 40 + : 'typography-paragraph-regular'} text-foreground-default" 41 + > 42 + {title} 43 + </span> 44 + {/if} 45 + {#each lines as line (line)} 46 + <p class="typography-paragraph-regular text-foreground-muted">{line}</p> 47 + {/each} 48 + </div> 49 + {#if action} 50 + <div class="flex shrink-0 items-center gap-2">{@render action()}</div> 51 + {/if} 52 + </div> 53 + {/if} 54 + {#if separator} 55 + <hr class="w-full border-0 border-t border-border-default" /> 56 + {/if} 57 + {#if children} 58 + {@render children()} 59 + {/if} 60 + </div>
+15
web/src/lib/components/settings/SettingsEmpty.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + message: string; 4 + } 5 + 6 + let { message }: Props = $props(); 7 + </script> 8 + 9 + <!-- the settings screens use a single-row card rather than the tall, italic 10 + EmptyState the rest of the app uses --> 11 + <div 12 + class="flex w-full items-center justify-center rounded-sm border border-border-default px-4 py-3 typography-paragraph-regular text-foreground-muted" 13 + > 14 + {message} 15 + </div>
+7 -5
web/src/lib/components/settings/SettingsList.svelte
··· 6 6 variants: { 7 7 // Figma spaces rows with a gap and drops the hairline in the middle of it, 8 8 // so the divider is split evenly across the two rows it separates rather 9 - // than sitting flush against one of them. 9 + // than sitting flush against one of them. Named in Tailwind spacing units 10 + // because the designs use all three of 8 / 12 / 16px. 10 11 gap: { 11 - md: "[&>*+*]:mt-2 [&>*+*]:border-t [&>*+*]:border-border-default [&>*+*]:pt-2", 12 - sm: "[&>*+*]:mt-1 [&>*+*]:border-t [&>*+*]:border-border-default [&>*+*]:pt-1" 12 + "2": "[&>*+*]:mt-1 [&>*+*]:border-t [&>*+*]:border-border-default [&>*+*]:pt-1", 13 + "3": "[&>*+*]:mt-1.5 [&>*+*]:border-t [&>*+*]:border-border-default [&>*+*]:pt-1.5", 14 + "4": "[&>*+*]:mt-2 [&>*+*]:border-t [&>*+*]:border-border-default [&>*+*]:pt-2" 13 15 }, 14 16 padding: { 15 17 default: "px-4 py-3", ··· 17 19 } 18 20 }, 19 21 defaultVariants: { 20 - gap: "md", 22 + gap: "4", 21 23 padding: "default" 22 24 } 23 25 }); ··· 35 37 children: Snippet; 36 38 } 37 39 38 - let { gap = "md", padding = "default", class: className, children }: Props = $props(); 40 + let { gap = "4", padding = "default", class: className, children }: Props = $props(); 39 41 40 42 const classes = $derived(settingsList({ gap, padding, class: className })); 41 43 </script>
+31 -7
web/src/lib/components/settings/SettingsRow.svelte
··· 5 5 interface Props { 6 6 /** plain-text label — omit and render whatever you need through `label` */ 7 7 title?: string; 8 + /** muted explanation under the title; pass an array to get one line each */ 9 + description?: string | string[]; 8 10 icon?: Component<SvelteHTMLElements["svg"]>; 9 11 /** replaces `title`/`icon` when the leading side is more than a label */ 10 12 label?: Snippet; 11 13 /** trailing controls: buttons, toggles, code chips */ 12 14 children?: Snippet; 15 + /** top-align the control instead of centring it, for tall right-hand content */ 16 + align?: "center" | "start"; 13 17 class?: string; 14 18 } 15 19 16 - let { title, icon, label, children, class: className }: Props = $props(); 20 + let { 21 + title, 22 + description, 23 + icon, 24 + label, 25 + children, 26 + align = "center", 27 + class: className 28 + }: Props = $props(); 17 29 18 30 const Glyph = $derived(icon); 31 + const lines = $derived( 32 + description === undefined ? [] : Array.isArray(description) ? description : [description] 33 + ); 19 34 </script> 20 35 21 - <div class="flex min-h-8 w-full items-center justify-between gap-4 {className ?? ''}"> 36 + <div 37 + class="flex min-h-8 w-full justify-between gap-4 {align === 'start' 38 + ? 'items-start' 39 + : 'items-center'} {className ?? ''}" 40 + > 22 41 {#if label} 23 42 {@render label()} 24 43 {:else} 25 - <span class="flex min-w-0 items-center gap-2"> 26 - {#if Glyph} 27 - <Glyph class="size-3.5 shrink-0 text-foreground-default" aria-hidden="true" /> 28 - {/if} 29 - <span class="typography-paragraph-regular text-foreground-default">{title}</span> 44 + <span class="flex min-w-0 flex-col"> 45 + <span class="flex items-center gap-2"> 46 + {#if Glyph} 47 + <Glyph class="size-3.5 shrink-0 text-foreground-default" aria-hidden="true" /> 48 + {/if} 49 + <span class="typography-paragraph-regular text-foreground-default">{title}</span> 50 + </span> 51 + {#each lines as line (line)} 52 + <span class="typography-paragraph-regular text-foreground-muted">{line}</span> 53 + {/each} 30 54 </span> 31 55 {/if} 32 56 {#if children}
+23
web/src/lib/components/settings/SettingsSaveBar.svelte
··· 1 + <script lang="ts"> 2 + import Button from "$lib/components/ui/Button.svelte"; 3 + import Save from "$icon/save"; 4 + import RotateCcw from "$icon/rotate-ccw"; 5 + 6 + interface Props { 7 + dirty: boolean; 8 + onreset?: () => void; 9 + onsave?: () => void; 10 + } 11 + 12 + let { dirty, onreset, onsave }: Props = $props(); 13 + </script> 14 + 15 + <!-- Figma keeps Save disabled until something changed, and only then reveals Reset 16 + and the "Unsaved changes" note beside it --> 17 + <div class="flex shrink-0 items-center gap-4"> 18 + {#if dirty} 19 + <Button icon={RotateCcw} onclick={onreset}>Reset</Button> 20 + <span class="typography-paragraph-regular text-foreground-muted">Unsaved changes</span> 21 + {/if} 22 + <Button variant="primary" icon={Save} disabled={!dirty} onclick={onsave}>Save</Button> 23 + </div>
+11 -4
web/src/lib/components/settings/SettingsSection.svelte
··· 1 1 <script lang="ts"> 2 2 import type { Snippet } from "svelte"; 3 3 import SettingsList from "./SettingsList.svelte"; 4 + import type { SettingsListVariants } from "./SettingsList.svelte"; 4 5 5 6 interface Props { 6 7 /** omit for the leading card, which Figma leaves unlabelled */ 7 8 title?: string; 8 - /** set false when the children provide their own container */ 9 + /** gap between the heading and its card — 12px on the user pages, 16 on repo */ 10 + headingGap?: "3" | "4"; 11 + /** row spacing inside the framed card */ 12 + rowGap?: SettingsListVariants["gap"]; 13 + /** set false when the children provide their own container(s) */ 9 14 framed?: boolean; 10 15 children: Snippet; 11 16 } 12 17 13 - let { title, framed = true, children }: Props = $props(); 18 + let { title, headingGap = "3", rowGap = "2", framed = true, children }: Props = $props(); 19 + 20 + const gapClass = $derived(headingGap === "4" ? "gap-4" : "gap-3"); 14 21 </script> 15 22 16 - <section class="flex w-full flex-col gap-3"> 23 + <section class="flex w-full flex-col {gapClass}"> 17 24 {#if title} 18 25 <h2 class="typography-heading-4 text-foreground-default">{title}</h2> 19 26 {/if} 20 27 {#if framed} 21 - <SettingsList gap="sm"> 28 + <SettingsList gap={rowGap}> 22 29 {@render children()} 23 30 </SettingsList> 24 31 {:else}
+2 -2
web/src/lib/components/settings/tabs/EmailsTab.svelte
··· 1 1 <script lang="ts"> 2 2 import Button from "$lib/components/ui/Button.svelte"; 3 3 import Tag from "$lib/components/ui/Tag.svelte"; 4 - import EmptyState from "$lib/components/ui/EmptyState.svelte"; 4 + import SettingsEmpty from "../SettingsEmpty.svelte"; 5 5 import SettingsHeader from "../SettingsHeader.svelte"; 6 6 import SettingsToolbar from "../SettingsToolbar.svelte"; 7 7 import SettingsList from "../SettingsList.svelte"; ··· 60 60 </SettingsToolbar> 61 61 62 62 {#if emails.length === 0} 63 - <EmptyState message="No emails added yet" /> 63 + <SettingsEmpty message="No emails added yet" /> 64 64 {:else} 65 65 <SettingsList> 66 66 {#each emails as email (email.id)}
+2 -2
web/src/lib/components/settings/tabs/KeysTab.svelte
··· 10 10 import Button from "$lib/components/ui/Button.svelte"; 11 11 import Spinner from "$lib/components/ui/Spinner.svelte"; 12 12 import ErrorAlert from "$lib/components/ui/Error.svelte"; 13 - import EmptyState from "$lib/components/ui/EmptyState.svelte"; 13 + import SettingsEmpty from "../SettingsEmpty.svelte"; 14 14 import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; 15 15 import SettingsHeader from "../SettingsHeader.svelte"; 16 16 import SettingsToolbar from "../SettingsToolbar.svelte"; ··· 107 107 <Spinner /> Loading… 108 108 </div> 109 109 {:else if keys.length === 0} 110 - <EmptyState message="No keys added yet" /> 110 + <SettingsEmpty message="No keys added yet" /> 111 111 {:else} 112 112 <SettingsList> 113 113 {#each keys as k (k.rkey)}
+2 -2
web/src/lib/components/settings/tabs/KnotsTab.svelte
··· 1 1 <script lang="ts"> 2 2 import Button from "$lib/components/ui/Button.svelte"; 3 3 import Tag from "$lib/components/ui/Tag.svelte"; 4 - import EmptyState from "$lib/components/ui/EmptyState.svelte"; 4 + import SettingsEmpty from "../SettingsEmpty.svelte"; 5 5 import SettingsHeader from "../SettingsHeader.svelte"; 6 6 import SettingsToolbar from "../SettingsToolbar.svelte"; 7 7 import SettingsList from "../SettingsList.svelte"; ··· 52 52 </SettingsToolbar> 53 53 54 54 {#if knots.length === 0} 55 - <EmptyState message="No knots registered yet" /> 55 + <SettingsEmpty message="No knots registered yet" /> 56 56 {:else} 57 57 <SettingsList> 58 58 {#each knots as knot (knot.id)}
+3 -14
web/src/lib/components/settings/tabs/ProfileTab.svelte
··· 9 9 import Toggle from "$lib/components/ui/Toggle.svelte"; 10 10 import SettingsSection from "../SettingsSection.svelte"; 11 11 import SettingsRow from "../SettingsRow.svelte"; 12 + import SettingsSaveBar from "../SettingsSaveBar.svelte"; 12 13 import CodeChip from "../CodeChip.svelte"; 13 14 import ThemePicker from "../ThemePicker.svelte"; 14 15 import Pencil from "$icon/pencil"; 15 - import Save from "$icon/save"; 16 - import RotateCcw from "$icon/rotate-ccw"; 17 16 import Key from "$icon/key"; 18 17 import Pause from "$icon/pause"; 19 18 import Trash from "$icon/trash-2"; ··· 63 62 const STATS = ["Repositories", "Followers", "Stars", "Pull requests", "Issues"]; 64 63 </script> 65 64 66 - {#snippet actions()} 67 - <div class="flex items-center gap-4"> 68 - {#if dirty} 69 - <Button icon={RotateCcw} onclick={reset}>Reset</Button> 70 - <span class="typography-paragraph-regular text-foreground-muted">Unsaved changes</span> 71 - {/if} 72 - <Button variant="primary" icon={Save} disabled={!dirty}>Save</Button> 73 - </div> 74 - {/snippet} 75 - 76 65 <div class="flex min-h-9 w-full items-center justify-between gap-4"> 77 66 <h1 class="typography-heading-2 text-foreground-default">Profile</h1> 78 - {@render actions()} 67 + <SettingsSaveBar {dirty} onreset={reset} /> 79 68 </div> 80 69 81 70 <div class="flex w-full flex-col gap-10"> ··· 163 152 </div> 164 153 165 154 <div class="flex w-full justify-end"> 166 - {@render actions()} 155 + <SettingsSaveBar {dirty} onreset={reset} /> 167 156 </div>
+2 -2
web/src/lib/components/settings/tabs/SitesTab.svelte
··· 1 1 <script lang="ts"> 2 2 import Button from "$lib/components/ui/Button.svelte"; 3 3 import Tag from "$lib/components/ui/Tag.svelte"; 4 - import EmptyState from "$lib/components/ui/EmptyState.svelte"; 4 + import SettingsEmpty from "../SettingsEmpty.svelte"; 5 5 import SettingsHeader from "../SettingsHeader.svelte"; 6 6 import SettingsToolbar from "../SettingsToolbar.svelte"; 7 7 import SettingsList from "../SettingsList.svelte"; ··· 38 38 </SettingsToolbar> 39 39 40 40 {#if sites.length === 0} 41 - <EmptyState message="No sites claimed yet" /> 41 + <SettingsEmpty message="No sites claimed yet" /> 42 42 {:else} 43 43 <SettingsList> 44 44 {#each sites as site (site.id)}
+2 -2
web/src/lib/components/settings/tabs/SpindlesTab.svelte
··· 1 1 <script lang="ts"> 2 2 import Button from "$lib/components/ui/Button.svelte"; 3 3 import Tag from "$lib/components/ui/Tag.svelte"; 4 - import EmptyState from "$lib/components/ui/EmptyState.svelte"; 4 + import SettingsEmpty from "../SettingsEmpty.svelte"; 5 5 import SettingsHeader from "../SettingsHeader.svelte"; 6 6 import SettingsToolbar from "../SettingsToolbar.svelte"; 7 7 import SettingsList from "../SettingsList.svelte"; ··· 49 49 </SettingsToolbar> 50 50 51 51 {#if spindles.length === 0} 52 - <EmptyState message="No spindles registered yet" /> 52 + <SettingsEmpty message="No spindles registered yet" /> 53 53 {:else} 54 54 <SettingsList> 55 55 {#each spindles as spindle (spindle.id)}
+36
web/src/routes/[handle]/[repo]/settings/+layout.svelte
··· 1 + <script lang="ts"> 2 + import { page } from "$app/state"; 3 + import SettingsNav, { type SettingsNavItem } from "$lib/components/settings/SettingsNav.svelte"; 4 + import SlidersHorizontal from "$icon/sliders-horizontal"; 5 + import UsersRound from "$icon/users-round"; 6 + import Layers from "$icon/layers"; 7 + import Webhook from "$icon/webhook"; 8 + import Globe from "$icon/globe"; 9 + 10 + let { children, data } = $props(); 11 + 12 + const base = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings`); 13 + 14 + const items = $derived<SettingsNavItem[]>([ 15 + { id: "general", label: "General", href: base, icon: SlidersHorizontal }, 16 + { id: "access", label: "Access", href: `${base}/access`, icon: UsersRound }, 17 + { id: "pipelines", label: "Pipelines", href: `${base}/pipelines`, icon: Layers }, 18 + { id: "hooks", label: "Hooks", href: `${base}/hooks`, icon: Webhook }, 19 + { id: "sites", label: "Sites", href: `${base}/sites`, icon: Globe } 20 + ]); 21 + 22 + // route ids look like /[handle]/[repo]/settings/<tab>/…, and a drill-down keeps 23 + // its parent tab active, so only the segment right after `settings` matters 24 + const segment = $derived(page.route.id?.split("/")[4] ?? ""); 25 + const active = $derived(items.some((i) => i.id === segment) ? segment : "general"); 26 + </script> 27 + 28 + <div 29 + class="flex min-h-[600px] w-full flex-col items-stretch gap-4 overflow-hidden rounded-sm border border-border-default bg-background-default md:flex-row md:items-start" 30 + > 31 + <SettingsNav {items} {active} label="Repository settings" /> 32 + 33 + <div class="flex min-w-0 flex-1 flex-col gap-8 px-4 py-6"> 34 + {@render children()} 35 + </div> 36 + </div>
+177
web/src/routes/[handle]/[repo]/settings/+page.svelte
··· 1 + <script lang="ts"> 2 + import { untrack } from "svelte"; 3 + import { resolve } from "$app/paths"; 4 + import Button from "$lib/components/ui/Button.svelte"; 5 + import Input from "$lib/components/ui/Input.svelte"; 6 + import Select from "$lib/components/ui/Select.svelte"; 7 + import Toggle from "$lib/components/ui/Toggle.svelte"; 8 + import SettingsEmpty from "$lib/components/settings/SettingsEmpty.svelte"; 9 + import SettingsSection from "$lib/components/settings/SettingsSection.svelte"; 10 + import SettingsBlock from "$lib/components/settings/SettingsBlock.svelte"; 11 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 12 + import SettingsRow from "$lib/components/settings/SettingsRow.svelte"; 13 + import SettingsSaveBar from "$lib/components/settings/SettingsSaveBar.svelte"; 14 + import Plus from "$icon/plus"; 15 + import X from "$icon/x"; 16 + import Pencil from "$icon/pencil"; 17 + import Trash from "$icon/trash-2"; 18 + 19 + let { data } = $props(); 20 + 21 + const base = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings`); 22 + const path = (p: string) => resolve(p as "/"); 23 + 24 + // mocked — repo settings have no write path in the rewrite yet, so the form is 25 + // seeded from the repo once rather than tracking it 26 + const initial = { 27 + description: "", 28 + website: "", 29 + topics: "", 30 + defaultBranch: untrack(() => data.repo.defaultBranch) 31 + }; 32 + let form = $state({ ...initial }); 33 + const dirty = $derived(JSON.stringify(form) !== JSON.stringify(initial)); 34 + const reset = () => (form = { ...initial }); 35 + 36 + let labels = $state([ 37 + { 38 + name: "documentation", 39 + color: "#06b6d4", 40 + meta: "Basic · sh.tangled.repo.issue", 41 + subscribed: true 42 + }, 43 + { 44 + name: "duplicate", 45 + color: "#ef4444", 46 + meta: "Basic · sh.tangled.repo.issue", 47 + subscribed: true 48 + }, 49 + { 50 + name: "good-first-issue", 51 + color: "#8b5cf6", 52 + meta: "Basic · sh.tangled.repo.issue", 53 + subscribed: true 54 + }, 55 + { name: "wontfix", color: "#3b82f6", meta: "Basic · sh.tangled.repo.issue", subscribed: true }, 56 + { 57 + name: "assignee", 58 + color: "#10b981", 59 + meta: "string type · DID format · Multiple · sh.tangled.repo.issue, sh.tangled.repo.pull", 60 + subscribed: true 61 + } 62 + ]); 63 + 64 + const unsubscribeAll = () => { 65 + labels = labels.map((l) => ({ ...l, subscribed: false })); 66 + }; 67 + 68 + const branches = $derived([data.repo.defaultBranch, "develop", "trunk"]); 69 + </script> 70 + 71 + <div class="flex min-h-9 w-full items-center justify-between gap-4"> 72 + <h1 class="typography-heading-2 text-foreground-default">General</h1> 73 + <SettingsSaveBar {dirty} onreset={reset} /> 74 + </div> 75 + 76 + <div class="flex w-full flex-col gap-10"> 77 + <!-- the leading pair of cards carries no heading in the design --> 78 + <div class="flex w-full flex-col gap-4"> 79 + <SettingsList gap="3"> 80 + <SettingsRow title="Description"> 81 + <Input 82 + bind:value={form.description} 83 + placeholder="What this project is about" 84 + class="w-80" 85 + /> 86 + </SettingsRow> 87 + <SettingsRow title="Website"> 88 + <Input bind:value={form.website} placeholder="URL" class="w-80" /> 89 + </SettingsRow> 90 + <SettingsRow title="Topics" description="List of topics separated by spaces."> 91 + <Input bind:value={form.topics} placeholder="infra, social, decentralized" class="w-80" /> 92 + </SettingsRow> 93 + </SettingsList> 94 + 95 + <SettingsList gap="3"> 96 + <SettingsRow 97 + title="Default branch" 98 + description={[ 99 + 'The default branch is considered the "base" branch in your repository, against which all pull requests and code commits are automatically made, unless you specify a different branch.' 100 + ]} 101 + > 102 + <Select bind:value={form.defaultBranch} aria-label="Default branch" class="w-80"> 103 + {#each branches as branch (branch)} 104 + <option value={branch}>{branch}</option> 105 + {/each} 106 + </Select> 107 + </SettingsRow> 108 + </SettingsList> 109 + </div> 110 + 111 + <SettingsSection title="Labels" framed={false} headingGap="4"> 112 + <div class="flex w-full flex-col gap-4"> 113 + <SettingsBlock 114 + title="Default labels" 115 + titleSize="large" 116 + description={[ 117 + "Manage your issues and pulls by creating labels to categorize them. Only repository owners may configure labels.", 118 + "You may choose to subscribe to default labels, or create entirely custom labels." 119 + ]} 120 + > 121 + {#snippet action()} 122 + <Button icon={X} onclick={unsubscribeAll}>Unsubscribe all</Button> 123 + {/snippet} 124 + 125 + <SettingsList gap="3"> 126 + {#each labels as item (item.name)} 127 + <SettingsRow> 128 + {#snippet label()} 129 + <span class="flex min-w-0 flex-col gap-1"> 130 + <span class="flex items-center gap-2"> 131 + <span 132 + class="size-2 shrink-0 rounded-full" 133 + style="background-color: {item.color}" 134 + aria-hidden="true" 135 + ></span> 136 + <span class="typography-paragraph-regular text-foreground-default"> 137 + {item.name} 138 + </span> 139 + </span> 140 + <span class="typography-paragraph-regular text-foreground-muted"> 141 + {item.meta} 142 + </span> 143 + </span> 144 + {/snippet} 145 + <Toggle bind:checked={item.subscribed} aria-label={item.name} /> 146 + </SettingsRow> 147 + {/each} 148 + </SettingsList> 149 + </SettingsBlock> 150 + 151 + <SettingsBlock title="Custom labels" titleSize="large"> 152 + {#snippet action()} 153 + <Button icon={Plus} href={path(`${base}/labels/new`)}>Add custom label</Button> 154 + {/snippet} 155 + <SettingsEmpty message="No custom labels added yet" /> 156 + </SettingsBlock> 157 + </div> 158 + </SettingsSection> 159 + 160 + <hr class="w-full border-0 border-t border-border-default" /> 161 + 162 + <SettingsSection title="Danger zone" headingGap="4" rowGap="3"> 163 + <SettingsRow title="Rename"> 164 + <Button icon={Pencil} href={path(`${base}/rename`)}>Rename repository</Button> 165 + </SettingsRow> 166 + <SettingsRow 167 + title="Delete" 168 + description="Deleting a repository is irreversible and permanent. Be certain before deleting a repository." 169 + > 170 + <Button variant="danger" icon={Trash}>Delete repository</Button> 171 + </SettingsRow> 172 + </SettingsSection> 173 + </div> 174 + 175 + <div class="flex w-full justify-end"> 176 + <SettingsSaveBar {dirty} onreset={reset} /> 177 + </div>
+69
web/src/routes/[handle]/[repo]/settings/access/+page.svelte
··· 1 + <script lang="ts"> 2 + import { untrack } from "svelte"; 3 + import { resolve } from "$app/paths"; 4 + import Avatar from "$lib/components/ui/Avatar.svelte"; 5 + import Button from "$lib/components/ui/Button.svelte"; 6 + import SettingsBlock from "$lib/components/settings/SettingsBlock.svelte"; 7 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 8 + import SettingsRow from "$lib/components/settings/SettingsRow.svelte"; 9 + import UserRoundPlus from "$icon/user-round-plus"; 10 + import UserRoundMinus from "$icon/user-round-minus"; 11 + 12 + let { data } = $props(); 13 + 14 + const base = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings`); 15 + const path = (p: string) => resolve(p as "/"); 16 + 17 + // mocked — collaborator management is not wired up yet 18 + let collaborators = $state([ 19 + { 20 + handle: untrack(() => data.repo.ownerHandle), 21 + did: untrack(() => data.repo.ownerDid), 22 + role: "Owner", 23 + owner: true 24 + }, 25 + { handle: "user2.tld", did: undefined, role: "Collaborator", owner: false } 26 + ]); 27 + 28 + const remove = (handle: string) => { 29 + collaborators = collaborators.filter((c) => c.handle !== handle); 30 + }; 31 + </script> 32 + 33 + <div class="flex min-h-8 w-full items-center gap-4"> 34 + <h1 class="typography-heading-2 text-foreground-default">Access</h1> 35 + </div> 36 + 37 + <SettingsBlock 38 + title="Collaborators" 39 + description={[ 40 + "Any user added as a collaborator will be able to push commits and tags to this repository,", 41 + "upload releases, and workflows." 42 + ]} 43 + separator 44 + > 45 + {#snippet action()} 46 + <Button icon={UserRoundPlus} href={path(`${base}/access/new`)}>Add collaborator</Button> 47 + {/snippet} 48 + 49 + <SettingsList gap="3"> 50 + {#each collaborators as person (person.handle)} 51 + <SettingsRow> 52 + {#snippet label()} 53 + <span class="flex min-w-0 items-center gap-2"> 54 + <Avatar did={person.did} handle={person.handle} size="size-5" /> 55 + <span class="typography-paragraph-regular text-foreground-default"> 56 + {person.handle} 57 + </span> 58 + <span class="typography-paragraph-regular text-foreground-muted">{person.role}</span> 59 + </span> 60 + {/snippet} 61 + {#if !person.owner} 62 + <Button icon={UserRoundMinus} onclick={() => remove(person.handle)}> 63 + Remove collaborator 64 + </Button> 65 + {/if} 66 + </SettingsRow> 67 + {/each} 68 + </SettingsList> 69 + </SettingsBlock>
+40
web/src/routes/[handle]/[repo]/settings/access/new/+page.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from "$app/navigation"; 3 + import { resolve } from "$app/paths"; 4 + import Button from "$lib/components/ui/Button.svelte"; 5 + import Input from "$lib/components/ui/Input.svelte"; 6 + import DrillDown from "$lib/components/settings/DrillDown.svelte"; 7 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 8 + import FormRow from "$lib/components/settings/FormRow.svelte"; 9 + import FormActions from "$lib/components/settings/FormActions.svelte"; 10 + import Plus from "$icon/plus"; 11 + import X from "$icon/x"; 12 + 13 + let { data } = $props(); 14 + 15 + const back = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings/access`); 16 + 17 + let user = $state(""); 18 + const valid = $derived(user.trim().length > 0); 19 + 20 + // mocked — collaborator management is not wired up yet 21 + const submit = () => goto(resolve(back as "/")); 22 + </script> 23 + 24 + <DrillDown 25 + backLabel="Access" 26 + backHref={back} 27 + title="Add collaborator" 28 + description="Collaborators can push to this repository." 29 + > 30 + <SettingsList padding="tight"> 31 + <FormRow label="User" for="collaborator"> 32 + <Input id="collaborator" bind:value={user} placeholder="user.tngl.sh" /> 33 + </FormRow> 34 + </SettingsList> 35 + 36 + <FormActions> 37 + <Button href={resolve(back as "/")} icon={X}>Cancel</Button> 38 + <Button variant="primary" icon={Plus} disabled={!valid} onclick={submit}>Add</Button> 39 + </FormActions> 40 + </DrillDown>
+97
web/src/routes/[handle]/[repo]/settings/hooks/+page.svelte
··· 1 + <script lang="ts"> 2 + import { untrack } from "svelte"; 3 + import { resolve } from "$app/paths"; 4 + import Avatar from "$lib/components/ui/Avatar.svelte"; 5 + import Button from "$lib/components/ui/Button.svelte"; 6 + import Toggle from "$lib/components/ui/Toggle.svelte"; 7 + import SettingsEmpty from "$lib/components/settings/SettingsEmpty.svelte"; 8 + import SettingsBlock from "$lib/components/settings/SettingsBlock.svelte"; 9 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 10 + import CodeChip from "$lib/components/settings/CodeChip.svelte"; 11 + import Plus from "$icon/plus"; 12 + import Eye from "$icon/eye"; 13 + import Pencil from "$icon/pencil"; 14 + import Trash from "$icon/trash-2"; 15 + import Inbox from "$icon/inbox"; 16 + 17 + let { data } = $props(); 18 + 19 + const base = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings`); 20 + const path = (p: string) => resolve(p as "/"); 21 + 22 + // mocked — webhooks are not wired up yet 23 + let hooks = $state([ 24 + { 25 + id: "1", 26 + url: "https://hooks.user.tld/tg/repository-cs-hk", 27 + events: "push, pull_request:created, pull_request:closed", 28 + added: "Added 2 mins ago by", 29 + by: untrack(() => data.repo.ownerHandle), 30 + enabled: true 31 + }, 32 + { 33 + id: "2", 34 + url: "https://hooks.user.tld/tg/repository", 35 + events: "push, pull_request:created, pull_request:closed", 36 + added: "Added 5 hours ago by", 37 + by: untrack(() => data.repo.ownerHandle), 38 + enabled: false 39 + } 40 + ]); 41 + 42 + const remove = (id: string) => { 43 + hooks = hooks.filter((h) => h.id !== id); 44 + }; 45 + </script> 46 + 47 + <div class="flex min-h-8 w-full items-center gap-4"> 48 + <h1 class="typography-heading-2 text-foreground-default">Hooks</h1> 49 + </div> 50 + 51 + <SettingsBlock 52 + description={[ 53 + "Webhooks allow external services to be notified when certain events happen.", 54 + "When the specified events happen, we'll send a POST request to each of the URLs you provide." 55 + ]} 56 + > 57 + {#snippet action()} 58 + <Button icon={Plus} href={path(`${base}/hooks/new`)}>New webhook</Button> 59 + {/snippet} 60 + 61 + {#if hooks.length === 0} 62 + <SettingsEmpty message="No webhooks yet" /> 63 + {:else} 64 + <SettingsList gap="3"> 65 + {#each hooks as hook (hook.id)} 66 + <!-- the toggle rides the top-right corner while the actions sit under it, 67 + so this row is a two-column block rather than a SettingsRow --> 68 + <div class="flex w-full items-start justify-between gap-4"> 69 + <div class="flex min-w-0 flex-col gap-1"> 70 + <CodeChip class="w-fit break-all">{hook.url}</CodeChip> 71 + <span 72 + class="flex items-center gap-1.5 typography-paragraph-regular text-foreground-muted" 73 + > 74 + <Eye class="size-3.5 shrink-0" aria-hidden="true" /> 75 + {hook.events} 76 + </span> 77 + <span 78 + class="flex items-center gap-1.5 typography-paragraph-regular text-foreground-muted" 79 + > 80 + {hook.added} 81 + <Avatar handle={hook.by} size="size-4" /> 82 + {hook.by} 83 + </span> 84 + </div> 85 + <div class="flex shrink-0 flex-col items-end gap-4"> 86 + <Toggle bind:checked={hook.enabled} aria-label="Enable {hook.url}" /> 87 + <div class="flex items-center gap-2"> 88 + <Button icon={Inbox}>Deliveries</Button> 89 + <Button icon={Pencil}>Edit</Button> 90 + <Button variant="danger" icon={Trash} onclick={() => remove(hook.id)}>Delete</Button> 91 + </div> 92 + </div> 93 + </div> 94 + {/each} 95 + </SettingsList> 96 + {/if} 97 + </SettingsBlock>
+81
web/src/routes/[handle]/[repo]/settings/hooks/new/+page.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from "$app/navigation"; 3 + import { resolve } from "$app/paths"; 4 + import Button from "$lib/components/ui/Button.svelte"; 5 + import Input from "$lib/components/ui/Input.svelte"; 6 + import Checkbox from "$lib/components/ui/Checkbox.svelte"; 7 + import DrillDown from "$lib/components/settings/DrillDown.svelte"; 8 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 9 + import FormRow from "$lib/components/settings/FormRow.svelte"; 10 + import FormActions from "$lib/components/settings/FormActions.svelte"; 11 + import Plus from "$icon/plus"; 12 + import X from "$icon/x"; 13 + 14 + let { data } = $props(); 15 + 16 + const back = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings/hooks`); 17 + 18 + let url = $state(""); 19 + let secret = $state(""); 20 + // mocked — webhooks are not wired up yet. Push is preselected, as in the design. 21 + let events = $state<Record<string, boolean>>({ 22 + push: true, 23 + renamed: false, 24 + opened: false, 25 + resubmitted: false, 26 + merged: false, 27 + closed: false, 28 + reopened: false 29 + }); 30 + 31 + const EVENTS: { id: string; label: string }[] = [ 32 + { id: "push", label: "Push events" }, 33 + { id: "renamed", label: "Repository renamed" }, 34 + { id: "opened", label: "Pull request opened" }, 35 + { id: "resubmitted", label: "Pull request resubmitted" }, 36 + { id: "merged", label: "Pull request merged" }, 37 + { id: "closed", label: "Pull request closed" }, 38 + { id: "reopened", label: "Pull request reopened" } 39 + ]; 40 + 41 + const valid = $derived(url.trim().length > 0 && Object.values(events).some(Boolean)); 42 + const submit = () => goto(resolve(back as "/")); 43 + </script> 44 + 45 + <DrillDown backLabel="Hooks" backHref={back} title="New webhook"> 46 + <SettingsList padding="tight"> 47 + <FormRow 48 + label="Payload URL" 49 + description="The URL that will receive the webhook POST requests." 50 + for="hook-url" 51 + > 52 + <Input id="hook-url" bind:value={url} placeholder="https://example.com/webhook" /> 53 + </FormRow> 54 + 55 + <FormRow 56 + label="Secret" 57 + optional 58 + description="If provided, webhook payloads will be signed with HMAC-SHA256. Leave blank to send unsigned webhooks." 59 + for="hook-secret" 60 + > 61 + <Input id="hook-secret" bind:value={secret} placeholder="A secret for signed webhooks" /> 62 + </FormRow> 63 + 64 + <FormRow 65 + label="Events" 66 + description="Additional event types (issues) will be available in future updates." 67 + align="start" 68 + > 69 + <div class="flex flex-col gap-1"> 70 + {#each EVENTS as event (event.id)} 71 + <Checkbox bind:checked={events[event.id]}>{event.label}</Checkbox> 72 + {/each} 73 + </div> 74 + </FormRow> 75 + </SettingsList> 76 + 77 + <FormActions> 78 + <Button href={resolve(back as "/")} icon={X}>Cancel</Button> 79 + <Button variant="primary" icon={Plus} disabled={!valid} onclick={submit}>Add</Button> 80 + </FormActions> 81 + </DrillDown>
+91
web/src/routes/[handle]/[repo]/settings/labels/new/+page.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from "$app/navigation"; 3 + import { resolve } from "$app/paths"; 4 + import Button from "$lib/components/ui/Button.svelte"; 5 + import Input from "$lib/components/ui/Input.svelte"; 6 + import Radio from "$lib/components/ui/Radio.svelte"; 7 + import Checkbox from "$lib/components/ui/Checkbox.svelte"; 8 + import DrillDown from "$lib/components/settings/DrillDown.svelte"; 9 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 10 + import FormRow from "$lib/components/settings/FormRow.svelte"; 11 + import FormActions from "$lib/components/settings/FormActions.svelte"; 12 + import Plus from "$icon/plus"; 13 + import X from "$icon/x"; 14 + 15 + let { data } = $props(); 16 + 17 + const back = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings`); 18 + 19 + // the eight swatches from the design 20 + const COLORS = [ 21 + "#ef4444", 22 + "#3b82f6", 23 + "#22c55e", 24 + "#f59e0b", 25 + "#8b5cf6", 26 + "#ec4899", 27 + "#06b6d4", 28 + "#6b7280" 29 + ]; 30 + 31 + // mocked — custom labels are not wired up yet 32 + let type = $state<"basic" | "key-value">("basic"); 33 + let name = $state(""); 34 + let scopeIssues = $state(true); 35 + let scopePulls = $state(true); 36 + let color = $state(COLORS[0]); 37 + 38 + const valid = $derived(name.trim().length > 0 && (scopeIssues || scopePulls)); 39 + const submit = () => goto(resolve(back as "/")); 40 + </script> 41 + 42 + <DrillDown backLabel="General" backHref={back} title="Add custom label"> 43 + <SettingsList padding="tight"> 44 + <FormRow 45 + label="Label type" 46 + description={[ 47 + "Basic labels can have a name and a color.", 48 + "Key-value labels are more detailed, they can have a key and an associated value.", 49 + "You may define additional constraints on label values." 50 + ]} 51 + > 52 + <div class="flex items-center gap-6"> 53 + <Radio value="basic" bind:group={type} name="label-type">Basic</Radio> 54 + <Radio value="key-value" bind:group={type} name="label-type">Key-value</Radio> 55 + </div> 56 + </FormRow> 57 + 58 + <FormRow label="Name" for="label-name"> 59 + <Input id="label-name" bind:value={name} placeholder="improvement" /> 60 + </FormRow> 61 + 62 + <FormRow label="Scope" align="start"> 63 + <div class="flex flex-col gap-1"> 64 + <Checkbox bind:checked={scopeIssues}>Issues</Checkbox> 65 + <Checkbox bind:checked={scopePulls}>Pull requests</Checkbox> 66 + </div> 67 + </FormRow> 68 + 69 + <FormRow label="Color"> 70 + <div class="flex items-center gap-2"> 71 + {#each COLORS as swatch (swatch)} 72 + <button 73 + type="button" 74 + onclick={() => (color = swatch)} 75 + aria-label="Use colour {swatch}" 76 + aria-pressed={color === swatch} 77 + class="size-5 cursor-pointer rounded-full transition-shadow {color === swatch 78 + ? 'ring-2 ring-foreground-default ring-offset-2 ring-offset-background-default' 79 + : ''}" 80 + style="background-color: {swatch}" 81 + ></button> 82 + {/each} 83 + </div> 84 + </FormRow> 85 + </SettingsList> 86 + 87 + <FormActions> 88 + <Button href={resolve(back as "/")} icon={X}>Cancel</Button> 89 + <Button variant="primary" icon={Plus} disabled={!valid} onclick={submit}>Add</Button> 90 + </FormActions> 91 + </DrillDown>
+96
web/src/routes/[handle]/[repo]/settings/pipelines/+page.svelte
··· 1 + <script lang="ts"> 2 + import { untrack } from "svelte"; 3 + import { resolve } from "$app/paths"; 4 + import Avatar from "$lib/components/ui/Avatar.svelte"; 5 + import Button from "$lib/components/ui/Button.svelte"; 6 + import Select from "$lib/components/ui/Select.svelte"; 7 + import SettingsBlock from "$lib/components/settings/SettingsBlock.svelte"; 8 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 9 + import SettingsRow from "$lib/components/settings/SettingsRow.svelte"; 10 + import DocsButton from "$lib/components/settings/DocsButton.svelte"; 11 + import Plus from "$icon/plus"; 12 + import Trash from "$icon/trash-2"; 13 + 14 + let { data } = $props(); 15 + 16 + const base = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings`); 17 + const path = (p: string) => resolve(p as "/"); 18 + 19 + // mocked — spindle selection and secrets are not wired up yet 20 + let spindle = $state(untrack(() => data.repo.spindle) ?? "spindle.tangled.sh"); 21 + const spindles = $derived([spindle, "spindle.user.tld"]); 22 + 23 + let secrets = $state([ 24 + { 25 + name: "generic_secret", 26 + added: "Added 5 hours ago by", 27 + by: untrack(() => data.repo.ownerHandle) 28 + } 29 + ]); 30 + 31 + const remove = (name: string) => { 32 + secrets = secrets.filter((s) => s.name !== name); 33 + }; 34 + </script> 35 + 36 + <div class="flex min-h-8 w-full items-center gap-4"> 37 + <h1 class="typography-heading-2 text-foreground-default">Pipelines</h1> 38 + </div> 39 + 40 + <SettingsBlock 41 + title="Spindle" 42 + description={[ 43 + "Choose a spindle to execute your workflows on. Only repository owners can configure spindles.", 44 + "Spindles can be selfhosted." 45 + ]} 46 + separator 47 + > 48 + {#snippet action()} 49 + <DocsButton href="https://docs.tangled.org/spindles" /> 50 + {/snippet} 51 + 52 + <SettingsList gap="3"> 53 + <SettingsRow title="Spindle"> 54 + <Select bind:value={spindle} aria-label="Spindle" class="w-80"> 55 + {#each spindles as option (option)} 56 + <option value={option}>{option}</option> 57 + {/each} 58 + </Select> 59 + </SettingsRow> 60 + </SettingsList> 61 + </SettingsBlock> 62 + 63 + <SettingsBlock 64 + title="Secrets" 65 + description={[ 66 + "Secrets are accessible in workflow runs via environment variables.", 67 + "Anyone with collaborator access to this repository can add and use secrets in workflow runs." 68 + ]} 69 + separator 70 + > 71 + {#snippet action()} 72 + <Button icon={Plus} href={path(`${base}/pipelines/secrets/new`)}>Add secret</Button> 73 + {/snippet} 74 + 75 + <SettingsList gap="3"> 76 + {#each secrets as secret (secret.name)} 77 + <SettingsRow> 78 + {#snippet label()} 79 + <span class="flex min-w-0 flex-col gap-1"> 80 + <span class="font-mono typography-monospace-regular break-all text-foreground-default"> 81 + {secret.name} 82 + </span> 83 + <span 84 + class="flex items-center gap-1.5 typography-paragraph-regular text-foreground-muted" 85 + > 86 + {secret.added} 87 + <Avatar handle={secret.by} size="size-4" /> 88 + {secret.by} 89 + </span> 90 + </span> 91 + {/snippet} 92 + <Button variant="danger" icon={Trash} onclick={() => remove(secret.name)}>Delete</Button> 93 + </SettingsRow> 94 + {/each} 95 + </SettingsList> 96 + </SettingsBlock>
+44
web/src/routes/[handle]/[repo]/settings/pipelines/secrets/new/+page.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from "$app/navigation"; 3 + import { resolve } from "$app/paths"; 4 + import Button from "$lib/components/ui/Button.svelte"; 5 + import Input from "$lib/components/ui/Input.svelte"; 6 + import DrillDown from "$lib/components/settings/DrillDown.svelte"; 7 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 8 + import FormRow from "$lib/components/settings/FormRow.svelte"; 9 + import FormActions from "$lib/components/settings/FormActions.svelte"; 10 + import Plus from "$icon/plus"; 11 + import X from "$icon/x"; 12 + 13 + let { data } = $props(); 14 + 15 + const back = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings/pipelines`); 16 + 17 + let name = $state(""); 18 + let value = $state(""); 19 + const valid = $derived(name.trim().length > 0 && value.length > 0); 20 + 21 + // mocked — secrets are not wired up yet 22 + const submit = () => goto(resolve(back as "/")); 23 + </script> 24 + 25 + <DrillDown 26 + backLabel="Pipelines" 27 + backHref={back} 28 + title="Add secret" 29 + description="Secrets are available as environment variables in the workflow." 30 + > 31 + <SettingsList padding="tight"> 32 + <FormRow label="Name" for="secret-name"> 33 + <Input id="secret-name" bind:value={name} placeholder="SECRET_NAME" /> 34 + </FormRow> 35 + <FormRow label="Value" for="secret-value"> 36 + <Input id="secret-value" bind:value placeholder="very important secret" /> 37 + </FormRow> 38 + </SettingsList> 39 + 40 + <FormActions> 41 + <Button href={resolve(back as "/")} icon={X}>Cancel</Button> 42 + <Button variant="primary" icon={Plus} disabled={!valid} onclick={submit}>Add</Button> 43 + </FormActions> 44 + </DrillDown>
+78
web/src/routes/[handle]/[repo]/settings/rename/+page.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from "$app/navigation"; 3 + import { resolve } from "$app/paths"; 4 + import Button from "$lib/components/ui/Button.svelte"; 5 + import Input from "$lib/components/ui/Input.svelte"; 6 + import DrillDown from "$lib/components/settings/DrillDown.svelte"; 7 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 8 + import FormRow from "$lib/components/settings/FormRow.svelte"; 9 + import FormActions from "$lib/components/settings/FormActions.svelte"; 10 + import Copy from "$icon/copy"; 11 + import CopyCheck from "$icon/copy-check"; 12 + import Plus from "$icon/plus"; 13 + import X from "$icon/x"; 14 + 15 + let { data } = $props(); 16 + 17 + const back = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings`); 18 + const did = $derived(data.repo.ownerDid); 19 + 20 + // the point of the screen: these survive a rename, the handle-based ones don't 21 + const stableUrls = $derived([`https://tangled.org/${did}`, `git@tangled.org:${did}`]); 22 + 23 + let copied = $state<string | null>(null); 24 + const copy = async (url: string) => { 25 + await navigator.clipboard.writeText(url); 26 + copied = url; 27 + setTimeout(() => (copied === url ? (copied = null) : null), 1500); 28 + }; 29 + 30 + let name = $state(""); 31 + const valid = $derived(name.trim().length > 0 && name.trim() !== data.repo.name); 32 + 33 + // mocked — renaming is not wired up yet 34 + const submit = () => goto(resolve(back as "/")); 35 + </script> 36 + 37 + <DrillDown 38 + backLabel="General" 39 + backHref={back} 40 + title="Rename repository" 41 + description={[ 42 + "Existing git remotes that use the old name will break.", 43 + "Use the stable, DID-based URLs below to avoid breakage on future renames." 44 + ]} 45 + > 46 + <div class="flex w-full flex-col gap-1"> 47 + {#each stableUrls as url (url)} 48 + <div class="flex items-center gap-2"> 49 + <code class="font-mono typography-monospace-regular break-all text-foreground-default"> 50 + {url} 51 + </code> 52 + <button 53 + type="button" 54 + onclick={() => copy(url)} 55 + aria-label="Copy {url}" 56 + class="cursor-pointer text-foreground-muted hover:text-foreground-default" 57 + > 58 + {#if copied === url} 59 + <CopyCheck class="size-3.5" aria-hidden="true" /> 60 + {:else} 61 + <Copy class="size-3.5" aria-hidden="true" /> 62 + {/if} 63 + </button> 64 + </div> 65 + {/each} 66 + </div> 67 + 68 + <SettingsList padding="tight"> 69 + <FormRow label="Name" for="repo-name"> 70 + <Input id="repo-name" bind:value={name} placeholder="great-project" /> 71 + </FormRow> 72 + </SettingsList> 73 + 74 + <FormActions> 75 + <Button href={resolve(back as "/")} icon={X}>Cancel</Button> 76 + <Button variant="primary" icon={Plus} disabled={!valid} onclick={submit}>Add</Button> 77 + </FormActions> 78 + </DrillDown>
+140
web/src/routes/[handle]/[repo]/settings/sites/+page.svelte
··· 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"; 5 + import Input from "$lib/components/ui/Input.svelte"; 6 + import Radio from "$lib/components/ui/Radio.svelte"; 7 + import Select from "$lib/components/ui/Select.svelte"; 8 + import Toggle from "$lib/components/ui/Toggle.svelte"; 9 + import Tag from "$lib/components/ui/Tag.svelte"; 10 + import SettingsEmpty from "$lib/components/settings/SettingsEmpty.svelte"; 11 + import SettingsSection from "$lib/components/settings/SettingsSection.svelte"; 12 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 13 + import SettingsRow from "$lib/components/settings/SettingsRow.svelte"; 14 + import SettingsSaveBar from "$lib/components/settings/SettingsSaveBar.svelte"; 15 + import CodeChip from "$lib/components/settings/CodeChip.svelte"; 16 + import Check from "$icon/check"; 17 + import ExternalLink from "$icon/external-link"; 18 + import Settings2 from "$icon/settings-2"; 19 + 20 + let { data } = $props(); 21 + 22 + const domain = "user.tngl.sh"; 23 + const subPath = $derived(`${domain}/${data.repo.name}`); 24 + 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; 29 + 30 + const initial = { 31 + branch: untrack(() => data.repo.defaultBranch), 32 + directory: "/", 33 + type: "index" as "index" | "subpath", 34 + disabled: false 35 + }; 36 + let form = $state({ ...initial }); 37 + const dirty = $derived(JSON.stringify(form) !== JSON.stringify(initial)); 38 + const reset = () => (form = { ...initial }); 39 + 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 + ]); 45 + 46 + const deploys = [ 47 + { branch: "main", status: "Success", reason: "Config change", when: "5 hours ago" } 48 + ]; 49 + </script> 50 + 51 + <div class="flex min-h-9 w-full items-center justify-between gap-4"> 52 + <h1 class="typography-heading-2 text-foreground-default">Sites</h1> 53 + <SettingsSaveBar {dirty} onreset={reset} /> 54 + </div> 55 + 56 + <div class="flex w-full flex-col gap-4"> 57 + <p class="typography-paragraph-regular text-foreground-muted"> 58 + 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 /> 60 + Only repository owners can configure sites. 61 + </p> 62 + 63 + {#if deployed} 64 + <ButtonGroup class="w-fit"> 65 + <Button icon={Check}>Live at {domain}</Button> 66 + <Button icon={ExternalLink}>Open</Button> 67 + </ButtonGroup> 68 + {/if} 69 + </div> 70 + 71 + <SettingsList gap="3"> 72 + <SettingsRow title="Branch" description="The branch to build and deploy the site from."> 73 + <Select bind:value={form.branch} aria-label="Branch" class="w-80"> 74 + {#each branches as branch (branch.value)} 75 + <option value={branch.value}>{branch.label}</option> 76 + {/each} 77 + </Select> 78 + </SettingsRow> 79 + 80 + <SettingsRow 81 + title="Deploy directory" 82 + description={[ 83 + "Path within the repository that contains your index.html.", 84 + "Use / for the root, or a subdirectory like /docs." 85 + ]} 86 + > 87 + <Input bind:value={form.directory} placeholder="/" class="w-80" /> 88 + </SettingsRow> 89 + 90 + <SettingsRow 91 + title="Type" 92 + description={[ 93 + "An index site is served at the root of your sites domain.", 94 + "A sub-path site is served under the repository name." 95 + ]} 96 + > 97 + <div class="flex w-80 flex-col gap-2"> 98 + <div class="flex flex-col gap-1"> 99 + <Radio value="index" bind:group={form.type} name="site-type">Index site</Radio> 100 + <CodeChip class="w-fit">{domain}</CodeChip> 101 + </div> 102 + <div class="flex flex-col gap-1"> 103 + <Radio value="subpath" bind:group={form.type} name="site-type">Sub-path site</Radio> 104 + <CodeChip class="w-fit">{subPath}</CodeChip> 105 + </div> 106 + </div> 107 + </SettingsRow> 108 + 109 + {#if deployed} 110 + <SettingsRow 111 + title="Disable site" 112 + description="Disables the site for this repository. The site will no longer be served." 113 + > 114 + <Toggle bind:checked={form.disabled} aria-label="Disable site" /> 115 + </SettingsRow> 116 + {/if} 117 + </SettingsList> 118 + 119 + <hr class="w-full border-0 border-t border-border-default" /> 120 + 121 + <SettingsSection title="Recent deploys" headingGap="4" rowGap="3" framed={deploys.length > 0}> 122 + {#if deploys.length === 0} 123 + <SettingsEmpty message="No deploys yet." /> 124 + {:else} 125 + {#each deploys as deploy (deploy.branch)} 126 + <SettingsRow> 127 + {#snippet label()} 128 + <span class="flex min-w-0 items-center gap-2"> 129 + <span class="font-mono typography-monospace-regular text-foreground-default"> 130 + {deploy.branch} 131 + </span> 132 + <Tag color="success" size="sm" icon={Check}>{deploy.status}</Tag> 133 + <Tag color="gray" size="sm" icon={Settings2}>{deploy.reason}</Tag> 134 + </span> 135 + {/snippet} 136 + <span class="typography-paragraph-regular text-foreground-muted">{deploy.when}</span> 137 + </SettingsRow> 138 + {/each} 139 + {/if} 140 + </SettingsSection>