summary | shortlog | log | commit | commitdiff | tree
raw | patch | inline | side by side (parent: 172a016)
raw | patch | inline | side by side (parent: 172a016)
author | Eric Wertz <ericwertz@Erics-MacBook-Pro.local> | |
Mon, 2 Jun 2025 01:58:40 +0000 (21:58 -0400) | ||
committer | Eric Wertz <ericwertz@Erics-MacBook-Pro.local> | |
Mon, 2 Jun 2025 01:58:40 +0000 (21:58 -0400) |
index 943db4aaf6acc77deefdb896059ee0e34013c402..e68a0da87aa09f806ea8a18dc2bcc725094aab3a 100644 (file)
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,
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,
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);
index 72f8f838b5d45a3ed30909d1d01e4b20e7702d68..5b56b159d223ad8a589eaa27f7f4a1962a6af111 100644 (file)
setUser: (user: User) => void;
checkAuth: () => void;
fetchData: (url: string) => Promise<any>;
+ logout: () => void;
}
.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
+ });
}
};
index da4432c2b3bc1b10a0e299ef053382a7df136d5d..70380e0fd3b7b17688e5dede1d62575dec32ecbc 100644 (file)
--- a/frontend/app_routes.tsx
+++ b/frontend/app_routes.tsx
export const AppRoutes = [
{
path: '/',
- view: Home,
+ view: List,
auth_required: true,
},
{
index 480b678a9530f4b2109e5fa2fa39d49e6bdc3cb0..7c0a295f0855be92b8996cd597aa82f52b5bed2a 100644 (file)
@@ -127,4 +127,82 @@ export const SettingsIcon = ({ size = 24, color = 'currentColor', className = ''
>
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
+);
+
+export const IndentIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => (
+ <svg
+ viewBox="0 0 24 24"
+ width={size}
+ height={size}
+ fill={color}
+ class={className}
+ style={{ verticalAlign: 'middle' }}
+ >
+ <path d="M3 21h18v-2H3v2zM3 8v8l4-4-4-4zm8 9h10v-2H11v2zM3 3v2h18V3H3zm8 6h10V7H11v2zm0 4h10v-2H11v2z" />
+ </svg>
+);
+
+export const OutdentIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => (
+ <svg
+ viewBox="0 0 24 24"
+ width={size}
+ height={size}
+ fill={color}
+ class={className}
+ style={{ verticalAlign: 'middle' }}
+ >
+ <path d="M3 21h18v-2H3v2zM7 8v8l-4-4 4-4zm4 9h10v-2H11v2zM3 3v2h18V3H3zm8 6h10V7H11v2zm0 4h10v-2H11v2z" />
+ </svg>
+);
+
+export const ImportantIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => (
+ <svg
+ viewBox="0 0 24 24"
+ width={size}
+ height={size}
+ fill={color}
+ class={className}
+ style={{ verticalAlign: 'middle' }}
+ >
+ <path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
+ </svg>
+);
+
+export const CheckIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => (
+ <svg
+ viewBox="0 0 24 24"
+ width={size}
+ height={size}
+ fill={color}
+ class={className}
+ style={{ verticalAlign: 'middle' }}
+ >
+ <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
+ </svg>
+);
+
+export const MoveUpIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => (
+ <svg
+ viewBox="0 0 24 24"
+ width={size}
+ height={size}
+ fill={color}
+ class={className}
+ style={{ verticalAlign: 'middle' }}
+ >
+ <path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z" />
+ </svg>
+);
+
+export const MoveDownIcon = ({ size = 24, color = 'currentColor', className = '' }: IconProps) => (
+ <svg
+ viewBox="0 0 24 24"
+ width={size}
+ height={size}
+ fill={color}
+ class={className}
+ style={{ verticalAlign: 'middle' }}
+ >
+ <path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z" />
+ </svg>
);
\ No newline at end of file
index 79a65eb49f95e9cacf9f4b165aeefca97103763d..acbfba85270f906d5085e02a519aea437bef0010 100644 (file)
-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<ListContextValue>({
+ 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<ItemProps[]>([]);
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);
- // 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,
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,
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<string>): 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) {
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
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,
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
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
);
}
}
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]);
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 (
- <ul>
- {items.filter(item => !item.__is_deleted).map(item => (
- <ListItem
- key={item._uuid}
- item={item}
- onChange={handleItemChange}
- moveFocus={handleMoveFocus}
- promote={handlePromote}
- demote={handleDemote}
- toggleImportant={handleToggleImportant}
- toggleCompleted={handleToggleCompleted}
- createItem={handleCreateItem}
- deleteItem={handleDeleteItem}
- />
- ))}
- </ul>
- );
-};
-
-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
index 97c7ae137f12eb36918f54168af3284787741d4c..46852a2b32c3cf65ac7e968fa481b2fae758eeec 100644 (file)
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<ListItemRef, ListItemProps>(({ item, onChange, moveFocus, promote, demote, toggleImportant, toggleCompleted, createItem, deleteItem }: ListItemProps, ref: Ref<ListItemRef>) => {
+export const ListItem = forwardRef<ListItemRef, ListItemProps>(({ item, onChange, moveFocus, promote, demote, toggleImportant, toggleCompleted, createItem, deleteItem, onFocus, moveUp, moveDown }: ListItemProps, ref: Ref<ListItemRef>) => {
const localRef = useRef<HTMLDivElement>(null);
const childRefs = useRef<(ListItemRef | null)[]>([]);
const [targetFocusInfo, setTargetFocusInfo] = useState<{ index: number, timestamp: number, focusLastChild: boolean, cursorPosition?: number } | null>(null);
}
};
- 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<ListItemRef, ListItemProps>(({ 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<ListItemRef, ListItemProps>(({ 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<ListItemRef, ListItemProps>(({ 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;
index 8d2764b3249be3758df04ada785de828fb4a9ddf..6151803a06e873fe6d13a8d85c983d77fab9963c 100644 (file)
-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 (
<div class="toolbar">
- <h1>Toolbar</h1>
- <button><MenuIcon size={24} /></button>
- <button><PersonIcon size={24} /></button>
- <button onClick={logout}><LogoutIcon size={24} /></button>
+ <div class="toolbar-left">
+ {buttons.map((button, index) => (
+ <div key={index} class="toolbar-button-container">
+ <button
+ onClick={button.onClick}
+ title={button.title}
+ disabled={button.disabled}
+ class={`toolbar-button ${button.active ? 'active' : ''} ${button.disabled ? 'disabled' : ''}`}
+ >
+ {button.icon}
+ </button>
+ {button.separatorAfter && (
+ <div class="toolbar-separator" />
+ )}
+ </div>
+ ))}
+ </div>
+
+ <div class="toolbar-right">
+ {appContext.isLoading ? (
+ <LoadingSpinner />
+ ) : (
+ <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 5ccf96256c24b54e7695d1b5c7025cef31ae668f..9346a029784ed615ae433c4e2fb748b07388b4b9 100644 (file)
--- a/frontend/styles.css
+++ b/frontend/styles.css
.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
index 9df8c0a5598380ee516beb2fabeef45181ad951c..b7704d24caec2908068f5ba542e22e342e2e00f2 100644 (file)
--- a/frontend/views/List.tsx
+++ b/frontend/views/List.tsx
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: <MoveUpIcon size={20} />,
+ onClick: listContext.moveUpItem,
+ title: 'Move Up (Ctrl+Up)',
+ disabled: !listContext.canMoveUp,
+ },
+ {
+ icon: <MoveDownIcon size={20} />,
+ onClick: listContext.moveDownItem,
+ title: 'Move Down (Ctrl+Down)',
+ disabled: !listContext.canMoveDown,
+ separatorAfter: true,
+ },
+ {
+ icon: <OutdentIcon size={20} />,
+ onClick: listContext.promoteItem,
+ title: 'Outdent (Shift+Tab)',
+ disabled: !listContext.canPromote,
+ },
+ {
+ icon: <IndentIcon size={20} />,
+ onClick: listContext.demoteItem,
+ title: 'Indent (Tab)',
+ disabled: !listContext.canDemote,
+ separatorAfter: true,
+ },
+ {
+ icon: <ImportantIcon size={20} />,
+ onClick: listContext.toggleImportantItem,
+ title: 'Toggle Important (Ctrl+B)',
+ disabled: !listContext.focusedItem,
+ active: listContext.focusedItem?.important || false,
+ },
+ {
+ icon: <CheckIcon size={20} />,
+ onClick: listContext.toggleCompletedItem,
+ title: 'Toggle Completed (Ctrl+S)',
+ disabled: !listContext.focusedItem,
+ active: !!listContext.focusedItem?.completed_at,
+ },
+ ];
+
+ return (
+ <div>
+ <Toolbar buttons={buttons} />
+ <div style={{ marginTop: '72px' }}>
+ <ul>
+ {listContext.items.filter(item => !item.__is_deleted).map(item => (
+ <ListItem
+ key={item._uuid}
+ item={item}
+ onChange={listContext.handleItemChange}
+ moveFocus={listContext.handleMoveFocus}
+ promote={listContext.handlePromote}
+ demote={listContext.handleDemote}
+ toggleImportant={listContext.handleToggleImportant}
+ toggleCompleted={listContext.handleToggleCompleted}
+ createItem={listContext.handleCreateItem}
+ deleteItem={listContext.handleDeleteItem}
+ onFocus={listContext.handleItemFocus}
+ moveUp={listContext.handleMoveUp}
+ moveDown={listContext.handleMoveDown}
+ />
+ ))}
+ </ul>
+ </div>
+ </div>
+ );
+};
export const List = () => {
- // ListContext will now fetch its own data.
- // No initialItems are passed.
- return <ListContext />;
+ const listState = useListState();
+
+ return (
+ <ListContextProvider.Provider value={{
+ focusedItem: listState.focusedItem,
+ canPromote: listState.canPromote,
+ canDemote: listState.canDemote,
+ canMoveUp: listState.canMoveUp,
+ canMoveDown: listState.canMoveDown,
+ promoteItem: listState.promoteItem,
+ demoteItem: listState.demoteItem,
+ toggleImportantItem: listState.toggleImportantItem,
+ toggleCompletedItem: listState.toggleCompletedItem,
+ moveUpItem: listState.moveUpItem,
+ moveDownItem: listState.moveDownItem,
+ items: listState.items,
+ handleItemChange: listState.handleItemChange,
+ handleMoveFocus: listState.handleMoveFocus,
+ handlePromote: listState.handlePromote,
+ handleDemote: listState.handleDemote,
+ handleToggleImportant: listState.handleToggleImportant,
+ handleToggleCompleted: listState.handleToggleCompleted,
+ handleCreateItem: listState.handleCreateItem,
+ handleDeleteItem: listState.handleDeleteItem,
+ handleItemFocus: listState.handleItemFocus,
+ handleMoveUp: listState.handleMoveUp,
+ handleMoveDown: listState.handleMoveDown,
+ }}>
+ <ListWithToolbar />
+ </ListContextProvider.Provider>
+ );
};
export default List;
\ No newline at end of file