Something went wrong. Try again.
csr tangled client in solid-js
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345import clsx from 'clsx';import { Bell, BookMarked, Eye, LogOut, Search, Settings, UserRound, Plus, LineSquiggle, BookPlus } from 'lucide-solid';import { A, useLocation, useNavigate } from '@solidjs/router';import { createQuery } from './lib/atproto-query';import { For, Show, createEffect, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js';
import { Avatar, buttonStyles, inputStyles } from './components/common';import { resolveActor } from './lib/api/identity';import { useAuth } from './lib/auth';import { useLiveEvents, type LiveEvent } from './lib/live-events';import { formatRelativeTime } from './lib/repo-utils';
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 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()!), }));
const closeMenu = (e: MouseEvent) => { const details = (e.currentTarget as HTMLElement).closest('details'); if (details) details.open = false; };
return ( <details data-topbar-menu class="untangled-topbar-menu"> <summary class="untangled-topbar-menu-summary"> <Avatar did={auth.currentDid()!} size="size-8 md:size-6" /> <span class="hidden md:inline">{viewerQuery.data?.handle ?? auth.currentDid()}</span> </summary> <div class="untangled-topbar-menu-dropdown"> {/* <button type="button" class="untangled-topbar-menu-item" onClick={() => { const id = prompt('Enter handle or DID'); if (id) void auth.signIn(id); }}> <div class="untangled-topbar-menu-add-account-icon"> <Plus class="size-4" /> </div> add account </button> */}
<div class="untangled-topbar-menu-item-separator"> <Show when={viewerQuery.data?.handle}> {(handle) => ( <> <A href={`/${handle()}`} class="untangled-topbar-menu-item" onClick={closeMenu}> <UserRound class="size-4" /> profile </A> <A href={`/${handle()}?tab=repos`} class="untangled-topbar-menu-item" onClick={closeMenu}> <BookMarked class="size-4" /> repositories </A> <A href={`/${handle()}?tab=strings`} class="untangled-topbar-menu-item" onClick={closeMenu}> <LineSquiggle class="size-4" /> strings </A> </> )} </Show>
<A href="/settings" class="untangled-topbar-menu-item" onClick={closeMenu}> <Settings class="size-4" /> settings </A>
<button type="button" class="untangled-topbar-menu-item untangled-topbar-menu-item-danger" onClick={() => void auth.signOut()} > <LogOut class="size-4" /> logout </button> </div> </div> </details> );};
const NewMenu: Component = () => { const closeMenu = (e: MouseEvent) => { const details = (e.currentTarget as HTMLElement).closest('details'); if (details) details.open = false; };
return ( <details data-topbar-menu class="untangled-topbar-menu"> <summary class="untangled-topbar-menu-summary btn text-sm no-underline hover:no-underline flex items-center gap-2 group border border-gray-200 dark:border-gray-700 rounded-sm px-3 py-1.5 bg-white dark:bg-gray-800 cursor-pointer"> <Plus class="size-4" /> <span class="hidden md:inline">new</span> </summary> <div class="untangled-topbar-menu-dropdown"> <div class="untangled-topbar-menu-item-separator"> <A href="/repo/new" class="untangled-topbar-menu-item" onClick={closeMenu}> <BookPlus class="size-4" /> new repository </A> <A href="/strings/new" class="untangled-topbar-menu-item" onClick={closeMenu}> <LineSquiggle class="size-4" /> new string </A> </div> </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 === "/") { const active = document.activeElement; if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || (active as HTMLElement).isContentEditable)) { return; }
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-4 sm:px-6 py-2"> <div class="flex justify-between gap-4 p-0 items-center"> <div class="flex shrink-0 items-center gap-2"> <A href="/" class="flex shrink-0 items-center gap-2 no-underline hover:no-underline"> <img src="/static/logos/dolly.svg" alt="" class="size-8" /> <div class="flex flex-col"> <span class="font-bold text-xl leading-none">untangled</span> <a href="https://untangled.wisp.place/ptr.pet/untangled" class="text-[10px] leading-none text-gray-400 dark:text-gray-500 hover:text-blue-500 dark:hover:text-blue-400 no-underline transition-colors mt-0.5" onClick={(e) => e.stopPropagation()} > src </a> </div> </A> </div>
<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> <A href="/watching" class="hidden sm:flex items-center gap-1.5 text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400" title="Watched repositories"> <Eye class="size-5" /> <span class="hidden lg:inline text-sm">watching</span> </A> <Show when={auth.currentDid()} fallback={<LoginMenu />} > <NewMenu /> <NotificationsMenu /> <UserProfileMenu /> </Show> </div> </div> </nav> );};
export const RootShell: Component<{ children?: JSX.Element }> = (props) => { const location = useLocation(); const isRaw = () => location.pathname.endsWith('/raw');
return ( <Show when={!isRaw()} fallback={<>{props.children}</>}> <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"> <Topbar /> </header>
<div class="flex-grow"> <div class="untangled-shell mx-auto flex flex-col gap-4">{props.children}</div> </div> </div> </Show> );};