From 266a40cbdefbf1de2da7d1c47c847502a3727fed Mon Sep 17 00:00:00 2001 From: Eric Wertz Date: Sun, 1 Jun 2025 21:58:40 -0400 Subject: [PATCH] Toolbar and list updates --- backend/ListController.cpp | 15 +- frontend/app/AppContext.tsx | 21 + frontend/app_routes.tsx | 2 +- frontend/components/Icons.tsx | 78 +++ frontend/components/ListContext.tsx | 769 +++++++++++++--------------- frontend/components/ListItem.tsx | 58 +-- frontend/components/Toolbar.tsx | 88 +++- frontend/styles.css | 93 +++- frontend/views/List.tsx | 114 ++++- 9 files changed, 758 insertions(+), 480 deletions(-) diff --git a/backend/ListController.cpp b/backend/ListController.cpp index 943db4a..e68a0da 100644 --- a/backend/ListController.cpp +++ b/backend/ListController.cpp @@ -256,6 +256,7 @@ CHTTPResponse* CListController::fetch(CHTTPRequest& request) { 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, @@ -279,6 +280,7 @@ CHTTPResponse* CListController::fetch(CHTTPRequest& request) { 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, @@ -302,29 +304,26 @@ CHTTPResponse* CListController::fetch(CHTTPRequest& request) { 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 - WHERE EXISTS ( - SELECT 1 FROM itemslink il - WHERE il.parent_id = t1.rowid - AND il.child_id = t2.rowid - AND il.type = 'listitem_hierarchy' - ) + 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 - ORDER BY t1.item_order ASC ) SELECT json_group_array(json(final_json)) as result FROM hierarchy WHERE level = 0 - ORDER BY item_order ASC + ORDER BY sort_path ASC )"; CDBQuery query = db->query(sql); diff --git a/frontend/app/AppContext.tsx b/frontend/app/AppContext.tsx index 72f8f83..5b56b15 100644 --- a/frontend/app/AppContext.tsx +++ b/frontend/app/AppContext.tsx @@ -24,6 +24,7 @@ interface AppContextType { setUser: (user: User) => void; checkAuth: () => void; fetchData: (url: string) => Promise; + logout: () => void; } @@ -113,6 +114,26 @@ export class AppContextProvider extends Component<{ children: any }> { .finally(() => { this.state.setIsLoading(false); }); + }, + logout: () => { + // Add a cache-busting query parameter + const cacheBuster = `?cb=${new Date().getTime()}`; + fetch( `auth/logout${cacheBuster}`, { credentials: 'include' } ) + .then(response => { + if (response.ok) { + console.log('Logout successful, redirecting...'); + window.location.href = '/'; + } else { + console.error('Logout failed:', response); + // Optionally, still redirect or show an error + window.location.href = '/'; // Or handle error more gracefully + } + }) + .catch(error => { + console.error('Error during logout fetch:', error); + // Optionally, still redirect or show an error + window.location.href = '/'; // Or handle error more gracefully + }); } }; diff --git a/frontend/app_routes.tsx b/frontend/app_routes.tsx index da4432c..70380e0 100644 --- a/frontend/app_routes.tsx +++ b/frontend/app_routes.tsx @@ -13,7 +13,7 @@ interface AppRoute { export const AppRoutes = [ { path: '/', - view: Home, + view: List, auth_required: true, }, { diff --git a/frontend/components/Icons.tsx b/frontend/components/Icons.tsx index 480b678..7c0a295 100644 --- a/frontend/components/Icons.tsx +++ b/frontend/components/Icons.tsx @@ -127,4 +127,82 @@ export const SettingsIcon = ({ size = 24, color = 'currentColor', className = '' > +); + +export const IndentIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => ( + + + +); + +export const OutdentIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => ( + + + +); + +export const ImportantIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => ( + + + +); + +export const CheckIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => ( + + + +); + +export const MoveUpIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => ( + + + +); + +export const MoveDownIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => ( + + + ); \ No newline at end of file diff --git a/frontend/components/ListContext.tsx b/frontend/components/ListContext.tsx index 79a65eb..acbfba8 100644 --- a/frontend/components/ListContext.tsx +++ b/frontend/components/ListContext.tsx @@ -1,17 +1,171 @@ -import { h } from 'preact'; +import { h, createContext } from 'preact'; import { useContext, useEffect, useState } from 'preact/hooks'; import { AppContext } from 'app/AppContext'; import { ListItem, ItemProps } from 'components/ListItem'; import { setCursorPosition, getCursorPosition } from '../util/util'; -export const ListContext = () => { +interface ListContextValue { + focusedItem: ItemProps | null; + canPromote: boolean; + canDemote: boolean; + canMoveUp: boolean; + canMoveDown: boolean; + promoteItem: () => void; + demoteItem: () => void; + toggleImportantItem: () => void; + toggleCompletedItem: () => void; + moveUpItem: () => void; + moveDownItem: () => void; + items: ItemProps[]; + handleItemChange: (item: ItemProps) => void; + handleMoveFocus: (currentItem: ItemProps, direction: 'up' | 'down', cursorPosition?: number) => void; + handlePromote: (currentItem: ItemProps) => void; + handleDemote: (currentItem: ItemProps) => void; + handleToggleImportant: (currentItem: ItemProps) => void; + handleToggleCompleted: (currentItem: ItemProps) => void; + handleCreateItem: (currentItem: ItemProps, beforeContent: string, afterContent: string) => void; + handleDeleteItem: (currentItem: ItemProps, remainingContent: string) => void; + handleItemFocus: (item: ItemProps) => void; + handleMoveUp: (currentItem: ItemProps) => void; + handleMoveDown: (currentItem: ItemProps) => void; +} + +export const ListContextProvider = createContext({ + focusedItem: null, + canPromote: false, + canDemote: false, + canMoveUp: false, + canMoveDown: false, + promoteItem: () => {}, + demoteItem: () => {}, + toggleImportantItem: () => {}, + toggleCompletedItem: () => {}, + moveUpItem: () => {}, + moveDownItem: () => {}, + items: [], + handleItemChange: () => {}, + handleMoveFocus: () => {}, + handlePromote: () => {}, + handleDemote: () => {}, + handleToggleImportant: () => {}, + handleToggleCompleted: () => {}, + handleCreateItem: () => {}, + handleDeleteItem: () => {}, + handleItemFocus: () => {}, + handleMoveUp: () => {}, + handleMoveDown: () => {}, +}); + +// Hook to manage list state and operations +export const useListState = () => { const [items, setItems] = useState([]); const [focusedItemUuid, setFocusedItemUuid] = useState(null); const [pendingFocusRestore, setPendingFocusRestore] = useState<{ uuid: string, cursorPosition: number } | null>(null); const [saveTimeout, setSaveTimeout] = useState(null); const { fetchData } = useContext(AppContext); - // Helper function to ensure all items have proper order values + // Core tree utilities + const findItemInTree = (items: ItemProps[], uuid: string): ItemProps | null => { + for (const item of items) { + if (item._uuid === uuid) return item; + const found = findItemInTree(item._children, uuid); + if (found) return found; + } + return null; + }; + + const updateItemInTree = (items: ItemProps[], targetUuid: string, updater: (item: ItemProps) => ItemProps): ItemProps[] => { + return items.map(item => { + if (item._uuid === targetUuid) return updater(item); + return { ...item, _children: updateItemInTree(item._children, targetUuid, updater) }; + }); + }; + + // Reorder items and update order properties + const reorderItems = (items: ItemProps[]): ItemProps[] => { + return items.map((item, index) => ({ + ...item, + order: index, + __is_dirty: true, + _children: reorderItems(item._children) + })); + }; + + // Find siblings and context for operations + const findItemContext = (items: ItemProps[], targetUuid: string): { + siblings: ItemProps[]; + index: number; + parent: ItemProps | null; + actualSiblings: ItemProps[]; + } | null => { + // Check root level + const rootIndex = items.findIndex(item => item._uuid === targetUuid); + if (rootIndex !== -1) { + return { + siblings: items.filter(item => !item.__is_deleted), + index: items.filter((item, i) => i < rootIndex && !item.__is_deleted).length, + parent: null, + actualSiblings: items + }; + } + + // Check nested levels + for (const item of items) { + const childIndex = item._children.findIndex(child => child._uuid === targetUuid); + if (childIndex !== -1) { + const visibleChildren = item._children.filter(child => !child.__is_deleted); + const visibleIndex = visibleChildren.findIndex(child => child._uuid === targetUuid); + return { + siblings: visibleChildren, + index: visibleIndex, + parent: item, + actualSiblings: item._children + }; + } + const result = findItemContext(item._children, targetUuid); + if (result) return result; + } + return null; + }; + + const focusedItem = focusedItemUuid ? findItemInTree(items, focusedItemUuid) : null; + + // Capability checks + const canPromote = focusedItem ? !!findItemContext(items, focusedItem._uuid)?.parent : false; + const canDemote = focusedItem ? (findItemContext(items, focusedItem._uuid)?.index ?? 0) > 0 : false; + const canMoveUp = focusedItem ? (findItemContext(items, focusedItem._uuid)?.index ?? 0) > 0 : false; + const canMoveDown = (() => { + if (!focusedItem) return false; + const context = findItemContext(items, focusedItem._uuid); + return context ? context.index < context.siblings.length - 1 : false; + })(); + + // Toolbar actions + const promoteItem = () => focusedItem && handlePromote(focusedItem); + const demoteItem = () => focusedItem && handleDemote(focusedItem); + const toggleImportantItem = () => focusedItem && handleToggleImportant(focusedItem); + const toggleCompletedItem = () => focusedItem && handleToggleCompleted(focusedItem); + const moveUpItem = () => focusedItem && handleMoveUp(focusedItem); + const moveDownItem = () => focusedItem && handleMoveDown(focusedItem); + + const handleItemFocus = (item: ItemProps) => { + if (item._uuid !== focusedItemUuid) { + setFocusedItemUuid(item._uuid); + } + }; + + // Schedule focus restoration with cursor position + const scheduleRestore = (uuid: string, cursorPosition: number) => { + setPendingFocusRestore({ uuid, cursorPosition }); + }; + + // Get cursor position from active element + const getCurrentCursorPosition = (itemUuid: string): number => { + const activeElement = document.activeElement as HTMLElement; + return (activeElement?.getAttribute('data-uuid') === itemUuid) ? getCursorPosition(activeElement) : 0; + }; + + // Initialize items with proper order values const ensureItemOrders = (items: ItemProps[]): ItemProps[] => { return items.map((item, index) => ({ ...item, @@ -23,45 +177,38 @@ export const ListContext = () => { const fetchItems = async () => { try { const response = await fetchData('/list/fetch') as { data: ItemProps[] }; - if (response?.data && response.data.length > 0) { - // Ensure all items have proper order values + if (response?.data?.length > 0) { const itemsWithOrders = ensureItemOrders(response.data); setItems(itemsWithOrders); - if (itemsWithOrders.length > 0) { - setFocusedItemUuid(itemsWithOrders[0]._uuid); - } - } - else { - //Set default empty item - setItems([{ + setFocusedItemUuid(itemsWithOrders[0]._uuid); + } else { + // Create default empty item + const defaultItem: ItemProps = { _uuid: crypto.randomUUID(), _type: 'item', _created_at: new Date().toISOString(), _updated_at: new Date().toISOString(), _children: [], - content: '', level: 0, important: false, completed_at: null, order: 0 - }]); + }; + setItems([defaultItem]); + setFocusedItemUuid(defaultItem._uuid); } } catch (error) { console.error('Failed to fetch items:', error); } }; - // Helper function to flatten the tree structure into an array with parentUuid references - // Only includes items that are dirty (need to be saved) + // Auto-save functionality const flattenItemsForSave = (items: ItemProps[], parentUuid?: string): any[] => { const result: any[] = []; - for (const item of items) { - // Only include dirty items if (item.__is_dirty) { - // Convert item to backend format - const flatItem = { + result.push({ uuid: item._uuid, content: item.content, level: item.level, @@ -71,81 +218,42 @@ export const ListContext = () => { parentUuid: parentUuid || '', isNew: item.__is_new || false, isDeleted: item.__is_deleted || false - }; - - result.push(flatItem); + }); } - - // Always recursively check children, regardless of parent dirty status - if (item._children && item._children.length > 0) { + if (item._children?.length > 0) { result.push(...flattenItemsForSave(item._children, item._uuid)); } } - return result; }; - // Helper function to clear dirty flags for saved items const clearDirtyFlags = (items: ItemProps[], savedUuids: Set): ItemProps[] => { - return items.filter(item => { - // Remove items that were saved as deleted - if (item.__is_deleted && savedUuids.has(item._uuid)) { - return false; - } - return true; - }).map(item => { - const updatedItem = { ...item }; - - // Clear dirty flags if this item was saved - if (savedUuids.has(item._uuid)) { - updatedItem.__is_dirty = false; - updatedItem.__is_new = false; - } - - // Recursively process children - if (item._children && item._children.length > 0) { - updatedItem._children = clearDirtyFlags(item._children, savedUuids); - } - - return updatedItem; - }); + return items.filter(item => !(item.__is_deleted && savedUuids.has(item._uuid))) + .map(item => ({ + ...item, + __is_dirty: savedUuids.has(item._uuid) ? false : item.__is_dirty, + __is_new: savedUuids.has(item._uuid) ? false : item.__is_new, + _children: clearDirtyFlags(item._children, savedUuids) + })); }; - // Save function with debouncing const scheduleSave = () => { - // Clear existing timeout - if (saveTimeout) { - clearTimeout(saveTimeout); - } + if (saveTimeout) clearTimeout(saveTimeout); - // Set new timeout for 1 second const newTimeout = setTimeout(async () => { try { const flatItems = flattenItemsForSave(items); - - // Skip save if no dirty items - if (flatItems.length === 0) { - console.log('No dirty items to save'); - return; - } + if (flatItems.length === 0) return; const response = await fetch('/list/save', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(flatItems) }); - if (!response.ok) { - throw new Error(`Save failed: ${response.status} ${response.statusText}`); - } + if (!response.ok) throw new Error(`Save failed: ${response.status}`); - const result = await response.json(); - console.log('List saved successfully:', result); - - // Clear dirty flags for saved items const savedUuids = new Set(flatItems.map(item => item.uuid)); setItems(prevItems => clearDirtyFlags(prevItems, savedUuids)); } catch (error) { @@ -156,12 +264,13 @@ export const ListContext = () => { setSaveTimeout(newTimeout); }; + // Basic operations const handleItemChange = (item: ItemProps) => { setItems(prevItems => updateItemInTree(prevItems, item._uuid, () => item)); }; const handleToggleImportant = (currentItem: ItemProps) => { - setItems(prevItems => updateItemInTree(prevItems, currentItem._uuid, (item) => ({ + setItems(prevItems => updateItemInTree(prevItems, currentItem._uuid, item => ({ ...item, important: !item.important, __is_dirty: true @@ -170,32 +279,22 @@ export const ListContext = () => { const handleToggleCompleted = (currentItem: ItemProps) => { const newCompletedAt = currentItem.completed_at ? null : new Date().toISOString(); - - // Recursive function to update completed status for item and all children - const updateCompletedRecursive = (item: ItemProps): ItemProps => ({ + const updateCompleted = (item: ItemProps): ItemProps => ({ ...item, completed_at: newCompletedAt, __is_dirty: true, - _children: item._children.map(updateCompletedRecursive) + _children: item._children.map(updateCompleted) }); - - setItems(prevItems => updateItemInTree(prevItems, currentItem._uuid, updateCompletedRecursive)); + setItems(prevItems => updateItemInTree(prevItems, currentItem._uuid, updateCompleted)); }; const handleCreateItem = (currentItem: ItemProps, beforeContent: string, afterContent: string) => { - // Generate a new UUID for the new item const newUuid = crypto.randomUUID(); - const now = new Date().toISOString(); - - // Calculate the order for the new item (next order after current item) - const newOrder = currentItem.order + 1; - - // Create the new item const newItem: ItemProps = { _uuid: newUuid, _type: 'item', - _created_at: now, - _updated_at: now, + _created_at: new Date().toISOString(), + _updated_at: new Date().toISOString(), _children: [], __is_new: true, __is_dirty: true, @@ -203,31 +302,26 @@ export const ListContext = () => { level: currentItem.level, important: false, completed_at: null, - order: newOrder + order: currentItem.order + 1 }; - // Update the current item with the before content - const updatedCurrentItem = { ...currentItem, content: beforeContent, __is_dirty: true }; - setItems(prevItems => { - // First update the current item - const itemsWithUpdatedCurrent = updateItemInTree(prevItems, currentItem._uuid, () => updatedCurrentItem); - - // Then insert the new item after the current item and reorder siblings - const insertAfterItemWithReorder = (items: ItemProps[], targetUuid: string, itemToInsert: ItemProps): ItemProps[] => { + // Update current item + const updatedItems = updateItemInTree(prevItems, currentItem._uuid, item => ({ + ...item, + content: beforeContent, + __is_dirty: true + })); + + // Insert new item and reorder + const insertItem = (items: ItemProps[], targetUuid: string): ItemProps[] => { for (let i = 0; i < items.length; i++) { if (items[i]._uuid === targetUuid) { - // Insert the new item after current item - const newItems = [...items.slice(0, i + 1), itemToInsert, ...items.slice(i + 1)]; - // Reorder all items after the insertion point - return newItems.map((item, index) => ({ - ...item, - order: index, - __is_dirty: index > i ? true : item.__is_dirty - })); + const newItems = [...items.slice(0, i + 1), newItem, ...items.slice(i + 1)]; + return reorderItems(newItems); } if (items[i]._children.length > 0) { - const result = insertAfterItemWithReorder(items[i]._children, targetUuid, itemToInsert); + const result = insertItem(items[i]._children, targetUuid); if (result !== items[i]._children) { return items.map((item, index) => index === i ? { ...item, _children: result } : item @@ -238,214 +332,125 @@ export const ListContext = () => { return items; }; - const newItems = insertAfterItemWithReorder(itemsWithUpdatedCurrent, currentItem._uuid, newItem); - - return newItems; + return insertItem(updatedItems, currentItem._uuid); }); - // Schedule focus on the new item + // Focus new item setTimeout(() => { - const newElement = document.querySelector(`[data-uuid="${newUuid}"]`) as HTMLElement; - if (newElement) { - newElement.focus(); - setCursorPosition(newElement, 0); + const element = document.querySelector(`[data-uuid="${newUuid}"]`) as HTMLElement; + if (element) { + element.focus(); + setCursorPosition(element, 0); } }, 0); }; const handleDeleteItem = (currentItem: ItemProps, remainingContent: string) => { - // Find the previous logical item in the tree const findPreviousItem = (items: ItemProps[], targetUuid: string): ItemProps | null => { let previousItem: ItemProps | null = null; - const traverse = (itemList: ItemProps[]): ItemProps | null => { for (const item of itemList) { - // Skip deleted items when finding previous item - if (item.__is_deleted) { - continue; - } - if (item._uuid === targetUuid) { - return previousItem; - } + if (item.__is_deleted) continue; + if (item._uuid === targetUuid) return previousItem; previousItem = item; - - // Check children const result = traverse(item._children); if (result) return result; } return null; }; - return traverse(items); }; setItems(prevItems => { const previousItem = findPreviousItem(prevItems, currentItem._uuid); - - if (!previousItem) { - // Can't delete if there's no previous item - return prevItems; - } + if (!previousItem) return prevItems; - // Update the previous item with the remaining content - const updatedPreviousItem = { - ...previousItem, - content: previousItem.content + remainingContent, + // Update previous item and mark current as deleted + let result = updateItemInTree(prevItems, previousItem._uuid, item => ({ + ...item, + content: item.content + remainingContent, __is_dirty: true - }; + })); - // Mark the current item as deleted instead of removing it - const itemsWithDeletedItem = updateItemInTree(prevItems, currentItem._uuid, (item) => ({ + result = updateItemInTree(result, currentItem._uuid, item => ({ ...item, __is_deleted: true, __is_dirty: true })); - // Update the previous item - const finalItems = updateItemInTree(itemsWithDeletedItem, previousItem._uuid, () => updatedPreviousItem); - - // Schedule focus on the previous item + // Focus previous item setTimeout(() => { - const prevElement = document.querySelector(`[data-uuid="${previousItem._uuid}"]`) as HTMLElement; - if (prevElement) { - prevElement.focus(); - setCursorPosition(prevElement, previousItem.content.length); + const element = document.querySelector(`[data-uuid="${previousItem._uuid}"]`) as HTMLElement; + if (element) { + element.focus(); + setCursorPosition(element, previousItem.content.length); } }, 0); - return finalItems; + return result; }); }; - const handleMoveFocus = (currentItem: ItemProps, direction: 'up' | 'down') => { - const currentIndex = items.findIndex(item => item._uuid === currentItem._uuid); - if (currentIndex === -1) return; - - let nextIndex; - if (direction === 'down') { - nextIndex = Math.min(items.length - 1, currentIndex + 1); - } else { - nextIndex = Math.max(0, currentIndex - 1); - } - - if (items[nextIndex]) { - setFocusedItemUuid(items[nextIndex]._uuid); - } - }; - - // Helper function to find an item in the nested structure - const findItemInTree = (items: ItemProps[], uuid: string): ItemProps | null => { - for (const item of items) { - if (item._uuid === uuid) { - return item; - } - const found = findItemInTree(item._children, uuid); - if (found) { - return found; + const handleMoveFocus = (currentItem: ItemProps, direction: 'up' | 'down', cursorPosition?: number) => { + const flattenVisible = (items: ItemProps[]): ItemProps[] => { + const result: ItemProps[] = []; + for (const item of items) { + if (!item.__is_deleted) { + result.push(item); + result.push(...flattenVisible(item._children)); + } } - } - return null; - }; + return result; + }; - // Helper function to update an item in the nested structure - const updateItemInTree = (items: ItemProps[], targetUuid: string, updater: (item: ItemProps) => ItemProps): ItemProps[] => { - return items.map(item => { - if (item._uuid === targetUuid) { - return updater(item); - } - return { - ...item, - _children: updateItemInTree(item._children, targetUuid, updater) - }; - }); - }; + const flatItems = flattenVisible(items); + const currentIndex = flatItems.findIndex(item => item._uuid === currentItem._uuid); + if (currentIndex === -1) return; - // Helper function to remove an item from the nested structure - const removeItemFromTree = (items: ItemProps[], targetUuid: string): { items: ItemProps[], removedItem: ItemProps | null } => { - for (let i = 0; i < items.length; i++) { - if (items[i]._uuid === targetUuid) { - const removedItem = items[i]; - return { - items: [...items.slice(0, i), ...items.slice(i + 1)], - removedItem - }; - } - const result = removeItemFromTree(items[i]._children, targetUuid); - if (result.removedItem) { - return { - items: items.map((item, index) => - index === i ? { ...item, _children: result.items } : item - ), - removedItem: result.removedItem - }; - } + const nextIndex = direction === 'down' + ? Math.min(flatItems.length - 1, currentIndex + 1) + : Math.max(0, currentIndex - 1); + + if (flatItems[nextIndex]?._uuid !== currentItem._uuid) { + setFocusedItemUuid(flatItems[nextIndex]._uuid); + setTimeout(() => { + const element = document.querySelector(`[data-uuid="${flatItems[nextIndex]._uuid}"]`) as HTMLElement; + if (element) { + element.focus(); + setCursorPosition(element, cursorPosition || 0); + } + }, 0); } - return { items, removedItem: null }; }; + // Promote/Demote operations const handlePromote = (currentItem: ItemProps) => { - // Store cursor position before the operation - const activeElement = document.activeElement as HTMLElement; - let cursorPosition = 0; - if (activeElement && activeElement.getAttribute('data-uuid') === currentItem._uuid) { - cursorPosition = getCursorPosition(activeElement); - } - - // Promote (outdent) - move item up one level + const cursorPosition = getCurrentCursorPosition(currentItem._uuid); + setItems(prevItems => { - // Find the parent of the current item - const findParent = (items: ItemProps[], targetUuid: string): ItemProps | null => { - for (const item of items) { - if (item._children.some(child => child._uuid === targetUuid)) { - return item; - } - const found = findParent(item._children, targetUuid); - if (found) return found; - } - return null; - }; - - const parent = findParent(prevItems, currentItem._uuid); - if (!parent) { - // Already at root level, can't promote further - return prevItems; - } + const context = findItemContext(prevItems, currentItem._uuid); + if (!context?.parent) return prevItems; - // Remove item from its current parent and reorder remaining siblings - const { items: itemsAfterRemoval, removedItem } = removeItemFromTree(prevItems, currentItem._uuid); - if (!removedItem) return prevItems; - - // Reorder the children of the former parent - const itemsWithReorderedSiblings = updateItemInTree(itemsAfterRemoval, parent._uuid, (parentItem) => ({ - ...parentItem, - _children: parentItem._children.map((child, index) => ({ - ...child, - order: index, - __is_dirty: true - })), + // Remove item from current location + let result = updateItemInTree(prevItems, context.parent._uuid, parent => ({ + ...parent, + _children: reorderItems(parent._children.filter(child => child._uuid !== currentItem._uuid)), __is_dirty: true })); - // Update the item's level - const promotedItem = { ...removedItem, level: Math.max(0, removedItem.level - 1) }; - - // Find where to insert the promoted item (after its former parent) and reorder siblings - const insertAfterParentWithReorder = (items: ItemProps[], parentUuid: string, itemToInsert: ItemProps): ItemProps[] => { + // Insert after parent with updated level + const promotedItem = { ...currentItem, level: Math.max(0, currentItem.level - 1), __is_dirty: true }; + const insertAfterParent = (items: ItemProps[], parentUuid: string): ItemProps[] => { for (let i = 0; i < items.length; i++) { if (items[i]._uuid === parentUuid) { - const newItems = [...items.slice(0, i + 1), itemToInsert, ...items.slice(i + 1)]; - // Reorder all items after the insertion point - return newItems.map((item, index) => ({ - ...item, - order: index, - __is_dirty: index > i ? true : item.__is_dirty - })); + const newItems = [...items.slice(0, i + 1), promotedItem, ...items.slice(i + 1)]; + return reorderItems(newItems); } if (items[i]._children.length > 0) { - const result = insertAfterParentWithReorder(items[i]._children, parentUuid, itemToInsert); - if (result !== items[i]._children) { + const children = insertAfterParent(items[i]._children, parentUuid); + if (children !== items[i]._children) { return items.map((item, index) => - index === i ? { ...item, _children: result } : item + index === i ? { ...item, _children: children } : item ); } } @@ -453,127 +458,114 @@ export const ListContext = () => { return items; }; - const newItems = insertAfterParentWithReorder(itemsWithReorderedSiblings, parent._uuid, promotedItem); - - // Schedule focus restoration - setPendingFocusRestore({ uuid: currentItem._uuid, cursorPosition }); - - return newItems; + result = insertAfterParent(result, context.parent._uuid); + scheduleRestore(currentItem._uuid, cursorPosition); + return result; }); }; const handleDemote = (currentItem: ItemProps) => { - // Store cursor position before the operation - const activeElement = document.activeElement as HTMLElement; - let cursorPosition = 0; - if (activeElement && activeElement.getAttribute('data-uuid') === currentItem._uuid) { - cursorPosition = getCursorPosition(activeElement); - } - - // Demote (indent) - move item down one level by making it a child of the previous sibling + const cursorPosition = getCurrentCursorPosition(currentItem._uuid); + setItems(prevItems => { - // Find the current item and its siblings - const findSiblingsAndIndex = (items: ItemProps[], targetUuid: string): { siblings: ItemProps[], index: number, parent: ItemProps | null } | null => { - // Check root level - const rootIndex = items.findIndex(item => item._uuid === targetUuid); - if (rootIndex !== -1) { - return { siblings: items, index: rootIndex, parent: null }; - } - - // Check nested levels - for (const item of items) { - const childIndex = item._children.findIndex(child => child._uuid === targetUuid); - if (childIndex !== -1) { - return { siblings: item._children, index: childIndex, parent: item }; - } - const result = findSiblingsAndIndex(item._children, targetUuid); - if (result) return result; - } - return null; - }; + const context = findItemContext(prevItems, currentItem._uuid); + if (!context || context.index === 0) return prevItems; - const siblingInfo = findSiblingsAndIndex(prevItems, currentItem._uuid); - if (!siblingInfo || siblingInfo.index === 0) { - // Can't demote if it's the first item or not found - return prevItems; - } - - const { siblings, index, parent } = siblingInfo; - const previousSibling = siblings[index - 1]; - - // Remove item from current position - const { items: itemsAfterRemoval, removedItem } = removeItemFromTree(prevItems, currentItem._uuid); - if (!removedItem) return prevItems; - - // Reorder remaining siblings where the item was removed - let itemsWithReorderedSiblings = itemsAfterRemoval; - if (parent) { - itemsWithReorderedSiblings = updateItemInTree(itemsAfterRemoval, parent._uuid, (parentItem) => ({ - ...parentItem, - _children: parentItem._children.map((child, childIndex) => ({ - ...child, - order: childIndex, - __is_dirty: true - })), - __is_dirty: true - })); - } else { - // Root level reordering - itemsWithReorderedSiblings = itemsAfterRemoval.map((item, itemIndex) => ({ - ...item, - order: itemIndex, + const previousSibling = context.siblings[context.index - 1]; + + // Remove from current location and reorder + let result = context.parent + ? updateItemInTree(prevItems, context.parent._uuid, parent => ({ + ...parent, + _children: reorderItems(parent._children.filter(child => child._uuid !== currentItem._uuid)), __is_dirty: true - })); - } + })) + : reorderItems(prevItems.filter(item => item._uuid !== currentItem._uuid)); - // Update the item's level and order (will be last child) + // Add as child of previous sibling const demotedItem = { - ...removedItem, - level: removedItem.level + 1, - order: -1, // Will be set correctly when adding to children + ...currentItem, + level: currentItem.level + 1, __is_dirty: true }; - // Add item as a child of the previous sibling - const newItems = updateItemInTree(itemsWithReorderedSiblings, previousSibling._uuid, (sibling) => { - const newChildren = [...sibling._children, demotedItem]; - return { - ...sibling, - _children: newChildren.map((child, childIndex) => ({ - ...child, - order: childIndex, - __is_dirty: true - })), - __is_dirty: true - }; - }); - - // Schedule focus restoration - setPendingFocusRestore({ uuid: currentItem._uuid, cursorPosition }); - - return newItems; + result = updateItemInTree(result, previousSibling._uuid, sibling => ({ + ...sibling, + _children: reorderItems([...sibling._children, demotedItem]), + __is_dirty: true + })); + + scheduleRestore(currentItem._uuid, cursorPosition); + return result; + }); + }; + + // Move operations (swap with adjacent sibling) + const handleMoveUp = (currentItem: ItemProps) => { + const cursorPosition = getCurrentCursorPosition(currentItem._uuid); + + setItems(prevItems => { + const context = findItemContext(prevItems, currentItem._uuid); + if (!context || context.index === 0) return prevItems; + + const { actualSiblings, parent } = context; + const currentActualIndex = actualSiblings.findIndex(item => item._uuid === currentItem._uuid); + const previousActualIndex = actualSiblings.findIndex(item => item._uuid === context.siblings[context.index - 1]._uuid); + + const swapItems = (items: ItemProps[]): ItemProps[] => { + const newItems = [...items]; + [newItems[currentActualIndex], newItems[previousActualIndex]] = + [newItems[previousActualIndex], newItems[currentActualIndex]]; + return reorderItems(newItems); + }; + + const result = parent + ? updateItemInTree(prevItems, parent._uuid, p => ({ ...p, _children: swapItems(p._children), __is_dirty: true })) + : swapItems(prevItems); + + scheduleRestore(currentItem._uuid, cursorPosition); + return result; + }); + }; + + const handleMoveDown = (currentItem: ItemProps) => { + const cursorPosition = getCurrentCursorPosition(currentItem._uuid); + + setItems(prevItems => { + const context = findItemContext(prevItems, currentItem._uuid); + if (!context || context.index === context.siblings.length - 1) return prevItems; + + const { actualSiblings, parent } = context; + const currentActualIndex = actualSiblings.findIndex(item => item._uuid === currentItem._uuid); + const nextActualIndex = actualSiblings.findIndex(item => item._uuid === context.siblings[context.index + 1]._uuid); + + const swapItems = (items: ItemProps[]): ItemProps[] => { + const newItems = [...items]; + [newItems[currentActualIndex], newItems[nextActualIndex]] = + [newItems[nextActualIndex], newItems[currentActualIndex]]; + return reorderItems(newItems); + }; + + const result = parent + ? updateItemInTree(prevItems, parent._uuid, p => ({ ...p, _children: swapItems(p._children), __is_dirty: true })) + : swapItems(prevItems); + + scheduleRestore(currentItem._uuid, cursorPosition); + return result; }); }; - // Effect to handle focus restoration after promote/demote + // Effects useEffect(() => { if (pendingFocusRestore) { const { uuid, cursorPosition } = pendingFocusRestore; - // Find the element by uuid and restore focus - const findAndFocusElement = () => { - const elements = document.querySelectorAll('[data-uuid]'); - for (const element of elements) { - if (element.getAttribute('data-uuid') === uuid) { - const contentEditable = element as HTMLElement; - contentEditable.focus(); - setCursorPosition(contentEditable, cursorPosition); - break; - } + setTimeout(() => { + const element = document.querySelector(`[data-uuid="${uuid}"]`) as HTMLElement; + if (element) { + element.focus(); + setCursorPosition(element, cursorPosition); } - }; - - // Use setTimeout to ensure the DOM has been updated - setTimeout(findAndFocusElement, 0); + }, 0); setPendingFocusRestore(null); } }, [pendingFocusRestore, items]); @@ -581,54 +573,25 @@ export const ListContext = () => { useEffect(() => { fetchItems(); }, []); - + useEffect(() => { - // Cleanup timeout on unmount return () => { - if (saveTimeout) { - clearTimeout(saveTimeout); - } + if (saveTimeout) clearTimeout(saveTimeout); }; }, [saveTimeout]); - - // Effect to auto-save when dirty items are detected + useEffect(() => { - // Check if there are any dirty items in the tree const hasDirtyItems = (items: ItemProps[]): boolean => { - for (const item of items) { - if (item.__is_dirty) { - return true; - } - if (item._children && item._children.length > 0 && hasDirtyItems(item._children)) { - return true; - } - } - return false; + return items.some(item => item.__is_dirty || (item._children?.length > 0 && hasDirtyItems(item._children))); }; - - if (hasDirtyItems(items)) { - scheduleSave(); - } + if (hasDirtyItems(items)) scheduleSave(); }, [items]); - return ( -
    - {items.filter(item => !item.__is_deleted).map(item => ( - - ))} -
- ); -}; - -export default ListContext; \ No newline at end of file + return { + items, setItems, focusedItem, canPromote, canDemote, canMoveUp, canMoveDown, + promoteItem, demoteItem, toggleImportantItem, toggleCompletedItem, moveUpItem, moveDownItem, + handleItemFocus, handleItemChange, handleMoveFocus, handlePromote, handleDemote, + handleToggleImportant, handleToggleCompleted, handleCreateItem, handleDeleteItem, + handleMoveUp, handleMoveDown, + }; +}; \ No newline at end of file diff --git a/frontend/components/ListItem.tsx b/frontend/components/ListItem.tsx index 97c7ae1..46852a2 100644 --- a/frontend/components/ListItem.tsx +++ b/frontend/components/ListItem.tsx @@ -31,13 +31,16 @@ interface ListItemProps { toggleCompleted: (currentItem: ItemProps) => void; createItem: (currentItem: ItemProps, beforeContent: string, afterContent: string) => void; deleteItem: (currentItem: ItemProps, remainingContent: string) => void; + onFocus?: (item: ItemProps) => void; + moveUp: (currentItem: ItemProps) => void; + moveDown: (currentItem: ItemProps) => void; }; interface ListItemRef { focus: (focusLastChild: boolean, cursorPosition: number) => void; } -export const ListItem = forwardRef(({ item, onChange, moveFocus, promote, demote, toggleImportant, toggleCompleted, createItem, deleteItem }: ListItemProps, ref: Ref) => { +export const ListItem = forwardRef(({ item, onChange, moveFocus, promote, demote, toggleImportant, toggleCompleted, createItem, deleteItem, onFocus, moveUp, moveDown }: ListItemProps, ref: Ref) => { const localRef = useRef(null); const childRefs = useRef<(ListItemRef | null)[]>([]); const [targetFocusInfo, setTargetFocusInfo] = useState<{ index: number, timestamp: number, focusLastChild: boolean, cursorPosition?: number } | null>(null); @@ -88,32 +91,9 @@ export const ListItem = forwardRef(({ item, onChange } }; - const handleMoveFocus = (currentItem: ItemProps, direction: 'up' | 'down', cursorPosition?: number) => { - const visibleChildren = item._children.filter(child => !child.__is_deleted); - const currentIndex = visibleChildren.findIndex((child: ItemProps) => child._uuid === currentItem._uuid); - - let nextIndex: number | undefined; - if (direction === 'down') { - if (currentIndex === visibleChildren.length - 1) { - moveFocus(item, 'down', cursorPosition || 0); - } else { - nextIndex = currentIndex + 1; - } - } else { - if (currentIndex === 0) { - if( localRef.current ) { - localRef.current.focus(); - setCursorPosition(localRef.current, cursorPosition || 0); - } - } else { - nextIndex = currentIndex - 1; - } - } - if (nextIndex !== undefined && visibleChildren[nextIndex]) { - // Find the actual index in the full children array for the target focus info - const targetChild = visibleChildren[nextIndex]; - const actualIndex = item._children.findIndex(child => child._uuid === targetChild._uuid); - setTargetFocusInfo({ index: actualIndex, timestamp: Date.now(), focusLastChild: direction === 'up', cursorPosition: cursorPosition }); + const handleFocus = (event: FocusEvent) => { + if (onFocus) { + onFocus(item); } }; @@ -157,17 +137,17 @@ export const ListItem = forwardRef(({ item, onChange deleteItem(item, remainingContent); } + } else if (event.key === 'ArrowUp' && event.ctrlKey) { + // Ctrl+Up - move item up in order + event.preventDefault(); + moveUp(item); + } else if (event.key === 'ArrowDown' && event.ctrlKey) { + // Ctrl+Down - move item down in order + event.preventDefault(); + moveDown(item); } else if (event.key === 'ArrowDown') { event.preventDefault(); - const visibleChildren = item._children.filter(child => !child.__is_deleted); - if( visibleChildren.length > 0 ) { - // Find the actual index of the first visible child - const firstVisibleChild = visibleChildren[0]; - const actualIndex = item._children.findIndex(child => child._uuid === firstVisibleChild._uuid); - setTargetFocusInfo({ index: actualIndex, timestamp: Date.now(), focusLastChild: false, cursorPosition: getCursorPosition(localRef.current) }); - } else { - moveFocus(item, 'down', getCursorPosition(localRef.current)); - } + moveFocus(item, 'down', getCursorPosition(localRef.current)); } else if (event.key === 'ArrowUp') { event.preventDefault(); moveFocus(item, 'up', getCursorPosition(localRef.current)); @@ -196,6 +176,7 @@ export const ListItem = forwardRef(({ item, onChange ref={localRef} contentEditable={true} onInput={handleInput} + 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' : ''}`} @@ -207,13 +188,16 @@ export const ListItem = forwardRef(({ item, onChange key={child._uuid} item={child} onChange={onChange} - moveFocus={handleMoveFocus} + moveFocus={moveFocus} promote={promote} demote={demote} toggleImportant={toggleImportant} toggleCompleted={toggleCompleted} createItem={createItem} deleteItem={deleteItem} + onFocus={onFocus} + moveUp={moveUp} + moveDown={moveDown} ref={(el: ListItemRef | null) => { if (childRefs.current && index < childRefs.current.length) { childRefs.current[index] = el; diff --git a/frontend/components/Toolbar.tsx b/frontend/components/Toolbar.tsx index 8d2764b..6151803 100644 --- a/frontend/components/Toolbar.tsx +++ b/frontend/components/Toolbar.tsx @@ -1,33 +1,69 @@ -import { h } from 'preact'; -import { LogoutIcon, MenuIcon, PersonIcon } from './Icons'; +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 const Toolbar = () => { - const logout = () => { - // Add a cache-busting query parameter - const cacheBuster = `?cb=${new Date().getTime()}`; - fetch( `auth/logout${cacheBuster}`, { credentials: 'include' } ) - .then(response => { - if (response.ok) { - console.log('Logout successful, redirecting...'); - window.location.href = '/'; - } else { - console.error('Logout failed:', response); - // Optionally, still redirect or show an error - window.location.href = '/'; // Or handle error more gracefully - } - }) - .catch(error => { - console.error('Error during logout fetch:', error); - // Optionally, still redirect or show an error - window.location.href = '/'; // Or handle error more gracefully - }); +export interface ToolBarButton { + icon: JSX.Element; + onClick: () => void; + title: string; + disabled?: boolean; + active?: boolean; + separatorAfter?: boolean; +} + +interface ToolbarProps { + buttons: ToolBarButton[]; + onMenuClick?: () => void; +} + +export const Toolbar = ({ buttons, onMenuClick }: ToolbarProps) => { + const appContext = useContext(AppContext); + + const handleMenuClick = () => { + if (onMenuClick) { + onMenuClick(); + } else { + // Default menu behavior - could open a drawer, show options, etc. + console.log('Menu clicked'); + } }; + return (
-

Toolbar

- - - +
+ {buttons.map((button, index) => ( +
+ + {button.separatorAfter && ( +
+ )} +
+ ))} +
+ +
+ {appContext.isLoading ? ( + + ) : ( + + )} + +
); }; diff --git a/frontend/styles.css b/frontend/styles.css index 5ccf962..9346a02 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -239,4 +239,95 @@ li > div[contenteditable] { .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; } \ No newline at end of file +.list-item-level-9 { margin-left: 216px; } + +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 16px; + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + border-bottom: 1px solid #dee2e6; + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + height: 52px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.toolbar-left { + display: flex; + align-items: center; + gap: 8px; +} + +.toolbar-right { + display: flex; + align-items: center; + gap: 8px; +} + +.toolbar-button-container { + display: flex; + align-items: center; + gap: 8px; +} + +.toolbar-separator { + width: 1px; + height: 24px; + background: #dee2e6; + margin: 0 8px; +} + +.toolbar-button { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: #495057; + cursor: pointer; + transition: all 0.2s ease; + padding: 0; + margin: 0; +} + +.toolbar-button:hover:not(.disabled) { + background: rgba(0, 116, 255, 0.1); + border-color: rgba(0, 116, 255, 0.2); + color: #0074ff; +} + +.toolbar-button:active:not(.disabled) { + transform: translateY(1px); + background: rgba(0, 116, 255, 0.15); +} + +.toolbar-button.active { + background: #0074ff; + color: white; + border-color: #0056b3; +} + +.toolbar-button.active:hover { + background: #0056b3; + color: white; +} + +.toolbar-button.disabled { + opacity: 0.4; + cursor: not-allowed; + color: #6c757d; +} + +.toolbar-button.disabled:hover { + background: transparent; + border-color: transparent; + color: #6c757d; +} \ No newline at end of file diff --git a/frontend/views/List.tsx b/frontend/views/List.tsx index 9df8c0a..b7704d2 100644 --- a/frontend/views/List.tsx +++ b/frontend/views/List.tsx @@ -1,10 +1,116 @@ import { h } from 'preact'; -import { ListContext } from 'components/ListContext'; +import { useContext } from 'preact/hooks'; +import { ListContextProvider, useListState } from 'components/ListContext'; +import { ListItem } from 'components/ListItem'; +import { Toolbar, ToolBarButton } from 'components/Toolbar'; +import { IndentIcon, OutdentIcon, ImportantIcon, CheckIcon, MoveUpIcon, MoveDownIcon } from 'components/Icons'; + +const ListWithToolbar = () => { + const listContext = useContext(ListContextProvider); + + const buttons: ToolBarButton[] = [ + { + icon: , + onClick: listContext.moveUpItem, + title: 'Move Up (Ctrl+Up)', + disabled: !listContext.canMoveUp, + }, + { + icon: , + onClick: listContext.moveDownItem, + title: 'Move Down (Ctrl+Down)', + disabled: !listContext.canMoveDown, + separatorAfter: true, + }, + { + icon: , + onClick: listContext.promoteItem, + title: 'Outdent (Shift+Tab)', + disabled: !listContext.canPromote, + }, + { + icon: , + onClick: listContext.demoteItem, + title: 'Indent (Tab)', + disabled: !listContext.canDemote, + separatorAfter: true, + }, + { + icon: , + onClick: listContext.toggleImportantItem, + title: 'Toggle Important (Ctrl+B)', + disabled: !listContext.focusedItem, + active: listContext.focusedItem?.important || false, + }, + { + icon: , + onClick: listContext.toggleCompletedItem, + title: 'Toggle Completed (Ctrl+S)', + disabled: !listContext.focusedItem, + active: !!listContext.focusedItem?.completed_at, + }, + ]; + + return ( +
+ +
+
    + {listContext.items.filter(item => !item.__is_deleted).map(item => ( + + ))} +
+
+
+ ); +}; export const List = () => { - // ListContext will now fetch its own data. - // No initialItems are passed. - return ; + const listState = useListState(); + + return ( + + + + ); }; export default List; \ No newline at end of file -- 2.49.0