From 3365804b42467abbce4850f5f74f2a8e6fdef116 Mon Sep 17 00:00:00 2001 From: eti Date: Thu, 30 Jul 2026 16:00:07 +0000 Subject: [PATCH] web/settings: rebuild the user settings screens from the redesign Ports the desktop user-settings screens from the Settings Redesign Figma (TFhrx7FBh2Ue5SGH8CUeBU) onto a shared set of primitives: a sidebar Navigation matching the DS component's four Active x Hovered states, plus header/toolbar/list/row/entry pieces and a drill-down shell for the in-place sub-views. Seven tabs and nine drill-downs. Keys keeps its existing `tangled.publicKey` wiring and moves the add form from a popover to /settings/keys/new; the rest are mocked, since nothing behind them exists yet. Three token pairs are new because the navigation aliases *invert* between modes -- light recesses the frame and raises the active item, dark does the opposite -- so they cannot be spelled with an existing pair. Button gains per-variant disabled surfaces: Figma models disabled as its own fill/text pair rather than a dimmed enabled state, which is what the disabled Save and Add buttons in the designs need. Tag grows the DS badge geometry as an additive `sm` size plus the status colours, leaving its existing callers untouched. Verified light and dark against the Figma renders; nav, card and surface tokens plus nav/card geometry and heading size all match. Refs TAN-575. Signed-off-by: eti --- web/src/app.css | 17 +++++++++++++++++ web/src/routes/settings/+layout.svelte | 35 +++++++++++++---------------------- web/src/lib/components/settings/AddKeyForm.svelte | 70 ---------------------------------------------------------------------- web/src/lib/components/settings/CodeChip.svelte | 17 +++++++++++++++++ web/src/lib/components/settings/DocsButton.svelte | 22 ++++++++++++++++++++++ web/src/lib/components/settings/DrillDown.svelte | 38 ++++++++++++++++++++++++++++++++++++++ web/src/lib/components/settings/FormActions.svelte | 19 +++++++++++++++++++ web/src/lib/components/settings/FormRow.svelte | 24 ++++++++++++++++++++++++ web/src/lib/components/settings/SettingsEntry.svelte | 39 +++++++++++++++++++++++++++++++++++++++ web/src/lib/components/settings/SettingsHeader.svelte | 24 ++++++++++++++++++++++++ web/src/lib/components/settings/SettingsList.svelte | 45 +++++++++++++++++++++++++++++++++++++++++++++ web/src/lib/components/settings/SettingsNav.svelte | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/lib/components/settings/SettingsRow.svelte | 35 +++++++++++++++++++++++++++++++++++ web/src/lib/components/settings/SettingsSection.svelte | 27 +++++++++++++++++++++++++++ web/src/lib/components/settings/SettingsToolbar.svelte | 25 +++++++++++++++++++++++++ web/src/lib/components/settings/ThemePicker.svelte | 37 +++++++++++++++++++++++++++++++++++++ web/src/lib/components/settings/ThemePreview.svelte | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/lib/components/ui/Button.svelte | 36 ++++++++++++++++++++++++------------ web/src/lib/components/ui/Radio.svelte | 2 +- web/src/lib/components/ui/Tag.svelte | 29 ++++++++++++++++++++++++----- web/src/lib/components/settings/tabs/EmailsTab.svelte | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------- web/src/lib/components/settings/tabs/KeysTab.svelte | 135 +++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------------------------------------------------- web/src/lib/components/settings/tabs/KnotsTab.svelte | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------ web/src/lib/components/settings/tabs/NotificationsTab.svelte | 144 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------- web/src/lib/components/settings/tabs/ProfileTab.svelte | 156 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------- web/src/lib/components/settings/tabs/SitesTab.svelte | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- web/src/lib/components/settings/tabs/SpindlesTab.svelte | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------- web/src/routes/settings/emails/new/+page.svelte | 36 ++++++++++++++++++++++++++++++++++++ web/src/routes/settings/keys/new/+page.svelte | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/routes/settings/knots/new/+page.svelte | 36 ++++++++++++++++++++++++++++++++++++ web/src/routes/settings/profile/avatar/+page.svelte | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/routes/settings/profile/deactivate/+page.svelte | 36 ++++++++++++++++++++++++++++++++++++ web/src/routes/settings/profile/delete/+page.svelte | 32 ++++++++++++++++++++++++++++++++ web/src/routes/settings/profile/handle/+page.svelte | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/routes/settings/profile/password/+page.svelte | 31 +++++++++++++++++++++++++++++++ web/src/routes/settings/spindles/new/+page.svelte | 36 ++++++++++++++++++++++++++++++++++++ 36 file(s) changed, 1586 insertion(s)(+), 269 deletion(s)(-) diff --git a/web/src/app.css b/web/src/app.css --- a/web/src/app.css +++ b/web/src/app.css @@ -36,6 +36,13 @@ --color-background-info-emphasis: #4f46e5; --color-background-neutral-emphasis: #1f2937; + /* --- background / navigation --- + The settings sidebar aliases *invert* between modes, so these cannot be + spelled as an existing pair: light recesses the frame (subtle) and raises + the active item (default), dark does the exact opposite. */ + --color-background-navigation-frame: #f9fafb; + --color-background-navigation-item: #ffffff; + /* --- foreground --- */ --color-foreground-default: #111827; --color-foreground-muted: #4b5563; @@ -50,8 +57,10 @@ --color-foreground-danger: #dc2626; --color-foreground-danger-strong: #991b1b; --color-foreground-danger-subtle: #f87171; + --color-foreground-danger-disabled: #fca5a5; --color-foreground-warning: #d97706; --color-foreground-warning-strong: #92400e; + --color-foreground-warning-disabled: #fcd34d; --color-foreground-info: #4338ca; --color-foreground-info-strong: #3730a3; --color-foreground-link-default: #111827; @@ -77,6 +86,7 @@ --color-border-success: #15803d; --color-border-danger: #fca5a5; --color-border-danger-subtle: #fecaca; + --color-border-navigation-item-active: #e5e7eb; /* --- shadow (inner depth) --- */ --color-shadow-inner-strong: #d1d5db; @@ -299,6 +309,10 @@ --color-background-info-emphasis: #4f46e5; --color-background-neutral-emphasis: #374151; + /* --- background / navigation (Dark) --- inverted vs light, see above */ + --color-background-navigation-frame: #1f2937; + --color-background-navigation-item: #374151; + /* --- foreground (Dark) --- */ --color-foreground-default: #f3f4f6; --color-foreground-muted: #9ca3af; @@ -312,8 +326,10 @@ --color-foreground-success-disabled: #15803d; --color-foreground-danger: #f87171; --color-foreground-danger-strong: #fecaca; + --color-foreground-danger-disabled: #f87171; --color-foreground-warning: #fbbf24; --color-foreground-warning-strong: #fde68a; + --color-foreground-warning-disabled: #fbbf24; --color-foreground-info: #818cf8; --color-foreground-info-strong: #c7d2fe; --color-foreground-link-default: #f3f4f6; @@ -338,6 +354,7 @@ --color-border-focus-danger: #ef4444; --color-border-success: #15803d; --color-border-danger: #991b1b; + --color-border-navigation-item-active: #4b5563; /* --- shadow (Dark) --- */ --color-shadow-inner-strong: #030712; diff --git a/web/src/routes/settings/+layout.svelte b/web/src/routes/settings/+layout.svelte --- a/web/src/routes/settings/+layout.svelte +++ b/web/src/routes/settings/+layout.svelte @@ -1,7 +1,7 @@ Settings · Tangled -
-
-

Settings

-
+
+
+ -
-
-
- -
- -
- {@render children()} -
+
+ {@render children()}
diff --git a/web/src/lib/components/settings/AddKeyForm.svelte b/web/src/lib/components/settings/AddKeyForm.svelte deleted file mode 100644 --- a/web/src/lib/components/settings/AddKeyForm.svelte +++ /dev/null @@ -1,70 +0,0 @@ - - -
- -

- SSH keys allow you to push to repositories in knots you're a member of. -

- - -
- - -
- {#if error} - - {/if} - diff --git a/web/src/lib/components/settings/CodeChip.svelte b/web/src/lib/components/settings/CodeChip.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/settings/CodeChip.svelte @@ -0,0 +1,17 @@ + + + + {@render children()} + diff --git a/web/src/lib/components/settings/DocsButton.svelte b/web/src/lib/components/settings/DocsButton.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/settings/DocsButton.svelte @@ -0,0 +1,22 @@ + + + + + + + diff --git a/web/src/lib/components/settings/DrillDown.svelte b/web/src/lib/components/settings/DrillDown.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/settings/DrillDown.svelte @@ -0,0 +1,38 @@ + + + + + +
+
+

{title}

+ {#if description} +

{description}

+ {/if} +
+ {@render children()} +
diff --git a/web/src/lib/components/settings/FormActions.svelte b/web/src/lib/components/settings/FormActions.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/settings/FormActions.svelte @@ -0,0 +1,19 @@ + + +
+ {#if start} +
{@render start()}
+ {/if} +
{@render children()}
+
diff --git a/web/src/lib/components/settings/FormRow.svelte b/web/src/lib/components/settings/FormRow.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/settings/FormRow.svelte @@ -0,0 +1,24 @@ + + +
+
+ + {#if description} + {description} + {/if} +
+
{@render children()}
+
diff --git a/web/src/lib/components/settings/SettingsEntry.svelte b/web/src/lib/components/settings/SettingsEntry.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/settings/SettingsEntry.svelte @@ -0,0 +1,39 @@ + + +
+
+
+ {#if Glyph} +
+ {#if meta} + {@render meta()} + {/if} +
+ {#if actions} +
{@render actions()}
+ {/if} +
diff --git a/web/src/lib/components/settings/SettingsHeader.svelte b/web/src/lib/components/settings/SettingsHeader.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/settings/SettingsHeader.svelte @@ -0,0 +1,24 @@ + + +
+
+

{title}

+ {#if action} + {@render action()} + {/if} +
+ {#if description} +

{description}

+ {/if} +
diff --git a/web/src/lib/components/settings/SettingsList.svelte b/web/src/lib/components/settings/SettingsList.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/settings/SettingsList.svelte @@ -0,0 +1,45 @@ + + + + +
+ {@render children()} +
diff --git a/web/src/lib/components/settings/SettingsNav.svelte b/web/src/lib/components/settings/SettingsNav.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/settings/SettingsNav.svelte @@ -0,0 +1,58 @@ + + + + + diff --git a/web/src/lib/components/settings/SettingsRow.svelte b/web/src/lib/components/settings/SettingsRow.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/settings/SettingsRow.svelte @@ -0,0 +1,35 @@ + + +
+ {#if label} + {@render label()} + {:else} + + {#if Glyph} + + {/if} + {#if children} +
{@render children()}
+ {/if} +
diff --git a/web/src/lib/components/settings/SettingsSection.svelte b/web/src/lib/components/settings/SettingsSection.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/settings/SettingsSection.svelte @@ -0,0 +1,27 @@ + + +
+ {#if title} +

{title}

+ {/if} + {#if framed} + + {@render children()} + + {:else} + {@render children()} + {/if} +
diff --git a/web/src/lib/components/settings/SettingsToolbar.svelte b/web/src/lib/components/settings/SettingsToolbar.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/settings/SettingsToolbar.svelte @@ -0,0 +1,25 @@ + + +
+ {#if start} + {@render start()} + {/if} + {#if end} +
{@render end()}
+ {/if} +
+{#if separator} +
+{/if} diff --git a/web/src/lib/components/settings/ThemePicker.svelte b/web/src/lib/components/settings/ThemePicker.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/settings/ThemePicker.svelte @@ -0,0 +1,37 @@ + + +
+ + + + + +
diff --git a/web/src/lib/components/settings/ThemePreview.svelte b/web/src/lib/components/settings/ThemePreview.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/settings/ThemePreview.svelte @@ -0,0 +1,102 @@ + + + + + diff --git a/web/src/lib/components/ui/Button.svelte b/web/src/lib/components/ui/Button.svelte --- a/web/src/lib/components/ui/Button.svelte +++ b/web/src/lib/components/ui/Button.svelte @@ -7,41 +7,53 @@ "transition-colors duration-150 ease-in-out", "hover:no-underline", "focus-visible:outline-2 focus-visible:outline-offset-2", - "disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50" + // each variant paints its own disabled surface (see the variant blocks) — + // Figma models disabled as a distinct fill/text pair, not a dimmed enabled state + "disabled:pointer-events-none aria-disabled:pointer-events-none" ], variants: { variant: { default: [ "border border-border-default bg-background-default text-foreground-default", "hover:bg-background-subtle", - "focus-visible:outline-border-focus" + "focus-visible:outline-border-focus", + "disabled:border-transparent disabled:bg-background-subtle disabled:text-foreground-subtle", + "aria-disabled:border-transparent aria-disabled:bg-background-subtle aria-disabled:text-foreground-subtle" ], primary: [ "border border-border-success bg-background-success-emphasis text-foreground-on-emphasis", "hover:bg-background-success-emphasis-hover", - "focus-visible:outline-background-success-emphasis" + "focus-visible:outline-background-success-emphasis", + "disabled:border-transparent disabled:bg-background-success-subtle disabled:text-foreground-success-disabled", + "aria-disabled:border-transparent aria-disabled:bg-background-success-subtle aria-disabled:text-foreground-success-disabled" ], ghost: [ "border border-transparent bg-transparent", "hover:bg-background-inset", - "focus-visible:outline-border-focus" + "focus-visible:outline-border-focus", + "disabled:text-foreground-disabled aria-disabled:text-foreground-disabled" ], // danger / success / warning are outline buttons in Figma: default surface + - // border, colored text only (bound to the fg-* tokens), no accent fill. + // border, colored text only (bound to the fg-* tokens), no accent fill. They + // keep that surface when disabled and only fade the label — the outline is + // what carries the variant, so dropping it would read as a different button. danger: [ "border border-border-default bg-background-default text-foreground-danger", "hover:bg-background-subtle", - "focus-visible:outline-foreground-danger" + "focus-visible:outline-foreground-danger", + "disabled:text-foreground-danger-disabled aria-disabled:text-foreground-danger-disabled" ], success: [ "border border-border-default bg-background-default text-foreground-success", "hover:bg-background-subtle", - "focus-visible:outline-foreground-success" + "focus-visible:outline-foreground-success", + "disabled:text-foreground-success-disabled aria-disabled:text-foreground-success-disabled" ], warning: [ "border border-border-default bg-background-default text-foreground-warning", "hover:bg-background-subtle", - "focus-visible:outline-foreground-warning" + "focus-visible:outline-foreground-warning", + "disabled:text-foreground-warning-disabled aria-disabled:text-foreground-warning-disabled" ] }, size: { @@ -61,19 +73,19 @@ variant: "default", insetShadow: true, class: [ - "relative z-0 before:absolute before:inset-0 before:-z-10 before:rounded-l-[max(calc(var(--btn-radius-l)-2px),0px)] before:rounded-r-[max(calc(var(--btn-radius-r)-2px),0px)] before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-subtle)] dark:before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-subtle)] before:transition-all before:duration-150 before:content-['']", + "relative z-0 before:absolute before:inset-0 before:-z-10 before:rounded-l-[max(calc(var(--btn-radius-l)-2px),0px)] before:rounded-r-[max(calc(var(--btn-radius-r)-2px),0px)] before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-subtle)] before:transition-all before:duration-150 before:content-[''] dark:before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-subtle)]", "hover:before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-strong-hover)]", "active:*:translate-y-0.5 active:before:shadow-[inset_0_2px_2px_0_var(--color-shadow-inner-subtle)]", - "disabled:active:*:translate-y-0" + "disabled:before:shadow-none disabled:active:*:translate-y-0" ] }, { variant: "primary", insetShadow: true, class: [ - "relative z-0 before:absolute before:inset-0 before:-z-10 before:rounded-l-[max(calc(var(--btn-radius-l)-2px),0px)] before:rounded-r-[max(calc(var(--btn-radius-r)-2px),0px)] before:shadow-[inset_0_-2px_0_0_var(--color-alpha-dark-25)] dark:before:shadow-[inset_0_-2px_0_0_var(--color-alpha-light-25)] before:transition-all before:duration-150 before:content-['']", + "relative z-0 before:absolute before:inset-0 before:-z-10 before:rounded-l-[max(calc(var(--btn-radius-l)-2px),0px)] before:rounded-r-[max(calc(var(--btn-radius-r)-2px),0px)] before:shadow-[inset_0_-2px_0_0_var(--color-alpha-dark-25)] before:transition-all before:duration-150 before:content-[''] dark:before:shadow-[inset_0_-2px_0_0_var(--color-alpha-light-25)]", "active:*:translate-y-0.5 active:before:shadow-[inset_0_2px_2px_0_var(--color-alpha-dark-50)] dark:active:before:shadow-[inset_0_2px_2px_0_var(--color-alpha-light-50)]", - "disabled:active:*:translate-y-0" + "disabled:before:shadow-none disabled:active:*:translate-y-0" ] } ], diff --git a/web/src/lib/components/ui/Radio.svelte b/web/src/lib/components/ui/Radio.svelte --- a/web/src/lib/components/ui/Radio.svelte +++ b/web/src/lib/components/ui/Radio.svelte @@ -31,7 +31,7 @@ {value} bind:group {disabled} - class="peer size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-border-strong + class="peer size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-foreground-placeholder bg-background-default transition-colors duration-150 checked:border-foreground-default hover:bg-background-subtle focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground-default diff --git a/web/src/lib/components/ui/Tag.svelte b/web/src/lib/components/ui/Tag.svelte --- a/web/src/lib/components/ui/Tag.svelte +++ b/web/src/lib/components/ui/Tag.svelte @@ -2,15 +2,26 @@ import { tv, type VariantProps } from "tailwind-variants"; export const tag = tv({ - base: "inline-flex items-center gap-1 rounded px-1.5 py-0.5 typography-paragraph-small leading-none font-medium select-none", + base: "inline-flex items-center gap-1 rounded leading-none font-medium select-none", variants: { color: { default: "border border-border-default bg-background-default text-foreground-default", - gray: "border border-transparent bg-background-inset text-foreground-muted" + gray: "border border-transparent bg-background-inset text-foreground-muted", + // status badges, per Figma's Tag usage on the settings screens + success: + "border border-transparent bg-background-success-subtle text-foreground-success-strong", + warning: "border border-transparent bg-background-warning-subtle text-foreground-warning", + info: "border border-transparent bg-background-info-subtle text-foreground-info-strong" + }, + size: { + /** the DS badge: 16px tall, mini type — what the settings screens use */ + sm: "px-1 typography-paragraph-mini", + md: "px-1.5 py-0.5 typography-paragraph-small" } }, defaultVariants: { - color: "default" + color: "default", + size: "md" } }); @@ -24,15 +35,23 @@ interface Props extends Omit, "class" | "children"> { color?: TagVariants["color"]; + size?: TagVariants["size"]; /** Optional leading icon component (e.g. `import Hash from "$icon/hash"`). */ icon?: Component; class?: string; children?: Snippet; } - let { color = "default", icon, class: className, children, ...rest }: Props = $props(); + let { + color = "default", + size = "md", + icon, + class: className, + children, + ...rest + }: Props = $props(); - const classes = $derived(tag({ color, class: className })); + const classes = $derived(tag({ color, size, class: className })); const Icon = $derived(icon); diff --git a/web/src/lib/components/settings/tabs/EmailsTab.svelte b/web/src/lib/components/settings/tabs/EmailsTab.svelte --- a/web/src/lib/components/settings/tabs/EmailsTab.svelte +++ b/web/src/lib/components/settings/tabs/EmailsTab.svelte @@ -1,12 +1,89 @@ -
-

Email addresses

-

- Commits authored using emails listed here will be associated with your Tangled profile. -

- -
- Coming soon -
+ + + + +
+ + {#snippet end()} + + {/snippet} + + + {#if emails.length === 0} + + {:else} + + {#each emails as email (email.id)} + + {#snippet tags()} + {#if email.verified} + Verified + {/if} + {#if email.primary} + Primary + {/if} + {/snippet} + {#snippet meta()} + {email.added} + {/snippet} + {#snippet actions()} + {#if !email.primary} + + {/if} + + {/snippet} + + {/each} + + {/if}
diff --git a/web/src/lib/components/settings/tabs/KeysTab.svelte b/web/src/lib/components/settings/tabs/KeysTab.svelte --- a/web/src/lib/components/settings/tabs/KeysTab.svelte +++ b/web/src/lib/components/settings/tabs/KeysTab.svelte @@ -3,18 +3,22 @@ import { createBobbinClient } from "$lib/api/client"; import { listPubKeys, - createPubKey, deletePubKey, type ListedRecord, type PublicKeyRecord } from "$lib/api/settings"; - import Section from "$lib/components/ui/Section.svelte"; import Button from "$lib/components/ui/Button.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte"; import ErrorAlert from "$lib/components/ui/Error.svelte"; - import KeyCard from "../KeyCard.svelte"; - import AddKeyForm from "../AddKeyForm.svelte"; + import EmptyState from "$lib/components/ui/EmptyState.svelte"; + import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; + import SettingsHeader from "../SettingsHeader.svelte"; + import SettingsToolbar from "../SettingsToolbar.svelte"; + import SettingsList from "../SettingsList.svelte"; + import SettingsEntry from "../SettingsEntry.svelte"; import Plus from "$icon/plus"; + import Trash from "$icon/trash-2"; + import Key from "$icon/key"; const auth = getAuth(); const user = $derived(auth.currentUser); @@ -23,12 +27,6 @@ let fingerprints = $state>({}); let loading = $state(true); let loadError = $state(null); - - let popover = $state(); - let name = $state(""); - let key = $state(""); - let submitting = $state(false); - let formError = $state(null); let deleting = $state(null); // ssh SHA256 fingerprint of the base64 key blob, matching `ssh-keygen -lf`. @@ -70,41 +68,6 @@ if (user?.did) void load(); }); - const resetForm = () => { - name = ""; - key = ""; - formError = null; - }; - - const add = async () => { - if (!auth.agent) return; - submitting = true; - formError = null; - try { - const trimmedName = name.trim(); - const trimmedKey = key.trim(); - const rkey = await createPubKey(auth.agent, trimmedName, trimmedKey); - const createdAt = new Date().toISOString(); - // prepend to local state (newest first) instead of refetching everything. - keys = [ - { - uri: `at://${auth.agent.sub}/sh.tangled.publicKey/${rkey}`, - rkey, - value: { $type: "sh.tangled.publicKey", name: trimmedName, key: trimmedKey, createdAt } - }, - ...keys - ]; - const fp = await sshFingerprint(trimmedKey); - if (fp) fingerprints = { ...fingerprints, [rkey]: fp }; - resetForm(); - popover?.hidePopover(); - } catch (err) { - formError = err instanceof Error ? err.message : "Failed to add key"; - } finally { - submitting = false; - } - }; - const remove = async (k: ListedRecord) => { if (!auth.agent) return; if (!confirm(`Are you sure you want to delete the key ${k.value.name}?`)) return; @@ -121,60 +84,56 @@ }; -
-
-
-

SSH keys

- -
-

- SSH public keys added here will be broadcasted to knots that you are a member of, allowing you - to push to repositories there and verify commit signatures. -

-
+ -
{ - if ((e as ToggleEvent).newState === "closed") resetForm(); - }} - class="m-auto w-96 max-w-[90vw] rounded border border-border-default bg-background-default p-4 text-foreground-default shadow-lg backdrop:bg-black/40" - > - popover?.hidePopover()} - /> -
+
+ + {#snippet end()} + + {/snippet} + {#if loadError} {/if} {#if loading} -
+
Loading…
+ {:else if keys.length === 0} + {:else} -
+ {#each keys as k (k.rkey)} - remove(k)} - /> + + {#snippet meta()} + {#if fingerprints[k.rkey]} + + {fingerprints[k.rkey]} + + {/if} + + added + + {/snippet} + {#snippet actions()} + + {/snippet} + {/each} -
+ {/if}
diff --git a/web/src/lib/components/settings/tabs/KnotsTab.svelte b/web/src/lib/components/settings/tabs/KnotsTab.svelte --- a/web/src/lib/components/settings/tabs/KnotsTab.svelte +++ b/web/src/lib/components/settings/tabs/KnotsTab.svelte @@ -1,13 +1,80 @@ -
-

Knots

-

- Knots are lightweight headless servers that enable users to host Git repositories with ease. - When creating a repository, you can choose a knot to store it on. -

- -
- Coming soon -
+ + + + +
+ + {#snippet start()} + + {/snippet} + {#snippet end()} + + {/snippet} + + + {#if knots.length === 0} + + {:else} + + {#each knots as knot (knot.id)} + + {#snippet tags()} + {#if knot.verified} + Verified + {:else} + Unverified + {/if} + {/snippet} + {#snippet meta()} + {knot.added} + {/snippet} + {#snippet actions()} + {#if !knot.verified} + + {/if} + + {/snippet} + + {/each} + + {/if}
diff --git a/web/src/lib/components/settings/tabs/NotificationsTab.svelte b/web/src/lib/components/settings/tabs/NotificationsTab.svelte --- a/web/src/lib/components/settings/tabs/NotificationsTab.svelte +++ b/web/src/lib/components/settings/tabs/NotificationsTab.svelte @@ -1,15 +1,129 @@ -
-

- Notification preferences -

-

- Choose which notifications you want to receive when activity happens on your repositories and - profile. -

- -
- Coming soon -
-
+ + + + {#snippet action()} + + {/snippet} + + + + {#each prefs as pref (pref.id)} + {@const Glyph = pref.icon} +
+
+ + + {pref.description} +
+ +
+ {/each} +
diff --git a/web/src/lib/components/settings/tabs/ProfileTab.svelte b/web/src/lib/components/settings/tabs/ProfileTab.svelte --- a/web/src/lib/components/settings/tabs/ProfileTab.svelte +++ b/web/src/lib/components/settings/tabs/ProfileTab.svelte @@ -2,6 +2,21 @@ import { getAuth } from "$lib/auth.svelte"; import { createBobbinClient } from "$lib/api/client"; import { resolveMiniDoc } from "$lib/api/identity"; + import Avatar from "$lib/components/ui/Avatar.svelte"; + import Button from "$lib/components/ui/Button.svelte"; + import Input from "$lib/components/ui/Input.svelte"; + import Select from "$lib/components/ui/Select.svelte"; + import Toggle from "$lib/components/ui/Toggle.svelte"; + import SettingsSection from "../SettingsSection.svelte"; + import SettingsRow from "../SettingsRow.svelte"; + import CodeChip from "../CodeChip.svelte"; + import ThemePicker from "../ThemePicker.svelte"; + import Pencil from "$icon/pencil"; + import Save from "$icon/save"; + import RotateCcw from "$icon/rotate-ccw"; + import Key from "$icon/key"; + import Pause from "$icon/pause"; + import Trash from "$icon/trash-2"; const auth = getAuth(); const user = $derived(auth.currentUser); @@ -24,26 +39,129 @@ cancelled = true; }; }); + + // everything below is mocked — nothing is persisted yet + const EMPTY = { + bio: "", + pronouns: "", + location: "", + bluesky: true, + links: ["", "", ""], + stats: ["", ""], + hideMine: false, + hideOthers: false, + theme: "light" as "auto" | "light" | "dark" + }; + + let form = $state(structuredClone(EMPTY)); + + // Figma keeps Save disabled until something actually changed, and only then + // reveals Reset and the "Unsaved changes" note + const dirty = $derived(JSON.stringify(form) !== JSON.stringify(EMPTY)); + const reset = () => (form = structuredClone(EMPTY)); + + const STATS = ["Repositories", "Followers", "Stars", "Pull requests", "Issues"]; -
-

Profile

-

- Your account information from your AT Protocol identity. -

- -
-
- Handle - {user?.handle ?? "…"} -
-
- Decentralized Identifier (DID) - {user?.did ?? "…"} -
-
- Personal Data Server (PDS) - {pds ?? "…"} -
+{#snippet actions()} +
+ {#if dirty} + + Unsaved changes + {/if} +
+{/snippet} + +
+

Profile

+ {@render actions()} +
+ +
+ +
+ + +
+ + + + + + + + + + + + + +
+ {#each form.links.keys() as i (i)} + + {/each} +
+
+ +
+ {#each form.stats.keys() as i (i)} + + {/each} +
+
+
+ + + + {user?.handle ?? "…"} + + + + {user?.did ?? "…"} + + + {pds ?? "…"} + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ +
+ {@render actions()}
diff --git a/web/src/lib/components/settings/tabs/SitesTab.svelte b/web/src/lib/components/settings/tabs/SitesTab.svelte --- a/web/src/lib/components/settings/tabs/SitesTab.svelte +++ b/web/src/lib/components/settings/tabs/SitesTab.svelte @@ -1,4 +1,58 @@ -
-

Git sites

- + + + + +
+ + {#snippet start()} + + {/snippet} + + + {#if sites.length === 0} + + {:else} + + {#each sites as site (site.id)} + + {#snippet tags()} + {#if site.active} + Active + {/if} + {/snippet} + {#snippet actions()} + + {/snippet} + + {/each} + + {/if}
diff --git a/web/src/lib/components/settings/tabs/SpindlesTab.svelte b/web/src/lib/components/settings/tabs/SpindlesTab.svelte --- a/web/src/lib/components/settings/tabs/SpindlesTab.svelte +++ b/web/src/lib/components/settings/tabs/SpindlesTab.svelte @@ -1,12 +1,77 @@ -
-

Spindles

-

- Spindles are servers that run continuous integration pipelines for your repositories. -

- -
- Coming soon -
+ + + + +
+ + {#snippet start()} + + {/snippet} + {#snippet end()} + + {/snippet} + + + {#if spindles.length === 0} + + {:else} + + {#each spindles as spindle (spindle.id)} + + {#snippet tags()} + {#if spindle.verified} + Verified + {:else} + Unverified + {/if} + {/snippet} + {#snippet meta()} + {spindle.added} + {/snippet} + {#snippet actions()} + {#if !spindle.verified} + + {/if} + + {/snippet} + + {/each} + + {/if}
diff --git a/web/src/routes/settings/emails/new/+page.svelte b/web/src/routes/settings/emails/new/+page.svelte new file mode 100644 --- /dev/null +++ b/web/src/routes/settings/emails/new/+page.svelte @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + diff --git a/web/src/routes/settings/keys/new/+page.svelte b/web/src/routes/settings/keys/new/+page.svelte new file mode 100644 --- /dev/null +++ b/web/src/routes/settings/keys/new/+page.svelte @@ -0,0 +1,64 @@ + + + + {#if error} + + {/if} + + + + + + + + + + + + + + + diff --git a/web/src/routes/settings/knots/new/+page.svelte b/web/src/routes/settings/knots/new/+page.svelte new file mode 100644 --- /dev/null +++ b/web/src/routes/settings/knots/new/+page.svelte @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + diff --git a/web/src/routes/settings/profile/avatar/+page.svelte b/web/src/routes/settings/profile/avatar/+page.svelte new file mode 100644 --- /dev/null +++ b/web/src/routes/settings/profile/avatar/+page.svelte @@ -0,0 +1,52 @@ + + + + + +
+ + {file ? file.name : "No file selected."} + + (file = e.currentTarget.files?.[0] ?? null)} + /> + +
+
+
+ + + {#snippet start()} + + {/snippet} + + + +
diff --git a/web/src/routes/settings/profile/deactivate/+page.svelte b/web/src/routes/settings/profile/deactivate/+page.svelte new file mode 100644 --- /dev/null +++ b/web/src/routes/settings/profile/deactivate/+page.svelte @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + diff --git a/web/src/routes/settings/profile/delete/+page.svelte b/web/src/routes/settings/profile/delete/+page.svelte new file mode 100644 --- /dev/null +++ b/web/src/routes/settings/profile/delete/+page.svelte @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + diff --git a/web/src/routes/settings/profile/handle/+page.svelte b/web/src/routes/settings/profile/handle/+page.svelte new file mode 100644 --- /dev/null +++ b/web/src/routes/settings/profile/handle/+page.svelte @@ -0,0 +1,59 @@ + + + + + +
+ +
+
+ + {#if ownDomain} + + {:else} + +
+ + + {SUFFIX} + +
+ {/if} +
+
+ + + + + +
diff --git a/web/src/routes/settings/profile/password/+page.svelte b/web/src/routes/settings/profile/password/+page.svelte new file mode 100644 --- /dev/null +++ b/web/src/routes/settings/profile/password/+page.svelte @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + diff --git a/web/src/routes/settings/spindles/new/+page.svelte b/web/src/routes/settings/spindles/new/+page.svelte new file mode 100644 --- /dev/null +++ b/web/src/routes/settings/spindles/new/+page.svelte @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + -- tangled.sh