···1920- video card
2122+- 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
···1<script lang="ts">
2 import { client, login } from '$lib/oauth/auth.svelte.js';
34- 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';
1516 import { COLUMNS, margin, mobileMargin } from '$lib';
17 import {
18- cardsEqual,
19 clamp,
20 compactItems,
021 fixCollisions,
22- getDescription,
23 getHideProfile,
24 getName,
25 isTyping,
0026 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';
04445 let {
46 data
47 }: {
48 data: WebsiteData;
49 } = $props();
00005051 // svelte-ignore state_referenced_locally
52 let items: Item[] = $state(data.cards);
···101 popover.hidePopover();
102 }
103104- 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···136137 items = [...items, item];
138139- const containerRect = container?.getBoundingClientRect();
140-141 newItem = {};
142143 await tick();
144145- // 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 }
154155 let isSaving = $state(false);
···159 async function save() {
160 isSaving = true;
161162- 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));
166167- 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 }
244245 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 {
00260 if (!container || !activeDragElement.item) return;
261262 // x, y represent the top-left corner of the dragged card
···268 const cellSize = (rect.width - currentMargin * 2) / COLUMNS;
269270 // 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;
0000273274 // 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;
00000000278279 // 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;
286287- let gridY = Math.max(
288- Math.round((y - rect.top - currentMargin) / cellSize),
289- 0
290- );
291292 if (isMobile) {
293 gridX = Math.floor(gridX / 2) * 2;
···314 const otherH = isMobile ? other.mobileH : other.h;
315316 // 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-000320 // 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- ));
410411 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;
00416 saveNewItem();
417 break;
418 }
419 }
420421- newItem = {};
422-423- if(linkValue === url) {
424 linkValue = '';
425 linkPopoverOpen = false;
426 }
427 }
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000428</script>
429430<svelte:body
···439 }}
440/>
441000000442<Head
443 favicon={data.profile.avatar ?? null}
444 title={getName(data)}
···448<Settings bind:open={showSettings} bind:data />
449450<Context {data}>
451- <!-- <ImageDropper processImageFile={(file: File) => {}} /> -->
0000000452453 {#if !dev}
454 <div
···460461 {#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 >
···511512 if (activeDragElement.item) {
513 // Get dragged card's original position for swapping
514- const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id);
00515516 // Reset all items to original positions first
517 for (const it of items) {
···538539 // 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"
000599 >
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';
34+ import { Navbar, Button, toast, Toaster, Toggle, Sidebar, Popover, Input } from '@foxui/core';
0000000005 import { BlueskyLogin } from '@foxui/social';
67 import { COLUMNS, margin, mobileMargin } from '$lib';
8 import {
09 clamp,
10 compactItems,
11+ createEmptyCard,
12 fixCollisions,
013 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';
023 import { innerWidth } from 'svelte/reactivity/window';
024 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';
3536 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);
4546 // svelte-ignore state_referenced_locally
47 let items: Item[] = $state(data.cards);
···96 popover.hidePopover();
97 }
9899+ let item = createEmptyCard(data.page);
100+101+ item.cardData = cardData ?? {};
102+00000000000103 const cardDef = CardDefinitionsByType[type];
104 cardDef?.createNew?.(item);
105···120121 items = [...items, item];
12200123 newItem = {};
124125 await tick();
126127+ scrollToItem(item, isMobile, container);
0000000128 }
129130 let isSaving = $state(false);
···134 async function save() {
135 isSaving = true;
136137+ await savePage(data, items, publication);
000138139+ publication = JSON.stringify(data.publication);
000000000000000000000000000000000000000000000000000000000000000000000000000140 }
141142 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;
160161 // x, y represent the top-left corner of the dragged card
···167 const cellSize = (rect.width - currentMargin * 2) / COLUMNS;
168169 // 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;
176177 // 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;
189190 // 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);
0000192 gridX = Math.floor(gridX / 2) * 2;
193194+ let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0);
000195196 if (isMobile) {
197 gridX = Math.floor(gridX / 2) * 2;
···218 const otherH = isMobile ? other.mobileH : other.h;
219220 // 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);
0000000000000000000298299 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 }
310311+ if (linkValue === url) {
00312 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>
468469<svelte:body
···478 }}
479/>
480481+<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 />
494495<Context {data}>
496+ <input
497+ type="file"
498+ accept="image/*"
499+ onchange={handleImageInputChange}
500+ class="hidden"
501+ multiple
502+ bind:this={imageInputRef}
503+ />
504505 {#if !dev}
506 <div
···512513 {#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 >
···563564 if (activeDragElement.item) {
565 // Get dragged card's original position for swapping
566+ const draggedOrigPos = activeDragElement.originalPositions.get(
567+ activeDragElement.item.id
568+ );
569570 // Reset all items to original positions first
571 for (const it of items) {
···592593 // 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
23- 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
23- general video card
04- link card: save favicon and og image to pds
5+0000006- option to hide cards on mobile
7- separate og image for main page
8- image cards: different images for dark and light mode