your personal website on atproto - mirror blento.app

Merge pull request #104 from flo-bit/various-fixes

add cards at current position (#76)

authored by Florian and committed by GitHub cda2c9ee 84984406

+187 -23
+2 -2
docs/Selfhosting.md
··· 24 ] 25 ``` 26 27 - 5. (maybe necessary? will improve performance at least) create your own kv store by running `npx wrangler kv namespace create USER_DATA_CACHE` and when asked add it to the `wrangler.jsonc` 28 29 DONE :) your blento should be live after a minute or two at `your-cloudflare-worker-or-custom-domain.com` and you can edit it by signing in with your bluesky account at `your-cloudflare-worker-or-custom-domain.com/edit` 30 31 6. some cards need their own additional env keys, if you have these cards in your profile, create your keys and add them to your cloudflare worker 32 33 - - github profile: GITHUB_TOKEN 34 - map: PUBLIC_MAPBOX_TOKEN
··· 24 ] 25 ``` 26 27 + 5. optionally to improve performance: create your own kv store by running `npx wrangler kv namespace create USER_DATA_CACHE` and when asked add it to the `wrangler.jsonc` 28 29 DONE :) your blento should be live after a minute or two at `your-cloudflare-worker-or-custom-domain.com` and you can edit it by signing in with your bluesky account at `your-cloudflare-worker-or-custom-domain.com/edit` 30 31 6. some cards need their own additional env keys, if you have these cards in your profile, create your keys and add them to your cloudflare worker 32 33 + - github profile: GITHUB_TOKEN (token with public_repo access) 34 - map: PUBLIC_MAPBOX_TOKEN
+2 -1
src/lib/cards/GameCards/DinoGameCard/index.ts
··· 13 card.mobileW = 8; 14 card.mobileH = 6; 15 card.cardData = {}; 16 - } 17 } as CardDefinition & { type: 'dino-game' };
··· 13 card.mobileW = 8; 14 card.mobileH = 6; 15 card.cardData = {}; 16 + }, 17 + canHaveLabel: true 18 } as CardDefinition & { type: 'dino-game' };
+2 -1
src/lib/cards/GameCards/TetrisCard/index.ts
··· 18 card.mobileH = 12; 19 card.cardData = {}; 20 }, 21 - maxH: 10 22 } as CardDefinition & { type: 'tetris' };
··· 18 card.mobileH = 12; 19 card.cardData = {}; 20 }, 21 + maxH: 10, 22 + canHaveLabel: true 23 } as CardDefinition & { type: 'tetris' };
+77 -1
src/lib/helper.ts
··· 240 ); 241 } 242 243 - export function setPositionOfNewItem(newItem: Item, items: Item[]) { 244 let foundPosition = false; 245 while (!foundPosition) { 246 for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) {
··· 240 ); 241 } 242 243 + export function setPositionOfNewItem( 244 + newItem: Item, 245 + items: Item[], 246 + viewportCenter?: { gridY: number; isMobile: boolean } 247 + ) { 248 + if (viewportCenter) { 249 + const { gridY, isMobile } = viewportCenter; 250 + 251 + if (isMobile) { 252 + // Place at viewport center Y 253 + newItem.mobileY = Math.max(0, Math.round(gridY - newItem.mobileH / 2)); 254 + newItem.mobileY = Math.floor(newItem.mobileY / 2) * 2; 255 + 256 + // Try to find a free X at this Y 257 + let found = false; 258 + for ( 259 + newItem.mobileX = 0; 260 + newItem.mobileX <= COLUMNS - newItem.mobileW; 261 + newItem.mobileX += 2 262 + ) { 263 + if (!items.some((item) => overlaps(newItem, item, true))) { 264 + found = true; 265 + break; 266 + } 267 + } 268 + if (!found) { 269 + newItem.mobileX = 0; 270 + } 271 + 272 + // Desktop: derive from mobile 273 + newItem.y = Math.max(0, Math.round(newItem.mobileY / 2)); 274 + found = false; 275 + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { 276 + if (!items.some((item) => overlaps(newItem, item, false))) { 277 + found = true; 278 + break; 279 + } 280 + } 281 + if (!found) { 282 + newItem.x = 0; 283 + } 284 + } else { 285 + // Place at viewport center Y 286 + newItem.y = Math.max(0, Math.round(gridY - newItem.h / 2)); 287 + 288 + // Try to find a free X at this Y 289 + let found = false; 290 + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { 291 + if (!items.some((item) => overlaps(newItem, item, false))) { 292 + found = true; 293 + break; 294 + } 295 + } 296 + if (!found) { 297 + newItem.x = 0; 298 + } 299 + 300 + // Mobile: derive from desktop 301 + newItem.mobileY = Math.max(0, Math.round(newItem.y * 2)); 302 + found = false; 303 + for ( 304 + newItem.mobileX = 0; 305 + newItem.mobileX <= COLUMNS - newItem.mobileW; 306 + newItem.mobileX += 2 307 + ) { 308 + if (!items.some((item) => overlaps(newItem, item, true))) { 309 + found = true; 310 + break; 311 + } 312 + } 313 + if (!found) { 314 + newItem.mobileX = 0; 315 + } 316 + } 317 + return; 318 + } 319 + 320 let foundPosition = false; 321 while (!foundPosition) { 322 for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) {
+104 -18
src/lib/website/EditableWebsite.svelte
··· 129 130 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 131 132 function newCard(type: string = 'link', cardData?: any) { 133 // close sidebar if open 134 const popover = document.getElementById('mobile-menu'); ··· 157 if (!newItem.item) return; 158 const item = newItem.item; 159 160 - setPositionOfNewItem(item, items); 161 162 items = [...items, item]; 163 164 newItem = {}; 165 ··· 373 } 374 } 375 376 async function processImageFile(file: File, gridX?: number, gridY?: number) { 377 const isGif = file.type === 'image/gif'; 378 ··· 386 image: { blob: file, objectUrl } 387 }; 388 389 - // If grid position is provided 390 if (gridX !== undefined && gridY !== undefined) { 391 if (isMobile) { 392 item.mobileX = gridX; 393 item.mobileY = gridY; 394 - // Find valid desktop position 395 - findValidPosition(item, items, false); 396 } else { 397 item.x = gridX; 398 item.y = gridY; 399 - // Find valid mobile position 400 - findValidPosition(item, items, true); 401 } 402 403 items = [...items, item]; 404 fixCollisions(items, item, isMobile); 405 } else { 406 - setPositionOfNewItem(item, items); 407 items = [...items, item]; 408 } 409 410 await tick(); ··· 481 } 482 } 483 484 - for (const file of imageFiles) { 485 - await processImageFile(file, gridX, gridY); 486 - 487 - // Move to next cell position 488 - const cardW = isMobile ? 4 : 2; 489 - gridX += cardW; 490 - if (gridX + cardW > COLUMNS) { 491 - gridX = 0; 492 - gridY += isMobile ? 4 : 2; 493 } 494 } 495 } ··· 537 objectUrl 538 }; 539 540 - setPositionOfNewItem(item, items); 541 items = [...items, item]; 542 543 await tick(); 544 ··· 758 bind:item={items[i]} 759 ondelete={() => { 760 items = items.filter((it) => it !== item); 761 - compactItems(items, isMobile); 762 }} 763 onsetsize={(newW: number, newH: number) => { 764 if (isMobile) {
··· 129 130 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 131 132 + function getViewportCenterGridY(): { gridY: number; isMobile: boolean } | undefined { 133 + if (!container) return undefined; 134 + const rect = container.getBoundingClientRect(); 135 + const currentMargin = isMobile ? mobileMargin : margin; 136 + const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 137 + const viewportCenterY = window.innerHeight / 2; 138 + const gridY = (viewportCenterY - rect.top - currentMargin) / cellSize; 139 + return { gridY, isMobile }; 140 + } 141 + 142 function newCard(type: string = 'link', cardData?: any) { 143 // close sidebar if open 144 const popover = document.getElementById('mobile-menu'); ··· 167 if (!newItem.item) return; 168 const item = newItem.item; 169 170 + const viewportCenter = getViewportCenterGridY(); 171 + setPositionOfNewItem(item, items, viewportCenter); 172 173 items = [...items, item]; 174 + 175 + // Push overlapping items down, then compact to fill gaps 176 + fixCollisions(items, item, false, true); 177 + fixCollisions(items, item, true, true); 178 + compactItems(items, false); 179 + compactItems(items, true); 180 181 newItem = {}; 182 ··· 390 } 391 } 392 393 + function getImageDimensions(src: string): Promise<{ width: number; height: number }> { 394 + return new Promise((resolve) => { 395 + const img = new Image(); 396 + img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); 397 + img.onerror = () => resolve({ width: 1, height: 1 }); 398 + img.src = src; 399 + }); 400 + } 401 + 402 + function getBestGridSize( 403 + imageWidth: number, 404 + imageHeight: number, 405 + candidates: [number, number][] 406 + ): [number, number] { 407 + const imageRatio = imageWidth / imageHeight; 408 + let best: [number, number] = candidates[0]; 409 + let bestDiff = Infinity; 410 + 411 + for (const candidate of candidates) { 412 + const gridRatio = candidate[0] / candidate[1]; 413 + const diff = Math.abs(Math.log(imageRatio) - Math.log(gridRatio)); 414 + if (diff < bestDiff) { 415 + bestDiff = diff; 416 + best = candidate; 417 + } 418 + } 419 + 420 + return best; 421 + } 422 + 423 + const desktopSizeCandidates: [number, number][] = [ 424 + [2, 2], 425 + [2, 4], 426 + [4, 2], 427 + [4, 4], 428 + [4, 6], 429 + [6, 4] 430 + ]; 431 + const mobileSizeCandidates: [number, number][] = [ 432 + [4, 4], 433 + [4, 6], 434 + [4, 8], 435 + [6, 4], 436 + [8, 4], 437 + [8, 6] 438 + ]; 439 + 440 async function processImageFile(file: File, gridX?: number, gridY?: number) { 441 const isGif = file.type === 'image/gif'; 442 ··· 450 image: { blob: file, objectUrl } 451 }; 452 453 + // Size card based on image aspect ratio 454 + const { width, height } = await getImageDimensions(objectUrl); 455 + const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates); 456 + const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates); 457 + item.w = dw; 458 + item.h = dh; 459 + item.mobileW = mw; 460 + item.mobileH = mh; 461 + 462 + // If grid position is provided (image dropped on grid) 463 if (gridX !== undefined && gridY !== undefined) { 464 if (isMobile) { 465 item.mobileX = gridX; 466 item.mobileY = gridY; 467 + // Derive desktop Y from mobile 468 + item.x = Math.floor((COLUMNS - item.w) / 2); 469 + item.x = Math.floor(item.x / 2) * 2; 470 + item.y = Math.max(0, Math.round(gridY / 2)); 471 } else { 472 item.x = gridX; 473 item.y = gridY; 474 + // Derive mobile Y from desktop 475 + item.mobileX = Math.floor((COLUMNS - item.mobileW) / 2); 476 + item.mobileX = Math.floor(item.mobileX / 2) * 2; 477 + item.mobileY = Math.max(0, Math.round(gridY * 2)); 478 } 479 480 items = [...items, item]; 481 fixCollisions(items, item, isMobile); 482 + fixCollisions(items, item, !isMobile); 483 } else { 484 + const viewportCenter = getViewportCenterGridY(); 485 + setPositionOfNewItem(item, items, viewportCenter); 486 items = [...items, item]; 487 + fixCollisions(items, item, false, true); 488 + fixCollisions(items, item, true, true); 489 + compactItems(items, false); 490 + compactItems(items, true); 491 } 492 493 await tick(); ··· 564 } 565 } 566 567 + for (let i = 0; i < imageFiles.length; i++) { 568 + // First image gets the drop position, rest use normal placement 569 + if (i === 0) { 570 + await processImageFile(imageFiles[i], gridX, gridY); 571 + } else { 572 + await processImageFile(imageFiles[i]); 573 } 574 } 575 } ··· 617 objectUrl 618 }; 619 620 + const viewportCenter = getViewportCenterGridY(); 621 + setPositionOfNewItem(item, items, viewportCenter); 622 items = [...items, item]; 623 + fixCollisions(items, item, false, true); 624 + fixCollisions(items, item, true, true); 625 + compactItems(items, false); 626 + compactItems(items, true); 627 628 await tick(); 629 ··· 843 bind:item={items[i]} 844 ondelete={() => { 845 items = items.filter((it) => it !== item); 846 + compactItems(items, false); 847 + compactItems(items, true); 848 }} 849 onsetsize={(newW: number, newH: number) => { 850 if (isMobile) {