chore: scaffold apps/basic from with-lingui

Made-with: Cursor
This commit is contained in:
zuiidea
2026-04-14 19:21:30 +08:00
parent 5913cd2f43
commit b08775bad8
82 changed files with 11062 additions and 0 deletions
@@ -0,0 +1,88 @@
.aurora__root {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
--aurora: repeating-linear-gradient(
100deg,
#3b82f6 10%,
#a5b4fc 15%,
#93c5fd 20%,
#ddd6fe 25%,
#60a5fa 30%
);
--dark-gradient: repeating-linear-gradient(
100deg,
#000 0%,
#000 7%,
transparent 10%,
transparent 12%,
#000 16%
);
--white-gradient: repeating-linear-gradient(
100deg,
#fff 0%,
#fff 7%,
transparent 10%,
transparent 12%,
#fff 16%
);
}
.aurora__glow {
position: absolute;
inset: -10px;
background-image: var(--white-gradient), var(--aurora);
background-size: 300% 200%;
background-position:
50% 50%,
50% 50%;
opacity: 0.5;
filter: blur(10px) invert(1);
-webkit-mask-image: radial-gradient(ellipse at 100% 0%, #000 10%, transparent 70%);
mask-image: radial-gradient(ellipse at 100% 0%, #000 10%, transparent 70%);
}
html[data-theme="dark"] .aurora__glow {
background-image: var(--dark-gradient), var(--aurora);
filter: blur(10px);
}
.aurora__glow::after {
content: "";
position: absolute;
inset: 0;
background-image: var(--white-gradient), var(--aurora);
background-size: 200% 100%;
background-position:
50% 50%,
50% 50%;
background-attachment: fixed;
mix-blend-mode: difference;
}
html[data-theme="dark"] .aurora__glow::after {
background-image: var(--dark-gradient), var(--aurora);
}
@keyframes aurora-keyframes {
0% {
background-position: 50%, 50%;
}
to {
background-position: 350%, 350%;
}
}
@media (prefers-reduced-motion: no-preference) {
.aurora__glow::after {
animation: aurora-keyframes 60s linear infinite;
}
}
@media (prefers-reduced-motion: reduce) {
.aurora__glow::after {
animation: none;
}
}
@@ -0,0 +1,14 @@
import "./index.css";
export type AuroraProps = {
className?: string;
};
export function Aurora({ className }: AuroraProps) {
const rootClass = className ? `aurora__root ${className}` : "aurora__root";
return (
<div className={rootClass} aria-hidden>
<div className="aurora__glow" />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { ReactNode } from "react";
import { usePermission } from "@/hooks/usePermission";
interface AuthProps {
permission: string;
children: ReactNode;
fallback?: ReactNode;
}
export function Auth({ permission, children, fallback = null }: AuthProps) {
const allowed = usePermission(permission);
return allowed ? <>{children}</> : <>{fallback}</>;
}
@@ -0,0 +1,64 @@
import { Flex, theme } from "antd";
import { useLingui } from "@lingui/react/macro";
import { BarChart3 } from "lucide-react";
import type { ReactElement } from "react";
/**
* Table empty state: icon in soft tile + bold title + secondary description (dashboard-style).
*/
export function DataTableEmpty(): ReactElement {
const { t } = useLingui();
const { token } = theme.useToken();
return (
<Flex
vertical
align="center"
justify="center"
gap={token.marginMD}
className="data-table-empty"
style={{
paddingBlock: token.paddingXL,
paddingInline: token.paddingLG,
maxWidth: 360,
marginInline: "auto",
}}
>
<Flex
align="center"
justify="center"
style={{
width: 44,
height: 44,
borderRadius: token.borderRadiusLG,
background: token.colorFillTertiary,
color: token.colorTextQuaternary,
}}
>
<BarChart3 size={22} strokeWidth={1.75} aria-hidden />
</Flex>
<Flex vertical align="center" gap={token.marginXXS} style={{ textAlign: "center" }}>
<span
style={{
fontSize: token.fontSizeLG,
fontWeight: token.fontWeightStrong,
color: token.colorText,
lineHeight: token.lineHeightLG,
}}
>
{t`No data`}
</span>
<span
style={{
fontSize: token.fontSize,
fontWeight: 400,
color: token.colorTextSecondary,
lineHeight: token.lineHeight,
}}
>
{t`Nothing to show in this list yet`}
</span>
</Flex>
</Flex>
);
}
@@ -0,0 +1,298 @@
import { Skeleton, Table, theme } from "antd";
import type { TableProps } from "antd";
import type { ColumnGroupType, ColumnsType, ColumnType } from "antd/es/table";
import type { GlobalToken } from "antd/es/theme/interface";
import type { CSSProperties } from "react";
import { useLayoutEffect, useMemo, useRef, useState } from "react";
function rowHeight(size: TableProps["size"]): number {
if (size === "large") return 56;
if (size === "middle") return 48;
return 40;
}
function numericScrollY(scroll: TableProps<object>["scroll"]): number | undefined {
const y = scroll?.y;
return typeof y === "number" && Number.isFinite(y) && y > 0 ? y : undefined;
}
function clampRows(bodyHeight: number, rowH: number): number {
return Math.max(3, Math.floor(bodyHeight / rowH));
}
function initialRowCount(scroll: TableProps<object>["scroll"], size: TableProps["size"]): number {
const y = numericScrollY(scroll);
return y != null ? clampRows(y, rowHeight(size)) : 10;
}
export type DataTableSkeletonProps<RecordType extends object> = Pick<
TableProps<RecordType>,
"columns" | "rowSelection" | "pagination" | "size" | "scroll" | "rootClassName" | "style"
> & {
/**
* Fixed skeleton row count. When omitted, rows are derived from `scroll.y` (if numeric)
* or from the table frame height so the body roughly fills the visible area.
*/
rowCount?: number;
};
function isActionColumn<RecordType extends object>(col: ColumnType<RecordType>): boolean {
return (
col.key === "actions" ||
col.dataIndex === "actions" ||
(typeof col.title === "string" && col.title.toLowerCase() === "actions")
);
}
function isIdColumn<RecordType extends object>(col: ColumnType<RecordType>): boolean {
return col.key === "id" || col.dataIndex === "id";
}
function isColumnGroup<RecordType extends object>(
col: ColumnType<RecordType> | ColumnGroupType<RecordType>,
): col is ColumnGroupType<RecordType> {
return "children" in col && Array.isArray(col.children) && col.children.length > 0;
}
/** ~ text icon button, capped small */
function actionSkeletonDiameter<RecordType extends object>(col: ColumnType<RecordType>): number {
if (typeof col.width === "number") {
return Math.min(24, Math.max(20, col.width - 36));
}
return 22;
}
const DATA_CELL_MIN = 48;
function dataCellMinWidth<RecordType extends object>(col: ColumnType<RecordType>): number {
const cap = typeof col.minWidth === "number" ? col.minWidth : col.width;
if (typeof cap !== "number") return DATA_CELL_MIN;
return Math.max(DATA_CELL_MIN, Math.min(cap, 320));
}
function cellJustify(align: ColumnType<object>["align"]): CSSProperties["justifyContent"] {
if (align === "right") return "flex-end";
if (align === "center") return "center";
return "flex-start";
}
function skeletonInputBarStyle(
token: GlobalToken,
opts: { floor: number; maxWidth: number | string; flex: string },
): CSSProperties {
return {
width: "100%",
minWidth: opts.floor,
maxWidth: opts.maxWidth,
height: token.controlHeightSM,
borderRadius: token.borderRadiusSM,
flex: opts.flex,
};
}
function mapSkeletonColumn<RecordType extends object>(
col: ColumnType<RecordType> | ColumnGroupType<RecordType>,
token: GlobalToken,
): ColumnType<RecordType> | ColumnGroupType<RecordType> {
if (isColumnGroup(col)) {
return {
...col,
children: (col.children as ColumnsType<RecordType>).map((child) =>
mapSkeletonColumn(child, token),
),
};
}
const c = col as ColumnType<RecordType>;
return {
...c,
sorter: false,
sortOrder: undefined,
render: (_: unknown, __: RecordType) => {
if (isActionColumn(c)) {
const w = actionSkeletonDiameter(c);
return (
<span
style={{
display: "flex",
width: "100%",
justifyContent: cellJustify(c.align),
lineHeight: 1,
}}
>
<Skeleton.Button
active
size="small"
shape="circle"
style={{ width: w, height: w, minWidth: w, flexShrink: 0 }}
/>
</span>
);
}
const justify = cellJustify(c.align);
const id = isIdColumn(c);
if (id) {
const floor = 28;
const maxBar = typeof c.width === "number" ? Math.max(28, Math.min(c.width - 10, 52)) : 40;
return (
<Skeleton.Input
active
size="small"
style={skeletonInputBarStyle(token, {
floor,
maxWidth: maxBar,
flex: "0 1 auto",
})}
/>
);
}
const floor = dataCellMinWidth(c);
const bar = skeletonInputBarStyle(token, {
floor,
maxWidth: "100%",
flex: "1 1 auto",
});
return (
<div
style={{
width: "100%",
minWidth: floor,
maxWidth: "100%",
display: "flex",
alignItems: "center",
justifyContent: justify,
boxSizing: "border-box",
...(c.ellipsis ? { overflow: "hidden" as const } : {}),
}}
>
<Skeleton.Input active size="small" style={bar} />
</div>
);
},
};
}
export function DataTableSkeleton<RecordType extends object = object>({
columns,
rowSelection,
pagination,
size = "small",
scroll,
rootClassName,
style,
rowCount,
}: DataTableSkeletonProps<RecordType>) {
const { token } = theme.useToken();
const frameRef = useRef<HTMLDivElement>(null);
const [measuredRows, setMeasuredRows] = useState(() => initialRowCount(scroll, size));
useLayoutEffect(() => {
if (rowCount != null) return;
const y = numericScrollY(scroll);
if (y != null) {
setMeasuredRows(clampRows(y, rowHeight(size)));
return;
}
const el = frameRef.current;
if (!el) return;
const rowH = rowHeight(size);
const measure = () => {
const thead = el.querySelector<HTMLElement>(".ant-table-thead");
const pag = pagination === false ? null : el.querySelector<HTMLElement>(".ant-pagination");
const theadH = thead?.offsetHeight ?? 39;
const pagH = pag ? pag.offsetHeight + 8 : 0;
const bodyH = Math.max(0, el.clientHeight - theadH - pagH - 2);
setMeasuredRows((prev) => {
const next = clampRows(bodyH, rowH);
return prev === next ? prev : next;
});
};
const ro = new ResizeObserver(() => requestAnimationFrame(measure));
ro.observe(el);
let raf = requestAnimationFrame(() => {
raf = requestAnimationFrame(measure);
});
return () => {
ro.disconnect();
cancelAnimationFrame(raf);
};
}, [rowCount, pagination, scroll?.y, size]);
const effectiveRowCount = rowCount ?? measuredRows;
const skeletonColumns = useMemo(() => {
if (!columns?.length) return [];
return (columns as ColumnsType<RecordType>).map((col) => mapSkeletonColumn(col, token));
}, [columns, token]);
const skeletonData = useMemo(
() =>
Array.from({ length: effectiveRowCount }, (_, i) => ({
__sk: String(i),
})) as unknown as RecordType[],
[effectiveRowCount],
);
const skeletonPagination = useMemo((): TableProps<RecordType>["pagination"] => {
if (pagination === false) return false;
if (typeof pagination === "object" && pagination !== null) {
return {
...pagination,
disabled: true,
onChange: undefined,
onShowSizeChange: undefined,
};
}
return {
current: 1,
pageSize: 10,
total: 100,
disabled: true,
showSizeChanger: true,
showTotal: () => (
<Skeleton.Input active size="small" style={{ width: 88, height: token.fontSize }} />
),
};
}, [pagination, token.fontSize]);
const selection =
rowSelection != null
? { ...rowSelection, getCheckboxProps: () => ({ disabled: true }) }
: undefined;
return (
<div
ref={frameRef}
style={{
display: "flex",
flexDirection: "column",
flex: 1,
minHeight: 0,
overflow: "hidden",
...style,
}}
>
<Table<RecordType>
rootClassName={rootClassName}
style={{ flex: 1, minHeight: 0, width: "100%" }}
size={size}
columns={skeletonColumns}
dataSource={skeletonData}
rowKey={(record) => String((record as unknown as { __sk: string }).__sk)}
pagination={skeletonPagination}
rowSelection={selection}
scroll={scroll}
showSorterTooltip={false}
/>
</div>
);
}
@@ -0,0 +1,35 @@
.data-table__table.ant-table-wrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.data-table__table .ant-spin-nested-loading,
.data-table__table .ant-spin-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.data-table__table .ant-table {
flex: 1;
min-height: 0;
}
.data-table__table .ant-table-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Empty state: let placeholder row breathe in flex-height tables */
.data-table__table .ant-table-tbody > tr.ant-table-placeholder > td {
border-bottom: none;
}
.data-table__table .ant-table-tbody > tr.ant-table-placeholder .ant-table-cell {
padding-block: 0;
}
@@ -0,0 +1,125 @@
import { Flex, Table, theme } from "antd";
import type { TableProps } from "antd";
import type { CSSProperties, ReactElement, ReactNode, Ref } from "react";
import { useMemo } from "react";
import { DataTableEmpty } from "./DataTableEmpty";
import { DataTableSkeleton } from "./DataTableSkeleton";
import "./index.css";
/** Merged onto `Table` `rootClassName` with the flex layout class. */
export const DATA_TABLE_ROOT_CLASS = "data-table__table";
export type DataTableProps<RecordType extends object = object> = {
/**
* When the table uses `scroll.y`, set true so the frame does not shrink in the flex column
* and optional `frameHeight` can pin the outer height.
*/
lockScrollHeight?: boolean;
/** Outer `max-height` (px) */
maxHeight?: number;
/** When `lockScrollHeight`, optional fixed outer `height` (usually same as max height) */
frameHeight?: number;
/** Flex column wrapping frame + optional `bottomExtra` */
layoutRef?: Ref<HTMLDivElement | null>;
/** Bordered box around the table (e.g. for `scrollHeight` vs `clientHeight` checks) */
frameRef?: Ref<HTMLDivElement | null>;
/** Optional content below the bordered table frame */
bottomExtra?: ReactNode;
} & TableProps<RecordType>;
export function DataTable<RecordType extends object = object>(
props: DataTableProps<RecordType>,
): ReactElement {
const {
lockScrollHeight,
maxHeight,
frameHeight,
layoutRef,
frameRef,
bottomExtra,
rootClassName,
style: tableStyle,
...tableProps
} = props;
const { token } = theme.useToken();
const { loading, ...restTableProps } = tableProps;
const spinLoading =
loading === true ||
(typeof loading === "object" &&
loading !== null &&
(loading as { spinning?: boolean }).spinning !== false);
const outerStyle: CSSProperties = {
flexGrow: 0,
flexShrink: lockScrollHeight ? 0 : 1,
flexBasis: "auto",
minHeight: 0,
maxHeight,
...(lockScrollHeight && frameHeight != null ? { height: frameHeight } : {}),
alignSelf: "stretch",
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: token.borderRadiusLG,
overflow: "hidden",
display: "flex",
flexDirection: "column",
};
const mergedRootClassName = [DATA_TABLE_ROOT_CLASS, rootClassName].filter(Boolean).join(" ");
const mergedLocale = useMemo(
() => ({
...restTableProps.locale,
emptyText: restTableProps.locale?.emptyText ?? <DataTableEmpty />,
}),
[restTableProps.locale],
);
return (
<Flex
ref={layoutRef}
vertical
gap={token.marginSM}
style={{
flex: "1 1 0%",
minHeight: 0,
overflow: "hidden",
}}
>
<div ref={frameRef} style={outerStyle}>
<div
className="data-table__inner"
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{spinLoading ? (
<DataTableSkeleton<RecordType>
columns={restTableProps.columns}
rowSelection={restTableProps.rowSelection}
pagination={restTableProps.pagination}
size={restTableProps.size}
scroll={restTableProps.scroll}
rootClassName={mergedRootClassName}
style={{ flex: 1, minHeight: 0, ...tableStyle }}
/>
) : (
<Table<RecordType>
{...restTableProps}
locale={mergedLocale}
loading={loading}
rootClassName={mergedRootClassName}
style={{ flex: 1, minHeight: 0, ...tableStyle }}
/>
)}
</div>
</div>
{bottomExtra}
</Flex>
);
}
@@ -0,0 +1,168 @@
import { Button, Flex, Popover, theme, Tooltip } from "antd";
import { ListFilter } from "lucide-react";
import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
export type FilterToolbarSlot = {
key: string;
/** Used to decide how many filters fit in one row before the rest go into the popover */
minWidth: number;
children: React.ReactNode;
};
export type FilterToolbarProps = {
slots: FilterToolbarSlot[];
/** Right side: primary actions (not collapsed) */
actions: React.ReactNode;
/** Reserve width for the “more filters” control when some slots are in the popover */
collapseTriggerReserve?: number;
/** Accessible label + optional popover title */
moreFiltersLabel: React.ReactNode;
moreFiltersTitle?: React.ReactNode;
};
function maxVisibleSlots(
slotWidths: number[],
availableForLeft: number,
innerGap: number,
triggerReserve: number,
): number {
const n = slotWidths.length;
if (n === 0) return 0;
const sum = (count: number) => {
let s = 0;
for (let i = 0; i < count; i++) s += slotWidths[i] ?? 0;
return s;
};
const inlineCost = (m: number) => {
if (m <= 0) return 0;
return sum(m) + (m - 1) * innerGap;
};
const totalLeft = (m: number) => {
if (m <= 0) return n > 0 ? triggerReserve : 0;
const base = inlineCost(m);
if (m >= n) return base;
return base + innerGap + triggerReserve;
};
for (let m = n; m >= 0; m--) {
if (totalLeft(m) <= availableForLeft) return m;
}
return 0;
}
export const FilterToolbar = forwardRef<HTMLDivElement, FilterToolbarProps>(function FilterToolbar(
{ slots, actions, collapseTriggerReserve = 40, moreFiltersLabel, moreFiltersTitle },
ref,
) {
const { token } = theme.useToken();
const gap = token.marginSM;
const containerRef = useRef<HTMLDivElement>(null);
const actionsRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(slots.length);
const [popoverOpen, setPopoverOpen] = useState(false);
const widths = useMemo(() => slots.map((s) => s.minWidth), [slots]);
const assignRef = useCallback(
(node: HTMLDivElement | null) => {
containerRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ref],
);
const remeasure = useCallback(() => {
const root = containerRef.current;
const act = actionsRef.current;
if (!root) return;
const actionsW = act?.offsetWidth ?? 0;
const hasActions = Boolean(act);
const between = hasActions ? gap : 0;
const available = Math.max(0, root.clientWidth - actionsW - between);
const next = maxVisibleSlots(widths, available, gap, collapseTriggerReserve);
setVisibleCount((prev) => (prev === next ? prev : next));
}, [widths, gap, collapseTriggerReserve]);
useLayoutEffect(() => {
remeasure();
const root = containerRef.current;
const act = actionsRef.current;
if (!root) return;
const ro = new ResizeObserver(() => remeasure());
ro.observe(root);
if (act) ro.observe(act);
return () => ro.disconnect();
}, [remeasure, slots.length, widths]);
useLayoutEffect(() => {
setPopoverOpen(false);
}, [visibleCount]);
const overflowSlots = slots.slice(visibleCount);
const inlineSlots = slots.slice(0, visibleCount);
const popoverContent =
overflowSlots.length > 0 ? (
<Flex vertical gap={token.marginMD} style={{ width: "100%" }}>
{overflowSlots.map((slot) => (
<div key={slot.key} style={{ minWidth: slot.minWidth }}>
{slot.children}
</div>
))}
</Flex>
) : null;
return (
<Flex
ref={assignRef}
wrap={false}
gap={gap}
align="center"
justify="space-between"
style={{ flexShrink: 0, minWidth: 0 }}
>
<Flex
wrap={false}
gap={gap}
align="center"
style={{ flex: "1 1 auto", minWidth: 0, overflow: "hidden" }}
>
{inlineSlots.map((slot) => (
<div
key={slot.key}
style={{
flex: "0 0 auto",
minWidth: slot.minWidth,
maxWidth: "100%",
}}
>
{slot.children}
</div>
))}
{overflowSlots.length > 0 ? (
<Popover
title={moreFiltersTitle ?? moreFiltersLabel}
content={popoverContent}
trigger="click"
placement="bottomLeft"
open={popoverOpen}
onOpenChange={setPopoverOpen}
overlayInnerStyle={{ maxWidth: "min(100vw - 32px, 360px)" }}
>
<Tooltip title={moreFiltersLabel}>
<Button
type="default"
icon={<ListFilter size={token.fontSize} />}
aria-label={typeof moreFiltersLabel === "string" ? moreFiltersLabel : undefined}
style={{ flex: "0 0 auto" }}
/>
</Tooltip>
</Popover>
) : null}
</Flex>
<Flex ref={actionsRef} wrap={false} gap={gap} align="center" style={{ flexShrink: 0 }}>
{actions}
</Flex>
</Flex>
);
});
@@ -0,0 +1,51 @@
import { Form, Modal, theme } from "antd";
import type { FormInstance } from "antd/es/form";
import type { ReactNode } from "react";
export type BaseFormModalProps<TValues> = {
open: boolean;
title: ReactNode;
okText: ReactNode;
cancelText: ReactNode;
form: FormInstance<TValues>;
confirmLoading?: boolean;
onCancel: () => void;
onFinish: (values: TValues) => void;
children: ReactNode;
};
export function BaseFormModal<TValues>({
open,
title,
okText,
cancelText,
form,
confirmLoading,
onCancel,
onFinish,
children,
}: BaseFormModalProps<TValues>) {
const { token } = theme.useToken();
return (
<Modal
centered
title={title}
open={open}
okText={okText}
cancelText={cancelText}
onCancel={onCancel}
onOk={() => form.submit()}
confirmLoading={confirmLoading}
styles={{
body: {
paddingTop: token.padding,
},
}}
>
<Form form={form} layout="vertical" scrollToFirstError onFinish={onFinish}>
{children}
</Form>
</Modal>
);
}
+11
View File
@@ -0,0 +1,11 @@
export function GitHub({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.833.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
/>
</svg>
);
}
+21
View File
@@ -0,0 +1,21 @@
export function Theme({ size }: { size: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 3l0 18" />
<path d="M12 9l4.65 -4.65" />
<path d="M12 14.3l7.37 -7.37" />
<path d="M12 19.6l8.85 -8.85" />
</svg>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { Theme } from "./Theme";
export { GitHub } from "./GitHub";
@@ -0,0 +1,41 @@
import { Flex, Typography, theme } from "antd";
import { useLingui } from "@lingui/react/macro";
import { GitHub } from "@/components/Icon";
const ANTD_ADMIN_REPO = "https://github.com/zuiidea/antd-admin";
export function AppFooter() {
const { token } = theme.useToken();
const { t } = useLingui();
const iconSize = Math.max(12, Math.round(Number(token.fontSizeSM)));
return (
<Flex
align="center"
justify="center"
wrap
gap={4}
style={{
lineHeight: token.lineHeight,
}}
>
<Typography.Text type="secondary" style={{ fontSize: token.fontSizeSM, marginBottom: 0 }}>
{t`Powered by`}
</Typography.Text>
<a
href={ANTD_ADMIN_REPO}
target="_blank"
rel="noopener noreferrer"
style={{
color: token.colorLink,
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
<GitHub size={iconSize} />
antd-admin
</a>
</Flex>
);
}
@@ -0,0 +1,164 @@
import { Layout, Button, Space, theme, Breadcrumb, Flex, Divider, Grid } from "antd";
import type { ItemType } from "antd/es/breadcrumb/Breadcrumb";
import { useLingui } from "@lingui/react/macro";
import { useSettingsStore, type Locale } from "@/stores/settings";
import { Link, useLocation, useMatches } from "@tanstack/react-router";
import { Home, Languages, PanelLeft, ShieldAlert, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Theme } from "@/components/Icon";
const { Header: AntHeader } = Layout;
const PATH_LABEL: Record<string, string> = {
"/dashboard": "Dashboard",
"/users": "Users",
"/403": "403",
};
function normalizePath(pathname: string): string {
if (pathname === "/") return pathname;
return pathname.replace(/\/+$/, "") || "/";
}
export type HeaderProps = {
/**
* When `false`, breadcrumb is hidden; left `Flex` still uses `flex={1}` so header actions stay right-aligned.
* Routes may also set `staticData: { hideBreadcrumb: true }` (deepest matching route wins).
*/
showBreadcrumb?: boolean;
};
export function Header({ showBreadcrumb: showBreadcrumbProp = true }: HeaderProps) {
const toggleSidebar = useSettingsStore((s) => s.toggleSidebar);
const toggleDarkMode = useSettingsStore((s) => s.toggleDarkMode);
const locale = useSettingsStore((s) => s.locale);
const setLocale = useSettingsStore((s) => s.setLocale);
const location = useLocation();
const matches = useMatches();
const { t } = useLingui();
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = !screens.lg;
const iconSize = token.fontSize;
const crumb = (Icon: LucideIcon, label: ReactNode, linkTo?: "/dashboard") => {
const row = (
<>
<Icon size={iconSize} aria-hidden style={{ flexShrink: 0, opacity: 0.88 }} />
<span>{label}</span>
</>
);
const rowStyle = {
display: "inline-flex" as const,
alignItems: "center" as const,
gap: token.marginXS,
color: "inherit" as const,
};
if (linkTo) {
return (
<Link to={linkTo} style={rowStyle}>
{row}
</Link>
);
}
return <span style={rowStyle}>{row}</span>;
};
const path = normalizePath(location.pathname);
const segments = path.split("/").filter(Boolean);
const firstSegmentPath = segments.length ? `/${segments[0]}` : "/dashboard";
const leafLabelKey = PATH_LABEL[firstSegmentPath] ?? segments[0] ?? "Dashboard";
const leafIcon: LucideIcon =
firstSegmentPath === "/users" ? Users : firstSegmentPath === "/403" ? ShieldAlert : Home;
const leafLabel =
leafLabelKey === "Dashboard"
? t`Dashboard`
: leafLabelKey === "Users"
? t`Users`
: leafLabelKey === "403"
? t`403`
: leafLabelKey;
const breadcrumbItems: ItemType[] = [];
const onDashboard = path === "/dashboard" || path === "/";
if (onDashboard) {
breadcrumbItems.push({
title: crumb(Home, t`Dashboard`),
});
} else {
breadcrumbItems.push({
title: crumb(Home, t`Dashboard`, "/dashboard"),
});
breadcrumbItems.push({
title: crumb(leafIcon, leafLabel),
});
if (segments.length > 1) {
const tail = segments.slice(1).join(" / ");
if (tail) {
breadcrumbItems.push({ title: tail });
}
}
}
const leafStatic = matches.at(-1)?.staticData as { hideBreadcrumb?: boolean } | undefined;
const hideBreadcrumbFromRoute = leafStatic?.hideBreadcrumb === true;
const showBreadcrumb =
showBreadcrumbProp && !hideBreadcrumbFromRoute && breadcrumbItems.length > 0;
const toggleLocale = () => {
const next: Locale = locale === "en" ? "zh" : "en";
setLocale(next);
};
return (
<AntHeader
style={{
background: "transparent",
borderBottom: `1px solid ${token.colorBorderSecondary}`,
padding: `0 ${token.padding}px`,
gap: token.sizeLG,
display: "flex",
}}
>
<Flex align="center" flex={1} style={{ minWidth: 0 }}>
{isMobile ? (
<Button
type="text"
size="small"
onClick={toggleSidebar}
icon={<PanelLeft size={token.size} />}
aria-label={t`Toggle sidebar`}
/>
) : null}
{showBreadcrumb ? (
<>
{isMobile ? <Divider vertical /> : null}
<Breadcrumb items={breadcrumbItems} />
</>
) : null}
</Flex>
<Space>
<Button
type="text"
onClick={toggleLocale}
icon={<Languages size={token.size} />}
aria-label={t`Switch language`}
/>
<Button
type="text"
onClick={toggleDarkMode}
icon={<Theme size={token.size} />}
aria-label={t`Toggle Theme`}
/>
</Space>
</AntHeader>
);
}
@@ -0,0 +1,39 @@
import { Layout, theme, Flex } from "antd";
import { Outlet } from "@tanstack/react-router";
import { Sidebar } from "../Sidebar";
import { Header } from "../Header";
const { Content } = Layout;
export function MainLayout() {
const { token } = theme.useToken();
return (
<Layout style={{ minHeight: "100vh" }}>
<Sidebar />
<Flex
vertical
style={{
flex: 1,
minWidth: 0,
minHeight: "100vh",
}}
>
<Header />
<Content
className="main-layout-main"
style={{
flex: 1,
padding: token.paddingLG,
minHeight: 0,
display: "flex",
flexDirection: "column",
overflow: "auto",
}}
>
<Outlet />
</Content>
</Flex>
</Layout>
);
}
@@ -0,0 +1,62 @@
/* Allow collapsed Menu popup submenus to overflow the Sider rail */
.ant-layout-sider-collapsed > .ant-layout-sider-children {
overflow: visible !important;
}
.sidebar-collapsed-brand {
position: relative;
width: min(40px, 100%);
max-width: 100%;
aspect-ratio: 1;
height: auto;
flex-shrink: 1;
min-width: 0;
border-radius: var(--ant-border-radius-lg, 12px);
transition: background-color 0.2s ease;
box-sizing: border-box;
}
.sidebar-collapsed-brand__logoLayer,
.sidebar-collapsed-brand__toggleLayer {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar-collapsed-brand__logoLayer {
opacity: 1;
transition: opacity 0.2s ease;
pointer-events: none;
}
.sidebar-collapsed-brand__toggleLayer {
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.sidebar-collapsed-brand:hover .sidebar-collapsed-brand__logoLayer {
opacity: 0;
}
.sidebar-collapsed-brand:hover .sidebar-collapsed-brand__toggleLayer {
opacity: 1;
pointer-events: auto;
}
.sidebar-collapsed-brand:focus-within .sidebar-collapsed-brand__logoLayer {
opacity: 0;
}
.sidebar-collapsed-brand:focus-within .sidebar-collapsed-brand__toggleLayer {
opacity: 1;
pointer-events: auto;
}
.sidebar-collapsed-brand__toggle.ant-btn {
width: 100%;
height: 100%;
margin: 0;
}
@@ -0,0 +1,405 @@
import { Menu, Layout, theme, Flex, Grid, Drawer, Button } from "antd";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useLocation } from "@tanstack/react-router";
import { useLingui } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro";
import {
Book,
Briefcase,
CircleDashed,
Folder,
Home,
PanelLeft,
SlidersHorizontal,
Star,
User,
Users,
Zap,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
import { useAuthStore } from "@/stores/auth";
import { useSettingsStore } from "@/stores/settings";
import type { MenuItem as MenuItemType } from "@/api/schemas";
import type { MenuProps } from "antd";
import { UserMenu } from "../UserMenu";
import "./index.css";
const { Sider } = Layout;
/** API menu `name` → Lingui descriptors (extracted into catalogs). */
const MENU_LABELS: Record<string, ReturnType<typeof msg>> = {
Platform: msg`Platform`,
Projects: msg`Projects`,
Dashboard: msg`Dashboard`,
Users: msg`Users`,
"Design Engineering": msg`Design Engineering`,
"Sales & Marketing": msg`Sales & Marketing`,
};
type AntMenuItem = Required<MenuProps>["items"][number];
type BuildMenuResult = {
items: AntMenuItem[];
keyToPath: Record<string, string>;
pathToKeyChain: Record<string, string[]>;
};
const MENU_ICON_MAP: Record<string, LucideIcon> = {
IconLucideLayoutDashboard: Home,
IconLucideUsers: User,
IconLucideUserList: Users,
/** Back-compat for older menu payloads still using IconLucideHistory */
IconLucideHistory: Users,
IconLucideStar: Star,
IconLucideSettings: SlidersHorizontal,
IconLucideBriefcase: Briefcase,
IconLucideBookOpen: Book,
IconLucideFolderKanban: Folder,
IconLucideSparkles: Zap,
};
function renderMenuIcon(icon: string | null, size = 16) {
const Icon = (icon && MENU_ICON_MAP[icon]) || CircleDashed;
return <Icon size={size} />;
}
function buildMenuItems(
menus: MenuItemType[],
t: ReturnType<typeof useLingui>["t"],
token: ReturnType<typeof theme.useToken>["token"],
collapsed = false,
iconSize = 16,
parentKeys: string[] = [],
): BuildMenuResult {
const sorted = menus.filter((m) => !m.hidden).sort((a, b) => a.sort - b.sort);
const keyToPath: Record<string, string> = {};
const pathToKeyChain: Record<string, string[]> = {};
const items: AntMenuItem[] = [];
for (const menu of sorted) {
const label = MENU_LABELS[menu.name] ? t(MENU_LABELS[menu.name]) : menu.name;
const key = menu.id;
if (menu.kind === "group") {
const built = buildMenuItems(menu.children, t, token, collapsed, iconSize, parentKeys);
Object.assign(keyToPath, built.keyToPath);
Object.assign(pathToKeyChain, built.pathToKeyChain);
if (built.items.length > 0) {
items.push({
type: "group",
key,
label: collapsed ? (
<span
style={{
width: "100%",
display: "inline-flex",
justifyContent: "center",
}}
>
<span
aria-label={String(label)}
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: token.colorTextQuaternary,
display: "inline-block",
}}
/>
</span>
) : (
<span
style={{
fontSize: token.fontSizeSM,
color: token.colorTextQuaternary,
}}
>
{label}
</span>
),
children: built.items,
});
}
continue;
}
const nextParents = [...parentKeys, key];
keyToPath[key] = menu.path;
const existing = pathToKeyChain[menu.path];
if (!existing || nextParents.length > existing.length) {
pathToKeyChain[menu.path] = nextParents;
}
let children: AntMenuItem[] | undefined;
if (menu.children?.length) {
const built = buildMenuItems(menu.children, t, token, collapsed, iconSize, nextParents);
Object.assign(keyToPath, built.keyToPath);
Object.assign(pathToKeyChain, built.pathToKeyChain);
children = built.items.length ? built.items : undefined;
}
items.push({
key,
label,
icon: renderMenuIcon(menu.icon, iconSize),
children,
});
}
return { items, keyToPath, pathToKeyChain };
}
export function Sidebar() {
const menus = useAuthStore((s) => s.menus);
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const collapsed = useSettingsStore((s) => s.sidebarCollapsed);
const setSidebarCollapsed = useSettingsStore((s) => s.setSidebarCollapsed);
const toggleSidebar = useSettingsStore((s) => s.toggleSidebar);
const navigate = useNavigate();
const location = useLocation();
const { t } = useLingui();
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = !screens.lg;
const mobileOpen = collapsed;
const builtMenu = useMemo(
() => buildMenuItems(menus, t, token, !isMobile && collapsed, token.size),
[menus, t, token, isMobile, collapsed, token.size],
);
const { selectedKey, routeOpenKeys } = useMemo(() => {
const chain = builtMenu.pathToKeyChain[location.pathname] ?? [];
return { selectedKey: chain.at(-1), routeOpenKeys: chain.slice(0, -1) };
}, [builtMenu, location.pathname]);
const routeOpenKeysSig = routeOpenKeys.join("\0");
const [openKeys, setOpenKeys] = useState<string[]>([]);
useEffect(() => {
if (isMobile) {
setSidebarCollapsed(false);
}
}, [isMobile, setSidebarCollapsed]);
/** Merge open keys required by the route with any submenu the user already expanded. */
useEffect(() => {
setOpenKeys((prev) => {
const next = new Set(prev);
for (const k of routeOpenKeys) next.add(k);
return [...next];
});
}, [location.pathname, routeOpenKeysSig]);
const userMenuItems: MenuProps["items"] = [
{
key: "logout",
label: t`Sign Out`,
onClick: () => {
if (isMobile) {
setSidebarCollapsed(false);
}
logout();
void navigate({ to: "/login" });
},
},
];
const sidebarContent = (isCollapsed: boolean, omitBrandToggle = false) => (
<Flex
vertical
style={{
height: "100%",
width: "100%",
minWidth: 0,
maxWidth: "100%",
boxSizing: "border-box",
}}
>
<Flex
vertical
justify="center"
align={isCollapsed ? "center" : "stretch"}
style={{
paddingBlock: token.paddingSM,
/* Collapsed rail is 64px: keep horizontal padding minimal so 40px brand / icons are not clipped by Sider overflow. */
paddingInline: isCollapsed ? token.paddingXXS : token.paddingSM,
minHeight: 64,
flexShrink: 0,
width: "100%",
minWidth: 0,
maxWidth: "100%",
boxSizing: "border-box",
}}
>
<Flex
align="center"
gap={token.marginSM}
style={{
width: "100%",
minWidth: 0,
maxWidth: "100%",
boxSizing: "border-box",
/* Collapsed: outer row already has paddingSM; extra inline padding would clip the 40px brand box. */
paddingInline: isCollapsed ? 0 : token.paddingXS,
minHeight: 40,
justifyContent: isCollapsed ? "center" : "flex-start",
}}
>
{isCollapsed ? (
<div className="sidebar-collapsed-brand">
<div className="sidebar-collapsed-brand__logoLayer">
<img
src={APP_FAVICON_SRC}
alt="logo"
width={24}
height={24}
style={{
borderRadius: token.borderRadius,
display: "block",
objectFit: "contain",
}}
/>
</div>
<div className="sidebar-collapsed-brand__toggleLayer">
<Button
type="text"
size="small"
className="sidebar-collapsed-brand__toggle"
onClick={toggleSidebar}
icon={<PanelLeft size={token.size} />}
aria-label={t`Toggle sidebar`}
/>
</div>
</div>
) : (
<>
<Flex
align="center"
gap={token.marginSM}
style={{
minWidth: 0,
flex: 1,
overflow: "hidden",
}}
>
<img
src={APP_FAVICON_SRC}
alt="logo"
width={24}
height={24}
style={{
borderRadius: token.borderRadius,
display: "block",
flexShrink: 0,
objectFit: "contain",
}}
/>
<div
style={{
minWidth: 0,
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontWeight: 600,
fontSize: token.fontSizeLG,
textTransform: "uppercase",
}}
>
{APP_BRAND_NAME}
</div>
</Flex>
{!omitBrandToggle ? (
<Button
type="text"
size="small"
onClick={toggleSidebar}
icon={<PanelLeft size={token.size} />}
aria-label={t`Toggle sidebar`}
style={{ flexShrink: 0 }}
/>
) : null}
</>
)}
</Flex>
</Flex>
<Menu
mode="inline"
selectedKeys={selectedKey ? [selectedKey] : []}
openKeys={openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])}
items={builtMenu.items}
getPopupContainer={() => document.body}
onClick={({ key }) => {
const path = builtMenu.keyToPath[String(key)];
if (!path) return;
if (isMobile) {
setSidebarCollapsed(false);
}
void navigate({ to: path });
}}
style={{
borderRight: "none",
flex: 1,
minWidth: 0,
maxWidth: "100%",
width: "100%",
boxSizing: "border-box",
overflowX: "hidden",
overflowY: "auto",
background: "transparent",
}}
/>
<UserMenu collapsed={isCollapsed} user={user} userMenuItems={userMenuItems} />
</Flex>
);
if (isMobile) {
return (
<Drawer
open={mobileOpen}
placement="left"
onClose={() => setSidebarCollapsed(false)}
size={320}
styles={{
body: {
padding: 0,
background: token.colorBgLayout,
overflow: "hidden",
maxWidth: "100%",
boxSizing: "border-box",
},
header: { display: "none" },
mask: { opacity: 0.5 },
}}
>
{sidebarContent(false, true)}
</Drawer>
);
}
return (
<Sider
key={collapsed ? "collapsed" : "expanded"}
theme="light"
collapsible
collapsed={collapsed}
trigger={null}
width={240}
collapsedWidth={64}
breakpoint="lg"
onBreakpoint={(broken) => {
if (broken) {
setSidebarCollapsed(false);
}
}}
style={{
borderRight: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgLayout,
alignSelf: "stretch",
minHeight: "100vh",
overflow: "visible",
}}
>
{sidebarContent(collapsed)}
</Sider>
);
}
@@ -0,0 +1,7 @@
.user-menu-trigger {
transition: background-color 0.2s ease;
}
.user-menu-trigger:hover {
background-color: var(--user-menu-hover-bg);
}
@@ -0,0 +1,103 @@
import { Avatar, Button, Dropdown, Flex, Typography, theme } from "antd";
import type { MenuProps } from "antd";
import type { User } from "@/api/schemas";
import { MoreVertical } from "lucide-react";
import "./index.css";
const { Text } = Typography;
interface UserMenuProps {
collapsed: boolean;
user: User | null;
userMenuItems: MenuProps["items"];
}
export function UserMenu({ collapsed, user, userMenuItems }: UserMenuProps) {
const { token } = theme.useToken();
const avatarSrc = (user?.avatar ?? "").trim() || undefined;
const avatarSize = collapsed ? 32 : token.controlHeight;
const avatar = (
<Avatar
size={avatarSize}
src={avatarSrc || undefined}
shape="circle"
style={{
flexShrink: 0,
width: avatarSize,
}}
>
{user?.username?.[0]?.toUpperCase()}
</Avatar>
);
const moreButton = (
<Button type="text" size="small" icon={<MoreVertical size={token.fontSize} />} />
);
return (
<Dropdown menu={{ items: userMenuItems }} trigger={["click"]}>
<div
style={{
margin: token.marginXS,
}}
>
<Flex
className="user-menu-trigger"
align="center"
justify="flex-start"
gap={token.marginSM}
style={{
width: "100%",
cursor: "pointer",
borderRadius: token.borderRadius,
padding: token.paddingXS,
["--user-menu-hover-bg" as string]: token.colorFillTertiary,
}}
>
{avatar}
{!collapsed ? (
<>
<Flex
vertical
justify="center"
gap={2}
style={{
flex: "1 1 0%",
minWidth: 0,
maxWidth: "100%",
overflow: "hidden",
}}
>
<Text
ellipsis
style={{
lineHeight: 1,
display: "block",
maxWidth: "100%",
}}
>
{user?.username ?? "—"}
</Text>
<Text
ellipsis
style={{
lineHeight: 1,
display: "block",
maxWidth: "100%",
fontSize: token.fontSizeSM,
color: token.colorTextQuaternary,
}}
>
{user?.email ?? "—"}
</Text>
</Flex>
{moreButton}
</>
) : null}
</Flex>
</div>
</Dropdown>
);
}
@@ -0,0 +1,70 @@
import { useNavigate } from "@tanstack/react-router";
import { useLingui } from "@lingui/react/macro";
import { ArrowLeft, Home, SearchX } from "lucide-react";
import { Button, Flex, Result, Space, theme } from "antd";
/** Shared 404 UI for `/404` route and root `notFoundComponent`. */
export function NotFound() {
const navigate = useNavigate();
const { t } = useLingui();
const { token } = theme.useToken();
const goDashboard = () => {
void navigate({ to: "/dashboard" });
};
const goBack = () => {
if (typeof window !== "undefined" && window.history.length > 1) {
window.history.back();
} else {
goDashboard();
}
};
return (
<Flex
vertical
style={{
/* Lock to viewport so tall Result + padding cannot grow past 100vh and scroll the page */
height: "100dvh",
maxHeight: "100dvh",
overflow: "hidden",
boxSizing: "border-box",
background: token.colorBgLayout,
color: token.colorText,
}}
>
<Flex
flex={1}
align="center"
justify="center"
style={{
minHeight: 0,
width: "100%",
padding: token.paddingXL,
overflow: "hidden",
boxSizing: "border-box",
}}
>
<Result
// Avoid status="404"|"403"|"500": antd ignores `icon` and shows built-in art; keep Lucide as the main icon.
icon={
<SearchX size={64} strokeWidth={1.25} color={token.colorTextQuaternary} aria-hidden />
}
title="404"
subTitle={t`Sorry, the page you visited does not exist.`}
extra={
<Space wrap size="middle">
<Button type="primary" icon={<Home size={16} aria-hidden />} onClick={goDashboard}>
{t`Back to Home`}
</Button>
<Button icon={<ArrowLeft size={16} aria-hidden />} onClick={goBack}>
{t`Go back`}
</Button>
</Space>
}
/>
</Flex>
</Flex>
);
}