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
76 changes: 0 additions & 76 deletions app/components/identity-photo-column.tsx

This file was deleted.

223 changes: 223 additions & 0 deletions app/components/identity-row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
"use client";

import {
useLayoutEffect,
useRef,
type CSSProperties,
type ReactNode,
} from "react";
import AvatarCard from "./avatar-card";
import HomeSocialLinks from "./home-social-links";
import { TocSection, tocId } from "./toc-section";

const MIN_AVATAR_PX = 120;
const MIN_ZONE_GAP_PX = 16;
const INTRO_MID_GAP_PX = 20;
const GRID_RULE_GAP_PX = 20;
const MD_MEDIA = "(min-width: 768px)";
const NAME_SECTION_ID = tocId("Kai Thomas Chen");
const GREETING_SECTION_ID = tocId("Greeting");
const CONTACT_SECTION_ID = tocId("Contact");

type ZoneGaps = { top: number; mid: number; bottom: number };

type RowLayout = {
avatarPx: number;
leftGaps: ZoneGaps;
introGaps: ZoneGaps;
};

function computeRowLayout(
nameHeight: number,
greetingHeight: number,
socialHeight: number,
columnWidth: number,
): RowLayout {
const introNatural = nameHeight + INTRO_MID_GAP_PX + greetingHeight;
const leftMin =
3 * MIN_ZONE_GAP_PX - GRID_RULE_GAP_PX + columnWidth + socialHeight;
const contentHeight = Math.max(introNatural, leftMin);

const maxAvatar = columnWidth;
let avatarPx = Math.min(
maxAvatar,
Math.max(
MIN_AVATAR_PX,
contentHeight - socialHeight - 3 * MIN_ZONE_GAP_PX + GRID_RULE_GAP_PX,
),
);

let zone =
(contentHeight - avatarPx - socialHeight + GRID_RULE_GAP_PX) / 3;
if (zone < MIN_ZONE_GAP_PX) {
avatarPx = Math.max(
MIN_AVATAR_PX,
Math.min(
maxAvatar,
contentHeight - socialHeight - 3 * MIN_ZONE_GAP_PX + GRID_RULE_GAP_PX,
),
);
zone = MIN_ZONE_GAP_PX;
}

const leftGaps: ZoneGaps = {
top: zone,
mid: zone,
bottom: zone - GRID_RULE_GAP_PX,
};

const introGaps: ZoneGaps =
contentHeight > introNatural + 0.5
? {
top: (contentHeight - nameHeight - greetingHeight) / 3,
mid: (contentHeight - nameHeight - greetingHeight) / 3,
bottom: (contentHeight - nameHeight - greetingHeight) / 3,
}
: { top: 0, mid: INTRO_MID_GAP_PX, bottom: 0 };

return { avatarPx, leftGaps, introGaps };
}

function clearInlineSpacing(el: HTMLElement | null | undefined) {
if (!el) return;
el.style.marginTop = "";
el.style.marginBottom = "";
}

type IdentityRowProps = {
children: ReactNode;
className?: string;
style?: CSSProperties;
};

export default function IdentityRow({ children, className = "", style }: IdentityRowProps) {
const rowRef = useRef<HTMLDivElement>(null);
const photoRef = useRef<HTMLDivElement>(null);
const avatarRef = useRef<HTMLDivElement>(null);
const socialRef = useRef<HTMLDivElement>(null);

useLayoutEffect(() => {
const row = rowRef.current;
const photo = photoRef.current;
const avatar = avatarRef.current;
const social = socialRef.current;
const nameSection = row?.querySelector<HTMLElement>(`#${NAME_SECTION_ID}`);
const greetingSection = row?.querySelector<HTMLElement>(`#${GREETING_SECTION_ID}`);
const contactSection = row?.querySelector<HTMLElement>(`#${CONTACT_SECTION_ID}`);

if (!row || !photo || !avatar || !social || !nameSection || !greetingSection || !contactSection) {
return;
}

const applySpacing = (next: RowLayout | null) => {
const isDesktop = window.matchMedia(MD_MEDIA).matches;

if (!isDesktop || !next) {
clearInlineSpacing(nameSection);
clearInlineSpacing(greetingSection);
clearInlineSpacing(contactSection);
avatar.style.marginTop = "";
avatar.style.width = "";
avatar.style.height = "";
return;
}

nameSection.style.marginTop = `${next.introGaps.top}px`;
greetingSection.style.marginTop = `${next.introGaps.mid}px`;
greetingSection.style.marginBottom = `${next.introGaps.bottom}px`;

avatar.style.marginTop = `${next.leftGaps.top}px`;
avatar.style.width = `${next.avatarPx}px`;
avatar.style.height = `${next.avatarPx}px`;
contactSection.style.marginTop = `${next.leftGaps.mid}px`;
contactSection.style.marginBottom = `${next.leftGaps.bottom}px`;
};

const update = () => {
if (!window.matchMedia(MD_MEDIA).matches) {
applySpacing(null);
return;
}

applySpacing(null);

const next = computeRowLayout(
nameSection.offsetHeight,
greetingSection.offsetHeight,
social.offsetHeight,
photo.clientWidth,
);
applySpacing(next);
};

update();

const ro = new ResizeObserver(update);
ro.observe(nameSection);
ro.observe(greetingSection);
ro.observe(social);
ro.observe(photo);

const mq = window.matchMedia(MD_MEDIA);
mq.addEventListener("change", update);

return () => {
ro.disconnect();
mq.removeEventListener("change", update);
applySpacing(null);
};
}, []);

return (
<div
ref={rowRef}
data-identity-row
className={`grid grid-cols-1 md:grid-cols-[36%_1fr] gap-y-6 md:gap-y-5 md:gap-x-10 ${className}`}
style={style}
>
{/* Intro — single DOM instance for PageToc */}
<div className="order-2 md:order-none md:col-start-2 md:row-start-1 min-w-0">
<div className="flex flex-col gap-5 md:gap-0 min-w-0">{children}</div>
<div
style={{ height: 1, margin: "0" }}
className="w-full max-w-[630px] bg-zinc-200 dark:bg-zinc-700 md:hidden"
/>
</div>

{/* Photo + social — single DOM instance */}
<div
ref={photoRef}
className="order-1 md:order-none md:col-start-1 md:row-start-1 w-full max-w-[320px] mx-auto md:mx-0 md:max-w-none flex flex-col gap-4 md:gap-0 items-center"
>
<div
ref={avatarRef}
className="w-full aspect-square md:aspect-auto mag-card overflow-hidden shrink-0"
style={{ padding: 0 }}
>
<AvatarCard src="/avatar.jpg" alt="Kai Thomas Chen" className="w-full h-full" />
</div>

<TocSection label="Contact" className="shrink-0 w-full">
<div ref={socialRef}>
<HomeSocialLinks />
</div>
</TocSection>

<div
style={{ height: 1, margin: "0" }}
className="w-full bg-zinc-200 dark:bg-zinc-700 md:hidden"
/>
</div>

{/* Desktop: shared rule row */}
<div
style={{ height: 1, margin: "0" }}
className="hidden md:block w-full bg-zinc-200 dark:bg-zinc-700 md:col-start-1 md:row-start-2"
/>
<div
style={{ height: 1, margin: "0" }}
className="hidden md:block w-full max-w-[630px] bg-zinc-200 dark:bg-zinc-700 md:col-start-2 md:row-start-2"
/>
</div>
);
}
11 changes: 10 additions & 1 deletion app/components/site-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,16 @@ export default function SiteFooter() {
>
Kai Thomas Chen
</Link>
. All rights reserved.
. All rights reserved.{" "}
<a
href="https://buttercut.kaichen.dev/"
target="_blank"
rel="noopener noreferrer"
className="text-zinc-300 dark:text-zinc-700 hover:text-[#C4894F] dark:hover:text-[#D9A870] transition-colors duration-150 no-underline"
>
Use this website&apos;s template
</a>
.
</p>
</div>
</footer>
Expand Down
Loading