From db4e9c7fbbee01a03f3034a2bdce36074085a0a1 Mon Sep 17 00:00:00 2001 From: CanisMinor Date: Thu, 6 Feb 2025 01:29:33 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=84=20style:=20Add=20Cache,=20Metadata?= =?UTF-8?q?,=20FeatureFlag=20Viewer=20to=20DevPanel=20(#5764)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ 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 --- .../DevPanel/CacheViewer/DataTable/index.tsx | 33 +++ .../DevPanel/CacheViewer/cacheProvider.tsx | 64 +++++ .../DevPanel/CacheViewer/getCacheEntries.ts | 52 +++++ src/features/DevPanel/CacheViewer/index.tsx | 25 ++ src/features/DevPanel/CacheViewer/schema.ts | 49 ++++ .../DevPanel/FeatureFlagViewer/Form.tsx | 93 ++++++++ .../DevPanel/FeatureFlagViewer/index.tsx | 11 + src/features/DevPanel/FloatPanel.tsx | 136 ----------- src/features/DevPanel/MetadataViewer/Ld.tsx | 25 ++ .../DevPanel/MetadataViewer/MetaData.tsx | 30 +++ src/features/DevPanel/MetadataViewer/Og.tsx | 75 ++++++ .../DevPanel/MetadataViewer/index.tsx | 80 +++++++ .../DevPanel/MetadataViewer/useHead.ts | 16 ++ .../PostgresViewer/DataTable/TableCell.tsx | 34 --- .../PostgresViewer/DataTable/index.tsx | 88 ++++--- .../Columns.tsx} | 10 +- .../{Schema.tsx => SchemaSidebar/index.tsx} | 104 ++++----- .../DevPanel/PostgresViewer/index.tsx | 6 +- src/features/DevPanel/features/FloatPanel.tsx | 218 ++++++++++++++++++ src/features/DevPanel/features/Header.tsx | 50 ++++ .../DevPanel/features/Table/TableCell.tsx | 73 ++++++ .../features/Table/TooltipContent.tsx | 39 ++++ .../Table.tsx => features/Table/index.tsx} | 26 +-- src/features/DevPanel/index.tsx | 34 ++- 24 files changed, 1072 insertions(+), 299 deletions(-) create mode 100644 src/features/DevPanel/CacheViewer/DataTable/index.tsx create mode 100644 src/features/DevPanel/CacheViewer/cacheProvider.tsx create mode 100644 src/features/DevPanel/CacheViewer/getCacheEntries.ts create mode 100644 src/features/DevPanel/CacheViewer/index.tsx create mode 100644 src/features/DevPanel/CacheViewer/schema.ts create mode 100644 src/features/DevPanel/FeatureFlagViewer/Form.tsx create mode 100644 src/features/DevPanel/FeatureFlagViewer/index.tsx delete mode 100644 src/features/DevPanel/FloatPanel.tsx create mode 100644 src/features/DevPanel/MetadataViewer/Ld.tsx create mode 100644 src/features/DevPanel/MetadataViewer/MetaData.tsx create mode 100644 src/features/DevPanel/MetadataViewer/Og.tsx create mode 100644 src/features/DevPanel/MetadataViewer/index.tsx create mode 100644 src/features/DevPanel/MetadataViewer/useHead.ts delete mode 100644 src/features/DevPanel/PostgresViewer/DataTable/TableCell.tsx rename src/features/DevPanel/PostgresViewer/{TableColumns.tsx => SchemaSidebar/Columns.tsx} (86%) rename src/features/DevPanel/PostgresViewer/{Schema.tsx => SchemaSidebar/index.tsx} (57%) create mode 100644 src/features/DevPanel/features/FloatPanel.tsx create mode 100644 src/features/DevPanel/features/Header.tsx create mode 100644 src/features/DevPanel/features/Table/TableCell.tsx create mode 100644 src/features/DevPanel/features/Table/TooltipContent.tsx rename src/features/DevPanel/{PostgresViewer/DataTable/Table.tsx => features/Table/index.tsx} (82%) diff --git a/src/features/DevPanel/CacheViewer/DataTable/index.tsx b/src/features/DevPanel/CacheViewer/DataTable/index.tsx new file mode 100644 index 0000000000000..a2f6b664165c0 --- /dev/null +++ b/src/features/DevPanel/CacheViewer/DataTable/index.tsx @@ -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 ( + <> +
refreshData(), + title: 'Refresh', + }, + ]} + title="Cache Entries" + /> + + + ); +}); + +export default DataTable; diff --git a/src/features/DevPanel/CacheViewer/cacheProvider.tsx b/src/features/DevPanel/CacheViewer/cacheProvider.tsx new file mode 100644 index 0000000000000..ddb4047ab7ade --- /dev/null +++ b/src/features/DevPanel/CacheViewer/cacheProvider.tsx @@ -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({ + 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 ( + + {props.children} + + ); +}; diff --git a/src/features/DevPanel/CacheViewer/getCacheEntries.ts b/src/features/DevPanel/CacheViewer/getCacheEntries.ts new file mode 100644 index 0000000000000..8c19989d41439 --- /dev/null +++ b/src/features/DevPanel/CacheViewer/getCacheEntries.ts @@ -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 => { + 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()); +}; diff --git a/src/features/DevPanel/CacheViewer/index.tsx b/src/features/DevPanel/CacheViewer/index.tsx new file mode 100644 index 0000000000000..4ff19e28ea473 --- /dev/null +++ b/src/features/DevPanel/CacheViewer/index.tsx @@ -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 ( +
+ +
+ ); + + return ( + + + + ); +}; + +export default CacheViewer; diff --git a/src/features/DevPanel/CacheViewer/schema.ts b/src/features/DevPanel/CacheViewer/schema.ts new file mode 100644 index 0000000000000..8e34ee46b8d92 --- /dev/null +++ b/src/features/DevPanel/CacheViewer/schema.ts @@ -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; diff --git a/src/features/DevPanel/FeatureFlagViewer/Form.tsx b/src/features/DevPanel/FeatureFlagViewer/Form.tsx new file mode 100644 index 0000000000000..f2b7c217a985e --- /dev/null +++ b/src/features/DevPanel/FeatureFlagViewer/Form.tsx @@ -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 ( + <> +
{ + form.resetFields(); + setData(flags); + }, + title: 'Reset', + }, + ]} + title={'Feature Flag Env'} + /> + +
{ + return { + children: , + label: snakeCase(key), + minWidth: undefined, + name: key, + valuePropName: 'checked', + }; + })} + itemsType={'flat'} + onValuesChange={(_, v) => setData(v)} + variant={'pure'} + /> +
+ {`FEATURE_FLAGS="${output.filter(Boolean).join(',')}"`} + + ); +}); + +export default FeatureFlagForm; diff --git a/src/features/DevPanel/FeatureFlagViewer/index.tsx b/src/features/DevPanel/FeatureFlagViewer/index.tsx new file mode 100644 index 0000000000000..2797e4bfbfc80 --- /dev/null +++ b/src/features/DevPanel/FeatureFlagViewer/index.tsx @@ -0,0 +1,11 @@ +import { getServerFeatureFlagsValue } from '@/config/featureFlags'; + +import FeatureFlagForm from './Form'; + +const FeatureFlagViewer = () => { + const serverFeatureFlags = getServerFeatureFlagsValue(); + + return ; +}; + +export default FeatureFlagViewer; diff --git a/src/features/DevPanel/FloatPanel.tsx b/src/features/DevPanel/FloatPanel.tsx deleted file mode 100644 index 0c8f5b98367d2..0000000000000 --- a/src/features/DevPanel/FloatPanel.tsx +++ /dev/null @@ -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 ( - <> - } - onClick={() => setIsExpanded(!isExpanded)} - style={{ bottom: 24, right: 24 }} - /> - {isExpanded && ( - { - 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} - > - - - 开发者面板 - setIsExpanded(false)} /> - - {children} - - - )} - - ); -}; - -export default CollapsibleFloatPanel; diff --git a/src/features/DevPanel/MetadataViewer/Ld.tsx b/src/features/DevPanel/MetadataViewer/Ld.tsx new file mode 100644 index 0000000000000..bd5b2c94e9103 --- /dev/null +++ b/src/features/DevPanel/MetadataViewer/Ld.tsx @@ -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 ( +
+ +
+ ); + + return ( + + {JSON.stringify(JSON.parse(ld), null, 2)} + + ); +}); + +export default Ld; diff --git a/src/features/DevPanel/MetadataViewer/MetaData.tsx b/src/features/DevPanel/MetadataViewer/MetaData.tsx new file mode 100644 index 0000000000000..1137e4b9eb8a4 --- /dev/null +++ b/src/features/DevPanel/MetadataViewer/MetaData.tsx @@ -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 ( + , + label: `Title (${title.length})`, + }, + { + children: , + label: `Description (${description.length})`, + }, + ]} + itemsType={'flat'} + variant={'pure'} + /> + ); +}); + +export default MetaData; diff --git a/src/features/DevPanel/MetadataViewer/Og.tsx b/src/features/DevPanel/MetadataViewer/Og.tsx new file mode 100644 index 0000000000000..58710883e531a --- /dev/null +++ b/src/features/DevPanel/MetadataViewer/Og.tsx @@ -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 ( + , + label: `OG Title (${ogTitle.length})`, + }, + { + children: , + label: `OG Description (${ogDescription.length})`, + }, + { + children: ( + +
+ lobehub.com +
+ +
+ ), + label: 'Og Image', + minWidth: undefined, + }, + { + children: , + label: 'Og Image Url', + }, + ]} + itemsType={'flat'} + variant={'pure'} + /> + ); +}); + +export default MetaData; diff --git a/src/features/DevPanel/MetadataViewer/index.tsx b/src/features/DevPanel/MetadataViewer/index.tsx new file mode 100644 index 0000000000000..4d205d8f6e976 --- /dev/null +++ b/src/features/DevPanel/MetadataViewer/index.tsx @@ -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.Og); + return ( + +
setActive(v as Tab)} + style={{ margin: 16 }} + variant={'compact'} + /> + } + /> + + {active === Tab.Og && } + {active === Tab.Meta && } + {active === Tab.Ld && } + + + ); +}); + +export default MetadataViewer; diff --git a/src/features/DevPanel/MetadataViewer/useHead.ts b/src/features/DevPanel/MetadataViewer/useHead.ts new file mode 100644 index 0000000000000..9e34e9238624f --- /dev/null +++ b/src/features/DevPanel/MetadataViewer/useHead.ts @@ -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 || ''; +}; diff --git a/src/features/DevPanel/PostgresViewer/DataTable/TableCell.tsx b/src/features/DevPanel/PostgresViewer/DataTable/TableCell.tsx deleted file mode 100644 index 257754f1eaa0b..0000000000000 --- a/src/features/DevPanel/PostgresViewer/DataTable/TableCell.tsx +++ /dev/null @@ -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 ( -
- ); -}; - -export default TableCell; diff --git a/src/features/DevPanel/PostgresViewer/DataTable/index.tsx b/src/features/DevPanel/PostgresViewer/DataTable/index.tsx index 00bc5da814bb6..21b92fb05cfc9 100644 --- a/src/features/DevPanel/PostgresViewer/DataTable/index.tsx +++ b/src/features/DevPanel/PostgresViewer/DataTable/index.tsx @@ -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(); + 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 || []; + return ( -
- {/* Toolbar */} -
-
{tableName || 'Select a table'}
-
- - - { + +
{ await mutate(FETCH_TABLE_DATA_KEY(tableName)); - }} - title={'Refresh'} - /> -
-
- - {/* Table */} -
console.log('Edit cell:', rowIndex, column)}> - {content} -
- + }, + title: 'Refresh', + }, + ]} + title={tableName || 'Select a table'} + /> + {tableName ? ( +
+ ) : ( +
+ +
+ )} + ); }; diff --git a/src/features/DevPanel/PostgresViewer/TableColumns.tsx b/src/features/DevPanel/PostgresViewer/SchemaSidebar/Columns.tsx similarity index 86% rename from src/features/DevPanel/PostgresViewer/TableColumns.tsx rename to src/features/DevPanel/PostgresViewer/SchemaSidebar/Columns.tsx index 209b4f4bd6ddc..42c22442344c7 100644 --- a/src/features/DevPanel/PostgresViewer/TableColumns.tsx +++ b/src/features/DevPanel/PostgresViewer/SchemaSidebar/Columns.tsx @@ -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 (
{isLoading ? ( -
Loading...
+ `
+ +
` ) : ( {data?.map((column) => ( @@ -64,4 +66,4 @@ const TableColumns = ({ tableName }: TableColumnsProps) => { ); }; -export default TableColumns; +export default Columns; diff --git a/src/features/DevPanel/PostgresViewer/Schema.tsx b/src/features/DevPanel/PostgresViewer/SchemaSidebar/index.tsx similarity index 57% rename from src/features/DevPanel/PostgresViewer/Schema.tsx rename to src/features/DevPanel/PostgresViewer/SchemaSidebar/index.tsx index 4d5438c3bde75..14af0e44dbcf4 100644 --- a/src/features/DevPanel/PostgresViewer/Schema.tsx +++ b/src/features/DevPanel/PostgresViewer/SchemaSidebar/index.tsx @@ -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 ( -
-
- - - Tables {data?.length} - public + + + + + + Tables {data?.length} + public + -
- {isLoading ? ( -
Loading...
- ) : ( - - {data?.map((table) => ( -
- { - toggleTable(table.name); - onTableSelect(table.name); - }} - > - - - - {table.name} - {table.count} + + {isLoading ? ( +
+ +
+ ) : ( + data?.map((table) => ( +
+ { + toggleTable(table.name); + onTableSelect(table.name); + }} + > + + + + {table.name} + {table.count} + - - {expandedTables.has(table.name) && } -
- ))} -
- )} -
+ {expandedTables.has(table.name) && } +
+ )) + )} + +
+ ); }; diff --git a/src/features/DevPanel/PostgresViewer/index.tsx b/src/features/DevPanel/PostgresViewer/index.tsx index 1a0c26ea8c8c8..2fdcaab24757d 100644 --- a/src/features/DevPanel/PostgresViewer/index.tsx +++ b/src/features/DevPanel/PostgresViewer/index.tsx @@ -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 ( - + ); diff --git a/src/features/DevPanel/features/FloatPanel.tsx b/src/features/DevPanel/features/FloatPanel.tsx new file mode 100644 index 0000000000000..22f183a3a25b7 --- /dev/null +++ b/src/features/DevPanel/features/FloatPanel.tsx @@ -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(({ items }) => { + const { styles, theme } = useStyles(); + const [tab, setTab] = useState(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 && ( + + ), + key: 'hide', + label: 'Hide Toolbar', + onClick: () => setIsHide(true), + }, + ], + }} + trigger={['hover']} + > + } + onClick={() => setIsExpanded(!isExpanded)} + /> + + )} + {isExpanded && ( + { + 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} + > + + } + bottomActions={[]} + style={{ + paddingBlock: 12, + width: 48, + }} + topActions={items.map((item) => ( + setTab(item.key)} + placement={'right'} + title={item.key} + > + {item.icon} + + ))} + /> + + + + {BRANDING_NAME} Dev Tools + / + {tab} + + setIsExpanded(false)} /> + + {items.map((item) => ( + + {item.children} + + ))} + + + + )} + + ); +}); + +export default CollapsibleFloatPanel; diff --git a/src/features/DevPanel/features/Header.tsx b/src/features/DevPanel/features/Header.tsx new file mode 100644 index 0000000000000..0b981b21658d0 --- /dev/null +++ b/src/features/DevPanel/features/Header.tsx @@ -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 { + actions?: ActionIconProps[]; + extra?: ReactNode; + title?: ReactNode; +} + +const Header = ({ title, actions = [], extra, ...rest }: HeaderProps) => { + const { styles } = useStyles(); + + return ( + +
{title}
+ + {extra} + {actions.map((action, index) => ( + + ))} + +
+ ); +}; + +export default Header; diff --git a/src/features/DevPanel/features/Table/TableCell.tsx b/src/features/DevPanel/features/Table/TableCell.tsx new file mode 100644 index 0000000000000..9cd41bea3f1c0 --- /dev/null +++ b/src/features/DevPanel/features/Table/TableCell.tsx @@ -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 ( +
+ ); +}; + +export default TableCell; diff --git a/src/features/DevPanel/features/Table/TooltipContent.tsx b/src/features/DevPanel/features/Table/TooltipContent.tsx new file mode 100644 index 0000000000000..16b9b825b12d9 --- /dev/null +++ b/src/features/DevPanel/features/Table/TooltipContent.tsx @@ -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 ; + } + + if (children.startsWith('http')) + return ( + + {children} + + ); + + const code = children.trim().trimEnd(); + + if ((code.startsWith('{') && code.endsWith('}')) || (code.startsWith('[') && code.endsWith(']'))) + return ( + + {JSON.stringify(JSON.parse(code), null, 2)} + + ); + + return {children}; +}); + +export default TooltipContent; diff --git a/src/features/DevPanel/PostgresViewer/DataTable/Table.tsx b/src/features/DevPanel/features/Table/index.tsx similarity index 82% rename from src/features/DevPanel/PostgresViewer/DataTable/Table.tsx rename to src/features/DevPanel/features/Table/index.tsx index 4c3e33cca3b49..5bdaf33b7fa2c 100644 --- a/src/features/DevPanel/PostgresViewer/DataTable/Table.tsx +++ b/src/features/DevPanel/features/Table/index.tsx @@ -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 ( +
+ +
+ ); - const tableData = usePgTable(tableName); - - const columns = tableColumns.data?.map((t) => t.name) || []; - const isLoading = tableColumns.isLoading || tableData.isLoading; - - if (!tableName) return
Select a table to view data
; - - if (isLoading) return
Loading...
; - - const dataSource = tableData.data?.data || []; const header = ( {columns.map((column) => ( diff --git a/src/features/DevPanel/index.tsx b/src/features/DevPanel/index.tsx index 5b749647a21a5..e5b32795823c4 100644 --- a/src/features/DevPanel/index.tsx +++ b/src/features/DevPanel/index.tsx @@ -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 = () => ( - - - + , + icon: , + key: 'Postgres Viewer', + }, + { + children: , + icon: , + key: 'SEO Metadata', + }, + { + children: , + icon: , + key: 'NextJS Caches', + }, + { + children: , + icon: , + key: 'Feature Flags', + }, + ]} + /> ); export default DevPanel;
console.log('Edit cell:', rowIndex, column)}> + {content}, + }, + }} + > + {content} + +