This commit is contained in:
2026-05-20 14:00:51 +07:00
commit 230eb9010c
30 changed files with 14065 additions and 0 deletions

View File

@@ -0,0 +1,391 @@
"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>
);
}