summary | shortlog | log | commit | commitdiff | tree
raw | patch | inline | side by side (parent: e1f7469)
raw | patch | inline | side by side (parent: e1f7469)
author | Eric Wertz <ericwertz@Erics-MacBook-Pro.local> | |
Sat, 9 Aug 2025 23:36:38 +0000 (19:36 -0400) | ||
committer | Eric Wertz <ericwertz@Erics-MacBook-Pro.local> | |
Sat, 9 Aug 2025 23:36:38 +0000 (19:36 -0400) |
45 files changed:
diff --git a/.gitignore b/.gitignore
index b00ae23eb80e2f4d759137f2ed34a2c777b660c5..be6a0cbcce2f877ebfbfa90e9660e5ca805b459c 100644 (file)
--- a/.gitignore
+++ b/.gitignore
frontend/node_modules
frontend/package-lock.json
+
+app_settings.json
diff --git a/Makefile b/Makefile
index 8c095dd3a268b2d91712912b661c4d6c59ca6c4d..4c774682bfa30d9bb9bd7eb590220a2938c2d995 100644 (file)
--- a/Makefile
+++ b/Makefile
INCLUDES = -I$(SRC_DIR) -I$(OPENSSL_PATH)/include
# Link flags
-LDFLAGS_BASE = -L$(OPENSSL_PATH)/lib -lssl -lcrypto
+LDFLAGS_BASE = -L$(OPENSSL_PATH)/lib -lssl -lcrypto -lcurl
ifeq ($(OS),Darwin)
LDFLAGS_OS_SPECIFIC = -Wl,-dead_strip -framework Security
index f0b4b3fdc269a3ab365785b5f62478b372853f39..dd5e0ccbf553749473136c711795968814d20b11 100644 (file)
return actions;
}
-CHTTPResponse* CAuthController::check(CHTTPRequest& request)
+void CAuthController::check(CHTTPRequest& request)
{
if(request.checkAuth())
{
- return new CJSONResponse(request, "success", "User is authenticated",
+ CJSONResponse response(request, "success", "User is authenticated",
"{\"authenticated\": true, \"username\": \"" + request.getUser().getUsername() + "\", \"uuid\": \"" + request.getUser().getUUID() + "\"}");
+ response.send();
}
else
{
- auto response = new CJSONResponse(request, "error", "User is not authenticated", "{\"authenticated\": false}");
- response->statusCode = 401;
- response->setHeader("Set-Cookie", "SESSID=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax");
- return response;
+ CJSONResponse response(request, "error", "User is not authenticated", "{\"authenticated\": false}");
+ response.statusCode = 401;
+ response.setHeader("Set-Cookie", "SESSID=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax");
+ response.send();
}
}
-CHTTPResponse* CAuthController::login(CHTTPRequest& request)
+void CAuthController::login(CHTTPRequest& request)
{
if( request.getUser().isLoggedIn() )
{
//Login will throw an exception if the user is not found or the password is incorrect
auto sessionId = request.getUser().login(request.getBody());
- auto response = new CJSONResponse(request, "success", "User is authenticated", "{\"authenticated\": true, \"username\": \"" + request.getUser().getUsername() + "\"}");
- response->setHeader("Set-Cookie", "SESSID=" + sessionId + "; Path=/; HttpOnly; SameSite=Lax");
- return response;
+ CJSONResponse response(request, "success", "User is authenticated", "{\"authenticated\": true, \"username\": \"" + request.getUser().getUsername() + "\"}");
+ response.setHeader("Set-Cookie", "SESSID=" + sessionId + "; Path=/; HttpOnly; SameSite=Lax");
+ response.send();
}
-CHTTPResponse* CAuthController::signup(CHTTPRequest& request)
+void CAuthController::signup(CHTTPRequest& request)
{
request.getUser().create(request.getBody());
- auto response = new CJSONResponse(request, "success", "User is authenticated", "{\"authenticated\": true}");
-
+ CJSONResponse response(request, "success", "User is authenticated", "{\"authenticated\": true}");
auto sessionId = request.getUser().login(request.getBody());
- response->setHeader("Set-Cookie", "SESSID=" + sessionId + "; Path=/; HttpOnly; SameSite=Lax");
-
- return response;
+ response.setHeader("Set-Cookie", "SESSID=" + sessionId + "; Path=/; HttpOnly; SameSite=Lax");
+ response.send();
}
-CHTTPResponse* CAuthController::logout(CHTTPRequest& request)
+void CAuthController::logout(CHTTPRequest& request)
{
request.getUser().logout();
- auto response = new CJSONResponse(request, "success", "User is logged out", "{\"authenticated\": false}");
- response->setHeader("Set-Cookie", "SESSID=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax");
- return response;
+ CJSONResponse response(request, "success", "User is logged out", "{\"authenticated\": false}");
+ response.setHeader("Set-Cookie", "SESSID=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax");
+ response.send();
}
\ No newline at end of file
index 210d607d5ac63453683b40d0bbeda00e9214635d..9f68f06893719c6467dec59758e5903b8e973a0a 100644 (file)
--- a/backend/AuthController.h
+++ b/backend/AuthController.h
const ACTIONMAP* getActions() override;
private:
- CHTTPResponse* check(CHTTPRequest& request);
- CHTTPResponse* signup(CHTTPRequest& request);
- CHTTPResponse* login(CHTTPRequest& request);
- CHTTPResponse* logout(CHTTPRequest& request);
+ void check(CHTTPRequest& request);
+ void signup(CHTTPRequest& request);
+ void login(CHTTPRequest& request);
+ void logout(CHTTPRequest& request);
};
\ No newline at end of file
diff --git a/backend/ChatController.cpp b/backend/ChatController.cpp
--- /dev/null
@@ -0,0 +1,427 @@
+#include "ChatController.h"
+#include "HTTPExceptions.h"
+#include "JSONResponse.h"
+#include "StreamingResponse.h"
+#include "SettingsHelper.h"
+#include "Database.h"
+#include "util.h"
+
+#include <sstream>
+#include <curl/curl.h>
+
+#include <thread>
+#include <chrono>
+
+// Lazy accessors to avoid static-init before g_rootDir is set
+static const std::string& CHAT_URL() {
+ static std::string v = CSettingsHelper::get("chat_url", "http://localhost:11434/api/chat");
+ return v;
+}
+static const std::string& WAKE_HOST() {
+ static std::string v = CSettingsHelper::get("wake_host", "localhost");
+ return v;
+}
+static const std::string& WAKE_ADDRESS() {
+ static std::string v = CSettingsHelper::get("wake_address", "");
+ return v;
+}
+
+// New item type constants for chat
+#define ITEM_TYPE_CHAT_CONVERSATION "chatconversation"
+#define ITEM_TYPE_CHAT_MESSAGE "chatmessage"
+#define LINK_TYPE_CHAT_MESSAGE "chatmessage"
+
+const ACTIONMAP* CChatController::getActions()
+{
+ static ACTIONMAP actions[] = {
+ {"status", true, [](CController* c, CHTTPRequest& r) { return static_cast<CChatController*>(c)->status(r); } },
+ {"update", true, [](CController* c, CHTTPRequest& r) { return static_cast<CChatController*>(c)->update(r); } },
+ {"list", true, [](CController* c, CHTTPRequest& r) { return static_cast<CChatController*>(c)->list(r); } },
+ {"fetch", true, [](CController* c, CHTTPRequest& r) { return static_cast<CChatController*>(c)->fetch(r); } },
+ { "", false, nullptr }
+ };
+ return actions;
+}
+
+void CChatController::status(CHTTPRequest& request)
+{
+ CStreamingResponse response(request);
+ response.begin();
+
+ // Do a quick ping test to see if host is reachable
+ int pingResult = system(std::string("ping -c 1 -t 1 " + WAKE_HOST()).c_str());
+ if (pingResult == 0)
+ {
+ response.stream("{\"status\": \"online\"}");
+ response.end();
+ return;
+ }
+
+ response.stream("{\"status\": \"offline\", \"message\": \"Waking up host\"}");
+
+ // Wake up the host
+ if (!WAKE_ADDRESS().empty()) {
+ system(std::string("wakeonlan " + WAKE_ADDRESS()).c_str());
+ }
+ std::this_thread::sleep_for(std::chrono::seconds(1));
+
+ // Try pinging for up to 60 seconds
+ auto start = std::chrono::steady_clock::now();
+ while (true) {
+ // Check if we've exceeded 60 seconds
+ auto now = std::chrono::steady_clock::now();
+ if (std::chrono::duration_cast<std::chrono::seconds>(now - start).count() >= 120) {
+ response.stream("{\"status\": \"offline\", \"message\": \"Host did not respond after 120 seconds\"}");
+ break;
+ }
+
+ // Try pinging
+ response.stream("{\"status\": \"waking\", \"message\": \"Sending ping\"}");
+ if (system(std::string("ping -c 1 -t 1 " + WAKE_HOST()).c_str()) == 0) {
+ response.stream("{\"status\": \"online\"}");
+ break;
+ }
+ }
+
+
+ response.end();
+}
+
+// Context object passed to the CURL write callback when streaming NDJSON
+struct NDJSONStreamContext {
+ CStreamingResponse* resp;
+ std::ostringstream* assistant_ss; // Accumulate assistant response
+ CDatabase* db;
+ std::string buffer; // Holds partial data between callbacks
+};
+
+// Forward streamed bytes from libcurl, parse NDJSON lines, stream assistant messages, and accumulate content
+static size_t NDJSONWriteCallback(char* ptr, size_t size, size_t nmemb, void* userdata)
+{
+ size_t totalSize = size * nmemb;
+ if (!userdata || totalSize == 0)
+ {
+ return totalSize;
+ }
+
+ NDJSONStreamContext* ctx = static_cast<NDJSONStreamContext*>(userdata);
+
+ // If client is no longer active, stop attempting to stream to it
+ if (ctx->resp && !ctx->resp->isActive()) {
+ ctx->resp = nullptr;
+ }
+
+ // Append the new data to the buffer
+ ctx->buffer.append(ptr, totalSize);
+
+ // Process complete lines (delimited by '\n')
+ size_t newlinePos;
+ while ((newlinePos = ctx->buffer.find('\n')) != std::string::npos)
+ {
+ std::string line = ctx->buffer.substr(0, newlinePos);
+ ctx->buffer.erase(0, newlinePos + 1);
+
+ if (line.empty())
+ continue;
+
+ // Try to extract the message content from the NDJSON object
+ std::string content;
+ try {
+ ctx->db->extractJsonValues(line, "$.message.content", content);
+
+ } catch (const std::exception&) {
+ // Ignore extraction errors (line might not have the expected JSON structure)
+ std::cout << "error extracting json values" << std::endl;
+ }
+
+ if (!content.empty())
+ {
+ // Accumulate the assistant response string
+ *(ctx->assistant_ss) << content;
+
+ // Build a JSON object { role: 'assistant', content: <content> }
+ std::string message_json = ctx->db->encodeJsonObject("role", "assistant", "content", content);
+
+ // Stream the JSON object to the client
+ if (ctx->resp)
+ {
+ try {
+ ctx->resp->stream(message_json);
+ if (!ctx->resp->isActive()) {
+ ctx->resp = nullptr;
+ }
+ } catch (const std::exception& e) {
+ std::cerr << "Error streaming chat chunk: " << e.what() << std::endl;
+ ctx->resp = nullptr;
+ }
+ }
+ }
+ }
+
+ return totalSize; // Tell libcurl we consumed the data
+}
+
+void CChatController::generateResponse(std::string chat_payload, CDatabase* db, std::ostringstream& assistant_response, CStreamingResponse* response)
+{
+ if (!db)
+ {
+ if( response )
+ streamChatChunk( response, db, "error", "database pointer is null" );
+ else
+ throw std::runtime_error("database pointer is null");
+ return;
+ }
+
+ // Initialise libcurl
+ curl_global_init(CURL_GLOBAL_DEFAULT);
+ CURL* curl = curl_easy_init();
+
+ if (curl)
+ {
+ // Upstream chat endpoint
+ curl_easy_setopt(curl, CURLOPT_URL, CHAT_URL().c_str());
+ curl_easy_setopt(curl, CURLOPT_POST, 1L);
+
+ // Send the prepared JSON payload
+ curl_easy_setopt(curl, CURLOPT_POSTFIELDS, chat_payload.c_str());
+
+ // Set required headers for JSON
+ struct curl_slist* headers = nullptr;
+ headers = curl_slist_append(headers, "Content-Type: application/json");
+ curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
+
+ // Configure streaming callback
+ NDJSONStreamContext stream_ctx{ response, &assistant_response, db, "" };
+ curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, NDJSONWriteCallback);
+ curl_easy_setopt(curl, CURLOPT_WRITEDATA, &stream_ctx);
+
+ // Perform the request (synchronous)
+ if( response )
+ streamMetaChunk( response, db, "status", "Awaiting response..." );
+ CURLcode res = curl_easy_perform(curl);
+ if (res != CURLE_OK)
+ {
+ if( response )
+ streamChatChunk( response, db, "error", curl_easy_strerror(res) );
+ else
+ throw std::runtime_error(curl_easy_strerror(res));
+ }
+
+ // Cleanup
+ curl_slist_free_all(headers);
+ curl_easy_cleanup(curl);
+ }
+ else
+ {
+ if( response )
+ streamChatChunk( response, db, "error", "failed to initialise libcurl" );
+ else
+ throw std::runtime_error("failed to initialise libcurl");
+ }
+
+ curl_global_cleanup();
+}
+
+void CChatController::list(CHTTPRequest& request)
+{
+ CDatabase* db = &request.getUser().getDatabase();
+ std::string conversations_json;
+ CDBQuery query = db->query("select "
+ "json_group_array( json_insert( json( json ), '$._uuid', uuid, '$._created_at', created_at, '$._updated_at', updated_at ) ORDER BY updated_at DESC ) "
+ "from items where type='chatconversation'");
+ query.fetch_row(conversations_json);
+ CJSONResponse response(request, "success", "success", conversations_json);
+ response.send();
+}
+
+void CChatController::fetch(CHTTPRequest& request)
+{
+ CDatabase* db = &request.getUser().getDatabase();
+ std::string conversation_id = request.requireArgument(0);
+ std::string conversation_json;
+ CDBQuery query = db->query("select "
+ "IF( ci.uuid IS NOT NULL, json_object( "
+ "'_uuid', ci.uuid, "
+ "'_created_at', ci.created_at, "
+ "'_updated_at', ci.updated_at, "
+ "'_title', ci.json->'title', "
+ "'messages', json_group_array( json(m.json) ORDER BY m.created_at ASC )"
+ "), \"\" ) "
+ "from items ci "
+ "join itemslink il on il.parent_id=ci.rowid and il.type='chatmessage' "
+ "join items m on m.rowid=il.child_id and m.type='chatmessage' "
+ "where ci.uuid=? and ci.type='chatconversation' and m.json->>'role'<>'system' "
+ "order by m.created_at asc", conversation_id);
+ query.fetch_row(conversation_json);
+ if( conversation_json.empty() )
+ throw HTTPNotFoundException("Conversation not found");
+ CJSONResponse response(request, "success", "success", conversation_json);
+ response.send();
+}
+
+void CChatController::update(CHTTPRequest& request)
+{
+ CDatabase* db = &request.getUser().getDatabase();
+ std::string requestBody = request.getBody();
+ if (requestBody.empty()) {
+ throw HTTPBadRequestException("Request body is empty");
+ }
+
+ std::string conversation_id, user_message;
+ try {
+ db->extractJsonValues( requestBody,
+ "$.conversation_id", conversation_id,
+ "$.user_message", user_message
+ );
+ } catch (const std::exception& e) {
+ throw HTTPBadRequestException("Invalid request body");
+ }
+
+ std::string conversation_uuid;
+ int conversation_rowid = 0;
+ std::vector<std::pair<std::string, std::string>> messages; // (role, content)
+
+ if (conversation_id == "new") {
+ // 1. Create new conversation item
+ conversation_uuid = generate_id();
+ std::string conversation_json = db->encodeJsonObject("title", "New Conversation");
+ db->query(
+ "INSERT INTO items (uuid, json, type) VALUES (?, ?, ?)",
+ conversation_uuid, conversation_json, ITEM_TYPE_CHAT_CONVERSATION
+ ).execute();
+ conversation_rowid = db->lastInsertRowid();
+
+ // Add system message
+ appendMessage(db, conversation_rowid, "system", "You are a helpful personal assistant");
+ } else {
+ // Existing conversation: fetch conversation rowid
+ conversation_uuid = conversation_id;
+ CDBQuery get_rowid_query = db->query("SELECT rowid FROM items WHERE uuid = ? AND type = ?", conversation_uuid, ITEM_TYPE_CHAT_CONVERSATION);
+ if (get_rowid_query.fetch_row(conversation_rowid) != SQLITE_ROW) {
+ throw HTTPBadRequestException("Conversation not found");
+ }
+ }
+
+ // Add the new user message to the conversation
+ appendMessage(db, conversation_rowid, "user", user_message);
+
+ // Fetch all messages for this conversation, ordered by creation time, as a JSON array
+ std::string messages_array;
+ {
+ const std::string fetch_msgs_sql =
+ "SELECT json_group_array(json(m.json)) FROM itemslink l "
+ "JOIN items m ON l.child_id = m.rowid "
+ "WHERE l.parent_id = ? AND l.type = ? AND m.type = ? "
+ "ORDER BY m.rowid ASC";
+ CDBQuery msgs_query = db->query(fetch_msgs_sql, conversation_rowid, LINK_TYPE_CHAT_MESSAGE, ITEM_TYPE_CHAT_MESSAGE);
+ if (msgs_query.fetch_row(messages_array) != SQLITE_ROW || messages_array.empty()) {
+ messages_array = "[]";
+ }
+ }
+
+ // Prepare chat model payload
+ std::string chat_payload = std::string("{\"model\": \"qwen3:8b\", \"messages\": ") + messages_array + "}";
+
+ // Begin streaming response
+ CStreamingResponse response(request);
+ response.begin();
+
+ if( conversation_id == "new" )
+ {
+ // Send conversation info as status
+ streamMetaChunk( &response, db, "conversation_id", conversation_uuid );
+ }
+
+ // Stream chat response and capture assistant content
+ std::ostringstream assistant_response_ss;
+ generateResponse(chat_payload, db, assistant_response_ss, &response);
+
+ std::string assistant_response_str = assistant_response_ss.str();
+
+ // After streaming, add assistant message to DB
+ if (!assistant_response_str.empty()) {
+ appendMessage(db, conversation_rowid, "assistant", assistant_response_str);
+ }
+
+ streamMetaChunk( &response, db, "response_finished", "true" );
+
+ if( conversation_id == "new" )
+ {
+ // Update conversation title
+ std::string title = generateTitle(db, conversation_rowid);
+ db->query("update items set json=json_set(json, '$.title', ?) where rowid=?", title, conversation_rowid).execute();
+ streamMetaChunk( &response, db, "title", title );
+ }
+
+ try {
+ response.end();
+ } catch (const std::exception& e) {
+ std::cerr << "Error ending response: " << e.what() << std::endl;
+ }
+}
+
+void CChatController::appendMessage( CDatabase* db, int conversation_rowid, std::string role, std::string content )
+{
+ std::string message_uuid = generate_id();
+ db->query(
+ "INSERT INTO items (uuid, json, type) VALUES (?, json_object('role', ?, 'content', ?), ?)",
+ message_uuid, role, content, ITEM_TYPE_CHAT_MESSAGE
+ ).execute();
+ // Get message rowid
+ int message_rowid = db->lastInsertRowid();
+ // Link message to conversation
+ db->query(
+ "INSERT INTO itemslink (parent_id, child_id, type) VALUES (?, ?, ?)",
+ conversation_rowid, message_rowid, LINK_TYPE_CHAT_MESSAGE
+ ).execute();
+}
+
+std::string CChatController::generateTitle( CDatabase* db, int conversation_rowid )
+{
+ std::string conversation_dump;
+ auto query = db->query("SELECT GROUP_CONCAT( str, '\n\n' ) FROM (select "
+ "concat( i.json->>'role', ': ', i.json->>'content') as str "
+ "from itemslink l "
+ "join items i on i.rowid=l.child_id "
+ "where l.type='chatmessage' and l.parent_id=? "
+ "order by i.created_at asc )", conversation_rowid);
+ query.fetch_row(conversation_dump);
+
+ std::string system_prompt = "/nothink\n"
+ "Please generate a brief, descriptive title (under 80 characters) summarizing the main topic or key points of the following conversation.\n"
+ "Only output the title with no other formatting.\n\n```\n" + conversation_dump + "\n```";
+ std::string chat_payload = std::string("{\"model\": \"qwen3:8b\", \"messages\": [ " +db->encodeJsonObject("role", "system", "content", system_prompt) + " ] }");
+
+ std::ostringstream assistant_response_ss;
+ generateResponse(chat_payload, db, assistant_response_ss);
+
+ std::string assistant_response_str = assistant_response_ss.str();
+
+ size_t pos = assistant_response_str.find("</think>");
+ if (pos != std::string::npos) {
+ std::string title = assistant_response_str.substr(pos + 8); // 8 is length of </think>
+ // Trim whitespace from start and end
+ title.erase(0, title.find_first_not_of(" \n\r\t"));
+ title.erase(title.find_last_not_of(" \n\r\t") + 1);
+ return title;
+ }
+ return assistant_response_str;
+}
+
+void CChatController::streamChatChunk( CStreamingResponse* response, CDatabase* db, const std::string role, const std::string content )
+{
+ try {
+ response->stream( db->encodeJsonObject( "role", role, "content", content ) );
+ } catch (const std::exception& e) {
+ std::cerr << "Error streaming chat chunk: " << e.what() << std::endl;
+ }
+}
+
+void CChatController::streamMetaChunk( CStreamingResponse* response, CDatabase* db, const std::string prop, const std::string value )
+{
+ try {
+ response->stream( db->encodeJsonObject( "role", "status", "content", "", prop, value ) );
+ } catch (const std::exception& e) {
+ std::cerr << "Error streaming meta chunk: " << e.what() << std::endl;
+ }
+}
\ No newline at end of file
diff --git a/backend/ChatController.h b/backend/ChatController.h
--- /dev/null
+++ b/backend/ChatController.h
@@ -0,0 +1,23 @@
+#pragma once
+
+#include "Controller.h"
+#include "StreamingResponse.h"
+#include <sstream>
+
+class CChatController : public CController
+{
+protected:
+ const ACTIONMAP* getActions() override;
+private:
+ void status(CHTTPRequest& request);
+ void update(CHTTPRequest& request);
+ void list(CHTTPRequest& request);
+ void fetch(CHTTPRequest& request);
+
+ void appendMessage( CDatabase* db, int conversation_rowid, std::string role, std::string content );
+ void generateResponse(std::string chat_payload, CDatabase* db, std::ostringstream& assistant_response, CStreamingResponse* response = nullptr);
+ std::string generateTitle( CDatabase* db, int conversation_rowid );
+
+ void streamChatChunk( CStreamingResponse* response, CDatabase* db, const std::string role, const std::string content );
+ void streamMetaChunk( CStreamingResponse* response, CDatabase* db, const std::string prop, const std::string value );
+};
\ No newline at end of file
diff --git a/backend/Controller.cpp b/backend/Controller.cpp
index 2104c1caebb28ea7c7beb06a55b92e77940ae241..59ac4e016881c27dd867b68e19d6b9c6f48fb78d 100644 (file)
--- a/backend/Controller.cpp
+++ b/backend/Controller.cpp
#include <stdexcept>
-CHTTPResponse* CController::handle(CHTTPRequest& request, const std::vector<std::string>& args)
+void CController::handle(CHTTPRequest& request, const std::vector<std::string>& args)
{
auto actions = getActions();
auto a = actions[0];
@@ -22,26 +22,30 @@ CHTTPResponse* CController::handle(CHTTPRequest& request, const std::vector<std:
throw HTTPUnauthorizedException("Must be logged in to access this resource");
}
}
- return a.action(this, request);
+ a.action(this, request);
+ return;
}
catch (const HTTPException& e)
{
- return errorResponse( request, e.what(), e.getStatusCode());
+ errorResponse( request, e.what(), e.getStatusCode());
+ return;
}
catch (const std::runtime_error& e)
{
- return errorResponse( request, e.what(), 500);
+ errorResponse( request, e.what(), 500);
+ return;
}
}
a = actions[++i];
}
- return errorResponse( request, "Action " + args[1] + " not found", 404);
+ errorResponse( request, "Action " + args[1] + " not found", 404);
}
-CHTTPResponse* CController::errorResponse(CHTTPRequest& request, const std::string& message, int statusCode)
+void CController::errorResponse(CHTTPRequest& request, const std::string& message, int statusCode)
{
- auto response = new CJSONResponse(request, "error", message, "{}");
- response->statusCode = statusCode;
- return response;
+ std::cout << "errorResponse: " << message << std::endl;
+ CJSONResponse response(request, "error", message, "{}");
+ response.statusCode = statusCode;
+ response.send();
}
diff --git a/backend/Controller.h b/backend/Controller.h
index 60b77cc1bf9b88054350c16e5561c58fe95ac4a3..fbc0ecad7ed64938de6a93fae01d6b4c295a94b6 100644 (file)
--- a/backend/Controller.h
+++ b/backend/Controller.h
{
std::string name;
bool auth_required;
- std::function<CHTTPResponse*(CController*, CHTTPRequest&)> action;
+ std::function<void(CController*, CHTTPRequest&)> action;
};
class CController
{
public:
virtual ~CController() = default;
- CHTTPResponse* handle(CHTTPRequest& request, const std::vector<std::string>& args);
- CHTTPResponse* errorResponse(CHTTPRequest& request, const std::string& message, int statusCode);
+ void handle(CHTTPRequest& request, const std::vector<std::string>& args);
+ void errorResponse(CHTTPRequest& request, const std::string& message, int statusCode);
protected:
virtual const ACTIONMAP* getActions() = 0;
};
\ No newline at end of file
diff --git a/backend/DBQuery.h b/backend/DBQuery.h
index a83ec946c8c0525d17fe6b49af6b40444d514bb8..4e4228183c5e2f1b98a6c81df4dc7497c7257df0 100644 (file)
--- a/backend/DBQuery.h
+++ b/backend/DBQuery.h
bind_params(index + 1, std::forward<Args>(args)...);
}
+ template<typename... Args>
+ void bind_params( int index, const char* value, Args&&... args)
+ {
+ if(value == nullptr)
+ {
+ sqlite3_bind_null(_stmt, index);
+ }
+ else
+ {
+ sqlite3_bind_text(_stmt, index, value, -1, SQLITE_TRANSIENT);
+ }
+ bind_params(index + 1, std::forward<Args>(args)...);
+ }
+
void bind_params( [[maybe_unused]] int index ) {};
template<typename... Args>
diff --git a/backend/Database.h b/backend/Database.h
index c93b5838dd75d0ebd359e0f98807cf70a8cbc29b..3dae779c708349bfccd2105cdcf6903c428876dd 100644 (file)
--- a/backend/Database.h
+++ b/backend/Database.h
#include <string>
#include <vector>
#include <sstream>
+#include <iostream>
#include "sqlite3.h"
#include "DBQuery.h"
int lastInsertRowid();
template<typename... Args>
- std::string encodeJsonObject(Args&... args)
+ std::string encodeJsonObject(Args&&... args)
{
static_assert(sizeof...(Args) % 2 == 0, "encodeJsonObject requires an even number of arguments");
std::ostringstream select_str;
- std::ostringstream params_str;
- build_encode_sql_object(select_str, params_str, 0, args...);
+ build_encode_sql_object(select_str, 0, std::forward<Args>(args)...);
std::ostringstream sql;
- sql << "SELECT json_object(" << select_str.str() << ") FROM (SELECT " << params_str.str() << " as json_request)";
+ sql << "SELECT json_object(" << select_str.str() << ")";
CDBQuery encode_query = query(sql.str());
- bind_encode_params(encode_query, 0, args...);
+ bind_encode_params(encode_query, 1, std::forward<Args>(args)...);
std::string result;
if( encode_query.fetch_row(result) == SQLITE_ROW )
void build_extract_sql_select([[maybe_unused]] std::ostringstream& sql, [[maybe_unused]] int i) {};
template<typename... Args, typename T>
- void build_encode_sql_object(std::ostringstream& select_str, std::ostringstream& params_str, int i, std::string json_key, [[maybe_unused]] T val, Args&... args)
+ void build_encode_sql_object(std::ostringstream& select_str, int i, std::string json_key, [[maybe_unused]] T val, Args&&... args)
{
- select_str << json_key << ", " << val;
- params_str << "? as " << json_key;
+ select_str << "'" << json_key << "', ?";
if( sizeof...(args) > 0 )
{
select_str << ", ";
}
- build_encode_sql_object(select_str, params_str, i + 1, args...);
+ build_encode_sql_object(select_str, i + 1, std::forward<Args>(args)...);
}
- void build_encode_sql_object([[maybe_unused]] std::ostringstream& select_str, [[maybe_unused]] std::ostringstream& params_str, [[maybe_unused]] int i) {};
+ void build_encode_sql_object([[maybe_unused]] std::ostringstream& select_str, [[maybe_unused]] int i) {};
template<typename... Args, typename T>
- void bind_encode_params(CDBQuery& query, int i, [[maybe_unused]] std::string json_key, T val, Args&... args)
+ void bind_encode_params(CDBQuery& query, int i, [[maybe_unused]] std::string json_key, T val, Args&&... args)
{
query.bind(i, val);
- bind_encode_params(query, i + 1, args...);
+ bind_encode_params(query, i + 1, std::forward<Args>(args)...);
}
void bind_encode_params([[maybe_unused]] CDBQuery& query, [[maybe_unused]] int i) {};
index 3619be387009e2b38dc8024735cfad2852ae703a..d5288415c4599b80dea0e049d123c41224a74812 100644 (file)
--- a/backend/HTTPRequest.cpp
+++ b/backend/HTTPRequest.cpp
#include "HTTPRequest.h"
+#include "HTTPExceptions.h"
#include <unistd.h>
#include <iostream>
_bodyRead = true;
return _body;
-}
\ No newline at end of file
+}
+
+std::string CHTTPRequest::requireArgument( unsigned int index )
+{
+ if( index + 2 >= _pathComponents.size() )
+ throw HTTPBadRequestException("Missing argument: " + std::to_string(index));
+ return _pathComponents[index + 2];
+}
\ No newline at end of file
diff --git a/backend/HTTPRequest.h b/backend/HTTPRequest.h
index 2cb9ad8b3cbd3eb2a4245041ab74b3559a49c102..ae8042154a343780ad5ba8009925e49639a64bbb 100644 (file)
--- a/backend/HTTPRequest.h
+++ b/backend/HTTPRequest.h
std::string getQueryParameter(const std::string& name) const;
std::string getHeader(const std::string& name) const;
std::string getCookie(const std::string& name) const;
+ std::string requireArgument( unsigned int index );
CUser& getUser() { return _user; }
int getSocket() const { return _socket; }
const std::vector<std::string>& getPathComponents() const { return _pathComponents; }
index 810ce2dbe005497ab9cb9b59d0298364d57b5536..033911461d9d9e9ce57da44461d75a092b6287d7 100644 (file)
#include "AuthController.h"
#include "JSONResponse.h"
#include "ListController.h"
+#include "ChatController.h"
#include <iostream>
#include <stdexcept>
"/login",
"/signup",
"/list",
+ "/chat",
};
bool isFrontendRoute(const std::string& path)
// Register default controllers
registerController("auth", std::make_unique<CAuthController>());
registerController("list", std::make_unique<CListController>());
+ registerController("chat", std::make_unique<CChatController>());
}
CHTTPRequestRouter::~CHTTPRequestRouter() = default;
@@ -38,31 +41,30 @@ void CHTTPRequestRouter::registerController(const std::string& name, std::unique
_controllers[name] = std::move(controller);
}
-CHTTPResponse* CHTTPRequestRouter::handle(CHTTPRequest& request)
+void CHTTPRequestRouter::handle(CHTTPRequest& request)
{
try
{
- return createResponse(request);
+ createResponse(request);
}
catch (const HTTPException& e)
{
- return errorResponse( request, e.what(), e.getStatusCode());
+ errorResponse( request, e.what(), e.getStatusCode());
}
catch (const std::runtime_error& e)
{
- return errorResponse( request, e.what(), 500);
+ errorResponse( request, e.what(), 500);
}
}
-CHTTPResponse* CHTTPRequestRouter::errorResponse(CHTTPRequest& request, const std::string& message, int statusCode)
+void CHTTPRequestRouter::errorResponse(CHTTPRequest& request, const std::string& message, int statusCode)
{
- auto response = new CHTTPResponse(request);
- response->responseBody << message;
- response->statusCode = statusCode;
- return response;
+ CJSONResponse response(request, "error", message, "{}");
+ response.statusCode = statusCode;
+ response.send();
}
-CHTTPResponse* CHTTPRequestRouter::createResponse(CHTTPRequest& request)
+void CHTTPRequestRouter::createResponse(CHTTPRequest& request)
{
auto pathComponents = request.getPathComponents();
if (pathComponents.size() > 1)
if (isFrontendRoute(request.getPath()))
{
- return new CStaticFileResponse(request, "index.html");
+ CStaticFileResponse response(request, "index.html");
+ response.send();
+ return;
}
// No path components or no matching controller - try static file
try
{
- return new CStaticFileResponse(request, request.getPath());
+ CStaticFileResponse response(request, request.getPath());
+ response.send();
}
catch (const HTTPNotFoundException& e)
{
- auto response = new CStaticFileResponse(request, "index.html");
- response->statusCode = 404;
- return response;
-
+ CStaticFileResponse response(request, "index.html");
+ response.statusCode = 404;
+ response.send();
}
}
\ No newline at end of file
index a08f6a109324e98088a90a9b809ca783cf7a74fc..8b2a7a6c5eae3d82e3e8c1a1a986f8e77cde4aeb 100644 (file)
CHTTPRequestRouter();
~CHTTPRequestRouter();
- CHTTPResponse* handle(CHTTPRequest& request);
+ void handle(CHTTPRequest& request);
// Allow dynamic controller registration
void registerController(const std::string& name, std::unique_ptr<CController> controller);
private:
std::unordered_map<std::string, std::unique_ptr<CController>> _controllers;
- CHTTPResponse* errorResponse(CHTTPRequest& request, const std::string& message, int statusCode);
- CHTTPResponse* createResponse(CHTTPRequest& request);
+ void errorResponse(CHTTPRequest& request, const std::string& message, int statusCode);
+ void createResponse(CHTTPRequest& request);
};
\ No newline at end of file
index e2c2344941a19b2c7eced1a26e2d3c81a182a9b3..4f40a0c64c1f8e741997f49527f41520792556a2 100644 (file)
--- a/backend/HTTPResponse.cpp
+++ b/backend/HTTPResponse.cpp
if( write(_request.getSocket(), out.str().c_str(), out.tellp()) == -1 )
{
- die("Failed to write response to socket");
+ std::cerr << "Failed to write response to socket" << std::endl;
}
+
+ log();
}
\ No newline at end of file
index c2bef17d5aef6a8af04ac00b77f4d18719cb59ca..1c620eab4754b847ac70887522ecde2131206e27 100644 (file)
return json_builder.str();
}
-CHTTPResponse* CListController::fetch(CHTTPRequest& request) {
+void CListController::fetch(CHTTPRequest& request) {
CDatabase* db = &request.getUser().getDatabase();
// Get root items (items with no parent)
}
result_builder << "]";
- return new CJSONResponse(request, "success", "List fetched successfully", result_builder.str());
+ CJSONResponse response(request, "success", "List fetched successfully", result_builder.str());
+ response.send();
}
-CHTTPResponse* CListController::save(CHTTPRequest& request) {
+void CListController::save(CHTTPRequest& request) {
CDatabase* db = &request.getUser().getDatabase();
const std::string sql_upsert_item =
"INSERT INTO items (uuid, json, type) "
db->query("COMMIT").execute();
- CJSONResponse* success_response = new CJSONResponse(request, "success", "List saved successfully");
- success_response->statusCode = 200; // OK
- return success_response;
+ CJSONResponse response(request, "success", "List saved successfully");
+ response.statusCode = 200; // OK
+ response.send();
} catch (...) {
try { db->query("ROLLBACK").execute(); } catch (...) {}
throw;
index f553b8d822c50293b6932fc1c8ecd2382d369f08..0db883a57d218e181aaad7227038b9f2a12fc61f 100644 (file)
--- a/backend/ListController.h
+++ b/backend/ListController.h
protected:
virtual const ACTIONMAP* getActions() override;
private:
- CHTTPResponse* save(CHTTPRequest& request);
- CHTTPResponse* fetch(CHTTPRequest& request);
+ void save(CHTTPRequest& request);
+ void fetch(CHTTPRequest& request);
std::string buildItemWithChildren(CDatabase* db, int rowid);
diff --git a/backend/SettingsHelper.cpp b/backend/SettingsHelper.cpp
--- /dev/null
@@ -0,0 +1,49 @@
+#include "SettingsHelper.h"
+
+#include <fstream>
+#include <sstream>
+#include <stdexcept>
+
+#include "util.h" // for g_rootDir
+#include "Database.h"
+
+std::map<std::string, std::string> CSettingsHelper::s_settings;
+std::once_flag CSettingsHelper::s_loadOnceFlag;
+
+void CSettingsHelper::ensureLoaded() {
+ std::call_once(s_loadOnceFlag, []() {
+ // Build path relative to g_rootDir
+ std::string path = g_rootDir + "/app_settings.json";
+ loadFromFile(path);
+ });
+}
+
+std::string CSettingsHelper::get(const std::string& key, const std::string& defaultValue) {
+ ensureLoaded();
+ auto it = s_settings.find(key);
+ if (it != s_settings.end()) return it->second;
+ return defaultValue;
+}
+
+void CSettingsHelper::loadFromFile(const std::string& path) {
+ std::ifstream in(path);
+ if (!in.is_open()) {
+ // No settings file; leave map empty
+ return;
+ }
+
+ std::ostringstream ss;
+ ss << in.rdbuf();
+ const std::string json = ss.str();
+
+ // Use SQLite JSON functions to iterate keys via json_each
+ CDatabase memDb(":memory:");
+ std::string key;
+ std::string value;
+ CDBQuery q = memDb.query("SELECT key, value FROM json_each(?)", json);
+ while (q.fetch_row(key, value) == SQLITE_ROW) {
+ s_settings[key] = value;
+ }
+}
+
+
diff --git a/backend/SettingsHelper.h b/backend/SettingsHelper.h
--- /dev/null
+++ b/backend/SettingsHelper.h
@@ -0,0 +1,23 @@
+#pragma once
+
+#include <string>
+#include <map>
+#include <mutex>
+
+// Simple static settings loader for app_settings.json
+class CSettingsHelper {
+public:
+ // Ensure settings are loaded (idempotent)
+ static void ensureLoaded();
+
+ // Get a value by key; returns defaultValue if key missing
+ static std::string get(const std::string& key, const std::string& defaultValue = "");
+
+private:
+ static void loadFromFile(const std::string& path);
+
+ static std::map<std::string, std::string> s_settings;
+ static std::once_flag s_loadOnceFlag;
+};
+
+
diff --git a/backend/StreamingResponse.cpp b/backend/StreamingResponse.cpp
--- /dev/null
@@ -0,0 +1,71 @@
+#include "StreamingResponse.h"
+
+#include <unistd.h>
+#include <fstream>
+#include <cerrno>
+
+CStreamingResponse::CStreamingResponse(CHTTPRequest& request) : CHTTPResponse(request)
+{
+}
+
+CStreamingResponse::~CStreamingResponse()
+{
+}
+
+void CStreamingResponse::begin()
+{
+ std::ostringstream headers;
+
+ setHeader("Content-Type", "text/event-stream; charset=utf-8");
+ setHeader("Cache-Control", "no-cache");
+ setHeader("Transfer-Encoding", "chunked");
+
+ writeStatusLine(headers);
+ writeHeaders(headers);
+ headers << crlf();
+
+ // Write headers first
+ if( write(_request.getSocket(), headers.str().c_str(), headers.tellp()) == -1 )
+ {
+ _active = false;
+ return;
+ }
+
+ // Prepare initial chunk (body)
+ std::string initialBody = "retry: 1000\n\n";
+ std::ostringstream chunk;
+ chunk << std::hex << initialBody.size() << crlf() << initialBody << crlf();
+ std::string chunkStr = chunk.str();
+ if( write(_request.getSocket(), chunkStr.c_str(), chunkStr.size()) == -1 )
+ {
+ _active = false;
+ return;
+ }
+}
+
+void CStreamingResponse::stream(const std::string& data)
+{
+ if (!_active) return;
+ std::string chunkData = "data: " + data + "\n\n";
+ std::ostringstream chunk;
+ chunk << std::hex << chunkData.size() << crlf() << chunkData << crlf();
+ std::string chunkStr = chunk.str();
+ if( write(_request.getSocket(), chunkStr.c_str(), chunkStr.size()) == -1 )
+ {
+ _active = false;
+ return;
+ }
+}
+
+void CStreamingResponse::end()
+{
+ if (!_active) return;
+ // Send zero-length chunk to signal end
+ std::string endChunk = std::string("0") + crlf() + crlf();
+ if( write(_request.getSocket(), endChunk.c_str(), endChunk.size()) == -1 )
+ {
+ _active = false;
+ return;
+ }
+ log();
+}
\ No newline at end of file
diff --git a/backend/StreamingResponse.h b/backend/StreamingResponse.h
--- /dev/null
@@ -0,0 +1,20 @@
+#pragma once
+
+#include "HTTPResponse.h"
+
+class CStreamingResponse : public CHTTPResponse
+{
+public:
+ CStreamingResponse(CHTTPRequest& request);
+ ~CStreamingResponse();
+
+ void begin();
+ void stream(const std::string& data);
+ void end();
+
+ // Returns false once the client disconnects or any write fails
+ bool isActive() const { return _active; }
+
+private:
+ bool _active = true;
+};
\ No newline at end of file
diff --git a/backend/main.cpp b/backend/main.cpp
index 0d24b3e1748ac37af05501d330a4d1f0f8b3e6cb..e3735fa3153b46e2437fbe079fd19fd76bdcd40d 100644 (file)
--- a/backend/main.cpp
+++ b/backend/main.cpp
#include <cstdlib>
#include <netinet/in.h>
#include <unistd.h>
+#include <signal.h>
#include "HTTPRequest.h"
#include "HTTPRequestRouter.h"
#include "util.h"
+#include "JSONResponse.h"
std::map<int,std::thread> threadMap;
std::vector<int> completeThreads;
return;
}
- // Create appropriate response handler using factory
- CHTTPResponse* response = router.handle(request);
+ try
+ {
+ router.handle(request);
+ }
+ catch(const std::exception& e)
+ {
+ std::cerr << "Unhandled exception: " << e.what() << '\n';
+ CJSONResponse response(request, "error", "Unhandled exception: " + std::string(e.what()), "{}");
+ response.statusCode = 500;
+ response.send();
+ }
- // Send the response
- response->send();
- response->log();
-
- // Clean up
- delete response;
// Close the connection
close(client_socket);
// Set the global root directory
g_rootDir = root_dir;
+ // Ignore SIGPIPE so writes to closed sockets don't kill the process
+ signal(SIGPIPE, SIG_IGN);
+
int server_socket;
struct sockaddr_in server_address;
return 1;
}
+ // Avoid SIGPIPE on macOS for this socket as well
+#ifdef SO_NOSIGPIPE
+ {
+ int on = 1;
+ setsockopt(server_socket, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on));
+ }
+#endif
+
// Set up the server address
server_address.sin_family = AF_INET;
server_address.sin_addr.s_addr = INADDR_ANY;
continue;
}
+ // Avoid SIGPIPE on client sockets (primarily macOS)
+#ifdef SO_NOSIGPIPE
+ {
+ int on = 1;
+ setsockopt(client_socket, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on));
+ }
+#endif
+
// Launch a new thread to handle the client
auto lk = std::unique_lock<std::mutex>( threadMapLock );
threadMap[threadId] = std::thread( handle_client, threadId, client_socket );
index 80e984af0bca4b14f5bc5124fd86a196cdebfc0e..1bce3ccb07b8456e5bab53375128e0fb8abebd28 100644 (file)
-import { h, Component, createContext } from 'preact';
+import { h, Component, createContext, ComponentChildren } from 'preact';
interface AppNotification {
visible: boolean;
uuid: string;
}
+interface AppToolbar {
+ position: 'top' | 'bottom';
+ component: ComponentChildren;
+}
+
// Define the shape of your context
interface AppContextType {
isLoading: boolean;
setUser: (user: User) => void;
checkAuth: () => void;
fetchData: (url: string, body?: any) => Promise<any>;
+ streamSSE: (url: string, body: any, onMessage: (obj: any) => void, onError?: (err: any) => void) => void;
logout: () => void;
+ setToolbars: (toolbars: AppToolbar[]) => void;
+ toolbars: AppToolbar[];
}
}
});
- return null;
+ return { status: 'error', message: error.message || 'Failed to load data. Please check connection.' };
})
.finally(() => {
this.setState({ isLoading: false });
});
},
+ streamSSE: async (url: string, body: any, onMessage: (obj: any) => void, onError?: (err: any) => void) => {
+ try {
+ const response = await fetch(url, {
+ method: body ? 'POST' : 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: body ? JSON.stringify(body) : undefined,
+ credentials: 'include',
+ });
+ if (!response.body) throw new Error('No response body');
+ const reader = response.body.getReader();
+ let buffer = '';
+ const decoder = new TextDecoder();
+ let done = false;
+ while (!done) {
+ const { value, done: streamDone } = await reader.read();
+ done = streamDone;
+ if (value) {
+ buffer += decoder.decode(value, { stream: !done });
+ let lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+ for (const line of lines) {
+ if (!line.trim()) continue;
+ try {
+ // Check if line starts with "data: " and extract JSON after it
+ if (line.startsWith('data: ')) {
+ const jsonStr = line.substring(6); // Skip "data: " prefix
+ const obj = JSON.parse(jsonStr);
+ onMessage(obj);
+ if (obj.done) {
+ reader.cancel();
+ return;
+ }
+ }
+ } catch (e) {
+ // Optionally log parse errors
+ }
+ }
+ }
+ }
+ // Handle any remaining buffer
+ if (buffer.trim()) {
+ try {
+ const obj = JSON.parse(buffer);
+ onMessage(obj);
+ } catch (e) {
+ // Optionally log parse errors
+ }
+ }
+ } catch (err) {
+ if (onError) onError(err);
+ }
+ },
logout: () => {
// Add a cache-busting query parameter
const cacheBuster = `?cb=${new Date().getTime()}`;
// Optionally, still redirect or show an error
window.location.href = '/'; // Or handle error more gracefully
});
- }
+ },
+ setToolbars: (toolbars: AppToolbar[]) => {
+ this.setState({ toolbars: toolbars });
+ },
+ toolbars: [],
};
render() {
</AppContext.Provider>
);
}
-}
\ No newline at end of file
+}
index d6da90bf5e84075268b4a042dd5ae1711148198c..464a406cd081f2a4e5d8763938b7702644249b5a 100644 (file)
--- a/frontend/app_routes.tsx
+++ b/frontend/app_routes.tsx
import List from 'views/List';
import SignUp from 'views/SignUp';
import Login from 'views/Login';
+import Chat from 'views/Chat';
interface AppRoute {
path: string;
title: 'Login',
auth_required: false,
},
+ {
+ path: '/chat',
+ view: Chat,
+ title: 'Chat',
+ auth_required: true,
+ },
];
export default AppRoutes;
\ No newline at end of file
diff --git a/frontend/components/Accordion.tsx b/frontend/components/Accordion.tsx
--- /dev/null
@@ -0,0 +1,30 @@
+import { h } from 'preact';
+import { useState } from 'preact/hooks';
+import { ChevronDownIcon, ChevronRightIcon } from './Icons';
+
+interface AccordionProps {
+ title: string;
+ children: h.JSX.Element;
+}
+
+export const Accordion = ({ title, children }: AccordionProps) => {
+ const [open, setOpen] = useState(false);
+ return (
+ <div className={`accordion${open ? ' open' : ''}`}>
+ <div
+ className="accordion-title"
+ tabIndex={0}
+ role="button"
+ aria-expanded={open}
+ onClick={() => setOpen(o => !o)}
+ onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') setOpen(o => !o); }}
+ >
+ <span className="accordion-icon">
+ <ChevronRightIcon size={20} />
+ </span>
+ <span className="accordion-title-text">{title}</span>
+ </div>
+ <div className={`accordion-content${open ? ' expanded' : ' collapsed'}`}>{children}</div>
+ </div>
+ );
+};
\ No newline at end of file
diff --git a/frontend/components/BottomBarContainer.tsx b/frontend/components/BottomBarContainer.tsx
--- /dev/null
@@ -0,0 +1,77 @@
+import { h, ComponentChildren, Fragment } from 'preact';
+import { useEffect, useRef, useState } from 'preact/hooks';
+
+interface BottomBarContainerProps {
+ children?: ComponentChildren;
+}
+
+export const BottomBarContainer = ({ children }: BottomBarContainerProps) => {
+ const barRef = useRef<HTMLDivElement>(null);
+ const [ height, setHeight ] = useState(0);
+ const spacerRef = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ function resizeHandler() {
+ if (!barRef.current) {
+ return;
+ }
+
+ // Check if visual viewport API is available
+ if (!window.visualViewport) {
+ return;
+ }
+
+ const { height: viewportHeight, offsetTop } = window.visualViewport;
+
+ // Get the window height for comparison
+ const windowHeight = window.innerHeight;
+
+ // If we're on desktop (viewport height is close to window height), reset transform
+ if (viewportHeight > windowHeight * 0.9) {
+ barRef.current.style.transform = 'translate(0px, 0px)';
+ return;
+ }
+
+ // Calculate how much the keyboard has reduced the viewport
+ const keyboardHeight = windowHeight - viewportHeight;
+
+ // Move the bottom bar up by the keyboard height and account for viewport offset
+ // We use negative value to move up, and subtract offsetTop to handle viewport shifts
+ barRef.current.style.transform = `translate(0px, -${keyboardHeight - offsetTop}px)`;
+ }
+
+ // run first time to initialize
+ resizeHandler();
+
+ // subscribe to events which affect scroll, or viewport position
+ window.visualViewport?.addEventListener('resize', resizeHandler);
+ window.visualViewport?.addEventListener('scroll', resizeHandler);
+ window?.addEventListener('touchmove', resizeHandler);
+
+ // unsubscribe
+ return () => {
+ window.visualViewport?.removeEventListener('resize', resizeHandler);
+ window.visualViewport?.removeEventListener('scroll', resizeHandler);
+ window?.removeEventListener('touchmove', resizeHandler);
+ };
+ }, []);
+
+ useEffect(() => {
+ if( spacerRef.current && barRef.current ) {
+ setHeight(barRef.current.offsetHeight);
+ }
+ }, [spacerRef.current, barRef.current]);
+
+ if( !children ) {
+ return null;
+ }
+
+ return (
+ <Fragment>
+ <div className="bottom-bar-container" ref={barRef}>
+ {children}
+ </div>
+ <div ref={spacerRef} style={{ height: `${height}px` }} />
+ </Fragment>
+ );
+};
\ No newline at end of file
diff --git a/frontend/components/ChatConversation.tsx b/frontend/components/ChatConversation.tsx
--- /dev/null
@@ -0,0 +1,20 @@
+import { h } from 'preact';
+import { ChatMessage, ChatMessageProps } from './ChatMessage';
+
+interface ChatConversationProps {
+ messages: ChatMessageProps[];
+ statusMessage: string | null;
+}
+
+export const ChatConversation = ({ messages, statusMessage }: ChatConversationProps) => {
+ return (
+ <div className="chat-conversation-scrollable">
+ <div className="chat-conversation">
+ {messages.map((message) => (
+ <ChatMessage {...message} />
+ ))}
+ </div>
+ { statusMessage && <div>{statusMessage}</div> }
+ </div>
+ );
+};
\ No newline at end of file
diff --git a/frontend/components/ChatInputForm.tsx b/frontend/components/ChatInputForm.tsx
--- /dev/null
@@ -0,0 +1,51 @@
+import { h } from 'preact';
+import { useState } from 'preact/hooks';
+import { SendIcon } from './Icons';
+
+interface ChatInputFormProps {
+ onSubmit: (message: string) => void;
+ inputDisabled: boolean;
+}
+
+export const ChatInputForm = ({ onSubmit, inputDisabled }: ChatInputFormProps) => {
+ const [input, setInput] = useState('');
+
+ const submitMessage = (e: Event) => {
+ e.preventDefault();
+ if (inputDisabled) return;
+ const trimmed = input.trim();
+ if (!trimmed) return;
+ setInput('');
+ onSubmit(trimmed);
+ };
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ if (inputDisabled) return;
+ const trimmed = input.trim();
+ if (!trimmed) return;
+ setInput('');
+ onSubmit(trimmed);
+ }
+ };
+
+ return (
+ <form onSubmit={submitMessage} className="chat-form chat-form-bottom">
+ <div className="chat-input-wrapper">
+ <textarea
+ value={input}
+ onInput={e => setInput((e.target as HTMLTextAreaElement).value)}
+ onKeyDown={handleKeyDown}
+ placeholder={inputDisabled ? "Waiting for response..." : "Type your message..."}
+ className="chat-input"
+ disabled={inputDisabled}
+ rows={3}
+ />
+ <button type="submit" disabled={inputDisabled || !input.trim()} className="chat-send-btn-embedded">
+ <SendIcon size={22} />
+ </button>
+ </div>
+ </form>
+ );
+};
\ No newline at end of file
diff --git a/frontend/components/ChatMessage.tsx b/frontend/components/ChatMessage.tsx
--- /dev/null
@@ -0,0 +1,32 @@
+import { h } from 'preact';
+import Markdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import { Accordion } from './Accordion';
+
+export interface ChatMessageProps {
+ role: 'user' | 'assistant' | 'error' | 'status';
+ content: string;
+}
+
+export const ChatMessage = ({ role, content }: ChatMessageProps) => {
+ const className =
+ role === 'user'
+ ? 'chat-message chat-message-user'
+ : role === 'assistant'
+ ? 'chat-message chat-message-assistant'
+ : 'chat-message';
+
+ let reasoningContent = null;
+ if( role === 'assistant' ) {
+ const match = content.match(/<think>\n(.*?)(?:<\/think>|$)/s);
+ if( match ) {
+ reasoningContent = match[1];
+ content = content.replace(match[0], '').trim();
+ }
+ }
+
+ return <div className={className}>
+ {reasoningContent && <Accordion title="Show Thinking" children={<div className="chat-message-reasoning">{reasoningContent}</div>} />}
+ <Markdown remarkPlugins={[remarkGfm]}>{content}</Markdown>
+ </div>;
+};
\ No newline at end of file
index 67d0d35d0685304cfd4c149388cf717595c58082..536d395d73f805e7ccf58496425383487454de73 100644 (file)
@@ -219,4 +219,88 @@ export const CircleIcon = ({ size = 24, color = 'currentColor', className = '' }
>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" />
</svg>
-);
\ No newline at end of file
+);
+
+export const ConversationHistoryIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => (
+ <svg
+ viewBox="0 0 24 24"
+ width={size}
+ height={size}
+ fill={color}
+ class={className}
+ style={{ verticalAlign: 'middle' }}
+ >
+ <path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/>
+ </svg>
+);
+
+export const NewConversationIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => (
+ <svg
+ viewBox="0 0 24 24"
+ width={size}
+ height={size}
+ fill="none"
+ stroke={color}
+ strokeWidth="2"
+ class={className}
+ style={{ verticalAlign: 'middle' }}
+ >
+ {/* Chat bubble */}
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
+ {/* Plus sign */}
+ <line x1="12" y1="7" x2="12" y2="13" />
+ <line x1="9" y1="10" x2="15" y2="10" />
+ </svg>
+);
+
+export const ChevronDownIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => (
+ <svg
+ viewBox="0 0 24 24"
+ width={size}
+ height={size}
+ fill="none"
+ stroke={color}
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class={className}
+ style={{ verticalAlign: 'middle' }}
+ >
+ <polyline points="6 9 12 15 18 9" />
+ </svg>
+);
+
+export const ChevronRightIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => (
+ <svg
+ viewBox="0 0 24 24"
+ width={size}
+ height={size}
+ fill="none"
+ stroke={color}
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class={className}
+ style={{ verticalAlign: 'middle' }}
+ >
+ <polyline points="9 6 15 12 9 18" />
+ </svg>
+);
+
+export const SendIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => (
+ <svg
+ viewBox="0 0 24 24"
+ width={size}
+ height={size}
+ fill="none"
+ stroke={color}
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ class={className}
+ style={{ verticalAlign: 'middle' }}
+ >
+ <line x1="22" y1="2" x2="11" y2="13" />
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
+ </svg>
+);
index 92da97ca1abbd6e5ad35bc5a72b7bbdb47b8f502..aac8e5fddaf0ba83c0c177c18adcf49153edbd43 100644 (file)
setSaveStatus('saving');
const response = await fetchData('/list/save', flatItems);
- if (!response.ok) throw new Error(`Save failed: ${response.status}`);
+ if (response.status === 'error') throw new Error(response.message);
const savedUuids = new Set(flatItems.map(item => item.uuid));
setItems(prevItems => clearDirtyFlags(prevItems, savedUuids));
index 140c468ba0df57d1d14cfe3bf67bd7dd4da8b77c..16a71677819373ff6bf406e2a094b5092c9490ff 100644 (file)
import { AppContext } from '../app/AppContext';
import 'styles.css';
-const LoadingSpinner = () => {
- return <div className="loader"></div>;
+interface LoadingSpinnerProps {
+ size?: 'small' | 'large';
+ color?: string;
+}
+
+const LoadingSpinner = ({ size = 'large', color = '#000' }: LoadingSpinnerProps) => {
+ const sizeClass = size === 'small' ? 'loader-small' : 'loader';
+ return <div className={sizeClass} style={{ width: size, height: size, borderColor: color }}></div>;
};
export default LoadingSpinner;
\ No newline at end of file
index 7daaac74c32cde86ab4f94438d10b3513a2e58e9..8de10042b347a5d2c793b63b5292e06a09668266 100644 (file)
import { h, JSX } from 'preact';
-import { useContext, useEffect, useRef } from 'preact/hooks';
+import { useContext } from 'preact/hooks';
import { AppContext } from 'app/AppContext';
import LoadingSpinner from './LoadingSpinner';
import { MenuIcon, CheckIcon, CircleIcon } from './Icons';
export const Toolbar = ({ buttons, onMenuClick }: ToolbarProps) => {
const appContext = useContext(AppContext);
- const toolbarRef = useRef<HTMLDivElement>(null);
const handleMenuClick = () => {
if (onMenuClick) {
}
};
- useEffect(() => {
- function resizeHandler() {
- if( !toolbarRef.current) {
- return;
- }
-
- if( window.visualViewport?.width && window.visualViewport.width > 480) {
- toolbarRef.current.style.transform = `translate( 0px, 0px)`;
- return;
- }
-
- // viewport height
- const viewportHeight = window.visualViewport?.height ?? 0;
- const offsetTop = window.visualViewport?.offsetTop ?? 0;
- const viewportScale = window.visualViewport?.scale ?? 1;
- // math - removed scale transform to prevent event handling issues
- toolbarRef.current.style.transform = `translate( 0px, ${viewportHeight - toolbarRef.current.offsetHeight + offsetTop}px)`;
- }
-
- // run first time to initialize
- resizeHandler();
-
- // subscribe to events which affect scroll, or viewport position
- window.visualViewport?.addEventListener('resize', resizeHandler);
- window.visualViewport?.addEventListener('scroll', resizeHandler);
- window?.addEventListener('touchmove', resizeHandler);
-
- // unsubscribe
- return () => {
- window.visualViewport?.removeEventListener('resize', resizeHandler);
- window.visualViewport?.removeEventListener('scroll', resizeHandler);
- window?.removeEventListener('touchmove', resizeHandler);
- };
- }, []);
-
return (
- <div class="toolbar-spacer">
- <div class="toolbar" ref={toolbarRef}>
- <div class="toolbar-left">
- <ToolbarButton
- icon={<MenuIcon size={20} />}
- onClick={handleMenuClick}
- title="Menu"
- menuOptions={[
- {
- label: 'List',
- onClick: () => {
- window.location.href = '/list';
- }
- },
- {
- label: 'Chat',
- onClick: () => {
- window.location.href = '/chat';
- }
- },
- {
- label: 'Logout',
- onClick: () => {
- appContext.logout();
- }
+ <div class="toolbar">
+ <div class="toolbar-left">
+ <ToolbarButton
+ icon={<MenuIcon size={20} />}
+ onClick={handleMenuClick}
+ title="Menu"
+ menuOptions={[
+ {
+ label: 'List',
+ onClick: () => {
+ window.location.href = '/list';
}
- ]}
- />
- <div class="toolbar-separator" />
- {buttons.map((button, index) => (
- <div key={index} class="toolbar-button-container">
- <ToolbarButton
- icon={button.icon}
- onClick={button.onClick}
- title={button.title}
- disabled={button.disabled}
- active={button.active}
- />
- {button.separatorAfter && (
- <div class="toolbar-separator" />
- )}
- </div>
- ))}
- </div>
-
- <div class="toolbar-right">
- {appContext.saveStatus === 'saving' ? (
- <LoadingSpinner />
- ) : appContext.saveStatus === 'saved' ? (
- <CheckIcon size={20} />
- ) : appContext.saveStatus === 'queued' ? (
- <CircleIcon size={20} />
- ) : (
- <CheckIcon size={20} />
- )}
- </div>
+ },
+ {
+ label: 'Chat',
+ onClick: () => {
+ window.location.href = '/chat';
+ }
+ },
+ {
+ label: 'Logout',
+ onClick: () => {
+ appContext.logout();
+ }
+ }
+ ]}
+ />
+ <div class="toolbar-separator" />
+ {buttons.map((button, index) => (
+ <div key={index} class="toolbar-button-container">
+ <ToolbarButton {...button}/>
+ {button.separatorAfter && (
+ <div class="toolbar-separator" />
+ )}
+ </div>
+ ))}
+ </div>
+
+ <div class="toolbar-right">
+ {appContext.saveStatus === 'saving' ? (
+ <LoadingSpinner size="small" />
+ ) : appContext.saveStatus === 'saved' ? (
+ <CheckIcon size={20} />
+ ) : appContext.saveStatus === 'queued' ? (
+ <CircleIcon size={20} />
+ ) : (
+ <CheckIcon size={20} />
+ )}
</div>
</div>
);
index 6df8e0fdab5e013296d1bb3ef2c436aa8f82cf83..c43f03912a91021f28db4bfbf208f471c519e4f0 100644 (file)
menuOptions
}: ToolbarButtonProps) => {
const [showMenu, setShowMenu] = useState(false);
+ const [menuPlacement, setMenuPlacement] = useState<{ dropUp: boolean; alignRight: boolean }>({ dropUp: false, alignRight: false });
+ const [menuMaxHeightPx, setMenuMaxHeightPx] = useState<number | undefined>(undefined);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
if (disabled) return;
if (hasMenu) {
- setShowMenu(!showMenu);
+ const willShow = !showMenu;
+ setShowMenu(willShow);
+ if (willShow) {
+ // Compute placement on next frame to ensure DOM is updated
+ requestAnimationFrame(() => {
+ computeAndSetMenuPlacement();
+ });
+ }
} else if (onClick) {
onClick();
}
setShowMenu(false);
};
+ const computeAndSetMenuPlacement = () => {
+ const buttonEl = buttonRef.current;
+ const menuEl = menuRef.current;
+ if (!buttonEl || !menuEl) return;
+
+ const buttonRect = buttonEl.getBoundingClientRect();
+ const menuRect = menuEl.getBoundingClientRect();
+
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+
+ // Determine vertical placement: drop up if not enough space below
+ const spaceBelow = viewportHeight - buttonRect.bottom;
+ const spaceAbove = buttonRect.top;
+ const dropUp = spaceBelow < menuRect.height && spaceAbove > spaceBelow;
+
+ // Determine horizontal alignment: align right if overflowing to the right and there's more space to the left
+ const wouldOverflowRight = buttonRect.left + menuRect.width > viewportWidth;
+ const alignRight = wouldOverflowRight && (buttonRect.right > menuRect.width || buttonRect.right > viewportWidth / 2);
+
+ setMenuPlacement({ dropUp, alignRight });
+
+ // Compute max available height for the menu to keep it on-screen
+ const verticalPaddingPx = 8; // small breathing room
+ const availableHeight = dropUp
+ ? Math.max(0, spaceAbove - verticalPaddingPx)
+ : Math.max(0, spaceBelow - verticalPaddingPx);
+ const clampedMax = Math.max(96, Math.min(Math.floor(viewportHeight * 0.9), Math.floor(availableHeight)));
+ setMenuMaxHeightPx(clampedMax);
+ };
+
+ // Recompute placement on resize/scroll when menu is open
+ useEffect(() => {
+ if (!showMenu) return;
+ const handler = () => computeAndSetMenuPlacement();
+ window.addEventListener('resize', handler);
+ window.addEventListener('scroll', handler, true);
+ const vv = (window as any).visualViewport as VisualViewport | undefined;
+ vv?.addEventListener('resize', handler);
+ vv?.addEventListener('scroll', handler);
+ // initial compute in case content changed
+ computeAndSetMenuPlacement();
+ return () => {
+ window.removeEventListener('resize', handler);
+ window.removeEventListener('scroll', handler, true);
+ vv?.removeEventListener('resize', handler);
+ vv?.removeEventListener('scroll', handler);
+ };
+ }, [showMenu]);
+
return (
<div class="toolbar-button-wrapper">
<button
</button>
{hasMenu && showMenu && (
- <div ref={menuRef} class="toolbar-menu">
+ <div
+ ref={menuRef}
+ class={`toolbar-menu ${menuPlacement.dropUp ? 'drop-up' : ''} ${menuPlacement.alignRight ? 'align-right' : ''}`}
+ style={{
+ maxHeight: menuMaxHeightPx ? `${menuMaxHeightPx}px` : undefined,
+ overflowY: 'auto',
+ WebkitOverflowScrolling: 'touch',
+ overscrollBehavior: 'contain'
+ }}
+ >
{menuOptions.map((option, index) => (
<button
key={index}
diff --git a/frontend/components/TopBarContainer.tsx b/frontend/components/TopBarContainer.tsx
--- /dev/null
@@ -0,0 +1,92 @@
+import { h, ComponentChildren, Fragment, toChildArray } from 'preact';
+import { useEffect, useRef, useState } from 'preact/hooks';
+
+interface TopBarContainerProps {
+ children?: ComponentChildren;
+}
+
+export const TopBarContainer = ({ children }: TopBarContainerProps) => {
+ const barRef = useRef<HTMLDivElement>(null);
+ const [ height, setHeight ] = useState(0);
+ const spacerRef = useRef<HTMLDivElement>(null);
+ const childCount = toChildArray(children).length;
+
+ useEffect(() => {
+ function resizeHandler() {
+ if (!barRef.current) {
+ return;
+ }
+
+ // Check if visual viewport API is available
+ if (!window.visualViewport) {
+ return;
+ }
+
+ const { height: viewportHeight, offsetTop } = window.visualViewport;
+
+ // Get the window height for comparison
+ const windowHeight = window.innerHeight;
+
+ // If we're on desktop (viewport height is close to window height), reset transform
+ if (viewportHeight > windowHeight * 0.9) {
+ barRef.current.style.transform = 'translate(0px, 0px)';
+ return;
+ }
+
+ // Calculate how much the keyboard has reduced the viewport
+ const keyboardHeight = windowHeight - viewportHeight;
+
+ // For top bar, we want to move it down by the keyboard height to keep it visible
+ // When keyboard appears, the viewport shrinks, so we need to move the top bar down
+ barRef.current.style.transform = `translate(0px, ${offsetTop}px)`;
+ }
+
+ // run first time to initialize
+ resizeHandler();
+
+ // subscribe to events which affect scroll, or viewport position
+ window.visualViewport?.addEventListener('resize', resizeHandler);
+ window.visualViewport?.addEventListener('scroll', resizeHandler);
+ window?.addEventListener('touchmove', resizeHandler);
+
+ // unsubscribe
+ return () => {
+ window.visualViewport?.removeEventListener('resize', resizeHandler);
+ window.visualViewport?.removeEventListener('scroll', resizeHandler);
+ window?.removeEventListener('touchmove', resizeHandler);
+ };
+ }, []);
+
+ useEffect(() => {
+ if( spacerRef.current && barRef.current ) {
+ setHeight(barRef.current.offsetHeight);
+ }
+ }, [spacerRef.current, barRef.current, childCount]);
+
+ // Keep spacer height in sync with bar size changes
+ useEffect(() => {
+ if (!barRef.current || typeof ResizeObserver === 'undefined') {
+ return;
+ }
+ const observer = new ResizeObserver(() => {
+ if (barRef.current) {
+ setHeight(barRef.current.offsetHeight);
+ }
+ });
+ observer.observe(barRef.current);
+ return () => observer.disconnect();
+ }, [barRef.current, childCount]);
+
+ if (childCount === 0) {
+ return null;
+ }
+
+ return (
+ <Fragment>
+ <div className="top-bar-container" ref={barRef}>
+ {children}
+ </div>
+ <div ref={spacerRef} style={{ height: `${height}px` }} />
+ </Fragment>
+ );
+};
\ No newline at end of file
diff --git a/frontend/components/View.tsx b/frontend/components/View.tsx
--- /dev/null
@@ -0,0 +1,75 @@
+import { h, ComponentChildren } from 'preact';
+import { useContext, useEffect, useRef, useState } from 'preact/hooks';
+import { AppContext } from '../app/AppContext';
+import { Toolbar } from 'components/Toolbar';
+import { TopBarContainer } from 'components/TopBarContainer';
+import { BottomBarContainer } from 'components/BottomBarContainer';
+
+interface ViewProps {
+ children: ComponentChildren;
+ onMobileViewEnable?: (isMobile: boolean) => void;
+}
+
+export const View = ({
+ children,
+ onMobileViewEnable,
+}: ViewProps) => {
+ const { toolbars } = useContext(AppContext);
+ const [ topBarContent, setTopBarContent ] = useState<ComponentChildren>(null);
+ const [ bottomBarContent, setBottomBarContent ] = useState<ComponentChildren>(null);
+ const [ isMobile, setIsMobile ] = useState(false);
+ const prevIsMobileRef = useRef<boolean>(false);
+
+ useEffect(() => {
+ const topBars = toolbars.filter(toolbar => toolbar.position === 'top');
+ const bottomBars = toolbars.filter(toolbar => toolbar.position === 'bottom');
+ setTopBarContent(topBars.map(bar => bar.component));
+ setBottomBarContent(bottomBars.map(bar => bar.component));
+ }, [toolbars]);
+
+ useEffect(() => {
+ const checkMobileState = () => {
+ if (window.visualViewport) {
+ const newIsMobile = window.visualViewport.width < 480;
+ setIsMobile(newIsMobile);
+ } else {
+ setIsMobile(false);
+ }
+ };
+
+ // Initial check
+ checkMobileState();
+
+ // Add resize event listener
+ window.addEventListener('resize', checkMobileState);
+
+ // Cleanup
+ return () => {
+ window.removeEventListener('resize', checkMobileState);
+ };
+ }, []);
+
+ useEffect(() => {
+ // Only call callback when mobile state changes
+ if (onMobileViewEnable && prevIsMobileRef.current !== isMobile) {
+ onMobileViewEnable(isMobile);
+ prevIsMobileRef.current = isMobile;
+ }
+ }, [isMobile, onMobileViewEnable]);
+
+ return (
+ <div className="view">
+ <TopBarContainer>
+ {topBarContent}
+ </TopBarContainer>
+
+ <main className="view-main">
+ {children}
+ </main>
+
+ <BottomBarContainer>
+ {bottomBarContent}
+ </BottomBarContainer>
+ </div>
+ );
+};
\ No newline at end of file
index 6d2aa9be9141459026292eb9f91708c098250707..0de8669d5eef393fc2be32f50cab8a077bed873d 100644 (file)
minify: !isDev,
jsxFactory: 'h',
jsxFragment: 'Fragment',
+ alias: {
+ 'react': 'preact/compat',
+ 'react-dom': 'preact/compat',
+ },
loader: {
'.js': 'jsx',
'.ts': 'tsx',
skipLibCheck: true,
jsx: 'transform',
jsxFactory: 'h',
- jsxFragmentFactory: 'Fragment'
+ jsxFragmentFactory: 'Fragment',
+ baseUrl: ".",
+ paths: {
+ react: ["./node_modules/preact/compat/"],
+ "react/jsx-runtime": ["./node_modules/preact/jsx-runtime"],
+ "react-dom": ["./node_modules/preact/compat/"],
+ "react-dom/*": ["./node_modules/preact/compat/*"]
+ }
}
}
}).catch((error) => {
diff --git a/frontend/package.json b/frontend/package.json
index db77790c99e24386fcec1e21aa5e2f86480995da..c0cf06d7153904d0a3c799ac931e8358f6866157 100644 (file)
--- a/frontend/package.json
+++ b/frontend/package.json
"dependencies": {
"esbuild": "^0.25.4",
"preact": "^10.26.6",
- "preact-router": "^4.1.2"
+ "preact-router": "^4.1.2",
+ "react-markdown": "^10.1.0",
+ "remark-gfm": "^4.0.0"
}
}
index 99609e80b2550870c11815d25c557226e1c154d7..17ed31b77e94791277901105cbccfae93e0abb0b 100644 (file)
<html lang="en">
<head>
<meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <meta name="viewport" content="height=device-height, width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=no, target-densitydpi=device-dpi">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<link rel="stylesheet" href="%CSS_PATH%">
diff --git a/frontend/styles.css b/frontend/styles.css
index 9135a9d9869236142cc333ca1c0d6be29f098435..cf09b81267aaa36ba78e1c6ad8d697c714845940 100644 (file)
--- a/frontend/styles.css
+++ b/frontend/styles.css
@import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&family=Merriweather:ital,opsz,wght@0,18..144,300..900;1,18..144,300..900&display=swap');
/* Base Styles */
-body {
+html, body {
font-family: 'Lato', sans-serif;
- padding-bottom: 3.25rem; /* Account for fixed bottom toolbar */
margin: 0;
- min-height: 100vh;
+ padding: 0;
+ /*height: 100%;*/
+ /*overflow-x: hidden;*/
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
}
animation: prixClipFix 2s linear infinite;
}
+.loader-small::before {
+ border-width: 0.15rem;
+}
+
@keyframes rotate {
100% { transform: rotate(360deg) }
}
100% { clip-path: polygon(50% 50%,0 0,100% 0,100% 100%,0 100%,0 0) }
}
-/* Form Styles */
-form {
+.login-form {
max-width: 25rem;
- margin: 2rem auto;
- padding: 2rem;
background: white;
border-radius: 0.5rem;
box-shadow: 0 0.125rem 0.625rem rgba(0, 0, 0, 0.1);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+/* Form Styles */
+/* Ensure inputs and buttons calculate width consistently */
+form input, form button, form textarea, form select {
+ box-sizing: border-box;
+}
+
+form {
+ margin: 2rem auto;
+ padding: 2rem;
}
form h1 {
}
/* List Item Styles */
-ul {
+.list {
list-style: disc;
}
font-style: italic;
}
+/* Container Styles */
+.top-bar-container {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 2000;
+ background: none;
+ display: flex;
+ flex-direction: column;
+}
+
+.bottom-bar-container {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 1000;
+ background: none;
+ display: flex;
+ flex-direction: column;
+}
+
+.hide-on-mobile {
+ display: block;
+}
+
+.hide-on-desktop {
+ display: none;
+}
+
+@media (max-width: 480px) {
+ .hide-on-mobile {
+ display: none;
+ }
+
+ .hide-on-desktop {
+ display: block;
+ }
+}
+
/* Toolbar Styles */
.toolbar {
display: flex;
backdrop-filter: blur(0.5rem);
-webkit-backdrop-filter: blur(0.5rem);
border-bottom: 1px solid #dee2e6;
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- z-index: 1000;
height: 3.25rem;
box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.1);
-webkit-overflow-scrolling: touch;
+ z-index: 10000;
}
.toolbar-spacer {
top: 100%;
left: 0;
min-width: 11.25rem;
+ max-width: min(90vw, 22rem);
background: white;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.15);
- z-index: 1001;
+ z-index: 10000;
margin-top: 0.25rem;
padding: 0.25rem 0;
backdrop-filter: blur(0.625rem);
animation: menuFadeIn 0.15s ease-out;
+ overflow-x: hidden;
+ /* Make long menus scroll instead of overflowing off-screen */
+ max-height: 90vh;
+}
+
+.toolbar-menu.align-right {
+ left: auto;
+ right: 0;
+}
+
+.toolbar-menu.drop-up {
+ top: auto;
+ bottom: 100%;
+ margin-top: 0;
+ margin-bottom: 0.25rem;
}
@keyframes menuFadeIn {
.toolbar-menu {
min-width: 10rem;
margin-top: 0.1875rem;
+ max-height: 85vh;
+ overscroll-behavior: contain;
+ -webkit-overflow-scrolling: touch;
}
.toolbar-menu-item {
.toolbar {
height: 2.5rem;
padding: 0.25rem 0.5rem;
- /*top: auto;
- bottom: 0;*/
+ border-bottom: none;
+ border-top: 1px solid #dee2e6;
}
.toolbar-spacer {
min-width: 8.75rem;
margin-top: 0.125rem;
padding: 0.125rem 0;
+ /* Mobile: tighter max-height so the OS chrome doesn't overlap */
+ max-height: 80vh;
+ overscroll-behavior: contain;
+ -webkit-overflow-scrolling: touch;
}
.toolbar-menu-item {
height: 0.75rem;
}
}
+
+/* Chat form styles */
+.chat-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin: 0;
+}
+
+/* Embedded chat input and send button */
+.chat-input-wrapper {
+ position: relative;
+ width: 100%;
+ display: flex;
+ align-items: flex-end;
+}
+
+.chat-input {
+ width: 100%;
+ font-size: 1rem;
+ padding: 0.75rem 3rem 0.75rem 0.75rem; /* right padding for button */
+ border: 1px solid #ddd;
+ border-radius: 0.25rem;
+ resize: vertical;
+ min-height: 3rem;
+ box-sizing: border-box;
+ transition: border-color 0.2s;
+}
+
+.chat-send-btn-embedded {
+ position: absolute;
+ right: 0.5rem;
+ bottom: 0.5rem;
+ background: #0074ff;
+ border: none;
+ border-radius: 50%;
+ width: 2.5rem;
+ height: 2.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.08);
+ transition: background 0.2s, box-shadow 0.2s;
+ z-index: 2;
+ padding: 0;
+}
+
+.chat-send-btn-embedded:disabled {
+ background: #b3d1ff;
+ cursor: not-allowed;
+}
+
+.chat-send-btn-embedded:not(:disabled):hover {
+ background: #0056b3;
+}
+
+/* Chat message styles */
+
+.chat-message {
+ margin-bottom: 0.5rem;
+ word-wrap: break-word;
+}
+
+.chat-message-user {
+ margin-left: auto; /* Push to right */
+ width: 60%;
+ background: #e6f0ff;
+ border-radius: 0.5rem;
+ padding: 0.5rem 0.75rem;
+}
+
+.chat-message-assistant {
+ width: 100%;
+ max-width: 90vw;
+ padding: 1.0rem;
+ word-wrap: break-word;
+}
+
+.chat-message-reasoning {
+ white-space: pre-wrap;
+ margin-bottom: 0.5rem;
+ word-wrap: break-word;
+ font-size: 0.9rem;
+ color: #666;
+ background: #f7f7f7;
+ border-radius: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid #e0e0e0;
+ font-family: monospace;
+}
+
+/* Accordion Styles */
+.accordion {
+ border: 1px solid #e0e0e0;
+ border-radius: 0.5rem;
+ margin-bottom: 1rem;
+ background: #fff;
+ box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.03);
+ overflow: hidden;
+}
+.accordion-title {
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+ padding: 0.75rem 1rem;
+ font-weight: 600;
+ font-size: 1.1rem;
+ user-select: none;
+ background: #f7f7f7;
+ border-bottom: 1px solid #e0e0e0;
+ transition: background 0.2s;
+ outline: none;
+}
+.accordion-title:focus, .accordion-title:hover {
+ background: #eaf3ff;
+}
+.accordion-icon {
+ margin-right: 0.75rem;
+ display: flex;
+ align-items: center;
+ transition: transform 0.5s cubic-bezier(0.4,0,0.2,1);
+}
+.accordion.open .accordion-icon {
+ transform: rotate(90deg);
+}
+.accordion-title-text {
+ flex: 1;
+}
+.accordion-content {
+ overflow: hidden;
+ max-height: 1000px;
+ transition: max-height 0.5s cubic-bezier(0.4,0,0.2,1), padding 0.5s, opacity 0.5s;
+ padding: 1rem;
+ opacity: 1;
+}
+.accordion-content.collapsed {
+ max-height: 0;
+ padding-top: 0;
+ padding-bottom: 0;
+ opacity: 0;
+ pointer-events: none;
+}
+.accordion-content.expanded {
+ opacity: 1;
+}
+
+.view {
+ display: flex;
+ flex-direction: column;
+ position: absolute;
+ width: 100vw;
+}
+
+.view-main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+}
+
+/* List-specific layout overrides */
+.list-container {
+ width: 100%;
+ margin: 0;
+ padding-left: 2rem;
+ box-sizing: border-box;
+ min-height: 0; /* Allow shrinking */
+}
+
+.chat-conversation-scrollable {
+ flex: 1;
+ padding: 1rem 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ overflow-y: auto;
+ /*overflow-x: hidden;*/
+ background: #f8f9fa;
+ min-width: 100vw;
+ min-height: 100vh;
+}
+
+.chat-conversation {
+ margin-left: 2.5rem;
+ margin-right: 2.5rem;
+}
+
+.chat-form.chat-form-bottom {
+ flex-shrink: 0;
+ background: linear-gradient(135deg, rgba(248, 249, 250, 0.95) 0%, rgba(233, 236, 239, 0.95) 100%);
+ backdrop-filter: blur(0.5rem);
+ -webkit-backdrop-filter: blur(0.5rem);
+ border-top: 1px solid #dee2e6;
+ box-shadow: 0 -0.125rem 0.5rem rgba(0, 0, 0, 0.05);
+ padding: 0.75rem 1rem;
+ margin: 0;
+}
+
+@media (max-width: 768px) {
+ .chat-conversation {
+ margin-left: 1rem;
+ margin-right: 1rem;
+ }
+}
+
+@media (max-width: 480px) {
+ .chat-conversation {
+ margin-left: 0.5rem;
+ margin-right: 0.5rem;
+ }
+ .chat-input-wrapper {
+ min-height: 3.5rem;
+ }
+ .chat-send-btn-embedded {
+ width: 2.2rem;
+ height: 2.2rem;
+ right: 0.25rem;
+ bottom: 0.25rem;
+ }
+ .chat-input {
+ padding-right: 2.5rem;
+ }
+}
\ No newline at end of file
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 2466fb093564489a249923194f994a27d3b1ac3f..5dcea51ab2f38ac9c602ae3b0a8c1d516f37b507 100644 (file)
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
+ "react": ["./node_modules/preact/compat/"],
+ "react/jsx-runtime": ["./node_modules/preact/jsx-runtime"],
+ "react-dom": ["./node_modules/preact/compat/"],
+ "react-dom/*": ["./node_modules/preact/compat/*"],
"components/*": ["components/*"]
}
},
diff --git a/frontend/views/Chat.tsx b/frontend/views/Chat.tsx
--- /dev/null
+++ b/frontend/views/Chat.tsx
@@ -0,0 +1,202 @@
+import { Fragment, h } from 'preact';
+import { useContext, useEffect, useState } from 'preact/hooks';
+import { AppContext } from '../app/AppContext';
+import LoadingSpinner from '../components/LoadingSpinner';
+import { ChatConversation } from 'components/ChatConversation';
+import { ChatInputForm } from 'components/ChatInputForm';
+import { ChatMessageProps } from 'components/ChatMessage';
+import { View } from 'components/View';
+import { ConversationHistoryIcon, NewConversationIcon } from 'components/Icons';
+import { Toolbar } from 'components/Toolbar';
+
+interface ChatMessageChunk {
+ role: 'user' | 'assistant' | 'error' | 'status';
+ content: string;
+ conversation_id?: string;
+ title?: string;
+ response_finished?: boolean;
+}
+
+interface ChatConversationItem {
+ _uuid: string;
+ _created_at: string;
+ _updated_at: string;
+ title: string;
+}
+
+const Chat = () => {
+ const { streamSSE, setToolbars, setSaveStatus, fetchData } = useContext(AppContext);
+ const [ status, setStatus ] = useState<string | null>('Checking status...');
+ const [ serverStatus, setServerStatus ] = useState<'checking' | 'online' | 'offline' | 'waking'>('checking');
+ const [ messages, setMessages ] = useState<ChatMessageProps[]>([]);
+ const [ conversationId, setConversationId ] = useState<string>('new');
+ const [ awaitingResponse, setAwaitingResponse ] = useState(false);
+ const [ conversationItems, setConversationItems ] = useState<ChatConversationItem[]>([]);
+ const [ loadingConversations, setLoadingConversations ] = useState<boolean>(false);
+ const [ loadingMessages, setLoadingMessages ] = useState<boolean>(false);
+ const [ isMobile, setIsMobile ] = useState<boolean>(false);
+
+ // Fetch conversation list and set toolbar buttons
+ useEffect(() => {
+ let cancelled = false;
+ setLoadingConversations(true);
+ fetchData('/chat/list')
+ .then((data) => {
+ if (cancelled) return;
+ if (data && data.status === 'success' && Array.isArray(data.data)) {
+ setConversationItems(data.data);
+ } else {
+ setConversationItems([]);
+ }
+ })
+ .catch(() => {
+ if (!cancelled) setConversationItems([]);
+ })
+ .finally(() => {
+ if (!cancelled) setLoadingConversations(false);
+ });
+ return () => { cancelled = true; };
+ }, [fetchData]);
+
+ // Update toolbar buttons when conversationItems or loadingConversations changes
+ useEffect(() => {
+ const buttons = [
+ {
+ icon: <NewConversationIcon size={20} />,
+ title: 'New Conversation',
+ disabled: loadingConversations,
+ onClick: () => {
+ setConversationId('new');
+ setMessages([]);
+ }
+ },
+ {
+ icon: <ConversationHistoryIcon size={20} />,
+ title: 'Conversation History',
+ disabled: loadingConversations,
+ menuOptions: conversationItems.map((item) => ({
+ label: item.title,
+ onClick: async () => {
+ setLoadingMessages(true);
+ try {
+ const data = await fetchData(`/chat/fetch/${item._uuid}`);
+ if (data && data.status === 'success' && data.data) {
+ setConversationId(item._uuid);
+ if (Array.isArray(data.data.messages)) {
+ setMessages(data.data.messages.map((msg: any) => ({ role: msg.role, content: msg.content })));
+ } else {
+ setMessages([]);
+ }
+ } else {
+ setMessages([]);
+ }
+ } catch (e) {
+ setMessages([]);
+ } finally {
+ setLoadingMessages(false);
+ }
+ },
+ })),
+ }
+ ];
+ setToolbars([
+ {
+ position: isMobile ? 'bottom' : 'top',
+ component: <Toolbar buttons={buttons} />,
+ },
+ {
+ position: 'bottom',
+ component: <ChatInputForm
+ onSubmit={sendMessage}
+ inputDisabled={awaitingResponse || loadingMessages}
+ />,
+ },
+ ]);
+ }, [isMobile, conversationItems, loadingConversations, setToolbars, fetchData, awaitingResponse, loadingMessages]);
+
+ // Check chat status on mount
+ useEffect(() => {
+ let cancelled = false;
+
+ streamSSE(
+ '/chat/status',
+ null,
+ (obj) => {
+ if (cancelled) return;
+
+ setServerStatus(obj.status);
+ setStatus(obj.message);
+ },
+ (err) => {
+ if (!cancelled) {
+ setServerStatus('offline');
+ setStatus(err.message);
+ }
+ }
+ );
+
+ return () => { cancelled = true; };
+ }, [streamSSE]);
+
+ const sendMessage = async (message: string) => {
+ // POST to /chat/update with user message
+ const body = { conversation_id: conversationId, user_message: message };
+ let assistantResponse = '';
+ const prevMessages: ChatMessageProps[] = [ ...messages, { role: 'user', content: message } ];
+ setMessages(prevMessages);
+ setSaveStatus('saving');
+ setAwaitingResponse(true);
+ try {
+ await streamSSE(
+ '/chat/update',
+ body,
+ (obj: ChatMessageChunk) => {
+ if (obj.role === 'assistant') {
+ assistantResponse += obj.content;
+ }
+ if (obj.role === 'status') {
+ if( obj.content )
+ setStatus(obj.content);
+ if( obj.conversation_id )
+ setConversationId(obj.conversation_id);
+ if( obj.title )
+ console.log("title", obj.title);
+ if( obj.response_finished )
+ setAwaitingResponse(false);
+ }
+ else
+ {
+ setStatus(null);
+ }
+ setMessages(() => [...prevMessages, { role: obj.role, content: assistantResponse }]);
+ },
+ (err) => {
+ setMessages(() => [...prevMessages, { role: 'error', content: err.message }]);
+ }
+ );
+ } catch (e) {
+ setMessages((prev) => [...prevMessages, { role: 'error', content: 'Error sending message.' }]);
+ }
+
+ setMessages((prev) => [...prevMessages, { role: 'assistant', content: assistantResponse }]);
+ setAwaitingResponse(false);
+ setSaveStatus('saved');
+ };
+
+ return (
+ <View onMobileViewEnable={setIsMobile}>
+ { serverStatus === 'online' ?
+ <ChatConversation
+ messages={messages}
+ statusMessage={status}
+ /> :
+ <div style={{ padding: '2rem', alignItems: 'center', display: 'flex', flexDirection: 'column' }}>
+ <LoadingSpinner />
+ <div style={{ marginTop: '1rem' }}>{status}</div>
+ </div>
+ }
+ </View>
+ )
+};
+
+export default Chat;
\ No newline at end of file
diff --git a/frontend/views/Home.tsx b/frontend/views/Home.tsx
--- a/frontend/views/Home.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { h } from 'preact';
-import { useContext } from 'preact/hooks';
-import { AppContext } from '../app/AppContext';
-import { Toolbar } from '../components/Toolbar';
-
-const Home = () => {
- const { user } = useContext(AppContext);
-
- return (
- <div>
- <Toolbar/>
- <h1>Welcome to List</h1>
- <p>Authenticated as {user?.username}</p>
- </div>
- );
-};
-
-export default Home;
\ No newline at end of file
index f06b3a8ab7812aa16e9fc77af773b8408ee06a54..2f7a1b89a3307a82ea8b5b0c991f804591a0f1d5 100644 (file)
--- a/frontend/views/List.tsx
+++ b/frontend/views/List.tsx
import { h } from 'preact';
-import { useContext } from 'preact/hooks';
+import { useContext, useEffect, useState } from 'preact/hooks';
import { ListContextProvider, useListState } from 'components/ListContext';
import { ListItem } from 'components/ListItem';
-import { Toolbar } from 'components/Toolbar';
-import ToolbarButton, { ToolbarButtonProps } from 'components/ToolbarButton';
+import { AppContext } from '../app/AppContext';
import { IndentIcon, OutdentIcon, ImportantIcon, CheckIcon, MoveUpIcon, MoveDownIcon } from 'components/Icons';
+import { View } from 'components/View';
+import Toolbar from 'components/Toolbar';
-const ListWithToolbar = () => {
+const ListComponent = () => {
const listContext = useContext(ListContextProvider);
+ const { setToolbars } = useContext(AppContext);
+ const [ isMobile, setIsMobile ] = useState(false);
- const buttons: ToolbarButtonProps[] = [
- {
- icon: <MoveUpIcon size={20} />,
- onClick: listContext.moveUpItem,
- title: 'Move Up (Ctrl+Up)',
- disabled: !listContext.canMoveUp,
- },
- {
- icon: <MoveDownIcon size={20} />,
- onClick: listContext.moveDownItem,
- title: 'Move Down (Ctrl+Down)',
- disabled: !listContext.canMoveDown,
- separatorAfter: true,
- },
- {
- icon: <OutdentIcon size={20} />,
- onClick: listContext.promoteItem,
- title: 'Outdent (Shift+Tab)',
- disabled: !listContext.canPromote,
- },
- {
- icon: <IndentIcon size={20} />,
- onClick: listContext.demoteItem,
- title: 'Indent (Tab)',
- disabled: !listContext.canDemote,
- separatorAfter: true,
- },
- {
- icon: <ImportantIcon size={20} />,
- onClick: listContext.toggleImportantItem,
- title: 'Toggle Important (Ctrl+B)',
- disabled: !listContext.focusedItem,
- active: listContext.focusedItem?.important || false,
- },
- {
- icon: <CheckIcon size={20} />,
- onClick: listContext.toggleCompletedItem,
- title: 'Toggle Completed (Ctrl+S)',
- disabled: !listContext.focusedItem,
- active: !!listContext.focusedItem?.completed_at,
- },
- ];
+ useEffect(() => {
+ const buttons = [
+ {
+ icon: <MoveUpIcon size={20} />,
+ onClick: listContext.moveUpItem,
+ title: 'Move Up (Ctrl+Up)',
+ disabled: !listContext.canMoveUp,
+ },
+ {
+ icon: <MoveDownIcon size={20} />,
+ onClick: listContext.moveDownItem,
+ title: 'Move Down (Ctrl+Down)',
+ disabled: !listContext.canMoveDown,
+ separatorAfter: true,
+ },
+ {
+ icon: <OutdentIcon size={20} />,
+ onClick: listContext.promoteItem,
+ title: 'Outdent (Shift+Tab)',
+ disabled: !listContext.canPromote,
+ },
+ {
+ icon: <IndentIcon size={20} />,
+ onClick: listContext.demoteItem,
+ title: 'Indent (Tab)',
+ disabled: !listContext.canDemote,
+ separatorAfter: true,
+ },
+ {
+ icon: <ImportantIcon size={20} />,
+ onClick: listContext.toggleImportantItem,
+ title: 'Toggle Important (Ctrl+B)',
+ disabled: !listContext.focusedItem,
+ active: listContext.focusedItem?.important || false,
+ },
+ {
+ icon: <CheckIcon size={20} />,
+ onClick: listContext.toggleCompletedItem,
+ title: 'Toggle Completed (Ctrl+S)',
+ disabled: !listContext.focusedItem,
+ active: !!listContext.focusedItem?.completed_at,
+ },
+ ];
+
+ setToolbars([
+ {
+ position: isMobile ? 'bottom' : 'top',
+ component: <Toolbar buttons={buttons} />,
+ },
+ ]);
+ }, [ isMobile, listContext.canMoveUp, listContext.canMoveDown, listContext.canPromote, listContext.canDemote, listContext.focusedItem?.important, listContext.focusedItem?.completed_at ]);
return (
- <div>
- <Toolbar buttons={buttons} />
- <div>
- <ul>
- {listContext.items.filter(item => !item.__is_deleted).map(item => (
- <ListItem
- key={item._uuid}
- item={item}
- onChange={listContext.handleItemChange}
- moveFocus={listContext.handleMoveFocus}
- promote={listContext.handlePromote}
- demote={listContext.handleDemote}
- toggleImportant={listContext.handleToggleImportant}
- toggleCompleted={listContext.handleToggleCompleted}
- createItem={listContext.handleCreateItem}
- deleteItem={listContext.handleDeleteItem}
- onFocus={listContext.handleItemFocus}
- moveUp={listContext.handleMoveUp}
- moveDown={listContext.handleMoveDown}
- />
- ))}
- </ul>
- </div>
- </div>
+ <View onMobileViewEnable={setIsMobile}>
+ <ul className="list-container">
+ {listContext.items.filter(item => !item.__is_deleted).map(item => (
+ <ListItem
+ key={item._uuid}
+ item={item}
+ onChange={listContext.handleItemChange}
+ moveFocus={listContext.handleMoveFocus}
+ promote={listContext.handlePromote}
+ demote={listContext.handleDemote}
+ toggleImportant={listContext.handleToggleImportant}
+ toggleCompleted={listContext.handleToggleCompleted}
+ createItem={listContext.handleCreateItem}
+ deleteItem={listContext.handleDeleteItem}
+ onFocus={listContext.handleItemFocus}
+ moveUp={listContext.handleMoveUp}
+ moveDown={listContext.handleMoveDown}
+ />
+ ))}
+ </ul>
+ </View>
);
};
handleMoveUp: listState.handleMoveUp,
handleMoveDown: listState.handleMoveDown,
}}>
- <ListWithToolbar />
+ <ListComponent />
</ListContextProvider.Provider>
);
};
index 1b08b95e0fc8f688a2faab4cc2eea29ab50e09c9..726422c8877fb7f64bcc88654275b49ce0370d6a 100644 (file)
--- a/frontend/views/Login.tsx
+++ b/frontend/views/Login.tsx
import { h } from "preact";
import { useContext } from "preact/hooks";
import { AppContext } from "../app/AppContext";
+import { View } from "components/View";
const Login = () => {
- const { fetchData } = useContext(AppContext);
+ const { fetchData, setNotification } = useContext(AppContext);
const handleSubmit = async (e: Event) => {
e.preventDefault();
if (!response) {
console.error('Login failed');
+ setNotification({
+ visible: true,
+ message: 'Invalid username or password',
+ showSpinner: false,
+ type: 'error'
+ });
return;
}
- // Handle successful login here
- window.location.href = '/'; // Redirect to home page after successful login
+ window.location.href = '/';
} catch (error) {
console.error('Login error:', error);
- // Handle login error here
+ setNotification({
+ visible: true,
+ message: 'Invalid username or password',
+ showSpinner: false,
+ type: 'error'
+ });
}
};
- return <div>
- Login
- <form onSubmit={handleSubmit}>
- <input type="text" name="username" />
- <input type="password" name="password" />
- <button type="submit">Login</button>
- </form>
- <a href="/signup">Sign up</a>
- </div>;
+ return (<View>
+ <div className="login-form">
+ <form onSubmit={handleSubmit}>
+ <input type="text" name="username" />
+ <input type="password" name="password" />
+ <button type="submit">Login</button>
+ </form>
+ <a href="/signup">Sign up</a>
+ </div>
+ </View>);
};
export default Login;
\ No newline at end of file