151 lines
4.7 KiB
TypeScript
151 lines
4.7 KiB
TypeScript
import Link from "next/link";
|
|
import { eq } from "drizzle-orm";
|
|
import { cn } from "@/lib/utils";
|
|
import { auth } from "@/lib/auth";
|
|
import { db } from "@/lib/db";
|
|
import { getRegistry } from "@/modules/_core/registry";
|
|
import { householdMembers, households, users } from "@/modules/_core/schema";
|
|
import { BrandMark } from "@/components/brand-mark";
|
|
import { NavIcon } from "@/components/nav-icon";
|
|
import { NavLink } from "@/components/nav-link";
|
|
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
|
|
|
|
type Variant = "side" | "top";
|
|
|
|
const PRIMARY_NAV: Array<{ href: string; label: string; icon: string }> = [
|
|
{ href: "/", label: "Dashboard", icon: "home" },
|
|
];
|
|
|
|
const SECONDARY_NAV: Array<{ href: string; label: string; icon: string }> = [
|
|
{ href: "/settings#sharing", label: "Share links", icon: "link" },
|
|
];
|
|
|
|
async function getHouseholdInfo(userId: string) {
|
|
const [row] = await db
|
|
.select({ household: households })
|
|
.from(householdMembers)
|
|
.innerJoin(households, eq(householdMembers.householdId, households.id))
|
|
.where(eq(householdMembers.userId, userId))
|
|
.limit(1);
|
|
if (!row) return null;
|
|
const members = await db
|
|
.select({ id: users.id, name: users.name, image: users.image, email: users.email })
|
|
.from(householdMembers)
|
|
.innerJoin(users, eq(householdMembers.userId, users.id))
|
|
.where(eq(householdMembers.householdId, row.household.id))
|
|
.limit(6);
|
|
return { household: row.household, members };
|
|
}
|
|
|
|
export async function Sidebar({ variant = "side" }: { variant?: Variant }) {
|
|
const { modules } = getRegistry();
|
|
const moduleNav = modules.flatMap((m) => (m.nav ? [m.nav] : []));
|
|
|
|
const navItems = [
|
|
...PRIMARY_NAV,
|
|
...moduleNav.map((n) => ({ href: n.href, label: n.label, icon: n.icon ?? "circle" })),
|
|
{ href: "/settings", label: "Settings", icon: "settings" },
|
|
];
|
|
|
|
const session = await auth();
|
|
const householdInfo = session?.user?.id ? await getHouseholdInfo(session.user.id) : null;
|
|
|
|
if (variant === "top") {
|
|
return (
|
|
<header className="sidebar sidebar-top">
|
|
<Link href="/" className="brand">
|
|
<BrandMark />
|
|
<span className="brand-name">famapp</span>
|
|
</Link>
|
|
{navItems.map((n) => (
|
|
<NavLink
|
|
key={n.href}
|
|
href={n.href}
|
|
label={n.label}
|
|
icon={n.icon}
|
|
className="!w-auto !h-9"
|
|
/>
|
|
))}
|
|
<div className="ml-auto" />
|
|
{householdInfo && (
|
|
<span className="badge">
|
|
<NavIcon name="people" className="size-3" />
|
|
{householdInfo.household.name}
|
|
</span>
|
|
)}
|
|
</header>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<aside className="sidebar">
|
|
<Link href="/" className="brand">
|
|
<BrandMark />
|
|
<span className="brand-name">famapp</span>
|
|
</Link>
|
|
{navItems.map((n) => (
|
|
<NavLink key={n.href} href={n.href} label={n.label} icon={n.icon} />
|
|
))}
|
|
<div className="nav-divider" />
|
|
{SECONDARY_NAV.map((n) => (
|
|
<NavLink key={n.href} href={n.href} label={n.label} icon={n.icon} />
|
|
))}
|
|
{householdInfo && <HouseholdPill info={householdInfo} />}
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function HouseholdPill({
|
|
info,
|
|
}: {
|
|
info: NonNullable<Awaited<ReturnType<typeof getHouseholdInfo>>>;
|
|
}) {
|
|
const visible = info.members.slice(0, 3);
|
|
return (
|
|
<div className="household-pill" title={info.household.name}>
|
|
<div style={{ display: "flex" }}>
|
|
{visible.map((m, i) => (
|
|
<AvatarFallbackWithName
|
|
key={m.id}
|
|
name={m.name ?? m.email ?? undefined}
|
|
image={m.image}
|
|
className={cn(i > 0 && "-ml-1.5", "shadow-[0_0_0_1.5px_var(--card)]")}
|
|
fallbackStyle={{ background: avatarColor(m.id) }}
|
|
title={m.name ?? m.email ?? undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="household-text" style={{ minWidth: 0 }}>
|
|
<div
|
|
style={{
|
|
fontWeight: 500,
|
|
color: "var(--ink)",
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
}}
|
|
>
|
|
{info.household.name}
|
|
</div>
|
|
<div className="muted" style={{ fontSize: 11 }}>
|
|
{info.members.length} {info.members.length === 1 ? "member" : "members"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Stable color from id — uses the 5 functional accents in rotation.
|
|
const ACCENT_HUES = [
|
|
"var(--c-household)",
|
|
"var(--c-private)",
|
|
"var(--c-kids)",
|
|
"var(--c-work)",
|
|
"var(--c-bills)",
|
|
];
|
|
function avatarColor(id: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) & 0xffff;
|
|
return ACCENT_HUES[hash % ACCENT_HUES.length] ?? "var(--c-household)";
|
|
}
|