BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

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

feat: drag and drop cols

+160 -39
+91 -18
src/components/deck/DeckColumn.tsx
··· 5 5 import { SearchPanel } from "$/components/search/SearchPanel"; 6 6 import type { Column, ColumnWidth } from "$/lib/api/types/columns"; 7 7 import type { PostView, SavedFeedItem } from "$/lib/types"; 8 - import { Match, Show, Switch } from "solid-js"; 8 + import { createSignal, Match, Show, Switch } from "solid-js"; 9 9 import { DiagnosticsColumn } from "./DiagnosticsColumn"; 10 10 import { 11 11 COLUMN_WIDTH_PX, ··· 21 21 type DeckColumnProps = { 22 22 column: Column; 23 23 feedColumn?: ResolvedFeedColumn; 24 + isDragOver: boolean; 24 25 onClose: (id: string) => void; 26 + onDragEnd: () => void; 27 + onDragOver: (id: string) => void; 28 + onDragStart: (id: string) => void; 29 + onDrop: (targetId: string) => void; 25 30 onMoveLeft: (id: string) => void; 26 31 onMoveRight: (id: string) => void; 27 32 onOpenThread: (uri: string) => void; 28 33 onWidthChange: (id: string, width: ColumnWidth) => void; 29 34 }; 30 35 36 + function snapToColumnWidth(px: number): ColumnWidth { 37 + const narrow = Math.abs(px - COLUMN_WIDTH_PX.narrow); 38 + const standard = Math.abs(px - COLUMN_WIDTH_PX.standard); 39 + const wide = Math.abs(px - COLUMN_WIDTH_PX.wide); 40 + if (narrow <= standard && narrow <= wide) return "narrow"; 41 + if (standard <= wide) return "standard"; 42 + return "wide"; 43 + } 44 + 31 45 function widthLabel(width: ColumnWidth): string { 32 46 switch (width) { 33 47 case "narrow": { ··· 45 59 type ColumnHeaderProps = { 46 60 column: Column; 47 61 onClose: () => void; 62 + onDragEnd: () => void; 63 + onDragStart: (e: DragEvent) => void; 48 64 onMoveLeft: () => void; 49 65 onMoveRight: () => void; 50 66 onWidthCycle: () => void; ··· 103 119 <header class="flex shrink-0 items-center gap-2 rounded-t-2xl bg-[rgba(14,14,14,0.94)] px-3 py-2.5 backdrop-blur-[18px] shadow-[inset_0_-1px_0_rgba(255,255,255,0.04)]"> 104 120 <span 105 121 class="flex cursor-grab items-center text-on-surface-variant opacity-40 hover:opacity-80 active:cursor-grabbing" 122 + draggable="true" 123 + onDragStart={(e) => props.onDragStart(e)} 124 + onDragEnd={() => props.onDragEnd()} 106 125 aria-hidden="true" 107 126 title="Drag to reorder"> 108 127 <i class="i-ri-draggable" /> ··· 244 263 } 245 264 246 265 export function DeckColumn(props: DeckColumnProps) { 266 + const [resizingWidth, setResizingWidth] = createSignal<number | null>(null); 247 267 const title = () => props.feedColumn?.title ?? columnTitle(props.column.kind, props.column.config); 248 - const widthPx = () => COLUMN_WIDTH_PX[props.column.width]; 268 + const widthPx = () => resizingWidth() ?? COLUMN_WIDTH_PX[props.column.width]; 269 + 270 + function handleDragStart(e: DragEvent) { 271 + if (e.dataTransfer) { 272 + e.dataTransfer.effectAllowed = "move"; 273 + } 274 + props.onDragStart(props.column.id); 275 + } 276 + 277 + function handleResizeStart(e: MouseEvent) { 278 + e.preventDefault(); 279 + const startX = e.clientX; 280 + const startWidth = COLUMN_WIDTH_PX[props.column.width]; 281 + 282 + function onMove(mv: MouseEvent) { 283 + setResizingWidth(Math.max(240, startWidth + mv.clientX - startX)); 284 + } 285 + 286 + function onUp() { 287 + const finalPx = resizingWidth() ?? startWidth; 288 + setResizingWidth(null); 289 + const snapped = snapToColumnWidth(finalPx); 290 + if (snapped !== props.column.width) { 291 + props.onWidthChange(props.column.id, snapped); 292 + } 293 + globalThis.removeEventListener("mousemove", onMove); 294 + globalThis.removeEventListener("mouseup", onUp); 295 + } 296 + 297 + globalThis.addEventListener("mousemove", onMove); 298 + globalThis.addEventListener("mouseup", onUp); 299 + } 249 300 250 301 return ( 251 - <section 252 - class="flex h-full shrink-0 flex-col overflow-hidden rounded-2xl bg-[rgba(8,8,8,0.32)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]" 253 - style={{ width: `${widthPx()}px` }}> 254 - <ColumnHeader 255 - column={props.column} 256 - title={title()} 257 - onClose={() => props.onClose(props.column.id)} 258 - onMoveLeft={() => props.onMoveLeft(props.column.id)} 259 - onMoveRight={() => props.onMoveRight(props.column.id)} 260 - onWidthCycle={() => props.onWidthChange(props.column.id, cycleWidth(props.column.width))} /> 261 - <div class="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)]"> 262 - <ColumnBody 302 + <div 303 + class="relative flex h-full shrink-0 flex-col" 304 + style={{ width: `${widthPx()}px` }} 305 + onDragOver={(e) => { 306 + e.preventDefault(); 307 + props.onDragOver(props.column.id); 308 + }} 309 + onDrop={(e) => { 310 + e.preventDefault(); 311 + props.onDrop(props.column.id); 312 + }}> 313 + <section 314 + class="flex h-full w-full flex-col overflow-hidden rounded-2xl bg-[rgba(8,8,8,0.32)] transition-shadow duration-150" 315 + classList={{ 316 + "shadow-[inset_0_0_0_2px_rgba(125,175,255,0.45)]": props.isDragOver, 317 + "shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]": !props.isDragOver, 318 + }}> 319 + <ColumnHeader 263 320 column={props.column} 264 - feedColumn={props.feedColumn} 265 - onClose={props.onClose} 266 - onOpenThread={props.onOpenThread} /> 321 + title={title()} 322 + onClose={() => props.onClose(props.column.id)} 323 + onDragEnd={props.onDragEnd} 324 + onDragStart={handleDragStart} 325 + onMoveLeft={() => props.onMoveLeft(props.column.id)} 326 + onMoveRight={() => props.onMoveRight(props.column.id)} 327 + onWidthCycle={() => props.onWidthChange(props.column.id, cycleWidth(props.column.width))} /> 328 + <div class="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)]"> 329 + <ColumnBody 330 + column={props.column} 331 + feedColumn={props.feedColumn} 332 + onClose={props.onClose} 333 + onOpenThread={props.onOpenThread} /> 334 + </div> 335 + </section> 336 + <div 337 + class="absolute -right-1 top-2 bottom-2 z-20 w-2 cursor-col-resize opacity-0 hover:opacity-100 transition-opacity duration-150 flex items-center justify-center" 338 + onMouseDown={handleResizeStart}> 339 + <div class="h-full w-0.5 rounded-full bg-primary/50" /> 267 340 </div> 268 - </section> 341 + </div> 269 342 ); 270 343 }
+65
src/components/deck/DeckWorkspace.tsx
··· 17 17 type DeckState = { 18 18 addPanelOpen: boolean; 19 19 columns: Column[]; 20 + dragOverId: string | null; 20 21 error: string | null; 21 22 feedColumns: Record<string, ResolvedFeedColumn>; 22 23 loading: boolean; ··· 72 73 function ColumnList( 73 74 props: { 74 75 columns: Column[]; 76 + dragOverId: string | null; 75 77 feedColumns: Record<string, ResolvedFeedColumn>; 76 78 onClose: (id: string) => void; 79 + onDragEnd: () => void; 80 + onDragOver: (id: string) => void; 81 + onDragStart: (id: string) => void; 82 + onDrop: (targetId: string) => void; 77 83 onMoveLeft: (id: string) => void; 78 84 onMoveRight: (id: string) => void; 79 85 onOpenThread: (uri: string) => void; ··· 92 98 <DeckColumn 93 99 column={column} 94 100 feedColumn={props.feedColumns[column.id]} 101 + isDragOver={props.dragOverId === column.id} 95 102 onClose={props.onClose} 103 + onDragEnd={props.onDragEnd} 104 + onDragOver={props.onDragOver} 105 + onDragStart={props.onDragStart} 106 + onDrop={props.onDrop} 96 107 onMoveLeft={props.onMoveLeft} 97 108 onMoveRight={props.onMoveRight} 98 109 onOpenThread={props.onOpenThread} ··· 123 134 const session = useAppSession(); 124 135 const navigate = useNavigate(); 125 136 let feedColumnRequest = 0; 137 + // Module-level variable: WebKit dataTransfer.getData() returns empty string on drop, 138 + // so we track the dragging column ID here instead. 139 + let draggingColumnId: string | null = null; 126 140 127 141 const [state, setState] = createStore<DeckState>({ 128 142 addPanelOpen: false, 129 143 columns: [], 144 + dragOverId: null, 130 145 error: null, 131 146 feedColumns: {}, 132 147 loading: true, ··· 296 311 } 297 312 } 298 313 314 + function handleDragStart(id: string) { 315 + draggingColumnId = id; 316 + } 317 + 318 + function handleDragEnd() { 319 + draggingColumnId = null; 320 + setState("dragOverId", null); 321 + } 322 + 323 + function handleDragOver(id: string) { 324 + if (draggingColumnId && draggingColumnId !== id) { 325 + setState("dragOverId", id); 326 + } 327 + } 328 + 329 + async function handleDrop(targetId: string) { 330 + const sourceId = draggingColumnId; 331 + draggingColumnId = null; 332 + setState("dragOverId", null); 333 + 334 + if (!sourceId || sourceId === targetId) return; 335 + 336 + const cols = state.columns; 337 + const fromIdx = cols.findIndex((c) => c.id === sourceId); 338 + const toIdx = cols.findIndex((c) => c.id === targetId); 339 + if (fromIdx === -1 || toIdx === -1) return; 340 + 341 + const newOrder = cols.map((c) => c.id); 342 + newOrder.splice(fromIdx, 1); 343 + newOrder.splice(toIdx, 0, sourceId); 344 + 345 + try { 346 + await reorderColumns(newOrder); 347 + setState( 348 + "columns", 349 + produce((draft) => { 350 + const item = draft.splice(fromIdx, 1)[0]; 351 + if (item) draft.splice(toIdx, 0, item); 352 + }), 353 + ); 354 + } catch (err) { 355 + logger.error(`Failed to reorder columns via drag: ${String(err)}`); 356 + } 357 + } 358 + 299 359 function handleOpenThread(uri: string) { 300 360 navigate(`/timeline/thread/${encodeURIComponent(uri)}`); 301 361 } ··· 337 397 <Show when={!state.loading && state.columns.length > 0}> 338 398 <ColumnList 339 399 columns={state.columns} 400 + dragOverId={state.dragOverId} 340 401 feedColumns={state.feedColumns} 341 402 onClose={handleClose} 403 + onDragEnd={handleDragEnd} 404 + onDragOver={handleDragOver} 405 + onDragStart={handleDragStart} 406 + onDrop={handleDrop} 342 407 onMoveLeft={handleMoveLeft} 343 408 onMoveRight={handleMoveRight} 344 409 onOpenThread={handleOpenThread}
+4 -19
src/components/profile/ProfileHero.tsx
··· 56 56 } 57 57 58 58 function StickyIdentity(props: { displayName: string; handle: string; progress: number }) { 59 - const style = createMemo(() => ({ 60 - opacity: `${Math.min(1, props.progress * 1.35)}`, 61 - transform: `translate3d(${(1 - props.progress) * -18}px, ${(1 - props.progress) * 52}px, 0) scale(${ 62 - 0.96 + props.progress * 0.04 63 - })`, 64 - })); 59 + const style = createMemo(() => ({ opacity: `${Math.min(1, props.progress * 1.35)}` })); 65 60 66 61 return ( 67 - <div class="mb-1 min-w-0 transition-[opacity,transform] duration-100 ease-out" style={style()}> 62 + <div class="mb-1 min-w-0 transition-opacity duration-100 ease-out" style={style()}> 68 63 <p class="m-0 truncate text-lg font-semibold leading-tight tracking-[-0.02em] text-on-surface"> 69 64 {props.displayName} 70 65 </p> ··· 207 202 export function ProfileHero( 208 203 props: { 209 204 avatarProgress: number; 210 - avatarScale: number; 211 205 coverOffset: number; 212 206 coverScale: number; 213 207 followLoading: boolean; ··· 230 224 const bannerStyle = createMemo(() => ({ 231 225 transform: `translate3d(0, ${props.coverOffset}px, 0) scale(${props.coverScale})`, 232 226 })); 233 - const avatarStyle = createMemo(() => ({ 234 - transform: `translate3d(${28 * props.avatarProgress}px, 0, 0) scale(${props.avatarScale})`, 235 - "transform-origin": "bottom left", 236 - })); 237 227 238 228 return ( 239 229 <header class="relative"> ··· 253 243 254 244 <div class="relative z-10 -mt-16 px-6 pb-6 max-[760px]:px-4 max-[520px]:px-3"> 255 245 <div class="sticky top-4 z-20 mb-4 flex items-center gap-3"> 256 - <div 257 - class="relative h-32 w-32 shrink-0 overflow-hidden rounded-full bg-black/60 shadow-[0_0_0_4px_rgba(8,8,8,0.96),0_0_0_6px_rgba(125,175,255,0.22),0_24px_40px_rgba(0,0,0,0.36)] backdrop-blur-sm transition-transform duration-100 ease-out" 258 - style={avatarStyle()}> 246 + <div class="relative h-32 w-32 shrink-0 overflow-hidden rounded-full bg-black/60 shadow-[0_0_0_4px_rgba(8,8,8,0.96),0_0_0_6px_rgba(125,175,255,0.22),0_24px_40px_rgba(0,0,0,0.36)] backdrop-blur-sm"> 259 247 <Show 260 248 when={props.profile.avatar} 261 249 fallback={ ··· 273 261 <div class="grid gap-5 pt-20"> 274 262 <div 275 263 class="flex flex-wrap items-start justify-between gap-4 transition-opacity duration-100 ease-out" 276 - style={{ 277 - opacity: 1 - props.avatarProgress, 278 - transform: `translate3d(0, ${props.avatarProgress * -12}px, 0)`, 279 - }}> 264 + style={{ opacity: 1 - props.avatarProgress }}> 280 265 <ProfileIdentity 281 266 description={props.profile.description ?? null} 282 267 displayName={displayName()}
-2
src/components/profile/ProfilePanel.tsx
··· 45 45 state.activeTab === "likes" ? state.likesFeed.items : filterProfileFeed(state.authorFeed.items, state.activeTab) 46 46 ); 47 47 const avatarProgress = createMemo(() => clamp((state.scrollTop - 18) / 180, 0, 1)); 48 - const avatarScale = createMemo(() => 1 - avatarProgress() * 0.34); 49 48 const coverOffset = createMemo(() => Math.min(state.scrollTop * 0.28, 88)); 50 49 const coverScale = createMemo(() => 1 + Math.min(state.scrollTop / 1600, 0.08)); 51 50 const viewLabel = createMemo(() => isSelf() ? "Your profile" : "Viewing profile"); ··· 404 403 <> 405 404 <ProfileHero 406 405 avatarProgress={avatarProgress()} 407 - avatarScale={avatarScale()} 408 406 coverOffset={coverOffset()} 409 407 coverScale={coverScale()} 410 408 followLoading={state.followLoading}