your personal website on atproto - mirror blento.app

link card

Florian 9718b007 10132991

+956 -251
+1
package.json
··· 58 58 "bits-ui": "^2.14.4", 59 59 "clsx": "^2.1.1", 60 60 "gsap": "^3.14.2", 61 + "link-preview-js": "^4.0.0", 61 62 "marked": "^15.0.11", 62 63 "svelte-sonner": "^1.0.7", 63 64 "tailwind-merge": "^3.4.0",
+129
pnpm-lock.yaml
··· 59 59 gsap: 60 60 specifier: ^3.14.2 61 61 version: 3.14.2 62 + link-preview-js: 63 + specifier: ^4.0.0 64 + version: 4.0.0 62 65 marked: 63 66 specifier: ^15.0.11 64 67 version: 15.0.11 ··· 1305 1308 resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==, tarball: https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz} 1306 1309 engines: {node: '>=18'} 1307 1310 1311 + boolbase@1.0.0: 1312 + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==, tarball: https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz} 1313 + 1308 1314 brace-expansion@1.1.11: 1309 1315 resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==, tarball: https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz} 1310 1316 ··· 1334 1340 chalk@4.1.2: 1335 1341 resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==, tarball: https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz} 1336 1342 engines: {node: '>=10'} 1343 + 1344 + cheerio-select@2.1.0: 1345 + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==, tarball: https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz} 1346 + 1347 + cheerio@1.0.0-rc.11: 1348 + resolution: {integrity: sha512-bQwNaDIBKID5ts/DsdhxrjqFXYfLw4ste+wMKqWA8DyKcS4qwsPP4Bk8ZNaTJjvpiX/qW3BT4sU7d6Bh5i+dag==, tarball: https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.11.tgz} 1349 + engines: {node: '>= 6'} 1337 1350 1338 1351 chokidar@4.0.3: 1339 1352 resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==, tarball: https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz} ··· 1395 1408 resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, tarball: https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz} 1396 1409 engines: {node: '>= 8'} 1397 1410 1411 + css-select@5.2.2: 1412 + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==, tarball: https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz} 1413 + 1414 + css-what@6.2.2: 1415 + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==, tarball: https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz} 1416 + engines: {node: '>= 6'} 1417 + 1398 1418 css.escape@1.5.1: 1399 1419 resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==, tarball: https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz} 1400 1420 ··· 1436 1456 1437 1457 devalue@5.6.0: 1438 1458 resolution: {integrity: sha512-BaD1s81TFFqbD6Uknni42TrolvEWA1Ih5L+OiHWmi4OYMJVwAYPGtha61I9KxTf52OvVHozHyjPu8zljqdF3uA==, tarball: https://registry.npmjs.org/devalue/-/devalue-5.6.0.tgz} 1459 + 1460 + dom-serializer@2.0.0: 1461 + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==, tarball: https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz} 1462 + 1463 + domelementtype@2.3.0: 1464 + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==, tarball: https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz} 1465 + 1466 + domhandler@5.0.3: 1467 + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==, tarball: https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz} 1468 + engines: {node: '>= 4'} 1469 + 1470 + domutils@3.2.2: 1471 + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==, tarball: https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz} 1439 1472 1440 1473 dunder-proto@1.0.1: 1441 1474 resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==, tarball: https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz} ··· 1456 1489 resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==, tarball: https://registry.npmjs.org/entities/-/entities-4.5.0.tgz} 1457 1490 engines: {node: '>=0.12'} 1458 1491 1492 + entities@6.0.1: 1493 + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==, tarball: https://registry.npmjs.org/entities/-/entities-6.0.1.tgz} 1494 + engines: {node: '>=0.12'} 1495 + 1459 1496 error-stack-parser-es@1.0.5: 1460 1497 resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==, tarball: https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz} 1461 1498 ··· 1707 1744 resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==, tarball: https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz} 1708 1745 engines: {node: '>= 0.4'} 1709 1746 1747 + htmlparser2@8.0.2: 1748 + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==, tarball: https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz} 1749 + 1710 1750 http-errors@2.0.0: 1711 1751 resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==, tarball: https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz} 1712 1752 engines: {node: '>= 0.8'} ··· 1866 1906 resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==, tarball: https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz} 1867 1907 engines: {node: '>=10'} 1868 1908 1909 + link-preview-js@4.0.0: 1910 + resolution: {integrity: sha512-KYYiMVOAguIFNdoVGVjd+6cgknv5XHbr1IuRdj3T5EkN7JNaqUh4a1NSCi5FX6zyZzILI55OQA6+YxBAcPY2wA==, tarball: https://registry.npmjs.org/link-preview-js/-/link-preview-js-4.0.0.tgz} 1911 + engines: {node: '>=18'} 1912 + 1869 1913 linkify-it@5.0.0: 1870 1914 resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==, tarball: https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz} 1871 1915 ··· 1987 2031 resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==, tarball: https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz} 1988 2032 engines: {node: '>= 0.6'} 1989 2033 2034 + nth-check@2.1.1: 2035 + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==, tarball: https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz} 2036 + 1990 2037 number-flow@0.5.8: 1991 2038 resolution: {integrity: sha512-FPr1DumWyGi5Nucoug14bC6xEz70A1TnhgSHhKyfqjgji2SOTz+iLJxKtv37N5JyJbteGYCm6NQ9p1O4KZ7iiA==, tarball: https://registry.npmjs.org/number-flow/-/number-flow-0.5.8.tgz} 1992 2039 ··· 2023 2070 parent-module@1.0.1: 2024 2071 resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==, tarball: https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz} 2025 2072 engines: {node: '>=6'} 2073 + 2074 + parse5-htmlparser2-tree-adapter@7.1.0: 2075 + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==, tarball: https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz} 2076 + 2077 + parse5@7.3.0: 2078 + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==, tarball: https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz} 2026 2079 2027 2080 parseurl@1.3.3: 2028 2081 resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==, tarball: https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz} ··· 3676 3729 transitivePeerDependencies: 3677 3730 - supports-color 3678 3731 3732 + boolbase@1.0.0: {} 3733 + 3679 3734 brace-expansion@1.1.11: 3680 3735 dependencies: 3681 3736 balanced-match: 1.0.2 ··· 3708 3763 ansi-styles: 4.3.0 3709 3764 supports-color: 7.2.0 3710 3765 3766 + cheerio-select@2.1.0: 3767 + dependencies: 3768 + boolbase: 1.0.0 3769 + css-select: 5.2.2 3770 + css-what: 6.2.2 3771 + domelementtype: 2.3.0 3772 + domhandler: 5.0.3 3773 + domutils: 3.2.2 3774 + 3775 + cheerio@1.0.0-rc.11: 3776 + dependencies: 3777 + cheerio-select: 2.1.0 3778 + dom-serializer: 2.0.0 3779 + domhandler: 5.0.3 3780 + domutils: 3.2.2 3781 + htmlparser2: 8.0.2 3782 + parse5: 7.3.0 3783 + parse5-htmlparser2-tree-adapter: 7.1.0 3784 + tslib: 2.8.1 3785 + 3711 3786 chokidar@4.0.3: 3712 3787 dependencies: 3713 3788 readdirp: 4.1.2 ··· 3759 3834 shebang-command: 2.0.0 3760 3835 which: 2.0.2 3761 3836 3837 + css-select@5.2.2: 3838 + dependencies: 3839 + boolbase: 1.0.0 3840 + css-what: 6.2.2 3841 + domhandler: 5.0.3 3842 + domutils: 3.2.2 3843 + nth-check: 2.1.1 3844 + 3845 + css-what@6.2.2: {} 3846 + 3762 3847 css.escape@1.5.1: {} 3763 3848 3764 3849 cssesc@3.0.0: {} ··· 3781 3866 3782 3867 devalue@5.6.0: {} 3783 3868 3869 + dom-serializer@2.0.0: 3870 + dependencies: 3871 + domelementtype: 2.3.0 3872 + domhandler: 5.0.3 3873 + entities: 4.5.0 3874 + 3875 + domelementtype@2.3.0: {} 3876 + 3877 + domhandler@5.0.3: 3878 + dependencies: 3879 + domelementtype: 2.3.0 3880 + 3881 + domutils@3.2.2: 3882 + dependencies: 3883 + dom-serializer: 2.0.0 3884 + domelementtype: 2.3.0 3885 + domhandler: 5.0.3 3886 + 3784 3887 dunder-proto@1.0.1: 3785 3888 dependencies: 3786 3889 call-bind-apply-helpers: 1.0.2 ··· 3797 3900 tapable: 2.2.1 3798 3901 3799 3902 entities@4.5.0: {} 3903 + 3904 + entities@6.0.1: {} 3800 3905 3801 3906 error-stack-parser-es@1.0.5: {} 3802 3907 ··· 4141 4246 dependencies: 4142 4247 function-bind: 1.1.2 4143 4248 4249 + htmlparser2@8.0.2: 4250 + dependencies: 4251 + domelementtype: 2.3.0 4252 + domhandler: 5.0.3 4253 + domutils: 3.2.2 4254 + entities: 4.5.0 4255 + 4144 4256 http-errors@2.0.0: 4145 4257 dependencies: 4146 4258 depd: 2.0.0 ··· 4262 4374 4263 4375 lilconfig@2.1.0: {} 4264 4376 4377 + link-preview-js@4.0.0: 4378 + dependencies: 4379 + cheerio: 1.0.0-rc.11 4380 + 4265 4381 linkify-it@5.0.0: 4266 4382 dependencies: 4267 4383 uc.micro: 2.1.0 ··· 4368 4484 4369 4485 negotiator@1.0.0: {} 4370 4486 4487 + nth-check@2.1.1: 4488 + dependencies: 4489 + boolbase: 1.0.0 4490 + 4371 4491 number-flow@0.5.8: 4372 4492 dependencies: 4373 4493 esm-env: 1.2.2 ··· 4406 4526 parent-module@1.0.1: 4407 4527 dependencies: 4408 4528 callsites: 3.1.0 4529 + 4530 + parse5-htmlparser2-tree-adapter@7.1.0: 4531 + dependencies: 4532 + domhandler: 5.0.3 4533 + parse5: 7.3.0 4534 + 4535 + parse5@7.3.0: 4536 + dependencies: 4537 + entities: 6.0.1 4409 4538 4410 4539 parseurl@1.3.3: {} 4411 4540
+56 -53
src/lib/EditableWebsite.svelte
··· 21 21 import { deleteRecord, putRecord } from './oauth/atproto'; 22 22 import { innerWidth } from 'svelte/reactivity/window'; 23 23 import { TID } from '@atproto/common-web'; 24 + import EditingCard from './cards/Card/EditingCard.svelte'; 24 25 25 26 let { 26 27 handle, ··· 65 66 mouseDeltaY: 0 66 67 }); 67 68 68 - let isMobile = $derived((innerWidth.current ?? 1000) < 768); 69 + let isMobile = $derived((innerWidth.current ?? 1000) < 1024); 69 70 70 71 const getX = (item: Item) => (isMobile ? (item.mobileX ?? item.x) : item.x); 71 72 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); ··· 73 74 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 74 75 75 76 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 76 - $inspect(maxHeight); 77 + 78 + function newCard(type: 'text' | 'image' | 'link' = 'link') { 79 + let newItem: Item = { 80 + id: TID.nextStr(), 81 + x: 0, 82 + y: 0, 83 + w: 1, 84 + h: 1, 85 + mobileH: 2, 86 + mobileW: 2, 87 + mobileX: 0, 88 + mobileY: 0, 89 + cardType: type, 90 + cardData: { 91 + href: 'https://bsky.app/profile/flo-bit.dev' 92 + } 93 + }; 94 + 95 + let foundPosition = false; 96 + while (!foundPosition) { 97 + for (newItem.x = 0; newItem.x <= 4 - newItem.w; newItem.x++) { 98 + let collision = items.find((item) => overlaps(newItem, item)); 99 + console.log('checking position', newItem.x, newItem.y, 'collision:', collision); 100 + if (!collision) { 101 + foundPosition = true; 102 + break; 103 + } 104 + } 105 + if (!foundPosition) newItem.y += 1; 106 + } 107 + 108 + let foundMobilePosition = false; 109 + while (!foundMobilePosition) { 110 + for (newItem.mobileX = 0; newItem.mobileX <= 4 - newItem.mobileW; newItem.mobileX += 1) { 111 + let collision = items.find((item) => overlaps(newItem, item, true)); 112 + 113 + if (!collision) { 114 + foundMobilePosition = true; 115 + break; 116 + } 117 + } 118 + if (!foundMobilePosition) newItem.mobileY! += 2; 119 + } 120 + 121 + items = [...items, newItem]; 122 + } 77 123 </script> 78 124 79 125 <Profile {handle} {did} /> 80 126 81 - <div class="md:grid md:grid-cols-3"> 127 + <div class="mx-auto max-w-2xl lg:grid-cols-4 lg:grid lg:max-w-none xl:grid-cols-3"> 82 128 <div></div> 83 129 <!-- svelte-ignore a11y_no_static_element_interactions --> 84 130 <div ··· 140 186 activeDragElement.element = null; 141 187 return true; 142 188 }} 143 - class="relative col-span-2 p-8" 189 + class="relative col-span-3 xl:col-span-2 py-8 px-2 lg:px-8" 144 190 style="container-type: inline-size;" 145 191 > 146 - {#each items.toSorted(sortItems) as item} 147 - <EditingImageCard 192 + {#each items as item, i} 193 + <EditingCard 148 194 ondragstart={(e) => { 149 195 const target = e.target as HTMLDivElement; 150 196 activeDragElement.element = target; ··· 156 202 activeDragElement.mouseDeltaX = rect.left + margin - e.clientX; 157 203 activeDragElement.mouseDeltaY = rect.top - e.clientY; 158 204 }} 159 - {item} 205 + bind:item={items[i]} 160 206 ondelete={() => { 161 207 items = items.filter((it) => it !== item); 162 208 }} ··· 196 242 <HeadItem collection="com.example.head" /> 197 243 198 244 <Navbar 199 - class="dark:bg-base-900 bg-base-100 top-auto bottom-2 mx-4 mt-3 max-w-3xl rounded-full px-4 md:mx-auto" 245 + class="dark:bg-base-900 bg-base-100 top-auto bottom-2 mx-4 mt-3 max-w-3xl rounded-full px-4 lg:mx-auto" 200 246 > 201 247 <div class="flex items-center gap-2"> 202 248 <Button ··· 229 275 variant="ghost" 230 276 class="backdrop-blur-none" 231 277 onclick={() => { 232 - let newItem: Item = { 233 - id: TID.nextStr(), 234 - x: 0, 235 - y: 0, 236 - w: 1, 237 - h: 1, 238 - mobileH: 2, 239 - mobileW: 2, 240 - mobileX: 0, 241 - mobileY: 0, 242 - cardType: 'image', 243 - cardData: { 244 - image: `https://picsum.photos/seed/1${crypto.randomUUID()}/800/800`, 245 - href: 'https://example.com', 246 - hrefText: 'Visit example page' 247 - } 248 - }; 249 - 250 - let foundPosition = false; 251 - while (!foundPosition) { 252 - for (newItem.x = 0; newItem.x <= 4 - newItem.w; newItem.x++) { 253 - let collision = items.find((item) => overlaps(newItem, item)); 254 - console.log('checking position', newItem.x, newItem.y, 'collision:', collision); 255 - if (!collision) { 256 - foundPosition = true; 257 - break; 258 - } 259 - } 260 - if (!foundPosition) newItem.y += 1; 261 - } 262 - 263 - let foundMobilePosition = false; 264 - while (!foundMobilePosition) { 265 - for (newItem.mobileX = 0; newItem.mobileX <= 4 - newItem.mobileW; newItem.mobileX += 1) { 266 - let collision = items.find((item) => overlaps(newItem, item, true)); 267 - 268 - if (!collision) { 269 - foundMobilePosition = true; 270 - break; 271 - } 272 - } 273 - if (!foundMobilePosition) newItem.mobileY! += 2; 274 - } 275 - 276 - items = [...items, newItem]; 278 + newCard(); 277 279 }} 278 280 > 279 281 <svg ··· 309 311 310 312 if (!originalItem) { 311 313 console.log('updated or new item', item); 314 + item.updatedAt = new Date().toISOString(); 312 315 await putRecord({ collection: 'com.example.bento', rkey: item.id, record: item }); 313 316 } 314 317 }
+2 -2
src/lib/Profile.svelte
··· 5 5 let { handle, did }: { handle: string; did: string } = $props(); 6 6 </script> 7 7 8 - <div class="flex px-12 py-24 md:fixed md:h-screen md:w-1/3"> 8 + <div class="mx-auto flex max-w-2xl px-10 lg:px-12 pt-16 lg:pt-24 pb-8 lg:fixed lg:h-screen lg:w-1/4 xl:w-1/3 lg:max-w-none"> 9 9 <div class="flex flex-col gap-4"> 10 10 <SingleRecord collection="app.bsky.actor.profile" rkey="self"> 11 11 {#snippet child(data)} ··· 16 16 data.value.avatar.ref.$link} 17 17 /> 18 18 <img 19 - class="rounded-fulll size-44 rounded-full" 19 + class="rounded-fulll size-32 lg:size-44 rounded-full" 20 20 src={'https://cdn.bsky.app/img/avatar/plain/' + did + '/' + data.value.avatar.ref.$link} 21 21 alt="" 22 22 />
+16 -10
src/lib/Website.svelte
··· 1 1 <script lang="ts"> 2 - import ImageCard from './cards/ImageCard/ImageCard.svelte'; 3 - import { sortItems } from './helper'; 2 + import Card from './cards/Card/Card.svelte'; 4 3 import Profile from './Profile.svelte'; 4 + import { sortItems } from './helper'; 5 5 import type { Item } from './types'; 6 + import { innerWidth } from 'svelte/reactivity/window'; 6 7 7 - let { handle, did, items }: { handle: string; did: string, items: Item[] } = $props(); 8 + let { handle, did, items }: { handle: string; did: string; items: Item[] } = $props(); 8 9 9 - let maxHeight = $derived(items.reduce((max, item) => Math.max(max, item.y + item.h), 0)); 10 + let isMobile = $derived((innerWidth.current ?? 1000) < 1024); 11 + 12 + let maxHeight = $derived( 13 + items.reduce( 14 + (max, item) => Math.max(max, isMobile ? item.mobileY + item.mobileH : item.y + item.h), 15 + 0 16 + ) 17 + ); 10 18 11 19 let container: HTMLDivElement | undefined = $state(); 12 20 </script> 13 21 14 22 <Profile {handle} {did} /> 15 23 16 - <div class="md:grid md:grid-cols-3"> 24 + <div class="mx-auto max-w-2xl lg:grid lg:max-w-none lg:grid-cols-4 xl:grid-cols-3"> 17 25 <div></div> 18 26 <div 19 27 bind:this={container} 20 - class="relative col-span-2 p-8" 28 + class="relative col-span-3 px-2 py-8 lg:px-8 xl:col-span-2" 21 29 style="container-type: inline-size;" 22 30 > 23 31 {#each items.toSorted(sortItems) as item} 24 - <ImageCard 25 - {item} 26 - /> 32 + <Card {item} /> 27 33 {/each} 28 - <div style="height: {((maxHeight) / 4) * 100}cqw;"></div> 34 + <div style="height: {(maxHeight / 4) * 100}cqw;"></div> 29 35 </div> 30 36 </div>
-1
src/lib/assets/favicon.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
+68
src/lib/cards/BaseCard/BaseCard.svelte
··· 1 + <script lang="ts"> 2 + import { margin, mobileMargin } from '$lib'; 3 + import type { Item } from '$lib/types'; 4 + import type { WithElementRef } from 'bits-ui'; 5 + import type { Snippet } from 'svelte'; 6 + import type { HTMLAttributes } from 'svelte/elements'; 7 + import { innerWidth } from 'svelte/reactivity/window'; 8 + 9 + export type BaseCardProps = { 10 + item: Item; 11 + controls?: Snippet<[]>; 12 + isEditing?: boolean; 13 + } & WithElementRef<HTMLAttributes<HTMLDivElement>>; 14 + 15 + let { 16 + item, 17 + children, 18 + ref = $bindable(null), 19 + isEditing = false, 20 + controls, 21 + ...rest 22 + }: BaseCardProps = $props(); 23 + 24 + let isMobile = $derived((innerWidth.current ?? 1000) < 1024); 25 + 26 + const getX = () => (isMobile ? (item.mobileX ?? item.x) : item.x); 27 + const getY = () => (isMobile ? (item.mobileY ?? item.y) : item.y); 28 + const getW = () => (isMobile ? (item.mobileW ?? item.w) : item.w); 29 + const getH = () => (isMobile ? (item.mobileH ?? item.h) : item.h); 30 + </script> 31 + 32 + <div 33 + id={item.id} 34 + data-flip-id={item.id} 35 + bind:this={ref} 36 + draggable={isEditing} 37 + class={[ 38 + 'card border-base-200 bg-base-50 group dark:border-base-800 dark:bg-base-900 focus-within:outline-accent-500 absolute z-0 rounded-2xl border outline-offset-2 focus-within:outline-2 lg:hidden' 39 + ]} 40 + style={`translate: calc(${(item.mobileX / 4) * 100}cqw + ${mobileMargin}px) calc(${(item.mobileY / 4) * 100}cqw + ${mobileMargin}px); 41 + width: calc(${(item.mobileW / 4) * 100}cqw - ${mobileMargin * 2}px); 42 + height: calc(${(item.mobileH / 4) * 100}cqw - ${mobileMargin * 2}px);`} 43 + {...rest} 44 + > 45 + <div class="relative h-full w-full overflow-hidden rounded-[15px]"> 46 + {@render children?.()} 47 + </div> 48 + {@render controls?.()} 49 + </div> 50 + 51 + <div 52 + id={item.id} 53 + data-flip-id={item.id} 54 + bind:this={ref} 55 + draggable={isEditing} 56 + class={[ 57 + 'card border-base-200 bg-base-50 group dark:border-base-800 dark:bg-base-900 focus-within:outline-accent-500 absolute z-0 hidden rounded-2xl border outline-offset-2 focus-within:outline-2 lg:block' 58 + ]} 59 + style={`translate: calc(${(item.x / 4) * 100}cqw + ${margin}px) calc(${(item.y / 4) * 100}cqw + ${margin}px); 60 + width: calc(${(item.w / 4) * 100}cqw - ${margin * 2}px); 61 + height: calc(${(item.h / 4) * 100}cqw - ${margin * 2}px);`} 62 + {...rest} 63 + > 64 + <div class="relative h-full w-full overflow-hidden rounded-[15px]"> 65 + {@render children?.()} 66 + </div> 67 + {@render controls?.()} 68 + </div>
+126
src/lib/cards/BaseCard/BaseEditingCard.svelte
··· 1 + <script lang="ts"> 2 + import type { WithElementRef } from 'bits-ui'; 3 + import type { HTMLAttributes } from 'svelte/elements'; 4 + import Card from './BaseCard.svelte'; 5 + import type { Item } from '$lib/types'; 6 + 7 + export type BaseEditingCardProps = { 8 + item: Item; 9 + ondelete: () => void; 10 + onsetsize: (newW: number, newH: number) => void; 11 + onshowsettings: () => void; 12 + } & WithElementRef<HTMLAttributes<HTMLDivElement>>; 13 + 14 + let { 15 + item, 16 + children, 17 + ref = $bindable(null), 18 + onsetsize, 19 + onshowsettings, 20 + ondelete, 21 + ...rest 22 + }: BaseEditingCardProps = $props(); 23 + </script> 24 + 25 + <Card {item} {...rest} isEditing={true} bind:ref> 26 + {@render children?.()} 27 + 28 + {#snippet controls()} 29 + <button 30 + onclick={() => { 31 + ondelete(); 32 + }} 33 + class="bg-base-100 border-base-200 dark:bg-base-800 dark:border-base-700 absolute -top-3 -left-3 hidden cursor-pointer items-center justify-center rounded-full border p-2 shadow-lg group-focus-within:inline-flex group-hover:inline-flex" 34 + > 35 + <svg 36 + xmlns="http://www.w3.org/2000/svg" 37 + fill="none" 38 + viewBox="0 0 24 24" 39 + stroke-width="1.5" 40 + stroke="currentColor" 41 + class="size-4" 42 + > 43 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 44 + </svg> 45 + <span class="sr-only">Delete card</span> 46 + </button> 47 + 48 + <div 49 + class="absolute -bottom-7 z-50 hidden w-full items-center justify-center text-xs group-focus-within:inline-flex group-hover:inline-flex" 50 + > 51 + <div 52 + class="bg-base-100 border-base-200 dark:bg-base-800 dark:border-base-700 inline-flex gap-0.5 rounded-2xl border p-1 px-2 shadow-lg" 53 + > 54 + <button 55 + onclick={() => { 56 + onsetsize?.(1, 1); 57 + }} 58 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 59 + > 60 + <div class="border-base-900 dark:border-base-50 size-3 rounded-sm border-2"></div> 61 + 62 + <span class="sr-only">set size to 1x1</span> 63 + </button> 64 + 65 + <button 66 + onclick={() => { 67 + onsetsize?.(2, 1); 68 + }} 69 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 70 + > 71 + <div class="border-base-900 dark:border-base-50 h-3 w-5 rounded-sm border-2"></div> 72 + <span class="sr-only">set size to 2x1</span> 73 + </button> 74 + <button 75 + onclick={() => { 76 + onsetsize?.(1, 2); 77 + }} 78 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 79 + > 80 + <div class="border-base-900 dark:border-base-50 h-5 w-3 rounded-sm border-2"></div> 81 + 82 + <span class="sr-only">set size to 1x2</span> 83 + </button> 84 + <button 85 + onclick={() => { 86 + onsetsize?.(2, 2); 87 + }} 88 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 89 + > 90 + <div class="border-base-900 dark:border-base-50 h-5 w-5 rounded-sm border-2"></div> 91 + 92 + <span class="sr-only">set size to 2x2</span> 93 + </button> 94 + 95 + <button 96 + onclick={() => { 97 + onshowsettings(); 98 + }} 99 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 100 + > 101 + <svg 102 + xmlns="http://www.w3.org/2000/svg" 103 + fill="none" 104 + viewBox="0 0 24 24" 105 + stroke-width="2" 106 + stroke="currentColor" 107 + class="size-5" 108 + > 109 + <path 110 + stroke-linecap="round" 111 + stroke-linejoin="round" 112 + d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" 113 + /> 114 + <path 115 + stroke-linecap="round" 116 + stroke-linejoin="round" 117 + d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 118 + /> 119 + </svg> 120 + 121 + <span class="sr-only">open card settings</span> 122 + </button> 123 + </div> 124 + </div> 125 + {/snippet} 126 + </Card>
+13 -45
src/lib/cards/Card/Card.svelte
··· 1 1 <script lang="ts"> 2 - import { margin } from '$lib'; 3 - import type { Item } from '$lib/types'; 4 - import type { WithElementRef } from 'bits-ui'; 5 - import type { Snippet } from 'svelte'; 6 - import type { HTMLAttributes } from 'svelte/elements'; 7 - import { innerWidth } from 'svelte/reactivity/window'; 2 + import { CardDefinitionsByType } from '.'; 3 + import BaseCard, { type BaseCardProps } from '../BaseCard/BaseCard.svelte'; 4 + import ImageCard from '../ImageCard/ImageCard.svelte'; 5 + import TextCard from '../TextCard/TextCard.svelte'; 8 6 9 - export type CardProps = { 10 - item: Item; 11 - controls?: Snippet<[]>; 12 - isEditing?: boolean; 13 - } & WithElementRef<HTMLAttributes<HTMLDivElement>>; 14 - 15 - let { 16 - item, 17 - children, 18 - ref = $bindable(null), 19 - isEditing = false, 20 - controls, 21 - ...rest 22 - }: CardProps = $props(); 23 - 24 - let isMobile = $derived((innerWidth.current ?? 1000) < 768); 25 - 26 - const getX = () => (isMobile ? (item.mobileX ?? item.x) : item.x); 27 - const getY = () => (isMobile ? (item.mobileY ?? item.y) : item.y); 28 - const getW = () => (isMobile ? (item.mobileW ?? item.w) : item.w); 29 - const getH = () => (isMobile ? (item.mobileH ?? item.h) : item.h); 7 + let { item, ref = $bindable(null), ...rest }: BaseCardProps = $props(); 30 8 </script> 31 9 32 - <div 33 - id={item.id} 34 - data-flip-id={item.id} 35 - bind:this={ref} 36 - draggable={isEditing} 37 - class={[ 38 - 'card border-base-200 bg-base-50 group dark:border-base-800 dark:bg-base-900 focus-within:outline-accent-500 absolute z-0 rounded-2xl border outline-offset-2 focus-within:outline-2' 39 - ]} 40 - style={`translate: calc(${(getX() / 4) * 100}cqw + ${margin}px) calc(${(getY() / 4) * 100}cqw + ${margin}px); 41 - width: calc(${(getW() / 4) * 100}cqw - ${margin * 2}px); 42 - height: calc(${(getH() / 4) * 100}cqw - ${margin * 2}px);`} 43 - {...rest} 44 - > 45 - <div class="relative h-full w-full overflow-hidden rounded-[15px]"> 46 - {@render children?.()} 47 - </div> 48 - {@render controls?.()} 49 - </div> 10 + {#if CardDefinitionsByType[item.cardType]} 11 + {@const cardDef = CardDefinitionsByType[item.cardType]} 12 + <cardDef.cardComponent {item} {ref} {...rest} /> 13 + {:else} 14 + <BaseCard {item} {...rest}> 15 + <div>Unsupported card type: {item.cardType}</div> 16 + </BaseCard> 17 + {/if}
+12 -122
src/lib/cards/Card/EditingCard.svelte
··· 1 1 <script lang="ts"> 2 - import type { WithElementRef } from 'bits-ui'; 3 - import type { HTMLAttributes } from 'svelte/elements'; 4 - import Card from './Card.svelte'; 5 - import type { Item } from '$lib/types'; 2 + import type { BaseEditingCardProps } from '../BaseCard/BaseEditingCard.svelte'; 3 + import { CardDefinitionsByType } from '.'; 4 + import BaseCard from '../BaseCard/BaseCard.svelte'; 6 5 7 - export type EditingCardProps = { 8 - item: Item; 9 - ondelete: () => void; 10 - onsetsize: (newW: number, newH: number) => void; 11 - onshowsettings: () => void; 12 - } & WithElementRef<HTMLAttributes<HTMLDivElement>>; 13 - 14 - let { 15 - item, 16 - children, 17 - ref = $bindable(null), 18 - onsetsize, 19 - onshowsettings, 20 - ondelete, 21 - ...rest 22 - }: EditingCardProps = $props(); 6 + let { item = $bindable(), ref = $bindable(), ...rest }: BaseEditingCardProps = $props(); 23 7 </script> 24 8 25 - <Card {item} {...rest} isEditing={true} bind:ref> 26 - {@render children?.()} 27 - 28 - {#snippet controls()} 29 - <button 30 - onclick={() => { 31 - ondelete(); 32 - }} 33 - class="bg-base-100 border-base-200 dark:bg-base-800 dark:border-base-700 absolute -top-3 -left-3 hidden cursor-pointer items-center justify-center rounded-full border p-2 shadow-lg group-focus-within:inline-flex group-hover:inline-flex" 34 - > 35 - <svg 36 - xmlns="http://www.w3.org/2000/svg" 37 - fill="none" 38 - viewBox="0 0 24 24" 39 - stroke-width="1.5" 40 - stroke="currentColor" 41 - class="size-4" 42 - > 43 - <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 44 - </svg> 45 - <span class="sr-only">Delete card</span> 46 - </button> 47 - 48 - <div 49 - class=" absolute -bottom-3 z-50 hidden w-full items-center justify-center text-xs group-focus-within:inline-flex group-hover:inline-flex" 50 - > 51 - <div 52 - class="bg-base-100 border-base-200 dark:bg-base-800 dark:border-base-700 inline-flex gap-0.5 rounded-2xl border p-1 px-2 shadow-lg" 53 - > 54 - <button 55 - onclick={() => { 56 - onsetsize?.(1, 1); 57 - }} 58 - class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 59 - > 60 - <div class="border-base-900 dark:border-base-50 size-3 rounded-sm border-2"></div> 61 - 62 - <span class="sr-only">set size to 1x1</span> 63 - </button> 64 - 65 - <button 66 - onclick={() => { 67 - onsetsize?.(2, 1); 68 - }} 69 - class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 70 - > 71 - <div class="border-base-900 dark:border-base-50 h-3 w-5 rounded-sm border-2"></div> 72 - <span class="sr-only">set size to 2x1</span> 73 - </button> 74 - <button 75 - onclick={() => { 76 - onsetsize?.(1, 2); 77 - }} 78 - class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 79 - > 80 - <div class="border-base-900 dark:border-base-50 h-5 w-3 rounded-sm border-2"></div> 81 - 82 - <span class="sr-only">set size to 1x2</span> 83 - </button> 84 - <button 85 - onclick={() => { 86 - onsetsize?.(2, 2); 87 - }} 88 - class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 89 - > 90 - <div class="border-base-900 dark:border-base-50 h-5 w-5 rounded-sm border-2"></div> 91 - 92 - <span class="sr-only">set size to 2x2</span> 93 - </button> 94 - 95 - <button 96 - onclick={() => { 97 - onshowsettings(); 98 - }} 99 - class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 100 - > 101 - <svg 102 - xmlns="http://www.w3.org/2000/svg" 103 - fill="none" 104 - viewBox="0 0 24 24" 105 - stroke-width="2" 106 - stroke="currentColor" 107 - class="size-5" 108 - > 109 - <path 110 - stroke-linecap="round" 111 - stroke-linejoin="round" 112 - d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" 113 - /> 114 - <path 115 - stroke-linecap="round" 116 - stroke-linejoin="round" 117 - d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 118 - /> 119 - </svg> 120 - 121 - <span class="sr-only">open card settings</span> 122 - </button> 123 - </div> 124 - </div> 125 - {/snippet} 126 - </Card> 9 + {#if CardDefinitionsByType[item.cardType]} 10 + {@const cardDef = CardDefinitionsByType[item.cardType]} 11 + <cardDef.editingCardComponent bind:item {ref} {...rest} /> 12 + {:else} 13 + <BaseCard {item} {...rest}> 14 + <div>Unsupported card type: {item.cardType}</div> 15 + </BaseCard> 16 + {/if}
+18
src/lib/cards/Card/index.ts
··· 1 + import { ImageCardDefinition } from '../ImageCard'; 2 + import { LinkCardDefinition } from '../LinkCard'; 3 + import { TextCardDefinition } from '../TextCard'; 4 + import type { CardDefinition } from '../types'; 5 + 6 + export const AllCardDefinitions = [ 7 + ImageCardDefinition, 8 + TextCardDefinition, 9 + LinkCardDefinition 10 + ] as const; 11 + 12 + export const CardDefinitionsByType = AllCardDefinitions.reduce( 13 + (acc, item) => { 14 + acc[item.type] = item; 15 + return acc; 16 + }, 17 + {} as Record<string, CardDefinition> 18 + );
+4 -4
src/lib/cards/ImageCard/EditingImageCard.svelte
··· 1 1 <script lang="ts"> 2 - import EditingCard, { type EditingCardProps } from '../Card/EditingCard.svelte'; 2 + import BaseEditingCard, { type BaseEditingCardProps } from '../BaseCard/BaseEditingCard.svelte'; 3 3 4 - let { item, ...rest }: EditingCardProps = $props(); 4 + let { item = $bindable(), ...rest }: BaseEditingCardProps = $props(); 5 5 </script> 6 6 7 - <EditingCard {item} {...rest}> 7 + <BaseEditingCard {item} {...rest}> 8 8 {#key item.cardData.image} 9 9 <img 10 10 class={[ ··· 27 27 </span> 28 28 </a> 29 29 {/if} 30 - </EditingCard> 30 + </BaseEditingCard>
+4 -4
src/lib/cards/ImageCard/ImageCard.svelte
··· 1 1 <script lang="ts"> 2 - import Card, { type CardProps } from '../Card/Card.svelte'; 2 + import BaseCard, { type BaseCardProps } from '../BaseCard/BaseCard.svelte'; 3 3 4 - let { item, ...rest }: CardProps = $props(); 4 + let { item, ...rest }: BaseCardProps = $props(); 5 5 </script> 6 6 7 - <Card {item} {...rest}> 7 + <BaseCard {item} {...rest}> 8 8 {#key item.cardData.image} 9 9 <img 10 10 class={[ ··· 46 46 </div> 47 47 </a> 48 48 {/if} 49 - </Card> 49 + </BaseCard>
+16 -2
src/lib/cards/ImageCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import EditingImageCard from './EditingImageCard.svelte'; 3 + import ImageCard from './ImageCard.svelte'; 4 + 1 5 export const ImageCardDefinition = { 2 - type: 'card.image' 3 - }; 6 + type: 'image', 7 + cardComponent: ImageCard, 8 + editingCardComponent: EditingImageCard, 9 + createNew: (card) => { 10 + card.cardType = 'image'; 11 + card.cardData = { 12 + src: '', 13 + alt: '', 14 + href: '' 15 + }; 16 + } 17 + } as CardDefinition & { type: 'image' };
+77
src/lib/cards/LinkCard/EditingLinkCard.svelte
··· 1 + <script lang="ts"> 2 + import BaseEditingCard, { type BaseEditingCardProps } from '../BaseCard/BaseEditingCard.svelte'; 3 + import { innerWidth } from 'svelte/reactivity/window'; 4 + 5 + let { item = $bindable(), ...rest }: BaseEditingCardProps = $props(); 6 + 7 + let isFetchingMetadata = $state(false); 8 + 9 + let isMobile = $derived((innerWidth.current ?? 1000) < 1024); 10 + 11 + $effect(() => { 12 + const fetchMetadata = async () => { 13 + if (!item.cardData.href) return; 14 + if (isFetchingMetadata) return; 15 + 16 + isFetchingMetadata = true; 17 + 18 + item.cardData.domain = new URL(item.cardData.href).hostname; 19 + 20 + try { 21 + const response = await fetch('/api/links?link=' + encodeURIComponent(item.cardData.href)); 22 + if (response.ok) { 23 + const data = await response.json(); 24 + item.cardData.description = data.description || ''; 25 + item.cardData.title = data.title || ''; 26 + item.cardData.image = data.images?.[0] || ''; 27 + item.cardData.favicon = data.favicons?.[0] || ''; 28 + } 29 + } catch (error) { 30 + console.error('Error fetching metadata:', error); 31 + } finally { 32 + isFetchingMetadata = false; 33 + } 34 + }; 35 + 36 + fetchMetadata(); 37 + }); 38 + </script> 39 + 40 + <BaseEditingCard {item} {...rest}> 41 + <div class="flex h-full flex-col justify-between p-4"> 42 + <div> 43 + <img class="mb-2 size-8 rounded-lg object-cover" src={item.cardData.favicon} alt="" /> 44 + <div class="text-base-900 dark:text-base-50 text-lg font-semibold">{item.cardData.title}</div> 45 + <!-- <div class="text-base-800 dark:text-base-100 mt-2 text-xs">{item.cardData.description}</div> --> 46 + <div class="text-accent-600 dark:text-accent-400 mt-2 text-xs font-light"> 47 + {item.cardData.domain} 48 + </div> 49 + </div> 50 + 51 + {#if ((isMobile && item.mobileH >= 4) || (!isMobile && item.h >= 2)) && item.cardData.image} 52 + <img class=" mb-2 max-h-32 w-full rounded-xl object-cover" src={item.cardData.image} alt="" /> 53 + {/if} 54 + <!-- {#key item.cardData.image} 55 + <img 56 + class={[ 57 + 'absolute inset-0 h-full w-full object-cover opacity-100 transition-transform duration-300 ease-in-out', 58 + item.cardData.href ? 'group-hover:scale-105' : '' 59 + ]} 60 + src={item.cardData.image} 61 + alt="" 62 + /> 63 + {/key} --> 64 + {#if item.cardData.href} 65 + <a 66 + href={item.cardData.href} 67 + class="absolute inset-0 h-full w-full" 68 + target="_blank" 69 + rel="noopener noreferrer" 70 + > 71 + <span class="sr-only"> 72 + {item.cardData.hrefText ?? 'Learn more'} 73 + </span> 74 + </a> 75 + {/if} 76 + </div> 77 + </BaseEditingCard>
+55
src/lib/cards/LinkCard/LinkCard.svelte
··· 1 + <script lang="ts"> 2 + import BaseCard, { type BaseCardProps } from '../BaseCard/BaseCard.svelte'; 3 + import { innerWidth } from 'svelte/reactivity/window'; 4 + let { item, ...rest }: BaseCardProps = $props(); 5 + 6 + let isMobile = $derived((innerWidth.current ?? 1000) < 1024); 7 + </script> 8 + 9 + <BaseCard {item} {...rest}> 10 + <div class="flex h-full flex-col justify-between p-4"> 11 + <div> 12 + <img class="mb-2 size-8 rounded-lg object-cover" src={item.cardData.favicon} alt="" /> 13 + <div class="text-base-900 dark:text-base-50 text-lg font-semibold">{item.cardData.title}</div> 14 + <!-- <div class="text-base-800 dark:text-base-100 mt-2 text-xs">{item.cardData.description}</div> --> 15 + <div class="text-accent-600 dark:text-accent-400 mt-2 text-xs font-light"> 16 + {item.cardData.domain} 17 + </div> 18 + </div> 19 + 20 + {#if ((isMobile && item.mobileH >= 4) || (!isMobile && item.h >= 2)) && item.cardData.image} 21 + <img class=" mb-2 max-h-32 w-full rounded-xl object-cover" src={item.cardData.image} alt="" /> 22 + {/if} 23 + {#if item.cardData.href} 24 + <a 25 + href={item.cardData.href} 26 + class="absolute inset-0 h-full w-full" 27 + target="_blank" 28 + rel="noopener noreferrer" 29 + > 30 + <span class="sr-only"> 31 + {item.cardData.hrefText ?? 'Learn more'} 32 + </span> 33 + 34 + <div 35 + class="bg-base-800/30 border-base-900/30 absolute top-2 right-2 rounded-full border p-1 text-white opacity-50 backdrop-blur-lg group-focus-within:opacity-100 group-hover:opacity-100" 36 + > 37 + <svg 38 + xmlns="http://www.w3.org/2000/svg" 39 + fill="none" 40 + viewBox="0 0 24 24" 41 + stroke-width="2.5" 42 + stroke="currentColor" 43 + class="size-4" 44 + > 45 + <path 46 + stroke-linecap="round" 47 + stroke-linejoin="round" 48 + d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 49 + /> 50 + </svg> 51 + </div> 52 + </a> 53 + {/if} 54 + </div> 55 + </BaseCard>
+17
src/lib/cards/LinkCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import EditingLinkCard from './EditingLinkCard.svelte'; 3 + import LinkCard from './LinkCard.svelte'; 4 + 5 + export const LinkCardDefinition = { 6 + type: 'link', 7 + cardComponent: LinkCard, 8 + editingCardComponent: EditingLinkCard, 9 + createNew: (card) => { 10 + card.cardType = 'link'; 11 + card.cardData = { 12 + src: '', 13 + alt: '', 14 + href: '' 15 + }; 16 + } 17 + } as CardDefinition & { type: 'link' };
+15
src/lib/cards/TextCard/EditingTextCard.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import BaseEditingCard, { type BaseEditingCardProps } from '../BaseCard/BaseEditingCard.svelte'; 4 + import MarkdownTextEditor from './MarkdownTextEditor.svelte'; 5 + 6 + let { item = $bindable<Item>(), ...rest }: BaseEditingCardProps = $props(); 7 + </script> 8 + 9 + <BaseEditingCard {item} {...rest}> 10 + <div 11 + class="prose dark:prose-invert prose-base prose-a:no-underline prose-a:text-accent-600 dark:prose-a:text-accent-400 prose-sm hover:bg-base-500/20 prose-p:first:mt-0 prose-p:last:mb-0 m-1 rounded-md p-1" 12 + > 13 + <MarkdownTextEditor bind:item /> 14 + </div> 15 + </BaseEditingCard>
+123
src/lib/cards/TextCard/MarkdownTextEditor.svelte
··· 1 + <script lang="ts"> 2 + import { onDestroy, onMount } from 'svelte'; 3 + import { Editor, type Content, type Extensions } from '@tiptap/core'; 4 + import StarterKit from '@tiptap/starter-kit'; 5 + import Image from '@tiptap/extension-image'; 6 + import Placeholder from '@tiptap/extension-placeholder'; 7 + import Link from '@tiptap/extension-link'; 8 + import { marked } from 'marked'; 9 + import { generateJSON } from '@tiptap/core'; 10 + import TurndownService from 'turndown'; 11 + import { RichTextLink } from './extensions/RichTextLink'; 12 + import type { Item } from '$lib/types'; 13 + 14 + let element: HTMLElement | undefined = $state(); 15 + let editor: Editor | null = $state(null); 16 + 17 + let loaded = $state(false); 18 + 19 + let edited = $state(false); 20 + 21 + let { 22 + item = $bindable(), 23 + placeholder = '', 24 + defaultContent = '' 25 + }: { 26 + item: Item; 27 + placeholder?: string; 28 + defaultContent?: string; 29 + } = $props(); 30 + 31 + const update = async () => { 32 + if (!editor) return {}; 33 + 34 + const html = editor.getHTML(); 35 + 36 + var turndownService = new TurndownService({ 37 + headingStyle: 'atx', 38 + bulletListMarker: '-' 39 + }); 40 + const markdown = turndownService.turndown(html); 41 + 42 + item.cardData.text = markdown; 43 + }; 44 + 45 + onMount(async () => { 46 + if (!element || editor) return; 47 + 48 + let json: Content = ''; 49 + 50 + try { 51 + let html = await marked.parse(item.cardData.text ?? (defaultContent as string)); 52 + 53 + // parse to json 54 + json = generateJSON(html, [ 55 + StarterKit.configure(), 56 + Image.configure(), 57 + RichTextLink.configure({ 58 + openOnClick: false 59 + }) 60 + ]); 61 + } catch (error) { 62 + console.error(error); 63 + } 64 + 65 + let extensions: Extensions = [ 66 + StarterKit.configure(), 67 + Image.configure(), 68 + Link.configure({ 69 + openOnClick: false 70 + }) 71 + ]; 72 + 73 + if (placeholder) { 74 + extensions.push( 75 + Placeholder.configure({ 76 + placeholder: placeholder 77 + }) 78 + ); 79 + } 80 + 81 + editor = new Editor({ 82 + element: element, 83 + extensions: extensions, 84 + onTransaction: () => { 85 + editor = editor; 86 + }, 87 + onUpdate: () => { 88 + update(); 89 + }, 90 + 91 + content: json, 92 + 93 + editorProps: { 94 + attributes: { 95 + class: 'outline-none' 96 + } 97 + } 98 + }); 99 + 100 + loaded = true; 101 + }); 102 + 103 + onDestroy(() => { 104 + if (editor) { 105 + editor.destroy(); 106 + } 107 + }); 108 + </script> 109 + 110 + <div bind:this={element}></div> 111 + 112 + <style> 113 + :global(.tiptap p.is-editor-empty:first-child::before) { 114 + color: var(--color-base-800); 115 + content: attr(data-placeholder); 116 + float: left; 117 + height: 0; 118 + pointer-events: none; 119 + } 120 + :global(.dark .tiptap p.is-editor-empty:first-child::before) { 121 + color: var(--color-base-200); 122 + } 123 + </style>
+18
src/lib/cards/TextCard/TextCard.svelte
··· 1 + <script lang="ts"> 2 + import { marked } from 'marked'; 3 + import BaseCard, { type BaseCardProps } from '../BaseCard/BaseCard.svelte'; 4 + 5 + let { item, ...rest }: BaseCardProps = $props(); 6 + 7 + const renderer = new marked.Renderer(); 8 + renderer.link = ({ href, title, text }) => 9 + `<a target="_blank" href="${href}" title="${title}">${text}</a>`; 10 + </script> 11 + 12 + <BaseCard {item} {...rest}> 13 + <div 14 + class="prose dark:prose-invert prose-base prose-a:no-underline prose-a:text-accent-600 dark:prose-a:text-accent-400 prose-sm prose-p:first:mt-0 prose-p:last:mb-0 m-1 rounded-md p-1 break-words" 15 + > 16 + {@html marked.parse(item.cardData.text ?? '', { renderer })} 17 + </div> 18 + </BaseCard>
+125
src/lib/cards/TextCard/extensions/RichTextLink.ts
··· 1 + import { InputRule, markInputRule, markPasteRule, PasteRule } from '@tiptap/core'; 2 + import { Link } from '@tiptap/extension-link'; 3 + 4 + import type { LinkOptions } from '@tiptap/extension-link'; 5 + 6 + /** 7 + * The input regex for Markdown links with title support, and multiple quotation marks (required 8 + * in case the `Typography` extension is being included). 9 + */ 10 + const inputRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)$/i; 11 + 12 + /** 13 + * The paste regex for Markdown links with title support, and multiple quotation marks (required 14 + * in case the `Typography` extension is being included). 15 + */ 16 + const pasteRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)/gi; 17 + 18 + /** 19 + * Input rule built specifically for the `Link` extension, which ignores the auto-linked URL in 20 + * parentheses (e.g., `(https://doist.dev)`). 21 + * 22 + * @see https://github.com/ueberdosis/tiptap/discussions/1865 23 + */ 24 + function linkInputRule(config: Parameters<typeof markInputRule>[0]) { 25 + const defaultMarkInputRule = markInputRule(config); 26 + 27 + return new InputRule({ 28 + find: config.find, 29 + handler(props) { 30 + const { tr } = props.state; 31 + 32 + defaultMarkInputRule.handler(props); 33 + tr.setMeta('preventAutolink', true); 34 + } 35 + }); 36 + } 37 + 38 + /** 39 + * Paste rule built specifically for the `Link` extension, which ignores the auto-linked URL in 40 + * parentheses (e.g., `(https://doist.dev)`). This extension was inspired from the multiple 41 + * implementations found in a Tiptap discussion at GitHub. 42 + * 43 + * @see https://github.com/ueberdosis/tiptap/discussions/1865 44 + */ 45 + function linkPasteRule(config: Parameters<typeof markPasteRule>[0]) { 46 + const defaultMarkPasteRule = markPasteRule(config); 47 + 48 + return new PasteRule({ 49 + find: config.find, 50 + handler(props) { 51 + const { tr } = props.state; 52 + 53 + defaultMarkPasteRule.handler(props); 54 + tr.setMeta('preventAutolink', true); 55 + } 56 + }); 57 + } 58 + 59 + /** 60 + * The options available to customize the `RichTextLink` extension. 61 + */ 62 + type RichTextLinkOptions = LinkOptions; 63 + 64 + /** 65 + * Custom extension that extends the built-in `Link` extension to add additional input/paste rules 66 + * for converting the Markdown link syntax (i.e. `[Doist](https://doist.com)`) into links, and also 67 + * adds support for the `title` attribute. 68 + */ 69 + const RichTextLink = Link.extend<RichTextLinkOptions>({ 70 + inclusive: false, 71 + addOptions() { 72 + return { 73 + ...this.parent?.(), 74 + openOnClick: 'whenNotEditable' 75 + }; 76 + }, 77 + addAttributes() { 78 + return { 79 + ...this.parent?.(), 80 + title: { 81 + default: null 82 + } 83 + }; 84 + }, 85 + addInputRules() { 86 + return [ 87 + linkInputRule({ 88 + find: inputRegex, 89 + type: this.type, 90 + 91 + // We need to use `pop()` to remove the last capture groups from the match to 92 + // satisfy Tiptap's `markPasteRule` expectation of having the content as the last 93 + // capture group in the match (this makes the attribute order important) 94 + getAttributes(match) { 95 + return { 96 + title: match.pop()?.trim(), 97 + href: match.pop()?.trim() 98 + }; 99 + } 100 + }) 101 + ]; 102 + }, 103 + addPasteRules() { 104 + return [ 105 + linkPasteRule({ 106 + find: pasteRegex, 107 + type: this.type, 108 + 109 + // We need to use `pop()` to remove the last capture groups from the match to 110 + // satisfy Tiptap's `markInputRule` expectation of having the content as the last 111 + // capture group in the match (this makes the attribute order important) 112 + getAttributes(match) { 113 + return { 114 + title: match.pop()?.trim(), 115 + href: match.pop()?.trim() 116 + }; 117 + } 118 + }) 119 + ]; 120 + } 121 + }); 122 + 123 + export { RichTextLink }; 124 + 125 + export type { RichTextLinkOptions };
+15
src/lib/cards/TextCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import EditingTextCard from './EditingTextCard.svelte'; 3 + import TextCard from './TextCard.svelte'; 4 + 5 + export const TextCardDefinition = { 6 + type: 'text', 7 + cardComponent: TextCard, 8 + editingCardComponent: EditingTextCard, 9 + createNew: (card) => { 10 + card.cardType = 'text'; 11 + card.cardData = { 12 + text: '' 13 + }; 14 + } 15 + } as CardDefinition & { type: 'text' };
+10
src/lib/cards/types.ts
··· 1 + import type { Component } from 'svelte'; 2 + import type { BaseCardProps } from './BaseCard/BaseCard.svelte'; 3 + import type { Item } from '$lib/types'; 4 + import type { BaseEditingCardProps } from './BaseCard/BaseEditingCard.svelte'; 5 + 6 + export type CardDefinition = { 7 + cardComponent: Component<BaseCardProps>; 8 + editingCardComponent: Component<BaseEditingCardProps>; 9 + createNew?: (item: Item) => void; 10 + };
+2 -1
src/lib/index.ts
··· 1 1 // place files you want to import through the `$lib` alias in this folder. 2 - export const margin = 16; 2 + export const margin = 16; 3 + export const mobileMargin = 12;
+2
src/lib/types.ts
··· 15 15 16 16 // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 17 cardData: any; 18 + 19 + updatedAt?: string; 18 20 };
+1 -1
src/lib/website/EditingWebsiteWrapper.svelte
··· 27 27 28 28 <HeadItem collection="com.example.head" /> 29 29 30 - <Navbar class="dark:bg-base-900 bg-base-100 top-auto bottom-2 mx-4 mt-3 max-w-3xl rounded-full px-4 md:mx-auto"> 30 + <Navbar class="dark:bg-base-900 bg-base-100 top-auto bottom-2 mx-4 mt-3 max-w-3xl rounded-full px-4 lg:mx-auto"> 31 31 <div class="flex items-center gap-2"> 32 32 <Button size="iconLg" variant="ghost" class="backdrop-blur-none" href={base + '/'}> 33 33 <span class="sr-only">home</span>
+1 -1
src/lib/website/foxui/bluesky-login/BlueskyLoginModal.svelte
··· 134 134 <p class="text-accent-500 mt-2 text-sm font-medium">{error}</p> 135 135 {/if} 136 136 137 - <Button type="submit" class="ml-auto mt-2 w-full md:w-auto" disabled={loading} 137 + <Button type="submit" class="ml-auto mt-2 w-full lg:w-auto" disabled={loading} 138 138 >{loading ? 'Loading...' : 'Login'}</Button 139 139 > 140 140 </form>
+1 -1
src/lib/website/foxui/modal/Modal.svelte
··· 103 103 {/if} 104 104 105 105 {#if yesButton || noButton} 106 - <div class="flex flex-col items-stretch justify-end gap-2 md:flex-row md:items-center"> 106 + <div class="flex flex-col items-stretch justify-end gap-2 lg:flex-row lg:items-center"> 107 107 {#if yesButton} 108 108 <Button 109 109 bind:ref={yesButtonRef}
+10
src/routes/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from "svelte"; 3 + 4 + onMount(async () => { 5 + const data = await fetch('/api/links?link=https://bento.me/flo-bit'); 6 + const json = await data.json(); 7 + console.log(json); 8 + }) 9 + </script> 10 + 1 11 <div class="flex h-screen items-center justify-center"> 2 12 <h1 class="text-7xl font-bold">blento</h1> 3 13 </div>
+17
src/routes/api/links/+server.ts
··· 1 + import { json } from '@sveltejs/kit'; 2 + import { getLinkPreview } from 'link-preview-js'; 3 + 4 + export async function GET({ url }) { 5 + const link = url.searchParams.get('link'); 6 + if (!link) { 7 + return json({ error: 'No link provided' }, { status: 400 }); 8 + } 9 + 10 + try { 11 + const data = await getLinkPreview(link); 12 + return json(data); 13 + } catch (error) { 14 + console.error('Error fetching link preview:', error); 15 + return json({ error: 'Failed to fetch link preview' }, { status: 500 }); 16 + } 17 + }
static/favicon.png

This is a binary file and will not be displayed.

+2 -4
wrangler.jsonc
··· 4 4 */ 5 5 { 6 6 "$schema": "node_modules/wrangler/config-schema.json", 7 - "name": "svelte-guestbook", 7 + "name": "blento", 8 8 "main": ".svelte-kit/cloudflare/_worker.js", 9 9 "compatibility_date": "2025-12-25", 10 - "compatibility_flags": [ 11 - "nodejs_als" 12 - ], 10 + "compatibility_flags": ["nodejs_als"], 13 11 "assets": { 14 12 "binding": "ASSETS", 15 13 "directory": ".svelte-kit/cloudflare"