module federation
This commit is contained in:
36
web/src/App.tsx
Normal file
36
web/src/App.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Layout } from 'antd';
|
||||
import ShellHeader from './components/ShellHeader';
|
||||
import AppContainer from './components/AppContainer';
|
||||
import LoadKMS from './components/LoadKMS';
|
||||
import { useApp } from './contexts/AppContext';
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { setUser } = useApp();
|
||||
|
||||
useEffect(() => {
|
||||
setUser({
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin User',
|
||||
});
|
||||
}, [setUser]);
|
||||
|
||||
return (
|
||||
<Layout className="shell-layout">
|
||||
<LoadKMS />
|
||||
<ShellHeader />
|
||||
<Content className="shell-content">
|
||||
<Routes>
|
||||
<Route path="/kms/*" element={<AppContainer />} />
|
||||
<Route path="/" element={<Navigate to="/kms" replace />} />
|
||||
<Route path="*" element={<AppContainer />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
23
web/src/bootstrap.tsx
Normal file
23
web/src/bootstrap.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import App from './App';
|
||||
import { AppProvider } from './contexts/AppContext';
|
||||
import './index.css';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<ConfigProvider>
|
||||
<AppProvider>
|
||||
<App />
|
||||
</AppProvider>
|
||||
</ConfigProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
96
web/src/components/AppContainer.tsx
Normal file
96
web/src/components/AppContainer.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import React, { Suspense, useEffect } from 'react';
|
||||
import { Tabs, Spin } from 'antd';
|
||||
import { useApp } from '../contexts/AppContext';
|
||||
import { FederatedApp } from '../types';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
const AppContainer: React.FC = () => {
|
||||
const { apps, activeAppId, setActiveApp } = useApp();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Auto-detect active app based on current URL path
|
||||
useEffect(() => {
|
||||
if (apps.length > 0) {
|
||||
const currentPath = location.pathname;
|
||||
|
||||
// Find app that matches the current path
|
||||
const matchingApp = apps.find(app => currentPath.startsWith(app.path));
|
||||
|
||||
if (matchingApp && activeAppId !== matchingApp.id) {
|
||||
setActiveApp(matchingApp.id);
|
||||
} else if (!activeAppId && !matchingApp) {
|
||||
// Default to first app if no match
|
||||
setActiveApp(apps[0].id);
|
||||
navigate(apps[0].path, { replace: true });
|
||||
}
|
||||
}
|
||||
}, [location.pathname, apps, activeAppId, setActiveApp, navigate]);
|
||||
|
||||
if (apps.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
fontSize: '16px',
|
||||
color: '#666'
|
||||
}}>
|
||||
No applications available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
const selectedApp = apps.find(app => app.id === key);
|
||||
if (selectedApp) {
|
||||
setActiveApp(key);
|
||||
navigate(selectedApp.path);
|
||||
}
|
||||
};
|
||||
|
||||
const renderAppContent = (app: FederatedApp) => {
|
||||
const AppComponent = app.component;
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '400px'
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ height: '100%', overflow: 'auto' }}>
|
||||
<AppComponent />
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const tabItems = apps.map(app => ({
|
||||
key: app.id,
|
||||
label: app.name,
|
||||
children: renderAppContent(app)
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', padding: '0' }}>
|
||||
<Tabs
|
||||
activeKey={activeAppId || (apps.length > 0 ? apps[0].id : undefined)}
|
||||
onChange={handleTabChange}
|
||||
className="app-tabs"
|
||||
type="card"
|
||||
style={{ height: '100%' }}
|
||||
items={tabItems}
|
||||
tabBarStyle={{ margin: 0, padding: '0 16px' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppContainer;
|
||||
98
web/src/components/GlobalSearch.tsx
Normal file
98
web/src/components/GlobalSearch.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Input, Dropdown, Spin, Empty, Typography } from 'antd';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { useApp } from '../contexts/AppContext';
|
||||
import { SearchResult } from '../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const GlobalSearch: React.FC = () => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { searchResults, isSearching, performSearch, clearSearch } = useApp();
|
||||
|
||||
const handleSearch = useCallback(
|
||||
async (value: string) => {
|
||||
setQuery(value);
|
||||
if (value.trim()) {
|
||||
await performSearch(value);
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
clearSearch();
|
||||
setIsOpen(false);
|
||||
}
|
||||
},
|
||||
[performSearch, clearSearch]
|
||||
);
|
||||
|
||||
const handleResultClick = (result: SearchResult) => {
|
||||
if (result.action) {
|
||||
result.action();
|
||||
}
|
||||
setIsOpen(false);
|
||||
setQuery('');
|
||||
clearSearch();
|
||||
};
|
||||
|
||||
const searchContent = (
|
||||
<div className="search-dropdown" style={{ minWidth: 400 }}>
|
||||
{isSearching ? (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : searchResults.length > 0 ? (
|
||||
searchResults.map((result) => (
|
||||
<div
|
||||
key={result.id}
|
||||
className="search-result-item"
|
||||
onClick={() => handleResultClick(result)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="search-result-title">{result.title}</div>
|
||||
{result.description && (
|
||||
<div className="search-result-description">{result.description}</div>
|
||||
)}
|
||||
<div className="search-result-app">
|
||||
<Text type="secondary">{result.appId}</Text>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : query ? (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<Empty
|
||||
description="No results found"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
open={isOpen && (isSearching || searchResults.length > 0 || query.length > 0)}
|
||||
onOpenChange={setIsOpen}
|
||||
dropdownRender={() => searchContent}
|
||||
trigger={['click']}
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Input
|
||||
size="middle"
|
||||
placeholder="Search across all applications..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={query}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
onPressEnter={() => handleSearch(query)}
|
||||
style={{ width: 400 }}
|
||||
allowClear
|
||||
onClear={() => {
|
||||
setQuery('');
|
||||
clearSearch();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalSearch;
|
||||
66
web/src/components/LoadKMS.tsx
Normal file
66
web/src/components/LoadKMS.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useApp } from '../contexts/AppContext';
|
||||
|
||||
const LoadKMS: React.FC = () => {
|
||||
const { registerApp } = useApp();
|
||||
const registeredRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadKMS = async () => {
|
||||
if (registeredRef.current) return;
|
||||
|
||||
try {
|
||||
console.log('Loading KMS app...');
|
||||
|
||||
// Dynamically import the KMS app
|
||||
const KMSApp = React.lazy(() => import('kms/App'));
|
||||
|
||||
// Try to load search provider separately
|
||||
let kmsSearchProvider;
|
||||
try {
|
||||
const SearchModule = await import('kms/SearchProvider');
|
||||
kmsSearchProvider = SearchModule.kmsSearchProvider;
|
||||
} catch (searchError) {
|
||||
console.warn('Failed to load KMS search provider:', searchError);
|
||||
kmsSearchProvider = undefined;
|
||||
}
|
||||
|
||||
registerApp({
|
||||
id: 'kms',
|
||||
name: 'KMS',
|
||||
path: '/kms',
|
||||
component: KMSApp,
|
||||
searchProvider: kmsSearchProvider,
|
||||
});
|
||||
|
||||
registeredRef.current = true;
|
||||
console.log('KMS app loaded successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to load KMS app:', error);
|
||||
|
||||
// Try to register with just basic import as fallback
|
||||
try {
|
||||
const KMSApp = React.lazy(() => import('kms/App'));
|
||||
|
||||
registerApp({
|
||||
id: 'kms',
|
||||
name: 'KMS',
|
||||
path: '/kms',
|
||||
component: KMSApp,
|
||||
});
|
||||
|
||||
registeredRef.current = true;
|
||||
console.log('KMS app loaded with fallback');
|
||||
} catch (fallbackError) {
|
||||
console.error('Complete KMS loading failure:', fallbackError);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadKMS();
|
||||
}, [registerApp]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default LoadKMS;
|
||||
41
web/src/components/ShellHeader.tsx
Normal file
41
web/src/components/ShellHeader.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { Layout, Space, Typography } from 'antd';
|
||||
import GlobalSearch from './GlobalSearch';
|
||||
import TimeZoneClock from './TimeZoneClock';
|
||||
import UserDropdown from './UserDropdown';
|
||||
|
||||
const { Header } = Layout;
|
||||
const { Title } = Typography;
|
||||
|
||||
const ShellHeader: React.FC = () => {
|
||||
return (
|
||||
<Header className="shell-header" style={{
|
||||
padding: '0 24px',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Title level={4} style={{ margin: 0, marginRight: 32 }}>
|
||||
Skybridge Platform
|
||||
</Title>
|
||||
<GlobalSearch />
|
||||
</div>
|
||||
|
||||
<Space size="large">
|
||||
<div className="timezone-clocks">
|
||||
<TimeZoneClock timezone="America/Los_Angeles" label="PST" />
|
||||
<TimeZoneClock timezone="America/New_York" label="EST" />
|
||||
<TimeZoneClock timezone="America/Chicago" label="CST" />
|
||||
<TimeZoneClock timezone="Asia/Kolkata" label="IST" />
|
||||
</div>
|
||||
|
||||
<UserDropdown />
|
||||
</Space>
|
||||
</Header>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShellHeader;
|
||||
28
web/src/components/TimeZoneClock.tsx
Normal file
28
web/src/components/TimeZoneClock.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import moment from 'moment-timezone';
|
||||
import { TimeZoneClockProps } from '../types';
|
||||
|
||||
const TimeZoneClock: React.FC<TimeZoneClockProps> = ({ timezone, label }) => {
|
||||
const [time, setTime] = useState(moment.tz(timezone));
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTime(moment.tz(timezone));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [timezone]);
|
||||
|
||||
return (
|
||||
<div className="timezone-clock">
|
||||
<div className="timezone-clock-time">
|
||||
{time.format('HH:mm')}
|
||||
</div>
|
||||
<div className="timezone-clock-label">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeZoneClock;
|
||||
44
web/src/components/UserDropdown.tsx
Normal file
44
web/src/components/UserDropdown.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Dropdown, Avatar, Typography, Space } from 'antd';
|
||||
import { UserOutlined, LogoutOutlined } from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { useApp } from '../contexts/AppContext';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const UserDropdown: React.FC = () => {
|
||||
const { user, logout } = useApp();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: 'Logout',
|
||||
onClick: logout,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
|
||||
<Space style={{ cursor: 'pointer' }}>
|
||||
<Avatar
|
||||
size="small"
|
||||
icon={<UserOutlined />}
|
||||
src={user.avatar}
|
||||
/>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div>{user.name || user.email}</div>
|
||||
{user.name && (
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{user.email}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserDropdown;
|
||||
108
web/src/contexts/AppContext.tsx
Normal file
108
web/src/contexts/AppContext.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import { FederatedApp, User, SearchResult } from '../types';
|
||||
|
||||
interface AppContextType {
|
||||
user: User | null;
|
||||
apps: FederatedApp[];
|
||||
activeAppId: string | null;
|
||||
searchResults: SearchResult[];
|
||||
isSearching: boolean;
|
||||
setUser: (user: User | null) => void;
|
||||
registerApp: (app: FederatedApp) => void;
|
||||
setActiveApp: (appId: string | null) => void;
|
||||
performSearch: (query: string) => Promise<void>;
|
||||
clearSearch: () => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppContextType | undefined>(undefined);
|
||||
|
||||
interface AppProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AppProvider: React.FC<AppProviderProps> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [apps, setApps] = useState<FederatedApp[]>([]);
|
||||
const [activeAppId, setActiveAppId] = useState<string | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const registerApp = (app: FederatedApp) => {
|
||||
setApps(prev => {
|
||||
const exists = prev.find(a => a.id === app.id);
|
||||
if (exists) return prev;
|
||||
return [...prev, app];
|
||||
});
|
||||
};
|
||||
|
||||
const setActiveApp = (appId: string | null) => {
|
||||
setActiveAppId(appId);
|
||||
};
|
||||
|
||||
const performSearch = async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
const allResults: SearchResult[] = [];
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
apps.map(async (app) => {
|
||||
if (app.searchProvider) {
|
||||
try {
|
||||
const results = await app.searchProvider(query);
|
||||
allResults.push(...results);
|
||||
} catch (error) {
|
||||
console.error(`Search failed for app ${app.id}:`, error);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setSearchResults(allResults);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchResults([]);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
setActiveAppId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppContext.Provider
|
||||
value={{
|
||||
user,
|
||||
apps,
|
||||
activeAppId,
|
||||
searchResults,
|
||||
isSearching,
|
||||
setUser,
|
||||
registerApp,
|
||||
setActiveApp,
|
||||
performSearch,
|
||||
clearSearch,
|
||||
logout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useApp = (): AppContextType => {
|
||||
const context = useContext(AppContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useApp must be used within an AppProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
101
web/src/index.css
Normal file
101
web/src/index.css
Normal file
@ -0,0 +1,101 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shell-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.shell-header {
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.shell-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timezone-clocks {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timezone-clock {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.timezone-clock-time {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.timezone-clock-label {
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.search-dropdown {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.search-result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search-result-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.search-result-description {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.search-result-app {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.app-tabs .ant-tabs-content-holder {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-tabs .ant-tabs-tabpane {
|
||||
height: 100%;
|
||||
}
|
||||
3
web/src/index.tsx
Normal file
3
web/src/index.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import('./bootstrap');
|
||||
|
||||
export {};
|
||||
10
web/src/types/federated.d.ts
vendored
Normal file
10
web/src/types/federated.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
declare module 'kms/App' {
|
||||
import React from 'react';
|
||||
const KMSApp: React.ComponentType;
|
||||
export default KMSApp;
|
||||
}
|
||||
|
||||
declare module 'kms/SearchProvider' {
|
||||
import { SearchResult } from './index';
|
||||
export const kmsSearchProvider: (query: string) => Promise<SearchResult[]>;
|
||||
}
|
||||
26
web/src/types/index.ts
Normal file
26
web/src/types/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export interface FederatedApp {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
component: React.ComponentType;
|
||||
searchProvider?: (query: string) => Promise<SearchResult[]>;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
appId: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
email: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface TimeZoneClockProps {
|
||||
timezone: string;
|
||||
label: string;
|
||||
}
|
||||
Reference in New Issue
Block a user