Something went wrong. Try again.
csr tangled client in solid-js
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359import clsx from 'clsx';import { Bell, LogOut, RotateCcw, Save, Search, Settings } from 'lucide-solid';import { A, useLocation, useNavigate } from '@solidjs/router';import { createQuery, useQueryClient } from '@tanstack/solid-query';import { For, Show, createEffect, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js';
import { Avatar, buttonStyles, inputStyles } from './components/common';import { clearApiCaches, resolveActor } from './lib/api/identity';import { useAuth } from './lib/auth';import { useLiveEvents, type LiveEvent } from './lib/live-events';import { formatRelativeTime } from './lib/repo-utils';import { useAppSettings } from './lib/settings';
const TOPBAR_MENU_SELECTOR = 'details[data-topbar-menu]';
const closeOpenTopbarMenus = (root: HTMLElement, target: Node) => { root.querySelectorAll<HTMLDetailsElement>(TOPBAR_MENU_SELECTOR).forEach((menu) => { if (menu.open && !menu.contains(target)) { menu.open = false; } });};
const eventLabel = (event: LiveEvent) => { if (event.source === 'sh.tangled.repo.issue:repo') { return `new issue on ${event.subject}`; }
if (event.source === 'sh.tangled.repo.pull:target.repo') { return `new pull on ${event.subject}`; }
return `${event.source} -> ${event.subject}`;};
const AppSettingsMenu: Component = () => { const settings = useAppSettings(); const queryClient = useQueryClient(); const [draftAppviewUrl, setDraftAppviewUrl] = createSignal(settings.appviewUrl()); const [draftRouteKnotRequestsThroughAppview, setDraftRouteKnotRequestsThroughAppview] = createSignal( settings.routeKnotRequestsThroughAppview(), ); const [error, setError] = createSignal<string | null>(null); const [saved, setSaved] = createSignal(false);
createEffect(() => { setDraftAppviewUrl(settings.appviewUrl()); setDraftRouteKnotRequestsThroughAppview(settings.routeKnotRequestsThroughAppview()); });
const reloadAppviewBackedQueries = () => { clearApiCaches(); void queryClient.invalidateQueries(); };
const onSave = (event: SubmitEvent) => { event.preventDefault(); try { settings.setAppviewUrl(draftAppviewUrl()); settings.setRouteKnotRequestsThroughAppview(draftRouteKnotRequestsThroughAppview()); setError(null); setSaved(true); reloadAppviewBackedQueries(); } catch (cause) { setSaved(false); setError(cause instanceof Error ? cause.message : 'Invalid appview URL'); } };
const onReset = () => { settings.resetAppviewSettings(); setDraftAppviewUrl(settings.defaultAppviewUrl); setDraftRouteKnotRequestsThroughAppview(false); setError(null); setSaved(true); reloadAppviewBackedQueries(); };
return ( <details data-topbar-menu class="relative"> <summary class={clsx('cursor-pointer list-none')} title="App settings"> <Settings class="size-5 text-gray-500 dark:text-gray-400" /> </summary> <div class="absolute right-0 mt-3 w-96 max-w-[calc(100vw-2rem)] rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-lg z-50"> <form onSubmit={onSave} class="flex flex-col gap-4"> <div class="space-y-1"> <label for="appview-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200"> appview URL </label> <input id="appview-url" value={draftAppviewUrl()} onInput={(event) => { setDraftAppviewUrl(event.currentTarget.value); setSaved(false); setError(null); }} placeholder={settings.defaultAppviewUrl} class={inputStyles()} /> </div>
<label class="flex items-center gap-3 text-sm text-gray-700 dark:text-gray-200"> <input type="checkbox" checked={draftRouteKnotRequestsThroughAppview()} onChange={(event) => { setDraftRouteKnotRequestsThroughAppview(event.currentTarget.checked); setSaved(false); setError(null); }} class="size-4 rounded border-gray-300 dark:border-gray-600" /> <span>route knot requests through appview</span> </label>
<div class="flex items-center justify-between gap-3"> <button type="button" class={buttonStyles()} onClick={onReset}> <RotateCcw class="size-4" /> reset </button> <button type="submit" class={buttonStyles('primary')}> <Save class="size-4" /> save </button> </div>
<Show when={error()}> <p class="text-sm text-red-500">{error()}</p> </Show> <Show when={saved()}> <p class="text-sm text-gray-500 dark:text-gray-400">saved</p> </Show> </form> </div> </details> );};
const LoginMenu: Component = () => { const auth = useAuth(); const [identifier, setIdentifier] = createSignal('');
const onSignIn = async (event: SubmitEvent) => { event.preventDefault(); await auth.signIn(identifier()); };
return ( <details data-topbar-menu class="relative"> <summary class={clsx(buttonStyles(), 'cursor-pointer list-none')}>login</summary> <div class="absolute right-0 mt-3 w-80 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-lg z-50"> <form onSubmit={onSignIn} class="flex flex-col gap-3"> <input value={identifier()} onInput={(event) => setIdentifier(event.currentTarget.value)} placeholder="handle or did" class={inputStyles()} /> <button type="submit" class={clsx(buttonStyles('primary'), 'justify-center')}> continue with OAuth </button> <Show when={auth.error()}> <p class="text-sm text-red-500">{auth.error()}</p> </Show> </form> </div> </details> );};
const NotificationsMenu: Component = () => { const live = useLiveEvents();
return ( <details data-topbar-menu class="relative"> <summary class="cursor-pointer list-none flex items-center gap-2" onClick={() => live.markRead()} > <div class="relative"> <Bell class="size-5 text-gray-500 dark:text-gray-400" /> <Show when={live.unread() > 0}> <span class="untangled-unread-badge absolute -top-2 -right-2 min-w-5 h-5 rounded-full bg-blue-600 text-white flex items-center justify-center px-1"> {Math.min(live.unread(), 99)} </span> </Show> </div> </summary> <div class="absolute right-0 mt-3 w-80 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg z-50 overflow-hidden"> <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 font-medium">live activity</div> <div class="max-h-96 overflow-auto"> <Show when={live.events().length > 0} fallback={<div class="px-4 py-6 text-sm text-gray-500 dark:text-gray-400">No live events yet.</div>} > <For each={live.events()}> {(event) => ( <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 text-sm"> <div class="font-medium break-all">{eventLabel(event)}</div> <div class="text-gray-500 dark:text-gray-400">{formatRelativeTime(event.receivedAt)}</div> </div> )} </For> </Show> </div> </div> </details> );};
const UserProfileMenu: Component = () => { const auth = useAuth();
const viewerQuery = createQuery(() => ({ queryKey: ['viewer', auth.currentDid()], enabled: Boolean(auth.currentDid()), queryFn: async () => resolveActor(auth.currentDid()!), }));
return ( <details data-topbar-menu class="relative"> <summary class="cursor-pointer list-none flex items-center gap-2"> <Avatar did={auth.currentDid()!} size="size-8 md:size-6" /> <span class="hidden md:inline">{viewerQuery.data?.handle ?? auth.currentDid()}</span> </summary> <div class="absolute right-0 mt-4 rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg z-50 text-sm w-56"> <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700"> <div class="font-medium truncate">{viewerQuery.data?.handle ?? auth.currentDid()}</div> <div class="text-gray-500 dark:text-gray-400 truncate">{auth.currentDid()}</div> </div> <button type="button" class="w-full px-4 py-3 flex items-center gap-2 text-left hover:bg-gray-50 hover:dark:bg-gray-700/50" onClick={() => void auth.signOut()} > <LogOut class="size-4" /> logout </button> </div> </details> );};
const TopbarSearch: Component = () => { const navigate = useNavigate(); const location = useLocation(); const [query, setQuery] = createSignal('');
let inputRef: HTMLInputElement | undefined; onMount(() => { const handler = (e: KeyboardEvent) => { if (e.ctrlKey || e.metaKey) return; if (e.key === "/") { e.preventDefault(); inputRef?.focus(); } else if (e.key === "Escape") { setQuery(''); inputRef?.blur(); } } window.addEventListener("keydown", handler) onCleanup(() => window.removeEventListener("keydown", handler)) });
createEffect(() => { if (location.pathname !== '/search') { setQuery(''); return; }
const params = new URLSearchParams(location.search); setQuery(params.get('q') ?? ''); });
const onSubmit = (event: SubmitEvent) => { event.preventDefault(); const trimmed = query().trim(); navigate(trimmed ? `/search?q=${encodeURIComponent(trimmed)}` : '/search'); };
return ( <form onSubmit={onSubmit} class="relative hidden md:block"> <div class="untangled-topbar-search-box"> <Search class="size-4 shrink-0 text-gray-400" /> <input ref={inputRef} value={query()} onInput={(event) => setQuery(event.currentTarget.value)} placeholder="search..." class="untangled-topbar-search-input" /> <kbd class="untangled-topbar-search-kbd">/</kbd> </div> </form> );};
const Topbar: Component = () => { const auth = useAuth(); let topbar: HTMLElement | undefined;
onMount(() => { const closeMenusForOutsideClick = (event: PointerEvent) => { const target = event.target; if (!topbar || !(target instanceof Node)) { return; }
closeOpenTopbarMenus(topbar, target); };
document.addEventListener('pointerdown', closeMenusForOutsideClick);
onCleanup(() => { document.removeEventListener('pointerdown', closeMenusForOutsideClick); }); });
return ( <nav ref={(element) => { topbar = element; }} class="mx-auto space-x-4 px-6 py-2"> <div class="flex justify-between gap-4 p-0 items-center"> <A href="/" class="text-2xl no-underline hover:no-underline flex shrink-0 items-center gap-2"> <img src="/static/logos/dolly.svg" alt="" class="size-8" /> <span class="font-bold text-xl">untangled</span> <span class="font-normal text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">alpha</span> </A>
<div class="flex shrink-0 items-center gap-4"> <TopbarSearch /> <A href="/search" class="md:hidden text-gray-500 dark:text-gray-400" title="Search"> <Search class="size-5" /> </A> <AppSettingsMenu /> <Show when={auth.currentDid()} fallback={<LoginMenu />} > <NotificationsMenu /> <UserProfileMenu /> </Show> </div> </div> </nav> );};
export const RootShell: Component<{ children?: JSX.Element }> = (props) => ( <div class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white"> <header class="untangled-safe-header w-full shadow-sm dark:text-white bg-white dark:bg-gray-800" style="z-index: 20;"> <Topbar /> </header>
<div class="flex-grow"> <div class="untangled-shell mx-auto flex flex-col gap-4">{props.children}</div> </div> </div>);