import clsx from 'clsx';
import {
Ban,
ChevronLeft,
ChevronRight,
CircleDot,
GitMerge,
GitPullRequest,
GitPullRequestClosed,
LoaderCircle,
UserRound,
} from 'lucide-solid';
import { A } from '@solidjs/router';
import { Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js';
import { createQuery, useQueryClient } from '@tanstack/solid-query';
export { useQueryClient };
import { resolveAvatarUrl } from '../lib/api/identity';
export const ToggleButton: Component<{
active: boolean;
label: string;
meta?: string;
onClick: () => void;
}> = (props) => (
<button
type="button"
class={clsx(
'untangled-toggle-button inline-flex items-center gap-2 border-r border-gray-300 px-3 text-sm transition-colors last:border-r-0 dark:border-gray-700',
props.active ? 'untangled-toggle-button-active' : 'untangled-toggle-button-idle',
)}
onClick={props.onClick}
>
<span>{props.label}</span>
<Show when={props.meta}>
<span
class={clsx(
'untangled-toggle-button-meta text-xs',
props.active ? 'untangled-toggle-button-meta-active' : 'untangled-toggle-button-meta-idle',
)}
>
{props.meta}
</span>
</Show>
</button>
);
export const PaginationControls: Component<{
basePath?: string;
offset: number;
limit: number;
totalCount?: number;
hasNext?: boolean;
query?: Record<string, string | undefined>;
hrefForOffset?: (offset: number) => string;
}> = (props) => {
const safeLimit = createMemo(() => Math.max(1, props.limit));
const hasExactTotal = createMemo(() => typeof props.totalCount === 'number');
const lastOffset = createMemo(() => Math.floor(Math.max((props.totalCount ?? 0) - 1, 0) / safeLimit()) * safeLimit());
const previousOffset = createMemo(() => Math.max(0, props.offset - safeLimit()));
const nextOffset = createMemo(() => props.offset + safeLimit());
const hasNextPage = createMemo(() =>
hasExactTotal() ? nextOffset() < (props.totalCount ?? 0) : Boolean(props.hasNext),
);
const pageNumber = (offset: number) => Math.floor(offset / safeLimit()) + 1;
const hrefForOffset = (offset: number) => {
if (props.hrefForOffset) {
return props.hrefForOffset(Math.max(0, offset));
}
const params = new URLSearchParams();
for (const [key, value] of Object.entries(props.query ?? {})) {
if (value) {
params.set(key, value);
}
}
params.set('offset', String(Math.max(0, offset)));
params.set('limit', String(safeLimit()));
return `${props.basePath ?? ''}?${params.toString()}`;
};
const showNextNumber = createMemo(() => hasExactTotal() ? nextOffset() < lastOffset() : hasNextPage());
const showGap = createMemo(() => hasExactTotal() && nextOffset() < (props.totalCount ?? 0) - safeLimit() * 2);
const linkClass = 'flex items-center gap-1 no-underline hover:no-underline dark:text-white text-sm';
return (
<Show when={props.offset > 0 || hasNextPage()}>
<div class="mt-4 flex items-center justify-center gap-5">
<Show
when={props.offset > 0}
fallback={
<span class={clsx(linkClass, 'cursor-not-allowed opacity-50')}>
<ChevronLeft class="size-4" />
prev
</span>
}
>
<A href={hrefForOffset(previousOffset())} class={linkClass}>
<ChevronLeft class="size-4" />
prev
</A>
</Show>
<Show when={props.offset > 0}>
<A href={hrefForOffset(0)}>1</A>
</Show>
<Show when={hasExactTotal() && previousOffset() > 0}>
<A href={hrefForOffset(previousOffset())}>{pageNumber(previousOffset())}</A>
</Show>
<span class="rounded-sm border border-gray-200 bg-white px-2 py-1 dark:border-gray-700 dark:bg-gray-800">
{pageNumber(props.offset)}
</span>
<Show when={showNextNumber()}>
<A href={hrefForOffset(nextOffset())}>{pageNumber(nextOffset())}</A>
</Show>
<Show when={showGap()}>
<span class="text-gray-400 dark:text-gray-500">--</span>
</Show>
<Show when={hasExactTotal() && props.offset < lastOffset()}>
<A href={hrefForOffset(lastOffset())}>{pageNumber(lastOffset())}</A>
</Show>
<Show
when={hasNextPage()}
fallback={
<span class={clsx(linkClass, 'cursor-not-allowed opacity-50')}>
next
<ChevronRight class="size-4" />
</span>
}
>
<A href={hrefForOffset(nextOffset())} class={linkClass}>
next
<ChevronRight class="size-4" />
</A>
</Show>
</div>
</Show>
);
};
export const LoadingState: Component<{ label: string }> = (props) => (
<div class="flex items-center justify-center gap-2 text-gray-500 dark:text-gray-400">
<LoaderCircle class="size-4 animate-spin" />
<span>{props.label}</span>
</div>
);
export const SkeletonBlock: Component<{ class?: string }> = (props) => (
<div aria-hidden="true" class={clsx('untangled-skeleton rounded', props.class)} />
);
export const ErrorState: Component<{ message: string }> = (props) => (
<div class="text-red-500 dark:text-red-300">{props.message}</div>
);
export const PlaceholderAvatar: Component<{ size?: string; iconSize?: string }> = (props) => (
<div
class={clsx(
'rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center flex-shrink-0',
props.size ?? 'size-8',
)}
>
<UserRound class={clsx('text-gray-400 dark:text-gray-500', props.iconSize ?? 'size-5')} />
</div>
);
export const Avatar: Component<{ did: string; size?: string }> = (props) => {
const [failed, setFailed] = createSignal(false);
const avatarQuery = createQuery(() => ({
queryKey: ['avatar', props.did],
queryFn: async () => resolveAvatarUrl(props.did),
staleTime: 300_000,
}));
createEffect(() => {
props.did;
setFailed(false);
});
return (
<Show
when={avatarQuery.data && !failed()}
fallback={<PlaceholderAvatar size={props.size} />}
>
<img
src={avatarQuery.data!}
alt=""
class={clsx(
'rounded-full border border-gray-300 dark:border-gray-700 bg-gray-200 dark:bg-gray-700 object-cover',
props.size ?? 'size-8',
)}
onError={() => setFailed(true)}
/>
</Show>
);
};
export const StateBadge: Component<{
state: 'open' | 'closed' | 'merged';
kind: 'issue' | 'pull';
}> = (props) => {
const isIssue = () => props.kind === 'issue';
const classes = createMemo(() => {
if (props.state === 'open') {
return 'bg-green-600 dark:bg-green-700';
}
if (props.state === 'merged') {
return 'bg-purple-600 dark:bg-purple-700';
}
if (props.kind === 'pull') {
return 'bg-red-600 dark:bg-red-700';
}
return 'bg-gray-800 dark:bg-gray-700';
});
return (
<span class={clsx('inline-flex items-center gap-1.5 rounded px-2 py-1 text-sm font-medium text-white', classes())}>
<Switch>
<Match when={props.state === 'open'}>
{isIssue() ? <CircleDot class="size-3.5" /> : <GitPullRequest class="size-3.5" />}
</Match>
<Match when={props.state === 'merged'}>
<GitMerge class="size-3.5" />
</Match>
<Match when={props.kind === 'pull'}>
<GitPullRequestClosed class="size-3.5" />
</Match>
<Match when={true}>
<Ban class="size-3.5" />
</Match>
</Switch>
{props.state}
</span>
);
};
export const cardStyles = (extra?: string): string =>
clsx('bg-white dark:bg-gray-800 rounded shadow-sm border border-gray-200 dark:border-gray-700', extra);
export const buttonStyles = (variant: 'default' | 'primary' = 'default'): string =>
clsx(
'untangled-button inline-flex items-center gap-2 rounded border px-3 text-sm no-underline hover:no-underline',
variant === 'primary'
? 'btn-create text-white hover:text-white'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/40',
);
export const inputStyles = (): string =>
'untangled-input w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100';
export const textareaStyles = (): string =>
'untangled-textarea w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100';