summary | shortlog | log | commit | commitdiff | tree
raw | patch | inline | side by side (parent: 266a40c)
raw | patch | inline | side by side (parent: 266a40c)
author | Eric Wertz <ericwertz@Erics-MacBook-Pro.local> | |
Wed, 4 Jun 2025 23:25:45 +0000 (19:25 -0400) | ||
committer | Eric Wertz <ericwertz@Erics-MacBook-Pro.local> | |
Wed, 4 Jun 2025 23:25:45 +0000 (19:25 -0400) |
diff --git a/backend/Database.h b/backend/Database.h
index 08a8ae6820ad9c203566940757903d671d10581f..c93b5838dd75d0ebd359e0f98807cf70a8cbc29b 100644 (file)
--- a/backend/Database.h
+++ b/backend/Database.h
int lastInsertRowid();
- /*template<typename... Args>
+ template<typename... 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...);
+
std::ostringstream sql;
- sql << "SELECT json_object(";
- build_encode_sql_object(sql, 0, args...);
- sql << ") FROM (SELECT ? as json_request)";
+ sql << "SELECT json_object(" << select_str.str() << ") FROM (SELECT " << params_str.str() << " as json_request)";
- CDBQuery encode_query = query(sql.str(), json);
- encode_query.extract_json_values(args...);
- }*/
+ CDBQuery encode_query = query(sql.str());
+ bind_encode_params(encode_query, 0, args...);
+
+ std::string result;
+ if( encode_query.fetch_row(result) == SQLITE_ROW )
+ {
+ return result;
+ }
+ return "{}";
+ }
private:
sqlite3* _db = nullptr;
std::string _db_path;
}
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)
+ {
+ select_str << json_key << ", " << val;
+ params_str << "? as " << json_key;
+ if( sizeof...(args) > 0 )
+ {
+ select_str << ", ";
+ }
+ build_encode_sql_object(select_str, params_str, i + 1, args...);
+ }
+
+ void build_encode_sql_object([[maybe_unused]] std::ostringstream& select_str, [[maybe_unused]] std::ostringstream& params_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)
+ {
+ query.bind(i, val);
+ bind_encode_params(query, i + 1, args...);
+ }
+
+ void bind_encode_params([[maybe_unused]] CDBQuery& query, [[maybe_unused]] int i) {};
};
\ No newline at end of file
index e68a0da87aa09f806ea8a18dc2bcc725094aab3a..c2bef17d5aef6a8af04ac00b77f4d18719cb59ca 100644 (file)
return actions;
}
-// DEPRECATED: This function is kept for backward compatibility but is no longer used
-// by the optimized fetch() method. It has O(N) database queries which is inefficient.
-std::string CListController::getItemJson(CDatabase* db, const int rowid) {
- //Get child items for uuid
- const std::string sql = "SELECT child_id FROM itemslink WHERE parent_id = ? AND type = 'listitem_hierarchy'";
- CDBQuery query = db->query(sql, rowid);
-
- std::string child_array_json = "";
+// Recursive function to build complete item JSON with all nested children
+std::string CListController::buildItemWithChildren(CDatabase* db, int rowid) {
+ // Get the item's basic data and JSON
+ const std::string item_sql = R"(
+ SELECT i.uuid, i.created_at, i.updated_at, i.type, i.json
+ FROM items i
+ WHERE i.rowid = ?
+ AND i.type = ?
+ AND IFNULL(json_extract(i.json, '$.completed_at'), '9999-01-01') > datetime('now', '-12 hours')
+ )";
+
+ CDBQuery item_query = db->query(item_sql, rowid, ITEM_TYPE);
+ std::string uuid, created_at, updated_at, type, json_data;
+
+ if (item_query.fetch_row(uuid, created_at, updated_at, type, json_data) != SQLITE_ROW) {
+ return ""; // Item filtered out or doesn't exist
+ }
+
+ // Get child items
+ const std::string children_sql = R"(
+ SELECT i.rowid, COALESCE(json_extract(i.json, '$.order'), 0) as item_order
+ FROM items i
+ JOIN itemslink il ON i.rowid = il.child_id
+ WHERE il.parent_id = ?
+ AND il.type = ?
+ AND i.type = ?
+ AND IFNULL(json_extract(i.json, '$.completed_at'), '9999-01-01') > datetime('now', '-12 hours')
+ ORDER BY item_order ASC
+ )";
+
+ CDBQuery children_query = db->query(children_sql, rowid, LINK_TYPE_HIERARCHY, ITEM_TYPE);
+
+ // Build children array efficiently
+ std::vector<std::string> children_json;
int child_rowid;
- while( query.fetch_row( child_rowid ) == SQLITE_ROW ) {
- std::string child_json = getItemJson(db, child_rowid);
- if( child_array_json.empty() ) {
- child_array_json = "[" + child_json;
- } else {
- child_array_json += "," + child_json;
+ int child_order;
+
+ while (children_query.fetch_row(child_rowid, child_order) == SQLITE_ROW) {
+ std::string child_json = buildItemWithChildren(db, child_rowid);
+ if (!child_json.empty()) {
+ children_json.push_back(child_json);
}
}
- if( child_array_json.empty() ) {
- child_array_json = "[]";
- } else {
- child_array_json += "]";
+
+ // Build final JSON array
+ std::ostringstream child_builder;
+ child_builder << "[";
+ for (size_t i = 0; i < children_json.size(); ++i) {
+ if (i > 0) child_builder << ",";
+ child_builder << children_json[i];
+ }
+ child_builder << "]";
+
+ // Build the final JSON efficiently
+ std::ostringstream json_builder;
+ json_builder << "{";
+ json_builder << "\"_uuid\":\"" << uuid << "\",";
+ json_builder << "\"_created_at\":\"" << created_at << "\",";
+ json_builder << "\"_updated_at\":\"" << updated_at << "\",";
+ json_builder << "\"_type\":\"" << type << "\",";
+ json_builder << "\"_children\":" << child_builder.str();
+
+ // Merge with item's custom JSON data
+ if (!json_data.empty() && json_data != "{}") {
+ // Add comma and append the item's JSON fields
+ json_builder << ",";
+
+ // Extract fields from json_data (skip the opening '{' and closing '}')
+ std::string item_fields = json_data.substr(1, json_data.length() - 2);
+ json_builder << item_fields;
}
+
+ json_builder << "}";
+ return json_builder.str();
+}
- const std::string obj_sql = R"(SELECT json_patch( json_object( '_uuid', i.uuid,
- '_created_at', i.created_at,
- '_updated_at', i.updated_at,
- '_type', i.type,
- '_children', json(?)
- ), json(i.json) )
+CHTTPResponse* CListController::fetch(CHTTPRequest& request) {
+ CDatabase* db = &request.getUser().getDatabase();
+
+ // Get root items (items with no parent)
+ const std::string root_sql = R"(
+ SELECT i.rowid, COALESCE(json_extract(i.json, '$.order'), 0) as item_order
FROM items i
- WHERE i.rowid = ?
- ORDER BY i.created_at ASC)";
- CDBQuery obj_query = db->query(obj_sql, child_array_json, rowid);
- std::string result_json_string;
- if( obj_query.fetch_row( result_json_string ) == SQLITE_ROW ) {
- return result_json_string;
+ LEFT JOIN itemslink il ON i.rowid = il.child_id AND il.type = ?
+ WHERE i.type = ?
+ AND il.parent_id IS NULL
+ AND IFNULL(json_extract(i.json, '$.completed_at'), '9999-01-01') > datetime('now', '-12 hours')
+ ORDER BY item_order ASC
+ )";
+
+ CDBQuery root_query = db->query(root_sql, LINK_TYPE_HIERARCHY, ITEM_TYPE);
+
+ // Build result array efficiently
+ std::vector<std::string> root_items;
+ int root_rowid;
+ int root_order;
+
+ while (root_query.fetch_row(root_rowid, root_order) == SQLITE_ROW) {
+ std::string item_json = buildItemWithChildren(db, root_rowid);
+ if (!item_json.empty()) {
+ root_items.push_back(item_json);
+ }
+ }
+
+ // Build final JSON array
+ std::ostringstream result_builder;
+ result_builder << "[";
+ for (size_t i = 0; i < root_items.size(); ++i) {
+ if (i > 0) result_builder << ",";
+ result_builder << root_items[i];
}
- return "{}";
+ result_builder << "]";
+
+ return new CJSONResponse(request, "success", "List fetched successfully", result_builder.str());
}
CHTTPResponse* CListController::save(CHTTPRequest& request) {
try { db->query("ROLLBACK").execute(); } catch (...) {}
throw;
}
-}
-
-// Alternative ultra-fast implementation using single recursive SQL query
-// Uncomment this to use instead of the above method
-CHTTPResponse* CListController::fetch(CHTTPRequest& request) {
- CDatabase* db = &request.getUser().getDatabase();
-
- // Single query approach using recursive CTE and JSON aggregation
- const std::string sql = R"(
- WITH RECURSIVE item_tree AS (
- -- Anchor: root items (no parent)
- SELECT
- i.rowid, i.uuid, i.created_at, i.updated_at, i.type, i.json,
- 0 as level,
- COALESCE(json_extract(i.json, '$.order'), 0) as item_order,
- printf('%010d', COALESCE(json_extract(i.json, '$.order'), 0)) as sort_path,
- json_patch(
- json_object(
- '_uuid', i.uuid,
- '_created_at', i.created_at,
- '_updated_at', i.updated_at,
- '_type', i.type,
- '_children', json('[]')
- ),
- COALESCE(json(i.json), json('{}'))
- ) as item_json
- FROM items i
- LEFT JOIN itemslink il ON i.rowid = il.child_id AND il.type = 'listitem_hierarchy'
- WHERE i.type = 'listitem' AND il.parent_id IS NULL
- AND IFNULL(json_extract(i.json, '$.completed_at'), '9999-01-01') > datetime('now', '-12 hours')
-
- UNION ALL
-
- -- Recursive: children
- SELECT
- child.rowid, child.uuid, child.created_at, child.updated_at,
- child.type, child.json,
- parent.level + 1,
- COALESCE(json_extract(child.json, '$.order'), 0) as item_order,
- parent.sort_path || '.' || printf('%010d', COALESCE(json_extract(child.json, '$.order'), 0)) as sort_path,
- json_patch(
- json_object(
- '_uuid', child.uuid,
- '_created_at', child.created_at,
- '_updated_at', child.updated_at,
- '_type', child.type,
- '_children', json('[]')
- ),
- COALESCE(json(child.json), json('{}'))
- ) as item_json
- FROM items child
- JOIN itemslink il ON child.rowid = il.child_id AND il.type = 'listitem_hierarchy'
- JOIN item_tree parent ON il.parent_id = parent.rowid
- WHERE child.type = 'listitem'
- AND IFNULL(json_extract(child.json, '$.completed_at'), '9999-01-01') > datetime('now', '-12 hours')
- ),
- -- Build hierarchy by updating parent _children arrays
- hierarchy AS (
- SELECT
- t1.rowid,
- t1.level,
- t1.created_at,
- t1.item_order,
- t1.sort_path,
- json_set(
- t1.item_json,
- '$._children',
- COALESCE(
- (SELECT json_group_array(json(t2.item_json))
- FROM item_tree t2
- JOIN itemslink il ON t2.rowid = il.child_id
- WHERE il.parent_id = t1.rowid
- AND il.type = 'listitem_hierarchy'
- ORDER BY t2.item_order ASC),
- json('[]')
- )
- ) as final_json
- FROM item_tree t1
- )
- SELECT json_group_array(json(final_json)) as result
- FROM hierarchy
- WHERE level = 0
- ORDER BY sort_path ASC
- )";
-
- CDBQuery query = db->query(sql);
- std::string result_json;
- if (query.fetch_row(result_json) == SQLITE_ROW && !result_json.empty()) {
- return new CJSONResponse(request, "success", "List fetched successfully", result_json);
- }
-
- return new CJSONResponse(request, "success", "List fetched successfully", "[]");
}
\ No newline at end of file
index bf329e916c0899563771a89c1c6b6e5243a8907f..f553b8d822c50293b6932fc1c8ecd2382d369f08 100644 (file)
--- a/backend/ListController.h
+++ b/backend/ListController.h
CHTTPResponse* save(CHTTPRequest& request);
CHTTPResponse* fetch(CHTTPRequest& request);
- std::string getItemJson(CDatabase* db, const int rowid);
+ std::string buildItemWithChildren(CDatabase* db, int rowid);
const std::string ITEM_TYPE = "listitem";
const std::string LINK_TYPE_HIERARCHY = "listitem_hierarchy";
index 5b56b159d223ad8a589eaa27f7f4a1962a6af111..5acbb61a645f981249e9216aa6afada72dfef431 100644 (file)
interface AppContextType {
isLoading: boolean;
setIsLoading: (loading: boolean) => void;
+ saveStatus: 'idle' | 'queued' | 'saving' | 'saved' | 'error';
+ setSaveStatus: (status: 'idle' | 'queued' | 'saving' | 'saved' | 'error') => void;
notification: AppNotification;
setNotification: (notification: AppNotification) => void;
loggedIn: boolean;
setIsLoading: (loading: boolean) => {
this.setState({ isLoading: loading });
},
+ saveStatus: 'idle',
+ setSaveStatus: (status: 'idle' | 'queued' | 'saving' | 'saved' | 'error') => {
+ this.setState({ saveStatus: status });
+ },
notification: {
visible: false,
message: '',
index 7c0a295f0855be92b8996cd597aa82f52b5bed2a..67d0d35d0685304cfd4c149388cf717595c58082 100644 (file)
@@ -205,4 +205,18 @@ export const MoveDownIcon = ({ size = 24, color = 'currentColor', className = ''
>
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z" />
</svg>
-);
\ No newline at end of file
+);
+
+export const CircleIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => (
+ <svg
+ viewBox="0 0 24 24"
+ width={size}
+ height={size}
+ fill="none"
+ stroke={color}
+ class={className}
+ style={{ verticalAlign: 'middle' }}
+ >
+ <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
index acbfba85270f906d5085e02a519aea437bef0010..0c2659a7e19540124e8295b3f2ef9e198440db54 100644 (file)
const [focusedItemUuid, setFocusedItemUuid] = useState<string | null>(null);
const [pendingFocusRestore, setPendingFocusRestore] = useState<{ uuid: string, cursorPosition: number } | null>(null);
const [saveTimeout, setSaveTimeout] = useState<number | null>(null);
- const { fetchData } = useContext(AppContext);
+ const { fetchData, setSaveStatus } = useContext(AppContext);
// Core tree utilities
const findItemInTree = (items: ItemProps[], uuid: string): ItemProps | null => {
if (response?.data?.length > 0) {
const itemsWithOrders = ensureItemOrders(response.data);
setItems(itemsWithOrders);
- setFocusedItemUuid(itemsWithOrders[0]._uuid);
+ //setFocusedItemUuid(itemsWithOrders[0]._uuid);
} else {
// Create default empty item
const defaultItem: ItemProps = {
const flatItems = flattenItemsForSave(items);
if (flatItems.length === 0) return;
+ setSaveStatus('saving');
const response = await fetch('/list/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
const savedUuids = new Set(flatItems.map(item => item.uuid));
setItems(prevItems => clearDirtyFlags(prevItems, savedUuids));
+ setSaveStatus('saved');
} catch (error) {
console.error('Failed to save list:', error);
+ setSaveStatus('error');
}
}, 1000);
+ setSaveStatus('queued');
setSaveTimeout(newTimeout);
};
index 46852a2b32c3cf65ac7e968fa481b2fae758eeec..af4f250159a7504da2a07184a8c205c44d3db517 100644 (file)
import { useRef, useEffect, useImperativeHandle, useState } from 'preact/hooks';
import { getCursorPosition, setCursorPosition } from '../util/util';
+const ITEM_LEVEL_COUNT = 5;
+
export interface ItemProps {
_uuid: string;
_type: string;
@@ -179,7 +181,7 @@ export const ListItem = forwardRef<ListItemRef, ListItemProps>(({ item, onChange
onFocus={handleFocus}
onKeyDown={handleKeyDown}
data-uuid={item._uuid}
- className={`list-item-level-${Math.min(item.level, 9)} ${item.important ? 'list-item-important' : ''} ${item.completed_at ? 'list-item-completed' : ''}`}
+ className={`list-item-level-${item.level % ITEM_LEVEL_COUNT} ${item.important ? 'list-item-important' : ''} ${item.completed_at ? 'list-item-completed' : ''}`}
dangerouslySetInnerHTML={{ __html: item.content }}/>
{item._children.filter(child => !child.__is_deleted).length > 0 && (
<ul>
index 6151803a06e873fe6d13a8d85c983d77fab9963c..f1227c672945b17b639786a41afdd838eaedda28 100644 (file)
import { useContext } from 'preact/hooks';
import { AppContext } from 'app/AppContext';
import LoadingSpinner from './LoadingSpinner';
-import { MenuIcon, CheckIcon } from './Icons';
-
-export interface ToolBarButton {
- icon: JSX.Element;
- onClick: () => void;
- title: string;
- disabled?: boolean;
- active?: boolean;
- separatorAfter?: boolean;
-}
+import { MenuIcon, CheckIcon, CircleIcon } from './Icons';
+import ToolbarButton, { ToolbarButtonProps } from './ToolbarButton';
interface ToolbarProps {
- buttons: ToolBarButton[];
+ buttons: ToolbarButtonProps[];
onMenuClick?: () => void;
}
return (
<div class="toolbar">
<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-separator" />
{buttons.map((button, index) => (
<div key={index} class="toolbar-button-container">
- <button
+ <ToolbarButton
+ icon={button.icon}
onClick={button.onClick}
title={button.title}
disabled={button.disabled}
- class={`toolbar-button ${button.active ? 'active' : ''} ${button.disabled ? 'disabled' : ''}`}
- >
- {button.icon}
- </button>
+ active={button.active}
+ />
{button.separatorAfter && (
<div class="toolbar-separator" />
)}
</div>
<div class="toolbar-right">
- {appContext.isLoading ? (
+ {appContext.saveStatus === 'saving' ? (
<LoadingSpinner />
+ ) : appContext.saveStatus === 'saved' ? (
+ <CheckIcon size={20} />
+ ) : appContext.saveStatus === 'queued' ? (
+ <CircleIcon size={20} />
) : (
<CheckIcon size={20} />
)}
- <button
- onClick={handleMenuClick}
- title="Menu"
- class="toolbar-button menu-button"
- >
- <MenuIcon size={20} />
- </button>
</div>
</div>
);
diff --git a/frontend/styles.css b/frontend/styles.css
index 9346a029784ed615ae433c4e2fb748b07388b4b9..af21cbdf8106bae5b4ba884dba2fe4b19676fbae 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 {
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ font-family: 'Lato', sans-serif;
}
h1 {
text-align: center;
transform: translateY(-100%);
animation: slideDown 0.3s ease forwards;
- z-index: 1000;
+ z-index: 2000;
border: 1px solid #000;
border-radius: 4px;
display: flex;
}
.list-item-important {
+ font-family: 'Merriweather', serif;
font-weight: bold;
+ font-size: 1.5rem;
}
.list-item-completed {
}
li > div[contenteditable] {
- padding: 8px 12px;
- margin: 2px 0;
- border-radius: 4px;
+ margin: 0.25rem 0;
+ padding: 0.125rem;
+ border-radius: 0.5rem;
border: 1px solid transparent;
transition: all 0.2s ease;
min-height: 1.2em;
li > div[contenteditable]:focus {
border-color: #0074ff;
background-color: rgba(0, 116, 255, 0.05);
- box-shadow: 0 0 0 2px rgba(0, 116, 255, 0.1);
+ box-shadow: 0.125rem 0.125rem 0.25rem rgba(0, 116, 255, 0.1);
}
li > div[contenteditable]:hover {
--indent-size: 24px;
}
-li > div[contenteditable] {
- margin-left: calc(var(--level, 0) * var(--indent-size));
+.list-item-level-0 {
+ font-weight: normal;
+ text-decoration: underline;
}
-/* Alternative approach using classes for different levels */
-.list-item-level-0 { margin-left: 0; }
-.list-item-level-1 { margin-left: 24px; }
-.list-item-level-2 { margin-left: 48px; }
-.list-item-level-3 { margin-left: 72px; }
-.list-item-level-4 { margin-left: 96px; }
-.list-item-level-5 { margin-left: 120px; }
-.list-item-level-6 { margin-left: 144px; }
-.list-item-level-7 { margin-left: 168px; }
-.list-item-level-8 { margin-left: 192px; }
-.list-item-level-9 { margin-left: 216px; }
+.list-item-level-0.list-item-completed {
+ text-decoration: line-through underline;
+}
+
+.list-item-level-2 {
+ font-weight: normal;
+}
+
+.list-item-level-3 {
+ font-weight: normal;
+ font-style: italic;
+}
+
+.list-item-level-4 {
+ font-weight: bold;
+ font-style: underline;
+}
+
+.list-item-level-5 {
+ font-weight: bold;
+ font-style: italic;
+}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
- background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
+ background: linear-gradient(135deg, rgba(248, 249, 250, 0.85) 0%, rgba(233, 236, 239, 0.85) 100%);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid #dee2e6;
position: fixed;
top: 0;
margin: 0 8px;
}
+/* Toolbar Button Wrapper for Menu Functionality */
+.toolbar-button-wrapper {
+ position: relative;
+ display: inline-block;
+}
+
.toolbar-button {
display: flex;
align-items: center;
transition: all 0.2s ease;
padding: 0;
margin: 0;
+ position: relative;
+}
+
+/* Menu indicator for buttons with menus */
+.toolbar-button.has-menu {
+ padding-right: 18px;
+ width: auto;
+ min-width: 40px;
+}
+
+.menu-indicator {
+ position: absolute;
+ right: 4px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 8px;
+ line-height: 1;
+ opacity: 0.7;
+ pointer-events: none;
}
.toolbar-button:hover:not(.disabled) {
color: white;
}
+.toolbar-button.active .menu-indicator {
+ opacity: 1;
+}
+
.toolbar-button.disabled {
opacity: 0.4;
cursor: not-allowed;
background: transparent;
border-color: transparent;
color: #6c757d;
+}
+
+/* Toolbar Menu Styles */
+.toolbar-menu {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ min-width: 180px;
+ background: white;
+ border: 1px solid #dee2e6;
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ z-index: 1001;
+ margin-top: 4px;
+ padding: 4px 0;
+ backdrop-filter: blur(10px);
+ animation: menuFadeIn 0.15s ease-out;
+}
+
+@keyframes menuFadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(-8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.toolbar-menu-item {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding: 8px 12px;
+ border: none;
+ background: transparent;
+ color: #495057;
+ font-size: 14px;
+ text-align: left;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ gap: 8px;
+}
+
+.toolbar-menu-item:hover:not(.disabled) {
+ background: rgba(0, 116, 255, 0.1);
+ color: #0074ff;
+}
+
+.toolbar-menu-item:active:not(.disabled) {
+ background: rgba(0, 116, 255, 0.15);
+}
+
+.toolbar-menu-item.disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ color: #6c757d;
+}
+
+.toolbar-menu-item.disabled:hover {
+ background: transparent;
+ color: #6c757d;
+}
+
+.menu-item-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+}
+
+.menu-item-label {
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
\ No newline at end of file
index b7704d24caec2908068f5ba542e22e342e2e00f2..3bdc4bd3fb47ed467e9a98c471deff7bf52d24b6 100644 (file)
--- a/frontend/views/List.tsx
+++ b/frontend/views/List.tsx
import { useContext } from 'preact/hooks';
import { ListContextProvider, useListState } from 'components/ListContext';
import { ListItem } from 'components/ListItem';
-import { Toolbar, ToolBarButton } from 'components/Toolbar';
+import { Toolbar } from 'components/Toolbar';
+import ToolbarButton, { ToolbarButtonProps } from 'components/ToolbarButton';
import { IndentIcon, OutdentIcon, ImportantIcon, CheckIcon, MoveUpIcon, MoveDownIcon } from 'components/Icons';
const ListWithToolbar = () => {
const listContext = useContext(ListContextProvider);
- const buttons: ToolBarButton[] = [
+ const buttons: ToolbarButtonProps[] = [
{
icon: <MoveUpIcon size={20} />,
onClick: listContext.moveUpItem,