Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ dist-ssr
.vinxi
__unconfig*
.turbo
.zed
158 changes: 135 additions & 23 deletions apps/dashboard/src/components/details/detail-page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,110 @@
import { Skeleton } from "@diffkit/ui/components/skeleton";
import { cn } from "@diffkit/ui/lib/utils";
import { Link } from "@tanstack/react-router";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
import { createContext, useContext, useEffect, useState } from "react";

const STAGGER_DELAY = 1;
const ITEM_DURATION = 1.25;
const FADE_OUT_DURATION = 0.5;
const PAUSE_BEFORE_RESTART = 1;

type StaggerContextValue = {
cycle: number;
groupOpacity: ReturnType<typeof useMotionValue<number>>;
};
const defaultGroupOpacity = {
get: () => 1,
set: () => {},
} as unknown as ReturnType<typeof useMotionValue<number>>;
const StaggerCycleContext = createContext<StaggerContextValue>({
cycle: 0,
groupOpacity: defaultGroupOpacity,
});

function StaggerLoop({
itemCount,
children,
}: {
itemCount: number;
children: React.ReactNode;
}) {
const [cycle, setCycle] = useState(0);
const groupOpacity = useMotionValue(1);

// biome-ignore lint/correctness/useExhaustiveDependencies: cycle drives the restart loop
useEffect(() => {
const lastItemFinish = (itemCount - 1) * STAGGER_DELAY + ITEM_DURATION;
const totalVisible = lastItemFinish + PAUSE_BEFORE_RESTART;

const timeout = setTimeout(() => {
const controls = animate(groupOpacity, 0, {
duration: FADE_OUT_DURATION,
ease: "easeInOut",
onComplete: () => {
groupOpacity.set(1);
setCycle((c) => c + 1);
},
});
return () => controls.stop();
}, totalVisible * 1000);

return () => clearTimeout(timeout);
}, [cycle, itemCount, groupOpacity]);

return (
<StaggerCycleContext.Provider value={{ cycle, groupOpacity }}>
{children}
</StaggerCycleContext.Provider>
);
}

function StaggerItem({
children,
index,
className,
}: {
children: React.ReactNode;
index: number;
className?: string;
}) {
const { cycle, groupOpacity } = useContext(StaggerCycleContext);
const itemOpacity = useMotionValue(0);
const combinedOpacity = useTransform(
[itemOpacity, groupOpacity],
([item, group]) => Math.min(item as number, group as number),
);

// biome-ignore lint/correctness/useExhaustiveDependencies: cycle resets the item animation
useEffect(() => {
itemOpacity.set(0);
const controls = animate(itemOpacity, 1, {
type: "spring",
duration: ITEM_DURATION,
bounce: 0,
delay: index * STAGGER_DELAY,
});
return () => controls.stop();
}, [cycle, index, itemOpacity]);

return (
<motion.div
key={cycle}
initial={{ y: 8 }}
animate={{ y: 0 }}
transition={{
type: "spring",
duration: ITEM_DURATION,
bounce: 0,
delay: index * STAGGER_DELAY,
}}
style={{ opacity: combinedOpacity }}
className={className}
>
{children}
</motion.div>
);
}

type DetailHeaderIcon = React.ComponentType<{
size?: number;
Expand Down Expand Up @@ -56,9 +160,13 @@ export function DetailPageTitle({
{collectionLabel}
</Link>
<span>/</span>
<span>
<Link
to="/$owner/$repo"
params={{ owner, repo }}
className="transition-colors hover:text-foreground"
>
{owner}/{repo}
</span>
</Link>
<span>/</span>
<span>#{number}</span>
</div>
Expand All @@ -76,32 +184,36 @@ export function DetailPageTitle({
);
}

export { StaggerItem };

export function DetailPageSkeletonLayout({
main,
sidebarSectionCount = 4,
children,
mainItemCount,
sidebarSectionCount = 3,
}: {
main: React.ReactNode;
children: React.ReactNode;
mainItemCount: number;
sidebarSectionCount?: number;
}) {
const totalItems = Math.max(mainItemCount, sidebarSectionCount);
return (
<div className="h-full overflow-auto">
<div className="mx-auto grid max-w-7xl gap-16 px-3 py-10 md:px-6 xl:grid-cols-[minmax(0,1fr)_minmax(16rem,20rem)]">
<div className="flex min-w-0 flex-col gap-8">{main}</div>
<aside className="flex h-fit flex-col gap-6 xl:sticky xl:top-10">
{Array.from(
{ length: sidebarSectionCount },
(_, index) => `sidebar-skeleton-${index}`,
).map((key) => (
<div key={key} className="flex flex-col gap-2.5">
<Skeleton className="h-4 w-24 rounded-md" />
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full rounded-md" />
<Skeleton className="h-4 w-[85%] rounded-md" />
</div>
</div>
))}
</aside>
<StaggerLoop itemCount={totalItems}>
<div className="h-full overflow-auto">
<div className="mx-auto grid max-w-7xl gap-16 px-3 py-10 md:px-6 xl:grid-cols-[minmax(0,1fr)_minmax(16rem,20rem)]">
<div className="flex min-w-0 flex-col gap-8">{children}</div>
<aside className="hidden h-fit flex-col gap-6 xl:sticky xl:top-10 xl:flex">
{Array.from({ length: sidebarSectionCount }, (_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton items, order never changes
<StaggerItem key={i} index={i}>
<div className="flex flex-col gap-2.5">
<Skeleton className="h-4 w-24 rounded-md" />
<Skeleton className="h-4 w-full rounded-md" />
</div>
</StaggerItem>
))}
</aside>
</div>
</div>
</div>
</StaggerLoop>
);
}
106 changes: 63 additions & 43 deletions apps/dashboard/src/components/issues/detail/issue-detail-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getRouteApi } from "@tanstack/react-router";
import {
DetailPageLayout,
DetailPageSkeletonLayout,
StaggerItem,
} from "#/components/details/detail-page";
import {
githubIssuePageQueryOptions,
Expand Down Expand Up @@ -89,58 +90,77 @@ export function IssueDetailPage() {

function IssueDetailPageSkeleton() {
return (
<DetailPageSkeletonLayout
main={
<>
<div className="flex flex-col gap-3">
<Skeleton className="h-4 w-48 rounded-md" />
<div className="flex items-start gap-3">
<Skeleton className="mt-1 size-5 rounded-full" />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Skeleton className="h-8 w-3/4 rounded-md" />
<div className="flex gap-2">
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-48 rounded-full" />
</div>
<DetailPageSkeletonLayout mainItemCount={4}>
<StaggerItem index={0}>
<div className="flex flex-col gap-3">
<Skeleton className="h-4 w-48 rounded-md" />
<div className="flex items-start gap-3">
<Skeleton className="mt-1 size-5 rounded-full" />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Skeleton className="h-8 w-3/4 rounded-md" />
<div className="flex gap-2">
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-4 w-48" />
</div>
</div>
</div>
</div>
</StaggerItem>

<div className="flex flex-wrap gap-1.5">
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-6 w-24 rounded-full" />
</div>
<StaggerItem index={1}>
<div className="flex flex-wrap gap-1.5">
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-6 w-24 rounded-full" />
</div>
</StaggerItem>

<div className="rounded-lg border bg-surface-0 p-5">
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full rounded-md" />
<Skeleton className="h-4 w-[92%] rounded-md" />
<Skeleton className="h-4 w-[78%] rounded-md" />
<Skeleton className="h-4 w-[66%] rounded-md" />
</div>
<StaggerItem index={2}>
<div className="rounded-lg border bg-surface-0 p-5">
<div className="flex flex-col gap-3">
<Skeleton className="h-4 w-full rounded-md" />
<Skeleton className="h-4 w-5/6 rounded-md" />
<Skeleton className="h-4 w-2/3 rounded-md" />
</div>
</div>
</StaggerItem>

<div className="flex flex-col">
<div className="flex items-center justify-between gap-2 rounded-lg bg-surface-1 px-4 py-2.5">
<Skeleton className="h-4 w-14 rounded-md" />
<Skeleton className="h-4 w-6 rounded-md" />
<StaggerItem index={3}>
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-2 rounded-lg bg-surface-1 px-4 py-2.5">
<Skeleton className="h-4 w-14 rounded-md" />
<Skeleton className="h-4 w-6 rounded-md" />
</div>
<div className="flex flex-col gap-5 pl-8">
{/* Comment */}
<div className="flex items-start gap-2">
<Skeleton className="size-5 rounded-full" />
<div className="flex flex-col gap-1.5">
<Skeleton className="h-3.5 w-36 rounded-md" />
<Skeleton className="h-12 w-64 rounded-lg" />
</div>
</div>
{/* Label event */}
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded" />
<Skeleton className="h-3.5 w-32 rounded-md" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
{/* Comment */}
<div className="flex items-start gap-2">
<Skeleton className="size-5 rounded-full" />
<div className="flex flex-col gap-1.5">
<Skeleton className="h-3.5 w-44 rounded-md" />
<Skeleton className="h-3.5 w-56 rounded-md" />
</div>
</div>
<div className="relative flex flex-col gap-5 py-5 pl-8 before:absolute before:left-4 before:top-0 before:h-full before:w-px before:bg-[linear-gradient(to_bottom,var(--color-border)_80%,transparent)]">
{["activity-1", "activity-2", "activity-3"].map((key) => (
<div key={key} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded-full" />
<Skeleton className="h-4 w-24 rounded-md" />
<Skeleton className="h-4 w-16 rounded-md" />
</div>
<Skeleton className="h-4 w-[88%] rounded-md" />
<Skeleton className="h-4 w-[72%] rounded-md" />
</div>
))}
{/* Assignment */}
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded" />
<Skeleton className="h-3.5 w-40 rounded-md" />
</div>
</div>
</>
}
/>
</div>
</StaggerItem>
</DetailPageSkeletonLayout>
);
}
Loading
Loading