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.

chore: various ui improvements

Hugo 905bbf15 1dc798f4

+234 -47
+4
packages/app/src/app.css.ts
··· 45 45 shadow: "rgba(0, 0, 0, 0.04)", 46 46 shadowStrong: "rgba(0, 0, 0, 0.08)", 47 47 focusRing: "rgba(59, 130, 246, 0.2)", 48 + skeleton: "#e2e8f0", 49 + skeletonShimmer: "#f1f5f9", 48 50 }, 49 51 ...shared, 50 52 }); ··· 69 71 shadow: "rgba(0, 0, 0, 0.2)", 70 72 shadowStrong: "rgba(0, 0, 0, 0.35)", 71 73 focusRing: "rgba(96, 165, 250, 0.25)", 74 + skeleton: "#334155", 75 + skeletonShimmer: "#475569", 72 76 }, 73 77 ...shared, 74 78 });
+17 -2
packages/app/src/app.tsx
··· 119 119 } 120 120 121 121 function MainContent({ moduleRoutes }: { moduleRoutes: ModuleRoute[] }) { 122 - const { pending, data } = sphereState.value; 122 + const { pending, loading, data } = sphereState.value; 123 123 124 124 if (isMultiSphere) { 125 125 const routes = useMemo(() => buildRoutes(moduleRoutes), [moduleRoutes]); ··· 159 159 160 160 // Still loading sphere 161 161 if (pending) { 162 + if (!loading) return null; 162 163 return ( 163 164 <div class={ui.container}> 164 - <p class={ui.muted}>Loading...</p> 165 + <div class={ui.section}> 166 + <div class={ui.skeletonLine} style={{ inlineSize: "200px", blockSize: "1.625rem" }} /> 167 + <div class={ui.skeletonLine} style={{ inlineSize: "80%" }} /> 168 + </div> 169 + <div class={ui.section}> 170 + <div class={ui.skeletonLine} style={{ inlineSize: "100px", blockSize: "1.125rem" }} /> 171 + <div class={ui.stackSm}> 172 + {[0, 1].map((i) => ( 173 + <div key={i} class={ui.card} style={{ pointerEvents: "none" }}> 174 + <div class={ui.skeletonLine} style={{ inlineSize: "50%" }} /> 175 + <div class={ui.skeletonLine} style={{ inlineSize: "70%", marginBlockStart: "8px" }} /> 176 + </div> 177 + ))} 178 + </div> 179 + </div> 165 180 </div> 166 181 ); 167 182 }
+19 -2
packages/app/src/pages/dashboard.tsx
··· 27 27 function MySpheres() { 28 28 const ssrData = ssrPageData.value?.["my-spheres"] as MySpheresData | undefined; 29 29 const version = sphereListVersion.value; 30 - const { data, pending } = useQuery(() => getMySpheres(), [version], { 30 + const { data, pending, loading } = useQuery(() => getMySpheres(), [version], { 31 31 initialData: version === 0 ? ssrData : undefined, 32 32 }); 33 33 34 34 const spheres = data?.spheres.filter((sp) => sp.role === "owner" || sp.role === "admin") ?? []; 35 35 36 - if (pending || spheres.length === 0) return null; 36 + if (pending && !data) { 37 + if (!loading) return null; 38 + return ( 39 + <div class={ui.section}> 40 + <div class={ui.skeletonLine} style={{ inlineSize: "120px", blockSize: "1.125rem" }} /> 41 + <div class={ui.stackSm}> 42 + {[0, 1].map((i) => ( 43 + <div key={i} class={ui.cardLink} style={{ pointerEvents: "none" }}> 44 + <div class={ui.skeletonLine} style={{ inlineSize: "60%" }} /> 45 + <div class={ui.skeletonLine} style={{ inlineSize: "80%" }} /> 46 + </div> 47 + ))} 48 + </div> 49 + </div> 50 + ); 51 + } 52 + 53 + if (spheres.length === 0) return null; 37 54 38 55 return ( 39 56 <div class={ui.section}>
+29 -3
packages/app/src/pages/sphere.tsx
··· 29 29 }; 30 30 31 31 export function SpherePage() { 32 - const { data } = sphereState.value; 32 + const { data, loading } = sphereState.value; 33 33 const handle = sphereHandle.value; 34 34 35 - if (!data || !handle) return null; 35 + if (!handle) return null; 36 + 37 + if (!data) { 38 + if (!loading) return null; 39 + return ( 40 + <div class={ui.container}> 41 + <div class={ui.section}> 42 + <div class={ui.row}> 43 + <div class={ui.skeletonLine} style={{ inlineSize: "200px", blockSize: "1.625rem" }} /> 44 + <div class={ui.skeletonLine} style={{ inlineSize: "60px", blockSize: "1.25rem" }} /> 45 + </div> 46 + <div class={ui.skeletonLine} style={{ inlineSize: "80%" }} /> 47 + </div> 48 + <div class={ui.section}> 49 + <div class={ui.skeletonLine} style={{ inlineSize: "100px", blockSize: "1.125rem" }} /> 50 + <div class={ui.stackSm}> 51 + {[0, 1].map((i) => ( 52 + <div key={i} class={ui.card} style={{ pointerEvents: "none" }}> 53 + <div class={ui.skeletonLine} style={{ inlineSize: "40%" }} /> 54 + <div class={ui.skeletonLine} style={{ inlineSize: "70%", marginBlockStart: "8px" }} /> 55 + </div> 56 + ))} 57 + </div> 58 + </div> 59 + </div> 60 + ); 61 + } 36 62 37 63 const modules = useQuery(() => getSphereModules(handle), [handle]); 38 64 ··· 73 99 <h2 class={ui.sectionTitle}>Modules</h2> 74 100 75 101 {enabledModules.length === 0 && availableToEnable.length === 0 && ( 76 - <p class={ui.muted}>No modules available.</p> 102 + <p class={ui.emptyState}>No modules available.</p> 77 103 )} 78 104 79 105 {enabledModules.length > 0 && (
+17
packages/app/src/server.ts
··· 188 188 try { 189 189 const res = await app.request( 190 190 `${apiBase}/feature-requests/${frData.featureRequest.id}/comments`, 191 + prefetchInit, 191 192 ); 192 193 if (res.ok) pageData["feature-request-comments"] = await res.json(); 194 + } catch { 195 + /* prefetch failed — client will fetch */ 196 + } 197 + } 198 + 199 + // For individual kanban tasks, also prefetch comments 200 + const taskData = pageData["kanban-task"] as 201 + | { task?: { id: string } } 202 + | undefined; 203 + if (taskData?.task?.id) { 204 + try { 205 + const res = await app.request( 206 + `${apiBase}/kanban/${taskData.task.id}/comments`, 207 + prefetchInit, 208 + ); 209 + if (res.ok) pageData["kanban-task-comments"] = await res.json(); 193 210 } catch { 194 211 /* prefetch failed — client will fetch */ 195 212 }
+16
packages/app/src/vite-ssr-plugin.ts
··· 165 165 /* prefetch failed — client will fetch */ 166 166 } 167 167 } 168 + 169 + // For individual kanban tasks, also prefetch comments 170 + const taskData = pageData["kanban-task"] as 171 + | { task?: { id: string } } 172 + | undefined; 173 + if (taskData?.task?.id) { 174 + try { 175 + const res = await fetch( 176 + `${apiBase}/kanban/${taskData.task.id}/comments`, 177 + { headers: { cookie } }, 178 + ); 179 + if (res.ok) pageData["kanban-task-comments"] = await res.json(); 180 + } catch { 181 + /* prefetch failed — client will fetch */ 182 + } 183 + } 168 184 } 169 185 170 186 const ssrData = {
+2
packages/client/src/theme.css.ts
··· 20 20 shadow: null, 21 21 shadowStrong: null, 22 22 focusRing: null, 23 + skeleton: null, 24 + skeletonShimmer: null, 23 25 }, 24 26 size: { 25 27 containerMaxWidth: null,
+56 -16
packages/client/src/ui.css.ts
··· 1 - import { globalStyle, style } from "@vanilla-extract/css"; 1 + import { globalStyle, keyframes, style } from "@vanilla-extract/css"; 2 2 import { vars } from "./theme.css.ts"; 3 3 4 4 globalStyle("html, body", { ··· 79 79 backgroundColor: vars.color.surface, 80 80 paddingBlock: vars.space.sm, 81 81 marginBlockEnd: vars.space.md, 82 - boxShadow: `0 1px 3px ${vars.color.shadow}, 0 1px 2px ${vars.color.shadow}`, 82 + boxShadow: `0 1px 4px ${vars.color.shadow}, 0 1px 2px ${vars.color.shadow}`, 83 83 transition: "background-color 0.2s, border-color 0.2s, box-shadow 0.2s", 84 84 "@media": { 85 85 [bp.sm]: { ··· 106 106 ":hover": { textDecoration: "none" }, 107 107 "@media": { 108 108 [bp.sm]: { 109 - fontSize: "1.125rem", 109 + fontSize: "1.25rem", 110 110 }, 111 111 }, 112 112 }); ··· 134 134 transition: "background-color 0.2s, border-color 0.2s, box-shadow 0.2s", 135 135 ":hover": { 136 136 backgroundColor: vars.color.surfaceHover, 137 + boxShadow: `0 4px 12px ${vars.color.shadowStrong}, 0 2px 4px ${vars.color.shadow}`, 137 138 }, 138 139 "@media": { 139 140 [bp.sm]: { ··· 160 161 backgroundColor: vars.color.surfaceHover, 161 162 borderColor: vars.color.primary, 162 163 boxShadow: `0 4px 12px ${vars.color.shadowStrong}, 0 2px 4px ${vars.color.shadow}`, 163 - transform: "translateY(-1px)", 164 + transform: "translateY(-2px)", 164 165 textDecoration: "none", 165 166 }, 166 167 "@media": { ··· 211 212 lineHeight: 1.5, 212 213 cursor: "pointer", 213 214 fontFamily: vars.font.body, 214 - minBlockSize: "36px", 215 + minBlockSize: "38px", 215 216 whiteSpace: "nowrap" as const, 216 217 transition: "background-color 0.15s, opacity 0.15s, box-shadow 0.15s, transform 0.1s", 217 218 }; ··· 221 222 paddingBlock: "6px", 222 223 paddingInline: vars.space.lg, 223 224 border: "none", 224 - fontSize: "0.75rem", 225 + fontSize: "0.8125rem", 225 226 backgroundColor: vars.color.primary, 226 227 color: "#fff", 227 228 boxShadow: `0 1px 2px ${vars.color.shadow}`, ··· 239 240 paddingBlock: "6px", 240 241 paddingInline: vars.space.lg, 241 242 border: `1px solid ${vars.color.border}`, 242 - fontSize: "0.75rem", 243 + fontSize: "0.8125rem", 243 244 backgroundColor: vars.color.surface, 244 245 color: vars.color.text, 245 246 ":hover": { ··· 255 256 paddingBlock: "6px", 256 257 paddingInline: vars.space.md, 257 258 border: "none", 258 - fontSize: "0.75rem", 259 - minBlockSize: "36px", 259 + fontSize: "0.8125rem", 260 260 backgroundColor: vars.color.danger, 261 261 color: "#fff", 262 262 ":hover": { opacity: 0.9 }, ··· 327 327 export const pageTitle = style({ 328 328 fontFamily: vars.font.heading, 329 329 fontSize: "1.25rem", 330 - fontWeight: 700, 330 + fontWeight: 800, 331 331 letterSpacing: "-0.02em", 332 332 "@media": { 333 333 [bp.sm]: { 334 - fontSize: "1.5rem", 334 + fontSize: "1.625rem", 335 335 }, 336 336 }, 337 337 }); ··· 339 339 export const sectionTitle = style({ 340 340 fontFamily: vars.font.heading, 341 341 fontSize: "1rem", 342 - fontWeight: 600, 342 + fontWeight: 700, 343 343 letterSpacing: "-0.01em", 344 344 "@media": { 345 345 [bp.sm]: { ··· 376 376 export const muted = style({ 377 377 color: vars.color.textMuted, 378 378 fontSize: "0.8125rem", 379 + lineHeight: 1.5, 379 380 }); 380 381 381 382 export const description = style({ ··· 440 441 paddingBlock: "2px", 441 442 paddingInline: vars.space.sm, 442 443 borderRadius: vars.radius.sm, 443 - fontSize: "0.75rem", 444 - fontWeight: 500, 444 + fontSize: "0.6875rem", 445 + fontWeight: 600, 446 + letterSpacing: "0.02em", 447 + lineHeight: 1, 448 + textTransform: "uppercase", 445 449 backgroundColor: vars.color.primaryLight, 446 450 color: vars.color.primary, 447 451 }); ··· 537 541 paddingInline: vars.space.md, 538 542 border: "none", 539 543 fontSize: "0.75rem", 540 - minBlockSize: "36px", 544 + minBlockSize: "36px", // override btnBase for compact variant 541 545 backgroundColor: vars.color.primary, 542 546 color: "#fff", 543 547 ":hover": { backgroundColor: vars.color.primaryHover }, ··· 550 554 paddingInline: vars.space.md, 551 555 border: `1px solid ${vars.color.border}`, 552 556 fontSize: "0.75rem", 553 - minBlockSize: "36px", 557 + minBlockSize: "36px", // override btnBase for compact variant 554 558 backgroundColor: vars.color.surface, 555 559 color: vars.color.text, 556 560 ":hover": { borderColor: vars.color.primary }, ··· 702 706 color: vars.color.primary, 703 707 }, 704 708 }); 709 + 710 + // ---- Empty state ---- 711 + 712 + export const emptyState = style({ 713 + textAlign: "center", 714 + paddingBlock: vars.space.xl, 715 + color: vars.color.textMuted, 716 + fontSize: "0.875rem", 717 + lineHeight: 1.6, 718 + }); 719 + 720 + // ---- Skeleton loading ---- 721 + 722 + const shimmer = keyframes({ 723 + "0%": { backgroundPosition: "-200% 0" }, 724 + "100%": { backgroundPosition: "200% 0" }, 725 + }); 726 + 727 + const skeletonBase = { 728 + background: `linear-gradient(90deg, ${vars.color.skeleton} 25%, ${vars.color.skeletonShimmer} 50%, ${vars.color.skeleton} 75%)`, 729 + backgroundSize: "200% 100%", 730 + animationName: shimmer, 731 + animationDuration: "1.5s", 732 + animationTimingFunction: "ease-in-out", 733 + animationIterationCount: "infinite" as const, 734 + borderRadius: vars.radius.sm, 735 + }; 736 + 737 + export const skeletonLine = style({ 738 + ...skeletonBase, 739 + blockSize: "1em", 740 + }); 741 + 742 + export const skeletonRect = style({ 743 + ...skeletonBase, 744 + });
+17 -13
packages/feature-requests/src/ui/pages/feature-request.tsx
··· 322 322 323 323 function CommentsSection({ 324 324 requestId, 325 + commentCount: initialCount, 325 326 status, 326 327 isAuthenticated, 327 328 canComment, ··· 330 331 currentHandle, 331 332 }: { 332 333 requestId: string; 334 + commentCount: number; 333 335 status: string; 334 336 isAuthenticated: boolean; 335 337 canComment: boolean; ··· 417 419 } 418 420 }; 419 421 420 - const commentTitle = `Comments (${comments.value.length})`; 422 + const count = comments.value.length > 0 || !pending ? comments.value.length : initialCount; 423 + const commentTitle = `Comments (${count})`; 421 424 422 425 return ( 423 426 <CollapsibleSection title={commentTitle} defaultExpanded> 427 + {canComment && 428 + !hasCommented.value && 429 + status !== "done" && 430 + status !== "not-planned" && 431 + status !== "duplicate" && ( 432 + <CommentForm 433 + requestId={requestId} 434 + currentHandle={currentHandle} 435 + onCreated={handleCreated} 436 + /> 437 + )} 438 + 424 439 <SortControls id="comment-sort-by" sortBy={commentSortBy} sortOrder={commentSortOrder} /> 425 440 {pending && comments.value.length === 0 ? ( 426 441 loading ? ( ··· 446 461 ))} 447 462 </div> 448 463 )} 449 - 450 - {canComment && 451 - !hasCommented.value && 452 - status !== "done" && 453 - status !== "not-planned" && 454 - status !== "duplicate" && ( 455 - <CommentForm 456 - requestId={requestId} 457 - currentHandle={currentHandle} 458 - onCreated={handleCreated} 459 - /> 460 - )} 461 464 </CollapsibleSection> 462 465 ); 463 466 } ··· 684 687 685 688 <CommentsSection 686 689 requestId={fr.id} 690 + commentCount={fr.commentCount} 687 691 status={fr.status} 688 692 isAuthenticated={isAuthenticated} 689 693 canComment={canComment.value}
+13 -2
packages/feature-requests/src/ui/pages/feature-requests.tsx
··· 266 266 267 267 {pending && !data ? ( 268 268 loading ? ( 269 - <p class={ui.muted}>Loading...</p> 269 + <div class={ui.stackSm}> 270 + {[0, 1, 2].map((i) => ( 271 + <div key={i} class={`${ui.card} ${frUi.cardWithVote}`}> 272 + <div class={ui.skeletonRect} style={{ inlineSize: "60px", blockSize: "66px" }} /> 273 + <div class={`${frUi.cardContent} ${ui.stack}`}> 274 + <div class={ui.skeletonLine} style={{ inlineSize: "60%" }} /> 275 + <div class={ui.skeletonLine} style={{ inlineSize: "100%" }} /> 276 + <div class={ui.skeletonLine} style={{ inlineSize: "40%" }} /> 277 + </div> 278 + </div> 279 + ))} 280 + </div> 270 281 ) : null 271 282 ) : error ? ( 272 283 <p class={ui.errorText}>{error}</p> 273 284 ) : !data || data.featureRequests.length === 0 ? ( 274 - <p class={ui.muted}> 285 + <p class={ui.emptyState}> 275 286 No feature requests yet. 276 287 {isAuthenticated && " Be the first to submit one."} 277 288 </p>
+1
packages/feature-requests/src/ui/ui.css.ts
··· 32 32 color: vars.color.primary, 33 33 }, 34 34 ":disabled": { opacity: 0.5, cursor: "not-allowed" }, 35 + ":active": { transform: "scale(0.95)" }, 35 36 }); 36 37 37 38 export const voteButtonActive = style({
+17 -1
packages/kanban/src/ui/pages/board.tsx
··· 83 83 84 84 {pending && !data ? ( 85 85 loading ? ( 86 - <p class={ui.muted}>Loading...</p> 86 + <div class={kbUi.boardGrid}> 87 + {[0, 1, 2].map((i) => ( 88 + <div key={i} class={kbUi.column}> 89 + <div class={kbUi.columnHeader}> 90 + <div class={ui.skeletonLine} style={{ inlineSize: "80px" }} /> 91 + <div class={ui.skeletonLine} style={{ inlineSize: "20px" }} /> 92 + </div> 93 + {[0, 1].map((j) => ( 94 + <div key={j} class={kbUi.taskCard} style={{ pointerEvents: "none" }}> 95 + <div class={ui.skeletonLine} style={{ inlineSize: "40px" }} /> 96 + <div class={ui.skeletonLine} style={{ inlineSize: "90%", marginBlockStart: "4px" }} /> 97 + <div class={ui.skeletonLine} style={{ inlineSize: "50%", marginBlockStart: "4px" }} /> 98 + </div> 99 + ))} 100 + </div> 101 + ))} 102 + </div> 87 103 ) : null 88 104 ) : error ? ( 89 105 <p class={ui.errorText}>{error}</p>
+23 -7
packages/kanban/src/ui/pages/task.tsx
··· 176 176 177 177 function CommentsSection({ 178 178 taskId, 179 + commentCount: initialCount, 179 180 canComment, 180 181 canModerate, 181 182 currentDid, 182 183 currentHandle, 183 184 }: { 184 185 taskId: string; 186 + commentCount: number; 185 187 canComment: boolean; 186 188 canModerate: boolean; 187 189 currentDid: string | null; 188 190 currentHandle: string | null; 189 191 }) { 190 - const comments = useSignal<KanbanTaskCommentListItem[]>([]); 191 - const { data, pending, loading } = useQuery(() => getComments(taskId), [taskId]); 192 + const prefetchedComments = ssrPageData.peek()?.["kanban-task-comments"] as 193 + | Awaited<ReturnType<typeof getComments>> 194 + | undefined; 195 + useEffect(() => { 196 + const pd = ssrPageData.peek(); 197 + if (pd && "kanban-task-comments" in pd) delete pd["kanban-task-comments"]; 198 + }, []); 199 + 200 + const comments = useSignal<KanbanTaskCommentListItem[]>(prefetchedComments?.comments ?? []); 201 + const { data, pending, loading } = useQuery( 202 + () => getComments(taskId), 203 + [taskId], 204 + prefetchedComments ? { initialData: prefetchedComments } : undefined, 205 + ); 192 206 193 207 const prevData = useRef(data); 194 208 if (data && data !== prevData.current) { ··· 216 230 } 217 231 }; 218 232 219 - const commentTitle = `Comments (${comments.value.length})`; 233 + const count = comments.value.length > 0 || !pending ? comments.value.length : initialCount; 234 + const commentTitle = `Comments (${count})`; 220 235 221 236 return ( 222 237 <CollapsibleSection title={commentTitle} defaultExpanded> 238 + {canComment && ( 239 + <CommentForm taskId={taskId} currentHandle={currentHandle} onCreated={handleCreated} /> 240 + )} 241 + 223 242 {pending && comments.value.length === 0 ? ( 224 243 loading ? ( 225 244 <p class={ui.muted}>Loading comments...</p> ··· 239 258 /> 240 259 ))} 241 260 </div> 242 - )} 243 - 244 - {canComment && ( 245 - <CommentForm taskId={taskId} currentHandle={currentHandle} onCreated={handleCreated} /> 246 261 )} 247 262 </CollapsibleSection> 248 263 ); ··· 553 568 554 569 <CommentsSection 555 570 taskId={task.id} 571 + commentCount={task.commentCount} 556 572 canComment={canComment.value} 557 573 canModerate={canModerate.value} 558 574 currentDid={currentDid}
+3 -1
packages/kanban/src/ui/ui.css.ts
··· 89 89 backgroundColor: vars.color.surface, 90 90 textDecoration: "none", 91 91 color: vars.color.text, 92 - transition: "border-color 0.15s", 92 + transition: "border-color 0.15s, box-shadow 0.15s, transform 0.15s", 93 93 ":hover": { 94 94 borderColor: vars.color.primary, 95 + boxShadow: `0 2px 8px ${vars.color.shadow}`, 96 + transform: "translateY(-1px)", 95 97 textDecoration: "none", 96 98 }, 97 99 });