Compare commits

9 Commits

Author SHA1 Message Date
8a54bb29fb wip 2025-03-22 17:15:35 +00:00
0e11c4153d wip 2025-03-22 13:48:21 +00:00
6e921e82fb wip 2025-03-21 22:40:00 +08:00
7a4578c16d wip 2025-03-21 17:48:08 +08:00
9cd93e60df wip 2025-03-20 14:53:02 +00:00
111b6fbadd wip 2025-03-20 18:06:49 +08:00
a2ef989b75 wip 2025-03-20 18:06:22 +08:00
9ad083b014 wip 2025-03-20 17:56:21 +08:00
8d3c805985 wip: change local dev 2025-03-20 05:47:13 +00:00
31 changed files with 1530 additions and 134 deletions

16
lib/client/storage.ts Normal file
View File

@@ -0,0 +1,16 @@
// lib/client/storage.ts
export const CLIENT_STORAGE_KEYS = {
THEME: "theme",
LANG: "lang"
} as const;
export const getInitialState = () => {
if (typeof window === "undefined") return {};
return {
uiStore: {
theme: localStorage.getItem(CLIENT_STORAGE_KEYS.THEME) || "light",
language: localStorage.getItem(CLIENT_STORAGE_KEYS.LANG) || "zh-CN"
}
};
};

4
.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./app/globals.css"
}

View File

@@ -1,26 +1,2 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
@plugin "daisyui";

View File

@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { StoreProvider } from "../stores/storeContext";
import { getInitialState } from "@/ lib/client/storage";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -22,12 +24,16 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const initialState = getInitialState();
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-hidden`}
>
{children}
<StoreProvider initialData={initialState}>
{children}
</StoreProvider>
</body>
</html>
);

View File

@@ -1,103 +1,103 @@
import Image from "next/image";
"use client";
import { useEffect, useState } from "react";
import DraggablePanel from "@/components/DraggablePanel";
import Logo from "@/widgets/Logo";
import Preview from "@/components/Draggable/Preview";
import Draggable from "@/components/Draggable/Draggable";
import PreviewStore from "@/stores/previewStore";
import { useObserver } from "mobx-react-lite";
import ComponentsStore from "@/stores/componentStore";
import ComponentPaletteDrawer from "@/components/ComponentPaletteDrawer";
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
const [isChangeSize, setIsSizeChangeSize] = useState(false);
const [isDraggable, setIsDraggable] = useState(false);
const { components } = ComponentsStore;
// useEffect(() => {
// ComponentsStore.initComponent([
// {
// id: "1",
// x: 0,
// y: 0,
// width: 320,
// height: 160,
// component: () => <Logo />
// },
// {
// id: "2",
// x: 336,
// y: 0,
// width: 160,
// height: 320,
// component: () => <Logo />
// },
// {
// id: "3",
// x: 336,
// y: 0,
// width: 160,
// height: 320,
// component: () => <Logo />
// },
// {
// id: "4",
// x: 336,
// y: 0,
// width: 160,
// height: 320,
// component: () => <Logo />
// },
// {
// id: "5",
// x: 336,
// y: 0,
// width: 160,
// height: 320,
// component: () => <Logo />
// },
// {
// id: "6",
// x: 336,
// y: 0,
// width: 160,
// height: 320,
// component: () => <Logo />
// },
// ]);
// }, [])
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
return useObserver(() => (
<div className="h-screen w-screen p-4">
<label className="swap">
<input
type="checkbox"
onChange={(e) => setIsSizeChangeSize(e.target.checked)}
/>
<div className="swap-on"></div>
<div className="swap-off"></div>
</label>
<label className="swap">
<input
type="checkbox"
onChange={(e) => setIsDraggable(e.target.checked)}
/>
<div className="swap-on"></div>
<div className="swap-off"></div>
</label>
<ComponentPaletteDrawer />
<DraggablePanel draggable={isDraggable}>
{ComponentsStore.components.map((item) => (
<Draggable
key={item.id}
id={item.id}
x={item.x}
y={item.y}
width={item.width}
height={item.height}
widgetsId={item.widgetsId}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
))}
</DraggablePanel>
</div>
);
));
}

0
app/settings/page.tsx Normal file
View File

42
app/signin/page.tsx Normal file
View File

@@ -0,0 +1,42 @@
import "@/app/globals.css";
import Logo from "@/widgets/Logo";
export default function LoginPage() {
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
{/* Logo Section */}
<div className="text-center">
<Logo />
<h2 className="mt-6 text-center text-3xl font-bold text-gray-900">
</h2>
</div>
{/* Login Form */}
<form className="mt-8 space-y-6" action="#" method="POST">
{/* Email Input */}
<div>
<input type="text" placeholder="帐号" className="input w-full" />
</div>
{/* Password Input */}
<div>
<input type="password" placeholder="密码" className="input w-full" />
</div>
{/* Remember Me & Forgot Password */}
<div className="flex items-center justify-between">
<label className="fieldset-label">
<input type="checkbox" defaultChecked className="checkbox" />
</label>
</div>
{/* Submit Button */}
<button className="btn w-full"></button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,117 @@
"use client";
import "@/app/globals.css";
import { useEffect, useRef, useState } from "react";
import ComponentsStore from "@/stores/componentStore";
import { useWidgets } from "@/hooks/useWidgets";
export default function ComponentPaletteDrawer() {
const checkboxRef = useRef<HTMLInputElement>(null);
const [widgetsId, setwidgetsId] = useState("logo");
const [data, setData] = useState("");
const {widgets, widgetsLibrary} = useWidgets();
useEffect(() => {
try {
const widget = widgets.find(item => item.id === widgetsId);
const defaultConfig = widget?.defaultConfig({})
const stringData = JSON.stringify(defaultConfig, undefined, 4);
setData(stringData);
} catch (error) {
}
}, [widgetsId]);
const onSubmit = (e: SubmitEvent) => {
e.preventDefault();
ComponentsStore.addComponent(widgetsId, data && JSON.parse(data));
checkboxRef.current?.click();
};
return (
<div className="drawer drawer-end">
<input
ref={checkboxRef}
id="ComponentPaletteDrawer"
type="checkbox"
className="drawer-toggle"
/>
<div className="drawer-content">
{/* Page content here */}
<label
htmlFor="ComponentPaletteDrawer"
className="drawer-button btn btn-primary"
>
Add Component
</label>
</div>
<div className="drawer-side z-50">
<label
htmlFor="ComponentPaletteDrawer"
aria-label="close sidebar"
className="drawer-overlay"
></label>
<div className="menu min-h-full w-2/5 bg-base-200 p-0 text-base-content">
<div className="navbar bg-base-100 shadow-sm">
<a className="btn text-xl btn-ghost">daisyUI</a>
</div>
<div className="p-4">
<div role="alert" className="alert alert-warning mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>Warning: 内容处于开发阶段仅供参考</span>
</div>
<div className="resize-y border overflow-hidden max-w-full h-64 ">
{
9 }
</div>
<form onSubmit={onSubmit} className="w-full">
<fieldset className="fieldset w-full">
<legend className="fieldset-legend"></legend>
<select
value={widgetsId}
onChange={(e) => setwidgetsId(e.target.value)}
className="select w-full"
>
<option disabled={true}></option>
{
widgets.map(item => {
return (<option value={item.id}>{item.name}</option>)
})
}
</select>
<p className="fieldset-label"></p>
</fieldset>
<fieldset className="fieldset w-full">
<legend className="fieldset-legend"></legend>
<textarea
value={data}
onChange={(e) => setData(e.target.value)}
className="textarea h-24 w-full"
placeholder="请输入 JSON 格式的配置信息"
></textarea>
<p className="fieldset-label">JSON格式的配置信息</p>
</fieldset>
<button className="btn w-full btn-primary"></button>
</form>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import "@/app/globals.css";
import _ from "lodash";
import { useDraggable } from "@dnd-kit/core";
import { useEffect, useRef, useState } from "react";
import { nearestMultiple } from "./utils";
import PreviewStore from "@/stores/previewStore";
import { useWidgets } from "@/hooks/useWidgets";
export default function Draggable(props: DraggablePropsType) {
const targetRef = useRef<HTMLDivElement>(null);
const timerRef = useRef<NodeJS.Timeout>(null);
const [size, setSize] = useState({ width: 16, height: 16 });
const { id, widgetsId, data, x, y, width:_width, height:_height } = props;
const [width, setWidth] = useState(_width);
const [height, setHeight] = useState(_height);
const {widgets, widgetsLibrary} = useWidgets();
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id,
data: props
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
opacity: 0.6,
}
: {};
useEffect(() => {
const element = targetRef.current;
if (!element) return;
const observer = new ResizeObserver(
_.throttle((entries: any) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
setSize({
width: nearestMultiple(width),
height: nearestMultiple(height),
});
const x = nearestMultiple(element.offsetLeft);
const y = nearestMultiple(element.offsetTop);
PreviewStore.changePreviewX(x);
PreviewStore.changePreviewY(y);
PreviewStore.changePreviewWidth(nearestMultiple(width));
PreviewStore.changePreviewHeight(nearestMultiple(height));
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
setWidth(nearestMultiple(width));
setHeight(nearestMultiple(height));
PreviewStore.clearPreview();
}, 500);
}
}, 300),
);
observer.observe(element);
return () => {
observer.disconnect();
};
}, []);
const className = transform ? "shadow-xl absolute w-min min-w-[128px] min-h-[128px]" : "absolute w-min resize overflow-hidden min-w-[128px] min-h-[128px]";
return (
<div className={className} ref={(el) => {
setNodeRef(el); // DnD 的 ref
(targetRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
}} style={{
top: y,
left: x,
width: width,
height: height,
...style,
}} {...attributes}>
<button
className="btn absolute top-1 right-1 z-40 btn-square btn-soft"
{...listeners}
onMouseDown={(e) => e.stopPropagation()}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-6 pointer-events-none"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 9h16.5m-16.5 6.75h16.5"
/>
</svg>
</button>
{widgetsLibrary[widgetsId] && widgetsLibrary[widgetsId](data ?? {})}
</div>
);
}
export type DraggablePropsType = {
id: string;
widgetsId: string;
data?: Record<string, unknown>;
x: number;
y: number;
width: number;
height: number;
};

View File

@@ -0,0 +1,14 @@
"use client";
import "@/app/globals.css";
export default function Droppable() {
return (
<div className="flex h-full w-full items-center justify-center bg-gray-300 text-5xl font-bold text-gray-900">
<span className="whitespace-nowrap md:whitespace-normal">NEXUSHUB</span>
</div>
);
}
export type DroppablePropsType = {
}

View File

@@ -0,0 +1,47 @@
"use client";
import "@/app/globals.css";
import { CSSProperties, useEffect, useState } from "react";
export default function Preview(props: PreviewPropsType) {
const {x, y, width, height} = props;
const [style, setStyle] = useState<CSSProperties>({
top: 0,
left: 0,
width: 0,
height: 0,
});
useEffect(() => {
setStyle({
top: y,
left: x,
width: width,
height: height,
display: width + height + x + y === 0 ? 'none' : 'block'
});
}, [props.height, props.width, props.x, props.y])
return (
<div
className="absolute border-2 z-50 border-emerald-500 bg-gradient-to-br from-emerald-100/30 to-cyan-100/30 backdrop-blur-[2px] rounded-lg shadow-lg shadow-emerald-200/50 animate-pulse"
style={style}
>
<span
className="absolute text-2xl bg-emerald-500 border-emerald-500 text-white top-0 p-0.5"
>
{width + height === 0 || `${width} * ${height}`}
</span>
<span
className="absolute text-2xl bg-emerald-500 border-emerald-500 text-white bottom-0 p-0.5"
>
{x + y === 0 || `${x}, ${y}`}
</span>
</div>
);
}
export type PreviewPropsType = {
x: number;
y: number;
width: number;
height: number;
};

View File

View File

View File

@@ -0,0 +1,9 @@
/**
* 计算最接近的 x 的倍数
* @param n 输入的数字
* @param [x=16] 推荐使用偶数
* @returns 最近的 x 的倍数
*/
export function nearestMultiple(n: number, x: number = 18): number {
return Math.floor((n + x/2) / x) * x;
}

View File

@@ -0,0 +1,43 @@
import React from "react";
import { useDraggable } from "@dnd-kit/core";
export function Draggable(props) {
const { id } = props;
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id,
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
opacity: 0.6,
}
: undefined;
const className = transform ? "z-30 shadow-xl relative w-min " : "z-10 relative w-min";
return (
<div className={className} ref={setNodeRef} style={style} {...attributes}>
<button
className="btn absolute top-1 right-1 z-20 btn-square btn-soft"
{...listeners}
onMouseDown={(e) => e.stopPropagation()} // 阻止事件冒泡
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 9h16.5m-16.5 6.75h16.5"
/>
</svg>
</button>
{props.children}
</div>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import classnames from "classnames";
import { DndContext } from "@dnd-kit/core";
import { Droppable } from "./Droppable";
import PreviewStore from "@/stores/previewStore";
import ComponentStore from '@/stores/componentStore';
import Preview from "./Draggable/Preview";
import { useObserver } from "mobx-react-lite";
import { nearestMultiple } from "./Draggable/utils";
import { ReactElement } from "react";
export default function DraggablePanel(props: DraggablePanelType) {
const { draggable, wdith = "full", height = "full", children } = props;
const draggablePanelClass = classnames(
`relative h-${height} w-${wdith}`,
{
"h-full": height === "full" || height === "100%",
"w-full": wdith === "full" || wdith === "100%",
"base-200": draggable, // 当 active 为 true 时添加
"base-100": !draggable, // 当 disabled 为 true 时添加
},
);
return useObserver(() => (
<DndContext
onDragMove={(event) => {
const node = event?.activatorEvent?.target?.parentNode;
const rect = node.getBoundingClientRect();
const { width, height } = rect;
const x = nearestMultiple(node.offsetLeft + event.delta.x);
const y = nearestMultiple(node.offsetTop + event.delta.y);
PreviewStore.changePreviewX(x);
PreviewStore.changePreviewY(y);
PreviewStore.changePreviewWidth(nearestMultiple(width));
PreviewStore.changePreviewHeight(nearestMultiple(height));
}}
onDragEnd={(event) => {
const node = event?.activatorEvent?.target?.parentNode;
const rect = node.getBoundingClientRect();
const { width, height } = rect;
const x = nearestMultiple(node.offsetLeft + event.delta.x);
const y = nearestMultiple(node.offsetTop + event.delta.y);
PreviewStore.clearPreview();
ComponentStore.changeComponent({
id: event?.active?.data?.current?.id,
widgetsId: event?.active?.data?.current?.widgetsId,
x,
y,
width: nearestMultiple(width),
height: nearestMultiple(height),
});
}}>
<Droppable>
<div className={draggablePanelClass}>
<Preview x={PreviewStore.x} y={PreviewStore.y} width={PreviewStore.width} height={PreviewStore.height} />
{children}
</div>
</Droppable>
</DndContext>
));
}
export type DraggablePanelType = {
draggable: boolean;
wdith?: number | string;
height?: number | string;
children: any;
// grid: boolean;
};

View File

@@ -0,0 +1,124 @@
"use client";
import { ReactElement, useEffect, useRef, useState } from "react";
import classnames from "classnames";
import _ from "lodash";
import { Draggable } from "./Draggable";
export default function DraggableWidget(props: DraggableWidgetType) {
const { id, draggable, children, x, y, w, h } = props;
const targetRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ width: 16, height: 16 });
const [isResize, setIsResize] = useState(false);
useEffect(() => {
const element = targetRef.current;
if (!element) return;
// 创建 ResizeObserver 实例
let timer: any;
const observer = new ResizeObserver(
_.throttle((entries: any) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
setSize({
width: nearestMultipleOf16(width),
height: nearestMultipleOf16(height),
});
if (timer) {
clearTimeout(timer);
}
setIsResize(true);
timer = setTimeout(() => {
entry.target.style.width = nearestMultipleOf16(width) + "px";
entry.target.style.height = nearestMultipleOf16(height) + "px";
// syncSize(entry.contentRect, containerRef.current);
setIsResize(false);
}, 150);
}
}, 30),
);
const syncSize = (contentRect, containerNode) => {
const { x, y, height, width } = contentRect;
const area = {
rowStart: x / 16 + 1,
columnStart: y / 16 + 1,
rowEnd: width / 16 + x / 16 + 1,
columnEnd: height / 16 + y / 16 + 1,
};
containerNode.style.gridArea = `${area.rowStart} / ${area.columnStart} / ${area.rowEnd} / ${area.columnEnd}`;
};
// 开始观察元素
observer.observe(element);
// 组件卸载时断开连接
return () => {
observer.disconnect();
};
}, []); // 空依赖数组确保只运行一次
const draggableWidgetClass = classnames(
"block w-full h-full max-h-[1048px] max-w-[1888px] cursor-pointer overflow-hidden",
{
"hover:bg-red-200 hover:outline-2 hover:outline-dashed hover:outline-gray-300 hover:opacity-30 resize":
draggable,
},
);
const draggableWidgetIndicatorBoxClass = classnames(
"absolute top-0 left-0 outline-2 outline-dashed outline-red-500 z-50 pointer-events-none",
{
invisible: !isResize,
},
);
return (
<Draggable id={id}>
<div
className="relative w-min"
ref={containerRef}
style={{
width: w,
height: h,
top: y,
left: x
}}
>
<div className={draggableWidgetClass} ref={targetRef}>
{children}
</div>
<div
className={draggableWidgetIndicatorBoxClass}
style={{
width: size.width,
height: size.height,
}}
>{`${size.width}x${size.height}`}</div>
</div>
</Draggable>
);
}
export type DraggableWidgetType = {
id: string;
draggable: boolean;
wdith?: number | string;
height?: number | string;
children: ReactElement;
x: number;
y: number;
w: number;
h: number;
};
/**
* 计算最接近的 16 的倍数
* @param n 输入的数字
* @returns 最近的 16 的倍数
*/
function nearestMultipleOf16(n: number): number {
return Math.floor((n + 8) / 16) * 16;
}

18
components/Droppable.tsx Normal file
View File

@@ -0,0 +1,18 @@
import React from 'react';
import {useDroppable} from '@dnd-kit/core';
export function Droppable(props) {
const {isOver, setNodeRef} = useDroppable({
id: 'droppable',
});
const style = {
color: isOver ? 'green' : undefined,
};
return (
<div ref={setNodeRef} style={style}>
{props.children}
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { useState, useCallback, useEffect } from 'react';
import dynamic from 'next/dynamic';
export interface WidgetModule {
id: string;
name: string;
version: string;
component: ReturnType<typeof dynamic>;
}
export const useDynamicWidgets = (): {
widgets: WidgetModule[];
refreshWidgets: () => Promise<void>;
} => {
const [widgets, setWidgets] = useState<WidgetModule[]>([]);
const loadWidgets = useCallback(async () => {
try {
// Webpack 的 require.context 匹配 widgets 目录
const context = require.context('../widgets', true, /\/index\.tsx$/);
console.log("[DEBUG] Matched files:", context.keys());
const modules = await Promise.all(
context.keys().map(async (key) => {
const folderName = key.split('/')[1];
// 动态导入元数据
console.log("[DEBUG] import files:", `../widgets/${folderName}/index.tsx`);
const meta = await import(`../widgets/${folderName}/index.tsx`);
// 创建动态组件(单独导入确保 Tree Shaking
const Component = dynamic(
() => import(`../widgets/${folderName}/index.tsx`),
{ ssr: false }
);
return {
id: meta.id,
name: meta.name,
version: meta.version,
component: Component
};
})
);
console.log(modules);
setWidgets(modules);
} catch (error) {
console.error('Failed to load widgets:', error);
setWidgets([]);
}
}, []);
// 初始化加载
useEffect(() => {
loadWidgets();
}, [loadWidgets]);
// 暴露刷新方法
const refreshWidgets = useCallback(async () => {
await loadWidgets();
}, [loadWidgets]);
return { widgets, refreshWidgets };
};

110
hooks/useWidgets.tsx Normal file
View File

@@ -0,0 +1,110 @@
"use client";
import { useState, useCallback, useEffect, ReactNode } from 'react';
import dynamic from 'next/dynamic';
import { DraggablePropsType } from '@/components/Draggable/Draggable';
import * as Logo from '@/widgets/Logo/index';
import * as Text from '@/widgets/Text/index';
import * as Image from '@/widgets/Image/index';
export interface WidgetModule {
id: string;
name: string;
version: string;
defaultConfig: (data: Record<string, unknown>) => Record<string, unknown>
}
export type widgetsLibraryType = Record<string, (data?:DraggablePropsType['data']) => ReactNode>;
const widgetPaths = [
'@/widgets/Logo/index',
'@/widgets/Text/index',
'@/widgets/Image/index',
];
export const useWidgets = (): {
widgets: WidgetModule[];
widgetsLibrary: widgetsLibraryType;
refreshWidgets: () => Promise<void>;
} => {
const [widgets, setWidgets] = useState<WidgetModule[]>([
{
id: 'logo',
name: 'Logo',
version: '1.0.0',
defaultConfig: Logo.defaultConfig
},
{
id: 'text',
name: '文本',
version: '1.0.0',
defaultConfig: Text.defaultConfig
},
{
id: 'image',
name: '图片',
version: '1.0.0',
defaultConfig: Image.defaultConfig
},
]);
const [widgetsLibrary, setWidgetsLibrary] = useState<widgetsLibraryType>({
'logo': (data) => {
const { default:Component, defaultConfig } = Logo;
return <Component {...defaultConfig(data)} />
},
'text': (data) => {
const { default:Component, defaultConfig } = Text;
return <Component data={defaultConfig(data)} />
},
'image': (data) => {
const { default:Component, defaultConfig } = Image;
return <Component data={defaultConfig(data)} />
}
});
const loadWidgets = useCallback(async () => {
try {
// Webpack 的 require.context 匹配 widgets 目录
console.log(window);
// const modules = await Promise.all(
// widgetPaths.map(async (key) => {
// console.log(key);
// debugger
// const meta = await require(key);
// // 创建动态组件(单独导入确保 Tree Shaking
// // const Component = dynamic(
// // () => import(key),
// // { ssr: false }
// // );
// return {
// id: meta.id,
// name: meta.name,
// version: meta.version,
// // component: Component
// };
// })
// );
// console.log(modules);
// setWidgets(modules);
} catch (error) {
console.error('Failed to load widgets:', error);
setWidgets([]);
}
}, []);
// 初始化加载
// useEffect(() => {
// loadWidgets();
// }, [loadWidgets]);
// 暴露刷新方法
const refreshWidgets = useCallback(async () => {
await loadWidgets();
}, [loadWidgets]);
return { widgets, widgetsLibrary, refreshWidgets };
};

View File

@@ -1,7 +1,17 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
// next.config.js
const nextConfig:NextConfig = {
// 移除自定义 webpack 配置Next.js 已内置 TypeScript 支持
// 保留以下配置即可
experimental: {
externalDir: true, // 如果需要引用外部目录
},
webpack: (config) => {
// 保留其他必要配置
return config;
},
};
export default nextConfig;

View File

@@ -9,19 +9,30 @@
"lint": "next lint"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@heroicons/react": "^2.2.0",
"classnames": "^2.5.1",
"lodash": "^4.17.21",
"mobx": "^6.13.7",
"mobx-react-lite": "^4.1.0",
"next": "15.2.3",
"next-auth": "5.0.0-beta.25",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next": "15.2.3"
"react-dom": "^19.0.0"
},
"devDependencies": {
"typescript": "^5",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4.0.14",
"@types/lodash": "^4.17.16",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"daisyui": "^5.0.6",
"eslint": "^9",
"eslint-config-next": "15.2.3",
"@eslint/eslintrc": "^3"
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.14",
"typescript": "^5"
}
}

293
pnpm-lock.yaml generated
View File

@@ -8,9 +8,30 @@ importers:
.:
dependencies:
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@heroicons/react':
specifier: ^2.2.0
version: 2.2.0(react@19.0.0)
classnames:
specifier: ^2.5.1
version: 2.5.1
lodash:
specifier: ^4.17.21
version: 4.17.21
mobx:
specifier: ^6.13.7
version: 6.13.7
mobx-react-lite:
specifier: ^4.1.0
version: 4.1.0(mobx@6.13.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next:
specifier: 15.2.3
version: 15.2.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next-auth:
specifier: 5.0.0-beta.25
version: 5.0.0-beta.25(next@15.2.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
react:
specifier: ^19.0.0
version: 19.0.0
@@ -22,8 +43,11 @@ importers:
specifier: ^3
version: 3.3.0
'@tailwindcss/postcss':
specifier: ^4
specifier: ^4.0.14
version: 4.0.14
'@types/lodash':
specifier: ^4.17.16
version: 4.17.16
'@types/node':
specifier: ^20
version: 20.17.24
@@ -33,14 +57,23 @@ importers:
'@types/react-dom':
specifier: ^19
version: 19.0.4(@types/react@19.0.11)
daisyui:
specifier: ^5.0.6
version: 5.0.6
eslint:
specifier: ^9
version: 9.22.0(jiti@2.4.2)
eslint-config-next:
specifier: 15.2.3
version: 15.2.3(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)
prettier:
specifier: ^3.5.3
version: 3.5.3
prettier-plugin-tailwindcss:
specifier: ^0.6.11
version: 0.6.11(prettier@3.5.3)
tailwindcss:
specifier: ^4
specifier: ^4.0.14
version: 4.0.14
typescript:
specifier: ^5
@@ -52,6 +85,36 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
'@auth/core@0.37.2':
resolution: {integrity: sha512-kUvzyvkcd6h1vpeMAojK2y7+PAV5H+0Cc9+ZlKYDFhDY31AlvsB+GW5vNO4qE3Y07KeQgvNO9U0QUx/fN62kBw==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
nodemailer: ^6.8.0
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@emnapi/core@1.3.1':
resolution: {integrity: sha512-pVGjBIt1Y6gg3EJN8jTcfpP/+uuRksIo055oE/OBkDNcjZqVbfkWCksG1Jp4yZnj3iKWyWX8fdG/j6UDYPbFog==}
@@ -99,6 +162,11 @@ packages:
resolution: {integrity: sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@heroicons/react@2.2.0':
resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==}
peerDependencies:
react: '>= 16 || ^19.0.0-rc'
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -297,6 +365,9 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@panva/hkdf@1.2.1':
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@@ -388,6 +459,9 @@ packages:
'@tybys/wasm-util@0.9.0':
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
'@types/estree@1.0.6':
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
@@ -397,6 +471,9 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/lodash@4.17.16':
resolution: {integrity: sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==}
'@types/node@20.17.24':
resolution: {integrity: sha512-d7fGCyB96w9BnWQrOsJtpyiSaBcAYYr75bnK6ZRjDbql2cGLj/3GsL5OYmLPNq76l7Gf2q4Rv9J2o6h5CrD9sA==}
@@ -625,6 +702,9 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
@@ -645,6 +725,10 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
cookie@0.7.1:
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
engines: {node: '>= 0.6'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -652,6 +736,9 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
daisyui@5.0.6:
resolution: {integrity: sha512-/e/9Gw/2y9oawBJlWkJMSEhRXdmfOLvcPl+6q/x2rPEdIVOtebs1t3ex2vwySl9vCRs1GGNBKCiL+P60Ps/wUw==}
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -1127,6 +1214,9 @@ packages:
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
hasBin: true
jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1236,6 +1326,9 @@ packages:
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@@ -1262,6 +1355,22 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
mobx-react-lite@4.1.0:
resolution: {integrity: sha512-QEP10dpHHBeQNv1pks3WnHRCem2Zp636lq54M2nKO2Sarr13pL4u6diQXf65yzXUn0mkk18SyIDCm9UOJYTi1w==}
peerDependencies:
mobx: ^6.9.0
react: ^16.8.0 || ^17 || ^18 || ^19
react-dom: '*'
react-native: '*'
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
mobx@6.13.7:
resolution: {integrity: sha512-aChaVU/DO5aRPmk1GX8L+whocagUUpBQqoPtJk+cm7UOXUk87J4PeWCh6nNmTTIfEhiR9DI/+FnA8dln/hTK7g==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1273,6 +1382,22 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
next-auth@5.0.0-beta.25:
resolution: {integrity: sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
next: ^14.0.0-0 || ^15.0.0-0
nodemailer: ^6.6.5
react: ^18.2.0 || ^19.0.0-0
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
next@15.2.3:
resolution: {integrity: sha512-x6eDkZxk2rPpu46E1ZVUWIBhYCLszmUY6fvHBFcbzJ9dD+qRX6vcHusaqqDlnY+VngKzKbAiG2iRCkPbmi8f7w==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
@@ -1294,6 +1419,9 @@ packages:
sass:
optional: true
oauth4webapi@3.3.1:
resolution: {integrity: sha512-ZwX7UqYrP3Lr+Glhca3a1/nF2jqf7VVyJfhGuW5JtrfDUxt0u+IoBPzFjZ2dd7PJGkdM6CFPVVYzuDYKHv101A==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -1380,10 +1508,81 @@ packages:
resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
engines: {node: ^10 || ^12 || >=14}
preact-render-to-string@5.2.3:
resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==}
peerDependencies:
preact: '>=10'
preact@10.11.3:
resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier-plugin-tailwindcss@0.6.11:
resolution: {integrity: sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==}
engines: {node: '>=14.21.3'}
peerDependencies:
'@ianvs/prettier-plugin-sort-imports': '*'
'@prettier/plugin-pug': '*'
'@shopify/prettier-plugin-liquid': '*'
'@trivago/prettier-plugin-sort-imports': '*'
'@zackad/prettier-plugin-twig': '*'
prettier: ^3.0
prettier-plugin-astro: '*'
prettier-plugin-css-order: '*'
prettier-plugin-import-sort: '*'
prettier-plugin-jsdoc: '*'
prettier-plugin-marko: '*'
prettier-plugin-multiline-arrays: '*'
prettier-plugin-organize-attributes: '*'
prettier-plugin-organize-imports: '*'
prettier-plugin-sort-imports: '*'
prettier-plugin-style-order: '*'
prettier-plugin-svelte: '*'
peerDependenciesMeta:
'@ianvs/prettier-plugin-sort-imports':
optional: true
'@prettier/plugin-pug':
optional: true
'@shopify/prettier-plugin-liquid':
optional: true
'@trivago/prettier-plugin-sort-imports':
optional: true
'@zackad/prettier-plugin-twig':
optional: true
prettier-plugin-astro:
optional: true
prettier-plugin-css-order:
optional: true
prettier-plugin-import-sort:
optional: true
prettier-plugin-jsdoc:
optional: true
prettier-plugin-marko:
optional: true
prettier-plugin-multiline-arrays:
optional: true
prettier-plugin-organize-attributes:
optional: true
prettier-plugin-organize-imports:
optional: true
prettier-plugin-sort-imports:
optional: true
prettier-plugin-style-order:
optional: true
prettier-plugin-svelte:
optional: true
prettier@3.5.3:
resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==}
engines: {node: '>=14'}
hasBin: true
pretty-format@3.8.0:
resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -1632,6 +1831,11 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-sync-external-store@1.4.0:
resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -1665,6 +1869,34 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@auth/core@0.37.2':
dependencies:
'@panva/hkdf': 1.2.1
'@types/cookie': 0.6.0
cookie: 0.7.1
jose: 5.10.0
oauth4webapi: 3.3.1
preact: 10.11.3
preact-render-to-string: 5.2.3(preact@10.11.3)
'@dnd-kit/accessibility@3.1.1(react@19.0.0)':
dependencies:
react: 19.0.0
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.0.0)
'@dnd-kit/utilities': 3.2.2(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.0.0)':
dependencies:
react: 19.0.0
tslib: 2.8.1
'@emnapi/core@1.3.1':
dependencies:
'@emnapi/wasi-threads': 1.0.1
@@ -1725,6 +1957,10 @@ snapshots:
'@eslint/core': 0.12.0
levn: 0.4.1
'@heroicons/react@2.2.0(react@19.0.0)':
dependencies:
react: 19.0.0
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.6':
@@ -1864,6 +2100,8 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@panva/hkdf@1.2.1': {}
'@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.11.0': {}
@@ -1941,12 +2179,16 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/cookie@0.6.0': {}
'@types/estree@1.0.6': {}
'@types/json-schema@7.0.15': {}
'@types/json5@0.0.29': {}
'@types/lodash@4.17.16': {}
'@types/node@20.17.24':
dependencies:
undici-types: 6.19.8
@@ -2214,6 +2456,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
classnames@2.5.1: {}
client-only@0.0.1: {}
color-convert@2.0.1:
@@ -2236,6 +2480,8 @@ snapshots:
concat-map@0.0.1: {}
cookie@0.7.1: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -2244,6 +2490,8 @@ snapshots:
csstype@3.1.3: {}
daisyui@5.0.6: {}
damerau-levenshtein@1.0.8: {}
data-view-buffer@1.0.2:
@@ -2882,6 +3130,8 @@ snapshots:
jiti@2.4.2: {}
jose@5.10.0: {}
js-tokens@4.0.0: {}
js-yaml@4.1.0:
@@ -2971,6 +3221,8 @@ snapshots:
lodash.merge@4.6.2: {}
lodash@4.17.21: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@@ -2994,12 +3246,28 @@ snapshots:
minimist@1.2.8: {}
mobx-react-lite@4.1.0(mobx@6.13.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
mobx: 6.13.7
react: 19.0.0
use-sync-external-store: 1.4.0(react@19.0.0)
optionalDependencies:
react-dom: 19.0.0(react@19.0.0)
mobx@6.13.7: {}
ms@2.1.3: {}
nanoid@3.3.10: {}
natural-compare@1.4.0: {}
next-auth@5.0.0-beta.25(next@15.2.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0):
dependencies:
'@auth/core': 0.37.2
next: 15.2.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
next@15.2.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@next/env': 15.2.3
@@ -3025,6 +3293,8 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
oauth4webapi@3.3.1: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -3120,8 +3390,23 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
preact-render-to-string@5.2.3(preact@10.11.3):
dependencies:
preact: 10.11.3
pretty-format: 3.8.0
preact@10.11.3: {}
prelude-ls@1.2.1: {}
prettier-plugin-tailwindcss@0.6.11(prettier@3.5.3):
dependencies:
prettier: 3.5.3
prettier@3.5.3: {}
pretty-format@3.8.0: {}
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -3459,6 +3744,10 @@ snapshots:
dependencies:
punycode: 2.3.1
use-sync-external-store@1.4.0(react@19.0.0):
dependencies:
react: 19.0.0
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0

69
stores/componentStore.ts Normal file
View File

@@ -0,0 +1,69 @@
import { DraggablePropsType } from "@/components/Draggable/Draggable";
import { makeAutoObservable, reaction } from "mobx";
import { debounce } from 'lodash';
class ComponentsStore {
components: DraggablePropsType[] = [];
constructor() {
makeAutoObservable(this);
this.loadFromLocalStorage(); // 初始化加载
// 监听 components 变化自动保存(防抖版)
reaction(
() => this.components.slice(), // 深度监听变化
debounce(() => this.saveToLocalStorage(), 500)
);
}
// 初始化时从本地存储加载
private loadFromLocalStorage() {
if (window === undefined) {
return
}
const data = localStorage.getItem("componentsStore");
if (data) {
try {
this.components = JSON.parse(data);
} catch (e) {
console.error("Failed to parse stored components", e);
}
}
}
// 防抖保存到本地存储
private saveToLocalStorage = debounce(() => {
localStorage.setItem("componentsStore", JSON.stringify(this.components));
}, 100);
// 以下方法会触发自动保存
initComponent(componentsList: DraggablePropsType[]) {
this.components = componentsList || [];
}
changeComponent(updatedComponent: DraggablePropsType) {
this.components = this.components.map(item =>
item.id === updatedComponent.id ? updatedComponent : item
);
}
deleteComponent(targetComponent: DraggablePropsType) {
this.components = this.components.filter(
item => item.id !== targetComponent.id
);
}
addComponent(widgetsId: string, data:Record<string, unknown>) {
this.components.push({
id: String(this.components.length),
x: 0,
y: 0,
width: 0,
height: 0,
widgetsId,
data,
})
}
}
export default new ComponentsStore();

17
stores/counterStore.ts Normal file
View File

@@ -0,0 +1,17 @@
import { makeAutoObservable } from "mobx";
export class CounterStore {
count = 0;
constructor() {
makeAutoObservable(this);
}
increment() {
this.count += 1;
}
decrement() {
this.count -= 1;
}
}

43
stores/previewStore.ts Normal file
View File

@@ -0,0 +1,43 @@
import { DraggablePropsType } from "@/components/Draggable/Draggable";
import { makeAutoObservable } from "mobx";
class PreviewStore {
width: number = 0;
height: number = 0;
x: number = 0;
y: number = 0;
constructor() {
makeAutoObservable(this);
}
changePreview(x: number, y: number, width: number, height: number) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
changePreviewX(x: number) {
this.x = x;
}
changePreviewY(y: number) {
this.y = y;
}
changePreviewWidth(width: number) {
this.width = width;
}
changePreviewHeight(height: number) {
this.height = height;
}
clearPreview() {
this.width = 0;
this.height = 0;
this.x = 0;
this.y = 0;
}
}
export default new PreviewStore();

29
stores/storeContext.tsx Normal file
View File

@@ -0,0 +1,29 @@
"use client";
// stores/storeContext.ts
import { createContext, useContext } from "react";
import { CounterStore } from "./counterStore";
const StoreContext = createContext<{
counterStore: CounterStore;
}>(null!);
export const useStores = () => useContext(StoreContext);
export const initializeStores = (initialData = {}) => {
const counterStore = new CounterStore();
// 服务端预取数据注入
if (initialData?.counterStore) {
counterStore.count = initialData.counterStore.count;
}
return { counterStore };
};
export const StoreProvider = ({ children, initialData }) => {
const stores = initializeStores(initialData);
const { Provider } = StoreContext;
return (
<StoreContext.Provider value={stores}>{children}</StoreContext.Provider>
);
};

7
types/plugin.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
export interface CardPlugin {
id: string; // 唯一标识
displayName: string; // 显示名称
component: React.ComponentType<{ config?: any }>; // 卡片组件
configSchema?: any; // 配置的JSON Schema用于后台配置界面
}

37
widgets/Image/index.tsx Normal file
View File

@@ -0,0 +1,37 @@
"use client";
import "@/app/globals.css";
import { DraggablePropsType } from "@/components/Draggable/Draggable";
// import Image from "next/Image";
import { CSSProperties } from "react";
export const id = "image";
export const name = "Image";
export const version = "1.0.0";
export const defaultConfig = (config: ImageData) => {
return {
src: 'https://www.todaybing.com/api/today',
...config,
}
}
type ImageData = {
src: string;
customStyles?: CSSProperties; // 自定义CSS扩展
};
interface ResponsiveImageProps {
data: DraggablePropsType["data"] & ImageData;
className?: string; // 外部容器类名
}
const WidgetsImage: React.FC<ResponsiveImageProps> = ({
data,
className
}) => {
return (
<img className="w-full h-full object-cover" src={data.src} style={data.customStyles} />
);
}
export default WidgetsImage;

21
widgets/Logo/index.tsx Normal file
View File

@@ -0,0 +1,21 @@
"use client";
import "@/app/globals.css";
import { DraggablePropsType } from "@/components/Draggable/Draggable";
export const id = "logo";
export const name = "Logo";
export const version = "1.0.0";
export const defaultConfig = (config: DraggablePropsType['data']) => {
return {
...config
}
}
export default function WidgetsLogo() {
return (
<div className="flex h-full w-full items-center justify-center text-5xl bg-base-200 font-bold">
<span className="whitespace-nowrap text-primary">
NEXUSHUB
</span>
</div>
);
}

86
widgets/Text/index.tsx Normal file
View File

@@ -0,0 +1,86 @@
import React, { CSSProperties } from 'react';
import { DraggablePropsType } from '../../components/Draggable/Draggable';
export const id = "text";
export const name = "文字";
export const version = "1.0.0";
export const defaultConfig = (config: FontData) => {
return {
textAlign: 'center',
content: '默认文本',
...config,
}
}
type FontData = {
content: string;
fontSize?: number; // 字体大小px
fontFamily?: string; // 字体类型
color?: string; // 字体颜色
fontWeight?: number; // 字重
lineHeight?: number; // 行高比例
textAlign?: 'left' | 'center' | 'right'; // 对齐方式
letterSpacing?: number; // 字间距px
customStyles?: CSSProperties; // 自定义CSS扩展
};
interface ResponsiveTextProps {
data: DraggablePropsType["data"] & FontData;
className?: string; // 外部容器类名
}
const WidgetsText: React.FC<ResponsiveTextProps> = ({
data,
className
}) => {
// 合并默认样式与传入样式
const textStyles: CSSProperties = {
fontSize: `${data.fontSize || 16}px`,
fontFamily: data.fontFamily || 'Arial, sans-serif',
color: data.color || '#333',
fontWeight: data.fontWeight || 400,
lineHeight: data.lineHeight ? `${data.lineHeight}em` : '1.5',
textAlign: data.textAlign || 'left',
letterSpacing: `${data.letterSpacing || 0}px`,
width: '100%',
height: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'flex',
alignItems: 'center',
justifyContent: data.textAlign === 'center' ? 'center' :
data.textAlign === 'right' ? 'flex-end' : 'flex-start',
...data.customStyles
};
return (
<div
className={className}
style={{
position: 'relative',
width: '100%',
height: '100%',
minWidth: '50px', // 防止容器坍缩
minHeight: '1em' // 最小高度保障
}}
>
<div
style={{
...textStyles,
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
padding: '0.2em' // 防止文本贴边
}}
title={data.content} // 添加tooltip
>
{data.content}
</div>
</div>
);
};
export default WidgetsText;