Merge pull request 'mobile-responsive-layout' (#9) from mobile-responsive-layout into main

Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2025-10-29 21:15:14 -04:00
11 changed files with 180 additions and 38 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

@@ -1,16 +1,27 @@
# GENERATED BY CLAUDE # GENERATED BY CLAUDE
import os
import sys import sys
import uuid import uuid
import asyncio import asyncio
from tortoise import Tortoise from tortoise import Tortoise
from blueprints.users.models import User 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): async def add_user(username: str, email: str, password: str):
"""Add a new user to the database""" """Add a new user to the database"""
await Tortoise.init( await Tortoise.init(
db_url="sqlite://database/raggr.db", db_url=DATABASE_URL,
modules={ modules={
"models": [ "models": [
"blueprints.users.models", "blueprints.users.models",
@@ -56,7 +67,7 @@ async def add_user(username: str, email: str, password: str):
async def list_users(): async def list_users():
"""List all users in the database""" """List all users in the database"""
await Tortoise.init( await Tortoise.init(
db_url="sqlite://database/raggr.db", db_url=DATABASE_URL,
modules={ modules={
"models": [ "models": [
"blueprints.users.models", "blueprints.users.models",
@@ -94,6 +105,11 @@ def print_usage():
print("\nExamples:") print("\nExamples:")
print(" python add_user.py add ryan ryan@example.com mypassword123") print(" python add_user.py add ryan ryan@example.com mypassword123")
print(" python add_user.py list") 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(): async def main():

View File

@@ -1,7 +1,12 @@
import os 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 = { TORTOISE_ORM = {
"connections": {"default": os.getenv("DATABASE_URL", "sqlite:///app/database/raggr.db")}, "connections": {"default": DATABASE_URL},
"apps": { "apps": {
"models": { "models": {
"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) app.register_blueprint(blueprints.conversation.conversation_blueprint)
# Database configuration with environment variable support
DATABASE_PATH = os.getenv("DATABASE_PATH", "database/raggr.db")
TORTOISE_CONFIG = { TORTOISE_CONFIG = {
"connections": {"default": "sqlite://database/raggr.db"}, "connections": {"default": f"sqlite://{DATABASE_PATH}"},
"apps": { "apps": {
"models": { "models": {
"models": [ "models": [

View File

@@ -186,7 +186,7 @@ def consult_oracle(
def llm_chat(input: str, transcript: str = "") -> str: def llm_chat(input: str, transcript: str = "") -> str:
system_prompt = "You are a helpful assistant that understands veterinary terms." system_prompt = "You are a helpful assistant that understands veterinary terms."
transcript_prompt = f"Here is the message transcript thus far {transcript}." transcript_prompt = f"Here is the message transcript thus far {transcript}."
prompt = f"""Answer the user in a humorous way as if you were a cat named Simba. Be very coy. 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 ""} {transcript_prompt if len(transcript) > 0 else ""}
Respond to this prompt: {input}""" Respond to this prompt: {input}"""
output = llm_client.chat(prompt=prompt, system_prompt=system_prompt) output = llm_client.chat(prompt=prompt, system_prompt=system_prompt)

View File

@@ -55,6 +55,21 @@ class UserService {
return data.access_token; 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( async fetchWithAuth(
url: string, url: string,
options: RequestInit = {}, options: RequestInit = {},

View File

@@ -7,7 +7,7 @@ type AnswerBubbleProps = {
export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => { export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
return ( return (
<div className="rounded-md bg-orange-100 p-3"> <div className="rounded-md bg-orange-100 p-3 sm:p-4">
{loading ? ( {loading ? (
<div className="flex flex-col w-full animate-pulse gap-2"> <div className="flex flex-col w-full animate-pulse gap-2">
<div className="flex flex-row gap-2 w-full"> <div className="flex flex-row gap-2 w-full">
@@ -20,8 +20,10 @@ export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex flex-col"> <div className="flex flex-col break-words overflow-wrap-anywhere">
<ReactMarkdown>{"🐈: " + text}</ReactMarkdown> <ReactMarkdown className="text-sm sm:text-base [&>*]:break-words">
{"🐈: " + text}
</ReactMarkdown>
</div> </div>
)} )}
</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 { conversationService } from "../api/conversationService";
import { QuestionBubble } from "./QuestionBubble"; import { QuestionBubble } from "./QuestionBubble";
import { AnswerBubble } from "./AnswerBubble"; import { AnswerBubble } from "./AnswerBubble";
@@ -39,8 +39,13 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
const [selectedConversation, setSelectedConversation] = const [selectedConversation, setSelectedConversation] =
useState<Conversation | null>(null); useState<Conversation | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"]; const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
const handleSelectConversation = (conversation: Conversation) => { const handleSelectConversation = (conversation: Conversation) => {
setShowConversations(false); setShowConversations(false);
setSelectedConversation(conversation); setSelectedConversation(conversation);
@@ -91,6 +96,10 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
loadConversations(); loadConversations();
}, []); }, []);
useEffect(() => {
scrollToBottom();
}, [messages]);
useEffect(() => { useEffect(() => {
const loadMessages = async () => { const loadMessages = async () => {
if (selectedConversation == null) return; if (selectedConversation == null) return;
@@ -112,8 +121,11 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
}, [selectedConversation]); }, [selectedConversation]);
const handleQuestionSubmit = async () => { const handleQuestionSubmit = async () => {
if (!query.trim()) return; // Don't submit empty messages
const currMessages = messages.concat([{ text: query, speaker: "user" }]); const currMessages = messages.concat([{ text: query, speaker: "user" }]);
setMessages(currMessages); setMessages(currMessages);
setQuery(""); // Clear input immediately after submission
if (simbaMode) { if (simbaMode) {
console.log("simba mode activated"); console.log("simba mode activated");
@@ -142,7 +154,6 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
setMessages( setMessages(
currMessages.concat([{ text: result.response, speaker: "simba" }]), currMessages.concat([{ text: result.response, speaker: "simba" }]),
); );
setQuery(""); // Clear input after successful send
} catch (error) { } catch (error) {
console.error("Failed to send query:", error); console.error("Failed to send query:", error);
// If session expired, redirect to login // If session expired, redirect to login
@@ -156,18 +167,26 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
setQuery(event.target.value); 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 ( return (
<div className="h-screen bg-opacity-20"> <div className="h-screen bg-opacity-20">
<div className="bg-white/85 h-screen"> <div className="bg-white/85 h-screen">
<div className="flex flex-row justify-center py-4"> <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-row justify-between"> <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"> <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> </header>
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2 justify-center sm:justify-end">
<button <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)} onClick={() => setShowConversations(!showConversations)}
> >
{showConversations {showConversations
@@ -175,7 +194,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
: "show conversations"} : "show conversations"}
</button> </button>
<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)} onClick={() => setAuthenticated(false)}
> >
logout logout
@@ -195,29 +214,34 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
} }
return <QuestionBubble key={index} text={msg.text} />; return <QuestionBubble key={index} text={msg.text} />;
})} })}
<div ref={messagesEndRef} />
<footer className="flex flex-col gap-2 sticky bottom-0"> <footer className="flex flex-col gap-2 sticky bottom-0">
<div className="flex flex-row justify-between gap-2 grow"> <div className="flex flex-row justify-between gap-2 grow">
<textarea <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} onChange={handleQueryChange}
onKeyDown={handleKeyDown}
value={query} value={query}
rows={2}
placeholder="Type your message... (Press Enter to send, Shift+Enter for new line)"
/> />
</div> </div>
<div className="flex flex-row justify-between gap-2 grow"> <div className="flex flex-row justify-between gap-2 grow">
<button <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()} onClick={() => handleQuestionSubmit()}
type="submit" type="submit"
> >
Submit Submit
</button> </button>
</div> </div>
<div className="flex flex-row justify-center gap-2 grow"> <div className="flex flex-row justify-center gap-2 grow items-center">
<input <input
type="checkbox" type="checkbox"
onChange={(event) => setSimbaMode(event.target.checked)} 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> </div>
</footer> </footer>
</div> </div>

View File

@@ -38,22 +38,25 @@ export const ConversationList = ({
}, []); }, []);
return ( 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) => { {conservations.map((conversation) => {
return ( return (
<div <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)} onClick={() => onSelectConversation(conversation)}
> >
<p>{conversation.title}</p> <p className="text-sm sm:text-base break-words">
{conversation.title}
</p>
</div> </div>
); );
})} })}
<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()} onClick={() => onCreateNewConversation()}
> >
<p> + Start a new thread</p> <p className="text-sm sm:text-base"> + Start a new thread</p>
</div> </div>
</div> </div>
); );

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { userService } from "../api/userService"; import { userService } from "../api/userService";
type LoginScreenProps = { type LoginScreenProps = {
@@ -9,8 +9,23 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
const [username, setUsername] = useState<string>(""); const [username, setUsername] = useState<string>("");
const [password, setPassword] = useState<string>(""); const [password, setPassword] = useState<string>("");
const [error, setError] = 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) { if (!username || !password) {
setError("Please enter username and password"); setError("Please enter username and password");
return; 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 ( return (
<div className="h-screen bg-opacity-20"> <div className="h-screen bg-opacity-20">
<div className="bg-white/85 h-screen"> <div className="bg-white/85 h-screen">
<div className="flex flex-row justify-center py-4"> <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-col gap-1">
<div className="flex flex-grow justify-center w-full bg-amber-400"> <div className="flex flex-grow justify-center w-full bg-amber-400 p-2">
<h1 className="text-xl font-bold"> <h1 className="text-base sm:text-xl font-bold text-center">
I AM LOOKING FOR A DESIGNER. THIS APP WILL REMAIN UGLY UNTIL A I AM LOOKING FOR A DESIGNER. THIS APP WILL REMAIN UGLY UNTIL A
DESIGNER COMES. DESIGNER COMES.
</h1> </h1>
</div> </div>
<header className="flex flex-row justify-center gap-2 grow sticky top-0 z-10 bg-white"> <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> </header>
<label htmlFor="username">username</label> <label htmlFor="username" className="text-sm sm:text-base">
username
</label>
<input <input
type="text" type="text"
id="username" id="username"
name="username" name="username"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} 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 <input
type="password" type="password"
id="password" id="password"
name="password" name="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} 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 && ( {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> </div>
<button <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} onClick={handleLogin}
> >
login login

View File

@@ -3,5 +3,9 @@ type QuestionBubbleProps = {
}; };
export const QuestionBubble = ({ text }: 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>
);
}; };