skeleton pog

This commit is contained in:
Michael Freno
2025-12-19 19:51:15 -05:00
parent 803d49d7fc
commit 76fb86d519
4 changed files with 235 additions and 77 deletions

View File

@@ -1,4 +1,5 @@
import { Component, For, createMemo } from "solid-js"; import { Component, For, createMemo, Show } from "solid-js";
import { SkeletonBox } from "./SkeletonLoader";
interface ContributionDay { interface ContributionDay {
date: string; date: string;
@@ -63,26 +64,50 @@ export const ActivityHeatmap: Component<{
return ( return (
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<h3 class="text-subtext0 text-sm font-semibold">{props.title}</h3> <h3 class="text-subtext0 text-sm font-semibold">{props.title}</h3>
<div class="flex gap-[2px] overflow-x-auto"> <Show
<For each={weeks()}> when={props.contributions && props.contributions.length > 0}
{(week) => ( fallback={
<div class="flex flex-col gap-[2px]"> <div class="relative">
<For each={week}> {/* Skeleton grid matching heatmap dimensions */}
{(day) => ( <div class="flex gap-[2px]">
<div <For each={Array(12)}>
class="h-2 w-2 rounded-[2px] transition-all hover:scale-125" {() => (
style={{ <div class="flex flex-col gap-[2px]">
"background-color": getColor(day.count), <For each={Array(7)}>
opacity: getOpacity(day.count) {() => <SkeletonBox class="h-2 w-2 rounded-[2px]" />}
}} </For>
title={`${day.date}: ${day.count} contributions`} </div>
/>
)} )}
</For> </For>
</div> </div>
)} {/* Centered spinner overlay */}
</For> <div class="bg-base/70 absolute inset-0 flex items-center justify-center backdrop-blur-sm">
</div> <SkeletonBox class="h-8 w-8" />
</div>
</div>
}
>
<div class="flex gap-[2px] overflow-x-auto">
<For each={weeks()}>
{(week) => (
<div class="flex flex-col gap-[2px]">
<For each={week}>
{(day) => (
<div
class="h-2 w-2 rounded-[2px] transition-all hover:scale-125"
style={{
"background-color": getColor(day.count),
opacity: getOpacity(day.count)
}}
title={`${day.date}: ${day.count} contributions`}
/>
)}
</For>
</div>
)}
</For>
</div>
</Show>
<div class="flex items-center gap-2 text-[10px]"> <div class="flex items-center gap-2 text-[10px]">
<span class="text-subtext1">Less</span> <span class="text-subtext1">Less</span>
<div class="flex gap-1"> <div class="flex gap-1">

View File

@@ -1,6 +1,13 @@
import { Typewriter } from "./Typewriter"; import { Typewriter } from "./Typewriter";
import { useBars } from "~/context/bars"; import { useBars } from "~/context/bars";
import { onMount, createEffect, createSignal, Show, For } from "solid-js"; import {
onMount,
createEffect,
createSignal,
Show,
For,
onCleanup
} from "solid-js";
import { api } from "~/lib/api"; import { api } from "~/lib/api";
import { TerminalSplash } from "./TerminalSplash"; import { TerminalSplash } from "./TerminalSplash";
import { insertSoftHyphens } from "~/lib/client-utils"; import { insertSoftHyphens } from "~/lib/client-utils";
@@ -9,6 +16,7 @@ import LinkedIn from "./icons/LinkedIn";
import { RecentCommits } from "./RecentCommits"; import { RecentCommits } from "./RecentCommits";
import { ActivityHeatmap } from "./ActivityHeatmap"; import { ActivityHeatmap } from "./ActivityHeatmap";
import { DarkModeToggle } from "./DarkModeToggle"; import { DarkModeToggle } from "./DarkModeToggle";
import { SkeletonBox, SkeletonText } from "./SkeletonLoader";
interface GitCommit { interface GitCommit {
sha: string; sha: string;
@@ -33,25 +41,35 @@ export function RightBarContent() {
const [giteaActivity, setGiteaActivity] = createSignal<ContributionDay[]>([]); const [giteaActivity, setGiteaActivity] = createSignal<ContributionDay[]>([]);
const [loading, setLoading] = createSignal(true); const [loading, setLoading] = createSignal(true);
onMount(async () => { onMount(() => {
// Fetch all data client-side only to avoid hydration mismatch // Fetch all data client-side only to avoid hydration mismatch
try { const fetchData = async () => {
const [ghCommits, gtCommits, ghActivity, gtActivity] = await Promise.all([ try {
api.gitActivity.getGitHubCommits.query({ limit: 3 }).catch(() => []), const [ghCommits, gtCommits, ghActivity, gtActivity] =
api.gitActivity.getGiteaCommits.query({ limit: 3 }).catch(() => []), await Promise.all([
api.gitActivity.getGitHubActivity.query().catch(() => []), api.gitActivity.getGitHubCommits
api.gitActivity.getGiteaActivity.query().catch(() => []) .query({ limit: 3 })
]); .catch(() => []),
api.gitActivity.getGiteaCommits.query({ limit: 3 }).catch(() => []),
api.gitActivity.getGitHubActivity.query().catch(() => []),
api.gitActivity.getGiteaActivity.query().catch(() => [])
]);
setGithubCommits(ghCommits); setGithubCommits(ghCommits);
setGiteaCommits(gtCommits); setGiteaCommits(gtCommits);
setGithubActivity(ghActivity); setGithubActivity(ghActivity);
setGiteaActivity(gtActivity); setGiteaActivity(gtActivity);
} catch (error) { } catch (error) {
console.error("Failed to fetch git activity:", error); console.error("Failed to fetch git activity:", error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
};
// Defer API calls to next tick to allow initial render to complete first
setTimeout(() => {
fetchData();
}, 0);
}); });
return ( return (
@@ -166,43 +184,11 @@ export function LeftBar() {
} }
}; };
onMount(async () => { onMount(() => {
// Mark as mounted to avoid hydration mismatch // Mark as mounted to avoid hydration mismatch
setIsMounted(true); setIsMounted(true);
// Fetch recent posts only on client side to avoid hydration mismatch // Setup ResizeObserver FIRST (synchronous) - this allows bar sizing to happen immediately
try {
const posts = await api.blog.getRecentPosts.query();
setRecentPosts(posts as any[]);
} catch (error) {
console.error("Failed to fetch recent posts:", error);
setRecentPosts([]);
}
// Fetch user info client-side only to avoid hydration mismatch
try {
const response = await fetch("/api/trpc/user.getProfile", {
method: "GET"
});
if (response.ok) {
const result = await response.json();
if (result.result?.data) {
setUserInfo({
email: result.result.data.email,
isAuthenticated: true
});
} else {
setUserInfo({ email: null, isAuthenticated: false });
}
} else {
setUserInfo({ email: null, isAuthenticated: false });
}
} catch (error) {
console.error("Failed to fetch user info:", error);
setUserInfo({ email: null, isAuthenticated: false });
}
if (ref) { if (ref) {
const updateSize = () => { const updateSize = () => {
actualWidth = ref?.offsetWidth || 0; actualWidth = ref?.offsetWidth || 0;
@@ -282,13 +268,54 @@ export function LeftBar() {
ref.addEventListener("touchend", handleTouchEnd, { passive: true }); ref.addEventListener("touchend", handleTouchEnd, { passive: true });
ref.addEventListener("keydown", handleKeyDown); ref.addEventListener("keydown", handleKeyDown);
return () => { onCleanup(() => {
resizeObserver.disconnect(); resizeObserver.disconnect();
ref?.removeEventListener("touchstart", handleTouchStart); ref?.removeEventListener("touchstart", handleTouchStart);
ref?.removeEventListener("touchend", handleTouchEnd); ref?.removeEventListener("touchend", handleTouchEnd);
ref?.removeEventListener("keydown", handleKeyDown); ref?.removeEventListener("keydown", handleKeyDown);
}; });
} }
// Fetch data asynchronously AFTER sizing setup (non-blocking)
const fetchData = async () => {
// Fetch recent posts only on client side to avoid hydration mismatch
try {
const posts = await api.blog.getRecentPosts.query();
setRecentPosts(posts as any[]);
} catch (error) {
console.error("Failed to fetch recent posts:", error);
setRecentPosts([]);
}
// Fetch user info client-side only to avoid hydration mismatch
try {
const response = await fetch("/api/trpc/user.getProfile", {
method: "GET"
});
if (response.ok) {
const result = await response.json();
if (result.result?.data) {
setUserInfo({
email: result.result.data.email,
isAuthenticated: true
});
} else {
setUserInfo({ email: null, isAuthenticated: false });
}
} else {
setUserInfo({ email: null, isAuthenticated: false });
}
} catch (error) {
console.error("Failed to fetch user info:", error);
setUserInfo({ email: null, isAuthenticated: false });
}
};
// Defer API calls to next tick to allow initial render/sizing to complete first
setTimeout(() => {
fetchData();
}, 0);
}); });
// Update size when visibility changes // Update size when visibility changes
@@ -365,7 +392,23 @@ export function LeftBar() {
<div class="flex flex-col py-8"> <div class="flex flex-col py-8">
<span class="text-lg font-semibold">Recent Posts</span> <span class="text-lg font-semibold">Recent Posts</span>
<div class="flex max-h-[50dvh] flex-col gap-3 pt-4"> <div class="flex max-h-[50dvh] flex-col gap-3 pt-4">
<Show when={recentPosts()} fallback={<TerminalSplash />}> <Show
when={recentPosts()}
fallback={
<For each={[1, 2, 3]}>
{() => (
<div class="flex flex-col gap-2">
<div class="relative overflow-hidden">
<SkeletonBox class="float-right mb-1 ml-2 h-12 w-16" />
<SkeletonText class="mb-1 w-full" />
<SkeletonText class="w-3/4" />
</div>
<SkeletonText class="clear-both w-24 text-xs" />
</div>
)}
</For>
}
>
<For each={recentPosts()}> <For each={recentPosts()}>
{(post) => ( {(post) => (
<a <a

View File

@@ -1,5 +1,6 @@
import { Component, For, Show } from "solid-js"; import { Component, For, Show } from "solid-js";
import { Typewriter } from "./Typewriter"; import { Typewriter } from "./Typewriter";
import { SkeletonText, SkeletonBox } from "./SkeletonLoader";
interface Commit { interface Commit {
sha: string; sha: string;
@@ -43,9 +44,30 @@ export const RecentCommits: Component<{
<Show <Show
when={!props.loading && props.commits && props.commits.length > 0} when={!props.loading && props.commits && props.commits.length > 0}
fallback={ fallback={
<div class="text-subtext1 text-xs"> <Show
{props.loading ? "Loading..." : "No recent commits"} when={props.loading}
</div> fallback={
<div class="text-subtext1 text-xs">No recent commits</div>
}
>
<div class="flex flex-col gap-2">
<For each={[1, 2, 3]}>
{() => (
<div class="block w-52 rounded-md p-2">
<div class="flex min-w-0 flex-col gap-1">
<SkeletonText class="h-3 w-full" />
<SkeletonText class="h-3 w-3/4" />
<SkeletonText class="h-2 w-16" />
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
<SkeletonBox class="h-4 w-16" />
<SkeletonText class="h-2 w-24" />
</div>
</div>
</div>
)}
</For>
</div>
</Show>
} }
> >
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@@ -55,7 +77,7 @@ export const RecentCommits: Component<{
href={commit.url} href={commit.url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="hover:bg-surface0 group block rounded-md p-2 transition-all duration-200 ease-in-out hover:scale-[1.02]" class="hover:bg-surface0 group block w-52 rounded-md p-2 transition-all duration-200 ease-in-out hover:scale-[1.02]"
> >
<Typewriter <Typewriter
speed={100} speed={100}

View File

@@ -0,0 +1,68 @@
import { onMount, onCleanup, createSignal, JSX } from "solid-js";
import { isServer } from "solid-js/web";
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
interface SkeletonProps {
class?: string;
}
function useSpinner() {
const [showing, setShowing] = createSignal(0);
onMount(() => {
if (isServer) return;
const interval = setInterval(() => {
setShowing((prev) => (prev + 1) % spinnerChars.length);
}, 50);
onCleanup(() => {
clearInterval(interval);
});
});
return () => spinnerChars[showing()];
}
export function SkeletonBox(props: SkeletonProps) {
const spinner = useSpinner();
return (
<div
class={`bg-surface0 text-overlay0 flex items-center justify-center rounded font-mono ${props.class || ""}`}
aria-label="Loading..."
role="status"
>
{spinner()}
</div>
);
}
export function SkeletonText(props: SkeletonProps) {
const spinner = useSpinner();
return (
<div
class={`bg-surface0 text-overlay0 inline-flex h-4 items-center rounded px-2 font-mono text-sm ${props.class || ""}`}
aria-label="Loading..."
role="status"
>
{spinner()}
</div>
);
}
export function SkeletonCircle(props: SkeletonProps) {
const spinner = useSpinner();
return (
<div
class={`bg-surface0 text-overlay0 flex items-center justify-center rounded-full font-mono ${props.class || ""}`}
aria-label="Loading..."
role="status"
>
{spinner()}
</div>
);
}