mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-05-17 00:38:01 +08:00
webui ui-kit: add global reusable modal/confirm/alert and loading overlay
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
import { AppProvider } from './context/AppContext';
|
import { AppProvider } from './context/AppContext';
|
||||||
|
import { UIProvider } from './context/UIContext';
|
||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import Chat from './pages/Chat';
|
import Chat from './pages/Chat';
|
||||||
@@ -14,20 +15,22 @@ import Memory from './pages/Memory';
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<AppProvider>
|
<AppProvider>
|
||||||
<BrowserRouter basename="/webui">
|
<UIProvider>
|
||||||
<Routes>
|
<BrowserRouter basename="/webui">
|
||||||
<Route path="/" element={<Layout />}>
|
<Routes>
|
||||||
<Route index element={<Dashboard />} />
|
<Route path="/" element={<Layout />}>
|
||||||
<Route path="chat" element={<Chat />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="logs" element={<Logs />} />
|
<Route path="chat" element={<Chat />} />
|
||||||
<Route path="skills" element={<Skills />} />
|
<Route path="logs" element={<Logs />} />
|
||||||
<Route path="config" element={<Config />} />
|
<Route path="skills" element={<Skills />} />
|
||||||
<Route path="cron" element={<Cron />} />
|
<Route path="config" element={<Config />} />
|
||||||
<Route path="nodes" element={<Nodes />} />
|
<Route path="cron" element={<Cron />} />
|
||||||
<Route path="memory" element={<Memory />} />
|
<Route path="nodes" element={<Nodes />} />
|
||||||
</Route>
|
<Route path="memory" element={<Memory />} />
|
||||||
</Routes>
|
</Route>
|
||||||
</BrowserRouter>
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</UIProvider>
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
117
webui/src/context/UIContext.tsx
Normal file
117
webui/src/context/UIContext.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import React, { createContext, useContext, useMemo, useState } from 'react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
|
||||||
|
type ModalOptions = {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UIContextType = {
|
||||||
|
loading: boolean;
|
||||||
|
showLoading: (text?: string) => void;
|
||||||
|
hideLoading: () => void;
|
||||||
|
alert: (opts: ModalOptions | string) => Promise<void>;
|
||||||
|
confirm: (opts: ModalOptions | string) => Promise<boolean>;
|
||||||
|
openModal: (node: React.ReactNode, title?: string) => void;
|
||||||
|
closeModal: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UIContext = createContext<UIContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const UIProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingText, setLoadingText] = useState('Loading...');
|
||||||
|
const [dialog, setDialog] = useState<null | { kind: 'alert' | 'confirm'; options: ModalOptions; resolve: (v: any) => void }>(null);
|
||||||
|
const [customModal, setCustomModal] = useState<null | { title?: string; node: React.ReactNode }>(null);
|
||||||
|
|
||||||
|
const value = useMemo<UIContextType>(() => ({
|
||||||
|
loading,
|
||||||
|
showLoading: (text?: string) => {
|
||||||
|
setLoadingText(text || 'Loading...');
|
||||||
|
setLoading(true);
|
||||||
|
},
|
||||||
|
hideLoading: () => setLoading(false),
|
||||||
|
alert: (opts) => new Promise<void>((resolve) => {
|
||||||
|
const options = typeof opts === 'string' ? { message: opts } : opts;
|
||||||
|
setDialog({ kind: 'alert', options, resolve });
|
||||||
|
}),
|
||||||
|
confirm: (opts) => new Promise<boolean>((resolve) => {
|
||||||
|
const options = typeof opts === 'string' ? { message: opts } : opts;
|
||||||
|
setDialog({ kind: 'confirm', options, resolve });
|
||||||
|
}),
|
||||||
|
openModal: (node, title) => setCustomModal({ node, title }),
|
||||||
|
closeModal: () => setCustomModal(null),
|
||||||
|
}), [loading]);
|
||||||
|
|
||||||
|
const closeDialog = (result: boolean) => {
|
||||||
|
if (!dialog) return;
|
||||||
|
dialog.resolve(dialog.kind === 'alert' ? undefined : result);
|
||||||
|
setDialog(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UIContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{loading && (
|
||||||
|
<motion.div className="fixed inset-0 z-[120] bg-black/55 backdrop-blur-sm flex items-center justify-center"
|
||||||
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
|
||||||
|
<div className="px-6 py-5 rounded-2xl border border-zinc-700 bg-zinc-900/95 shadow-2xl min-w-[240px] text-center">
|
||||||
|
<div className="mx-auto mb-3 h-8 w-8 border-2 border-zinc-600 border-t-indigo-400 rounded-full animate-spin" />
|
||||||
|
<div className="text-sm text-zinc-200">{loadingText}</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{dialog && (
|
||||||
|
<motion.div className="fixed inset-0 z-[130] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
|
||||||
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
|
||||||
|
<motion.div className="w-full max-w-md rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl"
|
||||||
|
initial={{ scale: 0.95, y: 8 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.95, y: 8 }}>
|
||||||
|
<div className="px-5 py-4 border-b border-zinc-800">
|
||||||
|
<h3 className="text-sm font-semibold text-zinc-100">{dialog.options.title || (dialog.kind === 'confirm' ? 'Please confirm' : 'Notice')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 text-sm text-zinc-300 whitespace-pre-wrap">{dialog.options.message}</div>
|
||||||
|
<div className="px-5 pb-5 flex items-center justify-end gap-2">
|
||||||
|
{dialog.kind === 'confirm' && (
|
||||||
|
<button onClick={() => closeDialog(false)} className="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-sm">{dialog.options.cancelText || 'Cancel'}</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => closeDialog(true)} className={`px-3 py-1.5 rounded-lg text-sm ${dialog.options.danger ? 'bg-red-600 hover:bg-red-500 text-white' : 'bg-indigo-600 hover:bg-indigo-500 text-white'}`}>
|
||||||
|
{dialog.options.confirmText || 'OK'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{customModal && (
|
||||||
|
<motion.div className="fixed inset-0 z-[125] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
|
||||||
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
|
||||||
|
<motion.div className="w-full max-w-4xl rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl overflow-hidden"
|
||||||
|
initial={{ scale: 0.96 }} animate={{ scale: 1 }} exit={{ scale: 0.96 }}>
|
||||||
|
<div className="px-5 py-3 border-b border-zinc-800 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-zinc-100">{customModal.title || 'Modal'}</h3>
|
||||||
|
<button onClick={() => setCustomModal(null)} className="text-zinc-400 hover:text-zinc-200">✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 max-h-[80vh] overflow-auto">{customModal.node}</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</UIContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUI = () => {
|
||||||
|
const ctx = useContext(UIContext);
|
||||||
|
if (!ctx) throw new Error('useUI must be used within UIProvider');
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { Plus, RefreshCw, Trash2, Edit2, Zap, X, FileText, Save } from 'lucide-r
|
|||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAppContext } from '../context/AppContext';
|
import { useAppContext } from '../context/AppContext';
|
||||||
|
import { useUI } from '../context/UIContext';
|
||||||
import { Skill } from '../types';
|
import { Skill } from '../types';
|
||||||
|
|
||||||
const initialSkillForm: Omit<Skill, 'id'> = {
|
const initialSkillForm: Omit<Skill, 'id'> = {
|
||||||
@@ -15,6 +16,7 @@ const initialSkillForm: Omit<Skill, 'id'> = {
|
|||||||
const Skills: React.FC = () => {
|
const Skills: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { skills, refreshSkills, q } = useAppContext();
|
const { skills, refreshSkills, q } = useAppContext();
|
||||||
|
const ui = useUI();
|
||||||
const [installName, setInstallName] = useState('');
|
const [installName, setInstallName] = useState('');
|
||||||
const qp = (k: string, v: string) => `${q}${q ? '&' : '?'}${k}=${encodeURIComponent(v)}`;
|
const qp = (k: string, v: string) => `${q}${q ? '&' : '?'}${k}=${encodeURIComponent(v)}`;
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
@@ -28,7 +30,7 @@ const Skills: React.FC = () => {
|
|||||||
const [fileContent, setFileContent] = useState('');
|
const [fileContent, setFileContent] = useState('');
|
||||||
|
|
||||||
async function deleteSkill(id: string) {
|
async function deleteSkill(id: string) {
|
||||||
if (!confirm('Are you sure you want to delete this skill?')) return;
|
if (!await ui.confirm({ title: 'Delete Skill', message: 'Are you sure you want to delete this skill?', danger: true, confirmText: 'Delete' })) return;
|
||||||
try {
|
try {
|
||||||
await fetch(`/webui/api/skills${qp('id', id)}`, { method: 'DELETE' });
|
await fetch(`/webui/api/skills${qp('id', id)}`, { method: 'DELETE' });
|
||||||
await refreshSkills();
|
await refreshSkills();
|
||||||
@@ -46,7 +48,7 @@ const Skills: React.FC = () => {
|
|||||||
body: JSON.stringify({ action: 'install', name }),
|
body: JSON.stringify({ action: 'install', name }),
|
||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
alert(await r.text());
|
await ui.alert({ title: 'Request Failed', message: await r.text() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setInstallName('');
|
setInstallName('');
|
||||||
@@ -66,10 +68,10 @@ const Skills: React.FC = () => {
|
|||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
await refreshSkills();
|
await refreshSkills();
|
||||||
} else {
|
} else {
|
||||||
alert(await r.text());
|
await ui.alert({ title: 'Request Failed', message: await r.text() });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(String(e));
|
await ui.alert({ title: 'Error', message: String(e) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +80,7 @@ const Skills: React.FC = () => {
|
|||||||
setIsFileModalOpen(true);
|
setIsFileModalOpen(true);
|
||||||
const r = await fetch(`/webui/api/skills${q ? `${q}&id=${encodeURIComponent(skillId)}&files=1` : `?id=${encodeURIComponent(skillId)}&files=1`}`);
|
const r = await fetch(`/webui/api/skills${q ? `${q}&id=${encodeURIComponent(skillId)}&files=1` : `?id=${encodeURIComponent(skillId)}&files=1`}`);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
alert(await r.text());
|
await ui.alert({ title: 'Request Failed', message: await r.text() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
@@ -96,7 +98,7 @@ const Skills: React.FC = () => {
|
|||||||
const url = `/webui/api/skills${q ? `${q}&id=${encodeURIComponent(skillId)}&file=${encodeURIComponent(file)}` : `?id=${encodeURIComponent(skillId)}&file=${encodeURIComponent(file)}`}`;
|
const url = `/webui/api/skills${q ? `${q}&id=${encodeURIComponent(skillId)}&file=${encodeURIComponent(file)}` : `?id=${encodeURIComponent(skillId)}&file=${encodeURIComponent(file)}`}`;
|
||||||
const r = await fetch(url);
|
const r = await fetch(url);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
alert(await r.text());
|
await ui.alert({ title: 'Request Failed', message: await r.text() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
@@ -112,10 +114,10 @@ const Skills: React.FC = () => {
|
|||||||
body: JSON.stringify({ action: 'write_file', id: activeSkill, file: activeFile, content: fileContent }),
|
body: JSON.stringify({ action: 'write_file', id: activeSkill, file: activeFile, content: fileContent }),
|
||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
alert(await r.text());
|
await ui.alert({ title: 'Request Failed', message: await r.text() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
alert('Saved');
|
await ui.alert({ title: 'Saved', message: 'Skill file saved successfully.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user