Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: drag and drop to update sphere labels or kanban columns

Hugo 2fbe3f53 2de0cc34

+192 -116
+16 -26
packages/app/src/pages/color-picker.css.ts
··· 30 30 cursor: "pointer", 31 31 }); 32 32 33 - export const reorderArrows = style({ 34 - display: "flex", 35 - flexDirection: "column", 36 - gap: "2px", 37 - }); 38 - 39 - export const arrowBtn = style({ 40 - display: "inline-flex", 41 - alignItems: "center", 42 - justifyContent: "center", 43 - inlineSize: "24px", 44 - blockSize: "24px", 45 - border: `1px solid ${vars.color.border}`, 46 - borderRadius: vars.radius.sm, 47 - backgroundColor: vars.color.surface, 48 - color: vars.color.text, 49 - cursor: "pointer", 50 - padding: 0, 51 - fontSize: "12px", 52 - lineHeight: 1, 33 + export const dragHandle = style({ 34 + color: vars.color.textMuted, 35 + cursor: "grab", 36 + flexShrink: 0, 53 37 selectors: { 54 - "&:disabled": { 55 - opacity: 0.3, 56 - cursor: "default", 57 - }, 58 - "&:hover:not(:disabled)": { 59 - backgroundColor: vars.color.surfaceHover, 38 + "&:active": { 39 + cursor: "grabbing", 60 40 }, 61 41 }, 62 42 }); 43 + 44 + export const dragging = style({ 45 + opacity: 0.3, 46 + }); 47 + 48 + export const dropIndicator = style({ 49 + blockSize: "2px", 50 + backgroundColor: vars.color.primary, 51 + borderRadius: "1px", 52 + });
+71 -27
packages/app/src/pages/sphere-labels.tsx
··· 1 + import { Fragment } from "preact"; 1 2 import { useSignal } from "@preact/signals"; 2 3 import { sphereState, sphereHandle } from "@exosphere/client/sphere"; 3 4 import { canDo } from "@exosphere/client/permissions"; 4 5 import { useQuery } from "@exosphere/client/hooks"; 5 6 import { spherePath } from "@exosphere/client/router"; 6 7 import { LabelBadge } from "@exosphere/client/components/label-badge"; 8 + import { GripVertical } from "lucide-preact"; 7 9 import * as ui from "@exosphere/client/ui.css"; 8 10 import * as cpUi from "./color-picker.css.ts"; 9 11 import { ··· 110 112 } 111 113 }; 112 114 113 - const handleMove = async (index: number, direction: -1 | 1) => { 115 + const dragIndex = useSignal<number | null>(null); 116 + const dropIndex = useSignal<number | null>(null); 117 + 118 + const onDragStart = (index: number) => (e: DragEvent) => { 119 + dragIndex.value = index; 120 + e.dataTransfer!.effectAllowed = "move"; 121 + e.dataTransfer!.setData("text/plain", ""); 122 + }; 123 + 124 + const onDragOver = (e: DragEvent) => { 125 + e.preventDefault(); 126 + if (dragIndex.value === null) return; 127 + const container = e.currentTarget as HTMLElement; 128 + const items = container.querySelectorAll("[data-drag-item]"); 129 + let insertAt = items.length; 130 + for (let i = 0; i < items.length; i++) { 131 + const rect = items[i].getBoundingClientRect(); 132 + if (e.clientY < rect.top + rect.height / 2) { 133 + insertAt = i; 134 + break; 135 + } 136 + } 137 + if (insertAt === dragIndex.value || insertAt === dragIndex.value + 1) { 138 + dropIndex.value = null; 139 + } else { 140 + dropIndex.value = insertAt; 141 + } 142 + }; 143 + 144 + const onDragLeave = (e: DragEvent) => { 145 + const container = e.currentTarget as HTMLElement; 146 + if (!container.contains(e.relatedTarget as Node)) { 147 + dropIndex.value = null; 148 + } 149 + }; 150 + 151 + const onDrop = async () => { 152 + const from = dragIndex.value; 153 + const to = dropIndex.value; 154 + dragIndex.value = null; 155 + dropIndex.value = null; 156 + if (from === null || to === null) return; 157 + 114 158 const labels = optimisticLabels.value ?? data?.labels; 115 159 if (!labels) return; 116 - const newIndex = index + direction; 117 - if (newIndex < 0 || newIndex >= labels.length) return; 160 + 118 161 const reordered = [...labels]; 119 - const [item] = reordered.splice(index, 1); 120 - reordered.splice(newIndex, 0, item); 162 + const [item] = reordered.splice(from, 1); 163 + reordered.splice(from < to ? to - 1 : to, 0, item); 121 164 optimisticLabels.value = reordered; 165 + 122 166 try { 123 167 await reorderSphereLabels( 124 168 handle, ··· 129 173 optimisticLabels.value = null; 130 174 listError.value = err instanceof Error ? err.message : "Failed to reorder labels."; 131 175 } 176 + }; 177 + 178 + const onDragEnd = () => { 179 + dragIndex.value = null; 180 + dropIndex.value = null; 132 181 }; 133 182 134 183 const confirmingDeleteId = useSignal<string | null>(null); ··· 211 260 ) : data.labels.length === 0 ? ( 212 261 <p class={ui.emptyState}>No labels yet.</p> 213 262 ) : ( 214 - <div class={ui.stackSm}> 263 + <div class={ui.stackSm} onDragOver={onDragOver} onDrop={onDrop} onDragLeave={onDragLeave}> 215 264 {(optimisticLabels.value ?? data.labels).map((label, index) => ( 216 - <div key={label.id} class={ui.card}> 217 - {editingId.value === label.id ? ( 265 + <Fragment key={label.id}> 266 + {dropIndex.value === index && <div class={cpUi.dropIndicator} />} 267 + <div 268 + class={`${ui.card}${dragIndex.value === index ? ` ${cpUi.dragging}` : ""}`} 269 + draggable={editingId.value !== label.id} 270 + onDragStart={onDragStart(index)} 271 + onDragEnd={onDragEnd} 272 + data-drag-item 273 + > 274 + {editingId.value === label.id ? ( 218 275 <div class={ui.formStack}> 219 276 <div> 220 277 <label class={ui.label}>Name</label> ··· 257 314 ) : ( 258 315 <div class={ui.row}> 259 316 <div class={ui.row}> 260 - <div class={cpUi.reorderArrows}> 261 - <button 262 - class={cpUi.arrowBtn} 263 - disabled={index === 0} 264 - onClick={() => handleMove(index, -1)} 265 - title="Move up" 266 - > 267 - &#8593; 268 - </button> 269 - <button 270 - class={cpUi.arrowBtn} 271 - disabled={index === (optimisticLabels.value ?? data.labels).length - 1} 272 - onClick={() => handleMove(index, 1)} 273 - title="Move down" 274 - > 275 - &#8595; 276 - </button> 277 - </div> 317 + <GripVertical size={16} class={cpUi.dragHandle} /> 278 318 <div> 279 319 <LabelBadge label={label} /> 280 320 {label.description && ( ··· 314 354 </div> 315 355 </div> 316 356 )} 317 - </div> 357 + </div> 358 + </Fragment> 318 359 ))} 360 + {dropIndex.value === (optimisticLabels.value ?? data.labels).length && ( 361 + <div class={cpUi.dropIndicator} /> 362 + )} 319 363 </div> 320 364 )} 321 365 </div>
+10 -1
packages/client/src/components/inline-label-editor.tsx
··· 41 41 onClick={() => (open.value = !open.value)} 42 42 title="Edit labels" 43 43 > 44 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 44 + <svg 45 + width="14" 46 + height="14" 47 + viewBox="0 0 24 24" 48 + fill="none" 49 + stroke="currentColor" 50 + stroke-width="2" 51 + stroke-linecap="round" 52 + stroke-linejoin="round" 53 + > 45 54 <path d="m15 5 6.3 6.3a2.4 2.4 0 0 1 0 3.4L17 19" /> 46 55 <path d="M9.586 5.586A2 2 0 0 0 8.172 5H3a1 1 0 0 0-1 1v5.172a2 2 0 0 0 .586 1.414L8.29 18.29a2.426 2.426 0 0 0 3.42 0l3.58-3.58a2.426 2.426 0 0 0 0-3.42z" /> 47 56 <circle cx="6.5" cy="9.5" r=".5" fill="currentColor" />
+1 -3
packages/feature-requests/src/ui/components/request-card.tsx
··· 95 95 96 96 <div class={ui.metaRow}> 97 97 {(fr.authorHandle || fr.authorDid) && ( 98 - <span class={ui.muted}> 99 - {fr.authorHandle ? `@${fr.authorHandle}` : fr.authorDid} 100 - </span> 98 + <span class={ui.muted}>{fr.authorHandle ? `@${fr.authorHandle}` : fr.authorDid}</span> 101 99 )} 102 100 <span class={ui.muted}>{formatDate(fr.createdAt)}</span> 103 101 </div>
+8 -8
packages/feature-requests/src/ui/pages/feature-request.tsx
··· 174 174 return ( 175 175 <div class={ui.cardFlat}> 176 176 <div class={ui.metaRow}> 177 - <span class={ui.muted}>{comment.authorHandle ? `@${comment.authorHandle}` : comment.authorDid}</span> 177 + <span class={ui.muted}> 178 + {comment.authorHandle ? `@${comment.authorHandle}` : comment.authorDid} 179 + </span> 178 180 <span class={ui.muted}>{formatDate(comment.createdAt, fullDateOpts)}</span> 179 181 {comment.createdAt !== comment.updatedAt && <span class={ui.muted}>(edited)</span>} 180 182 {isAuthor && !editing.value && ( ··· 639 641 if (!fr) return; 640 642 const label = availableLabels.value.find((l) => l.id === labelId); 641 643 if (!label) return; 642 - const nextLabels = selected 643 - ? [...fr.labels, label] 644 - : fr.labels.filter((l) => l.id !== labelId); 644 + const nextLabels = selected ? [...fr.labels, label] : fr.labels.filter((l) => l.id !== labelId); 645 645 const nextIds = nextLabels.map((l) => l.id); 646 646 batch(() => { 647 647 fr.labels = nextLabels; ··· 702 702 <select 703 703 class={frUi.sortSelect} 704 704 value={fr.status} 705 - onChange={(e) => handleStatusChange((e.target as HTMLSelectElement).value)} 705 + onChange={(e) => 706 + handleStatusChange((e.target as HTMLSelectElement).value) 707 + } 706 708 > 707 709 {fr.status === "duplicate" && ( 708 710 <option value="duplicate">{statusLabels.duplicate}</option> ··· 733 735 </RequestCard> 734 736 </div> 735 737 736 - {fr.description && ( 737 - <div class={frUi.descriptionBlock}>{fr.description}</div> 738 - )} 738 + {fr.description && <div class={frUi.descriptionBlock}>{fr.description}</div>} 739 739 740 740 {data.duplicateOf && ( 741 741 <p class={`${ui.muted} ${frUi.duplicateNotice}`}>
+72 -27
packages/kanban/src/ui/components/column-manager.tsx
··· 1 + import { Fragment } from "preact"; 1 2 import { useSignal } from "@preact/signals"; 3 + import { GripVertical } from "lucide-preact"; 2 4 import * as ui from "@exosphere/client/ui.css"; 3 5 import * as kbUi from "../ui.css.ts"; 4 6 import type { KanbanColumnDef } from "../../types.ts"; ··· 61 63 } 62 64 }; 63 65 64 - const handleMove = async (index: number, direction: -1 | 1) => { 65 - const newIndex = index + direction; 66 - if (newIndex < 0 || newIndex >= columns.length) return; 66 + const dragIndex = useSignal<number | null>(null); 67 + const dropIndex = useSignal<number | null>(null); 68 + 69 + const onDragStart = (index: number) => (e: DragEvent) => { 70 + dragIndex.value = index; 71 + e.dataTransfer!.effectAllowed = "move"; 72 + e.dataTransfer!.setData("text/plain", ""); 73 + }; 74 + 75 + const onContainerDragOver = (e: DragEvent) => { 76 + e.preventDefault(); 77 + if (dragIndex.value === null) return; 78 + const container = e.currentTarget as HTMLElement; 79 + const items = container.querySelectorAll("[data-drag-item]"); 80 + let insertAt = items.length; 81 + for (let i = 0; i < items.length; i++) { 82 + const rect = items[i].getBoundingClientRect(); 83 + if (e.clientY < rect.top + rect.height / 2) { 84 + insertAt = i; 85 + break; 86 + } 87 + } 88 + if (insertAt === dragIndex.value || insertAt === dragIndex.value + 1) { 89 + dropIndex.value = null; 90 + } else { 91 + dropIndex.value = insertAt; 92 + } 93 + }; 94 + 95 + const onContainerDragLeave = (e: DragEvent) => { 96 + const container = e.currentTarget as HTMLElement; 97 + if (!container.contains(e.relatedTarget as Node)) { 98 + dropIndex.value = null; 99 + } 100 + }; 101 + 102 + const onContainerDrop = async () => { 103 + const from = dragIndex.value; 104 + const to = dropIndex.value; 105 + dragIndex.value = null; 106 + dropIndex.value = null; 107 + if (from === null || to === null) return; 108 + 67 109 const reordered = [...columns]; 68 - const [item] = reordered.splice(index, 1); 69 - reordered.splice(newIndex, 0, item); 110 + const [item] = reordered.splice(from, 1); 111 + reordered.splice(from < to ? to - 1 : to, 0, item); 70 112 try { 71 113 await reorderColumnsApi(reordered.map((c) => c.id)); 72 114 onChanged(); 73 115 } catch (err) { 74 116 console.error("Failed to reorder columns:", err); 75 117 } 118 + }; 119 + 120 + const onDragEnd = () => { 121 + dragIndex.value = null; 122 + dropIndex.value = null; 76 123 }; 77 124 78 125 const startEdit = (col: KanbanColumnDef) => { ··· 90 137 91 138 return ( 92 139 <div class={kbUi.columnManager}> 93 - <div class={kbUi.columnManagerList}> 140 + <div 141 + class={kbUi.columnManagerList} 142 + onDragOver={onContainerDragOver} 143 + onDrop={onContainerDrop} 144 + onDragLeave={onContainerDragLeave} 145 + > 94 146 {columns.map((col, i) => ( 95 - <div key={col.id}> 96 - <div class={kbUi.columnManagerItem}> 97 - <div class={kbUi.columnManagerArrows}> 98 - <button 99 - class={kbUi.arrowBtn} 100 - disabled={i === 0} 101 - onClick={() => handleMove(i, -1)} 102 - title="Move left" 103 - > 104 - &#8592; 105 - </button> 106 - <button 107 - class={kbUi.arrowBtn} 108 - disabled={i === columns.length - 1} 109 - onClick={() => handleMove(i, 1)} 110 - title="Move right" 111 - > 112 - &#8594; 113 - </button> 114 - </div> 147 + <Fragment key={col.id}> 148 + {dropIndex.value === i && <div class={kbUi.dropIndicator} />} 149 + <div 150 + class={dragIndex.value === i ? kbUi.dragging : undefined} 151 + draggable={editingId.value !== col.id && deletingId.value !== col.id} 152 + onDragStart={onDragStart(i)} 153 + onDragEnd={onDragEnd} 154 + data-drag-item 155 + > 156 + <div class={kbUi.columnManagerItem}> 157 + <GripVertical size={16} class={kbUi.dragHandle} /> 115 158 116 159 {editingId.value === col.id ? ( 117 160 <input ··· 187 230 </button> 188 231 </div> 189 232 )} 190 - </div> 233 + </div> 234 + </Fragment> 191 235 ))} 236 + {dropIndex.value === columns.length && <div class={kbUi.dropIndicator} />} 192 237 </div> 193 238 194 239 <div class={kbUi.columnManagerAdd}>
+3 -1
packages/kanban/src/ui/pages/task.tsx
··· 133 133 return ( 134 134 <div class={ui.cardFlat}> 135 135 <div class={ui.metaRow}> 136 - <span class={ui.muted}>{comment.authorHandle ? `@${comment.authorHandle}` : comment.authorDid}</span> 136 + <span class={ui.muted}> 137 + {comment.authorHandle ? `@${comment.authorHandle}` : comment.authorDid} 138 + </span> 137 139 <span class={ui.muted}>{formatDate(comment.createdAt, fullDateOpts)}</span> 138 140 {comment.createdAt !== comment.updatedAt && <span class={ui.muted}>(edited)</span>} 139 141 {isAuthor && !editing.value && (
+11 -23
packages/kanban/src/ui/ui.css.ts
··· 284 284 color: vars.color.textMuted, 285 285 }); 286 286 287 - export const columnManagerArrows = style({ 288 - display: "flex", 289 - gap: "2px", 287 + export const dragHandle = style({ 288 + color: vars.color.textMuted, 289 + cursor: "grab", 290 + flexShrink: 0, 291 + selectors: { 292 + "&:active": { 293 + cursor: "grabbing", 294 + }, 295 + }, 290 296 }); 291 297 292 - export const arrowBtn = style({ 293 - display: "inline-flex", 294 - alignItems: "center", 295 - justifyContent: "center", 296 - inlineSize: "28px", 297 - blockSize: "28px", 298 - border: `1px solid ${vars.color.border}`, 299 - borderRadius: vars.radius.sm, 300 - backgroundColor: vars.color.surface, 301 - cursor: "pointer", 302 - fontSize: "0.75rem", 303 - color: vars.color.text, 304 - padding: 0, 305 - ":hover": { 306 - borderColor: vars.color.primary, 307 - }, 308 - ":disabled": { 309 - opacity: 0.3, 310 - cursor: "not-allowed", 311 - }, 298 + export const dragging = style({ 299 + opacity: 0.3, 312 300 }); 313 301 314 302 export const columnManagerAdd = style({