import clsx from 'clsx';
import { Bell, BookMarked, LogOut, Search, Settings, UserRound, Plus, LineSquiggle, BookPlus } from 'lucide-solid';
import { A, useLocation, useNavigate } from '@solidjs/router';
import { createQuery } 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 { 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>
<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>
);
};