110 lines
3.6 KiB
TypeScript
110 lines
3.6 KiB
TypeScript
"use client";
|
|
|
|
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
|
import type { Person } from "@/lib/auth-client";
|
|
import { useTheme } from "@/lib/use-theme";
|
|
import { HexAvatar } from "./HexAvatar";
|
|
import { Logo } from "./Logo";
|
|
import { CompactLayoutContext, isCompactWidth } from "./layout-context";
|
|
|
|
const iconProps = {
|
|
viewBox: "0 0 24 24",
|
|
fill: "none",
|
|
stroke: "currentColor",
|
|
strokeWidth: 1.7,
|
|
strokeLinecap: "round",
|
|
strokeLinejoin: "round",
|
|
"aria-hidden": true,
|
|
} as const;
|
|
|
|
function SunIcon() {
|
|
return (
|
|
<svg {...iconProps}>
|
|
<circle cx="12" cy="12" r="4" />
|
|
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function MoonIcon() {
|
|
return (
|
|
<svg {...iconProps}>
|
|
<path d="M20.5 14.5A8.5 8.5 0 0 1 9.5 3.5a8.5 8.5 0 1 0 11 11z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/** Left account panel: logo, wordmark, theme toggle, the person, live status. */
|
|
export function AccountPanel({ person }: { person: Person }) {
|
|
const { theme, toggleTheme } = useTheme();
|
|
const next = theme === "dark" ? "light" : "dark";
|
|
return (
|
|
<section className="tile account" aria-label="Account">
|
|
<div className="account-brand">
|
|
<Logo size={32} />
|
|
<span className="account-word type-wordmark">MINNECT</span>
|
|
<button type="button" className="account-theme" aria-label={`Switch to ${next} theme`} title={`Switch to ${next} theme`} onClick={toggleTheme}>
|
|
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
|
</button>
|
|
</div>
|
|
<div className="account-me">
|
|
<HexAvatar name={person.name || person.email} pictureUrl={person.pictureUrl} size="lg" />
|
|
<div className="account-me-text">
|
|
<strong>{person.name}</strong>
|
|
<span>{person.email}</span>
|
|
</div>
|
|
</div>
|
|
<div className="account-live">
|
|
<span className="account-dot" aria-hidden="true" />
|
|
Feed updates live
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export interface AppShellProps {
|
|
person: Person;
|
|
/** Find experts panel (left column, under the account panel). */
|
|
search: ReactNode;
|
|
/** Feed panel (center column). */
|
|
feed: ReactNode;
|
|
/** Featured experts and Trending minnects panels (right column). */
|
|
discover: ReactNode;
|
|
}
|
|
|
|
/** Signed-in bento shell (spec "Desktop and tablet layout", ADR 0001 section 3). */
|
|
export function AppShell({ person, search, feed, discover }: AppShellProps) {
|
|
const root = useRef<HTMLDivElement>(null);
|
|
const [compact, setCompact] = useState(false);
|
|
|
|
// The CSS switches layout with a container query on .app; the feed also
|
|
// needs to know, because the phone feed is different markup (full-screen slides).
|
|
useLayoutEffect(() => {
|
|
const el = root.current;
|
|
if (!el) return;
|
|
const measure = () => setCompact(isCompactWidth(el.getBoundingClientRect().width));
|
|
measure();
|
|
if (typeof ResizeObserver === "undefined") return;
|
|
const ro = new ResizeObserver(measure);
|
|
ro.observe(el);
|
|
return () => ro.disconnect();
|
|
}, []);
|
|
|
|
return (
|
|
<CompactLayoutContext.Provider value={compact}>
|
|
<div className="app" ref={root} data-layout={compact ? "compact" : "wide"}>
|
|
<main className="bento">
|
|
<div className="bento-col bento-left">
|
|
<AccountPanel person={person} />
|
|
{compact ? null : search}
|
|
</div>
|
|
<div className="bento-col bento-center">{feed}</div>
|
|
<aside className="bento-col bento-right" aria-label="Discover">
|
|
{compact ? null : discover}
|
|
</aside>
|
|
</main>
|
|
</div>
|
|
</CompactLayoutContext.Provider>
|
|
);
|
|
}
|