mirror of
https://github.com/deiucanta/chatpad.git
synced 2026-03-11 09:04:31 +00:00
feat: shared chat
This commit is contained in:
parent
2ffee3bf64
commit
4976b4648c
16 changed files with 1485 additions and 52 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,4 +1,4 @@
|
|||
/node_modules
|
||||
/.parcel-cache
|
||||
/dist
|
||||
node_modules
|
||||
.parcel-cache
|
||||
dist
|
||||
notes.txt
|
||||
|
|
|
|||
11
Spacefile
11
Spacefile
|
|
@ -10,3 +10,14 @@ micros:
|
|||
commands:
|
||||
- NODE_OPTIONS="--max-old-space-size=1024" npm run build
|
||||
dev: npm run dev
|
||||
public_routes:
|
||||
- "/shared/*"
|
||||
- "/assets/*"
|
||||
|
||||
- name: backend
|
||||
src: ./backend
|
||||
path: /api
|
||||
engine: nodejs16
|
||||
dev: npm run dev
|
||||
public_routes:
|
||||
- "/public/*"
|
||||
|
|
|
|||
38
backend/index.js
Normal file
38
backend/index.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import express from 'express'
|
||||
import { Deta } from 'deta'
|
||||
|
||||
const app = express()
|
||||
const deta = Deta()
|
||||
|
||||
const chats = deta.Base('chats')
|
||||
const messages = deta.Base('messages')
|
||||
|
||||
app.use(express.json())
|
||||
|
||||
app.get('/public/chats/:key', async (req, res) => {
|
||||
const key = req.params.key
|
||||
|
||||
const chat = await chats.get(key)
|
||||
if (!chat || !chat.shared) {
|
||||
return res.status(404).json({ message: 'Chat not found' })
|
||||
}
|
||||
|
||||
res.json(chat)
|
||||
})
|
||||
|
||||
app.get('/public/chats/:key/messages', async (req, res) => {
|
||||
const key = req.params.key
|
||||
|
||||
const chat = await chats.get(key)
|
||||
if (!chat || !chat.shared) {
|
||||
return res.status(404).json({ message: 'Chat not found' })
|
||||
}
|
||||
|
||||
const items = await messages.fetch({ chatId: key }, { desc: false })
|
||||
res.json(items)
|
||||
})
|
||||
|
||||
const PORT = process.env.PORT || 3000
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running on port ${PORT}`)
|
||||
})
|
||||
1118
backend/package-lock.json
generated
Normal file
1118
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
18
backend/package.json
Normal file
18
backend/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node --watch index.js",
|
||||
"start": "node index.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deta": "^2.0.0",
|
||||
"express": "^4.18.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
name="viewport"
|
||||
content="width=device-width, height=device-height, initial-scale=1.0, user-scalable=0, minimum-scale=1.0, maximum-scale=1.0, interactive-widget=resizes-content"
|
||||
/>
|
||||
<link rel="icon" href="./favicon.png" />
|
||||
<link rel="icon" href="/assets/favicon.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
|
@ -7,12 +7,15 @@ import { useHotkeys, useLocalStorage } from "@mantine/hooks";
|
|||
import { Notifications } from "@mantine/notifications";
|
||||
import {
|
||||
createBrowserHistory,
|
||||
Outlet,
|
||||
ReactLocation,
|
||||
Router,
|
||||
} from "@tanstack/react-location";
|
||||
import { ChatRoute } from "../routes/ChatRoute";
|
||||
import { PublicChatRoute } from "../routes/PublicChatRoute";
|
||||
import { IndexRoute } from "../routes/IndexRoute";
|
||||
import { Layout } from "./Layout";
|
||||
import { BaseLayout } from "../layouts/Base";
|
||||
import { PublicLayout } from "../layouts/Public";
|
||||
|
||||
const history = createBrowserHistory();
|
||||
const location = new ReactLocation({ history });
|
||||
|
|
@ -35,8 +38,9 @@ export function App() {
|
|||
<Router
|
||||
location={location}
|
||||
routes={[
|
||||
{ path: "/", element: <IndexRoute /> },
|
||||
{ path: "/chats/:chatId", element: <ChatRoute /> },
|
||||
{ id: "root", path: "/", element: <BaseLayout><IndexRoute /></BaseLayout>},
|
||||
{ id: "chat", path: "/chats/:chatId", element: <BaseLayout><ChatRoute /></BaseLayout> },
|
||||
{ id: "public_chat", path: "/shared/chats/:chatId", element: <PublicLayout><PublicChatRoute /></PublicLayout> },
|
||||
]}
|
||||
>
|
||||
<ColorSchemeProvider
|
||||
|
|
@ -108,7 +112,9 @@ export function App() {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<Layout />
|
||||
{/* <BaseLayout /> */}
|
||||
{/* <PublicLayout /> */}
|
||||
<Outlet />
|
||||
<Notifications position="bottom-right" style={{ marginBottom: '4rem' }} />
|
||||
</MantineProvider>
|
||||
</ColorSchemeProvider>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import { ActionIcon, Box, Header, TextInput, Tooltip } from "@mantine/core";
|
||||
import { IconAdjustments } from "@tabler/icons-react";
|
||||
import { ActionIcon, Box, Flex, Header, TextInput, Tooltip, useMantineTheme } from "@mantine/core";
|
||||
import { IconAdjustments, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { EditChatModal } from "./EditChatModal";
|
||||
import { Chat, detaDB } from "../db";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useChat, useChats } from "../hooks/contexts";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { ShareChatModal } from "./ShareChatModal";
|
||||
import { LogoText } from "./Logo";
|
||||
import { Link } from "@tanstack/react-location";
|
||||
|
||||
export function ChatHeader() {
|
||||
export function ChatHeader({ readOnly = false }: { readOnly?: boolean}) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [debounced] = useDebouncedValue(title, 800);
|
||||
const theme = useMantineTheme()
|
||||
|
||||
const { chat, setChat } = useChat()
|
||||
const { setChats } = useChats()
|
||||
|
|
@ -52,7 +56,19 @@ export function ChatHeader() {
|
|||
return (
|
||||
<Header height={60} p="xs" className="app-region-drag">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div></div>
|
||||
<div>
|
||||
{readOnly && (
|
||||
<Link to="/" style={{ display: 'block' }}>
|
||||
<LogoText
|
||||
style={{
|
||||
height: 22,
|
||||
color: theme.colors[theme.primaryColor][6],
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
id="chat-name"
|
||||
|
|
@ -65,23 +81,40 @@ export function ChatHeader() {
|
|||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
<EditChatModal chat={chat!}>
|
||||
<Tooltip label="Chat Settings">
|
||||
<ActionIcon
|
||||
size="xl"
|
||||
sx={(theme) => ({
|
||||
[theme.fn.smallerThan('md')]: {
|
||||
marginRight: '2.5rem',
|
||||
paddingBottom: 3,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<IconAdjustments size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</EditChatModal>
|
||||
<Flex
|
||||
align="center"
|
||||
sx={(theme) => ({
|
||||
[theme.fn.smallerThan('md')]: {
|
||||
marginRight: readOnly ? '' : '2.5rem',
|
||||
paddingBottom: 3,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<ShareChatModal chat={chat!} />
|
||||
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<EditChatModal chat={chat!}>
|
||||
<Tooltip label="Chat Settings">
|
||||
<ActionIcon size="xl">
|
||||
<IconAdjustments size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</EditChatModal>
|
||||
</>
|
||||
) : (
|
||||
<Tooltip label="Chat Settings">
|
||||
<a href="https://deta.space/discovery" target="_blank">
|
||||
<ActionIcon size="xl">
|
||||
<IconInfoCircle size={20} />
|
||||
</ActionIcon>
|
||||
</a>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Header>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { CreatePromptModal } from "./CreatePromptModal";
|
|||
import { LogoIcon } from "./Logo";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
export function MessageItem({ message, onDeleted, handleUseMessage }: { message: Message, onDeleted: (key: string) => void, handleUseMessage: (key: string) => void }) {
|
||||
export function MessageItem({ message, readOnly = false, onDeleted, handleUseMessage }: { message: Message, readOnly?: boolean, onDeleted?: (key: string) => void, handleUseMessage?: (key: string) => void }) {
|
||||
const wordCount = useMemo(() => {
|
||||
var matches = message.content.match(/[\w\d\’\'-\(\)]+/gi);
|
||||
return matches ? matches.length : 0;
|
||||
|
|
@ -36,7 +36,7 @@ export function MessageItem({ message, onDeleted, handleUseMessage }: { message:
|
|||
const handleDelete = async () => {
|
||||
await detaDB.messages.delete(message.key);
|
||||
|
||||
onDeleted(message.key)
|
||||
if (onDeleted) onDeleted(message.key)
|
||||
|
||||
setExpanded(false);
|
||||
|
||||
|
|
@ -101,7 +101,7 @@ export function MessageItem({ message, onDeleted, handleUseMessage }: { message:
|
|||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 10 }}>
|
||||
<Box>
|
||||
|
|
@ -111,12 +111,17 @@ export function MessageItem({ message, onDeleted, handleUseMessage }: { message:
|
|||
</Box>
|
||||
|
||||
<Box style={{ display: 'flex' }}>
|
||||
<CreatePromptModal content={message.content || ''} />
|
||||
<Tooltip label="Re-use Message" position="top">
|
||||
<ActionIcon onClick={() => handleUseMessage(message.content)}>
|
||||
<IconArrowForward opacity={0.5} size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{!readOnly && handleUseMessage && (
|
||||
<>
|
||||
<CreatePromptModal content={message.content || ''} />
|
||||
<Tooltip label="Re-use Message" position="top">
|
||||
<ActionIcon onClick={() => handleUseMessage(message.content)}>
|
||||
<IconArrowForward opacity={0.5} size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CopyButton value={message.content}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? "Copied Message" : "Copy Message"} position="top">
|
||||
|
|
@ -126,11 +131,14 @@ export function MessageItem({ message, onDeleted, handleUseMessage }: { message:
|
|||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
<Tooltip label="Delete Message" position="top">
|
||||
<ActionIcon onClick={handleDelete}>
|
||||
<IconTrash opacity={0.5} size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{!readOnly && (
|
||||
<Tooltip label="Delete Message" position="top">
|
||||
<ActionIcon onClick={handleDelete}>
|
||||
<IconTrash opacity={0.5} size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
|
|
|||
65
src/components/ShareChatModal.tsx
Normal file
65
src/components/ShareChatModal.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { ActionIcon, Button, Input, Modal, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconShare2 } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { Chat, detaDB } from "../db";
|
||||
import { useChat } from "../hooks/contexts";
|
||||
|
||||
export function ShareChatModal({ chat }: { chat: Chat }) {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
const { setChat } = useChat()
|
||||
|
||||
const handleClick = async () => {
|
||||
if (!chat.shared) {
|
||||
setSubmitting(true)
|
||||
await detaDB.chats.update({ shared: true }, chat.key)
|
||||
setChat(current => ({ ...(current as unknown as Chat), shared: true }))
|
||||
setSubmitting(false)
|
||||
|
||||
notifications.show({
|
||||
title: "Shared!",
|
||||
color: "green",
|
||||
message: "Chat is now public.",
|
||||
})
|
||||
}
|
||||
|
||||
setUrl(`${window.location.origin}/shared/chats/${chat.key}`)
|
||||
open()
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(url ?? '')
|
||||
|
||||
notifications.show({
|
||||
title: "Copied!",
|
||||
color: "green",
|
||||
message: "URL copied to clipboard.",
|
||||
})
|
||||
|
||||
close()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal opened={opened} onClose={close} title="Share Chat" size="md">
|
||||
<Stack>
|
||||
<Text size="sm">Use this URL to share this chat with others:</Text>
|
||||
<Input readOnly value={url ?? ''} />
|
||||
<Button type="submit" onClick={handleCopy}>
|
||||
Copy URL
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Tooltip label="Share Chat">
|
||||
<ActionIcon size="xl" onClick={handleClick} loading={submitting} loaderProps={{ size: 20 }}>
|
||||
<IconShare2 size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ export interface Chat {
|
|||
writingFormat?: string | null;
|
||||
model?: string | null;
|
||||
private?: boolean;
|
||||
shared?: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,3 +5,9 @@ export function useChatId() {
|
|||
const match = matchRoute({ to: "/chats/:chatId" });
|
||||
return match?.chatId;
|
||||
}
|
||||
|
||||
export function usePublicChatId() {
|
||||
const matchRoute = useMatchRoute();
|
||||
const match = matchRoute({ to: "/shared/chats/:chatId" });
|
||||
return match?.chatId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,27 +21,29 @@ import {
|
|||
IconSpyOff,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, Outlet, useNavigate, useRouter } from "@tanstack/react-location";
|
||||
import { Link, useNavigate, useRouter } from "@tanstack/react-location";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Chat, detaDB, Prompt, Settings } from "../db";
|
||||
import { useChatId } from "../hooks/useChatId";
|
||||
import { Chats } from "./Chats";
|
||||
import { CreatePromptModal } from "./CreatePromptModal";
|
||||
import { LogoText } from "./Logo";
|
||||
import { Prompts } from "./Prompts";
|
||||
import { SettingsModal } from "./SettingsModal";
|
||||
import { Chats } from "../components/Chats";
|
||||
import { CreatePromptModal } from "../components/CreatePromptModal";
|
||||
import { LogoText } from "../components/Logo";
|
||||
import { Prompts } from "../components/Prompts";
|
||||
import { SettingsModal } from "../components/SettingsModal";
|
||||
import { config } from "../utils/config";
|
||||
import { ChatContext, ChatsContext, IncognitoModeContext, PromptsContext, SettingsContext } from "../hooks/contexts";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import { ChatHeader } from "../components/ChatHeader";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import { CreateChatButton } from "./CreateChatButton";
|
||||
import { DeleteChatsModal } from "./DeleteChatsModal";
|
||||
import { CreateChatButton } from "../components/CreateChatButton";
|
||||
import { DeleteChatsModal } from "../components/DeleteChatsModal";
|
||||
|
||||
export function Layout() {
|
||||
export function BaseLayout({ children }: { children: React.ReactNode }) {
|
||||
const theme = useMantineTheme();
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
|
||||
console.log('route', router)
|
||||
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
const chatId = useChatId();
|
||||
|
||||
|
|
@ -312,7 +314,7 @@ export function Layout() {
|
|||
sx={{ position: "fixed", top: 16, right: 16, zIndex: 100 }}
|
||||
/>
|
||||
</MediaQuery>
|
||||
<Outlet />
|
||||
{children}
|
||||
</AppShell>
|
||||
</IncognitoModeContext.Provider>
|
||||
</PromptsContext.Provider>
|
||||
74
src/layouts/Public.tsx
Normal file
74
src/layouts/Public.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import {
|
||||
AppShell,
|
||||
Burger,
|
||||
MediaQuery,
|
||||
useMantineColorScheme,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
import { useRouter } from "@tanstack/react-location";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Chat } from "../db";
|
||||
import { usePublicChatId } from "../hooks/useChatId";
|
||||
import { ChatContext, IncognitoModeContext } from "../hooks/contexts";
|
||||
import { ChatHeader } from "../components/ChatHeader";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
|
||||
export function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
const theme = useMantineTheme();
|
||||
const router = useRouter();
|
||||
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const chatId = usePublicChatId();
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const [incognitoMode, setIncognitoMode] = useLocalStorage({
|
||||
key: 'incognito-mode', defaultValue: false, getInitialValueInEffect: false
|
||||
});
|
||||
|
||||
const [chat, setChat] = useState<Chat | null>(null);
|
||||
useEffect(() => {
|
||||
const dataFetch = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/public/chats/${chatId}`)
|
||||
const item = await res.json()
|
||||
|
||||
const fetchedChat = item as unknown as Chat
|
||||
setChat(fetchedChat);
|
||||
|
||||
document.title = fetchedChat.description ? `${fetchedChat.description} | Chatpad AI` : 'Chatpad AI'
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
};
|
||||
|
||||
if (chatId) {
|
||||
dataFetch();
|
||||
} else {
|
||||
setChat(null);
|
||||
}
|
||||
}, [chatId]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpened(false);
|
||||
}, [router.state.location]);
|
||||
|
||||
return (
|
||||
<ChatContext.Provider value={{ chat: chat, setChat: setChat }}>
|
||||
<IncognitoModeContext.Provider value={{ incognitoMode: incognitoMode, setIncognitoMode: setIncognitoMode }}>
|
||||
<AppShell
|
||||
className={`${colorScheme}-theme`}
|
||||
header={
|
||||
chat ? (
|
||||
<ChatHeader readOnly />
|
||||
) : undefined
|
||||
}
|
||||
layout="alt"
|
||||
padding={0}
|
||||
>
|
||||
{children}
|
||||
</AppShell>
|
||||
</IncognitoModeContext.Provider>
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
53
src/routes/PublicChatRoute.tsx
Normal file
53
src/routes/PublicChatRoute.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import {
|
||||
Card,
|
||||
Container,
|
||||
Skeleton,
|
||||
Stack,
|
||||
} from "@mantine/core";
|
||||
import { useState, useEffect } from "react";
|
||||
import { MessageItem } from "../components/MessageItem";
|
||||
import { Message } from "../db";
|
||||
import { usePublicChatId } from "../hooks/useChatId";
|
||||
|
||||
export function PublicChatRoute() {
|
||||
const chatId = usePublicChatId();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const dataFetch = async () => {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/public/chats/${chatId}/messages`)
|
||||
const { items } = await res.json()
|
||||
|
||||
setMessages(items as unknown as Message[]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
dataFetch();
|
||||
}, [chatId]);
|
||||
|
||||
if (!chatId) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container pt="xl" pb={100}>
|
||||
<Stack spacing="xs" id="test">
|
||||
{messages?.map((message) => (
|
||||
<MessageItem key={message.key} message={message} readOnly />
|
||||
))}
|
||||
</Stack>
|
||||
{loading && (
|
||||
<Card withBorder mt="xs">
|
||||
<Skeleton height={8} radius="xl" />
|
||||
<Skeleton height={8} mt={6} radius="xl" />
|
||||
<Skeleton height={8} mt={6} radius="xl" />
|
||||
<Skeleton height={8} mt={6} radius="xl" />
|
||||
<Skeleton height={8} mt={6} width="70%" radius="xl" />
|
||||
</Card>
|
||||
)}
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue