@baack-software/baack-ts-tasks 0.0.0-stage → 0.0.1
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.
- package/LICENSE +201 -0
- package/README.md +158 -2
- package/dist/activity.d.ts +20 -0
- package/dist/activity.d.ts.map +1 -0
- package/dist/activity.js +75 -0
- package/dist/activity.js.map +1 -0
- package/dist/blockers.d.ts +28 -0
- package/dist/blockers.d.ts.map +1 -0
- package/dist/blockers.js +134 -0
- package/dist/blockers.js.map +1 -0
- package/dist/conflicts.d.ts +50 -0
- package/dist/conflicts.d.ts.map +1 -0
- package/dist/conflicts.js +73 -0
- package/dist/conflicts.js.map +1 -0
- package/dist/draft.d.ts +45 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +155 -0
- package/dist/draft.js.map +1 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +37 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/react/TaskActivity.d.ts +15 -0
- package/dist/react/TaskActivity.d.ts.map +1 -0
- package/dist/react/TaskActivity.js +60 -0
- package/dist/react/TaskActivity.js.map +1 -0
- package/dist/react/TaskBoard.d.ts +38 -0
- package/dist/react/TaskBoard.d.ts.map +1 -0
- package/dist/react/TaskBoard.js +85 -0
- package/dist/react/TaskBoard.js.map +1 -0
- package/dist/react/TaskConflicts.d.ts +26 -0
- package/dist/react/TaskConflicts.d.ts.map +1 -0
- package/dist/react/TaskConflicts.js +87 -0
- package/dist/react/TaskConflicts.js.map +1 -0
- package/dist/react/TaskCreateForm.d.ts +13 -0
- package/dist/react/TaskCreateForm.d.ts.map +1 -0
- package/dist/react/TaskCreateForm.js +68 -0
- package/dist/react/TaskCreateForm.js.map +1 -0
- package/dist/react/TaskEditor.d.ts +33 -0
- package/dist/react/TaskEditor.d.ts.map +1 -0
- package/dist/react/TaskEditor.js +172 -0
- package/dist/react/TaskEditor.js.map +1 -0
- package/dist/react/TaskFilters.d.ts +15 -0
- package/dist/react/TaskFilters.d.ts.map +1 -0
- package/dist/react/TaskFilters.js +48 -0
- package/dist/react/TaskFilters.js.map +1 -0
- package/dist/react/TaskView.d.ts +30 -0
- package/dist/react/TaskView.d.ts.map +1 -0
- package/dist/react/TaskView.js +108 -0
- package/dist/react/TaskView.js.map +1 -0
- package/dist/react/TasksAbout.d.ts +24 -0
- package/dist/react/TasksAbout.d.ts.map +1 -0
- package/dist/react/TasksAbout.js +82 -0
- package/dist/react/TasksAbout.js.map +1 -0
- package/dist/react/copy.d.ts +154 -0
- package/dist/react/copy.d.ts.map +1 -0
- package/dist/react/copy.js +173 -0
- package/dist/react/copy.js.map +1 -0
- package/dist/react/fields.d.ts +36 -0
- package/dist/react/fields.d.ts.map +1 -0
- package/dist/react/fields.js +21 -0
- package/dist/react/fields.js.map +1 -0
- package/dist/react/hooks.d.ts +9 -0
- package/dist/react/hooks.d.ts.map +1 -0
- package/dist/react/hooks.js +31 -0
- package/dist/react/hooks.js.map +1 -0
- package/dist/react/index.d.ts +13 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +13 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/parts.d.ts +55 -0
- package/dist/react/parts.d.ts.map +1 -0
- package/dist/react/parts.js +92 -0
- package/dist/react/parts.js.map +1 -0
- package/dist/react/ui/alert.d.ts +11 -0
- package/dist/react/ui/alert.d.ts.map +1 -0
- package/dist/react/ui/alert.js +29 -0
- package/dist/react/ui/alert.js.map +1 -0
- package/dist/react/ui/avatar.d.ts +12 -0
- package/dist/react/ui/avatar.d.ts.map +1 -0
- package/dist/react/ui/avatar.js +24 -0
- package/dist/react/ui/avatar.js.map +1 -0
- package/dist/react/ui/badge.d.ts +10 -0
- package/dist/react/ui/badge.d.ts.map +1 -0
- package/dist/react/ui/badge.js +26 -0
- package/dist/react/ui/badge.js.map +1 -0
- package/dist/react/ui/button.d.ts +11 -0
- package/dist/react/ui/button.d.ts.map +1 -0
- package/dist/react/ui/button.js +37 -0
- package/dist/react/ui/button.js.map +1 -0
- package/dist/react/ui/card.d.ts +12 -0
- package/dist/react/ui/card.d.ts.map +1 -0
- package/dist/react/ui/card.js +26 -0
- package/dist/react/ui/card.js.map +1 -0
- package/dist/react/ui/input.d.ts +4 -0
- package/dist/react/ui/input.d.ts.map +1 -0
- package/dist/react/ui/input.js +8 -0
- package/dist/react/ui/input.js.map +1 -0
- package/dist/react/ui/label.d.ts +5 -0
- package/dist/react/ui/label.d.ts.map +1 -0
- package/dist/react/ui/label.js +10 -0
- package/dist/react/ui/label.js.map +1 -0
- package/dist/react/ui/radio-group.d.ts +6 -0
- package/dist/react/ui/radio-group.d.ts.map +1 -0
- package/dist/react/ui/radio-group.js +12 -0
- package/dist/react/ui/radio-group.js.map +1 -0
- package/dist/react/ui/select.d.ts +16 -0
- package/dist/react/ui/select.d.ts.map +1 -0
- package/dist/react/ui/select.js +37 -0
- package/dist/react/ui/select.js.map +1 -0
- package/dist/react/ui/separator.d.ts +5 -0
- package/dist/react/ui/separator.d.ts.map +1 -0
- package/dist/react/ui/separator.js +10 -0
- package/dist/react/ui/separator.js.map +1 -0
- package/dist/react/ui/sheet.d.ts +15 -0
- package/dist/react/ui/sheet.d.ts.map +1 -0
- package/dist/react/ui/sheet.js +38 -0
- package/dist/react/ui/sheet.js.map +1 -0
- package/dist/react/ui/skeleton.d.ts +3 -0
- package/dist/react/ui/skeleton.d.ts.map +1 -0
- package/dist/react/ui/skeleton.js +7 -0
- package/dist/react/ui/skeleton.js.map +1 -0
- package/dist/react/ui/table.d.ts +11 -0
- package/dist/react/ui/table.d.ts.map +1 -0
- package/dist/react/ui/table.js +29 -0
- package/dist/react/ui/table.js.map +1 -0
- package/dist/react/ui/tabs.d.ts +12 -0
- package/dist/react/ui/tabs.d.ts.map +1 -0
- package/dist/react/ui/tabs.js +31 -0
- package/dist/react/ui/tabs.js.map +1 -0
- package/dist/react/ui/textarea.d.ts +4 -0
- package/dist/react/ui/textarea.d.ts.map +1 -0
- package/dist/react/ui/textarea.js +8 -0
- package/dist/react/ui/textarea.js.map +1 -0
- package/dist/react/ui/toggle-group.d.ts +11 -0
- package/dist/react/ui/toggle-group.d.ts.map +1 -0
- package/dist/react/ui/toggle-group.js +25 -0
- package/dist/react/ui/toggle-group.js.map +1 -0
- package/dist/react/ui/toggle.d.ts +10 -0
- package/dist/react/ui/toggle.d.ts.map +1 -0
- package/dist/react/ui/toggle.js +27 -0
- package/dist/react/ui/toggle.js.map +1 -0
- package/dist/refs.d.ts +16 -0
- package/dist/refs.d.ts.map +1 -0
- package/dist/refs.js +43 -0
- package/dist/refs.js.map +1 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +4 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/recipes.d.ts +39 -0
- package/dist/server/recipes.d.ts.map +1 -0
- package/dist/server/recipes.js +81 -0
- package/dist/server/recipes.js.map +1 -0
- package/dist/server/webhooks.d.ts +135 -0
- package/dist/server/webhooks.d.ts.map +1 -0
- package/dist/server/webhooks.js +243 -0
- package/dist/server/webhooks.js.map +1 -0
- package/dist/store.d.ts +124 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +443 -0
- package/dist/store.js.map +1 -0
- package/dist/task-client.d.ts +137 -0
- package/dist/task-client.d.ts.map +1 -0
- package/dist/task-client.js +271 -0
- package/dist/task-client.js.map +1 -0
- package/dist/tasks.d.ts +84 -0
- package/dist/tasks.d.ts.map +1 -0
- package/dist/tasks.js +208 -0
- package/dist/tasks.js.map +1 -0
- package/dist/time.d.ts +15 -0
- package/dist/time.d.ts.map +1 -0
- package/dist/time.js +34 -0
- package/dist/time.js.map +1 -0
- package/dist/types.d.ts +74 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/package.json +101 -4
- package/src/activity.ts +86 -0
- package/src/blockers.ts +134 -0
- package/src/conflicts.ts +115 -0
- package/src/draft.ts +162 -0
- package/src/errors.ts +40 -0
- package/src/index.ts +10 -0
- package/src/react/TaskActivity.tsx +187 -0
- package/src/react/TaskBoard.tsx +264 -0
- package/src/react/TaskConflicts.tsx +217 -0
- package/src/react/TaskCreateForm.tsx +165 -0
- package/src/react/TaskEditor.tsx +485 -0
- package/src/react/TaskFilters.tsx +122 -0
- package/src/react/TaskView.tsx +291 -0
- package/src/react/TasksAbout.tsx +226 -0
- package/src/react/copy.tsx +253 -0
- package/src/react/fields.tsx +104 -0
- package/src/react/hooks.ts +41 -0
- package/src/react/index.ts +12 -0
- package/src/react/parts.tsx +176 -0
- package/src/react/ui/README.md +6 -0
- package/src/react/ui/alert.tsx +75 -0
- package/src/react/ui/avatar.tsx +109 -0
- package/src/react/ui/badge.tsx +48 -0
- package/src/react/ui/button.tsx +66 -0
- package/src/react/ui/card.tsx +102 -0
- package/src/react/ui/input.tsx +18 -0
- package/src/react/ui/label.tsx +23 -0
- package/src/react/ui/radio-group.tsx +41 -0
- package/src/react/ui/select.tsx +189 -0
- package/src/react/ui/separator.tsx +27 -0
- package/src/react/ui/sheet.tsx +145 -0
- package/src/react/ui/skeleton.tsx +13 -0
- package/src/react/ui/table.tsx +113 -0
- package/src/react/ui/tabs.tsx +89 -0
- package/src/react/ui/textarea.tsx +17 -0
- package/src/react/ui/toggle-group.tsx +89 -0
- package/src/react/ui/toggle.tsx +44 -0
- package/src/refs.ts +49 -0
- package/src/server/index.ts +3 -0
- package/src/server/recipes.ts +107 -0
- package/src/server/webhooks.ts +369 -0
- package/src/store.ts +538 -0
- package/src/task-client.ts +373 -0
- package/src/tasks.ts +263 -0
- package/src/time.ts +35 -0
- package/src/types.ts +101 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react';
|
|
2
|
+
import type { Entity, TaskPriority, TaskStatus, TextItem } from '@baack-software/baack-ts-client';
|
|
3
|
+
import { ContentText, LIVE_ATTRIBUTE, useLiveEntity } from '@baack-software/baack-ts-components/react';
|
|
4
|
+
import { ACTIVITY_LABELS } from '../activity.ts';
|
|
5
|
+
import { PRIORITY_LABELS, STATUS_LABELS } from '../tasks.ts';
|
|
6
|
+
import type { Activity } from '../types.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The task components' own words (labels, headings, buttons, empty states) as Baack content: each is a text item
|
|
10
|
+
* named here, at sort order 0, on one content entity. Until that entity is loaded, or while an item is missing, the
|
|
11
|
+
* en-GB placeholder below is shown, so the components work without any content and editors can fill it in, or
|
|
12
|
+
* translate it, later.
|
|
13
|
+
*
|
|
14
|
+
* Text that varies with data uses `{name}` slots, e.g. `{count} tasks`.
|
|
15
|
+
*/
|
|
16
|
+
const BASE_COPY = {
|
|
17
|
+
tasks_heading: 'Tasks',
|
|
18
|
+
tasks_new: 'New task',
|
|
19
|
+
tasks_view: 'View',
|
|
20
|
+
tasks_view_board: 'Board',
|
|
21
|
+
tasks_view_table: 'Table',
|
|
22
|
+
tasks_group_by: 'Columns',
|
|
23
|
+
tasks_group_status: 'Status',
|
|
24
|
+
tasks_group_priority: 'Priority',
|
|
25
|
+
tasks_group_assignee: 'Assignee',
|
|
26
|
+
tasks_group_project: 'Project',
|
|
27
|
+
tasks_group_workflow: 'Workflow',
|
|
28
|
+
tasks_group_stages: '{workflow} stages',
|
|
29
|
+
tasks_count: '{count} tasks',
|
|
30
|
+
tasks_count_one: '1 task',
|
|
31
|
+
tasks_empty: 'No tasks match these filters.',
|
|
32
|
+
tasks_none: 'There are no tasks yet. Create the first one.',
|
|
33
|
+
tasks_loading: 'Loading tasks…',
|
|
34
|
+
tasks_load_failed: 'The tasks could not be loaded.',
|
|
35
|
+
tasks_retry: 'Try again',
|
|
36
|
+
about_heading: 'Tasks',
|
|
37
|
+
about_open_count: '{count} open',
|
|
38
|
+
about_add_label: 'New task',
|
|
39
|
+
about_add_placeholder: 'Add a task…',
|
|
40
|
+
about_add: 'Add',
|
|
41
|
+
about_add_failed: 'The task could not be added.',
|
|
42
|
+
about_none: 'No open tasks.',
|
|
43
|
+
about_show_done: 'Show completed ({count})',
|
|
44
|
+
about_hide_done: 'Hide completed',
|
|
45
|
+
about_complete: 'Mark {name} as completed',
|
|
46
|
+
|
|
47
|
+
filter_label: 'Filter tasks',
|
|
48
|
+
filter_search: 'Search tasks',
|
|
49
|
+
filter_any: 'Any',
|
|
50
|
+
filter_mine: 'Assigned to me',
|
|
51
|
+
filter_open: 'Open only',
|
|
52
|
+
filter_clear: 'Clear filters',
|
|
53
|
+
|
|
54
|
+
field_title: 'Title',
|
|
55
|
+
field_description: 'Description',
|
|
56
|
+
field_status: 'Status',
|
|
57
|
+
field_priority: 'Priority',
|
|
58
|
+
field_assignee: 'Assignee',
|
|
59
|
+
field_project: 'Project',
|
|
60
|
+
field_due: 'Due',
|
|
61
|
+
field_updated: 'Updated',
|
|
62
|
+
field_stage: 'Stage',
|
|
63
|
+
field_blocking: 'Blocking',
|
|
64
|
+
|
|
65
|
+
option_unassigned: 'Unassigned',
|
|
66
|
+
option_no_project: 'No project',
|
|
67
|
+
option_no_stage: 'No stage',
|
|
68
|
+
option_no_workflow: 'No workflow',
|
|
69
|
+
due_none: 'No due date',
|
|
70
|
+
due_overdue: 'overdue',
|
|
71
|
+
due_clear: 'Clear due date',
|
|
72
|
+
|
|
73
|
+
blockers_heading: 'Blocked by',
|
|
74
|
+
blockers_none: 'Nothing is blocking this task.',
|
|
75
|
+
blockers_add: 'Add a blocker',
|
|
76
|
+
blockers_remove: 'Remove {name} as a blocker',
|
|
77
|
+
blockers_cycle: 'That task already waits on this one.',
|
|
78
|
+
blocked: 'Blocked',
|
|
79
|
+
blocked_by: 'Blocked by {name}',
|
|
80
|
+
|
|
81
|
+
related_heading: 'Related',
|
|
82
|
+
related_none: 'Nothing related yet.',
|
|
83
|
+
related_add: 'Add related',
|
|
84
|
+
related_remove: 'Remove {name}',
|
|
85
|
+
related_people: 'People',
|
|
86
|
+
related_companies: 'Companies',
|
|
87
|
+
related_entities: 'Content',
|
|
88
|
+
related_tasks: 'Tasks',
|
|
89
|
+
|
|
90
|
+
activity_heading: 'Activity',
|
|
91
|
+
activity_filter: 'Show activity',
|
|
92
|
+
activity_filter_all: 'All',
|
|
93
|
+
activity_filter_changes: 'Changes',
|
|
94
|
+
activity_filter_comments: 'Comments',
|
|
95
|
+
activity_more: 'Show older activity',
|
|
96
|
+
activity_empty: 'No activity yet.',
|
|
97
|
+
activity_by: 'by {name}',
|
|
98
|
+
activity_loading: 'Loading activity…',
|
|
99
|
+
|
|
100
|
+
comment_label: 'Comment',
|
|
101
|
+
comment_placeholder: 'Add a comment…',
|
|
102
|
+
comment_submit: 'Comment',
|
|
103
|
+
comment_reply: 'Reply',
|
|
104
|
+
comment_reply_label: 'Reply to {name}',
|
|
105
|
+
comment_cancel: 'Cancel',
|
|
106
|
+
|
|
107
|
+
editor_label: 'Task details',
|
|
108
|
+
editor_close: 'Close',
|
|
109
|
+
editor_save: 'Save',
|
|
110
|
+
editor_saving: 'Saving…',
|
|
111
|
+
editor_save_failed: 'The change could not be saved.',
|
|
112
|
+
conflict_heading: 'Someone else changed this task',
|
|
113
|
+
conflict_intro: 'They saved changes to the same fields while you were editing, so yours weren’t saved. Choose what to keep.',
|
|
114
|
+
conflict_theirs: 'Use theirs',
|
|
115
|
+
conflict_mine: 'Keep mine',
|
|
116
|
+
conflict_save: 'Save choices',
|
|
117
|
+
conflict_discard: 'Use theirs for all',
|
|
118
|
+
value_empty: 'Empty',
|
|
119
|
+
changed_elsewhere: 'Someone else changed {fields} while you were editing. Their changes are kept.',
|
|
120
|
+
changed_elsewhere_dismiss: 'Dismiss',
|
|
121
|
+
task_delete: 'Delete task',
|
|
122
|
+
task_delete_confirm: 'Delete this task? This can’t be undone.',
|
|
123
|
+
task_delete_cancel: 'Keep it',
|
|
124
|
+
|
|
125
|
+
create_heading: 'New task',
|
|
126
|
+
create_submit: 'Create task',
|
|
127
|
+
create_cancel: 'Cancel',
|
|
128
|
+
create_failed: 'The task could not be created.',
|
|
129
|
+
} as const;
|
|
130
|
+
|
|
131
|
+
type Lower<T extends string> = Lowercase<T>;
|
|
132
|
+
|
|
133
|
+
export type TaskCopyName =
|
|
134
|
+
| keyof typeof BASE_COPY
|
|
135
|
+
| `status_${Lower<TaskStatus>}`
|
|
136
|
+
| `priority_${Lower<TaskPriority>}`
|
|
137
|
+
| `activity_${Lower<Activity>}`;
|
|
138
|
+
|
|
139
|
+
function labels<K extends string>(prefix: string, from: Readonly<Record<K, string>>): Record<string, string> {
|
|
140
|
+
return Object.fromEntries(
|
|
141
|
+
Object.entries<string>(from).map(([key, label]) => [`${prefix}_${key.toLowerCase()}`, label]),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Every copy item's name and its en-GB placeholder. */
|
|
146
|
+
export const TASK_COPY = {
|
|
147
|
+
...BASE_COPY,
|
|
148
|
+
...labels('status', STATUS_LABELS),
|
|
149
|
+
...labels('priority', PRIORITY_LABELS),
|
|
150
|
+
...labels('activity', ACTIVITY_LABELS),
|
|
151
|
+
} as Readonly<Record<TaskCopyName, string>>;
|
|
152
|
+
|
|
153
|
+
export const statusCopy = (status: TaskStatus): TaskCopyName => `status_${status.toLowerCase() as Lower<TaskStatus>}`;
|
|
154
|
+
export const priorityCopy = (priority: TaskPriority): TaskCopyName =>
|
|
155
|
+
`priority_${priority.toLowerCase() as Lower<TaskPriority>}`;
|
|
156
|
+
/** The copy name for an activity, or undefined for one this library doesn't know yet. */
|
|
157
|
+
export const activityCopy = (activity: string): TaskCopyName | undefined => {
|
|
158
|
+
const name = `activity_${activity.toLowerCase()}`;
|
|
159
|
+
return name in TASK_COPY ? (name as TaskCopyName) : undefined;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
interface CopyContext {
|
|
163
|
+
texts: readonly TextItem[];
|
|
164
|
+
placeholders: Readonly<Record<string, string>>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const Copy = createContext<CopyContext>({ texts: [], placeholders: TASK_COPY });
|
|
168
|
+
|
|
169
|
+
export interface TaskCopyProviderProps {
|
|
170
|
+
/**
|
|
171
|
+
* The content entity holding the copy, for example read from the entity view. While it is absent, every text
|
|
172
|
+
* shows its placeholder.
|
|
173
|
+
*/
|
|
174
|
+
entity?: Entity | undefined;
|
|
175
|
+
/** Replaces placeholders, for an app that wants different defaults before content exists. */
|
|
176
|
+
placeholders?: Partial<Record<TaskCopyName, string>> | undefined;
|
|
177
|
+
children: ReactNode;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Supplies the task components' copy. With an entity, the Baack editor can change the copy in place: the content is
|
|
182
|
+
* followed live (`useLiveEntity`) and marked with `data-content-live`.
|
|
183
|
+
*/
|
|
184
|
+
export function TaskCopyProvider({ entity, placeholders, children }: TaskCopyProviderProps) {
|
|
185
|
+
const merged = useMemo(() => ({ ...TASK_COPY, ...placeholders }), [placeholders]);
|
|
186
|
+
if (!entity) return <Copy.Provider value={{ texts: [], placeholders: merged }}>{children}</Copy.Provider>;
|
|
187
|
+
return (
|
|
188
|
+
<LiveCopy entity={entity} placeholders={merged}>
|
|
189
|
+
{children}
|
|
190
|
+
</LiveCopy>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function LiveCopy({
|
|
195
|
+
entity,
|
|
196
|
+
placeholders,
|
|
197
|
+
children,
|
|
198
|
+
}: {
|
|
199
|
+
entity: Entity;
|
|
200
|
+
placeholders: Readonly<Record<string, string>>;
|
|
201
|
+
children: ReactNode;
|
|
202
|
+
}) {
|
|
203
|
+
const live = useLiveEntity(entity);
|
|
204
|
+
const value = useMemo(() => ({ texts: live.texts ?? [], placeholders }), [live, placeholders]);
|
|
205
|
+
return (
|
|
206
|
+
<Copy.Provider value={value}>
|
|
207
|
+
<div {...{ [LIVE_ATTRIBUTE]: '' }} className="contents">
|
|
208
|
+
{children}
|
|
209
|
+
</div>
|
|
210
|
+
</Copy.Provider>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function item(context: CopyContext, name: TaskCopyName): TextItem | undefined {
|
|
215
|
+
return context.texts.find((text) => text.name === name && text.sortOrder === 0);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function fill(text: string, values?: Readonly<Record<string, string | number>>): string {
|
|
219
|
+
if (!values) return text;
|
|
220
|
+
return text.replace(/\{(\w+)\}/g, (slot, key: string) => (key in values ? String(values[key]) : slot));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Copy as plain strings, for attributes (`aria-label`, `placeholder`) and text with `{slots}`. Prefer `<CopyText>`
|
|
225
|
+
* for visible text without slots, which the Baack editor can edit in place.
|
|
226
|
+
*/
|
|
227
|
+
export function useCopy(): (name: TaskCopyName, values?: Readonly<Record<string, string | number>>) => string {
|
|
228
|
+
const context = useContext(Copy);
|
|
229
|
+
return useCallback(
|
|
230
|
+
(name, values) => fill(item(context, name)?.value.trim() || context.placeholders[name] || name, values),
|
|
231
|
+
[context],
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface CopyTextProps {
|
|
236
|
+
name: TaskCopyName;
|
|
237
|
+
className?: string;
|
|
238
|
+
/** Element to render; default `span`. */
|
|
239
|
+
as?: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A piece of copy as a Baack text component, so the editor can find and change it. */
|
|
243
|
+
export function CopyText({ name, className, as }: CopyTextProps) {
|
|
244
|
+
const context = useContext(Copy);
|
|
245
|
+
return (
|
|
246
|
+
<ContentText
|
|
247
|
+
content={{ name, sortOrder: 0, item: item(context, name) }}
|
|
248
|
+
placeholder={context.placeholders[name] ?? name}
|
|
249
|
+
className={className}
|
|
250
|
+
as={as}
|
|
251
|
+
/>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { cn } from 'cn';
|
|
3
|
+
import { Label } from './ui/label.tsx';
|
|
4
|
+
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from './ui/select.tsx';
|
|
5
|
+
|
|
6
|
+
/** Radix Select can't use '' as a value, so "none" and "any" choices use these. */
|
|
7
|
+
export const NONE = '__none__';
|
|
8
|
+
export const ANY = '__any__';
|
|
9
|
+
|
|
10
|
+
/** A label above a control. */
|
|
11
|
+
export function Field({
|
|
12
|
+
id,
|
|
13
|
+
label,
|
|
14
|
+
className,
|
|
15
|
+
children,
|
|
16
|
+
}: {
|
|
17
|
+
id: string;
|
|
18
|
+
label: ReactNode;
|
|
19
|
+
className?: string;
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}) {
|
|
22
|
+
return (
|
|
23
|
+
<div className={cn('grid min-w-0 gap-1', className)}>
|
|
24
|
+
<Label htmlFor={id} className="text-muted-foreground text-xs">
|
|
25
|
+
{label}
|
|
26
|
+
</Label>
|
|
27
|
+
{children}
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Choice {
|
|
33
|
+
value: string;
|
|
34
|
+
label: string;
|
|
35
|
+
/** Shown before the label, e.g. an avatar. */
|
|
36
|
+
icon?: ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Choices under a heading, in a select with several kinds of choice. */
|
|
40
|
+
export interface ChoiceGroup {
|
|
41
|
+
label: string;
|
|
42
|
+
choices: readonly Choice[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A shadcn Select over a list of choices, followed by any groups of choices under their headings. */
|
|
46
|
+
export function ChoiceSelect({
|
|
47
|
+
id,
|
|
48
|
+
value,
|
|
49
|
+
choices,
|
|
50
|
+
groups = [],
|
|
51
|
+
onChange,
|
|
52
|
+
label,
|
|
53
|
+
size = 'sm',
|
|
54
|
+
disabled,
|
|
55
|
+
className,
|
|
56
|
+
}: {
|
|
57
|
+
id?: string;
|
|
58
|
+
value: string;
|
|
59
|
+
choices: readonly Choice[];
|
|
60
|
+
groups?: readonly ChoiceGroup[];
|
|
61
|
+
onChange: (value: string) => void;
|
|
62
|
+
/** Accessible name when there is no visible label. */
|
|
63
|
+
label?: string;
|
|
64
|
+
size?: 'sm' | 'default';
|
|
65
|
+
disabled?: boolean;
|
|
66
|
+
className?: string;
|
|
67
|
+
}) {
|
|
68
|
+
return (
|
|
69
|
+
<Select value={value} onValueChange={onChange} {...(disabled === undefined ? {} : { disabled })}>
|
|
70
|
+
<SelectTrigger
|
|
71
|
+
{...(id === undefined ? {} : { id })}
|
|
72
|
+
{...(label === undefined ? {} : { 'aria-label': label })}
|
|
73
|
+
size={size}
|
|
74
|
+
className={cn('w-full min-w-0', className)}
|
|
75
|
+
>
|
|
76
|
+
<SelectValue className="min-w-0" />
|
|
77
|
+
</SelectTrigger>
|
|
78
|
+
<SelectContent>
|
|
79
|
+
{choices.map((choice) => (
|
|
80
|
+
<ChoiceItem key={choice.value} choice={choice} />
|
|
81
|
+
))}
|
|
82
|
+
{groups
|
|
83
|
+
.filter((group) => group.choices.length > 0)
|
|
84
|
+
.map((group) => (
|
|
85
|
+
<SelectGroup key={group.label}>
|
|
86
|
+
<SelectLabel>{group.label}</SelectLabel>
|
|
87
|
+
{group.choices.map((choice) => (
|
|
88
|
+
<ChoiceItem key={choice.value} choice={choice} />
|
|
89
|
+
))}
|
|
90
|
+
</SelectGroup>
|
|
91
|
+
))}
|
|
92
|
+
</SelectContent>
|
|
93
|
+
</Select>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function ChoiceItem({ choice }: { choice: Choice }) {
|
|
98
|
+
return (
|
|
99
|
+
<SelectItem value={choice.value}>
|
|
100
|
+
{choice.icon}
|
|
101
|
+
<span className="truncate">{choice.label}</span>
|
|
102
|
+
</SelectItem>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
|
2
|
+
import type { TaskStore, TaskStoreState } from '../store.ts';
|
|
3
|
+
import type { Named, TaskDirectory } from './parts.tsx';
|
|
4
|
+
|
|
5
|
+
/** The store's state, re-rendering on every change. */
|
|
6
|
+
export function useTaskStore(store: TaskStore): TaskStoreState {
|
|
7
|
+
return useSyncExternalStore(store.subscribe, store.getState, store.getState);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** People, projects, tasks and workflows from the store's state, for the components. */
|
|
11
|
+
export function useTaskDirectory(state: TaskStoreState): TaskDirectory {
|
|
12
|
+
const { identities, projects, tasks, workflows } = state;
|
|
13
|
+
const people = useMemo(() => named(identities.values()), [identities]);
|
|
14
|
+
const projectList = useMemo(() => named(projects.values()), [projects]);
|
|
15
|
+
const workflowList = useMemo(() => [...workflows.values()], [workflows]);
|
|
16
|
+
return useMemo(
|
|
17
|
+
() => ({ people, projects: projectList, tasks, workflows: workflowList }),
|
|
18
|
+
[people, projectList, tasks, workflowList],
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function named(items: Iterable<{ urn: string; name?: string | null }>): Named[] {
|
|
23
|
+
return [...items]
|
|
24
|
+
.map((item) => ({ urn: item.urn, name: item.name || `…${item.urn.slice(-6)}` }))
|
|
25
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether a media query matches, following changes. True where there is no `matchMedia`, such as on the server. */
|
|
29
|
+
export function useMediaQuery(query: string): boolean {
|
|
30
|
+
const subscribe = useCallback(
|
|
31
|
+
(onChange: () => void) => {
|
|
32
|
+
if (typeof matchMedia !== 'function') return () => undefined;
|
|
33
|
+
const list = matchMedia(query);
|
|
34
|
+
list.addEventListener('change', onChange);
|
|
35
|
+
return () => list.removeEventListener('change', onChange);
|
|
36
|
+
},
|
|
37
|
+
[query],
|
|
38
|
+
);
|
|
39
|
+
const matches = () => typeof matchMedia !== 'function' || matchMedia(query).matches;
|
|
40
|
+
return useSyncExternalStore(subscribe, matches, () => true);
|
|
41
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from './copy.tsx';
|
|
2
|
+
export * from './parts.tsx';
|
|
3
|
+
export * from './fields.tsx';
|
|
4
|
+
export * from './hooks.ts';
|
|
5
|
+
export * from './TaskBoard.tsx';
|
|
6
|
+
export * from './TaskFilters.tsx';
|
|
7
|
+
export * from './TaskActivity.tsx';
|
|
8
|
+
export * from './TaskEditor.tsx';
|
|
9
|
+
export * from './TaskConflicts.tsx';
|
|
10
|
+
export * from './TaskCreateForm.tsx';
|
|
11
|
+
export * from './TaskView.tsx';
|
|
12
|
+
export * from './TasksAbout.tsx';
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { Task, TaskPriority, TaskStatus, Workflow } from '@baack-software/baack-ts-client';
|
|
2
|
+
import { Ban, CalendarDays, CalendarX2 } from 'lucide-react';
|
|
3
|
+
import { cn } from 'cn';
|
|
4
|
+
import { isOverdue } from '../tasks.ts';
|
|
5
|
+
import { parseTimestamp } from '../time.ts';
|
|
6
|
+
import type { Ref } from '../types.ts';
|
|
7
|
+
import { priorityCopy, statusCopy, useCopy } from './copy.tsx';
|
|
8
|
+
import { Avatar, AvatarFallback } from './ui/avatar.tsx';
|
|
9
|
+
|
|
10
|
+
/** A person, project or task the components can name. */
|
|
11
|
+
export interface Named {
|
|
12
|
+
urn: string;
|
|
13
|
+
name: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What the components know beyond the tasks themselves: people to assign, projects, and the tasks a blocker or
|
|
18
|
+
* related task refers to. From `TaskStore` state, or from any other source. Companies and content (entities) are
|
|
19
|
+
* offered as related items when given, and workflows give stage columns and a stage picker.
|
|
20
|
+
*/
|
|
21
|
+
export interface TaskDirectory {
|
|
22
|
+
people: readonly Named[];
|
|
23
|
+
projects: readonly Named[];
|
|
24
|
+
tasks: readonly Task[];
|
|
25
|
+
companies?: readonly Named[] | undefined;
|
|
26
|
+
entities?: readonly Named[] | undefined;
|
|
27
|
+
workflows?: readonly Workflow[] | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const EMPTY_DIRECTORY: TaskDirectory = { people: [], projects: [], tasks: [] };
|
|
31
|
+
|
|
32
|
+
/** The name of a person, project, task or stage, from the reference itself or the directory, else the end of its URN. */
|
|
33
|
+
export function nameOf(directory: TaskDirectory, ref: Ref & { name?: string | null; title?: string }): string {
|
|
34
|
+
return (
|
|
35
|
+
ref.name ||
|
|
36
|
+
ref.title ||
|
|
37
|
+
directory.people.find((p) => p.urn === ref.urn)?.name ||
|
|
38
|
+
directory.projects.find((p) => p.urn === ref.urn)?.name ||
|
|
39
|
+
directory.tasks.find((t) => t.urn === ref.urn)?.title ||
|
|
40
|
+
directory.companies?.find((c) => c.urn === ref.urn)?.name ||
|
|
41
|
+
directory.entities?.find((e) => e.urn === ref.urn)?.name ||
|
|
42
|
+
directory.workflows?.flatMap((w) => w.stages ?? []).find((s) => s.urn === ref.urn)?.name ||
|
|
43
|
+
`…${ref.urn.slice(-6)}`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const PRIORITY_CLASS: Record<TaskPriority, string> = {
|
|
48
|
+
CRITICAL: 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-200',
|
|
49
|
+
HIGH: 'bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-200',
|
|
50
|
+
MEDIUM: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200',
|
|
51
|
+
LOW: 'bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-200',
|
|
52
|
+
NONE: 'bg-muted text-muted-foreground',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function PriorityBadge({ priority, className }: { priority: TaskPriority; className?: string }) {
|
|
56
|
+
const copy = useCopy();
|
|
57
|
+
return (
|
|
58
|
+
<span
|
|
59
|
+
data-priority={priority}
|
|
60
|
+
className={cn('rounded px-1.5 py-px text-[11px] font-semibold', PRIORITY_CLASS[priority], className)}
|
|
61
|
+
>
|
|
62
|
+
{copy(priorityCopy(priority))}
|
|
63
|
+
</span>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const STATUS_CLASS: Record<TaskStatus, string> = {
|
|
68
|
+
UNDEFINED: 'border-border text-muted-foreground',
|
|
69
|
+
PENDING: 'border-border text-foreground',
|
|
70
|
+
IN_PROGRESS: 'border-primary/40 text-primary',
|
|
71
|
+
BLOCKED: 'border-destructive/40 text-destructive',
|
|
72
|
+
COMPLETED: 'border-emerald-600/40 text-emerald-700 dark:text-emerald-400',
|
|
73
|
+
CLOSED: 'border-border text-muted-foreground',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function StatusBadge({ status, className }: { status: TaskStatus; className?: string }) {
|
|
77
|
+
const copy = useCopy();
|
|
78
|
+
return (
|
|
79
|
+
<span
|
|
80
|
+
data-status={status}
|
|
81
|
+
className={cn(
|
|
82
|
+
'inline-flex h-5 items-center rounded-full border px-2 text-[11px] font-medium whitespace-nowrap',
|
|
83
|
+
STATUS_CLASS[status],
|
|
84
|
+
className,
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{copy(statusCopy(status))}
|
|
88
|
+
</span>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** "Blocked" with an icon, for a task waiting on open blockers. */
|
|
93
|
+
export function BlockedBadge({ title }: { title?: string }) {
|
|
94
|
+
const copy = useCopy();
|
|
95
|
+
return (
|
|
96
|
+
<span title={title} className="text-destructive inline-flex items-center gap-0.5 text-[11px] font-semibold">
|
|
97
|
+
<Ban aria-hidden="true" className="size-3" />
|
|
98
|
+
{copy('blocked')}
|
|
99
|
+
</span>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const dayFormat = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' });
|
|
104
|
+
const fullFormat = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
|
105
|
+
|
|
106
|
+
/** The due date ("12 Oct"), flagged when an open task is past it. */
|
|
107
|
+
export function DueDate({ task, now }: { task: Pick<Task, 'dueTimestamp' | 'status'>; now?: Date }) {
|
|
108
|
+
const copy = useCopy();
|
|
109
|
+
const due = parseTimestamp(task.dueTimestamp);
|
|
110
|
+
if (!due) return <span className="text-muted-foreground text-[11px]">{copy('due_none')}</span>;
|
|
111
|
+
const overdue = isOverdue(task, now);
|
|
112
|
+
const Icon = overdue ? CalendarX2 : CalendarDays;
|
|
113
|
+
return (
|
|
114
|
+
<time
|
|
115
|
+
dateTime={due.toISOString()}
|
|
116
|
+
title={fullFormat.format(due)}
|
|
117
|
+
className={cn(
|
|
118
|
+
'inline-flex items-center gap-1 text-[11px] whitespace-nowrap',
|
|
119
|
+
overdue ? 'text-destructive font-semibold' : 'text-muted-foreground',
|
|
120
|
+
)}
|
|
121
|
+
>
|
|
122
|
+
<Icon aria-hidden="true" className="size-3" />
|
|
123
|
+
<span className="sr-only">{copy('field_due')} </span>
|
|
124
|
+
{dayFormat.format(due)}
|
|
125
|
+
{overdue && <span className="sr-only">, {copy('due_overdue')}</span>}
|
|
126
|
+
</time>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** When something happened, in the viewer's locale and time zone. */
|
|
131
|
+
export function Timestamp({ value, className }: { value: string | null | undefined; className?: string }) {
|
|
132
|
+
const date = parseTimestamp(value);
|
|
133
|
+
if (!date) return null;
|
|
134
|
+
return (
|
|
135
|
+
<time dateTime={date.toISOString()} className={className}>
|
|
136
|
+
{fullFormat.format(date)}
|
|
137
|
+
</time>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function initials(name: string): string {
|
|
142
|
+
return name
|
|
143
|
+
.split(/\s+/)
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.map((part) => part[0])
|
|
146
|
+
.join('')
|
|
147
|
+
.slice(0, 2)
|
|
148
|
+
.toUpperCase();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Each has at least 4.5:1 contrast with white text.
|
|
152
|
+
const AVATAR_CLASS = ['bg-blue-700', 'bg-teal-700', 'bg-violet-700', 'bg-amber-700', 'bg-rose-700', 'bg-slate-700'];
|
|
153
|
+
|
|
154
|
+
function avatarClass(urn: string): string {
|
|
155
|
+
let hash = 0;
|
|
156
|
+
for (const char of urn) hash = (hash * 31 + char.charCodeAt(0)) | 0;
|
|
157
|
+
return AVATAR_CLASS[Math.abs(hash) % AVATAR_CLASS.length]!;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A person's initials in a colour that stays the same for them; a dashed circle when no one is assigned. */
|
|
161
|
+
export function PersonAvatar({ person, size = 'sm' }: { person?: Named | null; size?: 'sm' | 'default' | 'lg' }) {
|
|
162
|
+
if (!person) {
|
|
163
|
+
return (
|
|
164
|
+
<Avatar size={size} aria-hidden="true">
|
|
165
|
+
<AvatarFallback className="border-border border border-dashed bg-transparent">?</AvatarFallback>
|
|
166
|
+
</Avatar>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return (
|
|
170
|
+
<Avatar size={size} aria-hidden="true" title={person.name}>
|
|
171
|
+
<AvatarFallback className={cn('font-semibold text-white', avatarClass(person.urn))}>
|
|
172
|
+
{initials(person.name)}
|
|
173
|
+
</AvatarFallback>
|
|
174
|
+
</Avatar>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
shadcn/ui primitives, generated from the shadcn registry (`npx shadcn@4.21.0 add …`, style `radix-nova`, base colour
|
|
2
|
+
neutral, lucide icons) and kept as generated, apart from relative imports between them. Regenerate rather than edit,
|
|
3
|
+
and keep task-specific styling in the components one level up.
|
|
4
|
+
|
|
5
|
+
The colours come from the app's theme (the shadcn CSS variables: `--primary`, `--muted`, …), so the components follow
|
|
6
|
+
whatever theme the host app defines.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
import { cn } from "cn"
|
|
4
|
+
|
|
5
|
+
const alertVariants = cva(
|
|
6
|
+
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: "bg-card text-card-foreground",
|
|
11
|
+
destructive:
|
|
12
|
+
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
defaultVariants: {
|
|
16
|
+
variant: "default",
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
function Alert({
|
|
22
|
+
className,
|
|
23
|
+
variant,
|
|
24
|
+
...props
|
|
25
|
+
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
data-slot="alert"
|
|
29
|
+
role="alert"
|
|
30
|
+
className={cn(alertVariants({ variant }), className)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
data-slot="alert-title"
|
|
40
|
+
className={cn(
|
|
41
|
+
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
|
42
|
+
className
|
|
43
|
+
)}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function AlertDescription({
|
|
50
|
+
className,
|
|
51
|
+
...props
|
|
52
|
+
}: React.ComponentProps<"div">) {
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
data-slot="alert-description"
|
|
56
|
+
className={cn(
|
|
57
|
+
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
|
58
|
+
className
|
|
59
|
+
)}
|
|
60
|
+
{...props}
|
|
61
|
+
/>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
|
66
|
+
return (
|
|
67
|
+
<div
|
|
68
|
+
data-slot="alert-action"
|
|
69
|
+
className={cn("absolute top-2 right-2", className)}
|
|
70
|
+
{...props}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { Alert, AlertTitle, AlertDescription, AlertAction }
|