From 73260e54c50030d065ff9aa6b111a16d8afce762 Mon Sep 17 00:00:00 2001 From: dawn <90008@gaze.systems> Date: Sun, 31 May 2026 16:28:54 +0300 Subject: [PATCH] settings page wip --- AGENTS.md | 33 + public/oauth-client-metadata.json | 2 +- src/index.css | 18 +- src/lib/api/settings.ts | 255 ++++++ src/pages/settings.tsx | 1340 +++++++++++++++++++++++++++++ src/routes.tsx | 22 + 6 files changed, 1668 insertions(+), 2 deletions(-) create mode 100644 src/lib/api/settings.ts create mode 100644 src/pages/settings.tsx diff --git a/AGENTS.md b/AGENTS.md index ce5578d..d6e564c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,39 @@ changing local components: - Remove or hide upstream controls that are not implemented locally instead of shipping fake UI. - External upstream-only affordances can link to `https://tangled.org`, such as repo stars and `/:owner/:repo/feed.atom`. +## Local UI Style + +Upstream Tangled templates are the baseline, but Untangled should also preserve +the local design language already present in the Solid app. Before copying an +upstream class literally, compare nearby local pages and shared components. + +- Favor a quiet, dense tool UI: compact spacing, small radii, restrained + borders, and flat page sections. Avoid marketing-style cards, oversized + type, decorative gradients, and unnecessary nested panels. +- Top-level route titles are usually plain lowercase labels. Use uppercase + tracking only for compact metadata labels, table/list section labels, or + places where the local surrounding UI already uses that treatment. +- Action labels, nav labels, badges, pagination, and small utility links are + generally lowercase. Keep this consistent unless the surrounding local + component clearly uses title case. +- Prefer established primitives (`btn`, `btn-create`, `btn-flat`, + `buttonStyles`, `StateBadge`, `ToggleButton`) over custom button and badge + stacks. Preserve their icon sizes, min-heights, padding, hover behavior, and + inner icon/text grouping. +- Icon buttons and icon+text actions should use Lucide icons already used in + the app. Keep icons visible, aligned, and sized consistently with adjacent + controls. +- Badges shown next to controls should match the controls' visual density and + height. Avoid badge styles that look like separate one-off pills unless a + shared badge component already does that. +- Dialogs and destructive/creation popovers should feel like app chrome, not a + page section: center modal dialogs in the viewport, use a real backdrop when + blocking interaction, and prefer spacing over extra separator lines when the + header/body relationship is already clear. +- When Tailwind utilities are missing from `public/static/tw.css`, add a small + explicit `.untangled-*` rule in `src/index.css` rather than relying on an + unavailable arbitrary class. + ## Tailwind/CSS Pitfall The checked-in `public/static/tw.css` does not include all arbitrary Tailwind diff --git a/public/oauth-client-metadata.json b/public/oauth-client-metadata.json index 427bb36..2d69e0b 100644 --- a/public/oauth-client-metadata.json +++ b/public/oauth-client-metadata.json @@ -3,7 +3,7 @@ "client_name": "untangled", "client_uri": "https://untangled.wisp.place", "redirect_uris": ["https://untangled.wisp.place/oauth/callback"], - "scope": "atproto repo:sh.tangled.actor.profile repo:sh.tangled.repo repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* rpc:sh.tangled.repo.create?aud=* repo:sh.tangled.feed.comment repo:sh.tangled.string repo:sh.tangled.feed.star blob:*/*", + "scope": "atproto repo:sh.tangled.actor.profile repo:sh.tangled.publicKey repo:sh.tangled.repo repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* rpc:sh.tangled.repo.create?aud=* repo:sh.tangled.feed.comment repo:sh.tangled.string repo:sh.tangled.feed.star blob:*/*", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none", diff --git a/src/index.css b/src/index.css index cc03a7e..233e32a 100644 --- a/src/index.css +++ b/src/index.css @@ -784,6 +784,18 @@ pre { padding-bottom: max(2rem, calc(1.5rem + env(safe-area-inset-bottom))); } +.untangled-settings-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1.5rem; +} + +@media (min-width: 768px) { + .untangled-settings-grid { + grid-template-columns: 15rem minmax(0, 1fr); + } +} + .untangled-repo-frame-full { box-sizing: border-box; margin-left: calc(50% - 50vw); @@ -3974,6 +3986,10 @@ details[open] > .untangled-language-bar { } } - +/* Checkbox specific overrides to prevent extra layout padding */ +.untangled-checkbox { + padding: 0 !important; + margin: 0 !important; +} diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts new file mode 100644 index 0000000..bb24b7a --- /dev/null +++ b/src/lib/api/settings.ts @@ -0,0 +1,255 @@ +import { ok } from '@atcute/client'; +import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; +import { getRpc, createAuthRpc } from './client'; +import { resolveActor } from './identity'; +import { parseAtUri } from './records'; + +export interface PublicKeyRecord { + name: string; + key: string; + createdAt: string; + rkey: string; + uri: string; +} + +export interface KnotRecord { + domain: string; + createdAt: string; + rkey: string; + uri: string; +} + +export interface SpindleRecord { + instance: string; + createdAt: string; + rkey: string; + uri: string; +} + +export const listPublicKeys = async (did: string): Promise => { + const actor = await resolveActor(did); + const rpc = getRpc(actor.pds); + const keys: PublicKeyRecord[] = []; + let cursor: string | undefined; + + try { + do { + const page = await ok( + rpc.get('com.atproto.repo.listRecords', { + params: { + repo: actor.did, + collection: 'sh.tangled.publicKey', + limit: 100, + cursor, + reverse: true, + }, + }), + ); + + for (const r of page.records) { + const rkey = parseAtUri(r.uri).rkey; + const val = r.value as any; + keys.push({ + name: val.name || '', + key: val.key || '', + createdAt: val.createdAt || '', + rkey, + uri: r.uri, + }); + } + cursor = page.cursor; + } while (cursor); + } catch (err) { + console.error('Failed to list public keys from PDS:', err); + } + + return keys; +}; + +export const addPublicKey = async ( + agent: OAuthUserAgent, + name: string, + key: string, +): Promise => { + const rpc = createAuthRpc(agent); + await ok( + rpc.post('com.atproto.repo.createRecord', { + input: { + repo: agent.sub, + collection: 'sh.tangled.publicKey', + record: { + $type: 'sh.tangled.publicKey', + name, + key, + createdAt: new Date().toISOString(), + }, + }, + }), + ); +}; + +export const deletePublicKey = async ( + agent: OAuthUserAgent, + rkey: string, +): Promise => { + const rpc = createAuthRpc(agent); + await ok( + rpc.post('com.atproto.repo.deleteRecord', { + input: { + repo: agent.sub, + collection: 'sh.tangled.publicKey', + rkey, + }, + }), + ); +}; + +export const listKnots = async (did: string): Promise => { + const actor = await resolveActor(did); + const rpc = getRpc(actor.pds); + const knots: KnotRecord[] = []; + let cursor: string | undefined; + + try { + do { + const page = await ok( + rpc.get('com.atproto.repo.listRecords', { + params: { + repo: actor.did, + collection: 'sh.tangled.knot', + limit: 100, + cursor, + reverse: true, + }, + }), + ); + + for (const r of page.records) { + const rkey = parseAtUri(r.uri).rkey; + const val = r.value as any; + knots.push({ + domain: rkey, + createdAt: val.createdAt || '', + rkey, + uri: r.uri, + }); + } + cursor = page.cursor; + } while (cursor); + } catch (err) { + console.error('Failed to list knots from PDS:', err); + } + + return knots; +}; + +export const registerKnot = async ( + agent: OAuthUserAgent, + domain: string, +): Promise => { + const rpc = createAuthRpc(agent); + await ok( + rpc.post('com.atproto.repo.putRecord', { + input: { + repo: agent.sub, + collection: 'sh.tangled.knot', + rkey: domain, + record: { + $type: 'sh.tangled.knot', + createdAt: new Date().toISOString(), + }, + }, + }), + ); +}; + +export const unregisterKnot = async ( + agent: OAuthUserAgent, + domain: string, +): Promise => { + const rpc = createAuthRpc(agent); + await ok( + rpc.post('com.atproto.repo.deleteRecord', { + input: { + repo: agent.sub, + collection: 'sh.tangled.knot', + rkey: domain, + }, + }), + ); +}; + +export const listSpindles = async (did: string): Promise => { + const actor = await resolveActor(did); + const rpc = getRpc(actor.pds); + const spindles: SpindleRecord[] = []; + let cursor: string | undefined; + + try { + do { + const page = await ok( + rpc.get('com.atproto.repo.listRecords', { + params: { + repo: actor.did, + collection: 'sh.tangled.spindle', + limit: 100, + cursor, + reverse: true, + }, + }), + ); + + for (const r of page.records) { + const rkey = parseAtUri(r.uri).rkey; + const val = r.value as any; + spindles.push({ + instance: rkey, + createdAt: val.createdAt || '', + rkey, + uri: r.uri, + }); + } + cursor = page.cursor; + } while (cursor); + } catch (err) { + console.error('Failed to list spindles from PDS:', err); + } + + return spindles; +}; + +export const registerSpindle = async ( + agent: OAuthUserAgent, + instance: string, +): Promise => { + const rpc = createAuthRpc(agent); + await ok( + rpc.post('com.atproto.repo.putRecord', { + input: { + repo: agent.sub, + collection: 'sh.tangled.spindle', + rkey: instance, + record: { + $type: 'sh.tangled.spindle', + createdAt: new Date().toISOString(), + }, + }, + }), + ); +}; + +export const unregisterSpindle = async ( + agent: OAuthUserAgent, + instance: string, +): Promise => { + const rpc = createAuthRpc(agent); + await ok( + rpc.post('com.atproto.repo.deleteRecord', { + input: { + repo: agent.sub, + collection: 'sh.tangled.spindle', + rkey: instance, + }, + }), + ); +}; diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx new file mode 100644 index 0000000..9c4c384 --- /dev/null +++ b/src/pages/settings.tsx @@ -0,0 +1,1340 @@ +import { + For, + Show, + createSignal, + createEffect, + createMemo, + createResource, + type Component, + type JSX, +} from 'solid-js'; +import { Portal } from 'solid-js/web'; +import { A, useNavigate, useParams, useLocation, type RouteSectionProps } from '@solidjs/router'; +import { createQuery, useQueryClient } from '@tanstack/solid-query'; +import { + User, + Key, + Mail, + Bell, + HardDrive, + Volleyball, + Spool, + Plus, + Trash2, + RotateCw, + X, + Book, + ShieldCheck, + BookMarked, + LoaderCircle, + Save, +} from 'lucide-solid'; +import { useAuth } from '../lib/auth'; +import { formatRelativeTime, getErrorMessage } from '../lib/repo-utils'; +import { Avatar, inputStyles, textareaStyles } from '../components/common'; +import { resolveActor } from '../lib/api/identity'; +import { listRepoRecords } from '../lib/api/repos'; +import { + listPublicKeys, + addPublicKey, + deletePublicKey, + listKnots, + registerKnot, + unregisterKnot, + listSpindles, + registerSpindle, + unregisterSpindle, + type PublicKeyRecord, +} from '../lib/api/settings'; + +// Helper to compute SSH Fingerprint using SubtleCrypto +export const getSshFingerprint = async (key: string): Promise => { + try { + const parts = key.trim().split(/\s+/); + if (parts.length < 2) return 'Invalid SSH Key format'; + const base64Data = parts[1]; + const binaryString = atob(base64Data); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + const hashBuffer = await window.crypto.subtle.digest('SHA-256', bytes); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashString = hashArray.map(b => String.fromCharCode(b)).join(''); + const base64Hash = btoa(hashString).replace(/=+$/, ''); + return `SHA256:${base64Hash}`; + } catch (e) { + console.error('Fingerprint generation failed:', e); + return 'Error generating fingerprint'; + } +}; + +// Reusable Modal Component matching design system +const Modal: Component<{ + isOpen: boolean; + onClose: () => void; + title: string; + children: JSX.Element; +}> = (props) => { + return ( + + +
+
event.stopPropagation()} + > +
+

{props.title}

+ +
+
{props.children}
+
+
+
+
+ ); +}; + +// Sidebar active/inactive styling matching upstream template +const UserSettingsTabs = [ + { name: 'profile', label: 'profile', icon: User, path: '/settings/profile' }, + { name: 'keys', label: 'keys', icon: Key, path: '/settings/keys' }, + { name: 'emails', label: 'emails', icon: Mail, path: '/settings/emails' }, + { name: 'notifications', label: 'notifications', icon: Bell, path: '/settings/notifications' }, + { name: 'knots', label: 'knots', icon: Volleyball, path: '/settings/knots' }, + { name: 'spindles', label: 'spindles', icon: Spool, path: '/settings/spindles' }, +]; + +const settingsButtonClass = 'btn flex items-center gap-2'; +const settingsDangerButtonClass = + 'btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group'; +const settingsRegisterButtonClass = + 'btn rounded flex items-center dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group'; +const verifiedBadgeClass = + 'min-h-8 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm'; +const dashboardVerifiedBadgeClass = + 'min-h-8 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm'; + +export const SettingsLayout: Component = (props) => { + const auth = useAuth(); + const location = useLocation(); + + const isTabActive = (tabName: string, path: string) => { + if (tabName === 'knots' && location.pathname.startsWith('/settings/knots')) { + return true; + } + if (tabName === 'spindles' && location.pathname.startsWith('/settings/spindles')) { + return true; + } + return location.pathname === path; + }; + + return ( + +

settings

+
+ Please sign in to access settings. +
+ + } + > +
+

settings

+
+
+
+ {/* Sidebar Navigation */} +
+
+ + {(tab) => { + const active = () => isTabActive(tab.name, tab.path); + return ( + +
+ + {tab.label} +
+
+ ); + }} +
+
+
+ + {/* Tab Content */} +
+ {props.children} +
+
+
+
+ ); +}; + +// Profile settings tab implementation +export const ProfileSettingsTab: Component = () => { + const auth = useAuth(); + const [punchcardHideMine, setPunchcardHideMine] = createSignal(false); + const [punchcardHideOthers, setPunchcardHideOthers] = createSignal(false); + + // Load punchcard preference from localStorage + createEffect(() => { + const did = auth.currentDid(); + if (did) { + const mine = localStorage.getItem(`untangled.punchcard.hideMine.${did}`) === 'true'; + const others = localStorage.getItem(`untangled.punchcard.hideOthers.${did}`) === 'true'; + setPunchcardHideMine(mine); + setPunchcardHideOthers(others); + } + }); + + // Save punchcard preferences + const handlePunchcardChange = (field: 'mine' | 'others', value: boolean) => { + const did = auth.currentDid(); + if (!did) return; + if (field === 'mine') { + setPunchcardHideMine(value); + localStorage.setItem(`untangled.punchcard.hideMine.${did}`, String(value)); + } else { + setPunchcardHideOthers(value); + localStorage.setItem(`untangled.punchcard.hideOthers.${did}`, String(value)); + } + }; + + const actorQuery = createQuery(() => ({ + queryKey: ['actor', auth.currentDid()], + queryFn: async () => resolveActor(auth.currentDid()!), + enabled: Boolean(auth.currentDid()), + })); + + return ( +
+ {/* Profile Info Section */} +
+

PROFILE

+

+ Your account information from your AT Protocol identity. +

+
+
+ Handle +
+ + {actorQuery.isLoading ? 'Loading...' : actorQuery.data?.handle || auth.currentDid()} + +
+
+
+ Decentralized Identifier (DID) + {auth.currentDid()} +
+
+ Personal Data Server (PDS) + {actorQuery.isLoading ? 'Loading...' : actorQuery.data?.pds || 'Unknown PDS'} +
+
+
+ + {/* Punchcard Preferences Section */} +
+

PUNCHCARD

+

+ Configure punchcard visibility and preferences. +

+
+
+ handlePunchcardChange('mine', e.currentTarget.checked)} + class="my-0 py-0 rounded border-gray-300 dark:border-gray-600 text-blue-600 size-4 p-0 m-0 untangled-checkbox" + /> + +
+
+ handlePunchcardChange('others', e.currentTarget.checked)} + class="my-0 py-0 rounded border-gray-300 dark:border-gray-600 text-blue-600 size-4 p-0 m-0 untangled-checkbox" + /> + +
+
+
+
+ ); +}; + +// SSH Keys settings tab implementation +const SshKeyRow: Component<{ + keyRecord: PublicKeyRecord; + onDelete: () => void; +}> = (props) => { + const [fingerprint] = createResource(() => getSshFingerprint(props.keyRecord.key)); + + return ( +
+
+
+ + {props.keyRecord.name} +
+ + {fingerprint.loading ? 'Calculating fingerprint...' : fingerprint()} + +
+ added {formatRelativeTime(props.keyRecord.createdAt)} +
+
+ +
+ ); +}; + +export const KeysSettingsTab: Component = () => { + const auth = useAuth(); + const queryClient = useQueryClient(); + const [isOpen, setIsOpen] = createSignal(false); + const [keyName, setKeyName] = createSignal(''); + const [keyValue, setKeyValue] = createSignal(''); + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(''); + + const keysQuery = createQuery(() => ({ + queryKey: ['settings', 'keys', auth.currentDid()], + queryFn: async () => listPublicKeys(auth.currentDid()!), + enabled: Boolean(auth.currentDid()), + })); + + const handleAddKey = async (e: Event) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const agent = auth.agent(); + if (!agent) throw new Error('Not authenticated.'); + if (!keyValue().trim().startsWith('ssh-')) { + throw new Error("Invalid key format. SSH public keys usually start with 'ssh-'."); + } + + await addPublicKey(agent, keyName().trim(), keyValue().trim()); + await queryClient.refetchQueries({ queryKey: ['settings', 'keys', auth.currentDid()] }); + setKeyName(''); + setKeyValue(''); + setIsOpen(false); + } catch (err: unknown) { + console.error(err); + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }; + + const handleDeleteKey = async (rkey: string) => { + try { + const agent = auth.agent(); + if (!agent) throw new Error('Not authenticated.'); + + await deletePublicKey(agent, rkey); + await queryClient.refetchQueries({ queryKey: ['settings', 'keys', auth.currentDid()] }); + } catch (err: any) { + console.error(err); + alert(err.message || 'Failed to delete SSH key.'); + } + }; + + return ( +
+
+
+

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. +

+
+
+ +
+
+ +
+ Loading SSH keys...
} + > + + No keys added yet +
+ } + > + {(k) => handleDeleteKey(k.rkey)} />} + + + + + setIsOpen(false)} title="add ssh key"> +
+

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

+
+ + setKeyName(e.currentTarget.value)} + class={inputStyles()} + required + disabled={loading()} + /> +
+
+ +