your personal website on atproto - mirror blento.app

improve image card, some cleanup

Florian 1b8f6238 a53434f2

+363 -299
+7 -1
docs/Beta.md
··· 19 20 - video card 21 22 - -
··· 19 20 - video card 21 22 + - allow changing profile picture 23 + 24 + - allow editing profile stuff inline (in sidebar profile) 25 + 26 + - allow setting base and accent color 27 + 28 + - edit link of image card
-120
src/lib/cards/ImageCard/CreateImageCardModal.svelte
··· 1 - <script lang="ts"> 2 - import { Button, Input, Label, Modal, Subheading } from '@foxui/core'; 3 - import type { CreationModalComponentProps } from '../types'; 4 - import { compressImage } from '$lib/helper'; 5 - 6 - let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 7 - 8 - async function handleFileChange(event: Event) { 9 - const target = event.target as HTMLInputElement; 10 - if (!target.files || target.files.length < 1) return; 11 - 12 - const file = target.files[0]; 13 - const compressedFile = await compressImage(file); 14 - 15 - if (item.cardData.objectUrl) URL.revokeObjectURL(item.cardData.objectUrl); 16 - 17 - item.cardData.blob = compressedFile; 18 - item.cardData.objectUrl = URL.createObjectURL(compressedFile); 19 - } 20 - 21 - let inputRef = $state<HTMLInputElement | null>(null); 22 - 23 - function handleDragOver(event: DragEvent) { 24 - event.preventDefault(); 25 - if (event.dataTransfer) { 26 - event.dataTransfer.dropEffect = 'copy'; 27 - } 28 - } 29 - 30 - function handleDrop(event: DragEvent) { 31 - event.preventDefault(); 32 - const file = event.dataTransfer?.files[0]; 33 - if (file) { 34 - handleFileChange({ target: { files: [file] } } as unknown as Event); 35 - } 36 - } 37 - 38 - function handleDragLeave(event: DragEvent) { 39 - event.preventDefault(); 40 - if (event.dataTransfer) { 41 - event.dataTransfer.dropEffect = 'none'; 42 - } 43 - } 44 - </script> 45 - 46 - <Modal 47 - bind:open={ 48 - () => true, 49 - (change) => { 50 - if (!change) oncancel(); 51 - } 52 - } 53 - closeButton={false} 54 - > 55 - <Subheading>Select an image</Subheading> 56 - 57 - <!-- svelte-ignore a11y_click_events_have_key_events --> 58 - <!-- svelte-ignore a11y_no_static_element_interactions --> 59 - <div 60 - ondragover={handleDragOver} 61 - ondrop={handleDrop} 62 - ondragleave={handleDragLeave} 63 - onclick={() => { 64 - inputRef?.click(); 65 - }} 66 - class="dark:bg-accent-600/5 hover:bg-accent-400/10 dark:hover:bg-accent-600/10 border-accent-400 bg-accent-400/5 dark:border-accent-800 flex h-32 w-full cursor-pointer items-center justify-center gap-2 rounded-2xl border border-dashed p-2 transition-colors duration-200" 67 - > 68 - {#if !item.cardData.objectUrl} 69 - <svg 70 - xmlns="http://www.w3.org/2000/svg" 71 - fill="none" 72 - viewBox="0 0 24 24" 73 - stroke-width="1.5" 74 - stroke="currentColor" 75 - class="text-accent-600 dark:text-accent-400 size-6" 76 - > 77 - <path 78 - stroke-linecap="round" 79 - stroke-linejoin="round" 80 - d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" 81 - /> 82 - </svg> 83 - <span class="text-accent-600 dark:text-accent-400 text-sm">Click to upload image</span> 84 - {:else} 85 - <img 86 - alt="" 87 - src={item.cardData.objectUrl} 88 - class="max-h-full max-w-full rounded-xl object-contain" 89 - /> 90 - {/if} 91 - <input 92 - type="file" 93 - accept="image/*" 94 - onchange={handleFileChange} 95 - class="hidden" 96 - multiple 97 - bind:this={inputRef} 98 - /> 99 - </div> 100 - <Label class="mt-4">Link (optional):</Label> 101 - <Input bind:value={item.cardData.href} /> 102 - 103 - 104 - <div class="mt-4 flex justify-end gap-2"> 105 - <Button 106 - onclick={() => { 107 - if (item.cardData.objectUrl) URL.revokeObjectURL(item.cardData.objectUrl); 108 - 109 - oncancel(); 110 - }} 111 - variant="ghost">Cancel</Button 112 - > 113 - <Button 114 - disabled={!item.cardData.objectUrl} 115 - onclick={async () => { 116 - oncreate(); 117 - }}>Create</Button 118 - > 119 - </div> 120 - </Modal>
···
-2
src/lib/cards/ImageCard/index.ts
··· 1 import { uploadBlob } from '$lib/oauth/utils'; 2 import type { CardDefinition } from '../types'; 3 - import CreateImageCardModal from './CreateImageCardModal.svelte'; 4 import ImageCard from './ImageCard.svelte'; 5 6 export const ImageCardDefinition = { ··· 14 href: '' 15 }; 16 }, 17 - creationModalComponent: CreateImageCardModal, 18 upload: async (item) => { 19 if (item.cardData.blob) { 20 item.cardData.image = await uploadBlob(item.cardData.blob);
··· 1 import { uploadBlob } from '$lib/oauth/utils'; 2 import type { CardDefinition } from '../types'; 3 import ImageCard from './ImageCard.svelte'; 4 5 export const ImageCardDefinition = { ··· 13 href: '' 14 }; 15 }, 16 upload: async (item) => { 17 if (item.cardData.blob) { 18 item.cardData.image = await uploadBlob(item.cardData.blob);
+132 -2
src/lib/helper.ts
··· 1 import type { Item, WebsiteData } from './types'; 2 - import { COLUMNS } from '$lib'; 3 4 export function clamp(value: number, min: number, max: number): number { 5 return Math.min(Math.max(value, min), max); ··· 399 400 img.onerror = (err) => reject(err); 401 }); 402 - }
··· 1 import type { Item, WebsiteData } from './types'; 2 + import { COLUMNS, margin, mobileMargin } from '$lib'; 3 + import { CardDefinitionsByType } from './cards'; 4 + import { deleteRecord, putRecord } from './oauth/atproto'; 5 + import { toast } from '@foxui/core'; 6 + import { TID } from '@atproto/common-web'; 7 8 export function clamp(value: number, min: number, max: number): number { 9 return Math.min(Math.max(value, min), max); ··· 403 404 img.onerror = (err) => reject(err); 405 }); 406 + } 407 + 408 + export async function savePage( 409 + data: WebsiteData, 410 + currentItems: Item[], 411 + originalPublication: string 412 + ) { 413 + const promises = []; 414 + // find all cards that have been updated (where items differ from originalItems) 415 + for (let item of currentItems) { 416 + const originalItem = data.cards.find((i) => cardsEqual(i, item)); 417 + 418 + if (!originalItem) { 419 + console.log('updated or new item', item); 420 + item.updatedAt = new Date().toISOString(); 421 + // run optional upload function for this card type 422 + const cardDef = CardDefinitionsByType[item.cardType]; 423 + 424 + if (cardDef?.upload) { 425 + item = await cardDef?.upload(item); 426 + } 427 + 428 + item.page = data.page; 429 + item.version = 2; 430 + 431 + promises.push( 432 + putRecord({ 433 + collection: 'app.blento.card', 434 + rkey: item.id, 435 + record: item 436 + }) 437 + ); 438 + } 439 + } 440 + 441 + // delete items that are in originalItems but not in items 442 + for (const originalItem of data.cards) { 443 + const item = currentItems.find((i) => i.id === originalItem.id); 444 + if (!item) { 445 + console.log('deleting item', originalItem); 446 + promises.push( 447 + deleteRecord({ collection: 'app.blento.card', rkey: originalItem.id, did: data.did }) 448 + ); 449 + } 450 + } 451 + 452 + if (!originalPublication || originalPublication !== JSON.stringify(data.publication)) { 453 + data.publication ??= { 454 + name: getName(data), 455 + description: getDescription(data), 456 + preferences: { 457 + hideProfile: getHideProfile(data) 458 + } 459 + }; 460 + 461 + if (!data.publication.url) { 462 + data.publication.url = 'https://blento.app/' + data.handle; 463 + 464 + if (data.page !== 'blento.self') { 465 + data.publication.url += '/' + data.page.replace('blento.', ''); 466 + } 467 + } 468 + promises.push( 469 + putRecord({ 470 + collection: 'site.standard.publication', 471 + rkey: data.page, 472 + record: data.publication 473 + }) 474 + ); 475 + 476 + console.log('updating or adding publication', data.publication); 477 + } 478 + 479 + await Promise.all(promises); 480 + 481 + fetch('/' + data.handle + '/api/refreshData').then(() => { 482 + console.log('data refreshed!'); 483 + }); 484 + console.log('refreshing data'); 485 + 486 + toast('Saved', { 487 + description: 'Your website has been saved!' 488 + }); 489 + } 490 + 491 + export function createEmptyCard(page: string) { 492 + return { 493 + id: TID.nextStr(), 494 + x: 0, 495 + y: 0, 496 + w: 2, 497 + h: 2, 498 + mobileH: 4, 499 + mobileW: 4, 500 + mobileX: 0, 501 + mobileY: 0, 502 + cardType: '', 503 + cardData: {}, 504 + page 505 + } as Item; 506 + } 507 + 508 + export function scrollToItem( 509 + item: Item, 510 + isMobile: boolean, 511 + container: HTMLDivElement | undefined, 512 + force: boolean = false 513 + ) { 514 + // scroll to newly created card only if not fully visible 515 + const containerRect = container?.getBoundingClientRect(); 516 + if (!containerRect) return; 517 + const currentMargin = isMobile ? mobileMargin : margin; 518 + const currentY = isMobile ? item.mobileY : item.y; 519 + const currentH = isMobile ? item.mobileH : item.h; 520 + const cellSize = (containerRect.width - currentMargin * 2) / COLUMNS; 521 + 522 + const cardTop = containerRect.top + currentMargin + currentY * cellSize; 523 + const cardBottom = containerRect.top + currentMargin + (currentY + currentH) * cellSize; 524 + 525 + const isFullyVisible = cardTop >= 0 && cardBottom <= window.innerHeight; 526 + 527 + if (!isFullyVisible || force) { 528 + const bodyRect = document.body.getBoundingClientRect(); 529 + const offset = containerRect.top - bodyRect.top; 530 + window.scrollTo({ top: offset + cellSize * (currentY - 1), behavior: 'smooth' }); 531 + } 532 + }
+223 -166
src/lib/website/EditableWebsite.svelte
··· 1 <script lang="ts"> 2 import { client, login } from '$lib/oauth/auth.svelte.js'; 3 4 - import { 5 - Navbar, 6 - Button, 7 - toast, 8 - Toaster, 9 - Toggle, 10 - Sidebar, 11 - Popover, 12 - Input, 13 - } from '@foxui/core'; 14 import { BlueskyLogin } from '@foxui/social'; 15 16 import { COLUMNS, margin, mobileMargin } from '$lib'; 17 import { 18 - cardsEqual, 19 clamp, 20 compactItems, 21 fixCollisions, 22 - getDescription, 23 getHideProfile, 24 getName, 25 isTyping, 26 setPositionOfNewItem, 27 validateLink 28 } from '../helper'; 29 import Profile from './Profile.svelte'; 30 import type { Item, WebsiteData } from '../types'; 31 - import { deleteRecord, putRecord } from '../oauth/atproto'; 32 import { innerWidth } from 'svelte/reactivity/window'; 33 - import { TID } from '@atproto/common-web'; 34 import EditingCard from '../cards/Card/EditingCard.svelte'; 35 import { AllCardDefinitions, CardDefinitionsByType } from '../cards'; 36 import { tick, type Component } from 'svelte'; ··· 41 import Context from './Context.svelte'; 42 import Settings from './Settings.svelte'; 43 import Head from './Head.svelte'; 44 45 let { 46 data 47 }: { 48 data: WebsiteData; 49 } = $props(); 50 51 // svelte-ignore state_referenced_locally 52 let items: Item[] = $state(data.cards); ··· 101 popover.hidePopover(); 102 } 103 104 - let item: Item = { 105 - id: TID.nextStr(), 106 - x: 0, 107 - y: 0, 108 - w: 2, 109 - h: 2, 110 - mobileH: 4, 111 - mobileW: 4, 112 - mobileX: 0, 113 - mobileY: 0, 114 - cardType: type, 115 - cardData: cardData ?? {}, 116 - version: 2, 117 - page: data.page 118 - }; 119 const cardDef = CardDefinitionsByType[type]; 120 cardDef?.createNew?.(item); 121 ··· 136 137 items = [...items, item]; 138 139 - const containerRect = container?.getBoundingClientRect(); 140 - 141 newItem = {}; 142 143 await tick(); 144 145 - // scroll to newly created card 146 - if (!containerRect) return; 147 - const currentMargin = isMobile ? mobileMargin : margin; 148 - const currentY = isMobile ? item.mobileY : item.y; 149 - const bodyRect = document.body.getBoundingClientRect(); 150 - const offset = containerRect.top - bodyRect.top; 151 - const cellSize = (containerRect.width - currentMargin * 2) / COLUMNS; 152 - window.scrollTo({ top: offset + cellSize * (currentY - 1), behavior: 'smooth' }); 153 } 154 155 let isSaving = $state(false); ··· 159 async function save() { 160 isSaving = true; 161 162 - const promises = []; 163 - // find all cards that have been updated (where items differ from originalItems) 164 - for (let item of items) { 165 - const originalItem = data.cards.find((i) => cardsEqual(i, item)); 166 167 - if (!originalItem) { 168 - console.log('updated or new item', item); 169 - item.updatedAt = new Date().toISOString(); 170 - // run optional upload function for this card type 171 - const cardDef = CardDefinitionsByType[item.cardType]; 172 - 173 - if (cardDef?.upload) { 174 - item = await cardDef?.upload(item); 175 - } 176 - 177 - item.page = data.page; 178 - item.version = 2; 179 - 180 - promises.push( 181 - putRecord({ 182 - collection: 'app.blento.card', 183 - rkey: item.id, 184 - record: item 185 - }) 186 - ); 187 - } 188 - } 189 - 190 - // delete items that are in originalItems but not in items 191 - for (let originalItem of data.cards) { 192 - const item = items.find((i) => i.id === originalItem.id); 193 - if (!item) { 194 - console.log('deleting item', originalItem); 195 - promises.push( 196 - deleteRecord({ collection: 'app.blento.card', rkey: originalItem.id, did: data.did }) 197 - ); 198 - } 199 - } 200 - 201 - console.log(publication, data.publication); 202 - if (!publication || publication !== JSON.stringify(data.publication)) { 203 - data.publication ??= { 204 - name: getName(data), 205 - description: getDescription(data), 206 - preferences: { 207 - hideProfile: getHideProfile(data) 208 - } 209 - }; 210 - 211 - if (!data.publication.url) { 212 - data.publication.url = 'https://blento.app/' + data.handle; 213 - 214 - if (data.page !== 'blento.self') { 215 - data.publication.url += '/' + data.page.replace('blento.', ''); 216 - } 217 - } 218 - promises.push( 219 - putRecord({ 220 - collection: 'site.standard.publication', 221 - rkey: data.page, 222 - record: data.publication 223 - }) 224 - ); 225 - 226 - publication = JSON.stringify(data.publication); 227 - 228 - console.log('updating or adding publication', data.publication); 229 - } 230 - 231 - await Promise.all(promises); 232 - 233 - isSaving = false; 234 - 235 - fetch('/' + data.handle + '/api/refreshData').then(() => { 236 - console.log('data refreshed!'); 237 - }); 238 - console.log('refreshing data'); 239 - 240 - toast('Saved', { 241 - description: 'Your website has been saved!' 242 - }); 243 } 244 245 const sidebarItems = AllCardDefinitions.filter( ··· 256 e: DragEvent & { 257 currentTarget: EventTarget & HTMLDivElement; 258 } 259 - ): { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } | undefined { 260 if (!container || !activeDragElement.item) return; 261 262 // x, y represent the top-left corner of the dragged card ··· 268 const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 269 270 // Get card dimensions based on current view mode 271 - const cardW = isMobile ? (activeDragElement.item?.mobileW ?? activeDragElement.w) : activeDragElement.w; 272 - const cardH = isMobile ? (activeDragElement.item?.mobileH ?? activeDragElement.h) : activeDragElement.h; 273 274 // Get dragged card's original position 275 const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 276 - const draggedOrigX = draggedOrigPos ? (isMobile ? draggedOrigPos.mobileX : draggedOrigPos.x) : 0; 277 - const draggedOrigY = draggedOrigPos ? (isMobile ? draggedOrigPos.mobileY : draggedOrigPos.y) : 0; 278 279 // Calculate raw grid position based on top-left of dragged card 280 - let gridX = clamp( 281 - Math.round((x - rect.left - currentMargin) / cellSize), 282 - 0, 283 - COLUMNS - cardW 284 - ); 285 gridX = Math.floor(gridX / 2) * 2; 286 287 - let gridY = Math.max( 288 - Math.round((y - rect.top - currentMargin) / cellSize), 289 - 0 290 - ); 291 292 if (isMobile) { 293 gridX = Math.floor(gridX / 2) * 2; ··· 314 const otherH = isMobile ? other.mobileH : other.h; 315 316 // Check if dragged card's center point is within this card's original bounds 317 - if (centerGridX >= otherX && centerGridX < otherX + otherW && 318 - centerGridY >= otherY && centerGridY < otherY + otherH) { 319 - 320 // Check if this is a swap situation: 321 // Cards have the same dimensions and are on the same row 322 const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY; ··· 387 toast.error('invalid link'); 388 return; 389 } 390 - 391 - let item: Item = { 392 - id: TID.nextStr(), 393 - x: 0, 394 - y: 0, 395 - w: 2, 396 - h: 2, 397 - mobileH: 4, 398 - mobileW: 4, 399 - mobileX: 0, 400 - mobileY: 0, 401 - cardType: '', 402 - cardData: {} 403 - }; 404 - 405 - newItem.item = item; 406 - 407 - console.log(AllCardDefinitions.toSorted( 408 - (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0) 409 - )); 410 411 for (const cardDef of AllCardDefinitions.toSorted( 412 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0) 413 )) { 414 if (cardDef.onUrlHandler?.(link, item)) { 415 item.cardType = cardDef.type; 416 saveNewItem(); 417 break; 418 } 419 } 420 421 - newItem = {}; 422 - 423 - if(linkValue === url) { 424 linkValue = ''; 425 linkPopoverOpen = false; 426 } 427 } 428 </script> 429 430 <svelte:body ··· 439 }} 440 /> 441 442 <Head 443 favicon={data.profile.avatar ?? null} 444 title={getName(data)} ··· 448 <Settings bind:open={showSettings} bind:data /> 449 450 <Context {data}> 451 - <!-- <ImageDropper processImageFile={(file: File) => {}} /> --> 452 453 {#if !dev} 454 <div ··· 460 461 {#if showingMobileView} 462 <div 463 - class="bg-base-200 dark:bg-base-900 pointer-events-none fixed inset-0 -z-10 h-full w-full" 464 ></div> 465 {/if} 466 ··· 480 class={[ 481 '@container/wrapper relative w-full', 482 showingMobileView 483 - ? 'bg-base-50 dark:bg-base-950 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-[400px]' 484 : '' 485 ]} 486 > ··· 511 512 if (activeDragElement.item) { 513 // Get dragged card's original position for swapping 514 - const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 515 516 // Reset all items to original positions first 517 for (const it of items) { ··· 538 539 // Handle horizontal swap 540 if (result.swapWithId && draggedOrigPos) { 541 - const swapTarget = items.find(it => it.id === result.swapWithId); 542 if (swapTarget) { 543 // Move swap target to dragged card's original position 544 if (isMobile) { ··· 595 activeDragElement.lastPlacement = null; 596 return true; 597 }} 598 - class="@container/grid relative col-span-3 px-2 py-8 @5xl/wrapper:px-8" 599 > 600 {#each items as item, i (item.id)} 601 <!-- {#if item !== activeDragElement.item} --> ··· 750 variant="ghost" 751 class="backdrop-blur-none" 752 onclick={() => { 753 - newCard('image'); 754 }} 755 > 756 <svg
··· 1 <script lang="ts"> 2 import { client, login } from '$lib/oauth/auth.svelte.js'; 3 4 + import { Navbar, Button, toast, Toaster, Toggle, Sidebar, Popover, Input } from '@foxui/core'; 5 import { BlueskyLogin } from '@foxui/social'; 6 7 import { COLUMNS, margin, mobileMargin } from '$lib'; 8 import { 9 clamp, 10 compactItems, 11 + createEmptyCard, 12 fixCollisions, 13 getHideProfile, 14 getName, 15 isTyping, 16 + savePage, 17 + scrollToItem, 18 setPositionOfNewItem, 19 validateLink 20 } from '../helper'; 21 import Profile from './Profile.svelte'; 22 import type { Item, WebsiteData } from '../types'; 23 import { innerWidth } from 'svelte/reactivity/window'; 24 import EditingCard from '../cards/Card/EditingCard.svelte'; 25 import { AllCardDefinitions, CardDefinitionsByType } from '../cards'; 26 import { tick, type Component } from 'svelte'; ··· 31 import Context from './Context.svelte'; 32 import Settings from './Settings.svelte'; 33 import Head from './Head.svelte'; 34 + import { compressImage } from '../helper'; 35 36 let { 37 data 38 }: { 39 data: WebsiteData; 40 } = $props(); 41 + 42 + let imageInputRef: HTMLInputElement | undefined = $state(); 43 + let imageDragOver = $state(false); 44 + let imageDragPosition: { x: number; y: number } | null = $state(null); 45 46 // svelte-ignore state_referenced_locally 47 let items: Item[] = $state(data.cards); ··· 96 popover.hidePopover(); 97 } 98 99 + let item = createEmptyCard(data.page); 100 + 101 + item.cardData = cardData ?? {}; 102 + 103 const cardDef = CardDefinitionsByType[type]; 104 cardDef?.createNew?.(item); 105 ··· 120 121 items = [...items, item]; 122 123 newItem = {}; 124 125 await tick(); 126 127 + scrollToItem(item, isMobile, container); 128 } 129 130 let isSaving = $state(false); ··· 134 async function save() { 135 isSaving = true; 136 137 + await savePage(data, items, publication); 138 139 + publication = JSON.stringify(data.publication); 140 } 141 142 const sidebarItems = AllCardDefinitions.filter( ··· 153 e: DragEvent & { 154 currentTarget: EventTarget & HTMLDivElement; 155 } 156 + ): 157 + | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } 158 + | undefined { 159 if (!container || !activeDragElement.item) return; 160 161 // x, y represent the top-left corner of the dragged card ··· 167 const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 168 169 // Get card dimensions based on current view mode 170 + const cardW = isMobile 171 + ? (activeDragElement.item?.mobileW ?? activeDragElement.w) 172 + : activeDragElement.w; 173 + const cardH = isMobile 174 + ? (activeDragElement.item?.mobileH ?? activeDragElement.h) 175 + : activeDragElement.h; 176 177 // Get dragged card's original position 178 const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 179 + const draggedOrigX = draggedOrigPos 180 + ? isMobile 181 + ? draggedOrigPos.mobileX 182 + : draggedOrigPos.x 183 + : 0; 184 + const draggedOrigY = draggedOrigPos 185 + ? isMobile 186 + ? draggedOrigPos.mobileY 187 + : draggedOrigPos.y 188 + : 0; 189 190 // Calculate raw grid position based on top-left of dragged card 191 + let gridX = clamp(Math.round((x - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 192 gridX = Math.floor(gridX / 2) * 2; 193 194 + let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0); 195 196 if (isMobile) { 197 gridX = Math.floor(gridX / 2) * 2; ··· 218 const otherH = isMobile ? other.mobileH : other.h; 219 220 // Check if dragged card's center point is within this card's original bounds 221 + if ( 222 + centerGridX >= otherX && 223 + centerGridX < otherX + otherW && 224 + centerGridY >= otherY && 225 + centerGridY < otherY + otherH 226 + ) { 227 // Check if this is a swap situation: 228 // Cards have the same dimensions and are on the same row 229 const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY; ··· 294 toast.error('invalid link'); 295 return; 296 } 297 + let item = createEmptyCard(data.page); 298 299 for (const cardDef of AllCardDefinitions.toSorted( 300 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0) 301 )) { 302 if (cardDef.onUrlHandler?.(link, item)) { 303 item.cardType = cardDef.type; 304 + 305 + newItem.item = item; 306 saveNewItem(); 307 break; 308 } 309 } 310 311 + if (linkValue === url) { 312 linkValue = ''; 313 linkPopoverOpen = false; 314 } 315 } 316 + 317 + async function processImageFile(file: File, gridX?: number, gridY?: number) { 318 + const compressedFile = await compressImage(file); 319 + const objectUrl = URL.createObjectURL(compressedFile); 320 + 321 + let item = createEmptyCard(data.page); 322 + 323 + item.cardType = 'image'; 324 + item.cardData = { 325 + blob: compressedFile, 326 + objectUrl 327 + }; 328 + 329 + // If grid position is provided 330 + if (gridX !== undefined && gridY !== undefined) { 331 + if (isMobile) { 332 + item.mobileX = gridX; 333 + item.mobileY = gridY; 334 + } else { 335 + item.x = gridX; 336 + item.y = gridY; 337 + } 338 + 339 + items = [...items, item]; 340 + fixCollisions(items, item, isMobile); 341 + } else { 342 + setPositionOfNewItem(item, items); 343 + items = [...items, item]; 344 + } 345 + 346 + await tick(); 347 + 348 + scrollToItem(item, isMobile, container); 349 + } 350 + 351 + function handleImageDragOver(event: DragEvent) { 352 + const dt = event.dataTransfer; 353 + if (!dt) return; 354 + 355 + let hasImage = false; 356 + if (dt.items) { 357 + for (let i = 0; i < dt.items.length; i++) { 358 + const item = dt.items[i]; 359 + if (item && item.kind === 'file' && item.type.startsWith('image/')) { 360 + hasImage = true; 361 + break; 362 + } 363 + } 364 + } else if (dt.files) { 365 + for (let i = 0; i < dt.files.length; i++) { 366 + const file = dt.files[i]; 367 + if (file?.type.startsWith('image/')) { 368 + hasImage = true; 369 + break; 370 + } 371 + } 372 + } 373 + 374 + if (hasImage) { 375 + event.preventDefault(); 376 + event.stopPropagation(); 377 + 378 + imageDragOver = true; 379 + imageDragPosition = { x: event.clientX, y: event.clientY }; 380 + } 381 + } 382 + 383 + function handleImageDragLeave(event: DragEvent) { 384 + event.preventDefault(); 385 + event.stopPropagation(); 386 + imageDragOver = false; 387 + imageDragPosition = null; 388 + } 389 + 390 + async function handleImageDrop(event: DragEvent) { 391 + event.preventDefault(); 392 + event.stopPropagation(); 393 + const dropX = event.clientX; 394 + const dropY = event.clientY; 395 + imageDragOver = false; 396 + imageDragPosition = null; 397 + 398 + if (!event.dataTransfer?.files?.length) return; 399 + 400 + const imageFiles = Array.from(event.dataTransfer.files).filter((f) => 401 + f?.type.startsWith('image/') 402 + ); 403 + if (imageFiles.length === 0) return; 404 + 405 + // Calculate starting grid position from drop coordinates 406 + let gridX = 0; 407 + let gridY = 0; 408 + if (container) { 409 + const rect = container.getBoundingClientRect(); 410 + const currentMargin = isMobile ? mobileMargin : margin; 411 + const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 412 + const cardW = isMobile ? 4 : 2; 413 + 414 + gridX = clamp(Math.round((dropX - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 415 + gridX = Math.floor(gridX / 2) * 2; 416 + 417 + gridY = Math.max(Math.round((dropY - rect.top - currentMargin) / cellSize), 0); 418 + if (isMobile) { 419 + gridY = Math.floor(gridY / 2) * 2; 420 + } 421 + } 422 + 423 + for (const file of imageFiles) { 424 + await processImageFile(file, gridX, gridY); 425 + 426 + // Move to next cell position 427 + const cardW = isMobile ? 4 : 2; 428 + gridX += cardW; 429 + if (gridX + cardW > COLUMNS) { 430 + gridX = 0; 431 + gridY += isMobile ? 4 : 2; 432 + } 433 + } 434 + } 435 + 436 + async function handleImageInputChange(event: Event) { 437 + const target = event.target as HTMLInputElement; 438 + if (!target.files || target.files.length < 1) return; 439 + 440 + const files = Array.from(target.files); 441 + 442 + if (files.length === 1) { 443 + // Single file: use default positioning 444 + await processImageFile(files[0]); 445 + } else { 446 + // Multiple files: place in grid pattern starting from first available position 447 + let gridX = 0; 448 + let gridY = maxHeight; 449 + const cardW = isMobile ? 4 : 2; 450 + const cardH = isMobile ? 4 : 2; 451 + 452 + for (const file of files) { 453 + await processImageFile(file, gridX, gridY); 454 + 455 + // Move to next cell position 456 + gridX += cardW; 457 + if (gridX + cardW > COLUMNS) { 458 + gridX = 0; 459 + gridY += cardH; 460 + } 461 + } 462 + } 463 + 464 + // Reset the input so the same file can be selected again 465 + target.value = ''; 466 + } 467 </script> 468 469 <svelte:body ··· 478 }} 479 /> 480 481 + <svelte:window 482 + ondragover={handleImageDragOver} 483 + ondragleave={handleImageDragLeave} 484 + ondrop={handleImageDrop} 485 + /> 486 + 487 <Head 488 favicon={data.profile.avatar ?? null} 489 title={getName(data)} ··· 493 <Settings bind:open={showSettings} bind:data /> 494 495 <Context {data}> 496 + <input 497 + type="file" 498 + accept="image/*" 499 + onchange={handleImageInputChange} 500 + class="hidden" 501 + multiple 502 + bind:this={imageInputRef} 503 + /> 504 505 {#if !dev} 506 <div ··· 512 513 {#if showingMobileView} 514 <div 515 + class="bg-base-200 dark:bg-base-950 pointer-events-none fixed inset-0 -z-10 h-full w-full" 516 ></div> 517 {/if} 518 ··· 532 class={[ 533 '@container/wrapper relative w-full', 534 showingMobileView 535 + ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-[375px]' 536 : '' 537 ]} 538 > ··· 563 564 if (activeDragElement.item) { 565 // Get dragged card's original position for swapping 566 + const draggedOrigPos = activeDragElement.originalPositions.get( 567 + activeDragElement.item.id 568 + ); 569 570 // Reset all items to original positions first 571 for (const it of items) { ··· 592 593 // Handle horizontal swap 594 if (result.swapWithId && draggedOrigPos) { 595 + const swapTarget = items.find((it) => it.id === result.swapWithId); 596 if (swapTarget) { 597 // Move swap target to dragged card's original position 598 if (isMobile) { ··· 649 activeDragElement.lastPlacement = null; 650 return true; 651 }} 652 + class={[ 653 + '@container/grid relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8', 654 + imageDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed' 655 + ]} 656 > 657 {#each items as item, i (item.id)} 658 <!-- {#if item !== activeDragElement.item} --> ··· 807 variant="ghost" 808 class="backdrop-blur-none" 809 onclick={() => { 810 + imageInputRef?.click(); 811 }} 812 > 813 <svg
+1 -8
todo.md
··· 1 # todo 2 3 - general video card 4 - - edit already created cards (e.g. change link) 5 - link card: save favicon and og image to pds 6 - - paste handler for card creation (+ when entering link) 7 - - change general settings: 8 - - show profile 9 - - profile on side or top 10 - - base, accent color 11 - - title 12 - - favicon 13 - option to hide cards on mobile 14 - separate og image for main page 15 - image cards: different images for dark and light mode
··· 1 # todo 2 3 - general video card 4 - link card: save favicon and og image to pds 5 + 6 - option to hide cards on mobile 7 - separate og image for main page 8 - image cards: different images for dark and light mode