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.

ui: improve task and feature request creation

Hugo 8c69f1e9 e7fe56b2

+223 -14
+1
packages/client/package.json
··· 19 19 "./theme-state": "./src/theme-state.ts", 20 20 "./components/collapsible-section": "./src/components/collapsible-section.tsx", 21 21 "./components/invitation-banner": "./src/components/invitation-banner.tsx", 22 + "./components/modal": "./src/components/modal.tsx", 22 23 "./components/theme-toggle": "./src/components/theme-toggle.tsx" 23 24 }, 24 25 "peerDependencies": {
+110
packages/client/src/components/modal.css.ts
··· 1 + import { globalStyle, keyframes, style } from "@vanilla-extract/css"; 2 + import { vars } from "../theme.css.ts"; 3 + 4 + const bp = { 5 + sm: "screen and (min-width: 480px)", 6 + }; 7 + 8 + const fadeIn = keyframes({ 9 + from: { opacity: 0 }, 10 + to: { opacity: 1 }, 11 + }); 12 + 13 + const slideUp = keyframes({ 14 + from: { opacity: 0, transform: "translateY(16px)" }, 15 + to: { opacity: 1, transform: "translateY(0)" }, 16 + }); 17 + 18 + export const dialog = style({ 19 + boxSizing: "border-box", 20 + position: "fixed", 21 + inset: 0, 22 + paddingBlockStart: vars.space.md, 23 + paddingInline: vars.space.sm, 24 + border: "none", 25 + background: "none", 26 + color: vars.color.text, 27 + maxWidth: "none", 28 + maxHeight: "none", 29 + width: "100%", 30 + height: "100%", 31 + overflow: "auto", 32 + "@media": { 33 + [bp.sm]: { 34 + paddingBlockStart: "10vh", 35 + paddingInline: vars.space.md, 36 + }, 37 + }, 38 + }); 39 + 40 + globalStyle(`${dialog}[open]`, { 41 + display: "flex", 42 + alignItems: "flex-start", 43 + justifyContent: "center", 44 + animationName: fadeIn, 45 + animationDuration: "0.15s", 46 + animationTimingFunction: "ease-out", 47 + }); 48 + 49 + globalStyle(`${dialog}::backdrop`, { 50 + backgroundColor: "rgba(0, 0, 0, 0.5)", 51 + animationName: fadeIn, 52 + animationDuration: "0.15s", 53 + animationTimingFunction: "ease-out", 54 + }); 55 + 56 + export const content = style({ 57 + boxSizing: "border-box", 58 + backgroundColor: vars.color.surface, 59 + border: `1px solid ${vars.color.border}`, 60 + borderRadius: vars.radius.lg, 61 + paddingBlock: vars.space.md, 62 + paddingInline: vars.space.md, 63 + boxShadow: `0 8px 32px ${vars.color.shadowStrong}, 0 2px 8px ${vars.color.shadow}`, 64 + width: "100%", 65 + maxWidth: "560px", 66 + animationName: slideUp, 67 + animationDuration: "0.2s", 68 + animationTimingFunction: "ease-out", 69 + "@media": { 70 + [bp.sm]: { 71 + paddingBlock: vars.space.lg, 72 + paddingInline: vars.space.lg, 73 + }, 74 + }, 75 + }); 76 + 77 + export const header = style({ 78 + display: "flex", 79 + alignItems: "center", 80 + justifyContent: "space-between", 81 + marginBlockEnd: vars.space.lg, 82 + }); 83 + 84 + export const title = style({ 85 + fontFamily: vars.font.heading, 86 + fontSize: "1.125rem", 87 + fontWeight: 600, 88 + letterSpacing: "-0.01em", 89 + }); 90 + 91 + export const closeBtn = style({ 92 + display: "inline-flex", 93 + alignItems: "center", 94 + justifyContent: "center", 95 + background: "none", 96 + border: `1px solid ${vars.color.border}`, 97 + cursor: "pointer", 98 + color: vars.color.textMuted, 99 + inlineSize: "32px", 100 + blockSize: "32px", 101 + padding: 0, 102 + borderRadius: vars.radius.sm, 103 + lineHeight: 1, 104 + fontSize: "1.125rem", 105 + transition: "color 0.15s, background-color 0.15s", 106 + ":hover": { 107 + color: vars.color.text, 108 + backgroundColor: vars.color.surfaceHover, 109 + }, 110 + });
+51
packages/client/src/components/modal.tsx
··· 1 + import { useRef, useEffect } from "preact/hooks"; 2 + import type { Signal } from "@preact/signals"; 3 + import type { ComponentChildren } from "preact"; 4 + import * as s from "./modal.css.ts"; 5 + 6 + export function Modal({ 7 + open, 8 + onClose, 9 + title, 10 + children, 11 + }: { 12 + open: Signal<boolean>; 13 + onClose: () => void; 14 + title: string; 15 + children: ComponentChildren; 16 + }) { 17 + const dialogRef = useRef<HTMLDialogElement>(null); 18 + 19 + useEffect(() => { 20 + const el = dialogRef.current; 21 + if (!el) return; 22 + if (open.value && !el.open) { 23 + el.showModal(); 24 + } else if (!open.value && el.open) { 25 + el.close(); 26 + } 27 + }, [open.value]); 28 + 29 + // Close on backdrop click 30 + const handleClick = (e: MouseEvent) => { 31 + if (e.target === dialogRef.current) { 32 + onClose(); 33 + } 34 + }; 35 + 36 + return ( 37 + <dialog ref={dialogRef} class={s.dialog} onClose={onClose} onClick={handleClick}> 38 + {open.value && ( 39 + <div class={s.content}> 40 + <div class={s.header}> 41 + <span class={s.title}>{title}</span> 42 + <button type="button" class={s.closeBtn} onClick={onClose} aria-label="Close"> 43 + &times; 44 + </button> 45 + </div> 46 + {children} 47 + </div> 48 + )} 49 + </dialog> 50 + ); 51 + }
+6 -4
packages/feature-requests/src/ui/pages/feature-requests.tsx
··· 16 16 getMyVotes, 17 17 } from "../api/index.ts"; 18 18 import { categories, categoryLabels } from "../../schemas/feature-request.ts"; 19 + import { Modal } from "@exosphere/client/components/modal"; 19 20 import { RequestCard } from "../components/request-card.tsx"; 20 21 import { SortControls } from "../components/sort-controls.tsx"; 21 22 import { useSortParams } from "../hooks/use-sort-params.ts"; ··· 113 114 <textarea 114 115 id="fr-description" 115 116 class={ui.textarea} 117 + style={{ minHeight: "140px" }} 116 118 maxLength={10000} 117 119 placeholder="Describe the feature and why it would be useful" 118 120 value={description.value} ··· 235 237 <div class={ui.section}> 236 238 <div class={frUi.titleRow}> 237 239 <h1 class={ui.pageTitle}>Infuse</h1> 238 - {activeTab === "requests" && isAuthenticated && !showForm.value && ( 240 + {activeTab === "requests" && isAuthenticated && ( 239 241 <button class={ui.button} onClick={() => (showForm.value = true)}> 240 242 New request 241 243 </button> ··· 254 256 )} 255 257 </nav> 256 258 257 - {activeTab === "requests" && showForm.value && ( 258 - <div class={ui.card}> 259 + {activeTab === "requests" && ( 260 + <Modal open={showForm} onClose={() => (showForm.value = false)} title="New request"> 259 261 <SubmitForm onCreated={onCreated} /> 260 - </div> 262 + </Modal> 261 263 )} 262 264 </div> 263 265
+7
packages/kanban/src/ui/components/column.tsx
··· 17 17 onDrop, 18 18 onCardDragStart, 19 19 onCardDragEnd, 20 + onAddTask, 20 21 }: { 21 22 title: string; 22 23 status: string; ··· 29 30 onDrop?: (e: DragEvent) => void; 30 31 onCardDragStart?: (task: KanbanTaskListItem, status: string) => (e: DragEvent) => void; 31 32 onCardDragEnd?: () => void; 33 + onAddTask?: () => void; 32 34 }) { 33 35 const dt = dropTarget?.value; 34 36 const isOver = draggedTaskId && dt?.status === status; ··· 61 63 </Fragment> 62 64 ))} 63 65 {indicatorIndex === tasks.length && <div class={kbUi.dropIndicator} />} 66 + {onAddTask && ( 67 + <button type="button" class={kbUi.columnAddBtn} onClick={onAddTask}> 68 + + 69 + </button> 70 + )} 64 71 </div> 65 72 ); 66 73 }
+5 -2
packages/kanban/src/ui/components/task-form.tsx
··· 5 5 export function TaskForm({ 6 6 columns, 7 7 onCreated, 8 + initialStatus, 8 9 }: { 9 10 columns: KanbanColumnDef[]; 10 11 onCreated: () => void; 12 + initialStatus?: string; 11 13 }) { 12 14 const title = useSignal(""); 13 15 const description = useSignal(""); 14 - const status = useSignal(columns[0]?.slug ?? "backlog"); 16 + const status = useSignal(initialStatus ?? columns[0]?.slug ?? "backlog"); 15 17 const error = useSignal(""); 16 18 const submitting = useSignal(false); 17 19 ··· 69 71 70 72 <div> 71 73 <label class={ui.label} htmlFor="kb-status"> 72 - Column 74 + Status 73 75 </label> 74 76 <select 75 77 id="kb-status" ··· 92 94 <textarea 93 95 id="kb-description" 94 96 class={ui.textarea} 97 + style={{ minHeight: "140px" }} 95 98 maxLength={10000} 96 99 placeholder="Describe the task in detail" 97 100 value={description.value}
+13 -7
packages/kanban/src/ui/pages/board.tsx
··· 10 10 import * as kbUi from "../ui.css.ts"; 11 11 import { getTasks } from "../api/tasks.ts"; 12 12 import type { KanbanColumnDef, KanbanTaskListItem } from "../api/tasks.ts"; 13 + import { Modal } from "@exosphere/client/components/modal"; 13 14 import { Column } from "../components/column.tsx"; 14 15 import { TaskForm } from "../components/task-form.tsx"; 15 16 import { useBoardDnd } from "../hooks/use-board-dnd.ts"; 16 17 17 18 export function BoardPage() { 18 19 const showForm = useSignal(false); 20 + const formInitialStatus = useSignal<string | undefined>(undefined); 19 21 const prefetched = ssrPageData.peek()?.["kanban-tasks"] as 20 22 | Awaited<ReturnType<typeof getTasks>> 21 23 | undefined; ··· 51 53 52 54 const dnd = useBoardDnd(tasksByColumn, isAuthenticated && canChangeStatus.value); 53 55 56 + const openForm = (status?: string) => { 57 + formInitialStatus.value = status; 58 + showForm.value = true; 59 + }; 60 + 54 61 const onCreated = () => { 55 62 showForm.value = false; 56 63 refetch(); ··· 67 74 <Settings size={18} /> 68 75 </a> 69 76 )} 70 - {isAuthenticated && canCreate.value && !showForm.value && ( 71 - <button class={ui.button} onClick={() => (showForm.value = true)}> 77 + {isAuthenticated && canCreate.value && ( 78 + <button class={ui.button} onClick={() => openForm()}> 72 79 New task 73 80 </button> 74 81 )} 75 82 </div> 76 83 </div> 77 84 78 - {showForm.value && ( 79 - <div class={ui.card}> 80 - <TaskForm columns={columnDefs.value} onCreated={onCreated} /> 81 - </div> 82 - )} 85 + <Modal open={showForm} onClose={() => (showForm.value = false)} title="New task"> 86 + <TaskForm columns={columnDefs.value} onCreated={onCreated} initialStatus={formInitialStatus.value} /> 87 + </Modal> 83 88 84 89 {pending && !data ? ( 85 90 loading ? ( ··· 119 124 onDrop={dnd.onColumnDrop(col.slug)} 120 125 onCardDragStart={dnd.onDragStart} 121 126 onCardDragEnd={dnd.onDragEnd} 127 + onAddTask={isAuthenticated && canCreate.value ? () => openForm(col.slug) : undefined} 122 128 /> 123 129 ))} 124 130 </div>
+30 -1
packages/kanban/src/ui/ui.css.ts
··· 4 4 // ---- Board layout ---- 5 5 6 6 export const boardSection = style({ 7 + display: "flex", 8 + flexDirection: "column", 9 + minBlockSize: "calc(100vh - 220px)", 7 10 paddingInline: vars.space.xl, 8 11 "@media": { 9 12 "(min-width: 900px)": { ··· 17 20 gap: vars.space.md, 18 21 overflowX: "auto", 19 22 paddingBlockEnd: vars.space.sm, 23 + flex: 1, 20 24 "@media": { 21 25 "(max-width: 900px)": { 22 26 flexDirection: "column", ··· 28 32 display: "flex", 29 33 flexDirection: "column", 30 34 gap: vars.space.sm, 31 - minBlockSize: "200px", 32 35 flex: "1 1 0", 33 36 minInlineSize: "220px", 34 37 transition: "background-color 0.15s", ··· 55 58 export const columnCount = style({ 56 59 fontSize: "0.8125rem", 57 60 color: vars.color.textMuted, 61 + }); 62 + 63 + export const columnAddBtn = style({ 64 + display: "flex", 65 + alignItems: "center", 66 + justifyContent: "center", 67 + width: "100%", 68 + paddingBlock: vars.space.sm, 69 + borderRadius: vars.radius.sm, 70 + border: `1px solid ${vars.color.border}`, 71 + backgroundColor: vars.color.surface, 72 + color: vars.color.textMuted, 73 + cursor: "pointer", 74 + fontSize: "1.125rem", 75 + lineHeight: 1, 76 + opacity: 0, 77 + transition: "opacity 200ms, border-color 200ms, color 200ms", 78 + ":hover": { 79 + opacity: 1, 80 + borderColor: vars.color.primary, 81 + color: vars.color.primary, 82 + }, 83 + }); 84 + 85 + globalStyle(`${column}:hover ${columnAddBtn}`, { 86 + opacity: 0.64, 58 87 }); 59 88 60 89 // ---- Task card (compact, in column) ----