skeleton pog
This commit is contained in:
@@ -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,6 +64,29 @@ 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>
|
||||||
|
<Show
|
||||||
|
when={props.contributions && props.contributions.length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="relative">
|
||||||
|
{/* Skeleton grid matching heatmap dimensions */}
|
||||||
|
<div class="flex gap-[2px]">
|
||||||
|
<For each={Array(12)}>
|
||||||
|
{() => (
|
||||||
|
<div class="flex flex-col gap-[2px]">
|
||||||
|
<For each={Array(7)}>
|
||||||
|
{() => <SkeletonBox class="h-2 w-2 rounded-[2px]" />}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
{/* Centered spinner overlay */}
|
||||||
|
<div class="bg-base/70 absolute inset-0 flex items-center justify-center backdrop-blur-sm">
|
||||||
|
<SkeletonBox class="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div class="flex gap-[2px] overflow-x-auto">
|
<div class="flex gap-[2px] overflow-x-auto">
|
||||||
<For each={weeks()}>
|
<For each={weeks()}>
|
||||||
{(week) => (
|
{(week) => (
|
||||||
@@ -83,6 +107,7 @@ export const ActivityHeatmap: Component<{
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</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">
|
||||||
|
|||||||
@@ -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,11 +41,15 @@ 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
|
||||||
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const [ghCommits, gtCommits, ghActivity, gtActivity] = await Promise.all([
|
const [ghCommits, gtCommits, ghActivity, gtActivity] =
|
||||||
api.gitActivity.getGitHubCommits.query({ limit: 3 }).catch(() => []),
|
await Promise.all([
|
||||||
|
api.gitActivity.getGitHubCommits
|
||||||
|
.query({ limit: 3 })
|
||||||
|
.catch(() => []),
|
||||||
api.gitActivity.getGiteaCommits.query({ limit: 3 }).catch(() => []),
|
api.gitActivity.getGiteaCommits.query({ limit: 3 }).catch(() => []),
|
||||||
api.gitActivity.getGitHubActivity.query().catch(() => []),
|
api.gitActivity.getGitHubActivity.query().catch(() => []),
|
||||||
api.gitActivity.getGiteaActivity.query().catch(() => [])
|
api.gitActivity.getGiteaActivity.query().catch(() => [])
|
||||||
@@ -52,6 +64,12 @@ export function RightBarContent() {
|
|||||||
} 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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
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>
|
||||||
|
</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}
|
||||||
|
|||||||
68
src/components/SkeletonLoader.tsx
Normal file
68
src/components/SkeletonLoader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user