💄 style: Add Cache, Metadata, FeatureFlag Viewer to DevPanel (#5764)

*  feat: Add Chache DevTool

* 💄 style: Update Dev Panel style

*  feat: Add seo debug

*  feat: Add Feature Flag

* 💄 style: Update DevTool

* 💄 style: Update style

* 💄 style: Update style
This commit is contained in:
CanisMinor
2025-02-06 01:29:33 +08:00
committed by GitHub
parent 80fd5a8027
commit db4e9c7fbb
24 changed files with 1073 additions and 300 deletions
@@ -0,0 +1,33 @@
'use client';
import { RefreshCw } from 'lucide-react';
import { memo } from 'react';
import Header from '../../features/Header';
import Table from '../../features/Table';
import { useCachePanelContext } from '../cacheProvider';
const DataTable = memo(() => {
const { entries, isLoading, refreshData } = useCachePanelContext();
return (
<>
<Header
actions={[
{
icon: RefreshCw,
onClick: () => refreshData(),
title: 'Refresh',
},
]}
title="Cache Entries"
/>
<Table
columns={['url', 'headers.content-type', 'body', 'kind', 'tags', 'revalidate', 'timestamp']}
dataSource={entries}
loading={isLoading}
/>
</>
);
});
export default DataTable;
@@ -0,0 +1,64 @@
'use client';
import { usePathname } from 'next/navigation';
import {
PropsWithChildren,
createContext,
useContext,
useEffect,
useState,
useTransition,
} from 'react';
import { getCacheFiles } from './getCacheEntries';
import type { NextCacheFileData } from './schema';
interface CachePanelContextProps {
entries: NextCacheFileData[];
isLoading: boolean;
refreshData: () => void;
setEntries: (value: NextCacheFileData[]) => void;
}
const CachePanelContext = createContext<CachePanelContextProps>({
entries: [],
isLoading: false,
refreshData: () => {},
setEntries: () => {},
});
export const useCachePanelContext = () => useContext(CachePanelContext);
export const CachePanelContextProvider = (
props: PropsWithChildren<{
entries: NextCacheFileData[];
}>,
) => {
const [isLoading, startTransition] = useTransition();
const [entries, setEntries] = useState(props.entries);
const pathname = usePathname();
const refreshData = () => {
startTransition(async () => {
const files = await getCacheFiles();
setEntries(files ?? []);
});
};
useEffect(() => {
refreshData();
}, [pathname]);
return (
<CachePanelContext.Provider
value={{
entries,
isLoading,
refreshData,
setEntries,
}}
>
{props.children}
</CachePanelContext.Provider>
);
};
@@ -0,0 +1,52 @@
'use server';
import { existsSync, promises } from 'node:fs';
import pMap from 'p-map';
import { ZodError } from 'zod';
import { type NextCacheFileData, nextCacheFileSchema } from './schema';
const cachePath = '.next/cache/fetch-cache';
export const getCacheFiles = async (): Promise<NextCacheFileData[]> => {
if (!existsSync(cachePath)) {
return [];
}
const files = await promises.readdir(cachePath);
let result: NextCacheFileData[] = (await pMap(files, async (file) => {
// ignore tags-manifest file
if (/manifest/.test(file)) return false;
try {
const fileContent = await promises.readFile(`${cachePath}/${file}`).catch((err) => {
throw new Error(`Error reading file ${file}`, {
cause: err,
});
});
const fileStats = await promises.stat(`${cachePath}/${file}`).catch((err) => {
throw new Error(`Error reading file ${file}`, {
cause: err,
});
});
const jsonData = JSON.parse(fileContent.toString());
return nextCacheFileSchema.parse({
...jsonData,
id: file,
timestamp: new Date(fileStats.birthtime),
});
} catch (error) {
if (error instanceof ZodError) {
const issues = error.issues;
console.error(`File ${file} do not match the schema`, issues);
}
console.error(`Error parsing ${file}`);
return false;
}
})) as NextCacheFileData[];
result = result.filter(Boolean) as NextCacheFileData[];
return result.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
};
@@ -0,0 +1,25 @@
import { Empty } from 'antd';
import { Center } from 'react-layout-kit';
import DataTable from './DataTable';
import { CachePanelContextProvider } from './cacheProvider';
import { getCacheFiles } from './getCacheEntries';
const CacheViewer = async () => {
const files = await getCacheFiles();
if (!files || files.length === 0)
return (
<Center height={'80%'}>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Center>
);
return (
<CachePanelContextProvider entries={files}>
<DataTable />
</CachePanelContextProvider>
);
};
export default CacheViewer;
@@ -0,0 +1,49 @@
import { z } from 'zod';
const unstableCacheFileSchema = z.object({
data: z.object({
body: z.string(),
headers: z.object({}).transform(() => null),
status: z.number(),
url: z.literal(''),
}),
kind: z.union([z.literal('FETCH'), z.unknown()]),
revalidate: z.number().optional(),
tags: z.array(z.string()).optional().default([]),
});
const fetchCacheFileSchema = z.object({
data: z.object({
body: z.string(),
headers: z.record(z.string(), z.string()),
status: z.number(),
url: z.string().url(),
}),
id: z.string(),
kind: z.union([z.literal('FETCH'), z.unknown()]),
revalidate: z.number().optional(),
tags: z.array(z.string()).optional().default([]),
});
const atou = (str: string, type: string) => {
if (type.startsWith('image/')) return `data:${type};base64,${str}`;
return Buffer.from(str, 'base64').toString();
};
export const nextCacheFileSchema = z
.union([unstableCacheFileSchema, fetchCacheFileSchema])
.transform((item) => {
const { data, ...cacheEntry } = item;
const body =
data.url !== ''
? atou(data.body, data.headers ? data.headers['content-type'] : '')
: data.body;
return {
...cacheEntry,
...data,
body,
timestamp: data.headers?.date ? new Date(data.headers?.date) : new Date(),
url: data.url === '' ? 'unstable_cache' : data.url,
};
});
export type NextCacheFileData = z.infer<typeof nextCacheFileSchema>;
@@ -0,0 +1,93 @@
'use client';
import { Form, Highlighter } from '@lobehub/ui';
import { Switch } from 'antd';
import { createStyles } from 'antd-style';
import { snakeCase } from 'lodash-es';
import { ListRestartIcon } from 'lucide-react';
import { memo, useMemo, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import { DEFAULT_FEATURE_FLAGS } from '@/config/featureFlags';
import Header from '../features/Header';
const useStyles = createStyles(({ css, token, prefixCls }) => ({
container: css`
* {
font-family: ${token.fontFamilyCode};
font-size: 12px;
}
.${prefixCls}-form-item {
padding-block: 4px !important;
}
`,
}));
const FeatureFlagForm = memo<{ flags: any }>(({ flags }) => {
const { styles } = useStyles();
const [data, setData] = useState(flags);
const [form] = Form.useForm();
const output = useMemo(
() =>
Object.entries(data).map(([key, value]) => {
const flag = snakeCase(key);
// @ts-ignore
if (DEFAULT_FEATURE_FLAGS[flag] === value) return false;
if (value === true) return `+${flag}`;
return `-${flag}`;
}),
[data],
);
return (
<>
<Header
actions={[
{
icon: ListRestartIcon,
onClick: () => {
form.resetFields();
setData(flags);
},
title: 'Reset',
},
]}
title={'Feature Flag Env'}
/>
<Flexbox
className={styles.container}
height={'100%'}
paddingInline={16}
style={{ overflow: 'auto', position: 'relative' }}
width={'100%'}
>
<Form
form={form}
initialValues={flags}
itemMinWidth={'max(75%,240px)'}
items={Object.keys(flags).map((key) => {
return {
children: <Switch size={'small'} />,
label: snakeCase(key),
minWidth: undefined,
name: key,
valuePropName: 'checked',
};
})}
itemsType={'flat'}
onValuesChange={(_, v) => setData(v)}
variant={'pure'}
/>
</Flexbox>
<Highlighter
language={'env'}
style={{ flex: 'none', fontSize: 12 }}
wrap
>{`FEATURE_FLAGS="${output.filter(Boolean).join(',')}"`}</Highlighter>
</>
);
});
export default FeatureFlagForm;
@@ -0,0 +1,11 @@
import { getServerFeatureFlagsValue } from '@/config/featureFlags';
import FeatureFlagForm from './Form';
const FeatureFlagViewer = () => {
const serverFeatureFlags = getServerFeatureFlagsValue();
return <FeatureFlagForm flags={serverFeatureFlags} />;
};
export default FeatureFlagViewer;
-136
View File
@@ -1,136 +0,0 @@
import { ActionIcon, Icon } from '@lobehub/ui';
import { FloatButton } from 'antd';
import { createStyles } from 'antd-style';
import { BugIcon, BugOff, XIcon } from 'lucide-react';
import React, { PropsWithChildren, useEffect, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import { Rnd } from 'react-rnd';
// 定义样式
const useStyles = createStyles(({ token, css }) => {
return {
collapsed: css`
pointer-events: none;
transform: scale(0.8);
opacity: 0;
`,
content: css`
overflow: auto;
flex: 1;
height: 100%;
color: ${token.colorText};
`,
expanded: css`
pointer-events: auto;
transform: scale(1);
opacity: 1;
`,
header: css`
cursor: move;
user-select: none;
padding-block: 8px;
padding-inline: 16px;
border-block-end: 1px solid ${token.colorBorderSecondary};
border-start-start-radius: 12px;
border-start-end-radius: 12px;
font-weight: ${token.fontWeightStrong};
color: ${token.colorText};
background: ${token.colorFillAlter};
`,
panel: css`
position: fixed;
z-index: 1000;
overflow: hidden;
display: flex;
border-radius: 12px;
background: ${token.colorBgContainer};
box-shadow: ${token.boxShadow};
transition: opacity ${token.motionDurationMid} ${token.motionEaseInOut};
`,
};
});
const minWidth = 800;
const minHeight = 600;
const CollapsibleFloatPanel = ({ children }: PropsWithChildren) => {
const { styles } = useStyles();
const [isExpanded, setIsExpanded] = useState(false);
const [position, setPosition] = useState({ x: 100, y: 100 });
const [size, setSize] = useState({ height: minHeight, width: minWidth });
useEffect(() => {
try {
const localStoragePosition = localStorage.getItem('debug-panel-position');
if (localStoragePosition && JSON.parse(localStoragePosition)) {
setPosition(JSON.parse(localStoragePosition));
}
} catch {
/* empty */
}
try {
const localStorageSize = localStorage.getItem('debug-panel-size');
if (localStorageSize && JSON.parse(localStorageSize)) {
setSize(JSON.parse(localStorageSize));
}
} catch {
/* empty */
}
}, []);
return (
<>
<FloatButton
icon={<Icon icon={isExpanded ? BugOff : BugIcon} />}
onClick={() => setIsExpanded(!isExpanded)}
style={{ bottom: 24, right: 24 }}
/>
{isExpanded && (
<Rnd
bounds="window"
className={`${styles.panel} ${isExpanded ? styles.expanded : styles.collapsed}`}
dragHandleClassName="panel-drag-handle"
minHeight={minHeight}
minWidth={minWidth}
onDragStop={(e, d) => {
setPosition({ x: d.x, y: d.y });
}}
onResizeStop={(e, direction, ref, delta, position) => {
setSize({
height: Number(ref.style.height),
width: Number(ref.style.width),
});
setPosition(position);
}}
position={position}
size={size}
>
<Flexbox height={'100%'}>
<Flexbox
align={'center'}
className={`panel-drag-handle ${styles.header}`}
horizontal
justify={'space-between'}
>
<ActionIcon icon={XIcon} onClick={() => setIsExpanded(false)} />
</Flexbox>
<Flexbox className={styles.content}>{children}</Flexbox>
</Flexbox>
</Rnd>
)}
</>
);
};
export default CollapsibleFloatPanel;
@@ -0,0 +1,25 @@
import { Highlighter } from '@lobehub/ui';
import { Empty } from 'antd';
import { memo } from 'react';
import { Center } from 'react-layout-kit';
import { useLd } from './useHead';
const Ld = memo(() => {
const ld = useLd();
if (!ld)
return (
<Center height={'80%'}>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Center>
);
return (
<Highlighter language="json" type={'pure'}>
{JSON.stringify(JSON.parse(ld), null, 2)}
</Highlighter>
);
});
export default Ld;
@@ -0,0 +1,30 @@
import { Form } from '@lobehub/ui';
import { Input } from 'antd';
import { memo } from 'react';
import { useHead, useTitle } from './useHead';
const MetaData = memo(() => {
const title = useTitle();
const description = useHead('name', 'description');
return (
<Form
itemMinWidth={'max(75%,240px)'}
items={[
{
children: <Input value={title} variant={'filled'} />,
label: `Title (${title.length})`,
},
{
children: <Input.TextArea rows={2} value={description} variant={'filled'} />,
label: `Description (${description.length})`,
},
]}
itemsType={'flat'}
variant={'pure'}
/>
);
});
export default MetaData;
@@ -0,0 +1,75 @@
import { Form } from '@lobehub/ui';
import { Input } from 'antd';
import Image from 'next/image';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useHead } from './useHead';
const MetaData = memo(() => {
const ogTitle = useHead('property', 'og:title');
const ogDescription = useHead('property', 'og:description');
const ogImage = useHead('property', 'og:image');
return (
<Form
itemMinWidth={'max(75%,240px)'}
items={[
{
children: <Input value={ogTitle} variant={'filled'} />,
label: `OG Title (${ogTitle.length})`,
},
{
children: <Input.TextArea rows={2} value={ogDescription} variant={'filled'} />,
label: `OG Description (${ogDescription.length})`,
},
{
children: (
<Flexbox
height={186}
style={{
background: 'rgba(0, 0, 0, .5)',
borderRadius: 14,
overflow: 'hidden',
position: 'relative',
}}
width={358}
>
<div
style={{
background: 'rgba(0, 0, 0, .5)',
borderRadius: 4,
bottom: 10,
left: 10,
lineHeight: 1.3,
padding: '2px 6px',
position: 'absolute',
zIndex: 10,
}}
>
lobehub.com
</div>
<Image
alt={'og'}
fill
src={ogImage}
style={{ objectFit: 'cover' }}
unoptimized={true}
/>
</Flexbox>
),
label: 'Og Image',
minWidth: undefined,
},
{
children: <Input value={ogImage} variant={'filled'} />,
label: 'Og Image Url',
},
]}
itemsType={'flat'}
variant={'pure'}
/>
);
});
export default MetaData;
@@ -0,0 +1,80 @@
'use client';
import { TabsNav } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { memo, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import Header from '../features/Header';
import Ld from './Ld';
import MetaData from './MetaData';
import Og from './Og';
const useStyles = createStyles(({ css, prefixCls }) => ({
container: css`
* {
font-size: 12px;
}
.${prefixCls}-form-item {
padding-block: 8px;
}
`,
}));
enum Tab {
Ld = 'ld',
Meta = 'meta',
Og = 'og',
}
const MetadataViewer = memo(() => {
const { styles } = useStyles();
const [active, setActive] = useState<Tab>(Tab.Og);
return (
<Flexbox
className={styles.container}
height={'100%'}
style={{ overflow: 'hidden', position: 'relative' }}
width={'100%'}
>
<Header
style={{ paddingInlineStart: 0 }}
title={
<TabsNav
activeKey={active}
items={[
{
key: Tab.Og,
label: 'OG',
},
{
key: Tab.Meta,
label: 'MetaData',
},
{
key: Tab.Ld,
label: 'StructuredData',
},
]}
onChange={(v) => setActive(v as Tab)}
style={{ margin: 16 }}
variant={'compact'}
/>
}
/>
<Flexbox
flex={1}
height={'100%'}
paddingInline={16}
style={{ overflow: 'auto', paddingBottom: 16, position: 'relative' }}
width={'100%'}
>
{active === Tab.Og && <Og />}
{active === Tab.Meta && <MetaData />}
{active === Tab.Ld && <Ld />}
</Flexbox>
</Flexbox>
);
});
export default MetadataViewer;
@@ -0,0 +1,16 @@
import { isOnServerSide } from '@/utils/env';
export const useHead = (prop: string, name: string) => {
if (isOnServerSide) return '';
return document.querySelector(`meta[${prop}='${name}']`)?.getAttribute('content') || '';
};
export const useTitle = () => {
if (isOnServerSide) return '';
return document.querySelector(`title`)?.innerHTML || '';
};
export const useLd = () => {
if (isOnServerSide) return '';
return document.querySelector(`script[type='application/ld+json']`)?.innerHTML || '';
};
@@ -1,34 +0,0 @@
import React, { useMemo } from 'react';
interface TableCellProps {
column: string;
dataItem: any;
rowIndex: number;
}
const TableCell = ({ dataItem, column, rowIndex }: TableCellProps) => {
const data = dataItem[column];
const content = useMemo(() => {
switch (typeof data) {
case 'object': {
return JSON.stringify(data);
}
case 'boolean': {
return data ? 'True' : 'False';
}
default: {
return data;
}
}
}, [data]);
return (
<td key={column} onDoubleClick={() => console.log('Edit cell:', rowIndex, column)}>
{content}
</td>
);
};
export default TableCell;
@@ -1,42 +1,19 @@
import { ActionIcon, Icon } from '@lobehub/ui';
import { Button } from 'antd';
import { Empty } from 'antd';
import { createStyles } from 'antd-style';
import { Download, Filter, RefreshCw } from 'lucide-react';
import React from 'react';
import { Center, Flexbox } from 'react-layout-kit';
import { mutate } from 'swr';
import { FETCH_TABLE_DATA_KEY } from '../usePgTable';
import Table from './Table';
import Header from '../../features/Header';
import Table from '../../features/Table';
import { FETCH_TABLE_DATA_KEY, usePgTable, useTableColumns } from '../usePgTable';
const useStyles = createStyles(({ token, css }) => ({
dataPanel: css`
overflow: hidden;
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
background: ${token.colorBgContainer};
`,
toolbar: css`
display: flex;
align-items: center;
justify-content: space-between;
padding-block: 12px;
padding-inline: 16px;
border-block-end: 1px solid ${token.colorBorderSecondary};
`,
toolbarButtons: css`
display: flex;
gap: 4px;
`,
toolbarTitle: css`
font-size: ${token.fontSizeLG}px;
font-weight: ${token.fontWeightStrong};
color: ${token.colorText};
`,
}));
interface DataTableProps {
@@ -46,29 +23,42 @@ interface DataTableProps {
const DataTable = ({ tableName }: DataTableProps) => {
const { styles } = useStyles();
return (
<div className={styles.dataPanel}>
{/* Toolbar */}
<div className={styles.toolbar}>
<div className={styles.toolbarTitle}>{tableName || 'Select a table'}</div>
<div className={styles.toolbarButtons}>
<Button color={'default'} icon={<Icon icon={Filter} />} variant={'filled'}>
Filter
</Button>
<ActionIcon icon={Download} title={'Export'} />
<ActionIcon
icon={RefreshCw}
onClick={async () => {
await mutate(FETCH_TABLE_DATA_KEY(tableName));
}}
title={'Refresh'}
/>
</div>
</div>
const tableColumns = useTableColumns(tableName);
const tableData = usePgTable(tableName);
const columns = tableColumns.data?.map((t) => t.name) || [];
const isLoading = tableColumns.isLoading || tableData.isLoading;
const dataSource = tableData.data?.data || [];
{/* Table */}
<Table tableName={tableName} />
</div>
return (
<Flexbox className={styles.dataPanel} flex={1} height={'100%'}>
<Header
actions={[
{
icon: Filter,
title: 'Filter',
},
{
icon: Download,
title: 'Export',
},
{
icon: RefreshCw,
onClick: async () => {
await mutate(FETCH_TABLE_DATA_KEY(tableName));
},
title: 'Refresh',
},
]}
title={tableName || 'Select a table'}
/>
{tableName ? (
<Table columns={columns} dataSource={dataSource} loading={isLoading} />
) : (
<Center height={'80%'}>
<Empty description={'Select a table to view data'} image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Center>
)}
</Flexbox>
);
};
@@ -3,7 +3,7 @@ import { createStyles } from 'antd-style';
import React from 'react';
import { Flexbox } from 'react-layout-kit';
import { useTableColumns } from './usePgTable';
import { useTableColumns } from '../usePgTable';
const useStyles = createStyles(({ token, css }) => ({
container: css`
@@ -26,7 +26,7 @@ interface TableColumnsProps {
tableName: string;
}
const TableColumns = ({ tableName }: TableColumnsProps) => {
const Columns = ({ tableName }: TableColumnsProps) => {
const { styles } = useStyles();
const { data, isLoading } = useTableColumns(tableName);
@@ -34,7 +34,9 @@ const TableColumns = ({ tableName }: TableColumnsProps) => {
return (
<div className={styles.container}>
{isLoading ? (
<div>Loading...</div>
` <Center width={'100%'}>
<Icon icon={Loader2Icon} spin />
</Center>`
) : (
<Flexbox>
{data?.map((column) => (
@@ -64,4 +66,4 @@ const TableColumns = ({ tableName }: TableColumnsProps) => {
);
};
export default TableColumns;
export default Columns;
@@ -1,11 +1,11 @@
import { Icon } from '@lobehub/ui';
import { DraggablePanel, DraggablePanelBody, Icon } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { ChevronDown, ChevronRight, Database, Table as TableIcon } from 'lucide-react';
import { ChevronDown, ChevronRight, Database, Loader2Icon, Table as TableIcon } from 'lucide-react';
import React, { useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import { Center, Flexbox } from 'react-layout-kit';
import TableColumns from './TableColumns';
import { useFetchTables } from './usePgTable';
import { useFetchTables } from '../usePgTable';
import Columns from './Columns';
const useStyles = createStyles(({ token, css }) => ({
button: css`
@@ -51,24 +51,7 @@ const useStyles = createStyles(({ token, css }) => ({
color: ${token.colorTextTertiary};
`,
schemaHeader: css`
display: flex;
gap: 8px;
align-items: center;
padding-block: 12px;
padding-inline: 16px;
font-weight: ${token.fontWeightStrong};
color: ${token.colorText};
`,
schemaPanel: css`
overflow: scroll;
width: 280px;
height: 100%;
border-inline-end: 1px solid ${token.colorBorderSecondary};
background: ${token.colorBgContainer};
`,
selected: css`
background: ${token.colorFillSecondary};
@@ -146,41 +129,52 @@ const SchemaPanel = ({ onTableSelect, selectedTable }: SchemaPanelProps) => {
};
return (
<div className={styles.schemaPanel}>
<div className={styles.schemaHeader}>
<Database size={16} />
<Flexbox align={'center'} horizontal justify={'space-between'}>
<span>Tables {data?.length}</span>
<span className={styles.schema}>public</span>
<DraggablePanel placement={'left'}>
<Flexbox height={'100%'} style={{ overflow: 'hidden', position: 'relative' }}>
<Flexbox
align={'center'}
className={styles.schemaHeader}
gap={8}
horizontal
paddingBlock={12}
paddingInline={16}
>
<Database size={16} />
<Flexbox align={'center'} horizontal justify={'space-between'}>
<span>Tables {data?.length}</span>
<span className={styles.schema}>public</span>
</Flexbox>
</Flexbox>
</div>
{isLoading ? (
<div>Loading...</div>
) : (
<Flexbox>
{data?.map((table) => (
<div key={table.name}>
<Flexbox
className={cx(styles.tableItem, selectedTable === table.name && styles.selected)}
horizontal
onClick={() => {
toggleTable(table.name);
onTableSelect(table.name);
}}
>
<Icon icon={expandedTables.has(table.name) ? ChevronDown : ChevronRight} />
<TableIcon size={16} />
<Flexbox align={'center'} horizontal justify={'space-between'}>
<span>{table.name}</span>
<span className={styles.count}>{table.count}</span>
<DraggablePanelBody style={{ padding: 0 }}>
{isLoading ? (
<Center height={'100%'} width={'100%'}>
<Icon icon={Loader2Icon} spin />
</Center>
) : (
data?.map((table) => (
<div key={table.name}>
<Flexbox
className={cx(styles.tableItem, selectedTable === table.name && styles.selected)}
horizontal
onClick={() => {
toggleTable(table.name);
onTableSelect(table.name);
}}
>
<Icon icon={expandedTables.has(table.name) ? ChevronDown : ChevronRight} />
<TableIcon size={16} />
<Flexbox align={'center'} horizontal justify={'space-between'}>
<span>{table.name}</span>
<span className={styles.count}>{table.count}</span>
</Flexbox>
</Flexbox>
</Flexbox>
{expandedTables.has(table.name) && <TableColumns tableName={table.name} />}
</div>
))}
</Flexbox>
)}
</div>
{expandedTables.has(table.name) && <Columns tableName={table.name} />}
</div>
))
)}
</DraggablePanelBody>
</Flexbox>
</DraggablePanel>
);
};
@@ -1,8 +1,10 @@
'use client';
import React, { useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import DataTable from './DataTable';
import SchemaPanel from './Schema';
import SchemaSidebar from './SchemaSidebar';
// Main Database Panel Component
const DatabasePanel = () => {
@@ -10,7 +12,7 @@ const DatabasePanel = () => {
return (
<Flexbox height={'100%'} horizontal>
<SchemaPanel onTableSelect={setSelectedTable} selectedTable={selectedTable} />
<SchemaSidebar onTableSelect={setSelectedTable} selectedTable={selectedTable} />
<DataTable tableName={selectedTable} />
</Flexbox>
);
@@ -0,0 +1,218 @@
'use client';
import { ActionIcon, FluentEmoji, Icon, SideNav } from '@lobehub/ui';
import { Dropdown, FloatButton } from 'antd';
import { createStyles } from 'antd-style';
import { BugIcon, BugOff, XIcon } from 'lucide-react';
import { ReactNode, memo, useEffect, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import { Rnd } from 'react-rnd';
import { BRANDING_NAME } from '@/const/branding';
// 定义样式
const useStyles = createStyles(({ token, css, prefixCls }) => {
return {
collapsed: css`
pointer-events: none;
transform: scale(0.8);
opacity: 0;
`,
expanded: css`
pointer-events: auto;
transform: scale(1);
opacity: 1;
`,
floatButton: css`
inset-block-end: 16px;
inset-inline-end: 16px;
width: 36px;
height: 36px;
border: 1px solid ${token.colorBorderSecondary};
font-size: 20px;
.${prefixCls}-float-btn-body {
background: ${token.colorBgLayout};
&:hover {
width: auto;
background: ${token.colorBgElevated};
}
}
`,
header: css`
cursor: move;
user-select: none;
padding-block: 8px;
padding-inline: 16px;
border-block-end: 1px solid ${token.colorBorderSecondary};
color: ${token.colorText};
background: ${token.colorFillAlter};
`,
panel: css`
position: fixed;
z-index: 1000;
overflow: hidden;
display: flex;
border: 1px solid ${token.colorBorderSecondary};
border-radius: 12px;
background: ${token.colorBgContainer};
box-shadow: ${token.boxShadow};
transition: opacity ${token.motionDurationMid} ${token.motionEaseInOut};
`,
};
});
const minWidth = 800;
const minHeight = 600;
interface CollapsibleFloatPanelProps {
items: { children: ReactNode; icon: ReactNode; key: string }[];
}
const CollapsibleFloatPanel = memo<CollapsibleFloatPanelProps>(({ items }) => {
const { styles, theme } = useStyles();
const [tab, setTab] = useState<string>(items[0].key);
const [isHide, setIsHide] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [position, setPosition] = useState({ x: 100, y: 100 });
const [size, setSize] = useState({ height: minHeight, width: minWidth });
useEffect(() => {
try {
const localStoragePosition = localStorage.getItem('debug-panel-position');
if (localStoragePosition && JSON.parse(localStoragePosition)) {
setPosition(JSON.parse(localStoragePosition));
}
} catch {
/* empty */
}
try {
const localStorageSize = localStorage.getItem('debug-panel-size');
if (localStorageSize && JSON.parse(localStorageSize)) {
setSize(JSON.parse(localStorageSize));
}
} catch {
/* empty */
}
}, []);
return (
<>
{!isHide && (
<Dropdown
menu={{
items: [
{
icon: (
<Icon color={theme.colorTextSecondary} icon={BugOff} size={{ fontSize: 16 }} />
),
key: 'hide',
label: 'Hide Toolbar',
onClick: () => setIsHide(true),
},
],
}}
trigger={['hover']}
>
<FloatButton
className={styles.floatButton}
icon={<Icon icon={isExpanded ? BugOff : BugIcon} />}
onClick={() => setIsExpanded(!isExpanded)}
/>
</Dropdown>
)}
{isExpanded && (
<Rnd
bounds="window"
className={`${styles.panel} ${isExpanded ? styles.expanded : styles.collapsed}`}
dragHandleClassName="panel-drag-handle"
minHeight={minHeight}
minWidth={minWidth}
onDragStop={(e, d) => {
setPosition({ x: d.x, y: d.y });
}}
onResizeStop={(e, direction, ref, delta, position) => {
setSize({
height: Number(ref.style.height),
width: Number(ref.style.width),
});
setPosition(position);
}}
position={position}
size={size}
>
<Flexbox
height={'100%'}
horizontal
style={{ overflow: 'hidden', position: 'relative' }}
width={'100%'}
>
<SideNav
avatar={<FluentEmoji emoji={'🧰'} size={24} />}
bottomActions={[]}
style={{
paddingBlock: 12,
width: 48,
}}
topActions={items.map((item) => (
<ActionIcon
active={tab === item.key}
key={item.key}
onClick={() => setTab(item.key)}
placement={'right'}
title={item.key}
>
{item.icon}
</ActionIcon>
))}
/>
<Flexbox
height={'100%'}
style={{ overflow: 'hidden', position: 'relative' }}
width={'100%'}
>
<Flexbox
align={'center'}
className={`panel-drag-handle ${styles.header}`}
horizontal
justify={'space-between'}
>
<Flexbox align={'baseline'} gap={6} horizontal>
<b>{BRANDING_NAME} Dev Tools</b>
<span style={{ color: theme.colorTextDescription }}>/</span>
<span style={{ color: theme.colorTextDescription }}>{tab}</span>
</Flexbox>
<ActionIcon icon={XIcon} onClick={() => setIsExpanded(false)} />
</Flexbox>
{items.map((item) => (
<Flexbox
flex={1}
height={'100%'}
key={item.key}
style={{
display: tab === item.key ? 'flex' : 'none',
overflow: 'hidden',
}}
>
{item.children}
</Flexbox>
))}
</Flexbox>
</Flexbox>
</Rnd>
)}
</>
);
});
export default CollapsibleFloatPanel;
+50
View File
@@ -0,0 +1,50 @@
import { ActionIcon, type ActionIconProps } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import React, { ReactNode } from 'react';
import { Flexbox, FlexboxProps } from 'react-layout-kit';
const useStyles = createStyles(({ token, css }) => ({
header: css`
border-block-end: 1px solid ${token.colorBorderSecondary};
`,
title: css`
font-weight: 550;
`,
}));
interface HeaderProps extends Omit<FlexboxProps, 'title' | 'children'> {
actions?: ActionIconProps[];
extra?: ReactNode;
title?: ReactNode;
}
const Header = ({ title, actions = [], extra, ...rest }: HeaderProps) => {
const { styles } = useStyles();
return (
<Flexbox
align={'center'}
className={styles.header}
flex={'none'}
height={46}
horizontal
justify={'space-between'}
paddingInline={16}
{...rest}
>
<div className={styles.title}>{title}</div>
<Flexbox align={'center'} gap={4} horizontal>
{extra}
{actions.map((action, index) => (
<ActionIcon
{...action}
key={action.title || index}
size={{ blockSize: 28, fontSize: 16 }}
/>
))}
</Flexbox>
</Flexbox>
);
};
export default Header;
@@ -0,0 +1,73 @@
import { Typography } from 'antd';
import { createStyles } from 'antd-style';
import dayjs from 'dayjs';
import { get, isDate } from 'lodash-es';
import React, { useMemo } from 'react';
import TooltipContent from './TooltipContent';
const { Text } = Typography;
const useStyles = createStyles(({ token, css }) => ({
cell: css`
font-family: ${token.fontFamilyCode};
font-size: ${token.fontSizeSM}px;
`,
tooltip: css`
border: 1px solid ${token.colorBorder};
font-family: ${token.fontFamilyCode};
font-size: ${token.fontSizeSM}px;
color: ${token.colorText} !important;
word-break: break-all;
background: ${token.colorBgElevated} !important;
`,
}));
interface TableCellProps {
column: string;
dataItem: any;
rowIndex: number;
}
const TableCell = ({ dataItem, column, rowIndex }: TableCellProps) => {
const { styles } = useStyles();
const data = get(dataItem, column);
const content = useMemo(() => {
if (isDate(data)) return dayjs(data).format('YYYY-MM-DD HH:mm:ss');
switch (typeof data) {
case 'object': {
return JSON.stringify(data);
}
case 'boolean': {
return data ? 'True' : 'False';
}
default: {
return data;
}
}
}, [data]);
return (
<td key={column} onDoubleClick={() => console.log('Edit cell:', rowIndex, column)}>
<Text
className={styles.cell}
ellipsis={{
tooltip: {
arrow: false,
classNames: { body: styles.tooltip },
title: <TooltipContent>{content}</TooltipContent>,
},
}}
>
{content}
</Text>
</td>
);
};
export default TableCell;
@@ -0,0 +1,39 @@
import { Highlighter } from '@lobehub/ui';
import Link from 'next/link';
import { ReactNode, memo } from 'react';
import { Flexbox } from 'react-layout-kit';
const TooltipContent = memo<{ children: ReactNode }>(({ children }) => {
if (typeof children !== 'string') return children;
if (children.startsWith('data:image')) {
return <img src={children} style={{ height: 'auto', maxWidth: '100%' }} />;
}
if (children.startsWith('http'))
return (
<Link href={children} target={'_blank'}>
{children}
</Link>
);
const code = children.trim().trimEnd();
if ((code.startsWith('{') && code.endsWith('}')) || (code.startsWith('[') && code.endsWith(']')))
return (
<Highlighter
language={'json'}
style={{
maxHeight: 400,
overflow: 'auto',
}}
type={'pure'}
>
{JSON.stringify(JSON.parse(code), null, 2)}
</Highlighter>
);
return <Flexbox>{children}</Flexbox>;
});
export default TooltipContent;
@@ -1,9 +1,10 @@
import { Icon } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { Loader2Icon } from 'lucide-react';
import React from 'react';
import { Center } from 'react-layout-kit';
import { TableVirtuoso } from 'react-virtuoso';
import { usePgTable, useTableColumns } from '../usePgTable';
import TableCell from './TableCell';
const useStyles = createStyles(({ token, css }) => ({
@@ -88,24 +89,21 @@ const useStyles = createStyles(({ token, css }) => ({
}));
interface TableProps {
tableName?: string;
columns: string[];
dataSource: any[];
loading?: boolean;
}
const Table = ({ tableName }: TableProps) => {
const Table = ({ columns, dataSource, loading }: TableProps) => {
const { styles } = useStyles();
const tableColumns = useTableColumns(tableName);
if (loading)
return (
<Center height={'100%'}>
<Icon icon={Loader2Icon} spin />
</Center>
);
const tableData = usePgTable(tableName);
const columns = tableColumns.data?.map((t) => t.name) || [];
const isLoading = tableColumns.isLoading || tableData.isLoading;
if (!tableName) return <Center height={'80%'}>Select a table to view data</Center>;
if (isLoading) return <Center height={'80%'}>Loading...</Center>;
const dataSource = tableData.data?.data || [];
const header = (
<tr>
{columns.map((column) => (
+29 -5
View File
@@ -1,12 +1,36 @@
'use client';
import { BookText, DatabaseIcon, FlagIcon, GlobeLockIcon } from 'lucide-react';
import FloatPanel from './FloatPanel';
import CacheViewer from './CacheViewer';
import FeatureFlagViewer from './FeatureFlagViewer';
import MetadataViewer from './MetadataViewer';
import PostgresViewer from './PostgresViewer';
import FloatPanel from './features/FloatPanel';
const DevPanel = () => (
<FloatPanel>
<PostgresViewer />
</FloatPanel>
<FloatPanel
items={[
{
children: <PostgresViewer />,
icon: <DatabaseIcon size={16} />,
key: 'Postgres Viewer',
},
{
children: <MetadataViewer />,
icon: <BookText size={16} />,
key: 'SEO Metadata',
},
{
children: <CacheViewer />,
icon: <GlobeLockIcon size={16} />,
key: 'NextJS Caches',
},
{
children: <FeatureFlagViewer />,
icon: <FlagIcon size={16} />,
key: 'Feature Flags',
},
]}
/>
);
export default DevPanel;