feat: add localforage and conversations hook

This commit is contained in:
kangfenmao 2024-05-30 16:20:50 +08:00
parent 78c13d7586
commit aa4ede4427
12 changed files with 298 additions and 53 deletions

View File

@ -28,6 +28,7 @@
"@fontsource/inter": "^5.0.18",
"electron-updater": "^6.1.7",
"electron-window-state": "^5.0.3",
"localforage": "^1.10.0",
"react-router": "6",
"react-router-dom": "6",
"styled-components": "^6.1.11"

View File

@ -1,4 +1,3 @@
import styled from 'styled-components'
import Sidebar from './components/app/Sidebar'
import Statusbar from './components/app/Statusbar'
import HomePage from './pages/home/HomePage'
@ -10,7 +9,6 @@ import '@fontsource/inter'
function App(): JSX.Element {
return (
<BrowserRouter>
<MainContainer>
<Sidebar />
<Routes>
<Route path="/" element={<HomePage />} />
@ -18,15 +16,8 @@ function App(): JSX.Element {
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>
<Statusbar />
</MainContainer>
</BrowserRouter>
)
}
const MainContainer = styled.main`
display: flex;
flex-direction: row;
flex: 1;
`
export default App

View File

@ -1,4 +1,4 @@
@import 'https://at.alicdn.com/t/c/font_4563475_m52ypmvg0jc.css';
@import 'https://at.alicdn.com/t/c/font_4563475_65n90fm9f93.css';
:root {
--ev-c-white: #ffffff;
@ -101,8 +101,17 @@ code {
font-size: 85%;
}
html,
body,
#root {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
margin: 0;
}
#root {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
flex: 1;
}

View File

@ -23,7 +23,8 @@ const NavbarContainer = styled.div`
min-width: 100%;
display: flex;
flex-direction: row;
height: var(--navbar-height);
min-height: var(--navbar-height);
max-height: var(--navbar-height);
border-bottom: 1px solid #ffffff20;
-webkit-app-region: drag;
`

View File

@ -0,0 +1,51 @@
import { runAsyncFunction } from '@renderer/utils'
import localforage from 'localforage'
import { useEffect, useState } from 'react'
export type Conversation = {
id: string
name: string
avatar: string
lastMessage: string
lastMessageAt: string
}
export default function useConversations() {
const [conversations, setConversations] = useState<Conversation[]>([])
const [activeConversation, setActiveConversation] = useState<Conversation>()
// Use localforage to initialize conversations
useEffect(() => {
runAsyncFunction(async () => {
const conversations = await localforage.getItem<Conversation[]>('conversations')
conversations && setConversations(conversations)
})
}, [])
// Update localforage
useEffect(() => {
localforage.setItem('conversations', conversations)
}, [conversations])
const addConversation = (conversation) => {
setConversations([...conversations, conversation])
}
const removeConversation = (conversationId) => {
setConversations(conversations.filter((c) => c.id !== conversationId))
}
const updateConversation = (conversation) => {
setConversations(conversations.map((c) => (c.id === conversation.id ? conversation : c)))
}
return {
conversations,
activeConversation,
setConversations,
addConversation,
removeConversation,
updateConversation,
setActiveConversation
}
}

13
src/renderer/src/init.ts Normal file
View File

@ -0,0 +1,13 @@
import localforage from 'localforage'
function init() {
localforage.config({
driver: localforage.INDEXEDDB,
name: 'CherryAI',
version: 1.0,
storeName: 'cherryai',
description: 'Cherry AI storage'
})
}
init()

View File

@ -3,6 +3,7 @@ import './assets/css/base.css'
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './init'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>

View File

@ -1,67 +1,90 @@
import { Navbar, NavbarCenter, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
import { FC } from 'react'
import useConversations from '@renderer/hooks/useConversactions'
import { FC, useEffect } from 'react'
import styled from 'styled-components'
import Conversations from './components/Conversations'
import Chat from './components/Chat'
const HomePage: FC = () => {
const { conversations, activeConversation, setActiveConversation, addConversation } = useConversations()
useEffect(() => {
if (!activeConversation) {
setActiveConversation(conversations[0])
}
}, [activeConversation, conversations])
const onCreateConversation = () => {
window.electron.ipcRenderer.send('storage.set', { key: 'conversations', value: [] })
const _conversation = {
// ID auto increment
id: Math.random().toString(),
name: 'New conversation',
// placeholder url
avatar: 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y',
lastMessage: 'message',
lastMessageAt: 'now'
}
addConversation(_conversation)
setActiveConversation(_conversation)
}
return (
<MainContainer>
<Container>
<Navbar>
<NavbarLeft style={{ justifyContent: 'space-between' }}>
<NewButton onClick={onCreateConversation}>new</NewButton>
<NewButton onClick={onCreateConversation}>new</NewButton>
<NavbarLeft style={{ justifyContent: 'flex-end' }}>
<NewButton onClick={onCreateConversation}>
<i className="iconfont icon-a-addchat"></i>
</NewButton>
</NavbarLeft>
<NavbarCenter>Cherry AI</NavbarCenter>
<NavbarRight />
</Navbar>
<ContentContainer>
<Conversations />
<Chat />
<Conversations
conversations={conversations}
activeConversation={activeConversation}
onSelectConversation={setActiveConversation}
/>
<Chat activeConversation={activeConversation} />
<Settings />
</ContentContainer>
</MainContainer>
</Container>
)
}
const MainContainer = styled.div`
const Container = styled.div`
display: flex;
flex: 1;
flex-direction: column;
`
const NewButton = styled.button`
-webkit-app-region: none;
border-radius: 4px;
color: var(--color-text-1);
background-color: var(--color-background-soft);
border: 1px solid var(--color-background-soft);
&:hover {
background-color: var(--color-background-soft-hover);
cursor: pointer;
}
height: 100%;
`
const ContentContainer = styled.div`
display: flex;
flex: 1;
flex-direction: row;
`
const Conversations = styled.div`
display: flex;
min-width: var(--conversations-width);
border-right: 1px solid #ffffff20;
height: 100%;
`
const Chat = styled.div`
const NewButton = styled.div`
-webkit-app-region: none;
border-radius: 4px;
width: 34px;
height: 34px;
display: flex;
height: 100%;
flex: 1;
border-right: 1px solid #ffffff20;
flex-direction: row;
justify-content: center;
align-items: center;
transition: all 0.2s ease-in-out;
color: var(--color-icon);
.iconfont {
font-size: 22px;
}
&:hover {
background-color: var(--color-background-soft);
cursor: pointer;
color: var(--color-icon-white);
}
`
const Settings = styled.div`

View File

@ -0,0 +1,20 @@
import { Conversation } from '@renderer/hooks/useConversactions'
import { FC } from 'react'
import styled from 'styled-components'
interface Props {
activeConversation?: Conversation
}
const Chat: FC<Props> = ({ activeConversation }) => {
return <Container>{activeConversation?.lastMessage}</Container>
}
const Container = styled.div`
display: flex;
height: 100%;
flex: 1;
border-right: 1px solid #ffffff20;
`
export default Chat

View File

@ -0,0 +1,70 @@
import type { Conversation } from '@renderer/hooks/useConversactions'
import { FC } from 'react'
import styled from 'styled-components'
interface Props {
conversations: Conversation[]
activeConversation?: Conversation
onSelectConversation: (conversation: Conversation) => void
}
const Conversations: FC<Props> = ({ conversations, activeConversation, onSelectConversation }) => {
return (
<Container>
{conversations.map((conversation) => (
<Conversation
key={conversation.id}
onClick={() => onSelectConversation(conversation)}
className={conversation.id === activeConversation?.id ? 'active' : ''}>
<ConversationTime>{conversation.lastMessageAt}</ConversationTime>
<ConversationName>{conversation.name}</ConversationName>
<ConversationLastMessage>{conversation.lastMessage}</ConversationLastMessage>
</Conversation>
))}
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
min-width: var(--conversations-width);
border-right: 1px solid #ffffff20;
height: calc(100vh - var(--navbar-height) - var(--status-bar-height));
padding: 10px;
overflow-y: scroll;
`
const Conversation = styled.div`
display: flex;
flex-direction: column;
padding: 10px;
cursor: pointer;
&:hover {
background-color: var(--color-background-soft);
}
&.active {
background-color: var(--color-background-mute);
cursor: pointer;
}
border-radius: 8px;
margin-bottom: 10px;
`
const ConversationTime = styled.div`
font-size: 12px;
color: var(--color-text-2);
`
const ConversationName = styled.div`
font-size: 14px;
color: var(--color-text-1);
font-weight: bold;
`
const ConversationLastMessage = styled.div`
font-size: 12px;
color: var(--color-text-2);
`
export default Conversations

View File

@ -0,0 +1,46 @@
export const runAsyncFunction = async (fn: () => void) => {
await fn()
}
/**
* json
* @param str
*/
export function isJSON(str: any): boolean {
if (typeof str !== 'string') {
return false
}
try {
return typeof JSON.parse(str) === 'object'
} catch (e) {
return false
}
}
export const delay = (seconds: number) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(true)
}, seconds * 1000)
})
}
/**
* Waiting fn return true
**/
export const waitAsyncFunction = (fn: () => Promise<any>, interval = 200, stopTimeout = 60000) => {
let timeout = false
const timer = setTimeout(() => (timeout = true), stopTimeout)
return (async function check(): Promise<any> {
if (await fn()) {
clearTimeout(timer)
return Promise.resolve()
} else if (!timeout) {
return delay(interval / 1000).then(check)
} else {
return Promise.resolve()
}
})()
}

View File

@ -2889,6 +2889,11 @@ ignore@^5.2.0, ignore@^5.2.4:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef"
integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
immediate@~3.0.5:
version "3.0.6"
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==
immutable@^4.0.0:
version "4.3.6"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.6.tgz#6a05f7858213238e587fb83586ffa3b4b27f0447"
@ -3266,11 +3271,25 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
lie@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
integrity sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==
dependencies:
immediate "~3.0.5"
lines-and-columns@^1.1.6:
version "1.2.4"
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
localforage@^1.10.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.10.0.tgz#5c465dc5f62b2807c3a84c0c6a1b1b3212781dd4"
integrity sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==
dependencies:
lie "3.1.1"
locate-path@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"