feat(file input): adding feature to add and remove files

Next up is to create a seperate menu that will show all the files.
Probably as a seperate frame. Needs to be scrollable and can use similar
ui as before
This commit is contained in:
JK-le-dev 2025-10-19 04:32:32 -05:00
commit 7785047976
11 changed files with 182 additions and 48 deletions

15
web-app/components.json Normal file
View file

@ -0,0 +1,15 @@
{
"style": "default",
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"rsc": false,
"tsx": false,
"aliases": {
"utils": "~/lib/utils",
"components": "~/components"
}
}

View file

@ -13,6 +13,7 @@
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"class-variance-authority": "^0.7.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.1.0", "express": "^5.1.0",
@ -26,10 +27,12 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^7.9.4", "react-router": "^7.9.4",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.4",
"shadcn-ui": "^0.9.5",
"vite-jsconfig-paths": "^2.0.1" "vite-jsconfig-paths": "^2.0.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.38.0", "@eslint/js": "^9.38.0",
"daisyui": "^5.3.7",
"eslint": "^9.38.0", "eslint": "^9.38.0",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
@ -2607,12 +2610,33 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"license": "Apache-2.0",
"dependencies": {
"clsx": "^2.1.1"
},
"funding": {
"url": "https://polar.sh/cva"
}
},
"node_modules/classnames": { "node_modules/classnames": {
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2735,6 +2759,16 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/daisyui": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.3.7.tgz",
"integrity": "sha512-0+8PaSGift0HlIQABCeZzWOBV5Nx/vsI2TihB9hbaEyZENPlZZz+se2JnAH5rz9gBYTyDLB7NJup8hkREr6WBw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
}
},
"node_modules/data-view-buffer": { "node_modules/data-view-buffer": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@ -7558,6 +7592,30 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/shadcn-ui": {
"version": "0.9.5",
"resolved": "https://registry.npmjs.org/shadcn-ui/-/shadcn-ui-0.9.5.tgz",
"integrity": "sha512-dsBQWpdLLYCdSdmvOmu53nJhhWnQD1OiblhuhkI4rPYxPKTyfbmZ2NTJHWMu1fXN9PTfN6IVK5vvh+BrjHJx2g==",
"license": "MIT",
"dependencies": {
"chalk": "^5.4.1"
},
"bin": {
"shadcn-ui": "dist/index.js"
}
},
"node_modules/shadcn-ui/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View file

@ -18,6 +18,7 @@
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"class-variance-authority": "^0.7.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.1.0", "express": "^5.1.0",
@ -31,11 +32,13 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^7.9.4", "react-router": "^7.9.4",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.4",
"shadcn-ui": "^0.9.5",
"vite-jsconfig-paths": "^2.0.1" "vite-jsconfig-paths": "^2.0.1"
}, },
"packageManager": ">=npm@10.9.0", "packageManager": ">=npm@10.9.0",
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.38.0", "@eslint/js": "^9.38.0",
"daisyui": "^5.3.7",
"eslint": "^9.38.0", "eslint": "^9.38.0",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",

View file

@ -5,6 +5,7 @@ function App() {
return ( return (
<div className="dark min-h-screen bg-gray-950 text-white flex justify-center pt-12"> <div className="dark min-h-screen bg-gray-950 text-white flex justify-center pt-12">
<ChatLayout /> <ChatLayout />
<div></div>
</div> </div>
); );
} }

View file

@ -3,20 +3,6 @@ import ChatHeader from "src/components/ui/chat/chat-header";
import ChatWindow from "src/components/ui/chat/chat-window"; import ChatWindow from "src/components/ui/chat/chat-window";
import MessageInput from "src/components/ui/chat/message-input"; import MessageInput from "src/components/ui/chat/message-input";
import { GoogleGenAI } from "@google/genai"
const ai = new GoogleGenAI({ apiKey: import.meta.env.GEMINI_API_KEY })
async function AIRepsponse(userInputArray) {
const response = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: userInputArray
})
return response.text
}
let userInput = []
export default function ChatLayout() { export default function ChatLayout() {
const [messages, setMessages] = useState([ const [messages, setMessages] = useState([
{ {
@ -25,23 +11,21 @@ export default function ChatLayout() {
}, },
]); ]);
async function handleSend(text) { function handleSend(text) {
userInput.push(text)
const res = await AIRepsponse(userInput)
const userMsg = { role: "user", content: text }; const userMsg = { role: "user", content: text };
setMessages((s) => [...s, userMsg]); setMessages((s) => [...s, userMsg]);
// fake assistant reply after short delay
setTimeout(() => { setTimeout(() => {
setMessages((s) => [ setMessages((s) => [
...s, ...s,
{ role: "assistant", content: res }, { role: "assistant", content: `You said: ${text}` },
]); ]);
}, 600); }, 600);
} }
return ( return (
<div className="w-full max-w-4xl gap-4 p-4"> <div className="flex flex-col flex-start w-full max-w-3xl gap-4 p-4">
<ChatHeader /> <ChatHeader />
<ChatWindow messages={messages} /> <ChatWindow messages={messages} />
<MessageInput onSend={handleSend} /> <MessageInput onSend={handleSend} />

View file

@ -5,7 +5,7 @@ export default function FlameButton({ onClick }) {
return ( return (
<motion.button <motion.button
onClick={onClick} onClick={onClick}
className="bg-gray-700 p-2 rounded-2xl" className="bg-gray-700 cursor-pointer p-2 rounded-2xl border-2 border-gray-600"
whileHover={{ scale: 1.1 }} whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
> >

View file

@ -6,7 +6,7 @@ export default function DownButton({ onClick }) {
return ( return (
<motion.button <motion.button
onClick={onClick} onClick={onClick}
className="bg-gray-700 p-2 rounded-2xl" className="bg-gray-700 p-2 rounded-2xl file-input border-2 border-gray-600"
whileHover={{ scale: 1.1 }} whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
> >

View file

@ -1,16 +1,71 @@
import React from "react"; import React, { useState, useRef } from "react";
import { X } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { FilePlus2 } from "lucide-react"; import { FilePlus2 } from "lucide-react";
export default function SchematicButton({ onClick }) { export default function SchematicButton({ onFiles }) {
const [filesList, setFilesList] = useState([]);
const inputRef = useRef(null);
function handleFiles(e) {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
setFilesList((s) => [...s, ...files]);
if (onFiles) onFiles(files);
if (inputRef.current) inputRef.current.value = null;
}
function removeFile(index) {
setFilesList((s) => {
const copy = [...s];
copy.splice(index, 1);
return copy;
});
}
return ( return (
<motion.button <div className="flex items-center gap-2">
onClick={onClick} <label className="relative inline-block">
className=" bg-gray-700 p-2 rounded-2xl" <motion.input
whileHover={{ scale: 1.1 }} ref={inputRef}
whileTap={{ scale: 0.9 }} type="file"
> accept="image/*,application/pdf"
<FilePlus2 /> multiple
</motion.button> onChange={handleFiles}
className="file-input hidden"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
/>
<motion.div
className="bg-gray-700 p-2 rounded-2xl cursor-pointer border-2 border-gray-600"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => inputRef.current && inputRef.current.click()}
>
<FilePlus2 />
</motion.div>
</label>
{filesList.length > 0 && (
<div className="flex gap-2 items-center max-w-xs flex-wrap">
{filesList.map((f, i) => (
<div
key={i}
className="flex items-center gap-2 bg-gray-800 text-sm text-slate-200 px-2 py-1 rounded"
>
<span className="truncate max-w-[10rem]">{f.name}</span>
<button
onClick={() => removeFile(i)}
className="bg-gray-900 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs border-2 border-gray-700"
aria-label="Remove file"
>
<X />
</button>
</div>
))}
</div>
)}
</div>
); );
} }

View file

@ -1,13 +1,17 @@
import React from "react"; import React from "react";
import DeleteButton from "src/components/ui/button/delete-button";
import SchematicButton from "../button/schematic-button";
export default function ChatHeader({ title = "Title of Chat" }) { export default function ChatHeader({ title = "Title of Chat" }) {
return ( return (
<div className="w-full flex justify-center"> <div className="w-full flex justify-center">
<header className="text-slate-100 fixed top-4 "> <header className="text-slate-100 fixed top-4 max-w-3xl w-full px-4">
<div> <div className="flex justify-between items-center gap-4">
<SchematicButton></SchematicButton>
<h1 className="text-lg font-semibold shadow-md shadow-indigo-600 bg-gray-900 px-6 py-2 rounded-4xl border-2 border-gray-800"> <h1 className="text-lg font-semibold shadow-md shadow-indigo-600 bg-gray-900 px-6 py-2 rounded-4xl border-2 border-gray-800">
{title} {title}
</h1> </h1>
<DeleteButton></DeleteButton>
</div> </div>
</header> </header>
</div> </div>

View file

@ -1,12 +1,17 @@
import React, { useState } from "react"; import React, { useState, useRef, useEffect } from "react";
import DeleteButton from "src/components/ui/button/delete-button"; import DeleteButton from "src/components/ui/button/delete-button";
import DownButton from "src/components/ui/button/down-button"; import DownButton from "src/components/ui/button/down-button";
import SchematicButton from "src/components/ui/button/schematic-button";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { BotMessageSquare } from "lucide-react"; import { BotMessageSquare } from "lucide-react";
export default function MessageInput({ onSend }) { export default function MessageInput({ onSend }) {
const [text, setText] = useState(""); const [text, setText] = useState("");
const textareaRef = useRef(null);
useEffect(() => {
// ensure correct initial height
if (textareaRef.current) textareaRef.current.style.height = "auto";
}, []);
function handleSubmit(e) { function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
@ -17,29 +22,37 @@ export default function MessageInput({ onSend }) {
return ( return (
<div className="w-full flex justify-center"> <div className="w-full flex justify-center">
<footer className="fixed bottom-6 max-w-2xl w-full px-4"> <footer className="fixed bottom-6 max-w-3xl w-full px-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-4">
<div className="flex justify-between"> <div className="flex justify-between items-end">
<div className="flex gap-2"> <div className="flex">
<SchematicButton></SchematicButton> <DownButton></DownButton>
<DeleteButton></DeleteButton>
</div> </div>
<DownButton></DownButton>
</div> </div>
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="bg-gray-900 rounded-2xl border-2 border-gray-800 shadow-lg shadow-indigo-600" className="bg-gray-900 rounded-2xl border-2 border-gray-800 shadow-lg shadow-indigo-600"
> >
<div className="flex p-2 shadow-xl"> <div className="flex p-2 shadow-xl items-center">
<input <textarea
ref={textareaRef}
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => {
setText(e.target.value);
// auto-resize
const ta = textareaRef.current;
if (ta) {
ta.style.height = "auto";
ta.style.height = `${ta.scrollHeight}px`;
}
}}
placeholder="Type a message..." placeholder="Type a message..."
className="flex-1 mx-2 rounded-md shadow-2sx border-none focus:border-none focus:outline-none" rows={1}
className="flex-1 mx-2 rounded-md shadow-2sx border-none focus:border-none focus:outline-none resize-none overflow-auto max-h-40"
/> />
<motion.button <motion.button
type="submit" type="submit"
className="flex gap-2 px-4 py-2 bg-gray-700 rounded-xl ml-4" className="flex gap-2 px-4 py-2 bg-gray-700 rounded-xl ml-4 items-center"
whileHover={{ scale: 1.1 }} whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
> >

View file

@ -1,4 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "daisyui";
.dark { .dark {
--paragraph: 235, 236, 239; --paragraph: 235, 236, 239;