@@ -371,160 +133,320 @@ export const LinksTab = ({ linkIndex, notes, checklists }: LinksTabProps) => {
return (
-
-
-
-
-
-
-
- {totalNodes}
-
-
- {t("checklists.totalItems")}
-
-
-
-
-
-
-
-
-
-
- {totalLinks}
-
-
- {t("profile.connectionsTab")}
-
-
-
+ function Stats({
+ totalNodes,
+ totalLinks,
+ connectedItems,
+ }: {
+ totalNodes: number;
+ totalLinks: number;
+ connectedItems: number;
+ }) {
+ return (
+
+
+ } value={totalNodes} label={t("checklists.totalItems")} />
+ } value={totalLinks} label={t("profile.connectionsTab")} />
+ } value={connectedItems} label={t("profile.connectedItems")} />
+
+
+ );
+ }
-
-
-
-
-
-
- {networkData.nodes.filter((n) => n.connectionCount > 0).length}
-
-
- {t("profile.connectedItems")}
-
-
-
+ function Stat({
+ icon,
+ value,
+ label,
+ }: {
+ icon: React.ReactNode;
+ value: number;
+ label: string;
+ }) {
+ return (
+
+ );
+ }
+};
-
-
-
-
- {t("profile.linkNetwork")}
-
-
-
-
-
-
-
{t("checklists.title")}
-
-
-
-
-
+interface ControlsProps {
+ filters: ConnectionGraphFilters;
+ updateFilters: (next: Partial
) => void;
+ showLabels: boolean;
+ setShowLabels: (value: boolean) => void;
+ showArrows: boolean;
+ setShowArrows: (value: boolean) => void;
+ nodeScale: number;
+ setNodeScale: (value: number) => void;
+ linkWidth: number;
+ setLinkWidth: (value: number) => void;
+ linkDistance: number;
+ setLinkDistance: (value: number) => void;
+ repelForce: number;
+ setRepelForce: (value: number) => void;
+}
- {truncatedTotal > 0 && (
-
- Only showing partial content due to performance reasons.
-
- )}
+const Controls = ({
+ filters,
+ updateFilters,
+ showLabels,
+ setShowLabels,
+ showArrows,
+ setShowArrows,
+ nodeScale,
+ setNodeScale,
+ linkWidth,
+ setLinkWidth,
+ linkDistance,
+ setLinkDistance,
+ repelForce,
+ setRepelForce,
+}: ControlsProps) => {
+ const t = useTranslations();
-
- e.distance}
- centeringStrength={0.3}
- repulsivity={10}
- nodeSize={(n: any) => n.size}
- activeNodeSize={(n: any) => n.size * 1.5}
- nodeComponent={(props: any) => (
-
- )}
- linkThickness={2}
- linkColor={BORDER_COLOR}
- motionConfig={{
- mass: 1,
- tension: 120,
- friction: 14,
- }}
- />
-
+ return (
+
+
+
+
- {hoveredNode && (
-
-
- {hoveredNode.data.label}
-
-
- {hoveredNode.data.type} • {hoveredNode.data.connectionCount}{" "}
- {t("profile.connection", {
- count: hoveredNode.data.connectionCount,
- })}
- {hoveredNode.data.connectionCount >= 5
- ? ` (${t("profile.highlyConnected")})`
- : hoveredNode.data.connectionCount >= 2
- ? ` (${t("profile.moderatelyConnected")})`
- : hoveredNode.data.connectionCount === 0
- ? ` (${t("profile.isolated")})`
- : ""}
-
-
- {hoveredNode.data.id}
-
-
- )}
-
+
+ updateFilters({ showNotes: checked })}
+ />
+ updateFilters({ showChecklists: checked })}
+ />
+ updateFilters({ showOrphans: checked })}
+ />
+
+
+ updateFilters({ showTagLinks: checked })}
+ />
+
+
+
+
+
+
+
);
};
+
+const Toggle = ({
+ label,
+ checked,
+ onChange,
+ disabled = false,
+}: {
+ label: string;
+ checked: boolean;
+ onChange: (value: boolean) => void;
+ disabled?: boolean;
+}) => (
+
+);
+
+const Range = ({
+ label,
+ value,
+ min,
+ max,
+ step,
+ onChange,
+}: {
+ label: string;
+ value: number;
+ min: number;
+ max: number;
+ step: number;
+ onChange: (value: number) => void;
+}) => (
+
+);
diff --git a/app/_components/FeatureComponents/Profile/Parts/UserPreferencesTab.tsx b/app/_components/FeatureComponents/Profile/Parts/UserPreferencesTab.tsx
index d58076f8..70771574 100644
--- a/app/_components/FeatureComponents/Profile/Parts/UserPreferencesTab.tsx
+++ b/app/_components/FeatureComponents/Profile/Parts/UserPreferencesTab.tsx
@@ -28,6 +28,7 @@ import {
QuickCreateNotes,
HideConnectionIndicator,
HideStatusOnCards,
+ HideMobileStatusDropdown,
CodeBlockStyle,
} from "@/app/_types";
import { Modes } from "@/app/_types/enums";
@@ -73,6 +74,7 @@ const getSettingsFromUser = (user: SanitisedUser | null): Partial
quickCreateNotesCategory: user?.quickCreateNotesCategory || "",
hideConnectionIndicator: user?.hideConnectionIndicator || "disable",
hideStatusOnCards: user?.hideStatusOnCards || "disable",
+ hideMobileStatusDropdown: user?.hideMobileStatusDropdown || "disable",
codeBlockStyle: user?.codeBlockStyle || "default",
});
@@ -166,7 +168,10 @@ export const UserPreferencesTab = ({ noteCategories, localeOptions }: SettingsTa
"defaultChecklistFilter",
"checklistItemClickAction",
]);
- const hasKanbanChanges = hasChanges(["hideStatusOnCards"]);
+ const hasKanbanChanges = hasChanges([
+ "hideStatusOnCards",
+ "hideMobileStatusDropdown",
+ ]);
const validateAndSave = async >(
settings: T,
@@ -947,7 +952,7 @@ export const UserPreferencesTab = ({ noteCategories, localeOptions }: SettingsTa
+
+
+
+
+ handleSettingChange(
+ "hideMobileStatusDropdown",
+ value as HideMobileStatusDropdown
+ )
+ }
+ options={[
+ { id: "disable", name: t('settings.showMobileStatusDropdown') },
+ { id: "enable", name: t('settings.hideMobileStatusDropdown') },
+ ]}
+ placeholder={t('settings.selectMobileStatusDropdown')}
+ className="w-full"
+ />
+
+ {t('settings.hideMobileStatusDropdownDescription')}
+
+
diff --git a/app/_components/GlobalComponents/Auth/LoginForm.tsx b/app/_components/GlobalComponents/Auth/LoginForm.tsx
index 392d60a8..99ddbd65 100644
--- a/app/_components/GlobalComponents/Auth/LoginForm.tsx
+++ b/app/_components/GlobalComponents/Auth/LoginForm.tsx
@@ -9,6 +9,7 @@ import { Button } from "@/app/_components/GlobalComponents/Buttons/Button";
import { Orbit01Icon } from "hugeicons-react";
import { Logo } from "@/app/_components/GlobalComponents/Layout/Logo/Logo";
import { useTranslations } from "next-intl";
+import { Toggle } from "@/app/_components/GlobalComponents/FormElements/Toggle";
export default function LoginForm({
ssoEnabled,
@@ -17,14 +18,17 @@ export default function LoginForm({
ssoEnabled: boolean;
showRegisterLink?: boolean;
}) {
- const t = useTranslations('auth');
+ const t = useTranslations("auth");
const searchParams = useSearchParams();
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isSsoLoading, setIsSsoLoading] = useState(false);
- const [attemptsRemaining, setAttemptsRemaining] = useState(null);
+ const [attemptsRemaining, setAttemptsRemaining] = useState(
+ null,
+ );
const [lockedUntil, setLockedUntil] = useState(null);
const [countdown, setCountdown] = useState(0);
+ const [rememberMe, setRememberMe] = useState(true);
const { isDemoMode, appVersion, isRwMarkable } = useAppMode();
useEffect(() => {
@@ -87,7 +91,7 @@ export default function LoginForm({
throw error;
}
}
- setError(t('errorOccurred'));
+ setError(t("errorOccurred"));
setIsLoading(false);
}
}
@@ -96,7 +100,7 @@ export default function LoginForm({
- {t('welcomeBack')}
+ {t("welcomeBack")}
@@ -112,10 +116,10 @@ export default function LoginForm({
>
{isSsoLoading ? (
<>
-
{t('signingIn')}
+
{t("signingIn")}
>
) : (
- t('signInWithSSO')
+ t("signInWithSSO")
)}
@@ -123,9 +127,7 @@ export default function LoginForm({
-
- {t('orContinueWith')}
-
+ {t("orContinueWith")}
@@ -133,8 +135,8 @@ export default function LoginForm({
{isDemoMode && (
- {t('usernameLabel')}: demo
- {t('passwordLabel')}: demodemo
+ {t("usernameLabel")}: demo
+ {t("passwordLabel")}: demodemo
)}
@@ -148,29 +150,32 @@ export default function LoginForm({
{countdown > 0 && (
- {t('accountLocked', { seconds: countdown })}
+ {t("accountLocked", { seconds: countdown })}
)}
- {!countdown && attemptsRemaining !== null && attemptsRemaining > 0 && attemptsRemaining < 4 && (
-
-
- {t('attemptsRemaining', { count: attemptsRemaining })}
-
-
- )}
+ {!countdown &&
+ attemptsRemaining !== null &&
+ attemptsRemaining > 0 &&
+ attemptsRemaining < 4 && (
+
+
+ {t("attemptsRemaining", { count: attemptsRemaining })}
+
+
+ )}
0}
className="mt-1"
- placeholder={t('enterUsername')}
+ placeholder={t("enterUsername")}
defaultValue=""
autoComplete="username"
/>
@@ -179,18 +184,35 @@ export default function LoginForm({
0}
className="mt-1"
- placeholder={t('enterPassword')}
+ placeholder={t("enterPassword")}
autoComplete="current-password"
defaultValue=""
/>
+
+ 0}
+ />
+
+ setRememberMe(!rememberMe)}
+ className="text-sm lg:text-xs text-muted-foreground cursor-pointer select-none"
+ >
+ {t("rememberMe")}
+
+
+
{showRegisterLink && (
)}
{appVersion && (
)}
diff --git a/app/_components/GlobalComponents/Dropdowns/Dropdown.tsx b/app/_components/GlobalComponents/Dropdowns/Dropdown.tsx
index b215853a..e64dd18d 100644
--- a/app/_components/GlobalComponents/Dropdowns/Dropdown.tsx
+++ b/app/_components/GlobalComponents/Dropdowns/Dropdown.tsx
@@ -10,6 +10,7 @@ interface DropdownOption {
name: React.ReactNode;
icon?: React.ComponentType<{ className?: string }>;
colors?: { background: string; primary: string } | null;
+ className?: string;
}
interface DropdownProps {
@@ -135,7 +136,8 @@ export const Dropdown = ({
onClick={(e) => handleSelect(e, option.id.toString())}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-md lg:text-sm hover:bg-accent hover:text-accent-foreground",
- option.id === value && "bg-accent text-accent-foreground"
+ option.id === value && "bg-accent text-accent-foreground",
+ option.className
)}
>
{option.icon &&
}
diff --git a/app/_components/GlobalComponents/Modals/Modal.tsx b/app/_components/GlobalComponents/Modals/Modal.tsx
index d627a3ba..b3595ef9 100644
--- a/app/_components/GlobalComponents/Modals/Modal.tsx
+++ b/app/_components/GlobalComponents/Modals/Modal.tsx
@@ -1,7 +1,11 @@
"use client";
import { useRef, useEffect, useState } from "react";
-import { MultiplicationSignIcon } from "hugeicons-react";
+import {
+ MaximizeScreenIcon,
+ MinimizeScreenIcon,
+ MultiplicationSignIcon,
+} from "hugeicons-react";
import { Button } from "../Buttons/Button";
import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
@@ -13,6 +17,8 @@ interface ModalProps {
children: React.ReactNode;
className?: string;
size?: "default" | "fullscreen";
+ allowEnlarge?: boolean;
+ defaultEnlarged?: boolean;
}
export const Modal = ({
@@ -22,10 +28,13 @@ export const Modal = ({
children,
className = "",
size = "default",
+ allowEnlarge = false,
+ defaultEnlarged = false,
}: ModalProps) => {
const t = useTranslations();
const modalRef = useRef
(null);
const [portalElement, setPortalElement] = useState(null);
+ const [isEnlarged, setIsEnlarged] = useState(defaultEnlarged);
useEffect(() => {
let portalRoot = document.getElementById("modal-portal-root");
@@ -68,10 +77,21 @@ export const Modal = ({
};
}, [isOpen, onClose]);
+ useEffect(() => {
+ if (isOpen) {
+ setIsEnlarged(defaultEnlarged);
+ }
+ }, [isOpen, defaultEnlarged]);
+
if (!isOpen || !portalElement) {
return null;
}
+ const isFullscreenLayout = size === "fullscreen" || isEnlarged;
+
+ const enlargedDesktopClasses =
+ "lg:!w-[calc(100vw-2.5em)] lg:!h-[calc(100dvh-2.5em)] lg:!max-w-[calc(100vw-2.5em)] lg:!max-h-[calc(100dvh-2.5em)]";
+
const modalContent = (
e.stopPropagation()}
className={`
jotty-modal-content
- bg-background border border-border w-full shadow-xl
+ bg-background border border-border shadow-xl
translate-y-0 lg:translate-y-0 transition-all duration-200
+ w-full
${size === "fullscreen"
- ? "lg:w-[95vw] h-[90vh] flex flex-col lg:rounded-jotty rounded-t-xl overflow-hidden"
+ ? "h-[90vh] flex flex-col lg:rounded-jotty rounded-t-xl overflow-hidden lg:w-[95vw]"
: "lg:max-w-md lg:rounded-md rounded-t-xl p-6"}
${className}
+ ${isEnlarged ? enlargedDesktopClasses : ""}
`}
>
-
+
-
+
{title}
-
+
+ {allowEnlarge && (
+
+ )}
+
+
-
diff --git a/app/_components/GlobalComponents/Sidebar/SidebarWrapper.tsx b/app/_components/GlobalComponents/Sidebar/SidebarWrapper.tsx
index 7b9c7aa6..2bf1d4d2 100644
--- a/app/_components/GlobalComponents/Sidebar/SidebarWrapper.tsx
+++ b/app/_components/GlobalComponents/Sidebar/SidebarWrapper.tsx
@@ -115,7 +115,7 @@ export const SidebarWrapper = ({
diff --git a/app/_consts/dnd.ts b/app/_consts/dnd.ts
new file mode 100644
index 00000000..efca5835
--- /dev/null
+++ b/app/_consts/dnd.ts
@@ -0,0 +1,8 @@
+export const DRAG_THRESHOLD_PX = 8;
+export const LONG_PRESS_MS = 250;
+export const TOUCH_SLOP_PX = 10;
+export const EDGE_SCROLL_PX = 48;
+export const MAX_SCROLL_STEP = 14;
+export const CARD_GAP_PX = 8;
+export const INTERACTIVE_SELECTOR =
+ "button, input, textarea, select, a, [data-no-dnd]";
diff --git a/app/_hooks/dnd/DndProvider.tsx b/app/_hooks/dnd/DndProvider.tsx
new file mode 100644
index 00000000..508e40c6
--- /dev/null
+++ b/app/_hooks/dnd/DndProvider.tsx
@@ -0,0 +1,382 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ ReactNode,
+} from "react";
+import { createPortal } from "react-dom";
+import { DragRect, DropResult } from "@/app/_types/dnd";
+import {
+ DRAG_THRESHOLD_PX,
+ LONG_PRESS_MS,
+ TOUCH_SLOP_PX,
+ INTERACTIVE_SELECTOR,
+} from "@/app/_consts/dnd";
+import { findDropList, projectIndex } from "@/app/_utils/dnd/dnd-math";
+import { createRegistry, RectRegistry } from "@/app/_utils/dnd/rect-registry";
+import { createScroller } from "@/app/_utils/dnd/auto-scroll";
+import { useDragStore } from "@/app/_utils/dnd/drag-store";
+
+interface BeginArgs {
+ id: string;
+ listId: string;
+ index: number;
+}
+
+interface DndContextValue {
+ registry: RectRegistry;
+ begin: (event: React.PointerEvent, args: BeginArgs) => void;
+}
+
+interface DndProviderProps {
+ onDrop: (result: DropResult) => void;
+ onDragStart?: (itemId: string) => void;
+ onDragCancel?: (itemId: string) => void;
+ renderGhost: (itemId: string, rect: DragRect) => ReactNode;
+ children: ReactNode;
+}
+
+interface DragSession {
+ pointerId: number;
+ isTouch: boolean;
+ itemId: string;
+ listId: string;
+ index: number;
+ sourceEl: HTMLElement;
+ startX: number;
+ startY: number;
+ lastX: number;
+ lastY: number;
+ grabX: number;
+ grabY: number;
+ longPressTimer: number | null;
+ dragging: boolean;
+}
+
+const DndContext = createContext
(null);
+
+export const useDndContext = (): DndContextValue => {
+ const ctx = useContext(DndContext);
+ if (!ctx) throw new Error("dnd hooks must be used inside a DndProvider");
+ return ctx;
+};
+
+export const DndProvider = ({
+ onDrop,
+ onDragStart,
+ onDragCancel,
+ renderGhost,
+ children,
+}: DndProviderProps) => {
+ const registryRef = useRef(null);
+ if (!registryRef.current) registryRef.current = createRegistry();
+ const registry = registryRef.current;
+
+ const scrollerRef = useRef(createScroller());
+ const sessionRef = useRef(null);
+ const ghostNodeRef = useRef(null);
+ const frameRef = useRef(null);
+ const scrollPosRef = useRef(new Map());
+ const callbacksRef = useRef({ onDrop, onDragStart, onDragCancel });
+ callbacksRef.current = { onDrop, onDragStart, onDragCancel };
+
+ const [ghost, setGhost] = useState<{ id: string; rect: DragRect } | null>(
+ null,
+ );
+
+ const _moveGhost = useCallback((x: number, y: number) => {
+ const session = sessionRef.current;
+ const node = ghostNodeRef.current;
+ if (!session || !node) return;
+ node.style.transform = `translate3d(${x - session.grabX}px, ${
+ y - session.grabY
+ }px, 0)`;
+ }, []);
+
+ const _project = useCallback(() => {
+ const session = sessionRef.current;
+ if (!session || !session.dragging) return;
+ const point = { x: session.lastX, y: session.lastY };
+ const listId = findDropList(point, registry.listRects());
+ if (!listId) {
+ useDragStore.getState().aim(null, null);
+ return;
+ }
+ const index = projectIndex(point.y, registry.itemsOf(listId), session.itemId);
+ useDragStore.getState().aim(listId, index);
+ }, [registry]);
+
+ const _schedule = useCallback(() => {
+ if (frameRef.current !== null) return;
+ frameRef.current = requestAnimationFrame(() => {
+ frameRef.current = null;
+ const session = sessionRef.current;
+ if (!session || !session.dragging) return;
+ _moveGhost(session.lastX, session.lastY);
+ _project();
+ scrollerRef.current.update({ x: session.lastX, y: session.lastY });
+ });
+ }, [_moveGhost, _project]);
+
+ const _blockTouch = useCallback((event: TouchEvent) => {
+ event.preventDefault();
+ }, []);
+
+ const _blockMenu = useCallback((event: Event) => {
+ event.preventDefault();
+ }, []);
+
+ const _onScroll = useCallback(
+ (event: Event) => {
+ const session = sessionRef.current;
+ if (!session || !session.dragging) return;
+ const target =
+ event.target === document
+ ? (document.scrollingElement as HTMLElement | null)
+ : (event.target as HTMLElement);
+ if (!target) return;
+ const prev = scrollPosRef.current.get(target);
+ if (!prev) return;
+ const next = { x: target.scrollLeft, y: target.scrollTop };
+ scrollPosRef.current.set(target, next);
+ registry.shiftBy(next.x - prev.x, next.y - prev.y, target);
+ _schedule();
+ },
+ [registry, _schedule],
+ );
+
+ const _teardown = useCallback(() => {
+ const session = sessionRef.current;
+ if (session?.longPressTimer !== null && session?.longPressTimer !== undefined) {
+ window.clearTimeout(session.longPressTimer);
+ }
+ if (frameRef.current !== null) {
+ cancelAnimationFrame(frameRef.current);
+ frameRef.current = null;
+ }
+ scrollerRef.current.stop();
+ scrollPosRef.current.clear();
+ document.removeEventListener("touchmove", _blockTouch);
+ document.removeEventListener("contextmenu", _blockMenu);
+ window.removeEventListener("scroll", _onScroll, true);
+ document.body.style.userSelect = "";
+ document.body.style.webkitUserSelect = "";
+ sessionRef.current = null;
+ setGhost(null);
+ useDragStore.getState().settle();
+ }, [_blockTouch, _blockMenu, _onScroll]);
+
+ const _finish = useCallback(
+ (cancelled: boolean) => {
+ const session = sessionRef.current;
+ if (!session) return;
+
+ try {
+ if (session.dragging) {
+ const { activeId, sourceListId, sourceIndex, targetListId, targetIndex } =
+ useDragStore.getState();
+ const moved =
+ targetListId !== null &&
+ targetIndex !== null &&
+ !(targetListId === sourceListId && targetIndex === sourceIndex);
+
+ if (!cancelled && moved && activeId && sourceListId) {
+ callbacksRef.current.onDrop({
+ itemId: activeId,
+ sourceListId,
+ sourceIndex,
+ targetListId,
+ targetIndex,
+ });
+ } else {
+ callbacksRef.current.onDragCancel?.(session.itemId);
+ }
+ }
+ } finally {
+ _teardown();
+ }
+ },
+ [_teardown],
+ );
+
+ const _lift = useCallback(() => {
+ const session = sessionRef.current;
+ if (!session || session.dragging) return;
+ session.dragging = true;
+ session.longPressTimer = null;
+
+ try {
+ session.sourceEl.setPointerCapture(session.pointerId);
+ } catch (error) {
+ console.warn("pointer capture failed, continuing drag:", error);
+ }
+
+ registry.snapshot();
+ const sourceRect = registry
+ .itemsOf(session.listId)
+ .find((entry) => entry.id === session.itemId)?.rect;
+ const rect: DragRect = sourceRect || {
+ top: session.startY,
+ left: session.startX,
+ width: 0,
+ height: 0,
+ };
+ session.grabX = session.startX - rect.left;
+ session.grabY = session.startY - rect.top;
+
+ const scrollers = registry.scrollables();
+ scrollPosRef.current.clear();
+ scrollers.forEach((el) =>
+ scrollPosRef.current.set(el, { x: el.scrollLeft, y: el.scrollTop }),
+ );
+ scrollerRef.current.start(scrollers);
+
+ document.addEventListener("touchmove", _blockTouch, { passive: false });
+ document.addEventListener("contextmenu", _blockMenu);
+ window.addEventListener("scroll", _onScroll, true);
+ document.body.style.userSelect = "none";
+ document.body.style.webkitUserSelect = "none";
+
+ useDragStore
+ .getState()
+ .lift(session.itemId, session.listId, session.index, {
+ width: rect.width,
+ height: rect.height,
+ });
+ setGhost({ id: session.itemId, rect });
+ callbacksRef.current.onDragStart?.(session.itemId);
+ _schedule();
+ }, [registry, _blockTouch, _blockMenu, _onScroll, _schedule]);
+
+ useEffect(() => {
+ const onMove = (event: PointerEvent) => {
+ const session = sessionRef.current;
+ if (!session || event.pointerId !== session.pointerId) return;
+ session.lastX = event.clientX;
+ session.lastY = event.clientY;
+
+ if (session.dragging) {
+ _schedule();
+ return;
+ }
+
+ const distance = Math.hypot(
+ event.clientX - session.startX,
+ event.clientY - session.startY,
+ );
+ if (session.isTouch) {
+ if (distance > TOUCH_SLOP_PX) _teardown();
+ return;
+ }
+ if (distance > DRAG_THRESHOLD_PX) _lift();
+ };
+
+ const onUp = (event: PointerEvent) => {
+ const session = sessionRef.current;
+ if (!session || event.pointerId !== session.pointerId) return;
+ _finish(false);
+ };
+
+ const onCancel = (event: PointerEvent) => {
+ const session = sessionRef.current;
+ if (!session || event.pointerId !== session.pointerId) return;
+ _finish(true);
+ };
+
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key !== "Escape") return;
+ const session = sessionRef.current;
+ if (!session || !session.dragging) return;
+ event.preventDefault();
+ event.stopPropagation();
+ _finish(true);
+ };
+
+ const onBlur = () => {
+ if (sessionRef.current) _finish(true);
+ };
+
+ window.addEventListener("pointermove", onMove);
+ window.addEventListener("pointerup", onUp);
+ window.addEventListener("pointercancel", onCancel);
+ window.addEventListener("keydown", onKey, true);
+ window.addEventListener("blur", onBlur);
+ return () => {
+ window.removeEventListener("pointermove", onMove);
+ window.removeEventListener("pointerup", onUp);
+ window.removeEventListener("pointercancel", onCancel);
+ window.removeEventListener("keydown", onKey, true);
+ window.removeEventListener("blur", onBlur);
+ _teardown();
+ };
+ }, [_schedule, _lift, _finish, _teardown]);
+
+ const begin = useCallback(
+ (event: React.PointerEvent, args: BeginArgs) => {
+ if (sessionRef.current) return;
+ if (event.button !== 0) return;
+ const target = event.target as HTMLElement;
+ if (target.closest(INTERACTIVE_SELECTOR)) return;
+
+ const isTouch = event.pointerType === "touch";
+ const session: DragSession = {
+ pointerId: event.pointerId,
+ isTouch,
+ itemId: args.id,
+ listId: args.listId,
+ index: args.index,
+ sourceEl: event.currentTarget as HTMLElement,
+ startX: event.clientX,
+ startY: event.clientY,
+ lastX: event.clientX,
+ lastY: event.clientY,
+ grabX: 0,
+ grabY: 0,
+ longPressTimer: null,
+ dragging: false,
+ };
+ sessionRef.current = session;
+
+ if (isTouch) {
+ session.longPressTimer = window.setTimeout(_lift, LONG_PRESS_MS);
+ }
+ },
+ [_lift],
+ );
+
+ const contextValue = useMemo(
+ () => ({ registry, begin }),
+ [registry, begin],
+ );
+
+ return (
+
+ {children}
+ {ghost &&
+ createPortal(
+
+ {renderGhost(ghost.id, ghost.rect)}
+
,
+ document.body,
+ )}
+
+ );
+};
diff --git a/app/_hooks/dnd/index.tsx b/app/_hooks/dnd/index.tsx
new file mode 100644
index 00000000..ff158314
--- /dev/null
+++ b/app/_hooks/dnd/index.tsx
@@ -0,0 +1,3 @@
+export { DndProvider, useDndContext } from "./DndProvider";
+export { useDragItem } from "./useDragItem";
+export { useDropList } from "./useDropList";
diff --git a/app/_hooks/dnd/useDragItem.ts b/app/_hooks/dnd/useDragItem.ts
new file mode 100644
index 00000000..56e3a987
--- /dev/null
+++ b/app/_hooks/dnd/useDragItem.ts
@@ -0,0 +1,102 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, CSSProperties } from "react";
+import { DragPhase } from "@/app/_types/dnd";
+import { CARD_GAP_PX } from "@/app/_consts/dnd";
+import { displaceFor } from "@/app/_utils/dnd/dnd-math";
+import { useDragStore } from "@/app/_utils/dnd/drag-store";
+import { useDndContext } from "./DndProvider";
+
+interface UseDragItemArgs {
+ id: string;
+ listId: string;
+ index: number;
+ disabled?: boolean;
+ ghost?: boolean;
+}
+
+const BASE_STYLE: CSSProperties = { touchAction: "manipulation" };
+
+export const useDragItem = ({
+ id,
+ listId,
+ index,
+ disabled,
+ ghost,
+}: UseDragItemArgs) => {
+ const { registry, begin } = useDndContext();
+ const elRef = useRef(null);
+
+ const setNodeRef = useCallback((el: HTMLElement | null) => {
+ elRef.current = el;
+ }, []);
+
+ useEffect(() => {
+ const el = elRef.current;
+ if (!el || ghost) return;
+ registry.registerItem(id, listId, index, el);
+ return () => registry.unregisterItem(id);
+ }, [registry, id, listId, index, ghost]);
+
+ const isLifted = useDragStore(
+ (s) => !ghost && s.phase === DragPhase.DRAGGING && s.activeId === id,
+ );
+ const isAway = useDragStore(
+ (s) =>
+ !ghost &&
+ s.phase === DragPhase.DRAGGING &&
+ s.activeId === id &&
+ s.targetListId !== listId,
+ );
+ const dragHeight = useDragStore((s) =>
+ !ghost && s.phase === DragPhase.DRAGGING && s.activeId === id
+ ? s.dragSize.height
+ : 0,
+ );
+ const shiftY = useDragStore((s) => {
+ if (ghost || s.phase !== DragPhase.DRAGGING || s.activeId === id) return 0;
+ if (s.targetListId !== listId) return 0;
+ const sameList = s.sourceListId === listId;
+ const effIndex = sameList && index > s.sourceIndex ? index - 1 : index;
+ return displaceFor(
+ effIndex,
+ sameList ? s.sourceIndex : null,
+ s.targetIndex,
+ s.dragSize.height,
+ );
+ });
+
+ const style = useMemo(() => {
+ if (isLifted && isAway) {
+ return {
+ ...BASE_STYLE,
+ height: 0,
+ marginTop: -CARD_GAP_PX,
+ paddingTop: 0,
+ paddingBottom: 0,
+ borderTopWidth: 0,
+ borderBottomWidth: 0,
+ overflow: "hidden",
+ };
+ }
+ if (isLifted) {
+ return { ...BASE_STYLE, height: dragHeight || undefined };
+ }
+ if (shiftY !== 0) {
+ return { ...BASE_STYLE, transform: `translateY(${shiftY}px)` };
+ }
+ return BASE_STYLE;
+ }, [isLifted, isAway, dragHeight, shiftY]);
+
+ const handleProps = useMemo(
+ () => ({
+ onPointerDown: (event: React.PointerEvent) => {
+ if (disabled || ghost) return;
+ begin(event, { id, listId, index });
+ },
+ }),
+ [begin, disabled, ghost, id, listId, index],
+ );
+
+ return { setNodeRef, handleProps, isLifted, isAway, style };
+};
diff --git a/app/_hooks/dnd/useDropList.ts b/app/_hooks/dnd/useDropList.ts
new file mode 100644
index 00000000..a688e122
--- /dev/null
+++ b/app/_hooks/dnd/useDropList.ts
@@ -0,0 +1,36 @@
+"use client";
+
+import { useCallback, useEffect, useRef } from "react";
+import { DragPhase } from "@/app/_types/dnd";
+import { CARD_GAP_PX } from "@/app/_consts/dnd";
+import { useDragStore } from "@/app/_utils/dnd/drag-store";
+import { useDndContext } from "./DndProvider";
+
+export const useDropList = ({ id }: { id: string }) => {
+ const { registry } = useDndContext();
+ const elRef = useRef(null);
+
+ const setNodeRef = useCallback((el: HTMLElement | null) => {
+ elRef.current = el;
+ }, []);
+
+ useEffect(() => {
+ const el = elRef.current;
+ if (!el) return;
+ registry.registerList(id, el);
+ return () => registry.unregisterList(id);
+ }, [registry, id]);
+
+ const isOver = useDragStore(
+ (s) => s.phase === DragPhase.DRAGGING && s.targetListId === id,
+ );
+ const padBottom = useDragStore((s) =>
+ s.phase === DragPhase.DRAGGING &&
+ s.targetListId === id &&
+ s.sourceListId !== id
+ ? s.dragSize.height + CARD_GAP_PX
+ : 0,
+ );
+
+ return { setNodeRef, isOver, padBottom };
+};
diff --git a/app/_hooks/kanban/useCalendarView.ts b/app/_hooks/kanban/useCalendarView.ts
index 79d04195..ac3c17d3 100644
--- a/app/_hooks/kanban/useCalendarView.ts
+++ b/app/_hooks/kanban/useCalendarView.ts
@@ -1,13 +1,12 @@
"use client";
import { useState, useMemo, useCallback } from "react";
-import { Item, Checklist } from "@/app/_types";
+import { Checklist } from "@/app/_types";
import {
parseItemsForCalendar,
generateICS,
getCalendarGrid,
getItemsGroupedByDate,
- CalendarEvent,
} from "@/app/_utils/kanban/calendar-utils";
type CalendarViewMode = "month" | "week" | "day";
@@ -18,22 +17,22 @@ export const useCalendarView = (checklist: Checklist) => {
const events = useMemo(
() => parseItemsForCalendar(checklist.items),
- [checklist.items]
+ [checklist.items],
);
const itemsByDate = useMemo(
() => getItemsGroupedByDate(checklist.items),
- [checklist.items]
+ [checklist.items],
);
const calendarGrid = useMemo(
() => getCalendarGrid(currentDate.getFullYear(), currentDate.getMonth()),
- [currentDate]
+ [currentDate],
);
const unscheduledItems = useMemo(
() => checklist.items.filter((item) => !item.targetDate && !item.isArchived),
- [checklist.items]
+ [checklist.items],
);
const _navigateMonth = useCallback((direction: number) => {
@@ -61,19 +60,6 @@ export const useCalendarView = (checklist: Checklist) => {
URL.revokeObjectURL(url);
}, [checklist]);
- const getEventsForDate = useCallback(
- (date: Date): CalendarEvent[] => {
- const pad = (n: number) => String(n).padStart(2, "0");
- const localDateStr = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
- return events.filter((e) => {
- const eventDate = new Date(e.date);
- const eventLocalStr = `${eventDate.getFullYear()}-${pad(eventDate.getMonth() + 1)}-${pad(eventDate.getDate())}`;
- return eventLocalStr === localDateStr;
- });
- },
- [events]
- );
-
return {
currentDate,
viewMode,
@@ -86,6 +72,5 @@ export const useCalendarView = (checklist: Checklist) => {
goToNextMonth,
goToToday,
exportICS,
- getEventsForDate,
};
};
diff --git a/app/_hooks/kanban/useKanban.ts b/app/_hooks/kanban/useKanban.ts
index d30a1c98..9fbc1589 100644
--- a/app/_hooks/kanban/useKanban.ts
+++ b/app/_hooks/kanban/useKanban.ts
@@ -1,21 +1,20 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
-import { DragEndEvent, DragOverEvent, DragStartEvent } from "@dnd-kit/core";
import { Checklist, RecurrenceRule } from "@/app/_types";
+import { DragPhase } from "@/app/_types/dnd";
import {
createItem,
updateItemStatus,
createBulkItems,
- reorderItems,
} from "@/app/_server/actions/checklist-item";
import { getListById } from "@/app/_server/actions/checklist";
-import { TaskStatus } from "@/app/_types/enums";
import {
getCurrentUser,
getUserByChecklist,
} from "@/app/_server/actions/users";
-import { DEFAULT_KANBAN_STATUSES } from "@/app/_consts/kanban";
+import { getColumnItems } from "@/app/_utils/kanban/board-utils";
+import { useDragStore } from "@/app/_utils/dnd/drag-store";
interface UseKanbanBoardProps {
checklist: Checklist;
@@ -26,34 +25,50 @@ export const useKanbanBoard = ({
checklist,
onUpdate,
}: UseKanbanBoardProps) => {
- const [activeId, setActiveId] = useState(null);
- const [overId, setOverId] = useState(null);
const [localChecklist, setLocalChecklist] = useState(checklist);
const [isLoading, setIsLoading] = useState(false);
const [showBulkPasteModal, setShowBulkPasteModal] = useState(false);
const [focusKey, setFocusKey] = useState(0);
- const dragOriginalStatusRef = useRef(null);
-
- const validStatusIds = (
- localChecklist.statuses || DEFAULT_KANBAN_STATUSES
- ).map((s) => s.id);
+ const dragPhase = useDragStore((s) => s.phase);
+ const pendingRef = useRef(null);
useEffect(() => {
- if (
- checklist.id !== localChecklist.id ||
- checklist.updatedAt !== localChecklist.updatedAt
- ) {
+ const isDifferentList = checklist.id !== localChecklist.id;
+ const isNewer = checklist.updatedAt > localChecklist.updatedAt;
+
+ if (isDifferentList || isNewer) {
+ if (dragPhase !== DragPhase.IDLE) {
+ pendingRef.current = checklist;
+ return;
+ }
setLocalChecklist(checklist);
setFocusKey((prev) => prev + 1);
}
}, [
+ checklist,
checklist.id,
checklist.updatedAt,
localChecklist.id,
localChecklist.updatedAt,
+ dragPhase,
]);
+ useEffect(() => {
+ if (dragPhase !== DragPhase.IDLE) return;
+ const pending = pendingRef.current;
+ pendingRef.current = null;
+ if (!pending) return;
+ if (
+ pending.id === localChecklist.id &&
+ pending.updatedAt <= localChecklist.updatedAt
+ ) {
+ return;
+ }
+ setLocalChecklist(pending);
+ setFocusKey((prev) => prev + 1);
+ }, [dragPhase, localChecklist.id, localChecklist.updatedAt]);
+
const refreshChecklist = useCallback(async () => {
const checklistOwner = await getUserByChecklist(
localChecklist.id,
@@ -71,212 +86,9 @@ export const useKanbanBoard = ({
}, [localChecklist.id, localChecklist.category, onUpdate]);
const getItemsByStatus = useCallback(
- (status: string) => {
- const firstStatus =
- (localChecklist.statuses || DEFAULT_KANBAN_STATUSES).sort(
- (a, b) => a.order - b.order,
- )[0]?.id || TaskStatus.TODO;
- return localChecklist.items.filter((item) => {
- if (item.isArchived) return false;
- if (item.status === status) return true;
- if (status === firstStatus) {
- const hasValidStatus = validStatusIds.includes(item.status || "");
- return !hasValidStatus;
- }
- return false;
- });
- },
- [localChecklist.items, localChecklist.statuses, validStatusIds],
- );
-
- const handleDragStart = useCallback(
- (event: DragStartEvent) => {
- const id = event.active.id as string;
- setActiveId(id);
-
- const item = localChecklist.items.find((i) => i.id === id);
- dragOriginalStatusRef.current = item?.status || TaskStatus.TODO;
- },
- [localChecklist.items],
- );
-
- const handleDragOver = useCallback(
- (event: DragOverEvent) => {
- const { over } = event;
- setOverId(over ? (over.id as string) : null);
-
- if (!over || !activeId) return;
-
- const activeItem = localChecklist.items.find(
- (item) => item.id === activeId,
- );
- if (!activeItem) return;
-
- const overIdStr = over.id as string;
-
- let targetStatus: string | null = null;
- if (validStatusIds.includes(overIdStr)) {
- targetStatus = overIdStr;
- } else {
- const overItem = localChecklist.items.find(
- (item) => item.id === overIdStr,
- );
- if (overItem) targetStatus = overItem.status || TaskStatus.TODO;
- }
-
- if (targetStatus && activeItem.status !== targetStatus) {
- setLocalChecklist((prev) => ({
- ...prev,
- items: prev.items.map((item) =>
- item.id === activeId ? { ...item, status: targetStatus } : item,
- ),
- }));
- }
- },
- [activeId, localChecklist.items, validStatusIds],
- );
-
- const handleDragEnd = useCallback(
- async (event: DragEndEvent) => {
- const { active, over } = event;
- const origStatus = dragOriginalStatusRef.current;
- setActiveId(null);
- setOverId(null);
- dragOriginalStatusRef.current = null;
-
- if (!over) {
- if (origStatus) {
- setLocalChecklist((prev) => ({
- ...prev,
- items: prev.items.map((item) =>
- item.id === (active.id as string)
- ? { ...item, status: origStatus }
- : item,
- ),
- }));
- }
- return;
- }
-
- const activeIdStr = active.id as string;
- const overIdStr = over.id as string;
-
- const droppedOnColumn = validStatusIds.includes(overIdStr);
- const targetStatus = droppedOnColumn
- ? overIdStr
- : localChecklist.items.find((item) => item.id === overIdStr)?.status ||
- TaskStatus.TODO;
-
- const isCrossColumn = origStatus !== targetStatus;
-
- const columnItems = localChecklist.items.filter(
- (item) =>
- item.status === targetStatus &&
- !item.isArchived &&
- item.id !== activeIdStr,
- );
-
- const isDraggingDown = (() => {
- if (isCrossColumn) return false;
- const allColumnItems = localChecklist.items.filter(
- (item) => item.status === targetStatus && !item.isArchived,
- );
- const activeOrigIdx = allColumnItems.findIndex(
- (item) => item.id === activeIdStr,
- );
- const overOrigIdx = allColumnItems.findIndex(
- (item) => item.id === overIdStr,
- );
- return activeOrigIdx < overOrigIdx;
- })();
-
- let insertIndex: number;
- if (droppedOnColumn) {
- insertIndex = columnItems.length;
- } else {
- const overIndex = columnItems.findIndex(
- (item) => item.id === overIdStr,
- );
- if (overIndex === -1) {
- insertIndex = columnItems.length;
- } else if (isCrossColumn) {
- const overRect = over.rect;
- const dragY =
- event.activatorEvent && "clientY" in event.activatorEvent
- ? (event.activatorEvent as PointerEvent).clientY +
- (event.delta?.y || 0)
- : 0;
- const overMidY = overRect ? overRect.top + overRect.height / 2 : 0;
- insertIndex = dragY > overMidY ? overIndex + 1 : overIndex;
- } else {
- insertIndex = isDraggingDown ? overIndex + 1 : overIndex;
- }
- }
-
- const activeItem = localChecklist.items.find(
- (item) => item.id === activeIdStr,
- );
- if (!activeItem) return;
-
- const updatedActiveItem = { ...activeItem, status: targetStatus };
- const newColumnItems = [...columnItems];
- newColumnItems.splice(insertIndex, 0, updatedActiveItem);
-
- const otherItems = localChecklist.items.filter(
- (item) =>
- (item.status !== targetStatus || item.isArchived) &&
- item.id !== activeIdStr,
- );
- const allItems = [...otherItems, ...newColumnItems];
-
- setLocalChecklist((prev) => ({
- ...prev,
- items: allItems,
- updatedAt: new Date().toISOString(),
- }));
-
- if (isCrossColumn) {
- await _handleItemStatusUpdate(activeIdStr, targetStatus);
-
- if (!droppedOnColumn) {
- const reorderFormData = new FormData();
- reorderFormData.append("listId", localChecklist.id);
- reorderFormData.append("activeItemId", activeIdStr);
- reorderFormData.append("overItemId", overIdStr);
- reorderFormData.append(
- "category",
- localChecklist.category || "Uncategorized",
- );
- const overIdx = columnItems.findIndex(
- (item) => item.id === overIdStr,
- );
- if (insertIndex > overIdx) {
- reorderFormData.append("position", "after");
- }
-
- await reorderItems(reorderFormData);
- }
-
- await refreshChecklist();
- } else {
- if (droppedOnColumn || activeIdStr === overIdStr) return;
-
- const formData = new FormData();
- formData.append("listId", localChecklist.id);
- formData.append("activeItemId", activeIdStr);
- formData.append("overItemId", overIdStr);
- formData.append("category", localChecklist.category || "Uncategorized");
- if (isDraggingDown) {
- formData.append("position", "after");
- }
-
- const result = await reorderItems(formData);
- if (result.success) {
- await refreshChecklist();
- }
- }
- },
- [localChecklist, validStatusIds, refreshChecklist],
+ (status: string) =>
+ getColumnItems(localChecklist.items, status, localChecklist.statuses),
+ [localChecklist.items, localChecklist.statuses],
);
const _handleItemStatusUpdate = async (itemId: string, newStatus: string) => {
@@ -364,14 +176,9 @@ export const useKanbanBoard = ({
[onUpdate, refreshChecklist],
);
- const activeItem = activeId
- ? localChecklist.items.find((item) => item.id === activeId)
- : null;
-
return {
- activeId,
- overId,
localChecklist,
+ setLocalChecklist,
isLoading,
showBulkPasteModal,
setShowBulkPasteModal,
@@ -380,12 +187,8 @@ export const useKanbanBoard = ({
refreshChecklist,
handleItemUpdate,
getItemsByStatus,
- handleDragStart,
- handleDragOver,
- handleDragEnd,
handleAddItem,
handleBulkPaste,
handleItemStatusUpdate: _handleItemStatusUpdate,
- activeItem,
};
};
diff --git a/app/_hooks/kanban/useKanbanDnd.ts b/app/_hooks/kanban/useKanbanDnd.ts
new file mode 100644
index 00000000..f261f70f
--- /dev/null
+++ b/app/_hooks/kanban/useKanbanDnd.ts
@@ -0,0 +1,101 @@
+"use client";
+
+import { useCallback } from "react";
+import { useTranslations } from "next-intl";
+import { dropItem } from "@/app/_server/actions/checklist-item";
+import { Checklist, Item } from "@/app/_types";
+import { DropResult } from "@/app/_types/dnd";
+import {
+ applyDrop,
+ getColumnItems,
+ visToColIndex,
+} from "@/app/_utils/kanban/board-utils";
+import { useAppMode } from "@/app/_providers/AppModeProvider";
+import { useToast } from "@/app/_providers/ToastProvider";
+
+interface UseKanbanDndArgs {
+ checklist: Checklist;
+ setChecklist: (checklist: Checklist) => void;
+ onUpdate: (checklist: Checklist) => void;
+ visibleFor: (status: string) => Item[];
+ fallbackMove: (itemId: string, status: string) => Promise;
+}
+
+export const useKanbanDnd = ({
+ checklist,
+ setChecklist,
+ onUpdate,
+ visibleFor,
+ fallbackMove,
+}: UseKanbanDndArgs) => {
+ const t = useTranslations();
+ const { showToast } = useToast();
+ const { user } = useAppMode();
+
+ const handleDrop = useCallback(
+ async (result: DropResult) => {
+ const { itemId, targetListId, targetIndex } = result;
+
+ if (!checklist.uuid) {
+ console.warn("checklist has no uuid, falling back to status update");
+ await fallbackMove(itemId, targetListId);
+ return;
+ }
+
+ const visible = visibleFor(targetListId).filter(
+ (item) => item.id !== itemId,
+ );
+ const column = getColumnItems(
+ checklist.items,
+ targetListId,
+ checklist.statuses,
+ ).filter((item) => item.id !== itemId);
+ const colIndex = visToColIndex(visible, column, targetIndex);
+
+ const snapshot = checklist;
+ const now = new Date().toISOString();
+ const optimistic = applyDrop(
+ checklist,
+ itemId,
+ targetListId,
+ colIndex,
+ user?.username || "",
+ now,
+ );
+ setChecklist(optimistic);
+ onUpdate(optimistic);
+
+ const formData = new FormData();
+ formData.append("uuid", checklist.uuid);
+ formData.append("itemId", itemId);
+ formData.append("targetStatus", targetListId);
+ formData.append("targetIndex", String(colIndex));
+
+ let response: Awaited>;
+ try {
+ response = await dropItem(formData);
+ } catch (error) {
+ console.error("dropItem threw:", error);
+ response = { success: false, error: "drop failed" };
+ }
+
+ if (response.success && response.data) {
+ setChecklist(response.data);
+ onUpdate(response.data);
+ return;
+ }
+
+ console.error("dropItem failed:", response.error);
+ setChecklist(snapshot);
+ onUpdate(snapshot);
+ showToast({
+ type: "error",
+ title: t("common.error"),
+ message: t("kanban.moveFailed"),
+ });
+ },
+ [checklist, setChecklist, onUpdate, visibleFor, fallbackMove, user?.username, showToast, t],
+ );
+
+ return { handleDrop };
+};
diff --git a/app/_hooks/kanban/useKanbanItem.tsx b/app/_hooks/kanban/useKanbanItem.tsx
index 57ce1dbb..7dafe4a8 100644
--- a/app/_hooks/kanban/useKanbanItem.tsx
+++ b/app/_hooks/kanban/useKanbanItem.tsx
@@ -40,11 +40,6 @@ export const useKanbanItem = ({
const [editText, setEditText] = useState(item.text);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const inputRef = useRef(null);
- const isRunningRef = useRef(false);
- const startTimeRef = useRef(null);
-
- isRunningRef.current = isRunning;
- startTimeRef.current = startTime;
useEffect(() => {
const existingTime =
@@ -63,24 +58,15 @@ export const useKanbanItem = ({
const storageKey = TIMER_STORAGE_KEY(checklistId, item.id);
try {
const stored = localStorage.getItem(storageKey);
- if (!stored) return;
- const { startTime: storedStart, isRunning: storedRunning, endTime: storedEnd } = JSON.parse(stored);
- if (!storedRunning || !storedStart) return;
-
- if (storedEnd) {
- _saveTimerEntry(new Date(storedStart), new Date(storedEnd)).then((result) => {
- if (result.success) {
- try { localStorage.removeItem(storageKey); } catch (e) { console.error(e); }
- }
- });
- } else {
- setStartTime(new Date(storedStart));
- setIsRunning(true);
- setCurrentTime(Math.floor((Date.now() - new Date(storedStart).getTime()) / 1000));
+ if (stored) {
+ const { startTime: storedStart, isRunning: storedRunning } = JSON.parse(stored);
+ if (storedRunning && storedStart) {
+ setStartTime(new Date(storedStart));
+ setIsRunning(true);
+ setCurrentTime(Math.floor((Date.now() - new Date(storedStart).getTime()) / 1000));
+ }
}
- } catch (e) {
- console.error(e);
- }
+ } catch {}
}, [checklistId, item.id]);
useEffect(() => {
@@ -95,27 +81,6 @@ export const useKanbanItem = ({
}
}, [isRunning, startTime, checklistId, item.id]);
- useEffect(() => {
- const storageKey = TIMER_STORAGE_KEY(checklistId, item.id);
- const markInterrupted = () => {
- if (!isRunningRef.current || !startTimeRef.current) return;
- try {
- localStorage.setItem(storageKey, JSON.stringify({
- startTime: startTimeRef.current.toISOString(),
- isRunning: true,
- endTime: new Date().toISOString(),
- }));
- } catch (e) {
- console.error(e);
- }
- };
- window.addEventListener("beforeunload", markInterrupted);
- return () => {
- window.removeEventListener("beforeunload", markInterrupted);
- markInterrupted();
- };
- }, [checklistId, item.id]);
-
useEffect(() => {
let interval: NodeJS.Timeout;
if (isRunning && startTime) {
@@ -162,7 +127,6 @@ export const useKanbanItem = ({
function handleTimerToggle() {
if (isRunning) {
- try { localStorage.removeItem(TIMER_STORAGE_KEY(checklistId, item.id)); } catch (e) { console.error(e); }
setIsRunning(false);
if (startTime) {
const endTime = new Date();
@@ -202,7 +166,6 @@ export const useKanbanItem = ({
const stopTimerOnDrag = async () => {
if (isRunning && startTime) {
- try { localStorage.removeItem(TIMER_STORAGE_KEY(checklistId, item.id)); } catch (e) { console.error(e); }
const endTime = new Date();
await _saveTimerEntry(startTime, endTime);
setIsRunning(false);
diff --git a/app/_hooks/useChecklist.tsx b/app/_hooks/useChecklist.tsx
index 751fa70a..00d9fe61 100644
--- a/app/_hooks/useChecklist.tsx
+++ b/app/_hooks/useChecklist.tsx
@@ -508,7 +508,7 @@ export const useChecklist = ({
activeInfo.index < overInfo.index;
const position = isDropIndicator
- ? overId.startsWith("drop-after::")
+ ? overId.startsWith("drop-after")
? "after"
: "before"
: isDraggingDown
@@ -539,7 +539,7 @@ export const useChecklist = ({
activeInfo.index < overInfo.index;
const reorderPosition = isDropIndicator
- ? overId.startsWith("drop-after::")
+ ? overId.startsWith("drop-after")
? "after"
: "before"
: isDraggingDown
diff --git a/app/_hooks/useNoteEditor.tsx b/app/_hooks/useNoteEditor.tsx
index 7f92ba62..d23038d4 100644
--- a/app/_hooks/useNoteEditor.tsx
+++ b/app/_hooks/useNoteEditor.tsx
@@ -115,6 +115,7 @@ export const useNoteEditor = ({
registerNavigationGuard,
unregisterNavigationGuard,
executePendingNavigation,
+ setHasUnsavedChanges: setProviderUnsaved,
} = useNavigationGuard();
const autosaveTimeoutRef = useRef(null);
@@ -167,10 +168,15 @@ export const useNoteEditor = ({
}, [isEditing]);
useEffect(() => {
- if (notesDefaultMode !== "edit" && !isEditing) return;
+ if (notesDefaultMode !== "edit" && !isEditing) {
+ setProviderUnsaved(false);
+ return;
+ }
const titleChanged = title !== note.title;
const categoryChanged = category !== (note.category || "Uncategorized");
- setHasUnsavedChanges(contentIsDirty || titleChanged || categoryChanged);
+ const dirty = contentIsDirty || titleChanged || categoryChanged;
+ setHasUnsavedChanges(dirty);
+ setProviderUnsaved(dirty);
}, [contentIsDirty, title, category, note, isEditing]);
const handleSave = useCallback(
@@ -275,6 +281,7 @@ export const useNoteEditor = ({
setIsEditing(false);
setIsEditingEncrypted(false);
setContentIsDirty(false);
+ setProviderUnsaved(false);
const categoryPath = buildCategoryPath(
category || "Uncategorized",
@@ -352,6 +359,7 @@ export const useNoteEditor = ({
setTitle(note.title);
setCategory(note.category || "Uncategorized");
setContentIsDirty(false);
+ setProviderUnsaved(false);
const { contentWithoutMetadata } = extractYamlMetadata(note.content || "");
if (isMinimalMode) {
setEditorContent(contentWithoutMetadata);
diff --git a/app/_providers/NavigationGuardProvider.tsx b/app/_providers/NavigationGuardProvider.tsx
index 6970b6e5..5b98351d 100644
--- a/app/_providers/NavigationGuardProvider.tsx
+++ b/app/_providers/NavigationGuardProvider.tsx
@@ -5,6 +5,7 @@ import {
useContext,
useState,
useCallback,
+ useEffect,
ReactNode,
} from "react";
@@ -59,6 +60,18 @@ export const NavigationGuardProvider = ({
return navigationGuard ? !navigationGuard() : false;
}, [navigationGuard]);
+ useEffect(() => {
+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
+ if (hasUnsavedChanges) {
+ e.preventDefault();
+ e.returnValue = "";
+ }
+ };
+
+ window.addEventListener("beforeunload", onBeforeUnload);
+ return () => window.removeEventListener("beforeunload", onBeforeUnload);
+ }, [hasUnsavedChanges]);
+
const executePendingNavigation = useCallback(() => {
if (pendingNavigation) {
pendingNavigation();
diff --git a/app/_schemas/user-schemas.ts b/app/_schemas/user-schemas.ts
index 79b16818..012003f6 100644
--- a/app/_schemas/user-schemas.ts
+++ b/app/_schemas/user-schemas.ts
@@ -79,6 +79,12 @@ export const kanbanSettingsSchema = z.object({
message: "Hide status on cards must be either 'enable' or 'disable'",
})
.optional(),
+ hideMobileStatusDropdown: z
+ .enum(["enable", "disable"], {
+ message:
+ "Hide mobile status dropdown must be either 'enable' or 'disable'",
+ })
+ .optional(),
});
export const fileSettingsSchema = z.object({
diff --git a/app/_server/actions/auth/index.ts b/app/_server/actions/auth/index.ts
index 9ea9f069..3373aa47 100644
--- a/app/_server/actions/auth/index.ts
+++ b/app/_server/actions/auth/index.ts
@@ -53,15 +53,16 @@ const _generateSessionId = (): string => randomBytes(32).toString("hex");
async function _setSessionCookie(
sessionId: string,
cookieName: string,
- maxAge: number,
+ maxAge?: number,
) {
- (await cookies()).set(cookieName, sessionId, {
+ const opts: Parameters>["set"]>[2] = {
httpOnly: true,
secure: isSecureEnv(),
sameSite: "lax",
- maxAge,
path: "/",
- });
+ };
+ if (maxAge !== undefined) opts.maxAge = maxAge;
+ (await cookies()).set(cookieName, sessionId, opts);
}
async function _handleFailedLogin(
@@ -209,6 +210,7 @@ export const register = async (formData: FormData) => {
export const login = async (formData: FormData) => {
const username = formData.get("username") as string;
const password = formData.get("password") as string;
+ const rememberMe = formData.get("rememberMe") === "true";
if (!username || !password) {
return { error: "Username and password are required" };
@@ -276,8 +278,9 @@ export const login = async (formData: FormData) => {
await ensureUser(ldapResult.username, ldapResult.isAdmin);
const ldapSessionId = _generateSessionId();
- await createSession(ldapSessionId, ldapResult.username, "ldap");
- await _setSessionCookie(ldapSessionId, getSessionCookieName(), 30 * 24 * 60 * 60);
+ const ldapMaxAge = rememberMe ? 30 * 24 * 60 * 60 : undefined;
+ await createSession(ldapSessionId, ldapResult.username, "ldap", rememberMe);
+ await _setSessionCookie(ldapSessionId, getSessionCookieName(), ldapMaxAge);
await logAuthEvent("login", ldapResult.username, true);
redirect("/");
@@ -290,7 +293,7 @@ export const login = async (formData: FormData) => {
if (user.mfaEnabled) {
const pendingSessionId = _generateSessionId();
await _setSessionCookie(pendingSessionId, getMfaPendingCookieName(), 10 * 60);
- await createSession(pendingSessionId, user.username, "pending-mfa");
+ await createSession(pendingSessionId, user.username, "pending-mfa", rememberMe);
redirect("/auth/verify-mfa");
}
@@ -306,13 +309,10 @@ export const login = async (formData: FormData) => {
}
const sessionId = _generateSessionId();
- const sessions = await readSessions();
- sessions[sessionId] = user.username;
-
- await writeSessions(sessions);
+ const maxAge = rememberMe ? 30 * 24 * 60 * 60 : undefined;
- await createSession(sessionId, user.username, "local");
- await _setSessionCookie(sessionId, getSessionCookieName(), 30 * 24 * 60 * 60);
+ await createSession(sessionId, user.username, "local", rememberMe);
+ await _setSessionCookie(sessionId, getSessionCookieName(), maxAge);
await logAuthEvent("login", user.username, true);
redirect("/");
@@ -375,6 +375,9 @@ export const verifyMfaLogin = async (formData: FormData) => {
return { error: "Invalid session" };
}
+ const sessionDataStore = await readSessionData();
+ const rememberMe = sessionDataStore[pendingSessionId]?.rememberMe ?? false;
+
const users = await readJsonFile(USERS_FILE);
const user = users.find(
(u: User) => u.username.toLowerCase() === username.toLowerCase(),
@@ -425,16 +428,17 @@ export const verifyMfaLogin = async (formData: FormData) => {
}
const sessionId = _generateSessionId();
+ const maxAge = rememberMe ? 30 * 24 * 60 * 60 : undefined;
sessions[sessionId] = username;
delete sessions[pendingSessionId];
await writeSessions(sessions);
await removeSession(pendingSessionId);
- await createSession(sessionId, username, "local");
+ await createSession(sessionId, username, "local", rememberMe);
(await cookies()).delete(pendingCookieName);
- await _setSessionCookie(sessionId, getSessionCookieName(), 30 * 24 * 60 * 60);
+ await _setSessionCookie(sessionId, getSessionCookieName(), maxAge);
await logAuthEvent("login", username, true);
diff --git a/app/_server/actions/checklist-item/crud.ts b/app/_server/actions/checklist-item/crud.ts
index e45592a2..fc2e571a 100644
--- a/app/_server/actions/checklist-item/crud.ts
+++ b/app/_server/actions/checklist-item/crud.ts
@@ -102,6 +102,7 @@ export const updateItem = async (
const assignee = formData.get("assignee") as string | null;
const reminder = formData.get("reminder") as string | null;
const targetDate = formData.get("targetDate") as string | null;
+ const startDate = formData.get("startDate") as string | null;
const estimatedTime = formData.get("estimatedTime") as string | null;
const updatedList = {
@@ -118,6 +119,7 @@ export const updateItem = async (
reminder: reminder ? JSON.parse(reminder) : undefined,
}),
...(targetDate !== null && { targetDate: targetDate || undefined }),
+ ...(startDate !== null && { startDate: startDate || undefined }),
...(estimatedTime !== null && { estimatedTime: estimatedTime ? parseFloat(estimatedTime) : undefined }),
lastModifiedBy: currentUser,
lastModifiedAt: now,
diff --git a/app/_server/actions/checklist-item/drop.ts b/app/_server/actions/checklist-item/drop.ts
new file mode 100644
index 00000000..dc4ca130
--- /dev/null
+++ b/app/_server/actions/checklist-item/drop.ts
@@ -0,0 +1,120 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import path from "path";
+import { serverWriteFile, ensureDir } from "@/app/_server/actions/file";
+import { getListById } from "@/app/_server/actions/checklist";
+import { getUsername } from "@/app/_server/actions/users";
+import { checkUserPermission } from "../sharing";
+import { broadcast } from "@/app/_server/ws/broadcast";
+import { applyDrop } from "@/app/_utils/kanban/board-utils";
+import { listToMarkdown } from "@/app/_utils/checklist-utils";
+import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists";
+import { Checklist, Result } from "@/app/_types";
+import { ItemTypes, PermissionTypes } from "@/app/_types/enums";
+
+export const dropItem = async (
+ formData: FormData,
+): Promise> => {
+ try {
+ const uuid = formData.get("uuid") as string;
+ const itemId = formData.get("itemId") as string;
+ const targetStatus = formData.get("targetStatus") as string;
+ const targetIndexRaw = formData.get("targetIndex");
+
+ if (
+ !uuid ||
+ !itemId ||
+ !targetStatus ||
+ targetIndexRaw === null ||
+ targetIndexRaw === ""
+ ) {
+ return {
+ success: false,
+ error: "uuid, itemId, targetStatus and targetIndex are required",
+ };
+ }
+
+ const targetIndex = Number(targetIndexRaw);
+ if (Number.isNaN(targetIndex)) {
+ return { success: false, error: "targetIndex must be a number" };
+ }
+
+ const username = await getUsername();
+ const list = await getListById(uuid);
+ if (!list) {
+ return { success: false, error: "List not found" };
+ }
+
+ const canEdit = await checkUserPermission(
+ list.uuid || list.id,
+ list.category || "Uncategorized",
+ ItemTypes.CHECKLIST,
+ username,
+ PermissionTypes.EDIT,
+ );
+ if (!canEdit) {
+ return { success: false, error: "Permission denied" };
+ }
+
+ if (!list.items.some((item) => item.id === itemId)) {
+ return { success: false, error: "Item not found" };
+ }
+
+ const isValidStatus = (list.statuses || []).some(
+ (s) => s.id === targetStatus,
+ );
+ if (!isValidStatus) {
+ return { success: false, error: "Invalid target status" };
+ }
+
+ const now = new Date().toISOString();
+ const updatedList = applyDrop(
+ list,
+ itemId,
+ targetStatus,
+ Math.max(0, targetIndex),
+ username,
+ now,
+ );
+
+ const categoryDir = path.join(
+ process.cwd(),
+ "data",
+ CHECKLISTS_FOLDER,
+ list.owner!,
+ list.category || "Uncategorized",
+ );
+ await ensureDir(categoryDir);
+ await serverWriteFile(
+ path.join(categoryDir, `${list.id}.md`),
+ listToMarkdown(updatedList),
+ );
+
+ try {
+ revalidatePath("/");
+ revalidatePath(`/checklist/${list.id}`);
+ } catch (error) {
+ console.warn(
+ "Cache revalidation failed, but data was saved successfully:",
+ error,
+ );
+ }
+
+ try {
+ await broadcast({
+ type: "checklist",
+ action: "updated",
+ entityId: list.id,
+ username,
+ });
+ } catch (error) {
+ console.warn("Broadcast failed, but data was saved successfully:", error);
+ }
+
+ return { success: true, data: updatedList };
+ } catch (error) {
+ console.error("Error dropping item:", error);
+ return { success: false, error: "Failed to drop item" };
+ }
+};
diff --git a/app/_server/actions/checklist-item/index.ts b/app/_server/actions/checklist-item/index.ts
index 02c7e550..c25eba3b 100644
--- a/app/_server/actions/checklist-item/index.ts
+++ b/app/_server/actions/checklist-item/index.ts
@@ -18,6 +18,10 @@ export {
updateItemStatus,
} from "./status";
+export {
+ dropItem,
+} from "./drop";
+
export {
createSubItem,
} from "./sub-items";
diff --git a/app/_server/actions/checklist-item/status.ts b/app/_server/actions/checklist-item/status.ts
index ebc93917..d2efc3cc 100644
--- a/app/_server/actions/checklist-item/status.ts
+++ b/app/_server/actions/checklist-item/status.ts
@@ -12,52 +12,15 @@ import {
import { listToMarkdown } from "@/app/_utils/checklist-utils";
import { getUsername } from "@/app/_server/actions/users";
import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists";
-import { Checklist, Result } from "@/app/_types";
+import { Checklist, Result, TimeEntry } from "@/app/_types";
import {
ItemTypes,
PermissionTypes,
} from "@/app/_types/enums";
import { checkUserPermission } from "../sharing";
import { broadcast } from "@/app/_server/ws/broadcast";
-import { updateItem, updateAllChildren } from "@/app/_utils/item-tree-utils";
-import { KanbanStatus, Item } from "@/app/_types";
-
-const _findParent = (items: Item[], childId: string): Item | null => {
- for (const item of items) {
- if (item.children?.some((c) => c.id === childId)) return item;
- if (item.children) {
- const found = _findParent(item.children, childId);
- if (found) return found;
- }
- }
- return null;
-};
-
-const _autoCompleteParent = (
- items: Item[],
- childId: string,
- statuses: KanbanStatus[] | undefined,
- username: string,
- now: string
-): Item[] => {
- const parent = _findParent(items, childId);
- if (!parent || !parent.children) return items;
-
- const allChildrenCompleted = parent.children.every((c) => c.completed);
- if (!allChildrenCompleted) return items;
-
- const autoCompleteStatus = statuses?.find((s) => s.autoComplete);
- if (!autoCompleteStatus) return items;
-
- return updateItem(items, parent.id, (p) => ({
- ...p,
- completed: true,
- status: autoCompleteStatus.id,
- lastModifiedBy: username,
- lastModifiedAt: now,
- history: [...(p.history || []), { status: autoCompleteStatus.id, timestamp: now, user: username }],
- }));
-};
+import { updateItem } from "@/app/_utils/item-tree-utils";
+import { applyStatus, completeParent } from "@/app/_utils/item-status-utils";
export const updateItemStatus = async (
formData: FormData,
@@ -85,6 +48,29 @@ export const updateItemStatus = async (
};
}
+ let parsedTimeEntries: TimeEntry[] | null = null;
+ if (timeEntriesStr) {
+ try {
+ const parsed = JSON.parse(timeEntriesStr);
+ if (!Array.isArray(parsed)) {
+ throw new Error("timeEntries must be an array");
+ }
+ const allObjects = parsed.every(
+ (entry) =>
+ entry !== null &&
+ typeof entry === "object" &&
+ !Array.isArray(entry)
+ );
+ if (!allObjects) {
+ throw new Error("timeEntries must contain objects");
+ }
+ parsedTimeEntries = parsed;
+ } catch (e) {
+ console.error("Failed to parse timeEntries:", e);
+ return { success: false, error: "Invalid timeEntries payload" };
+ }
+ }
+
const list = await getListById(listId, username, category);
if (!list) {
return { success: false, error: "List not found" };
@@ -104,47 +90,21 @@ export const updateItemStatus = async (
const now = new Date().toISOString();
- const updatedItems = updateItem(list.items, itemId, (item) => {
- const updates: Partial & { history?: typeof item.history; timeEntries?: typeof item.timeEntries } = {};
-
- if (status) {
- updates.status = status;
- updates.lastModifiedBy = username;
- updates.lastModifiedAt = now;
-
- const targetStatus = list.statuses?.find((s) => s.id === status);
- if (targetStatus?.autoComplete) {
- updates.completed = true;
- if (item.children && item.children.length > 0) {
- updates.children = updateAllChildren(item.children, true, username, now);
- }
- } else if (item.completed && status !== item.status) {
- updates.completed = false;
- }
-
- if (status !== item.status) {
- const history = [...(item.history || [])];
- history.push({ status, timestamp: now, user: username });
- updates.history = history;
- }
- }
+ const statusItems = status
+ ? applyStatus(list.items, itemId, status, list.statuses, username, now)
+ : list.items;
- if (timeEntriesStr) {
- try {
- const timeEntries = JSON.parse(timeEntriesStr);
- updates.timeEntries = timeEntries.map((entry: { user?: string }) => ({
+ const updatedItems = parsedTimeEntries
+ ? updateItem(statusItems, itemId, (item) => ({
+ ...item,
+ timeEntries: parsedTimeEntries!.map((entry) => ({
...entry,
user: entry.user || username,
- }));
- } catch (e) {
- console.error("Failed to parse timeEntries:", e);
- }
- }
-
- return { ...item, ...updates };
- });
+ })),
+ }))
+ : statusItems;
- const itemsWithParentAutoComplete = _autoCompleteParent(
+ const itemsWithParentAutoComplete = completeParent(
updatedItems, itemId, list.statuses, username, now
);
diff --git a/app/_server/actions/file/index.ts b/app/_server/actions/file/index.ts
index 9ecb9e10..f058b678 100644
--- a/app/_server/actions/file/index.ts
+++ b/app/_server/actions/file/index.ts
@@ -10,6 +10,7 @@ import {
} from "@/app/_consts/files";
import fs from "fs/promises";
import path from "path";
+import { randomUUID } from "crypto";
import { Modes } from "@/app/_types/enums";
export interface OrderData {
@@ -88,15 +89,16 @@ export const writeJsonFile = async (
data: any,
filePath: string,
): Promise => {
+ const finalPath = path.join(process.cwd(), filePath);
+ const tmpPath = `${finalPath}.${randomUUID()}.tmp`;
+
try {
- await fs.mkdir(path.dirname(filePath), { recursive: true });
- await fs.writeFile(
- path.join(process.cwd(), filePath),
- JSON.stringify(data, null, 2),
- "utf-8",
- );
+ await fs.mkdir(path.dirname(finalPath), { recursive: true });
+ await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), "utf-8");
+ await fs.rename(tmpPath, finalPath);
} catch (error) {
console.error("Error writing data:", error);
+ try { await fs.unlink(tmpPath); } catch {}
throw error;
}
};
diff --git a/app/_server/actions/note/parsers.ts b/app/_server/actions/note/parsers.ts
index 228617fa..14a9046c 100644
--- a/app/_server/actions/note/parsers.ts
+++ b/app/_server/actions/note/parsers.ts
@@ -195,5 +195,5 @@ export const noteToMarkdown = (note: Note): string => {
const frontmatter = generateYamlFrontmatter(metadata);
- return `${frontmatter}${content}`.trim() + "\n";
+ return `${frontmatter}${content}`.trim();
};
diff --git a/app/_server/actions/session/index.ts b/app/_server/actions/session/index.ts
index c5b91b18..9996a9cf 100644
--- a/app/_server/actions/session/index.ts
+++ b/app/_server/actions/session/index.ts
@@ -17,6 +17,7 @@ export interface SessionData {
createdAt: string;
lastActivity: string;
loginType?: "local" | "sso" | "ldap" | "pending-mfa";
+ rememberMe?: boolean;
}
export interface Session {
@@ -49,6 +50,7 @@ export const createSession = async (
sessionId: string,
username: string,
loginType: "local" | "sso" | "pending-mfa" | "ldap",
+ rememberMe?: boolean,
): Promise => {
const headersList = await headers();
const userAgent = headersList.get("user-agent") || "Unknown";
@@ -64,6 +66,7 @@ export const createSession = async (
createdAt: new Date().toISOString(),
lastActivity: new Date().toISOString(),
loginType,
+ ...(rememberMe !== undefined && { rememberMe }),
};
const sessionsData = await readSessionData();
diff --git a/app/_server/actions/users/crud.ts b/app/_server/actions/users/crud.ts
index d59b346a..dcfd22fb 100644
--- a/app/_server/actions/users/crud.ts
+++ b/app/_server/actions/users/crud.ts
@@ -204,6 +204,7 @@ export const createUser = async (
preferredDateFormat: "system",
preferredTimeFormat: "system",
handedness: "right-handed",
+ hideMobileStatusDropdown: "disable",
};
const updatedUsers = [...existingUsers, newUser];
diff --git a/app/_styles/globals.css b/app/_styles/globals.css
index 8ad3724e..57647083 100644
--- a/app/_styles/globals.css
+++ b/app/_styles/globals.css
@@ -660,40 +660,65 @@ pre.text-foreground span,
}
}
-.overflow-y-auto {
- scrollbar-width: auto !important;
+@supports (-moz-appearance: none) {
+ .overflow-y-auto,
+ .overflow-x-auto,
+ .overflow-auto {
+ scrollbar-width: thin;
+ scrollbar-color: rgb(var(--primary) / 0.3) transparent;
+ }
}
.jotty-scrollable-content {
- -webkit-overflow-scrolling: touch;
- transform: translateZ(0);
+ transform: translateZ(0);
+ -webkit-overflow-scrolling: touch;
+}
+
+.overflow-y-auto::-webkit-scrollbar-button,
+.overflow-x-auto::-webkit-scrollbar-button,
+.overflow-auto::-webkit-scrollbar-button,
+.scrollbar-thin::-webkit-scrollbar-button {
+ display: none;
+ height: 0;
+ width: 0;
}
.overflow-x-auto::-webkit-scrollbar {
- height: 8px;
+ height: 6px;
}
.overflow-y-auto::-webkit-scrollbar,
-.overflow-x-auto::-webkit-scrollbar {
- -webkit-appearance: none;
- width: 8px;
- background-color: rgb(var(--muted) / 0.5) !important;
- border-radius: 6px;
+.overflow-x-auto::-webkit-scrollbar,
+.overflow-auto::-webkit-scrollbar,
+.scrollbar-thin::-webkit-scrollbar {
+ -webkit-appearance: none;
+ width: 6px;
+ background: transparent;
+}
+
+.overflow-y-auto::-webkit-scrollbar-track,
+.overflow-x-auto::-webkit-scrollbar-track,
+.overflow-auto::-webkit-scrollbar-track,
+.scrollbar-thin::-webkit-scrollbar-track {
+ background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb,
-.overflow-x-auto::-webkit-scrollbar-thumb {
- background-color: rgb(var(--primary) / 0.3) !important;
- border-radius: 6px;
- border: 2px solid transparent;
- background-clip: padding-box;
- visibility: visible !important;
- opacity: 1 !important;
- min-height: 20px !important;
- height: 20px !important;
- width: 4px !important;
+.overflow-x-auto::-webkit-scrollbar-thumb,
+.overflow-auto::-webkit-scrollbar-thumb,
+.scrollbar-thin::-webkit-scrollbar-thumb {
+ background-color: rgb(var(--primary) / 0.3);
+ border-radius: 3px;
}
+.overflow-y-auto::-webkit-scrollbar-thumb:hover,
+.overflow-x-auto::-webkit-scrollbar-thumb:hover,
+.overflow-auto::-webkit-scrollbar-thumb:hover,
+.scrollbar-thin::-webkit-scrollbar-thumb:hover {
+ background-color: rgb(var(--primary) / 0.5);
+}
+
+
@media (max-width: 992px) {
.jotty-checklist-page .jotty-quick-nav,
.jotty-embed .jotty-quick-nav {
diff --git a/app/_translations/de.json b/app/_translations/de.json
index b3895c33..8e0ed086 100644
--- a/app/_translations/de.json
+++ b/app/_translations/de.json
@@ -7,6 +7,8 @@
"view": "Anzeigen",
"new": "Neu",
"close": "Schließen",
+ "enlarge": "Vergrößern",
+ "shrink": "Verkleinern",
"confirm": "Bestätigen",
"loading": "Lädt...",
"search": "Suche",
@@ -280,6 +282,7 @@
"version": "Version {version}",
"createUser": "Benutzer erstellen",
"notAuthorized": "Du bist nicht berechtigt, auf diese Anwendung zuzugreifen. Kontaktiere deinen Administrator.",
+ "rememberMe": "Angemeldet bleiben",
"attemptsRemaining": "Warnung: Noch {count, plural, one {# Versuch} other {# Versuche}} bis zur Kontosperre",
"accountLocked": "Zu viele fehlgeschlagene Versuche. Bitte versuche es in {seconds} {seconds, plural, one {Sekunde} other {Sekunden}} erneut."
},
@@ -543,6 +546,7 @@
"autoComplete": "Automatisch abschließen"
},
"kanban": {
+ "status": "Status",
"priority": "Priorität",
"score": "Punktzahl",
"assignee": "Zugewiesen an",
@@ -572,6 +576,7 @@
"itemTitle": "Elementtitel",
"manageStatuses": "Status verwalten",
"viewArchived": "Archivierte anzeigen",
+ "changeStatus": "Status ändern",
"noBoards": "Keine Boards gefunden",
"noBoardsYet": "Noch keine Kanban-Boards",
"createFirstBoard": "Erstelle dein erstes Kanban-Board, um deine Projekte zu verwalten.",
@@ -586,6 +591,13 @@
"targetDate": "Zieldatum",
"statusUpdated": "Status aktualisiert",
"itemArchived": "Element archiviert",
+ "archiveAllItemsInColumn": "Alle Elemente in {column} archivieren",
+ "archiveAllConfirmTitle": "Alle Elemente archivieren?",
+ "archiveAllConfirmMessage": "Alle {count} Elemente in {column} archivieren? Du kannst sie aus dem Archiv wiederherstellen.",
+ "archiveAllSuccess": "{count} Elemente archiviert",
+ "archiveAllFailed": "Nicht alle Elemente konnten archiviert werden",
+ "moveFailed": "Karte konnte nicht verschoben werden",
+ "archiveAllPartial": "{archived} von {total} Elementen archiviert. Einige Elemente konnten nicht archiviert werden.",
"itemDeleted": "Element gelöscht",
"estimatedTime": "Geschätzte Zeit (Stunden)",
"actualVsEstimated": "{actual} / {estimated}",
@@ -680,7 +692,27 @@
"connection_plural": "Verbindungen",
"terminateSessionConfirm": "Bist du sicher, dass du diese Sitzung beenden willst?",
"terminateAllSessionsConfirm": "Bist du sicher, dass du alle anderen Sitzungen beenden willst?",
- "validationErrorTitle": "Validerungsfehler"
+ "validationErrorTitle": "Validerungsfehler",
+ "visibleGraphSummary": "{items} sichtbare Elemente, {links} sichtbare Verbindungen",
+ "graphTruncated": "Es werden die {visible} am stärksten verbundenen Elemente angezeigt. {omitted} schwächer verbundene Elemente wurden ausgeblendet.",
+ "totalConnections": "Gesamt",
+ "inboundConnections": "Eingehend",
+ "outboundConnections": "Ausgehend",
+ "updated": "Aktualisiert",
+ "openItem": "Element öffnen",
+ "selectItem": "Element auswählen",
+ "inspectNodeHint": "Tippen oder klicken Sie auf einen Knoten, um Titel, Kategorie, Verbindungsanzahl und letzte Aktivität anzuzeigen.",
+ "graphSearchPlaceholder": "Titel, Pfad, Kategorie oder Tag",
+ "orphans": "Verwaiste",
+ "labels": "Beschriftungen",
+ "arrows": "Pfeile",
+ "nodeSize": "Knotengröße",
+ "linkWidth": "Linienbreite",
+ "linkDistance": "Linienabstand",
+ "repelForce": "Abstoßung",
+ "tagLinks": "Tag-Verbindungen",
+ "linkedItems": "Verknüpfte Elemente",
+ "uuid": "UUID"
},
"settings": {
"title": "Einstellungen",
@@ -836,6 +868,11 @@
"showStatusOnCards": "Status anzeigen",
"hideStatusOnCards": "Status ausblenden",
"hideStatusOnCardsDescription": "Blendet das Status-Label auf Kanban-Karten aus. Der Status ist bereits durch die Spalte ersichtlich, in der die Karte liegt.",
+ "hideMobileStatusDropdownLabel": "Mobiles Status-Dropdown auf Karten",
+ "showMobileStatusDropdown": "Mobiles Dropdown anzeigen",
+ "hideMobileStatusDropdown": "Mobiles Dropdown ausblenden",
+ "selectMobileStatusDropdown": "Verhalten des mobilen Status-Dropdowns auswählen",
+ "hideMobileStatusDropdownDescription": "Steuert das immer sichtbare Status-Dropdown auf mobilen Kanban-Karten. Die Einstellung für das Status-Label bleibt separat.",
"selectStatusOnCards": "Statussichtbarkeit auswählen",
"codeBlockChoice": "Wählen Sie den Codeblockstil",
"themedCodeBlock": "Themenbezogener Codeblock",
diff --git a/app/_translations/en.json b/app/_translations/en.json
index 7c52cd33..37f52751 100644
--- a/app/_translations/en.json
+++ b/app/_translations/en.json
@@ -7,6 +7,8 @@
"view": "View",
"new": "New",
"close": "Close",
+ "enlarge": "Enlarge",
+ "shrink": "Shrink",
"confirm": "Confirm",
"loading": "Loading...",
"search": "Search",
@@ -306,7 +308,8 @@
"createUser": "Create User",
"attemptsRemaining": "Warning: {count, plural, one {# attempt} other {# attempts}} remaining before account lockout",
"accountLocked": "Too many failed attempts. Please try again in {seconds} {seconds, plural, one {second} other {seconds}}",
- "notAuthorized": "You are not authorized to access this application. Please contact your administrator."
+ "notAuthorized": "You are not authorized to access this application. Please contact your administrator.",
+ "rememberMe": "Remember me"
},
"mfa": {
"title": "Two-Factor Authentication",
@@ -568,6 +571,7 @@
"autoComplete": "Auto complete"
},
"kanban": {
+ "status": "Status",
"priority": "Priority",
"score": "Score",
"assignee": "Assignee",
@@ -597,6 +601,7 @@
"itemTitle": "Item Title",
"manageStatuses": "Manage Statuses",
"viewArchived": "View Archived",
+ "changeStatus": "Change status",
"noBoards": "No boards found",
"noBoardsYet": "No kanban boards yet",
"createFirstBoard": "Create your first kanban board to start managing your projects.",
@@ -611,6 +616,13 @@
"targetDate": "Target Date",
"statusUpdated": "Status updated",
"itemArchived": "Item archived",
+ "archiveAllItemsInColumn": "Archive all items in {column}",
+ "archiveAllConfirmTitle": "Archive all items?",
+ "archiveAllConfirmMessage": "Archive all {count} items in {column}? You can restore them from the archive.",
+ "archiveAllSuccess": "{count} items archived",
+ "archiveAllFailed": "Could not archive all items",
+ "moveFailed": "Could not move the card",
+ "archiveAllPartial": "{archived} of {total} items archived. Some items could not be archived.",
"itemDeleted": "Item deleted",
"estimatedTime": "Estimated Time (hours)",
"actualVsEstimated": "{actual} / {estimated}",
@@ -705,7 +717,27 @@
"connection_plural": "connections",
"terminateSessionConfirm": "Are you sure you want to terminate this session?",
"terminateAllSessionsConfirm": "Are you sure you want to terminate all other sessions?",
- "validationErrorTitle": "Validation Error"
+ "validationErrorTitle": "Validation Error",
+ "visibleGraphSummary": "{items} visible items, {links} visible links",
+ "graphTruncated": "Showing the top {visible} items by connection count. {omitted} lower-connected items were omitted.",
+ "totalConnections": "Total",
+ "inboundConnections": "In",
+ "outboundConnections": "Out",
+ "updated": "Updated",
+ "openItem": "Open item",
+ "selectItem": "Select an item",
+ "inspectNodeHint": "Tap or click a node to inspect title, category, link counts, and recent activity.",
+ "graphSearchPlaceholder": "Title, path, category, or tag",
+ "orphans": "Orphans",
+ "labels": "Labels",
+ "arrows": "Arrows",
+ "nodeSize": "Node size",
+ "linkWidth": "Link width",
+ "linkDistance": "Link distance",
+ "repelForce": "Repel",
+ "tagLinks": "Tag links",
+ "linkedItems": "Linked items",
+ "uuid": "UUID"
},
"settings": {
"title": "Settings",
@@ -862,6 +894,11 @@
"showStatusOnCards": "Show status",
"hideStatusOnCards": "Hide status",
"hideStatusOnCardsDescription": "Hides the status label from Kanban card items. The status is already indicated by the column the card is placed in.",
+ "hideMobileStatusDropdownLabel": "Mobile status dropdown on cards",
+ "showMobileStatusDropdown": "Show mobile dropdown",
+ "hideMobileStatusDropdown": "Hide mobile dropdown",
+ "selectMobileStatusDropdown": "Select mobile status dropdown behavior",
+ "hideMobileStatusDropdownDescription": "Controls the always-visible status dropdown on mobile Kanban cards. The status label setting is separate.",
"selectStatusOnCards": "Select status visibility",
"codeBlockChoice": "Choose the code block style",
"defaultCodeBlock": "Codepen (default)",
diff --git a/app/_translations/es.json b/app/_translations/es.json
index b557f4a0..69ca35f9 100644
--- a/app/_translations/es.json
+++ b/app/_translations/es.json
@@ -7,6 +7,8 @@
"view": "Ver",
"new": "Nuevo",
"close": "Cerrar",
+ "enlarge": "Ampliar",
+ "shrink": "Reducir",
"confirm": "Confirmar",
"loading": "Cargando...",
"search": "Buscar",
@@ -281,7 +283,8 @@
"createUser": "Crear usuario",
"attemptsRemaining": "Advertencia: {count, plural, one {# intento} other {# intentos}} restantes antes del bloqueo de cuenta",
"accountLocked": "Demasiados intentos fallidos. Por favor, inténtelo de nuevo en {seconds} {seconds, plural, one {segundo} other {segundos}}",
- "notAuthorized": "No estás autorizado para acceder a esta aplicación. Ponte en contacto con tu administrador."
+ "notAuthorized": "No estás autorizado para acceder a esta aplicación. Ponte en contacto con tu administrador.",
+ "rememberMe": "Recuérdame"
},
"mfa": {
"title": "Autenticación de dos factores",
@@ -543,6 +546,7 @@
"autoComplete": "Autocompletar"
},
"kanban": {
+ "status": "Estado",
"priority": "Prioridad",
"score": "Puntuación",
"assignee": "Asignado a",
@@ -572,6 +576,7 @@
"itemTitle": "Título del elemento",
"manageStatuses": "Gestionar estados",
"viewArchived": "Ver archivados",
+ "changeStatus": "Cambiar estado",
"noBoards": "No se encontraron tableros",
"noBoardsYet": "Aún no hay tableros Kanban",
"createFirstBoard": "Cree su primer tablero Kanban para empezar a gestionar sus proyectos.",
@@ -586,6 +591,13 @@
"targetDate": "Fecha objetivo",
"statusUpdated": "Estado actualizado",
"itemArchived": "Elemento archivado",
+ "archiveAllItemsInColumn": "Archivar todos los elementos en {column}",
+ "archiveAllConfirmTitle": "¿Archivar todos los elementos?",
+ "archiveAllConfirmMessage": "¿Archivar los {count} elementos en {column}? Puedes restaurarlos desde el archivo.",
+ "archiveAllSuccess": "{count} elementos archivados",
+ "archiveAllFailed": "No se pudieron archivar todos los elementos",
+ "moveFailed": "No se pudo mover la tarjeta",
+ "archiveAllPartial": "{archived} de {total} elementos archivados. Algunos elementos no se pudieron archivar.",
"itemDeleted": "Elemento eliminado",
"estimatedTime": "Tiempo estimado (horas)",
"actualVsEstimated": "{actual} / {estimated}",
@@ -680,7 +692,27 @@
"connection_plural": "conexiones",
"terminateSessionConfirm": "¿Está seguro de que desea terminar esta sesión?",
"terminateAllSessionsConfirm": "¿Está seguro de que desea terminar todas las demás sesiones?",
- "validationErrorTitle": "Error de validación"
+ "validationErrorTitle": "Error de validación",
+ "visibleGraphSummary": "{items} elementos visibles, {links} enlaces visibles",
+ "graphTruncated": "Mostrando los {visible} elementos con más conexiones. Se omitieron {omitted} elementos con menos conexiones.",
+ "totalConnections": "Total",
+ "inboundConnections": "Entrantes",
+ "outboundConnections": "Salientes",
+ "updated": "Actualizado",
+ "openItem": "Abrir elemento",
+ "selectItem": "Selecciona un elemento",
+ "inspectNodeHint": "Toca o haz clic en un nodo para ver el título, la categoría, el número de enlaces y la actividad reciente.",
+ "graphSearchPlaceholder": "Título, ruta, categoría o etiqueta",
+ "orphans": "Huérfanos",
+ "labels": "Etiquetas",
+ "arrows": "Flechas",
+ "nodeSize": "Tamaño del nodo",
+ "linkWidth": "Grosor del enlace",
+ "linkDistance": "Distancia del enlace",
+ "repelForce": "Repulsión",
+ "tagLinks": "Enlaces de etiquetas",
+ "linkedItems": "Elementos vinculados",
+ "uuid": "UUID"
},
"settings": {
"title": "Configuración",
@@ -836,6 +868,11 @@
"showStatusOnCards": "Mostrar estado",
"hideStatusOnCards": "Ocultar estado",
"hideStatusOnCardsDescription": "Oculta la etiqueta de estado en las tarjetas Kanban. El estado ya se indica por la columna en la que se encuentra la tarjeta.",
+ "hideMobileStatusDropdownLabel": "Menú desplegable de estado móvil en tarjetas",
+ "showMobileStatusDropdown": "Mostrar menú móvil",
+ "hideMobileStatusDropdown": "Ocultar menú móvil",
+ "selectMobileStatusDropdown": "Seleccionar comportamiento del menú de estado móvil",
+ "hideMobileStatusDropdownDescription": "Controla el menú desplegable de estado siempre visible en tarjetas Kanban móviles. La configuración de la etiqueta de estado es independiente.",
"selectStatusOnCards": "Seleccionar visibilidad del estado",
"codeBlockChoice": "Elija el estilo del bloque de código",
"themedCodeBlock": "Bloque de código temático",
diff --git a/app/_translations/fr.json b/app/_translations/fr.json
index 65224617..55e4b88f 100644
--- a/app/_translations/fr.json
+++ b/app/_translations/fr.json
@@ -7,6 +7,8 @@
"view": "Afficher",
"new": "Nouveau",
"close": "Fermer",
+ "enlarge": "Agrandir",
+ "shrink": "Réduire",
"confirm": "Confirmer",
"loading": "Chargement...",
"search": "Rechercher",
@@ -281,7 +283,8 @@
"createUser": "Créer un utilisateur",
"attemptsRemaining": "Attention : {count, plural, one {# tentative} other {# tentatives}} restantes avant le verrouillage du compte",
"accountLocked": "Trop de tentatives échouées. Veuillez réessayer dans {seconds} {seconds, plural, one {seconde} other {secondes}}",
- "notAuthorized": "Vous n’êtes pas autorisé à accéder à cette application. Contactez votre administrateur."
+ "notAuthorized": "Vous n’êtes pas autorisé à accéder à cette application. Contactez votre administrateur.",
+ "rememberMe": "Se souvenir de moi"
},
"mfa": {
"title": "Authentification à deux facteurs",
@@ -543,6 +546,7 @@
"autoComplete": "Complétion automatique"
},
"kanban": {
+ "status": "Statut",
"priority": "Priorité",
"score": "Score",
"assignee": "Assigné à",
@@ -572,6 +576,7 @@
"itemTitle": "Titre de l'élément",
"manageStatuses": "Gérer les statuts",
"viewArchived": "Voir les archivés",
+ "changeStatus": "Changer le statut",
"noBoards": "Aucun tableau trouvé",
"noBoardsYet": "Pas encore de tableaux Kanban",
"createFirstBoard": "Créez votre premier tableau Kanban pour gérer vos projets.",
@@ -586,6 +591,13 @@
"targetDate": "Date cible",
"statusUpdated": "Statut mis à jour",
"itemArchived": "Élément archivé",
+ "archiveAllItemsInColumn": "Archiver tous les éléments dans {column}",
+ "archiveAllConfirmTitle": "Archiver tous les éléments ?",
+ "archiveAllConfirmMessage": "Archiver les {count} éléments dans {column} ? Vous pourrez les restaurer depuis l'archive.",
+ "archiveAllSuccess": "{count} éléments archivés",
+ "archiveAllFailed": "Impossible d'archiver tous les éléments",
+ "moveFailed": "Impossible de déplacer la carte",
+ "archiveAllPartial": "{archived} élément(s) sur {total} archivé(s). Certains éléments n'ont pas pu être archivés.",
"itemDeleted": "Élément supprimé",
"estimatedTime": "Temps estimé (heures)",
"actualVsEstimated": "{actual} / {estimated}",
@@ -680,7 +692,27 @@
"connection_plural": "connexions",
"terminateSessionConfirm": "Êtes‑vous sûr de vouloir terminer cette session ?",
"terminateAllSessionsConfirm": "Êtes‑vous sûr de vouloir terminer toutes les autres sessions ?",
- "validationErrorTitle": "Erreur de validation"
+ "validationErrorTitle": "Erreur de validation",
+ "visibleGraphSummary": "{items} éléments visibles, {links} liens visibles",
+ "graphTruncated": "Affichage des {visible} éléments les plus connectés. {omitted} éléments moins connectés ont été masqués.",
+ "totalConnections": "Total",
+ "inboundConnections": "Entrants",
+ "outboundConnections": "Sortants",
+ "updated": "Mis à jour",
+ "openItem": "Ouvrir l'élément",
+ "selectItem": "Sélectionner un élément",
+ "inspectNodeHint": "Touchez ou cliquez sur un nœud pour consulter le titre, la catégorie, le nombre de liens et l'activité récente.",
+ "graphSearchPlaceholder": "Titre, chemin, catégorie ou tag",
+ "orphans": "Orphelins",
+ "labels": "Étiquettes",
+ "arrows": "Flèches",
+ "nodeSize": "Taille des nœuds",
+ "linkWidth": "Largeur des liens",
+ "linkDistance": "Distance des liens",
+ "repelForce": "Répulsion",
+ "tagLinks": "Liens de tags",
+ "linkedItems": "Éléments liés",
+ "uuid": "UUID"
},
"settings": {
"title": "Paramètres",
@@ -836,6 +868,11 @@
"showStatusOnCards": "Afficher le statut",
"hideStatusOnCards": "Masquer le statut",
"hideStatusOnCardsDescription": "Masque l'étiquette de statut sur les cartes Kanban. Le statut est déjà indiqué par la colonne dans laquelle se trouve la carte.",
+ "hideMobileStatusDropdownLabel": "Menu déroulant de statut mobile sur les cartes",
+ "showMobileStatusDropdown": "Afficher le menu mobile",
+ "hideMobileStatusDropdown": "Masquer le menu mobile",
+ "selectMobileStatusDropdown": "Sélectionner le comportement du menu de statut mobile",
+ "hideMobileStatusDropdownDescription": "Contrôle le menu déroulant de statut toujours visible sur les cartes Kanban mobiles. Le réglage de l'étiquette de statut est séparé.",
"selectStatusOnCards": "Sélectionner la visibilité du statut",
"codeBlockChoice": "Choisissez le style de bloc de code",
"themedCodeBlock": "Bloc de code thématique",
diff --git a/app/_translations/it.json b/app/_translations/it.json
index 5410a7ee..3ffa619d 100644
--- a/app/_translations/it.json
+++ b/app/_translations/it.json
@@ -7,6 +7,8 @@
"view": "Visualizza",
"new": "Nuovo",
"close": "Chiudi",
+ "enlarge": "Ingrandisci",
+ "shrink": "Riduci",
"confirm": "Conferma",
"loading": "Caricamento...",
"search": "Cerca",
@@ -281,7 +283,8 @@
"createUser": "Crea Utente",
"attemptsRemaining": "Attenzione: {count, plural, one {# tentativo} other {# tentativi}} rimanenti prima del blocco dell'account",
"accountLocked": "Troppi tentativi falliti. Riprova tra {seconds} {seconds, plural, one {secondo} other {secondi}}",
- "notAuthorized": "Non sei autorizzato ad accedere a questa applicazione. Contatta l’amministratore."
+ "notAuthorized": "Non sei autorizzato ad accedere a questa applicazione. Contatta l’amministratore.",
+ "rememberMe": "Ricordami"
},
"mfa": {
"title": "Autenticazione a Due Fattori",
@@ -543,6 +546,7 @@
"autoComplete": "Complete automaticamente"
},
"kanban": {
+ "status": "Stato",
"priority": "Priorità",
"score": "Punteggio",
"assignee": "Assegnato a",
@@ -572,6 +576,7 @@
"itemTitle": "Titolo elemento",
"manageStatuses": "Gestisci stati",
"viewArchived": "Visualizza archiviati",
+ "changeStatus": "Cambia stato",
"noBoards": "Nessuna board trovata",
"noBoardsYet": "Nessuna board Kanban ancora",
"createFirstBoard": "Crea la tua prima board Kanban per gestire i tuoi progetti.",
@@ -586,6 +591,13 @@
"targetDate": "Data obiettivo",
"statusUpdated": "Stato aggiornato",
"itemArchived": "Elemento archiviato",
+ "archiveAllItemsInColumn": "Archivia tutti gli elementi in {column}",
+ "archiveAllConfirmTitle": "Archiviare tutti gli elementi?",
+ "archiveAllConfirmMessage": "Archiviare tutti i {count} elementi in {column}? Potrai ripristinarli dall'archivio.",
+ "archiveAllSuccess": "{count} elementi archiviati",
+ "archiveAllFailed": "Impossibile archiviare tutti gli elementi",
+ "moveFailed": "Impossibile spostare la scheda",
+ "archiveAllPartial": "{archived} elementi su {total} archiviati. Alcuni elementi non sono stati archiviati.",
"itemDeleted": "Elemento eliminato",
"estimatedTime": "Tempo stimato (ore)",
"actualVsEstimated": "{actual} / {estimated}",
@@ -680,7 +692,27 @@
"connection_plural": "connessioni",
"terminateSessionConfirm": "Sei sicuro di voler terminare questa sessione?",
"terminateAllSessionsConfirm": "Sei sicuro di voler terminare tutte le altre sessioni?",
- "validationErrorTitle": "Errore di Validazione"
+ "validationErrorTitle": "Errore di Validazione",
+ "visibleGraphSummary": "{items} elementi visibili, {links} collegamenti visibili",
+ "graphTruncated": "Visualizzazione dei {visible} elementi più collegati. {omitted} elementi meno collegati sono stati omessi.",
+ "totalConnections": "Totale",
+ "inboundConnections": "In entrata",
+ "outboundConnections": "In uscita",
+ "updated": "Aggiornato",
+ "openItem": "Apri elemento",
+ "selectItem": "Seleziona un elemento",
+ "inspectNodeHint": "Tocca o fai clic su un nodo per visualizzare titolo, categoria, numero di collegamenti e attività recente.",
+ "graphSearchPlaceholder": "Titolo, percorso, categoria o tag",
+ "orphans": "Orfani",
+ "labels": "Etichette",
+ "arrows": "Frecce",
+ "nodeSize": "Dimensione nodi",
+ "linkWidth": "Larghezza collegamenti",
+ "linkDistance": "Distanza collegamenti",
+ "repelForce": "Repulsione",
+ "tagLinks": "Collegamenti tag",
+ "linkedItems": "Elementi collegati",
+ "uuid": "UUID"
},
"settings": {
"title": "Impostazioni",
@@ -836,6 +868,11 @@
"showStatusOnCards": "Mostra stato",
"hideStatusOnCards": "Nascondi stato",
"hideStatusOnCardsDescription": "Nasconde l'etichetta di stato dalle schede Kanban. Lo stato è già indicato dalla colonna in cui si trova la scheda.",
+ "hideMobileStatusDropdownLabel": "Menu a discesa stato mobile sulle schede",
+ "showMobileStatusDropdown": "Mostra menu mobile",
+ "hideMobileStatusDropdown": "Nascondi menu mobile",
+ "selectMobileStatusDropdown": "Seleziona comportamento del menu stato mobile",
+ "hideMobileStatusDropdownDescription": "Controlla il menu a discesa dello stato sempre visibile sulle schede Kanban mobili. L'impostazione dell'etichetta di stato è separata.",
"selectStatusOnCards": "Seleziona visibilità stato",
"codeBlockChoice": "Scegli lo stile del blocco di codice",
"themedCodeBlock": "Blocco di codice a tema",
diff --git a/app/_translations/klingon.json b/app/_translations/klingon.json
index 96732f20..af160134 100644
--- a/app/_translations/klingon.json
+++ b/app/_translations/klingon.json
@@ -7,6 +7,8 @@
"view": "legh",
"new": "chu'",
"close": "SoQ",
+ "enlarge": "jInmoH",
+ "shrink": "Hom",
"confirm": "pab",
"loading": "Su'lI'...",
"search": "nej",
@@ -306,7 +308,8 @@
"createUser": "lo'wI' yIchenmoH",
"attemptsRemaining": "ghuH: {count} logh neH bInIDlaH",
"accountLocked": "bInIDqu'. {seconds} lup 'uy' wa'leS yInIDqa'.",
- "notAuthorized": "bI'el 'e' chaw'be'lu'. ra'wI' yIghel."
+ "notAuthorized": "bI'el 'e' chaw'be'lu'. ra'wI' yIghel.",
+ "rememberMe": "qaqaw"
},
"mfa": {
"title": "cha'logh ngu'aS",
@@ -568,6 +571,7 @@
"autoComplete": "nIteb rIn"
},
"kanban": {
+ "status": "ghu'",
"priority": "nom mIw",
"score": "pong",
"assignee": "ghu' ghoS",
@@ -597,6 +601,7 @@
"itemTitle": "chel pong",
"manageStatuses": "ghu'mey yIvu'",
"viewArchived": "Pol yIlegh",
+ "changeStatus": "ghu' yIchoH",
"noBoards": "beQmey lutu'lu'be'",
"noBoardsYet": "Kanban beQmey not yet",
"createFirstBoard": "Kanban beQ wa' yIchel 'ej QInmey yIvu'.",
@@ -611,6 +616,13 @@
"targetDate": "jaj target",
"statusUpdated": "choH pIt",
"itemArchived": "Pol",
+ "archiveAllItemsInColumn": "{column} Hoch Dochmey pol",
+ "archiveAllConfirmTitle": "Hoch Dochmey pol?",
+ "archiveAllConfirmMessage": "{column}Daq {count} Dochmey Hoch pol? polmeH Daqvo' bIH DacheghmoHlaH.",
+ "archiveAllSuccess": "{count} Dochmey pol",
+ "archiveAllFailed": "Hoch Dochmey pollu'laHbe'",
+ "moveFailed": "navHom vIHmoHlu'laHbe'",
+ "archiveAllPartial": "{total}vo' {archived} Dochmey pol. Dochmey 'op pollu'laHbe'.",
"itemDeleted": "Qaw'pu'",
"estimatedTime": "pIq chartoq (rep)",
"actualVsEstimated": "{actual} / {estimated}",
@@ -705,7 +717,27 @@
"connection_plural": "rar",
"terminateSessionConfirm": "poHvam DamejmoH DaneH'a'?",
"terminateAllSessionsConfirm": "Hoch poHmey latlh DamejmoH DaneH'a'?",
- "validationErrorTitle": "nuD Qagh"
+ "validationErrorTitle": "nuD Qagh",
+ "visibleGraphSummary": "{items} Dochmey legh, {links} rarmey legh",
+ "graphTruncated": "{visible} Dochmey rarqu' 'anglu'. {omitted} Dochmey rar puS lImlu'pu'.",
+ "totalConnections": "naQ",
+ "inboundConnections": "'el",
+ "outboundConnections": "mej",
+ "updated": "chu'qa'",
+ "openItem": "Doch poSmoH",
+ "selectItem": "Doch wIv",
+ "inspectNodeHint": "Doch yISaH 'ej pong, Segh, rar mI', latlh Qu' yIlegh.",
+ "graphSearchPlaceholder": "pong, He, Segh, per ghap",
+ "orphans": "mobwI'pu'",
+ "labels": "permey",
+ "arrows": "naQHommey",
+ "nodeSize": "Doch tIn",
+ "linkWidth": "rar 'ab",
+ "linkDistance": "rar chuq",
+ "repelForce": "chev",
+ "tagLinks": "per rarmey",
+ "linkedItems": "Dochmey rar",
+ "uuid": "UUID"
},
"settings": {
"title": "SeH",
@@ -861,6 +893,11 @@
"showStatusOnCards": "ngoq legh",
"hideStatusOnCards": "ngoq So'",
"hideStatusOnCardsDescription": "Kanban ghItlhmey ngoq So'. ngoq DaH ram vIghItlh Daq legh.",
+ "hideMobileStatusDropdownLabel": "mobile ghu' menu ghItlhmeyDaq",
+ "showMobileStatusDropdown": "mobile menu yIcha'",
+ "hideMobileStatusDropdown": "mobile menu yISo'",
+ "selectMobileStatusDropdown": "mobile ghu' menu mIw yIwIv",
+ "hideMobileStatusDropdownDescription": "mobile Kanban ghItlhmeyDaq reH leghlu'bogh ghu' menu SeH. ngoq label setting latlh.",
"selectStatusOnCards": "ngoq leghlaHghach wIv",
"codeBlockChoice": "Choose the code block style",
"themedCodeBlock": "Themed codeblock",
diff --git a/app/_translations/ko.json b/app/_translations/ko.json
index fcf4122e..593a7755 100644
--- a/app/_translations/ko.json
+++ b/app/_translations/ko.json
@@ -7,6 +7,8 @@
"view": "보기",
"new": "새로 만들기",
"close": "닫기",
+ "enlarge": "확대",
+ "shrink": "축소",
"confirm": "확인",
"loading": "로딩 중...",
"search": "검색",
@@ -306,7 +308,8 @@
"createUser": "사용자 생성",
"attemptsRemaining": "경고: {count, plural, one {#회} other {#회}}의 시도가 남아 있습니다",
"accountLocked": "실패 시도가 너무 많습니다. {seconds} {seconds, plural, one {초} other {초}} 후에 다시 시도하세요",
- "notAuthorized": "접근 권한이 없습니다. 관리자에게 문의하세요."
+ "notAuthorized": "접근 권한이 없습니다. 관리자에게 문의하세요.",
+ "rememberMe": "로그인 상태 유지"
},
"mfa": {
"title": "2단계 인증 (2FA)",
@@ -568,6 +571,7 @@
"autoComplete": "자동 완료"
},
"kanban": {
+ "status": "상태",
"priority": "우선순위",
"score": "점수",
"assignee": "담당자",
@@ -597,6 +601,7 @@
"itemTitle": "항목 제목",
"manageStatuses": "상태 관리",
"viewArchived": "보관된 항목 보기",
+ "changeStatus": "상태 변경",
"noBoards": "보드를 찾을 수 없습니다",
"noBoardsYet": "아직 칸반 보드가 없습니다",
"createFirstBoard": "프로젝트 관리를 위해 첫 번째 칸반 보드를 만드세요.",
@@ -611,6 +616,13 @@
"targetDate": "목표일",
"statusUpdated": "상태가 업데이트됨",
"itemArchived": "항목이 보관됨",
+ "archiveAllItemsInColumn": "{column}의 모든 항목 보관",
+ "archiveAllConfirmTitle": "모든 항목을 보관할까요?",
+ "archiveAllConfirmMessage": "{column}의 항목 {count}개를 모두 보관할까요? 보관함에서 복원할 수 있습니다.",
+ "archiveAllSuccess": "항목 {count}개가 보관됨",
+ "archiveAllFailed": "모든 항목을 보관하지 못했습니다",
+ "moveFailed": "카드를 이동하지 못했습니다",
+ "archiveAllPartial": "항목 {total}개 중 {archived}개가 보관되었습니다. 일부 항목은 보관하지 못했습니다.",
"itemDeleted": "항목이 삭제됨",
"estimatedTime": "예상 시간(시간)",
"actualVsEstimated": "{actual} / {estimated}",
@@ -705,7 +717,27 @@
"connection_plural": "연결",
"terminateSessionConfirm": "이 세션을 종료하시겠습니까?",
"terminateAllSessionsConfirm": "다른 모든 세션을 종료하시겠습니까?",
- "validationErrorTitle": "유효성 검사 오류"
+ "validationErrorTitle": "유효성 검사 오류",
+ "visibleGraphSummary": "표시된 항목 {items}개, 표시된 링크 {links}개",
+ "graphTruncated": "연결 수가 가장 많은 상위 {visible}개 항목을 표시합니다. 연결이 적은 {omitted}개 항목은 생략되었습니다.",
+ "totalConnections": "전체",
+ "inboundConnections": "수신",
+ "outboundConnections": "발신",
+ "updated": "업데이트됨",
+ "openItem": "항목 열기",
+ "selectItem": "항목 선택",
+ "inspectNodeHint": "노드를 탭하거나 클릭하면 제목, 카테고리, 링크 수, 최근 활동을 확인할 수 있습니다.",
+ "graphSearchPlaceholder": "제목, 경로, 카테고리 또는 태그",
+ "orphans": "고립 항목",
+ "labels": "레이블",
+ "arrows": "화살표",
+ "nodeSize": "노드 크기",
+ "linkWidth": "링크 두께",
+ "linkDistance": "링크 거리",
+ "repelForce": "척력",
+ "tagLinks": "태그 링크",
+ "linkedItems": "연결된 항목",
+ "uuid": "UUID"
},
"settings": {
"title": "설정",
@@ -861,6 +893,11 @@
"showStatusOnCards": "상태 표시",
"hideStatusOnCards": "상태 숨기기",
"hideStatusOnCardsDescription": "칸반 카드에서 상태 레이블을 숨깁니다. 상태는 카드가 있는 열로 이미 표시됩니다.",
+ "hideMobileStatusDropdownLabel": "카드의 모바일 상태 드롭다운",
+ "showMobileStatusDropdown": "모바일 드롭다운 표시",
+ "hideMobileStatusDropdown": "모바일 드롭다운 숨기기",
+ "selectMobileStatusDropdown": "모바일 상태 드롭다운 동작 선택",
+ "hideMobileStatusDropdownDescription": "모바일 칸반 카드에서 항상 보이는 상태 드롭다운을 제어합니다. 상태 레이블 설정은 별도입니다.",
"selectStatusOnCards": "상태 표시 여부 선택",
"codeBlockChoice": "코드 블록 스타일을 선택하세요",
"themedCodeBlock": "테마 코드블록",
diff --git a/app/_translations/nl.json b/app/_translations/nl.json
index bad88248..a27b40d4 100644
--- a/app/_translations/nl.json
+++ b/app/_translations/nl.json
@@ -7,6 +7,8 @@
"view": "Bekijken",
"new": "Nieuw",
"close": "Sluiten",
+ "enlarge": "Vergroten",
+ "shrink": "Verkleinen",
"confirm": "Bevestigen",
"loading": "Laden...",
"search": "Zoeken",
@@ -281,7 +283,8 @@
"createUser": "Gebruiker aanmaken",
"attemptsRemaining": "Waarschuwing: {count, plural, one {# poging} other {# pogingen}} over voordat account wordt vergrendeld",
"accountLocked": "Te veel mislukte pogingen. Probeer het opnieuw over {seconds} {seconds, plural, one {seconde} other {seconden}}",
- "notAuthorized": "U bent niet geautoriseerd om toegang te krijgen tot deze applicatie. Neem contact op met uw beheerder."
+ "notAuthorized": "U bent niet geautoriseerd om toegang te krijgen tot deze applicatie. Neem contact op met uw beheerder.",
+ "rememberMe": "Aangemeld blijven"
},
"mfa": {
"title": "Twee-factorauthenticatie",
@@ -543,6 +546,7 @@
"autoComplete": "Automatisch voltooien"
},
"kanban": {
+ "status": "Status",
"priority": "Prioriteit",
"score": "Score",
"assignee": "Toegewezen aan",
@@ -572,6 +576,7 @@
"itemTitle": "Itemtitel",
"manageStatuses": "Statussen beheren",
"viewArchived": "Gearchiveerde bekijken",
+ "changeStatus": "Status wijzigen",
"noBoards": "Geen borden gevonden",
"noBoardsYet": "Nog geen Kanban-borden",
"createFirstBoard": "Maak je eerste Kanban-bord om je projecten te beheren.",
@@ -586,6 +591,13 @@
"targetDate": "Doeldatum",
"statusUpdated": "Status bijgewerkt",
"itemArchived": "Item gearchiveerd",
+ "archiveAllItemsInColumn": "Alle items in {column} archiveren",
+ "archiveAllConfirmTitle": "Alle items archiveren?",
+ "archiveAllConfirmMessage": "Alle {count} items in {column} archiveren? Je kunt ze herstellen vanuit het archief.",
+ "archiveAllSuccess": "{count} items gearchiveerd",
+ "archiveAllFailed": "Kon niet alle items archiveren",
+ "moveFailed": "Kon de kaart niet verplaatsen",
+ "archiveAllPartial": "{archived} van {total} items gearchiveerd. Sommige items konden niet worden gearchiveerd.",
"itemDeleted": "Item verwijderd",
"estimatedTime": "Geschatte tijd (uur)",
"actualVsEstimated": "{actual} / {estimated}",
@@ -680,7 +692,27 @@
"connection_plural": "verbindingen",
"terminateSessionConfirm": "Weet u zeker dat u deze sessie wilt beëindigen?",
"terminateAllSessionsConfirm": "Weet u zeker dat u alle andere sessies wilt beëindigen?",
- "validationErrorTitle": "Validatiefout"
+ "validationErrorTitle": "Validatiefout",
+ "visibleGraphSummary": "{items} zichtbare items, {links} zichtbare links",
+ "graphTruncated": "De {visible} meest verbonden items worden weergegeven. {omitted} minder verbonden items zijn weggelaten.",
+ "totalConnections": "Totaal",
+ "inboundConnections": "Inkomend",
+ "outboundConnections": "Uitgaand",
+ "updated": "Bijgewerkt",
+ "openItem": "Item openen",
+ "selectItem": "Selecteer een item",
+ "inspectNodeHint": "Tik of klik op een knooppunt om titel, categorie, aantal links en recente activiteit te bekijken.",
+ "graphSearchPlaceholder": "Titel, pad, categorie of tag",
+ "orphans": "Verweesd",
+ "labels": "Labels",
+ "arrows": "Pijlen",
+ "nodeSize": "Knooppuntgrootte",
+ "linkWidth": "Lijndikte",
+ "linkDistance": "Lijnafstand",
+ "repelForce": "Afstoting",
+ "tagLinks": "Taglinks",
+ "linkedItems": "Gekoppelde items",
+ "uuid": "UUID"
},
"settings": {
"title": "Instellingen",
@@ -836,6 +868,11 @@
"showStatusOnCards": "Status tonen",
"hideStatusOnCards": "Status verbergen",
"hideStatusOnCardsDescription": "Verbergt het statuslabel op Kanban-kaarten. De status is al zichtbaar via de kolom waarin de kaart zich bevindt.",
+ "hideMobileStatusDropdownLabel": "Mobiele statuskeuzelijst op kaarten",
+ "showMobileStatusDropdown": "Mobiele keuzelijst tonen",
+ "hideMobileStatusDropdown": "Mobiele keuzelijst verbergen",
+ "selectMobileStatusDropdown": "Gedrag van mobiele statuskeuzelijst selecteren",
+ "hideMobileStatusDropdownDescription": "Bepaalt de altijd zichtbare statuskeuzelijst op mobiele Kanban-kaarten. De instelling voor het statuslabel blijft apart.",
"selectStatusOnCards": "Selecteer zichtbaarheid status",
"codeBlockChoice": "Kies de codeblokstijl",
"themedCodeBlock": "Thema-codeblok",
diff --git a/app/_translations/pirate.json b/app/_translations/pirate.json
index bc5e316b..81b5db9e 100644
--- a/app/_translations/pirate.json
+++ b/app/_translations/pirate.json
@@ -7,6 +7,8 @@
"view": "Spy",
"new": "Fresh",
"close": "Batten Down",
+ "enlarge": "Hoist the Sails",
+ "shrink": "Lower the Sails",
"confirm": "Aye!",
"loading": "Hoisting the sails...",
"search": "Scour",
@@ -306,7 +308,8 @@
"createUser": "Recruit Deckhand",
"attemptsRemaining": "Warning: {count} tries left before we throw ye in the brig",
"accountLocked": "To the brig with ye! Wait {seconds} seconds.",
- "notAuthorized": "Ye lack the clearance! Talk to the Captain."
+ "notAuthorized": "Ye lack the clearance! Talk to the Captain.",
+ "rememberMe": "Remember me, matey"
},
"mfa": {
"title": "Two-Step Verification",
@@ -568,6 +571,7 @@
"autoComplete": "Auto finish"
},
"kanban": {
+ "status": "Condition",
"priority": "What matters most",
"score": "Booty score",
"assignee": "Crew hand",
@@ -597,6 +601,7 @@
"itemTitle": "Cargo title",
"manageStatuses": "Manage conditions",
"viewArchived": "Spy the hold",
+ "changeStatus": "Change condition",
"noBoards": "No boards in sight",
"noBoardsYet": "No Kanban boards yet",
"createFirstBoard": "Create yer first Kanban board to chart yer projects.",
@@ -611,6 +616,13 @@
"targetDate": "Target date",
"statusUpdated": "Status be updated",
"itemArchived": "Item sent to Davy Jones’s hold",
+ "archiveAllItemsInColumn": "Stow all items in {column}",
+ "archiveAllConfirmTitle": "Stow all items?",
+ "archiveAllConfirmMessage": "Stow all {count} items in {column}? Ye can haul them back from the archive.",
+ "archiveAllSuccess": "{count} items stowed",
+ "archiveAllFailed": "Could not stow all items",
+ "moveFailed": "Could not haul the card",
+ "archiveAllPartial": "{archived} of {total} items stowed. Some cargo could not be stowed.",
"itemDeleted": "Item walked the plank",
"estimatedTime": "Estimated hours o’ toil",
"actualVsEstimated": "{actual} / {estimated}",
@@ -705,7 +717,27 @@
"connection_plural": "lines",
"terminateSessionConfirm": "End this voyage?",
"terminateAllSessionsConfirm": "End all other voyages?",
- "validationErrorTitle": "Customs Error"
+ "validationErrorTitle": "Customs Error",
+ "visibleGraphSummary": "{items} items in sight, {links} lines in sight",
+ "graphTruncated": "Showin' the top {visible} items by line count. {omitted} lesser-tied items be left ashore.",
+ "totalConnections": "All Hands",
+ "inboundConnections": "Incomin'",
+ "outboundConnections": "Outgoin'",
+ "updated": "Freshened",
+ "openItem": "Open booty",
+ "selectItem": "Pick an item, matey",
+ "inspectNodeHint": "Tap or click a node to spy its title, category, line count, and recent doings.",
+ "graphSearchPlaceholder": "Title, course, category, or mark",
+ "orphans": "Castaways",
+ "labels": "Markings",
+ "arrows": "Arrows",
+ "nodeSize": "Node heft",
+ "linkWidth": "Rope width",
+ "linkDistance": "Rope length",
+ "repelForce": "Shove off",
+ "tagLinks": "Mark lines",
+ "linkedItems": "Tied items",
+ "uuid": "UUID"
},
"settings": {
"title": "Rigging",
@@ -861,6 +893,11 @@
"showStatusOnCards": "Show status",
"hideStatusOnCards": "Stow status",
"hideStatusOnCardsDescription": "Stows the status mark on yer Kanban scrolls. The status be already plain from the column the scroll sits in, ye scallywag.",
+ "hideMobileStatusDropdownLabel": "Mobile condition menu on scrolls",
+ "showMobileStatusDropdown": "Show mobile menu",
+ "hideMobileStatusDropdown": "Stow mobile menu",
+ "selectMobileStatusDropdown": "Choose mobile condition menu behavior",
+ "hideMobileStatusDropdownDescription": "Controls the always-visible condition menu on mobile Kanban scrolls. The status mark setting stays separate.",
"selectStatusOnCards": "Pick status visibility",
"codeBlockChoice": "Choose the code block style",
"themedCodeBlock": "Themed codeblock",
diff --git a/app/_translations/pl.json b/app/_translations/pl.json
index ca113cae..044c0794 100644
--- a/app/_translations/pl.json
+++ b/app/_translations/pl.json
@@ -7,6 +7,8 @@
"view": "Podgląd",
"new": "Nowy",
"close": "Zamknij",
+ "enlarge": "Powiększ",
+ "shrink": "Pomniejsz",
"confirm": "Potwierdź",
"loading": "Ładowanie…",
"search": "Szukaj",
@@ -281,7 +283,8 @@
"createUser": "Utwórz użytkownika",
"attemptsRemaining": "Uwaga: {count, plural, one {# próba} few {# próby} other {# prób}} pozostało przed zablokowaniem konta",
"accountLocked": "Zbyt wiele nieudanych prób. Spróbuj ponownie za {seconds} {seconds, plural, one {sekundę} few {sekundy} other {sekund}}",
- "notAuthorized": "Nie masz uprawnień dostępu do tej aplikacji. Skontaktuj się z administratorem."
+ "notAuthorized": "Nie masz uprawnień dostępu do tej aplikacji. Skontaktuj się z administratorem.",
+ "rememberMe": "Zapamiętaj mnie"
},
"mfa": {
"title": "Autoryzacja dwuskładnikowa",
@@ -543,6 +546,7 @@
"autoComplete": "Automatyczne uzupełnienie"
},
"kanban": {
+ "status": "Status",
"priority": "Priorytet",
"score": "Punkty",
"assignee": "Przypisany do",
@@ -572,6 +576,7 @@
"itemTitle": "Tytuł elementu",
"manageStatuses": "Zarządzaj statusami",
"viewArchived": "Pokaż zarchiwizowane",
+ "changeStatus": "Zmień status",
"noBoards": "Nie znaleziono tablic",
"noBoardsYet": "Brak tablic Kanban",
"createFirstBoard": "Utwórz pierwszą tablicę Kanban, aby zarządzać projektami.",
@@ -586,6 +591,13 @@
"targetDate": "Data docelowa",
"statusUpdated": "Status zaktualizowany",
"itemArchived": "Element zarchiwizowany",
+ "archiveAllItemsInColumn": "Archiwizuj wszystkie elementy w {column}",
+ "archiveAllConfirmTitle": "Zarchiwizować wszystkie elementy?",
+ "archiveAllConfirmMessage": "Zarchiwizować wszystkie elementy ({count}) w {column}? Możesz je przywrócić z archiwum.",
+ "archiveAllSuccess": "Zarchiwizowano elementy: {count}",
+ "archiveAllFailed": "Nie udało się zarchiwizować wszystkich elementów",
+ "moveFailed": "Nie udało się przenieść karty",
+ "archiveAllPartial": "Zarchiwizowano {archived} z {total} elementów. Niektórych elementów nie udało się zarchiwizować.",
"itemDeleted": "Element usunięty",
"estimatedTime": "Szacowany czas (godziny)",
"actualVsEstimated": "{actual} / {estimated}",
@@ -680,7 +692,27 @@
"connection_plural": "połączenia",
"terminateSessionConfirm": "Czy na pewno chcesz zakończyć tę sesję?",
"terminateAllSessionsConfirm": "Czy na pewno chcesz zakończyć wszystkie inne sesje?",
- "validationErrorTitle": "Błąd walidacji"
+ "validationErrorTitle": "Błąd walidacji",
+ "visibleGraphSummary": "{items, plural, one {# widoczny element} few {# widoczne elementy} other {# widocznych elementów}}, {links, plural, one {# widoczne połączenie} few {# widoczne połączenia} other {# widocznych połączeń}}",
+ "graphTruncated": "Wyświetlono {visible} najczęściej połączonych elementów. Pominięto {omitted, plural, one {# słabiej połączony element} few {# słabiej połączone elementy} other {# słabiej połączonych elementów}}.",
+ "totalConnections": "Łącznie",
+ "inboundConnections": "Przychodzące",
+ "outboundConnections": "Wychodzące",
+ "updated": "Zaktualizowano",
+ "openItem": "Otwórz element",
+ "selectItem": "Wybierz element",
+ "inspectNodeHint": "Dotknij lub kliknij węzeł, aby zobaczyć tytuł, kategorię, liczbę połączeń i ostatnią aktywność.",
+ "graphSearchPlaceholder": "Tytuł, ścieżka, kategoria lub tag",
+ "orphans": "Osierocone",
+ "labels": "Etykiety",
+ "arrows": "Strzałki",
+ "nodeSize": "Rozmiar węzła",
+ "linkWidth": "Szerokość połączeń",
+ "linkDistance": "Odległość połączeń",
+ "repelForce": "Odpychanie",
+ "tagLinks": "Połączenia tagów",
+ "linkedItems": "Połączone elementy",
+ "uuid": "UUID"
},
"settings": {
"title": "Ustawienia",
@@ -836,6 +868,11 @@
"showStatusOnCards": "Pokaż status",
"hideStatusOnCards": "Ukryj status",
"hideStatusOnCardsDescription": "Ukrywa etykietę statusu na kartach Kanban. Status jest już wskazany przez kolumnę, w której znajduje się karta.",
+ "hideMobileStatusDropdownLabel": "Mobilna lista statusu na kartach",
+ "showMobileStatusDropdown": "Pokaż listę mobilną",
+ "hideMobileStatusDropdown": "Ukryj listę mobilną",
+ "selectMobileStatusDropdown": "Wybierz zachowanie mobilnej listy statusu",
+ "hideMobileStatusDropdownDescription": "Steruje zawsze widoczną listą statusu na mobilnych kartach Kanban. Ustawienie etykiety statusu jest oddzielne.",
"selectStatusOnCards": "Wybierz widoczność statusu",
"codeBlockChoice": "Wybierz styl bloku kodu",
"themedCodeBlock": "Tematyczny blok kodu",
diff --git a/app/_translations/pt.json b/app/_translations/pt.json
new file mode 100644
index 00000000..94eabb48
--- /dev/null
+++ b/app/_translations/pt.json
@@ -0,0 +1,1672 @@
+{
+ "common": {
+ "save": "Guardar",
+ "cancel": "Cancelar",
+ "delete": "Eliminar",
+ "edit": "Editar",
+ "view": "Ver",
+ "new": "Novo",
+ "close": "Fechar",
+ "enlarge": "Ampliar",
+ "shrink": "Reduzir",
+ "confirm": "Confirmar",
+ "loading": "A carregar...",
+ "search": "Pesquisar",
+ "filter": "Filtrar",
+ "add": "Adicionar",
+ "remove": "Remover",
+ "yes": "Sim",
+ "no": "Não",
+ "ok": "OK",
+ "back": "Voltar",
+ "next": "Seguinte",
+ "previous": "Anterior",
+ "continue": "Continuar",
+ "submit": "Submeter",
+ "create": "Criar",
+ "update": "Atualizar",
+ "upload": "Carregar",
+ "download": "Transferir",
+ "export": "Exportar",
+ "import": "Importar",
+ "copy": "Copiar",
+ "copied": "Copiado para a área de transferência",
+ "copyFailed": "Falha ao copiar para a área de transferência",
+ "paste": "Colar",
+ "cut": "Cortar",
+ "undo": "Desfazer",
+ "redo": "Refazer",
+ "select": "Selecionar",
+ "selectAll": "Selecionar tudo",
+ "clear": "Limpar",
+ "reset": "Repor",
+ "apply": "Aplicar",
+ "settings": "Definições",
+ "profile": "Perfil",
+ "logout": "Terminar sessão",
+ "login": "Iniciar sessão",
+ "register": "Registar",
+ "home": "Início",
+ "dashboard": "Painel",
+ "admin": "Administrador",
+ "user": "Utilizador",
+ "users": "Utilizadores",
+ "name": "Nome",
+ "title": "Título",
+ "pinned": "Fixado",
+ "description": "Descrição",
+ "date": "Data",
+ "time": "Hora",
+ "status": "Estado",
+ "actions": "Ações",
+ "options": "Opções",
+ "help": "Ajuda",
+ "about": "Sobre",
+ "version": "Versão",
+ "theme": "Tema",
+ "language": "Idioma",
+ "password": "Palavra-passe",
+ "username": "Nome de utilizador",
+ "email": "E-mail",
+ "phone": "Telefone",
+ "address": "Morada",
+ "city": "Cidade",
+ "country": "País",
+ "error": "Erro",
+ "success": "Sucesso",
+ "warning": "Aviso",
+ "info": "Informação",
+ "required": "Obrigatório",
+ "optional": "Opcional",
+ "enabled": "Ativado",
+ "disabled": "Desativado",
+ "active": "Ativo",
+ "inactive": "Inativo",
+ "public": "Público",
+ "private": "Privado",
+ "shared": "Partilhado",
+ "owner": "Proprietário",
+ "created": "Criado",
+ "modified": "Modificado",
+ "deleted": "Eliminado",
+ "deleting": "A eliminar...",
+ "saving": "A guardar...",
+ "done": "Concluído",
+ "uploading": "A carregar...",
+ "open": "Abrir",
+ "enterPassword": "Introduzir palavra-passe",
+ "confirmPassword": "Confirmar palavra-passe",
+ "openFile": "Abrir ficheiro",
+ "downloadFile": "Transferir ficheiro",
+ "deleteFile": "Eliminar ficheiro",
+ "fileTooLarge": "O ficheiro é demasiado grande. O tamanho máximo é {maxSizeMB}MB. O seu ficheiro tem {fileSizeMB}MB.",
+ "uploadFailed": "Falha ao carregar",
+ "uploadFailedRetry": "Falha ao carregar. Por favor, tente novamente.",
+ "failedToDeleteFile": "Falha ao eliminar o ficheiro",
+ "previousMonth": "Mês anterior",
+ "nextMonth": "Mês seguinte",
+ "currentIcon": "Ícone atual",
+ "dropImageOrClick": "Arraste uma imagem ou clique para carregar",
+ "selectRecurrencePattern": "Selecionar padrão de recorrência",
+ "clearEndDate": "Limpar data de fim",
+ "selectStartDate": "Selecionar data de início",
+ "selectEndDateOptional": "Selecionar data de fim (opcional)",
+ "api": "API",
+ "appLogo": "Logótipo da aplicação",
+ "readingProgress": "Progresso de leitura",
+ "filesAndImages": "Ficheiros e imagens",
+ "uncategorized": "Sem categoria",
+ "keyword": "Palavra-chave",
+ "emoji": "Emoji",
+ "exampleKeyword": "reunião",
+ "exampleEmoji": "🤝",
+ "high": "Alto",
+ "medium": "Médio",
+ "low": "Baixo",
+ "collapseAll": "Recolher tudo",
+ "expandAll": "Expandir tudo",
+ "saveChanges": "Guardar alterações",
+ "all": "Tudo",
+ "items": "Itens",
+ "showAll": "Mostrar tudo",
+ "creating": "A criar...",
+ "createCategoryHeader": "Criar uma nova categoria",
+ "parentCategory": "Categoria principal (opcional)",
+ "noParent": "Sem principal (nível raiz)",
+ "categoryNamePlaceholder": "Introduzir nome da categoria...",
+ "notAllowedName": "{name} não é permitido. Por favor, escolha um nome diferente.",
+ "createCategory": "Criar categoria",
+ "selectCategory": "Selecionar categoria...",
+ "categoryWillBeCreatedIn": "A nova categoria será criada em:",
+ "clone": "Clonar",
+ "cloneItem": "Clonar {item}",
+ "selectCategoryForClone": "Selecione a categoria para onde pretende copiar este item:",
+ "rootLevel": "Nível raiz",
+ "newCategory": "Nova categoria",
+ "renameCategory": "Renomear categoria",
+ "deleteCategory": "Eliminar categoria",
+ "categoryName": "Nome da categoria",
+ "enterCategoryName": "Introduzir nome da categoria...",
+ "renaming": "A renomear...",
+ "rename": "Renomear",
+ "enterNewCategoryName": "Introduzir um novo nome para \"{categoryName}\"",
+ "filters": "Filtros",
+ "filterByCategories": "Filtrar por categorias...",
+ "byCategory": "Por categoria",
+ "recursive": "Recursivo",
+ "itemsPerPage": "Itens por página",
+ "perPage": "por página",
+ "page": "Página",
+ "pageOfPages": "{currentPage} de {totalPages}",
+ "searchPlaceholder": "Pesquisar notas e listas de verificação... (⌘K)",
+ "system": "Sistema",
+ "errors": "Erros",
+ "statistics": "Estatísticas",
+ "startDate": "Data de início",
+ "endDate": "Data de fim",
+ "category": "Categoria",
+ "exportJson": "Exportar JSON",
+ "exportCsv": "Exportar CSV",
+ "showingResults": "A mostrar {start}-{end} de {total}",
+ "dismiss": "Dispensar",
+ "cleaning": "A limpar...",
+ "noResultsFound": "Nenhum resultado encontrado",
+ "noResults": "Sem resultados",
+ "by": "por {owner}",
+ "unsavedChanges": "Alterações não guardadas",
+ "unsavedChangesDetected": "⚠️ Alterações não guardadas detetadas",
+ "unsavedChangesMessage": "Tem alterações não guardadas em {noteTitle}. Se sair agora, as suas alterações serão perdidas.",
+ "whatToDoWithUnsavedChanges": "O que pretende fazer com as alterações não guardadas?",
+ "discard": "Descartar",
+ "saveAndLeave": "Guardar e sair",
+ "thisNote": "esta nota",
+ "unpin": "Desafixar",
+ "pin": "Fixar",
+ "pinToHome": "Fixar no início",
+ "unpinFromHome": "Desafixar do início",
+ "archive": "Arquivar",
+ "confirmDeleteItem": "Tem a certeza de que pretende eliminar \"{itemTitle}\"?",
+ "sharedBy": "Partilhado por {sharer}",
+ "itemsCompleted": "{completedItems}/{totalItems} concluídos",
+ "releaseNotes": "Notas de versão v{version}",
+ "viewOnGithub": "Ver no GitHub",
+ "newVersion": "Nova versão: {version}",
+ "clickToSeeReleaseNotes": "Clique para ver as notas de versão {appName}",
+ "red": "Vermelho",
+ "orange": "Laranja",
+ "yellow": "Amarelo",
+ "green": "Verde",
+ "blue": "Azul",
+ "purple": "Roxo",
+ "pink": "Rosa",
+ "gray": "Cinzento",
+ "none": "Nenhum",
+ "auto": "Automático",
+ "unarchive": "Desarquivar",
+ "itemCount": "{count} itens",
+ "characterCount": "{count} caracteres",
+ "justNow": "Agora mesmo",
+ "completeAll": "Concluir tudo",
+ "resetAll": "Repor tudo",
+ "createdByOn": "Criado por {user} em {date}",
+ "lastModifiedByOn": "Última modificação por {user} em {date}",
+ "statusChanges": "Alterações de estado: {count}",
+ "createdBy": "Criado por {user}",
+ "lastModifiedBy": "Última modificação por {user}",
+ "minutesAgo": "{count, plural, one {há # minuto} other {há # minutos}}",
+ "hoursAgo": "{count, plural, one {há # hora} other {há # horas}}",
+ "daysAgo": "{count, plural, one {há # dia} other {há # dias}}",
+ "weeksAgo": "{count, plural, one {há # semana} other {há # semanas}}",
+ "monthsAgo": "{count, plural, one {há # mês} other {há # meses}}",
+ "yearsAgo": "{count, plural, one {há # ano} other {há # anos}}",
+ "closeWithoutAdding": "Fechar sem adicionar",
+ "adding": "A adicionar...",
+ "filterByStatus": "Filtrar por estado",
+ "noArchivedItems": "Sem itens arquivados",
+ "withThisStatus": "com este estado",
+ "archived": "Arquivado",
+ "restore": "Restaurar",
+ "checklist": "Lista de verificação",
+ "note": "Nota",
+ "read": "Lido",
+ "removeAll": "Remover tudo",
+ "noUsersFound": "Nenhum utilizador encontrado.",
+ "noCategoriesAvailable": "Sem categorias disponíveis",
+ "links": "Ligações",
+ "documentation": "Documentação",
+ "githubRepository": "Repositório GitHub",
+ "toggleSidebar": "Alternar barra lateral",
+ "openSearch": "Abrir pesquisa",
+ "closeSearch": "Fechar pesquisa",
+ "moreOptions": "Mais opções",
+ "history": "Histórico",
+ "disable": "Desativar",
+ "restoring": "A restaurar...",
+ "retry": "Tentar novamente",
+ "copiedToClipboard": "Copiado para a área de transferência",
+ "offline": "Sem ligação",
+ "connected": "Ligado",
+ "reconnecting": "A reconectar...",
+ "hide": "Ocultar",
+ "thisEntry": "esta entrada"
+ },
+ "history": {
+ "noteHistory": "Histórico da nota",
+ "restore": "Restaurar",
+ "restoreVersion": "Restaurar versão",
+ "restoreConfirm": "Restaurar esta versão? O conteúdo atual será substituído.",
+ "noHistory": "Sem histórico disponível",
+ "noHistoryDescription": "Guarde a sua nota para criar um ponto no histórico.",
+ "loadMore": "Carregar mais",
+ "featureDisabled": "Histórico desativado",
+ "disabledAdmin": "O histórico de notas está atualmente desativado. Pode ativá-lo nas Definições de administrador > Editor.",
+ "disabledUser": "O histórico de notas está atualmente desativado. Contacte o seu administrador para ativar esta funcionalidade.",
+ "goToSettings": "Ir para as definições",
+ "manualSavesOnly": "O histórico é criado apenas em guardações manuais, não em guardação automática.",
+ "fetchError": "Falha ao carregar o histórico",
+ "restoreError": "Falha ao restaurar a versão",
+ "viewContent": "Ver conteúdo",
+ "viewDiff": "Ver alterações",
+ "actionCreate": "Criado",
+ "actionUpdate": "Atualizado",
+ "actionRename": "Renomeado",
+ "actionMove": "Movido",
+ "actionDelete": "Eliminado",
+ "actionInit": "Inicializado",
+ "actionUnknown": "Alterado"
+ },
+ "auth": {
+ "loginTitle": "Iniciar sessão",
+ "setupTitle": "Configuração inicial",
+ "welcomeBack": "Bem-vindo de volta",
+ "signIn": "Inicie sessão na sua conta",
+ "signInWithOIDC": "Iniciar sessão com o fornecedor OIDC escolhido",
+ "signInButton": "Iniciar sessão",
+ "signInWithSSO": "Iniciar sessão com SSO",
+ "signingIn": "A iniciar sessão...",
+ "orContinueWith": "ou continuar com conta local",
+ "usernameLabel": "Nome de utilizador",
+ "passwordLabel": "Palavra-passe",
+ "confirmPasswordLabel": "Confirmar palavra-passe",
+ "loginButton": "Iniciar sessão",
+ "loggingIn": "A iniciar sessão...",
+ "invalidCredentials": "Nome de utilizador ou palavra-passe inválidos",
+ "sessionExpired": "A sua sessão expirou",
+ "setupComplete": "Configuração concluída",
+ "createAccount": "Criar a sua conta",
+ "errorOccurred": "Ocorreu um erro. Por favor, tente novamente.",
+ "enterUsername": "Introduzir o seu nome de utilizador",
+ "enterPassword": "Introduzir a sua palavra-passe",
+ "chooseUsername": "Escolher um nome de utilizador",
+ "choosePassword": "Escolher uma palavra-passe forte",
+ "confirmYourPassword": "Confirmar a sua palavra-passe",
+ "createAdminAccount": "Criar conta de administrador",
+ "creatingAccount": "A criar conta...",
+ "welcomeTo": "Bem-vindo ao {appName}",
+ "createAdminAccountDescription": "Crie a sua conta de administrador para começar",
+ "version": "Versão {version}",
+ "createUser": "Criar utilizador",
+ "attemptsRemaining": "Aviso: {count, plural, one {# tentativa restante} other {# tentativas restantes}} antes do bloqueio da conta",
+ "accountLocked": "Demasiadas tentativas falhadas. Por favor, tente novamente em {seconds} {seconds, plural, one {segundo} other {segundos}}",
+ "notAuthorized": "Não está autorizado a aceder a esta aplicação. Por favor, contacte o seu administrador.",
+ "rememberMe": "Lembrar-me"
+ },
+ "mfa": {
+ "title": "Autenticação de dois fatores",
+ "enable": "Ativar 2FA",
+ "disable": "Desativar 2FA",
+ "enabled": "2FA ativado",
+ "disabled": "2FA desativado",
+ "status": "Estado do 2FA",
+ "setupTitle": "Configurar autenticação de dois fatores",
+ "setupDescription": "Leia o código QR abaixo com a sua aplicação de autenticação (Google Authenticator, Authy, etc.)",
+ "scanQrCode": "Leia este código QR com a sua aplicação de autenticação",
+ "manualEntry": "Ou introduza este código manualmente:",
+ "verifySetup": "Introduza o código de 6 dígitos da sua aplicação para verificar",
+ "recoveryCode": "Código de recuperação",
+ "recoveryCodeTitle": "O seu código de recuperação",
+ "recoveryCodeInfo": "Guarde este código num local seguro. Pode fornecê-lo a um administrador do sistema para recuperar a sua conta caso perca o acesso à sua aplicação de autenticação.",
+ "verifyTitle": "Introduzir código de autenticação",
+ "verifyDescription": "Introduza o código de 6 dígitos da sua aplicação de autenticação",
+ "verifyPlaceholder": "000000",
+ "useRecoveryCode": "Usar código de recuperação",
+ "invalidCode": "Código de autenticação inválido",
+ "invalidRecoveryCode": "Código de recuperação inválido",
+ "mfaRequired": "A autenticação de dois fatores é obrigatória",
+ "mfaEnabledSuccess": "Autenticação de dois fatores ativada com sucesso",
+ "mfaDisabledSuccess": "Autenticação de dois fatores desativada",
+ "tooManyAttempts": "Demasiadas tentativas falhadas. Por favor, tente mais tarde.",
+ "disableTitle": "Desativar autenticação de dois fatores",
+ "disableDescription": "Introduza o seu código 2FA para confirmar a desativação da autenticação de dois fatores",
+ "disableWarning": "Isto tornará a sua conta menos segura. Tem a certeza?",
+ "verifying": "A verificar...",
+ "verify": "Verificar",
+ "setupComplete": "Configuração concluída!",
+ "setupCompleteDescription": "A autenticação de dois fatores foi ativada para a sua conta. Certifique-se de guardar o seu código de recuperação!",
+ "enterCode": "Introduzir código",
+ "accountName": "Conta: {username}",
+ "step1": "Passo 1: Ler código QR",
+ "step2": "Passo 2: Verificar código",
+ "verificationCode": "Código de verificação",
+ "verificationSuccess": "Verificação bem-sucedida",
+ "useAuthenticatorCode": "Usar código de autenticação",
+ "regenerateRecoveryCode": "Regenerar código de recuperação",
+ "regenerateRecoveryCodeTitle": "Regenerar código de recuperação",
+ "regenerateRecoveryCodeWarning": "Isto invalidará o seu código de recuperação atual",
+ "regenerateRecoveryCodeDescription": "Introduza o seu código 2FA para gerar um novo código de recuperação. O seu código antigo deixará de funcionar.",
+ "recoveryCodeRegenerated": "Novo código de recuperação gerado",
+ "recoveryCodeRegeneratedInfo": "O seu novo código de recuperação foi gerado. Guarde-o num local seguro - esta é a única vez que o verá.",
+ "recoveryCodeRegeneratedSuccess": "Código de recuperação regenerado com sucesso",
+ "recoveryCodeProfileDescription": "Use este código para recuperar a conta caso perca o acesso à sua aplicação de autenticação",
+ "disableMfaForUser": "Desativar 2FA para este utilizador",
+ "recoveryCodeRequired": "O código de recuperação é obrigatório",
+ "failedToDisableMfa": "Falha ao desativar MFA",
+ "adminDisableMfa": "Desativar 2FA"
+ },
+ "notes": {
+ "title": "Notas",
+ "newNote": "Nova nota",
+ "allNotes": "Todas as notas",
+ "recent": "Recentes",
+ "browseAndManage": "Navegar e gerir todas as suas notas",
+ "noNotesYet": "Ainda sem notas",
+ "createFirstNote": "Crie a sua primeira nota para começar a construir a sua base de conhecimentos.",
+ "createNewNote": "Criar nova nota",
+ "noNotesFound": "Nenhuma nota encontrada",
+ "tryAdjustingFilters": "Tente ajustar os filtros ou crie uma nova nota.",
+ "createNote": "Criar nota",
+ "editNote": "Editar nota",
+ "deleteNote": "Eliminar nota",
+ "noteTitle": "Título da nota",
+ "noteContent": "Conteúdo da nota",
+ "searchNotes": "Pesquisar notas",
+ "myNotes": "As minhas notas",
+ "sharedNotes": "Notas partilhadas",
+ "category": "Categoria",
+ "categories": "Categorias",
+ "uncategorized": "Sem categoria",
+ "newCategory": "Nova categoria",
+ "saveNote": "Guardar nota",
+ "discardChanges": "Descartar alterações",
+ "unsavedChanges": "Tem alterações não guardadas",
+ "drawioNoPreview": "Diagrama Draw.io (sem pré-visualização disponível)",
+ "excalidrawNoPreview": "Diagrama Excalidraw (sem pré-visualização disponível)",
+ "mermaidError": "Erro Mermaid:",
+ "referencedBy": "Referenciado por",
+ "noHeadingsFound": "Nenhum cabeçalho encontrado",
+ "contents": "Conteúdo",
+ "searchNotesAndChecklists": "Pesquisar notas e listas de verificação...",
+ "noResultsFound": "Nenhum resultado encontrado",
+ "startWritingAbove": "Comece a escrever a sua nota acima!",
+ "enterNoteTitle": "Introduzir título da nota...",
+ "byType": "Por tipo",
+ "publiclySharedNote": "Partilhado publicamente",
+ "copyRawContent": "Copiar markdown",
+ "quickSave": "Guardar rapidamente",
+ "noteNotFound": "Nota não encontrada",
+ "unarchiveNote": "Desarquivar nota",
+ "enterNoteName": "Introduzir nome da nota...",
+ "updateNote": "Atualizar nota",
+ "printSaveAsPdf": "Imprimir / Guardar como PDF",
+ "tableOfContents": "Índice",
+ "wordCount": "{count, plural, one {# palavra} other {# palavras}}",
+ "charCount": "{count, plural, one {# carácter} other {# caracteres}}",
+ "readingTime": "{count, plural, one {# minuto de leitura} other {# minutos de leitura}}",
+ "tags": "Etiquetas",
+ "noTagsFound": "Nenhuma etiqueta encontrada",
+ "searchTags": "Pesquisar etiquetas..."
+ },
+ "checklists": {
+ "title": "Listas de verificação",
+ "allChecklists": "Todas as listas de verificação",
+ "completed": "Concluídas",
+ "incomplete": "Incompletas",
+ "pinned": "Fixadas",
+ "taskLists": "Listas de tarefas",
+ "simpleLists": "Listas simples",
+ "browseAndManage": "Navegar e gerir todas as suas listas de verificação",
+ "byStatus": "Por estado",
+ "lists": "Listas",
+ "progress": "Progresso",
+ "totalItems": "Total de itens",
+ "noChecklistsYet": "Ainda sem listas de verificação",
+ "createFirstChecklist": "Crie a sua primeira lista de verificação para começar a organizar as suas tarefas.",
+ "newChecklist": "Nova lista de verificação",
+ "noChecklistsFound": "Nenhuma lista de verificação encontrada",
+ "tryAdjustingFiltersChecklist": "Tente ajustar os filtros ou crie uma nova lista de verificação.",
+ "createChecklist": "Criar lista de verificação",
+ "editChecklist": "Editar lista de verificação",
+ "deleteChecklist": "Eliminar lista de verificação",
+ "checklistTitle": "Título da lista de verificação",
+ "searchChecklists": "Pesquisar listas de verificação",
+ "myChecklists": "As minhas listas de verificação",
+ "sharedChecklists": "Listas de verificação partilhadas",
+ "addItem": "Adicionar item",
+ "removeItem": "Remover item",
+ "checkItem": "Marcar item",
+ "uncheckItem": "Desmarcar item",
+ "pending": "Pendente",
+ "loadingChecklist": "A carregar lista de verificação...",
+ "doNotRefresh": "Não atualize a página.",
+ "addNewItem": "Adicionar novo item...",
+ "bulkAddItems": "Adicionar itens em massa",
+ "bulk": "Em massa",
+ "addRecurringItem": "Adicionar item recorrente",
+ "addNewTask": "Adicionar nova tarefa...",
+ "publiclyShared": "Partilhado publicamente",
+ "subtasks": "Subtarefas",
+ "overallProgress": "Progresso geral",
+ "emptyChecklist": "Esta lista de verificação está vazia.",
+ "enterTaskTitle": "Introduzir título da tarefa...",
+ "addSubtask": "Adicionar uma subtarefa...",
+ "noteTitle": "Título da nota...",
+ "searchArchivedItems": "Pesquisar itens arquivados...",
+ "recent": "Listas de verificação recentes",
+ "checklistName": "Nome da lista de verificação",
+ "checklistNamePlaceholder": "Introduzir nome da lista de verificação...",
+ "createChecklistHeader": "Criar uma nova lista de verificação",
+ "allLists": "Todas as listas",
+ "addItemWithRecurrence": "Adicionar item com recorrência",
+ "recurringItemText": "Texto do item recorrente",
+ "recurringItemTextPlaceholder": "Introduzir o texto do item recorrente",
+ "checklistType": "Tipo de lista de verificação",
+ "simpleChecklist": "Lista de verificação simples",
+ "taskProject": "Projeto de tarefas",
+ "kanbanBoard": "Kanban",
+ "basicTodoItems": "Itens básicos a fazer",
+ "withTimeTracking": "Com controlo de tempo",
+ "noDescription": "Sem descrição",
+ "untitledTask": "Tarefa sem título",
+ "addDescriptionOptional": "Adicionar uma descrição (opcional)...",
+ "statusName": "Nome do estado",
+ "statusColor": "Cor do estado",
+ "archivedItems": "Itens arquivados",
+ "addSubItem": "Adicionar subitem",
+ "addSubItemPlaceholder": "Adicionar subitem...",
+ "checklistNotFound": "Lista de verificação não encontrada",
+ "unarchiveChecklist": "Desarquivar lista de verificação",
+ "enterChecklistName": "Introduzir nome da lista de verificação...",
+ "updating": "A atualizar...",
+ "updateChecklist": "Atualizar lista de verificação",
+ "checkAll": "Marcar tudo",
+ "uncheckAll": "Desmarcar tudo",
+ "clearAll": "Limpar tudo",
+ "checkAllConfirmTitle": "Marcar todos os itens?",
+ "checkAllConfirmMessage": "Tem a certeza de que pretende marcar todos os {count} itens e os seus filhos? Isto marcá-los-á como concluídos.",
+ "uncheckAllConfirmTitle": "Desmarcar todos os itens?",
+ "uncheckAllConfirmMessage": "Tem a certeza de que pretende desmarcar todos os {count} itens e os seus filhos? Isto marcá-los-á como incompletos.",
+ "clearAllConfirmTitle": "Limpar todos os itens?",
+ "clearAllCompletedConfirmMessage": "Tem a certeza de que pretende eliminar todos os {count} itens concluídos e os seus filhos? Esta ação não pode ser desfeita.",
+ "clearAllIncompleteConfirmMessage": "Tem a certeza de que pretende eliminar todos os {count} itens incompletos e os seus filhos? Esta ação não pode ser desfeita.",
+ "convertChecklistType": "Converter tipo de lista de verificação",
+ "convertTypeConfirmation": "Está prestes a converter de {currentType} para {newType}. Tem a certeza de que pretende continuar?",
+ "dataLossWarning": "⚠️ Aviso de perda de dados",
+ "dataLossWarningItems": "Os seguintes dados serão perdidos:",
+ "allTaskStatusesReset": "Todos os estados das tarefas serão repostos",
+ "timeTrackingDataLost": "Os dados de controlo de tempo serão perdidos",
+ "estimatedTimesRemoved": "Os tempos estimados serão removidos",
+ "targetDatesCleared": "As datas alvo serão eliminadas",
+ "enhancedFeatures": "✨ Funcionalidades melhoradas",
+ "enhancedFeaturesYouWillGain": "Vai ganhar:",
+ "kanbanBoardWithDragDrop": "Quadro Kanban com arrastar e largar",
+ "taskStatusTracking": "Controlo de estado de tarefas (A fazer, Em progresso, etc.)",
+ "timeTrackingWithTimer": "Controlo de tempo com temporizador integrado",
+ "actionCannotBeUndone": "Esta ação não pode ser desfeita. Tem a certeza de que pretende converter?",
+ "convertAndLoseData": "Converter e perder dados",
+ "convert": "Converter",
+ "convertToSimpleChecklist": "Converter para lista de verificação simples",
+ "convertToTaskProject": "Converter para projeto de tarefas",
+ "convertToKanbanBoard": "Converter para Kanban",
+ "pasteYourList": "Cole a sua lista (um item por linha)",
+ "addItems": "Adicionar {count} itens",
+ "itemsWillBeAdded": "{count} itens serão adicionados",
+ "itemsNotAddedYet": "Itens ainda não adicionados",
+ "youHaveItemsThatWillNotBeAdded": "Tem {count} itens que ainda não foram adicionados. Fechar sem adicionar ou adicioná-los agora?",
+ "noItemsYet": "Ainda sem itens",
+ "addFirstItem": "Adicione o seu primeiro item para começar!",
+ "noTasks": "Sem tarefas",
+ "noSubtasksYet": "Ainda sem subtarefas. Adicione uma abaixo para começar.",
+ "addTime": "Adicionar tempo",
+ "enterTimeInMinutes": "Introduzir o seu tempo em minutos"
+ },
+ "tasks": {
+ "title": "Tarefas",
+ "addTask": "Adicionar tarefa",
+ "createTask": "Criar tarefa",
+ "editTask": "Editar tarefa",
+ "deleteTask": "Eliminar tarefa",
+ "viewTask": "Ver tarefa",
+ "addSubtask": "Adicionar subtarefa",
+ "renameTask": "Renomear tarefa",
+ "archiveTask": "Arquivar tarefa",
+ "taskTitle": "Título da tarefa",
+ "taskDescription": "Descrição da tarefa",
+ "noTasks": "Nenhuma tarefa encontrada",
+ "searchTasks": "Pesquisar tarefas",
+ "allTasks": "Todas as tarefas",
+ "myTasks": "As minhas tarefas",
+ "assignedTasks": "Tarefas atribuídas",
+ "dueDate": "Data de entrega",
+ "priority": "Prioridade",
+ "assignee": "Responsável",
+ "status": "Estado",
+ "completed": "Concluída",
+ "inProgress": "Em progresso",
+ "todo": "A fazer",
+ "overdue": "Em atraso",
+ "browseAndManageTaskLists": "Navegar e gerir todas as suas listas de tarefas",
+ "noTaskListsYet": "Ainda sem listas de tarefas",
+ "createFirstTaskList": "Crie a sua primeira lista de tarefas para começar a gerir os seus projetos.",
+ "newTaskList": "Nova lista de tarefas",
+ "taskLists": "Listas de tarefas",
+ "noTaskListsFound": "Nenhuma lista de tarefas encontrada",
+ "tryAdjustingFiltersTaskList": "Tente ajustar os filtros ou crie uma nova lista de tarefas.",
+ "totalTime": "Tempo total:",
+ "recentTasks": "Tarefas recentes",
+ "showAllTasks": "Mostrar todas as tarefas",
+ "manageStatuses": "Gerir estados",
+ "viewArchived": "Ver arquivados",
+ "customizeStatuses": "Personalize os estados para este quadro kanban. Adicione, remova ou reordene os estados.",
+ "addStatus": "Adicionar estado",
+ "autoComplete": "Conclusão automática"
+ },
+ "kanban": {
+ "status": "Estado",
+ "changeStatus": "Alterar estado",
+ "archiveAllItemsInColumn": "Arquivar todos os itens em {column}",
+ "archiveAllConfirmTitle": "Arquivar todos os itens?",
+ "archiveAllConfirmMessage": "Arquivar todos os {count} itens em {column}? Pode restaurá-los a partir do arquivo.",
+ "archiveAllSuccess": "{count} itens arquivados",
+ "archiveAllFailed": "Não foi possível arquivar todos os itens",
+ "archiveAllPartial": "{archived} de {total} itens arquivados. Não foi possível arquivar alguns itens.",
+ "moveFailed": "Não foi possível mover o cartão",
+ "priority": "Prioridade",
+ "score": "Pontuação",
+ "assignee": "Responsável",
+ "reminder": "Lembrete",
+ "usernamePlaceholder": "nome de utilizador",
+ "scoreLabel": "Pontuação: {score}",
+ "today": "Hoje",
+ "exportIcs": "Exportar .ics",
+ "unscheduled": "Não agendado ({count})",
+ "moreEvents": "+{count} mais",
+ "critical": "Crítico",
+ "high": "Alto",
+ "medium": "Médio",
+ "low": "Baixo",
+ "none": "Nenhum",
+ "weekdaysSun": "Dom",
+ "weekdaysMon": "Seg",
+ "weekdaysTue": "Ter",
+ "weekdaysWed": "Qua",
+ "weekdaysThu": "Qui",
+ "weekdaysFri": "Sex",
+ "weekdaysSat": "Sáb",
+ "title": "Kanban",
+ "calendar": "Calendário",
+ "boards": "Quadros",
+ "addItem": "Adicionar item",
+ "itemTitle": "Título do item",
+ "manageStatuses": "Gerir estados",
+ "viewArchived": "Ver arquivados",
+ "noBoards": "Nenhum quadro encontrado",
+ "noBoardsYet": "Ainda sem quadros kanban",
+ "createFirstBoard": "Crie o seu primeiro quadro kanban para começar a gerir os seus projetos.",
+ "newBoard": "Novo quadro",
+ "tryAdjustingFilters": "Tente ajustar os filtros ou crie um novo quadro.",
+ "completed": "Concluído",
+ "todo": "A fazer",
+ "inProgress": "Em progresso",
+ "recentBoards": "Quadros recentes",
+ "showAllBoards": "Mostrar todos os quadros",
+ "unassigned": "Não atribuído",
+ "targetDate": "Data alvo",
+ "statusUpdated": "Estado atualizado",
+ "itemArchived": "Item arquivado",
+ "itemDeleted": "Item eliminado",
+ "estimatedTime": "Tempo estimado (horas)",
+ "actualVsEstimated": "{actual} / {estimated}",
+ "tempo": "Ritmo",
+ "noItemsFound": "Nenhum item corresponde aos filtros atuais",
+ "searchPlaceholder": "Pesquisar itens...",
+ "allPriorities": "Todas as prioridades",
+ "allAssignees": "Todos os responsáveis",
+ "timeEntries": "Registos de tempo",
+ "noTimeEntries": "Ainda sem registos de tempo",
+ "editSessions": "Editar sessões",
+ "session": "sessão",
+ "sessions": "sessões",
+ "startTime": "Início",
+ "endTime": "Fim",
+ "addItemToColumn": "Adicionar item a {column}",
+ "addItemPlaceholder": "Novo item..."
+ },
+ "profile": {
+ "userProfile": "Utilizador",
+ "manageAccount": "Gerir as definições e preferências da sua conta",
+ "profileTab": "Perfil",
+ "sessionsTab": "Sessões",
+ "archiveTab": "Arquivo",
+ "connectionsTab": "Ligações",
+ "encryptionTab": "Encriptação (beta)",
+ "userPreferencesTab": "Preferências do utilizador",
+ "userLogsTab": "Registos do utilizador",
+ "userSettings": "Definições do utilizador",
+ "deviceSettings": "Definições rápidas",
+ "quickSettingsHeader": "Definições rápidas (apenas dispositivo)",
+ "adminSettings": "Painel de administração",
+ "apiKey": "Chave API",
+ "apiKeyDescription": "Gerar uma chave API para acesso programático às suas listas de verificação e notas",
+ "hideApiKey": "Ocultar chave API",
+ "showApiKey": "Mostrar chave API",
+ "copyApiKey": "Copiar chave API",
+ "regenerateApiKey": "Regenerar chave API",
+ "generateApiKey": "Gerar chave API",
+ "generating": "A gerar...",
+ "generate": "Gerar",
+ "memberSince": "Membro desde",
+ "userType": "Tipo de utilizador",
+ "usernameUpdateWarning": "Atualizar o seu nome de utilizador irá terminar a sessão e será necessário iniciar sessão novamente.",
+ "enterCurrentPassword": "Introduzir palavra-passe atual",
+ "enterNewPassword": "Introduzir nova palavra-passe",
+ "confirmNewPassword": "Confirmar nova palavra-passe",
+ "saveProfile": "Guardar perfil",
+ "removeAvatar": "Remover avatar",
+ "usernameRequired": "O nome de utilizador é obrigatório",
+ "passwordsDoNotMatch": "As novas palavras-passe não coincidem",
+ "profileUpdated": "Perfil atualizado com sucesso!",
+ "failedToUpdateProfile": "Falha ao atualizar o perfil",
+ "failedToUpdateProfileRetry": "Falha ao atualizar o perfil. Por favor, tente novamente.",
+ "avatarUpdated": "Avatar atualizado com sucesso!",
+ "failedToUpdateAvatar": "Falha ao atualizar o avatar",
+ "failedToUpdateAvatarRetry": "Falha ao atualizar o avatar. Por favor, tente novamente.",
+ "avatarRemoved": "Avatar removido com sucesso!",
+ "failedToRemoveAvatar": "Falha ao remover o avatar",
+ "failedToRemoveAvatarRetry": "Falha ao remover o avatar. Por favor, tente novamente.",
+ "activeSessions": "Sessões ativas",
+ "activeSession": "{count} sessão ativa",
+ "activeSession_plural": "{count} sessões ativas",
+ "terminateAllOthers": "Terminar todas as outras",
+ "avatar": "Avatar",
+ "uploadProfilePicture": "Carregar uma fotografia de perfil (PNG, JPG, WebP até 5MB)",
+ "unknown": "Desconhecido",
+ "yourUsername": "O seu nome de utilizador",
+ "archivedContent": "Conteúdo arquivado",
+ "archivedItemsCount": "{archivedItemsCount} arquivados",
+ "noArchivedContent": "Nenhum conteúdo arquivado encontrado",
+ "noArchivedItemsFound": "Nenhum item arquivado encontrado correspondente a "{searchQuery}"",
+ "linkNetwork": "Rede de ligações",
+ "settingsDashboard": "Painel de definições",
+ "auditLogsTab": "Registos do utilizador",
+ "rebuildLinkIndexes": "Reconstruir índices de ligações para atualizar dados de ligação",
+ "rebuildingLinkIndex": "A reconstruir índice de ligações...",
+ "successfullyRebuiltIndex": "Índice de ligações reconstruído com sucesso",
+ "successfullyRebuiltIndexReload": "Índice de ligações reconstruído com sucesso! A página será recarregada para mostrar as ligações atualizadas.",
+ "failedToRebuildIndex": "Falha ao reconstruir o índice de ligações. Por favor, tente novamente.",
+ "contentLinks": "Ligações de conteúdo",
+ "visualizeRelationships": "Visualizar relações entre as suas notas e listas de verificação",
+ "connectedItems": "Itens ligados",
+ "noLinksFound": "Nenhuma ligação encontrada",
+ "startCreatingInternalLinks": "Comece a criar ligações internas digitando `@` nas suas notas para ver aqui a rede de relações.",
+ "orFormat": "ou",
+ "inYourContent": "no seu conteúdo.",
+ "highlyConnected": "muito ligado",
+ "moderatelyConnected": "moderadamente ligado",
+ "isolated": "isolado",
+ "connection": "ligação",
+ "connection_plural": "ligações",
+ "terminateSessionConfirm": "Tem a certeza de que pretende terminar esta sessão?",
+ "terminateAllSessionsConfirm": "Tem a certeza de que pretende terminar todas as outras sessões?",
+ "validationErrorTitle": "Erro de validação",
+ "visibleGraphSummary": "{items} itens visíveis, {links} ligações visíveis",
+ "graphTruncated": "A mostrar os {visible} itens com mais ligações. {omitted} itens com menos ligações foram omitidos.",
+ "totalConnections": "Total",
+ "inboundConnections": "Entrada",
+ "outboundConnections": "Saída",
+ "updated": "Atualizado",
+ "openItem": "Abrir item",
+ "selectItem": "Selecionar um item",
+ "inspectNodeHint": "Toque ou clique num nó para ver o título, a categoria, o número de ligações e a atividade recente.",
+ "graphSearchPlaceholder": "Título, caminho, categoria ou etiqueta",
+ "orphans": "Órfãos",
+ "labels": "Rótulos",
+ "arrows": "Setas",
+ "nodeSize": "Tamanho do nó",
+ "linkWidth": "Largura das ligações",
+ "linkDistance": "Distância das ligações",
+ "repelForce": "Repulsão",
+ "tagLinks": "Ligações de etiquetas",
+ "linkedItems": "Itens ligados",
+ "uuid": "UUID"
+ },
+ "settings": {
+ "hideMobileStatusDropdownLabel": "Menu de estado móvel nos cartões",
+ "showMobileStatusDropdown": "Mostrar menu móvel",
+ "hideMobileStatusDropdown": "Ocultar menu móvel",
+ "selectMobileStatusDropdown": "Selecionar comportamento do menu de estado móvel",
+ "hideMobileStatusDropdownDescription": "Controla o menu de estado sempre visível nos cartões Kanban em dispositivos móveis. A definição da etiqueta de estado é separada.",
+ "title": "Definições",
+ "general": "Geral",
+ "saveGeneral": "Guardar geral",
+ "appearance": "Aparência",
+ "account": "Conta",
+ "security": "Segurança",
+ "notifications": "Notificações",
+ "privacy": "Privacidade",
+ "advanced": "Avançado",
+ "preferredLanguage": "Idioma preferido",
+ "selectLanguage": "Selecionar idioma",
+ "choosePreferredLanguage": "Escolha o seu idioma preferido",
+ "customTranslationsInfo": "Quer adicionar o seu próprio idioma?",
+ "preferredTheme": "Tema preferido",
+ "loadingThemes": "A carregar temas...",
+ "selectTheme": "Selecionar um tema",
+ "choosePreferredTheme": "Escolha o seu tema preferido em todos os dispositivos.",
+ "initialLandingPage": "Página inicial",
+ "selectLandingPage": "Selecionar página inicial",
+ "selectDefaultPageAfterLogin": "Selecione a página predefinida a carregar após iniciar sessão.",
+ "fileRenameMode": "Modo de renomear ficheiros",
+ "selectFileRenameMode": "Selecionar modo de renomear ficheiros",
+ "chooseFileRenameMode": "Escolha como os ficheiros são renomeados ao guardar notas e listas de verificação.",
+ "preferredDateFormat": "Formato de data preferido",
+ "selectDateFormat": "Selecionar formato de data",
+ "choosePreferredDateFormat": "Escolha o seu formato de data preferido.",
+ "preferredTimeFormat": "Formato de hora preferido",
+ "selectTimeFormat": "Selecionar formato de hora",
+ "choosePreferredTimeFormat": "Escolha o seu formato de hora preferido.",
+ "handedness": "Mão dominante",
+ "selectHandedness": "Selecionar mão dominante",
+ "chooseHandedness": "Escolha se é destro ou esquerdino.",
+ "rightHanded": "Destro",
+ "leftHanded": "Esquerdino",
+ "notesPreferences": "Preferências de notas",
+ "saveEditor": "Guardar editor",
+ "autoSaveInterval": "Intervalo de guardação automática",
+ "selectAutoSaveInterval": "Selecionar intervalo de guardação automática",
+ "chooseAutoSaveInterval": "Escolha o intervalo para a guardação automática das suas notas.",
+ "defaultMode": "Modo predefinido",
+ "selectNotesDefaultMode": "Selecionar modo predefinido das notas",
+ "chooseNotesDefaultMode": "Escolha se a nota está automaticamente em modo de edição ou não {mode} no editor de notas).",
+ "defaultEditor": "Editor predefinido",
+ "selectNotesDefaultEditor": "Selecionar editor predefinido das notas",
+ "chooseDefaultEditor": "Escolha o editor predefinido para as suas notas - (pode sempre alternar clicando no botão {editor} no editor de notas).",
+ "tableSyntaxInNotes": "Sintaxe de tabela nas notas",
+ "selectTableSyntax": "Selecionar sintaxe de tabela",
+ "chooseTableSyntax": "Escolha como as tabelas são apresentadas nas suas notas.",
+ "markdownSyntaxTheme": "Tema de sintaxe Markdown",
+ "selectSyntaxTheme": "Selecionar tema de sintaxe",
+ "chooseSyntaxTheme": "Escolha o tema de realce de sintaxe para o editor markdown.",
+ "minimalMode": "Modo mínimo (desativar editor de texto enriquecido)",
+ "selectMinimalMode": "Selecionar modo mínimo",
+ "minimalModeDescription": "Quando ativado, utiliza um processador markdown simples em vez do editor de texto enriquecido. Funcionalidades avançadas como ligações bilaterais não funcionarão neste modo.",
+ "defaultNoteFilter": "Filtro de notas predefinido",
+ "selectDefaultNoteFilter": "Selecionar filtro de notas predefinido",
+ "chooseDefaultNoteFilter": "Escolha o filtro predefinido a aplicar ao abrir a página de Notas.",
+ "checklistsPreferences": "Preferências de listas de verificação",
+ "saveChecklists": "Guardar listas de verificação",
+ "recurringChecklists": "Listas de verificação recorrentes",
+ "beta": "(Beta)",
+ "selectEnableRecurring": "Selecionar ativar para adicionar listas de verificação recorrentes",
+ "showCompletedSuggestions": "Mostrar tarefas concluídas como sugestões",
+ "selectShowCompletedSuggestions": "Selecionar se deve mostrar tarefas concluídas como sugestões",
+ "completedSuggestionsDescription": "Ao adicionar novas tarefas, mostrar tarefas concluídas como sugestões que podem ser reativadas.",
+ "defaultChecklistFilter": "Filtro de lista de verificação predefinido",
+ "selectDefaultChecklistFilter": "Selecionar filtro de lista de verificação predefinido",
+ "chooseDefaultChecklistFilter": "Escolha o filtro predefinido a aplicar ao abrir a página de Listas de verificação.",
+ "accountManagement": "Gestão de conta",
+ "deleteAccount": "Eliminar conta",
+ "deleteAccountDescription": "Eliminar permanentemente a sua conta e todos os dados associados",
+ "disabledInDemoMode": "desativado no modo de demonstração",
+ "saveSettings": "Guardar definições",
+ "resetSettings": "Repor definições",
+ "exportData": "Exportar dados",
+ "importData": "Importar dados",
+ "changePassword": "Alterar palavra-passe",
+ "currentPassword": "Palavra-passe atual",
+ "newPassword": "Nova palavra-passe",
+ "confirmPassword": "Confirmar palavra-passe",
+ "passwordChanged": "Palavra-passe alterada com sucesso",
+ "settingsSaved": "Definições guardadas com sucesso",
+ "dashCase": "Com hífens (ex.: meu-ficheiro.md)",
+ "minimal": "Mínimo (remover apenas caracteres inválidos)",
+ "noRename": "Sem renomear (manter nome de ficheiro original)",
+ "lastVisitedPage": "Última página visitada",
+ "disabled": "Desativado",
+ "seconds": "{count} segundo",
+ "seconds_plural": "{count} segundos",
+ "hours12": "12 horas",
+ "hours24": "24 horas",
+ "useSystemDefault": "Usar predefinição do sistema",
+ "markdownTableSyntax": "Markdown",
+ "htmlTableSyntax": "HTML",
+ "default": "Predefinido",
+ "dark": "Escuro",
+ "funky": "Funky",
+ "okaidia": "Okaidia",
+ "tomorrow": "Tomorrow",
+ "twilight": "Twilight",
+ "coy": "Coy",
+ "solarizedLight": "Solarized Light",
+ "richTextEditor": "Editor de texto enriquecido",
+ "markdown": "Markdown",
+ "edit": "Editar",
+ "view": "Ver",
+ "enable": "Ativar",
+ "disable": "Desativar",
+ "useRichTextEditor": "Usar editor de texto enriquecido",
+ "markdownOnly": "Apenas Markdown",
+ "quickCreateNotes": "Criação rápida de notas",
+ "selectQuickCreateNotes": "Selecionar criação rápida de notas",
+ "chooseQuickCreateNotes": "Escolha o modo de criação rápida de notas.",
+ "quickCreateNotesShowModal": "Mostrar modal ao criar notas",
+ "quickCreateNotesSkipModal": "Ignorar modal e criar instantaneamente",
+ "quickCreateNotesDescription": "Quando ativado, criar uma nova nota ignora o modal de criação e leva-o diretamente ao editor de notas.",
+ "defaultCategory": "Categoria predefinida para criação rápida de notas",
+ "selectDefaultCategory": "Selecionar categoria predefinida...",
+ "defaultCategoryDescription": "As novas notas serão criadas nesta categoria ao usar a criação rápida.",
+ "customTheme": {
+ "cardForegroundColor": "Este texto utiliza a cor de primeiro plano do cartão",
+ "cardTitle": "Título do cartão",
+ "focusToSeeRingColor": "Focar para ver a cor do anel",
+ "sampleNoteTitle": "Ideias de projeto",
+ "sampleNoteCategory": "trabalho/projetos",
+ "sampleNoteContent": "# Brainstorm de projeto\n\n## Funcionalidades da aplicação móvel\n- **Alternar modo escuro** com transições suaves\n- **Suporte offline** para funcionalidades essenciais\n- **Notificações push** para lembretes\n\n## Melhorias no backend\n- **Limitação de taxa da API** para evitar abuso\n- **Otimização de base de dados** para consultas mais rápidas\n- **Camada de cache** para melhor desempenho\n\n*Esta é apenas uma pré-visualização do conteúdo da nota...*",
+ "searchPlaceholder": "Pesquisar notas e listas de verificação...",
+ "weeklyProgress": "Progresso semanal",
+ "tasksProgress": "7/10 tarefas",
+ "tasksCompleted": "7 concluídas",
+ "tasksRemaining": "3 restantes",
+ "primaryAction": "Ação principal",
+ "destructiveAction": "Destrutivo",
+ "secondaryAction": "Secundário",
+ "ghostAction": "Fantasma",
+ "successMessage": "Mensagem de sucesso",
+ "errorMessage": "Mensagem de erro",
+ "activeTab": "Separador ativo",
+ "inactiveTab": "Separador inativo",
+ "calendarDay": "Dia do calendário",
+ "sampleInputLabel": "Exemplo de entrada",
+ "sampleInputPlaceholder": "Introduzir algum texto..."
+ },
+ "hideConnectionIndicatorDescription": "Ocultar o indicador de ligação websocket do logótipo. Continuará a ver um ponto vermelho se estiver sem ligação, independentemente desta definição.",
+ "selectConnectionIndicator": "Selecionar estado do indicador de ligação",
+ "showIndicator": "Mostrar indicador",
+ "hideIndicator": "Ocultar indicador",
+ "hideConnectionIndicator": "Ocultar indicador de ligação do logótipo",
+ "kanbanSection": "Preferências Kanban",
+ "saveKanban": "Guardar Kanban",
+ "hideStatusOnCardsLabel": "Estado nos cartões Kanban",
+ "showStatusOnCards": "Mostrar estado",
+ "hideStatusOnCards": "Ocultar estado",
+ "hideStatusOnCardsDescription": "Oculta a etiqueta de estado dos itens dos cartões Kanban. O estado já é indicado pela coluna onde o cartão está colocado.",
+ "selectStatusOnCards": "Selecionar visibilidade do estado",
+ "codeBlockChoice": "Escolher o estilo do bloco de código",
+ "defaultCodeBlock": "Codepen (predefinido)",
+ "themedCodeBlock": "Bloco de código temático",
+ "codeBlockChoiceDescription": "Escolha entre o estilo de bloco de código predefinido semelhante ao codepen ou um estilo de bloco de código temático.",
+ "checklistItemClickAction": "Ação ao clicar em item de lista de verificação",
+ "selectChecklistItemClickAction": "Selecionar ação ao clicar em item de lista de verificação",
+ "checklistItemClickActionDescription": "Escolha o que acontece quando clica no texto de uma tarefa. \"Marcar como concluída\" alterna a caixa de verificação (predefinido); \"Editar nome da tarefa\" abre a tarefa para edição inline — ainda precisará de clicar na própria caixa de verificação para a concluir.",
+ "checklistItemClickActionToggle": "Marcar como concluída/incompleta",
+ "checklistItemClickActionEdit": "Editar item da lista"
+ },
+ "admin": {
+ "title": "Administrador",
+ "adminDashboard": "Painel de administração",
+ "deleteUserConfirmation": "Tem a certeza de que pretende eliminar o utilizador \"{username}\"? Esta ação não pode ser desfeita.",
+ "failedToDeleteUser": "Falha ao eliminar utilizador",
+ "manageUsers": "Gerir utilizadores",
+ "createUser": "Criar utilizador",
+ "editUser": "Editar utilizador",
+ "deleteUser": "Eliminar utilizador",
+ "userPermissions": "Permissões do utilizador",
+ "systemSettings": "Definições do sistema",
+ "logs": "Registos",
+ "backups": "Cópias de segurança",
+ "maintenance": "Manutenção",
+ "regularUsers": "Utilizadores regulares",
+ "adminUsers": "Utilizadores administradores",
+ "totalContent": "Conteúdo total",
+ "noSharedItems": "Sem itens partilhados",
+ "saveCSS": "Guardar CSS",
+ "themeName": "Nome do tema",
+ "customColor": "Cor personalizada",
+ "maximumFileUploadSize": "Tamanho máximo de carregamento de ficheiro",
+ "maxFileSizeDescription": "O tamanho máximo de ficheiro permitido para carregamentos em MB (aplica-se a imagens, vídeos e ficheiros)",
+ "applicationName": "Nome da aplicação",
+ "applicationNameDescription": "Aparece no separador do navegador e no nome da PWA.",
+ "applicationDescription": "Descrição da aplicação",
+ "applicationDescriptionText": "Utilizado para motores de busca e descrição da PWA.",
+ "applicationDescriptionPlaceholder": "Uma lista de verificação simples, rápida e leve...",
+ "favicon16": "Favicon 16x16",
+ "favicon16Description": "Favicon pequeno para separadores do navegador.",
+ "favicon32": "Favicon 32x32",
+ "favicon32Description": "Favicon padrão para a maioria dos navegadores.",
+ "appleTouchIcon": "Ícone Apple Touch 180x180",
+ "appleTouchIconDescription": "Ícone para o ecrã inicial do iOS.",
+ "icon192": "Ícone 192x192",
+ "icon192Description": "Ícone para o ecrã inicial do Android.",
+ "icon512": "Ícone 512x512",
+ "icon512Description": "Ícone de alta resolução para ecrãs de carregamento da PWA.",
+ "notifyNewUpdates": "Notificar-me sobre novas atualizações",
+ "thisUsesGithubAPI": "Isto utiliza a API do GitHub para obter atualizações; desativar totalmente desativa a API e não permite qualquer tráfego de/para o github.",
+ "parseContent": "Mostrar sempre conteúdo processado",
+ "parseContentEnabledDescription": "Quando ativada, esta definição mostrará os títulos processados na barra lateral, resultados de pesquisa e em toda a aplicação.",
+ "parseContentDisabledDescription": "Quando desativada, os nomes de ficheiros originais serão higienizados, tornados legíveis e mostrados em alternativa.",
+ "parseContentPerformanceWarning": "Definir isto como \"não\" melhorará o desempenho em grandes conjuntos de dados, mas pode afetar a legibilidade - especialmente em nomes de ficheiros com caracteres não latinos.",
+ "hideLanguageSelector": "Ocultar seletor de idioma no cabeçalho",
+ "hideLanguageSelectorDescription": "Mostrar/ocultar menu suspenso de idioma no cabeçalho",
+ "defaultDateFormat": "Formato de data predefinido",
+ "defaultDateFormatDescription": "Utilizado quando a preferência de formato de data do utilizador está definida como \"Usar predefinição do sistema\".",
+ "defaultTimeFormat": "Formato de hora predefinido",
+ "defaultTimeFormatDescription": "Utilizado quando a preferência de formato de hora do utilizador está definida como \"Usar predefinição do sistema\".",
+ "applicationIcons": "Ícones da aplicação",
+ "saveChanges": "Guardar alterações",
+ "unsavedChanges": "Tem alterações não guardadas.",
+ "loadError": "Erro de carregamento",
+ "couldNotFetchSettings": "Não foi possível obter as definições.",
+ "failedToLoadSettings": "Falha ao carregar as definições",
+ "settingsSavedSuccessfully": "Definições guardadas com sucesso.",
+ "failedToSaveSettings": "Falha ao guardar as definições",
+ "saveError": "Erro ao guardar",
+ "unknownErrorOccurred": "Ocorreu um erro desconhecido.",
+ "customCSS": "CSS personalizado",
+ "customThemes": "Temas personalizados",
+ "createTheme": "Criar tema",
+ "editTheme": "Editar tema",
+ "updateTheme": "Atualizar tema",
+ "iconName": "Nome do ícone",
+ "colorVariables": "Variáveis de cor",
+ "livePreview": "Pré-visualização em direto",
+ "unsavedCssChanges": "Tem alterações de CSS não guardadas.",
+ "noCustomThemesYet": "Ainda não foram criados temas personalizados. Clique em \"Criar tema\" para começar.",
+ "myCustomTheme": "O meu tema personalizado",
+ "iconNameDescription": "nome do ícone (ex.: PaintBrush04Icon, Sun03Icon, GibbousMoonIcon)",
+ "appVersion": "Versão da aplicação",
+ "totalUsers": "Total de utilizadores",
+ "totalChecklists": "Total de listas de verificação",
+ "totalNotes": "Total de notas",
+ "userDistribution": "Distribuição de utilizadores",
+ "contentOverview": "Visão geral do conteúdo",
+ "overview": "Visão geral",
+ "users": "Utilizadores",
+ "content": "Conteúdo",
+ "sharing": "Partilha",
+ "adminLogs": "Registos de administrador",
+ "editor": "Editor",
+ "styling": "Estilo",
+ "addUser": "Adicionar utilizador",
+ "searchUsers": "Pesquisar utilizadores...",
+ "you": "Você",
+ "userRole": "{role} • {checklists} listas de verificação, {notes} notas",
+ "noUsersFound": "Nenhum utilizador encontrado",
+ "noUsersYet": "Ainda sem utilizadores",
+ "noUsersMatchSearch": "Nenhum utilizador corresponde aos seus critérios de pesquisa.",
+ "usersWillAppear": "Os utilizadores aparecerão aqui quando se registarem.",
+ "configureEditorFeatures": "Configure quais as funcionalidades do editor que estão ativadas para todos os utilizadores.",
+ "enableSlashCommandsDescription": "Ativar o menu de comandos de barra (escreva \"/\" para inserir elementos)",
+ "enableBubbleMenuDescription": "Ativar a barra de ferramentas flutuante que aparece quando o texto está selecionado",
+ "enableTableToolbarDescription": "Ativar a barra de ferramentas que aparece ao editar tabelas",
+ "bilateralLinks": "Ligações bilaterais",
+ "experimental": "(Experimental)",
+ "bilateralLinksDescription": "Ativar a capacidade de criar ligações para notas e listas de verificação no mesmo documento.",
+ "bilateralLinksWarning": "Esta funcionalidade é experimental e pode não funcionar como esperado. Também pode acabar por ser removida/descontinuada no futuro.",
+ "historyEnabled": "Histórico de notas",
+ "historyDescription": "Acompanhar o histórico de versões das notas. Permite restaurar versões anteriores. Apenas as guardações manuais criam pontos no histórico (não a guardação automática).",
+ "disableHistory": "Desativar histórico",
+ "disableHistoryWarning": "Desativar o histórico eliminará permanentemente todas as versões guardadas de todos os utilizadores. Isto não pode ser desfeito.",
+ "historyDisabled": "O histórico de notas foi desativado e todos os dados do histórico foram eliminados.",
+ "failedToDisableHistory": "Falha ao desativar o histórico",
+ "configureExternalServices": "Configurar URLs de serviços externos para integrações do editor.",
+ "drawioUrl": "URL do Draw.io",
+ "optional": "(Opcional)",
+ "drawioUrlDescription": "Especifique um URL personalizado de instância Draw.io. Deixe vazio para usar a instância pública predefinida (https://embed.diagrams.net).",
+ "drawioUrlExample": "Exemplo para alojamento próprio: https://seu-dominio.com/drawio",
+ "drawioProxyEnabled": "Proxy Draw.io via Jotty",
+ "drawioProxyEnabledDescription": "Ativar isto para encaminhar o tráfego do Draw.io através do servidor do Jotty. Necessário para URLs de rede Docker interna (ex.: http://drawio:8080) que não são acessíveis a partir dos navegadores dos utilizadores.",
+ "excalidrawUrl": "URL do Excalidraw",
+ "excalidrawUrlDescription": "Especifique um URL personalizado de instância Excalidraw. Deixe vazio para usar a instância pública predefinida (https://excalidraw.com).",
+ "excalidrawUrlExample": "Exemplo para alojamento próprio: https://seu-dominio.com/excalidraw",
+ "excalidrawProxyEnabled": "Proxy Excalidraw via Jotty",
+ "excalidrawProxyEnabledDescription": "Ativar isto para encaminhar o tráfego do Excalidraw através do servidor do Jotty. Necessário para URLs de rede Docker interna (ex.: http://excalidraw:3000) que não são acessíveis a partir dos navegadores dos utilizadores.",
+ "saving": "A guardar...",
+ "editorSettingsSaved": "Definições do editor guardadas com sucesso.",
+ "customEmojis": "Emojis personalizados",
+ "noEmojisYet": "Ainda não foram criados emojis personalizados. Clique em "Adicionar emoji" para começar.",
+ "enterAnyEmoji": "Introduzir qualquer emoji ou usar o seletor de emoji (Windows: Win+.; Mac: Ctrl+Cmd+Space",
+ "sharingOverview": "Visão geral da atividade de partilha",
+ "detailedSharingBreakdown": "Análise detalhada dos padrões de partilha e envolvimento dos utilizadores",
+ "checkForUpdates": "Verificar atualizações",
+ "checkingForUpdates": "A verificar atualizações...",
+ "updateAvailable": "Atualização disponível!",
+ "upToDate": "Está atualizado!",
+ "issuesAndBugs": "Problemas e relatórios de erros",
+ "activeChecklistSharers": "Partilhadores ativos de listas de verificação",
+ "activeNotesSharers": "Partilhadores ativos de notas",
+ "totalSharing": "Total de ações de partilha",
+ "topContributors": "Principais contribuidores",
+ "itemsShared": "Itens partilhados",
+ "totalPublicItems": "{count} itens públicos",
+ "userContent": "{checklistsLength} listas de verificação • {notesLength} notas",
+ "rebuildIndexes": "Reconstruir índices",
+ "rebuilding": "A reconstruir...",
+ "successfullyRebuiltIndex": "Índice de ligações reconstruído com sucesso para {username}",
+ "failedToRebuildIndex": "Falha ao reconstruir o índice de ligações para {username}",
+ "totalItems": "{items} itens totais em {userCount}",
+ "privileges": "Privilégios de administrador",
+ "adminContentAccess": "Acesso ao conteúdo do administrador",
+ "adminContentAccessDescription": "Controla se os administradores podem aceder às notas e listas de verificação de todos os utilizadores. Quando desativado, os administradores só podem gerir utilizadores e definições, mas não podem ver o conteúdo de outros utilizadores.",
+ "adminContentAccessYes": "Os administradores podem aceder a todo o conteúdo",
+ "adminContentAccessNo": "Os administradores só podem aceder ao seu próprio conteúdo",
+ "superAdminOnly": "Apenas editável pelo proprietário do sistema",
+ "systemOwner": "Proprietário do sistema",
+ "cannotDeleteSuperAdmin": "Não é possível eliminar o super administrador (proprietário do sistema)",
+ "enterUsername": "Introduzir nome de utilizador",
+ "confirmNewPassword": "Confirmar nova palavra-passe",
+ "dataExport": "Exportação de dados",
+ "dataExportOptions": "Opções de exportação de dados",
+ "selectExportType": "Selecione um tipo de exportação e transfira dados da sua aplicação.",
+ "allChecklistsAndNotes": "Todas as listas de verificação e notas",
+ "allChecklistsAndNotesDescription": "Exporta todas as listas de verificação e notas criadas por todos os utilizadores. Isto gerará um ficheiro zip com dados JSON organizados por utilizador e categoria.",
+ "userChecklistsNotes": "Listas de verificação e notas de um utilizador específico",
+ "userChecklistsNotesDescription": "Selecione um utilizador no menu suspenso para exportar todas as listas de verificação e notas pertencentes a esse utilizador específico.",
+ "allUsersData": "Dados de todas as contas de utilizador",
+ "allUsersDataDescription": "Exporta um ficheiro JSON com todas as informações de conta de utilizador (nomes de utilizador, estado de administrador, etc.). Isto NÃO inclui o conteúdo das suas listas de verificação ou notas.",
+ "wholeDataFolder": "Pasta de dados completa (cópia de segurança)",
+ "wholeDataFolderDescription": "Exporta toda a pasta 'data', incluindo todo o conteúdo de utilizador, metadados e definições. Use com cuidado, pois pode ser um ficheiro grande.",
+ "noSharingPermissionsLabel": "Itens partilhados específicos estão ocultos com base nas suas permissões de acesso ao conteúdo.",
+ "contentHidden": "Detalhes do conteúdo ocultos",
+ "viewOnlySettingsNotice": "Apenas o proprietário do sistema pode modificar estas definições.",
+ "auditLogContentRedacted": "[Conteúdo oculto]",
+ "maxLogAgeDays": "Idade máxima dos registos (dias)",
+ "maxLogAgeDaysDescription": "Defina quanto tempo os registos de auditoria são retidos. Ao visualizar a página de registos de auditoria, será solicitado a limpar os registos mais antigos do que este limiar. Defina como 0 para retenção ilimitada (sem avisos de limpeza automática).",
+ "appPreferences": "Preferências da aplicação",
+ "editEmoji": "Editar emoji",
+ "addEmoji": "Adicionar emoji",
+ "rebuildLinkIndexesTitle": "Reconstruir índices de ligações",
+ "noContentYet": "Ainda sem conteúdo",
+ "userHasNoContent": "Este utilizador ainda não criou nenhuma lista de verificação ou nota.",
+ "selectUserToExport": "Selecionar um utilizador para exportar",
+ "deleteUserConfirmAdmin": "Tem a certeza de que pretende eliminar o utilizador \"{username}\"? Esta ação não pode ser desfeita.",
+ "failedToUnsharePublicItem": "Falha ao anular a partilha do item público",
+ "deleteAccountWarning": "Aviso: Esta ação não pode ser desfeita",
+ "enterPasswordToConfirm": "Introduza a sua palavra-passe para confirmar",
+ "tags": "Etiquetas",
+ "tagsDescription": "Ativar autocompletar hashtag no editor com o acionador #",
+ "updateTags": "Atualizar etiquetas",
+ "updatingTags": "A atualizar...",
+ "updateTagsTitle": "Analisar notas para hashtags e atualizar frontmatter",
+ "tagsUpdated": "Processados {processed} ficheiros, {updated} atualizados com novas etiquetas",
+ "failedToUpdateTags": "Falha ao atualizar etiquetas"
+ },
+ "errors": {
+ "notFound": "Não encontrado",
+ "pageNotFound": "Página não encontrada",
+ "unauthorized": "Não autorizado",
+ "forbidden": "Proibido",
+ "serverError": "Erro do servidor",
+ "somethingWentWrong": "Algo correu mal",
+ "tryAgain": "Tentar novamente",
+ "contactSupport": "Contactar suporte",
+ "invalidInput": "Entrada inválida",
+ "requiredField": "Este campo é obrigatório",
+ "emailInvalid": "Por favor, introduza um e-mail válido",
+ "passwordTooShort": "A palavra-passe deve ter pelo menos 8 caracteres",
+ "passwordMismatch": "As palavras-passe não coincidem",
+ "networkError": "Erro de rede",
+ "timeout": "Tempo limite do pedido esgotado",
+ "permissionDenied": "Permissão negada",
+ "loadError": "Erro de carregamento",
+ "failedToLoadCustomEmojis": "Falha ao carregar emojis personalizados",
+ "emojiDeletedSuccessfully": "Emoji eliminado com sucesso",
+ "deleteError": "Erro ao eliminar",
+ "failedToDeleteEmoji": "Falha ao eliminar emoji",
+ "validationError": "Erro de validação",
+ "keywordAndEmojiRequired": "A palavra-chave e o emoji são obrigatórios",
+ "duplicateKeyword": "Palavra-chave duplicada",
+ "keywordAlreadyExists": "A palavra-chave já existe",
+ "emojiUpdatedSuccessfully": "Emoji atualizado com sucesso",
+ "emojiCreatedSuccessfully": "Emoji criado com sucesso",
+ "saveError": "Erro ao guardar",
+ "failedToSaveEmoji": "Falha ao guardar emoji",
+ "failedToLoadCustomCSS": "Falha ao carregar CSS personalizado",
+ "couldNotLoadCustomCSS": "Não foi possível carregar o CSS personalizado.",
+ "couldNotLoadThemes": "Não foi possível carregar os temas.",
+ "customCSSSavedSuccessfully": "CSS personalizado guardado com sucesso.",
+ "themeNotFound": "Tema não encontrado.",
+ "themeDeletedSuccessfully": "Tema eliminado com sucesso.",
+ "themeNameRequired": "O nome do tema é obrigatório.",
+ "themeSavedSuccessfully": "Tema {action} com sucesso.",
+ "anUnknownErrorOccurred": "Ocorreu um erro desconhecido.",
+ "usernameRequired": "O nome de utilizador é obrigatório",
+ "passwordRequired": "A palavra-passe é obrigatória",
+ "passwordMinLength": "A palavra-passe deve ter pelo menos 6 caracteres",
+ "passwordsDoNotMatch": "As palavras-passe não coincidem",
+ "userSavedSuccessfully": "Utilizador {mode} com sucesso!",
+ "failedToSaveUser": "Falha ao {mode} utilizador",
+ "anErrorOccurred": "Ocorreu um erro.",
+ "deleteUserConfirmation": "Eliminar utilizador \"{username}\"? Isto não pode ser desfeito.",
+ "userDeletedSuccessfully": "Utilizador eliminado com sucesso!",
+ "failedToDeleteUser": "Falha ao eliminar utilizador"
+ },
+ "pwa": {
+ "installPromptTitle": "Instalar aplicação",
+ "installPromptMessage": "Instale esta aplicação no seu dispositivo para uma melhor experiência",
+ "installButton": "Instalar",
+ "updateAvailable": "Atualização disponível",
+ "updateMessage": "Está disponível uma nova versão",
+ "updateButton": "Atualizar",
+ "refreshButton": "Atualizar página",
+ "offlineMessage": "Está atualmente sem ligação"
+ },
+ "editor": {
+ "bold": "Negrito",
+ "italic": "Itálico",
+ "underline": "Sublinhado",
+ "strikethrough": "Rasurado",
+ "code": "Código",
+ "heading": "Cabeçalho",
+ "bulletList": "Lista com marcadores",
+ "orderedList": "Lista numerada",
+ "blockquote": "Citação em bloco",
+ "codeBlock": "Bloco de código",
+ "link": "Ligação",
+ "addLink": "Adicionar ligação",
+ "enterURL": "Introduzir URL",
+ "image": "Imagem",
+ "addImage": "Adicionar imagem",
+ "enterImageURL": "Introduzir URL da imagem",
+ "table": "Tabela",
+ "horizontalRule": "Linha horizontal",
+ "undo": "Desfazer",
+ "redo": "Refazer",
+ "clearFormatting": "Limpar formatação",
+ "textAlign": "Alinhamento do texto",
+ "alignLeft": "Alinhar à esquerda",
+ "alignCenter": "Centrar",
+ "alignRight": "Alinhar à direita",
+ "alignJustify": "Justificar",
+ "color": "Cor",
+ "highlight": "Realçar",
+ "fontSize": "Tamanho da letra",
+ "fontFamily": "Família de letras",
+ "insertLink": "Inserir ligação",
+ "insertImage": "Inserir imagem",
+ "insertTable": "Inserir tabela",
+ "addRowBefore": "Adicionar linha antes",
+ "addRowAfter": "Adicionar linha depois",
+ "addColumnBefore": "Adicionar coluna antes",
+ "addColumnAfter": "Adicionar coluna depois",
+ "mergeCells": "Unir células",
+ "splitCell": "Dividir célula",
+ "placeholder": "Comece a escrever...",
+ "url": "URL",
+ "hidePreview": "Ocultar pré-visualização",
+ "edit": "Editar",
+ "preview": "Pré-visualização",
+ "toggleEditorMode": "Alternar modo de editor",
+ "richEditor": "Editor enriquecido",
+ "markdown": "Markdown",
+ "toggleRichEditorMode": "Alternar modo de editor enriquecido",
+ "toggleMarkdownMode": "Alternar modo markdown",
+ "toggleBold": "Alternar negrito",
+ "toggleItalic": "Alternar itálico",
+ "toggleUnderline": "Alternar sublinhado",
+ "toggleStrikethrough": "Alternar rasurado",
+ "toggleInlineCode": "Alternar código inline",
+ "toggleHeading2": "Alternar cabeçalho 2",
+ "listOptions": "Lista",
+ "toggleBulletList": "Alternar lista com marcadores",
+ "toggleOrderedList": "Alternar lista numerada",
+ "indentListItem": "Aumentar recuo do item de lista",
+ "outdentListItem": "Diminuir recuo do item de lista",
+ "toggleBlockquote": "Alternar citação em bloco",
+ "toggleLink": "Alternar ligação",
+ "editImageSize": "Editar tamanho da imagem",
+ "editorFeatures": "Funcionalidades do editor",
+ "slashCommands": "Comandos de barra",
+ "bubbleMenu": "Menu de balão",
+ "tableToolbar": "Barra de ferramentas de tabela",
+ "externalServices": "Serviços externos",
+ "diagrams": "Diagramas",
+ "mermaidDiagram": "Diagrama Mermaid",
+ "drawioDiagram": "Diagrama Draw.io",
+ "excalidrawDiagram": "Diagrama Excalidraw",
+ "linkType": "Tipo de ligação:",
+ "bidirectional": "Bidirecional",
+ "pathBased": "Baseado em caminho",
+ "encryptedNote": "Nota encriptada",
+ "searchFonts": "Pesquisar letras...",
+ "noFontsFound": "Nenhuma letra encontrada",
+ "syntaxTheme": "Tema de sintaxe",
+ "addRowAbove": "Adicionar linha acima",
+ "addRowBelow": "Adicionar linha abaixo",
+ "addColumnLeft": "Adicionar coluna à esquerda",
+ "addColumnRight": "Adicionar coluna à direita",
+ "deleteRow": "Eliminar linha",
+ "deleteColumn": "Eliminar coluna",
+ "deleteTable": "Eliminar tabela",
+ "rows": "Linhas",
+ "columns": "Colunas",
+ "heading1": "Cabeçalho 1",
+ "bigSectionHeading": "Cabeçalho de secção grande",
+ "heading2": "Cabeçalho 2",
+ "mediumSectionHeading": "Cabeçalho de secção médio",
+ "createBulletedList": "Criar uma lista com marcadores",
+ "createOrderedList": "Criar uma lista numerada",
+ "taskList": "Lista de tarefas",
+ "createTaskList": "Criar uma lista de tarefas",
+ "createCodeBlock": "Criar um bloco de código",
+ "quote": "Citação",
+ "createBlockquote": "Criar uma citação em bloco",
+ "insertATable": "Inserir uma tabela",
+ "insertAnImage": "Inserir uma imagem",
+ "file": "Ficheiro",
+ "attachFile": "Anexar um ficheiro",
+ "collapsible": "Recolhível",
+ "createCollapsibleSection": "Criar uma secção recolhível",
+ "createMermaidDiagram": "Criar um diagrama Mermaid",
+ "createVisualDiagram": "Criar um diagrama visual",
+ "createExcalidrawDiagram": "Criar um diagrama Excalidraw",
+ "imageUrl": "URL da imagem",
+ "fileUrl": "URL do ficheiro",
+ "fileName": "Nome do ficheiro",
+ "defaultFileName": "ficheiro",
+ "summary": "Resumo",
+ "urlPrompt": "URL",
+ "editDiagram": "Editar diagrama",
+ "deleteDiagram": "Eliminar diagrama",
+ "enterMermaidCode": "Introduzir código de diagrama Mermaid...",
+ "widthPx": "Largura (px)",
+ "heightPx": "Altura (px)",
+ "customColor": "Cor personalizada",
+ "colorPlaceholder": "#000000",
+ "searchLanguages": "Pesquisar idiomas...",
+ "imageSize": "Tamanho da imagem",
+ "leaveEmptyForAutoSize": "Deixar vazio para dimensionamento automático",
+ "minimalMode": "Modo mínimo",
+ "rawMarkdown": "Markdown bruto",
+ "hideLineNumbers": "Ocultar números de linha",
+ "showLineNumbers": "Mostrar números de linha",
+ "showEditor": "Mostrar editor",
+ "showPreview": "Mostrar pré-visualização",
+ "markdownEditor": "Editor Markdown",
+ "previewMode": "Modo de pré-visualização",
+ "themeUpdated": "Tema atualizado",
+ "updateFailed": "Falha na atualização",
+ "mermaidDescription": "Fluxogramas e diagramas baseados em texto",
+ "drawioDescription": "Editor de diagramas visual",
+ "excalidrawDescription": "Editor de diagramas estilo esboço",
+ "noLanguagesFound": "Nenhum idioma encontrado",
+ "callout": "Aviso",
+ "createCallout": "Criar um bloco de aviso",
+ "changeCalloutType": "Alterar tipo de aviso",
+ "calloutInfo": "Informação",
+ "calloutWarning": "Aviso",
+ "calloutSuccess": "Sucesso",
+ "calloutDanger": "Perigo",
+ "ruler": "Régua",
+ "lineNumbers": "Números de linha"
+ },
+ "sharing": {
+ "share": "Partilhar",
+ "shareWith": "Partilhar com",
+ "sharedWith": "Partilhado com",
+ "shareLink": "Ligação de partilha",
+ "copyLink": "Copiar ligação",
+ "linkCopied": "Ligação copiada para a área de transferência",
+ "publicLink": "Ligação pública",
+ "privateLink": "Ligação privada",
+ "sharePermissions": "Permissões de partilha",
+ "canView": "Pode ver",
+ "canEdit": "Pode editar",
+ "canDelete": "Pode eliminar",
+ "removeAccess": "Remover acesso",
+ "shareSettings": "Definições de partilha",
+ "stopSharing": "Parar partilha",
+ "publicAccess": "Acesso público",
+ "copyUrl": "Copiar URL",
+ "publiclyAccessible": "Acessível publicamente",
+ "publicShares": "Partilhas públicas",
+ "shareWithUsers": "Partilhar com utilizadores",
+ "publicLinkTab": "Ligação pública",
+ "xTwitter": "X (Twitter)",
+ "checkOutThisItem": "Veja este {itemType}: {itemTitle}",
+ "reddit": "Reddit",
+ "facebook": "Facebook",
+ "email": "E-mail",
+ "emailSubject": "Veja este {itemType}: {itemTitle}",
+ "emailBody": "Queria partilhar este {itemType} consigo:\n\n{itemTitle}\n{publicUrl}",
+ "makeItemAccessible": "Tornar este {itemType} acessível a qualquer pessoa com a ligação.",
+ "makePrivate": "Tornar privado",
+ "makePublic": "Tornar público",
+ "failedToCopyUrl": "Falha ao copiar URL para a área de transferência",
+ "searchUsers": "Pesquisar utilizadores...",
+ "toggleAllPermissions": "Alternar todas as permissões",
+ "shareModalTitle": "Partilhar {type}"
+ },
+ "encryption": {
+ "encrypt": "Encriptar",
+ "decrypt": "Desencriptar",
+ "encrypted": "Encriptado",
+ "decrypted": "Desencriptado",
+ "encryptionKey": "Chave de encriptação",
+ "enterKey": "Introduzir chave de encriptação",
+ "confirmKey": "Confirmar chave de encriptação",
+ "keyMismatch": "As chaves não coincidem",
+ "invalidKey": "Chave de encriptação inválida",
+ "encryptNote": "Encriptar nota",
+ "decryptNote": "Desencriptar nota",
+ "viewNote": "Ver nota",
+ "editNote": "Editar nota",
+ "privateKeyWarning": "AVISO: A sua chave privada permite a desencriptação de todas as notas encriptadas. Exporte apenas se compreender as implicações de segurança. Continuar?",
+ "publicKeyExported": "Chave pública exportada com sucesso",
+ "privateKeyExported": "Chave privada exportada com sucesso",
+ "failedToExportKey": "Falha ao exportar chave",
+ "unexpectedError": "Ocorreu um erro inesperado",
+ "keysDeleted": "Chaves eliminadas com sucesso",
+ "failedToDeleteKeys": "Falha ao eliminar chaves",
+ "autoDecryptUpdated": "Definição de desencriptação automática atualizada",
+ "failedToUpdateSetting": "Falha ao atualizar a definição",
+ "enterValidPath": "Por favor, introduza um caminho válido",
+ "customPathSet": "Caminho de chave personalizado definido com sucesso",
+ "failedToSetCustomPath": "Falha ao definir caminho personalizado",
+ "thisNoteIsEncrypted": "Esta nota está encriptada",
+ "noteProtectedWith": "Esta nota está protegida com encriptação {type}.",
+ "methodUpdated": "Método de encriptação atualizado",
+ "failedToUpdateMethod": "Falha ao atualizar o método",
+ "encryptionMethod": "Método de encriptação",
+ "xchacha": "XChaCha20-Poly1305 (Recomendado)",
+ "pgp": "PGP",
+ "selectMethod": "Selecionar método de encriptação",
+ "encryptionMethodsTitle": "Métodos de encriptação",
+ "xchachaDescription": "XChaCha20-Poly1305: Encriptação moderna, rápida, baseada em frase-chave",
+ "pgpDescription": "PGP: Criptografia de chave pública tradicional com gestão de par de chaves",
+ "encryptionKeys": "Chaves de encriptação",
+ "keysConfigured": "Chaves configuradas",
+ "algorithm": "Algoritmo: ",
+ "noKeysConfigured": "Sem chaves de encriptação configuradas",
+ "generateOrImportKeys": "Gere um novo par de chaves ou importe chaves existentes para ativar a encriptação de notas.",
+ "keyManagement": "Gestão de chaves",
+ "generateNewKeyPair": "Gerar novo par de chaves",
+ "exportPublicKey": "Exportar chave pública",
+ "exportPrivateKey": "Exportar chave privada",
+ "deleteKeys": "Eliminar chaves",
+ "keysDisabledInDemo": "As chaves de encriptação armazenadas estão desativadas no modo de demonstração.",
+ "encryptionSettings": "Definições de encriptação",
+ "promptForPassphrase": "Pedir frase-chave ao abrir notas encriptadas",
+ "promptForPassphraseDescription": "Se ativado, ser-lhe-á pedida a frase-chave ao abrir uma nota encriptada. A nota permanecerá encriptada no servidor, mas poderá ver o conteúdo desencriptado na sessão do separador atual.",
+ "customKeyPath": "Caminho de chave personalizado (instalações locais)",
+ "customKeyPathDescription": "Especifique um caminho de sistema de ficheiros personalizado (absoluto) para armazenar chaves de encriptação. Isto só é necessário para instalações locais. Os utilizadores de Docker devem mapear o caminho predefinido no docker-compose.yml.",
+ "customPath": "Caminho personalizado",
+ "customPathPlaceholder": "/home/utilizador/minhas-chaves",
+ "customPathHelp": "Deve ser um caminho absoluto (ex.: /home/utilizador/minhas-chaves ou C:\\Users\\utilizador\\minhas-chaves)",
+ "setCustomPath": "Definir caminho personalizado",
+ "customPathDisabledInDemo": "O caminho de chave personalizado está desativado no modo de demonstração",
+ "deleteKeysConfirmTitle": "Eliminar chaves de encriptação?",
+ "deleteKeysConfirmMessage": "Isto eliminará permanentemente as suas chaves de encriptação. NÃO poderá desencriptar nenhuma nota encriptada sem reimportar a sua chave privada. Esta ação não pode ser desfeita.",
+ "editEncrypted": "Editar (manter encriptado)",
+ "editingEncryptedNote": "A editar nota encriptada",
+ "autoSaveDisabledForEncrypted": "A guardação automática está desativada para notas encriptadas. Lembre-se de guardar manualmente!",
+ "enterPassphraseToEdit": "Introduza a sua frase-chave para editar esta nota encriptada",
+ "noteWillStayEncrypted": "A nota permanecerá encriptada no servidor",
+ "savingEncryptedNote": "A encriptar e guardar nota...",
+ "saveEncryptedNote": "Guardar nota encriptada",
+ "enterPassphraseToSave": "Introduza a sua frase-chave para guardar a nota",
+ "noteWillBeEncryptedBeforeSaving": "O conteúdo da nota será encriptado antes de guardar",
+ "passphraseRequired": "A frase-chave é obrigatória",
+ "incorrectPassphrase": "Frase-chave incorreta",
+ "noteEncryptedSuccessfully": "Nota encriptada com sucesso",
+ "failedToEncryptNote": "Falha ao encriptar nota",
+ "noteDecryptedSuccessfully": "Nota desencriptada com sucesso",
+ "failedToDecryptNote": "Falha ao desencriptar nota",
+ "passphrase": "Frase-chave",
+ "enterYourPassphrase": "Introduza a sua frase-chave",
+ "encryptingNote": "A encriptar nota",
+ "viewDecryptedContent": "Ver conteúdo desencriptado",
+ "decryptingNote": "A desencriptar nota",
+ "noteWillBeDecryptedForViewing": "O conteúdo da nota será desencriptado apenas para visualização",
+ "cannotEditEncryptedNotes": "Não pode editar notas encriptadas",
+ "noteContentWillBeEncrypted": "O conteúdo da nota será encriptado com XChaCha20-Poly1305",
+ "onlySomeoneWithPassphraseCanDecrypt": "Apenas alguém com a frase-chave pode desencriptar",
+ "noteTitleWillRemainUnencrypted": "O título/frontmatter da nota permanecerá não encriptado",
+ "passphraseNeverStoredOnServer": "A sua frase-chave NUNCA será armazenada no servidor",
+ "thisWillDecryptNote": "Isto desencriptará o conteúdo da nota",
+ "noteWillBeRestoredAsUnencrypted": "O conteúdo da nota será restaurado como não encriptado",
+ "youWillBeAbleToEditAfterDecryption": "Poderá editar o conteúdo da nota após a desencriptação",
+ "publicKeyRequired": "A chave pública é obrigatória. Não tem nenhuma chave armazenada.",
+ "keyRequired": "{keyType} é obrigatório",
+ "privateKeyRequiredForSigning": "A chave privada é obrigatória para assinar",
+ "passphraseRequiredForSigning": "A frase-chave é obrigatória para assinar",
+ "signatureVerified": "Assinatura verificada.",
+ "signatureInvalid": "Aviso: Assinatura inválida ou não foi possível verificar.",
+ "decryptedAndVerified": "Desencriptado e verificado",
+ "decryptedWithWarning": "Desencriptado com aviso",
+ "publicKey": "Chave pública",
+ "privateKey": "Chave privada",
+ "noStoredKeysFound": "Nenhuma chave armazenada encontrada",
+ "noEncryptionKeysConfigured": "Não tem nenhuma chave de encriptação configurada",
+ "pastePublicKeyBelow": "Por favor, cole uma chave pública abaixo para encriptar o conteúdo da nota",
+ "generateOrImportKeysInProfile": "Pode gerar ou importar chaves específicas para o Jotty em Perfil → Encriptação",
+ "useCustomKey": "Usar chave {keyType} personalizada",
+ "signWithPrivateKey": "Assinar com chave privada",
+ "proveAuthenticityOfNote": "provar a autenticidade desta nota",
+ "useStoredPrivateKey": "Usar chave privada armazenada",
+ "privateKeyForSigning": "Chave privada para assinar",
+ "signingPassphrase": "Frase-chave de assinatura",
+ "enterPassphraseForSigningKey": "Introduzir frase-chave para chave de assinatura",
+ "noteContentWillBeEncryptedWithPGP": "O conteúdo da nota será encriptado com PGP",
+ "onlySomeoneWithPrivateKeyCanDecrypt": "Apenas alguém com a chave privada/frase-chave pode desencriptar",
+ "pgpSaveWithoutSigning": "Guardar PGP sem assinar",
+ "incorrectPassphraseForXChaChaSave": "Frase-chave incorreta fornecida para guardar XChaCha",
+ "generatePGPKeyPair": "Gerar par de chaves PGP",
+ "important": "Importante",
+ "nameOptional": "Nome (opcional)",
+ "yourName": "O seu nome",
+ "emailOptional": "E-mail (opcional)",
+ "yourEmail": "o-seu@email.com",
+ "generating": "A gerar...",
+ "generateKeys": "Gerar chaves",
+ "keysGeneratedSuccessfully": "Chaves geradas com sucesso",
+ "importPGPKeys": "Importar chaves PGP",
+ "publicKeyLabel": "Chave pública",
+ "privateKeyLabel": "Chave privada",
+ "importing": "A importar...",
+ "importKeys": "Importar chaves",
+ "rememberPassphraseCannotRecover": "Lembre-se da sua frase-chave! Não pode ser recuperada.",
+ "withoutPassphraseCannotDecrypt": "Sem a frase-chave, não pode desencriptar as suas notas.",
+ "backupPrivateKeySafely": "Faça uma cópia de segurança da sua chave privada em local seguro.",
+ "pgpKeyPairGeneratedSuccessfully": "Par de chaves PGP gerado com sucesso",
+ "failedToGenerateKeys": "Falha ao gerar chaves",
+ "fingerprint": "Impressão digital",
+ "keysSavedSecurely": "As suas chaves foram guardadas em segurança.",
+ "downloadKeysForBackup": "Transfira as suas chaves para cópia de segurança (opcional).",
+ "downloadPublicKey": "Transferir chave pública",
+ "downloadPrivateKey": "Transferir chave privada",
+ "importExistingKeys": "Importar chaves existentes",
+ "pasteAsciiArmoredKeys": "Cole as suas chaves PGP em formato ASCII-armored abaixo",
+ "keysMustStartWith": "As chaves devem começar com -----BEGIN PGP...",
+ "keysWillBeSecurelyStored": "As suas chaves serão armazenadas em segurança",
+ "bothKeysRequired": "São necessárias tanto a chave pública como a privada",
+ "invalidPublicKeyFormat": "Formato de chave pública inválido. Deve ser chave PGP em formato ASCII-armored.",
+ "invalidPrivateKeyFormat": "Formato de chave privada inválido. Deve ser chave PGP em formato ASCII-armored.",
+ "keysImportedSuccessfully": "Chaves importadas com sucesso",
+ "failedToImportKeys": "Falha ao importar chaves",
+ "created": "Encriptação criada",
+ "notePlaceholder": "Sou um marcador de posição porque não pode encriptar uma nota vazia."
+ },
+ "settingsModal": {
+ "sessionOnlyPrefix": "Estas opções aplicam-se apenas à sessão atual do navegador. Para definições permanentes, por favor verifique as suas",
+ "accountSettings": "preferências do utilizador",
+ "autosaveNotes": "Guardação automática de notas",
+ "showNotePreview": "Mostrar pré-visualização de notas nos cartões",
+ "notesCompactMode": "Modo compacto de notas",
+ "showEmojis": "Mostrar emojis nas listas de verificação",
+ "viewMode": "Modo de visualização",
+ "viewModeCard": "Cartões",
+ "viewModeList": "Lista",
+ "viewModeGrid": "Grelha"
+ },
+ "migration": {
+ "migrationComplete": "Migração concluída",
+ "sharingUpdateRequired": "Atualização do sistema de partilha necessária",
+ "migrationFailed": "Falha na migração",
+ "migrating": "A migrar...",
+ "startMigration": "Iniciar migração",
+ "migrationSuccessful": "Migração bem-sucedida",
+ "returnToApp": "Voltar à aplicação",
+ "adminAccessRequired": "Acesso de administrador necessário",
+ "adminAccessDescription": "Esta migração requer privilégios de administrador. Por favor, contacte um administrador para realizar a migração do sistema.",
+ "whatsHappening": "O que está a acontecer?",
+ "sharingDataMigrationDescription": "O sistema precisa de migrar os seus dados de partilha. Este é um processo único que requer acesso de administrador.",
+ "whatsChanging": "O que está a mudar?",
+ "automaticBackupIncluded": "Cópia de segurança automática incluída",
+ "yamlFrontmatterIndustryStandard": "YAML Frontmatter é o padrão da indústria",
+ "noMigrationNeeded": "Não foi necessária nenhuma migração - o seu sistema já está atualizado.",
+ "importantBackupData": "Importante: Faça uma cópia de segurança dos seus dados"
+ },
+ "toasts": {
+ "categoryRenamedSuccessfully": "Categoria renomeada com sucesso",
+ "failedToRenameCategory": "Falha ao renomear categoria",
+ "categoryRenamedSuccessfullyMessage": "Categoria {categoryName} renomeada para {newName}",
+ "failedToRenameCategoryMessage": "Ocorreu um erro ao renomear a categoria."
+ },
+ "auditLogs": {
+ "title": "Registos de auditoria",
+ "viewLogs": "Ver registos de auditoria",
+ "allLogs": "Todos os registos",
+ "myActivity": "A minha atividade",
+ "totalLogs": "Total de registos",
+ "logEntry": "Entrada de registo",
+ "logEntries": "Entradas de registo",
+ "noLogsYet": "Ainda sem registos de auditoria",
+ "noLogsFound": "Nenhum registo encontrado",
+ "noLogsDescription": "Ainda não foi registada nenhuma atividade.",
+ "exportLogs": "Exportar registos",
+ "exportJSON": "Exportar JSON",
+ "exportCSV": "Exportar CSV",
+ "cleanupOldLogs": "Limpar registos antigos",
+ "confirmCleanup": "Tem a certeza de que pretende eliminar registos com mais de {days} dias? Isto afetará TODOS os utilizadores e não pode ser desfeito.",
+ "confirmCleanupUnlimited": "Não está configurada nenhuma limpeza automática (retenção ilimitada). Ainda pode eliminar manualmente registos antigos se necessário.",
+ "deleteAllLogsModalTitle": "Eliminar todos os registos de auditoria",
+ "deleteAllLogsWarning": "Isto eliminará TODOS os registos, não apenas os antigos",
+ "deleteAllLogsWarningDescription": "Todo o histórico de registos de auditoria será apagado do sistema.",
+ "deletingAllLogs": "A eliminar todos os registos...",
+ "deleteAllLogsButton": "Eliminar todos os registos",
+ "cleanupSuccess": "{count} ficheiros de registo antigos eliminados com sucesso",
+ "cleanupError": "Falha ao limpar registos antigos",
+ "userDescription": "Ver toda a sua atividade e ações no sistema",
+ "adminDescription": "Ver e gerir toda a atividade dos utilizadores no sistema",
+ "level": "Nível",
+ "allLevels": "Todos os níveis",
+ "allCategories": "Todas as categorias",
+ "allActions": "Todas as ações",
+ "auth": "Autenticação",
+ "userManagement": "Gestão de utilizadores",
+ "checklist": "Lista de verificação",
+ "note": "Nota",
+ "sharing": "Partilha",
+ "settings": "Definições",
+ "encryption": "Encriptação",
+ "api": "API",
+ "system": "Sistema",
+ "timestamp": "Marca temporal",
+ "ipAddress": "Endereço IP",
+ "userAgent": "Agente do utilizador",
+ "resource": "Recurso",
+ "metadata": "Metadados",
+ "errorMessage": "Mensagem de erro",
+ "login": "Início de sessão",
+ "logout": "Fim de sessão",
+ "register": "Registo",
+ "sessionterminated": "Sessão terminada",
+ "usercreated": "Utilizador criado",
+ "userupdated": "Utilizador atualizado",
+ "userdeleted": "Utilizador eliminado",
+ "profileupdated": "Perfil atualizado",
+ "checklistcreated": "Lista de verificação criada",
+ "checklistupdated": "Lista de verificação atualizada",
+ "checklistdeleted": "Lista de verificação eliminada",
+ "checklistitemchecked": "Item de lista de verificação marcado",
+ "notecreated": "Nota criada",
+ "noteupdated": "Nota atualizada",
+ "notedeleted": "Nota eliminada",
+ "notearchived": "Nota arquivada",
+ "noteencrypted": "Nota encriptada",
+ "notedecrypted": "Nota desencriptada",
+ "noteeditedencrypted": "Nota editada (encriptada)",
+ "notesavedencrypted": "Nota guardada (encriptada)",
+ "itemshared": "Item partilhado",
+ "itemunshared": "Partilha de item removida",
+ "sharepermissionsupdated": "Permissões de partilha atualizadas",
+ "settingsupdated": "Definições atualizadas",
+ "appsettingsupdated": "Definições da aplicação atualizadas",
+ "usersettingsupdated": "Definições do utilizador atualizadas",
+ "themechanged": "Tema alterado",
+ "customthemesaved": "Tema personalizado guardado",
+ "customemojisaved": "Emoji personalizado guardado",
+ "customcsssaved": "CSS personalizado guardado",
+ "preferencesupdated": "Preferências atualizadas",
+ "categorycreated": "Categoria criada",
+ "categorydeleted": "Categoria eliminada",
+ "categoryrenamed": "Categoria renomeada",
+ "categorymoved": "Categoria movida",
+ "encryptionkeysgenerated": "Chaves de encriptação geradas",
+ "encryptionkeysimported": "Chaves de encriptação importadas",
+ "encryptionkeysdeleted": "Chaves de encriptação eliminadas",
+ "encryptionkeypathchanged": "Caminho da chave de encriptação alterado",
+ "logscleaned": "Registos limpos",
+ "recentActivity": "Atividade recente",
+ "topActions": "Principais ações",
+ "mostActiveUsers": "Utilizadores mais ativos",
+ "oldLogsFound": "Existem {count} ficheiros de registo com mais de {days} dias.",
+ "oldLogsFoundDescription": "Pretende limpá-los? Esta ação não pode ser desfeita.",
+ "cleanUpNow": "Limpar agora",
+ "cleanupSuccessMessage": "{count} ficheiros de registo antigos eliminados com sucesso.",
+ "security": "Segurança",
+ "mfasecretgenerated": "Segredo MFA gerado",
+ "mfaenabled": "MFA ativado",
+ "mfadisabled": "MFA desativado",
+ "mfaenablefailed": "Falha ao ativar MFA",
+ "mfaverificationsuccess": "Verificação MFA bem-sucedida",
+ "mfaverificationfailed": "Falha na verificação MFA",
+ "mfabackupcodeused": "Código de cópia de segurança MFA utilizado",
+ "mfabackupcodefailed": "Falha no código de cópia de segurança MFA",
+ "mfabackupcodesregenerated": "Códigos de cópia de segurança MFA regenerados"
+ },
+ "help": {
+ "howTo": "Como fazer",
+ "shortcuts": "Atalhos",
+ "markdownGuide": "Guia Markdown",
+ "customisations": "Personalizações",
+ "docker": "Docker",
+ "envVariables": "Variáveis de ambiente",
+ "mfa": "MFA",
+ "pwa": "PWA",
+ "encryption": "Encriptação",
+ "sso": "SSO",
+ "translations": "Traduções",
+ "unraid": "Unraid",
+ "patches": "Correções"
+ },
+ "notFound": {
+ "title": "Página não encontrada",
+ "newHaiku": "Novo Haiku",
+ "goHome": "Ir para o início",
+ "seenCount": "Viu {count} {count, plural, one {haiku} other {haikus}}",
+ "imNoPoet": "Não sou poeta, nem o Jotty. Estas são palavras aleatórias em sequência."
+ },
+ "notifications": {
+ "title": "Notificações",
+ "unread": "não lidas",
+ "markAllRead": "Marcar tudo como lido",
+ "clearAll": "Limpar tudo",
+ "empty": "Sem notificações",
+ "assignmentTitle": "Tarefa atribuída a si",
+ "assignmentMessage": "\"{task}\" em \"{board}\"",
+ "reminderTitle": "Tarefa em atraso",
+ "reminderMessage": "\"{task}\" em \"{board}\" ultrapassou o lembrete",
+ "sharingTitle": "{user} partilhou um(a) {type} consigo",
+ "sharingMessage": "Abrir para ver o(a) {type} partilhado(a)"
+ }
+}
diff --git a/app/_translations/ru.json b/app/_translations/ru.json
index a6630dbf..22bcfa36 100644
--- a/app/_translations/ru.json
+++ b/app/_translations/ru.json
@@ -7,6 +7,8 @@
"view": "Просмотр",
"new": "Новый",
"close": "Закрыть",
+ "enlarge": "Развернуть",
+ "shrink": "Свернуть",
"confirm": "Подтвердить",
"loading": "Загрузка...",
"search": "Поиск",
@@ -306,7 +308,8 @@
"createUser": "Создать пользователя",
"attemptsRemaining": "Предупреждение: осталось {count, plural, one {# попытка} few {# попытки} many {# попыток} other {# попыток}} до блокировки аккаунта",
"accountLocked": "Слишком много неудачных попыток. Пожалуйста, попробуйте снова через {seconds} {seconds, plural, one {секунду} few {секунды} many {секунд} other {секунд}}",
- "notAuthorized": "Вы не авторизованы для доступа к этому приложению. Пожалуйста, свяжитесь с администратором."
+ "notAuthorized": "Вы не авторизованы для доступа к этому приложению. Пожалуйста, свяжитесь с администратором.",
+ "rememberMe": "Запомнить меня"
},
"mfa": {
"title": "Двухфакторная аутентификация",
@@ -568,6 +571,7 @@
"autoComplete": "Автозавершение"
},
"kanban": {
+ "status": "Статус",
"priority": "Приоритет",
"score": "Оценка",
"assignee": "Исполнитель",
@@ -597,6 +601,7 @@
"itemTitle": "Название элемента",
"manageStatuses": "Управление статусами",
"viewArchived": "Просмотр архивных",
+ "changeStatus": "Изменить статус",
"noBoards": "Доски не найдены",
"noBoardsYet": "Пока нет досок Канбан",
"createFirstBoard": "Создайте первую доску Канбан для управления проектами.",
@@ -611,6 +616,13 @@
"targetDate": "Целевая дата",
"statusUpdated": "Статус обновлён",
"itemArchived": "Элемент архивирован",
+ "archiveAllItemsInColumn": "Архивировать все элементы в {column}",
+ "archiveAllConfirmTitle": "Архивировать все элементы?",
+ "archiveAllConfirmMessage": "Архивировать все элементы ({count}) в {column}? Их можно восстановить из архива.",
+ "archiveAllSuccess": "Архивировано элементов: {count}",
+ "archiveAllFailed": "Не удалось архивировать все элементы",
+ "moveFailed": "Не удалось переместить карточку",
+ "archiveAllPartial": "Архивировано {archived} из {total} элементов. Некоторые элементы не удалось архивировать.",
"itemDeleted": "Элемент удалён",
"estimatedTime": "Оценка времени (часы)",
"actualVsEstimated": "{actual} / {estimated}",
@@ -705,7 +717,27 @@
"connection_plural": "связей",
"terminateSessionConfirm": "Вы уверены, что хотите завершить эту сессию?",
"terminateAllSessionsConfirm": "Вы уверены, что хотите завершить все остальные сессии?",
- "validationErrorTitle": "Ошибка валидации"
+ "validationErrorTitle": "Ошибка валидации",
+ "visibleGraphSummary": "Видимых элементов: {items}, видимых связей: {links}",
+ "graphTruncated": "Показаны {visible} наиболее связанных элементов. {omitted} менее связанных элементов скрыто.",
+ "totalConnections": "Всего",
+ "inboundConnections": "Входящие",
+ "outboundConnections": "Исходящие",
+ "updated": "Обновлено",
+ "openItem": "Открыть элемент",
+ "selectItem": "Выберите элемент",
+ "inspectNodeHint": "Коснитесь или щёлкните узел, чтобы увидеть название, категорию, количество связей и недавнюю активность.",
+ "graphSearchPlaceholder": "Название, путь, категория или тег",
+ "orphans": "Одиночные",
+ "labels": "Подписи",
+ "arrows": "Стрелки",
+ "nodeSize": "Размер узла",
+ "linkWidth": "Толщина связи",
+ "linkDistance": "Длина связи",
+ "repelForce": "Отталкивание",
+ "tagLinks": "Связи по тегам",
+ "linkedItems": "Связанные элементы",
+ "uuid": "UUID"
},
"settings": {
"title": "Настройки",
@@ -861,6 +893,11 @@
"showStatusOnCards": "Показать статус",
"hideStatusOnCards": "Скрыть статус",
"hideStatusOnCardsDescription": "Скрывает метку статуса на карточках Канбан. Статус уже виден по колонке, в которой находится карточка.",
+ "hideMobileStatusDropdownLabel": "Мобильный выпадающий список статуса на карточках",
+ "showMobileStatusDropdown": "Показать мобильный список",
+ "hideMobileStatusDropdown": "Скрыть мобильный список",
+ "selectMobileStatusDropdown": "Выберите поведение мобильного списка статуса",
+ "hideMobileStatusDropdownDescription": "Управляет всегда видимым выпадающим списком статуса на мобильных карточках Канбан. Настройка метки статуса остаётся отдельной.",
"selectStatusOnCards": "Выберите видимость статуса",
"codeBlockChoice": "Выберите стиль блока кода",
"themedCodeBlock": "Тематический кодовый блок",
diff --git a/app/_translations/tr.json b/app/_translations/tr.json
index 795ab0c4..0a5c82a0 100644
--- a/app/_translations/tr.json
+++ b/app/_translations/tr.json
@@ -7,6 +7,8 @@
"view": "Görüntüle",
"new": "Yeni",
"close": "Kapat",
+ "enlarge": "Büyüt",
+ "shrink": "Küçült",
"confirm": "Onayla",
"loading": "Yükleniyor...",
"search": "Ara",
@@ -306,7 +308,8 @@
"createUser": "Kullanıcı Oluştur",
"attemptsRemaining": "Uyarı: Hesabın kilitlenmesine {count, plural, one {# deneme} other {# deneme}} kaldı",
"accountLocked": "Çok fazla hatalı deneme. Lütfen {seconds} {seconds, plural, one {saniye} other {saniye}} sonra tekrar deneyin",
- "notAuthorized": "Bu uygulamaya erişim yetkiniz yok. Lütfen yöneticinizle iletişime geçin."
+ "notAuthorized": "Bu uygulamaya erişim yetkiniz yok. Lütfen yöneticinizle iletişime geçin.",
+ "rememberMe": "Beni hatırla"
},
"mfa": {
"title": "İki Faktörlü Doğrulama",
@@ -568,6 +571,7 @@
"autoComplete": "Otomatik tamamla"
},
"kanban": {
+ "status": "Durum",
"priority": "Öncelik",
"score": "Puan",
"assignee": "Atanan",
@@ -597,6 +601,7 @@
"itemTitle": "Öğe başlığı",
"manageStatuses": "Durumları yönet",
"viewArchived": "Arşivlenenleri görüntüle",
+ "changeStatus": "Durumu değiştir",
"noBoards": "Pano bulunamadı",
"noBoardsYet": "Henüz Kanban panosu yok",
"createFirstBoard": "Projelerinizi yönetmek için ilk Kanban panonuzu oluşturun.",
@@ -611,6 +616,13 @@
"targetDate": "Hedef tarih",
"statusUpdated": "Durum güncellendi",
"itemArchived": "Öğe arşivlendi",
+ "archiveAllItemsInColumn": "{column} içindeki tüm öğeleri arşivle",
+ "archiveAllConfirmTitle": "Tüm öğeler arşivlensin mi?",
+ "archiveAllConfirmMessage": "{column} içindeki {count} öğenin tümü arşivlensin mi? Bunları arşivden geri yükleyebilirsiniz.",
+ "archiveAllSuccess": "{count} öğe arşivlendi",
+ "archiveAllFailed": "Tüm öğeler arşivlenemedi",
+ "moveFailed": "Kart taşınamadı",
+ "archiveAllPartial": "{total} öğeden {archived} tanesi arşivlendi. Bazı öğeler arşivlenemedi.",
"itemDeleted": "Öğe silindi",
"estimatedTime": "Tahmini süre (saat)",
"actualVsEstimated": "{actual} / {estimated}",
@@ -705,7 +717,27 @@
"connection_plural": "bağlantı",
"terminateSessionConfirm": "Bu oturumu sonlandırmak istediğinizden emin misiniz?",
"terminateAllSessionsConfirm": "Diğer tüm oturumları sonlandırmak istediğinizden emin misiniz?",
- "validationErrorTitle": "Doğrulama Hatası"
+ "validationErrorTitle": "Doğrulama Hatası",
+ "visibleGraphSummary": "{items} görünür öğe, {links} görünür bağlantı",
+ "graphTruncated": "En çok bağlantıya sahip {visible} öğe gösteriliyor. Daha az bağlantılı {omitted} öğe atlandı.",
+ "totalConnections": "Toplam",
+ "inboundConnections": "Gelen",
+ "outboundConnections": "Giden",
+ "updated": "Güncellendi",
+ "openItem": "Öğeyi aç",
+ "selectItem": "Bir öğe seçin",
+ "inspectNodeHint": "Başlığı, kategoriyi, bağlantı sayısını ve son etkinliği görmek için bir düğüme dokunun veya tıklayın.",
+ "graphSearchPlaceholder": "Başlık, yol, kategori veya etiket",
+ "orphans": "Bağlantısız",
+ "labels": "Etiketler",
+ "arrows": "Oklar",
+ "nodeSize": "Düğüm boyutu",
+ "linkWidth": "Bağlantı kalınlığı",
+ "linkDistance": "Bağlantı mesafesi",
+ "repelForce": "İtme",
+ "tagLinks": "Etiket bağlantıları",
+ "linkedItems": "Bağlantılı öğeler",
+ "uuid": "UUID"
},
"settings": {
"title": "Ayarlar",
@@ -865,6 +897,11 @@
"showStatusOnCards": "Durumu göster",
"hideStatusOnCards": "Durumu gizle",
"hideStatusOnCardsDescription": "Kanban kartlarındaki durum etiketini gizler. Durum, kartın bulunduğu sütun tarafından zaten belirtilmektedir.",
+ "hideMobileStatusDropdownLabel": "Kartlarda mobil durum açılır menüsü",
+ "showMobileStatusDropdown": "Mobil menüyü göster",
+ "hideMobileStatusDropdown": "Mobil menüyü gizle",
+ "selectMobileStatusDropdown": "Mobil durum menüsü davranışını seç",
+ "hideMobileStatusDropdownDescription": "Mobil Kanban kartlarında her zaman görünen durum açılır menüsünü kontrol eder. Durum etiketi ayarı ayrıdır.",
"selectStatusOnCards": "Durum görünürlüğünü seçin",
"checklistItemClickAction": "Kontrol listesi öğesi tıklama eylemi",
"selectChecklistItemClickAction": "Kontrol listesi öğesini seçin tıklama eylemi",
diff --git a/app/_translations/zh.json b/app/_translations/zh.json
index 750ab022..88983a47 100644
--- a/app/_translations/zh.json
+++ b/app/_translations/zh.json
@@ -7,6 +7,8 @@
"view": "查看",
"new": "新建",
"close": "关闭",
+ "enlarge": "放大",
+ "shrink": "缩小",
"confirm": "确认",
"loading": "加载中...",
"search": "搜索",
@@ -306,7 +308,8 @@
"createUser": "创建用户",
"attemptsRemaining": "警告:在账户锁定前还剩 {count, plural, one {# 次尝试} other {# 次尝试}}",
"accountLocked": "失败次数过多。请在 {seconds} {seconds, plural, one {秒} other {秒}} 后重试",
- "notAuthorized": "您没有权限访问此应用程序。请联系您的管理员。"
+ "notAuthorized": "您没有权限访问此应用程序。请联系您的管理员。",
+ "rememberMe": "记住我"
},
"mfa": {
"title": "双重认证 (2FA)",
@@ -568,6 +571,7 @@
"autoComplete": "自动完成"
},
"kanban": {
+ "status": "状态",
"priority": "优先级",
"score": "分数",
"assignee": "负责人",
@@ -597,6 +601,7 @@
"itemTitle": "项目标题",
"manageStatuses": "管理状态",
"viewArchived": "查看已归档",
+ "changeStatus": "更改状态",
"noBoards": "未找到看板",
"noBoardsYet": "暂无看板",
"createFirstBoard": "创建您的第一个看板以开始管理项目。",
@@ -611,6 +616,13 @@
"targetDate": "目标日期",
"statusUpdated": "状态已更新",
"itemArchived": "项目已归档",
+ "archiveAllItemsInColumn": "归档 {column} 中的所有项目",
+ "archiveAllConfirmTitle": "归档所有项目?",
+ "archiveAllConfirmMessage": "要归档 {column} 中的全部 {count} 个项目吗?你可以从归档中恢复它们。",
+ "archiveAllSuccess": "已归档 {count} 个项目",
+ "archiveAllFailed": "无法归档所有项目",
+ "moveFailed": "无法移动卡片",
+ "archiveAllPartial": "已归档 {total} 个项目中的 {archived} 个。部分项目无法归档。",
"itemDeleted": "项目已删除",
"estimatedTime": "预估时间(小时)",
"actualVsEstimated": "{actual} / {estimated}",
@@ -705,7 +717,27 @@
"connection_plural": "个连接",
"terminateSessionConfirm": "确定要终止此会话吗?",
"terminateAllSessionsConfirm": "确定要终止其他所有会话吗?",
- "validationErrorTitle": "验证错误"
+ "validationErrorTitle": "验证错误",
+ "visibleGraphSummary": "{items} 个可见项,{links} 条可见连接",
+ "graphTruncated": "显示连接数最多的前 {visible} 个项目。已省略 {omitted} 个连接较少的项目。",
+ "totalConnections": "总计",
+ "inboundConnections": "入站",
+ "outboundConnections": "出站",
+ "updated": "已更新",
+ "openItem": "打开项目",
+ "selectItem": "选择一个项目",
+ "inspectNodeHint": "点按或单击节点可查看标题、分类、连接数和最近活动。",
+ "graphSearchPlaceholder": "标题、路径、分类或标签",
+ "orphans": "孤立项",
+ "labels": "标签",
+ "arrows": "箭头",
+ "nodeSize": "节点大小",
+ "linkWidth": "连接宽度",
+ "linkDistance": "连接距离",
+ "repelForce": "排斥力",
+ "tagLinks": "标签连接",
+ "linkedItems": "关联项目",
+ "uuid": "UUID"
},
"settings": {
"title": "设置",
@@ -861,6 +893,11 @@
"showStatusOnCards": "显示状态",
"hideStatusOnCards": "隐藏状态",
"hideStatusOnCardsDescription": "隐藏看板卡片上的状态标签。状态已通过卡片所在的列显示。",
+ "hideMobileStatusDropdownLabel": "卡片上的移动端状态下拉菜单",
+ "showMobileStatusDropdown": "显示移动端下拉菜单",
+ "hideMobileStatusDropdown": "隐藏移动端下拉菜单",
+ "selectMobileStatusDropdown": "选择移动端状态下拉菜单行为",
+ "hideMobileStatusDropdownDescription": "控制移动端看板卡片上始终可见的状态下拉菜单。状态标签设置保持独立。",
"selectStatusOnCards": "选择状态可见性",
"codeBlockChoice": "选择代码块样式",
"themedCodeBlock": "主题代码块",
diff --git a/app/_types/checklist.ts b/app/_types/checklist.ts
index 3c2c0f75..181eb213 100644
--- a/app/_types/checklist.ts
+++ b/app/_types/checklist.ts
@@ -48,6 +48,7 @@ export interface Item {
status?: string;
timeEntries?: TimeEntry[];
estimatedTime?: number;
+ startDate?: string;
targetDate?: string;
children?: Item[];
createdBy?: string;
diff --git a/app/_types/dnd.ts b/app/_types/dnd.ts
new file mode 100644
index 00000000..f16f962f
--- /dev/null
+++ b/app/_types/dnd.ts
@@ -0,0 +1,35 @@
+export enum DragPhase {
+ IDLE = "idle",
+ DRAGGING = "dragging",
+}
+
+export interface DragPoint {
+ x: number;
+ y: number;
+}
+
+export interface DragRect {
+ top: number;
+ left: number;
+ width: number;
+ height: number;
+}
+
+export interface DragSize {
+ width: number;
+ height: number;
+}
+
+export interface DropResult {
+ itemId: string;
+ sourceListId: string;
+ sourceIndex: number;
+ targetListId: string;
+ targetIndex: number;
+}
+
+export interface ListItemRect {
+ id: string;
+ index: number;
+ rect: DragRect;
+}
diff --git a/app/_types/index.ts b/app/_types/index.ts
index 6d6922c7..4d8db9dd 100644
--- a/app/_types/index.ts
+++ b/app/_types/index.ts
@@ -38,6 +38,7 @@ export type {
QuickCreateNotes,
HideConnectionIndicator,
HideStatusOnCards,
+ HideMobileStatusDropdown,
CodeBlockStyle,
ChecklistItemClickAction,
} from "./user";
@@ -93,4 +94,12 @@ export type { WsEvent } from "./websocket";
export type { GetNotesOptions, GetChecklistsOptions } from "./options";
+export type {
+ DragPoint,
+ DragRect,
+ DragSize,
+ DropResult,
+ ListItemRect,
+} from "./dnd";
+
export type { AppNotification, AppNotificationData, NotificationType } from "./notifications";
diff --git a/app/_types/user.ts b/app/_types/user.ts
index 8bc8cb2c..57cb4efd 100644
--- a/app/_types/user.ts
+++ b/app/_types/user.ts
@@ -38,6 +38,7 @@ export type DefaultNoteFilter = "all" | "recent" | "pinned";
export type QuickCreateNotes = "enable" | "disable";
export type HideConnectionIndicator = "enable" | "disable";
export type HideStatusOnCards = "enable" | "disable";
+export type HideMobileStatusDropdown = "enable" | "disable";
export type CodeBlockStyle = "default" | "themed";
export type ChecklistItemClickAction = "toggle" | "edit";
@@ -75,6 +76,7 @@ export interface User {
quickCreateNotesCategory?: string;
hideConnectionIndicator?: HideConnectionIndicator;
hideStatusOnCards?: HideStatusOnCards;
+ hideMobileStatusDropdown?: HideMobileStatusDropdown;
codeBlockStyle?: CodeBlockStyle;
mfaEnabled?: boolean;
mfaSecret?: string;
diff --git a/app/_utils/api-item.ts b/app/_utils/api-item.ts
new file mode 100644
index 00000000..3243571c
--- /dev/null
+++ b/app/_utils/api-item.ts
@@ -0,0 +1,65 @@
+import { Item, KanbanPriority, StatusChange, TimeEntry } from "@/app/_types";
+import { TaskStatus } from "@/app/_types/enums";
+
+export interface ApiItem {
+ id: string;
+ index: number;
+ text: string;
+ completed: boolean;
+ status?: string;
+ time?: number | TimeEntry[];
+ description?: string;
+ priority?: KanbanPriority;
+ score?: number;
+ startDate?: string;
+ targetDate?: string;
+ estimatedTime?: number;
+ createdBy?: string;
+ createdAt?: string;
+ lastModifiedBy?: string;
+ lastModifiedAt?: string;
+ history?: StatusChange[];
+ children?: ApiItem[];
+}
+
+export const toApiItem = (
+ item: Item,
+ index: number,
+ isKanban: boolean,
+): ApiItem => {
+ const apiItem: ApiItem = {
+ id: item.id,
+ index,
+ text: item.text,
+ completed: item.completed,
+ };
+
+ if (isKanban) {
+ apiItem.status = item.status || TaskStatus.TODO;
+ apiItem.time =
+ item.timeEntries && item.timeEntries.length > 0 ? item.timeEntries : 0;
+ }
+
+ if (item.description !== undefined) apiItem.description = item.description;
+ if (item.priority !== undefined) apiItem.priority = item.priority;
+ if (item.score !== undefined) apiItem.score = item.score;
+ if (item.startDate !== undefined) apiItem.startDate = item.startDate;
+ if (item.targetDate !== undefined) apiItem.targetDate = item.targetDate;
+ if (item.estimatedTime !== undefined)
+ apiItem.estimatedTime = item.estimatedTime;
+ if (item.createdBy !== undefined) apiItem.createdBy = item.createdBy;
+ if (item.createdAt !== undefined) apiItem.createdAt = item.createdAt;
+ if (item.lastModifiedBy !== undefined)
+ apiItem.lastModifiedBy = item.lastModifiedBy;
+ if (item.lastModifiedAt !== undefined)
+ apiItem.lastModifiedAt = item.lastModifiedAt;
+ if (item.history !== undefined) apiItem.history = item.history;
+
+ if (item.children && item.children.length > 0) {
+ apiItem.children = item.children.map((child, childIndex) =>
+ toApiItem(child, childIndex, isKanban),
+ );
+ }
+
+ return apiItem;
+};
diff --git a/app/_utils/checklist-utils.ts b/app/_utils/checklist-utils.ts
index 9551efa3..237c71fb 100644
--- a/app/_utils/checklist-utils.ts
+++ b/app/_utils/checklist-utils.ts
@@ -104,9 +104,8 @@ export const parseMarkdown = (
);
let globalItemCounter = 0;
- const resolveItemId = (storedId: string | undefined, level: number): string => {
- const slot = globalItemCounter++;
- return storedId || `${id}-${level}-${slot}`;
+ const generateItemId = (level: number): string => {
+ return `${id}-${level}-${globalItemCounter++}`;
};
const buildNestedItems = (
@@ -176,6 +175,7 @@ export const parseMarkdown = (
let timeEntries: any[] = [];
let estimatedTime: number | undefined;
let targetDate: string | undefined;
+ let startDate: string | undefined;
let description: string | undefined;
let itemMetadata: Record = {};
let priority: KanbanPriority | undefined;
@@ -199,6 +199,8 @@ export const parseMarkdown = (
estimatedTime = parseInt(meta.substring(10));
} else if (meta.startsWith("target:")) {
targetDate = meta.substring(7);
+ } else if (meta.startsWith("start:")) {
+ startDate = meta.substring(6);
} else if (meta.startsWith("description:")) {
description = meta.substring(12).replace(/∣/g, "|");
} else if (meta.startsWith("metadata:")) {
@@ -225,13 +227,14 @@ export const parseMarkdown = (
});
item = {
- id: resolveItemId(itemMetadata.id, parentLevel),
+ id: itemMetadata.id || generateItemId(parentLevel),
text: itemText,
completed,
order: currentItemIndex,
status,
timeEntries,
estimatedTime,
+ startDate,
targetDate,
description,
...itemMetadata,
@@ -267,7 +270,7 @@ export const parseMarkdown = (
}
item = {
- id: resolveItemId(itemMetadata.id, parentLevel),
+ id: itemMetadata.id || generateItemId(parentLevel),
text: itemText,
completed,
order: currentItemIndex,
@@ -403,6 +406,10 @@ const generateItemMarkdown = (
metadata.push(`estimated:${item.estimatedTime}`);
}
+ if (item.startDate) {
+ metadata.push(`start:${item.startDate}`);
+ }
+
if (item.targetDate) {
metadata.push(`target:${item.targetDate}`);
}
@@ -470,6 +477,9 @@ const generateItemMarkdown = (
if (item.estimatedTime) {
itemMetadata.estimatedTime = item.estimatedTime;
}
+ if (item.startDate) {
+ itemMetadata.startDate = item.startDate;
+ }
if (item.targetDate) {
itemMetadata.targetDate = item.targetDate;
}
diff --git a/app/_utils/client-parser-utils.ts b/app/_utils/client-parser-utils.ts
index 996468f8..b04a7a01 100644
--- a/app/_utils/client-parser-utils.ts
+++ b/app/_utils/client-parser-utils.ts
@@ -42,6 +42,10 @@ export const parseChecklistContent = (
(line) => line.trim().startsWith("- [") || /^\s*- \[/.test(line),
);
+ let globalItemCounter = 0;
+ const generateItemId = (level: number): string =>
+ `${id}-${level}-${globalItemCounter++}`;
+
const buildNestedItems = (
lines: string[],
startIndex: number = 0,
@@ -111,6 +115,7 @@ export const parseChecklistContent = (
let timeEntries: any[] = [];
let estimatedTime: number | undefined;
let targetDate: string | undefined;
+ let startDate: string | undefined;
let description: string | undefined;
let itemMetadata: Record = {};
let priority: KanbanPriority | undefined;
@@ -134,6 +139,8 @@ export const parseChecklistContent = (
estimatedTime = parseInt(meta.substring(10));
} else if (meta.startsWith("target:")) {
targetDate = meta.substring(7);
+ } else if (meta.startsWith("start:")) {
+ startDate = meta.substring(6);
} else if (meta.startsWith("description:")) {
description = meta.substring(12).replace(/∣/g, "|");
} else if (meta.startsWith("metadata:")) {
@@ -164,13 +171,14 @@ export const parseChecklistContent = (
});
item = {
- id: (itemMetadata.id as string) || `${id}-${currentItemIndex}`,
+ id: (itemMetadata.id as string) || generateItemId(parentLevel),
text: itemText,
completed,
order: currentItemIndex,
status,
timeEntries,
estimatedTime,
+ startDate,
targetDate,
description,
...itemMetadata,
@@ -206,7 +214,7 @@ export const parseChecklistContent = (
}
item = {
- id: itemMetadata.id || `${id}-${currentItemIndex}`,
+ id: itemMetadata.id || generateItemId(parentLevel),
text: itemText,
completed,
order: currentItemIndex,
@@ -246,6 +254,21 @@ export const parseChecklistContent = (
const { items } = buildNestedItems(itemLines, 0, 0, 0);
+ const dedupeItemIds = (itemList: Item[], seen: Set): void => {
+ itemList.forEach((item) => {
+ if (item.id && seen.has(item.id)) {
+ item.id = generateItemId(0);
+ }
+ if (item.id) {
+ seen.add(item.id);
+ }
+ if (item.children && item.children.length > 0) {
+ dedupeItemIds(item.children, seen);
+ }
+ });
+ };
+ dedupeItemIds(items, new Set());
+
let statuses = undefined;
if (metadata.statuses && Array.isArray(metadata.statuses)) {
statuses = metadata.statuses;
diff --git a/app/_utils/dnd/auto-scroll.ts b/app/_utils/dnd/auto-scroll.ts
new file mode 100644
index 00000000..acdfa605
--- /dev/null
+++ b/app/_utils/dnd/auto-scroll.ts
@@ -0,0 +1,64 @@
+import { DragPoint } from "@/app/_types/dnd";
+import { EDGE_SCROLL_PX, MAX_SCROLL_STEP } from "@/app/_consts/dnd";
+
+export interface AutoScroller {
+ start: (scrollers: HTMLElement[]) => void;
+ update: (point: DragPoint) => void;
+ stop: () => void;
+}
+
+const _axisStep = (pos: number, start: number, end: number): number => {
+ if (end - start < EDGE_SCROLL_PX * 3) return 0;
+ if (pos < start + EDGE_SCROLL_PX) {
+ const strength = Math.min(1, (start + EDGE_SCROLL_PX - pos) / EDGE_SCROLL_PX);
+ return -Math.ceil(strength * MAX_SCROLL_STEP);
+ }
+ if (pos > end - EDGE_SCROLL_PX) {
+ const strength = Math.min(1, (pos - (end - EDGE_SCROLL_PX)) / EDGE_SCROLL_PX);
+ return Math.ceil(strength * MAX_SCROLL_STEP);
+ }
+ return 0;
+};
+
+export const createScroller = (): AutoScroller => {
+ let frame: number | null = null;
+ let point: DragPoint | null = null;
+ let scrollers: HTMLElement[] = [];
+
+ const _tick = () => {
+ frame = requestAnimationFrame(_tick);
+ if (!point) return;
+
+ for (const el of scrollers) {
+ const rect = el.getBoundingClientRect();
+ const inside =
+ point.x >= rect.left &&
+ point.x <= rect.right &&
+ point.y >= rect.top &&
+ point.y <= rect.bottom;
+ if (!inside) continue;
+
+ const dy = _axisStep(point.y, rect.top, rect.bottom);
+ const dx = _axisStep(point.x, rect.left, rect.right);
+ if (dy) el.scrollTop += dy;
+ if (dx) el.scrollLeft += dx;
+ }
+ };
+
+ return {
+ start: (targets) => {
+ scrollers = targets;
+ point = null;
+ if (frame === null) frame = requestAnimationFrame(_tick);
+ },
+ update: (next) => {
+ point = next;
+ },
+ stop: () => {
+ if (frame !== null) cancelAnimationFrame(frame);
+ frame = null;
+ point = null;
+ scrollers = [];
+ },
+ };
+};
diff --git a/app/_utils/dnd/dnd-math.ts b/app/_utils/dnd/dnd-math.ts
new file mode 100644
index 00000000..28c087f0
--- /dev/null
+++ b/app/_utils/dnd/dnd-math.ts
@@ -0,0 +1,65 @@
+import { DragPoint, DragRect, ListItemRect } from "@/app/_types/dnd";
+import { CARD_GAP_PX } from "@/app/_consts/dnd";
+
+const _contains = (rect: DragRect, point: DragPoint): boolean =>
+ point.x >= rect.left &&
+ point.x <= rect.left + rect.width &&
+ point.y >= rect.top &&
+ point.y <= rect.top + rect.height;
+
+export const findDropList = (
+ point: DragPoint,
+ lists: Map,
+): string | null => {
+ let nearest: string | null = null;
+ let nearestDist = Infinity;
+ let contained: string | null = null;
+
+ lists.forEach((rect, id) => {
+ if (contained) return;
+ if (_contains(rect, point)) {
+ contained = id;
+ return;
+ }
+ const overlapsY = point.y >= rect.top && point.y <= rect.top + rect.height;
+ if (!overlapsY) return;
+ const dist = Math.abs(point.x - (rect.left + rect.width / 2));
+ if (dist < nearestDist) {
+ nearestDist = dist;
+ nearest = id;
+ }
+ });
+
+ return contained || nearest;
+};
+
+export const projectIndex = (
+ pointerY: number,
+ items: ListItemRect[],
+ activeId: string,
+): number => {
+ let index = 0;
+ for (const entry of items) {
+ if (entry.id === activeId) continue;
+ const midY = entry.rect.top + entry.rect.height / 2;
+ if (pointerY > midY) index += 1;
+ }
+ return index;
+};
+
+export const displaceFor = (
+ effIndex: number,
+ activeEff: number | null,
+ targetIndex: number | null,
+ dragHeight: number,
+): number => {
+ if (targetIndex === null) return 0;
+ const delta = dragHeight + CARD_GAP_PX;
+
+ if (activeEff === null) {
+ return effIndex >= targetIndex ? delta : 0;
+ }
+ if (targetIndex <= effIndex && effIndex < activeEff) return delta;
+ if (activeEff <= effIndex && effIndex < targetIndex) return -delta;
+ return 0;
+};
diff --git a/app/_utils/dnd/drag-store.ts b/app/_utils/dnd/drag-store.ts
new file mode 100644
index 00000000..b697ae52
--- /dev/null
+++ b/app/_utils/dnd/drag-store.ts
@@ -0,0 +1,51 @@
+import { create } from "zustand";
+import { DragPhase, DragSize } from "@/app/_types/dnd";
+
+interface DragState {
+ phase: DragPhase;
+ activeId: string | null;
+ sourceListId: string | null;
+ sourceIndex: number;
+ targetListId: string | null;
+ targetIndex: number | null;
+ dragSize: DragSize;
+ lift: (
+ activeId: string,
+ sourceListId: string,
+ sourceIndex: number,
+ dragSize: DragSize,
+ ) => void;
+ aim: (targetListId: string | null, targetIndex: number | null) => void;
+ settle: () => void;
+}
+
+const IDLE_STATE = {
+ phase: DragPhase.IDLE,
+ activeId: null,
+ sourceListId: null,
+ sourceIndex: 0,
+ targetListId: null,
+ targetIndex: null,
+ dragSize: { width: 0, height: 0 },
+};
+
+export const useDragStore = create()((set) => ({
+ ...IDLE_STATE,
+ lift: (activeId, sourceListId, sourceIndex, dragSize) =>
+ set({
+ phase: DragPhase.DRAGGING,
+ activeId,
+ sourceListId,
+ sourceIndex,
+ targetListId: sourceListId,
+ targetIndex: sourceIndex,
+ dragSize,
+ }),
+ aim: (targetListId, targetIndex) =>
+ set((state) =>
+ state.targetListId === targetListId && state.targetIndex === targetIndex
+ ? state
+ : { targetListId, targetIndex },
+ ),
+ settle: () => set(IDLE_STATE),
+}));
diff --git a/app/_utils/dnd/rect-registry.ts b/app/_utils/dnd/rect-registry.ts
new file mode 100644
index 00000000..d09e1d7c
--- /dev/null
+++ b/app/_utils/dnd/rect-registry.ts
@@ -0,0 +1,120 @@
+import { DragRect, ListItemRect } from "@/app/_types/dnd";
+
+interface ListEntry {
+ el: HTMLElement;
+ rect: DragRect;
+}
+
+interface ItemEntry {
+ el: HTMLElement;
+ listId: string;
+ index: number;
+ rect: DragRect;
+}
+
+export interface RectRegistry {
+ registerList: (id: string, el: HTMLElement) => void;
+ unregisterList: (id: string) => void;
+ registerItem: (
+ id: string,
+ listId: string,
+ index: number,
+ el: HTMLElement,
+ ) => void;
+ unregisterItem: (id: string) => void;
+ snapshot: () => void;
+ shiftBy: (scrollDx: number, scrollDy: number, scroller: Node) => void;
+ listRects: () => Map;
+ itemsOf: (listId: string) => ListItemRect[];
+ scrollables: () => HTMLElement[];
+}
+
+const ZERO_RECT: DragRect = { top: 0, left: 0, width: 0, height: 0 };
+
+const _toRect = (el: HTMLElement): DragRect => {
+ const rect = el.getBoundingClientRect();
+ return {
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ height: rect.height,
+ };
+};
+
+const _scrolls = (el: HTMLElement): boolean => {
+ const style = window.getComputedStyle(el);
+ return /(auto|scroll)/.test(
+ `${style.overflow}${style.overflowX}${style.overflowY}`,
+ );
+};
+
+export const createRegistry = (): RectRegistry => {
+ const lists = new Map();
+ const items = new Map();
+
+ return {
+ registerList: (id, el) => {
+ lists.set(id, { el, rect: ZERO_RECT });
+ },
+ unregisterList: (id) => {
+ lists.delete(id);
+ },
+ registerItem: (id, listId, index, el) => {
+ items.set(id, { el, listId, index, rect: ZERO_RECT });
+ },
+ unregisterItem: (id) => {
+ items.delete(id);
+ },
+ snapshot: () => {
+ lists.forEach((entry) => {
+ entry.rect = _toRect(entry.el);
+ });
+ items.forEach((entry) => {
+ entry.rect = _toRect(entry.el);
+ });
+ },
+ shiftBy: (scrollDx, scrollDy, scroller) => {
+ if (!scrollDx && !scrollDy) return;
+ lists.forEach((entry) => {
+ if (!scroller.contains(entry.el)) return;
+ entry.rect = {
+ ...entry.rect,
+ left: entry.rect.left - scrollDx,
+ top: entry.rect.top - scrollDy,
+ };
+ });
+ items.forEach((entry) => {
+ if (!scroller.contains(entry.el)) return;
+ entry.rect = {
+ ...entry.rect,
+ left: entry.rect.left - scrollDx,
+ top: entry.rect.top - scrollDy,
+ };
+ });
+ },
+ listRects: () => {
+ const rects = new Map();
+ lists.forEach((entry, id) => rects.set(id, entry.rect));
+ return rects;
+ },
+ itemsOf: (listId) => {
+ const result: ListItemRect[] = [];
+ items.forEach((entry, id) => {
+ if (entry.listId !== listId) return;
+ result.push({ id, index: entry.index, rect: entry.rect });
+ });
+ return result.sort((a, b) => a.index - b.index);
+ },
+ scrollables: () => {
+ const found = new Set();
+ lists.forEach(({ el }) => {
+ let node: HTMLElement | null = el;
+ while (node) {
+ if (_scrolls(node)) found.add(node);
+ node = node.parentElement;
+ }
+ });
+ return Array.from(found);
+ },
+ };
+};
diff --git a/app/_utils/grep-utils.ts b/app/_utils/grep-utils.ts
index 85378c68..787fbdc3 100644
--- a/app/_utils/grep-utils.ts
+++ b/app/_utils/grep-utils.ts
@@ -11,12 +11,18 @@
* Enjoy it <3
*/
-import { exec } from "child_process";
+import { exec, execFile } from "child_process";
import { promisify } from "util";
import path from "path";
import yaml from "js-yaml";
const execAsync = promisify(exec);
+const execFileAsync = promisify(execFile);
+
+const _isNoMatch = (error: unknown): boolean =>
+ typeof error === "object" &&
+ error !== null &&
+ (error as { code?: number }).code === 1;
export interface GrepFileResult {
filePath: string;
@@ -248,9 +254,13 @@ export const grepSearchContent = async (
pattern: string,
): Promise => {
try {
- const { stdout } = await execAsync(
- `grep -rli "${pattern}" "${dir}" --include="*.md" 2>/dev/null || true`,
- );
+ const { stdout } = await execFileAsync("grep", [
+ "-rli",
+ "--include=*.md",
+ "--",
+ pattern,
+ dir,
+ ]);
const files = stdout.trim().split("\n").filter(Boolean);
@@ -264,18 +274,28 @@ export const grepSearchContent = async (
let matchLine = "";
try {
- const { stdout: matchOut } = await execAsync(
- `grep -im1 "${pattern}" "${filePath}" 2>/dev/null || true`,
- );
+ const { stdout: matchOut } = await execFileAsync("grep", [
+ "-im1",
+ "--",
+ pattern,
+ filePath,
+ ]);
matchLine = matchOut.trim();
- } catch {}
+ } catch (error) {
+ if (!_isNoMatch(error)) {
+ console.error("grep match-line failed:", error);
+ }
+ }
return { filePath, id, category, matchLine };
}),
);
return results;
- } catch {
+ } catch (error) {
+ if (!_isNoMatch(error)) {
+ console.error("grepSearchContent failed:", error);
+ }
return [];
}
};
diff --git a/app/_utils/item-status-utils.ts b/app/_utils/item-status-utils.ts
new file mode 100644
index 00000000..99ee66c6
--- /dev/null
+++ b/app/_utils/item-status-utils.ts
@@ -0,0 +1,84 @@
+import { Item, KanbanStatus } from "@/app/_types";
+import { updateItem, updateAllChildren } from "@/app/_utils/item-tree-utils";
+
+const _findParent = (items: Item[], childId: string): Item | null => {
+ for (const item of items) {
+ if (item.children?.some((c) => c.id === childId)) return item;
+ if (item.children) {
+ const found = _findParent(item.children, childId);
+ if (found) return found;
+ }
+ }
+ return null;
+};
+
+export const applyStatus = (
+ items: Item[],
+ itemId: string,
+ status: string,
+ statuses: KanbanStatus[] | undefined,
+ username: string,
+ now: string,
+): Item[] =>
+ updateItem(items, itemId, (item) => {
+ const updates: Partial- = {
+ status,
+ lastModifiedBy: username,
+ lastModifiedAt: now,
+ };
+
+ const targetStatus = statuses?.find((s) => s.id === status);
+ if (targetStatus?.autoComplete) {
+ updates.completed = true;
+ if (item.children && item.children.length > 0) {
+ updates.children = updateAllChildren(item.children, true, username, now);
+ }
+ } else if (item.completed && status !== item.status) {
+ updates.completed = false;
+ }
+
+ if (status !== item.status) {
+ updates.history = [
+ ...(item.history || []),
+ { status, timestamp: now, user: username },
+ ];
+ }
+
+ return { ...item, ...updates };
+ });
+
+export const completeParent = (
+ items: Item[],
+ childId: string,
+ statuses: KanbanStatus[] | undefined,
+ username: string,
+ now: string,
+): Item[] => {
+ const parent = _findParent(items, childId);
+ if (!parent || !parent.children) return items;
+
+ const allChildrenCompleted = parent.children.every((c) => c.completed);
+ if (!allChildrenCompleted) return items;
+
+ const autoCompleteStatus = statuses?.find((s) => s.autoComplete);
+ if (!autoCompleteStatus) return items;
+
+ const alreadyComplete =
+ parent.completed && parent.status === autoCompleteStatus.id;
+
+ const next = alreadyComplete
+ ? items
+ : updateItem(items, parent.id, (p) => ({
+ ...p,
+ completed: true,
+ status: autoCompleteStatus.id,
+ lastModifiedBy: username,
+ lastModifiedAt: now,
+ history: [
+ ...(p.history || []),
+ { status: autoCompleteStatus.id, timestamp: now, user: username },
+ ],
+ }));
+
+ return completeParent(next, parent.id, statuses, username, now);
+};
diff --git a/app/_utils/kanban/board-utils.ts b/app/_utils/kanban/board-utils.ts
new file mode 100644
index 00000000..26dfdf0a
--- /dev/null
+++ b/app/_utils/kanban/board-utils.ts
@@ -0,0 +1,111 @@
+import { Checklist, Item, KanbanStatus } from "@/app/_types";
+import { TaskStatus } from "@/app/_types/enums";
+import { DEFAULT_KANBAN_STATUSES } from "@/app/_consts/kanban";
+import { applyStatus, completeParent } from "@/app/_utils/item-status-utils";
+
+const _renumber = (items: Item[]): Item[] =>
+ items.map((item, idx) => ({
+ ...item,
+ order: idx,
+ children: item.children ? _renumber(item.children) : item.children,
+ }));
+
+const _firstStatusId = (statuses: KanbanStatus[] | undefined): string => {
+ const statusList = statuses || DEFAULT_KANBAN_STATUSES;
+ return (
+ [...statusList].sort((a, b) => a.order - b.order)[0]?.id || TaskStatus.TODO
+ );
+};
+
+export const getColumnItems = (
+ items: Item[],
+ statusId: string,
+ statuses: KanbanStatus[] | undefined,
+): Item[] => {
+ const statusList = statuses || DEFAULT_KANBAN_STATUSES;
+ const firstStatus = _firstStatusId(statuses);
+ const validIds = statusList.map((s) => s.id);
+
+ return items.filter((item) => {
+ if (item.isArchived) return false;
+ if (item.status === statusId) return true;
+ if (statusId === firstStatus) {
+ return !validIds.includes(item.status || "");
+ }
+ return false;
+ });
+};
+
+export const visToColIndex = (
+ visibleItems: Item[],
+ columnItems: Item[],
+ visIndex: number,
+): number => {
+ if (visIndex < visibleItems.length) {
+ const safeIndex = Math.max(0, visIndex);
+ const anchor = visibleItems[safeIndex];
+ if (!anchor) return columnItems.length;
+ const anchorIdx = columnItems.findIndex((item) => item.id === anchor.id);
+ return anchorIdx === -1 ? columnItems.length : anchorIdx;
+ }
+
+ const lastVisible = visibleItems[visibleItems.length - 1];
+ if (!lastVisible) return columnItems.length;
+
+ const lastIdx = columnItems.findIndex((item) => item.id === lastVisible.id);
+ return lastIdx === -1 ? columnItems.length : lastIdx + 1;
+};
+
+export const applyDrop = (
+ checklist: Checklist,
+ itemId: string,
+ targetStatus: string,
+ colIndex: number,
+ username: string,
+ now: string,
+): Checklist => {
+ const dragged = checklist.items.find((item) => item.id === itemId);
+ if (!dragged) return checklist;
+
+ const isStatusChange = (dragged.status || TaskStatus.TODO) !== targetStatus;
+ const withStatus = isStatusChange
+ ? completeParent(
+ applyStatus(
+ checklist.items,
+ itemId,
+ targetStatus,
+ checklist.statuses,
+ username,
+ now,
+ ),
+ itemId,
+ checklist.statuses,
+ username,
+ now,
+ )
+ : checklist.items;
+
+ const moved = withStatus.find((item) => item.id === itemId);
+ if (!moved) return checklist;
+
+ const remaining = withStatus.filter((item) => item.id !== itemId);
+ const column = getColumnItems(remaining, targetStatus, checklist.statuses);
+ const clamped = Math.max(0, Math.min(colIndex, column.length));
+
+ let insertAt: number;
+ if (column.length === 0) {
+ insertAt = remaining.length;
+ } else if (clamped < column.length) {
+ insertAt = remaining.findIndex((item) => item.id === column[clamped].id);
+ } else {
+ insertAt =
+ remaining.findIndex(
+ (item) => item.id === column[column.length - 1].id,
+ ) + 1;
+ }
+
+ const reordered = [...remaining];
+ reordered.splice(insertAt, 0, moved);
+
+ return { ...checklist, items: _renumber(reordered), updatedAt: now };
+};
diff --git a/app/_utils/kanban/calendar-utils.ts b/app/_utils/kanban/calendar-utils.ts
index cb05f667..e264211c 100644
--- a/app/_utils/kanban/calendar-utils.ts
+++ b/app/_utils/kanban/calendar-utils.ts
@@ -3,49 +3,83 @@ import { Item, KanbanStatus } from "@/app/_types";
export interface CalendarEvent {
id: string;
title: string;
- date: string;
+ startDate: string;
+ endDate: string;
status?: string;
priority?: string;
completed: boolean;
itemId: string;
}
+export interface WeekBarSegment {
+ event: CalendarEvent;
+ colStart: number;
+ colSpan: number;
+ lane: number;
+ continuesPrev: boolean;
+ continuesNext: boolean;
+}
+
+export const toDateKey = (dateStr: string): string => dateStr.split("T")[0];
+
+export const toLocalDateKey = (date: Date): string => {
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
+};
+
export const parseItemsForCalendar = (items: Item[]): CalendarEvent[] =>
items
.filter((item) => item.targetDate && !item.isArchived)
- .map((item) => ({
- id: item.id,
- title: item.text,
- date: item.targetDate!,
- status: item.status,
- priority: item.priority,
- completed: item.completed,
- itemId: item.id,
- }));
+ .map((item) => {
+ const endDate = toDateKey(item.targetDate!);
+ const startDate = item.startDate ? toDateKey(item.startDate) : endDate;
+ return {
+ id: item.id,
+ title: item.text,
+ startDate: startDate <= endDate ? startDate : endDate,
+ endDate: endDate >= startDate ? endDate : startDate,
+ status: item.status,
+ priority: item.priority,
+ completed: item.completed,
+ itemId: item.id,
+ };
+ });
const _escapeICS = (text: string): string =>
text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n");
-const _formatICSDate = (dateStr: string): string => {
+const _formatICSDateTime = (dateStr: string): string => {
const d = new Date(dateStr);
return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
};
+const _addDays = (dateKey: string, days: number): string => {
+ const [y, m, d] = dateKey.split("-").map(Number);
+ const date = new Date(y, m - 1, d + days);
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
+};
+
export const generateVEVENT = (item: Item, boardTitle: string): string => {
if (!item.targetDate) return "";
- const dtstart = _formatICSDate(item.targetDate);
- const dtend = _formatICSDate(
- new Date(new Date(item.targetDate).getTime() + 3600000).toISOString()
- );
- const now = _formatICSDate(new Date().toISOString());
+ const endDate = toDateKey(item.targetDate);
+ const startDate = item.startDate ? toDateKey(item.startDate) : endDate;
+ const rangeStart = startDate <= endDate ? startDate : endDate;
+ const rangeEnd = endDate >= startDate ? endDate : startDate;
+ const isMultiDay = rangeStart !== rangeEnd;
+ const now = _formatICSDateTime(new Date().toISOString());
const lines = [
"BEGIN:VEVENT",
`UID:${item.id}@jotty`,
`DTSTAMP:${now}`,
- `DTSTART:${dtstart}`,
- `DTEND:${dtend}`,
+ isMultiDay
+ ? `DTSTART;VALUE=DATE:${rangeStart.replace(/-/g, "")}`
+ : `DTSTART:${_formatICSDateTime(item.targetDate)}`,
+ isMultiDay
+ ? `DTEND;VALUE=DATE:${_addDays(rangeEnd, 1).replace(/-/g, "")}`
+ : `DTEND:${_formatICSDateTime(new Date(new Date(item.targetDate).getTime() + 3600000).toISOString())}`,
`SUMMARY:${_escapeICS(item.text)}`,
`DESCRIPTION:${_escapeICS(`Board: ${boardTitle}${item.description ? `\\n${item.description}` : ""}`)}`,
item.status ? `STATUS:${item.completed ? "COMPLETED" : "NEEDS-ACTION"}` : "",
@@ -79,9 +113,17 @@ export const getItemsGroupedByDate = (items: Item[]): Record =>
items
.filter((item) => item.targetDate && !item.isArchived)
.forEach((item) => {
- const date = item.targetDate!.split("T")[0];
- if (!grouped[date]) grouped[date] = [];
- grouped[date].push(item);
+ const endDate = toDateKey(item.targetDate!);
+ const startDate = item.startDate ? toDateKey(item.startDate) : endDate;
+ const rangeStart = startDate <= endDate ? startDate : endDate;
+ const rangeEnd = endDate >= startDate ? endDate : startDate;
+ let cursor = rangeStart;
+
+ while (cursor <= rangeEnd) {
+ if (!grouped[cursor]) grouped[cursor] = [];
+ grouped[cursor].push(item);
+ cursor = _addDays(cursor, 1);
+ }
});
return grouped;
@@ -118,3 +160,65 @@ export const getCalendarGrid = (year: number, month: number): (Date | null)[][]
return grid;
};
+
+export const getWeekBarSegments = (
+ week: (Date | null)[],
+ events: CalendarEvent[],
+): WeekBarSegment[] => {
+ const raw: Omit[] = [];
+
+ events.forEach((event) => {
+ let colStart = -1;
+ let colEnd = -1;
+
+ week.forEach((day, index) => {
+ if (!day) return;
+ const key = toLocalDateKey(day);
+ if (key >= event.startDate && key <= event.endDate) {
+ if (colStart === -1) colStart = index;
+ colEnd = index;
+ }
+ });
+
+ if (colStart === -1) return;
+
+ const startKey = toLocalDateKey(week[colStart]!);
+ const endKey = toLocalDateKey(week[colEnd]!);
+
+ raw.push({
+ event,
+ colStart,
+ colSpan: colEnd - colStart + 1,
+ continuesPrev: startKey > event.startDate,
+ continuesNext: endKey < event.endDate,
+ });
+ });
+
+ raw.sort((a, b) => a.colStart - b.colStart || b.colSpan - a.colSpan);
+
+ const lanes: { start: number; end: number }[][] = [];
+ const segments: WeekBarSegment[] = [];
+
+ raw.forEach((segment) => {
+ const segmentEnd = segment.colStart + segment.colSpan - 1;
+ let lane = 0;
+
+ while (true) {
+ if (!lanes[lane]) lanes[lane] = [];
+ const blocked = lanes[lane].some(
+ (occupied) => !(segmentEnd < occupied.start || segment.colStart > occupied.end),
+ );
+ if (!blocked) {
+ lanes[lane].push({ start: segment.colStart, end: segmentEnd });
+ segments.push({ ...segment, lane });
+ break;
+ }
+ lane += 1;
+ }
+ });
+
+ return segments;
+};
+
+export const getMaxBarLanes = (segments: WeekBarSegment[]): number =>
+ segments.reduce((max, segment) => Math.max(max, segment.lane + 1), 0);
diff --git a/app/_utils/kanban/index.tsx b/app/_utils/kanban/index.tsx
index 79e7244d..6e15b97e 100644
--- a/app/_utils/kanban/index.tsx
+++ b/app/_utils/kanban/index.tsx
@@ -2,7 +2,7 @@ import { Clock01Icon, TimeQuarterIcon } from "hugeicons-react";
import { TaskStatus } from "@/app/_types/enums";
import { Item, KanbanPriority } from "@/app/_types";
-import type { JSX } from "react";
+import type { CSSProperties, ReactElement } from "react";
export const formatTimerTime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
@@ -36,7 +36,7 @@ export const getStatusColor = (status?: string, customColor?: string): string =>
}
};
-export const getStatusIcon = (status?: string): JSX.Element | null => {
+export const getStatusIcon = (status?: string): ReactElement | null => {
switch (status) {
case TaskStatus.IN_PROGRESS:
return ;
@@ -64,6 +64,18 @@ const PRIORITY_CONFIG: Record
PRIORITY_CONFIG[priority || "none"].dotColor;
+export const getPriorityBarStyle = (
+ priority?: KanbanPriority,
+): CSSProperties => {
+ const { dotColor } = PRIORITY_CONFIG[priority || "none"];
+ if (dotColor === "transparent") return {};
+
+ return {
+ backgroundColor: `color-mix(in srgb, ${dotColor} 28%, transparent)`,
+ color: `color-mix(in srgb, ${dotColor} 60%, hsl(var(--foreground)))`,
+ };
+};
+
export const getPriorityLabel = (priority: KanbanPriority | undefined, t: (key: string) => string): string =>
t(PRIORITY_CONFIG[priority || "none"].translationKey);
diff --git a/app/api/checklists/[listId]/items/[itemIndex]/route.ts b/app/api/checklists/[listId]/items/[itemIndex]/route.ts
index 984a0b8f..00875cf6 100644
--- a/app/api/checklists/[listId]/items/[itemIndex]/route.ts
+++ b/app/api/checklists/[listId]/items/[itemIndex]/route.ts
@@ -1,13 +1,200 @@
import { NextRequest, NextResponse } from "next/server";
import { withApiAuth } from "@/app/_utils/api-utils";
import { getListById } from "@/app/_server/actions/checklist";
+import { updateItem } from "@/app/_server/actions/checklist-item";
import { listToMarkdown } from "@/app/_utils/checklist-utils";
import { serverWriteFile } from "@/app/_server/actions/file";
import path from "path";
import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists";
+import { KanbanPriorityLevel } from "@/app/_types/enums";
export const dynamic = "force-dynamic";
+const ALLOWED_PRIORITIES = Object.values(KanbanPriorityLevel) as string[];
+
+export async function PATCH(
+ request: NextRequest,
+ props: { params: Promise<{ listId: string; itemIndex: string }> },
+) {
+ const params = await props.params;
+ return withApiAuth(request, async (user) => {
+ try {
+ const body = await request.json();
+ const {
+ text,
+ description,
+ priority,
+ score,
+ startDate,
+ targetDate,
+ estimatedTime,
+ } = body;
+
+ const hasField =
+ text !== undefined ||
+ description !== undefined ||
+ priority !== undefined ||
+ score !== undefined ||
+ startDate !== undefined ||
+ targetDate !== undefined ||
+ estimatedTime !== undefined;
+
+ if (!hasField) {
+ return NextResponse.json(
+ { error: "Provide at least one field to update" },
+ { status: 400 },
+ );
+ }
+
+ if (text !== undefined && typeof text !== "string") {
+ return NextResponse.json(
+ { error: "'text' must be a string" },
+ { status: 400 },
+ );
+ }
+
+ if (
+ description !== undefined &&
+ description !== null &&
+ typeof description !== "string"
+ ) {
+ return NextResponse.json(
+ { error: "'description' must be a string" },
+ { status: 400 },
+ );
+ }
+
+ if (
+ priority !== undefined &&
+ priority !== null &&
+ !ALLOWED_PRIORITIES.includes(priority)
+ ) {
+ return NextResponse.json(
+ {
+ error: `'priority' must be one of: ${ALLOWED_PRIORITIES.join(", ")}`,
+ },
+ { status: 400 },
+ );
+ }
+
+ if (
+ score !== undefined &&
+ score !== null &&
+ (typeof score !== "number" || Number.isNaN(score))
+ ) {
+ return NextResponse.json(
+ { error: "'score' must be a number" },
+ { status: 400 },
+ );
+ }
+
+ if (
+ startDate !== undefined &&
+ startDate !== null &&
+ typeof startDate !== "string"
+ ) {
+ return NextResponse.json(
+ { error: "'startDate' must be a string" },
+ { status: 400 },
+ );
+ }
+
+ if (
+ targetDate !== undefined &&
+ targetDate !== null &&
+ typeof targetDate !== "string"
+ ) {
+ return NextResponse.json(
+ { error: "'targetDate' must be a string" },
+ { status: 400 },
+ );
+ }
+
+ if (
+ estimatedTime !== undefined &&
+ estimatedTime !== null &&
+ (typeof estimatedTime !== "number" || Number.isNaN(estimatedTime))
+ ) {
+ return NextResponse.json(
+ { error: "'estimatedTime' must be a number" },
+ { status: 400 },
+ );
+ }
+
+ const list = await getListById(params.listId, user.username);
+ if (!list) {
+ return NextResponse.json({ error: "List not found" }, { status: 404 });
+ }
+
+ const indexPath = params.itemIndex.split(".").map((i) => parseInt(i));
+
+ for (const idx of indexPath) {
+ if (isNaN(idx) || idx < 0) {
+ return NextResponse.json(
+ { error: "Invalid item index" },
+ { status: 400 },
+ );
+ }
+ }
+
+ let item: any = null;
+ let currentItems = list.items;
+
+ for (const idx of indexPath) {
+ if (idx >= currentItems.length) {
+ return NextResponse.json(
+ { error: "Item index out of range" },
+ { status: 400 },
+ );
+ }
+ item = currentItems[idx];
+ currentItems = item.children || [];
+ }
+
+ if (!item) {
+ return NextResponse.json({ error: "Item not found" }, { status: 404 });
+ }
+
+ const formData = new FormData();
+ formData.append("listId", list.id);
+ formData.append("itemId", item.id);
+ formData.append("category", list.category || "Uncategorized");
+ if (text !== undefined && text !== null) formData.append("text", text);
+ if (description !== undefined)
+ formData.append("description", description ?? "");
+ if (priority !== undefined) formData.append("priority", priority ?? "");
+ if (score !== undefined)
+ formData.append("score", score === null ? "" : String(score));
+ if (startDate !== undefined)
+ formData.append("startDate", startDate ?? "");
+ if (targetDate !== undefined)
+ formData.append("targetDate", targetDate ?? "");
+ if (estimatedTime !== undefined)
+ formData.append(
+ "estimatedTime",
+ estimatedTime === null ? "" : String(estimatedTime),
+ );
+
+ const result = await updateItem(list, formData, user.username, true);
+
+ if (!result.success) {
+ return NextResponse.json(
+ { error: result.error || "Failed to update item" },
+ { status: 500 },
+ );
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error("API Error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+ });
+}
+
export async function DELETE(
request: NextRequest,
props: { params: Promise<{ listId: string; itemIndex: string }> },
diff --git a/app/api/checklists/[listId]/items/reorder/route.ts b/app/api/checklists/[listId]/items/reorder/route.ts
new file mode 100644
index 00000000..e1731315
--- /dev/null
+++ b/app/api/checklists/[listId]/items/reorder/route.ts
@@ -0,0 +1,150 @@
+import { NextRequest, NextResponse } from "next/server";
+import { revalidatePath } from "next/cache";
+import { withApiAuth } from "@/app/_utils/api-utils";
+import { getListById } from "@/app/_server/actions/checklist";
+import { listToMarkdown } from "@/app/_utils/checklist-utils";
+import { serverWriteFile, ensureDir } from "@/app/_server/actions/file";
+import { checkUserPermission } from "@/app/_server/actions/sharing";
+import { broadcast } from "@/app/_server/ws/broadcast";
+import { ItemTypes, PermissionTypes } from "@/app/_types/enums";
+import path from "path";
+import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists";
+
+export const dynamic = "force-dynamic";
+
+type TreeItem = { id: string; order: number; children?: TreeItem[]; [key: string]: unknown };
+type Located = { item: TreeItem; siblings: TreeItem[]; index: number };
+
+const findInTree = (items: TreeItem[], id: string): Located | null => {
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].id === id) return { item: items[i], siblings: items, index: i };
+ if (items[i].children) {
+ const found = findInTree(items[i].children!, id);
+ if (found) return found;
+ }
+ }
+ return null;
+};
+
+const isDescendant = (ancestorId: string, targetId: string, items: TreeItem[]): boolean => {
+ const walk = (item: TreeItem): boolean => {
+ if (!item.children) return false;
+ return item.children.some((c) => c.id === targetId || walk(c));
+ };
+ const ancestor = findInTree(items, ancestorId);
+ return ancestor ? walk(ancestor.item) : false;
+};
+
+const stampOrder = (items: TreeItem[]): void => {
+ items.forEach((item, i) => {
+ item.order = i;
+ if (item.children) stampOrder(item.children);
+ });
+};
+
+const cloneTree = (items: TreeItem[]): TreeItem[] =>
+ items.map((item) => ({ ...item, children: item.children ? cloneTree(item.children) : undefined }));
+
+export async function PUT(
+ request: NextRequest,
+ props: { params: Promise<{ listId: string }> },
+) {
+ const params = await props.params;
+ return withApiAuth(request, async (user) => {
+ try {
+ const body = await request.json();
+ const { activeItemId, overItemId, position = "before", isDropInto = false } = body;
+
+ if (!activeItemId || !overItemId) {
+ return NextResponse.json(
+ { error: "'activeItemId' and 'overItemId' are required" },
+ { status: 400 },
+ );
+ }
+
+ if (position !== "before" && position !== "after") {
+ return NextResponse.json(
+ { error: "'position' must be 'before' or 'after'" },
+ { status: 400 },
+ );
+ }
+
+ const list = await getListById(params.listId, user.username);
+ if (!list) {
+ return NextResponse.json({ error: "List not found" }, { status: 404 });
+ }
+
+ const canEdit = await checkUserPermission(
+ list.uuid || params.listId,
+ list.category || "",
+ ItemTypes.CHECKLIST,
+ user.username,
+ PermissionTypes.EDIT,
+ );
+
+ if (!canEdit) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const items = cloneTree(list.items as unknown as TreeItem[]);
+
+ if (isDescendant(activeItemId, overItemId, items)) {
+ return NextResponse.json({ success: true });
+ }
+
+ const activeInfo = findInTree(items, activeItemId);
+ const overInfo = findInTree(items, overItemId);
+
+ if (!activeInfo || !overInfo) {
+ return NextResponse.json({ error: "Item not found" }, { status: 404 });
+ }
+
+ if (activeItemId === overItemId) {
+ return NextResponse.json({ success: true });
+ }
+
+ activeInfo.siblings.splice(activeInfo.index, 1);
+
+ if (isDropInto) {
+ if (!overInfo.item.children) overInfo.item.children = [];
+ overInfo.item.children.push(activeInfo.item);
+ } else {
+ let insertAt = overInfo.siblings.findIndex((i) => i.id === overItemId);
+ if (position === "after") insertAt += 1;
+ overInfo.siblings.splice(insertAt, 0, activeInfo.item);
+ }
+
+ stampOrder(items);
+
+ const updatedList = { ...list, items, updatedAt: new Date().toISOString() };
+
+ const ownerDir = path.join(process.cwd(), "data", CHECKLISTS_FOLDER, list.owner!);
+ const categoryDir = path.join(ownerDir, list.category || "Uncategorized");
+ await ensureDir(categoryDir);
+ await serverWriteFile(path.join(categoryDir, `${list.id}.md`), listToMarkdown(updatedList as any));
+
+ try {
+ revalidatePath("/");
+ revalidatePath(`/checklist/${list.id}`);
+ } catch (error) {
+ console.warn("Cache revalidation failed, but data was saved successfully:", error);
+ }
+
+ try {
+ await broadcast({
+ type: "checklist",
+ action: "updated",
+ entityId: list.id,
+ username: user.username,
+ });
+ } catch (error) {
+ console.warn("Broadcast failed, but data was saved successfully:", error);
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error("API Error:", error);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+ });
+}
diff --git a/app/api/checklists/route.ts b/app/api/checklists/route.ts
index b327e99d..00673097 100644
--- a/app/api/checklists/route.ts
+++ b/app/api/checklists/route.ts
@@ -1,8 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { withApiAuth } from "@/app/_utils/api-utils";
import { getUserChecklists, createList } from "@/app/_server/actions/checklist";
-import { ChecklistsTypes, isKanbanType, TaskStatus } from "@/app/_types/enums";
+import { ChecklistsTypes, isKanbanType } from "@/app/_types/enums";
import { Checklist, Result } from "@/app/_types";
+import { toApiItem } from "@/app/_utils/api-item";
export const dynamic = "force-dynamic";
@@ -24,7 +25,9 @@ export async function GET(request: NextRequest) {
);
}
- let userLists = lists.data.filter((list) => list.owner === user.username);
+ let userLists = lists.data.filter(
+ (list) => list.owner === user.username || list.isShared,
+ );
if (category) {
userLists = userLists.filter((list) => list.category === category);
@@ -43,43 +46,15 @@ export async function GET(request: NextRequest) {
);
}
- const transformItem = (
- item: any,
- index: number,
- listType: string,
- ): any => {
- const baseItem: any = {
- id: item.id,
- index,
- text: item.text,
- completed: item.completed,
- };
-
- if (isKanbanType(listType)) {
- baseItem.status = item.status || TaskStatus.TODO;
- baseItem.time =
- item.timeEntries && item.timeEntries.length > 0
- ? item.timeEntries
- : 0;
- }
-
- if (item.children && item.children.length > 0) {
- baseItem.children = item.children.map(
- (child: any, childIndex: number) =>
- transformItem(child, childIndex, listType),
- );
- }
-
- return baseItem;
- };
-
const checklists = userLists.map((list) => ({
id: list.uuid || list.id,
title: list.title,
category: list.category || "Uncategorized",
type: list.type || "simple",
+ owner: list.owner,
+ isShared: list.isShared ?? false,
items: list.items.map((item, index) =>
- transformItem(item, index, list.type),
+ toApiItem(item, index, isKanbanType(list.type)),
),
createdAt: list.createdAt,
updatedAt: list.updatedAt,
diff --git a/app/api/docs/route.ts b/app/api/docs/route.ts
index a2408439..5d26f13e 100644
--- a/app/api/docs/route.ts
+++ b/app/api/docs/route.ts
@@ -50,6 +50,7 @@ function mergePaths(spec: Record): Record {
const pathFiles = [
"./api/paths/health.yaml",
+ "./api/paths/search.yaml",
"./api/paths/checklists.yaml",
"./api/paths/notes.yaml",
"./api/paths/tasks.yaml",
diff --git a/app/api/search/route.ts b/app/api/search/route.ts
new file mode 100644
index 00000000..8b36caac
--- /dev/null
+++ b/app/api/search/route.ts
@@ -0,0 +1,80 @@
+import { NextRequest, NextResponse } from "next/server";
+import { withApiAuth } from "@/app/_utils/api-utils";
+import { search } from "@/app/_server/actions/search";
+import { grepSearchContent, grepExtractFrontmatter } from "@/app/_utils/grep-utils";
+import { NOTES_DIR } from "@/app/_consts/files";
+import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists";
+import path from "path";
+
+export const dynamic = "force-dynamic";
+
+const QUERY_MIN_LEN = 2;
+const RESULT_SLICE = 20;
+
+const cleanMatch = (line: string) =>
+ line
+ .replace(/^---$/, "")
+ .replace(/^- \[[x ]\]\s*/i, "")
+ .replace(/\s*\|.*$/, "")
+ .replace(/^#+\s*/, "")
+ .trim();
+
+export async function GET(request: NextRequest) {
+ return withApiAuth(request, async (user) => {
+ try {
+ const { searchParams } = new URL(request.url);
+ const query = searchParams.get("q") || "";
+ const typeFilter = searchParams.get("type");
+
+ if (query.trim().length < QUERY_MIN_LEN) {
+ return NextResponse.json(
+ { error: `Query must be at least ${QUERY_MIN_LEN} characters` },
+ { status: 400 },
+ );
+ }
+
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const notesDir = NOTES_DIR(user.username);
+ const checklistsDir = path.join(process.cwd(), "data", CHECKLISTS_FOLDER, user.username);
+
+ const [rawNotes, rawChecklists] = await Promise.all([
+ typeFilter === "checklist" ? Promise.resolve([]) : grepSearchContent(notesDir, escaped).catch(() => []),
+ typeFilter === "note" ? Promise.resolve([]) : grepSearchContent(checklistsDir, escaped).catch(() => []),
+ ]);
+
+ const toResult = async (
+ hits: { filePath: string; id: string; category: string; matchLine: string }[],
+ type: "note" | "checklist",
+ ) =>
+ Promise.all(
+ hits.slice(0, RESULT_SLICE).map(async (hit) => {
+ const meta = await grepExtractFrontmatter(hit.filePath);
+ const title = (meta?.title as string) || hit.id;
+ const cleaned = cleanMatch(hit.matchLine);
+ return {
+ id: hit.id,
+ uuid: meta?.uuid as string | undefined,
+ type,
+ title,
+ category: hit.category || "Uncategorized",
+ excerpt: cleaned && cleaned.toLowerCase() !== title.toLowerCase() ? cleaned : undefined,
+ };
+ }),
+ );
+
+ const [notes, checklists] = await Promise.all([
+ toResult(rawNotes, "note"),
+ toResult(rawChecklists, "checklist"),
+ ]);
+
+ return NextResponse.json({
+ query,
+ results: [...notes, ...checklists],
+ total: notes.length + checklists.length,
+ });
+ } catch (error) {
+ console.error("Search API error:", error);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+ });
+}
diff --git a/app/api/tasks/[taskId]/items/[itemIndex]/route.ts b/app/api/tasks/[taskId]/items/[itemIndex]/route.ts
index 342368a8..c9d8c823 100644
--- a/app/api/tasks/[taskId]/items/[itemIndex]/route.ts
+++ b/app/api/tasks/[taskId]/items/[itemIndex]/route.ts
@@ -6,9 +6,71 @@ import { serverWriteFile } from "@/app/_server/actions/file";
import path from "path";
import { isKanbanType } from "@/app/_types/enums";
import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists";
+import { toApiItem } from "@/app/_utils/api-item";
export const dynamic = "force-dynamic";
+export async function GET(
+ request: NextRequest,
+ props: { params: Promise<{ taskId: string; itemIndex: string }> },
+) {
+ const params = await props.params;
+ return withApiAuth(request, async (user) => {
+ try {
+ const task = await getListById(params.taskId, user.username);
+ if (!task) {
+ return NextResponse.json({ error: "Task not found" }, { status: 404 });
+ }
+
+ if (!isKanbanType(task.type)) {
+ return NextResponse.json(
+ { error: "Not a task checklist" },
+ { status: 400 },
+ );
+ }
+
+ const indexPath = params.itemIndex.split(".").map((i) => parseInt(i));
+
+ for (const idx of indexPath) {
+ if (isNaN(idx) || idx < 0) {
+ return NextResponse.json(
+ { error: "Invalid item index" },
+ { status: 400 },
+ );
+ }
+ }
+
+ let item: any = null;
+ let itemIndex = 0;
+ let currentItems = task.items;
+
+ for (const idx of indexPath) {
+ if (idx >= currentItems.length) {
+ return NextResponse.json(
+ { error: "Item index out of range" },
+ { status: 400 },
+ );
+ }
+ item = currentItems[idx];
+ itemIndex = idx;
+ currentItems = item.children || [];
+ }
+
+ if (!item) {
+ return NextResponse.json({ error: "Item not found" }, { status: 404 });
+ }
+
+ return NextResponse.json({ item: toApiItem(item, itemIndex, true) });
+ } catch (error) {
+ console.error("API Error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+ });
+}
+
export async function DELETE(
request: NextRequest,
props: { params: Promise<{ taskId: string; itemIndex: string }> },
diff --git a/app/api/tasks/[taskId]/route.ts b/app/api/tasks/[taskId]/route.ts
index b9591195..a724064e 100644
--- a/app/api/tasks/[taskId]/route.ts
+++ b/app/api/tasks/[taskId]/route.ts
@@ -5,7 +5,8 @@ import {
updateList,
deleteList,
} from "@/app/_server/actions/checklist";
-import { isKanbanType, TaskStatus } from "@/app/_types/enums";
+import { isKanbanType } from "@/app/_types/enums";
+import { toApiItem } from "@/app/_utils/api-item";
export const dynamic = "force-dynamic";
@@ -28,25 +29,6 @@ export async function GET(
);
}
- const transformItem = (item: any, index: number): any => {
- const baseItem: any = {
- id: item.id,
- index,
- text: item.text,
- status: item.status || TaskStatus.TODO,
- completed: item.completed,
- };
-
- if (item.children && item.children.length > 0) {
- baseItem.children = item.children.map(
- (child: any, childIndex: number) =>
- transformItem(child, childIndex),
- );
- }
-
- return baseItem;
- };
-
const transformedTask = {
id: task.uuid || task.id,
title: task.title,
@@ -56,7 +38,7 @@ export async function GET(
{ id: "in_progress", name: "In Progress", order: 1 },
{ id: "completed", name: "Completed", order: 2 },
],
- items: task.items.map((item, index) => transformItem(item, index)),
+ items: task.items.map((item, index) => toApiItem(item, index, true)),
createdAt: task.createdAt,
updatedAt: task.updatedAt,
};
diff --git a/app/api/tasks/route.ts b/app/api/tasks/route.ts
index 301eb0d5..5b2ff9c9 100644
--- a/app/api/tasks/route.ts
+++ b/app/api/tasks/route.ts
@@ -1,8 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { withApiAuth } from "@/app/_utils/api-utils";
import { getUserChecklists, createList } from "@/app/_server/actions/checklist";
-import { isKanbanType, TaskStatus } from "@/app/_types/enums";
+import { isKanbanType } from "@/app/_types/enums";
import { Checklist, Result } from "@/app/_types";
+import { toApiItem } from "@/app/_utils/api-item";
export const dynamic = "force-dynamic";
@@ -47,25 +48,6 @@ export async function GET(request: NextRequest) {
);
}
- const transformItem = (item: any, index: number): any => {
- const baseItem: any = {
- id: item.id,
- index,
- text: item.text,
- status: item.status || TaskStatus.TODO,
- completed: item.completed,
- };
-
- if (item.children && item.children.length > 0) {
- baseItem.children = item.children.map(
- (child: any, childIndex: number) =>
- transformItem(child, childIndex),
- );
- }
-
- return baseItem;
- };
-
const tasks = userTasks.map((list) => ({
id: list.uuid || list.id,
title: list.title,
@@ -75,7 +57,7 @@ export async function GET(request: NextRequest) {
{ id: "in_progress", name: "In Progress", order: 1 },
{ id: "completed", name: "Completed", order: 2 },
],
- items: list.items.map((item, index) => transformItem(item, index)),
+ items: list.items.map((item, index) => toApiItem(item, index, true)),
createdAt: list.createdAt,
updatedAt: list.updatedAt,
}));
diff --git a/howto/API.md b/howto/API.md
index a47ab456..40e89dbf 100644
--- a/howto/API.md
+++ b/howto/API.md
@@ -393,6 +393,24 @@ Items can contain a `children` array with nested sub-items:
"index": 0,
"text": "Parent Task",
"completed": false,
+ "status": "in_progress",
+ "description": "Implementation notes",
+ "priority": "high",
+ "score": 5,
+ "startDate": "2026-06-10",
+ "targetDate": "2026-06-15",
+ "estimatedTime": 2.5,
+ "createdBy": "alice",
+ "createdAt": "2026-06-01T09:00:00.000Z",
+ "lastModifiedBy": "alice",
+ "lastModifiedAt": "2026-06-02T10:30:00.000Z",
+ "history": [
+ {
+ "status": "in_progress",
+ "timestamp": "2026-06-01T09:00:00.000Z",
+ "user": "alice"
+ }
+ ],
"children": [
{
"id": "list-sub-456",
@@ -420,6 +438,44 @@ Items can contain a `children` array with nested sub-items:
}
```
+### Update Checklist Item
+
+**PATCH** `/api/checklists/{listId}/items/{itemIndex}`
+
+Updates one or more writable fields on a checklist item. Omitted fields are left unchanged. Supports nested items using index paths.
+
+**Request Body:**
+
+```json
+{
+ "text": "Updated task title",
+ "description": "Additional notes",
+ "priority": "high",
+ "score": 5,
+ "startDate": "2026-06-10",
+ "targetDate": "2026-06-15",
+ "estimatedTime": 2.5
+}
+```
+
+**Writable Fields:**
+
+- `text` (optional): Item title
+- `description` (optional): Item body or notes
+- `priority` (optional): `"critical"`, `"high"`, `"medium"`, `"low"`, or `"none"`
+- `score` (optional): Numeric score
+- `startDate` (optional): Start date as an ISO date string
+- `targetDate` (optional): Target date as an ISO date string
+- `estimatedTime` (optional): Estimated hours
+
+**Response:**
+
+```json
+{
+ "success": true
+}
+```
+
### 7. Check Item
**PUT** `/api/checklists/{listId}/items/{itemIndex}/check`
@@ -921,6 +977,45 @@ Index path examples:
}
```
+### Get Task Item
+
+**GET** `/api/tasks/{taskId}/items/{itemIndex}`
+
+Retrieves a single task item, including nested `children` and Kanban item fields. Supports nested index paths.
+
+**Response:**
+
+```json
+{
+ "item": {
+ "id": "task-item-1234567890",
+ "index": 0,
+ "text": "Implement user authentication",
+ "completed": false,
+ "status": "todo",
+ "time": 0,
+ "description": "Add login and token refresh",
+ "priority": "high",
+ "score": 5,
+ "startDate": "2026-06-10",
+ "targetDate": "2026-06-15",
+ "estimatedTime": 2.5,
+ "createdBy": "alice",
+ "createdAt": "2026-06-01T09:00:00.000Z",
+ "lastModifiedBy": "alice",
+ "lastModifiedAt": "2026-06-02T10:30:00.000Z",
+ "history": [
+ {
+ "status": "todo",
+ "timestamp": "2026-06-01T09:00:00.000Z",
+ "user": "alice"
+ }
+ ],
+ "children": []
+ }
+}
+```
+
### 23. Update Item Status
**PUT** `/api/tasks/{taskId}/items/{itemIndex}/status`
diff --git a/next.config.mjs b/next.config.mjs
index 5b039ebf..767bc697 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -3,18 +3,21 @@ import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./i18n.ts");
+const maxBodySize = "1gb";
+
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
serverExternalPackages: ["ws", "libsodium-wrappers-sumo"],
- serverActions: {
- bodySizeLimit: "100mb",
- },
experimental: {
+ // https://nextjs.org/docs/app/api-reference/config/next-config-js/serverActions
serverActions: {
- bodySizeLimit: "100mb",
+ bodySizeLimit: maxBodySize,
},
+ // https://nextjs.org/docs/app/api-reference/config/next-config-js/proxyClientMaxBodySize
+ proxyClientMaxBodySize: maxBodySize,
},
+ allowedDevOrigins: process.env.DEV_ORIGINS ? process.env.DEV_ORIGINS.split(',') : [],
images: {
unoptimized: true,
},
diff --git a/package.json b/package.json
index 423942cc..b81647b8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "jotty.page",
- "version": "1.24.0",
+ "version": "1.25.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -63,7 +63,7 @@
"ldapts": "8.1.7",
"libsodium-wrappers-sumo": "0.7.15",
"mermaid": "10.9.6",
- "next": "16.2.6",
+ "next": "16.2.9",
"next-intl": "4.9.2",
"next-themes": "0.2.1",
"openpgp": "6.3.0",
@@ -73,6 +73,7 @@
"react": "19.2.4",
"react-day-picker": "9.14.0",
"react-dom": "19.2.4",
+ "react-force-graph-2d": "^1.29.1",
"react-markdown": "10.1.0",
"react-masonry-css": "1.0.16",
"react-simple-code-editor": "0.14.1",
@@ -104,17 +105,20 @@
"lodash": "4.18.1",
"nanoid": "3.3.8",
"**/mermaid": "10.9.6",
+ "brace-expansion": "5.0.6",
+ "js-cookie": "3.0.7",
+ "postcss": "8.5.10",
+ "uuid": "11.1.1",
"@types/react": "19.2.13",
"@types/react-dom": "19.2.3",
"@isaacs/brace-expansion": "5.0.1",
- "brace-expansion": ">=5.0.6",
- "postcss": ">=8.5.10",
"markdown-it": "14.1.1",
"minimatch": "10.2.3",
"ajv": "6.14.0",
"rollup": "4.59.0",
"dompurify": "3.4.0",
"eslint-visitor-keys": "4.2.1",
+ "esbuild": "0.28.1",
"vite": "6.4.2"
},
"devDependencies": {
@@ -137,16 +141,15 @@
"@types/unist": "3.0.3",
"@types/uuid": "10.0.0",
"@types/ws": "8.18.1",
- "@vitest/coverage-v8": "4.0.17",
+ "@vitest/coverage-v8": "4.1.8",
"autoprefixer": "10.0.1",
- "esbuild": "0.27.3",
"eslint": "9",
- "eslint-config-next": "16.1.6",
- "postcss": "8",
+ "eslint-config-next": "16.2.9",
+ "postcss": "8.5.10",
"serwist": "9.5.4",
"tailwindcss": "3.3.0",
"tsx": "4.21.0",
"typescript": "5",
- "vitest": "4.0.17"
+ "vitest": "4.1.8"
}
}
diff --git a/patches/body-size-limit_20260427.js b/patches/body-size-limit_20260427.js
deleted file mode 100644
index f751d13e..00000000
--- a/patches/body-size-limit_20260427.js
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * Body Size Limit Patch (2026-04-27)
- *
- * Next.js 16 ignores `serverActions.bodySizeLimit` from next.config in standalone
- * builds, leaving the hard-coded 1MB cap baked into
- * node_modules/next/dist/compiled/next-server/app-page*.runtime.prod.js.
- * This patch rewrites that cap so server actions (file uploads, drawio attachments,
- * avatar uploads, etc.) accept payloads larger than 1MB.
- *
- * Configurable via the JOTTY_BODY_SIZE_LIMIT env var (default "100mb"). Accepts
- * the standard `` forms: b, kb, mb, gb (e.g. "50mb", "2gb").
- *
- * Idempotent: re-running with the same target is a no-op.
- *
- * See: https://github.com/fccview/jotty/issues/422
- */
-
-const fs = require("fs");
-const path = require("path");
-
-const _RUNTIME_FILES = [
- "app-page.runtime.prod.js",
- "app-page-experimental.runtime.prod.js",
- "app-page-turbo.runtime.prod.js",
- "app-page-turbo-experimental.runtime.prod.js",
- "server.runtime.prod.js",
-];
-
-const _UNIT_BYTES = {
- b: 1,
- kb: 1024,
- mb: 1024 * 1024,
- gb: 1024 * 1024 * 1024,
-};
-
-const _parseLimit = (input) => {
- const raw = String(input || "100mb").trim().toLowerCase();
- const match = raw.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/);
- if (!match) {
- throw new Error(`Invalid JOTTY_BODY_SIZE_LIMIT value: "${input}"`);
- }
- const amount = parseFloat(match[1]);
- const unit = match[2] || "mb";
- const bytes = Math.floor(amount * _UNIT_BYTES[unit]);
- return { label: `${amount}${unit}`, bytes };
-};
-
-const _patchFile = (filePath, label, bytes) => {
- if (!fs.existsSync(filePath)) return "missing";
- const original = fs.readFileSync(filePath, "utf8");
- let patched = original.replace(/"1 MB"/g, `"${label}"`);
- patched = patched.replace(
- /\.parse\(([a-zA-Z_$][\w$]*)\):1048576(?!\d)/g,
- `.parse($1):${bytes}`
- );
- patched = patched.replace(
- /(=[a-zA-Z_$][\w$]*\?\?)0xa00000(?!\d)/g,
- `$1${bytes}`
- );
- if (patched === original) return "noop";
- fs.writeFileSync(filePath, patched);
- return "patched";
-};
-
-const _collectRuntimeDirs = (projectRoot) => {
- const dirs = [
- path.join(projectRoot, "node_modules", "next", "dist", "compiled", "next-server"),
- path.join(
- projectRoot,
- ".next",
- "standalone",
- "node_modules",
- "next",
- "dist",
- "compiled",
- "next-server"
- ),
- ];
- return dirs.filter((d) => fs.existsSync(d));
-};
-
-module.exports = {
- name: "body-size-limit_20260427",
- apply: (ctx) => {
- const { label, bytes } = _parseLimit(process.env.JOTTY_BODY_SIZE_LIMIT);
- const dirs = _collectRuntimeDirs(ctx.projectRoot);
- const counts = { patched: 0, noop: 0, missing: 0 };
- for (const dir of dirs) {
- for (const fileName of _RUNTIME_FILES) {
- const status = _patchFile(path.join(dir, fileName), label, bytes);
- counts[status]++;
- }
- }
- return `limit=${label} (${bytes} bytes); patched=${counts.patched}, noop=${counts.noop}, missing=${counts.missing}`;
- },
-};
diff --git a/public/api/components/schemas.yaml b/public/api/components/schemas.yaml
index 8c6ba70a..7e274a0e 100644
--- a/public/api/components/schemas.yaml
+++ b/public/api/components/schemas.yaml
@@ -121,6 +121,64 @@ ChecklistItem:
type: string
enum: [todo, in_progress, paused, completed]
description: Only present for task-type checklists
+ description:
+ type: string
+ description: Item body or notes
+ example: "Details and acceptance criteria for this item"
+ priority:
+ type: string
+ enum: [critical, high, medium, low, none]
+ description: Kanban item priority
+ example: "high"
+ score:
+ type: number
+ description: Numeric score for ordering or estimation
+ example: 5
+ startDate:
+ type: string
+ format: date
+ description: Start date
+ example: "2026-06-10"
+ targetDate:
+ type: string
+ format: date
+ description: Due or target date
+ example: "2026-06-15"
+ estimatedTime:
+ type: number
+ description: Estimated time in hours
+ example: 2.5
+ createdBy:
+ type: string
+ description: Username of the user who created the item
+ example: "alice"
+ createdAt:
+ type: string
+ format: date-time
+ description: Item creation timestamp
+ lastModifiedBy:
+ type: string
+ description: Username of the last user to edit the item
+ example: "alice"
+ lastModifiedAt:
+ type: string
+ format: date-time
+ description: Last item edit timestamp
+ history:
+ type: array
+ description: Status change history
+ items:
+ type: object
+ properties:
+ status:
+ type: string
+ example: "in_progress"
+ timestamp:
+ type: string
+ format: date-time
+ user:
+ type: string
+ example: "alice"
time:
oneOf:
- type: integer
diff --git a/public/api/paths/checklists.yaml b/public/api/paths/checklists.yaml
index af966249..c3f0883a 100644
--- a/public/api/paths/checklists.yaml
+++ b/public/api/paths/checklists.yaml
@@ -1,7 +1,7 @@
/checklists:
get:
summary: Get All Checklists
- description: Retrieves all checklists for the authenticated user. Supports filtering and search.
+ description: Retrieves all checklists owned by or shared with the authenticated user. Supports filtering and search.
tags:
- Checklists
parameters:
@@ -55,6 +55,13 @@
createdAt:
type: string
format: date-time
+ owner:
+ type: string
+ example: "alice"
+ isShared:
+ type: boolean
+ description: True when this checklist was shared with the user by another user
+ example: false
updatedAt:
type: string
format: date-time
@@ -191,11 +198,79 @@
'400':
description: Invalid request
-/checklists/{listId}/items/{itemIndex}/check:
- put:
- summary: Mark Item as Completed
+/checklists/{listId}/items/{itemIndex}:
+ patch:
+ summary: Update Checklist Item
description: |
- Marks a checklist item as completed.
+ Updates one or more writable fields on a checklist item.
+
+ Supports nested items using index paths (e.g., "0" for first item, "0.1" for second child of first item).
+ tags:
+ - Checklists
+ parameters:
+ - name: listId
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - name: itemIndex
+ in: path
+ required: true
+ schema:
+ type: string
+ description: Index or index path (e.g., "0", "0.1", "2.0.1")
+ example: "0"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ text:
+ type: string
+ example: "Updated task text"
+ description:
+ type: string
+ example: "Additional notes about this item"
+ priority:
+ type: string
+ enum: [critical, high, medium, low, none]
+ example: "high"
+ score:
+ type: number
+ example: 5
+ startDate:
+ type: string
+ format: date
+ example: "2026-06-10"
+ targetDate:
+ type: string
+ format: date
+ example: "2026-06-15"
+ estimatedTime:
+ type: number
+ example: 2.5
+ responses:
+ '200':
+ description: Item updated successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ success:
+ type: boolean
+ example: true
+ '400':
+ description: Missing fields or invalid index
+ '404':
+ description: Checklist or item not found
+ delete:
+ summary: Delete Item
+ description: |
+ Deletes a checklist item.
Supports nested items using index paths (e.g., "0" for first item, "0.1" for second child of first item, "2.0.1" for nested grandchild).
tags:
@@ -213,10 +288,10 @@
schema:
type: string
description: Index or index path (e.g., "0", "0.1", "2.0.1")
- example: "0.1"
+ example: "1"
responses:
'200':
- description: Item marked as completed
+ description: Item deleted successfully
content:
application/json:
schema:
@@ -230,11 +305,70 @@
'400':
description: Invalid item index
-/checklists/{listId}/items/{itemIndex}/uncheck:
+/checklists/{listId}/items/reorder:
put:
- summary: Mark Item as Incomplete
+ summary: Reorder Checklist Items
description: |
- Marks a checklist item as incomplete.
+ Moves an item before or after another item, or nests it as a child.
+
+ Use `position: "before"` or `"after"` for sibling placement, or `isDropInto: true` to nest the active item inside the over item.
+ tags:
+ - Checklists
+ parameters:
+ - name: listId
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - activeItemId
+ - overItemId
+ properties:
+ activeItemId:
+ type: string
+ description: ID of the item being moved
+ example: "my-list-1716900000000"
+ overItemId:
+ type: string
+ description: ID of the reference item to move relative to
+ example: "my-list-1716900000001"
+ position:
+ type: string
+ enum: [before, after]
+ default: before
+ description: Whether to place the active item before or after the reference item
+ isDropInto:
+ type: boolean
+ default: false
+ description: When true, nests the active item as a child of the reference item
+ responses:
+ '200':
+ description: Items reordered successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ success:
+ type: boolean
+ example: true
+ '400':
+ description: Missing required fields or invalid position value
+ '404':
+ description: Checklist or item not found
+
+/checklists/{listId}/items/{itemIndex}/check:
+ put:
+ summary: Mark Item as Completed
+ description: |
+ Marks a checklist item as completed.
Supports nested items using index paths (e.g., "0" for first item, "0.1" for second child of first item, "2.0.1" for nested grandchild).
tags:
@@ -255,7 +389,7 @@
example: "0.1"
responses:
'200':
- description: Item marked as incomplete
+ description: Item marked as completed
content:
application/json:
schema:
@@ -269,11 +403,11 @@
'400':
description: Invalid item index
-/checklists/{listId}/items/{itemIndex}:
- delete:
- summary: Delete Item
+/checklists/{listId}/items/{itemIndex}/uncheck:
+ put:
+ summary: Mark Item as Incomplete
description: |
- Deletes a checklist item.
+ Marks a checklist item as incomplete.
Supports nested items using index paths (e.g., "0" for first item, "0.1" for second child of first item, "2.0.1" for nested grandchild).
tags:
@@ -291,10 +425,10 @@
schema:
type: string
description: Index or index path (e.g., "0", "0.1", "2.0.1")
- example: "1"
+ example: "0.1"
responses:
'200':
- description: Item deleted successfully
+ description: Item marked as incomplete
content:
application/json:
schema:
diff --git a/public/api/paths/search.yaml b/public/api/paths/search.yaml
new file mode 100644
index 00000000..4b23c086
--- /dev/null
+++ b/public/api/paths/search.yaml
@@ -0,0 +1,69 @@
+/search:
+ get:
+ summary: Search Notes and Checklists
+ description: |
+ Full-text search across all notes and checklists owned by the authenticated user.
+
+ Optionally filter results to a single content type. Results are ranked by relevance and capped at 20 per type.
+ tags:
+ - Search
+ parameters:
+ - name: q
+ in: query
+ required: true
+ schema:
+ type: string
+ minLength: 2
+ description: Search query (minimum 2 characters)
+ example: "meeting notes"
+ - name: type
+ in: query
+ schema:
+ type: string
+ enum: [note, checklist]
+ description: Restrict results to a single content type
+ example: "note"
+ responses:
+ '200':
+ description: Search results
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ query:
+ type: string
+ example: "meeting notes"
+ total:
+ type: integer
+ example: 4
+ results:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ example: "my-note-id"
+ uuid:
+ type: string
+ format: uuid
+ example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
+ type:
+ type: string
+ enum: [note, checklist]
+ example: "note"
+ title:
+ type: string
+ example: "Weekly Meeting Notes"
+ category:
+ type: string
+ example: "Work"
+ excerpt:
+ type: string
+ description: Matching line from the content, omitted when it matches the title
+ example: "discussed the new API roadmap"
+ '400':
+ description: Query too short (must be at least 2 characters)
+ '401':
+ description: Unauthorized
diff --git a/public/api/paths/tasks.yaml b/public/api/paths/tasks.yaml
index 84add838..e6735066 100644
--- a/public/api/paths/tasks.yaml
+++ b/public/api/paths/tasks.yaml
@@ -541,6 +541,40 @@
description: Task or item not found
/tasks/{taskId}/items/{itemIndex}:
+ get:
+ summary: Get Task Item
+ description: Retrieves a single item from a task checklist, including nested children and Kanban item fields.
+ tags:
+ - Tasks
+ parameters:
+ - name: taskId
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ description: UUID of the task
+ - name: itemIndex
+ in: path
+ required: true
+ schema:
+ type: string
+ description: Index path of the item to fetch (e.g., "0", "0.1", "2.0.1")
+ responses:
+ '200':
+ description: Item details
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ item:
+ $ref: '#/components/schemas/ChecklistItem'
+ '400':
+ description: Invalid item index or not a task checklist
+ '404':
+ description: Task or item not found
+
delete:
summary: Delete Task Item
description: Deletes an item from a task checklist
diff --git a/tests/api/items.test.ts b/tests/api/items.test.ts
index b4acb8c4..2258a859 100644
--- a/tests/api/items.test.ts
+++ b/tests/api/items.test.ts
@@ -14,7 +14,7 @@ import {
import { POST } from "@/app/api/checklists/[listId]/items/route"
import { PUT as CHECK } from "@/app/api/checklists/[listId]/items/[itemIndex]/check/route"
import { PUT as UNCHECK } from "@/app/api/checklists/[listId]/items/[itemIndex]/uncheck/route"
-import { DELETE } from "@/app/api/checklists/[listId]/items/[itemIndex]/route"
+import { DELETE, PATCH } from "@/app/api/checklists/[listId]/items/[itemIndex]/route"
describe("Checklist Items API", () => {
const mockList = {
@@ -288,6 +288,59 @@ describe("Checklist Items API", () => {
})
})
+ describe("PATCH /api/checklists/:id/items/:index", () => {
+ it("should update writable kanban item fields", async () => {
+ mockGetListById.mockResolvedValue({ ...mockList, type: "kanban" })
+ mockUpdateItem.mockResolvedValue({ success: true })
+
+ const request = createMockRequest("PATCH", "http://localhost:3000/api/checklists/uuid-1/items/0", {
+ description: "Implementation notes",
+ priority: "high",
+ score: 5,
+ startDate: "2026-06-10",
+ targetDate: "2026-06-15",
+ estimatedTime: 2.5,
+ })
+ const response = await PATCH(request, { params: Promise.resolve({ listId: "uuid-1", itemIndex: "0" }) })
+ const data = await getResponseJson(response)
+
+ expect(response.status).toBe(200)
+ expect(data.success).toBe(true)
+ expect(mockUpdateItem).toHaveBeenCalledOnce()
+
+ const formData = mockUpdateItem.mock.calls[0][1] as FormData
+ expect(formData.get("itemId")).toBe("item-1")
+ expect(formData.get("description")).toBe("Implementation notes")
+ expect(formData.get("priority")).toBe("high")
+ expect(formData.get("score")).toBe("5")
+ expect(formData.get("startDate")).toBe("2026-06-10")
+ expect(formData.get("targetDate")).toBe("2026-06-15")
+ expect(formData.get("estimatedTime")).toBe("2.5")
+ })
+
+ it("should return 400 when no patch fields are provided", async () => {
+ const request = createMockRequest("PATCH", "http://localhost:3000/api/checklists/uuid-1/items/0", {})
+ const response = await PATCH(request, { params: Promise.resolve({ listId: "uuid-1", itemIndex: "0" }) })
+ const data = await getResponseJson(response)
+
+ expect(response.status).toBe(400)
+ expect(data.error).toBe("Provide at least one field to update")
+ expect(mockUpdateItem).not.toHaveBeenCalled()
+ })
+
+ it("should return 400 for invalid priority", async () => {
+ const request = createMockRequest("PATCH", "http://localhost:3000/api/checklists/uuid-1/items/0", {
+ priority: "urgent",
+ })
+ const response = await PATCH(request, { params: Promise.resolve({ listId: "uuid-1", itemIndex: "0" }) })
+ const data = await getResponseJson(response)
+
+ expect(response.status).toBe(400)
+ expect(data.error).toContain("'priority' must be one of")
+ expect(mockUpdateItem).not.toHaveBeenCalled()
+ })
+ })
+
describe("DELETE /api/checklists/:id/items/:index", () => {
it("should delete an item", async () => {
mockGetListById.mockResolvedValue(mockList)
diff --git a/tests/api/tasks.test.ts b/tests/api/tasks.test.ts
index 8a8fc3a8..4dca3b34 100644
--- a/tests/api/tasks.test.ts
+++ b/tests/api/tasks.test.ts
@@ -20,7 +20,7 @@ import { GET as GET_TASK, PUT as PUT_TASK, DELETE as DELETE_TASK } from "@/app/a
import { GET as GET_STATUSES, POST as POST_STATUS } from "@/app/api/tasks/[taskId]/statuses/route"
import { PUT as PUT_STATUS, DELETE as DELETE_STATUS } from "@/app/api/tasks/[taskId]/statuses/[statusId]/route"
import { POST as POST_TASK_ITEM } from "@/app/api/tasks/[taskId]/items/route"
-import { DELETE as DELETE_TASK_ITEM } from "@/app/api/tasks/[taskId]/items/[itemIndex]/route"
+import { DELETE as DELETE_TASK_ITEM, GET as GET_TASK_ITEM } from "@/app/api/tasks/[taskId]/items/[itemIndex]/route"
import { PUT as PUT_ITEM_STATUS } from "@/app/api/tasks/[taskId]/items/[itemIndex]/status/route"
describe("Tasks API", () => {
@@ -42,12 +42,31 @@ describe("Tasks API", () => {
text: "Task Item 1",
status: "todo",
completed: false,
+ description: "Task notes",
+ priority: "high",
+ score: 8,
+ startDate: "2026-06-10",
+ targetDate: "2026-06-15",
+ estimatedTime: 3.5,
+ createdBy: "testuser",
+ createdAt: "2024-01-01T00:00:00.000Z",
+ lastModifiedBy: "testuser",
+ lastModifiedAt: "2024-01-02T00:00:00.000Z",
+ history: [
+ {
+ status: "todo",
+ timestamp: "2024-01-01T00:00:00.000Z",
+ user: "testuser",
+ },
+ ],
children: [
{
id: "item-1-1",
text: "Sub Task",
status: "todo",
completed: false,
+ description: "Sub-task notes",
+ priority: "medium",
},
],
},
@@ -586,6 +605,78 @@ describe("Tasks API", () => {
})
})
+ describe("GET /api/tasks/:taskId/items/:itemIndex", () => {
+ it("should return a transformed task item with rich fields", async () => {
+ mockGetListById.mockResolvedValue(mockTask)
+
+ const request = createMockRequest("GET", "http://localhost:3000/api/tasks/task-uuid-1/items/0")
+ const response = await GET_TASK_ITEM(request, { params: Promise.resolve({ taskId: "task-uuid-1", itemIndex: "0" }) })
+ const data = await getResponseJson(response)
+
+ expect(response.status).toBe(200)
+ expect(data.item).toMatchObject({
+ id: "item-1",
+ index: 0,
+ text: "Task Item 1",
+ completed: false,
+ status: "todo",
+ time: 0,
+ description: "Task notes",
+ priority: "high",
+ score: 8,
+ startDate: "2026-06-10",
+ targetDate: "2026-06-15",
+ estimatedTime: 3.5,
+ createdBy: "testuser",
+ createdAt: "2024-01-01T00:00:00.000Z",
+ lastModifiedBy: "testuser",
+ lastModifiedAt: "2024-01-02T00:00:00.000Z",
+ history: [
+ {
+ status: "todo",
+ timestamp: "2024-01-01T00:00:00.000Z",
+ user: "testuser",
+ },
+ ],
+ })
+ expect(data.item.children[0]).toMatchObject({
+ id: "item-1-1",
+ index: 0,
+ description: "Sub-task notes",
+ priority: "medium",
+ })
+ })
+
+ it("should return a transformed nested task item", async () => {
+ mockGetListById.mockResolvedValue(mockTask)
+
+ const request = createMockRequest("GET", "http://localhost:3000/api/tasks/task-uuid-1/items/0.0")
+ const response = await GET_TASK_ITEM(request, { params: Promise.resolve({ taskId: "task-uuid-1", itemIndex: "0.0" }) })
+ const data = await getResponseJson(response)
+
+ expect(response.status).toBe(200)
+ expect(data.item).toMatchObject({
+ id: "item-1-1",
+ index: 0,
+ text: "Sub Task",
+ status: "todo",
+ time: 0,
+ description: "Sub-task notes",
+ })
+ })
+
+ it("should return 400 for non-task checklist item reads", async () => {
+ mockGetListById.mockResolvedValue({ ...mockTask, type: "simple" })
+
+ const request = createMockRequest("GET", "http://localhost:3000/api/tasks/task-uuid-1/items/0")
+ const response = await GET_TASK_ITEM(request, { params: Promise.resolve({ taskId: "task-uuid-1", itemIndex: "0" }) })
+ const data = await getResponseJson(response)
+
+ expect(response.status).toBe(400)
+ expect(data.error).toBe("Not a task checklist")
+ })
+ })
+
describe("DELETE /api/tasks/:taskId/items/:itemIndex", () => {
it("should delete a task item", async () => {
mockGetListById.mockResolvedValue(mockTask)
diff --git a/tests/server-actions/auth.test.ts b/tests/server-actions/auth.test.ts
index ce9a9ff4..4bb3d785 100644
--- a/tests/server-actions/auth.test.ts
+++ b/tests/server-actions/auth.test.ts
@@ -346,7 +346,8 @@ describe('Auth Actions', () => {
expect(mockCreateSession).toHaveBeenCalledWith(
expect.any(String),
'alice',
- 'ldap'
+ 'ldap',
+ expect.any(Boolean)
)
})
diff --git a/tests/server-actions/drop-item.test.ts b/tests/server-actions/drop-item.test.ts
new file mode 100644
index 00000000..5b1e7f2d
--- /dev/null
+++ b/tests/server-actions/drop-item.test.ts
@@ -0,0 +1,227 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { resetAllMocks, createFormData } from "../setup";
+
+const mockEnsureDir = vi.fn();
+const mockServerWriteFile = vi.fn();
+const mockGetUsername = vi.fn();
+const mockCheckUserPermission = vi.fn();
+const mockGetListById = vi.fn();
+const mockBroadcast = vi.fn();
+
+vi.mock("@/app/_server/actions/file", () => ({
+ ensureDir: (...args: unknown[]) => mockEnsureDir(...args),
+ serverWriteFile: (...args: unknown[]) => mockServerWriteFile(...args),
+}));
+
+vi.mock("@/app/_server/actions/users", () => ({
+ getUsername: (...args: unknown[]) => mockGetUsername(...args),
+}));
+
+vi.mock("@/app/_server/actions/sharing", () => ({
+ checkUserPermission: (...args: unknown[]) => mockCheckUserPermission(...args),
+}));
+
+vi.mock("@/app/_server/actions/checklist", () => ({
+ getListById: (...args: unknown[]) => mockGetListById(...args),
+}));
+
+vi.mock("@/app/_server/ws/broadcast", () => ({
+ broadcast: (...args: unknown[]) => mockBroadcast(...args),
+}));
+
+vi.mock("@/app/_utils/checklist-utils", () => ({
+ listToMarkdown: vi.fn().mockReturnValue("# Test Board"),
+}));
+
+import { dropItem } from "@/app/_server/actions/checklist-item";
+
+const BOARD_UUID = "12345678-1234-1234-1234-123456789abc";
+
+const mockBoard = {
+ id: "test-board",
+ uuid: BOARD_UUID,
+ title: "Test Board",
+ category: "TestCategory",
+ owner: "testuser",
+ type: "kanban" as const,
+ statuses: [
+ { id: "todo", label: "To Do", order: 0, autoComplete: false },
+ { id: "in_progress", label: "In Progress", order: 1, autoComplete: false },
+ { id: "completed", label: "Completed", order: 2, autoComplete: true },
+ ],
+ items: [
+ { id: "task-1", text: "Task 1", completed: false, order: 0, status: "todo" },
+ { id: "task-2", text: "Task 2", completed: false, order: 1, status: "todo" },
+ {
+ id: "task-3",
+ text: "Task 3",
+ completed: false,
+ order: 2,
+ status: "in_progress",
+ },
+ ],
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-01T00:00:00.000Z",
+};
+
+describe("dropItem", () => {
+ beforeEach(() => {
+ resetAllMocks();
+ mockEnsureDir.mockResolvedValue(undefined);
+ mockServerWriteFile.mockResolvedValue(undefined);
+ mockGetUsername.mockResolvedValue("testuser");
+ mockCheckUserPermission.mockResolvedValue(true);
+ mockBroadcast.mockResolvedValue(undefined);
+ mockGetListById.mockImplementation(async (id: string) =>
+ id === BOARD_UUID ? structuredClone(mockBoard) : undefined,
+ );
+ });
+
+ it("moves an item cross-column to the exact index", async () => {
+ const result = await dropItem(
+ createFormData({
+ uuid: BOARD_UUID,
+ itemId: "task-1",
+ targetStatus: "in_progress",
+ targetIndex: "0",
+ }),
+ );
+
+ expect(result.success).toBe(true);
+ const inProgress = result.data!.items.filter(
+ (i) => i.status === "in_progress",
+ );
+ expect(inProgress.map((i) => i.id)).toEqual(["task-1", "task-3"]);
+ expect(result.data!.items.map((i) => i.order)).toEqual([0, 1, 2]);
+ expect(mockServerWriteFile).toHaveBeenCalledTimes(1);
+ expect(mockBroadcast).toHaveBeenCalledTimes(1);
+ expect(mockBroadcast).toHaveBeenCalledWith({
+ type: "checklist",
+ action: "updated",
+ entityId: "test-board",
+ username: "testuser",
+ });
+ });
+
+ it("reorders within a column", async () => {
+ const result = await dropItem(
+ createFormData({
+ uuid: BOARD_UUID,
+ itemId: "task-1",
+ targetStatus: "todo",
+ targetIndex: "2",
+ }),
+ );
+
+ expect(result.success).toBe(true);
+ const todo = result.data!.items.filter((i) => i.status === "todo");
+ expect(todo.map((i) => i.id)).toEqual(["task-2", "task-1"]);
+ });
+
+ it("marks the item completed when dropped on an autoComplete column", async () => {
+ const result = await dropItem(
+ createFormData({
+ uuid: BOARD_UUID,
+ itemId: "task-1",
+ targetStatus: "completed",
+ targetIndex: "0",
+ }),
+ );
+
+ expect(result.success).toBe(true);
+ const moved = result.data!.items.find((i) => i.id === "task-1");
+ expect(moved?.completed).toBe(true);
+ expect(moved?.status).toBe("completed");
+ });
+
+ it("clamps an out-of-range index to the column end", async () => {
+ const result = await dropItem(
+ createFormData({
+ uuid: BOARD_UUID,
+ itemId: "task-1",
+ targetStatus: "in_progress",
+ targetIndex: "99",
+ }),
+ );
+
+ expect(result.success).toBe(true);
+ const inProgress = result.data!.items.filter(
+ (i) => i.status === "in_progress",
+ );
+ expect(inProgress.map((i) => i.id)).toEqual(["task-3", "task-1"]);
+ });
+
+ it("rejects when required fields are missing", async () => {
+ const result = await dropItem(
+ createFormData({ uuid: BOARD_UUID, itemId: "task-1" }),
+ );
+
+ expect(result.success).toBe(false);
+ expect(mockServerWriteFile).not.toHaveBeenCalled();
+ expect(mockBroadcast).not.toHaveBeenCalled();
+ });
+
+ it("rejects an unknown list uuid", async () => {
+ const result = await dropItem(
+ createFormData({
+ uuid: "00000000-0000-0000-0000-000000000000",
+ itemId: "task-1",
+ targetStatus: "todo",
+ targetIndex: "0",
+ }),
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe("List not found");
+ });
+
+ it("rejects an unknown item", async () => {
+ const result = await dropItem(
+ createFormData({
+ uuid: BOARD_UUID,
+ itemId: "nope",
+ targetStatus: "todo",
+ targetIndex: "0",
+ }),
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe("Item not found");
+ expect(mockServerWriteFile).not.toHaveBeenCalled();
+ });
+
+ it("rejects without edit permission", async () => {
+ mockCheckUserPermission.mockResolvedValue(false);
+
+ const result = await dropItem(
+ createFormData({
+ uuid: BOARD_UUID,
+ itemId: "task-1",
+ targetStatus: "todo",
+ targetIndex: "0",
+ }),
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe("Permission denied");
+ expect(mockServerWriteFile).not.toHaveBeenCalled();
+ expect(mockBroadcast).not.toHaveBeenCalled();
+ });
+
+ it("returns a failure result when the write blows up", async () => {
+ mockServerWriteFile.mockRejectedValue(new Error("disk full"));
+
+ const result = await dropItem(
+ createFormData({
+ uuid: BOARD_UUID,
+ itemId: "task-1",
+ targetStatus: "todo",
+ targetIndex: "0",
+ }),
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe("Failed to drop item");
+ expect(mockBroadcast).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/utils/board-utils.test.ts b/tests/utils/board-utils.test.ts
new file mode 100644
index 00000000..d5a93344
--- /dev/null
+++ b/tests/utils/board-utils.test.ts
@@ -0,0 +1,225 @@
+import { describe, it, expect } from "vitest";
+import {
+ getColumnItems,
+ visToColIndex,
+ applyDrop,
+} from "@/app/_utils/kanban/board-utils";
+import { DEFAULT_KANBAN_STATUSES } from "@/app/_consts/kanban";
+import { TaskStatus } from "@/app/_types/enums";
+import { Checklist, Item } from "@/app/_types";
+
+const makeItem = (id: string, status?: string, extra?: Partial
- ): Item => ({
+ id,
+ text: id,
+ completed: false,
+ order: 0,
+ status,
+ ...extra,
+});
+
+const makeBoard = (items: Item[]): Checklist => ({
+ id: "board",
+ uuid: "11111111-2222-3333-4444-555555555555",
+ title: "Board",
+ type: "kanban",
+ items,
+ statuses: DEFAULT_KANBAN_STATUSES,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+});
+
+const NOW = "2026-06-12T12:00:00.000Z";
+const USER = "frodo";
+
+describe("getColumnItems", () => {
+ it("returns items of the requested status in array order", () => {
+ const items = [
+ makeItem("a", TaskStatus.TODO),
+ makeItem("b", TaskStatus.IN_PROGRESS),
+ makeItem("c", TaskStatus.TODO),
+ ];
+ const column = getColumnItems(items, TaskStatus.TODO, DEFAULT_KANBAN_STATUSES);
+ expect(column.map((i) => i.id)).toEqual(["a", "c"]);
+ });
+
+ it("excludes archived items", () => {
+ const items = [
+ makeItem("a", TaskStatus.TODO),
+ makeItem("b", TaskStatus.TODO, { isArchived: true }),
+ ];
+ const column = getColumnItems(items, TaskStatus.TODO, DEFAULT_KANBAN_STATUSES);
+ expect(column.map((i) => i.id)).toEqual(["a"]);
+ });
+
+ it("buckets invalid-status items into the first column only", () => {
+ const items = [
+ makeItem("a", "ghost-status"),
+ makeItem("b", undefined),
+ makeItem("c", TaskStatus.IN_PROGRESS),
+ ];
+ const first = getColumnItems(items, TaskStatus.TODO, DEFAULT_KANBAN_STATUSES);
+ const second = getColumnItems(
+ items,
+ TaskStatus.IN_PROGRESS,
+ DEFAULT_KANBAN_STATUSES,
+ );
+ expect(first.map((i) => i.id)).toEqual(["a", "b"]);
+ expect(second.map((i) => i.id)).toEqual(["c"]);
+ });
+});
+
+describe("visToColIndex", () => {
+ const column = [
+ makeItem("a", TaskStatus.TODO),
+ makeItem("b", TaskStatus.TODO),
+ makeItem("c", TaskStatus.TODO),
+ makeItem("d", TaskStatus.TODO),
+ ];
+
+ it("is the identity when no filter is active", () => {
+ expect(visToColIndex(column, column, 2)).toBe(2);
+ expect(visToColIndex(column, column, 4)).toBe(4);
+ });
+
+ it("maps a drop before a visible anchor to its column position", () => {
+ const visible = [column[1], column[3]];
+ expect(visToColIndex(visible, column, 0)).toBe(1);
+ expect(visToColIndex(visible, column, 1)).toBe(3);
+ });
+
+ it("maps a drop at the end of the visible list after the last visible item", () => {
+ const visible = [column[0], column[2]];
+ expect(visToColIndex(visible, column, 2)).toBe(3);
+ });
+
+ it("drops at the column end when nothing is visible", () => {
+ expect(visToColIndex([], column, 0)).toBe(4);
+ });
+});
+
+describe("applyDrop", () => {
+ it("reorders within a column without touching metadata", () => {
+ const board = makeBoard([
+ makeItem("a", TaskStatus.TODO),
+ makeItem("b", TaskStatus.TODO),
+ makeItem("c", TaskStatus.TODO),
+ ]);
+ const result = applyDrop(board, "a", TaskStatus.TODO, 2, USER, NOW);
+ const column = getColumnItems(
+ result.items,
+ TaskStatus.TODO,
+ DEFAULT_KANBAN_STATUSES,
+ );
+ expect(column.map((i) => i.id)).toEqual(["b", "c", "a"]);
+ expect(result.items.find((i) => i.id === "a")?.history).toBeUndefined();
+ expect(result.items.find((i) => i.id === "a")?.lastModifiedBy).toBeUndefined();
+ });
+
+ it("moves cross-column with status, history, and exact position", () => {
+ const board = makeBoard([
+ makeItem("a", TaskStatus.TODO),
+ makeItem("b", TaskStatus.IN_PROGRESS),
+ makeItem("c", TaskStatus.IN_PROGRESS),
+ ]);
+ const result = applyDrop(board, "a", TaskStatus.IN_PROGRESS, 1, USER, NOW);
+ const column = getColumnItems(
+ result.items,
+ TaskStatus.IN_PROGRESS,
+ DEFAULT_KANBAN_STATUSES,
+ );
+ const moved = result.items.find((i) => i.id === "a");
+ expect(column.map((i) => i.id)).toEqual(["b", "a", "c"]);
+ expect(moved?.status).toBe(TaskStatus.IN_PROGRESS);
+ expect(moved?.history).toEqual([
+ { status: TaskStatus.IN_PROGRESS, timestamp: NOW, user: USER },
+ ]);
+ expect(moved?.lastModifiedBy).toBe(USER);
+ });
+
+ it("marks the item completed when dropped on an autoComplete column", () => {
+ const board = makeBoard([
+ makeItem("a", TaskStatus.TODO, {
+ children: [makeItem("a1", TaskStatus.TODO)],
+ }),
+ makeItem("b", TaskStatus.COMPLETED),
+ ]);
+ const result = applyDrop(board, "a", TaskStatus.COMPLETED, 0, USER, NOW);
+ const moved = result.items.find((i) => i.id === "a");
+ expect(moved?.completed).toBe(true);
+ expect(moved?.children?.[0].completed).toBe(true);
+ });
+
+ it("clears completed when leaving an autoComplete column", () => {
+ const board = makeBoard([
+ makeItem("a", TaskStatus.COMPLETED, { completed: true }),
+ makeItem("b", TaskStatus.TODO),
+ ]);
+ const result = applyDrop(board, "a", TaskStatus.TODO, 0, USER, NOW);
+ const moved = result.items.find((i) => i.id === "a");
+ expect(moved?.completed).toBe(false);
+ expect(moved?.status).toBe(TaskStatus.TODO);
+ });
+
+ it("preserves the relative order of other columns", () => {
+ const board = makeBoard([
+ makeItem("a", TaskStatus.TODO),
+ makeItem("b", TaskStatus.PAUSED),
+ makeItem("c", TaskStatus.TODO),
+ makeItem("d", TaskStatus.PAUSED),
+ ]);
+ const result = applyDrop(board, "a", TaskStatus.IN_PROGRESS, 0, USER, NOW);
+ const paused = getColumnItems(
+ result.items,
+ TaskStatus.PAUSED,
+ DEFAULT_KANBAN_STATUSES,
+ );
+ const todo = getColumnItems(
+ result.items,
+ TaskStatus.TODO,
+ DEFAULT_KANBAN_STATUSES,
+ );
+ expect(paused.map((i) => i.id)).toEqual(["b", "d"]);
+ expect(todo.map((i) => i.id)).toEqual(["c"]);
+ });
+
+ it("clamps an out-of-range index to the column end", () => {
+ const board = makeBoard([
+ makeItem("a", TaskStatus.TODO),
+ makeItem("b", TaskStatus.IN_PROGRESS),
+ ]);
+ const result = applyDrop(board, "a", TaskStatus.IN_PROGRESS, 99, USER, NOW);
+ const column = getColumnItems(
+ result.items,
+ TaskStatus.IN_PROGRESS,
+ DEFAULT_KANBAN_STATUSES,
+ );
+ expect(column.map((i) => i.id)).toEqual(["b", "a"]);
+ });
+
+ it("drops into an empty column", () => {
+ const board = makeBoard([makeItem("a", TaskStatus.TODO)]);
+ const result = applyDrop(board, "a", TaskStatus.PAUSED, 0, USER, NOW);
+ expect(result.items.find((i) => i.id === "a")?.status).toBe(
+ TaskStatus.PAUSED,
+ );
+ });
+
+ it("renumbers order fields recursively", () => {
+ const board = makeBoard([
+ makeItem("a", TaskStatus.TODO, {
+ order: 7,
+ children: [makeItem("a1", TaskStatus.TODO, { order: 9 })],
+ }),
+ makeItem("b", TaskStatus.TODO, { order: 3 }),
+ ]);
+ const result = applyDrop(board, "a", TaskStatus.TODO, 1, USER, NOW);
+ expect(result.items.map((i) => i.order)).toEqual([0, 1]);
+ const moved = result.items.find((i) => i.id === "a");
+ expect(moved?.children?.[0].order).toBe(0);
+ });
+
+ it("returns the checklist untouched for an unknown item", () => {
+ const board = makeBoard([makeItem("a", TaskStatus.TODO)]);
+ expect(applyDrop(board, "nope", TaskStatus.TODO, 0, USER, NOW)).toBe(board);
+ });
+});
diff --git a/tests/utils/client-parser-utils.test.ts b/tests/utils/client-parser-utils.test.ts
new file mode 100644
index 00000000..5d4e4b3c
--- /dev/null
+++ b/tests/utils/client-parser-utils.test.ts
@@ -0,0 +1,62 @@
+import { describe, it, expect } from "vitest";
+import { parseChecklistContent } from "@/app/_utils/client-parser-utils";
+import { Item } from "@/app/_types";
+
+const collectIds = (items: Item[], acc: string[] = []): string[] => {
+ items.forEach((item) => {
+ acc.push(item.id);
+ if (item.children && item.children.length > 0) {
+ collectIds(item.children, acc);
+ }
+ });
+ return acc;
+};
+
+describe("parseChecklistContent — unique item IDs", () => {
+ it("assigns unique IDs to every item across nested groups (regression for #501)", () => {
+ const lines: string[] = [];
+ for (let g = 1; g <= 4; g++) {
+ lines.push(`- [ ] Group ${g}`);
+ for (let i = 1; i <= 5; i++) {
+ lines.push(` - [ ] Item ${g}.${i}`);
+ }
+ }
+ const content = lines.join("\n");
+
+ const { items } = parseChecklistContent(content, "my-list");
+ const ids = collectIds(items);
+
+ expect(ids).toHaveLength(4 + 4 * 5);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("preserves IDs stored in inline item metadata", () => {
+ const content = [
+ "- [ ] Item with stored id | metadata:{\"id\":\"stored-id-1\"}",
+ "- [ ] Item without stored id",
+ ].join("\n");
+
+ const { items } = parseChecklistContent(content, "my-list");
+
+ expect(items[0].id).toBe("stored-id-1");
+ expect(items[1].id).not.toBe("stored-id-1");
+ expect(items[1].id).toBeTruthy();
+ });
+
+ it("dedupes IDs from already-broken imported files", () => {
+ const content = [
+ "- [ ] Group A | metadata:{\"id\":\"file-0\"}",
+ " - [ ] Item A1 | metadata:{\"id\":\"file-0\"}",
+ " - [ ] Item A2 | metadata:{\"id\":\"file-1\"}",
+ "- [ ] Group B | metadata:{\"id\":\"file-1\"}",
+ " - [ ] Item B1 | metadata:{\"id\":\"file-2\"}",
+ ].join("\n");
+
+ const { items } = parseChecklistContent(content, "file");
+ const ids = collectIds(items);
+
+ expect(new Set(ids).size).toBe(ids.length);
+ expect(items[0].id).toBe("file-0");
+ expect(items[0].children?.[0].id).not.toBe("file-0");
+ });
+});
diff --git a/tests/utils/connections-graph-data.test.ts b/tests/utils/connections-graph-data.test.ts
new file mode 100644
index 00000000..b12a38c8
--- /dev/null
+++ b/tests/utils/connections-graph-data.test.ts
@@ -0,0 +1,279 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+import { ItemTypes } from "@/app/_types/enums";
+import { LinkIndex } from "@/app/_types";
+import {
+ buildConnectionGraphData,
+ filterArchivedLinkIndex,
+ filterConnectionGraphData,
+} from "@/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/graph-data";
+
+const noteA = "11111111-1111-4111-8111-111111111111";
+const noteB = "22222222-2222-4222-8222-222222222222";
+const noteC = "33333333-3333-4333-8333-333333333333";
+const listA = "44444444-4444-4444-8444-444444444444";
+const listB = "55555555-5555-4555-8555-555555555555";
+
+const emptyLinks = () => ({
+ isLinkedTo: { notes: [], checklists: [] },
+ isReferencedIn: { notes: [], checklists: [] },
+});
+
+const baseIndex = (): LinkIndex => ({
+ notes: {},
+ checklists: {},
+});
+
+const notes = [
+ {
+ uuid: noteA,
+ id: "alpha",
+ title: "Alpha",
+ category: "Work",
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-02T00:00:00.000Z",
+ tags: ["project"],
+ },
+ {
+ uuid: noteB,
+ id: "beta",
+ title: "Beta",
+ category: "Work",
+ createdAt: "2024-01-03T00:00:00.000Z",
+ updatedAt: "2024-01-04T00:00:00.000Z",
+ tags: ["reference"],
+ },
+ {
+ uuid: noteC,
+ id: "orphan",
+ title: "Orphan",
+ category: "Personal",
+ createdAt: "2024-01-05T00:00:00.000Z",
+ updatedAt: "2024-01-06T00:00:00.000Z",
+ },
+];
+
+const checklists = [
+ {
+ uuid: listA,
+ id: "tasks",
+ title: "Tasks",
+ type: "simple" as const,
+ category: "Work",
+ items: [],
+ createdAt: "2024-01-07T00:00:00.000Z",
+ updatedAt: "2024-01-08T00:00:00.000Z",
+ },
+ {
+ uuid: listB,
+ id: "archive",
+ title: "Archive",
+ type: "task" as const,
+ category: "Old",
+ items: [],
+ createdAt: "2024-01-09T00:00:00.000Z",
+ updatedAt: "2024-01-10T00:00:00.000Z",
+ },
+];
+
+describe("connections graph data", () => {
+ it("builds note-to-note explicit links", () => {
+ const index = baseIndex();
+ index.notes[noteA] = {
+ ...emptyLinks(),
+ isLinkedTo: { notes: [noteB], checklists: [] },
+ };
+
+ const graph = buildConnectionGraphData(index, notes, checklists);
+
+ expect(graph.nodes.map((node) => node.id).sort()).toEqual([noteA, noteB]);
+ expect(graph.links).toEqual([{ source: noteA, target: noteB, directed: true, type: "explicit" }]);
+ expect(graph.nodes.find((node) => node.id === noteA)?.outboundCount).toBe(1);
+ expect(graph.nodes.find((node) => node.id === noteB)?.inboundCount).toBe(1);
+ });
+
+ it("builds note-to-checklist and checklist-to-note links", () => {
+ const index = baseIndex();
+ index.notes[noteA] = {
+ ...emptyLinks(),
+ isLinkedTo: { notes: [], checklists: [listA] },
+ };
+ index.checklists[listA] = {
+ ...emptyLinks(),
+ isLinkedTo: { notes: [noteB], checklists: [] },
+ };
+
+ const graph = buildConnectionGraphData(index, notes, checklists);
+
+ expect(graph.links).toHaveLength(2);
+ expect(graph.links).toContainEqual({ source: noteA, target: listA, directed: true, type: "explicit" });
+ expect(graph.links).toContainEqual({ source: listA, target: noteB, directed: true, type: "explicit" });
+ expect(graph.nodes.find((node) => node.id === listA)?.type).toBe(ItemTypes.CHECKLIST);
+ });
+
+ it("connects items that share tags", () => {
+ const index = baseIndex();
+ const taggedNotes = [
+ { ...notes[0], tags: ["project", "shared"] },
+ { ...notes[1], tags: ["shared"] },
+ { ...notes[2], tags: ["other"] },
+ ];
+
+ const graph = buildConnectionGraphData(index, taggedNotes, checklists);
+
+ expect(graph.links).toContainEqual({
+ source: noteA,
+ target: noteB,
+ directed: false,
+ type: "tag",
+ tag: "shared",
+ });
+ expect(graph.links).not.toContainEqual(
+ expect.objectContaining({ source: noteA, target: noteC, type: "tag" }),
+ );
+ expect(graph.nodes.find((node) => node.id === noteA)?.connectionCount).toBe(1);
+ expect(graph.nodes.find((node) => node.id === noteB)?.connectionCount).toBe(1);
+ });
+
+ it("de-dupes duplicate bidirectional visible edges", () => {
+ const index = baseIndex();
+ index.notes[noteA] = {
+ ...emptyLinks(),
+ isLinkedTo: { notes: [noteB], checklists: [] },
+ };
+ index.notes[noteB] = {
+ ...emptyLinks(),
+ isLinkedTo: { notes: [noteA], checklists: [] },
+ };
+
+ const graph = buildConnectionGraphData(index, notes, checklists);
+
+ expect(graph.links).toHaveLength(1);
+ expect(graph.links[0]).toMatchObject({ source: noteA, target: noteB, type: "explicit" });
+ expect(graph.nodes.find((node) => node.id === noteA)?.connectionCount).toBe(2);
+ expect(graph.nodes.find((node) => node.id === noteB)?.connectionCount).toBe(2);
+ });
+
+ it("filters archived items by UUID first and legacy key fallback", () => {
+ const index = baseIndex();
+ index.notes[noteA] = {
+ ...emptyLinks(),
+ isLinkedTo: { notes: [noteB], checklists: ["Old/archive"] },
+ };
+ index.notes[noteB] = emptyLinks();
+ index.checklists["Old/archive"] = emptyLinks();
+
+ const filtered = filterArchivedLinkIndex(index, [
+ { uuid: noteB, id: "wrong", title: "Wrong", content: "", createdAt: "", updatedAt: "" },
+ { id: "archive", title: "Archive", type: "task", category: "Old", items: [], createdAt: "", updatedAt: "" },
+ ]);
+
+ expect(filtered.notes[noteB]).toBeUndefined();
+ expect(filtered.checklists["Old/archive"]).toBeUndefined();
+ expect(filtered.notes[noteA].isLinkedTo.notes).toEqual([]);
+ expect(filtered.notes[noteA].isLinkedTo.checklists).toEqual([]);
+ });
+
+ it("filters missing targets", () => {
+ const index = baseIndex();
+ index.notes[noteA] = {
+ ...emptyLinks(),
+ isLinkedTo: {
+ notes: ["66666666-6666-4666-8666-666666666666"],
+ checklists: [],
+ },
+ };
+
+ const graph = buildConnectionGraphData(index, notes, checklists);
+
+ expect(graph.nodes).toEqual([]);
+ expect(graph.links).toEqual([]);
+ });
+
+ it("includes or excludes orphan nodes", () => {
+ const index = baseIndex();
+
+ expect(buildConnectionGraphData(index, notes, checklists).nodes).toEqual([]);
+
+ const graph = buildConnectionGraphData(index, notes, checklists, true);
+
+ expect(graph.nodes.map((node) => node.id)).toContain(noteC);
+ expect(graph.nodes.find((node) => node.id === noteC)?.orphan).toBe(true);
+ });
+
+ it("filters visible graph data by search and type", () => {
+ const index = baseIndex();
+ index.notes[noteA] = {
+ ...emptyLinks(),
+ isLinkedTo: { notes: [], checklists: [listA] },
+ };
+
+ const graph = buildConnectionGraphData(index, notes, checklists, true);
+ const filtered = filterConnectionGraphData(graph, {
+ search: "tasks",
+ showNotes: true,
+ showChecklists: true,
+ showOrphans: false,
+ showTagLinks: true,
+ });
+
+ expect(filtered.nodes.map((node) => node.id)).toEqual([listA]);
+ expect(filtered.links).toEqual([]);
+ });
+
+ it("can hide tag links from the visible graph", () => {
+ const index = baseIndex();
+ const taggedNotes = [
+ { ...notes[0], tags: ["shared"] },
+ { ...notes[1], tags: ["shared"] },
+ ];
+ const graph = buildConnectionGraphData(index, taggedNotes, checklists);
+ const filtered = filterConnectionGraphData(graph, {
+ search: "",
+ showNotes: true,
+ showChecklists: true,
+ showOrphans: false,
+ showTagLinks: false,
+ });
+
+ expect(graph.links.some((link) => link.type === "tag")).toBe(true);
+ expect(filtered.links).toEqual([]);
+ expect(filtered.nodes).toEqual([]);
+ });
+
+ it("puts the graph before the controls on mobile while keeping desktop controls first", () => {
+ const source = readFileSync(
+ "app/_components/FeatureComponents/Profile/Parts/LinksTab.tsx",
+ "utf8",
+ );
+
+ expect(source).toContain("order-2 lg:order-1");
+ expect(source).toContain("order-1 lg:order-2");
+ expect(source.indexOf("order-2 lg:order-1")).toBeLessThan(
+ source.indexOf("order-1 lg:order-2"),
+ );
+ });
+
+ it("uses existing profile translation keys for graph labels", () => {
+ const source = readFileSync(
+ "app/_components/FeatureComponents/Profile/Parts/LinksTab.tsx",
+ "utf8",
+ );
+
+ expect(source).not.toContain('t("common.unknown")');
+ expect(source).toContain('t("profile.unknown")');
+ });
+
+ it("paints a larger node pointer area for mobile tapping", () => {
+ const source = readFileSync(
+ "app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/ConnectionsGraph.tsx",
+ "utf8",
+ );
+
+ expect(source).toContain("MIN_POINTER_RADIUS");
+ expect(source).toContain("nodePointerAreaPaint");
+ expect(source).toContain("globalScale");
+ expect(source).toContain("MIN_POINTER_RADIUS / globalScale");
+ expect(source).toContain("enableNodeDrag={!isCoarsePointer}");
+ });
+});
diff --git a/tests/utils/dnd-math.test.ts b/tests/utils/dnd-math.test.ts
new file mode 100644
index 00000000..29689ae3
--- /dev/null
+++ b/tests/utils/dnd-math.test.ts
@@ -0,0 +1,107 @@
+import { describe, it, expect } from "vitest";
+import {
+ findDropList,
+ projectIndex,
+ displaceFor,
+} from "@/app/_utils/dnd/dnd-math";
+import { CARD_GAP_PX } from "@/app/_consts/dnd";
+import { DragRect, ListItemRect } from "@/app/_types/dnd";
+
+const makeRect = (
+ top: number,
+ left: number,
+ width = 280,
+ height = 80,
+): DragRect => ({ top, left, width, height });
+
+const makeColumn = (ids: string[], top = 0): ListItemRect[] =>
+ ids.map((id, index) => ({
+ id,
+ index,
+ rect: makeRect(top + index * (80 + CARD_GAP_PX), 0),
+ }));
+
+describe("findDropList", () => {
+ const lists = new Map([
+ ["todo", makeRect(0, 0)],
+ ["in_progress", makeRect(0, 300)],
+ ["completed", makeRect(0, 600)],
+ ]);
+
+ it("returns the list containing the pointer", () => {
+ expect(findDropList({ x: 350, y: 40 }, lists)).toBe("in_progress");
+ });
+
+ it("falls back to the horizontally nearest list in dead zones", () => {
+ expect(findDropList({ x: 295, y: 40 }, lists)).toBe("in_progress");
+ expect(findDropList({ x: 285, y: 40 }, lists)).toBe("todo");
+ });
+
+ it("returns null when the pointer has no vertical overlap", () => {
+ expect(findDropList({ x: 295, y: 500 }, lists)).toBeNull();
+ });
+
+ it("returns null for an empty registry", () => {
+ expect(findDropList({ x: 10, y: 10 }, new Map())).toBeNull();
+ });
+});
+
+describe("projectIndex", () => {
+ it("returns 0 for an empty list", () => {
+ expect(projectIndex(100, [], "x")).toBe(0);
+ });
+
+ it("returns 0 when the pointer is above the first midpoint", () => {
+ const items = makeColumn(["a", "b", "c"]);
+ expect(projectIndex(10, items, "x")).toBe(0);
+ });
+
+ it("returns the slot between two midpoints", () => {
+ const items = makeColumn(["a", "b", "c"]);
+ expect(projectIndex(50, items, "x")).toBe(1);
+ expect(projectIndex(140, items, "x")).toBe(2);
+ });
+
+ it("returns the length when below all midpoints", () => {
+ const items = makeColumn(["a", "b", "c"]);
+ expect(projectIndex(900, items, "x")).toBe(3);
+ });
+
+ it("excludes the active card from counting", () => {
+ const items = makeColumn(["a", "b", "c"]);
+ expect(projectIndex(50, items, "a")).toBe(0);
+ expect(projectIndex(900, items, "b")).toBe(2);
+ });
+});
+
+describe("displaceFor", () => {
+ const dragHeight = 80;
+ const delta = dragHeight + CARD_GAP_PX;
+
+ it("returns 0 when there is no target", () => {
+ expect(displaceFor(0, 1, null, dragHeight)).toBe(0);
+ });
+
+ it("shifts cards up when dragging down in the same list", () => {
+ expect(displaceFor(0, 0, 2, dragHeight)).toBe(-delta);
+ expect(displaceFor(1, 0, 2, dragHeight)).toBe(-delta);
+ expect(displaceFor(2, 0, 2, dragHeight)).toBe(0);
+ });
+
+ it("shifts cards down when dragging up in the same list", () => {
+ expect(displaceFor(0, 2, 0, dragHeight)).toBe(delta);
+ expect(displaceFor(1, 2, 0, dragHeight)).toBe(delta);
+ expect(displaceFor(2, 2, 0, dragHeight)).toBe(0);
+ });
+
+ it("leaves cards alone when the target equals the source slot", () => {
+ expect(displaceFor(0, 1, 1, dragHeight)).toBe(0);
+ expect(displaceFor(1, 1, 1, dragHeight)).toBe(0);
+ });
+
+ it("shifts cards at or below the slot in a foreign list", () => {
+ expect(displaceFor(0, null, 1, dragHeight)).toBe(0);
+ expect(displaceFor(1, null, 1, dragHeight)).toBe(delta);
+ expect(displaceFor(2, null, 1, dragHeight)).toBe(delta);
+ });
+});
diff --git a/tests/utils/item-status-utils.test.ts b/tests/utils/item-status-utils.test.ts
new file mode 100644
index 00000000..92cdfc0b
--- /dev/null
+++ b/tests/utils/item-status-utils.test.ts
@@ -0,0 +1,121 @@
+import { describe, it, expect } from "vitest";
+import { applyStatus, completeParent } from "@/app/_utils/item-status-utils";
+import { DEFAULT_KANBAN_STATUSES } from "@/app/_consts/kanban";
+import { TaskStatus } from "@/app/_types/enums";
+import { Item } from "@/app/_types";
+
+const makeItem = (id: string, status?: string, extra?: Partial
- ): Item => ({
+ id,
+ text: id,
+ completed: false,
+ order: 0,
+ status,
+ ...extra,
+});
+
+const NOW = "2026-06-12T12:00:00.000Z";
+const USER = "samwise";
+
+describe("applyStatus", () => {
+ it("sets status, modifier metadata, and appends history", () => {
+ const items = [makeItem("a", TaskStatus.TODO)];
+ const result = applyStatus(
+ items, "a", TaskStatus.IN_PROGRESS, DEFAULT_KANBAN_STATUSES, USER, NOW,
+ );
+ expect(result[0].status).toBe(TaskStatus.IN_PROGRESS);
+ expect(result[0].lastModifiedBy).toBe(USER);
+ expect(result[0].lastModifiedAt).toBe(NOW);
+ expect(result[0].history).toEqual([
+ { status: TaskStatus.IN_PROGRESS, timestamp: NOW, user: USER },
+ ]);
+ });
+
+ it("does not append history when the status is unchanged", () => {
+ const items = [makeItem("a", TaskStatus.TODO)];
+ const result = applyStatus(
+ items, "a", TaskStatus.TODO, DEFAULT_KANBAN_STATUSES, USER, NOW,
+ );
+ expect(result[0].history).toBeUndefined();
+ });
+
+ it("completes the item and its children on an autoComplete status", () => {
+ const items = [
+ makeItem("a", TaskStatus.TODO, {
+ children: [makeItem("a1"), makeItem("a2")],
+ }),
+ ];
+ const result = applyStatus(
+ items, "a", TaskStatus.COMPLETED, DEFAULT_KANBAN_STATUSES, USER, NOW,
+ );
+ expect(result[0].completed).toBe(true);
+ expect(result[0].children?.every((c) => c.completed)).toBe(true);
+ });
+
+ it("clears completed when leaving an autoComplete status", () => {
+ const items = [
+ makeItem("a", TaskStatus.COMPLETED, { completed: true }),
+ ];
+ const result = applyStatus(
+ items, "a", TaskStatus.PAUSED, DEFAULT_KANBAN_STATUSES, USER, NOW,
+ );
+ expect(result[0].completed).toBe(false);
+ });
+});
+
+describe("completeParent", () => {
+ it("completes the parent when all children are completed", () => {
+ const items = [
+ makeItem("p", TaskStatus.IN_PROGRESS, {
+ children: [
+ makeItem("c1", TaskStatus.COMPLETED, { completed: true }),
+ makeItem("c2", TaskStatus.COMPLETED, { completed: true }),
+ ],
+ }),
+ ];
+ const result = completeParent(
+ items, "c1", DEFAULT_KANBAN_STATUSES, USER, NOW,
+ );
+ expect(result[0].completed).toBe(true);
+ expect(result[0].status).toBe(TaskStatus.COMPLETED);
+ expect(result[0].history).toEqual([
+ { status: TaskStatus.COMPLETED, timestamp: NOW, user: USER },
+ ]);
+ });
+
+ it("does nothing while a sibling is incomplete", () => {
+ const items = [
+ makeItem("p", TaskStatus.IN_PROGRESS, {
+ children: [
+ makeItem("c1", TaskStatus.COMPLETED, { completed: true }),
+ makeItem("c2", TaskStatus.TODO),
+ ],
+ }),
+ ];
+ const result = completeParent(
+ items, "c1", DEFAULT_KANBAN_STATUSES, USER, NOW,
+ );
+ expect(result[0].completed).toBe(false);
+ });
+
+ it("does nothing without an autoComplete status", () => {
+ const noAutoComplete = DEFAULT_KANBAN_STATUSES.map((s) => ({
+ ...s,
+ autoComplete: false,
+ }));
+ const items = [
+ makeItem("p", TaskStatus.IN_PROGRESS, {
+ children: [makeItem("c1", TaskStatus.COMPLETED, { completed: true })],
+ }),
+ ];
+ const result = completeParent(items, "c1", noAutoComplete, USER, NOW);
+ expect(result[0].completed).toBe(false);
+ });
+
+ it("does nothing for a top-level item", () => {
+ const items = [makeItem("a", TaskStatus.COMPLETED, { completed: true })];
+ const result = completeParent(
+ items, "a", DEFAULT_KANBAN_STATUSES, USER, NOW,
+ );
+ expect(result).toBe(items);
+ });
+});
diff --git a/yarn.lock b/yarn.lock
index ba50b8bd..67f0efb7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -86,11 +86,21 @@
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687"
integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
+"@babel/helper-string-parser@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f"
+ integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==
+
"@babel/helper-validator-identifier@^7.28.5":
version "7.28.5"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4"
integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==
+"@babel/helper-validator-identifier@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2"
+ integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==
+
"@babel/helper-validator-option@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f"
@@ -111,6 +121,13 @@
dependencies:
"@babel/types" "^7.29.0"
+"@babel/parser@^7.29.3":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.7.tgz#837b87387cbf5ec5530cb634b3c622f68edb9334"
+ integrity sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==
+ dependencies:
+ "@babel/types" "^7.29.7"
+
"@babel/runtime@^7.13.10", "@babel/runtime@^7.23.5":
version "7.29.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.2.tgz#9a6e2d05f4b6692e1801cd4fb176ad823930ed5e"
@@ -146,6 +163,14 @@
"@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.28.5"
+"@babel/types@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.7.tgz#8005e31d82712ee7adaef6e23c63b71a62770a92"
+ integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==
+ dependencies:
+ "@babel/helper-string-parser" "^7.29.7"
+ "@babel/helper-validator-identifier" "^7.29.7"
+
"@bcoe/v8-coverage@^1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa"
@@ -251,395 +276,135 @@
dependencies:
tslib "^2.4.0"
-"@esbuild/aix-ppc64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c"
- integrity sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==
-
-"@esbuild/aix-ppc64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2"
- integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==
-
-"@esbuild/aix-ppc64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53"
- integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==
-
-"@esbuild/android-arm64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz#8aa4965f8d0a7982dc21734bf6601323a66da752"
- integrity sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==
-
-"@esbuild/android-arm64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8"
- integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==
-
-"@esbuild/android-arm64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d"
- integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==
-
-"@esbuild/android-arm@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz#300712101f7f50f1d2627a162e6e09b109b6767a"
- integrity sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==
-
-"@esbuild/android-arm@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b"
- integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==
-
-"@esbuild/android-arm@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d"
- integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==
-
-"@esbuild/android-x64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz#87dfb27161202bdc958ef48bb61b09c758faee16"
- integrity sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==
-
-"@esbuild/android-x64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac"
- integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==
-
-"@esbuild/android-x64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07"
- integrity sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==
-
-"@esbuild/darwin-arm64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz#79197898ec1ff745d21c071e1c7cc3c802f0c1fd"
- integrity sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==
-
-"@esbuild/darwin-arm64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd"
- integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==
-
-"@esbuild/darwin-arm64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz#6f23000fb9b40b7e04b7d0606c0693bd0632f322"
- integrity sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==
-
-"@esbuild/darwin-x64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz#146400a8562133f45c4d2eadcf37ddd09718079e"
- integrity sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==
-
-"@esbuild/darwin-x64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a"
- integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==
-
-"@esbuild/darwin-x64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be"
- integrity sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==
-
-"@esbuild/freebsd-arm64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz#1c5f9ba7206e158fd2b24c59fa2d2c8bb47ca0fe"
- integrity sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==
-
-"@esbuild/freebsd-arm64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b"
- integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==
-
-"@esbuild/freebsd-arm64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62"
- integrity sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==
-
-"@esbuild/freebsd-x64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz#ea631f4a36beaac4b9279fa0fcc6ca29eaeeb2b3"
- integrity sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==
-
-"@esbuild/freebsd-x64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead"
- integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==
-
-"@esbuild/freebsd-x64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6"
- integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==
-
-"@esbuild/linux-arm64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz#e1066bce58394f1b1141deec8557a5f0a22f5977"
- integrity sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==
-
-"@esbuild/linux-arm64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6"
- integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==
-
-"@esbuild/linux-arm64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966"
- integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==
-
-"@esbuild/linux-arm@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz#452cd66b20932d08bdc53a8b61c0e30baf4348b9"
- integrity sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==
-
-"@esbuild/linux-arm@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11"
- integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==
-
-"@esbuild/linux-arm@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921"
- integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==
-
-"@esbuild/linux-ia32@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz#b24f8acc45bcf54192c7f2f3be1b53e6551eafe0"
- integrity sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==
-
-"@esbuild/linux-ia32@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29"
- integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==
-
-"@esbuild/linux-ia32@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e"
- integrity sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==
-
-"@esbuild/linux-loong64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz#f9cfffa7fc8322571fbc4c8b3268caf15bd81ad0"
- integrity sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==
-
-"@esbuild/linux-loong64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed"
- integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==
-
-"@esbuild/linux-loong64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205"
- integrity sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==
-
-"@esbuild/linux-mips64el@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz#575a14bd74644ffab891adc7d7e60d275296f2cd"
- integrity sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==
-
-"@esbuild/linux-mips64el@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1"
- integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==
-
-"@esbuild/linux-mips64el@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8"
- integrity sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==
-
-"@esbuild/linux-ppc64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz#75b99c70a95fbd5f7739d7692befe60601591869"
- integrity sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==
-
-"@esbuild/linux-ppc64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78"
- integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==
-
-"@esbuild/linux-ppc64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea"
- integrity sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==
-
-"@esbuild/linux-riscv64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz#2e3259440321a44e79ddf7535c325057da875cd6"
- integrity sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==
-
-"@esbuild/linux-riscv64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d"
- integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==
-
-"@esbuild/linux-riscv64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027"
- integrity sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==
-
-"@esbuild/linux-s390x@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz#17676cabbfe5928da5b2a0d6df5d58cd08db2663"
- integrity sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==
-
-"@esbuild/linux-s390x@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d"
- integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==
-
-"@esbuild/linux-s390x@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6"
- integrity sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==
-
-"@esbuild/linux-x64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz#0583775685ca82066d04c3507f09524d3cd7a306"
- integrity sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==
-
-"@esbuild/linux-x64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5"
- integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==
-
-"@esbuild/linux-x64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a"
- integrity sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==
-
-"@esbuild/netbsd-arm64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4"
- integrity sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==
-
-"@esbuild/netbsd-arm64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7"
- integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==
-
-"@esbuild/netbsd-arm64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690"
- integrity sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==
-
-"@esbuild/netbsd-x64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz#77da0d0a0d826d7c921eea3d40292548b258a076"
- integrity sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==
-
-"@esbuild/netbsd-x64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b"
- integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==
-
-"@esbuild/netbsd-x64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320"
- integrity sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==
-
-"@esbuild/openbsd-arm64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz#6296f5867aedef28a81b22ab2009c786a952dccd"
- integrity sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==
-
-"@esbuild/openbsd-arm64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5"
- integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==
-
-"@esbuild/openbsd-arm64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1"
- integrity sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==
-
-"@esbuild/openbsd-x64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz#f8d23303360e27b16cf065b23bbff43c14142679"
- integrity sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==
-
-"@esbuild/openbsd-x64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b"
- integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==
-
-"@esbuild/openbsd-x64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179"
- integrity sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==
-
-"@esbuild/openharmony-arm64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d"
- integrity sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==
-
-"@esbuild/openharmony-arm64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e"
- integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==
-
-"@esbuild/openharmony-arm64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410"
- integrity sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==
-
-"@esbuild/sunos-x64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz#a6ed7d6778d67e528c81fb165b23f4911b9b13d6"
- integrity sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==
-
-"@esbuild/sunos-x64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537"
- integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==
-
-"@esbuild/sunos-x64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d"
- integrity sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==
-
-"@esbuild/win32-arm64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz#9ac14c378e1b653af17d08e7d3ce34caef587323"
- integrity sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==
-
-"@esbuild/win32-arm64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e"
- integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==
-
-"@esbuild/win32-arm64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77"
- integrity sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==
-
-"@esbuild/win32-ia32@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz#918942dcbbb35cc14fca39afb91b5e6a3d127267"
- integrity sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==
-
-"@esbuild/win32-ia32@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c"
- integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==
-
-"@esbuild/win32-ia32@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d"
- integrity sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==
-
-"@esbuild/win32-x64@0.25.12":
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz#9bdad8176be7811ad148d1f8772359041f46c6c5"
- integrity sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==
-
-"@esbuild/win32-x64@0.27.3":
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17"
- integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==
-
-"@esbuild/win32-x64@0.27.7":
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b"
- integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==
+"@esbuild/aix-ppc64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be"
+ integrity sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==
+
+"@esbuild/android-arm64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz#b540a27d14e4afd058496a4dbec4d3f414db110a"
+ integrity sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==
+
+"@esbuild/android-arm@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f"
+ integrity sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==
+
+"@esbuild/android-x64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e"
+ integrity sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==
+
+"@esbuild/darwin-arm64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54"
+ integrity sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==
+
+"@esbuild/darwin-x64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772"
+ integrity sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==
+
+"@esbuild/freebsd-arm64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6"
+ integrity sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==
+
+"@esbuild/freebsd-x64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3"
+ integrity sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==
+
+"@esbuild/linux-arm64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717"
+ integrity sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==
+
+"@esbuild/linux-arm@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c"
+ integrity sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==
+
+"@esbuild/linux-ia32@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz#a580f9c676797833891e519fc7a1337c8afd8db3"
+ integrity sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==
+
+"@esbuild/linux-loong64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b"
+ integrity sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==
+
+"@esbuild/linux-mips64el@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8"
+ integrity sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==
+
+"@esbuild/linux-ppc64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d"
+ integrity sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==
+
+"@esbuild/linux-riscv64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08"
+ integrity sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==
+
+"@esbuild/linux-s390x@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc"
+ integrity sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==
+
+"@esbuild/linux-x64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz#6f0c3ce0cb64c534b70c4c45ecb2c16d34e35dfd"
+ integrity sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==
+
+"@esbuild/netbsd-arm64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36"
+ integrity sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==
+
+"@esbuild/netbsd-x64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347"
+ integrity sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==
+
+"@esbuild/openbsd-arm64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2"
+ integrity sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==
+
+"@esbuild/openbsd-x64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz#c427b9be5a64c262ff9a7eb70b5fbbaadf446c6c"
+ integrity sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==
+
+"@esbuild/openharmony-arm64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097"
+ integrity sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==
+
+"@esbuild/sunos-x64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a"
+ integrity sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==
+
+"@esbuild/win32-arm64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4"
+ integrity sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==
+
+"@esbuild/win32-ia32@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6"
+ integrity sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==
+
+"@esbuild/win32-x64@0.28.1":
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12"
+ integrity sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==
"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1":
version "4.9.1"
@@ -1126,57 +891,57 @@
"@emnapi/runtime" "^1.4.3"
"@tybys/wasm-util" "^0.10.0"
-"@next/env@16.2.6":
- version "16.2.6"
- resolved "https://registry.yarnpkg.com/@next/env/-/env-16.2.6.tgz#93a173801cb088463070cc6a113c68e26c1838ea"
- integrity sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==
+"@next/env@16.2.9":
+ version "16.2.9"
+ resolved "https://registry.yarnpkg.com/@next/env/-/env-16.2.9.tgz#c2bbcb1647503cae0f11e6a326799acd1ea29e9e"
+ integrity sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==
-"@next/eslint-plugin-next@16.1.6":
- version "16.1.6"
- resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz#73b56b01c9db506998bd1e2d303c2605b0a1b7b0"
- integrity sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==
+"@next/eslint-plugin-next@16.2.9":
+ version "16.2.9"
+ resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.9.tgz#0d788da191971636def239cdf3b42458796df6ff"
+ integrity sha512-UZi8+YT/MLgTC9nrrn2Xd4lBYv1B7lVmtWHfPcthAI5Tt/C1LuDe6DfmtCtJ+WQod3ksY4VrKSvk3oMVAnL7qw==
dependencies:
fast-glob "3.3.1"
-"@next/swc-darwin-arm64@16.2.6":
- version "16.2.6"
- resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz#0e19055594fbd2d198ce95cf5842bdbe57235d4e"
- integrity sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==
-
-"@next/swc-darwin-x64@16.2.6":
- version "16.2.6"
- resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz#487086280b56017bb547f5975ef1a3d6235a070f"
- integrity sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==
-
-"@next/swc-linux-arm64-gnu@16.2.6":
- version "16.2.6"
- resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz#b28ffbc31ee527a3ad48f29c2fd7b7f75bbdf180"
- integrity sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==
-
-"@next/swc-linux-arm64-musl@16.2.6":
- version "16.2.6"
- resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz#d2eac5600e7083527669fa8cb8758efbbf9c8210"
- integrity sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==
-
-"@next/swc-linux-x64-gnu@16.2.6":
- version "16.2.6"
- resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz#e70dc3469bf9bfef16da2ba240c07ef6cfe8ad55"
- integrity sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==
-
-"@next/swc-linux-x64-musl@16.2.6":
- version "16.2.6"
- resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz#49ecd2f622d0f3741654c59411f604dbbe1a38de"
- integrity sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==
-
-"@next/swc-win32-arm64-msvc@16.2.6":
- version "16.2.6"
- resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz#8a6dfec005364e1cf4fa1724c3d7fa0093660406"
- integrity sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==
-
-"@next/swc-win32-x64-msvc@16.2.6":
- version "16.2.6"
- resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz#4fb656ccfcf60cbf8ffedb40fc1b625987c82742"
- integrity sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==
+"@next/swc-darwin-arm64@16.2.9":
+ version "16.2.9"
+ resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.9.tgz#b67a7e67466e39be621553b7a32520bc73ca4fa4"
+ integrity sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==
+
+"@next/swc-darwin-x64@16.2.9":
+ version "16.2.9"
+ resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz#817f3609c231b07d6910c074eeaf7c41ae781f2d"
+ integrity sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==
+
+"@next/swc-linux-arm64-gnu@16.2.9":
+ version "16.2.9"
+ resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz#f7314d5c268a6c440208c46da92ab8d914c02d36"
+ integrity sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==
+
+"@next/swc-linux-arm64-musl@16.2.9":
+ version "16.2.9"
+ resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz#9258cf7a3f2f10a605ef85fb76601123f3b1d5ab"
+ integrity sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==
+
+"@next/swc-linux-x64-gnu@16.2.9":
+ version "16.2.9"
+ resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz#a6db6095f6495c6d55c48ba946b1578f5e75a359"
+ integrity sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==
+
+"@next/swc-linux-x64-musl@16.2.9":
+ version "16.2.9"
+ resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz#d7f857879886c3a378285fea438ba4636fcc3a9a"
+ integrity sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==
+
+"@next/swc-win32-arm64-msvc@16.2.9":
+ version "16.2.9"
+ resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz#d6d67c8e803b2c44b76b16495b5eda47165faf43"
+ integrity sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==
+
+"@next/swc-win32-x64-msvc@16.2.9":
+ version "16.2.9"
+ resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.9.tgz#6e36f17dd615c52761691b7f5be1d9d161abed2a"
+ integrity sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==
"@nivo/annotations@0.99.0":
version "0.99.0"
@@ -1924,7 +1689,7 @@
dependencies:
"@simple-git/args-pathspec" "^1.0.3"
-"@standard-schema/spec@^1.0.0":
+"@standard-schema/spec@^1.0.0", "@standard-schema/spec@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8"
integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==
@@ -2362,6 +2127,11 @@
resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-3.22.4.tgz#ef39840763245aa67c87985903ef62db365cde89"
integrity sha512-1buvLZemITTeKmPf2wGFWvvhRFKjdQ+JgMqc67xBraOKeDd8wQi1e2XlhCYAtlVMm5f6j+qlLC/MvwuHI2jHeQ==
+"@tweenjs/tween.js@18 - 25":
+ version "25.0.0"
+ resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-25.0.0.tgz#7266baebcc3affe62a3a54318a3ea82d904cd0b9"
+ integrity sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==
+
"@tybys/wasm-util@^0.10.0":
version "0.10.1"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414"
@@ -2863,79 +2633,81 @@
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777"
integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==
-"@vitest/coverage-v8@4.0.17":
- version "4.0.17"
- resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-4.0.17.tgz#3bb100e9a6766de282049fba28e21a010a73509a"
- integrity sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==
+"@vitest/coverage-v8@4.1.8":
+ version "4.1.8"
+ resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz#6a5dd34552840a0ace0396d0e94c7459beb80d14"
+ integrity sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==
dependencies:
"@bcoe/v8-coverage" "^1.0.2"
- "@vitest/utils" "4.0.17"
- ast-v8-to-istanbul "^0.3.10"
+ "@vitest/utils" "4.1.8"
+ ast-v8-to-istanbul "^1.0.0"
istanbul-lib-coverage "^3.2.2"
istanbul-lib-report "^3.0.1"
istanbul-reports "^3.2.0"
- magicast "^0.5.1"
+ magicast "^0.5.2"
obug "^2.1.1"
- std-env "^3.10.0"
- tinyrainbow "^3.0.3"
+ std-env "^4.0.0-rc.1"
+ tinyrainbow "^3.1.0"
-"@vitest/expect@4.0.17":
- version "4.0.17"
- resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.0.17.tgz#67bb0d4a7d37054590a19dcf831f7936d14a8a02"
- integrity sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==
+"@vitest/expect@4.1.8":
+ version "4.1.8"
+ resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.8.tgz#45154f1f8559f55c5281eb0dcb1ac37b581a87d8"
+ integrity sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==
dependencies:
- "@standard-schema/spec" "^1.0.0"
+ "@standard-schema/spec" "^1.1.0"
"@types/chai" "^5.2.2"
- "@vitest/spy" "4.0.17"
- "@vitest/utils" "4.0.17"
- chai "^6.2.1"
- tinyrainbow "^3.0.3"
+ "@vitest/spy" "4.1.8"
+ "@vitest/utils" "4.1.8"
+ chai "^6.2.2"
+ tinyrainbow "^3.1.0"
-"@vitest/mocker@4.0.17":
- version "4.0.17"
- resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.0.17.tgz#ce559098be1ae18ae5aa441a79939bcd7fc8f42f"
- integrity sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==
+"@vitest/mocker@4.1.8":
+ version "4.1.8"
+ resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.8.tgz#d006bfc5894a1af51e74deddef2535d6bd436b16"
+ integrity sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==
dependencies:
- "@vitest/spy" "4.0.17"
+ "@vitest/spy" "4.1.8"
estree-walker "^3.0.3"
magic-string "^0.30.21"
-"@vitest/pretty-format@4.0.17":
- version "4.0.17"
- resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.0.17.tgz#dde7cb2c01699d0943571137d1b482edff5fc000"
- integrity sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==
+"@vitest/pretty-format@4.1.8":
+ version "4.1.8"
+ resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.8.tgz#d9d2e248b900d7ad9556c4374fcdf1871c615193"
+ integrity sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==
dependencies:
- tinyrainbow "^3.0.3"
+ tinyrainbow "^3.1.0"
-"@vitest/runner@4.0.17":
- version "4.0.17"
- resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.0.17.tgz#dcc3bb4a4b1077858f3b105e91b2eeb208cee780"
- integrity sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==
+"@vitest/runner@4.1.8":
+ version "4.1.8"
+ resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.8.tgz#4631808f3996359b74ccc3ca262990e14c295d50"
+ integrity sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==
dependencies:
- "@vitest/utils" "4.0.17"
+ "@vitest/utils" "4.1.8"
pathe "^2.0.3"
-"@vitest/snapshot@4.0.17":
- version "4.0.17"
- resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.0.17.tgz#40d71a3dad4ac39812ed96a95fded46a920e1a31"
- integrity sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==
+"@vitest/snapshot@4.1.8":
+ version "4.1.8"
+ resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.8.tgz#37470135d64ea11bb2a839b1c6b7f5de7018f6ee"
+ integrity sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==
dependencies:
- "@vitest/pretty-format" "4.0.17"
+ "@vitest/pretty-format" "4.1.8"
+ "@vitest/utils" "4.1.8"
magic-string "^0.30.21"
pathe "^2.0.3"
-"@vitest/spy@4.0.17":
- version "4.0.17"
- resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.0.17.tgz#d0936f8908b4dae091d9b948be3bae8e19d1878d"
- integrity sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==
+"@vitest/spy@4.1.8":
+ version "4.1.8"
+ resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.8.tgz#3abfe9301d25c39f808dcaa9f10fec0dd370e564"
+ integrity sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==
-"@vitest/utils@4.0.17":
- version "4.0.17"
- resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.0.17.tgz#48181deab273c87ac4ee20c1c454ffe9c4f453fe"
- integrity sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==
+"@vitest/utils@4.1.8":
+ version "4.1.8"
+ resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.8.tgz#099ea5255cec08735410cf707edaba2c158c5ad9"
+ integrity sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==
dependencies:
- "@vitest/pretty-format" "4.0.17"
- tinyrainbow "^3.0.3"
+ "@vitest/pretty-format" "4.1.8"
+ convert-source-map "^2.0.0"
+ tinyrainbow "^3.1.0"
abbrev@^2.0.0:
version "2.0.0"
@@ -2949,6 +2721,11 @@ abort-controller@^3.0.0:
dependencies:
event-target-shim "^5.0.0"
+accessor-fn@1:
+ version "1.5.3"
+ resolved "https://registry.yarnpkg.com/accessor-fn/-/accessor-fn-1.5.3.tgz#5e2549d291d4ac022f532da9a554358dc525b0f7"
+ integrity sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==
+
acorn-jsx@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
@@ -3153,10 +2930,10 @@ ast-types-flow@^0.0.8:
resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.8.tgz#0a85e1c92695769ac13a428bb653e7538bea27d6"
integrity sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==
-ast-v8-to-istanbul@^0.3.10:
- version "0.3.12"
- resolved "https://registry.yarnpkg.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz#8eb1b7c86ef8499859be761b17ffd91406c0c36f"
- integrity sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==
+ast-v8-to-istanbul@^1.0.0:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz#9b3c38bdcadbb17f0da8cab2ca3daf8cdf5e0af2"
+ integrity sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==
dependencies:
"@jridgewell/trace-mapping" "^0.3.31"
estree-walker "^3.0.3"
@@ -3274,12 +3051,17 @@ baseline-browser-mapping@^2.10.12, baseline-browser-mapping@^2.9.0, baseline-bro
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz#7c99b86d43ae9be3810cac515f4675802e1f6b87"
integrity sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==
+"bezier-js@3 - 6":
+ version "6.1.4"
+ resolved "https://registry.yarnpkg.com/bezier-js/-/bezier-js-6.1.4.tgz#c7828f6c8900562b69d5040afb881bcbdad82001"
+ integrity sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==
+
binary-extensions@^2.0.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522"
integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==
-brace-expansion@>=5.0.6, brace-expansion@^5.0.2:
+brace-expansion@5.0.6, brace-expansion@^5.0.2:
version "5.0.6"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.6.tgz#ec68fe0a641a29d8711579caf641d05bae1f2285"
integrity sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==
@@ -3384,6 +3166,13 @@ caniuse-lite@^1.0.30001137, caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.300017
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz#31e97d1bfec332b3f2d7eea7781460c97629b3bf"
integrity sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==
+canvas-color-tracker@^1.3:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz#b924cf94b33441b82692938fca5b936be971a46d"
+ integrity sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==
+ dependencies:
+ tinycolor2 "^1.6.0"
+
canvas-roundrect-polyfill@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/canvas-roundrect-polyfill/-/canvas-roundrect-polyfill-0.0.1.tgz#70bf107ebe2037f26d839d7f809a26f4a95f5696"
@@ -3394,7 +3183,7 @@ ccount@^2.0.0:
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
-chai@^6.2.1:
+chai@^6.2.2:
version "6.2.2"
resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e"
integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==
@@ -3636,7 +3425,7 @@ cytoscape@^3.28.1:
dependencies:
internmap "^1.0.0"
-"d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.1.6, d3-array@^3.2.0:
+"d3-array@1 - 3", "d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.1.6, d3-array@^3.2.0:
version "3.2.4"
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5"
integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==
@@ -3648,6 +3437,11 @@ d3-axis@3:
resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-3.0.0.tgz#c42a4a13e8131d637b745fc2973824cfeaf93322"
integrity sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==
+d3-binarytree@1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz#ed43ebc13c70fbabfdd62df17480bc5a425753cc"
+ integrity sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==
+
d3-brush@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-3.0.0.tgz#6f767c4ed8dcb79de7ede3e1c0f89e63ef64d31c"
@@ -3724,6 +3518,17 @@ d3-fetch@3:
dependencies:
d3-dsv "1 - 3"
+"d3-force-3d@2 - 3":
+ version "3.0.6"
+ resolved "https://registry.yarnpkg.com/d3-force-3d/-/d3-force-3d-3.0.6.tgz#7ea4c26d7937b82993bd9444f570ed52f661d4aa"
+ integrity sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==
+ dependencies:
+ d3-binarytree "1"
+ d3-dispatch "1 - 3"
+ d3-octree "1"
+ d3-quadtree "1 - 3"
+ d3-timer "1 - 3"
+
d3-force@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-3.0.0.tgz#3e2ba1a61e70888fe3d9194e30d6d14eece155c4"
@@ -3771,6 +3576,11 @@ d3-hierarchy@3:
dependencies:
d3-color "1 - 3"
+d3-octree@1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/d3-octree/-/d3-octree-1.1.0.tgz#f07e353b76df872644e7130ab1a74c5ef2f4287e"
+ integrity sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==
+
d3-path@1:
version "1.0.9"
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf"
@@ -3809,7 +3619,7 @@ d3-sankey@^0.12.3:
d3-array "1 - 2"
d3-shape "^1.2.0"
-d3-scale-chromatic@3, d3-scale-chromatic@^3.0.0:
+"d3-scale-chromatic@1 - 3", d3-scale-chromatic@3, d3-scale-chromatic@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz#34c39da298b23c20e02f1a4b239bd0f22e7f1314"
integrity sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==
@@ -3817,7 +3627,7 @@ d3-scale-chromatic@3, d3-scale-chromatic@^3.0.0:
d3-color "1 - 3"
d3-interpolate "1 - 3"
-d3-scale@4, d3-scale@^4.0.2:
+"d3-scale@1 - 4", d3-scale@4, d3-scale@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396"
integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==
@@ -3896,7 +3706,7 @@ d3-time-format@^3.0.0:
d3-interpolate "1 - 3"
d3-timer "1 - 3"
-d3-zoom@3:
+"d3-zoom@2 - 3", d3-zoom@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3"
integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==
@@ -4298,10 +4108,10 @@ es-iterator-helpers@^1.2.1:
iterator.prototype "^1.1.5"
math-intrinsics "^1.1.0"
-es-module-lexer@^1.7.0:
- version "1.7.0"
- resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a"
- integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==
+es-module-lexer@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.1.0.tgz#1dfcbb5ea3bbfb63f28e1fc3676c3676d1c9624c"
+ integrity sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==
es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
version "1.1.1"
@@ -4346,101 +4156,37 @@ es6-promise-pool@2.5.0:
resolved "https://registry.yarnpkg.com/es6-promise-pool/-/es6-promise-pool-2.5.0.tgz#147c612b36b47f105027f9d2bf54a598a99d9ccb"
integrity sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA==
-esbuild@0.27.3:
- version "0.27.3"
- resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.3.tgz#5859ca8e70a3af956b26895ce4954d7e73bd27a8"
- integrity sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==
+esbuild@0.28.1, esbuild@^0.25.0, esbuild@^0.28.1, esbuild@~0.27.0:
+ version "0.28.1"
+ resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578"
+ integrity sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==
optionalDependencies:
- "@esbuild/aix-ppc64" "0.27.3"
- "@esbuild/android-arm" "0.27.3"
- "@esbuild/android-arm64" "0.27.3"
- "@esbuild/android-x64" "0.27.3"
- "@esbuild/darwin-arm64" "0.27.3"
- "@esbuild/darwin-x64" "0.27.3"
- "@esbuild/freebsd-arm64" "0.27.3"
- "@esbuild/freebsd-x64" "0.27.3"
- "@esbuild/linux-arm" "0.27.3"
- "@esbuild/linux-arm64" "0.27.3"
- "@esbuild/linux-ia32" "0.27.3"
- "@esbuild/linux-loong64" "0.27.3"
- "@esbuild/linux-mips64el" "0.27.3"
- "@esbuild/linux-ppc64" "0.27.3"
- "@esbuild/linux-riscv64" "0.27.3"
- "@esbuild/linux-s390x" "0.27.3"
- "@esbuild/linux-x64" "0.27.3"
- "@esbuild/netbsd-arm64" "0.27.3"
- "@esbuild/netbsd-x64" "0.27.3"
- "@esbuild/openbsd-arm64" "0.27.3"
- "@esbuild/openbsd-x64" "0.27.3"
- "@esbuild/openharmony-arm64" "0.27.3"
- "@esbuild/sunos-x64" "0.27.3"
- "@esbuild/win32-arm64" "0.27.3"
- "@esbuild/win32-ia32" "0.27.3"
- "@esbuild/win32-x64" "0.27.3"
-
-esbuild@^0.25.0:
- version "0.25.12"
- resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.12.tgz#97a1d041f4ab00c2fce2f838d2b9969a2d2a97a5"
- integrity sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==
- optionalDependencies:
- "@esbuild/aix-ppc64" "0.25.12"
- "@esbuild/android-arm" "0.25.12"
- "@esbuild/android-arm64" "0.25.12"
- "@esbuild/android-x64" "0.25.12"
- "@esbuild/darwin-arm64" "0.25.12"
- "@esbuild/darwin-x64" "0.25.12"
- "@esbuild/freebsd-arm64" "0.25.12"
- "@esbuild/freebsd-x64" "0.25.12"
- "@esbuild/linux-arm" "0.25.12"
- "@esbuild/linux-arm64" "0.25.12"
- "@esbuild/linux-ia32" "0.25.12"
- "@esbuild/linux-loong64" "0.25.12"
- "@esbuild/linux-mips64el" "0.25.12"
- "@esbuild/linux-ppc64" "0.25.12"
- "@esbuild/linux-riscv64" "0.25.12"
- "@esbuild/linux-s390x" "0.25.12"
- "@esbuild/linux-x64" "0.25.12"
- "@esbuild/netbsd-arm64" "0.25.12"
- "@esbuild/netbsd-x64" "0.25.12"
- "@esbuild/openbsd-arm64" "0.25.12"
- "@esbuild/openbsd-x64" "0.25.12"
- "@esbuild/openharmony-arm64" "0.25.12"
- "@esbuild/sunos-x64" "0.25.12"
- "@esbuild/win32-arm64" "0.25.12"
- "@esbuild/win32-ia32" "0.25.12"
- "@esbuild/win32-x64" "0.25.12"
-
-esbuild@~0.27.0:
- version "0.27.7"
- resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.7.tgz#bcadce22b2f3fd76f257e3a64f83a64986fea11f"
- integrity sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==
- optionalDependencies:
- "@esbuild/aix-ppc64" "0.27.7"
- "@esbuild/android-arm" "0.27.7"
- "@esbuild/android-arm64" "0.27.7"
- "@esbuild/android-x64" "0.27.7"
- "@esbuild/darwin-arm64" "0.27.7"
- "@esbuild/darwin-x64" "0.27.7"
- "@esbuild/freebsd-arm64" "0.27.7"
- "@esbuild/freebsd-x64" "0.27.7"
- "@esbuild/linux-arm" "0.27.7"
- "@esbuild/linux-arm64" "0.27.7"
- "@esbuild/linux-ia32" "0.27.7"
- "@esbuild/linux-loong64" "0.27.7"
- "@esbuild/linux-mips64el" "0.27.7"
- "@esbuild/linux-ppc64" "0.27.7"
- "@esbuild/linux-riscv64" "0.27.7"
- "@esbuild/linux-s390x" "0.27.7"
- "@esbuild/linux-x64" "0.27.7"
- "@esbuild/netbsd-arm64" "0.27.7"
- "@esbuild/netbsd-x64" "0.27.7"
- "@esbuild/openbsd-arm64" "0.27.7"
- "@esbuild/openbsd-x64" "0.27.7"
- "@esbuild/openharmony-arm64" "0.27.7"
- "@esbuild/sunos-x64" "0.27.7"
- "@esbuild/win32-arm64" "0.27.7"
- "@esbuild/win32-ia32" "0.27.7"
- "@esbuild/win32-x64" "0.27.7"
+ "@esbuild/aix-ppc64" "0.28.1"
+ "@esbuild/android-arm" "0.28.1"
+ "@esbuild/android-arm64" "0.28.1"
+ "@esbuild/android-x64" "0.28.1"
+ "@esbuild/darwin-arm64" "0.28.1"
+ "@esbuild/darwin-x64" "0.28.1"
+ "@esbuild/freebsd-arm64" "0.28.1"
+ "@esbuild/freebsd-x64" "0.28.1"
+ "@esbuild/linux-arm" "0.28.1"
+ "@esbuild/linux-arm64" "0.28.1"
+ "@esbuild/linux-ia32" "0.28.1"
+ "@esbuild/linux-loong64" "0.28.1"
+ "@esbuild/linux-mips64el" "0.28.1"
+ "@esbuild/linux-ppc64" "0.28.1"
+ "@esbuild/linux-riscv64" "0.28.1"
+ "@esbuild/linux-s390x" "0.28.1"
+ "@esbuild/linux-x64" "0.28.1"
+ "@esbuild/netbsd-arm64" "0.28.1"
+ "@esbuild/netbsd-x64" "0.28.1"
+ "@esbuild/openbsd-arm64" "0.28.1"
+ "@esbuild/openbsd-x64" "0.28.1"
+ "@esbuild/openharmony-arm64" "0.28.1"
+ "@esbuild/sunos-x64" "0.28.1"
+ "@esbuild/win32-arm64" "0.28.1"
+ "@esbuild/win32-ia32" "0.28.1"
+ "@esbuild/win32-x64" "0.28.1"
escalade@^3.2.0:
version "3.2.0"
@@ -4457,12 +4203,12 @@ escape-string-regexp@^5.0.0:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8"
integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==
-eslint-config-next@16.1.6:
- version "16.1.6"
- resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-16.1.6.tgz#75dd33bf32eb34b1e5be59f546e3c808429bab41"
- integrity sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==
+eslint-config-next@16.2.9:
+ version "16.2.9"
+ resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-16.2.9.tgz#cc3f63fe657cdb980078835f85f1f518cd549276"
+ integrity sha512-olGtBrs07bQchpaJWeqbk9GaMoU0oGmN/pYNEBXSbfgKngb5uHnPe37X6tVeh6DJfaWFQildvinGEOrolo5fmw==
dependencies:
- "@next/eslint-plugin-next" "16.1.6"
+ "@next/eslint-plugin-next" "16.2.9"
eslint-import-resolver-node "^0.3.6"
eslint-import-resolver-typescript "^3.5.2"
eslint-plugin-import "^2.32.0"
@@ -4702,7 +4448,7 @@ events@^3.3.0:
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
-expect-type@^1.2.2:
+expect-type@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.3.0.tgz#0d58ed361877a31bbc4dd6cf71bbfef7faf6bd68"
integrity sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==
@@ -4814,6 +4560,15 @@ flatted@^3.2.9:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726"
integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
+float-tooltip@^1.7:
+ version "1.7.5"
+ resolved "https://registry.yarnpkg.com/float-tooltip/-/float-tooltip-1.7.5.tgz#7083bf78f0de5a97f9c2d6aa8e90d2139f34047f"
+ integrity sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==
+ dependencies:
+ d3-selection "2 - 3"
+ kapsule "^1.16"
+ preact "10"
+
for-each@^0.3.3, for-each@^0.3.5:
version "0.3.5"
resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47"
@@ -4821,6 +4576,27 @@ for-each@^0.3.3, for-each@^0.3.5:
dependencies:
is-callable "^1.2.7"
+force-graph@^1.51:
+ version "1.51.4"
+ resolved "https://registry.yarnpkg.com/force-graph/-/force-graph-1.51.4.tgz#bd4b5b0d046f2c9e7c737988c821104a100a368d"
+ integrity sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==
+ dependencies:
+ "@tweenjs/tween.js" "18 - 25"
+ accessor-fn "1"
+ bezier-js "3 - 6"
+ canvas-color-tracker "^1.3"
+ d3-array "1 - 3"
+ d3-drag "2 - 3"
+ d3-force-3d "2 - 3"
+ d3-scale "1 - 4"
+ d3-scale-chromatic "1 - 3"
+ d3-selection "2 - 3"
+ d3-zoom "2 - 3"
+ float-tooltip "^1.7"
+ index-array-by "1"
+ kapsule "^1.16"
+ lodash-es "4"
+
foreground-child@^3.1.0:
version "3.3.1"
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
@@ -5210,10 +4986,10 @@ iconv-lite@0.6:
dependencies:
safer-buffer ">= 2.1.2 < 3.0.0"
-icu-minify@^4.12.0, icu-minify@^4.9.2:
- version "4.12.0"
- resolved "https://registry.yarnpkg.com/icu-minify/-/icu-minify-4.12.0.tgz#b187caecaa4369187a9025c624f657bf78397488"
- integrity sha512-zDmM05uav3t3+kxSfRrNlmyXOdj2b+uHA+p04CG32eJabtaHbugXujuL+YfRkwP9joAnf0Uh+RMGCKD5NLa5rQ==
+icu-minify@^4.13.0, icu-minify@^4.9.2:
+ version "4.13.0"
+ resolved "https://registry.yarnpkg.com/icu-minify/-/icu-minify-4.13.0.tgz#eba0eaafb7379cb8ec43572589d3da4fe446dd19"
+ integrity sha512-SIFMeUHZJjzS5RvIGvybKvWoHjDm9cGVEs2EpJ8PmywOdJLWyblPm7TdPLLoUtkJtwQD7iGhl2WMptZ+N0on+w==
dependencies:
"@formatjs/icu-messageformat-parser" "^3.4.0"
@@ -5272,6 +5048,11 @@ imurmurhash@^0.1.4:
resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==
+index-array-by@1:
+ version "1.4.2"
+ resolved "https://registry.yarnpkg.com/index-array-by/-/index-array-by-1.4.2.tgz#d6f82e9fbff3201c4dab64ba415d4d2923242fea"
+ integrity sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==
+
inherits@^2.0.3, inherits@~2.0.3:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
@@ -5607,6 +5388,11 @@ jackspeak@^3.1.2:
optionalDependencies:
"@pkgjs/parseargs" "^0.11.0"
+jerrypick@^1.1.1:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/jerrypick/-/jerrypick-1.1.2.tgz#eb5016304aeb9ac9b7dea6714aa5fe85b24cb8ad"
+ integrity sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==
+
jiti@^1.17.2:
version "1.21.7"
resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.7.tgz#9dd81043424a3d28458b193d965f0d18a2300ba9"
@@ -5638,10 +5424,10 @@ js-beautify@1.15.4:
js-cookie "^3.0.5"
nopt "^7.2.1"
-js-cookie@^3.0.5:
- version "3.0.5"
- resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc"
- integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==
+js-cookie@3.0.7, js-cookie@^3.0.5:
+ version "3.0.7"
+ resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.7.tgz#0a53abfc459c8e89c85d7a38eb6cb68714965b8c"
+ integrity sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==
js-tokens@^10.0.0:
version "10.0.0"
@@ -5735,6 +5521,13 @@ jws@^3.2.2:
jwa "^1.4.2"
safe-buffer "^5.0.1"
+kapsule@^1.16:
+ version "1.16.3"
+ resolved "https://registry.yarnpkg.com/kapsule/-/kapsule-1.16.3.tgz#5684ed89838b6658b30d0f2cc056dffc3ba68c30"
+ integrity sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==
+ dependencies:
+ lodash-es "4"
+
katex@^0.16.9:
version "0.16.45"
resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.45.tgz#ba60d39c54746b6b8d39ce0e7f6eace07143149c"
@@ -5874,7 +5667,7 @@ locate-path@^6.0.0:
dependencies:
p-locate "^5.0.0"
-lodash-es@4.17.21, lodash-es@4.18.1, lodash-es@^4.17.21:
+lodash-es@4, lodash-es@4.17.21, lodash-es@4.18.1, lodash-es@^4.17.21:
version "4.18.1"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d"
integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==
@@ -5970,12 +5763,12 @@ magic-string@^0.30.21:
dependencies:
"@jridgewell/sourcemap-codec" "^1.5.5"
-magicast@^0.5.1:
- version "0.5.2"
- resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.5.2.tgz#70cea9df729c164485049ea5df85a390281dfb9d"
- integrity sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==
+magicast@^0.5.2:
+ version "0.5.3"
+ resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.5.3.tgz#1800f6e76dd8b0dbe7257438a2c336aefabbd905"
+ integrity sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==
dependencies:
- "@babel/parser" "^7.29.0"
+ "@babel/parser" "^7.29.3"
"@babel/types" "^7.29.0"
source-map-js "^1.2.1"
@@ -6768,7 +6561,7 @@ mz@^2.7.0:
object-assign "^4.0.1"
thenify-all "^1.0.0"
-nanoid@3.3.3, nanoid@3.3.8, nanoid@4.0.2, nanoid@^3.3.11, nanoid@^3.3.12:
+nanoid@3.3.3, nanoid@3.3.8, nanoid@4.0.2, nanoid@^3.3.11:
version "3.3.8"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf"
integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==
@@ -6789,9 +6582,9 @@ negotiator@^1.0.0:
integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==
next-intl-swc-plugin-extractor@^4.9.2:
- version "4.12.0"
- resolved "https://registry.yarnpkg.com/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.12.0.tgz#a8710a0da49a2fa45bc66696975764cc83717b94"
- integrity sha512-jUxVEu1Nryjt4YgaDktSys7ioOgQfcNPF/SF2dbPNxbVb6U+P1INRgHeCVN+EC59H2rnTFIQwbddmOCrUWFr3g==
+ version "4.13.0"
+ resolved "https://registry.yarnpkg.com/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.13.0.tgz#5247bd0fbc873711aaaf1aeef50c8981829ab562"
+ integrity sha512-6S/fJI0KXvLCL8nhBo9P8eGaJPzmwJBTCzX0NaUIj0VyU8U89d//T+vjMLdNIXl5MlLaYH7B9MbAjb8Mvu+tqQ==
next-intl@4.9.2:
version "4.9.2"
@@ -6812,26 +6605,26 @@ next-themes@0.2.1:
resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.2.1.tgz#0c9f128e847979daf6c67f70b38e6b6567856e45"
integrity sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==
-next@16.2.6:
- version "16.2.6"
- resolved "https://registry.yarnpkg.com/next/-/next-16.2.6.tgz#4564833d2865efc598b7c63541b5771792d3d811"
- integrity sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==
+next@16.2.9:
+ version "16.2.9"
+ resolved "https://registry.yarnpkg.com/next/-/next-16.2.9.tgz#320c5b5701deeb18c0b596d137ca5dccf976682c"
+ integrity sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==
dependencies:
- "@next/env" "16.2.6"
+ "@next/env" "16.2.9"
"@swc/helpers" "0.5.15"
baseline-browser-mapping "^2.9.19"
caniuse-lite "^1.0.30001579"
postcss "8.4.31"
styled-jsx "5.1.6"
optionalDependencies:
- "@next/swc-darwin-arm64" "16.2.6"
- "@next/swc-darwin-x64" "16.2.6"
- "@next/swc-linux-arm64-gnu" "16.2.6"
- "@next/swc-linux-arm64-musl" "16.2.6"
- "@next/swc-linux-x64-gnu" "16.2.6"
- "@next/swc-linux-x64-musl" "16.2.6"
- "@next/swc-win32-arm64-msvc" "16.2.6"
- "@next/swc-win32-x64-msvc" "16.2.6"
+ "@next/swc-darwin-arm64" "16.2.9"
+ "@next/swc-darwin-x64" "16.2.9"
+ "@next/swc-linux-arm64-gnu" "16.2.9"
+ "@next/swc-linux-arm64-musl" "16.2.9"
+ "@next/swc-linux-x64-gnu" "16.2.9"
+ "@next/swc-linux-x64-musl" "16.2.9"
+ "@next/swc-win32-arm64-msvc" "16.2.9"
+ "@next/swc-win32-x64-msvc" "16.2.9"
sharp "^0.34.5"
node-addon-api@^7.0.0:
@@ -7234,7 +7027,7 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
-postcss@8:
+postcss@8.4.31, postcss@8.5.10, postcss@^8.0.9, postcss@^8.5.3:
version "8.5.10"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.10.tgz#8992d8c30acf3f12169e7c09514a12fed7e48356"
integrity sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==
@@ -7243,14 +7036,10 @@ postcss@8:
picocolors "^1.1.1"
source-map-js "^1.2.1"
-postcss@8.4.31, postcss@>=8.5.10, postcss@^8.0.9, postcss@^8.5.3:
- version "8.5.15"
- resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.15.tgz#d1eaf677a324e9ec02196da2d3fecf4a0b9a735c"
- integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==
- dependencies:
- nanoid "^3.3.12"
- picocolors "^1.1.1"
- source-map-js "^1.2.1"
+preact@10:
+ version "10.29.2"
+ resolved "https://registry.yarnpkg.com/preact/-/preact-10.29.2.tgz#3e6069c471718b8d124d1cd67565114532e88d70"
+ integrity sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==
prelude-ls@^1.2.1:
version "1.2.1"
@@ -7285,7 +7074,7 @@ process@^0.11.10:
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
-prop-types@^15.8.1:
+prop-types@15, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -7469,11 +7258,27 @@ react-dom@19.2.4:
dependencies:
scheduler "^0.27.0"
+react-force-graph-2d@^1.29.1:
+ version "1.29.1"
+ resolved "https://registry.yarnpkg.com/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz#a0784d4387b12b28e2b552058ec09d092b4e8cda"
+ integrity sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==
+ dependencies:
+ force-graph "^1.51"
+ prop-types "15"
+ react-kapsule "^2.5"
+
react-is@^16.13.1:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
+react-kapsule@^2.5:
+ version "2.5.7"
+ resolved "https://registry.yarnpkg.com/react-kapsule/-/react-kapsule-2.5.7.tgz#dcd957ae8e897ff48055fc8ff48ed04ebe3c5bd2"
+ integrity sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==
+ dependencies:
+ jerrypick "^1.1.1"
+
react-markdown@10.1.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-10.1.0.tgz#e22bc20faddbc07605c15284255653c0f3bad5ca"
@@ -8124,10 +7929,10 @@ stackback@0.0.2:
resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b"
integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==
-std-env@^3.10.0:
- version "3.10.0"
- resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b"
- integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==
+std-env@^4.0.0-rc.1:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/std-env/-/std-env-4.1.0.tgz#45899abc590d86d682e87f0acd1033a75084cd3f"
+ integrity sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==
stop-iteration-iterator@^1.1.0:
version "1.1.0"
@@ -8442,6 +8247,11 @@ tinybench@^2.9.0:
resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b"
integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==
+tinycolor2@^1.6.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e"
+ integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==
+
tinyexec@^1.0.2:
version "1.1.1"
resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.1.1.tgz#e1ff45dfa60d1dedb91b734956b78f6c2a3e821b"
@@ -8455,7 +8265,7 @@ tinyglobby@^0.2.11, tinyglobby@^0.2.13, tinyglobby@^0.2.15:
fdir "^6.5.0"
picomatch "^4.0.4"
-tinyrainbow@^3.0.3:
+tinyrainbow@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-3.1.0.tgz#1d8a623893f95cf0a2ddb9e5d11150e191409421"
integrity sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==
@@ -8764,13 +8574,13 @@ use-debounce@^10.0.4:
integrity sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ==
use-intl@^4.9.2:
- version "4.12.0"
- resolved "https://registry.yarnpkg.com/use-intl/-/use-intl-4.12.0.tgz#1017700cf01a60caecc4f1625a934ee72503d832"
- integrity sha512-r+qVb7UI1+kiOhjYsmsNUCY+jrnjVopwGeFlmMyQj4YInlwZzgMeMSv9n8MqnWWy77HL5BVM8K2WgX50SbtcpA==
+ version "4.13.0"
+ resolved "https://registry.yarnpkg.com/use-intl/-/use-intl-4.13.0.tgz#bc60d5b71864a5adf906c901fc660b6c578885f9"
+ integrity sha512-fAFDrWaASxlhXOipcOyb5VDD+YONqj6+8O8EcG/J7RBoOUF3A8YahRWLN+mBxYMrlMQB8N6Voqk5X+YC+HSL0A==
dependencies:
"@formatjs/fast-memoize" "^3.1.0"
"@schummar/icu-type-parser" "1.21.5"
- icu-minify "^4.12.0"
+ icu-minify "^4.13.0"
intl-messageformat "^11.1.0"
use-sidecar@^1.1.3:
@@ -8791,16 +8601,11 @@ util-deprecate@^1.0.2, util-deprecate@~1.0.1:
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
-uuid@11.1.1:
+uuid@11.1.1, "uuid@^9.0.0 || ^10 || ^11.1.0 || ^12 || ^13 || ^14.0.0":
version "11.1.1"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.1.tgz#f6d81d2e1c65d00762e5e29b16c5d2d995e208ad"
integrity sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==
-"uuid@^9.0.0 || ^10 || ^11.1.0 || ^12 || ^13 || ^14.0.0":
- version "14.0.0"
- resolved "https://registry.yarnpkg.com/uuid/-/uuid-14.0.0.tgz#0af883220163d264ffe0c084f6b8a89b9666966d"
- integrity sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==
-
uvu@^0.5.0:
version "0.5.6"
resolved "https://registry.yarnpkg.com/uvu/-/uvu-0.5.6.tgz#2754ca20bcb0bb59b64e9985e84d2e81058502df"
@@ -8855,7 +8660,7 @@ victory-vendor@^37.0.2:
d3-time "^3.0.0"
d3-timer "^3.0.1"
-vite@6.4.2, "vite@^6.0.0 || ^7.0.0":
+vite@6.4.2, "vite@^6.0.0 || ^7.0.0 || ^8.0.0":
version "6.4.2"
resolved "https://registry.yarnpkg.com/vite/-/vite-6.4.2.tgz#a4e548ca3a90ca9f3724582cab35e1ba15efc6f2"
integrity sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==
@@ -8869,30 +8674,30 @@ vite@6.4.2, "vite@^6.0.0 || ^7.0.0":
optionalDependencies:
fsevents "~2.3.3"
-vitest@4.0.17:
- version "4.0.17"
- resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.0.17.tgz#0e39e67a909a132afe434ee1278bdcf0c17fd063"
- integrity sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==
- dependencies:
- "@vitest/expect" "4.0.17"
- "@vitest/mocker" "4.0.17"
- "@vitest/pretty-format" "4.0.17"
- "@vitest/runner" "4.0.17"
- "@vitest/snapshot" "4.0.17"
- "@vitest/spy" "4.0.17"
- "@vitest/utils" "4.0.17"
- es-module-lexer "^1.7.0"
- expect-type "^1.2.2"
+vitest@4.1.8:
+ version "4.1.8"
+ resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.8.tgz#9fed17277bf7350497e54338898a7afd46dfd509"
+ integrity sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==
+ dependencies:
+ "@vitest/expect" "4.1.8"
+ "@vitest/mocker" "4.1.8"
+ "@vitest/pretty-format" "4.1.8"
+ "@vitest/runner" "4.1.8"
+ "@vitest/snapshot" "4.1.8"
+ "@vitest/spy" "4.1.8"
+ "@vitest/utils" "4.1.8"
+ es-module-lexer "^2.0.0"
+ expect-type "^1.3.0"
magic-string "^0.30.21"
obug "^2.1.1"
pathe "^2.0.3"
picomatch "^4.0.3"
- std-env "^3.10.0"
+ std-env "^4.0.0-rc.1"
tinybench "^2.9.0"
tinyexec "^1.0.2"
tinyglobby "^0.2.15"
- tinyrainbow "^3.0.3"
- vite "^6.0.0 || ^7.0.0"
+ tinyrainbow "^3.1.0"
+ vite "^6.0.0 || ^7.0.0 || ^8.0.0"
why-is-node-running "^2.3.0"
vscode-jsonrpc@8.2.0: