/* * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only */ import { useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { ArrowUpRight, HardDrive } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { cn } from '@/lib/utils'; import { formatValue } from '../helpers'; import { squarify } from '../treemap'; import type { StorageUse } from '../serverFacts'; const MAX_TILES = 24; const HEIGHT = 260; /** How full an account is, as a tile color: calm until it nears its quota. */ function fillClass(u: StorageUse): string { if (!u.quota) return 'bg-[var(--chart-1)]/75 hover:bg-[var(--chart-1)]'; const pct = u.used / u.quota; if (pct >= 0.9) return 'bg-rose-500/80 hover:bg-rose-500'; if (pct >= 0.75) return 'bg-amber-500/80 hover:bg-amber-500'; return 'bg-[var(--chart-1)]/75 hover:bg-[var(--chart-1)]'; } /** * Who uses the storage, as a map: every person is a tile sized by the space * their mail, files and calendars take, and colored by how close they are to * their quota. The biggest users are the biggest tiles; a click opens them. */ export function StorageTreemap({ storage }: { storage: StorageUse[] }) { const { t } = useTranslation(); const box = useRef(null); const [width, setWidth] = useState(0); useEffect(() => { const el = box.current; if (!el) return; const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width)); ro.observe(el); return () => ro.disconnect(); }, []); const { tiles, total, others } = useMemo(() => { const sorted = [...storage].filter((s) => s.used > 0).sort((a, b) => b.used - a.used); const top = sorted.slice(0, MAX_TILES); const rest = sorted.slice(MAX_TILES); const restUse = rest.reduce((s, r) => s + r.used, 0); const items: StorageUse[] = restUse ? [ ...top, { id: '', name: t('storage.others', '{{count}} others', { count: rest.length }), used: restUse, quota: null }, ] : top; return { tiles: squarify(items, (i) => i.used, width, HEIGHT), total: sorted.reduce((s, r) => s + r.used, 0), others: rest.length, }; }, [storage, width, t]); const nearFull = storage.filter((s) => s.quota && s.used / s.quota >= 0.9).length; return (
{t('storage.title', 'Who uses the space')}

{total > 0 ? t('storage.subtitle', '{{total}} across {{count}} people', { total: formatValue(total, 'bytes'), count: storage.length, }) : t('storage.empty', 'No one has stored anything yet.')} {nearFull > 0 && ( {t('storage.nearFull', { count: nearFull, defaultValue_one: '{{count}} nearly full', defaultValue_other: '{{count}} nearly full', })} )}

{t('storage.seePeople', 'See people')}
{total === 0 && (
{t('storage.emptyHint', 'Tiles appear here as people store mail and files.')}
)} {tiles.map(({ item, x, y, w, h }) => { const pct = item.quota ? Math.round((item.used / item.quota) * 100) : null; const label = `${item.name}: ${formatValue(item.used, 'bytes')}${pct !== null ? ` (${pct}%)` : ''}`; const roomy = w > 90 && h > 44; const style = { left: x + 1, top: y + 1, width: Math.max(0, w - 2), height: Math.max(0, h - 2) }; const body = ( <> {roomy && ( <> {item.name} {formatValue(item.used, 'bytes')} {pct !== null && ` ยท ${pct}%`} )} ); const cls = cn( 'absolute overflow-hidden rounded-md p-2 text-left text-white transition-colors', item.id ? fillClass(item) : 'bg-muted-foreground/40', ); return item.id ? ( {body} ) : (
{body}
); })}
{others > 0 && (

{t('storage.topOnly', 'The {{count}} biggest are shown on their own.', { count: MAX_TILES })}

)}
); }