Files
places/src/components/ui-primitives.tsx
2026-05-20 14:00:51 +07:00

392 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import type { CSSProperties, ReactNode } from "react";
import { Icons, type IconName } from "./icons";
// ── Header (sticky top) ─────────────────────────────────
export function Header({
title,
subtitle,
left,
right,
big = false,
sticky = true,
}: {
title: string;
subtitle?: ReactNode;
left?: ReactNode;
right?: ReactNode;
big?: boolean;
sticky?: boolean;
}) {
if (big) {
return (
<div className={sticky ? "app-header" : ""}>
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
gap: 12,
padding: "12px 16px 14px",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<h1
style={{
margin: 0,
fontSize: 30,
fontWeight: 700,
letterSpacing: "-0.02em",
lineHeight: 1.1,
}}
>
{title}
</h1>
{subtitle && (
<div
style={{
marginTop: 4,
fontSize: 14,
color: "var(--muted-foreground)",
}}
>
{subtitle}
</div>
)}
</div>
{right && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
paddingBottom: 2,
}}
>
{right}
</div>
)}
</div>
</div>
);
}
return (
<div className={sticky ? "app-header" : ""}>
<div
style={{
height: 56,
padding: "0 8px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
minWidth: 44,
}}
>
{left}
</div>
<div
style={{
flex: 1,
textAlign: left ? "center" : "left",
fontSize: 17,
fontWeight: 600,
letterSpacing: "-0.01em",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{title}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
minWidth: 44,
justifyContent: "flex-end",
}}
>
{right}
</div>
</div>
</div>
);
}
// ── Icon button (44×44, round) ─────────────────────────
export function IconBtn({
icon,
onClick,
label,
variant = "ghost",
size = 22,
stroke = 1.75,
...rest
}: {
icon: IconName;
onClick?: () => void;
label?: string;
variant?: "ghost" | "muted" | "glass" | "glass-dark";
size?: number;
stroke?: number;
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
const I = Icons[icon];
const bg =
variant === "ghost"
? "transparent"
: variant === "glass"
? "rgba(255,255,255,0.85)"
: variant === "glass-dark"
? "rgba(20,16,10,0.55)"
: "var(--muted)";
const fg =
variant === "glass-dark"
? "#fff"
: variant === "glass"
? "#1a1612"
: "var(--foreground)";
const backdrop = variant.startsWith("glass")
? "blur(20px) saturate(180%)"
: undefined;
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
style={{
width: 44,
height: 44,
borderRadius: 9999,
border: 0,
background: bg,
color: fg,
WebkitBackdropFilter: backdrop,
backdropFilter: backdrop,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
{...rest}
>
<I size={size} stroke={stroke} />
</button>
);
}
// ── Bottom tab bar ──────────────────────────────────────
export function TabBar({
active,
onTab,
onFab,
showFab = true,
}: {
active: string;
onTab: (id: string) => void;
onFab: () => void;
showFab?: boolean;
}) {
const tabs: { id: string; label: string; icon: IconName }[] = [
{ id: "places", label: "Địa điểm", icon: "MapPin" },
{ id: "collections", label: "Bộ sưu tập", icon: "Folder" },
{ id: "profile", label: "Hồ sơ", icon: "User" },
];
return (
<div className="tabbar">
{tabs.map((t) => {
const I = Icons[t.icon];
const isActive = active === t.id;
return (
<button
key={t.id}
className="tabbar-btn"
data-active={isActive}
onClick={() => onTab(t.id)}
>
<I size={22} stroke={isActive ? 2 : 1.75} />
<span>{t.label}</span>
</button>
);
})}
{showFab && (
<button
className="tabbar-fab"
onClick={onFab}
aria-label="Thêm địa điểm"
>
<Icons.Plus size={26} stroke={2.5} />
</button>
)}
</div>
);
}
// ── Offline banner ──────────────────────────────────────
export function OfflineBanner() {
return (
<div className="offline-banner">
<Icons.WifiOff size={14} stroke={2} />
<span>Đang xem bản offline. Một số thao tác bị tạm khóa.</span>
</div>
);
}
// ── Empty state ─────────────────────────────────────────
export function EmptyState({
icon = "MapPin",
title,
body,
cta,
}: {
icon?: IconName;
title: string;
body: string;
cta?: ReactNode;
}) {
const I = Icons[icon];
return (
<div
style={{
padding: "48px 24px",
textAlign: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 12,
}}
>
<div
style={{
width: 64,
height: 64,
borderRadius: 9999,
background: "var(--muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--muted-foreground)",
}}
>
<I size={28} stroke={1.6} />
</div>
<div>
<div
style={{
fontSize: 17,
fontWeight: 600,
letterSpacing: "-0.01em",
}}
>
{title}
</div>
<div
style={{
marginTop: 4,
fontSize: 14,
color: "var(--muted-foreground)",
lineHeight: 1.45,
}}
>
{body}
</div>
</div>
{cta && <div style={{ marginTop: 8 }}>{cta}</div>}
</div>
);
}
// ── Checkbox ──────────────────────────────────────────
export function Checkbox({
checked,
onClick,
}: {
checked?: boolean;
onClick?: () => void;
}) {
return (
<button
type="button"
className="checkbox"
data-checked={!!checked}
onClick={onClick}
>
<Icons.Check size={14} stroke={3} />
</button>
);
}
// ── Menu item (in sheet) ────────────────────────────────
export function MenuItem({
icon,
label,
onClick,
danger = false,
}: {
icon: IconName;
label: string;
onClick?: () => void;
danger?: boolean;
}) {
const I = Icons[icon];
return (
<button
onClick={onClick}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 14,
padding: "14px 20px",
background: "transparent",
border: 0,
color: danger ? "var(--danger)" : "var(--foreground)",
fontSize: 16,
fontWeight: 500,
textAlign: "left",
}}
>
<I size={20} stroke={1.75} />
{label}
</button>
);
}
// ── Field label (form) ──────────────────────────────────
export function FieldLabel({
children,
required,
}: {
children: ReactNode;
required?: boolean;
}) {
return (
<div
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--muted-foreground)",
textTransform: "uppercase",
letterSpacing: "0.04em",
margin: "16px 0 8px",
display: "flex",
alignItems: "center",
gap: 4,
} as CSSProperties}
>
{children}
{required && <span style={{ color: "var(--primary)" }}>*</span>}
</div>
);
}