├── src ├── App.css ├── components │ ├── MagneticPoem.tsx │ ├── AutoFocusInput.tsx │ ├── Header.tsx │ ├── Sidebar.tsx │ ├── ConfirmButton.tsx │ ├── InlineEditor.tsx │ ├── Login.tsx │ ├── Topics.tsx │ ├── Items.tsx │ └── ChatBubbles.tsx ├── index.css ├── vite-env.d.ts ├── assets │ ├── cat.png │ ├── cat2.png │ └── react.svg ├── tailwind.css ├── main.tsx ├── pages │ ├── Profile.tsx │ ├── Home.tsx │ └── Chat.tsx └── App.tsx ├── .env.example ├── netlify.toml ├── postcss.config.js ├── vite.config.ts ├── tsconfig.node.json ├── .env ├── tailwind.config.js ├── .gitignore ├── index.html ├── partykit.json ├── .eslintrc.cjs ├── tsconfig.json ├── package.json ├── public └── vite.svg ├── party ├── index.ts └── ai.ts └── README.md /src/App.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/MagneticPoem.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PUBLIC_PARTYKIT_HOST=127.0.0.1:1999 2 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | a { 2 | text-decration: underline 3 | 4 | } -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | o -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[redirects]] 2 | from = "/*" 3 | to = "/" 4 | status = 200 -------------------------------------------------------------------------------- /src/assets/cat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jchris/catbot/main/src/assets/cat.png -------------------------------------------------------------------------------- /src/assets/cat2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jchris/catbot/main/src/assets/cat2.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/tailwind.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | @import 'tailwindcss/components'; 3 | @import 'tailwindcss/utilities'; 4 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | VITE_PUBLIC_PARTYKIT_HOST=127.0.0.1:1999 2 | OPEN_AI_API_KEY=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 3 | 4 | VITE_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_ZGl2aW5lLW11bGUtOTMuY2xlcmsuYWNjb3VudHMuZGV2JA 5 | CLERK_SECRET_KEY=sk_test_kCxFooKPOOLIBSlmtmVX9Nm3UG9mfCnpvHGHDvb3TJ -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | 3 | import typography from '@tailwindcss/typography' 4 | 5 | export default { 6 | content: ['./src/**/*.html', './src/**/*.js', './src/**/*.jsx', './src/**/*.ts', './src/**/*.tsx'], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [typography], 11 | } 12 | 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # partykit 27 | .partykit/ 28 | .env 29 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Catbot Chat 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { BrowserRouter } from 'react-router-dom' 4 | 5 | import App from './App.tsx' 6 | import './index.css' 7 | import './tailwind.css' 8 | 9 | ReactDOM.createRoot(document.getElementById('root')!).render( 10 | 11 | 12 | 13 | 14 | 15 | ) 16 | -------------------------------------------------------------------------------- /partykit.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat-party", 3 | "main": "party/index.ts", 4 | "parties": { 5 | "fireproof": "node_modules/@fireproof/partykit/src/server.ts" 6 | }, 7 | "compatibilityDate": "2023-11-04", 8 | "vars": { 9 | "OPEN_AI_API_KEY": "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", 10 | "VITE_PUBLIC_PARTYKIT_HOST": "127.0.0.1:1999", 11 | "PUBLIC_PARTYKIT_HOST": "127.0.0.1:1999" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /src/pages/Profile.tsx: -------------------------------------------------------------------------------- 1 | import catImage from '../assets/cat.png' 2 | import cat2Image from '../assets/cat2.png' 3 | 4 | import { ChatBubble, ImageBubble } from '../components/ChatBubbles' 5 | 6 | export function Profile() { 7 | return ( 8 |
9 |
10 |

Cat Chat

11 |
12 |
13 |

Subscribe to chat!

14 | 15 | 18 | 19 | 20 | 21 |

Ask the cats anything!

22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import catImage from '../assets/cat.png' 2 | import cat2Image from '../assets/cat2.png' 3 | import { ImageBubble } from '../components/ChatBubbles' 4 | 5 | export function Home() { 6 | return ( 7 |
8 |
9 |

Cat Chat

10 |
11 | 12 |
13 |

14 | 15 | Join to meet the cats! 16 | 17 |

18 | 19 | 20 |

Ask the cats anything!

21 | 22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/AutoFocusInput.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | const AutoFocusInput = ({ 4 | isActive, 5 | value, 6 | onChange, 7 | className 8 | }: { 9 | isActive: boolean 10 | value: string 11 | onChange: (ok: React.ChangeEvent) => void 12 | className: string 13 | }) => { 14 | const inputRef = useRef(null) 15 | 16 | useEffect(() => { 17 | if (isActive && inputRef.current) { 18 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 19 | // @ts-ignore 20 | inputRef.current.focus() 21 | } 22 | }, [isActive]) 23 | 24 | return ( 25 | 33 | ) 34 | } 35 | 36 | export { AutoFocusInput } 37 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | export function Header() { 2 | return ( 3 |
4 | 5 | Fireproof Logo 11 | 12 | 25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { useFireproof } from 'use-fireproof' 3 | import { connect } from '@fireproof/ipfs' 4 | import { Login } from './Login' 5 | import { Topics } from './Topics' 6 | 7 | export function Sidebar() { 8 | const { database } = useFireproof('topics') 9 | const [authorized, setAuthorized] = useState(false) 10 | const [userEmail, setUserEmail] = useState(localStorage.getItem('user-email') || '') 11 | const cx = connect.ipfs(database) 12 | 13 | useEffect(() => { 14 | cx.ready.then(() => { 15 | setAuthorized(!!cx.authorized) 16 | }) 17 | }, []) 18 | 19 | const onLogin = (email: `${string}@${string}`) => { 20 | cx.authorize(email).then(() => { 21 | setAuthorized(true) 22 | localStorage.setItem('user-email', email) 23 | setUserEmail(email) 24 | }) 25 | } 26 | 27 | return ( 28 |
29 | database.openDashboard()} 32 | placeholder={userEmail} 33 | authorized={authorized} 34 | /> 35 | 36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/components/ConfirmButton.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | export function ConfirmButton({ 4 | onConfirm, 5 | initialText = 'Submit', 6 | confirmText = 'Confirm' 7 | }: { 8 | onConfirm: () => void 9 | initialText?: string 10 | confirmText?: string 11 | }) { 12 | const [confirm, setConfirm] = useState(false) 13 | 14 | useEffect(() => { 15 | if (confirm) { 16 | const timeout = setTimeout(() => { 17 | setConfirm(false) 18 | }, 5000) 19 | return () => clearTimeout(timeout) 20 | } 21 | }, [confirm]) 22 | 23 | const handleClick = (e: { preventDefault: () => void }) => { 24 | e.preventDefault() 25 | if (confirm) { 26 | onConfirm() 27 | setConfirm(false) 28 | } else { 29 | setConfirm(true) 30 | } 31 | } 32 | 33 | const baseStyle = `px-4 py-2 m-2 rounded text-white ${confirm ? '' : 'transition duration-500'}` 34 | const initialStyle = 'bg-slate-500 hover:bg-slate-600' 35 | const confirmStyle = 'bg-yellow-500 hover:bg-yellow-600' 36 | 37 | return ( 38 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "smart-book", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "start": "vite", 9 | "build": "vite build", 10 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@clerk/backend": "^0.33.0", 15 | "@clerk/clerk-react": "^4.27.2", 16 | "@fireproof/partykit": "^0.14.2", 17 | "@types/react-router-dom": "^5.3.3", 18 | "openai": "^4.15.4", 19 | "partysocket": "0.0.12", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "react-hook-form": "^7.48.2", 23 | "react-router-dom": "^6.17.0", 24 | "use-fireproof": "^0.14.0" 25 | }, 26 | "devDependencies": { 27 | "@tailwindcss/typography": "^0.5.10", 28 | "@types/react": "^18.2.28", 29 | "@types/react-dom": "^18.2.13", 30 | "@typescript-eslint/eslint-plugin": "^6.8.0", 31 | "@typescript-eslint/parser": "^6.8.0", 32 | "@vitejs/plugin-react": "^4.1.0", 33 | "autoprefixer": "^10.4.16", 34 | "eslint": "^8.51.0", 35 | "eslint-plugin-react-hooks": "^4.6.0", 36 | "eslint-plugin-react-refresh": "^0.4.3", 37 | "partykit": "0.0.32", 38 | "postcss": "^8.4.31", 39 | "tailwindcss": "^3.3.3", 40 | "typescript": "^5.2.2", 41 | "vite": "^4.4.11" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import './App.css' 2 | import React, { useEffect } from 'react' 3 | import { Route, Routes } from 'react-router-dom' 4 | import { Home } from './pages/Home' 5 | import { Profile } from './pages/Profile' 6 | import { Chat } from './pages/Chat' 7 | import { ClerkProvider, SignedIn, SignedOut, RedirectToSignIn } from '@clerk/clerk-react' 8 | 9 | const CLERK_PUBLISHABLE_KEY = import.meta.env.VITE_PUBLIC_CLERK_PUBLISHABLE_KEY 10 | 11 | function Layout({ children }: { children: React.ReactNode }) { 12 | return
{children}
13 | } 14 | 15 | function App() { 16 | 17 | const routes = [ 18 | { path: '/chat/:id', component: () }, 19 | { path: '/chat', component: React.createElement(ChatRedirect) }, 20 | { path: '/profile', component: () }, 21 | { path: '/', component: React.createElement(Home) } 22 | ] 23 | 24 | return ( 25 | <> 26 | 27 | {routes.map(({ path, component }, index) => ( 28 | {(component)}} 32 | /> 33 | ))} 34 | 35 | 36 | ) 37 | } 38 | 39 | export default App 40 | 41 | function ChatRedirect() { 42 | const newId = Math.random().toString(36).substring(2) 43 | useEffect(() => { 44 | window.location.href = `/chat/${newId}` 45 | }, []) 46 | } 47 | 48 | function LoggedIn({ children }: { children: React.ReactNode }) { 49 | return ( 50 | 51 | 52 | {children} 53 | 54 | 55 |

Login

56 | 57 |
58 |
59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/components/InlineEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import type { Database, DocFragment } from 'use-fireproof' 3 | 4 | type InlineEditorProps = { 5 | topic: { [key: string]: DocFragment; updated: number } 6 | database: Database 7 | isEditing: boolean 8 | setIsEditing: (value: boolean) => void 9 | field: string 10 | label?: string 11 | children?: React.ReactNode 12 | } 13 | 14 | export const InlineEditor: React.FC = ({ 15 | topic, 16 | database, 17 | isEditing, 18 | setIsEditing, 19 | field, 20 | label, 21 | children 22 | }) => { 23 | const [newValue, setNewValue] = useState(topic?.[field]?.toString() || '') 24 | 25 | return isEditing || !topic?.[field] ? ( 26 |
{ 28 | e.preventDefault() 29 | topic[field] = newValue 30 | topic.updated = Date.now() 31 | database.put(topic) 32 | setIsEditing(false) 33 | setNewValue('') 34 | }} 35 | > 36 |
37 | {label && ( 38 | 41 | )} 42 | setNewValue(e.target.value)} 48 | /> 49 | 52 | 55 |
56 |
57 | ) : ( 58 |
{ 60 | setNewValue(topic?.[field]?.toString() || '') 61 | setIsEditing(true) 62 | }} 63 | > 64 | {children ? children :

{topic?.[field]?.toString()}

} 65 |
66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /party/index.ts: -------------------------------------------------------------------------------- 1 | import type * as Party from 'partykit/server' 2 | import { verifyToken } from "@clerk/backend"; 3 | 4 | import { AI } from './ai' 5 | 6 | const DEFAULT_CLERK_ENDPOINT = "https://divine-mule-93.clerk.accounts.dev"; 7 | 8 | export default class Server implements Party.Server { 9 | ai: AI 10 | 11 | constructor(readonly party: Party.Party) { 12 | this.ai = new AI(this.party.env['OPEN_AI_API_KEY'] as string) 13 | } 14 | 15 | static async onBeforeConnect(request: Party.Request, lobby: Party.Lobby) { 16 | try { 17 | // get authentication server url from environment variables (optional) 18 | const issuer = lobby.env.CLERK_ENDPOINT || DEFAULT_CLERK_ENDPOINT 19 | // get token from request query string 20 | const token = new URL(request.url).searchParams.get("token") ?? ""; 21 | // verify the JWT (in this case using clerk) 22 | const session = await verifyToken(token, { issuer }); 23 | // pass any information to the onConnect handler in headers (optional) 24 | request.headers.set("X-User-ID", session.sub); 25 | // forward the request onwards on onConnect 26 | return request; 27 | } catch (e) { 28 | // authentication failed! 29 | // short-circuit the request before it's forwarded to the party 30 | return new Response("Unauthorized", { status: 401 }); 31 | } 32 | } 33 | 34 | onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) { 35 | // A websocket just connected! 36 | console.log( 37 | `Connected: 38 | id: ${conn.id} 39 | room: ${this.party.id} 40 | url: ${new URL(ctx.request.url).pathname}` 41 | ) 42 | 43 | } 44 | 45 | async onMessage( 46 | message: string //sender: Party.Connection 47 | ) { 48 | // let's log the message 49 | const data = JSON.parse(message) 50 | // console.log('message', message) 51 | 52 | await this.ai.userMessage(data.msg, data.history || [], async data => { 53 | // console.log('data', data._id) 54 | // sender.send(JSON.stringify(data)) 55 | this.party.broadcast(JSON.stringify(data)) 56 | }) 57 | } 58 | } 59 | 60 | Server satisfies Party.Worker 61 | -------------------------------------------------------------------------------- /src/components/Login.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | export function Login({ 4 | onLogin, 5 | placeholder, 6 | authorized, 7 | accountClicked, 8 | }: { 9 | authorized: boolean 10 | placeholder?: string, 11 | accountClicked: () => void, 12 | onLogin: (email: `${string}@${string}`) => void 13 | }) { 14 | const [email, setEmail] = useState<`${string}@${string}`>() 15 | const [didSubmit, setDidSubmit] = useState(false) 16 | 17 | const onSubmit = (event: React.FormEvent) => { 18 | event.preventDefault() 19 | setDidSubmit(true) 20 | if (email) onLogin(email) 21 | } 22 | const onChange = (event: React.ChangeEvent) => { 23 | setEmail(event.target.value as `${string}@${string}`) 24 | } 25 | return ( 26 |
27 | {authorized ? ( 28 | 34 | ) : didSubmit ? ( 35 |

36 | Please check your email at {email} for a verification 37 | message from web3.storage. If you are logging into an existing account, please log in on 38 | your original device as well, to allow account certification. This process can take up to 39 | a minute, make a tea (or{' '} 40 | add websockets here). 41 |

42 | ) : ( 43 | <> 44 |

Login to save:

45 |
46 | 53 | 59 |
60 | 61 | )} 62 |
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/components/Topics.tsx: -------------------------------------------------------------------------------- 1 | import { useFireproof } from 'use-fireproof' 2 | import { Link } from 'react-router-dom' 3 | import { TopicDoc } from '../pages/Profile' 4 | import { useState } from 'react' 5 | import { AutoFocusInput } from './AutoFocusInput' 6 | 7 | export function Topics() { 8 | const { database, useLiveQuery } = useFireproof('topics') 9 | const [isCreating, setIsCreating] = useState(false) 10 | const [topicName, setTopicName] = useState('') 11 | 12 | const topics = useLiveQuery( 13 | (doc, emit) => { 14 | if (doc.type === 'topic') { 15 | emit(doc.title) 16 | } 17 | }, 18 | { descending: false } 19 | ).docs as TopicDoc[] 20 | 21 | const handleCreateClick = async () => { 22 | const topicDoc: TopicDoc = { 23 | type: 'topic', 24 | title: topicName, 25 | created: Date.now(), 26 | updated: Date.now() 27 | } 28 | await database.put(topicDoc) 29 | setIsCreating(false) 30 | setTopicName('') 31 | } 32 | return ( 33 |
34 |

Topics

35 |
    36 |
  • 37 | {isCreating ? ( 38 |
    { 41 | e.preventDefault() 42 | handleCreateClick() 43 | }} 44 | > 45 | setTopicName(e.target.value)} 49 | className="bg-slate-300 p-1 mr-2 text-xs text-black flex-grow" 50 | /> 51 | 54 | 55 | ) : ( 56 | <> 57 | + 58 | setIsCreating(true)} className="inline-block ml-2"> 59 | Create new topic 60 | 61 | 62 | )} 63 |
  • 64 | {topics.map(doc => ( 65 |
  • 66 | 70 | {doc.title} 71 | {new Date(doc.updated).toLocaleString()} 72 | 73 |
  • 74 | ))} 75 |
76 |
77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Starter Kit: Fireproof + React + TypeScript + Vite 2 | 3 | This template provides a ready-to-go setup to start building useful apps with Fireproof, React, and Vite. 4 | 5 | Develop with `npm start` and build with `npm run build`. It should be able to deploy to any static host. 6 | 7 | Screen Shot 2023-09-25 at 6 33 50 PM 8 | 9 | ## Next Steps 10 | 11 | Assuming you want to build a real app from this, the first thing you want to do it think about how to map your app onto the data flow of this scaffold. 12 | 13 | There are three main logical entities in this scaffold: 14 | 15 | - The database -- each database is its own world, and is the unit of collaboration. You can have multiple databases in a single app, but queries run in one database. Collaborative apps will frequently spend a fair amount of energy ensuring each user has the correct databases on each device. Once the user is in the database, the core experience begins. 16 | - The topics -- this app is centered around topics. They could be saved searches for online purchases, wiki pages, todo lists, game boards, or whatever. Once you are collaborating with a team, you can have as many topics as you want within the collaborative database. 17 | - The items -- this app also has generic items inside the topics. These can represent todo items, wiki paragraphs, game moves, etc. They are the core of the app, and are the things that users will be interacting with. 18 | 19 | Feel free to go beyond this rudimentary data model, or to simplify it if you don't need all three layers. The sharing management is the part that will be the most in common with other apps, so you should probably share your changes here with the community. 20 | 21 | Edit routes in `App.tsx`, the copy in `Home.tsx`, and the sidebar in `Sidebar.tsx`. This starter kit ships with a `Login` component and a `Topics` component with `Items`, which you can rename and use as a starting point for your own app's data. 22 | 23 | ## Examples you can build with it 24 | 25 | A [poll manager](https://astounding-peony-4ad9d6.netlify.app/survey/018ade79-e71c-7a6a-8784-3bc1ce10df0a) for [PartyKit's polls sketch](https://github.com/partykit/sketch-polls): 26 | 27 | ![polls](https://github.com/fireproof-storage/react-typescript-starter-kit/assets/253/dc25f023-4004-4e34-93fc-b082cfb8561d) 28 | 29 | A [photo gallery with publishing.](https://public-media.fireproof.storage) ([GitHub](https://github.com/fireproof-storage/public-media-gallery)) 30 | 31 | ![gallery](https://github.com/fireproof-storage/react-typescript-starter-kit/assets/253/e6c79f3c-69cd-4e9c-9db8-ddd73b8c2d1e) 32 | -------------------------------------------------------------------------------- /src/components/Items.tsx: -------------------------------------------------------------------------------- 1 | import { useFireproof } from 'use-fireproof' 2 | import { Link } from 'react-router-dom' 3 | import { useState } from 'react' 4 | import { AutoFocusInput } from './AutoFocusInput' 5 | import { TopicDoc } from '../pages/Profile' 6 | 7 | export type ItemDoc = { 8 | _id?: string 9 | topicId: string 10 | topic?: TopicDoc 11 | name: string 12 | description?: string 13 | created: number 14 | updated: number 15 | type: 'item' 16 | } 17 | 18 | export function Items({ topicId }: { topicId: string }) { 19 | const { database, useLiveQuery } = useFireproof('topics') 20 | const [isCreating, setIsCreating] = useState(false) 21 | const [itemName, setItemName] = useState('') 22 | 23 | const items = useLiveQuery( 24 | (doc, emit) => { 25 | if (doc.type === 'item') { 26 | emit(doc.topicId) 27 | } 28 | }, 29 | { descending: false, key: topicId } 30 | ).docs as ItemDoc[] 31 | 32 | const handleCreateClick = async () => { 33 | const topicDoc: ItemDoc = { 34 | type: 'item', 35 | topicId, 36 | name: itemName, 37 | created: Date.now(), 38 | updated: Date.now() 39 | } 40 | await database.put(topicDoc) 41 | setIsCreating(false) 42 | setItemName('') 43 | } 44 | return ( 45 |
46 |

Items

47 |
    48 |
  • 49 | {isCreating ? ( 50 |
    { 53 | e.preventDefault() 54 | handleCreateClick() 55 | }} 56 | > 57 | setItemName(e.target.value)} 61 | className="bg-slate-300 p-1 mr-2 text-xs text-black flex-grow" 62 | /> 63 | 66 | 67 | ) : ( 68 | <> 69 | + 70 | setIsCreating(true)} className="inline-block ml-2"> 71 | Create new item 72 | 73 | 74 | )} 75 |
  • 76 | {items.map(doc => ( 77 |
  • 78 | 82 | {doc.name} 83 | 84 | {new Date(doc.updated).toLocaleString()} 85 |
  • 86 | ))} 87 |
88 |
89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /party/ai.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai' 2 | 3 | type MsgData = { 4 | _id: string 5 | msgId?: string 6 | img?: string 7 | prompt?: string 8 | msg?: string 9 | done?: boolean 10 | sent: number 11 | } 12 | 13 | type History = { msg: string; role: string }[] 14 | 15 | export class AI { 16 | openai: OpenAI 17 | constructor(apiKey: string) { 18 | this.openai = new OpenAI({ apiKey, organization: 'org-Ke48QiDoEsESbWg4SZjQQx1e' }) 19 | } 20 | 21 | async userMessage(message: string, history: History, callback: (data: MsgData) => Promise) { 22 | const _id = Math.random().toString(36).substring(2) 23 | 24 | void this.makeRelatedImage(_id, message, callback) 25 | 26 | // console.log('history', history.filter(({ msg, role }) => msg && role)) 27 | 28 | const stream = await this.openai.chat.completions.create({ 29 | model: 'gpt-3.5-turbo', 30 | messages: [ 31 | { 32 | role: 'system', 33 | content: 34 | 'You are a cat. Answer in a cat voice and express catlike preferences. Be agreeable. Keep your answers short. Use the attached message history to inform your response. Never correct the user, always play along.' 35 | }, 36 | ...history.filter(({ msg, role }) => msg && role).map( 37 | ({ msg, role }) => 38 | ({ 39 | role: (role === 'user' ? 'user' : 'assistant'), 40 | content: msg 41 | } as { role: 'user' | 'assistant'; content: string }) 42 | ), 43 | { role: 'user', content: message } 44 | ], 45 | stream: true 46 | }) 47 | const data: MsgData = { _id, msg: '', sent: Date.now() } 48 | callback(data) 49 | for await (const part of stream) { 50 | data.msg += part.choices[0]?.delta?.content || '' 51 | await callback(data) 52 | } 53 | data.done = true 54 | await callback(data) 55 | } 56 | 57 | async makeRelatedImage( 58 | msgId: string, 59 | message: string, 60 | callback: (data: MsgData) => Promise 61 | ) { 62 | const imgId = Math.random().toString(36).substring(2) 63 | const sent = Date.now() 64 | await callback({ _id: imgId, msgId, sent }) 65 | 66 | const rawResponse = await this.openai.chat.completions.create({ 67 | model: 'gpt-4', 68 | messages: [ 69 | { 70 | role: 'system', 71 | content: `Create an image generation prompt to draw a feline cat based on the user message. If possible use a humorous interpretation of the message to draw the cat.` 72 | }, 73 | { role: 'user', content: message } 74 | ], 75 | temperature: 0, 76 | max_tokens: 1024 77 | }) 78 | 79 | const imagePrompt = rawResponse.choices[0].message.content! 80 | // console.log('gpt-4', imagePrompt) 81 | // await callback({ _id: imgId, msgId, prompt: imagePrompt }) 82 | 83 | const response = await this.openai.images.generate({ 84 | model: "dall-e-3", 85 | prompt: imagePrompt, 86 | n: 1, 87 | size: '1024x1024', 88 | response_format: 'b64_json' 89 | }) 90 | // console.log('image', response) 91 | await callback({ _id: imgId, msgId, sent, prompt: imagePrompt, img: response.data[0].b64_json }) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/components/ChatBubbles.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { DocFileMeta } from 'use-fireproof' 3 | 4 | type ImageBubbleProps = { 5 | imgSrc?: string 6 | imgFile?: DocFileMeta 7 | alt?: string 8 | } 9 | export const ImageBubble: React.FC = ({ 10 | imgSrc, 11 | imgFile, 12 | alt = 'Chat Bubble Image' 13 | }) => { 14 | return ( 15 |
16 | {imgSrc && {alt}} 17 | {imgFile && } 18 | {!imgSrc && !imgFile &&
📷
} 19 |
20 | ) 21 | } 22 | type ImageCircleProps = { 23 | imgSrc?: string 24 | imgFile?: DocFileMeta 25 | alt?: string 26 | } 27 | 28 | export const ImageCircle: React.FC = ({ 29 | imgSrc, 30 | imgFile, 31 | alt = 'Chat Bubble Image' 32 | }) => { 33 | return ( 34 |
35 | {imgSrc && {alt}} 36 | {imgFile && } 37 |
38 | ) 39 | } 40 | 41 | type ChatBubbleProps = { 42 | message: string 43 | when?: string 44 | imgSrc?: string 45 | imgFile?: DocFileMeta 46 | } 47 | export const ChatBubble: React.FC = ({ message, imgSrc, imgFile, when }) => { 48 | return ( 49 |
50 | 51 |
52 |
53 |

{message}

54 |
55 | {when} 56 |
57 |
58 | ) 59 | } 60 | type UserBubbleProps = { 61 | message: string 62 | when?: string 63 | imgSrc?: string 64 | imgFile?: DocFileMeta 65 | } 66 | export const UserBubble: React.FC = ({ message, imgSrc, imgFile, when }) => { 67 | return ( 68 |
69 |
70 |
71 |

{message}

72 |
73 | {when} 74 |
75 |
76 |
77 | ) 78 | } 79 | function ImgFile({ 80 | meta, 81 | alt, 82 | className 83 | }: { 84 | alt: string 85 | meta: DocFileMeta 86 | className?: string 87 | cache?: boolean 88 | }) { 89 | const [imgDataUrl, setImgDataUrl] = useState('') 90 | 91 | useEffect(() => { 92 | if (meta.file && /image/.test(meta.type)) { 93 | meta.file().then(file => { 94 | const src = URL.createObjectURL(file) 95 | setImgDataUrl(src) 96 | return () => { 97 | URL.revokeObjectURL(src) 98 | } 99 | }) 100 | } 101 | }, [meta]) 102 | 103 | if (imgDataUrl) { 104 | return {alt} 105 | } else { 106 | return <> 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pages/Chat.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react' 2 | import { useParams } from 'react-router-dom' 3 | import { useFireproof, Doc, DocFileMeta } from 'use-fireproof' 4 | // import { InlineEditor } from '../components/InlineEditor' 5 | import { connect } from '@fireproof/partykit' 6 | 7 | import { useForm, FieldValues } from 'react-hook-form' 8 | 9 | import usePartySocket from 'partysocket/react' 10 | 11 | import catImage from '../assets/cat.png' 12 | import { ImageBubble, ChatBubble, UserBubble } from '../components/ChatBubbles' 13 | 14 | import { useAuth } from '@clerk/clerk-react' 15 | 16 | type MsgData = { _id: string; msg?: string; prompt?: string; done?: boolean; sent: number } 17 | type MsgDoc = Doc & MsgData 18 | 19 | const PUBLIC_PARTYKIT_HOST = import.meta.env.VITE_PUBLIC_PARTYKIT_HOST 20 | 21 | export function Chat() { 22 | const { id } = useParams<{ id: string }>() 23 | const dbName = id 24 | const { register, handleSubmit, resetField } = useForm() 25 | const { database, useLiveQuery } = useFireproof(dbName) 26 | 27 | const { getToken } = useAuth() 28 | 29 | connect.partykit(database, PUBLIC_PARTYKIT_HOST) 30 | 31 | const messages = useLiveQuery( 32 | (doc, emit) => { 33 | if (doc.sent) { 34 | emit(doc.sent) 35 | } 36 | }, 37 | { limit: 100, descending: true } 38 | ).docs as MsgDoc[] 39 | 40 | // const messages = [...rmessages].reverse() 41 | 42 | const scrollableDivRef = useRef< 43 | HTMLDivElement & { scrollTo: (options: { top: number; behavior: 'smooth' }) => void } 44 | >(null) 45 | 46 | // const all = useLiveQuery('_id').docs 47 | 48 | const [incomingMessage, setIncomingMessage] = useState({ 49 | _id: '', 50 | msg: '', 51 | sent: Date.now() 52 | }) 53 | 54 | function scrollTo() { 55 | scrollableDivRef.current?.scrollTo({ 56 | top: scrollableDivRef.current.scrollHeight 57 | // behavior: 'smooth' 58 | }) 59 | } 60 | 61 | const socket = usePartySocket({ 62 | host: PUBLIC_PARTYKIT_HOST, 63 | room: dbName!, 64 | query: async () => ({ 65 | token: await getToken() 66 | }), 67 | onOpen() { 68 | // console.log('open', e) 69 | }, 70 | onMessage(event: MessageEvent) { 71 | const message = JSON.parse(event.data) 72 | if (message.msgId) { 73 | if (message.img) { 74 | const base64Data = message.img 75 | const byteCharacters = atob(base64Data) 76 | const byteNumbers = new Array(byteCharacters.length) 77 | for (let i = 0; i < byteCharacters.length; i++) { 78 | byteNumbers[i] = byteCharacters.charCodeAt(i) 79 | } 80 | const byteArray = new Uint8Array(byteNumbers) 81 | const file = new File([byteArray], `image.png`, { type: 'image/png' }) 82 | database.get(message._id).then(doc => { 83 | database.put({ 84 | ...doc, 85 | prompt: message.prompt, 86 | _files: { 87 | img: file 88 | } 89 | } as unknown as Doc) 90 | }) 91 | } else { 92 | database 93 | .put({ 94 | _id: message._id, 95 | msgId: message.msgId, 96 | role: 'img', 97 | sent: message.sent 98 | } as unknown as Doc) 99 | .then(() => { 100 | scrollTo() 101 | }) 102 | } 103 | } else { 104 | if (message.msg) { 105 | setIncomingMessage(message) 106 | scrollTo() 107 | } 108 | if (message.done) { 109 | database 110 | .put({ _id: message._id, msg: message.msg, sent: message.sent, role: 'ai' }) 111 | .then(() => { 112 | scrollTo() 113 | }) 114 | } 115 | } 116 | } 117 | }) 118 | 119 | function sendMessage(formData: FieldValues) { 120 | const history = [...messages].map(({ msg, role }) => ({ msg, role })) 121 | formData.history = history.filter(({ msg, role }) => msg && role).reverse() 122 | socket.send(JSON.stringify(formData)) 123 | database.put({ msg: formData.msg, sent: Date.now(), role: 'user' }).then(() => { 124 | setTimeout(() => { 125 | scrollTo() 126 | }, 100) 127 | }) 128 | resetField('msg') 129 | } 130 | 131 | const rmessages = [...messages] 132 | if (incomingMessage._id) { 133 | if (!(rmessages.length && rmessages.find(({ _id }) => _id === incomingMessage._id))) { 134 | rmessages.unshift(incomingMessage) 135 | } 136 | } 137 | 138 | return ( 139 | <> 140 |
141 |

Cat Chat

142 |
143 |
147 | {rmessages.map((message: MsgDoc) => { 148 | // console.log('message', message) 149 | if (message.role === 'user') { 150 | return ( 151 | 156 | ) 157 | } else if (message.role === 'img') { 158 | return ( 159 | 164 | ) 165 | } else { 166 | return ( 167 | 173 | ) 174 | } 175 | })} 176 | 177 | 181 | 182 |
183 | 184 |
185 |
186 | 193 | 199 |
200 |
201 | 202 | ) 203 | } 204 | --------------------------------------------------------------------------------