@stacksjs/defaults 0.70.365 → 0.70.366

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.
@@ -27,14 +27,6 @@
27
27
  const error = derived(() => kanban.errorBoard())
28
28
  const board = derived(() => kanban.currentBoard())
29
29
  const columns = derived(() => kanban.currentColumns())
30
- const allLabels = derived(() => kanban.currentLabels())
31
-
32
- // Card detail modal state - read straight from the store. The
33
- // modal itself lives at the bottom of this file.
34
- const openCard = derived(() => kanban.openCard())
35
- const loadingCard = derived(() => kanban.loadingCard())
36
- const errorCard = derived(() => kanban.errorCard())
37
-
38
30
  // Inline composer state. Each column has its own (independent) "add
39
31
  // card" composer; the boolean tracks whether the input is open.
40
32
  const composingCardForColumnId = state(null)
@@ -42,23 +34,7 @@
42
34
  const composingNewColumn = state(false)
43
35
  const draftColumnName = state('')
44
36
 
45
- // Modal-local edit drafts. Title + description debounce-on-blur,
46
- // due-date commits on change. Labels + assignees commit instantly
47
- // via the sync endpoints (one round-trip per toggle).
48
- const titleDraft = state('')
49
- const descDraft = state('')
50
- const commentDraft = state('')
51
- const editingCommentId = state(null)
52
- const commentEditDraft = state('')
53
- const savingCommentId = state(null)
54
- const showLabelPicker = state(false)
55
- const showAssigneePicker = state(false)
56
- const showNewLabelComposer = state(false)
57
- const newLabelName = state('')
58
- const newLabelColor = state('slate')
59
- const dueDateDraft = state('')
60
-
61
- type DeleteKind = 'card' | 'column' | 'comment' | 'label'
37
+ type DeleteKind = 'card' | 'column'
62
38
  interface PendingDelete {
63
39
  kind: DeleteKind
64
40
  id: number
@@ -80,119 +56,15 @@
80
56
  const cardDropTarget = state<CardDropTarget | null>(null)
81
57
  const columnDropBeforeId = state<number | null>(null)
82
58
 
83
- function syncDraftsFromCard() {
84
- const c = openCard()
85
- titleDraft.set(c ? c.title : '')
86
- descDraft.set(c ? (c.description || '') : '')
87
- dueDateDraft.set(c?.dueDate ? c.dueDate.slice(0, 10) : '')
88
- commentDraft.set('')
89
- editingCommentId.set(null)
90
- commentEditDraft.set('')
91
- savingCommentId.set(null)
92
- }
93
- // Sync drafts when the open card changes (open / close / refetch).
94
- effect(() => { openCard(); Promise.resolve().then(syncDraftsFromCard) })
95
-
96
- async function commitTitle() {
97
- const c = openCard()
98
- if (!c) return
99
- const next = titleDraft().trim()
100
- if (!next || next === c.title) return
101
- await kanban.updateCard({ title: next })
102
- }
103
-
104
- async function commitDescription() {
105
- const c = openCard()
106
- if (!c) return
107
- const next = descDraft()
108
- if (next === (c.description || '')) return
109
- await kanban.updateCard({ description: next || null })
110
- }
111
-
112
- async function commitDueDate(value) {
113
- await kanban.updateCard({ dueDate: value || null })
114
- }
115
-
116
- async function toggleLabel(labelId) {
117
- const c = openCard()
118
- if (!c) return
119
- const has = c.labels.some(l => l.id === labelId)
120
- const next = has
121
- ? c.labels.filter(l => l.id !== labelId).map(l => l.id)
122
- : [...c.labels.map(l => l.id), labelId]
123
- await kanban.syncCardLabels(c.id, next)
124
- }
125
-
126
- async function toggleAssignee(userId) {
127
- const c = openCard()
128
- if (!c) return
129
- const has = c.assignees.some(a => a.userId === userId)
130
- const next = has
131
- ? c.assignees.filter(a => a.userId !== userId).map(a => a.userId)
132
- : [...c.assignees.map(a => a.userId), userId]
133
- await kanban.syncCardAssignees(c.id, next)
134
- }
135
-
136
- async function submitComment() {
137
- const text = commentDraft().trim()
138
- if (!text) return
139
- const ok = await kanban.addComment(text)
140
- if (ok) commentDraft.set('')
141
- }
142
-
143
- function startCommentEdit(id, body) {
144
- editingCommentId.set(id)
145
- commentEditDraft.set(body)
146
- }
147
-
148
- function cancelCommentEdit() {
149
- editingCommentId.set(null)
150
- commentEditDraft.set('')
151
- }
152
-
153
- async function saveCommentEdit(id) {
154
- const body = commentEditDraft().trim()
155
- if (!body || savingCommentId()) return
156
- savingCommentId.set(id)
157
- try {
158
- if (await kanban.updateComment(id, body))
159
- cancelCommentEdit()
160
- }
161
- finally {
162
- savingCommentId.set(null)
163
- }
164
- }
165
-
166
- function handleCommentEditKeydown(event, id) {
167
- if (event.key === 'Escape') {
168
- event.preventDefault()
169
- cancelCommentEdit()
170
- }
171
- if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
172
- event.preventDefault()
173
- void saveCommentEdit(id)
174
- }
175
- }
176
-
177
59
  function requestDelete(kind: DeleteKind, id: number, hasCards = false): void {
178
60
  pendingDelete.set({ kind, id, hasCards })
179
61
  }
180
62
 
181
- async function submitNewLabel() {
182
- const name = newLabelName().trim()
183
- if (!name) return
184
- const c = openCard()
185
- const boardId = c ? c.boardId : (board() ? board().id : null)
186
- if (!boardId) return
187
- const created = await kanban.createLabel({ boardId, name, color: newLabelColor() })
188
- if (created && c)
189
- await kanban.syncCardLabels(c.id, [...c.labels.map(l => l.id), created.id])
190
- newLabelName.set('')
191
- showNewLabelComposer.set(false)
63
+ function closeDelete(): void {
64
+ if (!deleting())
65
+ pendingDelete.set(null)
192
66
  }
193
67
 
194
- const labelColors = ['slate', 'red', 'amber', 'emerald', 'blue', 'violet', 'rose']
195
-
196
68
  const deleteTitle = derived(() => {
197
69
  const pending = pendingDelete()
198
70
  if (!pending)
@@ -200,8 +72,6 @@
200
72
  return {
201
73
  card: 'Delete this card?',
202
74
  column: 'Delete this column?',
203
- comment: 'Delete this comment?',
204
- label: 'Delete this label?',
205
75
  }[pending.kind]
206
76
  })
207
77
 
@@ -211,8 +81,6 @@
211
81
  return ''
212
82
  if (pending.kind === 'column' && pending.hasCards)
213
83
  return 'Every card in this column will also be permanently deleted.'
214
- if (pending.kind === 'label')
215
- return 'The label will be removed from every card that currently uses it.'
216
84
  return 'This action cannot be undone.'
217
85
  })
218
86
 
@@ -224,11 +92,7 @@
224
92
  try {
225
93
  const ok = pending.kind === 'card'
226
94
  ? await kanban.deleteCard(pending.id)
227
- : pending.kind === 'column'
228
- ? await kanban.deleteColumn(pending.id)
229
- : pending.kind === 'comment'
230
- ? await kanban.deleteComment(pending.id)
231
- : await kanban.deleteLabel(pending.id)
95
+ : await kanban.deleteColumn(pending.id)
232
96
  if (ok)
233
97
  pendingDelete.set(null)
234
98
  }
@@ -306,29 +170,12 @@
306
170
  closeColumnComposer()
307
171
  }
308
172
 
309
- // ─── Card detail modal handlers ───────────────────────────────────
173
+ // ─── Card detail launcher ─────────────────────────────────────────
310
174
 
311
175
  function openDetail(cardId) {
312
176
  kanban.loadUsers() // background-fetch for assignee picker
313
177
  kanban.openCardDetail(cardId)
314
178
  }
315
- function closeDetail() { kanban.closeCardDetail() }
316
-
317
- function handleBoardKeydown(event: KeyboardEvent): void {
318
- if (event.key !== 'Escape' || pendingDelete())
319
- return
320
- if (openCard())
321
- closeDetail()
322
- }
323
-
324
- function handleCommentKeydown(event: KeyboardEvent): void {
325
- if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
326
- event.preventDefault()
327
- void submitComment()
328
- }
329
- }
330
-
331
- useEventListener('keydown', handleBoardKeydown)
332
179
 
333
180
  // Label color → tailwind hue. Mirrors the column-accent map but
334
181
  // returns a more saturated chip class.
@@ -359,13 +206,6 @@
359
206
  return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
360
207
  }
361
208
 
362
- function formatTimestamp(iso) {
363
- if (!iso) return ''
364
- const d = new Date(iso)
365
- if (Number.isNaN(d.getTime())) return ''
366
- return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
367
- }
368
-
369
209
  // ─── Native drag and drop ─────────────────────────────────────────
370
210
 
371
211
  function setDragTransfer(event: DragEvent, kind: 'card' | 'column', id: number): void {
@@ -687,282 +527,16 @@
687
527
  </div>
688
528
  </div>
689
529
 
690
- <!-- Card detail modal -->
691
- <!-- Opens on card click. Fixed positioned with a backdrop; closes
692
- on backdrop click + Escape. Sections (top → bottom):
693
- · Title (inline-editable, blurs to commit)
694
- · Description (textarea, blurs to commit)
695
- · Sidebar: labels picker, assignees picker, due date, archive
696
- · Comments thread + composer
697
- All mutations route through the store, which fires optimistic
698
- updates with rollback on failure. -->
699
- <div
700
- :if="openCard()"
701
- class="flex fixed inset-0 overflow-y-auto z-50 items-start justify-center px-4 py-8 bg-black/50 dashboard-modal-layer"
702
- @click="closeDetail()"
703
- >
704
- <div
705
- @click.stop
706
- role="dialog"
707
- aria-modal="true"
708
- aria-label="Card details"
709
- tabindex="-1"
710
- class="my-8 max-w-3xl w-full bg-white dark:bg-zinc-900 border border-zinc-200 rounded-lg dark:border-zinc-800 shadow-xl"
711
- >
712
- <!-- Header: title + close -->
713
- <div class="flex gap-3 items-start px-6 py-4 border-b border-zinc-200 dark:border-zinc-800">
714
- <input
715
- x-model="titleDraft"
716
- @blur="commitTitle()"
717
- @keydown.enter.prevent="(e) => e.target.blur()"
718
- class="flex-1 -mx-2 -my-1 px-2 py-1 font-semibold text-lg text-zinc-900 dark:text-zinc-100 bg-transparent focus:bg-zinc-50 dark:focus:bg-zinc-800 rounded focus:outline-none"
719
- />
720
- <Button variant="ghost" size="sm" iconOnly ariaLabel="Close card details" @click="closeDetail()">
721
- <span class="block h-5 w-5 i-hugeicons-cancel-01"></span>
722
- </Button>
723
- </div>
724
-
725
- <div :if="loadingCard() && (!openCard() || openCard().comments.length === 0)" class="px-6 py-8 text-center text-sm text-zinc-500">
726
- Loading…
727
- </div>
728
-
729
- <div :if="errorCard()" class="px-6 py-3 bg-red-50 dark:bg-red-950/30 border-b border-red-200 dark:border-red-900/40">
730
- <p class="text-red-700 text-xs dark:text-red-300" :text="errorCard()"></p>
731
- </div>
732
-
733
- <div class="grid gap-6 grid-cols-1 md:grid-cols-3 px-6 py-5">
734
- <!-- Main column: description + comments -->
735
- <div class="md:col-span-2 space-y-6">
736
- <!-- Description -->
737
- <section>
738
- <h4 class="mb-2 font-medium text-xs text-zinc-500 tracking-wide uppercase">Description</h4>
739
- <textarea
740
- x-model="descDraft"
741
- @blur="commitDescription()"
742
- rows="4"
743
- placeholder="Add a more detailed description…"
744
- class="px-3 py-2 w-full text-sm text-zinc-800 dark:text-zinc-200 bg-white dark:bg-zinc-950 border border-zinc-200 rounded dark:border-zinc-800 focus:border-violet-400 focus:outline-none dark:focus:border-violet-600 resize-y"
745
- ></textarea>
746
- </section>
747
-
748
- <!-- Comments -->
749
- <section>
750
- <h4 class="mb-2 font-medium text-xs text-zinc-500 tracking-wide uppercase">Activity</h4>
751
-
752
- <!-- Composer -->
753
- <div class="mb-4">
754
- <textarea
755
- x-model="commentDraft"
756
- @keydown="handleCommentKeydown"
757
- rows="2"
758
- placeholder="Add a comment… (⌘+Enter to send)"
759
- class="px-3 py-2 w-full text-sm text-zinc-800 dark:text-zinc-200 bg-white dark:bg-zinc-950 border border-zinc-200 rounded dark:border-zinc-800 focus:border-violet-400 focus:outline-none dark:focus:border-violet-600 resize-none"
760
- ></textarea>
761
- <div class="flex justify-end mt-2">
762
- <Button size="sm" :disabled="!commentDraft().trim()" @click="submitComment()">
763
- Comment
764
- </Button>
765
- </div>
766
- </div>
767
-
768
- <!-- Thread -->
769
- <div :if="openCard().comments.length === 0 && !loadingCard()" class="italic text-xs text-zinc-400">
770
- No comments yet.
771
- </div>
772
- <ul class="space-y-3">
773
- <li
774
- :for="cm in openCard.comments"
775
- class="flex gap-3"
776
- >
777
- <span class="inline-flex flex-shrink-0 items-center justify-center h-7 w-7 font-medium text-[10px] text-white bg-violet-500 rounded-full" :text="initialsOf(cm.authorName, cm.authorEmail)"></span>
778
- <div class="flex-1 min-w-0">
779
- <div class="flex gap-2 items-baseline">
780
- <span class="font-medium text-xs text-zinc-700 dark:text-zinc-200" :text="cm.authorName || cm.authorEmail || 'Unknown'"></span>
781
- <span class="text-[10px] text-zinc-400" :text="formatTimestamp(cm.createdAt)"></span>
782
- <template :if="editingCommentId() !== cm.id">
783
- <Button variant="ghost" size="xs" @click="startCommentEdit(cm.id, cm.body)">Edit</Button>
784
- <Button variant="ghost" size="xs" @click="requestDelete('comment', cm.id)"><span class="text-red-500">Delete</span></Button>
785
- </template>
786
- </div>
787
- <p :if="editingCommentId() !== cm.id" class="mt-0.5 text-xs text-zinc-700 whitespace-pre-wrap dark:text-zinc-300" :text="cm.body"></p>
788
- <div :if="editingCommentId() === cm.id" class="mt-2">
789
- <textarea
790
- x-model="commentEditDraft"
791
- @keydown="handleCommentEditKeydown($event, cm.id)"
792
- rows="3"
793
- maxlength="10000"
794
- :aria-label="'Edit comment by ' + (cm.authorName || cm.authorEmail || 'Unknown')"
795
- class="px-3 py-2 w-full text-sm text-zinc-800 dark:text-zinc-200 bg-white dark:bg-zinc-950 border border-zinc-200 rounded dark:border-zinc-800 focus:border-violet-400 focus:outline-none dark:focus:border-violet-600 resize-y"
796
- ></textarea>
797
- <div class="flex gap-2 justify-end mt-2">
798
- <Button variant="secondary" size="xs" :disabled="savingCommentId() === cm.id" @click="cancelCommentEdit()">Cancel</Button>
799
- <Button size="xs" :loading="savingCommentId() === cm.id" :disabled="!commentEditDraft().trim()" @click="saveCommentEdit(cm.id)">Save comment</Button>
800
- </div>
801
- </div>
802
- </div>
803
- </li>
804
- </ul>
805
- </section>
806
- </div>
807
-
808
- <!-- Sidebar: labels, assignees, due date, archive -->
809
- <aside class="space-y-5">
810
- <!-- Labels -->
811
- <section>
812
- <div class="flex items-center justify-between mb-2">
813
- <h4 class="font-medium text-xs text-zinc-500 tracking-wide uppercase">Labels</h4>
814
- <Button
815
- variant="ghost"
816
- size="xs"
817
- interaction="toggle"
818
- :pressed="showLabelPicker()"
819
- @click="showLabelPicker.set(!showLabelPicker())"
820
- >
821
- <span :if="!showLabelPicker()">Edit</span>
822
- <span :if="showLabelPicker()">Done</span>
823
- </Button>
824
- </div>
825
-
826
- <!-- Selected labels -->
827
- <div :if="!showLabelPicker()" class="flex flex-wrap gap-1">
828
- <span
829
- :for="lbl in openCard.labels"
830
- :class="`px-2 py-0.5 text-xs font-medium rounded ${labelChipClass(lbl.color)}`"
831
- :text="lbl.name"
832
- ></span>
833
- <span :if="openCard().labels.length === 0" class="italic text-xs text-zinc-400">None</span>
834
- </div>
835
-
836
- <!-- Picker -->
837
- <div :if="showLabelPicker()" class="space-y-2">
838
- <div
839
- :for="lbl in allLabels"
840
- :class="`flex items-center gap-1 px-1 py-0.5 w-full rounded ${labelChipClass(lbl.color)}`"
841
- >
842
- <button
843
- type="button"
844
- class="flex flex-1 items-center justify-between px-1 py-0.5 min-w-0 text-left text-xs rounded hover:opacity-90"
845
- :aria-pressed="String(openCard().labels.some(l => l.id === lbl.id))"
846
- @click="toggleLabel(lbl.id)"
847
- >
848
- <span class="truncate" :text="lbl.name"></span>
849
- <span :if="openCard().labels.some(l => l.id === lbl.id)" class="h-3 w-3 i-hugeicons-tick-02"></span>
850
- </button>
851
- <Button variant="ghost" size="xs" iconOnly :ariaLabel="'Delete label ' + lbl.name" @click="requestDelete('label', lbl.id)">
852
- <span class="block h-3 w-3 text-red-500 i-hugeicons-cancel-01"></span>
853
- </Button>
854
- </div>
855
-
856
- <!-- New label composer -->
857
- <div :if="showNewLabelComposer()" class="pt-2 space-y-2 border-t border-zinc-200 dark:border-zinc-800">
858
- <input
859
- x-model="newLabelName"
860
- @keydown.enter.prevent="submitNewLabel()"
861
- placeholder="Label name"
862
- class="px-2 py-1 w-full text-xs bg-white dark:bg-zinc-950 border border-zinc-300 rounded dark:border-zinc-700 focus:border-violet-500 focus:outline-none"
863
- />
864
- <div class="flex gap-1 items-center" role="group" aria-label="Label color">
865
- <button
866
- :for="c in labelColors"
867
- type="button"
868
- @click="newLabelColor.set(c)"
869
- :class="`w-5 h-5 rounded ${labelChipClass(c)} ${newLabelColor() === c ? 'ring-2 ring-offset-1 ring-violet-500 dark:ring-offset-zinc-900' : ''}`"
870
- :aria-label="'Use ' + c + ' label color'"
871
- :aria-pressed="String(newLabelColor() === c)"
872
- ></button>
873
- </div>
874
- <div class="flex gap-1">
875
- <Button size="xs" @click="submitNewLabel()">Create</Button>
876
- <Button variant="ghost" size="xs" @click="showNewLabelComposer.set(false)">Cancel</Button>
877
- </div>
878
- </div>
879
- <Button
880
- :if="!showNewLabelComposer()"
881
- variant="outline"
882
- size="sm"
883
- fullWidth
884
- @click="showNewLabelComposer.set(true)"
885
- >
886
- + New label
887
- </Button>
888
- </div>
889
- </section>
890
-
891
- <!-- Assignees -->
892
- <section>
893
- <div class="flex items-center justify-between mb-2">
894
- <h4 class="font-medium text-xs text-zinc-500 tracking-wide uppercase">Assignees</h4>
895
- <Button
896
- variant="ghost"
897
- size="xs"
898
- interaction="toggle"
899
- :pressed="showAssigneePicker()"
900
- @click="showAssigneePicker.set(!showAssigneePicker())"
901
- >
902
- <span :if="!showAssigneePicker()">Edit</span>
903
- <span :if="showAssigneePicker()">Done</span>
904
- </Button>
905
- </div>
906
-
907
- <div :if="!showAssigneePicker()" class="flex flex-wrap gap-2">
908
- <div
909
- :for="asg in openCard.assignees"
910
- class="flex gap-1.5 items-center text-xs text-zinc-700 dark:text-zinc-200"
911
- >
912
- <span class="inline-flex items-center justify-center h-6 w-6 font-medium text-[10px] text-white bg-violet-500 rounded-full" :text="initialsOf(asg.name, asg.email)"></span>
913
- <span :text="asg.name || asg.email || 'Unknown'"></span>
914
- </div>
915
- <span :if="openCard().assignees.length === 0" class="italic text-xs text-zinc-400">Unassigned</span>
916
- </div>
917
-
918
- <div :if="showAssigneePicker()" class="overflow-y-auto space-y-1 max-h-48">
919
- <p :if="kanban.users().length === 0" class="italic text-xs text-zinc-400">No users to assign.</p>
920
- <button
921
- :for="u in kanban.users"
922
- type="button"
923
- :aria-pressed="String(openCard().assignees.some(a => a.userId === u.id))"
924
- @click="toggleAssignee(u.id)"
925
- class="flex gap-2 items-center px-2 py-1.5 w-full text-left text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded"
926
- >
927
- <span class="inline-flex flex-shrink-0 items-center justify-center h-6 w-6 font-medium text-[10px] text-white bg-violet-500 rounded-full" :text="initialsOf(u.name, u.email)"></span>
928
- <span class="flex-1 text-zinc-700 truncate dark:text-zinc-200" :text="u.name || u.email"></span>
929
- <span :if="openCard().assignees.some(a => a.userId === u.id)" class="h-3.5 w-3.5 text-violet-500 i-hugeicons-tick-02"></span>
930
- </button>
931
- </div>
932
- </section>
933
-
934
- <!-- Due date -->
935
- <section>
936
- <h4 class="mb-2 font-medium text-xs text-zinc-500 tracking-wide uppercase">Due date</h4>
937
- <input
938
- type="date"
939
- x-model="dueDateDraft"
940
- @change="commitDueDate(dueDateDraft())"
941
- class="px-2 py-1 w-full text-xs text-zinc-800 dark:text-zinc-200 bg-white dark:bg-zinc-950 border border-zinc-200 rounded dark:border-zinc-800 focus:border-violet-400 focus:outline-none"
942
- />
943
- </section>
944
-
945
- <!-- Meta -->
946
- <section class="pt-3 border-t border-zinc-200 dark:border-zinc-800">
947
- <p class="text-[10px] text-zinc-400">
948
- Created <span :text="formatTimestamp(openCard().createdAt)"></span>
949
- </p>
950
- <p :if="openCard().updatedAt && openCard().updatedAt !== openCard().createdAt" class="text-[10px] text-zinc-400">
951
- Updated <span :text="formatTimestamp(openCard().updatedAt)"></span>
952
- </p>
953
- </section>
954
- </aside>
955
- </div>
956
- </div>
957
- </div>
530
+ <KanbanCardDialog />
958
531
 
959
532
  <ConfirmDialog
960
533
  :isOpen="Boolean(pendingDelete())"
961
534
  :title="deleteTitle()"
962
535
  :description="deleteDescription()"
963
536
  :busy="deleting()"
537
+ :error="error() || ''"
964
538
  :confirmLabel="deleting() ? 'Deleting' : 'Delete'"
965
- @close="pendingDelete.set(null)"
539
+ @close="closeDelete"
966
540
  @confirm="confirmDelete"
967
541
  />
968
542
  </div>