@tuturuuu/ui 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +4 -4
  3. package/src/components/ui/calendar-app/components/habits-panel.tsx +5 -2
  4. package/src/components/ui/calendar-app/components/priority-view.tsx +15 -7
  5. package/src/components/ui/calendar-app/components/scheduling-dialog.tsx +7 -3
  6. package/src/components/ui/calendar-app/components/task-scheduler-panel.tsx +7 -3
  7. package/src/components/ui/calendar-app/components/time-tracker/index.tsx +14 -9
  8. package/src/components/ui/custom/settings/task-settings.tsx +6 -2
  9. package/src/components/ui/custom/tables/data-table.tsx +58 -8
  10. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -1
  11. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +3 -6
  12. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +11 -1
  13. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -3
  14. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-resource-context.test.ts +113 -0
  15. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-resource-context.ts +50 -0
  16. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +65 -18
  17. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +1 -1
  18. package/src/components/ui/tu-do/cycles/task-cycles-client.tsx +23 -14
  19. package/src/components/ui/tu-do/estimates/use-task-estimates.ts +9 -4
  20. package/src/components/ui/tu-do/habits/client.tsx +4 -1
  21. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +6 -3
  22. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +19 -11
  23. package/src/components/ui/tu-do/my-tasks/use-my-tasks-query.ts +15 -6
  24. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +18 -13
  25. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +17 -10
  26. package/src/components/ui/tu-do/shared/fade-setting-initializer.tsx +3 -1
  27. package/src/components/ui/tu-do/shared/list-view.tsx +1 -1
  28. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +6 -2
  29. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-update-shared-task.test.ts +9 -5
  30. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.ts +13 -8
  31. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-overrides.ts +21 -9
  32. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-update-shared-task.ts +10 -5
  33. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +4 -2
  34. package/src/components/ui/tu-do/shared/task-estimation-picker.tsx +5 -2
  35. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +1 -1
  36. package/src/hooks/__tests__/use-task-actions.test.tsx +51 -0
  37. package/src/hooks/task-actions-personal-external.ts +2 -3
  38. package/src/hooks/use-board-actions.ts +27 -17
  39. package/src/hooks/use-calendar.tsx +7 -3
  40. package/src/hooks/use-settings-dialog-shortcut.ts +51 -0
  41. package/src/lib/task-personal-external.ts +49 -0
  42. package/src/lib/tasks-app-url.ts +85 -0
@@ -9,6 +9,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@tuturuuu/ui/popover';
9
9
  import { Switch } from '@tuturuuu/ui/switch';
10
10
  import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
11
11
  import { useTranslations } from 'next-intl';
12
+ import { getTaskApiUrl } from '../../../../../../lib/tasks-app-url';
12
13
  import { TASK_SOUND_EFFECTS_ENABLED_CONFIG_ID } from '../../task-sound-effects';
13
14
 
14
15
  interface TaskSettingsData {
@@ -54,7 +55,9 @@ export function QuickSettingsPopover({
54
55
  const { data: settings, isLoading } = useQuery({
55
56
  queryKey: ['user-task-settings'],
56
57
  queryFn: async (): Promise<TaskSettingsData> => {
57
- const res = await fetch('/api/v1/users/task-settings');
58
+ const res = await fetch(getTaskApiUrl('/api/v1/users/task-settings'), {
59
+ credentials: 'include',
60
+ });
58
61
  if (!res.ok) {
59
62
  // Return defaults if API fails
60
63
  return { task_auto_assign_to_self: false, fade_completed_tasks: false };
@@ -68,8 +71,9 @@ export function QuickSettingsPopover({
68
71
 
69
72
  const updateSettings = useMutation({
70
73
  mutationFn: async (data: Partial<TaskSettingsData>) => {
71
- const res = await fetch('/api/v1/users/task-settings', {
74
+ const res = await fetch(getTaskApiUrl('/api/v1/users/task-settings'), {
72
75
  method: 'PATCH',
76
+ credentials: 'include',
73
77
  headers: { 'Content-Type': 'application/json' },
74
78
  body: JSON.stringify(data),
75
79
  });
@@ -48,11 +48,15 @@ describe('useUpdateSharedTask', () => {
48
48
  // Cast to any to access mutationFn which is available in the mock return value
49
49
  const response = await (result.current as any).mutationFn(payload);
50
50
 
51
- expect(global.fetch).toHaveBeenCalledWith('/api/v1/shared/tasks/SHARE123', {
52
- method: 'PATCH',
53
- headers: { 'Content-Type': 'application/json' },
54
- body: JSON.stringify(payload.updates),
55
- });
51
+ expect(global.fetch).toHaveBeenCalledWith(
52
+ 'http://localhost:7809/api/v1/shared/tasks/SHARE123',
53
+ {
54
+ method: 'PATCH',
55
+ credentials: 'include',
56
+ headers: { 'Content-Type': 'application/json' },
57
+ body: JSON.stringify(payload.updates),
58
+ }
59
+ );
56
60
  expect(response).toEqual(mockResponse);
57
61
  });
58
62
 
@@ -14,6 +14,7 @@ import {
14
14
  type WorkspaceTaskDescriptionUpdatePayload,
15
15
  type WorkspaceTaskUpdatePayload,
16
16
  } from '@tuturuuu/internal-api/tasks';
17
+ import { getTaskApiUrl } from '../../../../../../lib/tasks-app-url';
17
18
  import type { WorkspaceTaskLabel } from '../types';
18
19
 
19
20
  const TASK_DESCRIPTION_DIRECT_BODY_LIMIT_BYTES = 192 * 1024;
@@ -261,14 +262,18 @@ export async function createWorkspaceLabel(
261
262
  wsId: string,
262
263
  payload: { name: string; color: string }
263
264
  ) {
264
- const response = await fetch(`/api/v1/workspaces/${wsId}/labels`, {
265
- method: 'POST',
266
- headers: {
267
- 'Content-Type': 'application/json',
268
- },
269
- body: JSON.stringify(payload),
270
- cache: 'no-store',
271
- });
265
+ const response = await fetch(
266
+ getTaskApiUrl(`/api/v1/workspaces/${wsId}/labels`),
267
+ {
268
+ method: 'POST',
269
+ credentials: 'include',
270
+ headers: {
271
+ 'Content-Type': 'application/json',
272
+ },
273
+ body: JSON.stringify(payload),
274
+ cache: 'no-store',
275
+ }
276
+ );
272
277
 
273
278
  if (!response.ok) {
274
279
  throw new Error(await getErrorMessage(response, 'Failed to create label'));
@@ -3,6 +3,7 @@
3
3
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
4
  import type { TaskUserOverride } from '@tuturuuu/types';
5
5
  import { toast } from '@tuturuuu/ui/sonner';
6
+ import { getTaskApiUrl } from '../../../../../../lib/tasks-app-url';
6
7
 
7
8
  type OverrideInput = Partial<
8
9
  Omit<TaskUserOverride, 'task_id' | 'user_id' | 'created_at' | 'updated_at'>
@@ -23,7 +24,10 @@ export function useTaskOverrides(
23
24
  queryKey,
24
25
  queryFn: async (): Promise<TaskUserOverride | null> => {
25
26
  if (!taskId) return null;
26
- const res = await fetch(`/api/v1/users/me/tasks/${taskId}/overrides`);
27
+ const res = await fetch(
28
+ getTaskApiUrl(`/api/v1/users/me/tasks/${taskId}/overrides`),
29
+ { credentials: 'include' }
30
+ );
27
31
  if (!res.ok) throw new Error('Failed to fetch override');
28
32
  const json = await res.json();
29
33
  return json.data ?? null;
@@ -35,11 +39,15 @@ export function useTaskOverrides(
35
39
  const upsertMutation = useMutation({
36
40
  mutationFn: async (input: OverrideInput): Promise<TaskUserOverride> => {
37
41
  if (!taskId) throw new Error('No task ID');
38
- const res = await fetch(`/api/v1/users/me/tasks/${taskId}/overrides`, {
39
- method: 'PUT',
40
- headers: { 'Content-Type': 'application/json' },
41
- body: JSON.stringify(input),
42
- });
42
+ const res = await fetch(
43
+ getTaskApiUrl(`/api/v1/users/me/tasks/${taskId}/overrides`),
44
+ {
45
+ method: 'PUT',
46
+ credentials: 'include',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify(input),
49
+ }
50
+ );
43
51
  if (!res.ok) {
44
52
  const err = await res.json().catch(() => null);
45
53
  throw new Error(err?.error || 'Failed to save override');
@@ -85,9 +93,13 @@ export function useTaskOverrides(
85
93
  const deleteMutation = useMutation({
86
94
  mutationFn: async () => {
87
95
  if (!taskId) throw new Error('No task ID');
88
- const res = await fetch(`/api/v1/users/me/tasks/${taskId}/overrides`, {
89
- method: 'DELETE',
90
- });
96
+ const res = await fetch(
97
+ getTaskApiUrl(`/api/v1/users/me/tasks/${taskId}/overrides`),
98
+ {
99
+ method: 'DELETE',
100
+ credentials: 'include',
101
+ }
102
+ );
91
103
  if (!res.ok) throw new Error('Failed to delete override');
92
104
  },
93
105
  onMutate: async () => {
@@ -4,6 +4,7 @@ import {
4
4
  useQueryClient,
5
5
  } from '@tanstack/react-query';
6
6
  import type { Task } from '@tuturuuu/types/primitives/Task';
7
+ import { getTaskApiUrl } from '../../../../../../lib/tasks-app-url';
7
8
 
8
9
  export interface TaskUpdatePayload {
9
10
  name?: string;
@@ -33,11 +34,15 @@ export function useUpdateSharedTask(): UseMutationResult<
33
34
  shareCode: string;
34
35
  updates: TaskUpdatePayload;
35
36
  }) => {
36
- const response = await fetch(`/api/v1/shared/tasks/${shareCode}`, {
37
- method: 'PATCH',
38
- headers: { 'Content-Type': 'application/json' },
39
- body: JSON.stringify(updates),
40
- });
37
+ const response = await fetch(
38
+ getTaskApiUrl(`/api/v1/shared/tasks/${shareCode}`),
39
+ {
40
+ method: 'PATCH',
41
+ credentials: 'include',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify(updates),
44
+ }
45
+ );
41
46
 
42
47
  if (!response.ok) {
43
48
  const error = await response.json().catch(() => null);
@@ -35,6 +35,7 @@ import { usePathname } from 'next/navigation';
35
35
  import { useLocale, useTranslations } from 'next-intl';
36
36
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
37
37
  import * as Y from 'yjs';
38
+ import { getTaskApiUrl } from '../../../../lib/tasks-app-url';
38
39
  import { BoardEstimationConfigDialog } from '../boards/boardId/task-dialogs/BoardEstimationConfigDialog';
39
40
  import { TaskNewLabelDialog } from '../boards/boardId/task-dialogs/TaskNewLabelDialog';
40
41
  import { TaskNewProjectDialog } from '../boards/boardId/task-dialogs/TaskNewProjectDialog';
@@ -391,8 +392,9 @@ export function TaskEditDialog({
391
392
  const { data: userTaskSettings } = useQuery({
392
393
  queryKey: ['user-task-settings'],
393
394
  queryFn: async () => {
394
- const res = await fetch('/api/v1/users/task-settings', {
395
+ const res = await fetch(getTaskApiUrl('/api/v1/users/task-settings'), {
395
396
  cache: 'no-store',
397
+ credentials: 'include',
396
398
  });
397
399
  if (!res.ok) return { task_auto_assign_to_self: false };
398
400
  return res.json() as Promise<{ task_auto_assign_to_self: boolean }>;
@@ -793,7 +795,7 @@ export function TaskEditDialog({
793
795
  enabled: !!isOpen && !isCreateMode && !!task?.id,
794
796
  queryFn: async () => {
795
797
  const response = await fetch(
796
- `/api/v1/users/me/tasks/${task!.id}/schedule`,
798
+ getTaskApiUrl(`/api/v1/users/me/tasks/${task!.id}/schedule`),
797
799
  { cache: 'no-store' }
798
800
  );
799
801
  if (!response.ok) return null;
@@ -17,6 +17,7 @@ import { Label } from '@tuturuuu/ui/label';
17
17
  import { Popover, PopoverContent, PopoverTrigger } from '@tuturuuu/ui/popover';
18
18
  import { cn } from '@tuturuuu/utils/format';
19
19
  import { useMemo, useState } from 'react';
20
+ import { getTaskApiUrl } from '../../../../lib/tasks-app-url';
20
21
  import {
21
22
  buildEstimationIndices,
22
23
  mapEstimationPoints,
@@ -58,11 +59,13 @@ export function TaskEstimationPicker({
58
59
  queryFn: async () => {
59
60
  if (!boardId) return null;
60
61
  const response = await fetch(
61
- `/api/v1/workspaces/${wsId}/boards/${boardId}/estimation`
62
+ getTaskApiUrl(`/api/v1/workspaces/${wsId}/boards/${boardId}/estimation`)
62
63
  );
63
64
  if (!response.ok) {
64
65
  // If the endpoint doesn't exist, we'll create a fallback
65
- const boardResponse = await fetch(`/api/v1/workspaces/${wsId}/boards`);
66
+ const boardResponse = await fetch(
67
+ getTaskApiUrl(`/api/v1/workspaces/${wsId}/boards`)
68
+ );
66
69
  if (!boardResponse.ok) throw new Error('Failed to fetch boards');
67
70
  const boards = await boardResponse.json();
68
71
  const board = boards.find((b: any) => b.id === boardId);
@@ -180,7 +180,7 @@ export function TaskRowActionsMenu({
180
180
  canUseBoardAssignees ??
181
181
  (task.source_workspace_id ? true : !isPersonalWorkspace),
182
182
  assigneeMemberSource: task.source_workspace_id
183
- ? 'workspace'
183
+ ? 'board'
184
184
  : assigneeMemberSource,
185
185
  }
186
186
  );
@@ -41,6 +41,8 @@ vi.mock('@tuturuuu/internal-api/tasks', () => ({
41
41
  }));
42
42
 
43
43
  vi.mock('@tuturuuu/utils/task-helper', () => ({
44
+ isPersonalExternalStagingListId: (listId: string | null) =>
45
+ listId?.startsWith('personal-external-staging:') ?? false,
44
46
  useUpdateTask: vi.fn(),
45
47
  }));
46
48
 
@@ -1689,6 +1691,55 @@ describe('useTaskActions', () => {
1689
1691
  expect(setMenuOpen).toHaveBeenCalledWith(false);
1690
1692
  });
1691
1693
 
1694
+ it('moves a personal-workspace task with personal board metadata through the normal task route', async () => {
1695
+ const personalTask = {
1696
+ ...mockTask,
1697
+ personal_board_id: 'board-1',
1698
+ personal_list_id: 'list-1',
1699
+ source_workspace_id: 'ws-1',
1700
+ source_board_id: 'board-1',
1701
+ source_list_id: 'list-1',
1702
+ } as unknown as Task;
1703
+ const targetList = {
1704
+ id: 'list-2',
1705
+ name: 'Later',
1706
+ board_id: 'board-1',
1707
+ status: 'active',
1708
+ created_at: '2025-01-01T00:00:00Z',
1709
+ archived: false,
1710
+ deleted: false,
1711
+ creator_id: 'user-1',
1712
+ color: null,
1713
+ position: 3,
1714
+ } as unknown as TaskList;
1715
+
1716
+ queryClient.setQueryData(['tasks', 'board-1'], [personalTask]);
1717
+
1718
+ const { result } = renderHook(
1719
+ () =>
1720
+ useTaskActions({
1721
+ task: personalTask,
1722
+ boardId: 'board-1',
1723
+ targetCompletionList: mockCompletionList,
1724
+ targetClosedList: mockClosedList,
1725
+ availableLists: [...mockAvailableLists, targetList],
1726
+ onUpdate: vi.fn(),
1727
+ setIsLoading: vi.fn(),
1728
+ setMenuOpen: vi.fn(),
1729
+ }),
1730
+ { wrapper }
1731
+ );
1732
+
1733
+ await act(async () => {
1734
+ await result.current.handleMoveToList('list-2');
1735
+ });
1736
+
1737
+ expect(mockUpdateWorkspaceTask).toHaveBeenCalledWith('ws-1', 'task-1', {
1738
+ list_id: 'list-2',
1739
+ });
1740
+ expect(mockUpsertCurrentUserTaskPersonalPlacement).not.toHaveBeenCalled();
1741
+ });
1742
+
1692
1743
  it('moves an external task between personal lists through personal placement only', async () => {
1693
1744
  const targetList = {
1694
1745
  id: 'list-2',
@@ -11,11 +11,10 @@ import {
11
11
  isTaskBoardCompletedStatus,
12
12
  isTaskBoardTerminalStatus,
13
13
  } from '@tuturuuu/utils/task-list-status';
14
+ import { isPersonalExternalOverlayTask } from '../lib/task-personal-external';
14
15
 
15
16
  export function isPersonalExternalTask(task?: Task) {
16
- return (
17
- task?.is_personal_external === true || Boolean(task?.personal_board_id)
18
- );
17
+ return isPersonalExternalOverlayTask(task);
19
18
  }
20
19
 
21
20
  function findFirstListByStatus(lists: TaskList[], status: TaskBoardStatus) {
@@ -1,5 +1,6 @@
1
1
  import { useMutation, useQueryClient } from '@tanstack/react-query';
2
2
  import { toast } from '@tuturuuu/ui/sonner';
3
+ import { getTaskApiUrl } from '../lib/tasks-app-url';
3
4
 
4
5
  async function boardAction(
5
6
  wsId: string,
@@ -7,13 +8,17 @@ async function boardAction(
7
8
  method: 'PUT' | 'DELETE' | 'PATCH',
8
9
  body?: any
9
10
  ) {
10
- const response = await fetch(`/api/v1/workspaces/${wsId}/boards/${boardId}`, {
11
- method,
12
- headers: {
13
- 'Content-Type': 'application/json',
14
- },
15
- body: body ? JSON.stringify(body) : undefined,
16
- });
11
+ const response = await fetch(
12
+ getTaskApiUrl(`/api/v1/workspaces/${wsId}/boards/${boardId}`),
13
+ {
14
+ method,
15
+ credentials: 'include',
16
+ headers: {
17
+ 'Content-Type': 'application/json',
18
+ },
19
+ body: body ? JSON.stringify(body) : undefined,
20
+ }
21
+ );
17
22
 
18
23
  if (!response.ok) {
19
24
  const errorData = await response.json();
@@ -29,9 +34,10 @@ async function archiveAction(
29
34
  method: 'POST' | 'DELETE'
30
35
  ) {
31
36
  const response = await fetch(
32
- `/api/v1/workspaces/${wsId}/boards/${boardId}/archive`,
37
+ getTaskApiUrl(`/api/v1/workspaces/${wsId}/boards/${boardId}/archive`),
33
38
  {
34
39
  method,
40
+ credentials: 'include',
35
41
  headers: {
36
42
  'Content-Type': 'application/json',
37
43
  },
@@ -166,15 +172,19 @@ export function useBoardActions(wsId: string) {
166
172
  { boardId: string; options?: BoardActionOptions }
167
173
  >({
168
174
  mutationFn: ({ boardId }) =>
169
- fetch(`/api/v1/workspaces/${wsId}/task-boards/${boardId}/copy`, {
170
- method: 'POST',
171
- headers: {
172
- 'Content-Type': 'application/json',
173
- },
174
- body: JSON.stringify({
175
- targetWorkspaceId: wsId,
176
- }),
177
- }).then(async (res) => {
175
+ fetch(
176
+ getTaskApiUrl(`/api/v1/workspaces/${wsId}/task-boards/${boardId}/copy`),
177
+ {
178
+ method: 'POST',
179
+ credentials: 'include',
180
+ headers: {
181
+ 'Content-Type': 'application/json',
182
+ },
183
+ body: JSON.stringify({
184
+ targetWorkspaceId: wsId,
185
+ }),
186
+ }
187
+ ).then(async (res) => {
178
188
  if (!res.ok) {
179
189
  const errorData = await res.json();
180
190
  throw new Error(errorData.error || 'Failed to duplicate board');
@@ -29,6 +29,7 @@ import {
29
29
  useRef,
30
30
  useState,
31
31
  } from 'react';
32
+ import { getTaskApiUrl } from '../lib/tasks-app-url';
32
33
  import { useCalendarSync } from './use-calendar-sync';
33
34
 
34
35
  // Utility function to round time to nearest 15-minute interval
@@ -319,11 +320,14 @@ async function syncTaskDurationAfterEventChange(
319
320
  let totalScheduledMinutes = resizedEventMinutes;
320
321
 
321
322
  const scheduleResponse = await fetch(
322
- options?.isPersonalCalendar
323
- ? `/api/v1/users/me/tasks/${taskId}/schedule`
324
- : `/api/v1/workspaces/${calendarWsId}/tasks/${taskId}/schedule`,
323
+ getTaskApiUrl(
324
+ options?.isPersonalCalendar
325
+ ? `/api/v1/users/me/tasks/${taskId}/schedule`
326
+ : `/api/v1/workspaces/${calendarWsId}/tasks/${taskId}/schedule`
327
+ ),
325
328
  {
326
329
  cache: 'no-store',
330
+ credentials: 'include',
327
331
  }
328
332
  );
329
333
 
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+
5
+ interface UseSettingsDialogShortcutOptions {
6
+ enabled: boolean;
7
+ onOpen: () => void;
8
+ }
9
+
10
+ function isEditableShortcutTarget(target: EventTarget | null) {
11
+ if (!(target instanceof HTMLElement)) return false;
12
+
13
+ const tagName = target.tagName.toLowerCase();
14
+
15
+ return (
16
+ target.isContentEditable ||
17
+ tagName === 'input' ||
18
+ tagName === 'textarea' ||
19
+ tagName === 'select'
20
+ );
21
+ }
22
+
23
+ function isSettingsDialogShortcut(event: KeyboardEvent) {
24
+ return (event.metaKey || event.ctrlKey) && !event.altKey && event.key === ',';
25
+ }
26
+
27
+ /**
28
+ * Opens the app settings dialog on Cmd/Ctrl+, — the platform-wide convention.
29
+ * Ignores the shortcut while typing in editable fields and when another handler
30
+ * has already called preventDefault(). Shared by apps/web and every satellite
31
+ * app (wired once in the satellite user-nav shell).
32
+ */
33
+ export function useSettingsDialogShortcut({
34
+ enabled,
35
+ onOpen,
36
+ }: UseSettingsDialogShortcutOptions) {
37
+ useEffect(() => {
38
+ if (!enabled) return;
39
+
40
+ const handleKeyDown = (event: KeyboardEvent) => {
41
+ if (event.defaultPrevented || !isSettingsDialogShortcut(event)) return;
42
+ if (isEditableShortcutTarget(event.target)) return;
43
+
44
+ event.preventDefault();
45
+ onOpen();
46
+ };
47
+
48
+ window.addEventListener('keydown', handleKeyDown);
49
+ return () => window.removeEventListener('keydown', handleKeyDown);
50
+ }, [enabled, onOpen]);
51
+ }
@@ -0,0 +1,49 @@
1
+ import type { Task } from '@tuturuuu/types/primitives/Task';
2
+ import { isPersonalExternalStagingListId } from '@tuturuuu/utils/task-helper';
3
+
4
+ function hasValue(value: string | null | undefined): value is string {
5
+ return typeof value === 'string' && value.length > 0;
6
+ }
7
+
8
+ export function isPersonalExternalOverlayTask(task?: Task | null) {
9
+ if (!task) return false;
10
+
11
+ if (task.is_personal_external === true) {
12
+ return true;
13
+ }
14
+
15
+ if (task.is_personal_external === false) {
16
+ return false;
17
+ }
18
+
19
+ if (isPersonalExternalStagingListId(task.list_id)) {
20
+ return true;
21
+ }
22
+
23
+ const personalBoardId = hasValue(task.personal_board_id)
24
+ ? task.personal_board_id
25
+ : null;
26
+ const personalListId = hasValue(task.personal_list_id)
27
+ ? task.personal_list_id
28
+ : null;
29
+
30
+ if (!personalBoardId && !personalListId) {
31
+ return false;
32
+ }
33
+
34
+ if (
35
+ hasValue(task.source_board_id) &&
36
+ personalBoardId &&
37
+ task.source_board_id !== personalBoardId
38
+ ) {
39
+ return true;
40
+ }
41
+
42
+ return (
43
+ !hasValue(task.source_board_id) &&
44
+ hasValue(task.source_workspace_id) &&
45
+ hasValue(task.source_list_id) &&
46
+ personalListId !== null &&
47
+ task.source_list_id !== personalListId
48
+ );
49
+ }
@@ -0,0 +1,85 @@
1
+ function normalizeOrigin(value?: string) {
2
+ if (!value) {
3
+ return null;
4
+ }
5
+
6
+ const [firstValue] = value
7
+ .split(/[,\n]/u)
8
+ .map((entry) => entry.trim())
9
+ .filter(Boolean);
10
+
11
+ if (!firstValue) {
12
+ return null;
13
+ }
14
+
15
+ const normalized = /^[a-z]+:\/\//iu.test(firstValue)
16
+ ? firstValue
17
+ : `https://${firstValue}`;
18
+
19
+ try {
20
+ return new URL(normalized).origin;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ function getTasksAppOriginForBrowser() {
27
+ const configured =
28
+ normalizeOrigin(process.env.NEXT_PUBLIC_TASKS_APP_URL) ??
29
+ normalizeOrigin(process.env.NEXT_PUBLIC_TUDO_APP_URL);
30
+
31
+ if (configured) {
32
+ return configured;
33
+ }
34
+
35
+ if (typeof window === 'undefined') {
36
+ return null;
37
+ }
38
+
39
+ const { hostname, port, protocol } = window.location;
40
+ const normalizedHostname = hostname.toLowerCase();
41
+
42
+ if (
43
+ normalizedHostname === 'tasks.tuturuuu.com' ||
44
+ normalizedHostname === 'tasks.tuturuuu.localhost' ||
45
+ port === '7809'
46
+ ) {
47
+ return null;
48
+ }
49
+
50
+ if (
51
+ normalizedHostname === 'localhost' ||
52
+ normalizedHostname === '127.0.0.1' ||
53
+ normalizedHostname === '::1' ||
54
+ normalizedHostname === '[::1]'
55
+ ) {
56
+ return port ? `${protocol}//${hostname}:7809` : null;
57
+ }
58
+
59
+ if (
60
+ normalizedHostname === 'tuturuuu.localhost' ||
61
+ normalizedHostname.endsWith('.tuturuuu.localhost')
62
+ ) {
63
+ return `${protocol}//tasks.tuturuuu.localhost`;
64
+ }
65
+
66
+ if (
67
+ normalizedHostname === 'tuturuuu.com' ||
68
+ normalizedHostname.endsWith('.tuturuuu.com')
69
+ ) {
70
+ return 'https://tasks.tuturuuu.com';
71
+ }
72
+
73
+ return null;
74
+ }
75
+
76
+ export function getTasksAppUrl(path: string) {
77
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
78
+ const origin = getTasksAppOriginForBrowser();
79
+
80
+ return origin ? new URL(normalizedPath, origin).toString() : normalizedPath;
81
+ }
82
+
83
+ export function getTaskApiUrl(path: string) {
84
+ return getTasksAppUrl(path);
85
+ }