mirror of
https://github.com/zuiidea/antd-admin
synced 2026-10-28 11:24:50 +00:00
chore: scaffold apps/basic from with-lingui
Made-with: Cursor
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user