18 Commits

Author SHA1 Message Date
ryan
ceea83cb54 Merge branch 'main' into mobile-responsive-layout 2025-10-29 21:15:10 -04:00
Ryan Chen
1b60aab97c sdf 2025-10-29 21:14:52 -04:00
ryan
210bfc1476 Merge pull request 'query classification' (#8) from async-reindexing into main
Reviewed-on: #8
2025-10-29 21:13:42 -04:00
Ryan Chen
454fb1b52c Add authentication validation on login screen load
- Add validateToken() method to userService to check if refresh token is valid
- Automatically redirect to chat if user already has valid session
- Show 'Checking authentication...' loading state during validation
- Prevents unnecessary login if user is already authenticated
- Improves UX by skipping login screen when not needed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 12:24:10 -04:00
Ryan Chen
c3f2501585 Clear text input immediately upon message submission
- Clear input field right after user sends message (before API call)
- Add validation to prevent submitting empty/whitespace-only messages
- Improve UX by allowing user to type next message while waiting for response
- Works for both simba mode and normal mode

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 12:22:32 -04:00
Ryan Chen
1da21fabee Add auto-scroll to bottom for new messages
- Automatically scroll to latest message when new messages arrive
- Uses smooth scrolling behavior for better UX
- Triggers on message array changes
- Improves chat experience by keeping conversation in view

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 12:12:05 -04:00
Ryan Chen
dd5690ee53 Add submit on Enter for chat textarea
- Press Enter to submit message
- Press Shift+Enter to insert new line
- Add helpful placeholder text explaining keyboard shortcuts
- Improve chat UX with standard messaging behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 12:07:47 -04:00
Ryan Chen
5e7ac28b6f Update add_user.py to use configurable database path
- Use DATABASE_PATH and DATABASE_URL environment variables
- Consistent with app.py and aerich_config.py configuration
- Add environment variable documentation to help text
- Default remains database/raggr.db for backward compatibility

Usage:
  DATABASE_PATH=dev.db python add_user.py list

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 12:02:56 -04:00
Ryan Chen
29f8894e4a Add configurable database path via environment variables
- Add DATABASE_PATH environment variable support in app.py and aerich_config.py
- DATABASE_PATH: For simple relative/absolute paths (default: database/raggr.db)
- DATABASE_URL: For full connection strings (overrides DATABASE_PATH if set)
- Create .env.example with all configuration options documented
- Maintains backward compatibility with default database location

Usage:
  # Use default path
  python app.py

  # Use custom path for development
  DATABASE_PATH=dev.db python app.py

  # Use full connection string
  DATABASE_URL=sqlite://custom/path.db python app.py

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 12:01:16 -04:00
Ryan Chen
19d1df2f68 Improve mobile responsiveness with Tailwind breakpoints
- Replace fixed-width containers (min-w-xl max-w-xl) with responsive classes
- Mobile: full width with padding, Tablet: 90% max 768px, Desktop: max 1024px
- Make ChatScreen header stack vertically on mobile, horizontal on desktop
- Add touch-friendly button sizes (min 44x44px tap targets)
- Optimize textarea and form inputs for mobile keyboards
- Add text wrapping (break-words) to message bubbles to prevent overflow
- Apply responsive text sizing (text-sm sm:text-base) throughout
- Improve ConversationList with touch-friendly hit areas
- Add responsive padding/spacing across all components

All components now use standard Tailwind breakpoints:
- sm: 640px+ (tablet)
- md: 768px+ (larger tablet)
- lg: 1024px+ (desktop)
- xl: 1280px+ (large desktop)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 11:57:54 -04:00
Ryan Chen
e577cb335b query classification 2025-10-26 17:29:00 -04:00
Ryan Chen
591788dfa4 reindex pls 2025-10-26 11:06:32 -04:00
Ryan Chen
561b5bddce reindex pls 2025-10-26 11:04:33 -04:00
Ryan Chen
ddd455a4c6 reindex pls 2025-10-26 11:02:51 -04:00
ryan
07424e77e0 Merge pull request 'favicon' (#7) from update-favicon-and-title into main
Reviewed-on: #7
2025-10-26 10:49:27 -04:00
Ryan Chen
a56f752917 favicon 2025-10-26 10:48:59 -04:00
Ryan Chen
e8264e80ce Changing DB thing 2025-10-26 09:36:33 -04:00
ryan
04350045d3 Merge pull request 'Adding support for conversations and multiple threads' (#6) from conversation-uplift into main
Reviewed-on: #6
2025-10-26 09:25:52 -04:00
17 changed files with 322 additions and 78 deletions

28
.env.example Normal file
View File

@@ -0,0 +1,28 @@
# Database Configuration
# Use DATABASE_PATH for simple relative/absolute paths (e.g., "database/raggr.db" or "dev.db")
# Or use DATABASE_URL for full connection strings (e.g., "sqlite://database/raggr.db")
DATABASE_PATH=database/raggr.db
# DATABASE_URL=sqlite://database/raggr.db
# JWT Configuration
JWT_SECRET_KEY=your-secret-key-here
# Paperless Configuration
PAPERLESS_TOKEN=your-paperless-token
BASE_URL=192.168.1.5:8000
# Ollama Configuration
OLLAMA_URL=http://192.168.1.14:11434
OLLAMA_HOST=http://192.168.1.14:11434
# ChromaDB Configuration
CHROMADB_PATH=/path/to/chromadb
# OpenAI Configuration
OPENAI_API_KEY=your-openai-api-key
# Immich Configuration
IMMICH_URL=http://192.168.1.5:2283
IMMICH_API_KEY=your-immich-api-key
SEARCH_QUERY=simba cat
DOWNLOAD_DIR=./simba_photos

View File

@@ -24,7 +24,6 @@ RUN uv pip install --system -e .
# Copy application code
COPY *.py ./
COPY blueprints ./blueprints
COPY aerich.toml ./
COPY migrations ./migrations
COPY startup.sh ./
RUN chmod +x startup.sh
@@ -35,8 +34,8 @@ WORKDIR /app/raggr-frontend
RUN yarn install && yarn build
WORKDIR /app
# Create ChromaDB directory
RUN mkdir -p /app/chromadb
# Create ChromaDB and database directories
RUN mkdir -p /app/chromadb /app/database
# Expose port
EXPOSE 8080

View File

@@ -1,16 +1,27 @@
# GENERATED BY CLAUDE
import os
import sys
import uuid
import asyncio
from tortoise import Tortoise
from blueprints.users.models import User
from dotenv import load_dotenv
load_dotenv()
# Database configuration with environment variable support
DATABASE_PATH = os.getenv("DATABASE_PATH", "database/raggr.db")
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite://{DATABASE_PATH}")
print(DATABASE_URL)
async def add_user(username: str, email: str, password: str):
"""Add a new user to the database"""
await Tortoise.init(
db_url="sqlite://raggr.db",
db_url=DATABASE_URL,
modules={
"models": [
"blueprints.users.models",
@@ -56,7 +67,7 @@ async def add_user(username: str, email: str, password: str):
async def list_users():
"""List all users in the database"""
await Tortoise.init(
db_url="sqlite://raggr.db",
db_url=DATABASE_URL,
modules={
"models": [
"blueprints.users.models",
@@ -94,6 +105,11 @@ def print_usage():
print("\nExamples:")
print(" python add_user.py add ryan ryan@example.com mypassword123")
print(" python add_user.py list")
print("\nEnvironment Variables:")
print(" DATABASE_PATH - Path to database file (default: database/raggr.db)")
print(" DATABASE_URL - Full database URL (overrides DATABASE_PATH)")
print("\n Example with custom database:")
print(" DATABASE_PATH=dev.db python add_user.py list")
async def main():

View File

@@ -1,7 +1,12 @@
import os
# Database configuration with environment variable support
# Use DATABASE_PATH for relative paths or DATABASE_URL for full connection strings
DATABASE_PATH = os.getenv("DATABASE_PATH", "database/raggr.db")
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite://{DATABASE_PATH}")
TORTOISE_ORM = {
"connections": {"default": os.getenv("DATABASE_URL", "sqlite:///app/raggr.db")},
"connections": {"default": DATABASE_URL},
"apps": {
"models": {
"models": [

5
app.py
View File

@@ -26,8 +26,11 @@ app.register_blueprint(blueprints.users.user_blueprint)
app.register_blueprint(blueprints.conversation.conversation_blueprint)
# Database configuration with environment variable support
DATABASE_PATH = os.getenv("DATABASE_PATH", "database/raggr.db")
TORTOISE_CONFIG = {
"connections": {"default": "sqlite://raggr.db"},
"connections": {"default": f"sqlite://{DATABASE_PATH}"},
"apps": {
"models": {
"models": [

View File

@@ -12,6 +12,8 @@ services:
- OPENAI_API_KEY=${OPENAI_API_KEY}
volumes:
- chromadb_data:/app/chromadb
- database_data:/app/database
volumes:
chromadb_data:
database_data:

View File

@@ -27,7 +27,7 @@ headers = {"x-api-key": API_KEY, "Content-Type": "application/json"}
VISITED = {}
if __name__ == "__main__":
conn = sqlite3.connect("./visited.db")
conn = sqlite3.connect("./database/visited.db")
c = conn.cursor()
c.execute("select immich_id from visited")
rows = c.fetchall()

111
main.py
View File

@@ -7,6 +7,8 @@ import argparse
import chromadb
import ollama
import time
from request import PaperlessNGXService
from chunker import Chunker
@@ -36,6 +38,7 @@ parser.add_argument("query", type=str, help="questions about simba's health")
parser.add_argument(
"--reindex", action="store_true", help="re-index the simba documents"
)
parser.add_argument("--classify", action="store_true", help="test classification")
parser.add_argument("--index", help="index a file")
ppngx = PaperlessNGXService()
@@ -77,7 +80,7 @@ def chunk_data(docs, collection, doctypes):
logging.info(f"chunking {len(docs)} documents")
texts: list[str] = [doc["content"] for doc in docs]
with sqlite3.connect("visited.db") as conn:
with sqlite3.connect("database/visited.db") as conn:
to_insert = []
c = conn.cursor()
for index, text in enumerate(texts):
@@ -113,13 +116,22 @@ def chunk_text(texts: list[str], collection):
)
def classify_query(query: str, transcript: str) -> bool:
logging.info("Starting query generation")
qg_start = time.time()
qg = QueryGenerator()
query_type = qg.get_query_type(input=query, transcript=transcript)
logging.info(query_type)
qg_end = time.time()
logging.info(f"Query generation took {qg_end - qg_start:.2f} seconds")
return query_type == "Simba"
def consult_oracle(
input: str,
collection,
transcript: str = "",
):
import time
chunker = Chunker(collection)
start_time = time.time()
@@ -171,6 +183,16 @@ def consult_oracle(
return output
def llm_chat(input: str, transcript: str = "") -> str:
system_prompt = "You are a helpful assistant that understands veterinary terms."
transcript_prompt = f"Here is the message transcript thus far {transcript}."
prompt = f"""Answer the user in as if you were a cat named Simba. Don't act too catlike. Be assertive.
{transcript_prompt if len(transcript) > 0 else ""}
Respond to this prompt: {input}"""
output = llm_client.chat(prompt=prompt, system_prompt=system_prompt)
return output
def paperless_workflow(input):
# Step 1: Get the text
ppngx = PaperlessNGXService()
@@ -181,15 +203,23 @@ def paperless_workflow(input):
def consult_simba_oracle(input: str, transcript: str = ""):
return consult_oracle(
input=input,
collection=simba_docs,
transcript=transcript,
)
is_simba_related = classify_query(query=input, transcript=transcript)
if is_simba_related:
logging.info("Query is related to simba")
return consult_oracle(
input=input,
collection=simba_docs,
transcript=transcript,
)
logging.info("Query is NOT related to simba")
return llm_chat(input=input, transcript=transcript)
def filter_indexed_files(docs):
with sqlite3.connect("visited.db") as conn:
with sqlite3.connect("database/visited.db") as conn:
c = conn.cursor()
c.execute(
"CREATE TABLE IF NOT EXISTS indexed_documents (id INTEGER PRIMARY KEY AUTOINCREMENT, paperless_id INTEGER)"
@@ -202,38 +232,45 @@ def filter_indexed_files(docs):
return [doc for doc in docs if doc["id"] not in visited]
def reindex():
with sqlite3.connect("database/visited.db") as conn:
c = conn.cursor()
c.execute("DELETE FROM indexed_documents")
conn.commit()
# Delete all documents from the collection
all_docs = simba_docs.get()
if all_docs["ids"]:
simba_docs.delete(ids=all_docs["ids"])
logging.info("Fetching documents from Paperless-NGX")
ppngx = PaperlessNGXService()
docs = ppngx.get_data()
docs = filter_indexed_files(docs)
logging.info(f"Fetched {len(docs)} documents")
# Delete all chromadb data
ids = simba_docs.get(ids=None, limit=None, offset=0)
all_ids = ids["ids"]
if len(all_ids) > 0:
simba_docs.delete(ids=all_ids)
# Chunk documents
logging.info("Chunking documents now ...")
doctype_lookup = ppngx.get_doctypes()
chunk_data(docs, collection=simba_docs, doctypes=doctype_lookup)
logging.info("Done chunking documents")
if __name__ == "__main__":
args = parser.parse_args()
if args.reindex:
logging.info("Fetching documents from Paperless-NGX")
ppngx = PaperlessNGXService()
docs = ppngx.get_data()
docs = filter_indexed_files(docs)
logging.info(f"Fetched {len(docs)} documents")
reindex()
# Delete all chromadb data
ids = simba_docs.get(ids=None, limit=None, offset=0)
all_ids = ids["ids"]
if len(all_ids) > 0:
simba_docs.delete(ids=all_ids)
# Chunk documents
logging.info("Chunking documents now ...")
tag_lookup = ppngx.get_tags()
doctype_lookup = ppngx.get_doctypes()
chunk_data(docs, collection=simba_docs, doctypes=doctype_lookup)
logging.info("Done chunking documents")
# if args.index:
# with open(args.index) as file:
# extension = args.index.split(".")[-1]
# if extension == "pdf":
# pdf_path = ppngx.download_pdf_from_id(id=document_id)
# image_paths = pdf_to_image(filepath=pdf_path)
# print(f"summarizing {file}")
# generated_summary = summarize_pdf_image(filepaths=image_paths)
# elif extension in [".md", ".txt"]:
# chunk_text(texts=[file.readall()], collection=simba_docs)
if args.classify:
consult_simba_oracle(input="yohohoho testing")
consult_simba_oracle(input="write an email")
consult_simba_oracle(input="how much does simba weigh")
if args.query:
logging.info("Consulting oracle ...")

View File

@@ -49,11 +49,20 @@ DOCTYPE_OPTIONS = [
"Letter",
]
QUERY_TYPE_OPTIONS = [
"Simba",
"Other",
]
class DocumentType(BaseModel):
type: list[str] = Field(description="type of document", enum=DOCTYPE_OPTIONS)
class QueryType(BaseModel):
type: str = Field(desciption="type of query", enum=QUERY_TYPE_OPTIONS)
PROMPT = """
You are an information specialist that processes user queries. The current year is 2025. The user queries are all about
a cat, Simba, and its records. The types of records are listed below. Using the query, extract the
@@ -111,6 +120,27 @@ Query: "Who does Simba know?"
Tags: ["Letter", "Documentation"]
"""
QUERY_TYPE_PROMPT = f"""You are an information specialist that processes user queries.
A query can have one tag attached from the following options. Based on the query and the transcript which is listed below, determine
which of the following options is most appropriate: {",".join(QUERY_TYPE_OPTIONS)}
### Example 1
Query: "Who is Simba's current vet?"
Tags: ["Simba"]
### Example 2
Query: "What is the capital of Tokyo?"
Tags: ["Other"]
### Example 3
Query: "Can you help me write an email?"
Tags: ["Other"]
TRANSCRIPT:
"""
class QueryGenerator:
def __init__(self) -> None:
@@ -154,6 +184,33 @@ class QueryGenerator:
metadata_query = {"document_type": {"$in": type_data["type"]}}
return metadata_query
def get_query_type(self, input: str, transcript: str):
client = OpenAI()
response = client.chat.completions.create(
messages=[
{
"role": "system",
"content": "You are an information specialist that is really good at deciding what tags a query should have",
},
{
"role": "user",
"content": f"{QUERY_TYPE_PROMPT}\nTRANSCRIPT:\n{transcript}\nQUERY:{input}",
},
],
model="gpt-4o",
response_format={
"type": "json_schema",
"json_schema": {
"name": "query_type",
"schema": QueryType.model_json_schema(),
},
},
)
response_json_str = response.choices[0].message.content
type_data = json.loads(response_json_str)
return type_data["type"]
def get_query(self, input: str):
client = OpenAI()
response = client.responses.parse(

View File

@@ -3,4 +3,8 @@ import { pluginReact } from '@rsbuild/plugin-react';
export default defineConfig({
plugins: [pluginReact()],
html: {
title: 'Raggr',
favicon: './src/assets/favicon.svg',
},
});

View File

@@ -55,6 +55,21 @@ class UserService {
return data.access_token;
}
async validateToken(): Promise<boolean> {
const refreshToken = localStorage.getItem("refresh_token");
if (!refreshToken) {
return false;
}
try {
await this.refreshToken();
return true;
} catch (error) {
return false;
}
}
async fetchWithAuth(
url: string,
options: RequestInit = {},

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<text y="80" font-size="80" font-family="system-ui, -apple-system, sans-serif">🐱</text>
</svg>

After

Width:  |  Height:  |  Size: 163 B

View File

@@ -7,7 +7,7 @@ type AnswerBubbleProps = {
export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
return (
<div className="rounded-md bg-orange-100 p-3">
<div className="rounded-md bg-orange-100 p-3 sm:p-4">
{loading ? (
<div className="flex flex-col w-full animate-pulse gap-2">
<div className="flex flex-row gap-2 w-full">
@@ -20,8 +20,10 @@ export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
</div>
</div>
) : (
<div className="flex flex-col">
<ReactMarkdown>{"🐈: " + text}</ReactMarkdown>
<div className="flex flex-col break-words overflow-wrap-anywhere">
<ReactMarkdown className="text-sm sm:text-base [&>*]:break-words">
{"🐈: " + text}
</ReactMarkdown>
</div>
)}
</div>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { conversationService } from "../api/conversationService";
import { QuestionBubble } from "./QuestionBubble";
import { AnswerBubble } from "./AnswerBubble";
@@ -39,8 +39,13 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
const [selectedConversation, setSelectedConversation] =
useState<Conversation | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
const handleSelectConversation = (conversation: Conversation) => {
setShowConversations(false);
setSelectedConversation(conversation);
@@ -91,6 +96,10 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
loadConversations();
}, []);
useEffect(() => {
scrollToBottom();
}, [messages]);
useEffect(() => {
const loadMessages = async () => {
if (selectedConversation == null) return;
@@ -112,8 +121,11 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
}, [selectedConversation]);
const handleQuestionSubmit = async () => {
if (!query.trim()) return; // Don't submit empty messages
const currMessages = messages.concat([{ text: query, speaker: "user" }]);
setMessages(currMessages);
setQuery(""); // Clear input immediately after submission
if (simbaMode) {
console.log("simba mode activated");
@@ -142,7 +154,6 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
setMessages(
currMessages.concat([{ text: result.response, speaker: "simba" }]),
);
setQuery(""); // Clear input after successful send
} catch (error) {
console.error("Failed to send query:", error);
// If session expired, redirect to login
@@ -156,18 +167,26 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
setQuery(event.target.value);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Submit on Enter, but allow Shift+Enter for new line
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleQuestionSubmit();
}
};
return (
<div className="h-screen bg-opacity-20">
<div className="bg-white/85 h-screen">
<div className="flex flex-row justify-center py-4">
<div className="flex flex-col gap-4 min-w-xl max-w-xl">
<div className="flex flex-row justify-between">
<div className="flex flex-col gap-4 w-full px-4 sm:w-11/12 sm:max-w-2xl lg:max-w-4xl sm:px-0">
<div className="flex flex-col sm:flex-row gap-3 sm:gap-0 sm:justify-between">
<header className="flex flex-row justify-center gap-2 sticky top-0 z-10 bg-white">
<h1 className="text-3xl">ask simba!</h1>
<h1 className="text-2xl sm:text-3xl">ask simba!</h1>
</header>
<div className="flex flex-row gap-2">
<div className="flex flex-row gap-2 justify-center sm:justify-end">
<button
className="p-2 border border-green-400 bg-green-200 hover:bg-green-400 cursor-pointer rounded-md"
className="p-2 h-11 border border-green-400 bg-green-200 hover:bg-green-400 cursor-pointer rounded-md text-sm sm:text-base"
onClick={() => setShowConversations(!showConversations)}
>
{showConversations
@@ -175,7 +194,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
: "show conversations"}
</button>
<button
className="p-2 border border-red-400 bg-red-200 hover:bg-red-400 cursor-pointer rounded-md"
className="p-2 h-11 border border-red-400 bg-red-200 hover:bg-red-400 cursor-pointer rounded-md text-sm sm:text-base"
onClick={() => setAuthenticated(false)}
>
logout
@@ -195,29 +214,34 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
}
return <QuestionBubble key={index} text={msg.text} />;
})}
<div ref={messagesEndRef} />
<footer className="flex flex-col gap-2 sticky bottom-0">
<div className="flex flex-row justify-between gap-2 grow">
<textarea
className="p-4 border border-blue-200 rounded-md grow bg-white"
className="p-3 sm:p-4 border border-blue-200 rounded-md grow bg-white min-h-[44px] resize-y"
onChange={handleQueryChange}
onKeyDown={handleKeyDown}
value={query}
rows={2}
placeholder="Type your message... (Press Enter to send, Shift+Enter for new line)"
/>
</div>
<div className="flex flex-row justify-between gap-2 grow">
<button
className="p-4 border border-blue-400 bg-blue-200 hover:bg-blue-400 cursor-pointer rounded-md flex-grow"
className="p-3 sm:p-4 min-h-[44px] border border-blue-400 bg-blue-200 hover:bg-blue-400 cursor-pointer rounded-md flex-grow text-sm sm:text-base"
onClick={() => handleQuestionSubmit()}
type="submit"
>
Submit
</button>
</div>
<div className="flex flex-row justify-center gap-2 grow">
<div className="flex flex-row justify-center gap-2 grow items-center">
<input
type="checkbox"
onChange={(event) => setSimbaMode(event.target.checked)}
className="w-5 h-5 cursor-pointer"
/>
<p>simba mode?</p>
<p className="text-sm sm:text-base">simba mode?</p>
</div>
</footer>
</div>

View File

@@ -38,22 +38,25 @@ export const ConversationList = ({
}, []);
return (
<div className="bg-indigo-300 rounded-md p-3 flex flex-col">
<div className="bg-indigo-300 rounded-md p-3 sm:p-4 flex flex-col gap-1">
{conservations.map((conversation) => {
return (
<div
className="border-blue-400 bg-indigo-300 hover:bg-indigo-200 cursor-pointer rounded-md p-2"
key={conversation.id}
className="border-blue-400 bg-indigo-300 hover:bg-indigo-200 cursor-pointer rounded-md p-3 min-h-[44px] flex items-center"
onClick={() => onSelectConversation(conversation)}
>
<p>{conversation.title}</p>
<p className="text-sm sm:text-base break-words">
{conversation.title}
</p>
</div>
);
})}
<div
className="border-blue-400 bg-indigo-300 hover:bg-indigo-200 cursor-pointer rounded-md p-2"
className="border-blue-400 bg-indigo-300 hover:bg-indigo-200 cursor-pointer rounded-md p-3 min-h-[44px] flex items-center"
onClick={() => onCreateNewConversation()}
>
<p> + Start a new thread</p>
<p className="text-sm sm:text-base"> + Start a new thread</p>
</div>
</div>
);

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { userService } from "../api/userService";
type LoginScreenProps = {
@@ -9,8 +9,23 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
const [username, setUsername] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [error, setError] = useState<string>("");
const [isChecking, setIsChecking] = useState<boolean>(true);
useEffect(() => {
// Check if user is already authenticated
const checkAuth = async () => {
const isValid = await userService.validateToken();
if (isValid) {
setAuthenticated(true);
}
setIsChecking(false);
};
checkAuth();
}, [setAuthenticated]);
const handleLogin = async (e?: React.FormEvent) => {
e?.preventDefault();
const handleLogin = async () => {
if (!username || !password) {
setError("Please enter username and password");
return;
@@ -28,46 +43,73 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleLogin();
}
};
// Show loading state while checking authentication
if (isChecking) {
return (
<div className="h-screen bg-opacity-20">
<div className="bg-white/85 h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-lg sm:text-xl">Checking authentication...</p>
</div>
</div>
</div>
);
}
return (
<div className="h-screen bg-opacity-20">
<div className="bg-white/85 h-screen">
<div className="flex flex-row justify-center py-4">
<div className="flex flex-col gap-4 min-w-xl max-w-xl">
<div className="flex flex-col gap-4 w-full px-4 sm:w-11/12 sm:max-w-2xl lg:max-w-4xl sm:px-0">
<div className="flex flex-col gap-1">
<div className="flex flex-grow justify-center w-full bg-amber-400">
<h1 className="text-xl font-bold">
<div className="flex flex-grow justify-center w-full bg-amber-400 p-2">
<h1 className="text-base sm:text-xl font-bold text-center">
I AM LOOKING FOR A DESIGNER. THIS APP WILL REMAIN UGLY UNTIL A
DESIGNER COMES.
</h1>
</div>
<header className="flex flex-row justify-center gap-2 grow sticky top-0 z-10 bg-white">
<h1 className="text-3xl">ask simba!</h1>
<h1 className="text-2xl sm:text-3xl">ask simba!</h1>
</header>
<label htmlFor="username">username</label>
<label htmlFor="username" className="text-sm sm:text-base">
username
</label>
<input
type="text"
id="username"
name="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="border border-s-slate-950 p-3 rounded-md"
onKeyPress={handleKeyPress}
className="border border-s-slate-950 p-3 rounded-md min-h-[44px]"
/>
<label htmlFor="password">password</label>
<label htmlFor="password" className="text-sm sm:text-base">
password
</label>
<input
type="password"
id="password"
name="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="border border-s-slate-950 p-3 rounded-md"
onKeyPress={handleKeyPress}
className="border border-s-slate-950 p-3 rounded-md min-h-[44px]"
/>
{error && (
<div className="text-red-600 font-semibold">{error}</div>
<div className="text-red-600 font-semibold text-sm sm:text-base">
{error}
</div>
)}
</div>
<button
className="p-4 border border-blue-400 bg-blue-200 hover:bg-blue-400 cursor-pointer rounded-md flex-grow"
className="p-3 sm:p-4 min-h-[44px] border border-blue-400 bg-blue-200 hover:bg-blue-400 cursor-pointer rounded-md flex-grow text-sm sm:text-base"
onClick={handleLogin}
>
login

View File

@@ -3,5 +3,9 @@ type QuestionBubbleProps = {
};
export const QuestionBubble = ({ text }: QuestionBubbleProps) => {
return <div className="rounded-md bg-stone-200 p-3">🤦: {text}</div>;
return (
<div className="rounded-md bg-stone-200 p-3 sm:p-4 break-words overflow-wrap-anywhere text-sm sm:text-base">
🤦: {text}
</div>
);
};