392 lines
9.0 KiB
TypeScript
392 lines
9.0 KiB
TypeScript
"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>
|
||
);
|
||
}
|