]> Eric's Git Repo - listv4.git/commitdiff
Getting everything in order for initial deployment
authorEric Wertz <ericwertz@Erics-MacBook-Pro.local>
Wed, 4 Jun 2025 23:25:45 +0000 (19:25 -0400)
committerEric Wertz <ericwertz@Erics-MacBook-Pro.local>
Wed, 4 Jun 2025 23:25:45 +0000 (19:25 -0400)
backend/Database.h
backend/ListController.cpp
backend/ListController.h
frontend/app/AppContext.tsx
frontend/components/Icons.tsx
frontend/components/ListContext.tsx
frontend/components/ListItem.tsx
frontend/components/Toolbar.tsx
frontend/styles.css
frontend/views/List.tsx

index 08a8ae6820ad9c203566940757903d671d10581f..c93b5838dd75d0ebd359e0f98807cf70a8cbc29b 100644 (file)
@@ -42,18 +42,28 @@ class CDatabase
 
         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;
@@ -70,4 +80,27 @@ class CDatabase
         }
 
         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)
@@ -36,44 +36,120 @@ const ACTIONMAP* CListController::getActions() {
     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) {
@@ -241,96 +317,4 @@ 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)
@@ -18,7 +18,7 @@ private:
     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)
@@ -16,6 +16,8 @@ interface User {
 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;
@@ -39,6 +41,10 @@ export class AppContextProvider extends Component<{ children: any }> {
         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)
@@ -62,7 +62,7 @@ export const useListState = () => {
     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 => {
@@ -180,7 +180,7 @@ export const useListState = () => {
             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 = {
@@ -245,6 +245,7 @@ export const useListState = () => {
                 const flatItems = flattenItemsForSave(items);
                 if (flatItems.length === 0) return;
                 
+                setSaveStatus('saving');
                 const response = await fetch('/list/save', {
                     method: 'POST',
                     headers: { 'Content-Type': 'application/json' },
@@ -256,11 +257,14 @@ export const useListState = () => {
                 
                 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)
@@ -3,6 +3,8 @@ import { forwardRef } from 'preact/compat';
 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)
@@ -2,19 +2,11 @@ import { h, JSX } from 'preact';
 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;
 }
 
@@ -33,16 +25,41 @@ export const Toolbar = ({ buttons, onMenuClick }: ToolbarProps) => {
     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" />
                         )}
@@ -51,18 +68,15 @@ export const Toolbar = ({ buttons, onMenuClick }: ToolbarProps) => {
             </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>
     );
index 9346a029784ed615ae433c4e2fb748b07388b4b9..af21cbdf8106bae5b4ba884dba2fe4b19676fbae 100644 (file)
@@ -1,6 +1,8 @@
+@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 {
@@ -107,7 +109,7 @@ form button:active {
     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;
@@ -192,7 +194,9 @@ li {
 }
 
 .list-item-important {
+    font-family: 'Merriweather', serif;
     font-weight: bold;
+    font-size: 1.5rem;
 }
 
 .list-item-completed {
@@ -200,9 +204,9 @@ li {
 }
 
 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;
@@ -213,7 +217,7 @@ li > div[contenteditable] {
 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 {
@@ -225,28 +229,42 @@ li {
     --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;
@@ -282,6 +300,12 @@ li > div[contenteditable] {
     margin: 0 8px;
 }
 
+/* Toolbar Button Wrapper for Menu Functionality */
+.toolbar-button-wrapper {
+    position: relative;
+    display: inline-block;
+}
+
 .toolbar-button {
     display: flex;
     align-items: center;
@@ -296,6 +320,25 @@ li > div[contenteditable] {
     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) {
@@ -320,6 +363,10 @@ li > div[contenteditable] {
     color: white;
 }
 
+.toolbar-button.active .menu-indicator {
+    opacity: 1;
+}
+
 .toolbar-button.disabled {
     opacity: 0.4;
     cursor: not-allowed;
@@ -330,4 +377,83 @@ li > div[contenteditable] {
     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)
@@ -2,13 +2,14 @@ import { h } from 'preact';
 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,