From 5bbcdcca2c047e566fc2fd7ebc69311e53fad436 Mon Sep 17 00:00:00 2001 From: Dominic Elm Date: Thu, 1 Aug 2024 16:54:59 +0200 Subject: [PATCH] feat(ui): style sidebar and landing page (#27) --- .../bolt/app/components/chat/BaseChat.tsx | 57 ++++++--- .../bolt/app/components/chat/Chat.client.tsx | 27 +++-- .../bolt/app/components/header/Header.tsx | 18 ++- .../app/components/sidebar/HistoryItem.tsx | 80 ++++++++++++ .../app/components/sidebar/Menu.client.tsx | 114 ++++++++++++++++++ .../{sidemenu => sidebar}/date-binning.ts | 12 +- .../app/components/sidemenu/HistoryItem.tsx | 88 -------------- .../app/components/sidemenu/Menu.client.tsx | 99 --------------- packages/bolt/app/lib/stores/chat.ts | 1 + packages/bolt/app/styles/index.scss | 24 ---- packages/bolt/icons/chat.svg | 1 + packages/bolt/public/logo.svg | 1 + packages/bolt/public/logo_text.svg | 1 + 13 files changed, 275 insertions(+), 248 deletions(-) create mode 100644 packages/bolt/app/components/sidebar/HistoryItem.tsx create mode 100644 packages/bolt/app/components/sidebar/Menu.client.tsx rename packages/bolt/app/components/{sidemenu => sidebar}/date-binning.ts (78%) delete mode 100644 packages/bolt/app/components/sidemenu/HistoryItem.tsx delete mode 100644 packages/bolt/app/components/sidemenu/Menu.client.tsx create mode 100644 packages/bolt/icons/chat.svg create mode 100644 packages/bolt/public/logo.svg create mode 100644 packages/bolt/public/logo_text.svg diff --git a/packages/bolt/app/components/chat/BaseChat.tsx b/packages/bolt/app/components/chat/BaseChat.tsx index 7bbbeed..76f1b8f 100644 --- a/packages/bolt/app/components/chat/BaseChat.tsx +++ b/packages/bolt/app/components/chat/BaseChat.tsx @@ -1,15 +1,15 @@ import type { Message } from 'ai'; -import React, { type LegacyRef, type RefCallback } from 'react'; +import React, { type RefCallback } from 'react'; import { ClientOnly } from 'remix-utils/client-only'; +import { Menu } from '~/components/sidebar/Menu.client'; import { IconButton } from '~/components/ui/IconButton'; import { Workbench } from '~/components/workbench/Workbench.client'; import { classNames } from '~/utils/classNames'; import { Messages } from './Messages.client'; -import { Menu } from '~/components/sidemenu/Menu.client'; import { SendButton } from './SendButton.client'; interface BaseChatProps { - textareaRef?: LegacyRef | undefined; + textareaRef?: React.RefObject | undefined; messageRef?: RefCallback | undefined; scrollRef?: RefCallback | undefined; chatStarted?: boolean; @@ -19,12 +19,18 @@ interface BaseChatProps { promptEnhanced?: boolean; input?: string; handleStop?: () => void; - sendMessage?: (event: React.UIEvent) => void; + sendMessage?: (event: React.UIEvent, messageInput?: string) => void; handleInputChange?: (event: React.ChangeEvent) => void; enhancePrompt?: () => void; } -const EXAMPLES = [{ text: 'Example' }, { text: 'Example' }, { text: 'Example' }, { text: 'Example' }]; +const EXAMPLE_PROMPTS = [ + { text: 'Build a todo app in React using Tailwind' }, + { text: 'Build a simple blog using Astro' }, + { text: 'Create a cookie consent form using Material UI' }, + { text: 'Make a space invaders game' }, + { text: 'How do I can center a div?' }, +]; const TEXTAREA_MIN_HEIGHT = 72; @@ -53,22 +59,15 @@ export const BaseChat = React.forwardRef(
{() => }
-
+
{!chatStarted && ( -
-

Where ideas begin.

-

Bring ideas to life in seconds or get help on existing projects.

-
- {EXAMPLES.map((suggestion, index) => ( - - ))} -
+
+

Where ideas begin

+

Bring ideas to life in seconds or get help on existing projects.

)}
@@ -77,7 +76,7 @@ export const BaseChat = React.forwardRef( return chatStarted ? ( @@ -85,7 +84,7 @@ export const BaseChat = React.forwardRef( }}
@@ -172,6 +171,26 @@ export const BaseChat = React.forwardRef(
{/* Ghost Element */}
+ {!chatStarted && ( +
+
+ {EXAMPLE_PROMPTS.map((examplePrompt, index) => { + return ( + + ); + })} +
+
+ )}
{() => }
diff --git a/packages/bolt/app/components/chat/Chat.client.tsx b/packages/bolt/app/components/chat/Chat.client.tsx index 2e89dbf..73d167e 100644 --- a/packages/bolt/app/components/chat/Chat.client.tsx +++ b/packages/bolt/app/components/chat/Chat.client.tsx @@ -44,7 +44,7 @@ export function ChatImpl({ initialMessages, storeMessageHistory }: ChatProps) { const [animationScope, animate] = useAnimate(); - const { messages, isLoading, input, handleInputChange, setInput, handleSubmit, stop, append } = useChat({ + const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({ api: '/api/chat', onError: (error) => { logger.error('Request failed\n\n', error); @@ -61,6 +61,10 @@ export function ChatImpl({ initialMessages, storeMessageHistory }: ChatProps) { const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; + useEffect(() => { + chatStore.setKey('started', initialMessages.length > 0); + }, []); + useEffect(() => { parseMessages(messages, isLoading); @@ -101,13 +105,20 @@ export function ChatImpl({ initialMessages, storeMessageHistory }: ChatProps) { return; } - await animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }); + await Promise.all([ + animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }), + animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }), + ]); + + chatStore.setKey('started', true); setChatStarted(true); }; - const sendMessage = async (event: React.UIEvent) => { - if (input.length === 0 || isLoading) { + const sendMessage = async (_event: React.UIEvent, messageInput?: string) => { + const _input = messageInput || input; + + if (_input.length === 0 || isLoading) { return; } @@ -136,9 +147,7 @@ export function ChatImpl({ initialMessages, storeMessageHistory }: ChatProps) { * manually reset the input and we'd have to manually pass in file attachments. However, those * aren't relevant here. */ - append({ role: 'user', content: `${diff}\n\n${input}` }); - - setInput(''); + append({ role: 'user', content: `${diff}\n\n${_input}` }); /** * After sending a new message we reset all modifications since the model @@ -146,9 +155,11 @@ export function ChatImpl({ initialMessages, storeMessageHistory }: ChatProps) { */ workbenchStore.resetAllFileModifications(); } else { - handleSubmit(event); + append({ role: 'user', content: _input }); } + setInput(''); + resetEnhancer(); textareaRef.current?.blur(); diff --git a/packages/bolt/app/components/header/Header.tsx b/packages/bolt/app/components/header/Header.tsx index f7a7940..7f5f9fc 100644 --- a/packages/bolt/app/components/header/Header.tsx +++ b/packages/bolt/app/components/header/Header.tsx @@ -1,20 +1,26 @@ +import { useStore } from '@nanostores/react'; import { ClientOnly } from 'remix-utils/client-only'; -import { IconButton } from '~/components/ui/IconButton'; +import { chatStore } from '~/lib/stores/chat'; +import { classNames } from '~/utils/classNames'; import { OpenStackBlitz } from './OpenStackBlitz.client'; export function Header() { + const chat = useStore(chatStore); + return ( -
+
{() => } - - -
); diff --git a/packages/bolt/app/components/sidebar/HistoryItem.tsx b/packages/bolt/app/components/sidebar/HistoryItem.tsx new file mode 100644 index 0000000..4241656 --- /dev/null +++ b/packages/bolt/app/components/sidebar/HistoryItem.tsx @@ -0,0 +1,80 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { toast } from 'react-toastify'; +import { db, deleteId, type ChatHistory } from '~/lib/persistence'; +import { logger } from '~/utils/logger'; + +export function HistoryItem({ item, loadEntries }: { item: ChatHistory; loadEntries: () => void }) { + const [requestingDelete, setRequestingDelete] = useState(false); + const [hovering, setHovering] = useState(false); + const hoverRef = useRef(null); + + const deleteItem = useCallback((event: React.UIEvent) => { + event.preventDefault(); + + if (db) { + deleteId(db, item.id) + .then(() => loadEntries()) + .catch((error) => { + toast.error('Failed to delete conversation'); + logger.error(error); + }); + } + }, []); + + useEffect(() => { + let timeout: NodeJS.Timeout | undefined; + + function mouseEnter() { + setHovering(true); + + if (timeout) { + clearTimeout(timeout); + } + } + + function mouseLeave() { + setHovering(false); + + // wait for animation to finish before unsetting + timeout = setTimeout(() => { + setRequestingDelete(false); + }, 200); + } + + hoverRef.current?.addEventListener('mouseenter', mouseEnter); + hoverRef.current?.addEventListener('mouseleave', mouseLeave); + + return () => { + hoverRef.current?.removeEventListener('mouseenter', mouseEnter); + hoverRef.current?.removeEventListener('mouseleave', mouseLeave); + }; + }, []); + + return ( + + ); +} diff --git a/packages/bolt/app/components/sidebar/Menu.client.tsx b/packages/bolt/app/components/sidebar/Menu.client.tsx new file mode 100644 index 0000000..e9748ed --- /dev/null +++ b/packages/bolt/app/components/sidebar/Menu.client.tsx @@ -0,0 +1,114 @@ +import { motion, type Variants } from 'framer-motion'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { toast } from 'react-toastify'; +import { IconButton } from '~/components/ui/IconButton'; +import { db, getAll, type ChatHistory } from '~/lib/persistence'; +import { cubicEasingFn } from '~/utils/easings'; +import { HistoryItem } from './HistoryItem'; +import { binDates } from './date-binning'; + +const menuVariants = { + closed: { + opacity: 0, + left: '-150px', + transition: { + duration: 0.2, + ease: cubicEasingFn, + }, + }, + open: { + opacity: 1, + left: 0, + transition: { + duration: 0.2, + ease: cubicEasingFn, + }, + }, +} satisfies Variants; + +export function Menu() { + const menuRef = useRef(null); + const [list, setList] = useState([]); + const [open, setOpen] = useState(false); + + const loadEntries = useCallback(() => { + if (db) { + getAll(db) + .then((list) => list.filter((item) => item.urlId && item.description)) + .then(setList) + .catch((error) => toast.error(error.message)); + } + }, []); + + useEffect(() => { + if (open) { + loadEntries(); + } + }, [open]); + + useEffect(() => { + function onMouseMove(event: MouseEvent) { + if (event.pageX < 80) { + setOpen(true); + } + } + + function onMouseLeave(_event: MouseEvent) { + setOpen(false); + } + + menuRef.current?.addEventListener('mouseleave', onMouseLeave); + window.addEventListener('mousemove', onMouseMove); + + return () => { + menuRef.current?.removeEventListener('mouseleave', onMouseLeave); + window.removeEventListener('mousemove', onMouseMove); + }; + }, []); + + return ( + +
+ Bolt Logo +
+
+ +
Your Chats
+
+ {list.length === 0 &&
No previous conversations
} + {binDates(list).map(({ category, items }) => ( +
+
{category}
+ {items.map((item) => ( + + ))} +
+ ))} +
+ +
+
+ ); +} diff --git a/packages/bolt/app/components/sidemenu/date-binning.ts b/packages/bolt/app/components/sidebar/date-binning.ts similarity index 78% rename from packages/bolt/app/components/sidemenu/date-binning.ts rename to packages/bolt/app/components/sidebar/date-binning.ts index b69cd84..35cf453 100644 --- a/packages/bolt/app/components/sidemenu/date-binning.ts +++ b/packages/bolt/app/components/sidebar/date-binning.ts @@ -1,5 +1,5 @@ +import { format, isAfter, isThisWeek, isThisYear, isToday, isYesterday, subDays } from 'date-fns'; import type { ChatHistory } from '~/lib/persistence'; -import { format, isToday, isYesterday, isThisWeek, isThisYear, subDays, isAfter } from 'date-fns'; type Bin = { category: string; items: ChatHistory[] }; @@ -19,6 +19,7 @@ export function binDates(_list: ChatHistory[]) { }; binLookup[category] = bin; + bins.push(bin); } else { binLookup[category].items.push(item); @@ -38,7 +39,8 @@ function dateCategory(date: Date) { } if (isThisWeek(date)) { - return format(date, 'eeee'); // e.g. "Monday" + // e.g., "Monday" + return format(date, 'eeee'); } const thirtyDaysAgo = subDays(new Date(), 30); @@ -48,8 +50,10 @@ function dateCategory(date: Date) { } if (isThisYear(date)) { - return format(date, 'MMMM'); // e.g., "July" + // e.g., "July" + return format(date, 'MMMM'); } - return format(date, 'MMMM yyyy'); // e.g. "July 2023" + // e.g., "July 2023" + return format(date, 'MMMM yyyy'); } diff --git a/packages/bolt/app/components/sidemenu/HistoryItem.tsx b/packages/bolt/app/components/sidemenu/HistoryItem.tsx deleted file mode 100644 index a09795a..0000000 --- a/packages/bolt/app/components/sidemenu/HistoryItem.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { toast } from 'react-toastify'; -import { useCallback, useEffect, useState, useRef } from 'react'; -import { motion, type Variants } from 'framer-motion'; -import { cubicEasingFn } from '~/utils/easings'; -import { IconButton } from '~/components/ui/IconButton'; -import { db, deleteId, type ChatHistory } from '~/lib/persistence'; - -const iconVariants = { - closed: { - transform: 'translate(40px,0)', - opacity: 0, - transition: { - duration: 0.2, - ease: cubicEasingFn, - }, - }, - open: { - transform: 'translate(0,0)', - opacity: 1, - transition: { - duration: 0.2, - ease: cubicEasingFn, - }, - }, -} satisfies Variants; - -export function HistoryItem({ item, loadEntries }: { item: ChatHistory; loadEntries: () => void }) { - const [requestingDelete, setRequestingDelete] = useState(false); - const [hovering, setHovering] = useState(false); - const hoverRef = useRef(null); - - const deleteItem = useCallback(() => { - if (db) { - deleteId(db, item.id) - .then(() => loadEntries()) - .catch((error) => toast.error(error.message)); - } - }, []); - - useEffect(() => { - let timeout: NodeJS.Timeout | undefined; - - function mouseEnter() { - setHovering(true); - - if (timeout) { - clearTimeout(timeout); - } - } - - function mouseLeave() { - setHovering(false); - - // wait for animation to finish before unsetting - timeout = setTimeout(() => { - setRequestingDelete(false); - }, 200); - } - - hoverRef.current?.addEventListener('mouseenter', mouseEnter); - hoverRef.current?.addEventListener('mouseleave', mouseLeave); - - return () => { - hoverRef.current?.removeEventListener('mouseenter', mouseEnter); - hoverRef.current?.removeEventListener('mouseleave', mouseLeave); - }; - }, []); - - return ( -
-
- - {item.description} - - - {requestingDelete ? ( - - ) : ( - setRequestingDelete(true)} /> - )} - -
-
- ); -} diff --git a/packages/bolt/app/components/sidemenu/Menu.client.tsx b/packages/bolt/app/components/sidemenu/Menu.client.tsx deleted file mode 100644 index 8c77c49..0000000 --- a/packages/bolt/app/components/sidemenu/Menu.client.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { Fragment, useEffect, useState, useRef, useCallback } from 'react'; -import { toast } from 'react-toastify'; -import { motion, type Variants } from 'framer-motion'; -import { cubicEasingFn } from '~/utils/easings'; -import { db, getAll, type ChatHistory } from '~/lib/persistence'; -import { HistoryItem } from './HistoryItem'; -import { binDates } from './date-binning'; - -const menuVariants = { - closed: { - left: '-400px', - transition: { - duration: 0.2, - ease: cubicEasingFn, - }, - }, - open: { - left: 0, - transition: { - duration: 0.2, - ease: cubicEasingFn, - }, - }, -} satisfies Variants; - -export function Menu() { - const menuRef = useRef(null); - const [list, setList] = useState([]); - const [open, setOpen] = useState(false); - - const loadEntries = useCallback(() => { - if (db) { - getAll(db) - .then((list) => list.filter((item) => item.urlId && item.description)) - .then(setList) - .catch((error) => toast.error(error.message)); - } - }, []); - - useEffect(() => { - if (open) { - loadEntries(); - } - }, [open]); - - useEffect(() => { - function onMouseMove(event: MouseEvent) { - if (event.pageX < 80) { - setOpen(true); - } - } - - function onMouseLeave(_event: MouseEvent) { - setOpen(false); - } - - menuRef.current?.addEventListener('mouseleave', onMouseLeave); - window.addEventListener('mousemove', onMouseMove); - - return () => { - menuRef.current?.removeEventListener('mouseleave', onMouseLeave); - window.removeEventListener('mousemove', onMouseMove); - }; - }, []); - - return ( - - - -
Your Chats
-
- {list.length === 0 &&
No previous conversations
} - {binDates(list).map(({ category, items }) => ( - -
{category}
- {items.map((item) => ( - - ))} -
- ))} -
-
- - ); -} diff --git a/packages/bolt/app/lib/stores/chat.ts b/packages/bolt/app/lib/stores/chat.ts index d5f3a37..d3114a4 100644 --- a/packages/bolt/app/lib/stores/chat.ts +++ b/packages/bolt/app/lib/stores/chat.ts @@ -1,5 +1,6 @@ import { map } from 'nanostores'; export const chatStore = map({ + started: false, aborted: false, }); diff --git a/packages/bolt/app/styles/index.scss b/packages/bolt/app/styles/index.scss index 3901a96..928c15f 100644 --- a/packages/bolt/app/styles/index.scss +++ b/packages/bolt/app/styles/index.scss @@ -4,30 +4,6 @@ @import './components/terminal.scss'; @import './components/resize-handle.scss'; -body { - --at-apply: bg-bolt-elements-app-backgroundColor; - - font-family: 'Inter', sans-serif; - - &:before { - --line: color-mix(in lch, canvasText, transparent 93%); - --size: 50px; - - content: ''; - height: 100vh; - mask: linear-gradient(-25deg, transparent 60%, white); - pointer-events: none; - position: fixed; - top: -8px; - transform-style: flat; - width: 100vw; - z-index: -1; - background: - linear-gradient(90deg, var(--line) 1px, transparent 1px var(--size)) 50% 50% / var(--size) var(--size), - linear-gradient(var(--line) 1px, transparent 1px var(--size)) 50% 50% / var(--size) var(--size); - } -} - html, body { height: 100%; diff --git a/packages/bolt/icons/chat.svg b/packages/bolt/icons/chat.svg new file mode 100644 index 0000000..b341254 --- /dev/null +++ b/packages/bolt/icons/chat.svg @@ -0,0 +1 @@ + diff --git a/packages/bolt/public/logo.svg b/packages/bolt/public/logo.svg new file mode 100644 index 0000000..58d6874 --- /dev/null +++ b/packages/bolt/public/logo.svg @@ -0,0 +1 @@ + diff --git a/packages/bolt/public/logo_text.svg b/packages/bolt/public/logo_text.svg new file mode 100644 index 0000000..e0daa80 --- /dev/null +++ b/packages/bolt/public/logo_text.svg @@ -0,0 +1 @@ +