import clsx from 'clsx';
import {
BookMarked,
Link,
LoaderCircle,
MapPin,
Rss,
Star,
UserRoundPlus,
UserRoundMinus,
Users,
GalleryVertical,
Shield,
ShieldCheck,
ShieldAlert,
ArrowRight,
X,
Check,
Pencil,
LineSquiggle,
} from 'lucide-solid';
import { A, useParams, useSearchParams, useNavigate } from '@solidjs/router';
import { createQuery, useQueryClient } from '@tanstack/solid-query';
import { For, Show, Switch, Match, createMemo, createSignal, createEffect, type Component } from 'solid-js';
import type { Did } from '@atcute/lexicons/syntax';
import { ErrorState, LoadingState, PlaceholderAvatar, SkeletonBlock, textareaStyles, inputStyles } from '../components/common';
import { RepoCard, RepoCardSkeleton, StringCardSkeleton } from '../components/repo';
import { useStringPreloader } from '../lib/preloading';
import {
listFollowRecords,
createFollow,
deleteFollow,
getFollowers,
getFollowing,
getVouchRecord,
putVouchRecord,
deleteVouchRecord,
type FollowerFollowingItem,
type FollowRecord,
type VouchRecord,
} from '../lib/api/graph';
import { resolveActor, getActorProfile, resolveAvatarUrl, putActorProfile } from '../lib/api/identity';
import { listRepoRecords, getRepoByDid, type RepoContext } from '../lib/api/repos';
import { listStringRecords } from '../lib/api/strings';
import { useAuth } from '../lib/auth';
import { listAllAppviewRecords } from '../lib/api/appview';
import { formatRelativeTime, getErrorMessage } from '../lib/repo-utils';
import { ShTangledFeedStar, ShTangledActorProfile } from '@atcute/tangled';
const ProfileTabs: Component<{
activeTab: string;
reposCount: number;
starsCount: number;
stringsCount: number;
reposLoading?: boolean;
starsLoading?: boolean;
stringsLoading?: boolean;
}> = (props) => {
const tabs = [
{ id: 'overview', label: 'overview', icon: () => <GalleryVertical class="w-4 h-4 mr-2" /> },
{ id: 'repos', label: 'repos', icon: () => <BookMarked class="w-4 h-4 mr-2" />, count: () => props.reposCount, loading: () => props.reposLoading },
{ id: 'starred', label: 'starred', icon: () => <Star class="w-4 h-4 mr-2" />, count: () => props.starsCount, loading: () => props.starsLoading },
{ id: 'strings', label: 'strings', icon: () => <LineSquiggle class="w-4 h-4 mr-2" />, count: () => props.stringsCount, loading: () => props.stringsLoading },
];
return (
<nav class="w-full pl-4 overflow-x-auto overflow-y-hidden">
<div class="flex z-60">
<For each={tabs}>
{(tab) => {
const isActive = () => props.activeTab === tab.id;
return (
<A
href={`?tab=${tab.id}`}
class="relative -mr-px group no-underline hover:no-underline"
>
<div
class={clsx(
'px-4 py-1 mr-1 text-black dark:text-white text-base min-w-[80px] text-center relative rounded-t whitespace-nowrap',
isActive()
? '-mb-px bg-white dark:bg-gray-800 font-medium'
: 'group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25'
)}
>
<span class="flex items-center justify-center">
{tab.icon()}
{tab.label}
{tab.count !== undefined && (
<span class="bg-gray-200 dark:bg-gray-700 rounded py-0.5 px-1 text-sm ml-1 min-w-[20px] inline-flex items-center justify-center">
<Show when={!tab.loading?.()} fallback={<SkeletonBlock class="h-3 w-4" />}>
{tab.count()}
</Show>
</span>
)}
</span>
</div>
</A>
);
}}
</For>
</div>
</nav>
);
};
const ProfileCardComponent: Component<{
actor: any;
profile?: any;
followersCount: number;
followingCount: number;
loggedInUserDid?: string;
isFollowing: boolean;
onFollowToggle: () => void;
actionLoading: boolean;
onTabChange: (tab: string) => void;
vouchRecord?: VouchRecord | null;
onVouchSubmit: (kind: 'vouch' | 'denounce' | 'none', reason?: string) => Promise<void>;
vouchActionLoading: boolean;
onProfileUpdate: (profile: ShTangledActorProfile.Main) => Promise<void>;
profileUpdateLoading: boolean;
reposCount: number;
starsCount: number;
mergedPRCount: number;
closedPRCount: number;
openPRCount: number;
openIssueCount: number;
closedIssueCount: number;
profileLoading?: boolean;
followersLoading?: boolean;
followingLoading?: boolean;
vouchLoading?: boolean;
}> = (props) => {
const userIdent = () => props.actor.handle || props.actor.did;
const pronouns = () => props.profile?.pronouns;
const description = () => props.profile?.description;
const location = () => props.profile?.location;
const websiteLinks = () => props.profile?.links ?? [];
const includeBluesky = () => props.profile?.bluesky;
const isSelf = () => props.loggedInUserDid === props.actor.did;
const [kind, setKind] = createSignal<'vouch' | 'denounce' | 'none'>('none');
const [reason, setReason] = createSignal('');
let popoverRef: HTMLDivElement | undefined;
createEffect(() => {
const record = props.vouchRecord;
if (record) {
setKind(record.value.kind);
setReason(record.value.reason || '');
} else {
setKind('none');
setReason('');
}
});
const popoverId = () => `vouch-modal-${props.actor.did.replace(/[^a-zA-Z0-9]/g, '-')}`;
const isVouched = () => props.vouchRecord?.value.kind === 'vouch';
const isDenounced = () => props.vouchRecord?.value.kind === 'denounce';
const handleFollowClick = () => {
if (!props.loggedInUserDid) {
alert('Please sign in to follow users.');
return;
}
props.onFollowToggle();
};
const handleVouchClick = (e: MouseEvent) => {
if (!props.loggedInUserDid) {
e.preventDefault();
alert('Please sign in to vouch for users.');
}
};
const [isEditing, setIsEditing] = createSignal(false);
const [editBioText, setEditBioText] = createSignal('');
const [editPronounsText, setEditPronounsText] = createSignal('');
const [editLocationText, setEditLocationText] = createSignal('');
const [editBluesky, setEditBluesky] = createSignal(false);
const [editLinks, setEditLinks] = createSignal<string[]>(['', '', '', '', '']);
const [editStat1, setEditStat1] = createSignal('');
const [editStat2, setEditStat2] = createSignal('');
const startEditing = () => {
const p = props.profile;
setEditBioText(p?.description || '');
setEditPronounsText(p?.pronouns || '');
setEditLocationText(p?.location || '');
setEditBluesky(!!p?.bluesky);
const existingLinks = p?.links || [];
const newLinks = ['', '', '', '', ''];
for (let i = 0; i < 5; i++) {
newLinks[i] = existingLinks[i] || '';
}
setEditLinks(newLinks);
const existingStats = p?.stats || [];
setEditStat1(existingStats[0] || '');
setEditStat2(existingStats[1] || '');
setIsEditing(true);
};
const handleSubmitProfile = async (e: SubmitEvent) => {
e.preventDefault();
const finalLinks = editLinks().map(l => l.trim()).filter(Boolean);
const finalStats = [editStat1(), editStat2()].filter(Boolean);
const updatedProfile: ShTangledActorProfile.Main = {
$type: 'sh.tangled.actor.profile',
...props.profile,
description: editBioText().trim() || undefined,
pronouns: editPronounsText().trim() || undefined,
location: editLocationText().trim() || undefined,
bluesky: editBluesky(),
links: finalLinks.length > 0 ? finalLinks : undefined,
stats: finalStats.length > 0 ? finalStats : undefined,
};
await props.onProfileUpdate(updatedProfile);
setIsEditing(false);
};
const getStatValue = (kind: string) => {
if (kind === 'repository-count') return props.reposCount;
if (kind === 'star-count') return props.starsCount;
if (kind === 'merged-pull-request-count') return props.mergedPRCount;
if (kind === 'closed-pull-request-count') return props.closedPRCount;
if (kind === 'open-pull-request-count') return props.openPRCount;
if (kind === 'open-issue-count') return props.openIssueCount;
if (kind === 'closed-issue-count') return props.closedIssueCount;
return 0;
};
const formatStatLabel = (kind: string) => {
const labels: Record<string, string> = {
'merged-pull-request-count': 'merged prs',
'closed-pull-request-count': 'closed prs',
'open-pull-request-count': 'open prs',
'open-issue-count': 'open issues',
'closed-issue-count': 'closed issues',
'repository-count': 'repos',
'star-count': 'stars received',
};
return labels[kind] || kind;
};
const avatarQuery = createQuery(() => ({
queryKey: ['avatar', props.actor.did],
queryFn: async () => resolveAvatarUrl(props.actor.did),
staleTime: 300_000,
}));
return (
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
{/* Avatar */}
<div id="avatar" class="col-span-1 flex justify-center items-center">
<div class="w-3/4 aspect-square relative">
<Show
when={avatarQuery.data}
fallback={<PlaceholderAvatar size="absolute inset-0 w-full h-full p-2" iconSize="size-1/2" />}
>
<img
class="absolute inset-0 w-full h-full object-cover rounded-full p-2"
src={avatarQuery.data!}
alt={userIdent()}
/>
</Show>
</div>
</div>
{/* Details */}
<div class="col-span-2">
<div class="flex items-center flex-row flex-nowrap gap-2">
<p title={userIdent()} class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
{userIdent()}
</p>
<Show when={!props.profileLoading} fallback={<SkeletonBlock class="h-4 w-12" />}>
<Show when={pronouns()}>
<p class="text-gray-500 dark:text-gray-400">{pronouns()}</p>
</Show>
</Show>
</div>
<div class="md:hidden">
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
<span class="flex-shrink-0"><Users class="size-4" /></span>
<span id="followers">
<A href="?tab=followers">
<Show when={!props.followersLoading} fallback={<SkeletonBlock class="h-4 w-8 inline-block mr-1 align-middle" />}>
{props.followersCount}
</Show>
{' '}followers
</A>
</span>
<span class="select-none after:content-['·']"></span>
<span id="following">
<A href="?tab=following">
<Show when={!props.followingLoading} fallback={<SkeletonBlock class="h-4 w-8 inline-block mr-1 align-middle" />}>
{props.followingCount}
</Show>
{' '}following
</A>
</span>
</div>
</div>
</div>
{/* Bio and links */}
<div class="col-span-3 md:col-span-full">
<div id="profile-bio" class="text-sm space-y-2">
<Show
when={isEditing()}
fallback={
<>
<Show
when={!props.profileLoading}
fallback={
<div class="space-y-2 pb-4 md:pb-2">
<SkeletonBlock class="h-4 w-full" />
<SkeletonBlock class="h-4 w-5/6" />
</div>
}
>
<Show when={description()}>
<p class="text-base pb-4 md:pb-2">{description()}</p>
</Show>
</Show>
<div class="hidden md:block">
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
<span class="flex-shrink-0"><Users class="size-4" /></span>
<span id="followers">
<A href="?tab=followers">
<Show when={!props.followersLoading} fallback={<SkeletonBlock class="h-4 w-8 inline-block mr-1 align-middle" />}>
{props.followersCount}
</Show>
{' '}followers
</A>
</span>
<span class="select-none after:content-['·']"></span>
<span id="following">
<A href="?tab=following">
<Show when={!props.followingLoading} fallback={<SkeletonBlock class="h-4 w-8 inline-block mr-1 align-middle" />}>
{props.followingCount}
</Show>
{' '}following
</A>
</span>
</div>
</div>
<Show
when={!props.profileLoading}
fallback={
<div class="flex flex-col gap-2 mb-2">
<div class="flex items-center gap-2">
<SkeletonBlock class="size-4 shrink-0 rounded-full" />
<SkeletonBlock class="h-4 w-24" />
</div>
<div class="flex items-center gap-2">
<SkeletonBlock class="size-4 shrink-0 rounded-full" />
<SkeletonBlock class="h-4 w-36" />
</div>
</div>
}
>
<div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
<Show when={location()}>
<div class="flex items-center gap-2">
<span class="flex-shrink-0"><MapPin class="size-4" /></span>
<span>{location()}</span>
</div>
</Show>
<Show when={includeBluesky()}>
<div class="flex items-center gap-2">
<span class="flex-shrink-0">
<svg class="w-4 h-4 text-black dark:text-white" xmlns="http://www.w3.org/2000/svg" role="img" viewBox="-3 -3 30 30">
<title>Bluesky</title>
<path fill="none" stroke="currentColor" d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" stroke-width="2.25"/>
</svg>
</span>
<a id="bluesky-link" href={`https://bsky.app/profile/${props.actor.did}`} target="_blank" rel="noopener noreferrer">
{props.actor.handle}
</a>
</div>
</Show>
<For each={websiteLinks()}>
{(link) => (
<Show when={link}>
<div class="flex items-center gap-2">
<span class="flex-shrink-0"><Link class="size-4" /></span>
<a rel="nofollow me" href={link} target="_blank" class="truncate">
{link}
</a>
</div>
</Show>
)}
</For>
</div>
</Show>
<Show
when={!props.profileLoading}
fallback={
<div class="flex items-center justify-evenly gap-2 py-2">
<div class="flex flex-col items-center gap-2">
<SkeletonBlock class="h-6 w-8" />
<SkeletonBlock class="h-4 w-12" />
</div>
<div class="flex flex-col items-center gap-2">
<SkeletonBlock class="h-6 w-8" />
<SkeletonBlock class="h-4 w-12" />
</div>
</div>
}
>
<Show when={props.profile?.stats && props.profile.stats.length > 0}>
<div class="flex items-center justify-evenly gap-2 py-2">
<For each={props.profile.stats}>
{(statKind) => (
<Show when={statKind.length > 0}>
<div class="flex flex-col items-center gap-2">
<span class="text-xl font-bold">{getStatValue(statKind)}</span>
<span>{formatStatLabel(statKind)}</span>
</div>
</Show>
)}
</For>
</div>
</Show>
</Show>
<div class="flex my-2 items-center gap-2">
<Show
when={!props.profileLoading}
fallback={<SkeletonBlock class="h-8 w-full rounded" />}
>
<Show
when={!isSelf()}
fallback={
<button
type="button"
onClick={startEditing}
class="btn w-full flex gap-2 items-center justify-center group text-sm"
>
<Pencil class="size-4 inline" />
edit
</button>
}
>
<button
onClick={handleFollowClick}
disabled={props.actionLoading}
class="btn w-full flex gap-2 items-center justify-center group text-sm"
>
<Show
when={props.actionLoading}
fallback={
<Show
when={props.isFollowing}
fallback={
<>
<UserRoundPlus class="size-4 inline" />
follow
</>
}
>
<>
<UserRoundMinus class="size-4 inline" />
unfollow
</>
</Show>
}
>
<LoaderCircle class="size-4 animate-spin inline" />
<Show when={props.isFollowing} fallback="follow">
unfollow
</Show>
</Show>
</button>
</Show>
</Show>
<a
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
href={`/${userIdent()}/feed.atom`}
>
<Rss class="size-4" />
</a>
</div>
<Show when={!isSelf()}>
<div class="relative w-full mt-2">
<Show when={!props.profileLoading && !props.vouchLoading} fallback={<SkeletonBlock class="h-8 w-full rounded" />}>
<button
id={`vouch-btn-${popoverId()}`}
type="button"
popovertarget={popoverId()}
popovertargetaction="toggle"
onClick={handleVouchClick}
class={clsx(
'w-full flex gap-2 items-center justify-center text-sm',
isVouched() ? 'btn-create' : isDenounced() ? 'btn-cancel' : 'btn'
)}
>
<Show
when={isVouched()}
fallback={
<Show
when={isDenounced()}
fallback={
<>
<Shield class="size-4" />
vouch
</>
}
>
<>
<ShieldAlert class="size-4" />
denounced
</>
</Show>
}
>
<>
<ShieldCheck class="size-4" />
vouched
</>
</Show>
</button>
</Show>
<div
id={popoverId()}
popover="auto"
ref={popoverRef}
class="bg-white w-[95%] md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 space-y-2"
>
<form
onSubmit={async (e) => {
e.preventDefault();
await props.onVouchSubmit(kind(), kind() === 'none' ? undefined : reason());
popoverRef?.hidePopover();
}}
class="flex flex-col gap-6 group"
>
<div>
<p class="font-semibold mb-1 text-base">Vouch for {userIdent()}</p>
<p class="text-gray-600 dark:text-gray-400 text-sm">
<Show
when={props.vouchRecord}
fallback={
<>
Vouching builds a web-of-trust across Tangled. Vouch for users you
have had positive interactions with.{' '}
<a href="https://blog.tangled.org/vouching" class="inline-flex items-center gap-1" target="_blank" rel="noopener noreferrer">
Read more <ArrowRight class="size-3" />
</a>
</>
}
>
{(record) => (
<>
You {record().value.kind === 'vouch' ? 'vouched' : 'denounced'} {userIdent()}{' '}
{formatRelativeTime(record().value.createdAt)}.
You can change your decision below.
</>
)}
</Show>
</p>
</div>
<div class="grid grid-cols-3 gap-2">
<input
id={`vouch-input-${popoverId()}`}
type="radio"
name="kind"
value="vouch"
checked={kind() === 'vouch'}
onChange={() => setKind('vouch')}
class="peer/vouch hidden appearance-none"
/>
<input
id={`denounce-input-${popoverId()}`}
type="radio"
name="kind"
value="denounce"
checked={kind() === 'denounce'}
onChange={() => setKind('denounce')}
class="peer/denounce hidden appearance-none"
/>
<input
id={`none-input-${popoverId()}`}
type="radio"
name="kind"
value="none"
checked={kind() === 'none'}
onChange={() => setKind('none')}
class="peer/none hidden appearance-none"
/>
<label
for={`vouch-input-${popoverId()}`}
class="untangled-vouch-label items-center gap-2 rounded p-2 ring-1 ring-gray-200 dark:ring-gray-700 cursor-pointer hover:bg-gray-100 peer-checked/vouch:bg-green-200 peer-checked/vouch:text-green-800 peer-checked/vouch:ring-green-400 dark:hover:bg-white/5 dark:peer-checked/vouch:bg-green-700 dark:peer-checked/vouch:text-green-200 dark:peer-checked/vouch:ring-green-600"
>
<ShieldCheck class="size-5" />
<span class="text-sm font-medium">vouch</span>
</label>
<label
for={`denounce-input-${popoverId()}`}
class="untangled-vouch-label items-center gap-2 rounded p-2 ring-1 ring-gray-200 dark:ring-gray-700 cursor-pointer hover:bg-gray-100 peer-checked/denounce:bg-red-200 peer-checked/denounce:text-red-800 peer-checked/denounce:ring-red-400 dark:hover:bg-white/5 dark:peer-checked/denounce:bg-red-700 dark:peer-checked/denounce:text-red-200 dark:peer-checked/denounce:ring-red-600"
>
<ShieldAlert class="size-5" />
<span class="text-sm font-medium">denounce</span>
</label>
<label
for={`none-input-${popoverId()}`}
class="untangled-vouch-label items-center gap-2 rounded p-2 ring-1 ring-gray-200 dark:ring-gray-700 cursor-pointer hover:bg-gray-100 peer-checked/none:bg-gray-200 peer-checked/none:text-gray-800 peer-checked/none:ring-gray-400 dark:hover:bg-white/5 dark:peer-checked/none:bg-gray-700 dark:peer-checked/none:text-gray-200 dark:peer-checked/none:ring-gray-600"
>
<Shield class="size-5 opacity-50" />
<span class="text-sm font-medium">none</span>
</label>
<textarea
id={`reason-${popoverId()}`}
name="reason"
rows="1"
maxLength="300"
placeholder="Write a reason..."
value={reason()}
onInput={(e) => setReason(e.currentTarget.value)}
class={clsx(textareaStyles(), 'resize-none col-span-3 peer-checked/none:hidden', kind() === 'none' && 'hidden')}
/>
</div>
<div class="flex gap-2">
<button
type="button"
onClick={() => {
const record = props.vouchRecord;
if (record) {
setKind(record.value.kind);
setReason(record.value.reason || '');
} else {
setKind('none');
setReason('');
}
popoverRef?.hidePopover();
}}
class="btn flex-1 flex items-center justify-center gap-2"
>
<X class="size-4" />
cancel
</button>
<button
type="submit"
disabled={props.vouchActionLoading}
class="btn-create flex-1 flex items-center justify-center gap-2 text-white group"
>
<Show
when={props.vouchActionLoading}
fallback={
<>
<Check class="size-4 inline" />
{props.vouchRecord ? 'update' : 'save'}
</>
}
>
<>
<LoaderCircle class="size-4 animate-spin inline" />
{props.vouchRecord ? 'update' : 'save'}
</>
</Show>
</button>
</div>
</form>
</div>
</div>
</Show>
</>
}
>
{/* EDIT FORM */}
<form onSubmit={handleSubmitProfile} class="flex flex-col gap-4 my-2 max-w-full">
<div class="flex flex-col gap-1">
<label class="m-0 p-0 text-sm font-semibold" for="description">bio</label>
<textarea
id="description"
class={clsx(textareaStyles(), 'w-full p-2')}
rows="3"
placeholder="write a bio"
value={editBioText()}
onInput={(e) => setEditBioText(e.currentTarget.value)}
/>
</div>
<div class="flex flex-col gap-1">
<label class="m-0 p-0 text-sm font-semibold" for="pronouns">pronouns</label>
<input
id="pronouns"
type="text"
class={clsx(inputStyles(), 'w-full p-2')}
placeholder="they/them"
value={editPronounsText()}
onInput={(e) => setEditPronounsText(e.currentTarget.value)}
/>
</div>
<div class="flex flex-col gap-1">
<label class="m-0 p-0 text-sm font-semibold" for="location">location</label>
<div class="flex items-center gap-2 w-full">
<span class="flex-shrink-0"><MapPin class="size-4" /></span>
<input
id="location"
type="text"
class={clsx(inputStyles(), 'w-full p-2')}
value={editLocationText()}
onInput={(e) => setEditLocationText(e.currentTarget.value)}
/>
</div>
</div>
<div class="flex flex-col gap-1">
<label class="m-0 p-0 text-sm font-semibold">social links</label>
<div class="flex items-center gap-2 py-1">
<input
type="checkbox"
id="includeBluesky"
checked={editBluesky()}
onChange={(e) => setEditBluesky(e.currentTarget.checked)}
class="mr-2"
/>
<label for="includeBluesky" class="my-0 py-0 normal-case font-normal text-sm">link to bluesky account</label>
</div>
<For each={editLinks()}>
{(link, index) => (
<div class="flex items-center gap-2 w-full">
<span class="flex-shrink-0"><Link class="size-4" /></span>
<input
type="text"
class={clsx(inputStyles(), 'w-full p-2')}
value={link}
placeholder={`social link ${index() + 1}`}
onInput={(e) => {
const next = [...editLinks()];
next[index()] = e.currentTarget.value;
setEditLinks(next);
}}
/>
</div>
)}
</For>
</div>
<div class="flex flex-col gap-1">
<label class="m-0 p-0 text-sm font-semibold">vanity stats</label>
<select
value={editStat1()}
onChange={(e) => setEditStat1(e.currentTarget.value)}
class="w-full p-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 dark:text-white text-sm"
>
<option value="">choose stat 1</option>
<option value="merged-pull-request-count">merged pr count</option>
<option value="closed-pull-request-count">closed pr count</option>
<option value="open-pull-request-count">open pr count</option>
<option value="open-issue-count">open issue count</option>
<option value="closed-issue-count">closed issue count</option>
<option value="repository-count">repository count</option>
<option value="star-count">star count</option>
</select>
<select
value={editStat2()}
onChange={(e) => setEditStat2(e.currentTarget.value)}
class="w-full p-2 mt-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 dark:text-white text-sm"
>
<option value="">choose stat 2</option>
<option value="merged-pull-request-count">merged pr count</option>
<option value="closed-pull-request-count">closed pr count</option>
<option value="open-pull-request-count">open pr count</option>
<option value="open-issue-count">open issue count</option>
<option value="closed-issue-count">closed issue count</option>
<option value="repository-count">repository count</option>
<option value="star-count">star count</option>
</select>
</div>
<div class="flex items-center gap-2 justify-between">
<button
id="save-btn"
type="submit"
disabled={props.profileUpdateLoading}
class="btn-create p-1 w-full flex items-center justify-center gap-2 text-white text-sm"
>
<Show
when={props.profileUpdateLoading}
fallback={
<>
<Check class="size-4" />
save
</>
}
>
<>
<LoaderCircle class="w-4 h-4 animate-spin" />
save
</>
</Show>
</button>
<button
id="cancel-btn"
type="button"
onClick={() => setIsEditing(false)}
class="btn p-1 w-full flex items-center justify-center gap-2 text-sm"
>
<X class="size-4" />
cancel
</button>
</div>
</form>
</Show>
</div>
</div>
</div>
);
};
export const FollowCardSkeleton: Component = () => (
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700">
<div class="py-4 px-6 drop-shadow-sm bg-white dark:bg-gray-800 flex items-center gap-4">
<div class="flex-shrink-0 w-24 h-24">
<SkeletonBlock class="w-24 h-24 rounded-full p-2" />
</div>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0">
<div class="flex-1 min-h-0 justify-around flex flex-col space-y-2">
<SkeletonBlock class="h-5 w-1/3" />
<SkeletonBlock class="h-4 w-2/3" />
<div class="flex items-center gap-2">
<SkeletonBlock class="size-4 shrink-0 rounded-full" />
<SkeletonBlock class="h-4 w-24" />
</div>
</div>
<div class="w-full md:w-auto md:max-w-24 order-last md:order-none">
<SkeletonBlock class="h-8 w-24 rounded" />
</div>
</div>
</div>
</div>
);
const FollowCard: Component<{
item: FollowerFollowingItem;
loggedInUserDid?: string;
viewerFollows?: FollowRecord[];
onFollowToggle?: (did: Did) => void;
}> = (props) => {
const userIdent = () => props.item.handle || props.item.did;
const profileQuery = createQuery(() => ({
queryKey: ['profile-record', props.item.did],
queryFn: () => getActorProfile(props.item.actor),
staleTime: 300_000,
}));
const followersQuery = createQuery(() => ({
queryKey: ['profile-followers', props.item.did],
queryFn: () => getFollowers(props.item.did),
staleTime: 300_000,
}));
const followingQuery = createQuery(() => ({
queryKey: ['profile-following', props.item.did],
queryFn: () => getFollowing(props.item.did),
staleTime: 300_000,
}));
const isFollowing = () => {
const list = props.viewerFollows;
if (!list) return false;
return list.some(follow => follow.value.subject === props.item.did);
};
const isSelf = () => props.loggedInUserDid === props.item.did;
const avatarQuery = createQuery(() => ({
queryKey: ['avatar', props.item.did],
queryFn: async () => resolveAvatarUrl(props.item.did),
staleTime: 300_000,
}));
return (
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700">
<div class="py-4 px-6 drop-shadow-sm bg-white dark:bg-gray-800 flex items-center gap-4">
<div class="flex-shrink-0 max-h-full w-24 h-24">
<Show
when={avatarQuery.data}
fallback={<PlaceholderAvatar size="w-24 h-24 p-2" iconSize="size-10" />}
>
<img
class="object-cover rounded-full p-2 w-24 h-24"
src={avatarQuery.data!}
alt={userIdent()}
/>
</Show>
</div>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0">
<div class="flex-1 min-h-0 justify-around flex flex-col">
<A href={`/${userIdent()}`}>
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
{userIdent()}
</span>
</A>
<Show when={profileQuery.data?.description}>
<p class="text-sm pb-2 md:pb-2 break-words">
{profileQuery.data!.description}
</p>
</Show>
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
<span class="flex-shrink-0"><Users class="size-4" /></span>
<span id="followers">
<A href={`/${userIdent()}?tab=followers`}>
{followersQuery.data?.length ?? 0} followers
</A>
</span>
<span class="select-none after:content-['·']"></span>
<span id="following">
<A href={`/${userIdent()}?tab=following`}>
{followingQuery.data?.length ?? 0} following
</A>
</span>
</div>
</div>
<Show when={props.loggedInUserDid && !isSelf()}>
<div class="w-full md:w-auto md:max-w-24 order-last md:order-none">
<button
onClick={() => props.onFollowToggle?.(props.item.did)}
class="btn w-full flex gap-2 items-center justify-center group"
>
<Show
when={isFollowing()}
fallback={
<>
<UserRoundPlus class="size-4 inline" />
Follow
</>
}
>
<>
<UserRoundMinus class="size-4 inline" />
Unfollow
</>
</Show>
</button>
</div>
</Show>
</div>
</div>
</div>
);
};
export const ProfilePage: Component = () => {
const params = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const auth = useAuth();
const preloadString = useStringPreloader();
const navigate = useNavigate();
const [repoSearch, setRepoSearch] = createSignal('');
const [actionLoading, setActionLoading] = createSignal(false);
const [isRedirecting, setIsRedirecting] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const actorParam = () => params.actor as string;
createEffect(() => {
actorParam();
setIsRedirecting(false);
setError(null);
});
const isDid = () => actorParam().startsWith('did:');
const actorQuery = createQuery(() => ({
queryKey: ['profile-actor', actorParam()],
queryFn: () => resolveActor(actorParam()),
}));
const repoDidQuery = createQuery(() => ({
queryKey: ['repo-by-did', actorParam()],
enabled: isDid(),
queryFn: () => getRepoByDid(actorParam() as Did),
retry: false,
}));
createEffect(() => {
const repo = repoDidQuery.data;
if (repo) {
setIsRedirecting(true);
navigate(`/${repo.owner.handle}/${repo.slug}`, { replace: true });
}
});
createEffect(() => {
const err = repoDidQuery.error;
if (err) {
console.error('Failed to resolve DID as repo:', err);
setError(getErrorMessage(err));
}
});
const activeTab = () => {
const tab = searchParams.tab;
return (Array.isArray(tab) ? tab[0] : tab) || 'overview';
};
const profileQuery = createQuery(() => ({
queryKey: ['profile-record', actorQuery.data?.did],
enabled: !!actorQuery.data,
queryFn: () => getActorProfile(actorQuery.data!),
}));
const followersQuery = createQuery(() => ({
queryKey: ['profile-followers', actorQuery.data?.did],
enabled: !!actorQuery.data,
queryFn: () => getFollowers(actorQuery.data!.did),
}));
const followingQuery = createQuery(() => ({
queryKey: ['profile-following', actorQuery.data?.did],
enabled: !!actorQuery.data,
queryFn: () => getFollowing(actorQuery.data!.did),
}));
const reposQuery = createQuery(() => ({
queryKey: ['profile-repos', actorQuery.data?.did],
enabled: !!actorQuery.data,
queryFn: () => listRepoRecords(actorQuery.data!),
}));
const stringsQuery = createQuery(() => ({
queryKey: ['profile-strings', actorQuery.data?.did],
enabled: !!actorQuery.data,
queryFn: () => listStringRecords(actorQuery.data!),
}));
const starsQuery = createQuery(() => ({
queryKey: ['profile-stars', actorQuery.data?.did],
enabled: !!actorQuery.data,
queryFn: async () => {
const did = actorQuery.data!.did;
const starRecords = await listAllAppviewRecords<ShTangledFeedStar.Main>('sh.tangled.feed.listStarsBy', did);
const repos = await Promise.all(
starRecords.map(async (star) => {
const subject = star.value.subject as { did?: string; subjectDid?: string };
const repoDid = (subject.did ?? subject.subjectDid) as Did;
try {
return await getRepoByDid(repoDid);
} catch (e) {
console.error('failed to resolve starred repo', repoDid, e);
return null;
}
})
);
return repos.filter(Boolean) as RepoContext[];
},
}));
const viewerFollowsQuery = createQuery(() => ({
queryKey: ['viewer-follows', auth.currentDid()],
enabled: !!auth.currentDid(),
queryFn: () => listFollowRecords(auth.currentDid()!),
}));
const issuesQuery = createQuery(() => ({
queryKey: ['profile-issues-stat', actorQuery.data?.did],
enabled: !!actorQuery.data,
queryFn: async () => {
const did = actorQuery.data!.did;
try {
return await listAllAppviewRecords<any>('sh.tangled.repo.listIssuesBy', did);
} catch (e) {
console.error('Failed to fetch issues for stat', e);
return [];
}
}
}));
const pullsQuery = createQuery(() => ({
queryKey: ['profile-pulls-stat', actorQuery.data?.did],
enabled: !!actorQuery.data,
queryFn: async () => {
const did = actorQuery.data!.did;
try {
return await listAllAppviewRecords<any>('sh.tangled.repo.listPullsBy', did);
} catch (e) {
console.error('Failed to fetch pulls for stat', e);
return [];
}
}
}));
const mergedPRCount = () => (pullsQuery.data as any[])?.filter((p) => p.state === 'merged').length ?? 0;
const closedPRCount = () => (pullsQuery.data as any[])?.filter((p) => p.state === 'closed').length ?? 0;
const openPRCount = () => (pullsQuery.data as any[])?.filter((p) => p.state === 'open').length ?? 0;
const openIssueCount = () => (issuesQuery.data as any[])?.filter((i) => i.state === 'open').length ?? 0;
const closedIssueCount = () => (issuesQuery.data as any[])?.filter((i) => i.state === 'closed').length ?? 0;
const isFollowing = () => {
const list = viewerFollowsQuery.data;
const profileDid = actorQuery.data?.did;
if (!list || !profileDid) return false;
return list.some((follow) => follow.value.subject === profileDid);
};
const followRecordRkey = () => {
const list = viewerFollowsQuery.data;
const profileDid = actorQuery.data?.did;
if (!list || !profileDid) return undefined;
return list.find((follow) => follow.value.subject === profileDid)?.rkey;
};
const handleFollowToggle = async () => {
const agent = auth.agent();
const profileDid = actorQuery.data?.did;
if (!agent || !profileDid) return;
setActionLoading(true);
setError(null);
try {
const rkey = followRecordRkey();
if (rkey) {
await deleteFollow(agent, rkey);
} else {
await createFollow(agent, profileDid);
}
queryClient.invalidateQueries({ queryKey: ['viewer-follows', auth.currentDid()] });
queryClient.invalidateQueries({ queryKey: ['profile-followers', profileDid] });
} catch (e) {
console.error('Failed to toggle follow status', e);
setError(getErrorMessage(e));
} finally {
setActionLoading(false);
}
};
const handleItemFollowToggle = async (did: Did) => {
const agent = auth.agent();
if (!agent) return;
setError(null);
try {
const viewerFollows = viewerFollowsQuery.data ?? [];
const existing = viewerFollows.find((f) => f.value.subject === did);
if (existing) {
await deleteFollow(agent, existing.rkey);
} else {
await createFollow(agent, did);
}
queryClient.invalidateQueries({ queryKey: ['viewer-follows', auth.currentDid()] });
if (actorQuery.data?.did === did) {
queryClient.invalidateQueries({ queryKey: ['profile-followers', did] });
}
} catch (e) {
console.error('Failed to toggle follow status for list item', e);
setError(getErrorMessage(e));
}
};
const vouchQuery = createQuery(() => ({
queryKey: ['vouch-record', auth.currentDid(), actorQuery.data?.did],
enabled: !!auth.currentDid() && !!actorQuery.data?.did && auth.currentDid() !== actorQuery.data?.did,
queryFn: () => getVouchRecord(auth.currentDid()!, actorQuery.data!.did),
}));
const [vouchActionLoading, setVouchActionLoading] = createSignal(false);
const handleVouchSubmit = async (kind: 'vouch' | 'denounce' | 'none', reason?: string) => {
const agent = auth.agent();
const profileDid = actorQuery.data?.did;
if (!agent || !profileDid) return;
setVouchActionLoading(true);
setError(null);
try {
if (kind === 'none') {
await deleteVouchRecord(agent, profileDid);
} else {
await putVouchRecord(agent, profileDid, kind, reason);
}
queryClient.invalidateQueries({ queryKey: ['vouch-record', auth.currentDid(), profileDid] });
} catch (e) {
console.error('Failed to submit vouch status', e);
setError(getErrorMessage(e));
} finally {
setVouchActionLoading(false);
}
};
const [profileUpdateLoading, setProfileUpdateLoading] = createSignal(false);
const handleProfileUpdate = async (profile: ShTangledActorProfile.Main) => {
const agent = auth.agent();
const profileDid = actorQuery.data?.did;
if (!agent || !profileDid) return;
setProfileUpdateLoading(true);
setError(null);
try {
await putActorProfile(agent, profile);
queryClient.invalidateQueries({ queryKey: ['profile-record', profileDid] });
} catch (e) {
console.error('Failed to update profile record', e);
setError(getErrorMessage(e));
} finally {
setProfileUpdateLoading(false);
}
};
const pinnedRepos = createMemo(() => {
const list = reposQuery.data ?? [];
const pinnedDids = profileQuery.data?.pinnedRepositories ?? [];
if (pinnedDids.length > 0) {
return list.filter((repo) => {
const did = repo.value.repoDid;
const uri = repo.uri;
return (did && pinnedDids.includes(did)) || pinnedDids.includes(uri);
});
}
return list.slice(0, 4);
});
const filteredRepos = createMemo(() => {
const q = repoSearch().toLowerCase().trim();
const list = reposQuery.data ?? [];
if (!q) return list;
return list.filter(
(repo) =>
repo.value.name?.toLowerCase().includes(q) ||
repo.value.description?.toLowerCase().includes(q)
);
});
return (
<Switch>
<Match when={actorQuery.isLoading}>
<LoadingState label={isDid() ? "Resolving identifier..." : "Loading profile..."} />
</Match>
<Match when={actorQuery.error}>
<Show
when={isRedirecting() || (isDid() && repoDidQuery.isLoading)}
fallback={
<div class="p-6 border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800">
<ErrorState message="Could not resolve user profile." />
</div>
}
>
<LoadingState label="Resolving repository DID..." />
</Show>
</Match>
<Match when={actorQuery.data}>
{(resolvedActor) => (
<Switch>
<Match when={!profileQuery.isLoading && !profileQuery.data}>
{(() => {
const unresolvedAvatarQuery = createQuery(() => ({
queryKey: ['avatar', resolvedActor().did],
queryFn: async () => resolveAvatarUrl(resolvedActor().did),
staleTime: 300_000,
}));
return (
<section class="bg-white dark:bg-gray-800 px-2 py-6 md:p-6 rounded w-full dark:text-white shadow-sm">
<div class="flex items-center gap-6 p-4">
<Show
when={unresolvedAvatarQuery.data}
fallback={<PlaceholderAvatar size="w-28 h-28" iconSize="size-12" />}
>
<img
class="w-28 h-28 shrink-0 object-cover rounded-full"
src={unresolvedAvatarQuery.data!}
alt={resolvedActor().handle || resolvedActor().did}
/>
</Show>
<div>
<p class="text-lg font-bold">{resolvedActor().handle || resolvedActor().did}</p>
<p class="text-gray-700 dark:text-gray-300 mt-2">This user hasn't joined Tangled yet.</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Let them know we're waiting for them!</p>
</div>
</div>
</section>
);
})()}
</Match>
<Match when={profileQuery.data || profileQuery.isLoading}>
<div class="flex flex-col">
<ProfileTabs
activeTab={activeTab()}
reposCount={reposQuery.data?.length ?? 0}
starsCount={starsQuery.data?.length ?? 0}
stringsCount={stringsQuery.data?.length ?? 0}
reposLoading={reposQuery.isLoading}
starsLoading={starsQuery.isLoading}
stringsLoading={stringsQuery.isLoading}
/>
<section class="bg-white dark:bg-gray-800 px-2 py-6 md:p-6 rounded w-full dark:text-white shadow-sm">
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
{/* Sidebar */}
<div
class={clsx(
activeTab() === 'overview' ? 'md:col-span-3' : 'hidden md:block md:col-span-3',
'order-1'
)}
>
<ProfileCardComponent
actor={resolvedActor()}
profile={profileQuery.data}
followersCount={followersQuery.data?.length ?? 0}
followingCount={followingQuery.data?.length ?? 0}
loggedInUserDid={auth.currentDid() ?? undefined}
isFollowing={isFollowing()}
onFollowToggle={handleFollowToggle}
actionLoading={actionLoading()}
onTabChange={(tab) => setSearchParams({ tab })}
vouchRecord={vouchQuery.data}
onVouchSubmit={handleVouchSubmit}
vouchActionLoading={vouchActionLoading()}
onProfileUpdate={handleProfileUpdate}
profileUpdateLoading={profileUpdateLoading()}
reposCount={reposQuery.data?.length ?? 0}
starsCount={starsQuery.data?.length ?? 0}
mergedPRCount={mergedPRCount()}
closedPRCount={closedPRCount()}
openPRCount={openPRCount()}
openIssueCount={openIssueCount()}
closedIssueCount={closedIssueCount()}
profileLoading={profileQuery.isLoading}
followersLoading={followersQuery.isLoading}
followingLoading={followingQuery.isLoading}
vouchLoading={vouchQuery.isLoading}
/>
<Show when={error()}>
<div class="mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-sm text-red-600 dark:text-red-400">
{error()}
</div>
</Show>
</div>
<Switch>
{/* Overview Tab (split into two columns) */}
<Match when={activeTab() === 'overview'}>
{/* Pinned / Own Repos Column */}
<div id="all-repos" class="md:col-span-4 order-2 md:order-2">
<div class="grid grid-cols-1 gap-4">
<div>
<div class="text-base font-bold uppercase tracking-wider px-2 pb-4 dark:text-white flex items-center gap-2">
<A
href="?tab=repos"
class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"
>
<span>pinned repos</span>
</A>
</div>
<div id="repos" class="grid grid-cols-1 gap-4 items-stretch">
<Show
when={!reposQuery.isLoading}
fallback={
<>
<RepoCardSkeleton />
<RepoCardSkeleton />
<RepoCardSkeleton />
</>
}
>
<For
each={pinnedRepos()}
fallback={
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
<span>This user does not have any pinned repos.</span>
</div>
}
>
{(repo) => (
<RepoCard
owner={resolvedActor().handle}
name={repo.value.name || repo.rkey}
description={repo.value.description}
isFork={Boolean(repo.value.source)}
showOwner={false}
/>
)}
</For>
</Show>
</div>
</div>
</div>
</div>
{/* Activity Timeline Column */}
<div class="md:col-span-4 order-3 md:order-3">
<p class="text-base font-bold uppercase tracking-wider px-2 pb-4 dark:text-white">activity</p>
<div class="flex flex-col gap-4 relative">
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
<span>This user does not have any activity yet.</span>
</div>
</div>
</div>
</Match>
{/* Repositories Tab */}
<Match when={activeTab() === 'repos'}>
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
<div class="mb-4">
<form id="search-form" class="flex relative" onSubmit={(e) => e.preventDefault()}>
<div class="flex-1 flex relative">
<input
id="search-q"
class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none peer font-normal text-sm"
type="text"
placeholder="Search repos..."
value={repoSearch()}
onInput={(e) => setRepoSearch(e.currentTarget.value)}
/>
<Show when={repoSearch()}>
<button
type="button"
onClick={() => setRepoSearch('')}
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</Show>
</div>
<button
type="submit"
class="p-2 text-gray-400 border rounded-r border-gray-300 dark:border-gray-600"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
</button>
</form>
</div>
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
<Show
when={!reposQuery.isLoading}
fallback={
<For each={Array.from({ length: 5 })}>
{() => <RepoCardSkeleton />}
</For>
}
>
<For
each={filteredRepos()}
fallback={
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
<span>This user does not have any repos yet.</span>
</div>
}
>
{(repo) => (
<RepoCard
owner={resolvedActor().handle}
name={repo.value.name || repo.rkey}
description={repo.value.description}
isFork={Boolean(repo.value.source)}
showOwner={false}
/>
)}
</For>
</Show>
</div>
</div>
</Match>
{/* Starred Tab */}
<Match when={activeTab() === 'starred'}>
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
<Show
when={!starsQuery.isLoading}
fallback={
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
<For each={Array.from({ length: 4 })}>
{() => <RepoCardSkeleton />}
</For>
</div>
}
>
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
<For
each={starsQuery.data ?? []}
fallback={
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
<span>This user does not have any starred repos yet.</span>
</div>
}
>
{(repo) => (
<RepoCard
owner={repo.owner.handle}
name={repo.slug}
repoDid={repo.repoDid}
description={repo.record.value.description}
isFork={Boolean(repo.record.value.source)}
showOwner={true}
/>
)}
</For>
</div>
</Show>
</div>
</Match>
{/* Strings Tab */}
<Match when={activeTab() === 'strings'}>
<div id="all-strings" class="md:col-span-8 order-2 md:order-2">
<Show
when={!stringsQuery.isLoading}
fallback={
<div id="strings" class="grid grid-cols-1 gap-4 mb-6">
<For each={Array.from({ length: 3 })}>
{() => <StringCardSkeleton />}
</For>
</div>
}
>
<div id="strings" class="grid grid-cols-1 gap-4 mb-6">
<For
each={stringsQuery.data ?? []}
fallback={
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
<span>This user does not have any strings yet.</span>
</div>
}
>
{(item) => {
const lineCount = () => item.value.contents.split('\n').length;
const timeAgo = () => formatRelativeTime(item.value.createdAt);
return (
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
<div class="py-4 px-6 rounded bg-white dark:bg-gray-800">
<div class="font-medium dark:text-white flex gap-2 items-center">
<A
href={`/strings/${resolvedActor().handle || resolvedActor().did}/${item.rkey}`}
class="hover:underline"
{...preloadString(resolvedActor().handle || resolvedActor().did, item.rkey)}
>
{item.value.filename}
</A> </div>
{item.value.description && (
<div class="text-gray-600 dark:text-gray-300 text-sm">
{item.value.description}
</div>
)}
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto">
<span>{lineCount()} line{lineCount() !== 1 ? 's' : ''}</span>
<span class="select-none [&:before]:content-['·']"></span>
<span>{timeAgo()}</span>
</div>
</div>
</div>
);
}}
</For>
</div>
</Show>
</div>
</Match>
{/* Followers Tab */}
<Match when={activeTab() === 'followers'}>
<div id="all-followers" class="md:col-span-8 order-2 md:order-2">
<p class="text-base font-bold uppercase tracking-wider p-2 dark:text-white">all followers</p>
<Show
when={!followersQuery.isLoading}
fallback={
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
<For each={Array.from({ length: 3 })}>
{() => <FollowCardSkeleton />}
</For>
</div>
}
>
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
<For
each={followersQuery.data ?? []}
fallback={
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
<span>This user does not have any followers yet.</span>
</div>
}
>
{(item) => (
<FollowCard
item={item}
loggedInUserDid={auth.currentDid() ?? undefined}
viewerFollows={viewerFollowsQuery.data}
onFollowToggle={handleItemFollowToggle}
/>
)}
</For>
</div>
</Show>
</div>
</Match>
{/* Following Tab */}
<Match when={activeTab() === 'following'}>
<div id="all-following" class="md:col-span-8 order-2 md:order-2">
<p class="text-base font-bold uppercase tracking-wider p-2 dark:text-white">all following</p>
<Show
when={!followingQuery.isLoading}
fallback={
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
<For each={Array.from({ length: 3 })}>
{() => <FollowCardSkeleton />}
</For>
</div>
}
>
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
<For
each={followingQuery.data ?? []}
fallback={
<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">
<span>This user does not follow anyone yet.</span>
</div>
}
>
{(item) => (
<FollowCard
item={item}
loggedInUserDid={auth.currentDid() ?? undefined}
viewerFollows={viewerFollowsQuery.data}
onFollowToggle={handleItemFollowToggle}
/>
)}
</For>
</div>
</Show>
</div>
</Match>
</Switch>
</div>
</section>
</div>
</Match>
</Switch>
)}
</Match>
</Switch>
);
};