diff --git a/app/(loggedInRoutes)/settings/connections/page.tsx b/app/(loggedInRoutes)/settings/connections/page.tsx index 1d902458..db0bd01f 100644 --- a/app/(loggedInRoutes)/settings/connections/page.tsx +++ b/app/(loggedInRoutes)/settings/connections/page.tsx @@ -1,10 +1,10 @@ import { LinksTab } from "@/app/_components/FeatureComponents/Profile/Parts/LinksTab"; import { readLinkIndex } from "@/app/_server/actions/link"; -import { LinkIndex } from "@/app/_types"; import { getUsername } from "@/app/_server/actions/users"; import { getArchivedItems } from "@/app/_server/actions/archived"; import { getUserNotes } from "@/app/_server/actions/note"; import { getUserChecklists } from "@/app/_server/actions/checklist"; +import { filterArchivedLinkIndex } from "@/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/graph-data"; export default async function ConnectionsPage() { const username = await getUsername(); @@ -16,52 +16,7 @@ export default async function ConnectionsPage() { ]); const archivedItems = archivedResult.success ? archivedResult.data : []; - - const filterArchivedItems = (linkIndex: LinkIndex, archivedItems: any[]): LinkIndex => { - const archivedIds = new Set(archivedItems.map(item => `${item.category || 'Uncategorized'}/${item.id}`)); - - const filteredNotes = Object.fromEntries( - Object.entries(linkIndex.notes).filter(([key]) => !archivedIds.has(key)) - ); - - const filteredChecklists = Object.fromEntries( - Object.entries(linkIndex.checklists).filter(([key]) => !archivedIds.has(key)) - ); - - Object.keys(filteredNotes).forEach(noteKey => { - filteredNotes[noteKey].isLinkedTo.notes = filteredNotes[noteKey].isLinkedTo.notes.filter( - linkedKey => !archivedIds.has(linkedKey) - ); - filteredNotes[noteKey].isLinkedTo.checklists = filteredNotes[noteKey].isLinkedTo.checklists.filter( - linkedKey => !archivedIds.has(linkedKey) - ); - filteredNotes[noteKey].isReferencedIn.notes = filteredNotes[noteKey].isReferencedIn.notes.filter( - refKey => !archivedIds.has(refKey) - ); - filteredNotes[noteKey].isReferencedIn.checklists = filteredNotes[noteKey].isReferencedIn.checklists.filter( - refKey => !archivedIds.has(refKey) - ); - }); - - Object.keys(filteredChecklists).forEach(checklistKey => { - filteredChecklists[checklistKey].isLinkedTo.notes = filteredChecklists[checklistKey].isLinkedTo.notes.filter( - linkedKey => !archivedIds.has(linkedKey) - ); - filteredChecklists[checklistKey].isLinkedTo.checklists = filteredChecklists[checklistKey].isLinkedTo.checklists.filter( - linkedKey => !archivedIds.has(linkedKey) - ); - filteredChecklists[checklistKey].isReferencedIn.notes = filteredChecklists[checklistKey].isReferencedIn.notes.filter( - refKey => !archivedIds.has(refKey) - ); - filteredChecklists[checklistKey].isReferencedIn.checklists = filteredChecklists[checklistKey].isReferencedIn.checklists.filter( - refKey => !archivedIds.has(refKey) - ); - }); - - return { notes: filteredNotes, checklists: filteredChecklists }; - }; - - const filteredLinkIndex = filterArchivedItems(linkIndex, archivedItems || []); + const filteredLinkIndex = filterArchivedLinkIndex(linkIndex, archivedItems || []); const notes = notesResult.success ? notesResult.data || [] : []; const checklists = checklistsResult.success ? checklistsResult.data || [] : []; diff --git a/app/_components/FeatureComponents/Home/Parts/ChecklistHome.tsx b/app/_components/FeatureComponents/Home/Parts/ChecklistHome.tsx index 79d344e3..6021979f 100644 --- a/app/_components/FeatureComponents/Home/Parts/ChecklistHome.tsx +++ b/app/_components/FeatureComponents/Home/Parts/ChecklistHome.tsx @@ -164,7 +164,7 @@ export const ChecklistHome = ({ } return ( -
+
diff --git a/app/_components/FeatureComponents/Home/Parts/NotesHome.tsx b/app/_components/FeatureComponents/Home/Parts/NotesHome.tsx index 6a4e8325..32709d9f 100644 --- a/app/_components/FeatureComponents/Home/Parts/NotesHome.tsx +++ b/app/_components/FeatureComponents/Home/Parts/NotesHome.tsx @@ -159,7 +159,7 @@ export const NotesHome = ({ } return ( -
+
diff --git a/app/_components/FeatureComponents/Kanban/ArchivedItemsModal.tsx b/app/_components/FeatureComponents/Kanban/ArchivedItemsModal.tsx index c4fd0d91..6181155a 100644 --- a/app/_components/FeatureComponents/Kanban/ArchivedItemsModal.tsx +++ b/app/_components/FeatureComponents/Kanban/ArchivedItemsModal.tsx @@ -111,7 +111,7 @@ export const ArchivedItemsModal = ({

{t('common.archived')}{" "} {formatDateString(item.archivedAt)} - {item.archivedBy && ` ${t('common.by')} ${item.archivedBy}`} + {item.archivedBy && ` ${t('common.by', { owner: item.archivedBy })}`}

)}
diff --git a/app/_components/FeatureComponents/Kanban/CalendarView.tsx b/app/_components/FeatureComponents/Kanban/CalendarView.tsx index dbc060a9..15a88470 100644 --- a/app/_components/FeatureComponents/Kanban/CalendarView.tsx +++ b/app/_components/FeatureComponents/Kanban/CalendarView.tsx @@ -1,5 +1,6 @@ "use client"; +import { KeyboardEvent } from "react"; import { Checklist, Item } from "@/app/_types"; import { useCalendarView } from "@/app/_hooks/kanban/useCalendarView"; import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; @@ -10,7 +11,11 @@ import { Download04Icon, } from "hugeicons-react"; import { cn } from "@/app/_utils/global-utils"; -import { getPriorityDotColor } from "@/app/_utils/kanban/index"; +import { getPriorityBarStyle } from "@/app/_utils/kanban/index"; +import { + getMaxBarLanes, + getWeekBarSegments, +} from "@/app/_utils/kanban/calendar-utils"; import { useTranslations } from "next-intl"; interface CalendarViewProps { @@ -33,16 +38,18 @@ const _toLocalDate = (d: Date): string => { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }; +const _BAR_HEIGHT = 18; + export const CalendarView = ({ checklist, onItemClick }: CalendarViewProps) => { const t = useTranslations(); const { currentDate, calendarGrid, + events, goToPreviousMonth, goToNextMonth, goToToday, exportICS, - getEventsForDate, unscheduledItems, } = useCalendarView(checklist); @@ -53,6 +60,11 @@ export const CalendarView = ({ checklist, onItemClick }: CalendarViewProps) => { year: "numeric", }); + const _openItem = (itemId: string) => { + const item = checklist.items.find((entry) => entry.id === itemId); + if (item && onItemClick) onItemClick(item); + }; + return (
@@ -88,78 +100,99 @@ export const CalendarView = ({ checklist, onItemClick }: CalendarViewProps) => { ))}
- {calendarGrid.map((week, weekIndex) => ( -
- {week.map((day, dayIndex) => { - if (!day) { - return ( -
- ); - } + {calendarGrid.map((week, weekIndex) => { + const segments = getWeekBarSegments(week, events); + const maxLanes = getMaxBarLanes(segments); + const barAreaHeight = maxLanes * _BAR_HEIGHT; - const dateStr = _toLocalDate(day); - const dayEvents = getEventsForDate(day); - const isToday = dateStr === today; + return ( +
+ {week.map((day, dayIndex) => { + if (!day) { + return ( +
+ ); + } - return ( -
+ const dateStr = _toLocalDate(day); + const isToday = dateStr === today; + + return (
- {day.getDate()} -
-
- {dayEvents.slice(0, 3).map((event) => ( -
{ - const item = checklist.items.find( - (i) => i.id === event.itemId, - ); - if (item && onItemClick) onItemClick(item); - }} - className={cn( - "text-[10px] px-1 py-0.5 rounded truncate cursor-pointer hover:opacity-80 transition-opacity flex items-center gap-1 bg-muted text-muted-foreground border-l-2", - event.completed && "line-through opacity-60", - )} - style={{ - borderLeftColor: event.completed - ? "#22c55e" - : getPriorityDotColor( - event.priority as Parameters< - typeof getPriorityDotColor - >[0], - ), - }} - > - {event.title} -
- ))} - {dayEvents.length > 3 && ( -
- {t("kanban.moreEvents", { - count: dayEvents.length - 3, - })} -
- )} +
+ {day.getDate()} +
+ ); + })} + + {maxLanes > 0 && ( +
+ {segments.map((segment) => { + const barStyle = getPriorityBarStyle( + segment.event.priority as Parameters[0], + ); + + return ( +
_openItem(segment.event.itemId), + onKeyDown: (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + _openItem(segment.event.itemId); + } + }, + } + : {})} + className={cn( + "pointer-events-auto h-4 text-[10px] font-medium leading-4 px-1.5 truncate transition-[filter,opacity]", + onItemClick && + "cursor-pointer hover:brightness-95 dark:hover:brightness-110", + !barStyle.backgroundColor && "bg-muted/80 text-muted-foreground", + segment.continuesPrev ? "rounded-l-none ml-0" : "rounded-l-jotty ml-0.5", + segment.continuesNext ? "rounded-r-none mr-0" : "rounded-r-jotty mr-0.5", + segment.event.completed && "line-through opacity-70", + )} + style={{ + ...barStyle, + gridColumn: `${segment.colStart + 1} / span ${segment.colSpan}`, + gridRow: segment.lane + 1, + }} + > + {!segment.continuesPrev ? segment.event.title : "\u00a0"} +
+ ); + })}
- ); - })} -
- ))} + )} +
+ ); + })}
{unscheduledItems.length > 0 && ( diff --git a/app/_components/FeatureComponents/Kanban/Kanban.tsx b/app/_components/FeatureComponents/Kanban/Kanban.tsx index d341f904..9782916f 100644 --- a/app/_components/FeatureComponents/Kanban/Kanban.tsx +++ b/app/_components/FeatureComponents/Kanban/Kanban.tsx @@ -1,20 +1,8 @@ "use client"; -import { useState, useEffect, useMemo, useCallback } from "react"; -import { - DndContext, - DragOverlay, - PointerSensor, - KeyboardSensor, - TouchSensor, - useSensor, - useSensors, - pointerWithin, - closestCorners, - CollisionDetection, - rectIntersection, -} from "@dnd-kit/core"; -import { Checklist, KanbanStatus } from "@/app/_types"; +import { useState, useMemo, useCallback } from "react"; +import { DndProvider } from "@/app/_hooks/dnd"; +import { Checklist, Item, KanbanStatus } from "@/app/_types"; import { KanbanColumn } from "./KanbanColumn"; import { KanbanCard } from "./KanbanCard"; import { ChecklistHeading } from "../Checklists/Parts/Common/ChecklistHeading"; @@ -22,6 +10,7 @@ import { BulkPasteModal } from "@/app/_components/GlobalComponents/Modals/BulkPa import { StatusManager } from "./StatusManager"; import { ArchivedItems } from "./ArchivedItems"; import { useKanbanBoard } from "@/app/_hooks/kanban/useKanban"; +import { useKanbanDnd } from "@/app/_hooks/kanban/useKanbanDnd"; import { ItemTypes, TaskStatus, TaskStatusLabels } from "@/app/_types/enums"; import { ReferencedBySection } from "../Notes/Parts/ReferencedBySection"; import { getReferences } from "@/app/_utils/indexes-utils"; @@ -39,24 +28,37 @@ import { CalendarView } from "./CalendarView"; import { KanbanCardDetail } from "./KanbanCardDetail"; import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; import { updateChecklistStatuses } from "@/app/_server/actions/checklist"; -import { unarchiveItem } from "@/app/_server/actions/checklist-item"; +import { archiveItem, unarchiveItem } from "@/app/_server/actions/checklist-item"; import { useTranslations } from "next-intl"; import { DEFAULT_KANBAN_STATUSES } from "@/app/_consts/kanban"; +import { useToast } from "@/app/_providers/ToastProvider"; interface KanbanBoardProps { checklist: Checklist; onUpdate: (updatedChecklist: Checklist) => void; } +const _findItemById = (items: Item[], itemId: string): Item | null => { + for (const item of items) { + if (item.id === itemId) return item; + if (item.children) { + const found = _findItemById(item.children, itemId); + if (found) return found; + } + } + return null; +}; + export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { const t = useTranslations(); - const [isClient, setIsClient] = useState(false); + const { showToast } = useToast(); const [showStatusModal, setShowStatusModal] = useState(false); const [showArchivedModal, setShowArchivedModal] = useState(false); const [viewMode, setViewMode] = useState<"board" | "calendar">("board"); const [searchQuery, setSearchQuery] = useState(""); const [priorityFilter, setPriorityFilter] = useState(""); const [assigneeFilter, setAssigneeFilter] = useState(""); + const [detailItemId, setDetailItemId] = useState(null); const [calendarSelectedItem, setCalendarSelectedItem] = useState< import("@/app/_types").Item | null >(null); @@ -74,6 +76,7 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { const { permissions } = usePermissions(); const { localChecklist, + setLocalChecklist, isLoading, showBulkPasteModal, setShowBulkPasteModal, @@ -81,13 +84,9 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { refreshChecklist, handleItemUpdate, getItemsByStatus, - handleDragStart, - handleDragOver, - handleDragEnd, handleAddItem, handleBulkPaste, handleItemStatusUpdate, - activeItem, } = useKanbanBoard({ checklist, onUpdate }); const statuses = useMemo(() => { @@ -134,6 +133,10 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { ); const archivedItems = localChecklist.items.filter((item) => item.isArchived); + const detailItem = useMemo( + () => (detailItemId ? _findItemById(localChecklist.items, detailItemId) : null), + [detailItemId, localChecklist.items], + ); const handleUnarchive = async (itemId: string) => { const formData = new FormData(); @@ -148,6 +151,62 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { } }; + const handleArchiveAll = useCallback( + async (items: Item[]) => { + let archivedCount = 0; + let latestChecklist: Checklist | null = null; + + for (const item of items) { + const formData = new FormData(); + formData.append("listId", localChecklist.id); + formData.append("itemId", item.id); + formData.append("category", localChecklist.category || "Uncategorized"); + + const result = await archiveItem(formData); + if (!result.success || !result.data) { + if (latestChecklist) { + onUpdate(latestChecklist); + } + + await refreshChecklist(); + showToast({ + type: "error", + title: t("common.error"), + message: + archivedCount > 0 + ? t("kanban.archiveAllPartial", { + archived: archivedCount, + total: items.length, + }) + : t("kanban.archiveAllFailed"), + }); + return; + } + + archivedCount++; + latestChecklist = result.data; + } + + if (latestChecklist) { + onUpdate(latestChecklist); + await refreshChecklist(); + showToast({ + type: "success", + title: t("common.success"), + message: t("kanban.archiveAllSuccess", { count: items.length }), + }); + } + }, + [ + localChecklist.id, + localChecklist.category, + onUpdate, + refreshChecklist, + showToast, + t, + ], + ); + const handleToggleItem = useCallback( async (itemId: string, completed: boolean) => { const newStatus = completed ? TaskStatus.COMPLETED : TaskStatus.TODO; @@ -175,6 +234,19 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { [searchQuery, priorityFilter, assigneeFilter, _hasFilters], ); + const visibleFor = useCallback( + (status: string) => _filterItems(getItemsByStatus(status)), + [_filterItems, getItemsByStatus], + ); + + const { handleDrop } = useKanbanDnd({ + checklist: localChecklist, + setChecklist: setLocalChecklist, + onUpdate, + visibleFor, + fallbackMove: handleItemStatusUpdate, + }); + const _uniqueAssignees = useMemo(() => { const assignees = new Set(); localChecklist.items.forEach((item) => { @@ -189,18 +261,19 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { className={ columns.length <= 6 ? "h-full min-w-0 kanban-grid gap-4 p-2 sm:p-4" - : "h-full min-w-0 flex gap-4 p-2 sm:p-4" + : "min-h-0 min-w-0 flex gap-4 p-2 sm:p-4" } style={ columns.length <= 6 ? ({ - "--kanban-col-count": columns.length, - } as React.CSSProperties) + "--kanban-col-count": columns.length, + } as React.CSSProperties) : undefined } > {columns.map((column) => { - const items = _filterItems(getItemsByStatus(column.status)); + const columnItems = getItemsByStatus(column.status); + const items = _filterItems(columnItems); return (
{ checklistId={localChecklist.id} category={localChecklist.category || "Uncategorized"} onUpdate={handleItemUpdate} + onOpenDetail={(item) => setDetailItemId(item.id)} isShared={isShared} statusColor={statuses.find((s) => s.id === column.id)?.color} statuses={statuses} onAddItem={permissions?.canEdit ? (text) => handleAddItem(text, undefined, column.status) : undefined} + archivableCount={columnItems.length} + onArchiveAll={ + permissions?.canEdit + ? () => handleArchiveAll(columnItems) + : undefined + } />
); @@ -234,36 +314,11 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { handleItemUpdate, isShared, statuses, + handleArchiveAll, + permissions?.canEdit, ], ); - const _collisionDetection: CollisionDetection = useCallback((args) => { - const pointerCollisions = pointerWithin(args); - if (pointerCollisions.length > 0) return pointerCollisions; - return closestCorners(args); - }, []); - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - delay: 30, - tolerance: 5, - }, - }), - useSensor(TouchSensor, { - activationConstraint: { - delay: 250, - tolerance: 5, - }, - }), - useSensor(KeyboardSensor), - ); - - useEffect(() => { - setIsClient(true); - }, []); - const referencingItems = useMemo(() => { return getReferences( linkIndex, @@ -396,33 +451,29 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { onItemClick={(item) => setCalendarSelectedItem(item)} />
- ) : isClient ? ( - - {_renderColumns()} - - - {activeItem ? ( + ) : ( + { + const ghostItem = _findItemById(localChecklist.items, itemId); + if (!ghostItem) return null; + return ( { }} + onOpenDetail={() => { }} isShared={isShared} statuses={statuses} /> - ) : null} - - - ) : ( - _renderColumns() + ); + }} + > + {_renderColumns()} + )}
@@ -445,6 +496,18 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { /> )} + {detailItem && ( + setDetailItemId(null)} + onUpdate={handleItemUpdate} + checklistId={localChecklist.id} + category={localChecklist.category || "Uncategorized"} + /> + )} + {showBulkPasteModal && ( void; + onOpenDetail: (item: Item) => void; isShared: boolean; statuses: KanbanStatus[]; statusColor?: string; @@ -44,16 +46,19 @@ interface KanbanCardProps { const KanbanCardComponent = ({ checklist, item, + index = 0, + listId, isDragging, checklistId, category, onUpdate, + onOpenDetail, isShared, statuses, statusColor, }: KanbanCardProps) => { const t = useTranslations(); - const { usersPublicData } = useAppMode(); + const { usersPublicData, user } = useAppMode(); const { permissions } = usePermissions(); const { formatDateString, formatDateTimeString, formatTimeString } = usePreferredDateTime(); @@ -70,8 +75,9 @@ const KanbanCardComponent = ({ [usersPublicData], ); - const [showDetailModal, setShowDetailModal] = useState(false); const [showTimeEntriesModal, setShowTimeEntriesModal] = useState(false); + const [showStatusSheet, setShowStatusSheet] = useState(false); + const hideMobileStatusDropdown = user?.hideMobileStatusDropdown === "enable"; const kanbanItemHook = useKanbanItem({ checklist, @@ -81,28 +87,19 @@ const KanbanCardComponent = ({ onUpdate, }); - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging: isSortableDragging, - } = useSortable({ + const { setNodeRef, handleProps, isLifted, isAway, style } = useDragItem({ id: item.id, + listId: listId || item.status || TaskStatus.TODO, + index, disabled: kanbanItemHook.isEditing || !permissions?.canEdit, + ghost: isDragging, }); - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - useEffect(() => { - if (isSortableDragging) { + if (isLifted) { kanbanItemHook.stopTimerOnDrag(); } - }, [isSortableDragging]); + }, [isLifted]); const statusOptions = useMemo(() => { const options = statuses?.map((status) => ({ @@ -115,8 +112,46 @@ const KanbanCardComponent = ({ return options?.sort((a, b) => a.order - b.order); }, [statuses]); + const handleMobileStatusChange = async (newStatus: string) => { + await kanbanItemHook.handleStatusChange(newStatus); + setShowStatusSheet(false); + }; + return ( <> + {showStatusSheet && ( + setShowStatusSheet(false)} + title={t("kanban.changeStatus")} + > +
+ {statusOptions.map((status) => { + const isCurrent = status.id === (item.status || TaskStatus.TODO); + return ( + + ); + })} +
+
+ )} + {showTimeEntriesModal && item.timeEntries && ( )} - {showDetailModal && ( - setShowDetailModal(false)} - onUpdate={onUpdate} - checklistId={checklistId} - category={category} - /> - )} - -
+
setShowDetailModal(true)} + onDoubleClick={() => onOpenDetail(item)} className={cn( "group bg-background border rounded-jotty p-3 transition-all duration-200 hover:shadow-md cursor-grab active:cursor-grabbing min-w-0", getStatusColor(item.status), - (isDragging || isSortableDragging) && + isDragging && "opacity-60 scale-[0.98] shadow-lg border-primary/40 z-50 transition-all duration-200", + isLifted && "opacity-0 pointer-events-none", )} >
@@ -170,7 +201,8 @@ const KanbanCardComponent = ({ onEditTextChange={kanbanItemHook.setEditText} onEditSave={kanbanItemHook.handleSave} onEditKeyDown={kanbanItemHook.handleKeyDown} - onShowSubtaskModal={() => setShowDetailModal(true)} + onShowSubtaskModal={() => onOpenDetail(item)} + onShowStatusMenu={() => setShowStatusSheet(true)} onEdit={kanbanItemHook.handleEdit} onDelete={kanbanItemHook.handleDelete} onArchive={kanbanItemHook.handleArchive} @@ -256,21 +288,23 @@ const KanbanCardComponent = ({
)} -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - > - - kanbanItemHook.handleStatusChange(newStatus as TaskStatus) - } - className="w-full text-sm" - /> -
+ {!hideMobileStatusDropdown && ( +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + + kanbanItemHook.handleStatusChange(newStatus as TaskStatus) + } + className="w-full text-sm" + /> +
+ )}
diff --git a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx index b7c10cf0..5fa0c41c 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx @@ -22,6 +22,7 @@ import { useTranslations } from "next-intl"; import { KanbanPriorityLevel } from "@/app/_types/enums"; import { KanbanCardDetailProperties } from "./KanbanCardDetailProperties"; import { KanbanCardDetailSubtasks } from "./KanbanCardDetailSubtasks"; +import { DEFAULT_KANBAN_STATUSES } from "@/app/_consts/kanban"; interface KanbanCardDetailProps { checklist: Checklist; @@ -78,6 +79,9 @@ export const KanbanCardDetail = ({ const t = useTranslations(); const { permissions } = usePermissions(); const { formatDateTimeString } = usePreferredDateTime(); + const statuses = checklist.statuses || DEFAULT_KANBAN_STATUSES; + const defaultStatusId = + [...statuses].sort((a, b) => a.order - b.order)[0]?.id || ""; const [showHistory, setShowHistory] = useState(false); const [item, setItem] = useState(initialItem); @@ -87,7 +91,9 @@ export const KanbanCardDetail = ({ const [scoreInput, setScoreInput] = useState(initialItem.score?.toString() || ""); const [reminderInput, setReminderInput] = useState(initialItem.reminder?.datetime || ""); const [targetDateInput, setTargetDateInput] = useState(initialItem.targetDate || ""); + const [startDateInput, setStartDateInput] = useState(initialItem.startDate || ""); const [priorityInput, setPriorityInput] = useState(initialItem.priority || KanbanPriorityLevel.NONE); + const [statusInput, setStatusInput] = useState(initialItem.status || defaultStatusId); const [assigneeInput, setAssigneeInput] = useState(initialItem.assignee || ""); const [estimatedTimeInput, setEstimatedTimeInput] = useState(initialItem.estimatedTime?.toString() || ""); const [availableUsers, setAvailableUsers] = useState<{ username: string; avatarUrl?: string }[]>([]); @@ -100,10 +106,12 @@ export const KanbanCardDetail = ({ setScoreInput(initialItem.score?.toString() || ""); setReminderInput(initialItem.reminder?.datetime || ""); setTargetDateInput(initialItem.targetDate || ""); + setStartDateInput(initialItem.startDate || ""); setPriorityInput(initialItem.priority || KanbanPriorityLevel.NONE); + setStatusInput(initialItem.status || defaultStatusId); setAssigneeInput(initialItem.assignee || ""); setEstimatedTimeInput(initialItem.estimatedTime?.toString() || ""); - }, [initialItem]); + }, [initialItem, defaultStatusId]); useEffect(() => { if (!isOpen) return; @@ -299,6 +307,24 @@ export const KanbanCardDetail = ({ await _saveField({ priority }); }; + const handleStatusChange = async (status: string) => { + setStatusInput(status); + const formData = new FormData(); + formData.append("listId", checklistId); + formData.append("itemId", item.id); + formData.append("status", status); + formData.append("category", category); + const result = await updateItemStatus(formData); + if (result.success && result.data) { + onUpdate(result.data); + const updatedItem = _findItemInChecklist(result.data, item.id); + if (updatedItem) { + setItem(updatedItem); + setStatusInput(updatedItem.status || status); + } + } + }; + const handleScoreSave = async () => { const score = parseInt(scoreInput); if (isNaN(score)) return; @@ -311,9 +337,40 @@ export const KanbanCardDetail = ({ }); }; + const _dateKey = (value: string): string => + value.includes("T") ? _toLocalDateValue(value) : value; + + const _dateToIso = (value: string): string => + value + ? new Date(value.includes("T") ? value : `${value}T00:00:00`).toISOString() + : ""; + + const handleStartDateChange = async (value: string) => { + const iso = _dateToIso(value); + setStartDateInput(iso); + const targetKey = _dateKey(targetDateInput); + if (value && targetKey && value > targetKey) { + setTargetDateInput(iso); + await _saveField({ startDate: iso, targetDate: iso }); + return; + } + await _saveField({ startDate: iso }); + }; + const handleTargetDateChange = async (value: string) => { - setTargetDateInput(value); - await _saveField({ targetDate: value ? new Date(value).toISOString() : "" }); + const iso = _dateToIso(value); + setTargetDateInput(iso); + if (!value) { + await _saveField({ targetDate: "" }); + return; + } + const startKey = _dateKey(startDateInput); + if (startKey && startKey > value) { + setStartDateInput(iso); + await _saveField({ targetDate: iso, startDate: iso }); + return; + } + await _saveField({ targetDate: iso }); }; const handleEstimatedTimeSave = async () => { @@ -327,10 +384,13 @@ export const KanbanCardDetail = ({ isOpen={isOpen} onClose={onClose} title={item.text || t("checklists.untitledTask")} - className="[&_.jotty-modal-header]:shrink-0 lg:!max-w-[80vw] lg:!w-full lg:!h-[80vh] lg:!max-h-[80vh] max-h-[min(90dvh,100dvh)] !flex !flex-col overflow-y-auto overscroll-contain lg:overflow-hidden" + size="fullscreen" + allowEnlarge + defaultEnlarged + className="lg:!max-w-[80vw] lg:!w-full lg:!h-[80vh] lg:!max-h-[80vh] max-h-[min(90dvh,100dvh)]" > -
-
+
+
{isEditing ? (
@@ -436,14 +496,17 @@ export const KanbanCardDetail = ({ )}
-
+
diff --git a/app/_components/FeatureComponents/Kanban/KanbanCardDetailProperties.tsx b/app/_components/FeatureComponents/Kanban/KanbanCardDetailProperties.tsx index 5ccbd2a2..e236d74a 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanCardDetailProperties.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanCardDetailProperties.tsx @@ -1,31 +1,36 @@ "use client"; +import { useState, type ReactNode } from "react"; import { Dropdown } from "@/app/_components/GlobalComponents/Dropdowns/Dropdown"; import { UserAvatar } from "@/app/_components/GlobalComponents/User/UserAvatar"; import { DatePicker, DateTimePicker } from "@/app/_components/GlobalComponents/FormElements/DatePicker"; -import { Item, KanbanPriority } from "@/app/_types"; +import { Item, KanbanPriority, KanbanStatus } from "@/app/_types"; import { KanbanPriorityLevel } from "@/app/_types/enums"; import { getPriorityDotColor, getPriorityLabel, } from "@/app/_utils/kanban/index"; -import { UserIcon } from "hugeicons-react"; +import { ArrowDown01Icon, ArrowRight01Icon, UserIcon } from "hugeicons-react"; import { useTranslations } from "next-intl"; import { cn } from "@/app/_utils/global-utils"; interface KanbanCardDetailPropertiesProps { item: Item; + statuses: KanbanStatus[]; + statusInput: string; priorityInput: KanbanPriority; scoreInput: string; assigneeInput: string; reminderInput: string; targetDateInput: string; + startDateInput: string; estimatedTimeInput: string; availableUsers: { username: string; avatarUrl?: string }[]; canEdit: boolean; isShared: boolean; toLocalDateTimeValue: (iso: string) => string; toLocalDateValue: (iso: string) => string; + onStatusChange: (status: string) => void; onPriorityChange: (p: KanbanPriority) => void; onScoreChange: (v: string) => void; onScoreSave: () => void; @@ -33,24 +38,61 @@ interface KanbanCardDetailPropertiesProps { onReminderChange: (v: string) => void; onReminderSave: () => void; onTargetDateChange: (v: string) => void; + onStartDateChange: (v: string) => void; onEstimatedTimeChange: (v: string) => void; onEstimatedTimeSave: () => void; formatDateTimeString: (v: string) => string; } +interface PropertySectionProps { + title: string; + children: ReactNode; + defaultOpen?: boolean; +} + +const PropertySection = ({ + title, + children, + defaultOpen = true, +}: PropertySectionProps) => { + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( +
+ + {isOpen &&
{children}
} +
+ ); +}; + export const KanbanCardDetailProperties = ({ item, + statuses, + statusInput, priorityInput, scoreInput, assigneeInput, reminderInput, targetDateInput, + startDateInput, estimatedTimeInput, availableUsers, canEdit, isShared, toLocalDateTimeValue, toLocalDateValue, + onStatusChange, onPriorityChange, onScoreChange, onScoreSave, @@ -58,6 +100,7 @@ export const KanbanCardDetailProperties = ({ onReminderChange, onReminderSave, onTargetDateChange, + onStartDateChange, onEstimatedTimeChange, onEstimatedTimeSave, formatDateTimeString, @@ -72,6 +115,8 @@ export const KanbanCardDetailProperties = ({ KanbanPriorityLevel.NONE, ]; + const sortedStatuses = [...statuses].sort((a, b) => a.order - b.order); + const assigneeOptions = [ { id: "", @@ -119,17 +164,39 @@ export const KanbanCardDetailProperties = ({ } return ( -
+
{canEdit && ( <> -
- + +
+ {sortedStatuses.map((status) => ( + + ))} +
+
+ +
{priorities.map((p) => ( ))}
-
+ -
- + -
+ {isShared && ( -
- + -
+ )} -
- + -
+ -
- + + + + + -
+ -
- + )} -
+ )} {metadata.length > 0 && ( -
-
- {t("auditLogs.metadata")} -
+
{metadata.map((text, i) => (

))}

-
+ )}
); diff --git a/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx b/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx index a7da7dd1..4feebdad 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx @@ -1,18 +1,15 @@ "use client"; import { useMemo, memo, useState, useRef, useEffect } from "react"; -import { useDroppable } from "@dnd-kit/core"; -import { - SortableContext, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; +import { useDropList } from "@/app/_hooks/dnd"; import { Item, Checklist, KanbanStatus } from "@/app/_types"; import { KanbanCard } from "./KanbanCard"; import { cn } from "@/app/_utils/global-utils"; import { TaskStatus } from "@/app/_types/enums"; import { useTranslations } from "next-intl"; -import { TaskDaily01Icon, Add01Icon } from "hugeicons-react"; +import { TaskDaily01Icon, Add01Icon, Archive02Icon } from "hugeicons-react"; import { Input } from "../../GlobalComponents/FormElements/Input"; +import { ConfirmModal } from "../../GlobalComponents/Modals/ConfirmationModals/ConfirmModal"; interface KanbanColumnProps { checklist: Checklist; @@ -23,10 +20,13 @@ interface KanbanColumnProps { checklistId: string; category: string; onUpdate: (updatedChecklist: Checklist) => void; + onOpenDetail: (item: Item) => void; isShared: boolean; statusColor?: string; statuses: KanbanStatus[]; onAddItem?: (status: string) => Promise; + archivableCount?: number; + onArchiveAll?: () => Promise; } interface InlineAddInputProps { @@ -85,14 +85,19 @@ const KanbanColumnComponent = ({ category, isShared, onUpdate, + onOpenDetail, statusColor, statuses, onAddItem, + archivableCount = items.length, + onArchiveAll, }: KanbanColumnProps) => { const t = useTranslations(); - const { setNodeRef, isOver } = useDroppable({ id }); + const { setNodeRef, isOver, padBottom } = useDropList({ id }); const [showInlineInput, setShowInlineInput] = useState(false); const [isAddingItem, setIsAddingItem] = useState(false); + const [showArchiveAllModal, setShowArchiveAllModal] = useState(false); + const [isArchiving, setIsArchiving] = useState(false); const currentStatus = statuses.find((s) => s.id === status); const isAutoComplete = currentStatus?.autoComplete === true; @@ -136,6 +141,16 @@ const KanbanColumnComponent = ({ setIsAddingItem(false); }; + const handleArchiveAll = async () => { + if (!onArchiveAll || archivableCount === 0) return; + setIsArchiving(true); + try { + await onArchiveAll(); + } finally { + setIsArchiving(false); + } + }; + return (
@@ -160,6 +175,17 @@ const KanbanColumnComponent = ({ )} + {onArchiveAll && isAutoComplete && ( + + )} {items.length} @@ -176,44 +202,57 @@ const KanbanColumnComponent = ({ style={{ borderColor: isOver ? undefined : borderColor, backgroundColor: isOver ? undefined : bgColor, + paddingBottom: padBottom + ? `calc(0.75rem + ${padBottom}px)` + : undefined, }} > - item.id)} - strategy={verticalListSortingStrategy} - > -
- {showInlineInput && ( - setShowInlineInput(false)} - placeholder={t("kanban.addItemPlaceholder")} - /> - )} - {items.map((item) => ( - - ))} - {items.length === 0 && !showInlineInput && ( -
- - - {t("checklists.noTasks")} - -
- )} -
-
+
+ {showInlineInput && ( + setShowInlineInput(false)} + placeholder={t("kanban.addItemPlaceholder")} + /> + )} + {items.map((item, index) => ( + + ))} + {items.length === 0 && !showInlineInput && ( +
+ + + {t("checklists.noTasks")} + +
+ )} +
+ setShowArchiveAllModal(false)} + onConfirm={handleArchiveAll} + title={t("kanban.archiveAllConfirmTitle")} + message={t("kanban.archiveAllConfirmMessage", { + count: archivableCount, + column: title, + })} + confirmText={t("common.archive")} + variant="destructive" + />
); }; diff --git a/app/_components/FeatureComponents/Kanban/KanbanItemContent.tsx b/app/_components/FeatureComponents/Kanban/KanbanItemContent.tsx index 3d6e2c25..fddbcdbf 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanItemContent.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanItemContent.tsx @@ -23,6 +23,7 @@ interface KanbanItemContentProps { onEditSave: () => void; onEditKeyDown: (e: React.KeyboardEvent) => void; onShowSubtaskModal: () => void; + onShowStatusMenu: () => void; onEdit: () => void; onDelete: () => void; onArchive: () => void; @@ -43,6 +44,7 @@ const KanbanItemContentComponent = ({ onEditSave, onEditKeyDown, onShowSubtaskModal, + onShowStatusMenu, onEdit, onDelete, onArchive, @@ -110,6 +112,14 @@ const KanbanItemContentComponent = ({ value="" options={[ { id: "view", name: t("tasks.viewTask") }, + ...(permissions?.canEdit + ? [ + { + id: "status", + name: t("kanban.changeStatus"), + }, + ] + : []), ...(permissions?.canEdit ? [{ id: "add", name: t("tasks.addSubtask") }] : []), @@ -128,6 +138,9 @@ const KanbanItemContentComponent = ({ case "view": onShowSubtaskModal(); break; + case "status": + onShowStatusMenu(); + break; case "add": onShowSubtaskModal(); break; diff --git a/app/_components/FeatureComponents/Notes/Parts/MermaidRenderer.tsx b/app/_components/FeatureComponents/Notes/Parts/MermaidRenderer.tsx index bf36a779..1ee2c7fc 100644 --- a/app/_components/FeatureComponents/Notes/Parts/MermaidRenderer.tsx +++ b/app/_components/FeatureComponents/Notes/Parts/MermaidRenderer.tsx @@ -73,6 +73,9 @@ if (typeof window !== "undefined") { initializeMermaidTheme(); } +const normalizeDiagramType = (code: string): string => + code.replace(/^(\s*)sankey(\s)/m, "$1sankey-beta$2"); + export const MermaidRenderer = ({ code, className = "", @@ -91,7 +94,7 @@ export const MermaidRenderer = ({ setError(null); const id = `mermaid-view-${Math.random().toString(36).substring(2, 11)}`; - const { svg } = await mermaid.render(id, code); + const { svg } = await mermaid.render(id, normalizeDiagramType(code)); if (containerRef.current) { containerRef.current.innerHTML = svg; } diff --git a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/ExcalidrawExtension.tsx b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/ExcalidrawExtension.tsx index 2a9ae485..423b5498 100644 --- a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/ExcalidrawExtension.tsx +++ b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/ExcalidrawExtension.tsx @@ -85,23 +85,35 @@ export const ExcalidrawNodeView = ({ files: files || null, }; - const { exportToSvg } = await import("@excalidraw/excalidraw"); - const svg = await exportToSvg({ - elements, - appState, - files, - exportPadding: 20, - }); - svg.removeAttribute("width"); - svg.removeAttribute("height"); - svg.setAttribute("style", "max-width: 100%; height: auto;"); - const svgString = svg.outerHTML; + try { + const { exportToSvg } = await import("@excalidraw/excalidraw"); + const svg = await exportToSvg({ + elements, + appState, + files, + exportPadding: 20, + }); + svg.removeAttribute("width"); + svg.removeAttribute("height"); + svg.setAttribute("style", "max-width: 100%; height: auto;"); + const svgString = svg.outerHTML; - updateAttributes({ - diagramData: JSON.stringify(sceneData), - svgData: svgString, - }); + updateAttributes({ + diagramData: JSON.stringify(sceneData), + svgData: svgString, + }); + } catch (error) { + console.error("Failed to export Excalidraw diagram:", error); + } finally { + setIsEditing(false); + } + } + }; + const handleClose = () => { + if (excalidrawAPI) { + handleSave(); + } else { setIsEditing(false); } }; @@ -129,7 +141,7 @@ export const ExcalidrawNodeView = ({ setIsEditing(false)} + onClose={handleClose} title={
{t("editor.editDiagram")} diff --git a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/MermaidExtension.tsx b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/MermaidExtension.tsx index 49a49edf..b3aed5d2 100644 --- a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/MermaidExtension.tsx +++ b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/MermaidExtension.tsx @@ -57,6 +57,9 @@ if (typeof window !== "undefined") { initializeMermaidTheme(); } +const normalizeDiagramType = (code: string): string => + code.replace(/^(\s*)sankey(\s)/m, "$1sankey-beta$2"); + export const MermaidNodeView = ({ node, updateAttributes, @@ -78,7 +81,7 @@ export const MermaidNodeView = ({ setError(null); const id = `mermaid-${Math.random().toString(36).substring(2, 11)}`; - const { svg } = await mermaid.render(id, node.attrs.content); + const { svg } = await mermaid.render(id, normalizeDiagramType(node.attrs.content)); if (containerRef.current) { containerRef.current.innerHTML = svg; } diff --git a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/SlashCommands.tsx b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/SlashCommands.tsx index 4baf4aea..806e2114 100644 --- a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/SlashCommands.tsx +++ b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/SlashCommands.tsx @@ -348,17 +348,15 @@ export const SlashCommands = Extension.create({ })), ]; - if (!query.trim()) return allItems.slice(0, 8); + if (!query.trim()) return allItems; const lowerCaseQuery = query.toLowerCase(); - return allItems - .filter( - (item) => - item.title.toLowerCase().includes(lowerCaseQuery) || - item.category?.toLowerCase().includes(lowerCaseQuery) - ) - .slice(0, 8); + return allItems.filter( + (item) => + item.title.toLowerCase().includes(lowerCaseQuery) || + item.category?.toLowerCase().includes(lowerCaseQuery) + ); }, render: () => { let component: ReactRenderer; diff --git a/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/ConnectionsGraph.tsx b/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/ConnectionsGraph.tsx new file mode 100644 index 00000000..a925e906 --- /dev/null +++ b/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/ConnectionsGraph.tsx @@ -0,0 +1,588 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import type { PointerEvent } from "react"; +import ForceGraph2D, { + ForceGraphMethods, + LinkObject, + NodeObject, +} from "react-force-graph-2d"; +import { useRouter } from "next/navigation"; +import { CheckmarkSquare04Icon, File02Icon } from "hugeicons-react"; +import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; +import { ItemTypes } from "@/app/_types/enums"; +import { + ConnectionGraphData, + ConnectionGraphLink, + ConnectionGraphNode, +} from "./graph-data"; + +interface ConnectionsGraphProps { + graphData: ConnectionGraphData; + selectedNodeId: string | null; + onSelectedNodeChange: (nodeId: string | null) => void; + showLabels: boolean; + showArrows: boolean; + nodeScale: number; + linkWidth: number; + linkDistance: number; + repelForce: number; + labels: ConnectionsGraphLabels; +} + +export interface ConnectionsGraphLabels { + unknown: string; + total: string; + inbound: string; + outbound: string; + updated: string; + uuid: string; + openItem: string; + selectItem: string; + inspectHint: string; + linkedItems: string; +} + +type CanvasNode = NodeObject & ConnectionGraphNode; +type CanvasLink = LinkObject & + ConnectionGraphLink; +type GraphRef = ForceGraphMethods< + NodeObject, + LinkObject +>; +type LabelHitBox = { + node: CanvasNode; + left: number; + right: number; + top: number; + bottom: number; +}; +type AxisForce = ((alpha: number) => void) & { + initialize: (nodes: CanvasNode[]) => void; +}; + +const TYPE_COLORS: Record = { + [ItemTypes.NOTE]: "#3b82f6", + [ItemTypes.CHECKLIST]: "#10b981", +}; + +const getLinkId = ( + value: string | number | NodeObject | undefined, +) => { + if (typeof value === "string" || typeof value === "number") { + return String(value); + } + return value?.id ? String(value.id) : ""; +}; + +const MIN_POINTER_RADIUS = 24; +const COARSE_POINTER_RADIUS = 36; +const CLICK_MOVE_TOLERANCE = 10; +const LONG_RANGE_REPEL_FACTOR = 4; +const MIN_REPEL_DISTANCE_MAX = 260; +const CENTER_PULL_STRENGTH = 0.035; + +const getNodeRadius = (node: ConnectionGraphNode, scale: number) => + Math.max(4, Math.min(18, 4 + node.connectionCount * 1.1)) * scale; + +const createAxisForce = ( + axis: "x" | "y", + strength: number, +): AxisForce => { + let nodes: CanvasNode[] = []; + const velocity = axis === "x" ? "vx" : "vy"; + + const force = ((alpha: number) => { + nodes.forEach((node) => { + const position = node[axis] || 0; + node[velocity] = (node[velocity] || 0) + (0 - position) * strength * alpha; + }); + }) as AxisForce; + + force.initialize = (nextNodes: CanvasNode[]) => { + nodes = nextNodes; + }; + + return force; +}; + +const formatDate = (value: string) => { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date(value)); +}; + +export const ConnectionsGraph = ({ + graphData, + selectedNodeId, + onSelectedNodeChange, + showLabels, + showArrows, + nodeScale, + linkWidth, + linkDistance, + repelForce, + labels, +}: ConnectionsGraphProps) => { + const graphRef = useRef(undefined); + const containerRef = useRef(null); + const hasFitGraphRef = useRef(false); + const labelHitBoxesRef = useRef([]); + const pointerDownRef = useRef<{ x: number; y: number; pointerId: number } | null>( + null, + ); + const router = useRouter(); + const [dimensions, setDimensions] = useState({ width: 900, height: 620 }); + const [isCoarsePointer, setIsCoarsePointer] = useState(false); + + const selectedNode = useMemo( + () => graphData.nodes.find((node) => node.id === selectedNodeId) || null, + [graphData.nodes, selectedNodeId], + ); + const inspectedNode = selectedNode; + const nodeById = useMemo( + () => new Map(graphData.nodes.map((node) => [node.id, node])), + [graphData.nodes], + ); + const linkedNodes = useMemo(() => { + if (!inspectedNode) return []; + + const seen = new Set(); + return graphData.links + .flatMap((link) => { + if (link.source === inspectedNode.id) return [link.target]; + if (link.target === inspectedNode.id) return [link.source]; + return []; + }) + .filter((nodeId) => { + if (seen.has(nodeId)) return false; + seen.add(nodeId); + return true; + }) + .map((nodeId) => nodeById.get(nodeId)) + .filter((node): node is ConnectionGraphNode => Boolean(node)) + .sort((a, b) => a.title.localeCompare(b.title)); + }, [graphData.links, inspectedNode, nodeById]); + const canvasGraphData = useMemo( + () => ({ + nodes: graphData.nodes.map((node) => ({ ...node })), + links: graphData.links.map((link) => ({ ...link })), + }), + [graphData], + ); + + const neighborIds = useMemo(() => { + if (!selectedNodeId) return new Set(); + const ids = new Set([selectedNodeId]); + graphData.links.forEach((link) => { + if (link.source === selectedNodeId) ids.add(link.target); + if (link.target === selectedNodeId) ids.add(link.source); + }); + return ids; + }, [graphData.links, selectedNodeId]); + + useEffect(() => { + const coarsePointerQuery = window.matchMedia("(pointer: coarse)"); + const updatePointerMode = () => setIsCoarsePointer(coarsePointerQuery.matches); + + updatePointerMode(); + coarsePointerQuery.addEventListener("change", updatePointerMode); + return () => coarsePointerQuery.removeEventListener("change", updatePointerMode); + }, []); + + useEffect(() => { + const updateDimensions = () => { + if (!containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + setDimensions({ + width: Math.max(320, Math.round(rect.width)), + height: Math.max(420, Math.round(rect.height)), + }); + }; + + updateDimensions(); + const observer = new ResizeObserver(updateDimensions); + if (containerRef.current) observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + const chargeForce = graphRef.current?.d3Force("charge"); + chargeForce?.strength(-repelForce); + chargeForce?.distanceMax?.( + Math.max(MIN_REPEL_DISTANCE_MAX, linkDistance * LONG_RANGE_REPEL_FACTOR), + ); + graphRef.current?.d3Force("link")?.distance(linkDistance); + graphRef.current?.d3Force("x", createAxisForce("x", CENTER_PULL_STRENGTH)); + graphRef.current?.d3Force("y", createAxisForce("y", CENTER_PULL_STRENGTH)); + graphRef.current?.d3ReheatSimulation(); + hasFitGraphRef.current = false; + }, [linkDistance, repelForce]); + + useEffect(() => { + graphRef.current?.d3ReheatSimulation(); + hasFitGraphRef.current = false; + }, [graphData.nodes.length, graphData.links.length]); + + const focusNode = (node: CanvasNode) => { + onSelectedNodeChange(node.id); + if (node.x !== undefined && node.y !== undefined) { + graphRef.current?.centerAt(node.x, node.y, 650); + graphRef.current?.zoom(2.4, 650); + } + }; + + const openNode = (node: ConnectionGraphNode) => { + router.push(node.url); + }; + + const findNodeAtPointer = (clientX: number, clientY: number) => { + const graph = graphRef.current; + const container = containerRef.current; + if (!graph || !container) return null; + + const rect = container.getBoundingClientRect(); + const pointerX = clientX - rect.left; + const pointerY = clientY - rect.top; + const zoom = Math.max(0.001, graph.zoom()); + const minimumRadius = isCoarsePointer + ? COARSE_POINTER_RADIUS + : MIN_POINTER_RADIUS; + const graphPosition = graph.screen2GraphCoords(pointerX, pointerY); + + const labelHit = labelHitBoxesRef.current.find( + (box) => + graphPosition.x >= box.left && + graphPosition.x <= box.right && + graphPosition.y >= box.top && + graphPosition.y <= box.bottom, + ); + if (labelHit) return labelHit.node; + + let closestNode: CanvasNode | null = null; + let closestDistance = Number.POSITIVE_INFINITY; + + canvasGraphData.nodes.forEach((node) => { + const canvasNode = node as CanvasNode; + if (canvasNode.x === undefined || canvasNode.y === undefined) return; + + const screenPosition = graph.graph2ScreenCoords( + canvasNode.x, + canvasNode.y, + ); + const distance = Math.hypot( + screenPosition.x - pointerX, + screenPosition.y - pointerY, + ); + const hitRadius = Math.max( + minimumRadius, + getNodeRadius(canvasNode, nodeScale) * zoom, + ); + + if (distance <= hitRadius && distance < closestDistance) { + closestNode = canvasNode; + closestDistance = distance; + } + }); + + return closestNode; + }; + + const handlePointerDownCapture = (event: PointerEvent) => { + pointerDownRef.current = { + x: event.clientX, + y: event.clientY, + pointerId: event.pointerId, + }; + }; + + const handlePointerUpCapture = (event: PointerEvent) => { + const start = pointerDownRef.current; + pointerDownRef.current = null; + if (!start || start.pointerId !== event.pointerId) return; + + const moved = Math.hypot(event.clientX - start.x, event.clientY - start.y); + if (moved > CLICK_MOVE_TOLERANCE) return; + + const node = findNodeAtPointer(event.clientX, event.clientY); + if (!node) return; + + event.preventDefault(); + event.stopPropagation(); + + if (event.detail >= 2) { + openNode(node); + return; + } + + focusNode(node); + }; + + return ( +
+
+ + showArrows && (link as CanvasLink).type === "explicit" ? 5 : 0 + } + linkDirectionalArrowRelPos={1} + linkWidth={(link) => { + const canvasLink = link as CanvasLink; + const sourceId = getLinkId(canvasLink.source); + const targetId = getLinkId(canvasLink.target); + const highlighted = + !selectedNodeId || + sourceId === selectedNodeId || + targetId === selectedNodeId; + return highlighted + ? canvasLink.type === "tag" + ? Math.max(0.5, linkWidth * 0.7) + : linkWidth + : Math.max(0.5, linkWidth * 0.5); + }} + linkColor={(link) => { + const canvasLink = link as CanvasLink; + const sourceId = getLinkId(canvasLink.source); + const targetId = getLinkId(canvasLink.target); + if ( + !selectedNodeId || + sourceId === selectedNodeId || + targetId === selectedNodeId + ) { + return canvasLink.type === "tag" + ? "rgba(244,114,182,0.46)" + : "rgba(148,163,184,0.7)"; + } + return "rgba(148,163,184,0.18)"; + }} + nodeRelSize={nodeScale} + nodeVal={(node) => + Math.max(1.8, Math.min(9, 2 + (node as CanvasNode).weight)) + } + nodeCanvasObject={(node, ctx, globalScale) => { + const canvasNode = node as CanvasNode; + const baseColor = TYPE_COLORS[canvasNode.type] || TYPE_COLORS[ItemTypes.NOTE]; + const radius = getNodeRadius(canvasNode, nodeScale); + const isSelected = selectedNodeId === canvasNode.id; + const isNeighbor = neighborIds.has(canvasNode.id); + const dimmed = selectedNodeId && !isNeighbor; + + ctx.beginPath(); + ctx.arc(canvasNode.x || 0, canvasNode.y || 0, radius, 0, 2 * Math.PI); + ctx.fillStyle = dimmed ? "rgba(148,163,184,0.35)" : baseColor; + ctx.fill(); + + if (isSelected) { + ctx.lineWidth = 3 / globalScale; + ctx.strokeStyle = "#f8fafc"; + ctx.stroke(); + } + + }} + onRenderFramePost={(ctx, globalScale) => { + labelHitBoxesRef.current = []; + if (!showLabels && !selectedNodeId) return; + + const boxes: LabelHitBox[] = []; + const fontSize = Math.max(10, 13 / globalScale); + const paddingX = 4 / globalScale; + const paddingY = 3 / globalScale; + const gap = 6 / globalScale; + const sortedNodes = [...canvasGraphData.nodes].sort((a, b) => { + if (a.id === selectedNodeId) return -1; + if (b.id === selectedNodeId) return 1; + return b.connectionCount - a.connectionCount; + }); + + ctx.font = `600 ${fontSize}px Inter, system-ui, sans-serif`; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + + sortedNodes.forEach((node) => { + const canvasNode = node as CanvasNode; + if (canvasNode.x === undefined || canvasNode.y === undefined) return; + + const isPriority = canvasNode.id === selectedNodeId; + if (!showLabels && !isPriority) return; + + const radius = getNodeRadius(canvasNode, nodeScale); + const label = canvasNode.label.slice(0, 34); + const textWidth = ctx.measureText(label).width; + const x = canvasNode.x + radius + gap; + const y = canvasNode.y; + const box = { + left: x - paddingX, + right: x + textWidth + paddingX, + top: y - fontSize / 2 - paddingY, + bottom: y + fontSize / 2 + paddingY, + }; + const overlaps = boxes.some( + (existing) => + box.left < existing.right && + box.right > existing.left && + box.top < existing.bottom && + box.bottom > existing.top, + ); + + if (overlaps && !isPriority) return; + + const hitBox = { ...box, node: canvasNode }; + boxes.push(hitBox); + labelHitBoxesRef.current.push(hitBox); + ctx.fillStyle = "rgba(15,23,42,0.92)"; + ctx.fillText(label, x, y); + }); + }} + nodePointerAreaPaint={(node, color, ctx, globalScale) => { + const canvasNode = node as CanvasNode; + const radius = Math.max( + MIN_POINTER_RADIUS / globalScale, + getNodeRadius(canvasNode, nodeScale), + ); + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(canvasNode.x || 0, canvasNode.y || 0, radius, 0, 2 * Math.PI); + ctx.fill(); + }} + onNodeClick={(node, event) => { + if (event.detail >= 2) { + openNode(node as ConnectionGraphNode); + return; + } + focusNode(node as CanvasNode); + }} + onNodeRightClick={(node) => openNode(node as ConnectionGraphNode)} + onBackgroundClick={() => onSelectedNodeChange(null)} + onNodeDragEnd={(node) => { + node.fx = node.x; + node.fy = node.y; + graphRef.current?.pauseAnimation(); + }} + onEngineStop={() => { + if (!hasFitGraphRef.current) { + graphRef.current?.zoomToFit(450, 40); + hasFitGraphRef.current = true; + } + graphRef.current?.pauseAnimation(); + }} + cooldownTicks={70} + cooldownTime={2500} + d3AlphaDecay={0.08} + enableNodeDrag={!isCoarsePointer} + /> +
+ + +
+ ); +}; diff --git a/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/graph-data.ts b/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/graph-data.ts new file mode 100644 index 00000000..12d01dad --- /dev/null +++ b/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/graph-data.ts @@ -0,0 +1,342 @@ +import { Checklist, ItemType, LinkIndex, Note } from "@/app/_types"; +import { ItemTypes } from "@/app/_types/enums"; +import { buildCategoryPath } from "@/app/_utils/global-utils"; + +export const MAX_GRAPH_NODES = 600; +export const MAX_TAG_LINK_FANOUT = 8; + +export interface ConnectionGraphNode { + id: string; + label: string; + title: string; + type: ItemType; + category: string; + itemId: string; + path: string; + url: string; + tags: string[]; + createdAt?: string; + updatedAt?: string; + inboundCount: number; + outboundCount: number; + connectionCount: number; + weight: number; + orphan: boolean; +} + +export interface ConnectionGraphLink { + source: string; + target: string; + directed: boolean; + type: "explicit" | "tag"; + tag?: string; +} + +export interface ConnectionGraphData { + nodes: ConnectionGraphNode[]; + links: ConnectionGraphLink[]; + totalNodes: number; + totalLinks: number; + truncated: number; +} + +export interface ConnectionGraphFilters { + search: string; + showNotes: boolean; + showChecklists: boolean; + showOrphans: boolean; + showTagLinks: boolean; +} + +type ItemLike = Partial | Partial; + +const isUuidLike = (value: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value, + ); + +const itemTypePath = (type: ItemType) => + type === ItemTypes.CHECKLIST ? "checklist" : "note"; + +const fallbackTitle = (type: ItemType, id: string) => + `${type === ItemTypes.CHECKLIST ? "Checklist" : "Note"} ${id.slice(0, 8)}`; + +const getItemKey = (item: ItemLike) => + `${item.category || "Uncategorized"}/${item.id || ""}`; + +const normalizeTag = (tag: string) => tag.trim().replace(/^#/, "").toLowerCase(); + +const addItemToMaps = ( + item: ItemLike, + type: ItemType, + itemsByUuid: Map, + uuidByLegacyKey: Map, +) => { + if (!item.uuid) return; + itemsByUuid.set(item.uuid, { item, type }); + if (item.id) { + uuidByLegacyKey.set(getItemKey(item), item.uuid); + } +}; + +export const resolveGraphId = ( + rawId: string, + uuidByLegacyKey: Map, +) => { + if (isUuidLike(rawId)) return rawId; + return uuidByLegacyKey.get(rawId) || rawId; +}; + +export const buildArchivedIdSet = (archivedItems: ItemLike[]) => { + const archived = new Set(); + archivedItems.forEach((item) => { + if (item.uuid) archived.add(item.uuid); + if (item.id) archived.add(getItemKey(item)); + }); + return archived; +}; + +export const filterArchivedLinkIndex = ( + linkIndex: LinkIndex, + archivedItems: ItemLike[], +): LinkIndex => { + const archived = buildArchivedIdSet(archivedItems); + const shouldKeep = (id: string) => !archived.has(id); + const filterIds = (ids: string[]) => ids.filter(shouldKeep); + const filterGroup = (group: LinkIndex["notes"]) => + Object.fromEntries( + Object.entries(group) + .filter(([key]) => shouldKeep(key)) + .map(([key, links]) => [ + key, + { + isLinkedTo: { + notes: filterIds(links.isLinkedTo.notes), + checklists: filterIds(links.isLinkedTo.checklists), + }, + isReferencedIn: { + notes: filterIds(links.isReferencedIn.notes), + checklists: filterIds(links.isReferencedIn.checklists), + }, + }, + ]), + ); + + return { + notes: filterGroup(linkIndex.notes || {}), + checklists: filterGroup(linkIndex.checklists || {}), + }; +}; + +export const buildConnectionGraphData = ( + linkIndex: LinkIndex, + notes: Partial[], + checklists: Partial[], + includeOrphans = false, +): ConnectionGraphData => { + const itemsByUuid = new Map(); + const uuidByLegacyKey = new Map(); + + notes.forEach((note) => + addItemToMaps(note, ItemTypes.NOTE, itemsByUuid, uuidByLegacyKey), + ); + checklists.forEach((checklist) => + addItemToMaps( + checklist, + ItemTypes.CHECKLIST, + itemsByUuid, + uuidByLegacyKey, + ), + ); + + const outbound = new Map(); + const inbound = new Map(); + const tagConnections = new Map(); + const linkMap = new Map(); + + const addDirectedLinks = ( + sourceRaw: string, + targets: string[], + targetType: ItemType, + ) => { + const source = resolveGraphId(sourceRaw, uuidByLegacyKey); + if (!itemsByUuid.has(source)) return; + + targets.forEach((targetRaw) => { + const target = resolveGraphId(targetRaw, uuidByLegacyKey); + const targetItem = itemsByUuid.get(target); + if (!targetItem || targetItem.type !== targetType || source === target) { + return; + } + + outbound.set(source, (outbound.get(source) || 0) + 1); + inbound.set(target, (inbound.get(target) || 0) + 1); + + const linkKey = `explicit::${[source, target].sort().join("::")}`; + if (!linkMap.has(linkKey)) { + linkMap.set(linkKey, { source, target, directed: true, type: "explicit" }); + } + }); + }; + + Object.entries(linkIndex.notes || {}).forEach(([source, links]) => { + addDirectedLinks(source, links.isLinkedTo.notes, ItemTypes.NOTE); + addDirectedLinks(source, links.isLinkedTo.checklists, ItemTypes.CHECKLIST); + }); + + Object.entries(linkIndex.checklists || {}).forEach(([source, links]) => { + addDirectedLinks(source, links.isLinkedTo.notes, ItemTypes.NOTE); + addDirectedLinks(source, links.isLinkedTo.checklists, ItemTypes.CHECKLIST); + }); + + const nodesByTag = new Map(); + itemsByUuid.forEach(({ item }, uuid) => { + const uniqueTags = new Set((item.tags || []).map(normalizeTag).filter(Boolean)); + uniqueTags.forEach((tag) => { + if (!nodesByTag.has(tag)) nodesByTag.set(tag, []); + nodesByTag.get(tag)?.push(uuid); + }); + }); + + nodesByTag.forEach((taggedNodeIds, tag) => { + if (taggedNodeIds.length < 2) return; + const sortedIds = [...taggedNodeIds].sort(); + + sortedIds.forEach((source, sourceIndex) => { + const fanOutTargets = sortedIds.slice( + sourceIndex + 1, + sourceIndex + 1 + MAX_TAG_LINK_FANOUT, + ); + fanOutTargets.forEach((target) => { + const linkKey = `tag::${tag}::${source}::${target}`; + if (linkMap.has(linkKey)) return; + + linkMap.set(linkKey, { + source, + target, + directed: false, + type: "tag", + tag, + }); + tagConnections.set(source, (tagConnections.get(source) || 0) + 1); + tagConnections.set(target, (tagConnections.get(target) || 0) + 1); + }); + }); + }); + + const nodes = Array.from(itemsByUuid.entries()) + .map(([uuid, { item, type }]) => { + const inboundCount = inbound.get(uuid) || 0; + const outboundCount = outbound.get(uuid) || 0; + const tagConnectionCount = tagConnections.get(uuid) || 0; + const connectionCount = inboundCount + outboundCount + tagConnectionCount; + const category = item.category || "Uncategorized"; + const itemId = item.id || uuid; + const path = buildCategoryPath(category, itemId); + + return { + id: uuid, + label: item.title || fallbackTitle(type, uuid), + title: item.title || fallbackTitle(type, uuid), + type, + category, + itemId, + path, + url: `/${itemTypePath(type)}/${path}`, + tags: item.tags || [], + createdAt: item.createdAt, + updatedAt: item.updatedAt, + inboundCount, + outboundCount, + connectionCount, + weight: connectionCount, + orphan: connectionCount === 0, + }; + }) + .filter((node) => includeOrphans || !node.orphan) + .sort((a, b) => b.connectionCount - a.connectionCount); + + const visibleIds = new Set(nodes.map((node) => node.id)); + const links = Array.from(linkMap.values()).filter( + (link) => visibleIds.has(link.source) && visibleIds.has(link.target), + ); + + if (nodes.length > MAX_GRAPH_NODES) { + const keptNodes = nodes.slice(0, MAX_GRAPH_NODES); + const keptIds = new Set(keptNodes.map((node) => node.id)); + return { + nodes: keptNodes, + links: links.filter( + (link) => keptIds.has(link.source) && keptIds.has(link.target), + ), + totalNodes: nodes.length, + totalLinks: links.length, + truncated: nodes.length - MAX_GRAPH_NODES, + }; + } + + return { + nodes, + links, + totalNodes: nodes.length, + totalLinks: links.length, + truncated: 0, + }; +}; + +export const filterConnectionGraphData = ( + graphData: ConnectionGraphData, + filters: ConnectionGraphFilters, +): ConnectionGraphData => { + const query = filters.search.trim().toLowerCase(); + const sourceLinks = filters.showTagLinks + ? graphData.links + : graphData.links.filter((link) => link.type !== "tag"); + const explicitlyConnectedIds = new Set(); + sourceLinks.forEach((link) => { + explicitlyConnectedIds.add(link.source); + explicitlyConnectedIds.add(link.target); + }); + + const matchingIds = new Set( + graphData.nodes + .filter((node) => { + if (!filters.showNotes && node.type === ItemTypes.NOTE) return false; + if (!filters.showChecklists && node.type === ItemTypes.CHECKLIST) { + return false; + } + if (!filters.showOrphans && !explicitlyConnectedIds.has(node.id)) { + return false; + } + if (!query) return true; + + return [ + node.title, + node.label, + node.path, + node.category, + node.type, + ...node.tags, + ] + .join(" ") + .toLowerCase() + .includes(query); + }) + .map((node) => node.id), + ); + + const visibleIds = matchingIds; + + const nodes = graphData.nodes.filter((node) => visibleIds.has(node.id)); + const links = sourceLinks.filter( + (link) => visibleIds.has(link.source) && visibleIds.has(link.target), + ); + + return { + nodes, + links, + totalNodes: nodes.length, + totalLinks: links.length, + truncated: graphData.truncated, + }; +}; diff --git a/app/_components/FeatureComponents/Profile/Parts/LinksTab.tsx b/app/_components/FeatureComponents/Profile/Parts/LinksTab.tsx index 3ff3a75f..96b842c9 100644 --- a/app/_components/FeatureComponents/Profile/Parts/LinksTab.tsx +++ b/app/_components/FeatureComponents/Profile/Parts/LinksTab.tsx @@ -1,85 +1,26 @@ "use client"; import { useMemo, useState } from "react"; -import { LinkIndex } from "@/app/_types"; -import dynamic from "next/dynamic"; +import type React from "react"; import { File02Icon, Link04Icon, - SharedWifiIcon, RefreshIcon, + SharedWifiIcon, } from "hugeicons-react"; -import { Checklist, ItemType, Note } from "@/app/_types"; -import { ItemTypes } from "@/app/_types/enums"; +import { useTranslations } from "next-intl"; +import { LinkIndex } from "@/app/_types"; +import { Checklist, Note } from "@/app/_types"; import { getUsername } from "@/app/_server/actions/users"; import { rebuildLinkIndex } from "@/app/_server/actions/link"; import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; -import { useTranslations } from "next-intl"; import { useToast } from "@/app/_providers/ToastProvider"; - -const ResponsiveNetwork = dynamic( - () => import("@nivo/network").then((mod) => mod.ResponsiveNetwork), - { ssr: false }, -); - -const NOTES_COLOR = "#3b82f6"; -const CHECKLISTS_COLOR = "#10b981"; -const TEXT_COLOR = "rgb(var(--foreground))"; -const BORDER_COLOR = "rgb(var(--muted-foreground))"; -const MAX_GRAPH_NODES = 600; - -const getLabel = ( - node: any, - notes: Partial[], - checklists: Partial[], -) => { - const fullItem = - (notes.find((n) => n.uuid === node.data.id) as Note | undefined) || - (checklists.find((c) => c.uuid === node.data.id) as Checklist | undefined); - return `${fullItem?.id}.md`; -}; - -const CustomNode = ({ node, onHover, onLeave, notes, checklists }: any) => { - const label = getLabel(node, notes, checklists); - - const nodeColors: Record = { - note: NOTES_COLOR, - checklist: CHECKLISTS_COLOR, - }; - - const indicatorRadius = Math.max( - 3, - Math.min(12, 3 + (node.data.connectionCount || 0) * 0.8), - ); - const textOffset = indicatorRadius * 2 + 4; - - return ( - onHover && onHover(node, e)} - onMouseLeave={() => onLeave && onLeave()} - > - - - {label.length > 25 ? label.substring(0, 22) + "..." : label} - - - ); -}; +import { ConnectionsGraph } from "./ConnectionsGraph/ConnectionsGraph"; +import { + buildConnectionGraphData, + ConnectionGraphFilters, + filterConnectionGraphData, +} from "./ConnectionsGraph/graph-data"; interface LinksTabProps { linkIndex: LinkIndex; @@ -87,38 +28,36 @@ interface LinksTabProps { checklists: Partial[]; } -interface NetworkNode { - id: string; - label: string; - type: ItemType; - size: number; - color: string; - connectionCount: number; -} - -interface NetworkLink { - source: string; - target: string; - distance: number; -} - export const LinksTab = ({ linkIndex, notes, checklists }: LinksTabProps) => { const t = useTranslations(); const { showToast } = useToast(); - const [hoveredNode, setHoveredNode] = useState(null); - const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); const [rebuildingIndex, setRebuildingIndex] = useState(false); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [showLabels, setShowLabels] = useState(false); + const [showArrows, setShowArrows] = useState(true); + const [nodeScale, setNodeScale] = useState(1); + const [linkWidth, setLinkWidth] = useState(1.6); + const [linkDistance, setLinkDistance] = useState(90); + const [repelForce, setRepelForce] = useState(120); + const [filters, setFilters] = useState({ + search: "", + showNotes: true, + showChecklists: true, + showOrphans: false, + showTagLinks: true, + }); + + const graphData = useMemo( + () => buildConnectionGraphData(linkIndex, notes, checklists, true), + [linkIndex, notes, checklists], + ); - const handleNodeHover = (node: any, event?: any) => { - setHoveredNode(node); - if (event) { - setMousePosition({ x: event.clientX, y: event.clientY }); - } - }; + const visibleGraphData = useMemo( + () => filterConnectionGraphData(graphData, filters), + [filters, graphData], + ); - const handleNodeLeave = () => { - setHoveredNode(null); - }; + const connectedItems = graphData.nodes.filter((node) => !node.orphan).length; const handleRebuildIndex = async () => { setRebuildingIndex(true); @@ -143,226 +82,49 @@ export const LinksTab = ({ linkIndex, notes, checklists }: LinksTabProps) => { } }; - const networkData = useMemo(() => { - const nodes = new Map(); - const links: NetworkLink[] = []; - - Object.entries(linkIndex.notes).forEach(([uuid, itemLinks]) => { - const connectionCount = - itemLinks.isLinkedTo.notes.length + - itemLinks.isLinkedTo.checklists.length + - itemLinks.isReferencedIn.notes.length + - itemLinks.isReferencedIn.checklists.length; - if (connectionCount === 0) return; - if (!nodes.has(uuid)) { - const item = notes.find((n) => n.uuid === uuid); - const label = item?.title || `Note ${uuid.slice(0, 8)}`; - const size = Math.max(5, Math.min(25, 5 + connectionCount * 2)); - nodes.set(uuid, { - id: uuid, - label: label, - type: ItemTypes.NOTE, - size: size, - color: NOTES_COLOR, - connectionCount, - }); - } - }); - - Object.entries(linkIndex.checklists).forEach(([uuid, itemLinks]) => { - const connectionCount = - itemLinks.isLinkedTo.notes.length + - itemLinks.isLinkedTo.checklists.length + - itemLinks.isReferencedIn.notes.length + - itemLinks.isReferencedIn.checklists.length; - if (connectionCount === 0) return; - if (!nodes.has(uuid)) { - const item = checklists.find((c) => c.uuid === uuid); - const label = item?.title || `Checklist ${uuid.slice(0, 8)}`; - const size = Math.max(5, Math.min(25, 5 + connectionCount * 2)); - nodes.set(uuid, { - id: uuid, - label: label, - type: ItemTypes.CHECKLIST, - size: size, - color: CHECKLISTS_COLOR, - connectionCount, - }); - } - }); - - const linkSet = new Set(); - - Object.entries(linkIndex.notes).forEach(([sourcePath, itemLinks]) => { - itemLinks.isLinkedTo.notes.forEach((targetPath) => { - if (nodes.has(targetPath) && sourcePath !== targetPath) { - const linkKey = [sourcePath, targetPath].sort().join("->"); - if (!linkSet.has(linkKey)) { - linkSet.add(linkKey); - links.push({ - source: sourcePath, - target: targetPath, - distance: 80, - }); - } - } - }); - - itemLinks.isLinkedTo.checklists.forEach((targetPath) => { - if (nodes.has(targetPath) && sourcePath !== targetPath) { - const linkKey = [sourcePath, targetPath].sort().join("->"); - if (!linkSet.has(linkKey)) { - linkSet.add(linkKey); - links.push({ - source: sourcePath, - target: targetPath, - distance: 80, - }); - } - } - }); - }); - - Object.entries(linkIndex.checklists).forEach(([sourcePath, itemLinks]) => { - itemLinks.isLinkedTo.checklists.forEach((targetPath) => { - if (nodes.has(targetPath) && sourcePath !== targetPath) { - const linkKey = [sourcePath, targetPath].sort().join("->"); - if (!linkSet.has(linkKey)) { - linkSet.add(linkKey); - links.push({ - source: sourcePath, - target: targetPath, - distance: 80, - }); - } - } - }); - - itemLinks.isLinkedTo.notes.forEach((targetPath) => { - if (nodes.has(targetPath) && sourcePath !== targetPath) { - const linkKey = [sourcePath, targetPath].sort().join("->"); - if (!linkSet.has(linkKey)) { - linkSet.add(linkKey); - links.push({ - source: sourcePath, - target: targetPath, - distance: 80, - }); - } - } - }); - }); - - const nodeList = Array.from(nodes.values()); - if (nodeList.length > MAX_GRAPH_NODES) { - nodeList.sort((a, b) => b.connectionCount - a.connectionCount); - const keptIds = new Set( - nodeList.slice(0, MAX_GRAPH_NODES).map((n) => n.id), - ); - const keptNodes = nodeList.slice(0, MAX_GRAPH_NODES); - const keptLinks = links.filter( - (l) => keptIds.has(l.source) && keptIds.has(l.target), - ); - return { - nodes: keptNodes, - links: keptLinks, - truncated: nodeList.length, - }; - } - return { - nodes: nodeList, - links: links, - truncated: 0, - }; - }, [linkIndex, notes, checklists]); - - const totalNodes = networkData.nodes.length; - const totalLinks = networkData.links.length; - const truncatedTotal = - "truncated" in networkData && networkData.truncated > 0 - ? networkData.truncated - : 0; + const updateFilters = (next: Partial) => { + setFilters((current) => ({ ...current, ...next })); + }; - if (totalNodes === 0) { + if (graphData.totalNodes === 0) { return (
-
-

{t("profile.contentLinks")}

-

- {t("profile.visualizeRelationships")} -

-
- -
-
-
-
- -
-
-
- {totalNodes} -
-
- {t("checklists.totalItems")} -
-
-
- -
-
- -
-
-
- {totalLinks} -
-
- {t("profile.connectionsTab")} -
-
-
- -
-
- -
-
-
- { - networkData.nodes.filter((n) => n.connectionCount > 0) - .length - } -
-
- {t("profile.connectedItems")} -
-
-
-
-
- -
-
-
- -
+
+ +
+
+

{t("profile.noLinksFound")}

-

+

{t("profile.startCreatingInternalLinks")}{" "} - + /note/your-note {" "} {t("profile.orFormat")}{" "} - + /checklist/your-list {" "} {t("profile.inYourContent")}

+
@@ -371,160 +133,320 @@ export const LinksTab = ({ linkIndex, notes, checklists }: LinksTabProps) => { return (
+
+ + +
+
+
+

{t("profile.linkNetwork")}

+

+ {t("profile.visibleGraphSummary", { + items: visibleGraphData.nodes.length, + links: visibleGraphData.links.length, + })} +

+
+ +
+ + {graphData.truncated > 0 && ( +

+ {t("profile.graphTruncated", { + visible: visibleGraphData.nodes.length, + omitted: graphData.truncated, + })} +

+ )} + +
+ +
+ +
+ +
+
+
+ ); + + function Header() { + return (

{t("profile.contentLinks")}

{t("profile.visualizeRelationships")}

+ ); + } -
-
-
-
- -
-
-
- {totalNodes} -
-
- {t("checklists.totalItems")} -
-
-
- -
-
- -
-
-
- {totalLinks} -
-
- {t("profile.connectionsTab")} -
-
-
+ function Stats({ + totalNodes, + totalLinks, + connectedItems, + }: { + totalNodes: number; + totalLinks: number; + connectedItems: number; + }) { + return ( +
+
+ } value={totalNodes} label={t("checklists.totalItems")} /> + } value={totalLinks} label={t("profile.connectionsTab")} /> + } value={connectedItems} label={t("profile.connectedItems")} /> +
+
+ ); + } -
-
- -
-
-
- {networkData.nodes.filter((n) => n.connectionCount > 0).length} -
-
- {t("profile.connectedItems")} -
-
-
+ function Stat({ + icon, + value, + label, + }: { + icon: React.ReactNode; + value: number; + label: string; + }) { + return ( +
+
{icon}
+
+
{value}
+
{label}
+ ); + } +}; -
-
-
-

- {t("profile.linkNetwork")} -

-
-
-
-
- {t("notes.title")} -
-
-
- {t("checklists.title")} -
-
- -
-
+interface ControlsProps { + filters: ConnectionGraphFilters; + updateFilters: (next: Partial) => void; + showLabels: boolean; + setShowLabels: (value: boolean) => void; + showArrows: boolean; + setShowArrows: (value: boolean) => void; + nodeScale: number; + setNodeScale: (value: number) => void; + linkWidth: number; + setLinkWidth: (value: number) => void; + linkDistance: number; + setLinkDistance: (value: number) => void; + repelForce: number; + setRepelForce: (value: number) => void; +} - {truncatedTotal > 0 && ( -

- Only showing partial content due to performance reasons. -

- )} +const Controls = ({ + filters, + updateFilters, + showLabels, + setShowLabels, + showArrows, + setShowArrows, + nodeScale, + setNodeScale, + linkWidth, + setLinkWidth, + linkDistance, + setLinkDistance, + repelForce, + setRepelForce, +}: ControlsProps) => { + const t = useTranslations(); -
- e.distance} - centeringStrength={0.3} - repulsivity={10} - nodeSize={(n: any) => n.size} - activeNodeSize={(n: any) => n.size * 1.5} - nodeComponent={(props: any) => ( - - )} - linkThickness={2} - linkColor={BORDER_COLOR} - motionConfig={{ - mass: 1, - tension: 120, - friction: 14, - }} - /> -
+ return ( +
+
+ +
- {hoveredNode && ( -
-
- {hoveredNode.data.label} -
-
- {hoveredNode.data.type} • {hoveredNode.data.connectionCount}{" "} - {t("profile.connection", { - count: hoveredNode.data.connectionCount, - })} - {hoveredNode.data.connectionCount >= 5 - ? ` (${t("profile.highlyConnected")})` - : hoveredNode.data.connectionCount >= 2 - ? ` (${t("profile.moderatelyConnected")})` - : hoveredNode.data.connectionCount === 0 - ? ` (${t("profile.isolated")})` - : ""} -
-
- {hoveredNode.data.id} -
-
- )} -
+
+ updateFilters({ showNotes: checked })} + /> + updateFilters({ showChecklists: checked })} + /> + updateFilters({ showOrphans: checked })} + /> + + + updateFilters({ showTagLinks: checked })} + /> +
+ +
+ + + +
); }; + +const Toggle = ({ + label, + checked, + onChange, + disabled = false, +}: { + label: string; + checked: boolean; + onChange: (value: boolean) => void; + disabled?: boolean; +}) => ( + +); + +const Range = ({ + label, + value, + min, + max, + step, + onChange, +}: { + label: string; + value: number; + min: number; + max: number; + step: number; + onChange: (value: number) => void; +}) => ( + +); diff --git a/app/_components/FeatureComponents/Profile/Parts/UserPreferencesTab.tsx b/app/_components/FeatureComponents/Profile/Parts/UserPreferencesTab.tsx index d58076f8..70771574 100644 --- a/app/_components/FeatureComponents/Profile/Parts/UserPreferencesTab.tsx +++ b/app/_components/FeatureComponents/Profile/Parts/UserPreferencesTab.tsx @@ -28,6 +28,7 @@ import { QuickCreateNotes, HideConnectionIndicator, HideStatusOnCards, + HideMobileStatusDropdown, CodeBlockStyle, } from "@/app/_types"; import { Modes } from "@/app/_types/enums"; @@ -73,6 +74,7 @@ const getSettingsFromUser = (user: SanitisedUser | null): Partial quickCreateNotesCategory: user?.quickCreateNotesCategory || "", hideConnectionIndicator: user?.hideConnectionIndicator || "disable", hideStatusOnCards: user?.hideStatusOnCards || "disable", + hideMobileStatusDropdown: user?.hideMobileStatusDropdown || "disable", codeBlockStyle: user?.codeBlockStyle || "default", }); @@ -166,7 +168,10 @@ export const UserPreferencesTab = ({ noteCategories, localeOptions }: SettingsTa "defaultChecklistFilter", "checklistItemClickAction", ]); - const hasKanbanChanges = hasChanges(["hideStatusOnCards"]); + const hasKanbanChanges = hasChanges([ + "hideStatusOnCards", + "hideMobileStatusDropdown", + ]); const validateAndSave = async >( settings: T, @@ -947,7 +952,7 @@ export const UserPreferencesTab = ({ noteCategories, localeOptions }: SettingsTa
+ +
+ + + handleSettingChange( + "hideMobileStatusDropdown", + value as HideMobileStatusDropdown + ) + } + options={[ + { id: "disable", name: t('settings.showMobileStatusDropdown') }, + { id: "enable", name: t('settings.hideMobileStatusDropdown') }, + ]} + placeholder={t('settings.selectMobileStatusDropdown')} + className="w-full" + /> +

+ {t('settings.hideMobileStatusDropdownDescription')} +

+
diff --git a/app/_components/GlobalComponents/Auth/LoginForm.tsx b/app/_components/GlobalComponents/Auth/LoginForm.tsx index 392d60a8..99ddbd65 100644 --- a/app/_components/GlobalComponents/Auth/LoginForm.tsx +++ b/app/_components/GlobalComponents/Auth/LoginForm.tsx @@ -9,6 +9,7 @@ import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; import { Orbit01Icon } from "hugeicons-react"; import { Logo } from "@/app/_components/GlobalComponents/Layout/Logo/Logo"; import { useTranslations } from "next-intl"; +import { Toggle } from "@/app/_components/GlobalComponents/FormElements/Toggle"; export default function LoginForm({ ssoEnabled, @@ -17,14 +18,17 @@ export default function LoginForm({ ssoEnabled: boolean; showRegisterLink?: boolean; }) { - const t = useTranslations('auth'); + const t = useTranslations("auth"); const searchParams = useSearchParams(); const [error, setError] = useState(""); const [isLoading, setIsLoading] = useState(false); const [isSsoLoading, setIsSsoLoading] = useState(false); - const [attemptsRemaining, setAttemptsRemaining] = useState(null); + const [attemptsRemaining, setAttemptsRemaining] = useState( + null, + ); const [lockedUntil, setLockedUntil] = useState(null); const [countdown, setCountdown] = useState(0); + const [rememberMe, setRememberMe] = useState(true); const { isDemoMode, appVersion, isRwMarkable } = useAppMode(); useEffect(() => { @@ -87,7 +91,7 @@ export default function LoginForm({ throw error; } } - setError(t('errorOccurred')); + setError(t("errorOccurred")); setIsLoading(false); } } @@ -96,7 +100,7 @@ export default function LoginForm({

- {t('welcomeBack')} + {t("welcomeBack")}

@@ -112,10 +116,10 @@ export default function LoginForm({ > {isSsoLoading ? ( <> - {t('signingIn')} + {t("signingIn")} ) : ( - t('signInWithSSO') + t("signInWithSSO") )}
@@ -123,9 +127,7 @@ export default function LoginForm({
- - {t('orContinueWith')} - + {t("orContinueWith")}
@@ -133,8 +135,8 @@ export default function LoginForm({ {isDemoMode && (
- {t('usernameLabel')}: demo
- {t('passwordLabel')}: demodemo + {t("usernameLabel")}: demo
+ {t("passwordLabel")}: demodemo
)} @@ -148,29 +150,32 @@ export default function LoginForm({ {countdown > 0 && (
- {t('accountLocked', { seconds: countdown })} + {t("accountLocked", { seconds: countdown })}
)} - {!countdown && attemptsRemaining !== null && attemptsRemaining > 0 && attemptsRemaining < 4 && ( -
- - {t('attemptsRemaining', { count: attemptsRemaining })} - -
- )} + {!countdown && + attemptsRemaining !== null && + attemptsRemaining > 0 && + attemptsRemaining < 4 && ( +
+ + {t("attemptsRemaining", { count: attemptsRemaining })} + +
+ )}
0} className="mt-1" - placeholder={t('enterUsername')} + placeholder={t("enterUsername")} defaultValue="" autoComplete="username" /> @@ -179,18 +184,35 @@ export default function LoginForm({
0} className="mt-1" - placeholder={t('enterPassword')} + placeholder={t("enterPassword")} autoComplete="current-password" defaultValue="" />
+
+ 0} + /> + + setRememberMe(!rememberMe)} + className="text-sm lg:text-xs text-muted-foreground cursor-pointer select-none" + > + {t("rememberMe")} + +
+ {showRegisterLink && ( )} {appVersion && ( )}
diff --git a/app/_components/GlobalComponents/Dropdowns/Dropdown.tsx b/app/_components/GlobalComponents/Dropdowns/Dropdown.tsx index b215853a..e64dd18d 100644 --- a/app/_components/GlobalComponents/Dropdowns/Dropdown.tsx +++ b/app/_components/GlobalComponents/Dropdowns/Dropdown.tsx @@ -10,6 +10,7 @@ interface DropdownOption { name: React.ReactNode; icon?: React.ComponentType<{ className?: string }>; colors?: { background: string; primary: string } | null; + className?: string; } interface DropdownProps { @@ -135,7 +136,8 @@ export const Dropdown = ({ onClick={(e) => handleSelect(e, option.id.toString())} className={cn( "w-full flex items-center gap-2 px-3 py-2 text-md lg:text-sm hover:bg-accent hover:text-accent-foreground", - option.id === value && "bg-accent text-accent-foreground" + option.id === value && "bg-accent text-accent-foreground", + option.className )} > {option.icon && } diff --git a/app/_components/GlobalComponents/Modals/Modal.tsx b/app/_components/GlobalComponents/Modals/Modal.tsx index d627a3ba..b3595ef9 100644 --- a/app/_components/GlobalComponents/Modals/Modal.tsx +++ b/app/_components/GlobalComponents/Modals/Modal.tsx @@ -1,7 +1,11 @@ "use client"; import { useRef, useEffect, useState } from "react"; -import { MultiplicationSignIcon } from "hugeicons-react"; +import { + MaximizeScreenIcon, + MinimizeScreenIcon, + MultiplicationSignIcon, +} from "hugeicons-react"; import { Button } from "../Buttons/Button"; import { createPortal } from "react-dom"; import { useTranslations } from "next-intl"; @@ -13,6 +17,8 @@ interface ModalProps { children: React.ReactNode; className?: string; size?: "default" | "fullscreen"; + allowEnlarge?: boolean; + defaultEnlarged?: boolean; } export const Modal = ({ @@ -22,10 +28,13 @@ export const Modal = ({ children, className = "", size = "default", + allowEnlarge = false, + defaultEnlarged = false, }: ModalProps) => { const t = useTranslations(); const modalRef = useRef(null); const [portalElement, setPortalElement] = useState(null); + const [isEnlarged, setIsEnlarged] = useState(defaultEnlarged); useEffect(() => { let portalRoot = document.getElementById("modal-portal-root"); @@ -68,10 +77,21 @@ export const Modal = ({ }; }, [isOpen, onClose]); + useEffect(() => { + if (isOpen) { + setIsEnlarged(defaultEnlarged); + } + }, [isOpen, defaultEnlarged]); + if (!isOpen || !portalElement) { return null; } + const isFullscreenLayout = size === "fullscreen" || isEnlarged; + + const enlargedDesktopClasses = + "lg:!w-[calc(100vw-2.5em)] lg:!h-[calc(100dvh-2.5em)] lg:!max-w-[calc(100vw-2.5em)] lg:!max-h-[calc(100dvh-2.5em)]"; + const modalContent = (
e.stopPropagation()} className={` jotty-modal-content - bg-background border border-border w-full shadow-xl + bg-background border border-border shadow-xl translate-y-0 lg:translate-y-0 transition-all duration-200 + w-full ${size === "fullscreen" - ? "lg:w-[95vw] h-[90vh] flex flex-col lg:rounded-jotty rounded-t-xl overflow-hidden" + ? "h-[90vh] flex flex-col lg:rounded-jotty rounded-t-xl overflow-hidden lg:w-[95vw]" : "lg:max-w-md lg:rounded-md rounded-t-xl p-6"} ${className} + ${isEnlarged ? enlargedDesktopClasses : ""} `} > -
+
-
+
{title}
- +
+ {allowEnlarge && ( + + )} + +
-
+
{children}
diff --git a/app/_components/GlobalComponents/Sidebar/SidebarWrapper.tsx b/app/_components/GlobalComponents/Sidebar/SidebarWrapper.tsx index 7b9c7aa6..2bf1d4d2 100644 --- a/app/_components/GlobalComponents/Sidebar/SidebarWrapper.tsx +++ b/app/_components/GlobalComponents/Sidebar/SidebarWrapper.tsx @@ -115,7 +115,7 @@ export const SidebarWrapper = ({
diff --git a/app/_consts/dnd.ts b/app/_consts/dnd.ts new file mode 100644 index 00000000..efca5835 --- /dev/null +++ b/app/_consts/dnd.ts @@ -0,0 +1,8 @@ +export const DRAG_THRESHOLD_PX = 8; +export const LONG_PRESS_MS = 250; +export const TOUCH_SLOP_PX = 10; +export const EDGE_SCROLL_PX = 48; +export const MAX_SCROLL_STEP = 14; +export const CARD_GAP_PX = 8; +export const INTERACTIVE_SELECTOR = + "button, input, textarea, select, a, [data-no-dnd]"; diff --git a/app/_hooks/dnd/DndProvider.tsx b/app/_hooks/dnd/DndProvider.tsx new file mode 100644 index 00000000..508e40c6 --- /dev/null +++ b/app/_hooks/dnd/DndProvider.tsx @@ -0,0 +1,382 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { DragRect, DropResult } from "@/app/_types/dnd"; +import { + DRAG_THRESHOLD_PX, + LONG_PRESS_MS, + TOUCH_SLOP_PX, + INTERACTIVE_SELECTOR, +} from "@/app/_consts/dnd"; +import { findDropList, projectIndex } from "@/app/_utils/dnd/dnd-math"; +import { createRegistry, RectRegistry } from "@/app/_utils/dnd/rect-registry"; +import { createScroller } from "@/app/_utils/dnd/auto-scroll"; +import { useDragStore } from "@/app/_utils/dnd/drag-store"; + +interface BeginArgs { + id: string; + listId: string; + index: number; +} + +interface DndContextValue { + registry: RectRegistry; + begin: (event: React.PointerEvent, args: BeginArgs) => void; +} + +interface DndProviderProps { + onDrop: (result: DropResult) => void; + onDragStart?: (itemId: string) => void; + onDragCancel?: (itemId: string) => void; + renderGhost: (itemId: string, rect: DragRect) => ReactNode; + children: ReactNode; +} + +interface DragSession { + pointerId: number; + isTouch: boolean; + itemId: string; + listId: string; + index: number; + sourceEl: HTMLElement; + startX: number; + startY: number; + lastX: number; + lastY: number; + grabX: number; + grabY: number; + longPressTimer: number | null; + dragging: boolean; +} + +const DndContext = createContext(null); + +export const useDndContext = (): DndContextValue => { + const ctx = useContext(DndContext); + if (!ctx) throw new Error("dnd hooks must be used inside a DndProvider"); + return ctx; +}; + +export const DndProvider = ({ + onDrop, + onDragStart, + onDragCancel, + renderGhost, + children, +}: DndProviderProps) => { + const registryRef = useRef(null); + if (!registryRef.current) registryRef.current = createRegistry(); + const registry = registryRef.current; + + const scrollerRef = useRef(createScroller()); + const sessionRef = useRef(null); + const ghostNodeRef = useRef(null); + const frameRef = useRef(null); + const scrollPosRef = useRef(new Map()); + const callbacksRef = useRef({ onDrop, onDragStart, onDragCancel }); + callbacksRef.current = { onDrop, onDragStart, onDragCancel }; + + const [ghost, setGhost] = useState<{ id: string; rect: DragRect } | null>( + null, + ); + + const _moveGhost = useCallback((x: number, y: number) => { + const session = sessionRef.current; + const node = ghostNodeRef.current; + if (!session || !node) return; + node.style.transform = `translate3d(${x - session.grabX}px, ${ + y - session.grabY + }px, 0)`; + }, []); + + const _project = useCallback(() => { + const session = sessionRef.current; + if (!session || !session.dragging) return; + const point = { x: session.lastX, y: session.lastY }; + const listId = findDropList(point, registry.listRects()); + if (!listId) { + useDragStore.getState().aim(null, null); + return; + } + const index = projectIndex(point.y, registry.itemsOf(listId), session.itemId); + useDragStore.getState().aim(listId, index); + }, [registry]); + + const _schedule = useCallback(() => { + if (frameRef.current !== null) return; + frameRef.current = requestAnimationFrame(() => { + frameRef.current = null; + const session = sessionRef.current; + if (!session || !session.dragging) return; + _moveGhost(session.lastX, session.lastY); + _project(); + scrollerRef.current.update({ x: session.lastX, y: session.lastY }); + }); + }, [_moveGhost, _project]); + + const _blockTouch = useCallback((event: TouchEvent) => { + event.preventDefault(); + }, []); + + const _blockMenu = useCallback((event: Event) => { + event.preventDefault(); + }, []); + + const _onScroll = useCallback( + (event: Event) => { + const session = sessionRef.current; + if (!session || !session.dragging) return; + const target = + event.target === document + ? (document.scrollingElement as HTMLElement | null) + : (event.target as HTMLElement); + if (!target) return; + const prev = scrollPosRef.current.get(target); + if (!prev) return; + const next = { x: target.scrollLeft, y: target.scrollTop }; + scrollPosRef.current.set(target, next); + registry.shiftBy(next.x - prev.x, next.y - prev.y, target); + _schedule(); + }, + [registry, _schedule], + ); + + const _teardown = useCallback(() => { + const session = sessionRef.current; + if (session?.longPressTimer !== null && session?.longPressTimer !== undefined) { + window.clearTimeout(session.longPressTimer); + } + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + scrollerRef.current.stop(); + scrollPosRef.current.clear(); + document.removeEventListener("touchmove", _blockTouch); + document.removeEventListener("contextmenu", _blockMenu); + window.removeEventListener("scroll", _onScroll, true); + document.body.style.userSelect = ""; + document.body.style.webkitUserSelect = ""; + sessionRef.current = null; + setGhost(null); + useDragStore.getState().settle(); + }, [_blockTouch, _blockMenu, _onScroll]); + + const _finish = useCallback( + (cancelled: boolean) => { + const session = sessionRef.current; + if (!session) return; + + try { + if (session.dragging) { + const { activeId, sourceListId, sourceIndex, targetListId, targetIndex } = + useDragStore.getState(); + const moved = + targetListId !== null && + targetIndex !== null && + !(targetListId === sourceListId && targetIndex === sourceIndex); + + if (!cancelled && moved && activeId && sourceListId) { + callbacksRef.current.onDrop({ + itemId: activeId, + sourceListId, + sourceIndex, + targetListId, + targetIndex, + }); + } else { + callbacksRef.current.onDragCancel?.(session.itemId); + } + } + } finally { + _teardown(); + } + }, + [_teardown], + ); + + const _lift = useCallback(() => { + const session = sessionRef.current; + if (!session || session.dragging) return; + session.dragging = true; + session.longPressTimer = null; + + try { + session.sourceEl.setPointerCapture(session.pointerId); + } catch (error) { + console.warn("pointer capture failed, continuing drag:", error); + } + + registry.snapshot(); + const sourceRect = registry + .itemsOf(session.listId) + .find((entry) => entry.id === session.itemId)?.rect; + const rect: DragRect = sourceRect || { + top: session.startY, + left: session.startX, + width: 0, + height: 0, + }; + session.grabX = session.startX - rect.left; + session.grabY = session.startY - rect.top; + + const scrollers = registry.scrollables(); + scrollPosRef.current.clear(); + scrollers.forEach((el) => + scrollPosRef.current.set(el, { x: el.scrollLeft, y: el.scrollTop }), + ); + scrollerRef.current.start(scrollers); + + document.addEventListener("touchmove", _blockTouch, { passive: false }); + document.addEventListener("contextmenu", _blockMenu); + window.addEventListener("scroll", _onScroll, true); + document.body.style.userSelect = "none"; + document.body.style.webkitUserSelect = "none"; + + useDragStore + .getState() + .lift(session.itemId, session.listId, session.index, { + width: rect.width, + height: rect.height, + }); + setGhost({ id: session.itemId, rect }); + callbacksRef.current.onDragStart?.(session.itemId); + _schedule(); + }, [registry, _blockTouch, _blockMenu, _onScroll, _schedule]); + + useEffect(() => { + const onMove = (event: PointerEvent) => { + const session = sessionRef.current; + if (!session || event.pointerId !== session.pointerId) return; + session.lastX = event.clientX; + session.lastY = event.clientY; + + if (session.dragging) { + _schedule(); + return; + } + + const distance = Math.hypot( + event.clientX - session.startX, + event.clientY - session.startY, + ); + if (session.isTouch) { + if (distance > TOUCH_SLOP_PX) _teardown(); + return; + } + if (distance > DRAG_THRESHOLD_PX) _lift(); + }; + + const onUp = (event: PointerEvent) => { + const session = sessionRef.current; + if (!session || event.pointerId !== session.pointerId) return; + _finish(false); + }; + + const onCancel = (event: PointerEvent) => { + const session = sessionRef.current; + if (!session || event.pointerId !== session.pointerId) return; + _finish(true); + }; + + const onKey = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + const session = sessionRef.current; + if (!session || !session.dragging) return; + event.preventDefault(); + event.stopPropagation(); + _finish(true); + }; + + const onBlur = () => { + if (sessionRef.current) _finish(true); + }; + + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onCancel); + window.addEventListener("keydown", onKey, true); + window.addEventListener("blur", onBlur); + return () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onCancel); + window.removeEventListener("keydown", onKey, true); + window.removeEventListener("blur", onBlur); + _teardown(); + }; + }, [_schedule, _lift, _finish, _teardown]); + + const begin = useCallback( + (event: React.PointerEvent, args: BeginArgs) => { + if (sessionRef.current) return; + if (event.button !== 0) return; + const target = event.target as HTMLElement; + if (target.closest(INTERACTIVE_SELECTOR)) return; + + const isTouch = event.pointerType === "touch"; + const session: DragSession = { + pointerId: event.pointerId, + isTouch, + itemId: args.id, + listId: args.listId, + index: args.index, + sourceEl: event.currentTarget as HTMLElement, + startX: event.clientX, + startY: event.clientY, + lastX: event.clientX, + lastY: event.clientY, + grabX: 0, + grabY: 0, + longPressTimer: null, + dragging: false, + }; + sessionRef.current = session; + + if (isTouch) { + session.longPressTimer = window.setTimeout(_lift, LONG_PRESS_MS); + } + }, + [_lift], + ); + + const contextValue = useMemo( + () => ({ registry, begin }), + [registry, begin], + ); + + return ( + + {children} + {ghost && + createPortal( +
+ {renderGhost(ghost.id, ghost.rect)} +
, + document.body, + )} +
+ ); +}; diff --git a/app/_hooks/dnd/index.tsx b/app/_hooks/dnd/index.tsx new file mode 100644 index 00000000..ff158314 --- /dev/null +++ b/app/_hooks/dnd/index.tsx @@ -0,0 +1,3 @@ +export { DndProvider, useDndContext } from "./DndProvider"; +export { useDragItem } from "./useDragItem"; +export { useDropList } from "./useDropList"; diff --git a/app/_hooks/dnd/useDragItem.ts b/app/_hooks/dnd/useDragItem.ts new file mode 100644 index 00000000..56e3a987 --- /dev/null +++ b/app/_hooks/dnd/useDragItem.ts @@ -0,0 +1,102 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, CSSProperties } from "react"; +import { DragPhase } from "@/app/_types/dnd"; +import { CARD_GAP_PX } from "@/app/_consts/dnd"; +import { displaceFor } from "@/app/_utils/dnd/dnd-math"; +import { useDragStore } from "@/app/_utils/dnd/drag-store"; +import { useDndContext } from "./DndProvider"; + +interface UseDragItemArgs { + id: string; + listId: string; + index: number; + disabled?: boolean; + ghost?: boolean; +} + +const BASE_STYLE: CSSProperties = { touchAction: "manipulation" }; + +export const useDragItem = ({ + id, + listId, + index, + disabled, + ghost, +}: UseDragItemArgs) => { + const { registry, begin } = useDndContext(); + const elRef = useRef(null); + + const setNodeRef = useCallback((el: HTMLElement | null) => { + elRef.current = el; + }, []); + + useEffect(() => { + const el = elRef.current; + if (!el || ghost) return; + registry.registerItem(id, listId, index, el); + return () => registry.unregisterItem(id); + }, [registry, id, listId, index, ghost]); + + const isLifted = useDragStore( + (s) => !ghost && s.phase === DragPhase.DRAGGING && s.activeId === id, + ); + const isAway = useDragStore( + (s) => + !ghost && + s.phase === DragPhase.DRAGGING && + s.activeId === id && + s.targetListId !== listId, + ); + const dragHeight = useDragStore((s) => + !ghost && s.phase === DragPhase.DRAGGING && s.activeId === id + ? s.dragSize.height + : 0, + ); + const shiftY = useDragStore((s) => { + if (ghost || s.phase !== DragPhase.DRAGGING || s.activeId === id) return 0; + if (s.targetListId !== listId) return 0; + const sameList = s.sourceListId === listId; + const effIndex = sameList && index > s.sourceIndex ? index - 1 : index; + return displaceFor( + effIndex, + sameList ? s.sourceIndex : null, + s.targetIndex, + s.dragSize.height, + ); + }); + + const style = useMemo(() => { + if (isLifted && isAway) { + return { + ...BASE_STYLE, + height: 0, + marginTop: -CARD_GAP_PX, + paddingTop: 0, + paddingBottom: 0, + borderTopWidth: 0, + borderBottomWidth: 0, + overflow: "hidden", + }; + } + if (isLifted) { + return { ...BASE_STYLE, height: dragHeight || undefined }; + } + if (shiftY !== 0) { + return { ...BASE_STYLE, transform: `translateY(${shiftY}px)` }; + } + return BASE_STYLE; + }, [isLifted, isAway, dragHeight, shiftY]); + + const handleProps = useMemo( + () => ({ + onPointerDown: (event: React.PointerEvent) => { + if (disabled || ghost) return; + begin(event, { id, listId, index }); + }, + }), + [begin, disabled, ghost, id, listId, index], + ); + + return { setNodeRef, handleProps, isLifted, isAway, style }; +}; diff --git a/app/_hooks/dnd/useDropList.ts b/app/_hooks/dnd/useDropList.ts new file mode 100644 index 00000000..a688e122 --- /dev/null +++ b/app/_hooks/dnd/useDropList.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { DragPhase } from "@/app/_types/dnd"; +import { CARD_GAP_PX } from "@/app/_consts/dnd"; +import { useDragStore } from "@/app/_utils/dnd/drag-store"; +import { useDndContext } from "./DndProvider"; + +export const useDropList = ({ id }: { id: string }) => { + const { registry } = useDndContext(); + const elRef = useRef(null); + + const setNodeRef = useCallback((el: HTMLElement | null) => { + elRef.current = el; + }, []); + + useEffect(() => { + const el = elRef.current; + if (!el) return; + registry.registerList(id, el); + return () => registry.unregisterList(id); + }, [registry, id]); + + const isOver = useDragStore( + (s) => s.phase === DragPhase.DRAGGING && s.targetListId === id, + ); + const padBottom = useDragStore((s) => + s.phase === DragPhase.DRAGGING && + s.targetListId === id && + s.sourceListId !== id + ? s.dragSize.height + CARD_GAP_PX + : 0, + ); + + return { setNodeRef, isOver, padBottom }; +}; diff --git a/app/_hooks/kanban/useCalendarView.ts b/app/_hooks/kanban/useCalendarView.ts index 79d04195..ac3c17d3 100644 --- a/app/_hooks/kanban/useCalendarView.ts +++ b/app/_hooks/kanban/useCalendarView.ts @@ -1,13 +1,12 @@ "use client"; import { useState, useMemo, useCallback } from "react"; -import { Item, Checklist } from "@/app/_types"; +import { Checklist } from "@/app/_types"; import { parseItemsForCalendar, generateICS, getCalendarGrid, getItemsGroupedByDate, - CalendarEvent, } from "@/app/_utils/kanban/calendar-utils"; type CalendarViewMode = "month" | "week" | "day"; @@ -18,22 +17,22 @@ export const useCalendarView = (checklist: Checklist) => { const events = useMemo( () => parseItemsForCalendar(checklist.items), - [checklist.items] + [checklist.items], ); const itemsByDate = useMemo( () => getItemsGroupedByDate(checklist.items), - [checklist.items] + [checklist.items], ); const calendarGrid = useMemo( () => getCalendarGrid(currentDate.getFullYear(), currentDate.getMonth()), - [currentDate] + [currentDate], ); const unscheduledItems = useMemo( () => checklist.items.filter((item) => !item.targetDate && !item.isArchived), - [checklist.items] + [checklist.items], ); const _navigateMonth = useCallback((direction: number) => { @@ -61,19 +60,6 @@ export const useCalendarView = (checklist: Checklist) => { URL.revokeObjectURL(url); }, [checklist]); - const getEventsForDate = useCallback( - (date: Date): CalendarEvent[] => { - const pad = (n: number) => String(n).padStart(2, "0"); - const localDateStr = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; - return events.filter((e) => { - const eventDate = new Date(e.date); - const eventLocalStr = `${eventDate.getFullYear()}-${pad(eventDate.getMonth() + 1)}-${pad(eventDate.getDate())}`; - return eventLocalStr === localDateStr; - }); - }, - [events] - ); - return { currentDate, viewMode, @@ -86,6 +72,5 @@ export const useCalendarView = (checklist: Checklist) => { goToNextMonth, goToToday, exportICS, - getEventsForDate, }; }; diff --git a/app/_hooks/kanban/useKanban.ts b/app/_hooks/kanban/useKanban.ts index d30a1c98..9fbc1589 100644 --- a/app/_hooks/kanban/useKanban.ts +++ b/app/_hooks/kanban/useKanban.ts @@ -1,21 +1,20 @@ "use client"; import { useState, useEffect, useCallback, useRef } from "react"; -import { DragEndEvent, DragOverEvent, DragStartEvent } from "@dnd-kit/core"; import { Checklist, RecurrenceRule } from "@/app/_types"; +import { DragPhase } from "@/app/_types/dnd"; import { createItem, updateItemStatus, createBulkItems, - reorderItems, } from "@/app/_server/actions/checklist-item"; import { getListById } from "@/app/_server/actions/checklist"; -import { TaskStatus } from "@/app/_types/enums"; import { getCurrentUser, getUserByChecklist, } from "@/app/_server/actions/users"; -import { DEFAULT_KANBAN_STATUSES } from "@/app/_consts/kanban"; +import { getColumnItems } from "@/app/_utils/kanban/board-utils"; +import { useDragStore } from "@/app/_utils/dnd/drag-store"; interface UseKanbanBoardProps { checklist: Checklist; @@ -26,34 +25,50 @@ export const useKanbanBoard = ({ checklist, onUpdate, }: UseKanbanBoardProps) => { - const [activeId, setActiveId] = useState(null); - const [overId, setOverId] = useState(null); const [localChecklist, setLocalChecklist] = useState(checklist); const [isLoading, setIsLoading] = useState(false); const [showBulkPasteModal, setShowBulkPasteModal] = useState(false); const [focusKey, setFocusKey] = useState(0); - const dragOriginalStatusRef = useRef(null); - - const validStatusIds = ( - localChecklist.statuses || DEFAULT_KANBAN_STATUSES - ).map((s) => s.id); + const dragPhase = useDragStore((s) => s.phase); + const pendingRef = useRef(null); useEffect(() => { - if ( - checklist.id !== localChecklist.id || - checklist.updatedAt !== localChecklist.updatedAt - ) { + const isDifferentList = checklist.id !== localChecklist.id; + const isNewer = checklist.updatedAt > localChecklist.updatedAt; + + if (isDifferentList || isNewer) { + if (dragPhase !== DragPhase.IDLE) { + pendingRef.current = checklist; + return; + } setLocalChecklist(checklist); setFocusKey((prev) => prev + 1); } }, [ + checklist, checklist.id, checklist.updatedAt, localChecklist.id, localChecklist.updatedAt, + dragPhase, ]); + useEffect(() => { + if (dragPhase !== DragPhase.IDLE) return; + const pending = pendingRef.current; + pendingRef.current = null; + if (!pending) return; + if ( + pending.id === localChecklist.id && + pending.updatedAt <= localChecklist.updatedAt + ) { + return; + } + setLocalChecklist(pending); + setFocusKey((prev) => prev + 1); + }, [dragPhase, localChecklist.id, localChecklist.updatedAt]); + const refreshChecklist = useCallback(async () => { const checklistOwner = await getUserByChecklist( localChecklist.id, @@ -71,212 +86,9 @@ export const useKanbanBoard = ({ }, [localChecklist.id, localChecklist.category, onUpdate]); const getItemsByStatus = useCallback( - (status: string) => { - const firstStatus = - (localChecklist.statuses || DEFAULT_KANBAN_STATUSES).sort( - (a, b) => a.order - b.order, - )[0]?.id || TaskStatus.TODO; - return localChecklist.items.filter((item) => { - if (item.isArchived) return false; - if (item.status === status) return true; - if (status === firstStatus) { - const hasValidStatus = validStatusIds.includes(item.status || ""); - return !hasValidStatus; - } - return false; - }); - }, - [localChecklist.items, localChecklist.statuses, validStatusIds], - ); - - const handleDragStart = useCallback( - (event: DragStartEvent) => { - const id = event.active.id as string; - setActiveId(id); - - const item = localChecklist.items.find((i) => i.id === id); - dragOriginalStatusRef.current = item?.status || TaskStatus.TODO; - }, - [localChecklist.items], - ); - - const handleDragOver = useCallback( - (event: DragOverEvent) => { - const { over } = event; - setOverId(over ? (over.id as string) : null); - - if (!over || !activeId) return; - - const activeItem = localChecklist.items.find( - (item) => item.id === activeId, - ); - if (!activeItem) return; - - const overIdStr = over.id as string; - - let targetStatus: string | null = null; - if (validStatusIds.includes(overIdStr)) { - targetStatus = overIdStr; - } else { - const overItem = localChecklist.items.find( - (item) => item.id === overIdStr, - ); - if (overItem) targetStatus = overItem.status || TaskStatus.TODO; - } - - if (targetStatus && activeItem.status !== targetStatus) { - setLocalChecklist((prev) => ({ - ...prev, - items: prev.items.map((item) => - item.id === activeId ? { ...item, status: targetStatus } : item, - ), - })); - } - }, - [activeId, localChecklist.items, validStatusIds], - ); - - const handleDragEnd = useCallback( - async (event: DragEndEvent) => { - const { active, over } = event; - const origStatus = dragOriginalStatusRef.current; - setActiveId(null); - setOverId(null); - dragOriginalStatusRef.current = null; - - if (!over) { - if (origStatus) { - setLocalChecklist((prev) => ({ - ...prev, - items: prev.items.map((item) => - item.id === (active.id as string) - ? { ...item, status: origStatus } - : item, - ), - })); - } - return; - } - - const activeIdStr = active.id as string; - const overIdStr = over.id as string; - - const droppedOnColumn = validStatusIds.includes(overIdStr); - const targetStatus = droppedOnColumn - ? overIdStr - : localChecklist.items.find((item) => item.id === overIdStr)?.status || - TaskStatus.TODO; - - const isCrossColumn = origStatus !== targetStatus; - - const columnItems = localChecklist.items.filter( - (item) => - item.status === targetStatus && - !item.isArchived && - item.id !== activeIdStr, - ); - - const isDraggingDown = (() => { - if (isCrossColumn) return false; - const allColumnItems = localChecklist.items.filter( - (item) => item.status === targetStatus && !item.isArchived, - ); - const activeOrigIdx = allColumnItems.findIndex( - (item) => item.id === activeIdStr, - ); - const overOrigIdx = allColumnItems.findIndex( - (item) => item.id === overIdStr, - ); - return activeOrigIdx < overOrigIdx; - })(); - - let insertIndex: number; - if (droppedOnColumn) { - insertIndex = columnItems.length; - } else { - const overIndex = columnItems.findIndex( - (item) => item.id === overIdStr, - ); - if (overIndex === -1) { - insertIndex = columnItems.length; - } else if (isCrossColumn) { - const overRect = over.rect; - const dragY = - event.activatorEvent && "clientY" in event.activatorEvent - ? (event.activatorEvent as PointerEvent).clientY + - (event.delta?.y || 0) - : 0; - const overMidY = overRect ? overRect.top + overRect.height / 2 : 0; - insertIndex = dragY > overMidY ? overIndex + 1 : overIndex; - } else { - insertIndex = isDraggingDown ? overIndex + 1 : overIndex; - } - } - - const activeItem = localChecklist.items.find( - (item) => item.id === activeIdStr, - ); - if (!activeItem) return; - - const updatedActiveItem = { ...activeItem, status: targetStatus }; - const newColumnItems = [...columnItems]; - newColumnItems.splice(insertIndex, 0, updatedActiveItem); - - const otherItems = localChecklist.items.filter( - (item) => - (item.status !== targetStatus || item.isArchived) && - item.id !== activeIdStr, - ); - const allItems = [...otherItems, ...newColumnItems]; - - setLocalChecklist((prev) => ({ - ...prev, - items: allItems, - updatedAt: new Date().toISOString(), - })); - - if (isCrossColumn) { - await _handleItemStatusUpdate(activeIdStr, targetStatus); - - if (!droppedOnColumn) { - const reorderFormData = new FormData(); - reorderFormData.append("listId", localChecklist.id); - reorderFormData.append("activeItemId", activeIdStr); - reorderFormData.append("overItemId", overIdStr); - reorderFormData.append( - "category", - localChecklist.category || "Uncategorized", - ); - const overIdx = columnItems.findIndex( - (item) => item.id === overIdStr, - ); - if (insertIndex > overIdx) { - reorderFormData.append("position", "after"); - } - - await reorderItems(reorderFormData); - } - - await refreshChecklist(); - } else { - if (droppedOnColumn || activeIdStr === overIdStr) return; - - const formData = new FormData(); - formData.append("listId", localChecklist.id); - formData.append("activeItemId", activeIdStr); - formData.append("overItemId", overIdStr); - formData.append("category", localChecklist.category || "Uncategorized"); - if (isDraggingDown) { - formData.append("position", "after"); - } - - const result = await reorderItems(formData); - if (result.success) { - await refreshChecklist(); - } - } - }, - [localChecklist, validStatusIds, refreshChecklist], + (status: string) => + getColumnItems(localChecklist.items, status, localChecklist.statuses), + [localChecklist.items, localChecklist.statuses], ); const _handleItemStatusUpdate = async (itemId: string, newStatus: string) => { @@ -364,14 +176,9 @@ export const useKanbanBoard = ({ [onUpdate, refreshChecklist], ); - const activeItem = activeId - ? localChecklist.items.find((item) => item.id === activeId) - : null; - return { - activeId, - overId, localChecklist, + setLocalChecklist, isLoading, showBulkPasteModal, setShowBulkPasteModal, @@ -380,12 +187,8 @@ export const useKanbanBoard = ({ refreshChecklist, handleItemUpdate, getItemsByStatus, - handleDragStart, - handleDragOver, - handleDragEnd, handleAddItem, handleBulkPaste, handleItemStatusUpdate: _handleItemStatusUpdate, - activeItem, }; }; diff --git a/app/_hooks/kanban/useKanbanDnd.ts b/app/_hooks/kanban/useKanbanDnd.ts new file mode 100644 index 00000000..f261f70f --- /dev/null +++ b/app/_hooks/kanban/useKanbanDnd.ts @@ -0,0 +1,101 @@ +"use client"; + +import { useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { dropItem } from "@/app/_server/actions/checklist-item"; +import { Checklist, Item } from "@/app/_types"; +import { DropResult } from "@/app/_types/dnd"; +import { + applyDrop, + getColumnItems, + visToColIndex, +} from "@/app/_utils/kanban/board-utils"; +import { useAppMode } from "@/app/_providers/AppModeProvider"; +import { useToast } from "@/app/_providers/ToastProvider"; + +interface UseKanbanDndArgs { + checklist: Checklist; + setChecklist: (checklist: Checklist) => void; + onUpdate: (checklist: Checklist) => void; + visibleFor: (status: string) => Item[]; + fallbackMove: (itemId: string, status: string) => Promise; +} + +export const useKanbanDnd = ({ + checklist, + setChecklist, + onUpdate, + visibleFor, + fallbackMove, +}: UseKanbanDndArgs) => { + const t = useTranslations(); + const { showToast } = useToast(); + const { user } = useAppMode(); + + const handleDrop = useCallback( + async (result: DropResult) => { + const { itemId, targetListId, targetIndex } = result; + + if (!checklist.uuid) { + console.warn("checklist has no uuid, falling back to status update"); + await fallbackMove(itemId, targetListId); + return; + } + + const visible = visibleFor(targetListId).filter( + (item) => item.id !== itemId, + ); + const column = getColumnItems( + checklist.items, + targetListId, + checklist.statuses, + ).filter((item) => item.id !== itemId); + const colIndex = visToColIndex(visible, column, targetIndex); + + const snapshot = checklist; + const now = new Date().toISOString(); + const optimistic = applyDrop( + checklist, + itemId, + targetListId, + colIndex, + user?.username || "", + now, + ); + setChecklist(optimistic); + onUpdate(optimistic); + + const formData = new FormData(); + formData.append("uuid", checklist.uuid); + formData.append("itemId", itemId); + formData.append("targetStatus", targetListId); + formData.append("targetIndex", String(colIndex)); + + let response: Awaited>; + try { + response = await dropItem(formData); + } catch (error) { + console.error("dropItem threw:", error); + response = { success: false, error: "drop failed" }; + } + + if (response.success && response.data) { + setChecklist(response.data); + onUpdate(response.data); + return; + } + + console.error("dropItem failed:", response.error); + setChecklist(snapshot); + onUpdate(snapshot); + showToast({ + type: "error", + title: t("common.error"), + message: t("kanban.moveFailed"), + }); + }, + [checklist, setChecklist, onUpdate, visibleFor, fallbackMove, user?.username, showToast, t], + ); + + return { handleDrop }; +}; diff --git a/app/_hooks/kanban/useKanbanItem.tsx b/app/_hooks/kanban/useKanbanItem.tsx index 57ce1dbb..7dafe4a8 100644 --- a/app/_hooks/kanban/useKanbanItem.tsx +++ b/app/_hooks/kanban/useKanbanItem.tsx @@ -40,11 +40,6 @@ export const useKanbanItem = ({ const [editText, setEditText] = useState(item.text); const [showDeleteModal, setShowDeleteModal] = useState(false); const inputRef = useRef(null); - const isRunningRef = useRef(false); - const startTimeRef = useRef(null); - - isRunningRef.current = isRunning; - startTimeRef.current = startTime; useEffect(() => { const existingTime = @@ -63,24 +58,15 @@ export const useKanbanItem = ({ const storageKey = TIMER_STORAGE_KEY(checklistId, item.id); try { const stored = localStorage.getItem(storageKey); - if (!stored) return; - const { startTime: storedStart, isRunning: storedRunning, endTime: storedEnd } = JSON.parse(stored); - if (!storedRunning || !storedStart) return; - - if (storedEnd) { - _saveTimerEntry(new Date(storedStart), new Date(storedEnd)).then((result) => { - if (result.success) { - try { localStorage.removeItem(storageKey); } catch (e) { console.error(e); } - } - }); - } else { - setStartTime(new Date(storedStart)); - setIsRunning(true); - setCurrentTime(Math.floor((Date.now() - new Date(storedStart).getTime()) / 1000)); + if (stored) { + const { startTime: storedStart, isRunning: storedRunning } = JSON.parse(stored); + if (storedRunning && storedStart) { + setStartTime(new Date(storedStart)); + setIsRunning(true); + setCurrentTime(Math.floor((Date.now() - new Date(storedStart).getTime()) / 1000)); + } } - } catch (e) { - console.error(e); - } + } catch {} }, [checklistId, item.id]); useEffect(() => { @@ -95,27 +81,6 @@ export const useKanbanItem = ({ } }, [isRunning, startTime, checklistId, item.id]); - useEffect(() => { - const storageKey = TIMER_STORAGE_KEY(checklistId, item.id); - const markInterrupted = () => { - if (!isRunningRef.current || !startTimeRef.current) return; - try { - localStorage.setItem(storageKey, JSON.stringify({ - startTime: startTimeRef.current.toISOString(), - isRunning: true, - endTime: new Date().toISOString(), - })); - } catch (e) { - console.error(e); - } - }; - window.addEventListener("beforeunload", markInterrupted); - return () => { - window.removeEventListener("beforeunload", markInterrupted); - markInterrupted(); - }; - }, [checklistId, item.id]); - useEffect(() => { let interval: NodeJS.Timeout; if (isRunning && startTime) { @@ -162,7 +127,6 @@ export const useKanbanItem = ({ function handleTimerToggle() { if (isRunning) { - try { localStorage.removeItem(TIMER_STORAGE_KEY(checklistId, item.id)); } catch (e) { console.error(e); } setIsRunning(false); if (startTime) { const endTime = new Date(); @@ -202,7 +166,6 @@ export const useKanbanItem = ({ const stopTimerOnDrag = async () => { if (isRunning && startTime) { - try { localStorage.removeItem(TIMER_STORAGE_KEY(checklistId, item.id)); } catch (e) { console.error(e); } const endTime = new Date(); await _saveTimerEntry(startTime, endTime); setIsRunning(false); diff --git a/app/_hooks/useChecklist.tsx b/app/_hooks/useChecklist.tsx index 751fa70a..00d9fe61 100644 --- a/app/_hooks/useChecklist.tsx +++ b/app/_hooks/useChecklist.tsx @@ -508,7 +508,7 @@ export const useChecklist = ({ activeInfo.index < overInfo.index; const position = isDropIndicator - ? overId.startsWith("drop-after::") + ? overId.startsWith("drop-after") ? "after" : "before" : isDraggingDown @@ -539,7 +539,7 @@ export const useChecklist = ({ activeInfo.index < overInfo.index; const reorderPosition = isDropIndicator - ? overId.startsWith("drop-after::") + ? overId.startsWith("drop-after") ? "after" : "before" : isDraggingDown diff --git a/app/_hooks/useNoteEditor.tsx b/app/_hooks/useNoteEditor.tsx index 7f92ba62..d23038d4 100644 --- a/app/_hooks/useNoteEditor.tsx +++ b/app/_hooks/useNoteEditor.tsx @@ -115,6 +115,7 @@ export const useNoteEditor = ({ registerNavigationGuard, unregisterNavigationGuard, executePendingNavigation, + setHasUnsavedChanges: setProviderUnsaved, } = useNavigationGuard(); const autosaveTimeoutRef = useRef(null); @@ -167,10 +168,15 @@ export const useNoteEditor = ({ }, [isEditing]); useEffect(() => { - if (notesDefaultMode !== "edit" && !isEditing) return; + if (notesDefaultMode !== "edit" && !isEditing) { + setProviderUnsaved(false); + return; + } const titleChanged = title !== note.title; const categoryChanged = category !== (note.category || "Uncategorized"); - setHasUnsavedChanges(contentIsDirty || titleChanged || categoryChanged); + const dirty = contentIsDirty || titleChanged || categoryChanged; + setHasUnsavedChanges(dirty); + setProviderUnsaved(dirty); }, [contentIsDirty, title, category, note, isEditing]); const handleSave = useCallback( @@ -275,6 +281,7 @@ export const useNoteEditor = ({ setIsEditing(false); setIsEditingEncrypted(false); setContentIsDirty(false); + setProviderUnsaved(false); const categoryPath = buildCategoryPath( category || "Uncategorized", @@ -352,6 +359,7 @@ export const useNoteEditor = ({ setTitle(note.title); setCategory(note.category || "Uncategorized"); setContentIsDirty(false); + setProviderUnsaved(false); const { contentWithoutMetadata } = extractYamlMetadata(note.content || ""); if (isMinimalMode) { setEditorContent(contentWithoutMetadata); diff --git a/app/_providers/NavigationGuardProvider.tsx b/app/_providers/NavigationGuardProvider.tsx index 6970b6e5..5b98351d 100644 --- a/app/_providers/NavigationGuardProvider.tsx +++ b/app/_providers/NavigationGuardProvider.tsx @@ -5,6 +5,7 @@ import { useContext, useState, useCallback, + useEffect, ReactNode, } from "react"; @@ -59,6 +60,18 @@ export const NavigationGuardProvider = ({ return navigationGuard ? !navigationGuard() : false; }, [navigationGuard]); + useEffect(() => { + const onBeforeUnload = (e: BeforeUnloadEvent) => { + if (hasUnsavedChanges) { + e.preventDefault(); + e.returnValue = ""; + } + }; + + window.addEventListener("beforeunload", onBeforeUnload); + return () => window.removeEventListener("beforeunload", onBeforeUnload); + }, [hasUnsavedChanges]); + const executePendingNavigation = useCallback(() => { if (pendingNavigation) { pendingNavigation(); diff --git a/app/_schemas/user-schemas.ts b/app/_schemas/user-schemas.ts index 79b16818..012003f6 100644 --- a/app/_schemas/user-schemas.ts +++ b/app/_schemas/user-schemas.ts @@ -79,6 +79,12 @@ export const kanbanSettingsSchema = z.object({ message: "Hide status on cards must be either 'enable' or 'disable'", }) .optional(), + hideMobileStatusDropdown: z + .enum(["enable", "disable"], { + message: + "Hide mobile status dropdown must be either 'enable' or 'disable'", + }) + .optional(), }); export const fileSettingsSchema = z.object({ diff --git a/app/_server/actions/auth/index.ts b/app/_server/actions/auth/index.ts index 9ea9f069..3373aa47 100644 --- a/app/_server/actions/auth/index.ts +++ b/app/_server/actions/auth/index.ts @@ -53,15 +53,16 @@ const _generateSessionId = (): string => randomBytes(32).toString("hex"); async function _setSessionCookie( sessionId: string, cookieName: string, - maxAge: number, + maxAge?: number, ) { - (await cookies()).set(cookieName, sessionId, { + const opts: Parameters>["set"]>[2] = { httpOnly: true, secure: isSecureEnv(), sameSite: "lax", - maxAge, path: "/", - }); + }; + if (maxAge !== undefined) opts.maxAge = maxAge; + (await cookies()).set(cookieName, sessionId, opts); } async function _handleFailedLogin( @@ -209,6 +210,7 @@ export const register = async (formData: FormData) => { export const login = async (formData: FormData) => { const username = formData.get("username") as string; const password = formData.get("password") as string; + const rememberMe = formData.get("rememberMe") === "true"; if (!username || !password) { return { error: "Username and password are required" }; @@ -276,8 +278,9 @@ export const login = async (formData: FormData) => { await ensureUser(ldapResult.username, ldapResult.isAdmin); const ldapSessionId = _generateSessionId(); - await createSession(ldapSessionId, ldapResult.username, "ldap"); - await _setSessionCookie(ldapSessionId, getSessionCookieName(), 30 * 24 * 60 * 60); + const ldapMaxAge = rememberMe ? 30 * 24 * 60 * 60 : undefined; + await createSession(ldapSessionId, ldapResult.username, "ldap", rememberMe); + await _setSessionCookie(ldapSessionId, getSessionCookieName(), ldapMaxAge); await logAuthEvent("login", ldapResult.username, true); redirect("/"); @@ -290,7 +293,7 @@ export const login = async (formData: FormData) => { if (user.mfaEnabled) { const pendingSessionId = _generateSessionId(); await _setSessionCookie(pendingSessionId, getMfaPendingCookieName(), 10 * 60); - await createSession(pendingSessionId, user.username, "pending-mfa"); + await createSession(pendingSessionId, user.username, "pending-mfa", rememberMe); redirect("/auth/verify-mfa"); } @@ -306,13 +309,10 @@ export const login = async (formData: FormData) => { } const sessionId = _generateSessionId(); - const sessions = await readSessions(); - sessions[sessionId] = user.username; - - await writeSessions(sessions); + const maxAge = rememberMe ? 30 * 24 * 60 * 60 : undefined; - await createSession(sessionId, user.username, "local"); - await _setSessionCookie(sessionId, getSessionCookieName(), 30 * 24 * 60 * 60); + await createSession(sessionId, user.username, "local", rememberMe); + await _setSessionCookie(sessionId, getSessionCookieName(), maxAge); await logAuthEvent("login", user.username, true); redirect("/"); @@ -375,6 +375,9 @@ export const verifyMfaLogin = async (formData: FormData) => { return { error: "Invalid session" }; } + const sessionDataStore = await readSessionData(); + const rememberMe = sessionDataStore[pendingSessionId]?.rememberMe ?? false; + const users = await readJsonFile(USERS_FILE); const user = users.find( (u: User) => u.username.toLowerCase() === username.toLowerCase(), @@ -425,16 +428,17 @@ export const verifyMfaLogin = async (formData: FormData) => { } const sessionId = _generateSessionId(); + const maxAge = rememberMe ? 30 * 24 * 60 * 60 : undefined; sessions[sessionId] = username; delete sessions[pendingSessionId]; await writeSessions(sessions); await removeSession(pendingSessionId); - await createSession(sessionId, username, "local"); + await createSession(sessionId, username, "local", rememberMe); (await cookies()).delete(pendingCookieName); - await _setSessionCookie(sessionId, getSessionCookieName(), 30 * 24 * 60 * 60); + await _setSessionCookie(sessionId, getSessionCookieName(), maxAge); await logAuthEvent("login", username, true); diff --git a/app/_server/actions/checklist-item/crud.ts b/app/_server/actions/checklist-item/crud.ts index e45592a2..fc2e571a 100644 --- a/app/_server/actions/checklist-item/crud.ts +++ b/app/_server/actions/checklist-item/crud.ts @@ -102,6 +102,7 @@ export const updateItem = async ( const assignee = formData.get("assignee") as string | null; const reminder = formData.get("reminder") as string | null; const targetDate = formData.get("targetDate") as string | null; + const startDate = formData.get("startDate") as string | null; const estimatedTime = formData.get("estimatedTime") as string | null; const updatedList = { @@ -118,6 +119,7 @@ export const updateItem = async ( reminder: reminder ? JSON.parse(reminder) : undefined, }), ...(targetDate !== null && { targetDate: targetDate || undefined }), + ...(startDate !== null && { startDate: startDate || undefined }), ...(estimatedTime !== null && { estimatedTime: estimatedTime ? parseFloat(estimatedTime) : undefined }), lastModifiedBy: currentUser, lastModifiedAt: now, diff --git a/app/_server/actions/checklist-item/drop.ts b/app/_server/actions/checklist-item/drop.ts new file mode 100644 index 00000000..dc4ca130 --- /dev/null +++ b/app/_server/actions/checklist-item/drop.ts @@ -0,0 +1,120 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import path from "path"; +import { serverWriteFile, ensureDir } from "@/app/_server/actions/file"; +import { getListById } from "@/app/_server/actions/checklist"; +import { getUsername } from "@/app/_server/actions/users"; +import { checkUserPermission } from "../sharing"; +import { broadcast } from "@/app/_server/ws/broadcast"; +import { applyDrop } from "@/app/_utils/kanban/board-utils"; +import { listToMarkdown } from "@/app/_utils/checklist-utils"; +import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists"; +import { Checklist, Result } from "@/app/_types"; +import { ItemTypes, PermissionTypes } from "@/app/_types/enums"; + +export const dropItem = async ( + formData: FormData, +): Promise> => { + try { + const uuid = formData.get("uuid") as string; + const itemId = formData.get("itemId") as string; + const targetStatus = formData.get("targetStatus") as string; + const targetIndexRaw = formData.get("targetIndex"); + + if ( + !uuid || + !itemId || + !targetStatus || + targetIndexRaw === null || + targetIndexRaw === "" + ) { + return { + success: false, + error: "uuid, itemId, targetStatus and targetIndex are required", + }; + } + + const targetIndex = Number(targetIndexRaw); + if (Number.isNaN(targetIndex)) { + return { success: false, error: "targetIndex must be a number" }; + } + + const username = await getUsername(); + const list = await getListById(uuid); + if (!list) { + return { success: false, error: "List not found" }; + } + + const canEdit = await checkUserPermission( + list.uuid || list.id, + list.category || "Uncategorized", + ItemTypes.CHECKLIST, + username, + PermissionTypes.EDIT, + ); + if (!canEdit) { + return { success: false, error: "Permission denied" }; + } + + if (!list.items.some((item) => item.id === itemId)) { + return { success: false, error: "Item not found" }; + } + + const isValidStatus = (list.statuses || []).some( + (s) => s.id === targetStatus, + ); + if (!isValidStatus) { + return { success: false, error: "Invalid target status" }; + } + + const now = new Date().toISOString(); + const updatedList = applyDrop( + list, + itemId, + targetStatus, + Math.max(0, targetIndex), + username, + now, + ); + + const categoryDir = path.join( + process.cwd(), + "data", + CHECKLISTS_FOLDER, + list.owner!, + list.category || "Uncategorized", + ); + await ensureDir(categoryDir); + await serverWriteFile( + path.join(categoryDir, `${list.id}.md`), + listToMarkdown(updatedList), + ); + + try { + revalidatePath("/"); + revalidatePath(`/checklist/${list.id}`); + } catch (error) { + console.warn( + "Cache revalidation failed, but data was saved successfully:", + error, + ); + } + + try { + await broadcast({ + type: "checklist", + action: "updated", + entityId: list.id, + username, + }); + } catch (error) { + console.warn("Broadcast failed, but data was saved successfully:", error); + } + + return { success: true, data: updatedList }; + } catch (error) { + console.error("Error dropping item:", error); + return { success: false, error: "Failed to drop item" }; + } +}; diff --git a/app/_server/actions/checklist-item/index.ts b/app/_server/actions/checklist-item/index.ts index 02c7e550..c25eba3b 100644 --- a/app/_server/actions/checklist-item/index.ts +++ b/app/_server/actions/checklist-item/index.ts @@ -18,6 +18,10 @@ export { updateItemStatus, } from "./status"; +export { + dropItem, +} from "./drop"; + export { createSubItem, } from "./sub-items"; diff --git a/app/_server/actions/checklist-item/status.ts b/app/_server/actions/checklist-item/status.ts index ebc93917..d2efc3cc 100644 --- a/app/_server/actions/checklist-item/status.ts +++ b/app/_server/actions/checklist-item/status.ts @@ -12,52 +12,15 @@ import { import { listToMarkdown } from "@/app/_utils/checklist-utils"; import { getUsername } from "@/app/_server/actions/users"; import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists"; -import { Checklist, Result } from "@/app/_types"; +import { Checklist, Result, TimeEntry } from "@/app/_types"; import { ItemTypes, PermissionTypes, } from "@/app/_types/enums"; import { checkUserPermission } from "../sharing"; import { broadcast } from "@/app/_server/ws/broadcast"; -import { updateItem, updateAllChildren } from "@/app/_utils/item-tree-utils"; -import { KanbanStatus, Item } from "@/app/_types"; - -const _findParent = (items: Item[], childId: string): Item | null => { - for (const item of items) { - if (item.children?.some((c) => c.id === childId)) return item; - if (item.children) { - const found = _findParent(item.children, childId); - if (found) return found; - } - } - return null; -}; - -const _autoCompleteParent = ( - items: Item[], - childId: string, - statuses: KanbanStatus[] | undefined, - username: string, - now: string -): Item[] => { - const parent = _findParent(items, childId); - if (!parent || !parent.children) return items; - - const allChildrenCompleted = parent.children.every((c) => c.completed); - if (!allChildrenCompleted) return items; - - const autoCompleteStatus = statuses?.find((s) => s.autoComplete); - if (!autoCompleteStatus) return items; - - return updateItem(items, parent.id, (p) => ({ - ...p, - completed: true, - status: autoCompleteStatus.id, - lastModifiedBy: username, - lastModifiedAt: now, - history: [...(p.history || []), { status: autoCompleteStatus.id, timestamp: now, user: username }], - })); -}; +import { updateItem } from "@/app/_utils/item-tree-utils"; +import { applyStatus, completeParent } from "@/app/_utils/item-status-utils"; export const updateItemStatus = async ( formData: FormData, @@ -85,6 +48,29 @@ export const updateItemStatus = async ( }; } + let parsedTimeEntries: TimeEntry[] | null = null; + if (timeEntriesStr) { + try { + const parsed = JSON.parse(timeEntriesStr); + if (!Array.isArray(parsed)) { + throw new Error("timeEntries must be an array"); + } + const allObjects = parsed.every( + (entry) => + entry !== null && + typeof entry === "object" && + !Array.isArray(entry) + ); + if (!allObjects) { + throw new Error("timeEntries must contain objects"); + } + parsedTimeEntries = parsed; + } catch (e) { + console.error("Failed to parse timeEntries:", e); + return { success: false, error: "Invalid timeEntries payload" }; + } + } + const list = await getListById(listId, username, category); if (!list) { return { success: false, error: "List not found" }; @@ -104,47 +90,21 @@ export const updateItemStatus = async ( const now = new Date().toISOString(); - const updatedItems = updateItem(list.items, itemId, (item) => { - const updates: Partial & { history?: typeof item.history; timeEntries?: typeof item.timeEntries } = {}; - - if (status) { - updates.status = status; - updates.lastModifiedBy = username; - updates.lastModifiedAt = now; - - const targetStatus = list.statuses?.find((s) => s.id === status); - if (targetStatus?.autoComplete) { - updates.completed = true; - if (item.children && item.children.length > 0) { - updates.children = updateAllChildren(item.children, true, username, now); - } - } else if (item.completed && status !== item.status) { - updates.completed = false; - } - - if (status !== item.status) { - const history = [...(item.history || [])]; - history.push({ status, timestamp: now, user: username }); - updates.history = history; - } - } + const statusItems = status + ? applyStatus(list.items, itemId, status, list.statuses, username, now) + : list.items; - if (timeEntriesStr) { - try { - const timeEntries = JSON.parse(timeEntriesStr); - updates.timeEntries = timeEntries.map((entry: { user?: string }) => ({ + const updatedItems = parsedTimeEntries + ? updateItem(statusItems, itemId, (item) => ({ + ...item, + timeEntries: parsedTimeEntries!.map((entry) => ({ ...entry, user: entry.user || username, - })); - } catch (e) { - console.error("Failed to parse timeEntries:", e); - } - } - - return { ...item, ...updates }; - }); + })), + })) + : statusItems; - const itemsWithParentAutoComplete = _autoCompleteParent( + const itemsWithParentAutoComplete = completeParent( updatedItems, itemId, list.statuses, username, now ); diff --git a/app/_server/actions/file/index.ts b/app/_server/actions/file/index.ts index 9ecb9e10..f058b678 100644 --- a/app/_server/actions/file/index.ts +++ b/app/_server/actions/file/index.ts @@ -10,6 +10,7 @@ import { } from "@/app/_consts/files"; import fs from "fs/promises"; import path from "path"; +import { randomUUID } from "crypto"; import { Modes } from "@/app/_types/enums"; export interface OrderData { @@ -88,15 +89,16 @@ export const writeJsonFile = async ( data: any, filePath: string, ): Promise => { + const finalPath = path.join(process.cwd(), filePath); + const tmpPath = `${finalPath}.${randomUUID()}.tmp`; + try { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile( - path.join(process.cwd(), filePath), - JSON.stringify(data, null, 2), - "utf-8", - ); + await fs.mkdir(path.dirname(finalPath), { recursive: true }); + await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), "utf-8"); + await fs.rename(tmpPath, finalPath); } catch (error) { console.error("Error writing data:", error); + try { await fs.unlink(tmpPath); } catch {} throw error; } }; diff --git a/app/_server/actions/note/parsers.ts b/app/_server/actions/note/parsers.ts index 228617fa..14a9046c 100644 --- a/app/_server/actions/note/parsers.ts +++ b/app/_server/actions/note/parsers.ts @@ -195,5 +195,5 @@ export const noteToMarkdown = (note: Note): string => { const frontmatter = generateYamlFrontmatter(metadata); - return `${frontmatter}${content}`.trim() + "\n"; + return `${frontmatter}${content}`.trim(); }; diff --git a/app/_server/actions/session/index.ts b/app/_server/actions/session/index.ts index c5b91b18..9996a9cf 100644 --- a/app/_server/actions/session/index.ts +++ b/app/_server/actions/session/index.ts @@ -17,6 +17,7 @@ export interface SessionData { createdAt: string; lastActivity: string; loginType?: "local" | "sso" | "ldap" | "pending-mfa"; + rememberMe?: boolean; } export interface Session { @@ -49,6 +50,7 @@ export const createSession = async ( sessionId: string, username: string, loginType: "local" | "sso" | "pending-mfa" | "ldap", + rememberMe?: boolean, ): Promise => { const headersList = await headers(); const userAgent = headersList.get("user-agent") || "Unknown"; @@ -64,6 +66,7 @@ export const createSession = async ( createdAt: new Date().toISOString(), lastActivity: new Date().toISOString(), loginType, + ...(rememberMe !== undefined && { rememberMe }), }; const sessionsData = await readSessionData(); diff --git a/app/_server/actions/users/crud.ts b/app/_server/actions/users/crud.ts index d59b346a..dcfd22fb 100644 --- a/app/_server/actions/users/crud.ts +++ b/app/_server/actions/users/crud.ts @@ -204,6 +204,7 @@ export const createUser = async ( preferredDateFormat: "system", preferredTimeFormat: "system", handedness: "right-handed", + hideMobileStatusDropdown: "disable", }; const updatedUsers = [...existingUsers, newUser]; diff --git a/app/_styles/globals.css b/app/_styles/globals.css index 8ad3724e..57647083 100644 --- a/app/_styles/globals.css +++ b/app/_styles/globals.css @@ -660,40 +660,65 @@ pre.text-foreground span, } } -.overflow-y-auto { - scrollbar-width: auto !important; +@supports (-moz-appearance: none) { + .overflow-y-auto, + .overflow-x-auto, + .overflow-auto { + scrollbar-width: thin; + scrollbar-color: rgb(var(--primary) / 0.3) transparent; + } } .jotty-scrollable-content { - -webkit-overflow-scrolling: touch; - transform: translateZ(0); + transform: translateZ(0); + -webkit-overflow-scrolling: touch; +} + +.overflow-y-auto::-webkit-scrollbar-button, +.overflow-x-auto::-webkit-scrollbar-button, +.overflow-auto::-webkit-scrollbar-button, +.scrollbar-thin::-webkit-scrollbar-button { + display: none; + height: 0; + width: 0; } .overflow-x-auto::-webkit-scrollbar { - height: 8px; + height: 6px; } .overflow-y-auto::-webkit-scrollbar, -.overflow-x-auto::-webkit-scrollbar { - -webkit-appearance: none; - width: 8px; - background-color: rgb(var(--muted) / 0.5) !important; - border-radius: 6px; +.overflow-x-auto::-webkit-scrollbar, +.overflow-auto::-webkit-scrollbar, +.scrollbar-thin::-webkit-scrollbar { + -webkit-appearance: none; + width: 6px; + background: transparent; +} + +.overflow-y-auto::-webkit-scrollbar-track, +.overflow-x-auto::-webkit-scrollbar-track, +.overflow-auto::-webkit-scrollbar-track, +.scrollbar-thin::-webkit-scrollbar-track { + background: transparent; } .overflow-y-auto::-webkit-scrollbar-thumb, -.overflow-x-auto::-webkit-scrollbar-thumb { - background-color: rgb(var(--primary) / 0.3) !important; - border-radius: 6px; - border: 2px solid transparent; - background-clip: padding-box; - visibility: visible !important; - opacity: 1 !important; - min-height: 20px !important; - height: 20px !important; - width: 4px !important; +.overflow-x-auto::-webkit-scrollbar-thumb, +.overflow-auto::-webkit-scrollbar-thumb, +.scrollbar-thin::-webkit-scrollbar-thumb { + background-color: rgb(var(--primary) / 0.3); + border-radius: 3px; } +.overflow-y-auto::-webkit-scrollbar-thumb:hover, +.overflow-x-auto::-webkit-scrollbar-thumb:hover, +.overflow-auto::-webkit-scrollbar-thumb:hover, +.scrollbar-thin::-webkit-scrollbar-thumb:hover { + background-color: rgb(var(--primary) / 0.5); +} + + @media (max-width: 992px) { .jotty-checklist-page .jotty-quick-nav, .jotty-embed .jotty-quick-nav { diff --git a/app/_translations/de.json b/app/_translations/de.json index b3895c33..8e0ed086 100644 --- a/app/_translations/de.json +++ b/app/_translations/de.json @@ -7,6 +7,8 @@ "view": "Anzeigen", "new": "Neu", "close": "Schließen", + "enlarge": "Vergrößern", + "shrink": "Verkleinern", "confirm": "Bestätigen", "loading": "Lädt...", "search": "Suche", @@ -280,6 +282,7 @@ "version": "Version {version}", "createUser": "Benutzer erstellen", "notAuthorized": "Du bist nicht berechtigt, auf diese Anwendung zuzugreifen. Kontaktiere deinen Administrator.", + "rememberMe": "Angemeldet bleiben", "attemptsRemaining": "Warnung: Noch {count, plural, one {# Versuch} other {# Versuche}} bis zur Kontosperre", "accountLocked": "Zu viele fehlgeschlagene Versuche. Bitte versuche es in {seconds} {seconds, plural, one {Sekunde} other {Sekunden}} erneut." }, @@ -543,6 +546,7 @@ "autoComplete": "Automatisch abschließen" }, "kanban": { + "status": "Status", "priority": "Priorität", "score": "Punktzahl", "assignee": "Zugewiesen an", @@ -572,6 +576,7 @@ "itemTitle": "Elementtitel", "manageStatuses": "Status verwalten", "viewArchived": "Archivierte anzeigen", + "changeStatus": "Status ändern", "noBoards": "Keine Boards gefunden", "noBoardsYet": "Noch keine Kanban-Boards", "createFirstBoard": "Erstelle dein erstes Kanban-Board, um deine Projekte zu verwalten.", @@ -586,6 +591,13 @@ "targetDate": "Zieldatum", "statusUpdated": "Status aktualisiert", "itemArchived": "Element archiviert", + "archiveAllItemsInColumn": "Alle Elemente in {column} archivieren", + "archiveAllConfirmTitle": "Alle Elemente archivieren?", + "archiveAllConfirmMessage": "Alle {count} Elemente in {column} archivieren? Du kannst sie aus dem Archiv wiederherstellen.", + "archiveAllSuccess": "{count} Elemente archiviert", + "archiveAllFailed": "Nicht alle Elemente konnten archiviert werden", + "moveFailed": "Karte konnte nicht verschoben werden", + "archiveAllPartial": "{archived} von {total} Elementen archiviert. Einige Elemente konnten nicht archiviert werden.", "itemDeleted": "Element gelöscht", "estimatedTime": "Geschätzte Zeit (Stunden)", "actualVsEstimated": "{actual} / {estimated}", @@ -680,7 +692,27 @@ "connection_plural": "Verbindungen", "terminateSessionConfirm": "Bist du sicher, dass du diese Sitzung beenden willst?", "terminateAllSessionsConfirm": "Bist du sicher, dass du alle anderen Sitzungen beenden willst?", - "validationErrorTitle": "Validerungsfehler" + "validationErrorTitle": "Validerungsfehler", + "visibleGraphSummary": "{items} sichtbare Elemente, {links} sichtbare Verbindungen", + "graphTruncated": "Es werden die {visible} am stärksten verbundenen Elemente angezeigt. {omitted} schwächer verbundene Elemente wurden ausgeblendet.", + "totalConnections": "Gesamt", + "inboundConnections": "Eingehend", + "outboundConnections": "Ausgehend", + "updated": "Aktualisiert", + "openItem": "Element öffnen", + "selectItem": "Element auswählen", + "inspectNodeHint": "Tippen oder klicken Sie auf einen Knoten, um Titel, Kategorie, Verbindungsanzahl und letzte Aktivität anzuzeigen.", + "graphSearchPlaceholder": "Titel, Pfad, Kategorie oder Tag", + "orphans": "Verwaiste", + "labels": "Beschriftungen", + "arrows": "Pfeile", + "nodeSize": "Knotengröße", + "linkWidth": "Linienbreite", + "linkDistance": "Linienabstand", + "repelForce": "Abstoßung", + "tagLinks": "Tag-Verbindungen", + "linkedItems": "Verknüpfte Elemente", + "uuid": "UUID" }, "settings": { "title": "Einstellungen", @@ -836,6 +868,11 @@ "showStatusOnCards": "Status anzeigen", "hideStatusOnCards": "Status ausblenden", "hideStatusOnCardsDescription": "Blendet das Status-Label auf Kanban-Karten aus. Der Status ist bereits durch die Spalte ersichtlich, in der die Karte liegt.", + "hideMobileStatusDropdownLabel": "Mobiles Status-Dropdown auf Karten", + "showMobileStatusDropdown": "Mobiles Dropdown anzeigen", + "hideMobileStatusDropdown": "Mobiles Dropdown ausblenden", + "selectMobileStatusDropdown": "Verhalten des mobilen Status-Dropdowns auswählen", + "hideMobileStatusDropdownDescription": "Steuert das immer sichtbare Status-Dropdown auf mobilen Kanban-Karten. Die Einstellung für das Status-Label bleibt separat.", "selectStatusOnCards": "Statussichtbarkeit auswählen", "codeBlockChoice": "Wählen Sie den Codeblockstil", "themedCodeBlock": "Themenbezogener Codeblock", diff --git a/app/_translations/en.json b/app/_translations/en.json index 7c52cd33..37f52751 100644 --- a/app/_translations/en.json +++ b/app/_translations/en.json @@ -7,6 +7,8 @@ "view": "View", "new": "New", "close": "Close", + "enlarge": "Enlarge", + "shrink": "Shrink", "confirm": "Confirm", "loading": "Loading...", "search": "Search", @@ -306,7 +308,8 @@ "createUser": "Create User", "attemptsRemaining": "Warning: {count, plural, one {# attempt} other {# attempts}} remaining before account lockout", "accountLocked": "Too many failed attempts. Please try again in {seconds} {seconds, plural, one {second} other {seconds}}", - "notAuthorized": "You are not authorized to access this application. Please contact your administrator." + "notAuthorized": "You are not authorized to access this application. Please contact your administrator.", + "rememberMe": "Remember me" }, "mfa": { "title": "Two-Factor Authentication", @@ -568,6 +571,7 @@ "autoComplete": "Auto complete" }, "kanban": { + "status": "Status", "priority": "Priority", "score": "Score", "assignee": "Assignee", @@ -597,6 +601,7 @@ "itemTitle": "Item Title", "manageStatuses": "Manage Statuses", "viewArchived": "View Archived", + "changeStatus": "Change status", "noBoards": "No boards found", "noBoardsYet": "No kanban boards yet", "createFirstBoard": "Create your first kanban board to start managing your projects.", @@ -611,6 +616,13 @@ "targetDate": "Target Date", "statusUpdated": "Status updated", "itemArchived": "Item archived", + "archiveAllItemsInColumn": "Archive all items in {column}", + "archiveAllConfirmTitle": "Archive all items?", + "archiveAllConfirmMessage": "Archive all {count} items in {column}? You can restore them from the archive.", + "archiveAllSuccess": "{count} items archived", + "archiveAllFailed": "Could not archive all items", + "moveFailed": "Could not move the card", + "archiveAllPartial": "{archived} of {total} items archived. Some items could not be archived.", "itemDeleted": "Item deleted", "estimatedTime": "Estimated Time (hours)", "actualVsEstimated": "{actual} / {estimated}", @@ -705,7 +717,27 @@ "connection_plural": "connections", "terminateSessionConfirm": "Are you sure you want to terminate this session?", "terminateAllSessionsConfirm": "Are you sure you want to terminate all other sessions?", - "validationErrorTitle": "Validation Error" + "validationErrorTitle": "Validation Error", + "visibleGraphSummary": "{items} visible items, {links} visible links", + "graphTruncated": "Showing the top {visible} items by connection count. {omitted} lower-connected items were omitted.", + "totalConnections": "Total", + "inboundConnections": "In", + "outboundConnections": "Out", + "updated": "Updated", + "openItem": "Open item", + "selectItem": "Select an item", + "inspectNodeHint": "Tap or click a node to inspect title, category, link counts, and recent activity.", + "graphSearchPlaceholder": "Title, path, category, or tag", + "orphans": "Orphans", + "labels": "Labels", + "arrows": "Arrows", + "nodeSize": "Node size", + "linkWidth": "Link width", + "linkDistance": "Link distance", + "repelForce": "Repel", + "tagLinks": "Tag links", + "linkedItems": "Linked items", + "uuid": "UUID" }, "settings": { "title": "Settings", @@ -862,6 +894,11 @@ "showStatusOnCards": "Show status", "hideStatusOnCards": "Hide status", "hideStatusOnCardsDescription": "Hides the status label from Kanban card items. The status is already indicated by the column the card is placed in.", + "hideMobileStatusDropdownLabel": "Mobile status dropdown on cards", + "showMobileStatusDropdown": "Show mobile dropdown", + "hideMobileStatusDropdown": "Hide mobile dropdown", + "selectMobileStatusDropdown": "Select mobile status dropdown behavior", + "hideMobileStatusDropdownDescription": "Controls the always-visible status dropdown on mobile Kanban cards. The status label setting is separate.", "selectStatusOnCards": "Select status visibility", "codeBlockChoice": "Choose the code block style", "defaultCodeBlock": "Codepen (default)", diff --git a/app/_translations/es.json b/app/_translations/es.json index b557f4a0..69ca35f9 100644 --- a/app/_translations/es.json +++ b/app/_translations/es.json @@ -7,6 +7,8 @@ "view": "Ver", "new": "Nuevo", "close": "Cerrar", + "enlarge": "Ampliar", + "shrink": "Reducir", "confirm": "Confirmar", "loading": "Cargando...", "search": "Buscar", @@ -281,7 +283,8 @@ "createUser": "Crear usuario", "attemptsRemaining": "Advertencia: {count, plural, one {# intento} other {# intentos}} restantes antes del bloqueo de cuenta", "accountLocked": "Demasiados intentos fallidos. Por favor, inténtelo de nuevo en {seconds} {seconds, plural, one {segundo} other {segundos}}", - "notAuthorized": "No estás autorizado para acceder a esta aplicación. Ponte en contacto con tu administrador." + "notAuthorized": "No estás autorizado para acceder a esta aplicación. Ponte en contacto con tu administrador.", + "rememberMe": "Recuérdame" }, "mfa": { "title": "Autenticación de dos factores", @@ -543,6 +546,7 @@ "autoComplete": "Autocompletar" }, "kanban": { + "status": "Estado", "priority": "Prioridad", "score": "Puntuación", "assignee": "Asignado a", @@ -572,6 +576,7 @@ "itemTitle": "Título del elemento", "manageStatuses": "Gestionar estados", "viewArchived": "Ver archivados", + "changeStatus": "Cambiar estado", "noBoards": "No se encontraron tableros", "noBoardsYet": "Aún no hay tableros Kanban", "createFirstBoard": "Cree su primer tablero Kanban para empezar a gestionar sus proyectos.", @@ -586,6 +591,13 @@ "targetDate": "Fecha objetivo", "statusUpdated": "Estado actualizado", "itemArchived": "Elemento archivado", + "archiveAllItemsInColumn": "Archivar todos los elementos en {column}", + "archiveAllConfirmTitle": "¿Archivar todos los elementos?", + "archiveAllConfirmMessage": "¿Archivar los {count} elementos en {column}? Puedes restaurarlos desde el archivo.", + "archiveAllSuccess": "{count} elementos archivados", + "archiveAllFailed": "No se pudieron archivar todos los elementos", + "moveFailed": "No se pudo mover la tarjeta", + "archiveAllPartial": "{archived} de {total} elementos archivados. Algunos elementos no se pudieron archivar.", "itemDeleted": "Elemento eliminado", "estimatedTime": "Tiempo estimado (horas)", "actualVsEstimated": "{actual} / {estimated}", @@ -680,7 +692,27 @@ "connection_plural": "conexiones", "terminateSessionConfirm": "¿Está seguro de que desea terminar esta sesión?", "terminateAllSessionsConfirm": "¿Está seguro de que desea terminar todas las demás sesiones?", - "validationErrorTitle": "Error de validación" + "validationErrorTitle": "Error de validación", + "visibleGraphSummary": "{items} elementos visibles, {links} enlaces visibles", + "graphTruncated": "Mostrando los {visible} elementos con más conexiones. Se omitieron {omitted} elementos con menos conexiones.", + "totalConnections": "Total", + "inboundConnections": "Entrantes", + "outboundConnections": "Salientes", + "updated": "Actualizado", + "openItem": "Abrir elemento", + "selectItem": "Selecciona un elemento", + "inspectNodeHint": "Toca o haz clic en un nodo para ver el título, la categoría, el número de enlaces y la actividad reciente.", + "graphSearchPlaceholder": "Título, ruta, categoría o etiqueta", + "orphans": "Huérfanos", + "labels": "Etiquetas", + "arrows": "Flechas", + "nodeSize": "Tamaño del nodo", + "linkWidth": "Grosor del enlace", + "linkDistance": "Distancia del enlace", + "repelForce": "Repulsión", + "tagLinks": "Enlaces de etiquetas", + "linkedItems": "Elementos vinculados", + "uuid": "UUID" }, "settings": { "title": "Configuración", @@ -836,6 +868,11 @@ "showStatusOnCards": "Mostrar estado", "hideStatusOnCards": "Ocultar estado", "hideStatusOnCardsDescription": "Oculta la etiqueta de estado en las tarjetas Kanban. El estado ya se indica por la columna en la que se encuentra la tarjeta.", + "hideMobileStatusDropdownLabel": "Menú desplegable de estado móvil en tarjetas", + "showMobileStatusDropdown": "Mostrar menú móvil", + "hideMobileStatusDropdown": "Ocultar menú móvil", + "selectMobileStatusDropdown": "Seleccionar comportamiento del menú de estado móvil", + "hideMobileStatusDropdownDescription": "Controla el menú desplegable de estado siempre visible en tarjetas Kanban móviles. La configuración de la etiqueta de estado es independiente.", "selectStatusOnCards": "Seleccionar visibilidad del estado", "codeBlockChoice": "Elija el estilo del bloque de código", "themedCodeBlock": "Bloque de código temático", diff --git a/app/_translations/fr.json b/app/_translations/fr.json index 65224617..55e4b88f 100644 --- a/app/_translations/fr.json +++ b/app/_translations/fr.json @@ -7,6 +7,8 @@ "view": "Afficher", "new": "Nouveau", "close": "Fermer", + "enlarge": "Agrandir", + "shrink": "Réduire", "confirm": "Confirmer", "loading": "Chargement...", "search": "Rechercher", @@ -281,7 +283,8 @@ "createUser": "Créer un utilisateur", "attemptsRemaining": "Attention : {count, plural, one {# tentative} other {# tentatives}} restantes avant le verrouillage du compte", "accountLocked": "Trop de tentatives échouées. Veuillez réessayer dans {seconds} {seconds, plural, one {seconde} other {secondes}}", - "notAuthorized": "Vous n’êtes pas autorisé à accéder à cette application. Contactez votre administrateur." + "notAuthorized": "Vous n’êtes pas autorisé à accéder à cette application. Contactez votre administrateur.", + "rememberMe": "Se souvenir de moi" }, "mfa": { "title": "Authentification à deux facteurs", @@ -543,6 +546,7 @@ "autoComplete": "Complétion automatique" }, "kanban": { + "status": "Statut", "priority": "Priorité", "score": "Score", "assignee": "Assigné à", @@ -572,6 +576,7 @@ "itemTitle": "Titre de l'élément", "manageStatuses": "Gérer les statuts", "viewArchived": "Voir les archivés", + "changeStatus": "Changer le statut", "noBoards": "Aucun tableau trouvé", "noBoardsYet": "Pas encore de tableaux Kanban", "createFirstBoard": "Créez votre premier tableau Kanban pour gérer vos projets.", @@ -586,6 +591,13 @@ "targetDate": "Date cible", "statusUpdated": "Statut mis à jour", "itemArchived": "Élément archivé", + "archiveAllItemsInColumn": "Archiver tous les éléments dans {column}", + "archiveAllConfirmTitle": "Archiver tous les éléments ?", + "archiveAllConfirmMessage": "Archiver les {count} éléments dans {column} ? Vous pourrez les restaurer depuis l'archive.", + "archiveAllSuccess": "{count} éléments archivés", + "archiveAllFailed": "Impossible d'archiver tous les éléments", + "moveFailed": "Impossible de déplacer la carte", + "archiveAllPartial": "{archived} élément(s) sur {total} archivé(s). Certains éléments n'ont pas pu être archivés.", "itemDeleted": "Élément supprimé", "estimatedTime": "Temps estimé (heures)", "actualVsEstimated": "{actual} / {estimated}", @@ -680,7 +692,27 @@ "connection_plural": "connexions", "terminateSessionConfirm": "Êtes‑vous sûr de vouloir terminer cette session ?", "terminateAllSessionsConfirm": "Êtes‑vous sûr de vouloir terminer toutes les autres sessions ?", - "validationErrorTitle": "Erreur de validation" + "validationErrorTitle": "Erreur de validation", + "visibleGraphSummary": "{items} éléments visibles, {links} liens visibles", + "graphTruncated": "Affichage des {visible} éléments les plus connectés. {omitted} éléments moins connectés ont été masqués.", + "totalConnections": "Total", + "inboundConnections": "Entrants", + "outboundConnections": "Sortants", + "updated": "Mis à jour", + "openItem": "Ouvrir l'élément", + "selectItem": "Sélectionner un élément", + "inspectNodeHint": "Touchez ou cliquez sur un nœud pour consulter le titre, la catégorie, le nombre de liens et l'activité récente.", + "graphSearchPlaceholder": "Titre, chemin, catégorie ou tag", + "orphans": "Orphelins", + "labels": "Étiquettes", + "arrows": "Flèches", + "nodeSize": "Taille des nœuds", + "linkWidth": "Largeur des liens", + "linkDistance": "Distance des liens", + "repelForce": "Répulsion", + "tagLinks": "Liens de tags", + "linkedItems": "Éléments liés", + "uuid": "UUID" }, "settings": { "title": "Paramètres", @@ -836,6 +868,11 @@ "showStatusOnCards": "Afficher le statut", "hideStatusOnCards": "Masquer le statut", "hideStatusOnCardsDescription": "Masque l'étiquette de statut sur les cartes Kanban. Le statut est déjà indiqué par la colonne dans laquelle se trouve la carte.", + "hideMobileStatusDropdownLabel": "Menu déroulant de statut mobile sur les cartes", + "showMobileStatusDropdown": "Afficher le menu mobile", + "hideMobileStatusDropdown": "Masquer le menu mobile", + "selectMobileStatusDropdown": "Sélectionner le comportement du menu de statut mobile", + "hideMobileStatusDropdownDescription": "Contrôle le menu déroulant de statut toujours visible sur les cartes Kanban mobiles. Le réglage de l'étiquette de statut est séparé.", "selectStatusOnCards": "Sélectionner la visibilité du statut", "codeBlockChoice": "Choisissez le style de bloc de code", "themedCodeBlock": "Bloc de code thématique", diff --git a/app/_translations/it.json b/app/_translations/it.json index 5410a7ee..3ffa619d 100644 --- a/app/_translations/it.json +++ b/app/_translations/it.json @@ -7,6 +7,8 @@ "view": "Visualizza", "new": "Nuovo", "close": "Chiudi", + "enlarge": "Ingrandisci", + "shrink": "Riduci", "confirm": "Conferma", "loading": "Caricamento...", "search": "Cerca", @@ -281,7 +283,8 @@ "createUser": "Crea Utente", "attemptsRemaining": "Attenzione: {count, plural, one {# tentativo} other {# tentativi}} rimanenti prima del blocco dell'account", "accountLocked": "Troppi tentativi falliti. Riprova tra {seconds} {seconds, plural, one {secondo} other {secondi}}", - "notAuthorized": "Non sei autorizzato ad accedere a questa applicazione. Contatta l’amministratore." + "notAuthorized": "Non sei autorizzato ad accedere a questa applicazione. Contatta l’amministratore.", + "rememberMe": "Ricordami" }, "mfa": { "title": "Autenticazione a Due Fattori", @@ -543,6 +546,7 @@ "autoComplete": "Complete automaticamente" }, "kanban": { + "status": "Stato", "priority": "Priorità", "score": "Punteggio", "assignee": "Assegnato a", @@ -572,6 +576,7 @@ "itemTitle": "Titolo elemento", "manageStatuses": "Gestisci stati", "viewArchived": "Visualizza archiviati", + "changeStatus": "Cambia stato", "noBoards": "Nessuna board trovata", "noBoardsYet": "Nessuna board Kanban ancora", "createFirstBoard": "Crea la tua prima board Kanban per gestire i tuoi progetti.", @@ -586,6 +591,13 @@ "targetDate": "Data obiettivo", "statusUpdated": "Stato aggiornato", "itemArchived": "Elemento archiviato", + "archiveAllItemsInColumn": "Archivia tutti gli elementi in {column}", + "archiveAllConfirmTitle": "Archiviare tutti gli elementi?", + "archiveAllConfirmMessage": "Archiviare tutti i {count} elementi in {column}? Potrai ripristinarli dall'archivio.", + "archiveAllSuccess": "{count} elementi archiviati", + "archiveAllFailed": "Impossibile archiviare tutti gli elementi", + "moveFailed": "Impossibile spostare la scheda", + "archiveAllPartial": "{archived} elementi su {total} archiviati. Alcuni elementi non sono stati archiviati.", "itemDeleted": "Elemento eliminato", "estimatedTime": "Tempo stimato (ore)", "actualVsEstimated": "{actual} / {estimated}", @@ -680,7 +692,27 @@ "connection_plural": "connessioni", "terminateSessionConfirm": "Sei sicuro di voler terminare questa sessione?", "terminateAllSessionsConfirm": "Sei sicuro di voler terminare tutte le altre sessioni?", - "validationErrorTitle": "Errore di Validazione" + "validationErrorTitle": "Errore di Validazione", + "visibleGraphSummary": "{items} elementi visibili, {links} collegamenti visibili", + "graphTruncated": "Visualizzazione dei {visible} elementi più collegati. {omitted} elementi meno collegati sono stati omessi.", + "totalConnections": "Totale", + "inboundConnections": "In entrata", + "outboundConnections": "In uscita", + "updated": "Aggiornato", + "openItem": "Apri elemento", + "selectItem": "Seleziona un elemento", + "inspectNodeHint": "Tocca o fai clic su un nodo per visualizzare titolo, categoria, numero di collegamenti e attività recente.", + "graphSearchPlaceholder": "Titolo, percorso, categoria o tag", + "orphans": "Orfani", + "labels": "Etichette", + "arrows": "Frecce", + "nodeSize": "Dimensione nodi", + "linkWidth": "Larghezza collegamenti", + "linkDistance": "Distanza collegamenti", + "repelForce": "Repulsione", + "tagLinks": "Collegamenti tag", + "linkedItems": "Elementi collegati", + "uuid": "UUID" }, "settings": { "title": "Impostazioni", @@ -836,6 +868,11 @@ "showStatusOnCards": "Mostra stato", "hideStatusOnCards": "Nascondi stato", "hideStatusOnCardsDescription": "Nasconde l'etichetta di stato dalle schede Kanban. Lo stato è già indicato dalla colonna in cui si trova la scheda.", + "hideMobileStatusDropdownLabel": "Menu a discesa stato mobile sulle schede", + "showMobileStatusDropdown": "Mostra menu mobile", + "hideMobileStatusDropdown": "Nascondi menu mobile", + "selectMobileStatusDropdown": "Seleziona comportamento del menu stato mobile", + "hideMobileStatusDropdownDescription": "Controlla il menu a discesa dello stato sempre visibile sulle schede Kanban mobili. L'impostazione dell'etichetta di stato è separata.", "selectStatusOnCards": "Seleziona visibilità stato", "codeBlockChoice": "Scegli lo stile del blocco di codice", "themedCodeBlock": "Blocco di codice a tema", diff --git a/app/_translations/klingon.json b/app/_translations/klingon.json index 96732f20..af160134 100644 --- a/app/_translations/klingon.json +++ b/app/_translations/klingon.json @@ -7,6 +7,8 @@ "view": "legh", "new": "chu'", "close": "SoQ", + "enlarge": "jInmoH", + "shrink": "Hom", "confirm": "pab", "loading": "Su'lI'...", "search": "nej", @@ -306,7 +308,8 @@ "createUser": "lo'wI' yIchenmoH", "attemptsRemaining": "ghuH: {count} logh neH bInIDlaH", "accountLocked": "bInIDqu'. {seconds} lup 'uy' wa'leS yInIDqa'.", - "notAuthorized": "bI'el 'e' chaw'be'lu'. ra'wI' yIghel." + "notAuthorized": "bI'el 'e' chaw'be'lu'. ra'wI' yIghel.", + "rememberMe": "qaqaw" }, "mfa": { "title": "cha'logh ngu'aS", @@ -568,6 +571,7 @@ "autoComplete": "nIteb rIn" }, "kanban": { + "status": "ghu'", "priority": "nom mIw", "score": "pong", "assignee": "ghu' ghoS", @@ -597,6 +601,7 @@ "itemTitle": "chel pong", "manageStatuses": "ghu'mey yIvu'", "viewArchived": "Pol yIlegh", + "changeStatus": "ghu' yIchoH", "noBoards": "beQmey lutu'lu'be'", "noBoardsYet": "Kanban beQmey not yet", "createFirstBoard": "Kanban beQ wa' yIchel 'ej QInmey yIvu'.", @@ -611,6 +616,13 @@ "targetDate": "jaj target", "statusUpdated": "choH pIt", "itemArchived": "Pol", + "archiveAllItemsInColumn": "{column} Hoch Dochmey pol", + "archiveAllConfirmTitle": "Hoch Dochmey pol?", + "archiveAllConfirmMessage": "{column}Daq {count} Dochmey Hoch pol? polmeH Daqvo' bIH DacheghmoHlaH.", + "archiveAllSuccess": "{count} Dochmey pol", + "archiveAllFailed": "Hoch Dochmey pollu'laHbe'", + "moveFailed": "navHom vIHmoHlu'laHbe'", + "archiveAllPartial": "{total}vo' {archived} Dochmey pol. Dochmey 'op pollu'laHbe'.", "itemDeleted": "Qaw'pu'", "estimatedTime": "pIq chartoq (rep)", "actualVsEstimated": "{actual} / {estimated}", @@ -705,7 +717,27 @@ "connection_plural": "rar", "terminateSessionConfirm": "poHvam DamejmoH DaneH'a'?", "terminateAllSessionsConfirm": "Hoch poHmey latlh DamejmoH DaneH'a'?", - "validationErrorTitle": "nuD Qagh" + "validationErrorTitle": "nuD Qagh", + "visibleGraphSummary": "{items} Dochmey legh, {links} rarmey legh", + "graphTruncated": "{visible} Dochmey rarqu' 'anglu'. {omitted} Dochmey rar puS lImlu'pu'.", + "totalConnections": "naQ", + "inboundConnections": "'el", + "outboundConnections": "mej", + "updated": "chu'qa'", + "openItem": "Doch poSmoH", + "selectItem": "Doch wIv", + "inspectNodeHint": "Doch yISaH 'ej pong, Segh, rar mI', latlh Qu' yIlegh.", + "graphSearchPlaceholder": "pong, He, Segh, per ghap", + "orphans": "mobwI'pu'", + "labels": "permey", + "arrows": "naQHommey", + "nodeSize": "Doch tIn", + "linkWidth": "rar 'ab", + "linkDistance": "rar chuq", + "repelForce": "chev", + "tagLinks": "per rarmey", + "linkedItems": "Dochmey rar", + "uuid": "UUID" }, "settings": { "title": "SeH", @@ -861,6 +893,11 @@ "showStatusOnCards": "ngoq legh", "hideStatusOnCards": "ngoq So'", "hideStatusOnCardsDescription": "Kanban ghItlhmey ngoq So'. ngoq DaH ram vIghItlh Daq legh.", + "hideMobileStatusDropdownLabel": "mobile ghu' menu ghItlhmeyDaq", + "showMobileStatusDropdown": "mobile menu yIcha'", + "hideMobileStatusDropdown": "mobile menu yISo'", + "selectMobileStatusDropdown": "mobile ghu' menu mIw yIwIv", + "hideMobileStatusDropdownDescription": "mobile Kanban ghItlhmeyDaq reH leghlu'bogh ghu' menu SeH. ngoq label setting latlh.", "selectStatusOnCards": "ngoq leghlaHghach wIv", "codeBlockChoice": "Choose the code block style", "themedCodeBlock": "Themed codeblock", diff --git a/app/_translations/ko.json b/app/_translations/ko.json index fcf4122e..593a7755 100644 --- a/app/_translations/ko.json +++ b/app/_translations/ko.json @@ -7,6 +7,8 @@ "view": "보기", "new": "새로 만들기", "close": "닫기", + "enlarge": "확대", + "shrink": "축소", "confirm": "확인", "loading": "로딩 중...", "search": "검색", @@ -306,7 +308,8 @@ "createUser": "사용자 생성", "attemptsRemaining": "경고: {count, plural, one {#회} other {#회}}의 시도가 남아 있습니다", "accountLocked": "실패 시도가 너무 많습니다. {seconds} {seconds, plural, one {초} other {초}} 후에 다시 시도하세요", - "notAuthorized": "접근 권한이 없습니다. 관리자에게 문의하세요." + "notAuthorized": "접근 권한이 없습니다. 관리자에게 문의하세요.", + "rememberMe": "로그인 상태 유지" }, "mfa": { "title": "2단계 인증 (2FA)", @@ -568,6 +571,7 @@ "autoComplete": "자동 완료" }, "kanban": { + "status": "상태", "priority": "우선순위", "score": "점수", "assignee": "담당자", @@ -597,6 +601,7 @@ "itemTitle": "항목 제목", "manageStatuses": "상태 관리", "viewArchived": "보관된 항목 보기", + "changeStatus": "상태 변경", "noBoards": "보드를 찾을 수 없습니다", "noBoardsYet": "아직 칸반 보드가 없습니다", "createFirstBoard": "프로젝트 관리를 위해 첫 번째 칸반 보드를 만드세요.", @@ -611,6 +616,13 @@ "targetDate": "목표일", "statusUpdated": "상태가 업데이트됨", "itemArchived": "항목이 보관됨", + "archiveAllItemsInColumn": "{column}의 모든 항목 보관", + "archiveAllConfirmTitle": "모든 항목을 보관할까요?", + "archiveAllConfirmMessage": "{column}의 항목 {count}개를 모두 보관할까요? 보관함에서 복원할 수 있습니다.", + "archiveAllSuccess": "항목 {count}개가 보관됨", + "archiveAllFailed": "모든 항목을 보관하지 못했습니다", + "moveFailed": "카드를 이동하지 못했습니다", + "archiveAllPartial": "항목 {total}개 중 {archived}개가 보관되었습니다. 일부 항목은 보관하지 못했습니다.", "itemDeleted": "항목이 삭제됨", "estimatedTime": "예상 시간(시간)", "actualVsEstimated": "{actual} / {estimated}", @@ -705,7 +717,27 @@ "connection_plural": "연결", "terminateSessionConfirm": "이 세션을 종료하시겠습니까?", "terminateAllSessionsConfirm": "다른 모든 세션을 종료하시겠습니까?", - "validationErrorTitle": "유효성 검사 오류" + "validationErrorTitle": "유효성 검사 오류", + "visibleGraphSummary": "표시된 항목 {items}개, 표시된 링크 {links}개", + "graphTruncated": "연결 수가 가장 많은 상위 {visible}개 항목을 표시합니다. 연결이 적은 {omitted}개 항목은 생략되었습니다.", + "totalConnections": "전체", + "inboundConnections": "수신", + "outboundConnections": "발신", + "updated": "업데이트됨", + "openItem": "항목 열기", + "selectItem": "항목 선택", + "inspectNodeHint": "노드를 탭하거나 클릭하면 제목, 카테고리, 링크 수, 최근 활동을 확인할 수 있습니다.", + "graphSearchPlaceholder": "제목, 경로, 카테고리 또는 태그", + "orphans": "고립 항목", + "labels": "레이블", + "arrows": "화살표", + "nodeSize": "노드 크기", + "linkWidth": "링크 두께", + "linkDistance": "링크 거리", + "repelForce": "척력", + "tagLinks": "태그 링크", + "linkedItems": "연결된 항목", + "uuid": "UUID" }, "settings": { "title": "설정", @@ -861,6 +893,11 @@ "showStatusOnCards": "상태 표시", "hideStatusOnCards": "상태 숨기기", "hideStatusOnCardsDescription": "칸반 카드에서 상태 레이블을 숨깁니다. 상태는 카드가 있는 열로 이미 표시됩니다.", + "hideMobileStatusDropdownLabel": "카드의 모바일 상태 드롭다운", + "showMobileStatusDropdown": "모바일 드롭다운 표시", + "hideMobileStatusDropdown": "모바일 드롭다운 숨기기", + "selectMobileStatusDropdown": "모바일 상태 드롭다운 동작 선택", + "hideMobileStatusDropdownDescription": "모바일 칸반 카드에서 항상 보이는 상태 드롭다운을 제어합니다. 상태 레이블 설정은 별도입니다.", "selectStatusOnCards": "상태 표시 여부 선택", "codeBlockChoice": "코드 블록 스타일을 선택하세요", "themedCodeBlock": "테마 코드블록", diff --git a/app/_translations/nl.json b/app/_translations/nl.json index bad88248..a27b40d4 100644 --- a/app/_translations/nl.json +++ b/app/_translations/nl.json @@ -7,6 +7,8 @@ "view": "Bekijken", "new": "Nieuw", "close": "Sluiten", + "enlarge": "Vergroten", + "shrink": "Verkleinen", "confirm": "Bevestigen", "loading": "Laden...", "search": "Zoeken", @@ -281,7 +283,8 @@ "createUser": "Gebruiker aanmaken", "attemptsRemaining": "Waarschuwing: {count, plural, one {# poging} other {# pogingen}} over voordat account wordt vergrendeld", "accountLocked": "Te veel mislukte pogingen. Probeer het opnieuw over {seconds} {seconds, plural, one {seconde} other {seconden}}", - "notAuthorized": "U bent niet geautoriseerd om toegang te krijgen tot deze applicatie. Neem contact op met uw beheerder." + "notAuthorized": "U bent niet geautoriseerd om toegang te krijgen tot deze applicatie. Neem contact op met uw beheerder.", + "rememberMe": "Aangemeld blijven" }, "mfa": { "title": "Twee-factorauthenticatie", @@ -543,6 +546,7 @@ "autoComplete": "Automatisch voltooien" }, "kanban": { + "status": "Status", "priority": "Prioriteit", "score": "Score", "assignee": "Toegewezen aan", @@ -572,6 +576,7 @@ "itemTitle": "Itemtitel", "manageStatuses": "Statussen beheren", "viewArchived": "Gearchiveerde bekijken", + "changeStatus": "Status wijzigen", "noBoards": "Geen borden gevonden", "noBoardsYet": "Nog geen Kanban-borden", "createFirstBoard": "Maak je eerste Kanban-bord om je projecten te beheren.", @@ -586,6 +591,13 @@ "targetDate": "Doeldatum", "statusUpdated": "Status bijgewerkt", "itemArchived": "Item gearchiveerd", + "archiveAllItemsInColumn": "Alle items in {column} archiveren", + "archiveAllConfirmTitle": "Alle items archiveren?", + "archiveAllConfirmMessage": "Alle {count} items in {column} archiveren? Je kunt ze herstellen vanuit het archief.", + "archiveAllSuccess": "{count} items gearchiveerd", + "archiveAllFailed": "Kon niet alle items archiveren", + "moveFailed": "Kon de kaart niet verplaatsen", + "archiveAllPartial": "{archived} van {total} items gearchiveerd. Sommige items konden niet worden gearchiveerd.", "itemDeleted": "Item verwijderd", "estimatedTime": "Geschatte tijd (uur)", "actualVsEstimated": "{actual} / {estimated}", @@ -680,7 +692,27 @@ "connection_plural": "verbindingen", "terminateSessionConfirm": "Weet u zeker dat u deze sessie wilt beëindigen?", "terminateAllSessionsConfirm": "Weet u zeker dat u alle andere sessies wilt beëindigen?", - "validationErrorTitle": "Validatiefout" + "validationErrorTitle": "Validatiefout", + "visibleGraphSummary": "{items} zichtbare items, {links} zichtbare links", + "graphTruncated": "De {visible} meest verbonden items worden weergegeven. {omitted} minder verbonden items zijn weggelaten.", + "totalConnections": "Totaal", + "inboundConnections": "Inkomend", + "outboundConnections": "Uitgaand", + "updated": "Bijgewerkt", + "openItem": "Item openen", + "selectItem": "Selecteer een item", + "inspectNodeHint": "Tik of klik op een knooppunt om titel, categorie, aantal links en recente activiteit te bekijken.", + "graphSearchPlaceholder": "Titel, pad, categorie of tag", + "orphans": "Verweesd", + "labels": "Labels", + "arrows": "Pijlen", + "nodeSize": "Knooppuntgrootte", + "linkWidth": "Lijndikte", + "linkDistance": "Lijnafstand", + "repelForce": "Afstoting", + "tagLinks": "Taglinks", + "linkedItems": "Gekoppelde items", + "uuid": "UUID" }, "settings": { "title": "Instellingen", @@ -836,6 +868,11 @@ "showStatusOnCards": "Status tonen", "hideStatusOnCards": "Status verbergen", "hideStatusOnCardsDescription": "Verbergt het statuslabel op Kanban-kaarten. De status is al zichtbaar via de kolom waarin de kaart zich bevindt.", + "hideMobileStatusDropdownLabel": "Mobiele statuskeuzelijst op kaarten", + "showMobileStatusDropdown": "Mobiele keuzelijst tonen", + "hideMobileStatusDropdown": "Mobiele keuzelijst verbergen", + "selectMobileStatusDropdown": "Gedrag van mobiele statuskeuzelijst selecteren", + "hideMobileStatusDropdownDescription": "Bepaalt de altijd zichtbare statuskeuzelijst op mobiele Kanban-kaarten. De instelling voor het statuslabel blijft apart.", "selectStatusOnCards": "Selecteer zichtbaarheid status", "codeBlockChoice": "Kies de codeblokstijl", "themedCodeBlock": "Thema-codeblok", diff --git a/app/_translations/pirate.json b/app/_translations/pirate.json index bc5e316b..81b5db9e 100644 --- a/app/_translations/pirate.json +++ b/app/_translations/pirate.json @@ -7,6 +7,8 @@ "view": "Spy", "new": "Fresh", "close": "Batten Down", + "enlarge": "Hoist the Sails", + "shrink": "Lower the Sails", "confirm": "Aye!", "loading": "Hoisting the sails...", "search": "Scour", @@ -306,7 +308,8 @@ "createUser": "Recruit Deckhand", "attemptsRemaining": "Warning: {count} tries left before we throw ye in the brig", "accountLocked": "To the brig with ye! Wait {seconds} seconds.", - "notAuthorized": "Ye lack the clearance! Talk to the Captain." + "notAuthorized": "Ye lack the clearance! Talk to the Captain.", + "rememberMe": "Remember me, matey" }, "mfa": { "title": "Two-Step Verification", @@ -568,6 +571,7 @@ "autoComplete": "Auto finish" }, "kanban": { + "status": "Condition", "priority": "What matters most", "score": "Booty score", "assignee": "Crew hand", @@ -597,6 +601,7 @@ "itemTitle": "Cargo title", "manageStatuses": "Manage conditions", "viewArchived": "Spy the hold", + "changeStatus": "Change condition", "noBoards": "No boards in sight", "noBoardsYet": "No Kanban boards yet", "createFirstBoard": "Create yer first Kanban board to chart yer projects.", @@ -611,6 +616,13 @@ "targetDate": "Target date", "statusUpdated": "Status be updated", "itemArchived": "Item sent to Davy Jones’s hold", + "archiveAllItemsInColumn": "Stow all items in {column}", + "archiveAllConfirmTitle": "Stow all items?", + "archiveAllConfirmMessage": "Stow all {count} items in {column}? Ye can haul them back from the archive.", + "archiveAllSuccess": "{count} items stowed", + "archiveAllFailed": "Could not stow all items", + "moveFailed": "Could not haul the card", + "archiveAllPartial": "{archived} of {total} items stowed. Some cargo could not be stowed.", "itemDeleted": "Item walked the plank", "estimatedTime": "Estimated hours o’ toil", "actualVsEstimated": "{actual} / {estimated}", @@ -705,7 +717,27 @@ "connection_plural": "lines", "terminateSessionConfirm": "End this voyage?", "terminateAllSessionsConfirm": "End all other voyages?", - "validationErrorTitle": "Customs Error" + "validationErrorTitle": "Customs Error", + "visibleGraphSummary": "{items} items in sight, {links} lines in sight", + "graphTruncated": "Showin' the top {visible} items by line count. {omitted} lesser-tied items be left ashore.", + "totalConnections": "All Hands", + "inboundConnections": "Incomin'", + "outboundConnections": "Outgoin'", + "updated": "Freshened", + "openItem": "Open booty", + "selectItem": "Pick an item, matey", + "inspectNodeHint": "Tap or click a node to spy its title, category, line count, and recent doings.", + "graphSearchPlaceholder": "Title, course, category, or mark", + "orphans": "Castaways", + "labels": "Markings", + "arrows": "Arrows", + "nodeSize": "Node heft", + "linkWidth": "Rope width", + "linkDistance": "Rope length", + "repelForce": "Shove off", + "tagLinks": "Mark lines", + "linkedItems": "Tied items", + "uuid": "UUID" }, "settings": { "title": "Rigging", @@ -861,6 +893,11 @@ "showStatusOnCards": "Show status", "hideStatusOnCards": "Stow status", "hideStatusOnCardsDescription": "Stows the status mark on yer Kanban scrolls. The status be already plain from the column the scroll sits in, ye scallywag.", + "hideMobileStatusDropdownLabel": "Mobile condition menu on scrolls", + "showMobileStatusDropdown": "Show mobile menu", + "hideMobileStatusDropdown": "Stow mobile menu", + "selectMobileStatusDropdown": "Choose mobile condition menu behavior", + "hideMobileStatusDropdownDescription": "Controls the always-visible condition menu on mobile Kanban scrolls. The status mark setting stays separate.", "selectStatusOnCards": "Pick status visibility", "codeBlockChoice": "Choose the code block style", "themedCodeBlock": "Themed codeblock", diff --git a/app/_translations/pl.json b/app/_translations/pl.json index ca113cae..044c0794 100644 --- a/app/_translations/pl.json +++ b/app/_translations/pl.json @@ -7,6 +7,8 @@ "view": "Podgląd", "new": "Nowy", "close": "Zamknij", + "enlarge": "Powiększ", + "shrink": "Pomniejsz", "confirm": "Potwierdź", "loading": "Ładowanie…", "search": "Szukaj", @@ -281,7 +283,8 @@ "createUser": "Utwórz użytkownika", "attemptsRemaining": "Uwaga: {count, plural, one {# próba} few {# próby} other {# prób}} pozostało przed zablokowaniem konta", "accountLocked": "Zbyt wiele nieudanych prób. Spróbuj ponownie za {seconds} {seconds, plural, one {sekundę} few {sekundy} other {sekund}}", - "notAuthorized": "Nie masz uprawnień dostępu do tej aplikacji. Skontaktuj się z administratorem." + "notAuthorized": "Nie masz uprawnień dostępu do tej aplikacji. Skontaktuj się z administratorem.", + "rememberMe": "Zapamiętaj mnie" }, "mfa": { "title": "Autoryzacja dwuskładnikowa", @@ -543,6 +546,7 @@ "autoComplete": "Automatyczne uzupełnienie" }, "kanban": { + "status": "Status", "priority": "Priorytet", "score": "Punkty", "assignee": "Przypisany do", @@ -572,6 +576,7 @@ "itemTitle": "Tytuł elementu", "manageStatuses": "Zarządzaj statusami", "viewArchived": "Pokaż zarchiwizowane", + "changeStatus": "Zmień status", "noBoards": "Nie znaleziono tablic", "noBoardsYet": "Brak tablic Kanban", "createFirstBoard": "Utwórz pierwszą tablicę Kanban, aby zarządzać projektami.", @@ -586,6 +591,13 @@ "targetDate": "Data docelowa", "statusUpdated": "Status zaktualizowany", "itemArchived": "Element zarchiwizowany", + "archiveAllItemsInColumn": "Archiwizuj wszystkie elementy w {column}", + "archiveAllConfirmTitle": "Zarchiwizować wszystkie elementy?", + "archiveAllConfirmMessage": "Zarchiwizować wszystkie elementy ({count}) w {column}? Możesz je przywrócić z archiwum.", + "archiveAllSuccess": "Zarchiwizowano elementy: {count}", + "archiveAllFailed": "Nie udało się zarchiwizować wszystkich elementów", + "moveFailed": "Nie udało się przenieść karty", + "archiveAllPartial": "Zarchiwizowano {archived} z {total} elementów. Niektórych elementów nie udało się zarchiwizować.", "itemDeleted": "Element usunięty", "estimatedTime": "Szacowany czas (godziny)", "actualVsEstimated": "{actual} / {estimated}", @@ -680,7 +692,27 @@ "connection_plural": "połączenia", "terminateSessionConfirm": "Czy na pewno chcesz zakończyć tę sesję?", "terminateAllSessionsConfirm": "Czy na pewno chcesz zakończyć wszystkie inne sesje?", - "validationErrorTitle": "Błąd walidacji" + "validationErrorTitle": "Błąd walidacji", + "visibleGraphSummary": "{items, plural, one {# widoczny element} few {# widoczne elementy} other {# widocznych elementów}}, {links, plural, one {# widoczne połączenie} few {# widoczne połączenia} other {# widocznych połączeń}}", + "graphTruncated": "Wyświetlono {visible} najczęściej połączonych elementów. Pominięto {omitted, plural, one {# słabiej połączony element} few {# słabiej połączone elementy} other {# słabiej połączonych elementów}}.", + "totalConnections": "Łącznie", + "inboundConnections": "Przychodzące", + "outboundConnections": "Wychodzące", + "updated": "Zaktualizowano", + "openItem": "Otwórz element", + "selectItem": "Wybierz element", + "inspectNodeHint": "Dotknij lub kliknij węzeł, aby zobaczyć tytuł, kategorię, liczbę połączeń i ostatnią aktywność.", + "graphSearchPlaceholder": "Tytuł, ścieżka, kategoria lub tag", + "orphans": "Osierocone", + "labels": "Etykiety", + "arrows": "Strzałki", + "nodeSize": "Rozmiar węzła", + "linkWidth": "Szerokość połączeń", + "linkDistance": "Odległość połączeń", + "repelForce": "Odpychanie", + "tagLinks": "Połączenia tagów", + "linkedItems": "Połączone elementy", + "uuid": "UUID" }, "settings": { "title": "Ustawienia", @@ -836,6 +868,11 @@ "showStatusOnCards": "Pokaż status", "hideStatusOnCards": "Ukryj status", "hideStatusOnCardsDescription": "Ukrywa etykietę statusu na kartach Kanban. Status jest już wskazany przez kolumnę, w której znajduje się karta.", + "hideMobileStatusDropdownLabel": "Mobilna lista statusu na kartach", + "showMobileStatusDropdown": "Pokaż listę mobilną", + "hideMobileStatusDropdown": "Ukryj listę mobilną", + "selectMobileStatusDropdown": "Wybierz zachowanie mobilnej listy statusu", + "hideMobileStatusDropdownDescription": "Steruje zawsze widoczną listą statusu na mobilnych kartach Kanban. Ustawienie etykiety statusu jest oddzielne.", "selectStatusOnCards": "Wybierz widoczność statusu", "codeBlockChoice": "Wybierz styl bloku kodu", "themedCodeBlock": "Tematyczny blok kodu", diff --git a/app/_translations/pt.json b/app/_translations/pt.json new file mode 100644 index 00000000..94eabb48 --- /dev/null +++ b/app/_translations/pt.json @@ -0,0 +1,1672 @@ +{ + "common": { + "save": "Guardar", + "cancel": "Cancelar", + "delete": "Eliminar", + "edit": "Editar", + "view": "Ver", + "new": "Novo", + "close": "Fechar", + "enlarge": "Ampliar", + "shrink": "Reduzir", + "confirm": "Confirmar", + "loading": "A carregar...", + "search": "Pesquisar", + "filter": "Filtrar", + "add": "Adicionar", + "remove": "Remover", + "yes": "Sim", + "no": "Não", + "ok": "OK", + "back": "Voltar", + "next": "Seguinte", + "previous": "Anterior", + "continue": "Continuar", + "submit": "Submeter", + "create": "Criar", + "update": "Atualizar", + "upload": "Carregar", + "download": "Transferir", + "export": "Exportar", + "import": "Importar", + "copy": "Copiar", + "copied": "Copiado para a área de transferência", + "copyFailed": "Falha ao copiar para a área de transferência", + "paste": "Colar", + "cut": "Cortar", + "undo": "Desfazer", + "redo": "Refazer", + "select": "Selecionar", + "selectAll": "Selecionar tudo", + "clear": "Limpar", + "reset": "Repor", + "apply": "Aplicar", + "settings": "Definições", + "profile": "Perfil", + "logout": "Terminar sessão", + "login": "Iniciar sessão", + "register": "Registar", + "home": "Início", + "dashboard": "Painel", + "admin": "Administrador", + "user": "Utilizador", + "users": "Utilizadores", + "name": "Nome", + "title": "Título", + "pinned": "Fixado", + "description": "Descrição", + "date": "Data", + "time": "Hora", + "status": "Estado", + "actions": "Ações", + "options": "Opções", + "help": "Ajuda", + "about": "Sobre", + "version": "Versão", + "theme": "Tema", + "language": "Idioma", + "password": "Palavra-passe", + "username": "Nome de utilizador", + "email": "E-mail", + "phone": "Telefone", + "address": "Morada", + "city": "Cidade", + "country": "País", + "error": "Erro", + "success": "Sucesso", + "warning": "Aviso", + "info": "Informação", + "required": "Obrigatório", + "optional": "Opcional", + "enabled": "Ativado", + "disabled": "Desativado", + "active": "Ativo", + "inactive": "Inativo", + "public": "Público", + "private": "Privado", + "shared": "Partilhado", + "owner": "Proprietário", + "created": "Criado", + "modified": "Modificado", + "deleted": "Eliminado", + "deleting": "A eliminar...", + "saving": "A guardar...", + "done": "Concluído", + "uploading": "A carregar...", + "open": "Abrir", + "enterPassword": "Introduzir palavra-passe", + "confirmPassword": "Confirmar palavra-passe", + "openFile": "Abrir ficheiro", + "downloadFile": "Transferir ficheiro", + "deleteFile": "Eliminar ficheiro", + "fileTooLarge": "O ficheiro é demasiado grande. O tamanho máximo é {maxSizeMB}MB. O seu ficheiro tem {fileSizeMB}MB.", + "uploadFailed": "Falha ao carregar", + "uploadFailedRetry": "Falha ao carregar. Por favor, tente novamente.", + "failedToDeleteFile": "Falha ao eliminar o ficheiro", + "previousMonth": "Mês anterior", + "nextMonth": "Mês seguinte", + "currentIcon": "Ícone atual", + "dropImageOrClick": "Arraste uma imagem ou clique para carregar", + "selectRecurrencePattern": "Selecionar padrão de recorrência", + "clearEndDate": "Limpar data de fim", + "selectStartDate": "Selecionar data de início", + "selectEndDateOptional": "Selecionar data de fim (opcional)", + "api": "API", + "appLogo": "Logótipo da aplicação", + "readingProgress": "Progresso de leitura", + "filesAndImages": "Ficheiros e imagens", + "uncategorized": "Sem categoria", + "keyword": "Palavra-chave", + "emoji": "Emoji", + "exampleKeyword": "reunião", + "exampleEmoji": "🤝", + "high": "Alto", + "medium": "Médio", + "low": "Baixo", + "collapseAll": "Recolher tudo", + "expandAll": "Expandir tudo", + "saveChanges": "Guardar alterações", + "all": "Tudo", + "items": "Itens", + "showAll": "Mostrar tudo", + "creating": "A criar...", + "createCategoryHeader": "Criar uma nova categoria", + "parentCategory": "Categoria principal (opcional)", + "noParent": "Sem principal (nível raiz)", + "categoryNamePlaceholder": "Introduzir nome da categoria...", + "notAllowedName": "{name} não é permitido. Por favor, escolha um nome diferente.", + "createCategory": "Criar categoria", + "selectCategory": "Selecionar categoria...", + "categoryWillBeCreatedIn": "A nova categoria será criada em:", + "clone": "Clonar", + "cloneItem": "Clonar {item}", + "selectCategoryForClone": "Selecione a categoria para onde pretende copiar este item:", + "rootLevel": "Nível raiz", + "newCategory": "Nova categoria", + "renameCategory": "Renomear categoria", + "deleteCategory": "Eliminar categoria", + "categoryName": "Nome da categoria", + "enterCategoryName": "Introduzir nome da categoria...", + "renaming": "A renomear...", + "rename": "Renomear", + "enterNewCategoryName": "Introduzir um novo nome para \"{categoryName}\"", + "filters": "Filtros", + "filterByCategories": "Filtrar por categorias...", + "byCategory": "Por categoria", + "recursive": "Recursivo", + "itemsPerPage": "Itens por página", + "perPage": "por página", + "page": "Página", + "pageOfPages": "{currentPage} de {totalPages}", + "searchPlaceholder": "Pesquisar notas e listas de verificação... (⌘K)", + "system": "Sistema", + "errors": "Erros", + "statistics": "Estatísticas", + "startDate": "Data de início", + "endDate": "Data de fim", + "category": "Categoria", + "exportJson": "Exportar JSON", + "exportCsv": "Exportar CSV", + "showingResults": "A mostrar {start}-{end} de {total}", + "dismiss": "Dispensar", + "cleaning": "A limpar...", + "noResultsFound": "Nenhum resultado encontrado", + "noResults": "Sem resultados", + "by": "por {owner}", + "unsavedChanges": "Alterações não guardadas", + "unsavedChangesDetected": "⚠️ Alterações não guardadas detetadas", + "unsavedChangesMessage": "Tem alterações não guardadas em {noteTitle}. Se sair agora, as suas alterações serão perdidas.", + "whatToDoWithUnsavedChanges": "O que pretende fazer com as alterações não guardadas?", + "discard": "Descartar", + "saveAndLeave": "Guardar e sair", + "thisNote": "esta nota", + "unpin": "Desafixar", + "pin": "Fixar", + "pinToHome": "Fixar no início", + "unpinFromHome": "Desafixar do início", + "archive": "Arquivar", + "confirmDeleteItem": "Tem a certeza de que pretende eliminar \"{itemTitle}\"?", + "sharedBy": "Partilhado por {sharer}", + "itemsCompleted": "{completedItems}/{totalItems} concluídos", + "releaseNotes": "Notas de versão v{version}", + "viewOnGithub": "Ver no GitHub", + "newVersion": "Nova versão: {version}", + "clickToSeeReleaseNotes": "Clique para ver as notas de versão {appName}", + "red": "Vermelho", + "orange": "Laranja", + "yellow": "Amarelo", + "green": "Verde", + "blue": "Azul", + "purple": "Roxo", + "pink": "Rosa", + "gray": "Cinzento", + "none": "Nenhum", + "auto": "Automático", + "unarchive": "Desarquivar", + "itemCount": "{count} itens", + "characterCount": "{count} caracteres", + "justNow": "Agora mesmo", + "completeAll": "Concluir tudo", + "resetAll": "Repor tudo", + "createdByOn": "Criado por {user} em {date}", + "lastModifiedByOn": "Última modificação por {user} em {date}", + "statusChanges": "Alterações de estado: {count}", + "createdBy": "Criado por {user}", + "lastModifiedBy": "Última modificação por {user}", + "minutesAgo": "{count, plural, one {há # minuto} other {há # minutos}}", + "hoursAgo": "{count, plural, one {há # hora} other {há # horas}}", + "daysAgo": "{count, plural, one {há # dia} other {há # dias}}", + "weeksAgo": "{count, plural, one {há # semana} other {há # semanas}}", + "monthsAgo": "{count, plural, one {há # mês} other {há # meses}}", + "yearsAgo": "{count, plural, one {há # ano} other {há # anos}}", + "closeWithoutAdding": "Fechar sem adicionar", + "adding": "A adicionar...", + "filterByStatus": "Filtrar por estado", + "noArchivedItems": "Sem itens arquivados", + "withThisStatus": "com este estado", + "archived": "Arquivado", + "restore": "Restaurar", + "checklist": "Lista de verificação", + "note": "Nota", + "read": "Lido", + "removeAll": "Remover tudo", + "noUsersFound": "Nenhum utilizador encontrado.", + "noCategoriesAvailable": "Sem categorias disponíveis", + "links": "Ligações", + "documentation": "Documentação", + "githubRepository": "Repositório GitHub", + "toggleSidebar": "Alternar barra lateral", + "openSearch": "Abrir pesquisa", + "closeSearch": "Fechar pesquisa", + "moreOptions": "Mais opções", + "history": "Histórico", + "disable": "Desativar", + "restoring": "A restaurar...", + "retry": "Tentar novamente", + "copiedToClipboard": "Copiado para a área de transferência", + "offline": "Sem ligação", + "connected": "Ligado", + "reconnecting": "A reconectar...", + "hide": "Ocultar", + "thisEntry": "esta entrada" + }, + "history": { + "noteHistory": "Histórico da nota", + "restore": "Restaurar", + "restoreVersion": "Restaurar versão", + "restoreConfirm": "Restaurar esta versão? O conteúdo atual será substituído.", + "noHistory": "Sem histórico disponível", + "noHistoryDescription": "Guarde a sua nota para criar um ponto no histórico.", + "loadMore": "Carregar mais", + "featureDisabled": "Histórico desativado", + "disabledAdmin": "O histórico de notas está atualmente desativado. Pode ativá-lo nas Definições de administrador > Editor.", + "disabledUser": "O histórico de notas está atualmente desativado. Contacte o seu administrador para ativar esta funcionalidade.", + "goToSettings": "Ir para as definições", + "manualSavesOnly": "O histórico é criado apenas em guardações manuais, não em guardação automática.", + "fetchError": "Falha ao carregar o histórico", + "restoreError": "Falha ao restaurar a versão", + "viewContent": "Ver conteúdo", + "viewDiff": "Ver alterações", + "actionCreate": "Criado", + "actionUpdate": "Atualizado", + "actionRename": "Renomeado", + "actionMove": "Movido", + "actionDelete": "Eliminado", + "actionInit": "Inicializado", + "actionUnknown": "Alterado" + }, + "auth": { + "loginTitle": "Iniciar sessão", + "setupTitle": "Configuração inicial", + "welcomeBack": "Bem-vindo de volta", + "signIn": "Inicie sessão na sua conta", + "signInWithOIDC": "Iniciar sessão com o fornecedor OIDC escolhido", + "signInButton": "Iniciar sessão", + "signInWithSSO": "Iniciar sessão com SSO", + "signingIn": "A iniciar sessão...", + "orContinueWith": "ou continuar com conta local", + "usernameLabel": "Nome de utilizador", + "passwordLabel": "Palavra-passe", + "confirmPasswordLabel": "Confirmar palavra-passe", + "loginButton": "Iniciar sessão", + "loggingIn": "A iniciar sessão...", + "invalidCredentials": "Nome de utilizador ou palavra-passe inválidos", + "sessionExpired": "A sua sessão expirou", + "setupComplete": "Configuração concluída", + "createAccount": "Criar a sua conta", + "errorOccurred": "Ocorreu um erro. Por favor, tente novamente.", + "enterUsername": "Introduzir o seu nome de utilizador", + "enterPassword": "Introduzir a sua palavra-passe", + "chooseUsername": "Escolher um nome de utilizador", + "choosePassword": "Escolher uma palavra-passe forte", + "confirmYourPassword": "Confirmar a sua palavra-passe", + "createAdminAccount": "Criar conta de administrador", + "creatingAccount": "A criar conta...", + "welcomeTo": "Bem-vindo ao {appName}", + "createAdminAccountDescription": "Crie a sua conta de administrador para começar", + "version": "Versão {version}", + "createUser": "Criar utilizador", + "attemptsRemaining": "Aviso: {count, plural, one {# tentativa restante} other {# tentativas restantes}} antes do bloqueio da conta", + "accountLocked": "Demasiadas tentativas falhadas. Por favor, tente novamente em {seconds} {seconds, plural, one {segundo} other {segundos}}", + "notAuthorized": "Não está autorizado a aceder a esta aplicação. Por favor, contacte o seu administrador.", + "rememberMe": "Lembrar-me" + }, + "mfa": { + "title": "Autenticação de dois fatores", + "enable": "Ativar 2FA", + "disable": "Desativar 2FA", + "enabled": "2FA ativado", + "disabled": "2FA desativado", + "status": "Estado do 2FA", + "setupTitle": "Configurar autenticação de dois fatores", + "setupDescription": "Leia o código QR abaixo com a sua aplicação de autenticação (Google Authenticator, Authy, etc.)", + "scanQrCode": "Leia este código QR com a sua aplicação de autenticação", + "manualEntry": "Ou introduza este código manualmente:", + "verifySetup": "Introduza o código de 6 dígitos da sua aplicação para verificar", + "recoveryCode": "Código de recuperação", + "recoveryCodeTitle": "O seu código de recuperação", + "recoveryCodeInfo": "Guarde este código num local seguro. Pode fornecê-lo a um administrador do sistema para recuperar a sua conta caso perca o acesso à sua aplicação de autenticação.", + "verifyTitle": "Introduzir código de autenticação", + "verifyDescription": "Introduza o código de 6 dígitos da sua aplicação de autenticação", + "verifyPlaceholder": "000000", + "useRecoveryCode": "Usar código de recuperação", + "invalidCode": "Código de autenticação inválido", + "invalidRecoveryCode": "Código de recuperação inválido", + "mfaRequired": "A autenticação de dois fatores é obrigatória", + "mfaEnabledSuccess": "Autenticação de dois fatores ativada com sucesso", + "mfaDisabledSuccess": "Autenticação de dois fatores desativada", + "tooManyAttempts": "Demasiadas tentativas falhadas. Por favor, tente mais tarde.", + "disableTitle": "Desativar autenticação de dois fatores", + "disableDescription": "Introduza o seu código 2FA para confirmar a desativação da autenticação de dois fatores", + "disableWarning": "Isto tornará a sua conta menos segura. Tem a certeza?", + "verifying": "A verificar...", + "verify": "Verificar", + "setupComplete": "Configuração concluída!", + "setupCompleteDescription": "A autenticação de dois fatores foi ativada para a sua conta. Certifique-se de guardar o seu código de recuperação!", + "enterCode": "Introduzir código", + "accountName": "Conta: {username}", + "step1": "Passo 1: Ler código QR", + "step2": "Passo 2: Verificar código", + "verificationCode": "Código de verificação", + "verificationSuccess": "Verificação bem-sucedida", + "useAuthenticatorCode": "Usar código de autenticação", + "regenerateRecoveryCode": "Regenerar código de recuperação", + "regenerateRecoveryCodeTitle": "Regenerar código de recuperação", + "regenerateRecoveryCodeWarning": "Isto invalidará o seu código de recuperação atual", + "regenerateRecoveryCodeDescription": "Introduza o seu código 2FA para gerar um novo código de recuperação. O seu código antigo deixará de funcionar.", + "recoveryCodeRegenerated": "Novo código de recuperação gerado", + "recoveryCodeRegeneratedInfo": "O seu novo código de recuperação foi gerado. Guarde-o num local seguro - esta é a única vez que o verá.", + "recoveryCodeRegeneratedSuccess": "Código de recuperação regenerado com sucesso", + "recoveryCodeProfileDescription": "Use este código para recuperar a conta caso perca o acesso à sua aplicação de autenticação", + "disableMfaForUser": "Desativar 2FA para este utilizador", + "recoveryCodeRequired": "O código de recuperação é obrigatório", + "failedToDisableMfa": "Falha ao desativar MFA", + "adminDisableMfa": "Desativar 2FA" + }, + "notes": { + "title": "Notas", + "newNote": "Nova nota", + "allNotes": "Todas as notas", + "recent": "Recentes", + "browseAndManage": "Navegar e gerir todas as suas notas", + "noNotesYet": "Ainda sem notas", + "createFirstNote": "Crie a sua primeira nota para começar a construir a sua base de conhecimentos.", + "createNewNote": "Criar nova nota", + "noNotesFound": "Nenhuma nota encontrada", + "tryAdjustingFilters": "Tente ajustar os filtros ou crie uma nova nota.", + "createNote": "Criar nota", + "editNote": "Editar nota", + "deleteNote": "Eliminar nota", + "noteTitle": "Título da nota", + "noteContent": "Conteúdo da nota", + "searchNotes": "Pesquisar notas", + "myNotes": "As minhas notas", + "sharedNotes": "Notas partilhadas", + "category": "Categoria", + "categories": "Categorias", + "uncategorized": "Sem categoria", + "newCategory": "Nova categoria", + "saveNote": "Guardar nota", + "discardChanges": "Descartar alterações", + "unsavedChanges": "Tem alterações não guardadas", + "drawioNoPreview": "Diagrama Draw.io (sem pré-visualização disponível)", + "excalidrawNoPreview": "Diagrama Excalidraw (sem pré-visualização disponível)", + "mermaidError": "Erro Mermaid:", + "referencedBy": "Referenciado por", + "noHeadingsFound": "Nenhum cabeçalho encontrado", + "contents": "Conteúdo", + "searchNotesAndChecklists": "Pesquisar notas e listas de verificação...", + "noResultsFound": "Nenhum resultado encontrado", + "startWritingAbove": "Comece a escrever a sua nota acima!", + "enterNoteTitle": "Introduzir título da nota...", + "byType": "Por tipo", + "publiclySharedNote": "Partilhado publicamente", + "copyRawContent": "Copiar markdown", + "quickSave": "Guardar rapidamente", + "noteNotFound": "Nota não encontrada", + "unarchiveNote": "Desarquivar nota", + "enterNoteName": "Introduzir nome da nota...", + "updateNote": "Atualizar nota", + "printSaveAsPdf": "Imprimir / Guardar como PDF", + "tableOfContents": "Índice", + "wordCount": "{count, plural, one {# palavra} other {# palavras}}", + "charCount": "{count, plural, one {# carácter} other {# caracteres}}", + "readingTime": "{count, plural, one {# minuto de leitura} other {# minutos de leitura}}", + "tags": "Etiquetas", + "noTagsFound": "Nenhuma etiqueta encontrada", + "searchTags": "Pesquisar etiquetas..." + }, + "checklists": { + "title": "Listas de verificação", + "allChecklists": "Todas as listas de verificação", + "completed": "Concluídas", + "incomplete": "Incompletas", + "pinned": "Fixadas", + "taskLists": "Listas de tarefas", + "simpleLists": "Listas simples", + "browseAndManage": "Navegar e gerir todas as suas listas de verificação", + "byStatus": "Por estado", + "lists": "Listas", + "progress": "Progresso", + "totalItems": "Total de itens", + "noChecklistsYet": "Ainda sem listas de verificação", + "createFirstChecklist": "Crie a sua primeira lista de verificação para começar a organizar as suas tarefas.", + "newChecklist": "Nova lista de verificação", + "noChecklistsFound": "Nenhuma lista de verificação encontrada", + "tryAdjustingFiltersChecklist": "Tente ajustar os filtros ou crie uma nova lista de verificação.", + "createChecklist": "Criar lista de verificação", + "editChecklist": "Editar lista de verificação", + "deleteChecklist": "Eliminar lista de verificação", + "checklistTitle": "Título da lista de verificação", + "searchChecklists": "Pesquisar listas de verificação", + "myChecklists": "As minhas listas de verificação", + "sharedChecklists": "Listas de verificação partilhadas", + "addItem": "Adicionar item", + "removeItem": "Remover item", + "checkItem": "Marcar item", + "uncheckItem": "Desmarcar item", + "pending": "Pendente", + "loadingChecklist": "A carregar lista de verificação...", + "doNotRefresh": "Não atualize a página.", + "addNewItem": "Adicionar novo item...", + "bulkAddItems": "Adicionar itens em massa", + "bulk": "Em massa", + "addRecurringItem": "Adicionar item recorrente", + "addNewTask": "Adicionar nova tarefa...", + "publiclyShared": "Partilhado publicamente", + "subtasks": "Subtarefas", + "overallProgress": "Progresso geral", + "emptyChecklist": "Esta lista de verificação está vazia.", + "enterTaskTitle": "Introduzir título da tarefa...", + "addSubtask": "Adicionar uma subtarefa...", + "noteTitle": "Título da nota...", + "searchArchivedItems": "Pesquisar itens arquivados...", + "recent": "Listas de verificação recentes", + "checklistName": "Nome da lista de verificação", + "checklistNamePlaceholder": "Introduzir nome da lista de verificação...", + "createChecklistHeader": "Criar uma nova lista de verificação", + "allLists": "Todas as listas", + "addItemWithRecurrence": "Adicionar item com recorrência", + "recurringItemText": "Texto do item recorrente", + "recurringItemTextPlaceholder": "Introduzir o texto do item recorrente", + "checklistType": "Tipo de lista de verificação", + "simpleChecklist": "Lista de verificação simples", + "taskProject": "Projeto de tarefas", + "kanbanBoard": "Kanban", + "basicTodoItems": "Itens básicos a fazer", + "withTimeTracking": "Com controlo de tempo", + "noDescription": "Sem descrição", + "untitledTask": "Tarefa sem título", + "addDescriptionOptional": "Adicionar uma descrição (opcional)...", + "statusName": "Nome do estado", + "statusColor": "Cor do estado", + "archivedItems": "Itens arquivados", + "addSubItem": "Adicionar subitem", + "addSubItemPlaceholder": "Adicionar subitem...", + "checklistNotFound": "Lista de verificação não encontrada", + "unarchiveChecklist": "Desarquivar lista de verificação", + "enterChecklistName": "Introduzir nome da lista de verificação...", + "updating": "A atualizar...", + "updateChecklist": "Atualizar lista de verificação", + "checkAll": "Marcar tudo", + "uncheckAll": "Desmarcar tudo", + "clearAll": "Limpar tudo", + "checkAllConfirmTitle": "Marcar todos os itens?", + "checkAllConfirmMessage": "Tem a certeza de que pretende marcar todos os {count} itens e os seus filhos? Isto marcá-los-á como concluídos.", + "uncheckAllConfirmTitle": "Desmarcar todos os itens?", + "uncheckAllConfirmMessage": "Tem a certeza de que pretende desmarcar todos os {count} itens e os seus filhos? Isto marcá-los-á como incompletos.", + "clearAllConfirmTitle": "Limpar todos os itens?", + "clearAllCompletedConfirmMessage": "Tem a certeza de que pretende eliminar todos os {count} itens concluídos e os seus filhos? Esta ação não pode ser desfeita.", + "clearAllIncompleteConfirmMessage": "Tem a certeza de que pretende eliminar todos os {count} itens incompletos e os seus filhos? Esta ação não pode ser desfeita.", + "convertChecklistType": "Converter tipo de lista de verificação", + "convertTypeConfirmation": "Está prestes a converter de {currentType} para {newType}. Tem a certeza de que pretende continuar?", + "dataLossWarning": "⚠️ Aviso de perda de dados", + "dataLossWarningItems": "Os seguintes dados serão perdidos:", + "allTaskStatusesReset": "Todos os estados das tarefas serão repostos", + "timeTrackingDataLost": "Os dados de controlo de tempo serão perdidos", + "estimatedTimesRemoved": "Os tempos estimados serão removidos", + "targetDatesCleared": "As datas alvo serão eliminadas", + "enhancedFeatures": "✨ Funcionalidades melhoradas", + "enhancedFeaturesYouWillGain": "Vai ganhar:", + "kanbanBoardWithDragDrop": "Quadro Kanban com arrastar e largar", + "taskStatusTracking": "Controlo de estado de tarefas (A fazer, Em progresso, etc.)", + "timeTrackingWithTimer": "Controlo de tempo com temporizador integrado", + "actionCannotBeUndone": "Esta ação não pode ser desfeita. Tem a certeza de que pretende converter?", + "convertAndLoseData": "Converter e perder dados", + "convert": "Converter", + "convertToSimpleChecklist": "Converter para lista de verificação simples", + "convertToTaskProject": "Converter para projeto de tarefas", + "convertToKanbanBoard": "Converter para Kanban", + "pasteYourList": "Cole a sua lista (um item por linha)", + "addItems": "Adicionar {count} itens", + "itemsWillBeAdded": "{count} itens serão adicionados", + "itemsNotAddedYet": "Itens ainda não adicionados", + "youHaveItemsThatWillNotBeAdded": "Tem {count} itens que ainda não foram adicionados. Fechar sem adicionar ou adicioná-los agora?", + "noItemsYet": "Ainda sem itens", + "addFirstItem": "Adicione o seu primeiro item para começar!", + "noTasks": "Sem tarefas", + "noSubtasksYet": "Ainda sem subtarefas. Adicione uma abaixo para começar.", + "addTime": "Adicionar tempo", + "enterTimeInMinutes": "Introduzir o seu tempo em minutos" + }, + "tasks": { + "title": "Tarefas", + "addTask": "Adicionar tarefa", + "createTask": "Criar tarefa", + "editTask": "Editar tarefa", + "deleteTask": "Eliminar tarefa", + "viewTask": "Ver tarefa", + "addSubtask": "Adicionar subtarefa", + "renameTask": "Renomear tarefa", + "archiveTask": "Arquivar tarefa", + "taskTitle": "Título da tarefa", + "taskDescription": "Descrição da tarefa", + "noTasks": "Nenhuma tarefa encontrada", + "searchTasks": "Pesquisar tarefas", + "allTasks": "Todas as tarefas", + "myTasks": "As minhas tarefas", + "assignedTasks": "Tarefas atribuídas", + "dueDate": "Data de entrega", + "priority": "Prioridade", + "assignee": "Responsável", + "status": "Estado", + "completed": "Concluída", + "inProgress": "Em progresso", + "todo": "A fazer", + "overdue": "Em atraso", + "browseAndManageTaskLists": "Navegar e gerir todas as suas listas de tarefas", + "noTaskListsYet": "Ainda sem listas de tarefas", + "createFirstTaskList": "Crie a sua primeira lista de tarefas para começar a gerir os seus projetos.", + "newTaskList": "Nova lista de tarefas", + "taskLists": "Listas de tarefas", + "noTaskListsFound": "Nenhuma lista de tarefas encontrada", + "tryAdjustingFiltersTaskList": "Tente ajustar os filtros ou crie uma nova lista de tarefas.", + "totalTime": "Tempo total:", + "recentTasks": "Tarefas recentes", + "showAllTasks": "Mostrar todas as tarefas", + "manageStatuses": "Gerir estados", + "viewArchived": "Ver arquivados", + "customizeStatuses": "Personalize os estados para este quadro kanban. Adicione, remova ou reordene os estados.", + "addStatus": "Adicionar estado", + "autoComplete": "Conclusão automática" + }, + "kanban": { + "status": "Estado", + "changeStatus": "Alterar estado", + "archiveAllItemsInColumn": "Arquivar todos os itens em {column}", + "archiveAllConfirmTitle": "Arquivar todos os itens?", + "archiveAllConfirmMessage": "Arquivar todos os {count} itens em {column}? Pode restaurá-los a partir do arquivo.", + "archiveAllSuccess": "{count} itens arquivados", + "archiveAllFailed": "Não foi possível arquivar todos os itens", + "archiveAllPartial": "{archived} de {total} itens arquivados. Não foi possível arquivar alguns itens.", + "moveFailed": "Não foi possível mover o cartão", + "priority": "Prioridade", + "score": "Pontuação", + "assignee": "Responsável", + "reminder": "Lembrete", + "usernamePlaceholder": "nome de utilizador", + "scoreLabel": "Pontuação: {score}", + "today": "Hoje", + "exportIcs": "Exportar .ics", + "unscheduled": "Não agendado ({count})", + "moreEvents": "+{count} mais", + "critical": "Crítico", + "high": "Alto", + "medium": "Médio", + "low": "Baixo", + "none": "Nenhum", + "weekdaysSun": "Dom", + "weekdaysMon": "Seg", + "weekdaysTue": "Ter", + "weekdaysWed": "Qua", + "weekdaysThu": "Qui", + "weekdaysFri": "Sex", + "weekdaysSat": "Sáb", + "title": "Kanban", + "calendar": "Calendário", + "boards": "Quadros", + "addItem": "Adicionar item", + "itemTitle": "Título do item", + "manageStatuses": "Gerir estados", + "viewArchived": "Ver arquivados", + "noBoards": "Nenhum quadro encontrado", + "noBoardsYet": "Ainda sem quadros kanban", + "createFirstBoard": "Crie o seu primeiro quadro kanban para começar a gerir os seus projetos.", + "newBoard": "Novo quadro", + "tryAdjustingFilters": "Tente ajustar os filtros ou crie um novo quadro.", + "completed": "Concluído", + "todo": "A fazer", + "inProgress": "Em progresso", + "recentBoards": "Quadros recentes", + "showAllBoards": "Mostrar todos os quadros", + "unassigned": "Não atribuído", + "targetDate": "Data alvo", + "statusUpdated": "Estado atualizado", + "itemArchived": "Item arquivado", + "itemDeleted": "Item eliminado", + "estimatedTime": "Tempo estimado (horas)", + "actualVsEstimated": "{actual} / {estimated}", + "tempo": "Ritmo", + "noItemsFound": "Nenhum item corresponde aos filtros atuais", + "searchPlaceholder": "Pesquisar itens...", + "allPriorities": "Todas as prioridades", + "allAssignees": "Todos os responsáveis", + "timeEntries": "Registos de tempo", + "noTimeEntries": "Ainda sem registos de tempo", + "editSessions": "Editar sessões", + "session": "sessão", + "sessions": "sessões", + "startTime": "Início", + "endTime": "Fim", + "addItemToColumn": "Adicionar item a {column}", + "addItemPlaceholder": "Novo item..." + }, + "profile": { + "userProfile": "Utilizador", + "manageAccount": "Gerir as definições e preferências da sua conta", + "profileTab": "Perfil", + "sessionsTab": "Sessões", + "archiveTab": "Arquivo", + "connectionsTab": "Ligações", + "encryptionTab": "Encriptação (beta)", + "userPreferencesTab": "Preferências do utilizador", + "userLogsTab": "Registos do utilizador", + "userSettings": "Definições do utilizador", + "deviceSettings": "Definições rápidas", + "quickSettingsHeader": "Definições rápidas (apenas dispositivo)", + "adminSettings": "Painel de administração", + "apiKey": "Chave API", + "apiKeyDescription": "Gerar uma chave API para acesso programático às suas listas de verificação e notas", + "hideApiKey": "Ocultar chave API", + "showApiKey": "Mostrar chave API", + "copyApiKey": "Copiar chave API", + "regenerateApiKey": "Regenerar chave API", + "generateApiKey": "Gerar chave API", + "generating": "A gerar...", + "generate": "Gerar", + "memberSince": "Membro desde", + "userType": "Tipo de utilizador", + "usernameUpdateWarning": "Atualizar o seu nome de utilizador irá terminar a sessão e será necessário iniciar sessão novamente.", + "enterCurrentPassword": "Introduzir palavra-passe atual", + "enterNewPassword": "Introduzir nova palavra-passe", + "confirmNewPassword": "Confirmar nova palavra-passe", + "saveProfile": "Guardar perfil", + "removeAvatar": "Remover avatar", + "usernameRequired": "O nome de utilizador é obrigatório", + "passwordsDoNotMatch": "As novas palavras-passe não coincidem", + "profileUpdated": "Perfil atualizado com sucesso!", + "failedToUpdateProfile": "Falha ao atualizar o perfil", + "failedToUpdateProfileRetry": "Falha ao atualizar o perfil. Por favor, tente novamente.", + "avatarUpdated": "Avatar atualizado com sucesso!", + "failedToUpdateAvatar": "Falha ao atualizar o avatar", + "failedToUpdateAvatarRetry": "Falha ao atualizar o avatar. Por favor, tente novamente.", + "avatarRemoved": "Avatar removido com sucesso!", + "failedToRemoveAvatar": "Falha ao remover o avatar", + "failedToRemoveAvatarRetry": "Falha ao remover o avatar. Por favor, tente novamente.", + "activeSessions": "Sessões ativas", + "activeSession": "{count} sessão ativa", + "activeSession_plural": "{count} sessões ativas", + "terminateAllOthers": "Terminar todas as outras", + "avatar": "Avatar", + "uploadProfilePicture": "Carregar uma fotografia de perfil (PNG, JPG, WebP até 5MB)", + "unknown": "Desconhecido", + "yourUsername": "O seu nome de utilizador", + "archivedContent": "Conteúdo arquivado", + "archivedItemsCount": "{archivedItemsCount} arquivados", + "noArchivedContent": "Nenhum conteúdo arquivado encontrado", + "noArchivedItemsFound": "Nenhum item arquivado encontrado correspondente a "{searchQuery}"", + "linkNetwork": "Rede de ligações", + "settingsDashboard": "Painel de definições", + "auditLogsTab": "Registos do utilizador", + "rebuildLinkIndexes": "Reconstruir índices de ligações para atualizar dados de ligação", + "rebuildingLinkIndex": "A reconstruir índice de ligações...", + "successfullyRebuiltIndex": "Índice de ligações reconstruído com sucesso", + "successfullyRebuiltIndexReload": "Índice de ligações reconstruído com sucesso! A página será recarregada para mostrar as ligações atualizadas.", + "failedToRebuildIndex": "Falha ao reconstruir o índice de ligações. Por favor, tente novamente.", + "contentLinks": "Ligações de conteúdo", + "visualizeRelationships": "Visualizar relações entre as suas notas e listas de verificação", + "connectedItems": "Itens ligados", + "noLinksFound": "Nenhuma ligação encontrada", + "startCreatingInternalLinks": "Comece a criar ligações internas digitando `@` nas suas notas para ver aqui a rede de relações.", + "orFormat": "ou", + "inYourContent": "no seu conteúdo.", + "highlyConnected": "muito ligado", + "moderatelyConnected": "moderadamente ligado", + "isolated": "isolado", + "connection": "ligação", + "connection_plural": "ligações", + "terminateSessionConfirm": "Tem a certeza de que pretende terminar esta sessão?", + "terminateAllSessionsConfirm": "Tem a certeza de que pretende terminar todas as outras sessões?", + "validationErrorTitle": "Erro de validação", + "visibleGraphSummary": "{items} itens visíveis, {links} ligações visíveis", + "graphTruncated": "A mostrar os {visible} itens com mais ligações. {omitted} itens com menos ligações foram omitidos.", + "totalConnections": "Total", + "inboundConnections": "Entrada", + "outboundConnections": "Saída", + "updated": "Atualizado", + "openItem": "Abrir item", + "selectItem": "Selecionar um item", + "inspectNodeHint": "Toque ou clique num nó para ver o título, a categoria, o número de ligações e a atividade recente.", + "graphSearchPlaceholder": "Título, caminho, categoria ou etiqueta", + "orphans": "Órfãos", + "labels": "Rótulos", + "arrows": "Setas", + "nodeSize": "Tamanho do nó", + "linkWidth": "Largura das ligações", + "linkDistance": "Distância das ligações", + "repelForce": "Repulsão", + "tagLinks": "Ligações de etiquetas", + "linkedItems": "Itens ligados", + "uuid": "UUID" + }, + "settings": { + "hideMobileStatusDropdownLabel": "Menu de estado móvel nos cartões", + "showMobileStatusDropdown": "Mostrar menu móvel", + "hideMobileStatusDropdown": "Ocultar menu móvel", + "selectMobileStatusDropdown": "Selecionar comportamento do menu de estado móvel", + "hideMobileStatusDropdownDescription": "Controla o menu de estado sempre visível nos cartões Kanban em dispositivos móveis. A definição da etiqueta de estado é separada.", + "title": "Definições", + "general": "Geral", + "saveGeneral": "Guardar geral", + "appearance": "Aparência", + "account": "Conta", + "security": "Segurança", + "notifications": "Notificações", + "privacy": "Privacidade", + "advanced": "Avançado", + "preferredLanguage": "Idioma preferido", + "selectLanguage": "Selecionar idioma", + "choosePreferredLanguage": "Escolha o seu idioma preferido", + "customTranslationsInfo": "Quer adicionar o seu próprio idioma?", + "preferredTheme": "Tema preferido", + "loadingThemes": "A carregar temas...", + "selectTheme": "Selecionar um tema", + "choosePreferredTheme": "Escolha o seu tema preferido em todos os dispositivos.", + "initialLandingPage": "Página inicial", + "selectLandingPage": "Selecionar página inicial", + "selectDefaultPageAfterLogin": "Selecione a página predefinida a carregar após iniciar sessão.", + "fileRenameMode": "Modo de renomear ficheiros", + "selectFileRenameMode": "Selecionar modo de renomear ficheiros", + "chooseFileRenameMode": "Escolha como os ficheiros são renomeados ao guardar notas e listas de verificação.", + "preferredDateFormat": "Formato de data preferido", + "selectDateFormat": "Selecionar formato de data", + "choosePreferredDateFormat": "Escolha o seu formato de data preferido.", + "preferredTimeFormat": "Formato de hora preferido", + "selectTimeFormat": "Selecionar formato de hora", + "choosePreferredTimeFormat": "Escolha o seu formato de hora preferido.", + "handedness": "Mão dominante", + "selectHandedness": "Selecionar mão dominante", + "chooseHandedness": "Escolha se é destro ou esquerdino.", + "rightHanded": "Destro", + "leftHanded": "Esquerdino", + "notesPreferences": "Preferências de notas", + "saveEditor": "Guardar editor", + "autoSaveInterval": "Intervalo de guardação automática", + "selectAutoSaveInterval": "Selecionar intervalo de guardação automática", + "chooseAutoSaveInterval": "Escolha o intervalo para a guardação automática das suas notas.", + "defaultMode": "Modo predefinido", + "selectNotesDefaultMode": "Selecionar modo predefinido das notas", + "chooseNotesDefaultMode": "Escolha se a nota está automaticamente em modo de edição ou não {mode} no editor de notas).", + "defaultEditor": "Editor predefinido", + "selectNotesDefaultEditor": "Selecionar editor predefinido das notas", + "chooseDefaultEditor": "Escolha o editor predefinido para as suas notas - (pode sempre alternar clicando no botão {editor} no editor de notas).", + "tableSyntaxInNotes": "Sintaxe de tabela nas notas", + "selectTableSyntax": "Selecionar sintaxe de tabela", + "chooseTableSyntax": "Escolha como as tabelas são apresentadas nas suas notas.", + "markdownSyntaxTheme": "Tema de sintaxe Markdown", + "selectSyntaxTheme": "Selecionar tema de sintaxe", + "chooseSyntaxTheme": "Escolha o tema de realce de sintaxe para o editor markdown.", + "minimalMode": "Modo mínimo (desativar editor de texto enriquecido)", + "selectMinimalMode": "Selecionar modo mínimo", + "minimalModeDescription": "Quando ativado, utiliza um processador markdown simples em vez do editor de texto enriquecido. Funcionalidades avançadas como ligações bilaterais não funcionarão neste modo.", + "defaultNoteFilter": "Filtro de notas predefinido", + "selectDefaultNoteFilter": "Selecionar filtro de notas predefinido", + "chooseDefaultNoteFilter": "Escolha o filtro predefinido a aplicar ao abrir a página de Notas.", + "checklistsPreferences": "Preferências de listas de verificação", + "saveChecklists": "Guardar listas de verificação", + "recurringChecklists": "Listas de verificação recorrentes", + "beta": "(Beta)", + "selectEnableRecurring": "Selecionar ativar para adicionar listas de verificação recorrentes", + "showCompletedSuggestions": "Mostrar tarefas concluídas como sugestões", + "selectShowCompletedSuggestions": "Selecionar se deve mostrar tarefas concluídas como sugestões", + "completedSuggestionsDescription": "Ao adicionar novas tarefas, mostrar tarefas concluídas como sugestões que podem ser reativadas.", + "defaultChecklistFilter": "Filtro de lista de verificação predefinido", + "selectDefaultChecklistFilter": "Selecionar filtro de lista de verificação predefinido", + "chooseDefaultChecklistFilter": "Escolha o filtro predefinido a aplicar ao abrir a página de Listas de verificação.", + "accountManagement": "Gestão de conta", + "deleteAccount": "Eliminar conta", + "deleteAccountDescription": "Eliminar permanentemente a sua conta e todos os dados associados", + "disabledInDemoMode": "desativado no modo de demonstração", + "saveSettings": "Guardar definições", + "resetSettings": "Repor definições", + "exportData": "Exportar dados", + "importData": "Importar dados", + "changePassword": "Alterar palavra-passe", + "currentPassword": "Palavra-passe atual", + "newPassword": "Nova palavra-passe", + "confirmPassword": "Confirmar palavra-passe", + "passwordChanged": "Palavra-passe alterada com sucesso", + "settingsSaved": "Definições guardadas com sucesso", + "dashCase": "Com hífens (ex.: meu-ficheiro.md)", + "minimal": "Mínimo (remover apenas caracteres inválidos)", + "noRename": "Sem renomear (manter nome de ficheiro original)", + "lastVisitedPage": "Última página visitada", + "disabled": "Desativado", + "seconds": "{count} segundo", + "seconds_plural": "{count} segundos", + "hours12": "12 horas", + "hours24": "24 horas", + "useSystemDefault": "Usar predefinição do sistema", + "markdownTableSyntax": "Markdown", + "htmlTableSyntax": "HTML", + "default": "Predefinido", + "dark": "Escuro", + "funky": "Funky", + "okaidia": "Okaidia", + "tomorrow": "Tomorrow", + "twilight": "Twilight", + "coy": "Coy", + "solarizedLight": "Solarized Light", + "richTextEditor": "Editor de texto enriquecido", + "markdown": "Markdown", + "edit": "Editar", + "view": "Ver", + "enable": "Ativar", + "disable": "Desativar", + "useRichTextEditor": "Usar editor de texto enriquecido", + "markdownOnly": "Apenas Markdown", + "quickCreateNotes": "Criação rápida de notas", + "selectQuickCreateNotes": "Selecionar criação rápida de notas", + "chooseQuickCreateNotes": "Escolha o modo de criação rápida de notas.", + "quickCreateNotesShowModal": "Mostrar modal ao criar notas", + "quickCreateNotesSkipModal": "Ignorar modal e criar instantaneamente", + "quickCreateNotesDescription": "Quando ativado, criar uma nova nota ignora o modal de criação e leva-o diretamente ao editor de notas.", + "defaultCategory": "Categoria predefinida para criação rápida de notas", + "selectDefaultCategory": "Selecionar categoria predefinida...", + "defaultCategoryDescription": "As novas notas serão criadas nesta categoria ao usar a criação rápida.", + "customTheme": { + "cardForegroundColor": "Este texto utiliza a cor de primeiro plano do cartão", + "cardTitle": "Título do cartão", + "focusToSeeRingColor": "Focar para ver a cor do anel", + "sampleNoteTitle": "Ideias de projeto", + "sampleNoteCategory": "trabalho/projetos", + "sampleNoteContent": "# Brainstorm de projeto\n\n## Funcionalidades da aplicação móvel\n- **Alternar modo escuro** com transições suaves\n- **Suporte offline** para funcionalidades essenciais\n- **Notificações push** para lembretes\n\n## Melhorias no backend\n- **Limitação de taxa da API** para evitar abuso\n- **Otimização de base de dados** para consultas mais rápidas\n- **Camada de cache** para melhor desempenho\n\n*Esta é apenas uma pré-visualização do conteúdo da nota...*", + "searchPlaceholder": "Pesquisar notas e listas de verificação...", + "weeklyProgress": "Progresso semanal", + "tasksProgress": "7/10 tarefas", + "tasksCompleted": "7 concluídas", + "tasksRemaining": "3 restantes", + "primaryAction": "Ação principal", + "destructiveAction": "Destrutivo", + "secondaryAction": "Secundário", + "ghostAction": "Fantasma", + "successMessage": "Mensagem de sucesso", + "errorMessage": "Mensagem de erro", + "activeTab": "Separador ativo", + "inactiveTab": "Separador inativo", + "calendarDay": "Dia do calendário", + "sampleInputLabel": "Exemplo de entrada", + "sampleInputPlaceholder": "Introduzir algum texto..." + }, + "hideConnectionIndicatorDescription": "Ocultar o indicador de ligação websocket do logótipo. Continuará a ver um ponto vermelho se estiver sem ligação, independentemente desta definição.", + "selectConnectionIndicator": "Selecionar estado do indicador de ligação", + "showIndicator": "Mostrar indicador", + "hideIndicator": "Ocultar indicador", + "hideConnectionIndicator": "Ocultar indicador de ligação do logótipo", + "kanbanSection": "Preferências Kanban", + "saveKanban": "Guardar Kanban", + "hideStatusOnCardsLabel": "Estado nos cartões Kanban", + "showStatusOnCards": "Mostrar estado", + "hideStatusOnCards": "Ocultar estado", + "hideStatusOnCardsDescription": "Oculta a etiqueta de estado dos itens dos cartões Kanban. O estado já é indicado pela coluna onde o cartão está colocado.", + "selectStatusOnCards": "Selecionar visibilidade do estado", + "codeBlockChoice": "Escolher o estilo do bloco de código", + "defaultCodeBlock": "Codepen (predefinido)", + "themedCodeBlock": "Bloco de código temático", + "codeBlockChoiceDescription": "Escolha entre o estilo de bloco de código predefinido semelhante ao codepen ou um estilo de bloco de código temático.", + "checklistItemClickAction": "Ação ao clicar em item de lista de verificação", + "selectChecklistItemClickAction": "Selecionar ação ao clicar em item de lista de verificação", + "checklistItemClickActionDescription": "Escolha o que acontece quando clica no texto de uma tarefa. \"Marcar como concluída\" alterna a caixa de verificação (predefinido); \"Editar nome da tarefa\" abre a tarefa para edição inline — ainda precisará de clicar na própria caixa de verificação para a concluir.", + "checklistItemClickActionToggle": "Marcar como concluída/incompleta", + "checklistItemClickActionEdit": "Editar item da lista" + }, + "admin": { + "title": "Administrador", + "adminDashboard": "Painel de administração", + "deleteUserConfirmation": "Tem a certeza de que pretende eliminar o utilizador \"{username}\"? Esta ação não pode ser desfeita.", + "failedToDeleteUser": "Falha ao eliminar utilizador", + "manageUsers": "Gerir utilizadores", + "createUser": "Criar utilizador", + "editUser": "Editar utilizador", + "deleteUser": "Eliminar utilizador", + "userPermissions": "Permissões do utilizador", + "systemSettings": "Definições do sistema", + "logs": "Registos", + "backups": "Cópias de segurança", + "maintenance": "Manutenção", + "regularUsers": "Utilizadores regulares", + "adminUsers": "Utilizadores administradores", + "totalContent": "Conteúdo total", + "noSharedItems": "Sem itens partilhados", + "saveCSS": "Guardar CSS", + "themeName": "Nome do tema", + "customColor": "Cor personalizada", + "maximumFileUploadSize": "Tamanho máximo de carregamento de ficheiro", + "maxFileSizeDescription": "O tamanho máximo de ficheiro permitido para carregamentos em MB (aplica-se a imagens, vídeos e ficheiros)", + "applicationName": "Nome da aplicação", + "applicationNameDescription": "Aparece no separador do navegador e no nome da PWA.", + "applicationDescription": "Descrição da aplicação", + "applicationDescriptionText": "Utilizado para motores de busca e descrição da PWA.", + "applicationDescriptionPlaceholder": "Uma lista de verificação simples, rápida e leve...", + "favicon16": "Favicon 16x16", + "favicon16Description": "Favicon pequeno para separadores do navegador.", + "favicon32": "Favicon 32x32", + "favicon32Description": "Favicon padrão para a maioria dos navegadores.", + "appleTouchIcon": "Ícone Apple Touch 180x180", + "appleTouchIconDescription": "Ícone para o ecrã inicial do iOS.", + "icon192": "Ícone 192x192", + "icon192Description": "Ícone para o ecrã inicial do Android.", + "icon512": "Ícone 512x512", + "icon512Description": "Ícone de alta resolução para ecrãs de carregamento da PWA.", + "notifyNewUpdates": "Notificar-me sobre novas atualizações", + "thisUsesGithubAPI": "Isto utiliza a API do GitHub para obter atualizações; desativar totalmente desativa a API e não permite qualquer tráfego de/para o github.", + "parseContent": "Mostrar sempre conteúdo processado", + "parseContentEnabledDescription": "Quando ativada, esta definição mostrará os títulos processados na barra lateral, resultados de pesquisa e em toda a aplicação.", + "parseContentDisabledDescription": "Quando desativada, os nomes de ficheiros originais serão higienizados, tornados legíveis e mostrados em alternativa.", + "parseContentPerformanceWarning": "Definir isto como \"não\" melhorará o desempenho em grandes conjuntos de dados, mas pode afetar a legibilidade - especialmente em nomes de ficheiros com caracteres não latinos.", + "hideLanguageSelector": "Ocultar seletor de idioma no cabeçalho", + "hideLanguageSelectorDescription": "Mostrar/ocultar menu suspenso de idioma no cabeçalho", + "defaultDateFormat": "Formato de data predefinido", + "defaultDateFormatDescription": "Utilizado quando a preferência de formato de data do utilizador está definida como \"Usar predefinição do sistema\".", + "defaultTimeFormat": "Formato de hora predefinido", + "defaultTimeFormatDescription": "Utilizado quando a preferência de formato de hora do utilizador está definida como \"Usar predefinição do sistema\".", + "applicationIcons": "Ícones da aplicação", + "saveChanges": "Guardar alterações", + "unsavedChanges": "Tem alterações não guardadas.", + "loadError": "Erro de carregamento", + "couldNotFetchSettings": "Não foi possível obter as definições.", + "failedToLoadSettings": "Falha ao carregar as definições", + "settingsSavedSuccessfully": "Definições guardadas com sucesso.", + "failedToSaveSettings": "Falha ao guardar as definições", + "saveError": "Erro ao guardar", + "unknownErrorOccurred": "Ocorreu um erro desconhecido.", + "customCSS": "CSS personalizado", + "customThemes": "Temas personalizados", + "createTheme": "Criar tema", + "editTheme": "Editar tema", + "updateTheme": "Atualizar tema", + "iconName": "Nome do ícone", + "colorVariables": "Variáveis de cor", + "livePreview": "Pré-visualização em direto", + "unsavedCssChanges": "Tem alterações de CSS não guardadas.", + "noCustomThemesYet": "Ainda não foram criados temas personalizados. Clique em \"Criar tema\" para começar.", + "myCustomTheme": "O meu tema personalizado", + "iconNameDescription": "nome do ícone (ex.: PaintBrush04Icon, Sun03Icon, GibbousMoonIcon)", + "appVersion": "Versão da aplicação", + "totalUsers": "Total de utilizadores", + "totalChecklists": "Total de listas de verificação", + "totalNotes": "Total de notas", + "userDistribution": "Distribuição de utilizadores", + "contentOverview": "Visão geral do conteúdo", + "overview": "Visão geral", + "users": "Utilizadores", + "content": "Conteúdo", + "sharing": "Partilha", + "adminLogs": "Registos de administrador", + "editor": "Editor", + "styling": "Estilo", + "addUser": "Adicionar utilizador", + "searchUsers": "Pesquisar utilizadores...", + "you": "Você", + "userRole": "{role} • {checklists} listas de verificação, {notes} notas", + "noUsersFound": "Nenhum utilizador encontrado", + "noUsersYet": "Ainda sem utilizadores", + "noUsersMatchSearch": "Nenhum utilizador corresponde aos seus critérios de pesquisa.", + "usersWillAppear": "Os utilizadores aparecerão aqui quando se registarem.", + "configureEditorFeatures": "Configure quais as funcionalidades do editor que estão ativadas para todos os utilizadores.", + "enableSlashCommandsDescription": "Ativar o menu de comandos de barra (escreva \"/\" para inserir elementos)", + "enableBubbleMenuDescription": "Ativar a barra de ferramentas flutuante que aparece quando o texto está selecionado", + "enableTableToolbarDescription": "Ativar a barra de ferramentas que aparece ao editar tabelas", + "bilateralLinks": "Ligações bilaterais", + "experimental": "(Experimental)", + "bilateralLinksDescription": "Ativar a capacidade de criar ligações para notas e listas de verificação no mesmo documento.", + "bilateralLinksWarning": "Esta funcionalidade é experimental e pode não funcionar como esperado. Também pode acabar por ser removida/descontinuada no futuro.", + "historyEnabled": "Histórico de notas", + "historyDescription": "Acompanhar o histórico de versões das notas. Permite restaurar versões anteriores. Apenas as guardações manuais criam pontos no histórico (não a guardação automática).", + "disableHistory": "Desativar histórico", + "disableHistoryWarning": "Desativar o histórico eliminará permanentemente todas as versões guardadas de todos os utilizadores. Isto não pode ser desfeito.", + "historyDisabled": "O histórico de notas foi desativado e todos os dados do histórico foram eliminados.", + "failedToDisableHistory": "Falha ao desativar o histórico", + "configureExternalServices": "Configurar URLs de serviços externos para integrações do editor.", + "drawioUrl": "URL do Draw.io", + "optional": "(Opcional)", + "drawioUrlDescription": "Especifique um URL personalizado de instância Draw.io. Deixe vazio para usar a instância pública predefinida (https://embed.diagrams.net).", + "drawioUrlExample": "Exemplo para alojamento próprio: https://seu-dominio.com/drawio", + "drawioProxyEnabled": "Proxy Draw.io via Jotty", + "drawioProxyEnabledDescription": "Ativar isto para encaminhar o tráfego do Draw.io através do servidor do Jotty. Necessário para URLs de rede Docker interna (ex.: http://drawio:8080) que não são acessíveis a partir dos navegadores dos utilizadores.", + "excalidrawUrl": "URL do Excalidraw", + "excalidrawUrlDescription": "Especifique um URL personalizado de instância Excalidraw. Deixe vazio para usar a instância pública predefinida (https://excalidraw.com).", + "excalidrawUrlExample": "Exemplo para alojamento próprio: https://seu-dominio.com/excalidraw", + "excalidrawProxyEnabled": "Proxy Excalidraw via Jotty", + "excalidrawProxyEnabledDescription": "Ativar isto para encaminhar o tráfego do Excalidraw através do servidor do Jotty. Necessário para URLs de rede Docker interna (ex.: http://excalidraw:3000) que não são acessíveis a partir dos navegadores dos utilizadores.", + "saving": "A guardar...", + "editorSettingsSaved": "Definições do editor guardadas com sucesso.", + "customEmojis": "Emojis personalizados", + "noEmojisYet": "Ainda não foram criados emojis personalizados. Clique em "Adicionar emoji" para começar.", + "enterAnyEmoji": "Introduzir qualquer emoji ou usar o seletor de emoji (Windows: Win+.; Mac: Ctrl+Cmd+Space", + "sharingOverview": "Visão geral da atividade de partilha", + "detailedSharingBreakdown": "Análise detalhada dos padrões de partilha e envolvimento dos utilizadores", + "checkForUpdates": "Verificar atualizações", + "checkingForUpdates": "A verificar atualizações...", + "updateAvailable": "Atualização disponível!", + "upToDate": "Está atualizado!", + "issuesAndBugs": "Problemas e relatórios de erros", + "activeChecklistSharers": "Partilhadores ativos de listas de verificação", + "activeNotesSharers": "Partilhadores ativos de notas", + "totalSharing": "Total de ações de partilha", + "topContributors": "Principais contribuidores", + "itemsShared": "Itens partilhados", + "totalPublicItems": "{count} itens públicos", + "userContent": "{checklistsLength} listas de verificação • {notesLength} notas", + "rebuildIndexes": "Reconstruir índices", + "rebuilding": "A reconstruir...", + "successfullyRebuiltIndex": "Índice de ligações reconstruído com sucesso para {username}", + "failedToRebuildIndex": "Falha ao reconstruir o índice de ligações para {username}", + "totalItems": "{items} itens totais em {userCount}", + "privileges": "Privilégios de administrador", + "adminContentAccess": "Acesso ao conteúdo do administrador", + "adminContentAccessDescription": "Controla se os administradores podem aceder às notas e listas de verificação de todos os utilizadores. Quando desativado, os administradores só podem gerir utilizadores e definições, mas não podem ver o conteúdo de outros utilizadores.", + "adminContentAccessYes": "Os administradores podem aceder a todo o conteúdo", + "adminContentAccessNo": "Os administradores só podem aceder ao seu próprio conteúdo", + "superAdminOnly": "Apenas editável pelo proprietário do sistema", + "systemOwner": "Proprietário do sistema", + "cannotDeleteSuperAdmin": "Não é possível eliminar o super administrador (proprietário do sistema)", + "enterUsername": "Introduzir nome de utilizador", + "confirmNewPassword": "Confirmar nova palavra-passe", + "dataExport": "Exportação de dados", + "dataExportOptions": "Opções de exportação de dados", + "selectExportType": "Selecione um tipo de exportação e transfira dados da sua aplicação.", + "allChecklistsAndNotes": "Todas as listas de verificação e notas", + "allChecklistsAndNotesDescription": "Exporta todas as listas de verificação e notas criadas por todos os utilizadores. Isto gerará um ficheiro zip com dados JSON organizados por utilizador e categoria.", + "userChecklistsNotes": "Listas de verificação e notas de um utilizador específico", + "userChecklistsNotesDescription": "Selecione um utilizador no menu suspenso para exportar todas as listas de verificação e notas pertencentes a esse utilizador específico.", + "allUsersData": "Dados de todas as contas de utilizador", + "allUsersDataDescription": "Exporta um ficheiro JSON com todas as informações de conta de utilizador (nomes de utilizador, estado de administrador, etc.). Isto NÃO inclui o conteúdo das suas listas de verificação ou notas.", + "wholeDataFolder": "Pasta de dados completa (cópia de segurança)", + "wholeDataFolderDescription": "Exporta toda a pasta 'data', incluindo todo o conteúdo de utilizador, metadados e definições. Use com cuidado, pois pode ser um ficheiro grande.", + "noSharingPermissionsLabel": "Itens partilhados específicos estão ocultos com base nas suas permissões de acesso ao conteúdo.", + "contentHidden": "Detalhes do conteúdo ocultos", + "viewOnlySettingsNotice": "Apenas o proprietário do sistema pode modificar estas definições.", + "auditLogContentRedacted": "[Conteúdo oculto]", + "maxLogAgeDays": "Idade máxima dos registos (dias)", + "maxLogAgeDaysDescription": "Defina quanto tempo os registos de auditoria são retidos. Ao visualizar a página de registos de auditoria, será solicitado a limpar os registos mais antigos do que este limiar. Defina como 0 para retenção ilimitada (sem avisos de limpeza automática).", + "appPreferences": "Preferências da aplicação", + "editEmoji": "Editar emoji", + "addEmoji": "Adicionar emoji", + "rebuildLinkIndexesTitle": "Reconstruir índices de ligações", + "noContentYet": "Ainda sem conteúdo", + "userHasNoContent": "Este utilizador ainda não criou nenhuma lista de verificação ou nota.", + "selectUserToExport": "Selecionar um utilizador para exportar", + "deleteUserConfirmAdmin": "Tem a certeza de que pretende eliminar o utilizador \"{username}\"? Esta ação não pode ser desfeita.", + "failedToUnsharePublicItem": "Falha ao anular a partilha do item público", + "deleteAccountWarning": "Aviso: Esta ação não pode ser desfeita", + "enterPasswordToConfirm": "Introduza a sua palavra-passe para confirmar", + "tags": "Etiquetas", + "tagsDescription": "Ativar autocompletar hashtag no editor com o acionador #", + "updateTags": "Atualizar etiquetas", + "updatingTags": "A atualizar...", + "updateTagsTitle": "Analisar notas para hashtags e atualizar frontmatter", + "tagsUpdated": "Processados {processed} ficheiros, {updated} atualizados com novas etiquetas", + "failedToUpdateTags": "Falha ao atualizar etiquetas" + }, + "errors": { + "notFound": "Não encontrado", + "pageNotFound": "Página não encontrada", + "unauthorized": "Não autorizado", + "forbidden": "Proibido", + "serverError": "Erro do servidor", + "somethingWentWrong": "Algo correu mal", + "tryAgain": "Tentar novamente", + "contactSupport": "Contactar suporte", + "invalidInput": "Entrada inválida", + "requiredField": "Este campo é obrigatório", + "emailInvalid": "Por favor, introduza um e-mail válido", + "passwordTooShort": "A palavra-passe deve ter pelo menos 8 caracteres", + "passwordMismatch": "As palavras-passe não coincidem", + "networkError": "Erro de rede", + "timeout": "Tempo limite do pedido esgotado", + "permissionDenied": "Permissão negada", + "loadError": "Erro de carregamento", + "failedToLoadCustomEmojis": "Falha ao carregar emojis personalizados", + "emojiDeletedSuccessfully": "Emoji eliminado com sucesso", + "deleteError": "Erro ao eliminar", + "failedToDeleteEmoji": "Falha ao eliminar emoji", + "validationError": "Erro de validação", + "keywordAndEmojiRequired": "A palavra-chave e o emoji são obrigatórios", + "duplicateKeyword": "Palavra-chave duplicada", + "keywordAlreadyExists": "A palavra-chave já existe", + "emojiUpdatedSuccessfully": "Emoji atualizado com sucesso", + "emojiCreatedSuccessfully": "Emoji criado com sucesso", + "saveError": "Erro ao guardar", + "failedToSaveEmoji": "Falha ao guardar emoji", + "failedToLoadCustomCSS": "Falha ao carregar CSS personalizado", + "couldNotLoadCustomCSS": "Não foi possível carregar o CSS personalizado.", + "couldNotLoadThemes": "Não foi possível carregar os temas.", + "customCSSSavedSuccessfully": "CSS personalizado guardado com sucesso.", + "themeNotFound": "Tema não encontrado.", + "themeDeletedSuccessfully": "Tema eliminado com sucesso.", + "themeNameRequired": "O nome do tema é obrigatório.", + "themeSavedSuccessfully": "Tema {action} com sucesso.", + "anUnknownErrorOccurred": "Ocorreu um erro desconhecido.", + "usernameRequired": "O nome de utilizador é obrigatório", + "passwordRequired": "A palavra-passe é obrigatória", + "passwordMinLength": "A palavra-passe deve ter pelo menos 6 caracteres", + "passwordsDoNotMatch": "As palavras-passe não coincidem", + "userSavedSuccessfully": "Utilizador {mode} com sucesso!", + "failedToSaveUser": "Falha ao {mode} utilizador", + "anErrorOccurred": "Ocorreu um erro.", + "deleteUserConfirmation": "Eliminar utilizador \"{username}\"? Isto não pode ser desfeito.", + "userDeletedSuccessfully": "Utilizador eliminado com sucesso!", + "failedToDeleteUser": "Falha ao eliminar utilizador" + }, + "pwa": { + "installPromptTitle": "Instalar aplicação", + "installPromptMessage": "Instale esta aplicação no seu dispositivo para uma melhor experiência", + "installButton": "Instalar", + "updateAvailable": "Atualização disponível", + "updateMessage": "Está disponível uma nova versão", + "updateButton": "Atualizar", + "refreshButton": "Atualizar página", + "offlineMessage": "Está atualmente sem ligação" + }, + "editor": { + "bold": "Negrito", + "italic": "Itálico", + "underline": "Sublinhado", + "strikethrough": "Rasurado", + "code": "Código", + "heading": "Cabeçalho", + "bulletList": "Lista com marcadores", + "orderedList": "Lista numerada", + "blockquote": "Citação em bloco", + "codeBlock": "Bloco de código", + "link": "Ligação", + "addLink": "Adicionar ligação", + "enterURL": "Introduzir URL", + "image": "Imagem", + "addImage": "Adicionar imagem", + "enterImageURL": "Introduzir URL da imagem", + "table": "Tabela", + "horizontalRule": "Linha horizontal", + "undo": "Desfazer", + "redo": "Refazer", + "clearFormatting": "Limpar formatação", + "textAlign": "Alinhamento do texto", + "alignLeft": "Alinhar à esquerda", + "alignCenter": "Centrar", + "alignRight": "Alinhar à direita", + "alignJustify": "Justificar", + "color": "Cor", + "highlight": "Realçar", + "fontSize": "Tamanho da letra", + "fontFamily": "Família de letras", + "insertLink": "Inserir ligação", + "insertImage": "Inserir imagem", + "insertTable": "Inserir tabela", + "addRowBefore": "Adicionar linha antes", + "addRowAfter": "Adicionar linha depois", + "addColumnBefore": "Adicionar coluna antes", + "addColumnAfter": "Adicionar coluna depois", + "mergeCells": "Unir células", + "splitCell": "Dividir célula", + "placeholder": "Comece a escrever...", + "url": "URL", + "hidePreview": "Ocultar pré-visualização", + "edit": "Editar", + "preview": "Pré-visualização", + "toggleEditorMode": "Alternar modo de editor", + "richEditor": "Editor enriquecido", + "markdown": "Markdown", + "toggleRichEditorMode": "Alternar modo de editor enriquecido", + "toggleMarkdownMode": "Alternar modo markdown", + "toggleBold": "Alternar negrito", + "toggleItalic": "Alternar itálico", + "toggleUnderline": "Alternar sublinhado", + "toggleStrikethrough": "Alternar rasurado", + "toggleInlineCode": "Alternar código inline", + "toggleHeading2": "Alternar cabeçalho 2", + "listOptions": "Lista", + "toggleBulletList": "Alternar lista com marcadores", + "toggleOrderedList": "Alternar lista numerada", + "indentListItem": "Aumentar recuo do item de lista", + "outdentListItem": "Diminuir recuo do item de lista", + "toggleBlockquote": "Alternar citação em bloco", + "toggleLink": "Alternar ligação", + "editImageSize": "Editar tamanho da imagem", + "editorFeatures": "Funcionalidades do editor", + "slashCommands": "Comandos de barra", + "bubbleMenu": "Menu de balão", + "tableToolbar": "Barra de ferramentas de tabela", + "externalServices": "Serviços externos", + "diagrams": "Diagramas", + "mermaidDiagram": "Diagrama Mermaid", + "drawioDiagram": "Diagrama Draw.io", + "excalidrawDiagram": "Diagrama Excalidraw", + "linkType": "Tipo de ligação:", + "bidirectional": "Bidirecional", + "pathBased": "Baseado em caminho", + "encryptedNote": "Nota encriptada", + "searchFonts": "Pesquisar letras...", + "noFontsFound": "Nenhuma letra encontrada", + "syntaxTheme": "Tema de sintaxe", + "addRowAbove": "Adicionar linha acima", + "addRowBelow": "Adicionar linha abaixo", + "addColumnLeft": "Adicionar coluna à esquerda", + "addColumnRight": "Adicionar coluna à direita", + "deleteRow": "Eliminar linha", + "deleteColumn": "Eliminar coluna", + "deleteTable": "Eliminar tabela", + "rows": "Linhas", + "columns": "Colunas", + "heading1": "Cabeçalho 1", + "bigSectionHeading": "Cabeçalho de secção grande", + "heading2": "Cabeçalho 2", + "mediumSectionHeading": "Cabeçalho de secção médio", + "createBulletedList": "Criar uma lista com marcadores", + "createOrderedList": "Criar uma lista numerada", + "taskList": "Lista de tarefas", + "createTaskList": "Criar uma lista de tarefas", + "createCodeBlock": "Criar um bloco de código", + "quote": "Citação", + "createBlockquote": "Criar uma citação em bloco", + "insertATable": "Inserir uma tabela", + "insertAnImage": "Inserir uma imagem", + "file": "Ficheiro", + "attachFile": "Anexar um ficheiro", + "collapsible": "Recolhível", + "createCollapsibleSection": "Criar uma secção recolhível", + "createMermaidDiagram": "Criar um diagrama Mermaid", + "createVisualDiagram": "Criar um diagrama visual", + "createExcalidrawDiagram": "Criar um diagrama Excalidraw", + "imageUrl": "URL da imagem", + "fileUrl": "URL do ficheiro", + "fileName": "Nome do ficheiro", + "defaultFileName": "ficheiro", + "summary": "Resumo", + "urlPrompt": "URL", + "editDiagram": "Editar diagrama", + "deleteDiagram": "Eliminar diagrama", + "enterMermaidCode": "Introduzir código de diagrama Mermaid...", + "widthPx": "Largura (px)", + "heightPx": "Altura (px)", + "customColor": "Cor personalizada", + "colorPlaceholder": "#000000", + "searchLanguages": "Pesquisar idiomas...", + "imageSize": "Tamanho da imagem", + "leaveEmptyForAutoSize": "Deixar vazio para dimensionamento automático", + "minimalMode": "Modo mínimo", + "rawMarkdown": "Markdown bruto", + "hideLineNumbers": "Ocultar números de linha", + "showLineNumbers": "Mostrar números de linha", + "showEditor": "Mostrar editor", + "showPreview": "Mostrar pré-visualização", + "markdownEditor": "Editor Markdown", + "previewMode": "Modo de pré-visualização", + "themeUpdated": "Tema atualizado", + "updateFailed": "Falha na atualização", + "mermaidDescription": "Fluxogramas e diagramas baseados em texto", + "drawioDescription": "Editor de diagramas visual", + "excalidrawDescription": "Editor de diagramas estilo esboço", + "noLanguagesFound": "Nenhum idioma encontrado", + "callout": "Aviso", + "createCallout": "Criar um bloco de aviso", + "changeCalloutType": "Alterar tipo de aviso", + "calloutInfo": "Informação", + "calloutWarning": "Aviso", + "calloutSuccess": "Sucesso", + "calloutDanger": "Perigo", + "ruler": "Régua", + "lineNumbers": "Números de linha" + }, + "sharing": { + "share": "Partilhar", + "shareWith": "Partilhar com", + "sharedWith": "Partilhado com", + "shareLink": "Ligação de partilha", + "copyLink": "Copiar ligação", + "linkCopied": "Ligação copiada para a área de transferência", + "publicLink": "Ligação pública", + "privateLink": "Ligação privada", + "sharePermissions": "Permissões de partilha", + "canView": "Pode ver", + "canEdit": "Pode editar", + "canDelete": "Pode eliminar", + "removeAccess": "Remover acesso", + "shareSettings": "Definições de partilha", + "stopSharing": "Parar partilha", + "publicAccess": "Acesso público", + "copyUrl": "Copiar URL", + "publiclyAccessible": "Acessível publicamente", + "publicShares": "Partilhas públicas", + "shareWithUsers": "Partilhar com utilizadores", + "publicLinkTab": "Ligação pública", + "xTwitter": "X (Twitter)", + "checkOutThisItem": "Veja este {itemType}: {itemTitle}", + "reddit": "Reddit", + "facebook": "Facebook", + "email": "E-mail", + "emailSubject": "Veja este {itemType}: {itemTitle}", + "emailBody": "Queria partilhar este {itemType} consigo:\n\n{itemTitle}\n{publicUrl}", + "makeItemAccessible": "Tornar este {itemType} acessível a qualquer pessoa com a ligação.", + "makePrivate": "Tornar privado", + "makePublic": "Tornar público", + "failedToCopyUrl": "Falha ao copiar URL para a área de transferência", + "searchUsers": "Pesquisar utilizadores...", + "toggleAllPermissions": "Alternar todas as permissões", + "shareModalTitle": "Partilhar {type}" + }, + "encryption": { + "encrypt": "Encriptar", + "decrypt": "Desencriptar", + "encrypted": "Encriptado", + "decrypted": "Desencriptado", + "encryptionKey": "Chave de encriptação", + "enterKey": "Introduzir chave de encriptação", + "confirmKey": "Confirmar chave de encriptação", + "keyMismatch": "As chaves não coincidem", + "invalidKey": "Chave de encriptação inválida", + "encryptNote": "Encriptar nota", + "decryptNote": "Desencriptar nota", + "viewNote": "Ver nota", + "editNote": "Editar nota", + "privateKeyWarning": "AVISO: A sua chave privada permite a desencriptação de todas as notas encriptadas. Exporte apenas se compreender as implicações de segurança. Continuar?", + "publicKeyExported": "Chave pública exportada com sucesso", + "privateKeyExported": "Chave privada exportada com sucesso", + "failedToExportKey": "Falha ao exportar chave", + "unexpectedError": "Ocorreu um erro inesperado", + "keysDeleted": "Chaves eliminadas com sucesso", + "failedToDeleteKeys": "Falha ao eliminar chaves", + "autoDecryptUpdated": "Definição de desencriptação automática atualizada", + "failedToUpdateSetting": "Falha ao atualizar a definição", + "enterValidPath": "Por favor, introduza um caminho válido", + "customPathSet": "Caminho de chave personalizado definido com sucesso", + "failedToSetCustomPath": "Falha ao definir caminho personalizado", + "thisNoteIsEncrypted": "Esta nota está encriptada", + "noteProtectedWith": "Esta nota está protegida com encriptação {type}.", + "methodUpdated": "Método de encriptação atualizado", + "failedToUpdateMethod": "Falha ao atualizar o método", + "encryptionMethod": "Método de encriptação", + "xchacha": "XChaCha20-Poly1305 (Recomendado)", + "pgp": "PGP", + "selectMethod": "Selecionar método de encriptação", + "encryptionMethodsTitle": "Métodos de encriptação", + "xchachaDescription": "XChaCha20-Poly1305: Encriptação moderna, rápida, baseada em frase-chave", + "pgpDescription": "PGP: Criptografia de chave pública tradicional com gestão de par de chaves", + "encryptionKeys": "Chaves de encriptação", + "keysConfigured": "Chaves configuradas", + "algorithm": "Algoritmo: ", + "noKeysConfigured": "Sem chaves de encriptação configuradas", + "generateOrImportKeys": "Gere um novo par de chaves ou importe chaves existentes para ativar a encriptação de notas.", + "keyManagement": "Gestão de chaves", + "generateNewKeyPair": "Gerar novo par de chaves", + "exportPublicKey": "Exportar chave pública", + "exportPrivateKey": "Exportar chave privada", + "deleteKeys": "Eliminar chaves", + "keysDisabledInDemo": "As chaves de encriptação armazenadas estão desativadas no modo de demonstração.", + "encryptionSettings": "Definições de encriptação", + "promptForPassphrase": "Pedir frase-chave ao abrir notas encriptadas", + "promptForPassphraseDescription": "Se ativado, ser-lhe-á pedida a frase-chave ao abrir uma nota encriptada. A nota permanecerá encriptada no servidor, mas poderá ver o conteúdo desencriptado na sessão do separador atual.", + "customKeyPath": "Caminho de chave personalizado (instalações locais)", + "customKeyPathDescription": "Especifique um caminho de sistema de ficheiros personalizado (absoluto) para armazenar chaves de encriptação. Isto só é necessário para instalações locais. Os utilizadores de Docker devem mapear o caminho predefinido no docker-compose.yml.", + "customPath": "Caminho personalizado", + "customPathPlaceholder": "/home/utilizador/minhas-chaves", + "customPathHelp": "Deve ser um caminho absoluto (ex.: /home/utilizador/minhas-chaves ou C:\\Users\\utilizador\\minhas-chaves)", + "setCustomPath": "Definir caminho personalizado", + "customPathDisabledInDemo": "O caminho de chave personalizado está desativado no modo de demonstração", + "deleteKeysConfirmTitle": "Eliminar chaves de encriptação?", + "deleteKeysConfirmMessage": "Isto eliminará permanentemente as suas chaves de encriptação. NÃO poderá desencriptar nenhuma nota encriptada sem reimportar a sua chave privada. Esta ação não pode ser desfeita.", + "editEncrypted": "Editar (manter encriptado)", + "editingEncryptedNote": "A editar nota encriptada", + "autoSaveDisabledForEncrypted": "A guardação automática está desativada para notas encriptadas. Lembre-se de guardar manualmente!", + "enterPassphraseToEdit": "Introduza a sua frase-chave para editar esta nota encriptada", + "noteWillStayEncrypted": "A nota permanecerá encriptada no servidor", + "savingEncryptedNote": "A encriptar e guardar nota...", + "saveEncryptedNote": "Guardar nota encriptada", + "enterPassphraseToSave": "Introduza a sua frase-chave para guardar a nota", + "noteWillBeEncryptedBeforeSaving": "O conteúdo da nota será encriptado antes de guardar", + "passphraseRequired": "A frase-chave é obrigatória", + "incorrectPassphrase": "Frase-chave incorreta", + "noteEncryptedSuccessfully": "Nota encriptada com sucesso", + "failedToEncryptNote": "Falha ao encriptar nota", + "noteDecryptedSuccessfully": "Nota desencriptada com sucesso", + "failedToDecryptNote": "Falha ao desencriptar nota", + "passphrase": "Frase-chave", + "enterYourPassphrase": "Introduza a sua frase-chave", + "encryptingNote": "A encriptar nota", + "viewDecryptedContent": "Ver conteúdo desencriptado", + "decryptingNote": "A desencriptar nota", + "noteWillBeDecryptedForViewing": "O conteúdo da nota será desencriptado apenas para visualização", + "cannotEditEncryptedNotes": "Não pode editar notas encriptadas", + "noteContentWillBeEncrypted": "O conteúdo da nota será encriptado com XChaCha20-Poly1305", + "onlySomeoneWithPassphraseCanDecrypt": "Apenas alguém com a frase-chave pode desencriptar", + "noteTitleWillRemainUnencrypted": "O título/frontmatter da nota permanecerá não encriptado", + "passphraseNeverStoredOnServer": "A sua frase-chave NUNCA será armazenada no servidor", + "thisWillDecryptNote": "Isto desencriptará o conteúdo da nota", + "noteWillBeRestoredAsUnencrypted": "O conteúdo da nota será restaurado como não encriptado", + "youWillBeAbleToEditAfterDecryption": "Poderá editar o conteúdo da nota após a desencriptação", + "publicKeyRequired": "A chave pública é obrigatória. Não tem nenhuma chave armazenada.", + "keyRequired": "{keyType} é obrigatório", + "privateKeyRequiredForSigning": "A chave privada é obrigatória para assinar", + "passphraseRequiredForSigning": "A frase-chave é obrigatória para assinar", + "signatureVerified": "Assinatura verificada.", + "signatureInvalid": "Aviso: Assinatura inválida ou não foi possível verificar.", + "decryptedAndVerified": "Desencriptado e verificado", + "decryptedWithWarning": "Desencriptado com aviso", + "publicKey": "Chave pública", + "privateKey": "Chave privada", + "noStoredKeysFound": "Nenhuma chave armazenada encontrada", + "noEncryptionKeysConfigured": "Não tem nenhuma chave de encriptação configurada", + "pastePublicKeyBelow": "Por favor, cole uma chave pública abaixo para encriptar o conteúdo da nota", + "generateOrImportKeysInProfile": "Pode gerar ou importar chaves específicas para o Jotty em Perfil → Encriptação", + "useCustomKey": "Usar chave {keyType} personalizada", + "signWithPrivateKey": "Assinar com chave privada", + "proveAuthenticityOfNote": "provar a autenticidade desta nota", + "useStoredPrivateKey": "Usar chave privada armazenada", + "privateKeyForSigning": "Chave privada para assinar", + "signingPassphrase": "Frase-chave de assinatura", + "enterPassphraseForSigningKey": "Introduzir frase-chave para chave de assinatura", + "noteContentWillBeEncryptedWithPGP": "O conteúdo da nota será encriptado com PGP", + "onlySomeoneWithPrivateKeyCanDecrypt": "Apenas alguém com a chave privada/frase-chave pode desencriptar", + "pgpSaveWithoutSigning": "Guardar PGP sem assinar", + "incorrectPassphraseForXChaChaSave": "Frase-chave incorreta fornecida para guardar XChaCha", + "generatePGPKeyPair": "Gerar par de chaves PGP", + "important": "Importante", + "nameOptional": "Nome (opcional)", + "yourName": "O seu nome", + "emailOptional": "E-mail (opcional)", + "yourEmail": "o-seu@email.com", + "generating": "A gerar...", + "generateKeys": "Gerar chaves", + "keysGeneratedSuccessfully": "Chaves geradas com sucesso", + "importPGPKeys": "Importar chaves PGP", + "publicKeyLabel": "Chave pública", + "privateKeyLabel": "Chave privada", + "importing": "A importar...", + "importKeys": "Importar chaves", + "rememberPassphraseCannotRecover": "Lembre-se da sua frase-chave! Não pode ser recuperada.", + "withoutPassphraseCannotDecrypt": "Sem a frase-chave, não pode desencriptar as suas notas.", + "backupPrivateKeySafely": "Faça uma cópia de segurança da sua chave privada em local seguro.", + "pgpKeyPairGeneratedSuccessfully": "Par de chaves PGP gerado com sucesso", + "failedToGenerateKeys": "Falha ao gerar chaves", + "fingerprint": "Impressão digital", + "keysSavedSecurely": "As suas chaves foram guardadas em segurança.", + "downloadKeysForBackup": "Transfira as suas chaves para cópia de segurança (opcional).", + "downloadPublicKey": "Transferir chave pública", + "downloadPrivateKey": "Transferir chave privada", + "importExistingKeys": "Importar chaves existentes", + "pasteAsciiArmoredKeys": "Cole as suas chaves PGP em formato ASCII-armored abaixo", + "keysMustStartWith": "As chaves devem começar com -----BEGIN PGP...", + "keysWillBeSecurelyStored": "As suas chaves serão armazenadas em segurança", + "bothKeysRequired": "São necessárias tanto a chave pública como a privada", + "invalidPublicKeyFormat": "Formato de chave pública inválido. Deve ser chave PGP em formato ASCII-armored.", + "invalidPrivateKeyFormat": "Formato de chave privada inválido. Deve ser chave PGP em formato ASCII-armored.", + "keysImportedSuccessfully": "Chaves importadas com sucesso", + "failedToImportKeys": "Falha ao importar chaves", + "created": "Encriptação criada", + "notePlaceholder": "Sou um marcador de posição porque não pode encriptar uma nota vazia." + }, + "settingsModal": { + "sessionOnlyPrefix": "Estas opções aplicam-se apenas à sessão atual do navegador. Para definições permanentes, por favor verifique as suas", + "accountSettings": "preferências do utilizador", + "autosaveNotes": "Guardação automática de notas", + "showNotePreview": "Mostrar pré-visualização de notas nos cartões", + "notesCompactMode": "Modo compacto de notas", + "showEmojis": "Mostrar emojis nas listas de verificação", + "viewMode": "Modo de visualização", + "viewModeCard": "Cartões", + "viewModeList": "Lista", + "viewModeGrid": "Grelha" + }, + "migration": { + "migrationComplete": "Migração concluída", + "sharingUpdateRequired": "Atualização do sistema de partilha necessária", + "migrationFailed": "Falha na migração", + "migrating": "A migrar...", + "startMigration": "Iniciar migração", + "migrationSuccessful": "Migração bem-sucedida", + "returnToApp": "Voltar à aplicação", + "adminAccessRequired": "Acesso de administrador necessário", + "adminAccessDescription": "Esta migração requer privilégios de administrador. Por favor, contacte um administrador para realizar a migração do sistema.", + "whatsHappening": "O que está a acontecer?", + "sharingDataMigrationDescription": "O sistema precisa de migrar os seus dados de partilha. Este é um processo único que requer acesso de administrador.", + "whatsChanging": "O que está a mudar?", + "automaticBackupIncluded": "Cópia de segurança automática incluída", + "yamlFrontmatterIndustryStandard": "YAML Frontmatter é o padrão da indústria", + "noMigrationNeeded": "Não foi necessária nenhuma migração - o seu sistema já está atualizado.", + "importantBackupData": "Importante: Faça uma cópia de segurança dos seus dados" + }, + "toasts": { + "categoryRenamedSuccessfully": "Categoria renomeada com sucesso", + "failedToRenameCategory": "Falha ao renomear categoria", + "categoryRenamedSuccessfullyMessage": "Categoria {categoryName} renomeada para {newName}", + "failedToRenameCategoryMessage": "Ocorreu um erro ao renomear a categoria." + }, + "auditLogs": { + "title": "Registos de auditoria", + "viewLogs": "Ver registos de auditoria", + "allLogs": "Todos os registos", + "myActivity": "A minha atividade", + "totalLogs": "Total de registos", + "logEntry": "Entrada de registo", + "logEntries": "Entradas de registo", + "noLogsYet": "Ainda sem registos de auditoria", + "noLogsFound": "Nenhum registo encontrado", + "noLogsDescription": "Ainda não foi registada nenhuma atividade.", + "exportLogs": "Exportar registos", + "exportJSON": "Exportar JSON", + "exportCSV": "Exportar CSV", + "cleanupOldLogs": "Limpar registos antigos", + "confirmCleanup": "Tem a certeza de que pretende eliminar registos com mais de {days} dias? Isto afetará TODOS os utilizadores e não pode ser desfeito.", + "confirmCleanupUnlimited": "Não está configurada nenhuma limpeza automática (retenção ilimitada). Ainda pode eliminar manualmente registos antigos se necessário.", + "deleteAllLogsModalTitle": "Eliminar todos os registos de auditoria", + "deleteAllLogsWarning": "Isto eliminará TODOS os registos, não apenas os antigos", + "deleteAllLogsWarningDescription": "Todo o histórico de registos de auditoria será apagado do sistema.", + "deletingAllLogs": "A eliminar todos os registos...", + "deleteAllLogsButton": "Eliminar todos os registos", + "cleanupSuccess": "{count} ficheiros de registo antigos eliminados com sucesso", + "cleanupError": "Falha ao limpar registos antigos", + "userDescription": "Ver toda a sua atividade e ações no sistema", + "adminDescription": "Ver e gerir toda a atividade dos utilizadores no sistema", + "level": "Nível", + "allLevels": "Todos os níveis", + "allCategories": "Todas as categorias", + "allActions": "Todas as ações", + "auth": "Autenticação", + "userManagement": "Gestão de utilizadores", + "checklist": "Lista de verificação", + "note": "Nota", + "sharing": "Partilha", + "settings": "Definições", + "encryption": "Encriptação", + "api": "API", + "system": "Sistema", + "timestamp": "Marca temporal", + "ipAddress": "Endereço IP", + "userAgent": "Agente do utilizador", + "resource": "Recurso", + "metadata": "Metadados", + "errorMessage": "Mensagem de erro", + "login": "Início de sessão", + "logout": "Fim de sessão", + "register": "Registo", + "sessionterminated": "Sessão terminada", + "usercreated": "Utilizador criado", + "userupdated": "Utilizador atualizado", + "userdeleted": "Utilizador eliminado", + "profileupdated": "Perfil atualizado", + "checklistcreated": "Lista de verificação criada", + "checklistupdated": "Lista de verificação atualizada", + "checklistdeleted": "Lista de verificação eliminada", + "checklistitemchecked": "Item de lista de verificação marcado", + "notecreated": "Nota criada", + "noteupdated": "Nota atualizada", + "notedeleted": "Nota eliminada", + "notearchived": "Nota arquivada", + "noteencrypted": "Nota encriptada", + "notedecrypted": "Nota desencriptada", + "noteeditedencrypted": "Nota editada (encriptada)", + "notesavedencrypted": "Nota guardada (encriptada)", + "itemshared": "Item partilhado", + "itemunshared": "Partilha de item removida", + "sharepermissionsupdated": "Permissões de partilha atualizadas", + "settingsupdated": "Definições atualizadas", + "appsettingsupdated": "Definições da aplicação atualizadas", + "usersettingsupdated": "Definições do utilizador atualizadas", + "themechanged": "Tema alterado", + "customthemesaved": "Tema personalizado guardado", + "customemojisaved": "Emoji personalizado guardado", + "customcsssaved": "CSS personalizado guardado", + "preferencesupdated": "Preferências atualizadas", + "categorycreated": "Categoria criada", + "categorydeleted": "Categoria eliminada", + "categoryrenamed": "Categoria renomeada", + "categorymoved": "Categoria movida", + "encryptionkeysgenerated": "Chaves de encriptação geradas", + "encryptionkeysimported": "Chaves de encriptação importadas", + "encryptionkeysdeleted": "Chaves de encriptação eliminadas", + "encryptionkeypathchanged": "Caminho da chave de encriptação alterado", + "logscleaned": "Registos limpos", + "recentActivity": "Atividade recente", + "topActions": "Principais ações", + "mostActiveUsers": "Utilizadores mais ativos", + "oldLogsFound": "Existem {count} ficheiros de registo com mais de {days} dias.", + "oldLogsFoundDescription": "Pretende limpá-los? Esta ação não pode ser desfeita.", + "cleanUpNow": "Limpar agora", + "cleanupSuccessMessage": "{count} ficheiros de registo antigos eliminados com sucesso.", + "security": "Segurança", + "mfasecretgenerated": "Segredo MFA gerado", + "mfaenabled": "MFA ativado", + "mfadisabled": "MFA desativado", + "mfaenablefailed": "Falha ao ativar MFA", + "mfaverificationsuccess": "Verificação MFA bem-sucedida", + "mfaverificationfailed": "Falha na verificação MFA", + "mfabackupcodeused": "Código de cópia de segurança MFA utilizado", + "mfabackupcodefailed": "Falha no código de cópia de segurança MFA", + "mfabackupcodesregenerated": "Códigos de cópia de segurança MFA regenerados" + }, + "help": { + "howTo": "Como fazer", + "shortcuts": "Atalhos", + "markdownGuide": "Guia Markdown", + "customisations": "Personalizações", + "docker": "Docker", + "envVariables": "Variáveis de ambiente", + "mfa": "MFA", + "pwa": "PWA", + "encryption": "Encriptação", + "sso": "SSO", + "translations": "Traduções", + "unraid": "Unraid", + "patches": "Correções" + }, + "notFound": { + "title": "Página não encontrada", + "newHaiku": "Novo Haiku", + "goHome": "Ir para o início", + "seenCount": "Viu {count} {count, plural, one {haiku} other {haikus}}", + "imNoPoet": "Não sou poeta, nem o Jotty. Estas são palavras aleatórias em sequência." + }, + "notifications": { + "title": "Notificações", + "unread": "não lidas", + "markAllRead": "Marcar tudo como lido", + "clearAll": "Limpar tudo", + "empty": "Sem notificações", + "assignmentTitle": "Tarefa atribuída a si", + "assignmentMessage": "\"{task}\" em \"{board}\"", + "reminderTitle": "Tarefa em atraso", + "reminderMessage": "\"{task}\" em \"{board}\" ultrapassou o lembrete", + "sharingTitle": "{user} partilhou um(a) {type} consigo", + "sharingMessage": "Abrir para ver o(a) {type} partilhado(a)" + } +} diff --git a/app/_translations/ru.json b/app/_translations/ru.json index a6630dbf..22bcfa36 100644 --- a/app/_translations/ru.json +++ b/app/_translations/ru.json @@ -7,6 +7,8 @@ "view": "Просмотр", "new": "Новый", "close": "Закрыть", + "enlarge": "Развернуть", + "shrink": "Свернуть", "confirm": "Подтвердить", "loading": "Загрузка...", "search": "Поиск", @@ -306,7 +308,8 @@ "createUser": "Создать пользователя", "attemptsRemaining": "Предупреждение: осталось {count, plural, one {# попытка} few {# попытки} many {# попыток} other {# попыток}} до блокировки аккаунта", "accountLocked": "Слишком много неудачных попыток. Пожалуйста, попробуйте снова через {seconds} {seconds, plural, one {секунду} few {секунды} many {секунд} other {секунд}}", - "notAuthorized": "Вы не авторизованы для доступа к этому приложению. Пожалуйста, свяжитесь с администратором." + "notAuthorized": "Вы не авторизованы для доступа к этому приложению. Пожалуйста, свяжитесь с администратором.", + "rememberMe": "Запомнить меня" }, "mfa": { "title": "Двухфакторная аутентификация", @@ -568,6 +571,7 @@ "autoComplete": "Автозавершение" }, "kanban": { + "status": "Статус", "priority": "Приоритет", "score": "Оценка", "assignee": "Исполнитель", @@ -597,6 +601,7 @@ "itemTitle": "Название элемента", "manageStatuses": "Управление статусами", "viewArchived": "Просмотр архивных", + "changeStatus": "Изменить статус", "noBoards": "Доски не найдены", "noBoardsYet": "Пока нет досок Канбан", "createFirstBoard": "Создайте первую доску Канбан для управления проектами.", @@ -611,6 +616,13 @@ "targetDate": "Целевая дата", "statusUpdated": "Статус обновлён", "itemArchived": "Элемент архивирован", + "archiveAllItemsInColumn": "Архивировать все элементы в {column}", + "archiveAllConfirmTitle": "Архивировать все элементы?", + "archiveAllConfirmMessage": "Архивировать все элементы ({count}) в {column}? Их можно восстановить из архива.", + "archiveAllSuccess": "Архивировано элементов: {count}", + "archiveAllFailed": "Не удалось архивировать все элементы", + "moveFailed": "Не удалось переместить карточку", + "archiveAllPartial": "Архивировано {archived} из {total} элементов. Некоторые элементы не удалось архивировать.", "itemDeleted": "Элемент удалён", "estimatedTime": "Оценка времени (часы)", "actualVsEstimated": "{actual} / {estimated}", @@ -705,7 +717,27 @@ "connection_plural": "связей", "terminateSessionConfirm": "Вы уверены, что хотите завершить эту сессию?", "terminateAllSessionsConfirm": "Вы уверены, что хотите завершить все остальные сессии?", - "validationErrorTitle": "Ошибка валидации" + "validationErrorTitle": "Ошибка валидации", + "visibleGraphSummary": "Видимых элементов: {items}, видимых связей: {links}", + "graphTruncated": "Показаны {visible} наиболее связанных элементов. {omitted} менее связанных элементов скрыто.", + "totalConnections": "Всего", + "inboundConnections": "Входящие", + "outboundConnections": "Исходящие", + "updated": "Обновлено", + "openItem": "Открыть элемент", + "selectItem": "Выберите элемент", + "inspectNodeHint": "Коснитесь или щёлкните узел, чтобы увидеть название, категорию, количество связей и недавнюю активность.", + "graphSearchPlaceholder": "Название, путь, категория или тег", + "orphans": "Одиночные", + "labels": "Подписи", + "arrows": "Стрелки", + "nodeSize": "Размер узла", + "linkWidth": "Толщина связи", + "linkDistance": "Длина связи", + "repelForce": "Отталкивание", + "tagLinks": "Связи по тегам", + "linkedItems": "Связанные элементы", + "uuid": "UUID" }, "settings": { "title": "Настройки", @@ -861,6 +893,11 @@ "showStatusOnCards": "Показать статус", "hideStatusOnCards": "Скрыть статус", "hideStatusOnCardsDescription": "Скрывает метку статуса на карточках Канбан. Статус уже виден по колонке, в которой находится карточка.", + "hideMobileStatusDropdownLabel": "Мобильный выпадающий список статуса на карточках", + "showMobileStatusDropdown": "Показать мобильный список", + "hideMobileStatusDropdown": "Скрыть мобильный список", + "selectMobileStatusDropdown": "Выберите поведение мобильного списка статуса", + "hideMobileStatusDropdownDescription": "Управляет всегда видимым выпадающим списком статуса на мобильных карточках Канбан. Настройка метки статуса остаётся отдельной.", "selectStatusOnCards": "Выберите видимость статуса", "codeBlockChoice": "Выберите стиль блока кода", "themedCodeBlock": "Тематический кодовый блок", diff --git a/app/_translations/tr.json b/app/_translations/tr.json index 795ab0c4..0a5c82a0 100644 --- a/app/_translations/tr.json +++ b/app/_translations/tr.json @@ -7,6 +7,8 @@ "view": "Görüntüle", "new": "Yeni", "close": "Kapat", + "enlarge": "Büyüt", + "shrink": "Küçült", "confirm": "Onayla", "loading": "Yükleniyor...", "search": "Ara", @@ -306,7 +308,8 @@ "createUser": "Kullanıcı Oluştur", "attemptsRemaining": "Uyarı: Hesabın kilitlenmesine {count, plural, one {# deneme} other {# deneme}} kaldı", "accountLocked": "Çok fazla hatalı deneme. Lütfen {seconds} {seconds, plural, one {saniye} other {saniye}} sonra tekrar deneyin", - "notAuthorized": "Bu uygulamaya erişim yetkiniz yok. Lütfen yöneticinizle iletişime geçin." + "notAuthorized": "Bu uygulamaya erişim yetkiniz yok. Lütfen yöneticinizle iletişime geçin.", + "rememberMe": "Beni hatırla" }, "mfa": { "title": "İki Faktörlü Doğrulama", @@ -568,6 +571,7 @@ "autoComplete": "Otomatik tamamla" }, "kanban": { + "status": "Durum", "priority": "Öncelik", "score": "Puan", "assignee": "Atanan", @@ -597,6 +601,7 @@ "itemTitle": "Öğe başlığı", "manageStatuses": "Durumları yönet", "viewArchived": "Arşivlenenleri görüntüle", + "changeStatus": "Durumu değiştir", "noBoards": "Pano bulunamadı", "noBoardsYet": "Henüz Kanban panosu yok", "createFirstBoard": "Projelerinizi yönetmek için ilk Kanban panonuzu oluşturun.", @@ -611,6 +616,13 @@ "targetDate": "Hedef tarih", "statusUpdated": "Durum güncellendi", "itemArchived": "Öğe arşivlendi", + "archiveAllItemsInColumn": "{column} içindeki tüm öğeleri arşivle", + "archiveAllConfirmTitle": "Tüm öğeler arşivlensin mi?", + "archiveAllConfirmMessage": "{column} içindeki {count} öğenin tümü arşivlensin mi? Bunları arşivden geri yükleyebilirsiniz.", + "archiveAllSuccess": "{count} öğe arşivlendi", + "archiveAllFailed": "Tüm öğeler arşivlenemedi", + "moveFailed": "Kart taşınamadı", + "archiveAllPartial": "{total} öğeden {archived} tanesi arşivlendi. Bazı öğeler arşivlenemedi.", "itemDeleted": "Öğe silindi", "estimatedTime": "Tahmini süre (saat)", "actualVsEstimated": "{actual} / {estimated}", @@ -705,7 +717,27 @@ "connection_plural": "bağlantı", "terminateSessionConfirm": "Bu oturumu sonlandırmak istediğinizden emin misiniz?", "terminateAllSessionsConfirm": "Diğer tüm oturumları sonlandırmak istediğinizden emin misiniz?", - "validationErrorTitle": "Doğrulama Hatası" + "validationErrorTitle": "Doğrulama Hatası", + "visibleGraphSummary": "{items} görünür öğe, {links} görünür bağlantı", + "graphTruncated": "En çok bağlantıya sahip {visible} öğe gösteriliyor. Daha az bağlantılı {omitted} öğe atlandı.", + "totalConnections": "Toplam", + "inboundConnections": "Gelen", + "outboundConnections": "Giden", + "updated": "Güncellendi", + "openItem": "Öğeyi aç", + "selectItem": "Bir öğe seçin", + "inspectNodeHint": "Başlığı, kategoriyi, bağlantı sayısını ve son etkinliği görmek için bir düğüme dokunun veya tıklayın.", + "graphSearchPlaceholder": "Başlık, yol, kategori veya etiket", + "orphans": "Bağlantısız", + "labels": "Etiketler", + "arrows": "Oklar", + "nodeSize": "Düğüm boyutu", + "linkWidth": "Bağlantı kalınlığı", + "linkDistance": "Bağlantı mesafesi", + "repelForce": "İtme", + "tagLinks": "Etiket bağlantıları", + "linkedItems": "Bağlantılı öğeler", + "uuid": "UUID" }, "settings": { "title": "Ayarlar", @@ -865,6 +897,11 @@ "showStatusOnCards": "Durumu göster", "hideStatusOnCards": "Durumu gizle", "hideStatusOnCardsDescription": "Kanban kartlarındaki durum etiketini gizler. Durum, kartın bulunduğu sütun tarafından zaten belirtilmektedir.", + "hideMobileStatusDropdownLabel": "Kartlarda mobil durum açılır menüsü", + "showMobileStatusDropdown": "Mobil menüyü göster", + "hideMobileStatusDropdown": "Mobil menüyü gizle", + "selectMobileStatusDropdown": "Mobil durum menüsü davranışını seç", + "hideMobileStatusDropdownDescription": "Mobil Kanban kartlarında her zaman görünen durum açılır menüsünü kontrol eder. Durum etiketi ayarı ayrıdır.", "selectStatusOnCards": "Durum görünürlüğünü seçin", "checklistItemClickAction": "Kontrol listesi öğesi tıklama eylemi", "selectChecklistItemClickAction": "Kontrol listesi öğesini seçin tıklama eylemi", diff --git a/app/_translations/zh.json b/app/_translations/zh.json index 750ab022..88983a47 100644 --- a/app/_translations/zh.json +++ b/app/_translations/zh.json @@ -7,6 +7,8 @@ "view": "查看", "new": "新建", "close": "关闭", + "enlarge": "放大", + "shrink": "缩小", "confirm": "确认", "loading": "加载中...", "search": "搜索", @@ -306,7 +308,8 @@ "createUser": "创建用户", "attemptsRemaining": "警告:在账户锁定前还剩 {count, plural, one {# 次尝试} other {# 次尝试}}", "accountLocked": "失败次数过多。请在 {seconds} {seconds, plural, one {秒} other {秒}} 后重试", - "notAuthorized": "您没有权限访问此应用程序。请联系您的管理员。" + "notAuthorized": "您没有权限访问此应用程序。请联系您的管理员。", + "rememberMe": "记住我" }, "mfa": { "title": "双重认证 (2FA)", @@ -568,6 +571,7 @@ "autoComplete": "自动完成" }, "kanban": { + "status": "状态", "priority": "优先级", "score": "分数", "assignee": "负责人", @@ -597,6 +601,7 @@ "itemTitle": "项目标题", "manageStatuses": "管理状态", "viewArchived": "查看已归档", + "changeStatus": "更改状态", "noBoards": "未找到看板", "noBoardsYet": "暂无看板", "createFirstBoard": "创建您的第一个看板以开始管理项目。", @@ -611,6 +616,13 @@ "targetDate": "目标日期", "statusUpdated": "状态已更新", "itemArchived": "项目已归档", + "archiveAllItemsInColumn": "归档 {column} 中的所有项目", + "archiveAllConfirmTitle": "归档所有项目?", + "archiveAllConfirmMessage": "要归档 {column} 中的全部 {count} 个项目吗?你可以从归档中恢复它们。", + "archiveAllSuccess": "已归档 {count} 个项目", + "archiveAllFailed": "无法归档所有项目", + "moveFailed": "无法移动卡片", + "archiveAllPartial": "已归档 {total} 个项目中的 {archived} 个。部分项目无法归档。", "itemDeleted": "项目已删除", "estimatedTime": "预估时间(小时)", "actualVsEstimated": "{actual} / {estimated}", @@ -705,7 +717,27 @@ "connection_plural": "个连接", "terminateSessionConfirm": "确定要终止此会话吗?", "terminateAllSessionsConfirm": "确定要终止其他所有会话吗?", - "validationErrorTitle": "验证错误" + "validationErrorTitle": "验证错误", + "visibleGraphSummary": "{items} 个可见项,{links} 条可见连接", + "graphTruncated": "显示连接数最多的前 {visible} 个项目。已省略 {omitted} 个连接较少的项目。", + "totalConnections": "总计", + "inboundConnections": "入站", + "outboundConnections": "出站", + "updated": "已更新", + "openItem": "打开项目", + "selectItem": "选择一个项目", + "inspectNodeHint": "点按或单击节点可查看标题、分类、连接数和最近活动。", + "graphSearchPlaceholder": "标题、路径、分类或标签", + "orphans": "孤立项", + "labels": "标签", + "arrows": "箭头", + "nodeSize": "节点大小", + "linkWidth": "连接宽度", + "linkDistance": "连接距离", + "repelForce": "排斥力", + "tagLinks": "标签连接", + "linkedItems": "关联项目", + "uuid": "UUID" }, "settings": { "title": "设置", @@ -861,6 +893,11 @@ "showStatusOnCards": "显示状态", "hideStatusOnCards": "隐藏状态", "hideStatusOnCardsDescription": "隐藏看板卡片上的状态标签。状态已通过卡片所在的列显示。", + "hideMobileStatusDropdownLabel": "卡片上的移动端状态下拉菜单", + "showMobileStatusDropdown": "显示移动端下拉菜单", + "hideMobileStatusDropdown": "隐藏移动端下拉菜单", + "selectMobileStatusDropdown": "选择移动端状态下拉菜单行为", + "hideMobileStatusDropdownDescription": "控制移动端看板卡片上始终可见的状态下拉菜单。状态标签设置保持独立。", "selectStatusOnCards": "选择状态可见性", "codeBlockChoice": "选择代码块样式", "themedCodeBlock": "主题代码块", diff --git a/app/_types/checklist.ts b/app/_types/checklist.ts index 3c2c0f75..181eb213 100644 --- a/app/_types/checklist.ts +++ b/app/_types/checklist.ts @@ -48,6 +48,7 @@ export interface Item { status?: string; timeEntries?: TimeEntry[]; estimatedTime?: number; + startDate?: string; targetDate?: string; children?: Item[]; createdBy?: string; diff --git a/app/_types/dnd.ts b/app/_types/dnd.ts new file mode 100644 index 00000000..f16f962f --- /dev/null +++ b/app/_types/dnd.ts @@ -0,0 +1,35 @@ +export enum DragPhase { + IDLE = "idle", + DRAGGING = "dragging", +} + +export interface DragPoint { + x: number; + y: number; +} + +export interface DragRect { + top: number; + left: number; + width: number; + height: number; +} + +export interface DragSize { + width: number; + height: number; +} + +export interface DropResult { + itemId: string; + sourceListId: string; + sourceIndex: number; + targetListId: string; + targetIndex: number; +} + +export interface ListItemRect { + id: string; + index: number; + rect: DragRect; +} diff --git a/app/_types/index.ts b/app/_types/index.ts index 6d6922c7..4d8db9dd 100644 --- a/app/_types/index.ts +++ b/app/_types/index.ts @@ -38,6 +38,7 @@ export type { QuickCreateNotes, HideConnectionIndicator, HideStatusOnCards, + HideMobileStatusDropdown, CodeBlockStyle, ChecklistItemClickAction, } from "./user"; @@ -93,4 +94,12 @@ export type { WsEvent } from "./websocket"; export type { GetNotesOptions, GetChecklistsOptions } from "./options"; +export type { + DragPoint, + DragRect, + DragSize, + DropResult, + ListItemRect, +} from "./dnd"; + export type { AppNotification, AppNotificationData, NotificationType } from "./notifications"; diff --git a/app/_types/user.ts b/app/_types/user.ts index 8bc8cb2c..57cb4efd 100644 --- a/app/_types/user.ts +++ b/app/_types/user.ts @@ -38,6 +38,7 @@ export type DefaultNoteFilter = "all" | "recent" | "pinned"; export type QuickCreateNotes = "enable" | "disable"; export type HideConnectionIndicator = "enable" | "disable"; export type HideStatusOnCards = "enable" | "disable"; +export type HideMobileStatusDropdown = "enable" | "disable"; export type CodeBlockStyle = "default" | "themed"; export type ChecklistItemClickAction = "toggle" | "edit"; @@ -75,6 +76,7 @@ export interface User { quickCreateNotesCategory?: string; hideConnectionIndicator?: HideConnectionIndicator; hideStatusOnCards?: HideStatusOnCards; + hideMobileStatusDropdown?: HideMobileStatusDropdown; codeBlockStyle?: CodeBlockStyle; mfaEnabled?: boolean; mfaSecret?: string; diff --git a/app/_utils/api-item.ts b/app/_utils/api-item.ts new file mode 100644 index 00000000..3243571c --- /dev/null +++ b/app/_utils/api-item.ts @@ -0,0 +1,65 @@ +import { Item, KanbanPriority, StatusChange, TimeEntry } from "@/app/_types"; +import { TaskStatus } from "@/app/_types/enums"; + +export interface ApiItem { + id: string; + index: number; + text: string; + completed: boolean; + status?: string; + time?: number | TimeEntry[]; + description?: string; + priority?: KanbanPriority; + score?: number; + startDate?: string; + targetDate?: string; + estimatedTime?: number; + createdBy?: string; + createdAt?: string; + lastModifiedBy?: string; + lastModifiedAt?: string; + history?: StatusChange[]; + children?: ApiItem[]; +} + +export const toApiItem = ( + item: Item, + index: number, + isKanban: boolean, +): ApiItem => { + const apiItem: ApiItem = { + id: item.id, + index, + text: item.text, + completed: item.completed, + }; + + if (isKanban) { + apiItem.status = item.status || TaskStatus.TODO; + apiItem.time = + item.timeEntries && item.timeEntries.length > 0 ? item.timeEntries : 0; + } + + if (item.description !== undefined) apiItem.description = item.description; + if (item.priority !== undefined) apiItem.priority = item.priority; + if (item.score !== undefined) apiItem.score = item.score; + if (item.startDate !== undefined) apiItem.startDate = item.startDate; + if (item.targetDate !== undefined) apiItem.targetDate = item.targetDate; + if (item.estimatedTime !== undefined) + apiItem.estimatedTime = item.estimatedTime; + if (item.createdBy !== undefined) apiItem.createdBy = item.createdBy; + if (item.createdAt !== undefined) apiItem.createdAt = item.createdAt; + if (item.lastModifiedBy !== undefined) + apiItem.lastModifiedBy = item.lastModifiedBy; + if (item.lastModifiedAt !== undefined) + apiItem.lastModifiedAt = item.lastModifiedAt; + if (item.history !== undefined) apiItem.history = item.history; + + if (item.children && item.children.length > 0) { + apiItem.children = item.children.map((child, childIndex) => + toApiItem(child, childIndex, isKanban), + ); + } + + return apiItem; +}; diff --git a/app/_utils/checklist-utils.ts b/app/_utils/checklist-utils.ts index 9551efa3..237c71fb 100644 --- a/app/_utils/checklist-utils.ts +++ b/app/_utils/checklist-utils.ts @@ -104,9 +104,8 @@ export const parseMarkdown = ( ); let globalItemCounter = 0; - const resolveItemId = (storedId: string | undefined, level: number): string => { - const slot = globalItemCounter++; - return storedId || `${id}-${level}-${slot}`; + const generateItemId = (level: number): string => { + return `${id}-${level}-${globalItemCounter++}`; }; const buildNestedItems = ( @@ -176,6 +175,7 @@ export const parseMarkdown = ( let timeEntries: any[] = []; let estimatedTime: number | undefined; let targetDate: string | undefined; + let startDate: string | undefined; let description: string | undefined; let itemMetadata: Record = {}; let priority: KanbanPriority | undefined; @@ -199,6 +199,8 @@ export const parseMarkdown = ( estimatedTime = parseInt(meta.substring(10)); } else if (meta.startsWith("target:")) { targetDate = meta.substring(7); + } else if (meta.startsWith("start:")) { + startDate = meta.substring(6); } else if (meta.startsWith("description:")) { description = meta.substring(12).replace(/∣/g, "|"); } else if (meta.startsWith("metadata:")) { @@ -225,13 +227,14 @@ export const parseMarkdown = ( }); item = { - id: resolveItemId(itemMetadata.id, parentLevel), + id: itemMetadata.id || generateItemId(parentLevel), text: itemText, completed, order: currentItemIndex, status, timeEntries, estimatedTime, + startDate, targetDate, description, ...itemMetadata, @@ -267,7 +270,7 @@ export const parseMarkdown = ( } item = { - id: resolveItemId(itemMetadata.id, parentLevel), + id: itemMetadata.id || generateItemId(parentLevel), text: itemText, completed, order: currentItemIndex, @@ -403,6 +406,10 @@ const generateItemMarkdown = ( metadata.push(`estimated:${item.estimatedTime}`); } + if (item.startDate) { + metadata.push(`start:${item.startDate}`); + } + if (item.targetDate) { metadata.push(`target:${item.targetDate}`); } @@ -470,6 +477,9 @@ const generateItemMarkdown = ( if (item.estimatedTime) { itemMetadata.estimatedTime = item.estimatedTime; } + if (item.startDate) { + itemMetadata.startDate = item.startDate; + } if (item.targetDate) { itemMetadata.targetDate = item.targetDate; } diff --git a/app/_utils/client-parser-utils.ts b/app/_utils/client-parser-utils.ts index 996468f8..b04a7a01 100644 --- a/app/_utils/client-parser-utils.ts +++ b/app/_utils/client-parser-utils.ts @@ -42,6 +42,10 @@ export const parseChecklistContent = ( (line) => line.trim().startsWith("- [") || /^\s*- \[/.test(line), ); + let globalItemCounter = 0; + const generateItemId = (level: number): string => + `${id}-${level}-${globalItemCounter++}`; + const buildNestedItems = ( lines: string[], startIndex: number = 0, @@ -111,6 +115,7 @@ export const parseChecklistContent = ( let timeEntries: any[] = []; let estimatedTime: number | undefined; let targetDate: string | undefined; + let startDate: string | undefined; let description: string | undefined; let itemMetadata: Record = {}; let priority: KanbanPriority | undefined; @@ -134,6 +139,8 @@ export const parseChecklistContent = ( estimatedTime = parseInt(meta.substring(10)); } else if (meta.startsWith("target:")) { targetDate = meta.substring(7); + } else if (meta.startsWith("start:")) { + startDate = meta.substring(6); } else if (meta.startsWith("description:")) { description = meta.substring(12).replace(/∣/g, "|"); } else if (meta.startsWith("metadata:")) { @@ -164,13 +171,14 @@ export const parseChecklistContent = ( }); item = { - id: (itemMetadata.id as string) || `${id}-${currentItemIndex}`, + id: (itemMetadata.id as string) || generateItemId(parentLevel), text: itemText, completed, order: currentItemIndex, status, timeEntries, estimatedTime, + startDate, targetDate, description, ...itemMetadata, @@ -206,7 +214,7 @@ export const parseChecklistContent = ( } item = { - id: itemMetadata.id || `${id}-${currentItemIndex}`, + id: itemMetadata.id || generateItemId(parentLevel), text: itemText, completed, order: currentItemIndex, @@ -246,6 +254,21 @@ export const parseChecklistContent = ( const { items } = buildNestedItems(itemLines, 0, 0, 0); + const dedupeItemIds = (itemList: Item[], seen: Set): void => { + itemList.forEach((item) => { + if (item.id && seen.has(item.id)) { + item.id = generateItemId(0); + } + if (item.id) { + seen.add(item.id); + } + if (item.children && item.children.length > 0) { + dedupeItemIds(item.children, seen); + } + }); + }; + dedupeItemIds(items, new Set()); + let statuses = undefined; if (metadata.statuses && Array.isArray(metadata.statuses)) { statuses = metadata.statuses; diff --git a/app/_utils/dnd/auto-scroll.ts b/app/_utils/dnd/auto-scroll.ts new file mode 100644 index 00000000..acdfa605 --- /dev/null +++ b/app/_utils/dnd/auto-scroll.ts @@ -0,0 +1,64 @@ +import { DragPoint } from "@/app/_types/dnd"; +import { EDGE_SCROLL_PX, MAX_SCROLL_STEP } from "@/app/_consts/dnd"; + +export interface AutoScroller { + start: (scrollers: HTMLElement[]) => void; + update: (point: DragPoint) => void; + stop: () => void; +} + +const _axisStep = (pos: number, start: number, end: number): number => { + if (end - start < EDGE_SCROLL_PX * 3) return 0; + if (pos < start + EDGE_SCROLL_PX) { + const strength = Math.min(1, (start + EDGE_SCROLL_PX - pos) / EDGE_SCROLL_PX); + return -Math.ceil(strength * MAX_SCROLL_STEP); + } + if (pos > end - EDGE_SCROLL_PX) { + const strength = Math.min(1, (pos - (end - EDGE_SCROLL_PX)) / EDGE_SCROLL_PX); + return Math.ceil(strength * MAX_SCROLL_STEP); + } + return 0; +}; + +export const createScroller = (): AutoScroller => { + let frame: number | null = null; + let point: DragPoint | null = null; + let scrollers: HTMLElement[] = []; + + const _tick = () => { + frame = requestAnimationFrame(_tick); + if (!point) return; + + for (const el of scrollers) { + const rect = el.getBoundingClientRect(); + const inside = + point.x >= rect.left && + point.x <= rect.right && + point.y >= rect.top && + point.y <= rect.bottom; + if (!inside) continue; + + const dy = _axisStep(point.y, rect.top, rect.bottom); + const dx = _axisStep(point.x, rect.left, rect.right); + if (dy) el.scrollTop += dy; + if (dx) el.scrollLeft += dx; + } + }; + + return { + start: (targets) => { + scrollers = targets; + point = null; + if (frame === null) frame = requestAnimationFrame(_tick); + }, + update: (next) => { + point = next; + }, + stop: () => { + if (frame !== null) cancelAnimationFrame(frame); + frame = null; + point = null; + scrollers = []; + }, + }; +}; diff --git a/app/_utils/dnd/dnd-math.ts b/app/_utils/dnd/dnd-math.ts new file mode 100644 index 00000000..28c087f0 --- /dev/null +++ b/app/_utils/dnd/dnd-math.ts @@ -0,0 +1,65 @@ +import { DragPoint, DragRect, ListItemRect } from "@/app/_types/dnd"; +import { CARD_GAP_PX } from "@/app/_consts/dnd"; + +const _contains = (rect: DragRect, point: DragPoint): boolean => + point.x >= rect.left && + point.x <= rect.left + rect.width && + point.y >= rect.top && + point.y <= rect.top + rect.height; + +export const findDropList = ( + point: DragPoint, + lists: Map, +): string | null => { + let nearest: string | null = null; + let nearestDist = Infinity; + let contained: string | null = null; + + lists.forEach((rect, id) => { + if (contained) return; + if (_contains(rect, point)) { + contained = id; + return; + } + const overlapsY = point.y >= rect.top && point.y <= rect.top + rect.height; + if (!overlapsY) return; + const dist = Math.abs(point.x - (rect.left + rect.width / 2)); + if (dist < nearestDist) { + nearestDist = dist; + nearest = id; + } + }); + + return contained || nearest; +}; + +export const projectIndex = ( + pointerY: number, + items: ListItemRect[], + activeId: string, +): number => { + let index = 0; + for (const entry of items) { + if (entry.id === activeId) continue; + const midY = entry.rect.top + entry.rect.height / 2; + if (pointerY > midY) index += 1; + } + return index; +}; + +export const displaceFor = ( + effIndex: number, + activeEff: number | null, + targetIndex: number | null, + dragHeight: number, +): number => { + if (targetIndex === null) return 0; + const delta = dragHeight + CARD_GAP_PX; + + if (activeEff === null) { + return effIndex >= targetIndex ? delta : 0; + } + if (targetIndex <= effIndex && effIndex < activeEff) return delta; + if (activeEff <= effIndex && effIndex < targetIndex) return -delta; + return 0; +}; diff --git a/app/_utils/dnd/drag-store.ts b/app/_utils/dnd/drag-store.ts new file mode 100644 index 00000000..b697ae52 --- /dev/null +++ b/app/_utils/dnd/drag-store.ts @@ -0,0 +1,51 @@ +import { create } from "zustand"; +import { DragPhase, DragSize } from "@/app/_types/dnd"; + +interface DragState { + phase: DragPhase; + activeId: string | null; + sourceListId: string | null; + sourceIndex: number; + targetListId: string | null; + targetIndex: number | null; + dragSize: DragSize; + lift: ( + activeId: string, + sourceListId: string, + sourceIndex: number, + dragSize: DragSize, + ) => void; + aim: (targetListId: string | null, targetIndex: number | null) => void; + settle: () => void; +} + +const IDLE_STATE = { + phase: DragPhase.IDLE, + activeId: null, + sourceListId: null, + sourceIndex: 0, + targetListId: null, + targetIndex: null, + dragSize: { width: 0, height: 0 }, +}; + +export const useDragStore = create()((set) => ({ + ...IDLE_STATE, + lift: (activeId, sourceListId, sourceIndex, dragSize) => + set({ + phase: DragPhase.DRAGGING, + activeId, + sourceListId, + sourceIndex, + targetListId: sourceListId, + targetIndex: sourceIndex, + dragSize, + }), + aim: (targetListId, targetIndex) => + set((state) => + state.targetListId === targetListId && state.targetIndex === targetIndex + ? state + : { targetListId, targetIndex }, + ), + settle: () => set(IDLE_STATE), +})); diff --git a/app/_utils/dnd/rect-registry.ts b/app/_utils/dnd/rect-registry.ts new file mode 100644 index 00000000..d09e1d7c --- /dev/null +++ b/app/_utils/dnd/rect-registry.ts @@ -0,0 +1,120 @@ +import { DragRect, ListItemRect } from "@/app/_types/dnd"; + +interface ListEntry { + el: HTMLElement; + rect: DragRect; +} + +interface ItemEntry { + el: HTMLElement; + listId: string; + index: number; + rect: DragRect; +} + +export interface RectRegistry { + registerList: (id: string, el: HTMLElement) => void; + unregisterList: (id: string) => void; + registerItem: ( + id: string, + listId: string, + index: number, + el: HTMLElement, + ) => void; + unregisterItem: (id: string) => void; + snapshot: () => void; + shiftBy: (scrollDx: number, scrollDy: number, scroller: Node) => void; + listRects: () => Map; + itemsOf: (listId: string) => ListItemRect[]; + scrollables: () => HTMLElement[]; +} + +const ZERO_RECT: DragRect = { top: 0, left: 0, width: 0, height: 0 }; + +const _toRect = (el: HTMLElement): DragRect => { + const rect = el.getBoundingClientRect(); + return { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }; +}; + +const _scrolls = (el: HTMLElement): boolean => { + const style = window.getComputedStyle(el); + return /(auto|scroll)/.test( + `${style.overflow}${style.overflowX}${style.overflowY}`, + ); +}; + +export const createRegistry = (): RectRegistry => { + const lists = new Map(); + const items = new Map(); + + return { + registerList: (id, el) => { + lists.set(id, { el, rect: ZERO_RECT }); + }, + unregisterList: (id) => { + lists.delete(id); + }, + registerItem: (id, listId, index, el) => { + items.set(id, { el, listId, index, rect: ZERO_RECT }); + }, + unregisterItem: (id) => { + items.delete(id); + }, + snapshot: () => { + lists.forEach((entry) => { + entry.rect = _toRect(entry.el); + }); + items.forEach((entry) => { + entry.rect = _toRect(entry.el); + }); + }, + shiftBy: (scrollDx, scrollDy, scroller) => { + if (!scrollDx && !scrollDy) return; + lists.forEach((entry) => { + if (!scroller.contains(entry.el)) return; + entry.rect = { + ...entry.rect, + left: entry.rect.left - scrollDx, + top: entry.rect.top - scrollDy, + }; + }); + items.forEach((entry) => { + if (!scroller.contains(entry.el)) return; + entry.rect = { + ...entry.rect, + left: entry.rect.left - scrollDx, + top: entry.rect.top - scrollDy, + }; + }); + }, + listRects: () => { + const rects = new Map(); + lists.forEach((entry, id) => rects.set(id, entry.rect)); + return rects; + }, + itemsOf: (listId) => { + const result: ListItemRect[] = []; + items.forEach((entry, id) => { + if (entry.listId !== listId) return; + result.push({ id, index: entry.index, rect: entry.rect }); + }); + return result.sort((a, b) => a.index - b.index); + }, + scrollables: () => { + const found = new Set(); + lists.forEach(({ el }) => { + let node: HTMLElement | null = el; + while (node) { + if (_scrolls(node)) found.add(node); + node = node.parentElement; + } + }); + return Array.from(found); + }, + }; +}; diff --git a/app/_utils/grep-utils.ts b/app/_utils/grep-utils.ts index 85378c68..787fbdc3 100644 --- a/app/_utils/grep-utils.ts +++ b/app/_utils/grep-utils.ts @@ -11,12 +11,18 @@ * Enjoy it <3 */ -import { exec } from "child_process"; +import { exec, execFile } from "child_process"; import { promisify } from "util"; import path from "path"; import yaml from "js-yaml"; const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); + +const _isNoMatch = (error: unknown): boolean => + typeof error === "object" && + error !== null && + (error as { code?: number }).code === 1; export interface GrepFileResult { filePath: string; @@ -248,9 +254,13 @@ export const grepSearchContent = async ( pattern: string, ): Promise => { try { - const { stdout } = await execAsync( - `grep -rli "${pattern}" "${dir}" --include="*.md" 2>/dev/null || true`, - ); + const { stdout } = await execFileAsync("grep", [ + "-rli", + "--include=*.md", + "--", + pattern, + dir, + ]); const files = stdout.trim().split("\n").filter(Boolean); @@ -264,18 +274,28 @@ export const grepSearchContent = async ( let matchLine = ""; try { - const { stdout: matchOut } = await execAsync( - `grep -im1 "${pattern}" "${filePath}" 2>/dev/null || true`, - ); + const { stdout: matchOut } = await execFileAsync("grep", [ + "-im1", + "--", + pattern, + filePath, + ]); matchLine = matchOut.trim(); - } catch {} + } catch (error) { + if (!_isNoMatch(error)) { + console.error("grep match-line failed:", error); + } + } return { filePath, id, category, matchLine }; }), ); return results; - } catch { + } catch (error) { + if (!_isNoMatch(error)) { + console.error("grepSearchContent failed:", error); + } return []; } }; diff --git a/app/_utils/item-status-utils.ts b/app/_utils/item-status-utils.ts new file mode 100644 index 00000000..99ee66c6 --- /dev/null +++ b/app/_utils/item-status-utils.ts @@ -0,0 +1,84 @@ +import { Item, KanbanStatus } from "@/app/_types"; +import { updateItem, updateAllChildren } from "@/app/_utils/item-tree-utils"; + +const _findParent = (items: Item[], childId: string): Item | null => { + for (const item of items) { + if (item.children?.some((c) => c.id === childId)) return item; + if (item.children) { + const found = _findParent(item.children, childId); + if (found) return found; + } + } + return null; +}; + +export const applyStatus = ( + items: Item[], + itemId: string, + status: string, + statuses: KanbanStatus[] | undefined, + username: string, + now: string, +): Item[] => + updateItem(items, itemId, (item) => { + const updates: Partial = { + status, + lastModifiedBy: username, + lastModifiedAt: now, + }; + + const targetStatus = statuses?.find((s) => s.id === status); + if (targetStatus?.autoComplete) { + updates.completed = true; + if (item.children && item.children.length > 0) { + updates.children = updateAllChildren(item.children, true, username, now); + } + } else if (item.completed && status !== item.status) { + updates.completed = false; + } + + if (status !== item.status) { + updates.history = [ + ...(item.history || []), + { status, timestamp: now, user: username }, + ]; + } + + return { ...item, ...updates }; + }); + +export const completeParent = ( + items: Item[], + childId: string, + statuses: KanbanStatus[] | undefined, + username: string, + now: string, +): Item[] => { + const parent = _findParent(items, childId); + if (!parent || !parent.children) return items; + + const allChildrenCompleted = parent.children.every((c) => c.completed); + if (!allChildrenCompleted) return items; + + const autoCompleteStatus = statuses?.find((s) => s.autoComplete); + if (!autoCompleteStatus) return items; + + const alreadyComplete = + parent.completed && parent.status === autoCompleteStatus.id; + + const next = alreadyComplete + ? items + : updateItem(items, parent.id, (p) => ({ + ...p, + completed: true, + status: autoCompleteStatus.id, + lastModifiedBy: username, + lastModifiedAt: now, + history: [ + ...(p.history || []), + { status: autoCompleteStatus.id, timestamp: now, user: username }, + ], + })); + + return completeParent(next, parent.id, statuses, username, now); +}; diff --git a/app/_utils/kanban/board-utils.ts b/app/_utils/kanban/board-utils.ts new file mode 100644 index 00000000..26dfdf0a --- /dev/null +++ b/app/_utils/kanban/board-utils.ts @@ -0,0 +1,111 @@ +import { Checklist, Item, KanbanStatus } from "@/app/_types"; +import { TaskStatus } from "@/app/_types/enums"; +import { DEFAULT_KANBAN_STATUSES } from "@/app/_consts/kanban"; +import { applyStatus, completeParent } from "@/app/_utils/item-status-utils"; + +const _renumber = (items: Item[]): Item[] => + items.map((item, idx) => ({ + ...item, + order: idx, + children: item.children ? _renumber(item.children) : item.children, + })); + +const _firstStatusId = (statuses: KanbanStatus[] | undefined): string => { + const statusList = statuses || DEFAULT_KANBAN_STATUSES; + return ( + [...statusList].sort((a, b) => a.order - b.order)[0]?.id || TaskStatus.TODO + ); +}; + +export const getColumnItems = ( + items: Item[], + statusId: string, + statuses: KanbanStatus[] | undefined, +): Item[] => { + const statusList = statuses || DEFAULT_KANBAN_STATUSES; + const firstStatus = _firstStatusId(statuses); + const validIds = statusList.map((s) => s.id); + + return items.filter((item) => { + if (item.isArchived) return false; + if (item.status === statusId) return true; + if (statusId === firstStatus) { + return !validIds.includes(item.status || ""); + } + return false; + }); +}; + +export const visToColIndex = ( + visibleItems: Item[], + columnItems: Item[], + visIndex: number, +): number => { + if (visIndex < visibleItems.length) { + const safeIndex = Math.max(0, visIndex); + const anchor = visibleItems[safeIndex]; + if (!anchor) return columnItems.length; + const anchorIdx = columnItems.findIndex((item) => item.id === anchor.id); + return anchorIdx === -1 ? columnItems.length : anchorIdx; + } + + const lastVisible = visibleItems[visibleItems.length - 1]; + if (!lastVisible) return columnItems.length; + + const lastIdx = columnItems.findIndex((item) => item.id === lastVisible.id); + return lastIdx === -1 ? columnItems.length : lastIdx + 1; +}; + +export const applyDrop = ( + checklist: Checklist, + itemId: string, + targetStatus: string, + colIndex: number, + username: string, + now: string, +): Checklist => { + const dragged = checklist.items.find((item) => item.id === itemId); + if (!dragged) return checklist; + + const isStatusChange = (dragged.status || TaskStatus.TODO) !== targetStatus; + const withStatus = isStatusChange + ? completeParent( + applyStatus( + checklist.items, + itemId, + targetStatus, + checklist.statuses, + username, + now, + ), + itemId, + checklist.statuses, + username, + now, + ) + : checklist.items; + + const moved = withStatus.find((item) => item.id === itemId); + if (!moved) return checklist; + + const remaining = withStatus.filter((item) => item.id !== itemId); + const column = getColumnItems(remaining, targetStatus, checklist.statuses); + const clamped = Math.max(0, Math.min(colIndex, column.length)); + + let insertAt: number; + if (column.length === 0) { + insertAt = remaining.length; + } else if (clamped < column.length) { + insertAt = remaining.findIndex((item) => item.id === column[clamped].id); + } else { + insertAt = + remaining.findIndex( + (item) => item.id === column[column.length - 1].id, + ) + 1; + } + + const reordered = [...remaining]; + reordered.splice(insertAt, 0, moved); + + return { ...checklist, items: _renumber(reordered), updatedAt: now }; +}; diff --git a/app/_utils/kanban/calendar-utils.ts b/app/_utils/kanban/calendar-utils.ts index cb05f667..e264211c 100644 --- a/app/_utils/kanban/calendar-utils.ts +++ b/app/_utils/kanban/calendar-utils.ts @@ -3,49 +3,83 @@ import { Item, KanbanStatus } from "@/app/_types"; export interface CalendarEvent { id: string; title: string; - date: string; + startDate: string; + endDate: string; status?: string; priority?: string; completed: boolean; itemId: string; } +export interface WeekBarSegment { + event: CalendarEvent; + colStart: number; + colSpan: number; + lane: number; + continuesPrev: boolean; + continuesNext: boolean; +} + +export const toDateKey = (dateStr: string): string => dateStr.split("T")[0]; + +export const toLocalDateKey = (date: Date): string => { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +}; + export const parseItemsForCalendar = (items: Item[]): CalendarEvent[] => items .filter((item) => item.targetDate && !item.isArchived) - .map((item) => ({ - id: item.id, - title: item.text, - date: item.targetDate!, - status: item.status, - priority: item.priority, - completed: item.completed, - itemId: item.id, - })); + .map((item) => { + const endDate = toDateKey(item.targetDate!); + const startDate = item.startDate ? toDateKey(item.startDate) : endDate; + return { + id: item.id, + title: item.text, + startDate: startDate <= endDate ? startDate : endDate, + endDate: endDate >= startDate ? endDate : startDate, + status: item.status, + priority: item.priority, + completed: item.completed, + itemId: item.id, + }; + }); const _escapeICS = (text: string): string => text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n"); -const _formatICSDate = (dateStr: string): string => { +const _formatICSDateTime = (dateStr: string): string => { const d = new Date(dateStr); return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, ""); }; +const _addDays = (dateKey: string, days: number): string => { + const [y, m, d] = dateKey.split("-").map(Number); + const date = new Date(y, m - 1, d + days); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +}; + export const generateVEVENT = (item: Item, boardTitle: string): string => { if (!item.targetDate) return ""; - const dtstart = _formatICSDate(item.targetDate); - const dtend = _formatICSDate( - new Date(new Date(item.targetDate).getTime() + 3600000).toISOString() - ); - const now = _formatICSDate(new Date().toISOString()); + const endDate = toDateKey(item.targetDate); + const startDate = item.startDate ? toDateKey(item.startDate) : endDate; + const rangeStart = startDate <= endDate ? startDate : endDate; + const rangeEnd = endDate >= startDate ? endDate : startDate; + const isMultiDay = rangeStart !== rangeEnd; + const now = _formatICSDateTime(new Date().toISOString()); const lines = [ "BEGIN:VEVENT", `UID:${item.id}@jotty`, `DTSTAMP:${now}`, - `DTSTART:${dtstart}`, - `DTEND:${dtend}`, + isMultiDay + ? `DTSTART;VALUE=DATE:${rangeStart.replace(/-/g, "")}` + : `DTSTART:${_formatICSDateTime(item.targetDate)}`, + isMultiDay + ? `DTEND;VALUE=DATE:${_addDays(rangeEnd, 1).replace(/-/g, "")}` + : `DTEND:${_formatICSDateTime(new Date(new Date(item.targetDate).getTime() + 3600000).toISOString())}`, `SUMMARY:${_escapeICS(item.text)}`, `DESCRIPTION:${_escapeICS(`Board: ${boardTitle}${item.description ? `\\n${item.description}` : ""}`)}`, item.status ? `STATUS:${item.completed ? "COMPLETED" : "NEEDS-ACTION"}` : "", @@ -79,9 +113,17 @@ export const getItemsGroupedByDate = (items: Item[]): Record => items .filter((item) => item.targetDate && !item.isArchived) .forEach((item) => { - const date = item.targetDate!.split("T")[0]; - if (!grouped[date]) grouped[date] = []; - grouped[date].push(item); + const endDate = toDateKey(item.targetDate!); + const startDate = item.startDate ? toDateKey(item.startDate) : endDate; + const rangeStart = startDate <= endDate ? startDate : endDate; + const rangeEnd = endDate >= startDate ? endDate : startDate; + let cursor = rangeStart; + + while (cursor <= rangeEnd) { + if (!grouped[cursor]) grouped[cursor] = []; + grouped[cursor].push(item); + cursor = _addDays(cursor, 1); + } }); return grouped; @@ -118,3 +160,65 @@ export const getCalendarGrid = (year: number, month: number): (Date | null)[][] return grid; }; + +export const getWeekBarSegments = ( + week: (Date | null)[], + events: CalendarEvent[], +): WeekBarSegment[] => { + const raw: Omit[] = []; + + events.forEach((event) => { + let colStart = -1; + let colEnd = -1; + + week.forEach((day, index) => { + if (!day) return; + const key = toLocalDateKey(day); + if (key >= event.startDate && key <= event.endDate) { + if (colStart === -1) colStart = index; + colEnd = index; + } + }); + + if (colStart === -1) return; + + const startKey = toLocalDateKey(week[colStart]!); + const endKey = toLocalDateKey(week[colEnd]!); + + raw.push({ + event, + colStart, + colSpan: colEnd - colStart + 1, + continuesPrev: startKey > event.startDate, + continuesNext: endKey < event.endDate, + }); + }); + + raw.sort((a, b) => a.colStart - b.colStart || b.colSpan - a.colSpan); + + const lanes: { start: number; end: number }[][] = []; + const segments: WeekBarSegment[] = []; + + raw.forEach((segment) => { + const segmentEnd = segment.colStart + segment.colSpan - 1; + let lane = 0; + + while (true) { + if (!lanes[lane]) lanes[lane] = []; + const blocked = lanes[lane].some( + (occupied) => !(segmentEnd < occupied.start || segment.colStart > occupied.end), + ); + if (!blocked) { + lanes[lane].push({ start: segment.colStart, end: segmentEnd }); + segments.push({ ...segment, lane }); + break; + } + lane += 1; + } + }); + + return segments; +}; + +export const getMaxBarLanes = (segments: WeekBarSegment[]): number => + segments.reduce((max, segment) => Math.max(max, segment.lane + 1), 0); diff --git a/app/_utils/kanban/index.tsx b/app/_utils/kanban/index.tsx index 79e7244d..6e15b97e 100644 --- a/app/_utils/kanban/index.tsx +++ b/app/_utils/kanban/index.tsx @@ -2,7 +2,7 @@ import { Clock01Icon, TimeQuarterIcon } from "hugeicons-react"; import { TaskStatus } from "@/app/_types/enums"; import { Item, KanbanPriority } from "@/app/_types"; -import type { JSX } from "react"; +import type { CSSProperties, ReactElement } from "react"; export const formatTimerTime = (seconds: number): string => { const hours = Math.floor(seconds / 3600); @@ -36,7 +36,7 @@ export const getStatusColor = (status?: string, customColor?: string): string => } }; -export const getStatusIcon = (status?: string): JSX.Element | null => { +export const getStatusIcon = (status?: string): ReactElement | null => { switch (status) { case TaskStatus.IN_PROGRESS: return ; @@ -64,6 +64,18 @@ const PRIORITY_CONFIG: Record PRIORITY_CONFIG[priority || "none"].dotColor; +export const getPriorityBarStyle = ( + priority?: KanbanPriority, +): CSSProperties => { + const { dotColor } = PRIORITY_CONFIG[priority || "none"]; + if (dotColor === "transparent") return {}; + + return { + backgroundColor: `color-mix(in srgb, ${dotColor} 28%, transparent)`, + color: `color-mix(in srgb, ${dotColor} 60%, hsl(var(--foreground)))`, + }; +}; + export const getPriorityLabel = (priority: KanbanPriority | undefined, t: (key: string) => string): string => t(PRIORITY_CONFIG[priority || "none"].translationKey); diff --git a/app/api/checklists/[listId]/items/[itemIndex]/route.ts b/app/api/checklists/[listId]/items/[itemIndex]/route.ts index 984a0b8f..00875cf6 100644 --- a/app/api/checklists/[listId]/items/[itemIndex]/route.ts +++ b/app/api/checklists/[listId]/items/[itemIndex]/route.ts @@ -1,13 +1,200 @@ import { NextRequest, NextResponse } from "next/server"; import { withApiAuth } from "@/app/_utils/api-utils"; import { getListById } from "@/app/_server/actions/checklist"; +import { updateItem } from "@/app/_server/actions/checklist-item"; import { listToMarkdown } from "@/app/_utils/checklist-utils"; import { serverWriteFile } from "@/app/_server/actions/file"; import path from "path"; import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists"; +import { KanbanPriorityLevel } from "@/app/_types/enums"; export const dynamic = "force-dynamic"; +const ALLOWED_PRIORITIES = Object.values(KanbanPriorityLevel) as string[]; + +export async function PATCH( + request: NextRequest, + props: { params: Promise<{ listId: string; itemIndex: string }> }, +) { + const params = await props.params; + return withApiAuth(request, async (user) => { + try { + const body = await request.json(); + const { + text, + description, + priority, + score, + startDate, + targetDate, + estimatedTime, + } = body; + + const hasField = + text !== undefined || + description !== undefined || + priority !== undefined || + score !== undefined || + startDate !== undefined || + targetDate !== undefined || + estimatedTime !== undefined; + + if (!hasField) { + return NextResponse.json( + { error: "Provide at least one field to update" }, + { status: 400 }, + ); + } + + if (text !== undefined && typeof text !== "string") { + return NextResponse.json( + { error: "'text' must be a string" }, + { status: 400 }, + ); + } + + if ( + description !== undefined && + description !== null && + typeof description !== "string" + ) { + return NextResponse.json( + { error: "'description' must be a string" }, + { status: 400 }, + ); + } + + if ( + priority !== undefined && + priority !== null && + !ALLOWED_PRIORITIES.includes(priority) + ) { + return NextResponse.json( + { + error: `'priority' must be one of: ${ALLOWED_PRIORITIES.join(", ")}`, + }, + { status: 400 }, + ); + } + + if ( + score !== undefined && + score !== null && + (typeof score !== "number" || Number.isNaN(score)) + ) { + return NextResponse.json( + { error: "'score' must be a number" }, + { status: 400 }, + ); + } + + if ( + startDate !== undefined && + startDate !== null && + typeof startDate !== "string" + ) { + return NextResponse.json( + { error: "'startDate' must be a string" }, + { status: 400 }, + ); + } + + if ( + targetDate !== undefined && + targetDate !== null && + typeof targetDate !== "string" + ) { + return NextResponse.json( + { error: "'targetDate' must be a string" }, + { status: 400 }, + ); + } + + if ( + estimatedTime !== undefined && + estimatedTime !== null && + (typeof estimatedTime !== "number" || Number.isNaN(estimatedTime)) + ) { + return NextResponse.json( + { error: "'estimatedTime' must be a number" }, + { status: 400 }, + ); + } + + const list = await getListById(params.listId, user.username); + if (!list) { + return NextResponse.json({ error: "List not found" }, { status: 404 }); + } + + const indexPath = params.itemIndex.split(".").map((i) => parseInt(i)); + + for (const idx of indexPath) { + if (isNaN(idx) || idx < 0) { + return NextResponse.json( + { error: "Invalid item index" }, + { status: 400 }, + ); + } + } + + let item: any = null; + let currentItems = list.items; + + for (const idx of indexPath) { + if (idx >= currentItems.length) { + return NextResponse.json( + { error: "Item index out of range" }, + { status: 400 }, + ); + } + item = currentItems[idx]; + currentItems = item.children || []; + } + + if (!item) { + return NextResponse.json({ error: "Item not found" }, { status: 404 }); + } + + const formData = new FormData(); + formData.append("listId", list.id); + formData.append("itemId", item.id); + formData.append("category", list.category || "Uncategorized"); + if (text !== undefined && text !== null) formData.append("text", text); + if (description !== undefined) + formData.append("description", description ?? ""); + if (priority !== undefined) formData.append("priority", priority ?? ""); + if (score !== undefined) + formData.append("score", score === null ? "" : String(score)); + if (startDate !== undefined) + formData.append("startDate", startDate ?? ""); + if (targetDate !== undefined) + formData.append("targetDate", targetDate ?? ""); + if (estimatedTime !== undefined) + formData.append( + "estimatedTime", + estimatedTime === null ? "" : String(estimatedTime), + ); + + const result = await updateItem(list, formData, user.username, true); + + if (!result.success) { + return NextResponse.json( + { error: result.error || "Failed to update item" }, + { status: 500 }, + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("API Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } + }); +} + export async function DELETE( request: NextRequest, props: { params: Promise<{ listId: string; itemIndex: string }> }, diff --git a/app/api/checklists/[listId]/items/reorder/route.ts b/app/api/checklists/[listId]/items/reorder/route.ts new file mode 100644 index 00000000..e1731315 --- /dev/null +++ b/app/api/checklists/[listId]/items/reorder/route.ts @@ -0,0 +1,150 @@ +import { NextRequest, NextResponse } from "next/server"; +import { revalidatePath } from "next/cache"; +import { withApiAuth } from "@/app/_utils/api-utils"; +import { getListById } from "@/app/_server/actions/checklist"; +import { listToMarkdown } from "@/app/_utils/checklist-utils"; +import { serverWriteFile, ensureDir } from "@/app/_server/actions/file"; +import { checkUserPermission } from "@/app/_server/actions/sharing"; +import { broadcast } from "@/app/_server/ws/broadcast"; +import { ItemTypes, PermissionTypes } from "@/app/_types/enums"; +import path from "path"; +import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists"; + +export const dynamic = "force-dynamic"; + +type TreeItem = { id: string; order: number; children?: TreeItem[]; [key: string]: unknown }; +type Located = { item: TreeItem; siblings: TreeItem[]; index: number }; + +const findInTree = (items: TreeItem[], id: string): Located | null => { + for (let i = 0; i < items.length; i++) { + if (items[i].id === id) return { item: items[i], siblings: items, index: i }; + if (items[i].children) { + const found = findInTree(items[i].children!, id); + if (found) return found; + } + } + return null; +}; + +const isDescendant = (ancestorId: string, targetId: string, items: TreeItem[]): boolean => { + const walk = (item: TreeItem): boolean => { + if (!item.children) return false; + return item.children.some((c) => c.id === targetId || walk(c)); + }; + const ancestor = findInTree(items, ancestorId); + return ancestor ? walk(ancestor.item) : false; +}; + +const stampOrder = (items: TreeItem[]): void => { + items.forEach((item, i) => { + item.order = i; + if (item.children) stampOrder(item.children); + }); +}; + +const cloneTree = (items: TreeItem[]): TreeItem[] => + items.map((item) => ({ ...item, children: item.children ? cloneTree(item.children) : undefined })); + +export async function PUT( + request: NextRequest, + props: { params: Promise<{ listId: string }> }, +) { + const params = await props.params; + return withApiAuth(request, async (user) => { + try { + const body = await request.json(); + const { activeItemId, overItemId, position = "before", isDropInto = false } = body; + + if (!activeItemId || !overItemId) { + return NextResponse.json( + { error: "'activeItemId' and 'overItemId' are required" }, + { status: 400 }, + ); + } + + if (position !== "before" && position !== "after") { + return NextResponse.json( + { error: "'position' must be 'before' or 'after'" }, + { status: 400 }, + ); + } + + const list = await getListById(params.listId, user.username); + if (!list) { + return NextResponse.json({ error: "List not found" }, { status: 404 }); + } + + const canEdit = await checkUserPermission( + list.uuid || params.listId, + list.category || "", + ItemTypes.CHECKLIST, + user.username, + PermissionTypes.EDIT, + ); + + if (!canEdit) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const items = cloneTree(list.items as unknown as TreeItem[]); + + if (isDescendant(activeItemId, overItemId, items)) { + return NextResponse.json({ success: true }); + } + + const activeInfo = findInTree(items, activeItemId); + const overInfo = findInTree(items, overItemId); + + if (!activeInfo || !overInfo) { + return NextResponse.json({ error: "Item not found" }, { status: 404 }); + } + + if (activeItemId === overItemId) { + return NextResponse.json({ success: true }); + } + + activeInfo.siblings.splice(activeInfo.index, 1); + + if (isDropInto) { + if (!overInfo.item.children) overInfo.item.children = []; + overInfo.item.children.push(activeInfo.item); + } else { + let insertAt = overInfo.siblings.findIndex((i) => i.id === overItemId); + if (position === "after") insertAt += 1; + overInfo.siblings.splice(insertAt, 0, activeInfo.item); + } + + stampOrder(items); + + const updatedList = { ...list, items, updatedAt: new Date().toISOString() }; + + const ownerDir = path.join(process.cwd(), "data", CHECKLISTS_FOLDER, list.owner!); + const categoryDir = path.join(ownerDir, list.category || "Uncategorized"); + await ensureDir(categoryDir); + await serverWriteFile(path.join(categoryDir, `${list.id}.md`), listToMarkdown(updatedList as any)); + + try { + revalidatePath("/"); + revalidatePath(`/checklist/${list.id}`); + } catch (error) { + console.warn("Cache revalidation failed, but data was saved successfully:", error); + } + + try { + await broadcast({ + type: "checklist", + action: "updated", + entityId: list.id, + username: user.username, + }); + } catch (error) { + console.warn("Broadcast failed, but data was saved successfully:", error); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("API Error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } + }); +} diff --git a/app/api/checklists/route.ts b/app/api/checklists/route.ts index b327e99d..00673097 100644 --- a/app/api/checklists/route.ts +++ b/app/api/checklists/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { withApiAuth } from "@/app/_utils/api-utils"; import { getUserChecklists, createList } from "@/app/_server/actions/checklist"; -import { ChecklistsTypes, isKanbanType, TaskStatus } from "@/app/_types/enums"; +import { ChecklistsTypes, isKanbanType } from "@/app/_types/enums"; import { Checklist, Result } from "@/app/_types"; +import { toApiItem } from "@/app/_utils/api-item"; export const dynamic = "force-dynamic"; @@ -24,7 +25,9 @@ export async function GET(request: NextRequest) { ); } - let userLists = lists.data.filter((list) => list.owner === user.username); + let userLists = lists.data.filter( + (list) => list.owner === user.username || list.isShared, + ); if (category) { userLists = userLists.filter((list) => list.category === category); @@ -43,43 +46,15 @@ export async function GET(request: NextRequest) { ); } - const transformItem = ( - item: any, - index: number, - listType: string, - ): any => { - const baseItem: any = { - id: item.id, - index, - text: item.text, - completed: item.completed, - }; - - if (isKanbanType(listType)) { - baseItem.status = item.status || TaskStatus.TODO; - baseItem.time = - item.timeEntries && item.timeEntries.length > 0 - ? item.timeEntries - : 0; - } - - if (item.children && item.children.length > 0) { - baseItem.children = item.children.map( - (child: any, childIndex: number) => - transformItem(child, childIndex, listType), - ); - } - - return baseItem; - }; - const checklists = userLists.map((list) => ({ id: list.uuid || list.id, title: list.title, category: list.category || "Uncategorized", type: list.type || "simple", + owner: list.owner, + isShared: list.isShared ?? false, items: list.items.map((item, index) => - transformItem(item, index, list.type), + toApiItem(item, index, isKanbanType(list.type)), ), createdAt: list.createdAt, updatedAt: list.updatedAt, diff --git a/app/api/docs/route.ts b/app/api/docs/route.ts index a2408439..5d26f13e 100644 --- a/app/api/docs/route.ts +++ b/app/api/docs/route.ts @@ -50,6 +50,7 @@ function mergePaths(spec: Record): Record { const pathFiles = [ "./api/paths/health.yaml", + "./api/paths/search.yaml", "./api/paths/checklists.yaml", "./api/paths/notes.yaml", "./api/paths/tasks.yaml", diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 00000000..8b36caac --- /dev/null +++ b/app/api/search/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withApiAuth } from "@/app/_utils/api-utils"; +import { search } from "@/app/_server/actions/search"; +import { grepSearchContent, grepExtractFrontmatter } from "@/app/_utils/grep-utils"; +import { NOTES_DIR } from "@/app/_consts/files"; +import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists"; +import path from "path"; + +export const dynamic = "force-dynamic"; + +const QUERY_MIN_LEN = 2; +const RESULT_SLICE = 20; + +const cleanMatch = (line: string) => + line + .replace(/^---$/, "") + .replace(/^- \[[x ]\]\s*/i, "") + .replace(/\s*\|.*$/, "") + .replace(/^#+\s*/, "") + .trim(); + +export async function GET(request: NextRequest) { + return withApiAuth(request, async (user) => { + try { + const { searchParams } = new URL(request.url); + const query = searchParams.get("q") || ""; + const typeFilter = searchParams.get("type"); + + if (query.trim().length < QUERY_MIN_LEN) { + return NextResponse.json( + { error: `Query must be at least ${QUERY_MIN_LEN} characters` }, + { status: 400 }, + ); + } + + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const notesDir = NOTES_DIR(user.username); + const checklistsDir = path.join(process.cwd(), "data", CHECKLISTS_FOLDER, user.username); + + const [rawNotes, rawChecklists] = await Promise.all([ + typeFilter === "checklist" ? Promise.resolve([]) : grepSearchContent(notesDir, escaped).catch(() => []), + typeFilter === "note" ? Promise.resolve([]) : grepSearchContent(checklistsDir, escaped).catch(() => []), + ]); + + const toResult = async ( + hits: { filePath: string; id: string; category: string; matchLine: string }[], + type: "note" | "checklist", + ) => + Promise.all( + hits.slice(0, RESULT_SLICE).map(async (hit) => { + const meta = await grepExtractFrontmatter(hit.filePath); + const title = (meta?.title as string) || hit.id; + const cleaned = cleanMatch(hit.matchLine); + return { + id: hit.id, + uuid: meta?.uuid as string | undefined, + type, + title, + category: hit.category || "Uncategorized", + excerpt: cleaned && cleaned.toLowerCase() !== title.toLowerCase() ? cleaned : undefined, + }; + }), + ); + + const [notes, checklists] = await Promise.all([ + toResult(rawNotes, "note"), + toResult(rawChecklists, "checklist"), + ]); + + return NextResponse.json({ + query, + results: [...notes, ...checklists], + total: notes.length + checklists.length, + }); + } catch (error) { + console.error("Search API error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } + }); +} diff --git a/app/api/tasks/[taskId]/items/[itemIndex]/route.ts b/app/api/tasks/[taskId]/items/[itemIndex]/route.ts index 342368a8..c9d8c823 100644 --- a/app/api/tasks/[taskId]/items/[itemIndex]/route.ts +++ b/app/api/tasks/[taskId]/items/[itemIndex]/route.ts @@ -6,9 +6,71 @@ import { serverWriteFile } from "@/app/_server/actions/file"; import path from "path"; import { isKanbanType } from "@/app/_types/enums"; import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists"; +import { toApiItem } from "@/app/_utils/api-item"; export const dynamic = "force-dynamic"; +export async function GET( + request: NextRequest, + props: { params: Promise<{ taskId: string; itemIndex: string }> }, +) { + const params = await props.params; + return withApiAuth(request, async (user) => { + try { + const task = await getListById(params.taskId, user.username); + if (!task) { + return NextResponse.json({ error: "Task not found" }, { status: 404 }); + } + + if (!isKanbanType(task.type)) { + return NextResponse.json( + { error: "Not a task checklist" }, + { status: 400 }, + ); + } + + const indexPath = params.itemIndex.split(".").map((i) => parseInt(i)); + + for (const idx of indexPath) { + if (isNaN(idx) || idx < 0) { + return NextResponse.json( + { error: "Invalid item index" }, + { status: 400 }, + ); + } + } + + let item: any = null; + let itemIndex = 0; + let currentItems = task.items; + + for (const idx of indexPath) { + if (idx >= currentItems.length) { + return NextResponse.json( + { error: "Item index out of range" }, + { status: 400 }, + ); + } + item = currentItems[idx]; + itemIndex = idx; + currentItems = item.children || []; + } + + if (!item) { + return NextResponse.json({ error: "Item not found" }, { status: 404 }); + } + + return NextResponse.json({ item: toApiItem(item, itemIndex, true) }); + } catch (error) { + console.error("API Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } + }); +} + export async function DELETE( request: NextRequest, props: { params: Promise<{ taskId: string; itemIndex: string }> }, diff --git a/app/api/tasks/[taskId]/route.ts b/app/api/tasks/[taskId]/route.ts index b9591195..a724064e 100644 --- a/app/api/tasks/[taskId]/route.ts +++ b/app/api/tasks/[taskId]/route.ts @@ -5,7 +5,8 @@ import { updateList, deleteList, } from "@/app/_server/actions/checklist"; -import { isKanbanType, TaskStatus } from "@/app/_types/enums"; +import { isKanbanType } from "@/app/_types/enums"; +import { toApiItem } from "@/app/_utils/api-item"; export const dynamic = "force-dynamic"; @@ -28,25 +29,6 @@ export async function GET( ); } - const transformItem = (item: any, index: number): any => { - const baseItem: any = { - id: item.id, - index, - text: item.text, - status: item.status || TaskStatus.TODO, - completed: item.completed, - }; - - if (item.children && item.children.length > 0) { - baseItem.children = item.children.map( - (child: any, childIndex: number) => - transformItem(child, childIndex), - ); - } - - return baseItem; - }; - const transformedTask = { id: task.uuid || task.id, title: task.title, @@ -56,7 +38,7 @@ export async function GET( { id: "in_progress", name: "In Progress", order: 1 }, { id: "completed", name: "Completed", order: 2 }, ], - items: task.items.map((item, index) => transformItem(item, index)), + items: task.items.map((item, index) => toApiItem(item, index, true)), createdAt: task.createdAt, updatedAt: task.updatedAt, }; diff --git a/app/api/tasks/route.ts b/app/api/tasks/route.ts index 301eb0d5..5b2ff9c9 100644 --- a/app/api/tasks/route.ts +++ b/app/api/tasks/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { withApiAuth } from "@/app/_utils/api-utils"; import { getUserChecklists, createList } from "@/app/_server/actions/checklist"; -import { isKanbanType, TaskStatus } from "@/app/_types/enums"; +import { isKanbanType } from "@/app/_types/enums"; import { Checklist, Result } from "@/app/_types"; +import { toApiItem } from "@/app/_utils/api-item"; export const dynamic = "force-dynamic"; @@ -47,25 +48,6 @@ export async function GET(request: NextRequest) { ); } - const transformItem = (item: any, index: number): any => { - const baseItem: any = { - id: item.id, - index, - text: item.text, - status: item.status || TaskStatus.TODO, - completed: item.completed, - }; - - if (item.children && item.children.length > 0) { - baseItem.children = item.children.map( - (child: any, childIndex: number) => - transformItem(child, childIndex), - ); - } - - return baseItem; - }; - const tasks = userTasks.map((list) => ({ id: list.uuid || list.id, title: list.title, @@ -75,7 +57,7 @@ export async function GET(request: NextRequest) { { id: "in_progress", name: "In Progress", order: 1 }, { id: "completed", name: "Completed", order: 2 }, ], - items: list.items.map((item, index) => transformItem(item, index)), + items: list.items.map((item, index) => toApiItem(item, index, true)), createdAt: list.createdAt, updatedAt: list.updatedAt, })); diff --git a/howto/API.md b/howto/API.md index a47ab456..40e89dbf 100644 --- a/howto/API.md +++ b/howto/API.md @@ -393,6 +393,24 @@ Items can contain a `children` array with nested sub-items: "index": 0, "text": "Parent Task", "completed": false, + "status": "in_progress", + "description": "Implementation notes", + "priority": "high", + "score": 5, + "startDate": "2026-06-10", + "targetDate": "2026-06-15", + "estimatedTime": 2.5, + "createdBy": "alice", + "createdAt": "2026-06-01T09:00:00.000Z", + "lastModifiedBy": "alice", + "lastModifiedAt": "2026-06-02T10:30:00.000Z", + "history": [ + { + "status": "in_progress", + "timestamp": "2026-06-01T09:00:00.000Z", + "user": "alice" + } + ], "children": [ { "id": "list-sub-456", @@ -420,6 +438,44 @@ Items can contain a `children` array with nested sub-items: } ``` +### Update Checklist Item + +**PATCH** `/api/checklists/{listId}/items/{itemIndex}` + +Updates one or more writable fields on a checklist item. Omitted fields are left unchanged. Supports nested items using index paths. + +**Request Body:** + +```json +{ + "text": "Updated task title", + "description": "Additional notes", + "priority": "high", + "score": 5, + "startDate": "2026-06-10", + "targetDate": "2026-06-15", + "estimatedTime": 2.5 +} +``` + +**Writable Fields:** + +- `text` (optional): Item title +- `description` (optional): Item body or notes +- `priority` (optional): `"critical"`, `"high"`, `"medium"`, `"low"`, or `"none"` +- `score` (optional): Numeric score +- `startDate` (optional): Start date as an ISO date string +- `targetDate` (optional): Target date as an ISO date string +- `estimatedTime` (optional): Estimated hours + +**Response:** + +```json +{ + "success": true +} +``` + ### 7. Check Item **PUT** `/api/checklists/{listId}/items/{itemIndex}/check` @@ -921,6 +977,45 @@ Index path examples: } ``` +### Get Task Item + +**GET** `/api/tasks/{taskId}/items/{itemIndex}` + +Retrieves a single task item, including nested `children` and Kanban item fields. Supports nested index paths. + +**Response:** + +```json +{ + "item": { + "id": "task-item-1234567890", + "index": 0, + "text": "Implement user authentication", + "completed": false, + "status": "todo", + "time": 0, + "description": "Add login and token refresh", + "priority": "high", + "score": 5, + "startDate": "2026-06-10", + "targetDate": "2026-06-15", + "estimatedTime": 2.5, + "createdBy": "alice", + "createdAt": "2026-06-01T09:00:00.000Z", + "lastModifiedBy": "alice", + "lastModifiedAt": "2026-06-02T10:30:00.000Z", + "history": [ + { + "status": "todo", + "timestamp": "2026-06-01T09:00:00.000Z", + "user": "alice" + } + ], + "children": [] + } +} +``` + ### 23. Update Item Status **PUT** `/api/tasks/{taskId}/items/{itemIndex}/status` diff --git a/next.config.mjs b/next.config.mjs index 5b039ebf..767bc697 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -3,18 +3,21 @@ import createNextIntlPlugin from "next-intl/plugin"; const withNextIntl = createNextIntlPlugin("./i18n.ts"); +const maxBodySize = "1gb"; + /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", serverExternalPackages: ["ws", "libsodium-wrappers-sumo"], - serverActions: { - bodySizeLimit: "100mb", - }, experimental: { + // https://nextjs.org/docs/app/api-reference/config/next-config-js/serverActions serverActions: { - bodySizeLimit: "100mb", + bodySizeLimit: maxBodySize, }, + // https://nextjs.org/docs/app/api-reference/config/next-config-js/proxyClientMaxBodySize + proxyClientMaxBodySize: maxBodySize, }, + allowedDevOrigins: process.env.DEV_ORIGINS ? process.env.DEV_ORIGINS.split(',') : [], images: { unoptimized: true, }, diff --git a/package.json b/package.json index 423942cc..b81647b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jotty.page", - "version": "1.24.0", + "version": "1.25.0", "private": true, "scripts": { "dev": "next dev", @@ -63,7 +63,7 @@ "ldapts": "8.1.7", "libsodium-wrappers-sumo": "0.7.15", "mermaid": "10.9.6", - "next": "16.2.6", + "next": "16.2.9", "next-intl": "4.9.2", "next-themes": "0.2.1", "openpgp": "6.3.0", @@ -73,6 +73,7 @@ "react": "19.2.4", "react-day-picker": "9.14.0", "react-dom": "19.2.4", + "react-force-graph-2d": "^1.29.1", "react-markdown": "10.1.0", "react-masonry-css": "1.0.16", "react-simple-code-editor": "0.14.1", @@ -104,17 +105,20 @@ "lodash": "4.18.1", "nanoid": "3.3.8", "**/mermaid": "10.9.6", + "brace-expansion": "5.0.6", + "js-cookie": "3.0.7", + "postcss": "8.5.10", + "uuid": "11.1.1", "@types/react": "19.2.13", "@types/react-dom": "19.2.3", "@isaacs/brace-expansion": "5.0.1", - "brace-expansion": ">=5.0.6", - "postcss": ">=8.5.10", "markdown-it": "14.1.1", "minimatch": "10.2.3", "ajv": "6.14.0", "rollup": "4.59.0", "dompurify": "3.4.0", "eslint-visitor-keys": "4.2.1", + "esbuild": "0.28.1", "vite": "6.4.2" }, "devDependencies": { @@ -137,16 +141,15 @@ "@types/unist": "3.0.3", "@types/uuid": "10.0.0", "@types/ws": "8.18.1", - "@vitest/coverage-v8": "4.0.17", + "@vitest/coverage-v8": "4.1.8", "autoprefixer": "10.0.1", - "esbuild": "0.27.3", "eslint": "9", - "eslint-config-next": "16.1.6", - "postcss": "8", + "eslint-config-next": "16.2.9", + "postcss": "8.5.10", "serwist": "9.5.4", "tailwindcss": "3.3.0", "tsx": "4.21.0", "typescript": "5", - "vitest": "4.0.17" + "vitest": "4.1.8" } } diff --git a/patches/body-size-limit_20260427.js b/patches/body-size-limit_20260427.js deleted file mode 100644 index f751d13e..00000000 --- a/patches/body-size-limit_20260427.js +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Body Size Limit Patch (2026-04-27) - * - * Next.js 16 ignores `serverActions.bodySizeLimit` from next.config in standalone - * builds, leaving the hard-coded 1MB cap baked into - * node_modules/next/dist/compiled/next-server/app-page*.runtime.prod.js. - * This patch rewrites that cap so server actions (file uploads, drawio attachments, - * avatar uploads, etc.) accept payloads larger than 1MB. - * - * Configurable via the JOTTY_BODY_SIZE_LIMIT env var (default "100mb"). Accepts - * the standard `` forms: b, kb, mb, gb (e.g. "50mb", "2gb"). - * - * Idempotent: re-running with the same target is a no-op. - * - * See: https://github.com/fccview/jotty/issues/422 - */ - -const fs = require("fs"); -const path = require("path"); - -const _RUNTIME_FILES = [ - "app-page.runtime.prod.js", - "app-page-experimental.runtime.prod.js", - "app-page-turbo.runtime.prod.js", - "app-page-turbo-experimental.runtime.prod.js", - "server.runtime.prod.js", -]; - -const _UNIT_BYTES = { - b: 1, - kb: 1024, - mb: 1024 * 1024, - gb: 1024 * 1024 * 1024, -}; - -const _parseLimit = (input) => { - const raw = String(input || "100mb").trim().toLowerCase(); - const match = raw.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/); - if (!match) { - throw new Error(`Invalid JOTTY_BODY_SIZE_LIMIT value: "${input}"`); - } - const amount = parseFloat(match[1]); - const unit = match[2] || "mb"; - const bytes = Math.floor(amount * _UNIT_BYTES[unit]); - return { label: `${amount}${unit}`, bytes }; -}; - -const _patchFile = (filePath, label, bytes) => { - if (!fs.existsSync(filePath)) return "missing"; - const original = fs.readFileSync(filePath, "utf8"); - let patched = original.replace(/"1 MB"/g, `"${label}"`); - patched = patched.replace( - /\.parse\(([a-zA-Z_$][\w$]*)\):1048576(?!\d)/g, - `.parse($1):${bytes}` - ); - patched = patched.replace( - /(=[a-zA-Z_$][\w$]*\?\?)0xa00000(?!\d)/g, - `$1${bytes}` - ); - if (patched === original) return "noop"; - fs.writeFileSync(filePath, patched); - return "patched"; -}; - -const _collectRuntimeDirs = (projectRoot) => { - const dirs = [ - path.join(projectRoot, "node_modules", "next", "dist", "compiled", "next-server"), - path.join( - projectRoot, - ".next", - "standalone", - "node_modules", - "next", - "dist", - "compiled", - "next-server" - ), - ]; - return dirs.filter((d) => fs.existsSync(d)); -}; - -module.exports = { - name: "body-size-limit_20260427", - apply: (ctx) => { - const { label, bytes } = _parseLimit(process.env.JOTTY_BODY_SIZE_LIMIT); - const dirs = _collectRuntimeDirs(ctx.projectRoot); - const counts = { patched: 0, noop: 0, missing: 0 }; - for (const dir of dirs) { - for (const fileName of _RUNTIME_FILES) { - const status = _patchFile(path.join(dir, fileName), label, bytes); - counts[status]++; - } - } - return `limit=${label} (${bytes} bytes); patched=${counts.patched}, noop=${counts.noop}, missing=${counts.missing}`; - }, -}; diff --git a/public/api/components/schemas.yaml b/public/api/components/schemas.yaml index 8c6ba70a..7e274a0e 100644 --- a/public/api/components/schemas.yaml +++ b/public/api/components/schemas.yaml @@ -121,6 +121,64 @@ ChecklistItem: type: string enum: [todo, in_progress, paused, completed] description: Only present for task-type checklists + description: + type: string + description: Item body or notes + example: "Details and acceptance criteria for this item" + priority: + type: string + enum: [critical, high, medium, low, none] + description: Kanban item priority + example: "high" + score: + type: number + description: Numeric score for ordering or estimation + example: 5 + startDate: + type: string + format: date + description: Start date + example: "2026-06-10" + targetDate: + type: string + format: date + description: Due or target date + example: "2026-06-15" + estimatedTime: + type: number + description: Estimated time in hours + example: 2.5 + createdBy: + type: string + description: Username of the user who created the item + example: "alice" + createdAt: + type: string + format: date-time + description: Item creation timestamp + lastModifiedBy: + type: string + description: Username of the last user to edit the item + example: "alice" + lastModifiedAt: + type: string + format: date-time + description: Last item edit timestamp + history: + type: array + description: Status change history + items: + type: object + properties: + status: + type: string + example: "in_progress" + timestamp: + type: string + format: date-time + user: + type: string + example: "alice" time: oneOf: - type: integer diff --git a/public/api/paths/checklists.yaml b/public/api/paths/checklists.yaml index af966249..c3f0883a 100644 --- a/public/api/paths/checklists.yaml +++ b/public/api/paths/checklists.yaml @@ -1,7 +1,7 @@ /checklists: get: summary: Get All Checklists - description: Retrieves all checklists for the authenticated user. Supports filtering and search. + description: Retrieves all checklists owned by or shared with the authenticated user. Supports filtering and search. tags: - Checklists parameters: @@ -55,6 +55,13 @@ createdAt: type: string format: date-time + owner: + type: string + example: "alice" + isShared: + type: boolean + description: True when this checklist was shared with the user by another user + example: false updatedAt: type: string format: date-time @@ -191,11 +198,79 @@ '400': description: Invalid request -/checklists/{listId}/items/{itemIndex}/check: - put: - summary: Mark Item as Completed +/checklists/{listId}/items/{itemIndex}: + patch: + summary: Update Checklist Item description: | - Marks a checklist item as completed. + Updates one or more writable fields on a checklist item. + + Supports nested items using index paths (e.g., "0" for first item, "0.1" for second child of first item). + tags: + - Checklists + parameters: + - name: listId + in: path + required: true + schema: + type: string + format: uuid + - name: itemIndex + in: path + required: true + schema: + type: string + description: Index or index path (e.g., "0", "0.1", "2.0.1") + example: "0" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + text: + type: string + example: "Updated task text" + description: + type: string + example: "Additional notes about this item" + priority: + type: string + enum: [critical, high, medium, low, none] + example: "high" + score: + type: number + example: 5 + startDate: + type: string + format: date + example: "2026-06-10" + targetDate: + type: string + format: date + example: "2026-06-15" + estimatedTime: + type: number + example: 2.5 + responses: + '200': + description: Item updated successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + '400': + description: Missing fields or invalid index + '404': + description: Checklist or item not found + delete: + summary: Delete Item + description: | + Deletes a checklist item. Supports nested items using index paths (e.g., "0" for first item, "0.1" for second child of first item, "2.0.1" for nested grandchild). tags: @@ -213,10 +288,10 @@ schema: type: string description: Index or index path (e.g., "0", "0.1", "2.0.1") - example: "0.1" + example: "1" responses: '200': - description: Item marked as completed + description: Item deleted successfully content: application/json: schema: @@ -230,11 +305,70 @@ '400': description: Invalid item index -/checklists/{listId}/items/{itemIndex}/uncheck: +/checklists/{listId}/items/reorder: put: - summary: Mark Item as Incomplete + summary: Reorder Checklist Items description: | - Marks a checklist item as incomplete. + Moves an item before or after another item, or nests it as a child. + + Use `position: "before"` or `"after"` for sibling placement, or `isDropInto: true` to nest the active item inside the over item. + tags: + - Checklists + parameters: + - name: listId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - activeItemId + - overItemId + properties: + activeItemId: + type: string + description: ID of the item being moved + example: "my-list-1716900000000" + overItemId: + type: string + description: ID of the reference item to move relative to + example: "my-list-1716900000001" + position: + type: string + enum: [before, after] + default: before + description: Whether to place the active item before or after the reference item + isDropInto: + type: boolean + default: false + description: When true, nests the active item as a child of the reference item + responses: + '200': + description: Items reordered successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + '400': + description: Missing required fields or invalid position value + '404': + description: Checklist or item not found + +/checklists/{listId}/items/{itemIndex}/check: + put: + summary: Mark Item as Completed + description: | + Marks a checklist item as completed. Supports nested items using index paths (e.g., "0" for first item, "0.1" for second child of first item, "2.0.1" for nested grandchild). tags: @@ -255,7 +389,7 @@ example: "0.1" responses: '200': - description: Item marked as incomplete + description: Item marked as completed content: application/json: schema: @@ -269,11 +403,11 @@ '400': description: Invalid item index -/checklists/{listId}/items/{itemIndex}: - delete: - summary: Delete Item +/checklists/{listId}/items/{itemIndex}/uncheck: + put: + summary: Mark Item as Incomplete description: | - Deletes a checklist item. + Marks a checklist item as incomplete. Supports nested items using index paths (e.g., "0" for first item, "0.1" for second child of first item, "2.0.1" for nested grandchild). tags: @@ -291,10 +425,10 @@ schema: type: string description: Index or index path (e.g., "0", "0.1", "2.0.1") - example: "1" + example: "0.1" responses: '200': - description: Item deleted successfully + description: Item marked as incomplete content: application/json: schema: diff --git a/public/api/paths/search.yaml b/public/api/paths/search.yaml new file mode 100644 index 00000000..4b23c086 --- /dev/null +++ b/public/api/paths/search.yaml @@ -0,0 +1,69 @@ +/search: + get: + summary: Search Notes and Checklists + description: | + Full-text search across all notes and checklists owned by the authenticated user. + + Optionally filter results to a single content type. Results are ranked by relevance and capped at 20 per type. + tags: + - Search + parameters: + - name: q + in: query + required: true + schema: + type: string + minLength: 2 + description: Search query (minimum 2 characters) + example: "meeting notes" + - name: type + in: query + schema: + type: string + enum: [note, checklist] + description: Restrict results to a single content type + example: "note" + responses: + '200': + description: Search results + content: + application/json: + schema: + type: object + properties: + query: + type: string + example: "meeting notes" + total: + type: integer + example: 4 + results: + type: array + items: + type: object + properties: + id: + type: string + example: "my-note-id" + uuid: + type: string + format: uuid + example: "f47ac10b-58cc-4372-a567-0e02b2c3d479" + type: + type: string + enum: [note, checklist] + example: "note" + title: + type: string + example: "Weekly Meeting Notes" + category: + type: string + example: "Work" + excerpt: + type: string + description: Matching line from the content, omitted when it matches the title + example: "discussed the new API roadmap" + '400': + description: Query too short (must be at least 2 characters) + '401': + description: Unauthorized diff --git a/public/api/paths/tasks.yaml b/public/api/paths/tasks.yaml index 84add838..e6735066 100644 --- a/public/api/paths/tasks.yaml +++ b/public/api/paths/tasks.yaml @@ -541,6 +541,40 @@ description: Task or item not found /tasks/{taskId}/items/{itemIndex}: + get: + summary: Get Task Item + description: Retrieves a single item from a task checklist, including nested children and Kanban item fields. + tags: + - Tasks + parameters: + - name: taskId + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the task + - name: itemIndex + in: path + required: true + schema: + type: string + description: Index path of the item to fetch (e.g., "0", "0.1", "2.0.1") + responses: + '200': + description: Item details + content: + application/json: + schema: + type: object + properties: + item: + $ref: '#/components/schemas/ChecklistItem' + '400': + description: Invalid item index or not a task checklist + '404': + description: Task or item not found + delete: summary: Delete Task Item description: Deletes an item from a task checklist diff --git a/tests/api/items.test.ts b/tests/api/items.test.ts index b4acb8c4..2258a859 100644 --- a/tests/api/items.test.ts +++ b/tests/api/items.test.ts @@ -14,7 +14,7 @@ import { import { POST } from "@/app/api/checklists/[listId]/items/route" import { PUT as CHECK } from "@/app/api/checklists/[listId]/items/[itemIndex]/check/route" import { PUT as UNCHECK } from "@/app/api/checklists/[listId]/items/[itemIndex]/uncheck/route" -import { DELETE } from "@/app/api/checklists/[listId]/items/[itemIndex]/route" +import { DELETE, PATCH } from "@/app/api/checklists/[listId]/items/[itemIndex]/route" describe("Checklist Items API", () => { const mockList = { @@ -288,6 +288,59 @@ describe("Checklist Items API", () => { }) }) + describe("PATCH /api/checklists/:id/items/:index", () => { + it("should update writable kanban item fields", async () => { + mockGetListById.mockResolvedValue({ ...mockList, type: "kanban" }) + mockUpdateItem.mockResolvedValue({ success: true }) + + const request = createMockRequest("PATCH", "http://localhost:3000/api/checklists/uuid-1/items/0", { + description: "Implementation notes", + priority: "high", + score: 5, + startDate: "2026-06-10", + targetDate: "2026-06-15", + estimatedTime: 2.5, + }) + const response = await PATCH(request, { params: Promise.resolve({ listId: "uuid-1", itemIndex: "0" }) }) + const data = await getResponseJson(response) + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(mockUpdateItem).toHaveBeenCalledOnce() + + const formData = mockUpdateItem.mock.calls[0][1] as FormData + expect(formData.get("itemId")).toBe("item-1") + expect(formData.get("description")).toBe("Implementation notes") + expect(formData.get("priority")).toBe("high") + expect(formData.get("score")).toBe("5") + expect(formData.get("startDate")).toBe("2026-06-10") + expect(formData.get("targetDate")).toBe("2026-06-15") + expect(formData.get("estimatedTime")).toBe("2.5") + }) + + it("should return 400 when no patch fields are provided", async () => { + const request = createMockRequest("PATCH", "http://localhost:3000/api/checklists/uuid-1/items/0", {}) + const response = await PATCH(request, { params: Promise.resolve({ listId: "uuid-1", itemIndex: "0" }) }) + const data = await getResponseJson(response) + + expect(response.status).toBe(400) + expect(data.error).toBe("Provide at least one field to update") + expect(mockUpdateItem).not.toHaveBeenCalled() + }) + + it("should return 400 for invalid priority", async () => { + const request = createMockRequest("PATCH", "http://localhost:3000/api/checklists/uuid-1/items/0", { + priority: "urgent", + }) + const response = await PATCH(request, { params: Promise.resolve({ listId: "uuid-1", itemIndex: "0" }) }) + const data = await getResponseJson(response) + + expect(response.status).toBe(400) + expect(data.error).toContain("'priority' must be one of") + expect(mockUpdateItem).not.toHaveBeenCalled() + }) + }) + describe("DELETE /api/checklists/:id/items/:index", () => { it("should delete an item", async () => { mockGetListById.mockResolvedValue(mockList) diff --git a/tests/api/tasks.test.ts b/tests/api/tasks.test.ts index 8a8fc3a8..4dca3b34 100644 --- a/tests/api/tasks.test.ts +++ b/tests/api/tasks.test.ts @@ -20,7 +20,7 @@ import { GET as GET_TASK, PUT as PUT_TASK, DELETE as DELETE_TASK } from "@/app/a import { GET as GET_STATUSES, POST as POST_STATUS } from "@/app/api/tasks/[taskId]/statuses/route" import { PUT as PUT_STATUS, DELETE as DELETE_STATUS } from "@/app/api/tasks/[taskId]/statuses/[statusId]/route" import { POST as POST_TASK_ITEM } from "@/app/api/tasks/[taskId]/items/route" -import { DELETE as DELETE_TASK_ITEM } from "@/app/api/tasks/[taskId]/items/[itemIndex]/route" +import { DELETE as DELETE_TASK_ITEM, GET as GET_TASK_ITEM } from "@/app/api/tasks/[taskId]/items/[itemIndex]/route" import { PUT as PUT_ITEM_STATUS } from "@/app/api/tasks/[taskId]/items/[itemIndex]/status/route" describe("Tasks API", () => { @@ -42,12 +42,31 @@ describe("Tasks API", () => { text: "Task Item 1", status: "todo", completed: false, + description: "Task notes", + priority: "high", + score: 8, + startDate: "2026-06-10", + targetDate: "2026-06-15", + estimatedTime: 3.5, + createdBy: "testuser", + createdAt: "2024-01-01T00:00:00.000Z", + lastModifiedBy: "testuser", + lastModifiedAt: "2024-01-02T00:00:00.000Z", + history: [ + { + status: "todo", + timestamp: "2024-01-01T00:00:00.000Z", + user: "testuser", + }, + ], children: [ { id: "item-1-1", text: "Sub Task", status: "todo", completed: false, + description: "Sub-task notes", + priority: "medium", }, ], }, @@ -586,6 +605,78 @@ describe("Tasks API", () => { }) }) + describe("GET /api/tasks/:taskId/items/:itemIndex", () => { + it("should return a transformed task item with rich fields", async () => { + mockGetListById.mockResolvedValue(mockTask) + + const request = createMockRequest("GET", "http://localhost:3000/api/tasks/task-uuid-1/items/0") + const response = await GET_TASK_ITEM(request, { params: Promise.resolve({ taskId: "task-uuid-1", itemIndex: "0" }) }) + const data = await getResponseJson(response) + + expect(response.status).toBe(200) + expect(data.item).toMatchObject({ + id: "item-1", + index: 0, + text: "Task Item 1", + completed: false, + status: "todo", + time: 0, + description: "Task notes", + priority: "high", + score: 8, + startDate: "2026-06-10", + targetDate: "2026-06-15", + estimatedTime: 3.5, + createdBy: "testuser", + createdAt: "2024-01-01T00:00:00.000Z", + lastModifiedBy: "testuser", + lastModifiedAt: "2024-01-02T00:00:00.000Z", + history: [ + { + status: "todo", + timestamp: "2024-01-01T00:00:00.000Z", + user: "testuser", + }, + ], + }) + expect(data.item.children[0]).toMatchObject({ + id: "item-1-1", + index: 0, + description: "Sub-task notes", + priority: "medium", + }) + }) + + it("should return a transformed nested task item", async () => { + mockGetListById.mockResolvedValue(mockTask) + + const request = createMockRequest("GET", "http://localhost:3000/api/tasks/task-uuid-1/items/0.0") + const response = await GET_TASK_ITEM(request, { params: Promise.resolve({ taskId: "task-uuid-1", itemIndex: "0.0" }) }) + const data = await getResponseJson(response) + + expect(response.status).toBe(200) + expect(data.item).toMatchObject({ + id: "item-1-1", + index: 0, + text: "Sub Task", + status: "todo", + time: 0, + description: "Sub-task notes", + }) + }) + + it("should return 400 for non-task checklist item reads", async () => { + mockGetListById.mockResolvedValue({ ...mockTask, type: "simple" }) + + const request = createMockRequest("GET", "http://localhost:3000/api/tasks/task-uuid-1/items/0") + const response = await GET_TASK_ITEM(request, { params: Promise.resolve({ taskId: "task-uuid-1", itemIndex: "0" }) }) + const data = await getResponseJson(response) + + expect(response.status).toBe(400) + expect(data.error).toBe("Not a task checklist") + }) + }) + describe("DELETE /api/tasks/:taskId/items/:itemIndex", () => { it("should delete a task item", async () => { mockGetListById.mockResolvedValue(mockTask) diff --git a/tests/server-actions/auth.test.ts b/tests/server-actions/auth.test.ts index ce9a9ff4..4bb3d785 100644 --- a/tests/server-actions/auth.test.ts +++ b/tests/server-actions/auth.test.ts @@ -346,7 +346,8 @@ describe('Auth Actions', () => { expect(mockCreateSession).toHaveBeenCalledWith( expect.any(String), 'alice', - 'ldap' + 'ldap', + expect.any(Boolean) ) }) diff --git a/tests/server-actions/drop-item.test.ts b/tests/server-actions/drop-item.test.ts new file mode 100644 index 00000000..5b1e7f2d --- /dev/null +++ b/tests/server-actions/drop-item.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { resetAllMocks, createFormData } from "../setup"; + +const mockEnsureDir = vi.fn(); +const mockServerWriteFile = vi.fn(); +const mockGetUsername = vi.fn(); +const mockCheckUserPermission = vi.fn(); +const mockGetListById = vi.fn(); +const mockBroadcast = vi.fn(); + +vi.mock("@/app/_server/actions/file", () => ({ + ensureDir: (...args: unknown[]) => mockEnsureDir(...args), + serverWriteFile: (...args: unknown[]) => mockServerWriteFile(...args), +})); + +vi.mock("@/app/_server/actions/users", () => ({ + getUsername: (...args: unknown[]) => mockGetUsername(...args), +})); + +vi.mock("@/app/_server/actions/sharing", () => ({ + checkUserPermission: (...args: unknown[]) => mockCheckUserPermission(...args), +})); + +vi.mock("@/app/_server/actions/checklist", () => ({ + getListById: (...args: unknown[]) => mockGetListById(...args), +})); + +vi.mock("@/app/_server/ws/broadcast", () => ({ + broadcast: (...args: unknown[]) => mockBroadcast(...args), +})); + +vi.mock("@/app/_utils/checklist-utils", () => ({ + listToMarkdown: vi.fn().mockReturnValue("# Test Board"), +})); + +import { dropItem } from "@/app/_server/actions/checklist-item"; + +const BOARD_UUID = "12345678-1234-1234-1234-123456789abc"; + +const mockBoard = { + id: "test-board", + uuid: BOARD_UUID, + title: "Test Board", + category: "TestCategory", + owner: "testuser", + type: "kanban" as const, + statuses: [ + { id: "todo", label: "To Do", order: 0, autoComplete: false }, + { id: "in_progress", label: "In Progress", order: 1, autoComplete: false }, + { id: "completed", label: "Completed", order: 2, autoComplete: true }, + ], + items: [ + { id: "task-1", text: "Task 1", completed: false, order: 0, status: "todo" }, + { id: "task-2", text: "Task 2", completed: false, order: 1, status: "todo" }, + { + id: "task-3", + text: "Task 3", + completed: false, + order: 2, + status: "in_progress", + }, + ], + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", +}; + +describe("dropItem", () => { + beforeEach(() => { + resetAllMocks(); + mockEnsureDir.mockResolvedValue(undefined); + mockServerWriteFile.mockResolvedValue(undefined); + mockGetUsername.mockResolvedValue("testuser"); + mockCheckUserPermission.mockResolvedValue(true); + mockBroadcast.mockResolvedValue(undefined); + mockGetListById.mockImplementation(async (id: string) => + id === BOARD_UUID ? structuredClone(mockBoard) : undefined, + ); + }); + + it("moves an item cross-column to the exact index", async () => { + const result = await dropItem( + createFormData({ + uuid: BOARD_UUID, + itemId: "task-1", + targetStatus: "in_progress", + targetIndex: "0", + }), + ); + + expect(result.success).toBe(true); + const inProgress = result.data!.items.filter( + (i) => i.status === "in_progress", + ); + expect(inProgress.map((i) => i.id)).toEqual(["task-1", "task-3"]); + expect(result.data!.items.map((i) => i.order)).toEqual([0, 1, 2]); + expect(mockServerWriteFile).toHaveBeenCalledTimes(1); + expect(mockBroadcast).toHaveBeenCalledTimes(1); + expect(mockBroadcast).toHaveBeenCalledWith({ + type: "checklist", + action: "updated", + entityId: "test-board", + username: "testuser", + }); + }); + + it("reorders within a column", async () => { + const result = await dropItem( + createFormData({ + uuid: BOARD_UUID, + itemId: "task-1", + targetStatus: "todo", + targetIndex: "2", + }), + ); + + expect(result.success).toBe(true); + const todo = result.data!.items.filter((i) => i.status === "todo"); + expect(todo.map((i) => i.id)).toEqual(["task-2", "task-1"]); + }); + + it("marks the item completed when dropped on an autoComplete column", async () => { + const result = await dropItem( + createFormData({ + uuid: BOARD_UUID, + itemId: "task-1", + targetStatus: "completed", + targetIndex: "0", + }), + ); + + expect(result.success).toBe(true); + const moved = result.data!.items.find((i) => i.id === "task-1"); + expect(moved?.completed).toBe(true); + expect(moved?.status).toBe("completed"); + }); + + it("clamps an out-of-range index to the column end", async () => { + const result = await dropItem( + createFormData({ + uuid: BOARD_UUID, + itemId: "task-1", + targetStatus: "in_progress", + targetIndex: "99", + }), + ); + + expect(result.success).toBe(true); + const inProgress = result.data!.items.filter( + (i) => i.status === "in_progress", + ); + expect(inProgress.map((i) => i.id)).toEqual(["task-3", "task-1"]); + }); + + it("rejects when required fields are missing", async () => { + const result = await dropItem( + createFormData({ uuid: BOARD_UUID, itemId: "task-1" }), + ); + + expect(result.success).toBe(false); + expect(mockServerWriteFile).not.toHaveBeenCalled(); + expect(mockBroadcast).not.toHaveBeenCalled(); + }); + + it("rejects an unknown list uuid", async () => { + const result = await dropItem( + createFormData({ + uuid: "00000000-0000-0000-0000-000000000000", + itemId: "task-1", + targetStatus: "todo", + targetIndex: "0", + }), + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("List not found"); + }); + + it("rejects an unknown item", async () => { + const result = await dropItem( + createFormData({ + uuid: BOARD_UUID, + itemId: "nope", + targetStatus: "todo", + targetIndex: "0", + }), + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("Item not found"); + expect(mockServerWriteFile).not.toHaveBeenCalled(); + }); + + it("rejects without edit permission", async () => { + mockCheckUserPermission.mockResolvedValue(false); + + const result = await dropItem( + createFormData({ + uuid: BOARD_UUID, + itemId: "task-1", + targetStatus: "todo", + targetIndex: "0", + }), + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("Permission denied"); + expect(mockServerWriteFile).not.toHaveBeenCalled(); + expect(mockBroadcast).not.toHaveBeenCalled(); + }); + + it("returns a failure result when the write blows up", async () => { + mockServerWriteFile.mockRejectedValue(new Error("disk full")); + + const result = await dropItem( + createFormData({ + uuid: BOARD_UUID, + itemId: "task-1", + targetStatus: "todo", + targetIndex: "0", + }), + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("Failed to drop item"); + expect(mockBroadcast).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/utils/board-utils.test.ts b/tests/utils/board-utils.test.ts new file mode 100644 index 00000000..d5a93344 --- /dev/null +++ b/tests/utils/board-utils.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect } from "vitest"; +import { + getColumnItems, + visToColIndex, + applyDrop, +} from "@/app/_utils/kanban/board-utils"; +import { DEFAULT_KANBAN_STATUSES } from "@/app/_consts/kanban"; +import { TaskStatus } from "@/app/_types/enums"; +import { Checklist, Item } from "@/app/_types"; + +const makeItem = (id: string, status?: string, extra?: Partial): Item => ({ + id, + text: id, + completed: false, + order: 0, + status, + ...extra, +}); + +const makeBoard = (items: Item[]): Checklist => ({ + id: "board", + uuid: "11111111-2222-3333-4444-555555555555", + title: "Board", + type: "kanban", + items, + statuses: DEFAULT_KANBAN_STATUSES, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}); + +const NOW = "2026-06-12T12:00:00.000Z"; +const USER = "frodo"; + +describe("getColumnItems", () => { + it("returns items of the requested status in array order", () => { + const items = [ + makeItem("a", TaskStatus.TODO), + makeItem("b", TaskStatus.IN_PROGRESS), + makeItem("c", TaskStatus.TODO), + ]; + const column = getColumnItems(items, TaskStatus.TODO, DEFAULT_KANBAN_STATUSES); + expect(column.map((i) => i.id)).toEqual(["a", "c"]); + }); + + it("excludes archived items", () => { + const items = [ + makeItem("a", TaskStatus.TODO), + makeItem("b", TaskStatus.TODO, { isArchived: true }), + ]; + const column = getColumnItems(items, TaskStatus.TODO, DEFAULT_KANBAN_STATUSES); + expect(column.map((i) => i.id)).toEqual(["a"]); + }); + + it("buckets invalid-status items into the first column only", () => { + const items = [ + makeItem("a", "ghost-status"), + makeItem("b", undefined), + makeItem("c", TaskStatus.IN_PROGRESS), + ]; + const first = getColumnItems(items, TaskStatus.TODO, DEFAULT_KANBAN_STATUSES); + const second = getColumnItems( + items, + TaskStatus.IN_PROGRESS, + DEFAULT_KANBAN_STATUSES, + ); + expect(first.map((i) => i.id)).toEqual(["a", "b"]); + expect(second.map((i) => i.id)).toEqual(["c"]); + }); +}); + +describe("visToColIndex", () => { + const column = [ + makeItem("a", TaskStatus.TODO), + makeItem("b", TaskStatus.TODO), + makeItem("c", TaskStatus.TODO), + makeItem("d", TaskStatus.TODO), + ]; + + it("is the identity when no filter is active", () => { + expect(visToColIndex(column, column, 2)).toBe(2); + expect(visToColIndex(column, column, 4)).toBe(4); + }); + + it("maps a drop before a visible anchor to its column position", () => { + const visible = [column[1], column[3]]; + expect(visToColIndex(visible, column, 0)).toBe(1); + expect(visToColIndex(visible, column, 1)).toBe(3); + }); + + it("maps a drop at the end of the visible list after the last visible item", () => { + const visible = [column[0], column[2]]; + expect(visToColIndex(visible, column, 2)).toBe(3); + }); + + it("drops at the column end when nothing is visible", () => { + expect(visToColIndex([], column, 0)).toBe(4); + }); +}); + +describe("applyDrop", () => { + it("reorders within a column without touching metadata", () => { + const board = makeBoard([ + makeItem("a", TaskStatus.TODO), + makeItem("b", TaskStatus.TODO), + makeItem("c", TaskStatus.TODO), + ]); + const result = applyDrop(board, "a", TaskStatus.TODO, 2, USER, NOW); + const column = getColumnItems( + result.items, + TaskStatus.TODO, + DEFAULT_KANBAN_STATUSES, + ); + expect(column.map((i) => i.id)).toEqual(["b", "c", "a"]); + expect(result.items.find((i) => i.id === "a")?.history).toBeUndefined(); + expect(result.items.find((i) => i.id === "a")?.lastModifiedBy).toBeUndefined(); + }); + + it("moves cross-column with status, history, and exact position", () => { + const board = makeBoard([ + makeItem("a", TaskStatus.TODO), + makeItem("b", TaskStatus.IN_PROGRESS), + makeItem("c", TaskStatus.IN_PROGRESS), + ]); + const result = applyDrop(board, "a", TaskStatus.IN_PROGRESS, 1, USER, NOW); + const column = getColumnItems( + result.items, + TaskStatus.IN_PROGRESS, + DEFAULT_KANBAN_STATUSES, + ); + const moved = result.items.find((i) => i.id === "a"); + expect(column.map((i) => i.id)).toEqual(["b", "a", "c"]); + expect(moved?.status).toBe(TaskStatus.IN_PROGRESS); + expect(moved?.history).toEqual([ + { status: TaskStatus.IN_PROGRESS, timestamp: NOW, user: USER }, + ]); + expect(moved?.lastModifiedBy).toBe(USER); + }); + + it("marks the item completed when dropped on an autoComplete column", () => { + const board = makeBoard([ + makeItem("a", TaskStatus.TODO, { + children: [makeItem("a1", TaskStatus.TODO)], + }), + makeItem("b", TaskStatus.COMPLETED), + ]); + const result = applyDrop(board, "a", TaskStatus.COMPLETED, 0, USER, NOW); + const moved = result.items.find((i) => i.id === "a"); + expect(moved?.completed).toBe(true); + expect(moved?.children?.[0].completed).toBe(true); + }); + + it("clears completed when leaving an autoComplete column", () => { + const board = makeBoard([ + makeItem("a", TaskStatus.COMPLETED, { completed: true }), + makeItem("b", TaskStatus.TODO), + ]); + const result = applyDrop(board, "a", TaskStatus.TODO, 0, USER, NOW); + const moved = result.items.find((i) => i.id === "a"); + expect(moved?.completed).toBe(false); + expect(moved?.status).toBe(TaskStatus.TODO); + }); + + it("preserves the relative order of other columns", () => { + const board = makeBoard([ + makeItem("a", TaskStatus.TODO), + makeItem("b", TaskStatus.PAUSED), + makeItem("c", TaskStatus.TODO), + makeItem("d", TaskStatus.PAUSED), + ]); + const result = applyDrop(board, "a", TaskStatus.IN_PROGRESS, 0, USER, NOW); + const paused = getColumnItems( + result.items, + TaskStatus.PAUSED, + DEFAULT_KANBAN_STATUSES, + ); + const todo = getColumnItems( + result.items, + TaskStatus.TODO, + DEFAULT_KANBAN_STATUSES, + ); + expect(paused.map((i) => i.id)).toEqual(["b", "d"]); + expect(todo.map((i) => i.id)).toEqual(["c"]); + }); + + it("clamps an out-of-range index to the column end", () => { + const board = makeBoard([ + makeItem("a", TaskStatus.TODO), + makeItem("b", TaskStatus.IN_PROGRESS), + ]); + const result = applyDrop(board, "a", TaskStatus.IN_PROGRESS, 99, USER, NOW); + const column = getColumnItems( + result.items, + TaskStatus.IN_PROGRESS, + DEFAULT_KANBAN_STATUSES, + ); + expect(column.map((i) => i.id)).toEqual(["b", "a"]); + }); + + it("drops into an empty column", () => { + const board = makeBoard([makeItem("a", TaskStatus.TODO)]); + const result = applyDrop(board, "a", TaskStatus.PAUSED, 0, USER, NOW); + expect(result.items.find((i) => i.id === "a")?.status).toBe( + TaskStatus.PAUSED, + ); + }); + + it("renumbers order fields recursively", () => { + const board = makeBoard([ + makeItem("a", TaskStatus.TODO, { + order: 7, + children: [makeItem("a1", TaskStatus.TODO, { order: 9 })], + }), + makeItem("b", TaskStatus.TODO, { order: 3 }), + ]); + const result = applyDrop(board, "a", TaskStatus.TODO, 1, USER, NOW); + expect(result.items.map((i) => i.order)).toEqual([0, 1]); + const moved = result.items.find((i) => i.id === "a"); + expect(moved?.children?.[0].order).toBe(0); + }); + + it("returns the checklist untouched for an unknown item", () => { + const board = makeBoard([makeItem("a", TaskStatus.TODO)]); + expect(applyDrop(board, "nope", TaskStatus.TODO, 0, USER, NOW)).toBe(board); + }); +}); diff --git a/tests/utils/client-parser-utils.test.ts b/tests/utils/client-parser-utils.test.ts new file mode 100644 index 00000000..5d4e4b3c --- /dev/null +++ b/tests/utils/client-parser-utils.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { parseChecklistContent } from "@/app/_utils/client-parser-utils"; +import { Item } from "@/app/_types"; + +const collectIds = (items: Item[], acc: string[] = []): string[] => { + items.forEach((item) => { + acc.push(item.id); + if (item.children && item.children.length > 0) { + collectIds(item.children, acc); + } + }); + return acc; +}; + +describe("parseChecklistContent — unique item IDs", () => { + it("assigns unique IDs to every item across nested groups (regression for #501)", () => { + const lines: string[] = []; + for (let g = 1; g <= 4; g++) { + lines.push(`- [ ] Group ${g}`); + for (let i = 1; i <= 5; i++) { + lines.push(` - [ ] Item ${g}.${i}`); + } + } + const content = lines.join("\n"); + + const { items } = parseChecklistContent(content, "my-list"); + const ids = collectIds(items); + + expect(ids).toHaveLength(4 + 4 * 5); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("preserves IDs stored in inline item metadata", () => { + const content = [ + "- [ ] Item with stored id | metadata:{\"id\":\"stored-id-1\"}", + "- [ ] Item without stored id", + ].join("\n"); + + const { items } = parseChecklistContent(content, "my-list"); + + expect(items[0].id).toBe("stored-id-1"); + expect(items[1].id).not.toBe("stored-id-1"); + expect(items[1].id).toBeTruthy(); + }); + + it("dedupes IDs from already-broken imported files", () => { + const content = [ + "- [ ] Group A | metadata:{\"id\":\"file-0\"}", + " - [ ] Item A1 | metadata:{\"id\":\"file-0\"}", + " - [ ] Item A2 | metadata:{\"id\":\"file-1\"}", + "- [ ] Group B | metadata:{\"id\":\"file-1\"}", + " - [ ] Item B1 | metadata:{\"id\":\"file-2\"}", + ].join("\n"); + + const { items } = parseChecklistContent(content, "file"); + const ids = collectIds(items); + + expect(new Set(ids).size).toBe(ids.length); + expect(items[0].id).toBe("file-0"); + expect(items[0].children?.[0].id).not.toBe("file-0"); + }); +}); diff --git a/tests/utils/connections-graph-data.test.ts b/tests/utils/connections-graph-data.test.ts new file mode 100644 index 00000000..b12a38c8 --- /dev/null +++ b/tests/utils/connections-graph-data.test.ts @@ -0,0 +1,279 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { ItemTypes } from "@/app/_types/enums"; +import { LinkIndex } from "@/app/_types"; +import { + buildConnectionGraphData, + filterArchivedLinkIndex, + filterConnectionGraphData, +} from "@/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/graph-data"; + +const noteA = "11111111-1111-4111-8111-111111111111"; +const noteB = "22222222-2222-4222-8222-222222222222"; +const noteC = "33333333-3333-4333-8333-333333333333"; +const listA = "44444444-4444-4444-8444-444444444444"; +const listB = "55555555-5555-4555-8555-555555555555"; + +const emptyLinks = () => ({ + isLinkedTo: { notes: [], checklists: [] }, + isReferencedIn: { notes: [], checklists: [] }, +}); + +const baseIndex = (): LinkIndex => ({ + notes: {}, + checklists: {}, +}); + +const notes = [ + { + uuid: noteA, + id: "alpha", + title: "Alpha", + category: "Work", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-02T00:00:00.000Z", + tags: ["project"], + }, + { + uuid: noteB, + id: "beta", + title: "Beta", + category: "Work", + createdAt: "2024-01-03T00:00:00.000Z", + updatedAt: "2024-01-04T00:00:00.000Z", + tags: ["reference"], + }, + { + uuid: noteC, + id: "orphan", + title: "Orphan", + category: "Personal", + createdAt: "2024-01-05T00:00:00.000Z", + updatedAt: "2024-01-06T00:00:00.000Z", + }, +]; + +const checklists = [ + { + uuid: listA, + id: "tasks", + title: "Tasks", + type: "simple" as const, + category: "Work", + items: [], + createdAt: "2024-01-07T00:00:00.000Z", + updatedAt: "2024-01-08T00:00:00.000Z", + }, + { + uuid: listB, + id: "archive", + title: "Archive", + type: "task" as const, + category: "Old", + items: [], + createdAt: "2024-01-09T00:00:00.000Z", + updatedAt: "2024-01-10T00:00:00.000Z", + }, +]; + +describe("connections graph data", () => { + it("builds note-to-note explicit links", () => { + const index = baseIndex(); + index.notes[noteA] = { + ...emptyLinks(), + isLinkedTo: { notes: [noteB], checklists: [] }, + }; + + const graph = buildConnectionGraphData(index, notes, checklists); + + expect(graph.nodes.map((node) => node.id).sort()).toEqual([noteA, noteB]); + expect(graph.links).toEqual([{ source: noteA, target: noteB, directed: true, type: "explicit" }]); + expect(graph.nodes.find((node) => node.id === noteA)?.outboundCount).toBe(1); + expect(graph.nodes.find((node) => node.id === noteB)?.inboundCount).toBe(1); + }); + + it("builds note-to-checklist and checklist-to-note links", () => { + const index = baseIndex(); + index.notes[noteA] = { + ...emptyLinks(), + isLinkedTo: { notes: [], checklists: [listA] }, + }; + index.checklists[listA] = { + ...emptyLinks(), + isLinkedTo: { notes: [noteB], checklists: [] }, + }; + + const graph = buildConnectionGraphData(index, notes, checklists); + + expect(graph.links).toHaveLength(2); + expect(graph.links).toContainEqual({ source: noteA, target: listA, directed: true, type: "explicit" }); + expect(graph.links).toContainEqual({ source: listA, target: noteB, directed: true, type: "explicit" }); + expect(graph.nodes.find((node) => node.id === listA)?.type).toBe(ItemTypes.CHECKLIST); + }); + + it("connects items that share tags", () => { + const index = baseIndex(); + const taggedNotes = [ + { ...notes[0], tags: ["project", "shared"] }, + { ...notes[1], tags: ["shared"] }, + { ...notes[2], tags: ["other"] }, + ]; + + const graph = buildConnectionGraphData(index, taggedNotes, checklists); + + expect(graph.links).toContainEqual({ + source: noteA, + target: noteB, + directed: false, + type: "tag", + tag: "shared", + }); + expect(graph.links).not.toContainEqual( + expect.objectContaining({ source: noteA, target: noteC, type: "tag" }), + ); + expect(graph.nodes.find((node) => node.id === noteA)?.connectionCount).toBe(1); + expect(graph.nodes.find((node) => node.id === noteB)?.connectionCount).toBe(1); + }); + + it("de-dupes duplicate bidirectional visible edges", () => { + const index = baseIndex(); + index.notes[noteA] = { + ...emptyLinks(), + isLinkedTo: { notes: [noteB], checklists: [] }, + }; + index.notes[noteB] = { + ...emptyLinks(), + isLinkedTo: { notes: [noteA], checklists: [] }, + }; + + const graph = buildConnectionGraphData(index, notes, checklists); + + expect(graph.links).toHaveLength(1); + expect(graph.links[0]).toMatchObject({ source: noteA, target: noteB, type: "explicit" }); + expect(graph.nodes.find((node) => node.id === noteA)?.connectionCount).toBe(2); + expect(graph.nodes.find((node) => node.id === noteB)?.connectionCount).toBe(2); + }); + + it("filters archived items by UUID first and legacy key fallback", () => { + const index = baseIndex(); + index.notes[noteA] = { + ...emptyLinks(), + isLinkedTo: { notes: [noteB], checklists: ["Old/archive"] }, + }; + index.notes[noteB] = emptyLinks(); + index.checklists["Old/archive"] = emptyLinks(); + + const filtered = filterArchivedLinkIndex(index, [ + { uuid: noteB, id: "wrong", title: "Wrong", content: "", createdAt: "", updatedAt: "" }, + { id: "archive", title: "Archive", type: "task", category: "Old", items: [], createdAt: "", updatedAt: "" }, + ]); + + expect(filtered.notes[noteB]).toBeUndefined(); + expect(filtered.checklists["Old/archive"]).toBeUndefined(); + expect(filtered.notes[noteA].isLinkedTo.notes).toEqual([]); + expect(filtered.notes[noteA].isLinkedTo.checklists).toEqual([]); + }); + + it("filters missing targets", () => { + const index = baseIndex(); + index.notes[noteA] = { + ...emptyLinks(), + isLinkedTo: { + notes: ["66666666-6666-4666-8666-666666666666"], + checklists: [], + }, + }; + + const graph = buildConnectionGraphData(index, notes, checklists); + + expect(graph.nodes).toEqual([]); + expect(graph.links).toEqual([]); + }); + + it("includes or excludes orphan nodes", () => { + const index = baseIndex(); + + expect(buildConnectionGraphData(index, notes, checklists).nodes).toEqual([]); + + const graph = buildConnectionGraphData(index, notes, checklists, true); + + expect(graph.nodes.map((node) => node.id)).toContain(noteC); + expect(graph.nodes.find((node) => node.id === noteC)?.orphan).toBe(true); + }); + + it("filters visible graph data by search and type", () => { + const index = baseIndex(); + index.notes[noteA] = { + ...emptyLinks(), + isLinkedTo: { notes: [], checklists: [listA] }, + }; + + const graph = buildConnectionGraphData(index, notes, checklists, true); + const filtered = filterConnectionGraphData(graph, { + search: "tasks", + showNotes: true, + showChecklists: true, + showOrphans: false, + showTagLinks: true, + }); + + expect(filtered.nodes.map((node) => node.id)).toEqual([listA]); + expect(filtered.links).toEqual([]); + }); + + it("can hide tag links from the visible graph", () => { + const index = baseIndex(); + const taggedNotes = [ + { ...notes[0], tags: ["shared"] }, + { ...notes[1], tags: ["shared"] }, + ]; + const graph = buildConnectionGraphData(index, taggedNotes, checklists); + const filtered = filterConnectionGraphData(graph, { + search: "", + showNotes: true, + showChecklists: true, + showOrphans: false, + showTagLinks: false, + }); + + expect(graph.links.some((link) => link.type === "tag")).toBe(true); + expect(filtered.links).toEqual([]); + expect(filtered.nodes).toEqual([]); + }); + + it("puts the graph before the controls on mobile while keeping desktop controls first", () => { + const source = readFileSync( + "app/_components/FeatureComponents/Profile/Parts/LinksTab.tsx", + "utf8", + ); + + expect(source).toContain("order-2 lg:order-1"); + expect(source).toContain("order-1 lg:order-2"); + expect(source.indexOf("order-2 lg:order-1")).toBeLessThan( + source.indexOf("order-1 lg:order-2"), + ); + }); + + it("uses existing profile translation keys for graph labels", () => { + const source = readFileSync( + "app/_components/FeatureComponents/Profile/Parts/LinksTab.tsx", + "utf8", + ); + + expect(source).not.toContain('t("common.unknown")'); + expect(source).toContain('t("profile.unknown")'); + }); + + it("paints a larger node pointer area for mobile tapping", () => { + const source = readFileSync( + "app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/ConnectionsGraph.tsx", + "utf8", + ); + + expect(source).toContain("MIN_POINTER_RADIUS"); + expect(source).toContain("nodePointerAreaPaint"); + expect(source).toContain("globalScale"); + expect(source).toContain("MIN_POINTER_RADIUS / globalScale"); + expect(source).toContain("enableNodeDrag={!isCoarsePointer}"); + }); +}); diff --git a/tests/utils/dnd-math.test.ts b/tests/utils/dnd-math.test.ts new file mode 100644 index 00000000..29689ae3 --- /dev/null +++ b/tests/utils/dnd-math.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import { + findDropList, + projectIndex, + displaceFor, +} from "@/app/_utils/dnd/dnd-math"; +import { CARD_GAP_PX } from "@/app/_consts/dnd"; +import { DragRect, ListItemRect } from "@/app/_types/dnd"; + +const makeRect = ( + top: number, + left: number, + width = 280, + height = 80, +): DragRect => ({ top, left, width, height }); + +const makeColumn = (ids: string[], top = 0): ListItemRect[] => + ids.map((id, index) => ({ + id, + index, + rect: makeRect(top + index * (80 + CARD_GAP_PX), 0), + })); + +describe("findDropList", () => { + const lists = new Map([ + ["todo", makeRect(0, 0)], + ["in_progress", makeRect(0, 300)], + ["completed", makeRect(0, 600)], + ]); + + it("returns the list containing the pointer", () => { + expect(findDropList({ x: 350, y: 40 }, lists)).toBe("in_progress"); + }); + + it("falls back to the horizontally nearest list in dead zones", () => { + expect(findDropList({ x: 295, y: 40 }, lists)).toBe("in_progress"); + expect(findDropList({ x: 285, y: 40 }, lists)).toBe("todo"); + }); + + it("returns null when the pointer has no vertical overlap", () => { + expect(findDropList({ x: 295, y: 500 }, lists)).toBeNull(); + }); + + it("returns null for an empty registry", () => { + expect(findDropList({ x: 10, y: 10 }, new Map())).toBeNull(); + }); +}); + +describe("projectIndex", () => { + it("returns 0 for an empty list", () => { + expect(projectIndex(100, [], "x")).toBe(0); + }); + + it("returns 0 when the pointer is above the first midpoint", () => { + const items = makeColumn(["a", "b", "c"]); + expect(projectIndex(10, items, "x")).toBe(0); + }); + + it("returns the slot between two midpoints", () => { + const items = makeColumn(["a", "b", "c"]); + expect(projectIndex(50, items, "x")).toBe(1); + expect(projectIndex(140, items, "x")).toBe(2); + }); + + it("returns the length when below all midpoints", () => { + const items = makeColumn(["a", "b", "c"]); + expect(projectIndex(900, items, "x")).toBe(3); + }); + + it("excludes the active card from counting", () => { + const items = makeColumn(["a", "b", "c"]); + expect(projectIndex(50, items, "a")).toBe(0); + expect(projectIndex(900, items, "b")).toBe(2); + }); +}); + +describe("displaceFor", () => { + const dragHeight = 80; + const delta = dragHeight + CARD_GAP_PX; + + it("returns 0 when there is no target", () => { + expect(displaceFor(0, 1, null, dragHeight)).toBe(0); + }); + + it("shifts cards up when dragging down in the same list", () => { + expect(displaceFor(0, 0, 2, dragHeight)).toBe(-delta); + expect(displaceFor(1, 0, 2, dragHeight)).toBe(-delta); + expect(displaceFor(2, 0, 2, dragHeight)).toBe(0); + }); + + it("shifts cards down when dragging up in the same list", () => { + expect(displaceFor(0, 2, 0, dragHeight)).toBe(delta); + expect(displaceFor(1, 2, 0, dragHeight)).toBe(delta); + expect(displaceFor(2, 2, 0, dragHeight)).toBe(0); + }); + + it("leaves cards alone when the target equals the source slot", () => { + expect(displaceFor(0, 1, 1, dragHeight)).toBe(0); + expect(displaceFor(1, 1, 1, dragHeight)).toBe(0); + }); + + it("shifts cards at or below the slot in a foreign list", () => { + expect(displaceFor(0, null, 1, dragHeight)).toBe(0); + expect(displaceFor(1, null, 1, dragHeight)).toBe(delta); + expect(displaceFor(2, null, 1, dragHeight)).toBe(delta); + }); +}); diff --git a/tests/utils/item-status-utils.test.ts b/tests/utils/item-status-utils.test.ts new file mode 100644 index 00000000..92cdfc0b --- /dev/null +++ b/tests/utils/item-status-utils.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from "vitest"; +import { applyStatus, completeParent } from "@/app/_utils/item-status-utils"; +import { DEFAULT_KANBAN_STATUSES } from "@/app/_consts/kanban"; +import { TaskStatus } from "@/app/_types/enums"; +import { Item } from "@/app/_types"; + +const makeItem = (id: string, status?: string, extra?: Partial): Item => ({ + id, + text: id, + completed: false, + order: 0, + status, + ...extra, +}); + +const NOW = "2026-06-12T12:00:00.000Z"; +const USER = "samwise"; + +describe("applyStatus", () => { + it("sets status, modifier metadata, and appends history", () => { + const items = [makeItem("a", TaskStatus.TODO)]; + const result = applyStatus( + items, "a", TaskStatus.IN_PROGRESS, DEFAULT_KANBAN_STATUSES, USER, NOW, + ); + expect(result[0].status).toBe(TaskStatus.IN_PROGRESS); + expect(result[0].lastModifiedBy).toBe(USER); + expect(result[0].lastModifiedAt).toBe(NOW); + expect(result[0].history).toEqual([ + { status: TaskStatus.IN_PROGRESS, timestamp: NOW, user: USER }, + ]); + }); + + it("does not append history when the status is unchanged", () => { + const items = [makeItem("a", TaskStatus.TODO)]; + const result = applyStatus( + items, "a", TaskStatus.TODO, DEFAULT_KANBAN_STATUSES, USER, NOW, + ); + expect(result[0].history).toBeUndefined(); + }); + + it("completes the item and its children on an autoComplete status", () => { + const items = [ + makeItem("a", TaskStatus.TODO, { + children: [makeItem("a1"), makeItem("a2")], + }), + ]; + const result = applyStatus( + items, "a", TaskStatus.COMPLETED, DEFAULT_KANBAN_STATUSES, USER, NOW, + ); + expect(result[0].completed).toBe(true); + expect(result[0].children?.every((c) => c.completed)).toBe(true); + }); + + it("clears completed when leaving an autoComplete status", () => { + const items = [ + makeItem("a", TaskStatus.COMPLETED, { completed: true }), + ]; + const result = applyStatus( + items, "a", TaskStatus.PAUSED, DEFAULT_KANBAN_STATUSES, USER, NOW, + ); + expect(result[0].completed).toBe(false); + }); +}); + +describe("completeParent", () => { + it("completes the parent when all children are completed", () => { + const items = [ + makeItem("p", TaskStatus.IN_PROGRESS, { + children: [ + makeItem("c1", TaskStatus.COMPLETED, { completed: true }), + makeItem("c2", TaskStatus.COMPLETED, { completed: true }), + ], + }), + ]; + const result = completeParent( + items, "c1", DEFAULT_KANBAN_STATUSES, USER, NOW, + ); + expect(result[0].completed).toBe(true); + expect(result[0].status).toBe(TaskStatus.COMPLETED); + expect(result[0].history).toEqual([ + { status: TaskStatus.COMPLETED, timestamp: NOW, user: USER }, + ]); + }); + + it("does nothing while a sibling is incomplete", () => { + const items = [ + makeItem("p", TaskStatus.IN_PROGRESS, { + children: [ + makeItem("c1", TaskStatus.COMPLETED, { completed: true }), + makeItem("c2", TaskStatus.TODO), + ], + }), + ]; + const result = completeParent( + items, "c1", DEFAULT_KANBAN_STATUSES, USER, NOW, + ); + expect(result[0].completed).toBe(false); + }); + + it("does nothing without an autoComplete status", () => { + const noAutoComplete = DEFAULT_KANBAN_STATUSES.map((s) => ({ + ...s, + autoComplete: false, + })); + const items = [ + makeItem("p", TaskStatus.IN_PROGRESS, { + children: [makeItem("c1", TaskStatus.COMPLETED, { completed: true })], + }), + ]; + const result = completeParent(items, "c1", noAutoComplete, USER, NOW); + expect(result[0].completed).toBe(false); + }); + + it("does nothing for a top-level item", () => { + const items = [makeItem("a", TaskStatus.COMPLETED, { completed: true })]; + const result = completeParent( + items, "a", DEFAULT_KANBAN_STATUSES, USER, NOW, + ); + expect(result).toBe(items); + }); +}); diff --git a/yarn.lock b/yarn.lock index ba50b8bd..67f0efb7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -86,11 +86,21 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== +"@babel/helper-string-parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f" + integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw== + "@babel/helper-validator-identifier@^7.28.5": version "7.28.5" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== + "@babel/helper-validator-option@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" @@ -111,6 +121,13 @@ dependencies: "@babel/types" "^7.29.0" +"@babel/parser@^7.29.3": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.7.tgz#837b87387cbf5ec5530cb634b3c622f68edb9334" + integrity sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg== + dependencies: + "@babel/types" "^7.29.7" + "@babel/runtime@^7.13.10", "@babel/runtime@^7.23.5": version "7.29.2" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.2.tgz#9a6e2d05f4b6692e1801cd4fb176ad823930ed5e" @@ -146,6 +163,14 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" +"@babel/types@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.7.tgz#8005e31d82712ee7adaef6e23c63b71a62770a92" + integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA== + dependencies: + "@babel/helper-string-parser" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + "@bcoe/v8-coverage@^1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" @@ -251,395 +276,135 @@ dependencies: tslib "^2.4.0" -"@esbuild/aix-ppc64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c" - integrity sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA== - -"@esbuild/aix-ppc64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2" - integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg== - -"@esbuild/aix-ppc64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53" - integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg== - -"@esbuild/android-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz#8aa4965f8d0a7982dc21734bf6601323a66da752" - integrity sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg== - -"@esbuild/android-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8" - integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg== - -"@esbuild/android-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d" - integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ== - -"@esbuild/android-arm@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz#300712101f7f50f1d2627a162e6e09b109b6767a" - integrity sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg== - -"@esbuild/android-arm@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b" - integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA== - -"@esbuild/android-arm@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d" - integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ== - -"@esbuild/android-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz#87dfb27161202bdc958ef48bb61b09c758faee16" - integrity sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg== - -"@esbuild/android-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac" - integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ== - -"@esbuild/android-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07" - integrity sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg== - -"@esbuild/darwin-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz#79197898ec1ff745d21c071e1c7cc3c802f0c1fd" - integrity sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg== - -"@esbuild/darwin-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd" - integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg== - -"@esbuild/darwin-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz#6f23000fb9b40b7e04b7d0606c0693bd0632f322" - integrity sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw== - -"@esbuild/darwin-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz#146400a8562133f45c4d2eadcf37ddd09718079e" - integrity sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA== - -"@esbuild/darwin-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a" - integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg== - -"@esbuild/darwin-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be" - integrity sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ== - -"@esbuild/freebsd-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz#1c5f9ba7206e158fd2b24c59fa2d2c8bb47ca0fe" - integrity sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg== - -"@esbuild/freebsd-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b" - integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w== - -"@esbuild/freebsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62" - integrity sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w== - -"@esbuild/freebsd-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz#ea631f4a36beaac4b9279fa0fcc6ca29eaeeb2b3" - integrity sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ== - -"@esbuild/freebsd-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead" - integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA== - -"@esbuild/freebsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6" - integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ== - -"@esbuild/linux-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz#e1066bce58394f1b1141deec8557a5f0a22f5977" - integrity sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ== - -"@esbuild/linux-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6" - integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg== - -"@esbuild/linux-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966" - integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A== - -"@esbuild/linux-arm@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz#452cd66b20932d08bdc53a8b61c0e30baf4348b9" - integrity sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw== - -"@esbuild/linux-arm@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11" - integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw== - -"@esbuild/linux-arm@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921" - integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA== - -"@esbuild/linux-ia32@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz#b24f8acc45bcf54192c7f2f3be1b53e6551eafe0" - integrity sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA== - -"@esbuild/linux-ia32@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29" - integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg== - -"@esbuild/linux-ia32@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e" - integrity sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg== - -"@esbuild/linux-loong64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz#f9cfffa7fc8322571fbc4c8b3268caf15bd81ad0" - integrity sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng== - -"@esbuild/linux-loong64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed" - integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA== - -"@esbuild/linux-loong64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205" - integrity sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q== - -"@esbuild/linux-mips64el@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz#575a14bd74644ffab891adc7d7e60d275296f2cd" - integrity sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw== - -"@esbuild/linux-mips64el@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1" - integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw== - -"@esbuild/linux-mips64el@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8" - integrity sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw== - -"@esbuild/linux-ppc64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz#75b99c70a95fbd5f7739d7692befe60601591869" - integrity sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA== - -"@esbuild/linux-ppc64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78" - integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA== - -"@esbuild/linux-ppc64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea" - integrity sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ== - -"@esbuild/linux-riscv64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz#2e3259440321a44e79ddf7535c325057da875cd6" - integrity sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w== - -"@esbuild/linux-riscv64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d" - integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ== - -"@esbuild/linux-riscv64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027" - integrity sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ== - -"@esbuild/linux-s390x@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz#17676cabbfe5928da5b2a0d6df5d58cd08db2663" - integrity sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg== - -"@esbuild/linux-s390x@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d" - integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw== - -"@esbuild/linux-s390x@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6" - integrity sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw== - -"@esbuild/linux-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz#0583775685ca82066d04c3507f09524d3cd7a306" - integrity sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw== - -"@esbuild/linux-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5" - integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA== - -"@esbuild/linux-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a" - integrity sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA== - -"@esbuild/netbsd-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4" - integrity sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg== - -"@esbuild/netbsd-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7" - integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA== - -"@esbuild/netbsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690" - integrity sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w== - -"@esbuild/netbsd-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz#77da0d0a0d826d7c921eea3d40292548b258a076" - integrity sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ== - -"@esbuild/netbsd-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b" - integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA== - -"@esbuild/netbsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320" - integrity sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw== - -"@esbuild/openbsd-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz#6296f5867aedef28a81b22ab2009c786a952dccd" - integrity sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A== - -"@esbuild/openbsd-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5" - integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw== - -"@esbuild/openbsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1" - integrity sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A== - -"@esbuild/openbsd-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz#f8d23303360e27b16cf065b23bbff43c14142679" - integrity sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw== - -"@esbuild/openbsd-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b" - integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ== - -"@esbuild/openbsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179" - integrity sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg== - -"@esbuild/openharmony-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d" - integrity sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg== - -"@esbuild/openharmony-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e" - integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g== - -"@esbuild/openharmony-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410" - integrity sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw== - -"@esbuild/sunos-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz#a6ed7d6778d67e528c81fb165b23f4911b9b13d6" - integrity sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w== - -"@esbuild/sunos-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537" - integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA== - -"@esbuild/sunos-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d" - integrity sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA== - -"@esbuild/win32-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz#9ac14c378e1b653af17d08e7d3ce34caef587323" - integrity sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg== - -"@esbuild/win32-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e" - integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA== - -"@esbuild/win32-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77" - integrity sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA== - -"@esbuild/win32-ia32@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz#918942dcbbb35cc14fca39afb91b5e6a3d127267" - integrity sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ== - -"@esbuild/win32-ia32@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c" - integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q== - -"@esbuild/win32-ia32@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d" - integrity sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw== - -"@esbuild/win32-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz#9bdad8176be7811ad148d1f8772359041f46c6c5" - integrity sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA== - -"@esbuild/win32-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17" - integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA== - -"@esbuild/win32-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b" - integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg== +"@esbuild/aix-ppc64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be" + integrity sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ== + +"@esbuild/android-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz#b540a27d14e4afd058496a4dbec4d3f414db110a" + integrity sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg== + +"@esbuild/android-arm@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f" + integrity sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ== + +"@esbuild/android-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e" + integrity sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng== + +"@esbuild/darwin-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54" + integrity sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q== + +"@esbuild/darwin-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772" + integrity sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ== + +"@esbuild/freebsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6" + integrity sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw== + +"@esbuild/freebsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3" + integrity sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ== + +"@esbuild/linux-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717" + integrity sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g== + +"@esbuild/linux-arm@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c" + integrity sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ== + +"@esbuild/linux-ia32@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz#a580f9c676797833891e519fc7a1337c8afd8db3" + integrity sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w== + +"@esbuild/linux-loong64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b" + integrity sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg== + +"@esbuild/linux-mips64el@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8" + integrity sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ== + +"@esbuild/linux-ppc64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d" + integrity sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ== + +"@esbuild/linux-riscv64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08" + integrity sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ== + +"@esbuild/linux-s390x@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc" + integrity sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag== + +"@esbuild/linux-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz#6f0c3ce0cb64c534b70c4c45ecb2c16d34e35dfd" + integrity sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA== + +"@esbuild/netbsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36" + integrity sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw== + +"@esbuild/netbsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347" + integrity sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg== + +"@esbuild/openbsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2" + integrity sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q== + +"@esbuild/openbsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz#c427b9be5a64c262ff9a7eb70b5fbbaadf446c6c" + integrity sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw== + +"@esbuild/openharmony-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097" + integrity sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg== + +"@esbuild/sunos-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a" + integrity sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ== + +"@esbuild/win32-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4" + integrity sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA== + +"@esbuild/win32-ia32@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6" + integrity sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg== + +"@esbuild/win32-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12" + integrity sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A== "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.9.1" @@ -1126,57 +891,57 @@ "@emnapi/runtime" "^1.4.3" "@tybys/wasm-util" "^0.10.0" -"@next/env@16.2.6": - version "16.2.6" - resolved "https://registry.yarnpkg.com/@next/env/-/env-16.2.6.tgz#93a173801cb088463070cc6a113c68e26c1838ea" - integrity sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw== +"@next/env@16.2.9": + version "16.2.9" + resolved "https://registry.yarnpkg.com/@next/env/-/env-16.2.9.tgz#c2bbcb1647503cae0f11e6a326799acd1ea29e9e" + integrity sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg== -"@next/eslint-plugin-next@16.1.6": - version "16.1.6" - resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz#73b56b01c9db506998bd1e2d303c2605b0a1b7b0" - integrity sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ== +"@next/eslint-plugin-next@16.2.9": + version "16.2.9" + resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.9.tgz#0d788da191971636def239cdf3b42458796df6ff" + integrity sha512-UZi8+YT/MLgTC9nrrn2Xd4lBYv1B7lVmtWHfPcthAI5Tt/C1LuDe6DfmtCtJ+WQod3ksY4VrKSvk3oMVAnL7qw== dependencies: fast-glob "3.3.1" -"@next/swc-darwin-arm64@16.2.6": - version "16.2.6" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz#0e19055594fbd2d198ce95cf5842bdbe57235d4e" - integrity sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg== - -"@next/swc-darwin-x64@16.2.6": - version "16.2.6" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz#487086280b56017bb547f5975ef1a3d6235a070f" - integrity sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ== - -"@next/swc-linux-arm64-gnu@16.2.6": - version "16.2.6" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz#b28ffbc31ee527a3ad48f29c2fd7b7f75bbdf180" - integrity sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w== - -"@next/swc-linux-arm64-musl@16.2.6": - version "16.2.6" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz#d2eac5600e7083527669fa8cb8758efbbf9c8210" - integrity sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA== - -"@next/swc-linux-x64-gnu@16.2.6": - version "16.2.6" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz#e70dc3469bf9bfef16da2ba240c07ef6cfe8ad55" - integrity sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw== - -"@next/swc-linux-x64-musl@16.2.6": - version "16.2.6" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz#49ecd2f622d0f3741654c59411f604dbbe1a38de" - integrity sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g== - -"@next/swc-win32-arm64-msvc@16.2.6": - version "16.2.6" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz#8a6dfec005364e1cf4fa1724c3d7fa0093660406" - integrity sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg== - -"@next/swc-win32-x64-msvc@16.2.6": - version "16.2.6" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz#4fb656ccfcf60cbf8ffedb40fc1b625987c82742" - integrity sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA== +"@next/swc-darwin-arm64@16.2.9": + version "16.2.9" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.9.tgz#b67a7e67466e39be621553b7a32520bc73ca4fa4" + integrity sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw== + +"@next/swc-darwin-x64@16.2.9": + version "16.2.9" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz#817f3609c231b07d6910c074eeaf7c41ae781f2d" + integrity sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w== + +"@next/swc-linux-arm64-gnu@16.2.9": + version "16.2.9" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz#f7314d5c268a6c440208c46da92ab8d914c02d36" + integrity sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw== + +"@next/swc-linux-arm64-musl@16.2.9": + version "16.2.9" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz#9258cf7a3f2f10a605ef85fb76601123f3b1d5ab" + integrity sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA== + +"@next/swc-linux-x64-gnu@16.2.9": + version "16.2.9" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz#a6db6095f6495c6d55c48ba946b1578f5e75a359" + integrity sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg== + +"@next/swc-linux-x64-musl@16.2.9": + version "16.2.9" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz#d7f857879886c3a378285fea438ba4636fcc3a9a" + integrity sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw== + +"@next/swc-win32-arm64-msvc@16.2.9": + version "16.2.9" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz#d6d67c8e803b2c44b76b16495b5eda47165faf43" + integrity sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ== + +"@next/swc-win32-x64-msvc@16.2.9": + version "16.2.9" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.9.tgz#6e36f17dd615c52761691b7f5be1d9d161abed2a" + integrity sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w== "@nivo/annotations@0.99.0": version "0.99.0" @@ -1924,7 +1689,7 @@ dependencies: "@simple-git/args-pathspec" "^1.0.3" -"@standard-schema/spec@^1.0.0": +"@standard-schema/spec@^1.0.0", "@standard-schema/spec@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== @@ -2362,6 +2127,11 @@ resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-3.22.4.tgz#ef39840763245aa67c87985903ef62db365cde89" integrity sha512-1buvLZemITTeKmPf2wGFWvvhRFKjdQ+JgMqc67xBraOKeDd8wQi1e2XlhCYAtlVMm5f6j+qlLC/MvwuHI2jHeQ== +"@tweenjs/tween.js@18 - 25": + version "25.0.0" + resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-25.0.0.tgz#7266baebcc3affe62a3a54318a3ea82d904cd0b9" + integrity sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A== + "@tybys/wasm-util@^0.10.0": version "0.10.1" resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" @@ -2863,79 +2633,81 @@ resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== -"@vitest/coverage-v8@4.0.17": - version "4.0.17" - resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-4.0.17.tgz#3bb100e9a6766de282049fba28e21a010a73509a" - integrity sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw== +"@vitest/coverage-v8@4.1.8": + version "4.1.8" + resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz#6a5dd34552840a0ace0396d0e94c7459beb80d14" + integrity sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw== dependencies: "@bcoe/v8-coverage" "^1.0.2" - "@vitest/utils" "4.0.17" - ast-v8-to-istanbul "^0.3.10" + "@vitest/utils" "4.1.8" + ast-v8-to-istanbul "^1.0.0" istanbul-lib-coverage "^3.2.2" istanbul-lib-report "^3.0.1" istanbul-reports "^3.2.0" - magicast "^0.5.1" + magicast "^0.5.2" obug "^2.1.1" - std-env "^3.10.0" - tinyrainbow "^3.0.3" + std-env "^4.0.0-rc.1" + tinyrainbow "^3.1.0" -"@vitest/expect@4.0.17": - version "4.0.17" - resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.0.17.tgz#67bb0d4a7d37054590a19dcf831f7936d14a8a02" - integrity sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ== +"@vitest/expect@4.1.8": + version "4.1.8" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.8.tgz#45154f1f8559f55c5281eb0dcb1ac37b581a87d8" + integrity sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ== dependencies: - "@standard-schema/spec" "^1.0.0" + "@standard-schema/spec" "^1.1.0" "@types/chai" "^5.2.2" - "@vitest/spy" "4.0.17" - "@vitest/utils" "4.0.17" - chai "^6.2.1" - tinyrainbow "^3.0.3" + "@vitest/spy" "4.1.8" + "@vitest/utils" "4.1.8" + chai "^6.2.2" + tinyrainbow "^3.1.0" -"@vitest/mocker@4.0.17": - version "4.0.17" - resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.0.17.tgz#ce559098be1ae18ae5aa441a79939bcd7fc8f42f" - integrity sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ== +"@vitest/mocker@4.1.8": + version "4.1.8" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.8.tgz#d006bfc5894a1af51e74deddef2535d6bd436b16" + integrity sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw== dependencies: - "@vitest/spy" "4.0.17" + "@vitest/spy" "4.1.8" estree-walker "^3.0.3" magic-string "^0.30.21" -"@vitest/pretty-format@4.0.17": - version "4.0.17" - resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.0.17.tgz#dde7cb2c01699d0943571137d1b482edff5fc000" - integrity sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw== +"@vitest/pretty-format@4.1.8": + version "4.1.8" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.8.tgz#d9d2e248b900d7ad9556c4374fcdf1871c615193" + integrity sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA== dependencies: - tinyrainbow "^3.0.3" + tinyrainbow "^3.1.0" -"@vitest/runner@4.0.17": - version "4.0.17" - resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.0.17.tgz#dcc3bb4a4b1077858f3b105e91b2eeb208cee780" - integrity sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ== +"@vitest/runner@4.1.8": + version "4.1.8" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.8.tgz#4631808f3996359b74ccc3ca262990e14c295d50" + integrity sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg== dependencies: - "@vitest/utils" "4.0.17" + "@vitest/utils" "4.1.8" pathe "^2.0.3" -"@vitest/snapshot@4.0.17": - version "4.0.17" - resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.0.17.tgz#40d71a3dad4ac39812ed96a95fded46a920e1a31" - integrity sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ== +"@vitest/snapshot@4.1.8": + version "4.1.8" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.8.tgz#37470135d64ea11bb2a839b1c6b7f5de7018f6ee" + integrity sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ== dependencies: - "@vitest/pretty-format" "4.0.17" + "@vitest/pretty-format" "4.1.8" + "@vitest/utils" "4.1.8" magic-string "^0.30.21" pathe "^2.0.3" -"@vitest/spy@4.0.17": - version "4.0.17" - resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.0.17.tgz#d0936f8908b4dae091d9b948be3bae8e19d1878d" - integrity sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew== +"@vitest/spy@4.1.8": + version "4.1.8" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.8.tgz#3abfe9301d25c39f808dcaa9f10fec0dd370e564" + integrity sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA== -"@vitest/utils@4.0.17": - version "4.0.17" - resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.0.17.tgz#48181deab273c87ac4ee20c1c454ffe9c4f453fe" - integrity sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w== +"@vitest/utils@4.1.8": + version "4.1.8" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.8.tgz#099ea5255cec08735410cf707edaba2c158c5ad9" + integrity sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg== dependencies: - "@vitest/pretty-format" "4.0.17" - tinyrainbow "^3.0.3" + "@vitest/pretty-format" "4.1.8" + convert-source-map "^2.0.0" + tinyrainbow "^3.1.0" abbrev@^2.0.0: version "2.0.0" @@ -2949,6 +2721,11 @@ abort-controller@^3.0.0: dependencies: event-target-shim "^5.0.0" +accessor-fn@1: + version "1.5.3" + resolved "https://registry.yarnpkg.com/accessor-fn/-/accessor-fn-1.5.3.tgz#5e2549d291d4ac022f532da9a554358dc525b0f7" + integrity sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA== + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -3153,10 +2930,10 @@ ast-types-flow@^0.0.8: resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.8.tgz#0a85e1c92695769ac13a428bb653e7538bea27d6" integrity sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ== -ast-v8-to-istanbul@^0.3.10: - version "0.3.12" - resolved "https://registry.yarnpkg.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz#8eb1b7c86ef8499859be761b17ffd91406c0c36f" - integrity sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g== +ast-v8-to-istanbul@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz#9b3c38bdcadbb17f0da8cab2ca3daf8cdf5e0af2" + integrity sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA== dependencies: "@jridgewell/trace-mapping" "^0.3.31" estree-walker "^3.0.3" @@ -3274,12 +3051,17 @@ baseline-browser-mapping@^2.10.12, baseline-browser-mapping@^2.9.0, baseline-bro resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz#7c99b86d43ae9be3810cac515f4675802e1f6b87" integrity sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ== +"bezier-js@3 - 6": + version "6.1.4" + resolved "https://registry.yarnpkg.com/bezier-js/-/bezier-js-6.1.4.tgz#c7828f6c8900562b69d5040afb881bcbdad82001" + integrity sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg== + binary-extensions@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== -brace-expansion@>=5.0.6, brace-expansion@^5.0.2: +brace-expansion@5.0.6, brace-expansion@^5.0.2: version "5.0.6" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.6.tgz#ec68fe0a641a29d8711579caf641d05bae1f2285" integrity sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g== @@ -3384,6 +3166,13 @@ caniuse-lite@^1.0.30001137, caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.300017 resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz#31e97d1bfec332b3f2d7eea7781460c97629b3bf" integrity sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ== +canvas-color-tracker@^1.3: + version "1.3.2" + resolved "https://registry.yarnpkg.com/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz#b924cf94b33441b82692938fca5b936be971a46d" + integrity sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg== + dependencies: + tinycolor2 "^1.6.0" + canvas-roundrect-polyfill@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/canvas-roundrect-polyfill/-/canvas-roundrect-polyfill-0.0.1.tgz#70bf107ebe2037f26d839d7f809a26f4a95f5696" @@ -3394,7 +3183,7 @@ ccount@^2.0.0: resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== -chai@^6.2.1: +chai@^6.2.2: version "6.2.2" resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== @@ -3636,7 +3425,7 @@ cytoscape@^3.28.1: dependencies: internmap "^1.0.0" -"d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.1.6, d3-array@^3.2.0: +"d3-array@1 - 3", "d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.1.6, d3-array@^3.2.0: version "3.2.4" resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5" integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg== @@ -3648,6 +3437,11 @@ d3-axis@3: resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-3.0.0.tgz#c42a4a13e8131d637b745fc2973824cfeaf93322" integrity sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw== +d3-binarytree@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz#ed43ebc13c70fbabfdd62df17480bc5a425753cc" + integrity sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw== + d3-brush@3: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-3.0.0.tgz#6f767c4ed8dcb79de7ede3e1c0f89e63ef64d31c" @@ -3724,6 +3518,17 @@ d3-fetch@3: dependencies: d3-dsv "1 - 3" +"d3-force-3d@2 - 3": + version "3.0.6" + resolved "https://registry.yarnpkg.com/d3-force-3d/-/d3-force-3d-3.0.6.tgz#7ea4c26d7937b82993bd9444f570ed52f661d4aa" + integrity sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA== + dependencies: + d3-binarytree "1" + d3-dispatch "1 - 3" + d3-octree "1" + d3-quadtree "1 - 3" + d3-timer "1 - 3" + d3-force@3: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-3.0.0.tgz#3e2ba1a61e70888fe3d9194e30d6d14eece155c4" @@ -3771,6 +3576,11 @@ d3-hierarchy@3: dependencies: d3-color "1 - 3" +d3-octree@1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/d3-octree/-/d3-octree-1.1.0.tgz#f07e353b76df872644e7130ab1a74c5ef2f4287e" + integrity sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A== + d3-path@1: version "1.0.9" resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" @@ -3809,7 +3619,7 @@ d3-sankey@^0.12.3: d3-array "1 - 2" d3-shape "^1.2.0" -d3-scale-chromatic@3, d3-scale-chromatic@^3.0.0: +"d3-scale-chromatic@1 - 3", d3-scale-chromatic@3, d3-scale-chromatic@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz#34c39da298b23c20e02f1a4b239bd0f22e7f1314" integrity sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ== @@ -3817,7 +3627,7 @@ d3-scale-chromatic@3, d3-scale-chromatic@^3.0.0: d3-color "1 - 3" d3-interpolate "1 - 3" -d3-scale@4, d3-scale@^4.0.2: +"d3-scale@1 - 4", d3-scale@4, d3-scale@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== @@ -3896,7 +3706,7 @@ d3-time-format@^3.0.0: d3-interpolate "1 - 3" d3-timer "1 - 3" -d3-zoom@3: +"d3-zoom@2 - 3", d3-zoom@3: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3" integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw== @@ -4298,10 +4108,10 @@ es-iterator-helpers@^1.2.1: iterator.prototype "^1.1.5" math-intrinsics "^1.1.0" -es-module-lexer@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" - integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== +es-module-lexer@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.1.0.tgz#1dfcbb5ea3bbfb63f28e1fc3676c3676d1c9624c" + integrity sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ== es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: version "1.1.1" @@ -4346,101 +4156,37 @@ es6-promise-pool@2.5.0: resolved "https://registry.yarnpkg.com/es6-promise-pool/-/es6-promise-pool-2.5.0.tgz#147c612b36b47f105027f9d2bf54a598a99d9ccb" integrity sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA== -esbuild@0.27.3: - version "0.27.3" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.3.tgz#5859ca8e70a3af956b26895ce4954d7e73bd27a8" - integrity sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg== +esbuild@0.28.1, esbuild@^0.25.0, esbuild@^0.28.1, esbuild@~0.27.0: + version "0.28.1" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578" + integrity sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw== optionalDependencies: - "@esbuild/aix-ppc64" "0.27.3" - "@esbuild/android-arm" "0.27.3" - "@esbuild/android-arm64" "0.27.3" - "@esbuild/android-x64" "0.27.3" - "@esbuild/darwin-arm64" "0.27.3" - "@esbuild/darwin-x64" "0.27.3" - "@esbuild/freebsd-arm64" "0.27.3" - "@esbuild/freebsd-x64" "0.27.3" - "@esbuild/linux-arm" "0.27.3" - "@esbuild/linux-arm64" "0.27.3" - "@esbuild/linux-ia32" "0.27.3" - "@esbuild/linux-loong64" "0.27.3" - "@esbuild/linux-mips64el" "0.27.3" - "@esbuild/linux-ppc64" "0.27.3" - "@esbuild/linux-riscv64" "0.27.3" - "@esbuild/linux-s390x" "0.27.3" - "@esbuild/linux-x64" "0.27.3" - "@esbuild/netbsd-arm64" "0.27.3" - "@esbuild/netbsd-x64" "0.27.3" - "@esbuild/openbsd-arm64" "0.27.3" - "@esbuild/openbsd-x64" "0.27.3" - "@esbuild/openharmony-arm64" "0.27.3" - "@esbuild/sunos-x64" "0.27.3" - "@esbuild/win32-arm64" "0.27.3" - "@esbuild/win32-ia32" "0.27.3" - "@esbuild/win32-x64" "0.27.3" - -esbuild@^0.25.0: - version "0.25.12" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.12.tgz#97a1d041f4ab00c2fce2f838d2b9969a2d2a97a5" - integrity sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg== - optionalDependencies: - "@esbuild/aix-ppc64" "0.25.12" - "@esbuild/android-arm" "0.25.12" - "@esbuild/android-arm64" "0.25.12" - "@esbuild/android-x64" "0.25.12" - "@esbuild/darwin-arm64" "0.25.12" - "@esbuild/darwin-x64" "0.25.12" - "@esbuild/freebsd-arm64" "0.25.12" - "@esbuild/freebsd-x64" "0.25.12" - "@esbuild/linux-arm" "0.25.12" - "@esbuild/linux-arm64" "0.25.12" - "@esbuild/linux-ia32" "0.25.12" - "@esbuild/linux-loong64" "0.25.12" - "@esbuild/linux-mips64el" "0.25.12" - "@esbuild/linux-ppc64" "0.25.12" - "@esbuild/linux-riscv64" "0.25.12" - "@esbuild/linux-s390x" "0.25.12" - "@esbuild/linux-x64" "0.25.12" - "@esbuild/netbsd-arm64" "0.25.12" - "@esbuild/netbsd-x64" "0.25.12" - "@esbuild/openbsd-arm64" "0.25.12" - "@esbuild/openbsd-x64" "0.25.12" - "@esbuild/openharmony-arm64" "0.25.12" - "@esbuild/sunos-x64" "0.25.12" - "@esbuild/win32-arm64" "0.25.12" - "@esbuild/win32-ia32" "0.25.12" - "@esbuild/win32-x64" "0.25.12" - -esbuild@~0.27.0: - version "0.27.7" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.7.tgz#bcadce22b2f3fd76f257e3a64f83a64986fea11f" - integrity sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w== - optionalDependencies: - "@esbuild/aix-ppc64" "0.27.7" - "@esbuild/android-arm" "0.27.7" - "@esbuild/android-arm64" "0.27.7" - "@esbuild/android-x64" "0.27.7" - "@esbuild/darwin-arm64" "0.27.7" - "@esbuild/darwin-x64" "0.27.7" - "@esbuild/freebsd-arm64" "0.27.7" - "@esbuild/freebsd-x64" "0.27.7" - "@esbuild/linux-arm" "0.27.7" - "@esbuild/linux-arm64" "0.27.7" - "@esbuild/linux-ia32" "0.27.7" - "@esbuild/linux-loong64" "0.27.7" - "@esbuild/linux-mips64el" "0.27.7" - "@esbuild/linux-ppc64" "0.27.7" - "@esbuild/linux-riscv64" "0.27.7" - "@esbuild/linux-s390x" "0.27.7" - "@esbuild/linux-x64" "0.27.7" - "@esbuild/netbsd-arm64" "0.27.7" - "@esbuild/netbsd-x64" "0.27.7" - "@esbuild/openbsd-arm64" "0.27.7" - "@esbuild/openbsd-x64" "0.27.7" - "@esbuild/openharmony-arm64" "0.27.7" - "@esbuild/sunos-x64" "0.27.7" - "@esbuild/win32-arm64" "0.27.7" - "@esbuild/win32-ia32" "0.27.7" - "@esbuild/win32-x64" "0.27.7" + "@esbuild/aix-ppc64" "0.28.1" + "@esbuild/android-arm" "0.28.1" + "@esbuild/android-arm64" "0.28.1" + "@esbuild/android-x64" "0.28.1" + "@esbuild/darwin-arm64" "0.28.1" + "@esbuild/darwin-x64" "0.28.1" + "@esbuild/freebsd-arm64" "0.28.1" + "@esbuild/freebsd-x64" "0.28.1" + "@esbuild/linux-arm" "0.28.1" + "@esbuild/linux-arm64" "0.28.1" + "@esbuild/linux-ia32" "0.28.1" + "@esbuild/linux-loong64" "0.28.1" + "@esbuild/linux-mips64el" "0.28.1" + "@esbuild/linux-ppc64" "0.28.1" + "@esbuild/linux-riscv64" "0.28.1" + "@esbuild/linux-s390x" "0.28.1" + "@esbuild/linux-x64" "0.28.1" + "@esbuild/netbsd-arm64" "0.28.1" + "@esbuild/netbsd-x64" "0.28.1" + "@esbuild/openbsd-arm64" "0.28.1" + "@esbuild/openbsd-x64" "0.28.1" + "@esbuild/openharmony-arm64" "0.28.1" + "@esbuild/sunos-x64" "0.28.1" + "@esbuild/win32-arm64" "0.28.1" + "@esbuild/win32-ia32" "0.28.1" + "@esbuild/win32-x64" "0.28.1" escalade@^3.2.0: version "3.2.0" @@ -4457,12 +4203,12 @@ escape-string-regexp@^5.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== -eslint-config-next@16.1.6: - version "16.1.6" - resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-16.1.6.tgz#75dd33bf32eb34b1e5be59f546e3c808429bab41" - integrity sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA== +eslint-config-next@16.2.9: + version "16.2.9" + resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-16.2.9.tgz#cc3f63fe657cdb980078835f85f1f518cd549276" + integrity sha512-olGtBrs07bQchpaJWeqbk9GaMoU0oGmN/pYNEBXSbfgKngb5uHnPe37X6tVeh6DJfaWFQildvinGEOrolo5fmw== dependencies: - "@next/eslint-plugin-next" "16.1.6" + "@next/eslint-plugin-next" "16.2.9" eslint-import-resolver-node "^0.3.6" eslint-import-resolver-typescript "^3.5.2" eslint-plugin-import "^2.32.0" @@ -4702,7 +4448,7 @@ events@^3.3.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== -expect-type@^1.2.2: +expect-type@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.3.0.tgz#0d58ed361877a31bbc4dd6cf71bbfef7faf6bd68" integrity sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA== @@ -4814,6 +4560,15 @@ flatted@^3.2.9: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== +float-tooltip@^1.7: + version "1.7.5" + resolved "https://registry.yarnpkg.com/float-tooltip/-/float-tooltip-1.7.5.tgz#7083bf78f0de5a97f9c2d6aa8e90d2139f34047f" + integrity sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg== + dependencies: + d3-selection "2 - 3" + kapsule "^1.16" + preact "10" + for-each@^0.3.3, for-each@^0.3.5: version "0.3.5" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" @@ -4821,6 +4576,27 @@ for-each@^0.3.3, for-each@^0.3.5: dependencies: is-callable "^1.2.7" +force-graph@^1.51: + version "1.51.4" + resolved "https://registry.yarnpkg.com/force-graph/-/force-graph-1.51.4.tgz#bd4b5b0d046f2c9e7c737988c821104a100a368d" + integrity sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w== + dependencies: + "@tweenjs/tween.js" "18 - 25" + accessor-fn "1" + bezier-js "3 - 6" + canvas-color-tracker "^1.3" + d3-array "1 - 3" + d3-drag "2 - 3" + d3-force-3d "2 - 3" + d3-scale "1 - 4" + d3-scale-chromatic "1 - 3" + d3-selection "2 - 3" + d3-zoom "2 - 3" + float-tooltip "^1.7" + index-array-by "1" + kapsule "^1.16" + lodash-es "4" + foreground-child@^3.1.0: version "3.3.1" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" @@ -5210,10 +4986,10 @@ iconv-lite@0.6: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -icu-minify@^4.12.0, icu-minify@^4.9.2: - version "4.12.0" - resolved "https://registry.yarnpkg.com/icu-minify/-/icu-minify-4.12.0.tgz#b187caecaa4369187a9025c624f657bf78397488" - integrity sha512-zDmM05uav3t3+kxSfRrNlmyXOdj2b+uHA+p04CG32eJabtaHbugXujuL+YfRkwP9joAnf0Uh+RMGCKD5NLa5rQ== +icu-minify@^4.13.0, icu-minify@^4.9.2: + version "4.13.0" + resolved "https://registry.yarnpkg.com/icu-minify/-/icu-minify-4.13.0.tgz#eba0eaafb7379cb8ec43572589d3da4fe446dd19" + integrity sha512-SIFMeUHZJjzS5RvIGvybKvWoHjDm9cGVEs2EpJ8PmywOdJLWyblPm7TdPLLoUtkJtwQD7iGhl2WMptZ+N0on+w== dependencies: "@formatjs/icu-messageformat-parser" "^3.4.0" @@ -5272,6 +5048,11 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== +index-array-by@1: + version "1.4.2" + resolved "https://registry.yarnpkg.com/index-array-by/-/index-array-by-1.4.2.tgz#d6f82e9fbff3201c4dab64ba415d4d2923242fea" + integrity sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw== + inherits@^2.0.3, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" @@ -5607,6 +5388,11 @@ jackspeak@^3.1.2: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" +jerrypick@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/jerrypick/-/jerrypick-1.1.2.tgz#eb5016304aeb9ac9b7dea6714aa5fe85b24cb8ad" + integrity sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA== + jiti@^1.17.2: version "1.21.7" resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.7.tgz#9dd81043424a3d28458b193d965f0d18a2300ba9" @@ -5638,10 +5424,10 @@ js-beautify@1.15.4: js-cookie "^3.0.5" nopt "^7.2.1" -js-cookie@^3.0.5: - version "3.0.5" - resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc" - integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw== +js-cookie@3.0.7, js-cookie@^3.0.5: + version "3.0.7" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.7.tgz#0a53abfc459c8e89c85d7a38eb6cb68714965b8c" + integrity sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw== js-tokens@^10.0.0: version "10.0.0" @@ -5735,6 +5521,13 @@ jws@^3.2.2: jwa "^1.4.2" safe-buffer "^5.0.1" +kapsule@^1.16: + version "1.16.3" + resolved "https://registry.yarnpkg.com/kapsule/-/kapsule-1.16.3.tgz#5684ed89838b6658b30d0f2cc056dffc3ba68c30" + integrity sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg== + dependencies: + lodash-es "4" + katex@^0.16.9: version "0.16.45" resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.45.tgz#ba60d39c54746b6b8d39ce0e7f6eace07143149c" @@ -5874,7 +5667,7 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash-es@4.17.21, lodash-es@4.18.1, lodash-es@^4.17.21: +lodash-es@4, lodash-es@4.17.21, lodash-es@4.18.1, lodash-es@^4.17.21: version "4.18.1" resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d" integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A== @@ -5970,12 +5763,12 @@ magic-string@^0.30.21: dependencies: "@jridgewell/sourcemap-codec" "^1.5.5" -magicast@^0.5.1: - version "0.5.2" - resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.5.2.tgz#70cea9df729c164485049ea5df85a390281dfb9d" - integrity sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ== +magicast@^0.5.2: + version "0.5.3" + resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.5.3.tgz#1800f6e76dd8b0dbe7257438a2c336aefabbd905" + integrity sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw== dependencies: - "@babel/parser" "^7.29.0" + "@babel/parser" "^7.29.3" "@babel/types" "^7.29.0" source-map-js "^1.2.1" @@ -6768,7 +6561,7 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nanoid@3.3.3, nanoid@3.3.8, nanoid@4.0.2, nanoid@^3.3.11, nanoid@^3.3.12: +nanoid@3.3.3, nanoid@3.3.8, nanoid@4.0.2, nanoid@^3.3.11: version "3.3.8" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== @@ -6789,9 +6582,9 @@ negotiator@^1.0.0: integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== next-intl-swc-plugin-extractor@^4.9.2: - version "4.12.0" - resolved "https://registry.yarnpkg.com/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.12.0.tgz#a8710a0da49a2fa45bc66696975764cc83717b94" - integrity sha512-jUxVEu1Nryjt4YgaDktSys7ioOgQfcNPF/SF2dbPNxbVb6U+P1INRgHeCVN+EC59H2rnTFIQwbddmOCrUWFr3g== + version "4.13.0" + resolved "https://registry.yarnpkg.com/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.13.0.tgz#5247bd0fbc873711aaaf1aeef50c8981829ab562" + integrity sha512-6S/fJI0KXvLCL8nhBo9P8eGaJPzmwJBTCzX0NaUIj0VyU8U89d//T+vjMLdNIXl5MlLaYH7B9MbAjb8Mvu+tqQ== next-intl@4.9.2: version "4.9.2" @@ -6812,26 +6605,26 @@ next-themes@0.2.1: resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.2.1.tgz#0c9f128e847979daf6c67f70b38e6b6567856e45" integrity sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A== -next@16.2.6: - version "16.2.6" - resolved "https://registry.yarnpkg.com/next/-/next-16.2.6.tgz#4564833d2865efc598b7c63541b5771792d3d811" - integrity sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw== +next@16.2.9: + version "16.2.9" + resolved "https://registry.yarnpkg.com/next/-/next-16.2.9.tgz#320c5b5701deeb18c0b596d137ca5dccf976682c" + integrity sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww== dependencies: - "@next/env" "16.2.6" + "@next/env" "16.2.9" "@swc/helpers" "0.5.15" baseline-browser-mapping "^2.9.19" caniuse-lite "^1.0.30001579" postcss "8.4.31" styled-jsx "5.1.6" optionalDependencies: - "@next/swc-darwin-arm64" "16.2.6" - "@next/swc-darwin-x64" "16.2.6" - "@next/swc-linux-arm64-gnu" "16.2.6" - "@next/swc-linux-arm64-musl" "16.2.6" - "@next/swc-linux-x64-gnu" "16.2.6" - "@next/swc-linux-x64-musl" "16.2.6" - "@next/swc-win32-arm64-msvc" "16.2.6" - "@next/swc-win32-x64-msvc" "16.2.6" + "@next/swc-darwin-arm64" "16.2.9" + "@next/swc-darwin-x64" "16.2.9" + "@next/swc-linux-arm64-gnu" "16.2.9" + "@next/swc-linux-arm64-musl" "16.2.9" + "@next/swc-linux-x64-gnu" "16.2.9" + "@next/swc-linux-x64-musl" "16.2.9" + "@next/swc-win32-arm64-msvc" "16.2.9" + "@next/swc-win32-x64-msvc" "16.2.9" sharp "^0.34.5" node-addon-api@^7.0.0: @@ -7234,7 +7027,7 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^ resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@8: +postcss@8.4.31, postcss@8.5.10, postcss@^8.0.9, postcss@^8.5.3: version "8.5.10" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.10.tgz#8992d8c30acf3f12169e7c09514a12fed7e48356" integrity sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ== @@ -7243,14 +7036,10 @@ postcss@8: picocolors "^1.1.1" source-map-js "^1.2.1" -postcss@8.4.31, postcss@>=8.5.10, postcss@^8.0.9, postcss@^8.5.3: - version "8.5.15" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.15.tgz#d1eaf677a324e9ec02196da2d3fecf4a0b9a735c" - integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A== - dependencies: - nanoid "^3.3.12" - picocolors "^1.1.1" - source-map-js "^1.2.1" +preact@10: + version "10.29.2" + resolved "https://registry.yarnpkg.com/preact/-/preact-10.29.2.tgz#3e6069c471718b8d124d1cd67565114532e88d70" + integrity sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ== prelude-ls@^1.2.1: version "1.2.1" @@ -7285,7 +7074,7 @@ process@^0.11.10: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== -prop-types@^15.8.1: +prop-types@15, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -7469,11 +7258,27 @@ react-dom@19.2.4: dependencies: scheduler "^0.27.0" +react-force-graph-2d@^1.29.1: + version "1.29.1" + resolved "https://registry.yarnpkg.com/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz#a0784d4387b12b28e2b552058ec09d092b4e8cda" + integrity sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ== + dependencies: + force-graph "^1.51" + prop-types "15" + react-kapsule "^2.5" + react-is@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-kapsule@^2.5: + version "2.5.7" + resolved "https://registry.yarnpkg.com/react-kapsule/-/react-kapsule-2.5.7.tgz#dcd957ae8e897ff48055fc8ff48ed04ebe3c5bd2" + integrity sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A== + dependencies: + jerrypick "^1.1.1" + react-markdown@10.1.0: version "10.1.0" resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-10.1.0.tgz#e22bc20faddbc07605c15284255653c0f3bad5ca" @@ -8124,10 +7929,10 @@ stackback@0.0.2: resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== -std-env@^3.10.0: - version "3.10.0" - resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" - integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== +std-env@^4.0.0-rc.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-4.1.0.tgz#45899abc590d86d682e87f0acd1033a75084cd3f" + integrity sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ== stop-iteration-iterator@^1.1.0: version "1.1.0" @@ -8442,6 +8247,11 @@ tinybench@^2.9.0: resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== +tinycolor2@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" + integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw== + tinyexec@^1.0.2: version "1.1.1" resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.1.1.tgz#e1ff45dfa60d1dedb91b734956b78f6c2a3e821b" @@ -8455,7 +8265,7 @@ tinyglobby@^0.2.11, tinyglobby@^0.2.13, tinyglobby@^0.2.15: fdir "^6.5.0" picomatch "^4.0.4" -tinyrainbow@^3.0.3: +tinyrainbow@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-3.1.0.tgz#1d8a623893f95cf0a2ddb9e5d11150e191409421" integrity sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw== @@ -8764,13 +8574,13 @@ use-debounce@^10.0.4: integrity sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ== use-intl@^4.9.2: - version "4.12.0" - resolved "https://registry.yarnpkg.com/use-intl/-/use-intl-4.12.0.tgz#1017700cf01a60caecc4f1625a934ee72503d832" - integrity sha512-r+qVb7UI1+kiOhjYsmsNUCY+jrnjVopwGeFlmMyQj4YInlwZzgMeMSv9n8MqnWWy77HL5BVM8K2WgX50SbtcpA== + version "4.13.0" + resolved "https://registry.yarnpkg.com/use-intl/-/use-intl-4.13.0.tgz#bc60d5b71864a5adf906c901fc660b6c578885f9" + integrity sha512-fAFDrWaASxlhXOipcOyb5VDD+YONqj6+8O8EcG/J7RBoOUF3A8YahRWLN+mBxYMrlMQB8N6Voqk5X+YC+HSL0A== dependencies: "@formatjs/fast-memoize" "^3.1.0" "@schummar/icu-type-parser" "1.21.5" - icu-minify "^4.12.0" + icu-minify "^4.13.0" intl-messageformat "^11.1.0" use-sidecar@^1.1.3: @@ -8791,16 +8601,11 @@ util-deprecate@^1.0.2, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -uuid@11.1.1: +uuid@11.1.1, "uuid@^9.0.0 || ^10 || ^11.1.0 || ^12 || ^13 || ^14.0.0": version "11.1.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.1.tgz#f6d81d2e1c65d00762e5e29b16c5d2d995e208ad" integrity sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ== -"uuid@^9.0.0 || ^10 || ^11.1.0 || ^12 || ^13 || ^14.0.0": - version "14.0.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-14.0.0.tgz#0af883220163d264ffe0c084f6b8a89b9666966d" - integrity sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg== - uvu@^0.5.0: version "0.5.6" resolved "https://registry.yarnpkg.com/uvu/-/uvu-0.5.6.tgz#2754ca20bcb0bb59b64e9985e84d2e81058502df" @@ -8855,7 +8660,7 @@ victory-vendor@^37.0.2: d3-time "^3.0.0" d3-timer "^3.0.1" -vite@6.4.2, "vite@^6.0.0 || ^7.0.0": +vite@6.4.2, "vite@^6.0.0 || ^7.0.0 || ^8.0.0": version "6.4.2" resolved "https://registry.yarnpkg.com/vite/-/vite-6.4.2.tgz#a4e548ca3a90ca9f3724582cab35e1ba15efc6f2" integrity sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ== @@ -8869,30 +8674,30 @@ vite@6.4.2, "vite@^6.0.0 || ^7.0.0": optionalDependencies: fsevents "~2.3.3" -vitest@4.0.17: - version "4.0.17" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.0.17.tgz#0e39e67a909a132afe434ee1278bdcf0c17fd063" - integrity sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg== - dependencies: - "@vitest/expect" "4.0.17" - "@vitest/mocker" "4.0.17" - "@vitest/pretty-format" "4.0.17" - "@vitest/runner" "4.0.17" - "@vitest/snapshot" "4.0.17" - "@vitest/spy" "4.0.17" - "@vitest/utils" "4.0.17" - es-module-lexer "^1.7.0" - expect-type "^1.2.2" +vitest@4.1.8: + version "4.1.8" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.8.tgz#9fed17277bf7350497e54338898a7afd46dfd509" + integrity sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig== + dependencies: + "@vitest/expect" "4.1.8" + "@vitest/mocker" "4.1.8" + "@vitest/pretty-format" "4.1.8" + "@vitest/runner" "4.1.8" + "@vitest/snapshot" "4.1.8" + "@vitest/spy" "4.1.8" + "@vitest/utils" "4.1.8" + es-module-lexer "^2.0.0" + expect-type "^1.3.0" magic-string "^0.30.21" obug "^2.1.1" pathe "^2.0.3" picomatch "^4.0.3" - std-env "^3.10.0" + std-env "^4.0.0-rc.1" tinybench "^2.9.0" tinyexec "^1.0.2" tinyglobby "^0.2.15" - tinyrainbow "^3.0.3" - vite "^6.0.0 || ^7.0.0" + tinyrainbow "^3.1.0" + vite "^6.0.0 || ^7.0.0 || ^8.0.0" why-is-node-running "^2.3.0" vscode-jsonrpc@8.2.0: