From 1ebbb1096f9f5afab22ff63b7787f533c5ee9b89 Mon Sep 17 00:00:00 2001 From: Nikolai Andree Date: Mon, 18 May 2026 16:25:04 +0200 Subject: [PATCH 01/40] fix(scrollbar): consistent thin scrollbar across Firefox and Edge/Chromium Replace the old webkit-only 8px scrollbar block with a unified approach: - @supports (-moz-appearance: none) guard so Firefox-only scrollbar-width/color rules don't trigger Chrome 121+ standard scrollbar API - Unified 6px webkit pseudo-element rules for .overflow-y-auto, .overflow-x-auto, .overflow-auto and .scrollbar-thin (no arrows, transparent track) - Remove hide-scrollbar from Notes/Checklist overview and Sidebar so scrollbars are visible in both browsers - Add tailwind-scrollbar plugin classes to KanbanCardDetail panes and Kanban board Co-Authored-By: Claude Sonnet 4.6 --- .../Home/Parts/ChecklistHome.tsx | 2 +- .../Home/Parts/NotesHome.tsx | 2 +- .../FeatureComponents/Kanban/Kanban.tsx | 2 +- .../Kanban/KanbanCardDetail.tsx | 4 +- .../Sidebar/SidebarWrapper.tsx | 2 +- app/_styles/globals.css | 63 +++++++++++++------ 6 files changed, 49 insertions(+), 26 deletions(-) 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/Kanban.tsx b/app/_components/FeatureComponents/Kanban/Kanban.tsx index d341f904..e4c39b7b 100644 --- a/app/_components/FeatureComponents/Kanban/Kanban.tsx +++ b/app/_components/FeatureComponents/Kanban/Kanban.tsx @@ -388,7 +388,7 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { )}
)} -
+
{viewMode === "calendar" ? (
-
+
{isEditing ? (
@@ -436,7 +436,7 @@ export const KanbanCardDetail = ({ )}
-
+
diff --git a/app/_styles/globals.css b/app/_styles/globals.css index 8ad3724e..d322f92c 100644 --- a/app/_styles/globals.css +++ b/app/_styles/globals.css @@ -660,38 +660,61 @@ pre.text-foreground span, } } -.overflow-y-auto { - scrollbar-width: auto !important; +@supports (-moz-appearance: none) { + .overflow-y-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) { From 36acd2cb86b07cf13248dd8ce9811d1664eac2ef Mon Sep 17 00:00:00 2001 From: Nikolai Andree Date: Mon, 18 May 2026 16:52:05 +0200 Subject: [PATCH 02/40] fix(kanban): restore modal scroll panes after upstream Modal wrapper change Upstream added a bare
wrapper around Modal children in default size mode, breaking the flex height chain. Target it via [&>div:last-child] to give it flex-1 + flex-col so the inner panes get a defined height and can scroll again. Co-Authored-By: Claude Sonnet 4.6 --- app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx index f076eafa..94b784ed 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx @@ -327,7 +327,7 @@ 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" + className="[&_.jotty-modal-header]:shrink-0 [&>div:last-child]:flex-1 [&>div:last-child]:min-h-0 [&>div:last-child]:flex [&>div:last-child]:flex-col [&>div:last-child]:overflow-y-auto lg:!max-w-[80vw] lg:!w-full lg:!h-[80vh] lg:!max-h-[80vh] max-h-[min(90dvh,100dvh)] !flex !flex-col lg:overflow-hidden" >
From 030362ad8e84c32485fe6c8a57755c01727d58e5 Mon Sep 17 00:00:00 2001 From: Nikolai Andree Date: Tue, 19 May 2026 09:38:50 +0200 Subject: [PATCH 03/40] fix(kanban): fix scrollbar visibility in modal panes for Edge and Firefox - Remove tailwind-scrollbar plugin classes from both scroll panes; the plugin sets scrollbar-width:thin unconditionally which triggers Chrome 121+ standard scrollbar API (auto-hide overlay) making scrollbars invisible in Edge - Change lg:overflow-y-auto to overflow-y-auto so globals.css webkit rules (.overflow-y-auto::-webkit-scrollbar) apply, giving a always-visible 6px bar - Add lg:[&>div:last-child]:overflow-hidden to prevent the Modal wrapper div from showing its own scrollbar on desktop, which overlapped in Firefox Co-Authored-By: Claude Sonnet 4.6 --- .../FeatureComponents/Kanban/KanbanCardDetail.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx index 94b784ed..4aa0fe85 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx @@ -327,10 +327,10 @@ export const KanbanCardDetail = ({ isOpen={isOpen} onClose={onClose} title={item.text || t("checklists.untitledTask")} - className="[&_.jotty-modal-header]:shrink-0 [&>div:last-child]:flex-1 [&>div:last-child]:min-h-0 [&>div:last-child]:flex [&>div:last-child]:flex-col [&>div:last-child]:overflow-y-auto lg:!max-w-[80vw] lg:!w-full lg:!h-[80vh] lg:!max-h-[80vh] max-h-[min(90dvh,100dvh)] !flex !flex-col lg:overflow-hidden" + className="[&_.jotty-modal-header]:shrink-0 [&>div:last-child]:flex-1 [&>div:last-child]:min-h-0 [&>div:last-child]:flex [&>div:last-child]:flex-col [&>div:last-child]:overflow-y-auto lg:[&>div:last-child]:overflow-hidden lg:!max-w-[80vw] lg:!w-full lg:!h-[80vh] lg:!max-h-[80vh] max-h-[min(90dvh,100dvh)] !flex !flex-col lg:overflow-hidden" >
-
+
{isEditing ? (
@@ -436,7 +436,7 @@ export const KanbanCardDetail = ({ )}
-
+
Date: Tue, 19 May 2026 10:25:28 +0200 Subject: [PATCH 04/40] fix(kanban): use real CSS class to fix Modal wrapper flex chain Tailwind arbitrary variants with pseudo-selectors ([&>div:last-child]) do not compile reliably. Replace with a dedicated CSS class: .jotty-modal-content.jotty-kanban-detail-modal > div:last-child targets the bare wrapper div upstream injected, making it flex-1 flex-col so the inner layout and per-pane overflow-y-auto work again. Co-Authored-By: Claude Sonnet 4.6 --- .../FeatureComponents/Kanban/KanbanCardDetail.tsx | 2 +- app/_styles/globals.css | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx index 4aa0fe85..8355de36 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx @@ -327,7 +327,7 @@ export const KanbanCardDetail = ({ isOpen={isOpen} onClose={onClose} title={item.text || t("checklists.untitledTask")} - className="[&_.jotty-modal-header]:shrink-0 [&>div:last-child]:flex-1 [&>div:last-child]:min-h-0 [&>div:last-child]:flex [&>div:last-child]:flex-col [&>div:last-child]:overflow-y-auto lg:[&>div:last-child]:overflow-hidden lg:!max-w-[80vw] lg:!w-full lg:!h-[80vh] lg:!max-h-[80vh] max-h-[min(90dvh,100dvh)] !flex !flex-col lg:overflow-hidden" + className="jotty-kanban-detail-modal [&_.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 lg:overflow-hidden" >
diff --git a/app/_styles/globals.css b/app/_styles/globals.css index d322f92c..ff40824b 100644 --- a/app/_styles/globals.css +++ b/app/_styles/globals.css @@ -717,6 +717,20 @@ pre.text-foreground span, background-color: rgb(var(--primary) / 0.5); } +.jotty-modal-content.jotty-kanban-detail-modal > div:last-child { + flex: 1 1 0%; + min-height: 0; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +@media (min-width: 1024px) { + .jotty-modal-content.jotty-kanban-detail-modal > div:last-child { + overflow: hidden; + } +} + @media (max-width: 992px) { .jotty-checklist-page .jotty-quick-nav, .jotty-embed .jotty-quick-nav { From cee596ca88f44c31a00fdc92864c1cc87d6700f0 Mon Sep 17 00:00:00 2001 From: Nikolai Andree Date: Tue, 19 May 2026 11:00:50 +0200 Subject: [PATCH 05/40] fix(kanban): use size=fullscreen to restore modal scroll layout The upstream Modal wrapper div breaks the flex chain for default size. size=fullscreen gives the children wrapper flex-1 overflow-auto by design. Inner div uses min-h-full + lg:overflow-hidden so panes scroll independently on desktop while the wrapper scrolls as one unit on mobile. Removes the fragile CSS child selector approach. Co-Authored-By: Claude Sonnet 4.6 --- .../FeatureComponents/Kanban/KanbanCardDetail.tsx | 5 +++-- app/_styles/globals.css | 13 ------------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx index 8355de36..5fa2caad 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx @@ -327,9 +327,10 @@ export const KanbanCardDetail = ({ isOpen={isOpen} onClose={onClose} title={item.text || t("checklists.untitledTask")} - className="jotty-kanban-detail-modal [&_.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 lg:overflow-hidden" + size="fullscreen" + className="lg:!max-w-[80vw] lg:!w-full lg:!h-[80vh] lg:!max-h-[80vh] max-h-[min(90dvh,100dvh)]" > -
+
{isEditing ? (
diff --git a/app/_styles/globals.css b/app/_styles/globals.css index ff40824b..dbdbd66a 100644 --- a/app/_styles/globals.css +++ b/app/_styles/globals.css @@ -717,19 +717,6 @@ pre.text-foreground span, background-color: rgb(var(--primary) / 0.5); } -.jotty-modal-content.jotty-kanban-detail-modal > div:last-child { - flex: 1 1 0%; - min-height: 0; - display: flex; - flex-direction: column; - overflow-y: auto; -} - -@media (min-width: 1024px) { - .jotty-modal-content.jotty-kanban-detail-modal > div:last-child { - overflow: hidden; - } -} @media (max-width: 992px) { .jotty-checklist-page .jotty-quick-nav, From f40f14982f0e61b82fb91209655acf6be793631a Mon Sep 17 00:00:00 2001 From: Nikolai Andree Date: Tue, 19 May 2026 11:34:01 +0200 Subject: [PATCH 06/40] fix(scrollbar): remove redundant tailwind-scrollbar plugin classes scrollbar-thin and related plugin classes set scrollbar-width on Chrome 121+, which causes it to ignore the ::-webkit-scrollbar rules in globals.css and fall back to native auto-hide scrollbars. The global webkit rules already cover .overflow-y-auto and .overflow-auto, so the plugin classes are counterproductive on those elements. --- app/_components/FeatureComponents/Kanban/Kanban.tsx | 2 +- app/_components/GlobalComponents/Sidebar/SidebarWrapper.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/_components/FeatureComponents/Kanban/Kanban.tsx b/app/_components/FeatureComponents/Kanban/Kanban.tsx index e4c39b7b..d341f904 100644 --- a/app/_components/FeatureComponents/Kanban/Kanban.tsx +++ b/app/_components/FeatureComponents/Kanban/Kanban.tsx @@ -388,7 +388,7 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { )}
)} -
+
{viewMode === "calendar" ? (
From 1a6d7f2312383c29c8e6983d07461cd70b4e3c69 Mon Sep 17 00:00:00 2001 From: Nikolai Andree Date: Tue, 19 May 2026 12:22:24 +0200 Subject: [PATCH 07/40] fix(scrollbar): include overflow-x-auto in Firefox scrollbar-width rule The Firefox-only @supports block listed overflow-y-auto and overflow-auto but missed overflow-x-auto, leaving horizontally scrolling containers with the default OS scrollbar in Firefox. The corresponding webkit rules already include overflow-x-auto. --- app/_styles/globals.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/_styles/globals.css b/app/_styles/globals.css index dbdbd66a..57647083 100644 --- a/app/_styles/globals.css +++ b/app/_styles/globals.css @@ -662,6 +662,7 @@ pre.text-foreground span, @supports (-moz-appearance: none) { .overflow-y-auto, + .overflow-x-auto, .overflow-auto { scrollbar-width: thin; scrollbar-color: rgb(var(--primary) / 0.3) transparent; From fc85822b753b6a4cf974112626f6a4a7f973b55c Mon Sep 17 00:00:00 2001 From: Nikolai Andree Date: Fri, 22 May 2026 09:48:26 +0200 Subject: [PATCH 08/40] feat(kanban): add archive all for completed columns --- .../FeatureComponents/Kanban/Kanban.tsx | 57 ++++++++++++++++++- .../FeatureComponents/Kanban/KanbanColumn.tsx | 43 +++++++++++++- app/_translations/de.json | 5 ++ app/_translations/en.json | 5 ++ app/_translations/es.json | 5 ++ app/_translations/fr.json | 5 ++ app/_translations/it.json | 5 ++ app/_translations/klingon.json | 5 ++ app/_translations/ko.json | 5 ++ app/_translations/nl.json | 5 ++ app/_translations/pirate.json | 5 ++ app/_translations/pl.json | 5 ++ app/_translations/ru.json | 5 ++ app/_translations/tr.json | 5 ++ app/_translations/zh.json | 5 ++ 15 files changed, 161 insertions(+), 4 deletions(-) diff --git a/app/_components/FeatureComponents/Kanban/Kanban.tsx b/app/_components/FeatureComponents/Kanban/Kanban.tsx index d341f904..9cc968b5 100644 --- a/app/_components/FeatureComponents/Kanban/Kanban.tsx +++ b/app/_components/FeatureComponents/Kanban/Kanban.tsx @@ -14,7 +14,7 @@ import { CollisionDetection, rectIntersection, } from "@dnd-kit/core"; -import { Checklist, KanbanStatus } from "@/app/_types"; +import { Checklist, Item, KanbanStatus } from "@/app/_types"; import { KanbanColumn } from "./KanbanColumn"; import { KanbanCard } from "./KanbanCard"; import { ChecklistHeading } from "../Checklists/Parts/Common/ChecklistHeading"; @@ -39,9 +39,10 @@ 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; @@ -50,6 +51,7 @@ interface KanbanBoardProps { export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { const t = useTranslations(); + const { showToast } = useToast(); const [isClient, setIsClient] = useState(false); const [showStatusModal, setShowStatusModal] = useState(false); const [showArchivedModal, setShowArchivedModal] = useState(false); @@ -148,6 +150,50 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { } }; + const handleArchiveAll = useCallback( + async (items: Item[]) => { + 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) { + showToast({ + type: "error", + title: t("common.error"), + message: t("kanban.archiveAllFailed"), + }); + await refreshChecklist(); + return; + } + + 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; @@ -200,7 +246,8 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { } > {columns.map((column) => { - const items = _filterItems(getItemsByStatus(column.status)); + const columnItems = getItemsByStatus(column.status); + const items = _filterItems(columnItems); return (
{ statusColor={statuses.find((s) => s.id === column.id)?.color} statuses={statuses} onAddItem={permissions?.canEdit ? (text) => handleAddItem(text, undefined, column.status) : undefined} + archiveItems={columnItems} + onArchiveAll={permissions?.canEdit ? handleArchiveAll : undefined} />
); @@ -234,6 +283,8 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { handleItemUpdate, isShared, statuses, + handleArchiveAll, + permissions?.canEdit, ], ); diff --git a/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx b/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx index a7da7dd1..3547d69e 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx @@ -11,8 +11,9 @@ 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; @@ -27,6 +28,8 @@ interface KanbanColumnProps { statusColor?: string; statuses: KanbanStatus[]; onAddItem?: (status: string) => Promise; + archiveItems?: Item[]; + onArchiveAll?: (items: Item[]) => Promise; } interface InlineAddInputProps { @@ -88,14 +91,19 @@ const KanbanColumnComponent = ({ statusColor, statuses, onAddItem, + archiveItems, + onArchiveAll, }: KanbanColumnProps) => { const t = useTranslations(); const { setNodeRef, isOver } = useDroppable({ 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; + const archivableItems = archiveItems ?? items; const defaultColors: Record = useMemo( () => ({ @@ -136,6 +144,16 @@ const KanbanColumnComponent = ({ setIsAddingItem(false); }; + const handleArchiveAll = async () => { + if (!onArchiveAll || archivableItems.length === 0) return; + setIsArchiving(true); + try { + await onArchiveAll(archivableItems); + } finally { + setIsArchiving(false); + } + }; + return (
@@ -160,6 +178,17 @@ const KanbanColumnComponent = ({ )} + {onArchiveAll && isAutoComplete && ( + + )} {items.length} @@ -214,6 +243,18 @@ const KanbanColumnComponent = ({
+ setShowArchiveAllModal(false)} + onConfirm={handleArchiveAll} + title={t("kanban.archiveAllConfirmTitle")} + message={t("kanban.archiveAllConfirmMessage", { + count: archivableItems.length, + column: title, + })} + confirmText={t("common.archive")} + variant="destructive" + />
); }; diff --git a/app/_translations/de.json b/app/_translations/de.json index b3895c33..e5a92373 100644 --- a/app/_translations/de.json +++ b/app/_translations/de.json @@ -586,6 +586,11 @@ "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", "itemDeleted": "Element gelöscht", "estimatedTime": "Geschätzte Zeit (Stunden)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/en.json b/app/_translations/en.json index 7c52cd33..d311d35f 100644 --- a/app/_translations/en.json +++ b/app/_translations/en.json @@ -611,6 +611,11 @@ "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", "itemDeleted": "Item deleted", "estimatedTime": "Estimated Time (hours)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/es.json b/app/_translations/es.json index b557f4a0..def16e0e 100644 --- a/app/_translations/es.json +++ b/app/_translations/es.json @@ -586,6 +586,11 @@ "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", "itemDeleted": "Elemento eliminado", "estimatedTime": "Tiempo estimado (horas)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/fr.json b/app/_translations/fr.json index 65224617..b92a4676 100644 --- a/app/_translations/fr.json +++ b/app/_translations/fr.json @@ -586,6 +586,11 @@ "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", "itemDeleted": "Élément supprimé", "estimatedTime": "Temps estimé (heures)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/it.json b/app/_translations/it.json index 5410a7ee..c49db8c8 100644 --- a/app/_translations/it.json +++ b/app/_translations/it.json @@ -586,6 +586,11 @@ "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", "itemDeleted": "Elemento eliminato", "estimatedTime": "Tempo stimato (ore)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/klingon.json b/app/_translations/klingon.json index 96732f20..6cf5c527 100644 --- a/app/_translations/klingon.json +++ b/app/_translations/klingon.json @@ -611,6 +611,11 @@ "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'", "itemDeleted": "Qaw'pu'", "estimatedTime": "pIq chartoq (rep)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/ko.json b/app/_translations/ko.json index fcf4122e..3ab61106 100644 --- a/app/_translations/ko.json +++ b/app/_translations/ko.json @@ -611,6 +611,11 @@ "targetDate": "목표일", "statusUpdated": "상태가 업데이트됨", "itemArchived": "항목이 보관됨", + "archiveAllItemsInColumn": "{column}의 모든 항목 보관", + "archiveAllConfirmTitle": "모든 항목을 보관할까요?", + "archiveAllConfirmMessage": "{column}의 항목 {count}개를 모두 보관할까요? 보관함에서 복원할 수 있습니다.", + "archiveAllSuccess": "항목 {count}개가 보관됨", + "archiveAllFailed": "모든 항목을 보관하지 못했습니다", "itemDeleted": "항목이 삭제됨", "estimatedTime": "예상 시간(시간)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/nl.json b/app/_translations/nl.json index bad88248..ad84156e 100644 --- a/app/_translations/nl.json +++ b/app/_translations/nl.json @@ -586,6 +586,11 @@ "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", "itemDeleted": "Item verwijderd", "estimatedTime": "Geschatte tijd (uur)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/pirate.json b/app/_translations/pirate.json index bc5e316b..ab15d03f 100644 --- a/app/_translations/pirate.json +++ b/app/_translations/pirate.json @@ -611,6 +611,11 @@ "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", "itemDeleted": "Item walked the plank", "estimatedTime": "Estimated hours o’ toil", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/pl.json b/app/_translations/pl.json index ca113cae..162e2eff 100644 --- a/app/_translations/pl.json +++ b/app/_translations/pl.json @@ -586,6 +586,11 @@ "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", "itemDeleted": "Element usunięty", "estimatedTime": "Szacowany czas (godziny)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/ru.json b/app/_translations/ru.json index a6630dbf..8e9f3de3 100644 --- a/app/_translations/ru.json +++ b/app/_translations/ru.json @@ -611,6 +611,11 @@ "targetDate": "Целевая дата", "statusUpdated": "Статус обновлён", "itemArchived": "Элемент архивирован", + "archiveAllItemsInColumn": "Архивировать все элементы в {column}", + "archiveAllConfirmTitle": "Архивировать все элементы?", + "archiveAllConfirmMessage": "Архивировать все элементы ({count}) в {column}? Их можно восстановить из архива.", + "archiveAllSuccess": "Архивировано элементов: {count}", + "archiveAllFailed": "Не удалось архивировать все элементы", "itemDeleted": "Элемент удалён", "estimatedTime": "Оценка времени (часы)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/tr.json b/app/_translations/tr.json index 795ab0c4..ed9c8072 100644 --- a/app/_translations/tr.json +++ b/app/_translations/tr.json @@ -611,6 +611,11 @@ "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", "itemDeleted": "Öğe silindi", "estimatedTime": "Tahmini süre (saat)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/zh.json b/app/_translations/zh.json index 750ab022..166598aa 100644 --- a/app/_translations/zh.json +++ b/app/_translations/zh.json @@ -611,6 +611,11 @@ "targetDate": "目标日期", "statusUpdated": "状态已更新", "itemArchived": "项目已归档", + "archiveAllItemsInColumn": "归档 {column} 中的所有项目", + "archiveAllConfirmTitle": "归档所有项目?", + "archiveAllConfirmMessage": "要归档 {column} 中的全部 {count} 个项目吗?你可以从归档中恢复它们。", + "archiveAllSuccess": "已归档 {count} 个项目", + "archiveAllFailed": "无法归档所有项目", "itemDeleted": "项目已删除", "estimatedTime": "预估时间(小时)", "actualVsEstimated": "{actual} / {estimated}", From 4d6abbdea510f42eab4731b01c1ab8d5a9549dc3 Mon Sep 17 00:00:00 2001 From: Nikolai Andree Date: Fri, 22 May 2026 10:01:02 +0200 Subject: [PATCH 09/40] fix(kanban): clarify archive all failure handling --- .../FeatureComponents/Kanban/Kanban.tsx | 24 +++++++++++++++---- .../FeatureComponents/Kanban/KanbanColumn.tsx | 15 ++++++------ app/_translations/de.json | 1 + app/_translations/en.json | 1 + app/_translations/es.json | 1 + app/_translations/fr.json | 1 + app/_translations/it.json | 1 + app/_translations/klingon.json | 1 + app/_translations/ko.json | 1 + app/_translations/nl.json | 1 + app/_translations/pirate.json | 1 + app/_translations/pl.json | 1 + app/_translations/ru.json | 1 + app/_translations/tr.json | 1 + app/_translations/zh.json | 1 + 15 files changed, 40 insertions(+), 12 deletions(-) diff --git a/app/_components/FeatureComponents/Kanban/Kanban.tsx b/app/_components/FeatureComponents/Kanban/Kanban.tsx index 9cc968b5..ddc64f98 100644 --- a/app/_components/FeatureComponents/Kanban/Kanban.tsx +++ b/app/_components/FeatureComponents/Kanban/Kanban.tsx @@ -152,6 +152,7 @@ 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) { @@ -162,15 +163,26 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { const result = await archiveItem(formData); if (!result.success || !result.data) { + if (latestChecklist) { + onUpdate(latestChecklist); + } + + await refreshChecklist(); showToast({ type: "error", title: t("common.error"), - message: t("kanban.archiveAllFailed"), + message: + archivedCount > 0 + ? t("kanban.archiveAllPartial", { + archived: archivedCount, + total: items.length, + }) + : t("kanban.archiveAllFailed"), }); - await refreshChecklist(); return; } + archivedCount++; latestChecklist = result.data; } @@ -267,8 +279,12 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { statusColor={statuses.find((s) => s.id === column.id)?.color} statuses={statuses} onAddItem={permissions?.canEdit ? (text) => handleAddItem(text, undefined, column.status) : undefined} - archiveItems={columnItems} - onArchiveAll={permissions?.canEdit ? handleArchiveAll : undefined} + archivableCount={columnItems.length} + onArchiveAll={ + permissions?.canEdit + ? () => handleArchiveAll(columnItems) + : undefined + } />
); diff --git a/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx b/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx index 3547d69e..248669ac 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx @@ -28,8 +28,8 @@ interface KanbanColumnProps { statusColor?: string; statuses: KanbanStatus[]; onAddItem?: (status: string) => Promise; - archiveItems?: Item[]; - onArchiveAll?: (items: Item[]) => Promise; + archivableCount?: number; + onArchiveAll?: () => Promise; } interface InlineAddInputProps { @@ -91,7 +91,7 @@ const KanbanColumnComponent = ({ statusColor, statuses, onAddItem, - archiveItems, + archivableCount = items.length, onArchiveAll, }: KanbanColumnProps) => { const t = useTranslations(); @@ -103,7 +103,6 @@ const KanbanColumnComponent = ({ const currentStatus = statuses.find((s) => s.id === status); const isAutoComplete = currentStatus?.autoComplete === true; - const archivableItems = archiveItems ?? items; const defaultColors: Record = useMemo( () => ({ @@ -145,10 +144,10 @@ const KanbanColumnComponent = ({ }; const handleArchiveAll = async () => { - if (!onArchiveAll || archivableItems.length === 0) return; + if (!onArchiveAll || archivableCount === 0) return; setIsArchiving(true); try { - await onArchiveAll(archivableItems); + await onArchiveAll(); } finally { setIsArchiving(false); } @@ -183,7 +182,7 @@ const KanbanColumnComponent = ({ onClick={() => setShowArchiveAllModal(true)} title={t("kanban.archiveAllItemsInColumn", { column: title })} aria-label={t("kanban.archiveAllItemsInColumn", { column: title })} - disabled={archivableItems.length === 0 || isArchiving} + disabled={archivableCount === 0 || isArchiving} className="flex items-center justify-center h-5 w-5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed" > @@ -249,7 +248,7 @@ const KanbanColumnComponent = ({ onConfirm={handleArchiveAll} title={t("kanban.archiveAllConfirmTitle")} message={t("kanban.archiveAllConfirmMessage", { - count: archivableItems.length, + count: archivableCount, column: title, })} confirmText={t("common.archive")} diff --git a/app/_translations/de.json b/app/_translations/de.json index e5a92373..672f1971 100644 --- a/app/_translations/de.json +++ b/app/_translations/de.json @@ -591,6 +591,7 @@ "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", + "archiveAllPartial": "{archived} von {total} Elementen archiviert. Einige Elemente konnten nicht archiviert werden.", "itemDeleted": "Element gelöscht", "estimatedTime": "Geschätzte Zeit (Stunden)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/en.json b/app/_translations/en.json index d311d35f..34a53983 100644 --- a/app/_translations/en.json +++ b/app/_translations/en.json @@ -616,6 +616,7 @@ "archiveAllConfirmMessage": "Archive all {count} items in {column}? You can restore them from the archive.", "archiveAllSuccess": "{count} items archived", "archiveAllFailed": "Could not archive all items", + "archiveAllPartial": "{archived} of {total} items archived. Some items could not be archived.", "itemDeleted": "Item deleted", "estimatedTime": "Estimated Time (hours)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/es.json b/app/_translations/es.json index def16e0e..69bf1c9f 100644 --- a/app/_translations/es.json +++ b/app/_translations/es.json @@ -591,6 +591,7 @@ "archiveAllConfirmMessage": "¿Archivar los {count} elementos en {column}? Puedes restaurarlos desde el archivo.", "archiveAllSuccess": "{count} elementos archivados", "archiveAllFailed": "No se pudieron archivar todos los elementos", + "archiveAllPartial": "{archived} de {total} elementos archivados. Algunos elementos no se pudieron archivar.", "itemDeleted": "Elemento eliminado", "estimatedTime": "Tiempo estimado (horas)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/fr.json b/app/_translations/fr.json index b92a4676..51a1b23c 100644 --- a/app/_translations/fr.json +++ b/app/_translations/fr.json @@ -591,6 +591,7 @@ "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", + "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}", diff --git a/app/_translations/it.json b/app/_translations/it.json index c49db8c8..d9addba4 100644 --- a/app/_translations/it.json +++ b/app/_translations/it.json @@ -591,6 +591,7 @@ "archiveAllConfirmMessage": "Archiviare tutti i {count} elementi in {column}? Potrai ripristinarli dall'archivio.", "archiveAllSuccess": "{count} elementi archiviati", "archiveAllFailed": "Impossibile archiviare tutti gli elementi", + "archiveAllPartial": "{archived} elementi su {total} archiviati. Alcuni elementi non sono stati archiviati.", "itemDeleted": "Elemento eliminato", "estimatedTime": "Tempo stimato (ore)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/klingon.json b/app/_translations/klingon.json index 6cf5c527..b57442f7 100644 --- a/app/_translations/klingon.json +++ b/app/_translations/klingon.json @@ -616,6 +616,7 @@ "archiveAllConfirmMessage": "{column}Daq {count} Dochmey Hoch pol? polmeH Daqvo' bIH DacheghmoHlaH.", "archiveAllSuccess": "{count} Dochmey pol", "archiveAllFailed": "Hoch Dochmey pollu'laHbe'", + "archiveAllPartial": "{total}vo' {archived} Dochmey pol. Dochmey 'op pollu'laHbe'.", "itemDeleted": "Qaw'pu'", "estimatedTime": "pIq chartoq (rep)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/ko.json b/app/_translations/ko.json index 3ab61106..c339f3f4 100644 --- a/app/_translations/ko.json +++ b/app/_translations/ko.json @@ -616,6 +616,7 @@ "archiveAllConfirmMessage": "{column}의 항목 {count}개를 모두 보관할까요? 보관함에서 복원할 수 있습니다.", "archiveAllSuccess": "항목 {count}개가 보관됨", "archiveAllFailed": "모든 항목을 보관하지 못했습니다", + "archiveAllPartial": "항목 {total}개 중 {archived}개가 보관되었습니다. 일부 항목은 보관하지 못했습니다.", "itemDeleted": "항목이 삭제됨", "estimatedTime": "예상 시간(시간)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/nl.json b/app/_translations/nl.json index ad84156e..5ac6d658 100644 --- a/app/_translations/nl.json +++ b/app/_translations/nl.json @@ -591,6 +591,7 @@ "archiveAllConfirmMessage": "Alle {count} items in {column} archiveren? Je kunt ze herstellen vanuit het archief.", "archiveAllSuccess": "{count} items gearchiveerd", "archiveAllFailed": "Kon niet alle items archiveren", + "archiveAllPartial": "{archived} van {total} items gearchiveerd. Sommige items konden niet worden gearchiveerd.", "itemDeleted": "Item verwijderd", "estimatedTime": "Geschatte tijd (uur)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/pirate.json b/app/_translations/pirate.json index ab15d03f..54dee792 100644 --- a/app/_translations/pirate.json +++ b/app/_translations/pirate.json @@ -616,6 +616,7 @@ "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", + "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}", diff --git a/app/_translations/pl.json b/app/_translations/pl.json index 162e2eff..46615875 100644 --- a/app/_translations/pl.json +++ b/app/_translations/pl.json @@ -591,6 +591,7 @@ "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", + "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}", diff --git a/app/_translations/ru.json b/app/_translations/ru.json index 8e9f3de3..6f84f23d 100644 --- a/app/_translations/ru.json +++ b/app/_translations/ru.json @@ -616,6 +616,7 @@ "archiveAllConfirmMessage": "Архивировать все элементы ({count}) в {column}? Их можно восстановить из архива.", "archiveAllSuccess": "Архивировано элементов: {count}", "archiveAllFailed": "Не удалось архивировать все элементы", + "archiveAllPartial": "Архивировано {archived} из {total} элементов. Некоторые элементы не удалось архивировать.", "itemDeleted": "Элемент удалён", "estimatedTime": "Оценка времени (часы)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/tr.json b/app/_translations/tr.json index ed9c8072..1f8a47db 100644 --- a/app/_translations/tr.json +++ b/app/_translations/tr.json @@ -616,6 +616,7 @@ "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", + "archiveAllPartial": "{total} öğeden {archived} tanesi arşivlendi. Bazı öğeler arşivlenemedi.", "itemDeleted": "Öğe silindi", "estimatedTime": "Tahmini süre (saat)", "actualVsEstimated": "{actual} / {estimated}", diff --git a/app/_translations/zh.json b/app/_translations/zh.json index 166598aa..e7c6165e 100644 --- a/app/_translations/zh.json +++ b/app/_translations/zh.json @@ -616,6 +616,7 @@ "archiveAllConfirmMessage": "要归档 {column} 中的全部 {count} 个项目吗?你可以从归档中恢复它们。", "archiveAllSuccess": "已归档 {count} 个项目", "archiveAllFailed": "无法归档所有项目", + "archiveAllPartial": "已归档 {total} 个项目中的 {archived} 个。部分项目无法归档。", "itemDeleted": "项目已删除", "estimatedTime": "预估时间(小时)", "actualVsEstimated": "{actual} / {estimated}", From 307cd6658148fc259d42c8b94e082991953c1fdd Mon Sep 17 00:00:00 2001 From: Nikolai Andree Date: Fri, 22 May 2026 16:48:33 +0200 Subject: [PATCH 10/40] fix(checklist): respect drop position when reordering subitems Subitem drag-and-drop always inserted the dragged item before the drop target instead of at the indicated position, so it landed at the top of the parent's children. The before/after check in handleDragEnd matched the drop-zone id prefix "drop-after::", but subitem drop indicators use the prefix "drop-after-child::". The check never matched for subitems, so position always fell back to "before". Top-level reorder was unaffected because its indicators do use "drop-after::". Match the prefix "drop-after" so both top-level and subitem indicators resolve correctly, consistent with the isDropIndicator check that already uses that prefix. Co-Authored-By: Claude Opus 4.7 --- app/_hooks/useChecklist.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From ee966999969bdba22e37b053132c8e30bc3d5b7b Mon Sep 17 00:00:00 2001 From: fccview Date: Wed, 27 May 2026 08:33:12 +0100 Subject: [PATCH 11/40] fix small translation issue --- .../Kanban/ArchivedItemsModal.tsx | 2 +- app/_hooks/kanban/useKanbanItem.tsx | 53 +-- app/_server/actions/note/parsers.ts | 2 +- app/_utils/checklist-utils.ts | 9 +- package.json | 18 +- yarn.lock | 359 +++++++----------- 6 files changed, 149 insertions(+), 294 deletions(-) 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/_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/_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/_utils/checklist-utils.ts b/app/_utils/checklist-utils.ts index 9551efa3..dbf2963e 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 = ( @@ -225,7 +224,7 @@ export const parseMarkdown = ( }); item = { - id: resolveItemId(itemMetadata.id, parentLevel), + id: itemMetadata.id || generateItemId(parentLevel), text: itemText, completed, order: currentItemIndex, @@ -267,7 +266,7 @@ export const parseMarkdown = ( } item = { - id: resolveItemId(itemMetadata.id, parentLevel), + id: itemMetadata.id || generateItemId(parentLevel), text: itemText, completed, order: currentItemIndex, diff --git a/package.json b/package.json index 423942cc..607c147d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jotty.page", - "version": "1.24.0", + "version": "1.23.0", "private": true, "scripts": { "dev": "next dev", @@ -20,7 +20,7 @@ "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", - "@excalidraw/excalidraw": "0.18.1", + "@excalidraw/excalidraw": "0.18.0", "@fontsource-variable/google-sans-code": "5.2.3", "@fontsource-variable/ibm-plex-sans": "5.2.8", "@fontsource-variable/inter": "5.2.8", @@ -62,9 +62,9 @@ "jsonwebtoken": "9.0.2", "ldapts": "8.1.7", "libsodium-wrappers-sumo": "0.7.15", - "mermaid": "10.9.6", - "next": "16.2.6", - "next-intl": "4.9.2", + "mermaid": "10.9.4", + "next": "16.2.3", + "next-intl": "4.9.1", "next-themes": "0.2.1", "openpgp": "6.3.0", "prismjs": "1.30.0", @@ -94,8 +94,8 @@ "turndown-plugin-gfm": "1.0.2", "unified": "11.0.5", "unist-util-visit": "5.0.0", - "uuid": "11.1.1", - "ws": "8.20.1", + "uuid": "11.1.0", + "ws": "8.19.0", "zod": "4.1.12", "zustand": "5.0.6" }, @@ -103,12 +103,10 @@ "lodash-es": "4.18.1", "lodash": "4.18.1", "nanoid": "3.3.8", - "**/mermaid": "10.9.6", + "**/mermaid": "10.9.4", "@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", diff --git a/yarn.lock b/yarn.lock index ba50b8bd..d74049dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -161,38 +161,6 @@ resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz#923ca57e173c6b232bbbb07347b1be982f03e783" integrity sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A== -"@chevrotain/cst-dts-gen@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz#5e0863cc57dc45e204ccfee6303225d15d9d4783" - integrity sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ== - dependencies: - "@chevrotain/gast" "11.0.3" - "@chevrotain/types" "11.0.3" - lodash-es "4.17.21" - -"@chevrotain/gast@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/gast/-/gast-11.0.3.tgz#e84d8880323fe8cbe792ef69ce3ffd43a936e818" - integrity sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q== - dependencies: - "@chevrotain/types" "11.0.3" - lodash-es "4.17.21" - -"@chevrotain/regexp-to-ast@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz#11429a81c74a8e6a829271ce02fc66166d56dcdb" - integrity sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA== - -"@chevrotain/types@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/types/-/types-11.0.3.tgz#f8a03914f7b937f594f56eb89312b3b8f1c91848" - integrity sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ== - -"@chevrotain/utils@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/utils/-/utils-11.0.3.tgz#e39999307b102cff3645ec4f5b3665f5297a2224" - integrity sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ== - "@date-fns/tz@^1.4.1": version "1.4.1" resolved "https://registry.yarnpkg.com/@date-fns/tz/-/tz-1.4.1.tgz#2d905f282304630e07bef6d02d2e7dbf3f0cc4e4" @@ -709,14 +677,14 @@ "@eslint/core" "^0.17.0" levn "^0.4.1" -"@excalidraw/excalidraw@0.18.1": - version "0.18.1" - resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.18.1.tgz#fd6ad752807c814698c5f51dab778845b7e4bba7" - integrity sha512-6i5Gt7IDTOH//qa0Z315Ly5iVRhjWpu2whrlQFqkuwrkKUWgRsMk0P5qdE7bpyDpai7jeLeWYkyj1eVAfni1lw== +"@excalidraw/excalidraw@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.18.0.tgz#9f818e2df80a8735af54f8cc21da67997785532f" + integrity sha512-QkIiS+5qdy8lmDWTKsuy0sK/fen/LRDtbhm2lc2xcFcqhv2/zdg95bYnl+wnwwXGHo7kEmP65BSiMHE7PJ3Zpw== dependencies: "@braintree/sanitize-url" "6.0.2" "@excalidraw/laser-pointer" "1.3.1" - "@excalidraw/mermaid-to-excalidraw" "2.2.2" + "@excalidraw/mermaid-to-excalidraw" "1.1.2" "@excalidraw/random-username" "1.1.0" "@radix-ui/react-popover" "1.1.6" "@radix-ui/react-tabs" "1.0.2" @@ -756,14 +724,13 @@ resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb" integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== -"@excalidraw/mermaid-to-excalidraw@2.2.2": - version "2.2.2" - resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.2.2.tgz#ee6b597a0d95b9a76f7ae41ce0e3733a9b96e4a0" - integrity sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg== +"@excalidraw/mermaid-to-excalidraw@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.2.tgz#74d9507971976a7d3d960a1b2e8fb49a9f1f0d22" + integrity sha512-hAFv/TTIsOdoy0dL5v+oBd297SQ+Z88gZ5u99fCIFuEMHfQuPgLhU/ztKhFSTs7fISwVo6fizny/5oQRR3d4tQ== dependencies: "@excalidraw/markdown-to-text" "0.1.2" - "@mermaid-js/parser" "^0.6.3" - mermaid "^11.12.1" + mermaid "10.9.3" nanoid "4.0.2" "@excalidraw/random-username@1.1.0": @@ -1105,13 +1072,6 @@ resolved "https://registry.yarnpkg.com/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz#8ace5259254426ccef57f3175bc64ed7095ed919" integrity sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw== -"@mermaid-js/parser@^0.6.3": - version "0.6.3" - resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-0.6.3.tgz#3ce92dad2c5d696d29e11e21109c66a7886c824e" - integrity sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA== - dependencies: - langium "3.3.1" - "@mixmark-io/domino@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@mixmark-io/domino/-/domino-2.2.0.tgz#4e8ec69bf1afeb7a14f0628b7e2c0f35bdb336c3" @@ -1126,10 +1086,10 @@ "@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.3": + version "16.2.3" + resolved "https://registry.yarnpkg.com/@next/env/-/env-16.2.3.tgz#eda120ae25aa43b3ff9c0621f5fa6e10e46ef749" + integrity sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA== "@next/eslint-plugin-next@16.1.6": version "16.1.6" @@ -1138,45 +1098,45 @@ 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.3": + version "16.2.3" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.3.tgz#ec4fea25a921dce0847a2b8d7df419ea49615172" + integrity sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg== + +"@next/swc-darwin-x64@16.2.3": + version "16.2.3" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.3.tgz#de3d5281f8ca81ef23527d93e81229e6f85c4ec7" + integrity sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ== + +"@next/swc-linux-arm64-gnu@16.2.3": + version "16.2.3" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.3.tgz#dbd85b17dd94e23a676084089b5b383bbf9d346c" + integrity sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q== + +"@next/swc-linux-arm64-musl@16.2.3": + version "16.2.3" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.3.tgz#a2361a6e741c64c8e6cac347631e4001150f1711" + integrity sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw== + +"@next/swc-linux-x64-gnu@16.2.3": + version "16.2.3" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.3.tgz#d356deb1ae924d1e3a5071d64f5be0e3f1e916ac" + integrity sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ== + +"@next/swc-linux-x64-musl@16.2.3": + version "16.2.3" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.3.tgz#3b307a0691995a8fa323d32a83eb100e3ac03358" + integrity sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw== + +"@next/swc-win32-arm64-msvc@16.2.3": + version "16.2.3" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.3.tgz#eae5f6f105d0c855911821be74931f755761dc6d" + integrity sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw== + +"@next/swc-win32-x64-msvc@16.2.3": + version "16.2.3" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.3.tgz#aff6de2107cb29c9e8f3242e43f432d00dbea0e0" + integrity sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw== "@nivo/annotations@0.99.0": version "0.99.0" @@ -3279,10 +3239,10 @@ binary-extensions@^2.0.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: - 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== +brace-expansion@^5.0.2: + version "5.0.5" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" + integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== dependencies: balanced-match "^4.0.2" @@ -3427,25 +3387,6 @@ character-reference-invalid@^2.0.0: resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz#85c66b041e43b47210faf401278abf808ac45cb9" integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw== -chevrotain-allstar@~0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz#b7412755f5d83cc139ab65810cdb00d8db40e6ca" - integrity sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw== - dependencies: - lodash-es "^4.17.21" - -chevrotain@~11.0.3: - version "11.0.3" - resolved "https://registry.yarnpkg.com/chevrotain/-/chevrotain-11.0.3.tgz#88ffc1fb4b5739c715807eaeedbbf200e202fc1b" - integrity sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw== - dependencies: - "@chevrotain/cst-dts-gen" "11.0.3" - "@chevrotain/gast" "11.0.3" - "@chevrotain/regexp-to-ast" "11.0.3" - "@chevrotain/types" "11.0.3" - "@chevrotain/utils" "11.0.3" - lodash-es "4.17.21" - "chokidar@>=3.0.0 <4.0.0", chokidar@^3.5.3: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" @@ -3907,7 +3848,7 @@ d3-zoom@3: d3-selection "2 - 3" d3-transition "2 - 3" -d3@^7.4.0, d3@^7.9.0: +d3@^7.4.0, d3@^7.8.2: version "7.9.0" resolved "https://registry.yarnpkg.com/d3/-/d3-7.9.0.tgz#579e7acb3d749caf8860bd1741ae8d371070cd5d" integrity sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA== @@ -3943,12 +3884,12 @@ d3@^7.4.0, d3@^7.9.0: d3-transition "3" d3-zoom "3" -dagre-d3-es@7.0.13: - version "7.0.13" - resolved "https://registry.yarnpkg.com/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz#acfb4b449f6dcdd48d8ea8081a6d8c59bc8128c3" - integrity sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q== +dagre-d3-es@7.0.10: + version "7.0.10" + resolved "https://registry.yarnpkg.com/dagre-d3-es/-/dagre-d3-es-7.0.10.tgz#19800d4be674379a3cd8c86a8216a2ac6827cadc" + integrity sha512-qTCQmEhcynucuaZgY5/+ti3X/rnszKZhEQH/ZdWdtP1tA/y3VoHJzcVrO9pjjJCNpigfscAtoUB5ONcd2wNn0A== dependencies: - d3 "^7.9.0" + d3 "^7.8.2" lodash-es "^4.17.21" damerau-levenshtein@^1.0.8: @@ -4129,7 +4070,7 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -dompurify@3.4.0, dompurify@^3.2.4: +dompurify@3.4.0, "dompurify@^3.0.5 <3.1.7": version "3.4.0" resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.0.tgz#b1fc33ebdadb373241621e0a30e4ad81573dfd0b" integrity sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg== @@ -5210,10 +5151,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.9.1: + version "4.9.1" + resolved "https://registry.yarnpkg.com/icu-minify/-/icu-minify-4.9.1.tgz#95504d2147713e851755ef810234cf4f4d5457e7" + integrity sha512-6NkfF9GHHFouqnz+wuiLjCWQiyxoEyJ5liUv4Jxxo/8wyhV7MY0L0iTEGDAVEa4aAD58WqTxFMa20S5nyMjwNw== dependencies: "@formatjs/icu-messageformat-parser" "^3.4.0" @@ -5764,17 +5705,6 @@ kolorist@1.8.0: resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.8.0.tgz#edddbbbc7894bc13302cdf740af6374d4a04743c" integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ== -langium@3.3.1: - version "3.3.1" - resolved "https://registry.yarnpkg.com/langium/-/langium-3.3.1.tgz#da745a40d5ad8ee565090fed52eaee643be4e591" - integrity sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w== - dependencies: - chevrotain "~11.0.3" - chevrotain-allstar "~0.3.0" - vscode-languageserver "~9.0.1" - vscode-languageserver-textdocument "~1.0.11" - vscode-uri "~3.0.8" - language-subtag-registry@^0.3.20: version "0.3.23" resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz#23529e04d9e3b74679d70142df3fd2eb6ec572e7" @@ -5874,7 +5804,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.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== @@ -6223,10 +6153,10 @@ merge2@^1.3.0: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -mermaid@10.9.6, mermaid@^11.12.1: - version "10.9.6" - resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.6.tgz#75e683737b20adaff034e61ee1974dfd1a70c379" - integrity sha512-XRjjRaI4aPCAMpVaOhxIwLYdx3U4Cb6mN0M268ggFAfFRqsvyFW8zxWbEZazN/mPkqsVWThb0oa1UawWK+XMNg== +mermaid@10.9.3, mermaid@10.9.4: + version "10.9.4" + resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.4.tgz#985fd4b6d73ae795b87f0b32f620a56d3d6bf1f8" + integrity sha512-VIG2B0R9ydvkS+wShA8sXqkzfpYglM2Qwj7VyUeqzNVqSGPoP/tcaUr3ub4ESykv8eqQJn3p99bHNvYdg3gCHQ== dependencies: "@braintree/sanitize-url" "^6.0.1" "@types/d3-scale" "^4.0.3" @@ -6235,9 +6165,9 @@ mermaid@10.9.6, mermaid@^11.12.1: cytoscape-cose-bilkent "^4.1.0" d3 "^7.4.0" d3-sankey "^0.12.3" - dagre-d3-es "7.0.13" + dagre-d3-es "7.0.10" dayjs "^1.11.7" - dompurify "^3.2.4" + dompurify "^3.0.5 <3.1.7" elkjs "^0.9.0" katex "^0.16.9" khroma "^2.0.0" @@ -6246,7 +6176,7 @@ mermaid@10.9.6, mermaid@^11.12.1: non-layered-tidy-tree-layout "^2.0.2" stylis "^4.1.3" ts-dedent "^2.2.0" - uuid "^9.0.0 || ^10 || ^11.1.0 || ^12 || ^13 || ^14.0.0" + uuid "^9.0.0" web-worker "^1.2.0" micromark-core-commonmark@^1.0.1: @@ -6768,7 +6698,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, nanoid@^3.3.6: version "3.3.8" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== @@ -6788,50 +6718,50 @@ negotiator@^1.0.0: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" 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== +next-intl-swc-plugin-extractor@^4.9.1: + version "4.9.1" + resolved "https://registry.yarnpkg.com/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.9.1.tgz#29f8583ec6777a1b79888d045978d8f037e3c03f" + integrity sha512-8whJJ6oxJz8JqkHarggmmuEDyXgC7nEnaPhZD91CJwEWW4xp0AST3Mw17YxvHyP2vAF3taWfFbs1maD+WWtz3w== -next-intl@4.9.2: - version "4.9.2" - resolved "https://registry.yarnpkg.com/next-intl/-/next-intl-4.9.2.tgz#a5a279611197e8b5a3018ef8fd2999589315d345" - integrity sha512-AZoMRsVGLZczB2hisq1OTWmNAYAKwk/jaWH4+9pfl5TCG8kbILZZptZHux9zw7DyN1yzh6X7jmaQvoykHs9Y7Q== +next-intl@4.9.1: + version "4.9.1" + resolved "https://registry.yarnpkg.com/next-intl/-/next-intl-4.9.1.tgz#0f75c85e1157d468b8b0d51de0ae30cee51fc6a2" + integrity sha512-N7ga0CjtYcdxNvaKNIi6eJ2mmatlHK5hp8rt0YO2Omoc1m0gean242/Ukdj6+gJNiReBVcYIjK0HZeNx7CV1ug== dependencies: "@formatjs/intl-localematcher" "^0.8.1" "@parcel/watcher" "^2.4.1" "@swc/core" "^1.15.2" - icu-minify "^4.9.2" + icu-minify "^4.9.1" negotiator "^1.0.0" - next-intl-swc-plugin-extractor "^4.9.2" + next-intl-swc-plugin-extractor "^4.9.1" po-parser "^2.1.1" - use-intl "^4.9.2" + use-intl "^4.9.1" next-themes@0.2.1: version "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.3: + version "16.2.3" + resolved "https://registry.yarnpkg.com/next/-/next-16.2.3.tgz#091b6565d46b3fb494fbb5c73d201171890787a5" + integrity sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA== dependencies: - "@next/env" "16.2.6" + "@next/env" "16.2.3" "@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.3" + "@next/swc-darwin-x64" "16.2.3" + "@next/swc-linux-arm64-gnu" "16.2.3" + "@next/swc-linux-arm64-musl" "16.2.3" + "@next/swc-linux-x64-gnu" "16.2.3" + "@next/swc-linux-x64-musl" "16.2.3" + "@next/swc-win32-arm64-msvc" "16.2.3" + "@next/swc-win32-x64-msvc" "16.2.3" sharp "^0.34.5" node-addon-api@^7.0.0: @@ -7234,7 +7164,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, 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 +7173,14 @@ 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== +postcss@8.4.31: + version "8.4.31" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" + integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== dependencies: - nanoid "^3.3.12" - picocolors "^1.1.1" - source-map-js "^1.2.1" + nanoid "^3.3.6" + picocolors "^1.0.0" + source-map-js "^1.0.2" prelude-ls@^1.2.1: version "1.2.1" @@ -8090,7 +8020,7 @@ slugify@1.6.6: resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b" integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw== -"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.2.1: +"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2, source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== @@ -8763,14 +8693,14 @@ use-debounce@^10.0.4: resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-10.1.1.tgz#b08b596b60a55fd4c18b44b37fdc02f058baf30a" 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== +use-intl@^4.9.1: + version "4.9.1" + resolved "https://registry.yarnpkg.com/use-intl/-/use-intl-4.9.1.tgz#230eea8bb62cfb2ab021ba9c65e546b8f92b07de" + integrity sha512-iGVV/xFYlhe3btafRlL8RPLD2Jsuet4yqn9DR6LWWbMhULsJnXgLonDkzDmsAIBIwFtk02oJuX/Ox2vwHKF+UQ== dependencies: "@formatjs/fast-memoize" "^3.1.0" "@schummar/icu-type-parser" "1.21.5" - icu-minify "^4.12.0" + icu-minify "^4.9.1" intl-messageformat "^11.1.0" use-sidecar@^1.1.3: @@ -8791,15 +8721,15 @@ 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: - 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@11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" + integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A== -"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== +uuid@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" + integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== uvu@^0.5.0: version "0.5.6" @@ -8895,41 +8825,6 @@ vitest@4.0.17: vite "^6.0.0 || ^7.0.0" why-is-node-running "^2.3.0" -vscode-jsonrpc@8.2.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz#f43dfa35fb51e763d17cd94dcca0c9458f35abf9" - integrity sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA== - -vscode-languageserver-protocol@3.17.5: - version "3.17.5" - resolved "https://registry.yarnpkg.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz#864a8b8f390835572f4e13bd9f8313d0e3ac4bea" - integrity sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg== - dependencies: - vscode-jsonrpc "8.2.0" - vscode-languageserver-types "3.17.5" - -vscode-languageserver-textdocument@~1.0.11: - version "1.0.12" - resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz#457ee04271ab38998a093c68c2342f53f6e4a631" - integrity sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA== - -vscode-languageserver-types@3.17.5: - version "3.17.5" - resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz#3273676f0cf2eab40b3f44d085acbb7f08a39d8a" - integrity sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg== - -vscode-languageserver@~9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz#500aef82097eb94df90d008678b0b6b5f474015b" - integrity sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g== - dependencies: - vscode-languageserver-protocol "3.17.5" - -vscode-uri@~3.0.8: - version "3.0.8" - resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f" - integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw== - w3c-keyname@^2.2.0: version "2.2.8" resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5" @@ -9069,10 +8964,10 @@ wrap-ansi@^8.1.0: string-width "^5.0.1" strip-ansi "^7.0.1" -ws@8.20.1: - version "8.20.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.1.tgz#91a9ae2b312ccf98e0a85ec499b48cef45ab0ddb" - integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w== +ws@8.19.0: + version "8.19.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.19.0.tgz#ddc2bdfa5b9ad860204f5a72a4863a8895fd8c8b" + integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg== y18n@^4.0.0: version "4.0.3" From 1cbfdf34880068053257350db5ac261dbb034da5 Mon Sep 17 00:00:00 2001 From: fccview Date: Wed, 27 May 2026 10:48:26 +0100 Subject: [PATCH 12/40] Add remember me toggle to sign in and try gix atomic json read on session object --- .../GlobalComponents/Auth/LoginForm.tsx | 90 +++++++++++++------ app/_server/actions/auth/index.ts | 34 +++---- app/_server/actions/file/index.ts | 13 +-- app/_server/actions/session/index.ts | 3 + app/_translations/de.json | 1 + app/_translations/en.json | 3 +- app/_translations/es.json | 3 +- app/_translations/fr.json | 3 +- app/_translations/it.json | 3 +- app/_translations/klingon.json | 3 +- app/_translations/ko.json | 3 +- app/_translations/nl.json | 3 +- app/_translations/pirate.json | 3 +- app/_translations/pl.json | 3 +- app/_translations/ru.json | 3 +- app/_translations/tr.json | 3 +- app/_translations/zh.json | 3 +- 17 files changed, 116 insertions(+), 61 deletions(-) diff --git a/app/_components/GlobalComponents/Auth/LoginForm.tsx b/app/_components/GlobalComponents/Auth/LoginForm.tsx index 392d60a8..625f4f7e 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/_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/file/index.ts b/app/_server/actions/file/index.ts index 9ecb9e10..2f95bb60 100644 --- a/app/_server/actions/file/index.ts +++ b/app/_server/actions/file/index.ts @@ -88,15 +88,16 @@ export const writeJsonFile = async ( data: any, filePath: string, ): Promise => { + const finalPath = path.join(process.cwd(), filePath); + const tmpPath = finalPath + ".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/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/_translations/de.json b/app/_translations/de.json index 672f1971..02d289d8 100644 --- a/app/_translations/de.json +++ b/app/_translations/de.json @@ -280,6 +280,7 @@ "version": "Version {version}", "createUser": "Benutzer erstellen", "notAuthorized": "Du bist nicht berechtigt, auf diese Anwendung zuzugreifen. Kontaktiere deinen Administrator.", + "rememberMe": "Remember me", "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." }, diff --git a/app/_translations/en.json b/app/_translations/en.json index 34a53983..726e41bf 100644 --- a/app/_translations/en.json +++ b/app/_translations/en.json @@ -306,7 +306,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", diff --git a/app/_translations/es.json b/app/_translations/es.json index 69bf1c9f..f04c7577 100644 --- a/app/_translations/es.json +++ b/app/_translations/es.json @@ -281,7 +281,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": "Remember me" }, "mfa": { "title": "Autenticación de dos factores", diff --git a/app/_translations/fr.json b/app/_translations/fr.json index 51a1b23c..cdaed4a7 100644 --- a/app/_translations/fr.json +++ b/app/_translations/fr.json @@ -281,7 +281,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": "Remember me" }, "mfa": { "title": "Authentification à deux facteurs", diff --git a/app/_translations/it.json b/app/_translations/it.json index d9addba4..668e5d31 100644 --- a/app/_translations/it.json +++ b/app/_translations/it.json @@ -281,7 +281,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": "Remember me" }, "mfa": { "title": "Autenticazione a Due Fattori", diff --git a/app/_translations/klingon.json b/app/_translations/klingon.json index b57442f7..125d334f 100644 --- a/app/_translations/klingon.json +++ b/app/_translations/klingon.json @@ -306,7 +306,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": "Remember me" }, "mfa": { "title": "cha'logh ngu'aS", diff --git a/app/_translations/ko.json b/app/_translations/ko.json index c339f3f4..cf16d8b2 100644 --- a/app/_translations/ko.json +++ b/app/_translations/ko.json @@ -306,7 +306,8 @@ "createUser": "사용자 생성", "attemptsRemaining": "경고: {count, plural, one {#회} other {#회}}의 시도가 남아 있습니다", "accountLocked": "실패 시도가 너무 많습니다. {seconds} {seconds, plural, one {초} other {초}} 후에 다시 시도하세요", - "notAuthorized": "접근 권한이 없습니다. 관리자에게 문의하세요." + "notAuthorized": "접근 권한이 없습니다. 관리자에게 문의하세요.", + "rememberMe": "Remember me" }, "mfa": { "title": "2단계 인증 (2FA)", diff --git a/app/_translations/nl.json b/app/_translations/nl.json index 5ac6d658..e0b7a24d 100644 --- a/app/_translations/nl.json +++ b/app/_translations/nl.json @@ -281,7 +281,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": "Remember me" }, "mfa": { "title": "Twee-factorauthenticatie", diff --git a/app/_translations/pirate.json b/app/_translations/pirate.json index 54dee792..dfba2231 100644 --- a/app/_translations/pirate.json +++ b/app/_translations/pirate.json @@ -306,7 +306,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" }, "mfa": { "title": "Two-Step Verification", diff --git a/app/_translations/pl.json b/app/_translations/pl.json index 46615875..727e485e 100644 --- a/app/_translations/pl.json +++ b/app/_translations/pl.json @@ -281,7 +281,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": "Remember me" }, "mfa": { "title": "Autoryzacja dwuskładnikowa", diff --git a/app/_translations/ru.json b/app/_translations/ru.json index 6f84f23d..0cf068ef 100644 --- a/app/_translations/ru.json +++ b/app/_translations/ru.json @@ -306,7 +306,8 @@ "createUser": "Создать пользователя", "attemptsRemaining": "Предупреждение: осталось {count, plural, one {# попытка} few {# попытки} many {# попыток} other {# попыток}} до блокировки аккаунта", "accountLocked": "Слишком много неудачных попыток. Пожалуйста, попробуйте снова через {seconds} {seconds, plural, one {секунду} few {секунды} many {секунд} other {секунд}}", - "notAuthorized": "Вы не авторизованы для доступа к этому приложению. Пожалуйста, свяжитесь с администратором." + "notAuthorized": "Вы не авторизованы для доступа к этому приложению. Пожалуйста, свяжитесь с администратором.", + "rememberMe": "Remember me" }, "mfa": { "title": "Двухфакторная аутентификация", diff --git a/app/_translations/tr.json b/app/_translations/tr.json index 1f8a47db..f3960dc8 100644 --- a/app/_translations/tr.json +++ b/app/_translations/tr.json @@ -306,7 +306,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": "Remember me" }, "mfa": { "title": "İki Faktörlü Doğrulama", diff --git a/app/_translations/zh.json b/app/_translations/zh.json index e7c6165e..52d1ee2b 100644 --- a/app/_translations/zh.json +++ b/app/_translations/zh.json @@ -306,7 +306,8 @@ "createUser": "创建用户", "attemptsRemaining": "警告:在账户锁定前还剩 {count, plural, one {# 次尝试} other {# 次尝试}}", "accountLocked": "失败次数过多。请在 {seconds} {seconds, plural, one {秒} other {秒}} 后重试", - "notAuthorized": "您没有权限访问此应用程序。请联系您的管理员。" + "notAuthorized": "您没有权限访问此应用程序。请联系您的管理员。", + "rememberMe": "Remember me" }, "mfa": { "title": "双重认证 (2FA)", From 426685af22b699a1ae74bec2629f4c585f9b3b56 Mon Sep 17 00:00:00 2001 From: fccview Date: Wed, 27 May 2026 11:34:09 +0100 Subject: [PATCH 13/40] fix tests --- tests/server-actions/auth.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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) ) }) From 99dcff582dbedf3f6f137727cc3125cd26eb858d Mon Sep 17 00:00:00 2001 From: Nikolai Andree Date: Wed, 27 May 2026 15:40:52 +0200 Subject: [PATCH 14/40] Add status field to card detail modal --- .../Kanban/KanbanCardDetail.tsx | 29 ++++++++++++++- .../Kanban/KanbanCardDetailProperties.tsx | 36 ++++++++++++++++++- app/_translations/de.json | 1 + app/_translations/en.json | 1 + 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx index b7c10cf0..807706ce 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); @@ -88,6 +92,7 @@ export const KanbanCardDetail = ({ const [reminderInput, setReminderInput] = useState(initialItem.reminder?.datetime || ""); const [targetDateInput, setTargetDateInput] = useState(initialItem.targetDate || ""); 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 }[]>([]); @@ -101,9 +106,10 @@ export const KanbanCardDetail = ({ setReminderInput(initialItem.reminder?.datetime || ""); setTargetDateInput(initialItem.targetDate || ""); 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 +305,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; @@ -439,6 +463,8 @@ export const KanbanCardDetail = ({
diff --git a/app/_components/FeatureComponents/Kanban/KanbanCardDetailProperties.tsx b/app/_components/FeatureComponents/Kanban/KanbanCardDetailProperties.tsx index 5ccbd2a2..0da0a244 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanCardDetailProperties.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanCardDetailProperties.tsx @@ -3,7 +3,7 @@ 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, @@ -15,6 +15,8 @@ import { cn } from "@/app/_utils/global-utils"; interface KanbanCardDetailPropertiesProps { item: Item; + statuses: KanbanStatus[]; + statusInput: string; priorityInput: KanbanPriority; scoreInput: string; assigneeInput: string; @@ -26,6 +28,7 @@ interface KanbanCardDetailPropertiesProps { isShared: boolean; toLocalDateTimeValue: (iso: string) => string; toLocalDateValue: (iso: string) => string; + onStatusChange: (status: string) => void; onPriorityChange: (p: KanbanPriority) => void; onScoreChange: (v: string) => void; onScoreSave: () => void; @@ -40,6 +43,8 @@ interface KanbanCardDetailPropertiesProps { export const KanbanCardDetailProperties = ({ item, + statuses, + statusInput, priorityInput, scoreInput, assigneeInput, @@ -51,6 +56,7 @@ export const KanbanCardDetailProperties = ({ isShared, toLocalDateTimeValue, toLocalDateValue, + onStatusChange, onPriorityChange, onScoreChange, onScoreSave, @@ -72,6 +78,8 @@ export const KanbanCardDetailProperties = ({ KanbanPriorityLevel.NONE, ]; + const sortedStatuses = [...statuses].sort((a, b) => a.order - b.order); + const assigneeOptions = [ { id: "", @@ -122,6 +130,32 @@ export const KanbanCardDetailProperties = ({
{canEdit && ( <> +
+ +
+ {sortedStatuses.map((status) => ( + + ))} +
+
+
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/_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/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/_translations/de.json b/app/_translations/de.json index f736b298..14f5d64c 100644 --- a/app/_translations/de.json +++ b/app/_translations/de.json @@ -838,6 +838,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 d9f6ced8..f7e77bc1 100644 --- a/app/_translations/en.json +++ b/app/_translations/en.json @@ -864,6 +864,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/_types/index.ts b/app/_types/index.ts index 6d6922c7..6cd31aff 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"; 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; From a6b9feb08608534a3f275fb05774bc64063f0d7c Mon Sep 17 00:00:00 2001 From: Nikolai Andree Date: Wed, 27 May 2026 16:19:05 +0200 Subject: [PATCH 17/40] Add mobile status action sheet --- .../FeatureComponents/Kanban/KanbanCard.tsx | 42 +++++++++++++++++++ .../Kanban/KanbanItemContent.tsx | 14 +++++++ .../GlobalComponents/Dropdowns/Dropdown.tsx | 4 +- app/_translations/de.json | 1 + app/_translations/en.json | 1 + app/_translations/es.json | 8 ++++ app/_translations/fr.json | 8 ++++ app/_translations/it.json | 8 ++++ app/_translations/klingon.json | 8 ++++ app/_translations/ko.json | 8 ++++ app/_translations/nl.json | 8 ++++ app/_translations/pirate.json | 8 ++++ app/_translations/pl.json | 8 ++++ app/_translations/ru.json | 8 ++++ app/_translations/tr.json | 8 ++++ app/_translations/zh.json | 8 ++++ 16 files changed, 149 insertions(+), 1 deletion(-) diff --git a/app/_components/FeatureComponents/Kanban/KanbanCard.tsx b/app/_components/FeatureComponents/Kanban/KanbanCard.tsx index 38d4cdba..af73eefe 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanCard.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanCard.tsx @@ -5,6 +5,7 @@ import { CSS } from "@dnd-kit/utilities"; import { Item, Checklist, KanbanStatus } from "@/app/_types"; import { cn } from "@/app/_utils/global-utils"; import { Dropdown } from "@/app/_components/GlobalComponents/Dropdowns/Dropdown"; +import { Modal } from "@/app/_components/GlobalComponents/Modals/Modal"; import { useState, useEffect, memo, useMemo, useCallback } from "react"; import { TaskStatus } from "@/app/_types/enums"; import { KanbanCardDetail } from "./KanbanCardDetail"; @@ -72,6 +73,7 @@ const KanbanCardComponent = ({ const [showDetailModal, setShowDetailModal] = useState(false); const [showTimeEntriesModal, setShowTimeEntriesModal] = useState(false); + const [showStatusSheet, setShowStatusSheet] = useState(false); const hideMobileStatusDropdown = user?.hideMobileStatusDropdown === "enable"; const kanbanItemHook = useKanbanItem({ @@ -116,8 +118,47 @@ 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")} + className="lg:hidden" + > +
+ {statusOptions.map((status) => { + const isCurrent = status.id === (item.status || TaskStatus.TODO); + return ( + + ); + })} +
+
+ )} + {showTimeEntriesModal && item.timeEntries && ( setShowDetailModal(true)} + onShowStatusMenu={() => setShowStatusSheet(true)} onEdit={kanbanItemHook.handleEdit} onDelete={kanbanItemHook.handleDelete} onArchive={kanbanItemHook.handleArchive} diff --git a/app/_components/FeatureComponents/Kanban/KanbanItemContent.tsx b/app/_components/FeatureComponents/Kanban/KanbanItemContent.tsx index 3d6e2c25..2003b030 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,15 @@ const KanbanItemContentComponent = ({ value="" options={[ { id: "view", name: t("tasks.viewTask") }, + ...(permissions?.canEdit + ? [ + { + id: "status", + name: t("kanban.changeStatus"), + className: "lg:hidden", + }, + ] + : []), ...(permissions?.canEdit ? [{ id: "add", name: t("tasks.addSubtask") }] : []), @@ -128,6 +139,9 @@ const KanbanItemContentComponent = ({ case "view": onShowSubtaskModal(); break; + case "status": + onShowStatusMenu(); + break; case "add": onShowSubtaskModal(); break; 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/_translations/de.json b/app/_translations/de.json index 14f5d64c..650ac6d8 100644 --- a/app/_translations/de.json +++ b/app/_translations/de.json @@ -574,6 +574,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.", diff --git a/app/_translations/en.json b/app/_translations/en.json index f7e77bc1..cd3ff478 100644 --- a/app/_translations/en.json +++ b/app/_translations/en.json @@ -599,6 +599,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.", diff --git a/app/_translations/es.json b/app/_translations/es.json index b557f4a0..8928fdda 100644 --- a/app/_translations/es.json +++ b/app/_translations/es.json @@ -543,6 +543,8 @@ "autoComplete": "Autocompletar" }, "kanban": { + "properties": "Propiedades", + "status": "Estado", "priority": "Prioridad", "score": "Puntuación", "assignee": "Asignado a", @@ -572,6 +574,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.", @@ -836,6 +839,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..30d189a3 100644 --- a/app/_translations/fr.json +++ b/app/_translations/fr.json @@ -543,6 +543,8 @@ "autoComplete": "Complétion automatique" }, "kanban": { + "properties": "Propriétés", + "status": "Statut", "priority": "Priorité", "score": "Score", "assignee": "Assigné à", @@ -572,6 +574,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.", @@ -836,6 +839,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..5a0d51d4 100644 --- a/app/_translations/it.json +++ b/app/_translations/it.json @@ -543,6 +543,8 @@ "autoComplete": "Complete automaticamente" }, "kanban": { + "properties": "Proprietà", + "status": "Stato", "priority": "Priorità", "score": "Punteggio", "assignee": "Assegnato a", @@ -572,6 +574,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.", @@ -836,6 +839,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..27bf7acb 100644 --- a/app/_translations/klingon.json +++ b/app/_translations/klingon.json @@ -568,6 +568,8 @@ "autoComplete": "nIteb rIn" }, "kanban": { + "properties": "DI'onmey", + "status": "ghu'", "priority": "nom mIw", "score": "pong", "assignee": "ghu' ghoS", @@ -597,6 +599,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'.", @@ -861,6 +864,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..06ce6fee 100644 --- a/app/_translations/ko.json +++ b/app/_translations/ko.json @@ -568,6 +568,8 @@ "autoComplete": "자동 완료" }, "kanban": { + "properties": "속성", + "status": "상태", "priority": "우선순위", "score": "점수", "assignee": "담당자", @@ -597,6 +599,7 @@ "itemTitle": "항목 제목", "manageStatuses": "상태 관리", "viewArchived": "보관된 항목 보기", + "changeStatus": "상태 변경", "noBoards": "보드를 찾을 수 없습니다", "noBoardsYet": "아직 칸반 보드가 없습니다", "createFirstBoard": "프로젝트 관리를 위해 첫 번째 칸반 보드를 만드세요.", @@ -861,6 +864,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..f952d9d7 100644 --- a/app/_translations/nl.json +++ b/app/_translations/nl.json @@ -543,6 +543,8 @@ "autoComplete": "Automatisch voltooien" }, "kanban": { + "properties": "Eigenschappen", + "status": "Status", "priority": "Prioriteit", "score": "Score", "assignee": "Toegewezen aan", @@ -572,6 +574,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.", @@ -836,6 +839,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..78f16590 100644 --- a/app/_translations/pirate.json +++ b/app/_translations/pirate.json @@ -568,6 +568,8 @@ "autoComplete": "Auto finish" }, "kanban": { + "properties": "Particulars", + "status": "Condition", "priority": "What matters most", "score": "Booty score", "assignee": "Crew hand", @@ -597,6 +599,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.", @@ -861,6 +864,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..89335362 100644 --- a/app/_translations/pl.json +++ b/app/_translations/pl.json @@ -543,6 +543,8 @@ "autoComplete": "Automatyczne uzupełnienie" }, "kanban": { + "properties": "Właściwości", + "status": "Status", "priority": "Priorytet", "score": "Punkty", "assignee": "Przypisany do", @@ -572,6 +574,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.", @@ -836,6 +839,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/ru.json b/app/_translations/ru.json index a6630dbf..2836cc08 100644 --- a/app/_translations/ru.json +++ b/app/_translations/ru.json @@ -568,6 +568,8 @@ "autoComplete": "Автозавершение" }, "kanban": { + "properties": "Свойства", + "status": "Статус", "priority": "Приоритет", "score": "Оценка", "assignee": "Исполнитель", @@ -597,6 +599,7 @@ "itemTitle": "Название элемента", "manageStatuses": "Управление статусами", "viewArchived": "Просмотр архивных", + "changeStatus": "Изменить статус", "noBoards": "Доски не найдены", "noBoardsYet": "Пока нет досок Канбан", "createFirstBoard": "Создайте первую доску Канбан для управления проектами.", @@ -861,6 +864,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..c6afedae 100644 --- a/app/_translations/tr.json +++ b/app/_translations/tr.json @@ -568,6 +568,8 @@ "autoComplete": "Otomatik tamamla" }, "kanban": { + "properties": "Özellikler", + "status": "Durum", "priority": "Öncelik", "score": "Puan", "assignee": "Atanan", @@ -597,6 +599,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.", @@ -865,6 +868,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..d112db20 100644 --- a/app/_translations/zh.json +++ b/app/_translations/zh.json @@ -568,6 +568,8 @@ "autoComplete": "自动完成" }, "kanban": { + "properties": "属性", + "status": "状态", "priority": "优先级", "score": "分数", "assignee": "负责人", @@ -597,6 +599,7 @@ "itemTitle": "项目标题", "manageStatuses": "管理状态", "viewArchived": "查看已归档", + "changeStatus": "更改状态", "noBoards": "未找到看板", "noBoardsYet": "暂无看板", "createFirstBoard": "创建您的第一个看板以开始管理项目。", @@ -861,6 +864,11 @@ "showStatusOnCards": "显示状态", "hideStatusOnCards": "隐藏状态", "hideStatusOnCardsDescription": "隐藏看板卡片上的状态标签。状态已通过卡片所在的列显示。", + "hideMobileStatusDropdownLabel": "卡片上的移动端状态下拉菜单", + "showMobileStatusDropdown": "显示移动端下拉菜单", + "hideMobileStatusDropdown": "隐藏移动端下拉菜单", + "selectMobileStatusDropdown": "选择移动端状态下拉菜单行为", + "hideMobileStatusDropdownDescription": "控制移动端看板卡片上始终可见的状态下拉菜单。状态标签设置保持独立。", "selectStatusOnCards": "选择状态可见性", "codeBlockChoice": "选择代码块样式", "themedCodeBlock": "主题代码块", From fdbfeac9ec90fc51a6378bb31a235d4959beaa73 Mon Sep 17 00:00:00 2001 From: Nikolai Andree Date: Wed, 27 May 2026 16:47:53 +0200 Subject: [PATCH 18/40] Refine card detail status interactions --- .../FeatureComponents/Kanban/Kanban.tsx | 32 ++++++++++- .../FeatureComponents/Kanban/KanbanCard.tsx | 36 ++++++------ .../Kanban/KanbanCardDetailProperties.tsx | 55 ++++++------------- .../FeatureComponents/Kanban/KanbanColumn.tsx | 3 + app/_translations/de.json | 1 - app/_translations/en.json | 1 - app/_translations/es.json | 1 - app/_translations/fr.json | 1 - app/_translations/it.json | 1 - app/_translations/klingon.json | 1 - app/_translations/ko.json | 1 - app/_translations/nl.json | 1 - app/_translations/pirate.json | 1 - app/_translations/pl.json | 1 - app/_translations/ru.json | 1 - app/_translations/tr.json | 1 - app/_translations/zh.json | 1 - 17 files changed, 71 insertions(+), 68 deletions(-) diff --git a/app/_components/FeatureComponents/Kanban/Kanban.tsx b/app/_components/FeatureComponents/Kanban/Kanban.tsx index d341f904..b930a853 100644 --- a/app/_components/FeatureComponents/Kanban/Kanban.tsx +++ b/app/_components/FeatureComponents/Kanban/Kanban.tsx @@ -14,7 +14,7 @@ import { CollisionDetection, rectIntersection, } from "@dnd-kit/core"; -import { Checklist, KanbanStatus } from "@/app/_types"; +import { Checklist, Item, KanbanStatus } from "@/app/_types"; import { KanbanColumn } from "./KanbanColumn"; import { KanbanCard } from "./KanbanCard"; import { ChecklistHeading } from "../Checklists/Parts/Common/ChecklistHeading"; @@ -48,6 +48,17 @@ interface KanbanBoardProps { 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); @@ -57,6 +68,7 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { 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); @@ -134,6 +146,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(); @@ -216,6 +232,7 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { 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} @@ -415,6 +432,7 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { checklistId={localChecklist.id} category={localChecklist.category || "Uncategorized"} onUpdate={refreshChecklist} + onOpenDetail={() => {}} isShared={isShared} statuses={statuses} /> @@ -445,6 +463,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; @@ -49,6 +49,7 @@ const KanbanCardComponent = ({ checklistId, category, onUpdate, + onOpenDetail, isShared, statuses, statusColor, @@ -71,7 +72,6 @@ const KanbanCardComponent = ({ [usersPublicData], ); - const [showDetailModal, setShowDetailModal] = useState(false); const [showTimeEntriesModal, setShowTimeEntriesModal] = useState(false); const [showStatusSheet, setShowStatusSheet] = useState(false); const hideMobileStatusDropdown = user?.hideMobileStatusDropdown === "enable"; @@ -123,6 +123,20 @@ const KanbanCardComponent = ({ setShowStatusSheet(false); }; + const handleOpenStatusSheet = () => { + if (typeof window !== "undefined" && window.innerWidth >= 1024) return; + setShowStatusSheet(true); + }; + + useEffect(() => { + if (!showStatusSheet) return; + const handleResize = () => { + if (window.innerWidth >= 1024) setShowStatusSheet(false); + }; + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [showStatusSheet]); + return ( <> {showStatusSheet && ( @@ -172,18 +186,6 @@ const KanbanCardComponent = ({ /> )} - {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), @@ -212,8 +214,8 @@ const KanbanCardComponent = ({ onEditTextChange={kanbanItemHook.setEditText} onEditSave={kanbanItemHook.handleSave} onEditKeyDown={kanbanItemHook.handleKeyDown} - onShowSubtaskModal={() => setShowDetailModal(true)} - onShowStatusMenu={() => setShowStatusSheet(true)} + onShowSubtaskModal={() => onOpenDetail(item)} + onShowStatusMenu={handleOpenStatusSheet} onEdit={kanbanItemHook.handleEdit} onDelete={kanbanItemHook.handleDelete} onArchive={kanbanItemHook.handleArchive} diff --git a/app/_components/FeatureComponents/Kanban/KanbanCardDetailProperties.tsx b/app/_components/FeatureComponents/Kanban/KanbanCardDetailProperties.tsx index 1ea29e1e..4a3cfc9c 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanCardDetailProperties.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanCardDetailProperties.tsx @@ -162,15 +162,13 @@ export const KanbanCardDetailProperties = ({ return (
{canEdit && ( - -
- + <> +
{sortedStatuses.map((status) => ( ))}
-
+
-
- +
{priorities.map((p) => ( ))}
-
+ -
- + -
+ {isShared && ( -
- + -
+ )} -
- + -
+ -
- + -
+ -
- + )} -
- + + )} {metadata.length > 0 && ( diff --git a/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx b/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx index a7da7dd1..d97dfd0f 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx @@ -23,6 +23,7 @@ interface KanbanColumnProps { checklistId: string; category: string; onUpdate: (updatedChecklist: Checklist) => void; + onOpenDetail: (item: Item) => void; isShared: boolean; statusColor?: string; statuses: KanbanStatus[]; @@ -85,6 +86,7 @@ const KanbanColumnComponent = ({ category, isShared, onUpdate, + onOpenDetail, statusColor, statuses, onAddItem, @@ -198,6 +200,7 @@ const KanbanColumnComponent = ({ checklistId={checklistId} category={category} onUpdate={onUpdate} + onOpenDetail={onOpenDetail} isShared={isShared} statuses={statuses} statusColor={statusColor} diff --git a/app/_translations/de.json b/app/_translations/de.json index 650ac6d8..bce49709 100644 --- a/app/_translations/de.json +++ b/app/_translations/de.json @@ -543,7 +543,6 @@ "autoComplete": "Automatisch abschließen" }, "kanban": { - "properties": "Eigenschaften", "status": "Status", "priority": "Priorität", "score": "Punktzahl", diff --git a/app/_translations/en.json b/app/_translations/en.json index cd3ff478..d4437cc9 100644 --- a/app/_translations/en.json +++ b/app/_translations/en.json @@ -568,7 +568,6 @@ "autoComplete": "Auto complete" }, "kanban": { - "properties": "Properties", "status": "Status", "priority": "Priority", "score": "Score", diff --git a/app/_translations/es.json b/app/_translations/es.json index 8928fdda..7f2d477d 100644 --- a/app/_translations/es.json +++ b/app/_translations/es.json @@ -543,7 +543,6 @@ "autoComplete": "Autocompletar" }, "kanban": { - "properties": "Propiedades", "status": "Estado", "priority": "Prioridad", "score": "Puntuación", diff --git a/app/_translations/fr.json b/app/_translations/fr.json index 30d189a3..2cfdbe46 100644 --- a/app/_translations/fr.json +++ b/app/_translations/fr.json @@ -543,7 +543,6 @@ "autoComplete": "Complétion automatique" }, "kanban": { - "properties": "Propriétés", "status": "Statut", "priority": "Priorité", "score": "Score", diff --git a/app/_translations/it.json b/app/_translations/it.json index 5a0d51d4..a0af4858 100644 --- a/app/_translations/it.json +++ b/app/_translations/it.json @@ -543,7 +543,6 @@ "autoComplete": "Complete automaticamente" }, "kanban": { - "properties": "Proprietà", "status": "Stato", "priority": "Priorità", "score": "Punteggio", diff --git a/app/_translations/klingon.json b/app/_translations/klingon.json index 27bf7acb..bfd1474c 100644 --- a/app/_translations/klingon.json +++ b/app/_translations/klingon.json @@ -568,7 +568,6 @@ "autoComplete": "nIteb rIn" }, "kanban": { - "properties": "DI'onmey", "status": "ghu'", "priority": "nom mIw", "score": "pong", diff --git a/app/_translations/ko.json b/app/_translations/ko.json index 06ce6fee..21a2430c 100644 --- a/app/_translations/ko.json +++ b/app/_translations/ko.json @@ -568,7 +568,6 @@ "autoComplete": "자동 완료" }, "kanban": { - "properties": "속성", "status": "상태", "priority": "우선순위", "score": "점수", diff --git a/app/_translations/nl.json b/app/_translations/nl.json index f952d9d7..40fa952d 100644 --- a/app/_translations/nl.json +++ b/app/_translations/nl.json @@ -543,7 +543,6 @@ "autoComplete": "Automatisch voltooien" }, "kanban": { - "properties": "Eigenschappen", "status": "Status", "priority": "Prioriteit", "score": "Score", diff --git a/app/_translations/pirate.json b/app/_translations/pirate.json index 78f16590..a88f8fdf 100644 --- a/app/_translations/pirate.json +++ b/app/_translations/pirate.json @@ -568,7 +568,6 @@ "autoComplete": "Auto finish" }, "kanban": { - "properties": "Particulars", "status": "Condition", "priority": "What matters most", "score": "Booty score", diff --git a/app/_translations/pl.json b/app/_translations/pl.json index 89335362..f7a732dc 100644 --- a/app/_translations/pl.json +++ b/app/_translations/pl.json @@ -543,7 +543,6 @@ "autoComplete": "Automatyczne uzupełnienie" }, "kanban": { - "properties": "Właściwości", "status": "Status", "priority": "Priorytet", "score": "Punkty", diff --git a/app/_translations/ru.json b/app/_translations/ru.json index 2836cc08..781b0d14 100644 --- a/app/_translations/ru.json +++ b/app/_translations/ru.json @@ -568,7 +568,6 @@ "autoComplete": "Автозавершение" }, "kanban": { - "properties": "Свойства", "status": "Статус", "priority": "Приоритет", "score": "Оценка", diff --git a/app/_translations/tr.json b/app/_translations/tr.json index c6afedae..9d2f388a 100644 --- a/app/_translations/tr.json +++ b/app/_translations/tr.json @@ -568,7 +568,6 @@ "autoComplete": "Otomatik tamamla" }, "kanban": { - "properties": "Özellikler", "status": "Durum", "priority": "Öncelik", "score": "Puan", diff --git a/app/_translations/zh.json b/app/_translations/zh.json index d112db20..e20e1d1d 100644 --- a/app/_translations/zh.json +++ b/app/_translations/zh.json @@ -568,7 +568,6 @@ "autoComplete": "自动完成" }, "kanban": { - "properties": "属性", "status": "状态", "priority": "优先级", "score": "分数", From 0057d39f0ebb88df518d0599d29135bcdca97c70 Mon Sep 17 00:00:00 2001 From: Nikolai Andree Date: Wed, 27 May 2026 16:55:54 +0200 Subject: [PATCH 19/40] Show card status action on all viewports --- .../FeatureComponents/Kanban/KanbanCard.tsx | 17 +---------------- .../Kanban/KanbanItemContent.tsx | 1 - 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/app/_components/FeatureComponents/Kanban/KanbanCard.tsx b/app/_components/FeatureComponents/Kanban/KanbanCard.tsx index 765cf6cc..03179cee 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanCard.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanCard.tsx @@ -123,20 +123,6 @@ const KanbanCardComponent = ({ setShowStatusSheet(false); }; - const handleOpenStatusSheet = () => { - if (typeof window !== "undefined" && window.innerWidth >= 1024) return; - setShowStatusSheet(true); - }; - - useEffect(() => { - if (!showStatusSheet) return; - const handleResize = () => { - if (window.innerWidth >= 1024) setShowStatusSheet(false); - }; - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, [showStatusSheet]); - return ( <> {showStatusSheet && ( @@ -144,7 +130,6 @@ const KanbanCardComponent = ({ isOpen={showStatusSheet} onClose={() => setShowStatusSheet(false)} title={t("kanban.changeStatus")} - className="lg:hidden" >
{statusOptions.map((status) => { @@ -215,7 +200,7 @@ const KanbanCardComponent = ({ onEditSave={kanbanItemHook.handleSave} onEditKeyDown={kanbanItemHook.handleKeyDown} onShowSubtaskModal={() => onOpenDetail(item)} - onShowStatusMenu={handleOpenStatusSheet} + onShowStatusMenu={() => setShowStatusSheet(true)} onEdit={kanbanItemHook.handleEdit} onDelete={kanbanItemHook.handleDelete} onArchive={kanbanItemHook.handleArchive} diff --git a/app/_components/FeatureComponents/Kanban/KanbanItemContent.tsx b/app/_components/FeatureComponents/Kanban/KanbanItemContent.tsx index 2003b030..fddbcdbf 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanItemContent.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanItemContent.tsx @@ -117,7 +117,6 @@ const KanbanItemContentComponent = ({ { id: "status", name: t("kanban.changeStatus"), - className: "lg:hidden", }, ] : []), From 2dbabf3b06116c97830807e94d4d74a3b60c7ba3 Mon Sep 17 00:00:00 2001 From: ajam13 Date: Thu, 28 May 2026 00:01:02 +0100 Subject: [PATCH 20/40] Added portuguese translation file --- app/_translations/pt.json | 1635 +++++++++++++++++++++++++++++++++++++ 1 file changed, 1635 insertions(+) create mode 100644 app/_translations/pt.json diff --git a/app/_translations/pt.json b/app/_translations/pt.json new file mode 100644 index 00000000..d80c1470 --- /dev/null +++ b/app/_translations/pt.json @@ -0,0 +1,1635 @@ +{ + "common": { + "save": "Guardar", + "cancel": "Cancelar", + "delete": "Eliminar", + "edit": "Editar", + "view": "Ver", + "new": "Novo", + "close": "Fechar", + "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." + }, + "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": { + "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" + }, + "settings": { + "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)" + } +} From df144d569d4beee9bf10f5970d9aacdaf7db2b62 Mon Sep 17 00:00:00 2001 From: Nikolai Andree Date: Fri, 29 May 2026 16:37:44 +0200 Subject: [PATCH 21/40] fix: assign unique nested item IDs in client parser (#501) The client-side parseChecklistContent generated fallback item IDs as `${id}-${currentItemIndex}` and reset currentItemIndex to 0 in each recursive call for nested children. Group headers and their nested items therefore shared the same numeric range, producing duplicate IDs across siblings and groups. Clicking one item toggled every other item with the same ID. The server-side parser (parseMarkdown in checklist-utils.ts) already avoided this by using a monotonic counter combined with level (`${id}-${level}-${counter}`). Align the client parser with the same scheme so a file dropped into the importer is assigned the same IDs that the server would produce. Also add a post-parse dedup pass so already-imported files with broken duplicate IDs heal themselves on the next save: the first occurrence keeps its ID, every later collision is reassigned a fresh unique ID. Stored metadata IDs are still honoured. Tests cover a 4-group, 5-children-each fresh parse (all IDs unique), preservation of stored item-metadata IDs, and dedup of pre-broken imported content. --- app/_utils/client-parser-utils.ts | 23 ++++++++- tests/utils/client-parser-utils.test.ts | 62 +++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 tests/utils/client-parser-utils.test.ts diff --git a/app/_utils/client-parser-utils.ts b/app/_utils/client-parser-utils.ts index 996468f8..d27becd4 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, @@ -164,7 +168,7 @@ export const parseChecklistContent = ( }); item = { - id: (itemMetadata.id as string) || `${id}-${currentItemIndex}`, + id: (itemMetadata.id as string) || generateItemId(parentLevel), text: itemText, completed, order: currentItemIndex, @@ -206,7 +210,7 @@ export const parseChecklistContent = ( } item = { - id: itemMetadata.id || `${id}-${currentItemIndex}`, + id: itemMetadata.id || generateItemId(parentLevel), text: itemText, completed, order: currentItemIndex, @@ -246,6 +250,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/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"); + }); +}); From cb7a272aad084a61c4488c818d860e7a151cb6eb Mon Sep 17 00:00:00 2001 From: fccview Date: Fri, 29 May 2026 18:56:35 +0100 Subject: [PATCH 22/40] fix warning on leave and autosave exalidraw --- .../TipTap/CustomExtensions/ExcalidrawExtension.tsx | 10 +++++++++- app/_hooks/useNoteEditor.tsx | 12 ++++++++++-- app/_providers/NavigationGuardProvider.tsx | 12 ++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/ExcalidrawExtension.tsx b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/ExcalidrawExtension.tsx index 2a9ae485..102c7b57 100644 --- a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/ExcalidrawExtension.tsx +++ b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/ExcalidrawExtension.tsx @@ -106,6 +106,14 @@ export const ExcalidrawNodeView = ({ } }; + const handleClose = () => { + if (excalidrawAPI) { + handleSave(); + } else { + setIsEditing(false); + } + }; + const handleDelete = () => { setShowDeleteModal(true); }; @@ -129,7 +137,7 @@ export const ExcalidrawNodeView = ({ setIsEditing(false)} + onClose={handleClose} title={
{t("editor.editDiagram")} 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..80e70b7e 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,17 @@ export const NavigationGuardProvider = ({ return navigationGuard ? !navigationGuard() : false; }, [navigationGuard]); + useEffect(() => { + const onBeforeUnload = (e: BeforeUnloadEvent) => { + if (hasUnsavedChanges) { + e.preventDefault(); + } + }; + + window.addEventListener("beforeunload", onBeforeUnload); + return () => window.removeEventListener("beforeunload", onBeforeUnload); + }, [hasUnsavedChanges]); + const executePendingNavigation = useCallback(() => { if (pendingNavigation) { pendingNavigation(); From d18a7836e4562deb159bcab1a6cbcd52eae5a3cf Mon Sep 17 00:00:00 2001 From: fccview Date: Fri, 29 May 2026 19:04:40 +0100 Subject: [PATCH 23/40] fix sankey --- .../FeatureComponents/Notes/Parts/MermaidRenderer.tsx | 5 ++++- .../Notes/Parts/TipTap/CustomExtensions/MermaidExtension.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) 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/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; } From 8532a9dde0c8d52fcbe57644f9a3d4361ba3df08 Mon Sep 17 00:00:00 2001 From: fccview Date: Fri, 29 May 2026 19:26:57 +0100 Subject: [PATCH 24/40] fix bidirectional dropdown bug --- .../TipTap/CustomExtensions/SlashCommands.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) 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; From 8965991d6254f4868e619f50fdab6d923e8d385d Mon Sep 17 00:00:00 2001 From: fccview Date: Fri, 29 May 2026 20:09:02 +0100 Subject: [PATCH 25/40] add new api endpoints --- .../[listId]/items/[itemIndex]/route.ts | 70 ++++++++++ .../[listId]/items/reorder/route.ts | 112 ++++++++++++++++ app/api/checklists/route.ts | 6 +- app/api/docs/route.ts | 1 + app/api/search/route.ts | 80 ++++++++++++ public/api/paths/checklists.yaml | 120 +++++++++++++++++- public/api/paths/search.yaml | 69 ++++++++++ 7 files changed, 456 insertions(+), 2 deletions(-) create mode 100644 app/api/checklists/[listId]/items/reorder/route.ts create mode 100644 app/api/search/route.ts create mode 100644 public/api/paths/search.yaml diff --git a/app/api/checklists/[listId]/items/[itemIndex]/route.ts b/app/api/checklists/[listId]/items/[itemIndex]/route.ts index 984a0b8f..cab50548 100644 --- a/app/api/checklists/[listId]/items/[itemIndex]/route.ts +++ b/app/api/checklists/[listId]/items/[itemIndex]/route.ts @@ -1,6 +1,7 @@ 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"; @@ -8,6 +9,75 @@ import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists"; export const dynamic = "force-dynamic"; +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 } = body; + + if (!text && description === undefined) { + return NextResponse.json( + { error: "Provide at least 'text' or 'description' to update" }, + { 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) formData.append("text", text); + if (description !== undefined) formData.append("description", description ?? ""); + + 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..4feeebb5 --- /dev/null +++ b/app/api/checklists/[listId]/items/reorder/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from "next/server"; +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 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 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 }); + } + + 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)); + + 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..f542a5a4 100644 --- a/app/api/checklists/route.ts +++ b/app/api/checklists/route.ts @@ -24,7 +24,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); @@ -78,6 +80,8 @@ export async function GET(request: NextRequest) { 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), ), 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/public/api/paths/checklists.yaml b/public/api/paths/checklists.yaml index af966249..8ccf209b 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,6 +198,117 @@ '400': description: Invalid request +/checklists/{listId}/items/{itemIndex}: + patch: + summary: Update Checklist Item + description: | + Updates the text or description of 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" + 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 + +/checklists/{listId}/items/reorder: + put: + summary: Reorder Checklist Items + description: | + 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 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 From c96d4ae9ea76bda40101bbd906ae9565302ad1d1 Mon Sep 17 00:00:00 2001 From: fccview Date: Fri, 29 May 2026 20:27:50 +0100 Subject: [PATCH 26/40] fix scrollable modal for tasks --- app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx index 807706ce..685c4d77 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx @@ -353,7 +353,7 @@ export const KanbanCardDetail = ({ 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" > -
+
{isEditing ? (
From 3c999880cdcb7e172b2b66860f5b14b0faf7b3e3 Mon Sep 17 00:00:00 2001 From: David McArthur Date: Sun, 7 Jun 2026 01:31:46 +0100 Subject: [PATCH 27/40] Add proxyClientMaxBodySize to next.config.mjs to fix bodySizeLimit bug --- next.config.mjs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/next.config.mjs b/next.config.mjs index 5b039ebf..0779a00b 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -3,17 +3,19 @@ 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, }, images: { unoptimized: true, From a200ed2f2e13c698b4b878481775a1f8cf963c0e Mon Sep 17 00:00:00 2001 From: fccview Date: Thu, 11 Jun 2026 08:18:11 +0100 Subject: [PATCH 28/40] remove body size limit patch as not needed anymore --- patches/body-size-limit_20260427.js | 96 ----------------------------- 1 file changed, 96 deletions(-) delete mode 100644 patches/body-size-limit_20260427.js 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}`; - }, -}; From 47fec09fb5f4766f86457fdd723573e5bc452bb8 Mon Sep 17 00:00:00 2001 From: fccview Date: Fri, 12 Jun 2026 19:53:02 +0100 Subject: [PATCH 29/40] Start refactoring for kanban and dnd in general --- .../FeatureComponents/Kanban/Kanban.tsx | 96 ++--- .../FeatureComponents/Kanban/KanbanCard.tsx | 44 +- .../FeatureComponents/Kanban/KanbanColumn.tsx | 80 ++-- app/_consts/dnd.ts | 8 + app/_hooks/dnd/DndProvider.tsx | 379 ++++++++++++++++++ app/_hooks/dnd/index.tsx | 3 + app/_hooks/dnd/useDragItem.ts | 102 +++++ app/_hooks/dnd/useDropList.ts | 36 ++ app/_hooks/kanban/useKanban.ts | 257 ++---------- app/_hooks/kanban/useKanbanDnd.ts | 94 +++++ app/_server/actions/checklist-item/drop.ts | 98 +++++ app/_server/actions/checklist-item/index.ts | 4 + app/_server/actions/checklist-item/status.ts | 103 ++--- app/_translations/de.json | 1 + app/_translations/en.json | 1 + app/_translations/es.json | 1 + app/_translations/fr.json | 1 + app/_translations/it.json | 1 + app/_translations/klingon.json | 1 + app/_translations/ko.json | 1 + app/_translations/nl.json | 1 + app/_translations/pirate.json | 1 + app/_translations/pl.json | 1 + app/_translations/pt.json | 1 + app/_translations/ru.json | 1 + app/_translations/tr.json | 1 + app/_translations/zh.json | 1 + app/_types/dnd.ts | 35 ++ app/_types/index.ts | 8 + app/_utils/dnd/auto-scroll.ts | 64 +++ app/_utils/dnd/dnd-math.ts | 65 +++ app/_utils/dnd/drag-store.ts | 51 +++ app/_utils/dnd/rect-registry.ts | 120 ++++++ app/_utils/item-status-utils.ts | 77 ++++ app/_utils/kanban/board-utils.ts | 103 +++++ jin2a_plan.md | 65 +++ next.config.mjs | 1 + tests/server-actions/drop-item.test.ts | 227 +++++++++++ tests/utils/board-utils.test.ts | 225 +++++++++++ tests/utils/dnd-math.test.ts | 107 +++++ tests/utils/item-status-utils.test.ts | 121 ++++++ 41 files changed, 2153 insertions(+), 434 deletions(-) create mode 100644 app/_consts/dnd.ts create mode 100644 app/_hooks/dnd/DndProvider.tsx create mode 100644 app/_hooks/dnd/index.tsx create mode 100644 app/_hooks/dnd/useDragItem.ts create mode 100644 app/_hooks/dnd/useDropList.ts create mode 100644 app/_hooks/kanban/useKanbanDnd.ts create mode 100644 app/_server/actions/checklist-item/drop.ts create mode 100644 app/_types/dnd.ts create mode 100644 app/_utils/dnd/auto-scroll.ts create mode 100644 app/_utils/dnd/dnd-math.ts create mode 100644 app/_utils/dnd/drag-store.ts create mode 100644 app/_utils/dnd/rect-registry.ts create mode 100644 app/_utils/item-status-utils.ts create mode 100644 app/_utils/kanban/board-utils.ts create mode 100644 jin2a_plan.md create mode 100644 tests/server-actions/drop-item.test.ts create mode 100644 tests/utils/board-utils.test.ts create mode 100644 tests/utils/dnd-math.test.ts create mode 100644 tests/utils/item-status-utils.test.ts diff --git a/app/_components/FeatureComponents/Kanban/Kanban.tsx b/app/_components/FeatureComponents/Kanban/Kanban.tsx index 548c97f5..f0a67461 100644 --- a/app/_components/FeatureComponents/Kanban/Kanban.tsx +++ b/app/_components/FeatureComponents/Kanban/Kanban.tsx @@ -1,19 +1,7 @@ "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 { 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"; @@ -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"; @@ -63,7 +52,6 @@ const _findItemById = (items: Item[], itemId: string): Item | null => { export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { const t = useTranslations(); const { showToast } = useToast(); - const [isClient, setIsClient] = useState(false); const [showStatusModal, setShowStatusModal] = useState(false); const [showArchivedModal, setShowArchivedModal] = useState(false); const [viewMode, setViewMode] = useState<"board" | "calendar">("board"); @@ -88,6 +76,7 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { const { permissions } = usePermissions(); const { localChecklist, + setLocalChecklist, isLoading, showBulkPasteModal, setShowBulkPasteModal, @@ -95,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(() => { @@ -249,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) => { @@ -321,33 +319,6 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { ], ); - 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, @@ -480,34 +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()} + )}
diff --git a/app/_components/FeatureComponents/Kanban/KanbanCard.tsx b/app/_components/FeatureComponents/Kanban/KanbanCard.tsx index 03179cee..f0f11b7c 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanCard.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanCard.tsx @@ -1,7 +1,6 @@ "use client"; -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; +import { useDragItem } from "@/app/_hooks/dnd"; import { Item, Checklist, KanbanStatus } from "@/app/_types"; import { cn } from "@/app/_utils/global-utils"; import { Dropdown } from "@/app/_components/GlobalComponents/Dropdowns/Dropdown"; @@ -32,6 +31,8 @@ import { TimeEntriesModal } from "./TimeEntriesModal"; interface KanbanCardProps { checklist: Checklist; item: Item; + index?: number; + listId?: string; isDragging?: boolean; checklistId: string; category: string; @@ -45,6 +46,8 @@ interface KanbanCardProps { const KanbanCardComponent = ({ checklist, item, + index = 0, + listId, isDragging, checklistId, category, @@ -84,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) => ({ @@ -171,19 +165,27 @@ const KanbanCardComponent = ({ /> )} -
+
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", )} >
diff --git a/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx b/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx index 4f6ded6c..4feebdad 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx @@ -1,11 +1,7 @@ "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"; @@ -97,7 +93,7 @@ const KanbanColumnComponent = ({ 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); @@ -206,44 +202,44 @@ 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")} + +
+ )} +
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; + + 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); + } + } + _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/useKanban.ts b/app/_hooks/kanban/useKanban.ts index d30a1c98..df72aef0 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 ) { + 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..4e634844 --- /dev/null +++ b/app/_hooks/kanban/useKanbanDnd.ts @@ -0,0 +1,94 @@ +"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)); + + const response = await dropItem(formData); + 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/_server/actions/checklist-item/drop.ts b/app/_server/actions/checklist-item/drop.ts new file mode 100644 index 00000000..0a1649f1 --- /dev/null +++ b/app/_server/actions/checklist-item/drop.ts @@ -0,0 +1,98 @@ +"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 targetIndex = Number(formData.get("targetIndex")); + + if (!uuid || !itemId || !targetStatus || Number.isNaN(targetIndex)) { + return { + success: false, + error: "uuid, itemId, targetStatus and targetIndex are required", + }; + } + + 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 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, + ); + } + + await broadcast({ + type: "checklist", + action: "updated", + entityId: list.id, + username, + }); + + 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..9089d791 100644 --- a/app/_server/actions/checklist-item/status.ts +++ b/app/_server/actions/checklist-item/status.ts @@ -19,45 +19,8 @@ import { } 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, @@ -104,47 +67,29 @@ 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); + const statusItems = status + ? applyStatus(list.items, itemId, status, list.statuses, username, now) + : list.items; + + const updatedItems = timeEntriesStr + ? updateItem(statusItems, itemId, (item) => { + try { + const timeEntries = JSON.parse(timeEntriesStr); + return { + ...item, + timeEntries: timeEntries.map((entry: { user?: string }) => ({ + ...entry, + user: entry.user || username, + })), + }; + } catch (e) { + console.error("Failed to parse timeEntries:", e); + return item; } - } 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; - } - } - - if (timeEntriesStr) { - try { - const timeEntries = JSON.parse(timeEntriesStr); - updates.timeEntries = timeEntries.map((entry: { user?: string }) => ({ - ...entry, - user: entry.user || username, - })); - } catch (e) { - console.error("Failed to parse timeEntries:", e); - } - } - - return { ...item, ...updates }; - }); - - const itemsWithParentAutoComplete = _autoCompleteParent( + }) + : statusItems; + + const itemsWithParentAutoComplete = completeParent( updatedItems, itemId, list.statuses, username, now ); diff --git a/app/_translations/de.json b/app/_translations/de.json index 3eb5c82b..0d335c9b 100644 --- a/app/_translations/de.json +++ b/app/_translations/de.json @@ -594,6 +594,7 @@ "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)", diff --git a/app/_translations/en.json b/app/_translations/en.json index a0bd5572..3d493f51 100644 --- a/app/_translations/en.json +++ b/app/_translations/en.json @@ -619,6 +619,7 @@ "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)", diff --git a/app/_translations/es.json b/app/_translations/es.json index 067890dc..ff6848ea 100644 --- a/app/_translations/es.json +++ b/app/_translations/es.json @@ -594,6 +594,7 @@ "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)", diff --git a/app/_translations/fr.json b/app/_translations/fr.json index 1410ed95..e26857fe 100644 --- a/app/_translations/fr.json +++ b/app/_translations/fr.json @@ -594,6 +594,7 @@ "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)", diff --git a/app/_translations/it.json b/app/_translations/it.json index 0c89e329..6779775a 100644 --- a/app/_translations/it.json +++ b/app/_translations/it.json @@ -594,6 +594,7 @@ "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)", diff --git a/app/_translations/klingon.json b/app/_translations/klingon.json index d00ad754..ab5080ed 100644 --- a/app/_translations/klingon.json +++ b/app/_translations/klingon.json @@ -619,6 +619,7 @@ "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)", diff --git a/app/_translations/ko.json b/app/_translations/ko.json index a0b77f6e..83994811 100644 --- a/app/_translations/ko.json +++ b/app/_translations/ko.json @@ -619,6 +619,7 @@ "archiveAllConfirmMessage": "{column}의 항목 {count}개를 모두 보관할까요? 보관함에서 복원할 수 있습니다.", "archiveAllSuccess": "항목 {count}개가 보관됨", "archiveAllFailed": "모든 항목을 보관하지 못했습니다", + "moveFailed": "카드를 이동하지 못했습니다", "archiveAllPartial": "항목 {total}개 중 {archived}개가 보관되었습니다. 일부 항목은 보관하지 못했습니다.", "itemDeleted": "항목이 삭제됨", "estimatedTime": "예상 시간(시간)", diff --git a/app/_translations/nl.json b/app/_translations/nl.json index 1b458cd7..18e7e605 100644 --- a/app/_translations/nl.json +++ b/app/_translations/nl.json @@ -594,6 +594,7 @@ "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)", diff --git a/app/_translations/pirate.json b/app/_translations/pirate.json index 2a13322e..9b861200 100644 --- a/app/_translations/pirate.json +++ b/app/_translations/pirate.json @@ -619,6 +619,7 @@ "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", diff --git a/app/_translations/pl.json b/app/_translations/pl.json index 96ff7a96..9b7a4de5 100644 --- a/app/_translations/pl.json +++ b/app/_translations/pl.json @@ -594,6 +594,7 @@ "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)", diff --git a/app/_translations/pt.json b/app/_translations/pt.json index d80c1470..a1ee32a1 100644 --- a/app/_translations/pt.json +++ b/app/_translations/pt.json @@ -568,6 +568,7 @@ "autoComplete": "Conclusão automática" }, "kanban": { + "moveFailed": "Não foi possível mover o cartão", "priority": "Prioridade", "score": "Pontuação", "assignee": "Responsável", diff --git a/app/_translations/ru.json b/app/_translations/ru.json index 2d0f99a3..d53bac06 100644 --- a/app/_translations/ru.json +++ b/app/_translations/ru.json @@ -619,6 +619,7 @@ "archiveAllConfirmMessage": "Архивировать все элементы ({count}) в {column}? Их можно восстановить из архива.", "archiveAllSuccess": "Архивировано элементов: {count}", "archiveAllFailed": "Не удалось архивировать все элементы", + "moveFailed": "Не удалось переместить карточку", "archiveAllPartial": "Архивировано {archived} из {total} элементов. Некоторые элементы не удалось архивировать.", "itemDeleted": "Элемент удалён", "estimatedTime": "Оценка времени (часы)", diff --git a/app/_translations/tr.json b/app/_translations/tr.json index 9817c4ab..e584e622 100644 --- a/app/_translations/tr.json +++ b/app/_translations/tr.json @@ -619,6 +619,7 @@ "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)", diff --git a/app/_translations/zh.json b/app/_translations/zh.json index 2014253d..ff62ae09 100644 --- a/app/_translations/zh.json +++ b/app/_translations/zh.json @@ -619,6 +619,7 @@ "archiveAllConfirmMessage": "要归档 {column} 中的全部 {count} 个项目吗?你可以从归档中恢复它们。", "archiveAllSuccess": "已归档 {count} 个项目", "archiveAllFailed": "无法归档所有项目", + "moveFailed": "无法移动卡片", "archiveAllPartial": "已归档 {total} 个项目中的 {archived} 个。部分项目无法归档。", "itemDeleted": "项目已删除", "estimatedTime": "预估时间(小时)", 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 6cd31aff..4d8db9dd 100644 --- a/app/_types/index.ts +++ b/app/_types/index.ts @@ -94,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/_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..12ae7b99 --- /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 = el.parentElement; + while (node) { + if (_scrolls(node)) found.add(node); + node = node.parentElement; + } + }); + return Array.from(found); + }, + }; +}; diff --git a/app/_utils/item-status-utils.ts b/app/_utils/item-status-utils.ts new file mode 100644 index 00000000..f63f8677 --- /dev/null +++ b/app/_utils/item-status-utils.ts @@ -0,0 +1,77 @@ +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; + + 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 }, + ], + })); +}; diff --git a/app/_utils/kanban/board-utils.ts b/app/_utils/kanban/board-utils.ts new file mode 100644 index 00000000..72727cda --- /dev/null +++ b/app/_utils/kanban/board-utils.ts @@ -0,0 +1,103 @@ +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, + })); + +export const getColumnItems = ( + items: Item[], + statusId: string, + statuses: KanbanStatus[] | undefined, +): Item[] => { + const statusList = statuses || DEFAULT_KANBAN_STATUSES; + const firstStatus = + [...statusList].sort((a, b) => a.order - b.order)[0]?.id || TaskStatus.TODO; + 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 anchor = visibleItems[visIndex]; + 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/jin2a_plan.md b/jin2a_plan.md new file mode 100644 index 00000000..18df06d9 --- /dev/null +++ b/jin2a_plan.md @@ -0,0 +1,65 @@ +# jin2a suggestions - outstanding work + +Discord thread: jin2a_ (30-31 May 2026). Items already shipped this session are marked. + +--- + +## Done this session + +- **Right-hand column dropdown** - KanbanCard three-dots menu now detects viewport edge and flips left. Fixed in `Dropdown.tsx`. +- **Date picker year jump** - custom `CaptionLabel` with a year ` updateFilters({ search: event.target.value })} + placeholder={t("profile.graphSearchPlaceholder")} + className="h-10 w-full rounded-jotty border border-input bg-background px-3 text-sm" + /> + +
- {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/_translations/de.json b/app/_translations/de.json index e05195e4..ce3aacaf 100644 --- a/app/_translations/de.json +++ b/app/_translations/de.json @@ -692,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} 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": "Tippen oder klicken Sie auf einen Knoten, um Titel, Kategorie, Linkanzahlen und aktuelle Aktivität zu prüfen.", + "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": "Verknüpfte Elemente", + "uuid": "UUID" }, "settings": { "title": "Einstellungen", diff --git a/app/_translations/en.json b/app/_translations/en.json index a12e6010..37f52751 100644 --- a/app/_translations/en.json +++ b/app/_translations/en.json @@ -717,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", diff --git a/app/_translations/es.json b/app/_translations/es.json index 4d08f95d..eaab60e2 100644 --- a/app/_translations/es.json +++ b/app/_translations/es.json @@ -692,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} 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": "Toca o haz clic en un nodo para ver el título, la categoría, los recuentos de enlaces y la actividad reciente.", + "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": "Elementos vinculados", + "uuid": "UUID" }, "settings": { "title": "Configuración", diff --git a/app/_translations/fr.json b/app/_translations/fr.json index 5ce0e88d..2b88b5e8 100644 --- a/app/_translations/fr.json +++ b/app/_translations/fr.json @@ -692,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} 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": "Touchez ou cliquez sur un nœud pour consulter le titre, la catégorie, le nombre de liens et l’activité récente.", + "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": "Éléments liés", + "uuid": "UUID" }, "settings": { "title": "Paramètres", diff --git a/app/_translations/it.json b/app/_translations/it.json index ae3ff5f7..401d866a 100644 --- a/app/_translations/it.json +++ b/app/_translations/it.json @@ -692,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} 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": "Tocca o fai clic su un nodo per vedere titolo, categoria, conteggi dei link e attività recente.", + "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": "Elementi collegati", + "uuid": "UUID" }, "settings": { "title": "Impostazioni", diff --git a/app/_translations/klingon.json b/app/_translations/klingon.json index 934ce777..42728417 100644 --- a/app/_translations/klingon.json +++ b/app/_translations/klingon.json @@ -717,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} 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": "SeH", diff --git a/app/_translations/ko.json b/app/_translations/ko.json index c872f0bb..df5e5276 100644 --- a/app/_translations/ko.json +++ b/app/_translations/ko.json @@ -717,7 +717,27 @@ "connection_plural": "연결", "terminateSessionConfirm": "이 세션을 종료하시겠습니까?", "terminateAllSessionsConfirm": "다른 모든 세션을 종료하시겠습니까?", - "validationErrorTitle": "유효성 검사 오류" + "validationErrorTitle": "유효성 검사 오류", + "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": "노드를 탭하거나 클릭해 제목, 카테고리, 링크 수, 최근 활동을 확인하세요.", + "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": "연결된 항목", + "uuid": "UUID" }, "settings": { "title": "설정", diff --git a/app/_translations/nl.json b/app/_translations/nl.json index 586fbd88..414ecbda 100644 --- a/app/_translations/nl.json +++ b/app/_translations/nl.json @@ -692,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} 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": "Tik of klik op een knooppunt om titel, categorie, aantallen links en recente activiteit te bekijken.", + "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": "Gekoppelde items", + "uuid": "UUID" }, "settings": { "title": "Instellingen", diff --git a/app/_translations/pirate.json b/app/_translations/pirate.json index 9e674cf8..42ffc88b 100644 --- a/app/_translations/pirate.json +++ b/app/_translations/pirate.json @@ -717,7 +717,27 @@ "connection_plural": "lines", "terminateSessionConfirm": "End this voyage?", "terminateAllSessionsConfirm": "End all other voyages?", - "validationErrorTitle": "Customs Error" + "validationErrorTitle": "Customs 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": "Rigging", diff --git a/app/_translations/pl.json b/app/_translations/pl.json index 5963f074..3790198e 100644 --- a/app/_translations/pl.json +++ b/app/_translations/pl.json @@ -692,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} 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": "Dotknij lub kliknij węzeł, aby sprawdzić tytuł, kategorię, liczbę linków i ostatnią aktywność.", + "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": "Połączone elementy", + "uuid": "UUID" }, "settings": { "title": "Ustawienia", diff --git a/app/_translations/pt.json b/app/_translations/pt.json index 43b8467f..09210e66 100644 --- a/app/_translations/pt.json +++ b/app/_translations/pt.json @@ -708,7 +708,27 @@ "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" + "validationErrorTitle": "Erro de validação", + "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": "Toque ou clique num nó para ver o título, a categoria, as contagens de ligações e a atividade recente.", + "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": "Itens vinculados", + "uuid": "UUID" }, "settings": { "title": "Definições", diff --git a/app/_translations/ru.json b/app/_translations/ru.json index 91f5a7d5..9bf53ed7 100644 --- a/app/_translations/ru.json +++ b/app/_translations/ru.json @@ -717,7 +717,27 @@ "connection_plural": "связей", "terminateSessionConfirm": "Вы уверены, что хотите завершить эту сессию?", "terminateAllSessionsConfirm": "Вы уверены, что хотите завершить все остальные сессии?", - "validationErrorTitle": "Ошибка валидации" + "validationErrorTitle": "Ошибка валидации", + "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": "Коснитесь узла или нажмите на него, чтобы посмотреть название, категорию, количество ссылок и недавнюю активность.", + "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": "Связанные элементы", + "uuid": "UUID" }, "settings": { "title": "Настройки", diff --git a/app/_translations/tr.json b/app/_translations/tr.json index 6c30ee67..5f5cc157 100644 --- a/app/_translations/tr.json +++ b/app/_translations/tr.json @@ -717,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} 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": "Başlığı, kategoriyi, bağlantı sayılarını ve son etkinliği incelemek için bir düğüme dokunun veya tıklayın.", + "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": "Bağlantılı öğeler", + "uuid": "UUID" }, "settings": { "title": "Ayarlar", diff --git a/app/_translations/zh.json b/app/_translations/zh.json index 4c330117..d1c8093c 100644 --- a/app/_translations/zh.json +++ b/app/_translations/zh.json @@ -717,7 +717,27 @@ "connection_plural": "个连接", "terminateSessionConfirm": "确定要终止此会话吗?", "terminateAllSessionsConfirm": "确定要终止其他所有会话吗?", - "validationErrorTitle": "验证错误" + "validationErrorTitle": "验证错误", + "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": "点按或点击节点即可查看标题、类别、链接数量和最近活动。", + "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": "已链接项目", + "uuid": "UUID" }, "settings": { "title": "设置", diff --git a/package.json b/package.json index 607c147d..400ccad6 100644 --- a/package.json +++ b/package.json @@ -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", 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/yarn.lock b/yarn.lock index d74049dc..cdec683f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2322,6 +2322,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" @@ -2909,6 +2914,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" @@ -3234,6 +3244,11 @@ 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" @@ -3344,6 +3359,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" @@ -3577,7 +3599,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== @@ -3589,6 +3611,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" @@ -3665,6 +3692,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" @@ -3712,6 +3750,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" @@ -3750,7 +3793,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== @@ -3758,7 +3801,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== @@ -3837,7 +3880,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== @@ -4755,6 +4798,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" @@ -4762,6 +4814,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" @@ -5213,6 +5286,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" @@ -5548,6 +5626,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" @@ -5676,6 +5759,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" @@ -5804,7 +5894,7 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash-es@4.18.1, lodash-es@^4.17.21: +lodash-es@4, 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== @@ -7182,6 +7272,11 @@ postcss@8.4.31: picocolors "^1.0.0" source-map-js "^1.0.2" +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" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -7215,7 +7310,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== @@ -7399,11 +7494,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" @@ -8372,6 +8483,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" From 96c7701184aa60d051a6d1d0fab53d6de8724a72 Mon Sep 17 00:00:00 2001 From: fccview Date: Sat, 13 Jun 2026 09:52:15 +0100 Subject: [PATCH 34/40] make dev origins an env var so people can develop with their own local ips if needed --- next.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next.config.mjs b/next.config.mjs index 9eab369a..767bc697 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -17,7 +17,7 @@ const nextConfig = { // https://nextjs.org/docs/app/api-reference/config/next-config-js/proxyClientMaxBodySize proxyClientMaxBodySize: maxBodySize, }, - allowedDevOrigins: ['192.168.86.233'], + allowedDevOrigins: process.env.DEV_ORIGINS ? process.env.DEV_ORIGINS.split(',') : [], images: { unoptimized: true, }, From 59823e16ef8bfd20aa1e248126887048dcb60ffc Mon Sep 17 00:00:00 2001 From: fccview Date: Sat, 13 Jun 2026 17:03:28 +0100 Subject: [PATCH 35/40] fix rabbit nitpicks --- .../FeatureComponents/Kanban/CalendarView.tsx | 9 +++ .../Kanban/KanbanCardDetail.tsx | 20 +++-- .../CustomExtensions/ExcalidrawExtension.tsx | 38 +++++----- .../Parts/ConnectionsGraph/graph-data.ts | 7 +- .../GlobalComponents/Auth/LoginForm.tsx | 1 + app/_hooks/kanban/useKanbanDnd.ts | 9 ++- app/_providers/NavigationGuardProvider.tsx | 1 + app/_server/actions/checklist-item/drop.ts | 23 ++++-- app/_server/actions/checklist-item/status.ts | 40 +++++----- app/_server/actions/file/index.ts | 3 +- app/_translations/de.json | 38 +++++----- app/_translations/es.json | 36 ++++----- app/_translations/fr.json | 36 ++++----- app/_translations/it.json | 38 +++++----- app/_translations/klingon.json | 40 +++++----- app/_translations/ko.json | 38 +++++----- app/_translations/nl.json | 36 ++++----- app/_translations/pirate.json | 38 +++++----- app/_translations/pl.json | 38 +++++----- app/_translations/pt.json | 39 +++++----- app/_translations/ru.json | 38 +++++----- app/_translations/tr.json | 38 +++++----- app/_translations/zh.json | 40 +++++----- app/_utils/dnd/rect-registry.ts | 2 +- app/_utils/item-status-utils.ts | 29 ++++--- app/_utils/kanban/board-utils.ts | 4 +- .../[listId]/items/[itemIndex]/route.ts | 18 +++++ .../[listId]/items/reorder/route.ts | 38 ++++++++++ public/api/paths/checklists.yaml | 76 +++++++++---------- 29 files changed, 462 insertions(+), 349 deletions(-) diff --git a/app/_components/FeatureComponents/Kanban/CalendarView.tsx b/app/_components/FeatureComponents/Kanban/CalendarView.tsx index 5c7adf59..2b799c88 100644 --- a/app/_components/FeatureComponents/Kanban/CalendarView.tsx +++ b/app/_components/FeatureComponents/Kanban/CalendarView.tsx @@ -154,7 +154,16 @@ export const CalendarView = ({ checklist, onItemClick }: CalendarViewProps) => { return (
_openItem(segment.event.itemId)} + onKeyDown={(e) => { + 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 cursor-pointer hover:brightness-95 dark:hover:brightness-110 transition-[filter,opacity]", !barStyle.backgroundColor && "bg-muted/80 text-muted-foreground", diff --git a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx index bc9c247c..5fa0c41c 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx @@ -340,12 +340,17 @@ 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) => { - setStartDateInput(value); - const iso = value ? new Date(value).toISOString() : ""; + const iso = _dateToIso(value); + setStartDateInput(iso); const targetKey = _dateKey(targetDateInput); if (value && targetKey && value > targetKey) { - setTargetDateInput(value); + setTargetDateInput(iso); await _saveField({ startDate: iso, targetDate: iso }); return; } @@ -353,16 +358,15 @@ export const KanbanCardDetail = ({ }; const handleTargetDateChange = async (value: string) => { - setTargetDateInput(value); + const iso = _dateToIso(value); + setTargetDateInput(iso); if (!value) { - setStartDateInput(""); - await _saveField({ targetDate: "", startDate: "" }); + await _saveField({ targetDate: "" }); return; } - const iso = new Date(value).toISOString(); const startKey = _dateKey(startDateInput); if (startKey && startKey > value) { - setStartDateInput(value); + setStartDateInput(iso); await _saveField({ targetDate: iso, startDate: iso }); return; } diff --git a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/ExcalidrawExtension.tsx b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/ExcalidrawExtension.tsx index 102c7b57..423b5498 100644 --- a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/ExcalidrawExtension.tsx +++ b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/ExcalidrawExtension.tsx @@ -85,24 +85,28 @@ 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; - - updateAttributes({ - diagramData: JSON.stringify(sceneData), - svgData: svgString, - }); + 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; - setIsEditing(false); + updateAttributes({ + diagramData: JSON.stringify(sceneData), + svgData: svgString, + }); + } catch (error) { + console.error("Failed to export Excalidraw diagram:", error); + } finally { + setIsEditing(false); + } } }; diff --git a/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/graph-data.ts b/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/graph-data.ts index 0f695ce5..12d01dad 100644 --- a/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/graph-data.ts +++ b/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/graph-data.ts @@ -3,6 +3,7 @@ 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; @@ -202,7 +203,11 @@ export const buildConnectionGraphData = ( const sortedIds = [...taggedNodeIds].sort(); sortedIds.forEach((source, sourceIndex) => { - sortedIds.slice(sourceIndex + 1).forEach((target) => { + 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; diff --git a/app/_components/GlobalComponents/Auth/LoginForm.tsx b/app/_components/GlobalComponents/Auth/LoginForm.tsx index 625f4f7e..99ddbd65 100644 --- a/app/_components/GlobalComponents/Auth/LoginForm.tsx +++ b/app/_components/GlobalComponents/Auth/LoginForm.tsx @@ -247,6 +247,7 @@ export default function LoginForm({
{t("version", { version: appVersion })} diff --git a/app/_hooks/kanban/useKanbanDnd.ts b/app/_hooks/kanban/useKanbanDnd.ts index 4e634844..f261f70f 100644 --- a/app/_hooks/kanban/useKanbanDnd.ts +++ b/app/_hooks/kanban/useKanbanDnd.ts @@ -71,7 +71,14 @@ export const useKanbanDnd = ({ formData.append("targetStatus", targetListId); formData.append("targetIndex", String(colIndex)); - const response = await dropItem(formData); + 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); diff --git a/app/_providers/NavigationGuardProvider.tsx b/app/_providers/NavigationGuardProvider.tsx index 80e70b7e..5b98351d 100644 --- a/app/_providers/NavigationGuardProvider.tsx +++ b/app/_providers/NavigationGuardProvider.tsx @@ -64,6 +64,7 @@ export const NavigationGuardProvider = ({ const onBeforeUnload = (e: BeforeUnloadEvent) => { if (hasUnsavedChanges) { e.preventDefault(); + e.returnValue = ""; } }; diff --git a/app/_server/actions/checklist-item/drop.ts b/app/_server/actions/checklist-item/drop.ts index 0a1649f1..7fba77d2 100644 --- a/app/_server/actions/checklist-item/drop.ts +++ b/app/_server/actions/checklist-item/drop.ts @@ -50,6 +50,13 @@ export const dropItem = async ( 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, @@ -83,12 +90,16 @@ export const dropItem = async ( ); } - await broadcast({ - type: "checklist", - action: "updated", - entityId: list.id, - username, - }); + 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) { diff --git a/app/_server/actions/checklist-item/status.ts b/app/_server/actions/checklist-item/status.ts index 9089d791..be217ab5 100644 --- a/app/_server/actions/checklist-item/status.ts +++ b/app/_server/actions/checklist-item/status.ts @@ -12,7 +12,7 @@ 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, @@ -48,6 +48,20 @@ 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"); + } + 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" }; @@ -71,22 +85,14 @@ export const updateItemStatus = async ( ? applyStatus(list.items, itemId, status, list.statuses, username, now) : list.items; - const updatedItems = timeEntriesStr - ? updateItem(statusItems, itemId, (item) => { - try { - const timeEntries = JSON.parse(timeEntriesStr); - return { - ...item, - timeEntries: timeEntries.map((entry: { user?: string }) => ({ - ...entry, - user: entry.user || username, - })), - }; - } catch (e) { - console.error("Failed to parse timeEntries:", e); - return item; - } - }) + const updatedItems = parsedTimeEntries + ? updateItem(statusItems, itemId, (item) => ({ + ...item, + timeEntries: parsedTimeEntries!.map((entry) => ({ + ...entry, + user: entry.user || username, + })), + })) : statusItems; const itemsWithParentAutoComplete = completeParent( diff --git a/app/_server/actions/file/index.ts b/app/_server/actions/file/index.ts index 2f95bb60..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 { @@ -89,7 +90,7 @@ export const writeJsonFile = async ( filePath: string, ): Promise => { const finalPath = path.join(process.cwd(), filePath); - const tmpPath = finalPath + ".tmp"; + const tmpPath = `${finalPath}.${randomUUID()}.tmp`; try { await fs.mkdir(path.dirname(finalPath), { recursive: true }); diff --git a/app/_translations/de.json b/app/_translations/de.json index ce3aacaf..8e0ed086 100644 --- a/app/_translations/de.json +++ b/app/_translations/de.json @@ -282,7 +282,7 @@ "version": "Version {version}", "createUser": "Benutzer erstellen", "notAuthorized": "Du bist nicht berechtigt, auf diese Anwendung zuzugreifen. Kontaktiere deinen Administrator.", - "rememberMe": "Remember me", + "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." }, @@ -693,24 +693,24 @@ "terminateSessionConfirm": "Bist du sicher, dass du diese Sitzung beenden willst?", "terminateAllSessionsConfirm": "Bist du sicher, dass du alle anderen Sitzungen beenden willst?", "validationErrorTitle": "Validerungsfehler", - "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": "Tippen oder klicken Sie auf einen Knoten, um Titel, Kategorie, Linkanzahlen und aktuelle Aktivität zu prüfen.", - "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", + "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" }, diff --git a/app/_translations/es.json b/app/_translations/es.json index eaab60e2..69ca35f9 100644 --- a/app/_translations/es.json +++ b/app/_translations/es.json @@ -284,7 +284,7 @@ "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.", - "rememberMe": "Remember me" + "rememberMe": "Recuérdame" }, "mfa": { "title": "Autenticación de dos factores", @@ -693,24 +693,24 @@ "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", - "visibleGraphSummary": "{items} visible items, {links} visible links", - "graphTruncated": "Showing the top {visible} items by connection count. {omitted} lower-connected items were omitted.", + "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": "In", - "outboundConnections": "Out", - "updated": "Updated", - "openItem": "Open item", - "selectItem": "Select an item", - "inspectNodeHint": "Toca o haz clic en un nodo para ver el título, la categoría, los recuentos de enlaces y la actividad reciente.", - "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", + "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" }, diff --git a/app/_translations/fr.json b/app/_translations/fr.json index 2b88b5e8..55e4b88f 100644 --- a/app/_translations/fr.json +++ b/app/_translations/fr.json @@ -284,7 +284,7 @@ "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.", - "rememberMe": "Remember me" + "rememberMe": "Se souvenir de moi" }, "mfa": { "title": "Authentification à deux facteurs", @@ -693,24 +693,24 @@ "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", - "visibleGraphSummary": "{items} visible items, {links} visible links", - "graphTruncated": "Showing the top {visible} items by connection count. {omitted} lower-connected items were omitted.", + "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": "In", - "outboundConnections": "Out", - "updated": "Updated", - "openItem": "Open item", - "selectItem": "Select an item", - "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": "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", + "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" }, diff --git a/app/_translations/it.json b/app/_translations/it.json index 401d866a..3ffa619d 100644 --- a/app/_translations/it.json +++ b/app/_translations/it.json @@ -284,7 +284,7 @@ "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.", - "rememberMe": "Remember me" + "rememberMe": "Ricordami" }, "mfa": { "title": "Autenticazione a Due Fattori", @@ -693,24 +693,24 @@ "terminateSessionConfirm": "Sei sicuro di voler terminare questa sessione?", "terminateAllSessionsConfirm": "Sei sicuro di voler terminare tutte le altre sessioni?", "validationErrorTitle": "Errore di Validazione", - "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": "Tocca o fai clic su un nodo per vedere titolo, categoria, conteggi dei link e attività recente.", - "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", + "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" }, diff --git a/app/_translations/klingon.json b/app/_translations/klingon.json index 42728417..af160134 100644 --- a/app/_translations/klingon.json +++ b/app/_translations/klingon.json @@ -309,7 +309,7 @@ "attemptsRemaining": "ghuH: {count} logh neH bInIDlaH", "accountLocked": "bInIDqu'. {seconds} lup 'uy' wa'leS yInIDqa'.", "notAuthorized": "bI'el 'e' chaw'be'lu'. ra'wI' yIghel.", - "rememberMe": "Remember me" + "rememberMe": "qaqaw" }, "mfa": { "title": "cha'logh ngu'aS", @@ -718,25 +718,25 @@ "terminateSessionConfirm": "poHvam DamejmoH DaneH'a'?", "terminateAllSessionsConfirm": "Hoch poHmey latlh DamejmoH DaneH'a'?", "validationErrorTitle": "nuD Qagh", - "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", + "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": { diff --git a/app/_translations/ko.json b/app/_translations/ko.json index df5e5276..593a7755 100644 --- a/app/_translations/ko.json +++ b/app/_translations/ko.json @@ -309,7 +309,7 @@ "attemptsRemaining": "경고: {count, plural, one {#회} other {#회}}의 시도가 남아 있습니다", "accountLocked": "실패 시도가 너무 많습니다. {seconds} {seconds, plural, one {초} other {초}} 후에 다시 시도하세요", "notAuthorized": "접근 권한이 없습니다. 관리자에게 문의하세요.", - "rememberMe": "Remember me" + "rememberMe": "로그인 상태 유지" }, "mfa": { "title": "2단계 인증 (2FA)", @@ -718,24 +718,24 @@ "terminateSessionConfirm": "이 세션을 종료하시겠습니까?", "terminateAllSessionsConfirm": "다른 모든 세션을 종료하시겠습니까?", "validationErrorTitle": "유효성 검사 오류", - "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": "노드를 탭하거나 클릭해 제목, 카테고리, 링크 수, 최근 활동을 확인하세요.", - "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", + "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" }, diff --git a/app/_translations/nl.json b/app/_translations/nl.json index 414ecbda..a27b40d4 100644 --- a/app/_translations/nl.json +++ b/app/_translations/nl.json @@ -284,7 +284,7 @@ "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.", - "rememberMe": "Remember me" + "rememberMe": "Aangemeld blijven" }, "mfa": { "title": "Twee-factorauthenticatie", @@ -693,24 +693,24 @@ "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", - "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": "Tik of klik op een knooppunt om titel, categorie, aantallen links en recente activiteit te bekijken.", - "graphSearchPlaceholder": "Title, path, category, or tag", - "orphans": "Orphans", + "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": "Arrows", - "nodeSize": "Node size", - "linkWidth": "Link width", - "linkDistance": "Link distance", - "repelForce": "Repel", - "tagLinks": "Tag links", + "arrows": "Pijlen", + "nodeSize": "Knooppuntgrootte", + "linkWidth": "Lijndikte", + "linkDistance": "Lijnafstand", + "repelForce": "Afstoting", + "tagLinks": "Taglinks", "linkedItems": "Gekoppelde items", "uuid": "UUID" }, diff --git a/app/_translations/pirate.json b/app/_translations/pirate.json index 42ffc88b..81b5db9e 100644 --- a/app/_translations/pirate.json +++ b/app/_translations/pirate.json @@ -309,7 +309,7 @@ "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.", - "rememberMe": "Remember me" + "rememberMe": "Remember me, matey" }, "mfa": { "title": "Two-Step Verification", @@ -718,25 +718,25 @@ "terminateSessionConfirm": "End this voyage?", "terminateAllSessionsConfirm": "End all other voyages?", "validationErrorTitle": "Customs 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", + "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 size", - "linkWidth": "Link width", - "linkDistance": "Link distance", - "repelForce": "Repel", - "tagLinks": "Tag links", - "linkedItems": "Linked items", + "nodeSize": "Node heft", + "linkWidth": "Rope width", + "linkDistance": "Rope length", + "repelForce": "Shove off", + "tagLinks": "Mark lines", + "linkedItems": "Tied items", "uuid": "UUID" }, "settings": { diff --git a/app/_translations/pl.json b/app/_translations/pl.json index 3790198e..044c0794 100644 --- a/app/_translations/pl.json +++ b/app/_translations/pl.json @@ -284,7 +284,7 @@ "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.", - "rememberMe": "Remember me" + "rememberMe": "Zapamiętaj mnie" }, "mfa": { "title": "Autoryzacja dwuskładnikowa", @@ -693,24 +693,24 @@ "terminateSessionConfirm": "Czy na pewno chcesz zakończyć tę sesję?", "terminateAllSessionsConfirm": "Czy na pewno chcesz zakończyć wszystkie inne sesje?", "validationErrorTitle": "Błąd walidacji", - "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": "Dotknij lub kliknij węzeł, aby sprawdzić tytuł, kategorię, liczbę linków i ostatnią aktywność.", - "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", + "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" }, diff --git a/app/_translations/pt.json b/app/_translations/pt.json index 09210e66..1f131754 100644 --- a/app/_translations/pt.json +++ b/app/_translations/pt.json @@ -308,7 +308,8 @@ "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." + "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", @@ -709,25 +710,25 @@ "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} visible items, {links} visible links", - "graphTruncated": "Showing the top {visible} items by connection count. {omitted} lower-connected items were omitted.", + "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": "In", - "outboundConnections": "Out", - "updated": "Updated", - "openItem": "Open item", - "selectItem": "Select an item", - "inspectNodeHint": "Toque ou clique num nó para ver o título, a categoria, as contagens de ligações e a atividade recente.", - "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": "Itens vinculados", + "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": { diff --git a/app/_translations/ru.json b/app/_translations/ru.json index 9bf53ed7..22bcfa36 100644 --- a/app/_translations/ru.json +++ b/app/_translations/ru.json @@ -309,7 +309,7 @@ "attemptsRemaining": "Предупреждение: осталось {count, plural, one {# попытка} few {# попытки} many {# попыток} other {# попыток}} до блокировки аккаунта", "accountLocked": "Слишком много неудачных попыток. Пожалуйста, попробуйте снова через {seconds} {seconds, plural, one {секунду} few {секунды} many {секунд} other {секунд}}", "notAuthorized": "Вы не авторизованы для доступа к этому приложению. Пожалуйста, свяжитесь с администратором.", - "rememberMe": "Remember me" + "rememberMe": "Запомнить меня" }, "mfa": { "title": "Двухфакторная аутентификация", @@ -718,24 +718,24 @@ "terminateSessionConfirm": "Вы уверены, что хотите завершить эту сессию?", "terminateAllSessionsConfirm": "Вы уверены, что хотите завершить все остальные сессии?", "validationErrorTitle": "Ошибка валидации", - "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": "Коснитесь узла или нажмите на него, чтобы посмотреть название, категорию, количество ссылок и недавнюю активность.", - "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", + "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" }, diff --git a/app/_translations/tr.json b/app/_translations/tr.json index 5f5cc157..0a5c82a0 100644 --- a/app/_translations/tr.json +++ b/app/_translations/tr.json @@ -309,7 +309,7 @@ "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.", - "rememberMe": "Remember me" + "rememberMe": "Beni hatırla" }, "mfa": { "title": "İki Faktörlü Doğrulama", @@ -718,24 +718,24 @@ "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ı", - "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": "Başlığı, kategoriyi, bağlantı sayılarını ve son etkinliği incelemek için bir düğüme dokunun veya tıklayın.", - "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", + "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" }, diff --git a/app/_translations/zh.json b/app/_translations/zh.json index d1c8093c..88983a47 100644 --- a/app/_translations/zh.json +++ b/app/_translations/zh.json @@ -309,7 +309,7 @@ "attemptsRemaining": "警告:在账户锁定前还剩 {count, plural, one {# 次尝试} other {# 次尝试}}", "accountLocked": "失败次数过多。请在 {seconds} {seconds, plural, one {秒} other {秒}} 后重试", "notAuthorized": "您没有权限访问此应用程序。请联系您的管理员。", - "rememberMe": "Remember me" + "rememberMe": "记住我" }, "mfa": { "title": "双重认证 (2FA)", @@ -718,25 +718,25 @@ "terminateSessionConfirm": "确定要终止此会话吗?", "terminateAllSessionsConfirm": "确定要终止其他所有会话吗?", "validationErrorTitle": "验证错误", - "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": "点按或点击节点即可查看标题、类别、链接数量和最近活动。", - "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": "已链接项目", + "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": { diff --git a/app/_utils/dnd/rect-registry.ts b/app/_utils/dnd/rect-registry.ts index 12ae7b99..d09e1d7c 100644 --- a/app/_utils/dnd/rect-registry.ts +++ b/app/_utils/dnd/rect-registry.ts @@ -108,7 +108,7 @@ export const createRegistry = (): RectRegistry => { scrollables: () => { const found = new Set(); lists.forEach(({ el }) => { - let node = el.parentElement; + let node: HTMLElement | null = el; while (node) { if (_scrolls(node)) found.add(node); node = node.parentElement; diff --git a/app/_utils/item-status-utils.ts b/app/_utils/item-status-utils.ts index f63f8677..99ee66c6 100644 --- a/app/_utils/item-status-utils.ts +++ b/app/_utils/item-status-utils.ts @@ -63,15 +63,22 @@ export const completeParent = ( 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 }, - ], - })); + 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 index 72727cda..3b24434c 100644 --- a/app/_utils/kanban/board-utils.ts +++ b/app/_utils/kanban/board-utils.ts @@ -36,7 +36,9 @@ export const visToColIndex = ( visIndex: number, ): number => { if (visIndex < visibleItems.length) { - const anchor = visibleItems[visIndex]; + 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; } diff --git a/app/api/checklists/[listId]/items/[itemIndex]/route.ts b/app/api/checklists/[listId]/items/[itemIndex]/route.ts index cab50548..41d0c95b 100644 --- a/app/api/checklists/[listId]/items/[itemIndex]/route.ts +++ b/app/api/checklists/[listId]/items/[itemIndex]/route.ts @@ -26,6 +26,24 @@ export async function PATCH( ); } + 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 }, + ); + } + const list = await getListById(params.listId, user.username); if (!list) { return NextResponse.json({ error: "List not found" }, { status: 404 }); diff --git a/app/api/checklists/[listId]/items/reorder/route.ts b/app/api/checklists/[listId]/items/reorder/route.ts index 4feeebb5..e1731315 100644 --- a/app/api/checklists/[listId]/items/reorder/route.ts +++ b/app/api/checklists/[listId]/items/reorder/route.ts @@ -1,8 +1,12 @@ 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"; @@ -70,6 +74,18 @@ export async function PUT( 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)) { @@ -83,6 +99,10 @@ export async function PUT( 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) { @@ -103,6 +123,24 @@ export async function PUT( 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); diff --git a/public/api/paths/checklists.yaml b/public/api/paths/checklists.yaml index 8ccf209b..6f0d4751 100644 --- a/public/api/paths/checklists.yaml +++ b/public/api/paths/checklists.yaml @@ -249,6 +249,43 @@ 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: + - 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: "1" + responses: + '200': + description: Item deleted successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + '404': + description: Checklist or item not found + '400': + description: Invalid item index /checklists/{listId}/items/reorder: put: @@ -386,42 +423,3 @@ description: Checklist or item not found '400': description: Invalid item index - -/checklists/{listId}/items/{itemIndex}: - 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: - - 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: "1" - responses: - '200': - description: Item deleted successfully - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - example: true - '404': - description: Checklist or item not found - '400': - description: Invalid item index From 2d05c95564b896bc77342912b58ea1309424ee22 Mon Sep 17 00:00:00 2001 From: fccview Date: Sat, 13 Jun 2026 17:17:09 +0100 Subject: [PATCH 36/40] fix dependency vulnerabilities --- package.json | 30 +- yarn.lock | 1220 +++++++++++++++++++++----------------------------- 2 files changed, 524 insertions(+), 726 deletions(-) diff --git a/package.json b/package.json index 400ccad6..b81647b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jotty.page", - "version": "1.23.0", + "version": "1.25.0", "private": true, "scripts": { "dev": "next dev", @@ -20,7 +20,7 @@ "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", - "@excalidraw/excalidraw": "0.18.0", + "@excalidraw/excalidraw": "0.18.1", "@fontsource-variable/google-sans-code": "5.2.3", "@fontsource-variable/ibm-plex-sans": "5.2.8", "@fontsource-variable/inter": "5.2.8", @@ -62,9 +62,9 @@ "jsonwebtoken": "9.0.2", "ldapts": "8.1.7", "libsodium-wrappers-sumo": "0.7.15", - "mermaid": "10.9.4", - "next": "16.2.3", - "next-intl": "4.9.1", + "mermaid": "10.9.6", + "next": "16.2.9", + "next-intl": "4.9.2", "next-themes": "0.2.1", "openpgp": "6.3.0", "prismjs": "1.30.0", @@ -95,8 +95,8 @@ "turndown-plugin-gfm": "1.0.2", "unified": "11.0.5", "unist-util-visit": "5.0.0", - "uuid": "11.1.0", - "ws": "8.19.0", + "uuid": "11.1.1", + "ws": "8.20.1", "zod": "4.1.12", "zustand": "5.0.6" }, @@ -104,7 +104,11 @@ "lodash-es": "4.18.1", "lodash": "4.18.1", "nanoid": "3.3.8", - "**/mermaid": "10.9.4", + "**/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", @@ -114,6 +118,7 @@ "rollup": "4.59.0", "dompurify": "3.4.0", "eslint-visitor-keys": "4.2.1", + "esbuild": "0.28.1", "vite": "6.4.2" }, "devDependencies": { @@ -136,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/yarn.lock b/yarn.lock index cdec683f..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" @@ -161,6 +186,38 @@ resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz#923ca57e173c6b232bbbb07347b1be982f03e783" integrity sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A== +"@chevrotain/cst-dts-gen@11.0.3": + version "11.0.3" + resolved "https://registry.yarnpkg.com/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz#5e0863cc57dc45e204ccfee6303225d15d9d4783" + integrity sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ== + dependencies: + "@chevrotain/gast" "11.0.3" + "@chevrotain/types" "11.0.3" + lodash-es "4.17.21" + +"@chevrotain/gast@11.0.3": + version "11.0.3" + resolved "https://registry.yarnpkg.com/@chevrotain/gast/-/gast-11.0.3.tgz#e84d8880323fe8cbe792ef69ce3ffd43a936e818" + integrity sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q== + dependencies: + "@chevrotain/types" "11.0.3" + lodash-es "4.17.21" + +"@chevrotain/regexp-to-ast@11.0.3": + version "11.0.3" + resolved "https://registry.yarnpkg.com/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz#11429a81c74a8e6a829271ce02fc66166d56dcdb" + integrity sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA== + +"@chevrotain/types@11.0.3": + version "11.0.3" + resolved "https://registry.yarnpkg.com/@chevrotain/types/-/types-11.0.3.tgz#f8a03914f7b937f594f56eb89312b3b8f1c91848" + integrity sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ== + +"@chevrotain/utils@11.0.3": + version "11.0.3" + resolved "https://registry.yarnpkg.com/@chevrotain/utils/-/utils-11.0.3.tgz#e39999307b102cff3645ec4f5b3665f5297a2224" + integrity sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ== + "@date-fns/tz@^1.4.1": version "1.4.1" resolved "https://registry.yarnpkg.com/@date-fns/tz/-/tz-1.4.1.tgz#2d905f282304630e07bef6d02d2e7dbf3f0cc4e4" @@ -219,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" @@ -677,14 +474,14 @@ "@eslint/core" "^0.17.0" levn "^0.4.1" -"@excalidraw/excalidraw@0.18.0": - version "0.18.0" - resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.18.0.tgz#9f818e2df80a8735af54f8cc21da67997785532f" - integrity sha512-QkIiS+5qdy8lmDWTKsuy0sK/fen/LRDtbhm2lc2xcFcqhv2/zdg95bYnl+wnwwXGHo7kEmP65BSiMHE7PJ3Zpw== +"@excalidraw/excalidraw@0.18.1": + version "0.18.1" + resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.18.1.tgz#fd6ad752807c814698c5f51dab778845b7e4bba7" + integrity sha512-6i5Gt7IDTOH//qa0Z315Ly5iVRhjWpu2whrlQFqkuwrkKUWgRsMk0P5qdE7bpyDpai7jeLeWYkyj1eVAfni1lw== dependencies: "@braintree/sanitize-url" "6.0.2" "@excalidraw/laser-pointer" "1.3.1" - "@excalidraw/mermaid-to-excalidraw" "1.1.2" + "@excalidraw/mermaid-to-excalidraw" "2.2.2" "@excalidraw/random-username" "1.1.0" "@radix-ui/react-popover" "1.1.6" "@radix-ui/react-tabs" "1.0.2" @@ -724,13 +521,14 @@ resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb" integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== -"@excalidraw/mermaid-to-excalidraw@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.2.tgz#74d9507971976a7d3d960a1b2e8fb49a9f1f0d22" - integrity sha512-hAFv/TTIsOdoy0dL5v+oBd297SQ+Z88gZ5u99fCIFuEMHfQuPgLhU/ztKhFSTs7fISwVo6fizny/5oQRR3d4tQ== +"@excalidraw/mermaid-to-excalidraw@2.2.2": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.2.2.tgz#ee6b597a0d95b9a76f7ae41ce0e3733a9b96e4a0" + integrity sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg== dependencies: "@excalidraw/markdown-to-text" "0.1.2" - mermaid "10.9.3" + "@mermaid-js/parser" "^0.6.3" + mermaid "^11.12.1" nanoid "4.0.2" "@excalidraw/random-username@1.1.0": @@ -1072,6 +870,13 @@ resolved "https://registry.yarnpkg.com/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz#8ace5259254426ccef57f3175bc64ed7095ed919" integrity sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw== +"@mermaid-js/parser@^0.6.3": + version "0.6.3" + resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-0.6.3.tgz#3ce92dad2c5d696d29e11e21109c66a7886c824e" + integrity sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA== + dependencies: + langium "3.3.1" + "@mixmark-io/domino@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@mixmark-io/domino/-/domino-2.2.0.tgz#4e8ec69bf1afeb7a14f0628b7e2c0f35bdb336c3" @@ -1086,57 +891,57 @@ "@emnapi/runtime" "^1.4.3" "@tybys/wasm-util" "^0.10.0" -"@next/env@16.2.3": - version "16.2.3" - resolved "https://registry.yarnpkg.com/@next/env/-/env-16.2.3.tgz#eda120ae25aa43b3ff9c0621f5fa6e10e46ef749" - integrity sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA== +"@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.3": - version "16.2.3" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.3.tgz#ec4fea25a921dce0847a2b8d7df419ea49615172" - integrity sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg== - -"@next/swc-darwin-x64@16.2.3": - version "16.2.3" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.3.tgz#de3d5281f8ca81ef23527d93e81229e6f85c4ec7" - integrity sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ== - -"@next/swc-linux-arm64-gnu@16.2.3": - version "16.2.3" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.3.tgz#dbd85b17dd94e23a676084089b5b383bbf9d346c" - integrity sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q== - -"@next/swc-linux-arm64-musl@16.2.3": - version "16.2.3" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.3.tgz#a2361a6e741c64c8e6cac347631e4001150f1711" - integrity sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw== - -"@next/swc-linux-x64-gnu@16.2.3": - version "16.2.3" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.3.tgz#d356deb1ae924d1e3a5071d64f5be0e3f1e916ac" - integrity sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ== - -"@next/swc-linux-x64-musl@16.2.3": - version "16.2.3" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.3.tgz#3b307a0691995a8fa323d32a83eb100e3ac03358" - integrity sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw== - -"@next/swc-win32-arm64-msvc@16.2.3": - version "16.2.3" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.3.tgz#eae5f6f105d0c855911821be74931f755761dc6d" - integrity sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw== - -"@next/swc-win32-x64-msvc@16.2.3": - version "16.2.3" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.3.tgz#aff6de2107cb29c9e8f3242e43f432d00dbea0e0" - integrity sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw== +"@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" @@ -1884,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== @@ -2828,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" @@ -3123,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" @@ -3254,10 +3061,10 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== -brace-expansion@^5.0.2: - version "5.0.5" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" - integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== +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== dependencies: balanced-match "^4.0.2" @@ -3376,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== @@ -3409,6 +3216,25 @@ character-reference-invalid@^2.0.0: resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz#85c66b041e43b47210faf401278abf808ac45cb9" integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw== +chevrotain-allstar@~0.3.0: + version "0.3.1" + resolved "https://registry.yarnpkg.com/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz#b7412755f5d83cc139ab65810cdb00d8db40e6ca" + integrity sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw== + dependencies: + lodash-es "^4.17.21" + +chevrotain@~11.0.3: + version "11.0.3" + resolved "https://registry.yarnpkg.com/chevrotain/-/chevrotain-11.0.3.tgz#88ffc1fb4b5739c715807eaeedbbf200e202fc1b" + integrity sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw== + dependencies: + "@chevrotain/cst-dts-gen" "11.0.3" + "@chevrotain/gast" "11.0.3" + "@chevrotain/regexp-to-ast" "11.0.3" + "@chevrotain/types" "11.0.3" + "@chevrotain/utils" "11.0.3" + lodash-es "4.17.21" + "chokidar@>=3.0.0 <4.0.0", chokidar@^3.5.3: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" @@ -3891,7 +3717,7 @@ d3-time-format@^3.0.0: d3-selection "2 - 3" d3-transition "2 - 3" -d3@^7.4.0, d3@^7.8.2: +d3@^7.4.0, d3@^7.9.0: version "7.9.0" resolved "https://registry.yarnpkg.com/d3/-/d3-7.9.0.tgz#579e7acb3d749caf8860bd1741ae8d371070cd5d" integrity sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA== @@ -3927,12 +3753,12 @@ d3@^7.4.0, d3@^7.8.2: d3-transition "3" d3-zoom "3" -dagre-d3-es@7.0.10: - version "7.0.10" - resolved "https://registry.yarnpkg.com/dagre-d3-es/-/dagre-d3-es-7.0.10.tgz#19800d4be674379a3cd8c86a8216a2ac6827cadc" - integrity sha512-qTCQmEhcynucuaZgY5/+ti3X/rnszKZhEQH/ZdWdtP1tA/y3VoHJzcVrO9pjjJCNpigfscAtoUB5ONcd2wNn0A== +dagre-d3-es@7.0.13: + version "7.0.13" + resolved "https://registry.yarnpkg.com/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz#acfb4b449f6dcdd48d8ea8081a6d8c59bc8128c3" + integrity sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q== dependencies: - d3 "^7.8.2" + d3 "^7.9.0" lodash-es "^4.17.21" damerau-levenshtein@^1.0.8: @@ -4113,7 +3939,7 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -dompurify@3.4.0, "dompurify@^3.0.5 <3.1.7": +dompurify@3.4.0, dompurify@^3.2.4: version "3.4.0" resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.0.tgz#b1fc33ebdadb373241621e0a30e4ad81573dfd0b" integrity sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg== @@ -4282,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" @@ -4330,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" @@ -4441,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" @@ -4686,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== @@ -5224,10 +4986,10 @@ iconv-lite@0.6: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -icu-minify@^4.9.1: - version "4.9.1" - resolved "https://registry.yarnpkg.com/icu-minify/-/icu-minify-4.9.1.tgz#95504d2147713e851755ef810234cf4f4d5457e7" - integrity sha512-6NkfF9GHHFouqnz+wuiLjCWQiyxoEyJ5liUv4Jxxo/8wyhV7MY0L0iTEGDAVEa4aAD58WqTxFMa20S5nyMjwNw== +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" @@ -5662,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" @@ -5795,6 +5557,17 @@ kolorist@1.8.0: resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.8.0.tgz#edddbbbc7894bc13302cdf740af6374d4a04743c" integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ== +langium@3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/langium/-/langium-3.3.1.tgz#da745a40d5ad8ee565090fed52eaee643be4e591" + integrity sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w== + dependencies: + chevrotain "~11.0.3" + chevrotain-allstar "~0.3.0" + vscode-languageserver "~9.0.1" + vscode-languageserver-textdocument "~1.0.11" + vscode-uri "~3.0.8" + language-subtag-registry@^0.3.20: version "0.3.23" resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz#23529e04d9e3b74679d70142df3fd2eb6ec572e7" @@ -5894,7 +5667,7 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash-es@4, 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== @@ -5990,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" @@ -6243,10 +6016,10 @@ merge2@^1.3.0: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -mermaid@10.9.3, mermaid@10.9.4: - version "10.9.4" - resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.4.tgz#985fd4b6d73ae795b87f0b32f620a56d3d6bf1f8" - integrity sha512-VIG2B0R9ydvkS+wShA8sXqkzfpYglM2Qwj7VyUeqzNVqSGPoP/tcaUr3ub4ESykv8eqQJn3p99bHNvYdg3gCHQ== +mermaid@10.9.6, mermaid@^11.12.1: + version "10.9.6" + resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.6.tgz#75e683737b20adaff034e61ee1974dfd1a70c379" + integrity sha512-XRjjRaI4aPCAMpVaOhxIwLYdx3U4Cb6mN0M268ggFAfFRqsvyFW8zxWbEZazN/mPkqsVWThb0oa1UawWK+XMNg== dependencies: "@braintree/sanitize-url" "^6.0.1" "@types/d3-scale" "^4.0.3" @@ -6255,9 +6028,9 @@ mermaid@10.9.3, mermaid@10.9.4: cytoscape-cose-bilkent "^4.1.0" d3 "^7.4.0" d3-sankey "^0.12.3" - dagre-d3-es "7.0.10" + dagre-d3-es "7.0.13" dayjs "^1.11.7" - dompurify "^3.0.5 <3.1.7" + dompurify "^3.2.4" elkjs "^0.9.0" katex "^0.16.9" khroma "^2.0.0" @@ -6266,7 +6039,7 @@ mermaid@10.9.3, mermaid@10.9.4: non-layered-tidy-tree-layout "^2.0.2" stylis "^4.1.3" ts-dedent "^2.2.0" - uuid "^9.0.0" + uuid "^9.0.0 || ^10 || ^11.1.0 || ^12 || ^13 || ^14.0.0" web-worker "^1.2.0" micromark-core-commonmark@^1.0.1: @@ -6788,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.6: +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== @@ -6808,50 +6581,50 @@ negotiator@^1.0.0: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== -next-intl-swc-plugin-extractor@^4.9.1: - version "4.9.1" - resolved "https://registry.yarnpkg.com/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.9.1.tgz#29f8583ec6777a1b79888d045978d8f037e3c03f" - integrity sha512-8whJJ6oxJz8JqkHarggmmuEDyXgC7nEnaPhZD91CJwEWW4xp0AST3Mw17YxvHyP2vAF3taWfFbs1maD+WWtz3w== +next-intl-swc-plugin-extractor@^4.9.2: + 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.1: - version "4.9.1" - resolved "https://registry.yarnpkg.com/next-intl/-/next-intl-4.9.1.tgz#0f75c85e1157d468b8b0d51de0ae30cee51fc6a2" - integrity sha512-N7ga0CjtYcdxNvaKNIi6eJ2mmatlHK5hp8rt0YO2Omoc1m0gean242/Ukdj6+gJNiReBVcYIjK0HZeNx7CV1ug== +next-intl@4.9.2: + version "4.9.2" + resolved "https://registry.yarnpkg.com/next-intl/-/next-intl-4.9.2.tgz#a5a279611197e8b5a3018ef8fd2999589315d345" + integrity sha512-AZoMRsVGLZczB2hisq1OTWmNAYAKwk/jaWH4+9pfl5TCG8kbILZZptZHux9zw7DyN1yzh6X7jmaQvoykHs9Y7Q== dependencies: "@formatjs/intl-localematcher" "^0.8.1" "@parcel/watcher" "^2.4.1" "@swc/core" "^1.15.2" - icu-minify "^4.9.1" + icu-minify "^4.9.2" negotiator "^1.0.0" - next-intl-swc-plugin-extractor "^4.9.1" + next-intl-swc-plugin-extractor "^4.9.2" po-parser "^2.1.1" - use-intl "^4.9.1" + use-intl "^4.9.2" next-themes@0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.2.1.tgz#0c9f128e847979daf6c67f70b38e6b6567856e45" integrity sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A== -next@16.2.3: - version "16.2.3" - resolved "https://registry.yarnpkg.com/next/-/next-16.2.3.tgz#091b6565d46b3fb494fbb5c73d201171890787a5" - integrity sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA== +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.3" + "@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.3" - "@next/swc-darwin-x64" "16.2.3" - "@next/swc-linux-arm64-gnu" "16.2.3" - "@next/swc-linux-arm64-musl" "16.2.3" - "@next/swc-linux-x64-gnu" "16.2.3" - "@next/swc-linux-x64-musl" "16.2.3" - "@next/swc-win32-arm64-msvc" "16.2.3" - "@next/swc-win32-x64-msvc" "16.2.3" + "@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: @@ -7254,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.0.9, postcss@^8.5.3: +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== @@ -7263,15 +7036,6 @@ postcss@8, postcss@^8.0.9, postcss@^8.5.3: picocolors "^1.1.1" source-map-js "^1.2.1" -postcss@8.4.31: - version "8.4.31" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" - integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== - dependencies: - nanoid "^3.3.6" - picocolors "^1.0.0" - source-map-js "^1.0.2" - preact@10: version "10.29.2" resolved "https://registry.yarnpkg.com/preact/-/preact-10.29.2.tgz#3e6069c471718b8d124d1cd67565114532e88d70" @@ -8131,7 +7895,7 @@ slugify@1.6.6: resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b" integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw== -"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2, source-map-js@^1.2.1: +"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== @@ -8165,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" @@ -8501,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== @@ -8809,14 +8573,14 @@ use-debounce@^10.0.4: resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-10.1.1.tgz#b08b596b60a55fd4c18b44b37fdc02f058baf30a" integrity sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ== -use-intl@^4.9.1: - version "4.9.1" - resolved "https://registry.yarnpkg.com/use-intl/-/use-intl-4.9.1.tgz#230eea8bb62cfb2ab021ba9c65e546b8f92b07de" - integrity sha512-iGVV/xFYlhe3btafRlL8RPLD2Jsuet4yqn9DR6LWWbMhULsJnXgLonDkzDmsAIBIwFtk02oJuX/Ox2vwHKF+UQ== +use-intl@^4.9.2: + 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.9.1" + icu-minify "^4.13.0" intl-messageformat "^11.1.0" use-sidecar@^1.1.3: @@ -8837,15 +8601,10 @@ 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.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" - integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A== - -uuid@^9.0.0: - version "9.0.1" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" - integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== +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== uvu@^0.5.0: version "0.5.6" @@ -8901,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== @@ -8915,32 +8674,67 @@ 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: + version "8.2.0" + resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz#f43dfa35fb51e763d17cd94dcca0c9458f35abf9" + integrity sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA== + +vscode-languageserver-protocol@3.17.5: + version "3.17.5" + resolved "https://registry.yarnpkg.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz#864a8b8f390835572f4e13bd9f8313d0e3ac4bea" + integrity sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg== + dependencies: + vscode-jsonrpc "8.2.0" + vscode-languageserver-types "3.17.5" + +vscode-languageserver-textdocument@~1.0.11: + version "1.0.12" + resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz#457ee04271ab38998a093c68c2342f53f6e4a631" + integrity sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA== + +vscode-languageserver-types@3.17.5: + version "3.17.5" + resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz#3273676f0cf2eab40b3f44d085acbb7f08a39d8a" + integrity sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg== + +vscode-languageserver@~9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz#500aef82097eb94df90d008678b0b6b5f474015b" + integrity sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g== + dependencies: + vscode-languageserver-protocol "3.17.5" + +vscode-uri@~3.0.8: + version "3.0.8" + resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f" + integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw== + w3c-keyname@^2.2.0: version "2.2.8" resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5" @@ -9080,10 +8874,10 @@ wrap-ansi@^8.1.0: string-width "^5.0.1" strip-ansi "^7.0.1" -ws@8.19.0: - version "8.19.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.19.0.tgz#ddc2bdfa5b9ad860204f5a72a4863a8895fd8c8b" - integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg== +ws@8.20.1: + version "8.20.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.1.tgz#91a9ae2b312ccf98e0a85ec499b48cef45ab0ddb" + integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w== y18n@^4.0.0: version "4.0.3" From 0f08f4af7ccacd735566cfe90a1ed165b8355582 Mon Sep 17 00:00:00 2001 From: fccview Date: Sat, 13 Jun 2026 19:23:31 +0100 Subject: [PATCH 37/40] continue fixing nitpicks --- .../FeatureComponents/Kanban/CalendarView.tsx | 29 ++++++++----- app/_hooks/dnd/DndProvider.tsx | 41 ++++++++++--------- app/_hooks/kanban/useKanban.ts | 8 ++-- app/_server/actions/checklist-item/drop.ts | 15 ++++++- app/_server/actions/checklist-item/status.ts | 9 ++++ app/_utils/grep-utils.ts | 38 +++++++++++++---- app/_utils/kanban/board-utils.ts | 10 ++++- 7 files changed, 103 insertions(+), 47 deletions(-) diff --git a/app/_components/FeatureComponents/Kanban/CalendarView.tsx b/app/_components/FeatureComponents/Kanban/CalendarView.tsx index 2b799c88..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"; @@ -154,18 +155,24 @@ export const CalendarView = ({ checklist, onItemClick }: CalendarViewProps) => { return (
_openItem(segment.event.itemId)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - _openItem(segment.event.itemId); - } - }} + {...(onItemClick + ? { + role: "button" as const, + tabIndex: 0, + "aria-label": segment.event.title, + onClick: () => _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 cursor-pointer hover:brightness-95 dark:hover:brightness-110 transition-[filter,opacity]", + "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", diff --git a/app/_hooks/dnd/DndProvider.tsx b/app/_hooks/dnd/DndProvider.tsx index 5b82fe44..508e40c6 100644 --- a/app/_hooks/dnd/DndProvider.tsx +++ b/app/_hooks/dnd/DndProvider.tsx @@ -177,27 +177,30 @@ export const DndProvider = ({ const session = sessionRef.current; if (!session) return; - 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); + 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(); }, [_teardown], ); diff --git a/app/_hooks/kanban/useKanban.ts b/app/_hooks/kanban/useKanban.ts index df72aef0..9fbc1589 100644 --- a/app/_hooks/kanban/useKanban.ts +++ b/app/_hooks/kanban/useKanban.ts @@ -34,10 +34,10 @@ export const useKanbanBoard = ({ 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; diff --git a/app/_server/actions/checklist-item/drop.ts b/app/_server/actions/checklist-item/drop.ts index 7fba77d2..dc4ca130 100644 --- a/app/_server/actions/checklist-item/drop.ts +++ b/app/_server/actions/checklist-item/drop.ts @@ -20,15 +20,26 @@ export const dropItem = async ( const uuid = formData.get("uuid") as string; const itemId = formData.get("itemId") as string; const targetStatus = formData.get("targetStatus") as string; - const targetIndex = Number(formData.get("targetIndex")); + const targetIndexRaw = formData.get("targetIndex"); - if (!uuid || !itemId || !targetStatus || Number.isNaN(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) { diff --git a/app/_server/actions/checklist-item/status.ts b/app/_server/actions/checklist-item/status.ts index be217ab5..d2efc3cc 100644 --- a/app/_server/actions/checklist-item/status.ts +++ b/app/_server/actions/checklist-item/status.ts @@ -55,6 +55,15 @@ export const updateItemStatus = async ( 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); 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/kanban/board-utils.ts b/app/_utils/kanban/board-utils.ts index 3b24434c..26dfdf0a 100644 --- a/app/_utils/kanban/board-utils.ts +++ b/app/_utils/kanban/board-utils.ts @@ -10,14 +10,20 @@ const _renumber = (items: Item[]): Item[] => 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 = - [...statusList].sort((a, b) => a.order - b.order)[0]?.id || TaskStatus.TODO; + const firstStatus = _firstStatusId(statuses); const validIds = statusList.map((s) => s.id); return items.filter((item) => { From ad55b04b8baf608b587d4ef323536b02cbc4925d Mon Sep 17 00:00:00 2001 From: fccview Date: Sat, 13 Jun 2026 19:33:21 +0100 Subject: [PATCH 38/40] last two nitpicks --- app/_translations/pt.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/_translations/pt.json b/app/_translations/pt.json index 1f131754..94eabb48 100644 --- a/app/_translations/pt.json +++ b/app/_translations/pt.json @@ -571,6 +571,14 @@ "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", @@ -732,6 +740,11 @@ "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", From e732198ebf0ecaeea600fc478eb41cb42c688b7c Mon Sep 17 00:00:00 2001 From: fccview Date: Sat, 13 Jun 2026 19:59:09 +0100 Subject: [PATCH 39/40] add more endpoints --- app/_utils/api-item.ts | 65 ++++++++++ .../[listId]/items/[itemIndex]/route.ts | 113 ++++++++++++++++-- app/api/checklists/route.ts | 35 +----- .../tasks/[taskId]/items/[itemIndex]/route.ts | 62 ++++++++++ app/api/tasks/[taskId]/route.ts | 24 +--- app/api/tasks/route.ts | 24 +--- howto/API.md | 95 +++++++++++++++ public/api/components/schemas.yaml | 58 +++++++++ public/api/paths/checklists.yaml | 20 +++- public/api/paths/tasks.yaml | 34 ++++++ tests/api/items.test.ts | 55 ++++++++- tests/api/tasks.test.ts | 93 +++++++++++++- 12 files changed, 594 insertions(+), 84 deletions(-) create mode 100644 app/_utils/api-item.ts 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/api/checklists/[listId]/items/[itemIndex]/route.ts b/app/api/checklists/[listId]/items/[itemIndex]/route.ts index 41d0c95b..7a0004d3 100644 --- a/app/api/checklists/[listId]/items/[itemIndex]/route.ts +++ b/app/api/checklists/[listId]/items/[itemIndex]/route.ts @@ -6,9 +6,12 @@ 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 }> }, @@ -17,11 +20,28 @@ export async function PATCH( return withApiAuth(request, async (user) => { try { const body = await request.json(); - const { text, description } = body; + 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 (!text && description === undefined) { + if (!hasField) { return NextResponse.json( - { error: "Provide at least 'text' or 'description' to update" }, + { error: "Provide at least one field to update" }, { status: 400 }, ); } @@ -44,6 +64,63 @@ export async function PATCH( ); } + 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 }); @@ -53,7 +130,10 @@ export async function PATCH( for (const idx of indexPath) { if (isNaN(idx) || idx < 0) { - return NextResponse.json({ error: "Invalid item index" }, { status: 400 }); + return NextResponse.json( + { error: "Invalid item index" }, + { status: 400 }, + ); } } @@ -62,7 +142,10 @@ export async function PATCH( for (const idx of indexPath) { if (idx >= currentItems.length) { - return NextResponse.json({ error: "Item index out of range" }, { status: 400 }); + return NextResponse.json( + { error: "Item index out of range" }, + { status: 400 }, + ); } item = currentItems[idx]; currentItems = item.children || []; @@ -77,7 +160,20 @@ export async function PATCH( formData.append("itemId", item.id); formData.append("category", list.category || "Uncategorized"); if (text) formData.append("text", text); - if (description !== undefined) formData.append("description", description ?? ""); + 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); @@ -91,7 +187,10 @@ export async function PATCH( return NextResponse.json({ success: true }); } catch (error) { console.error("API Error:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); } }); } diff --git a/app/api/checklists/route.ts b/app/api/checklists/route.ts index f542a5a4..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"; @@ -45,36 +46,6 @@ 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, @@ -83,7 +54,7 @@ export async function GET(request: NextRequest) { 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/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..2c114a9b 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": "todo", + "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": "todo", + "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/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 6f0d4751..c3f0883a 100644 --- a/public/api/paths/checklists.yaml +++ b/public/api/paths/checklists.yaml @@ -202,7 +202,7 @@ patch: summary: Update Checklist Item description: | - Updates the text or description of a checklist item. + 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: @@ -234,6 +234,24 @@ 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 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) From 330709179e45a729654ad6f837b24bbb2e8cd0a9 Mon Sep 17 00:00:00 2001 From: fccview Date: Sun, 14 Jun 2026 08:03:19 +0100 Subject: [PATCH 40/40] latest nitpick, the bunny is a savage --- app/api/checklists/[listId]/items/[itemIndex]/route.ts | 2 +- howto/API.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/api/checklists/[listId]/items/[itemIndex]/route.ts b/app/api/checklists/[listId]/items/[itemIndex]/route.ts index 7a0004d3..00875cf6 100644 --- a/app/api/checklists/[listId]/items/[itemIndex]/route.ts +++ b/app/api/checklists/[listId]/items/[itemIndex]/route.ts @@ -159,7 +159,7 @@ export async function PATCH( formData.append("listId", list.id); formData.append("itemId", item.id); formData.append("category", list.category || "Uncategorized"); - if (text) formData.append("text", text); + if (text !== undefined && text !== null) formData.append("text", text); if (description !== undefined) formData.append("description", description ?? ""); if (priority !== undefined) formData.append("priority", priority ?? ""); diff --git a/howto/API.md b/howto/API.md index 2c114a9b..40e89dbf 100644 --- a/howto/API.md +++ b/howto/API.md @@ -393,7 +393,7 @@ Items can contain a `children` array with nested sub-items: "index": 0, "text": "Parent Task", "completed": false, - "status": "todo", + "status": "in_progress", "description": "Implementation notes", "priority": "high", "score": 5, @@ -406,7 +406,7 @@ Items can contain a `children` array with nested sub-items: "lastModifiedAt": "2026-06-02T10:30:00.000Z", "history": [ { - "status": "todo", + "status": "in_progress", "timestamp": "2026-06-01T09:00:00.000Z", "user": "alice" }