your personal website on atproto - mirror blento.app
25
fork

Configure Feed

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

Merge branch 'main' into v2-refactor

# Conflicts:
# .gitignore
# pnpm-lock.yaml
# src/lib/website/load.ts

Florian 1bb79750 1ac9cfd8

+1073 -106
+37
.github/workflows/mirror.yml
··· 1 + name: mirror 2 + 3 + on: 4 + push: 5 + branches: 6 + - main 7 + tags: 8 + - '*' 9 + 10 + permissions: 11 + contents: read 12 + 13 + jobs: 14 + mirror: 15 + name: 🕸️ Mirror to Tangled 16 + if: ${{ github.repository == 'flo-bit/blento' }} 17 + runs-on: ubuntu-24.04-arm 18 + 19 + steps: 20 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 21 + with: 22 + fetch-depth: 0 23 + 24 + - name: 🔑 Configure SSH 25 + env: 26 + TANGLED_SSH_KEY: ${{ secrets.TANGLED_SSH_KEY }} 27 + run: | 28 + mkdir -p ~/.ssh 29 + echo "$TANGLED_SSH_KEY" > ~/.ssh/id_ed25519 30 + chmod 600 ~/.ssh/id_ed25519 31 + ssh-keyscan -t ed25519 tangled.org >> ~/.ssh/known_hosts 2>/dev/null 32 + 33 + - name: ⬆︎ Push to Tangled 34 + run: | 35 + git remote add tangled git@tangled.org:flo-bit.dev/blento 36 + git push tangled main --force 37 + git push tangled --tags --force
+1
.gitignore
··· 26 26 27 27 sveltekit-cloudflare-workers 28 28 29 + inlay 29 30 30 31 scripts/backups
+3
.prettierignore
··· 2 2 package-lock.json 3 3 pnpm-lock.yaml 4 4 yarn.lock 5 + 6 + # Unrelated sub-project 7 + inlay
+1
eslint.config.js
··· 44 44 'svelte/no-at-html-tags': 'off', 45 45 '@typescript-eslint/no-explicit-any': 'off', 46 46 'no-unused-vars': 'off', 47 + 'no-useless-assignment': 'off', 47 48 '@typescript-eslint/no-unused-vars': [ 48 49 'warn', 49 50 {
+3 -1
package.json
··· 12 12 "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 13 13 "lint": "prettier --check . && eslint .", 14 14 "format": "eslint --fix . && prettier --write .", 15 + "test": "vitest run", 15 16 "deploy": "pnpm run build && wrangler deploy", 16 17 "cf-typegen": "wrangler types ./src/worker-configuration.d.ts", 17 18 "env:generate-key": "npx tsx src/lib/atproto/scripts/generate-key.ts", ··· 47 48 "typescript": "^5.9.3", 48 49 "typescript-eslint": "^8.57.0", 49 50 "valibot": "^1.3.1", 50 - "vite": "^8.0.0" 51 + "vite": "^8.0.0", 52 + "vitest": "^4.1.4" 51 53 }, 52 54 "dependencies": { 53 55 "@atcute/atproto": "^3.1.10",
+273 -35
pnpm-lock.yaml
··· 209 209 devDependencies: 210 210 '@atcute/lex-cli': 211 211 specifier: ^2.6.1 212 - version: 2.6.1 212 + version: 2.8.0 213 213 '@atcute/lexicon-doc': 214 214 specifier: ^2.1.2 215 - version: 2.1.2 215 + version: 2.2.0 216 216 '@eslint/compat': 217 217 specifier: ^2.0.3 218 218 version: 2.0.3(eslint@10.0.3(jiti@2.6.1)) ··· 282 282 vite: 283 283 specifier: ^8.0.0 284 284 version: 8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1) 285 + vitest: 286 + specifier: ^4.1.4 287 + version: 4.1.4(@types/node@25.0.10)(vite@8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1)) 285 288 286 289 packages: 287 290 ··· 326 329 '@atcute/jetstream@1.1.2': 327 330 resolution: {integrity: sha512-u6p/h2xppp7LE6W/9xErAJ6frfN60s8adZuCKtfAaaBBiiYbb1CfpzN8Uc+2qtJZNorqGvuuDb5572Jmh7yHBQ==} 328 331 329 - '@atcute/lex-cli@2.6.1': 330 - resolution: {integrity: sha512-eSF2/ANOfegrDsbOi4iLbdCsubCBtRigoyShOix0wBCm1TVc7L7QsTgkZAy8Tet+spT1zPVVysPLlywUzMYSYw==} 332 + '@atcute/lex-cli@2.8.0': 333 + resolution: {integrity: sha512-eNPO0hhGhrCXQ7vEgVhqAaSHSsT3me1Jcc99rHaPgne1xP7fBfprf+E02M6BUqwrBz95YpnyuLPmVKNEk1jLwA==} 331 334 hasBin: true 332 335 333 - '@atcute/lexicon-doc@2.1.2': 334 - resolution: {integrity: sha512-jTLcOka7b8BIn2SnIZm2m7l6unlJ0gpgW1MnRpSqNbly/AvyRUR/GREduh/QmjT4SGasDm8vdhrM0kOSPFpDLQ==} 336 + '@atcute/lexicon-doc@2.2.0': 337 + resolution: {integrity: sha512-6l4lDlL6KPLDGknRh6HlfGbv98haUgQ0DFaAr1yA4vA95b8YYZUZ4/370ENpiq+d6Lv0tdDAMvOon2mynrp3pQ==} 335 338 336 339 '@atcute/lexicon-resolver@0.1.6': 337 340 resolution: {integrity: sha512-wJC/ChmpP7k+ywpOd07CMvioXjIGaFpF3bDwXLi/086LYjSWHOvtW6pyC+mqP5wLhjyH2hn4wmi77Buew1l1aw==} ··· 339 342 '@atcute/identity': ^1.1.0 340 343 '@atcute/identity-resolver': ^1.1.3 341 344 342 - '@atcute/lexicons@1.2.10': 343 - resolution: {integrity: sha512-0EfRDQQjOgb06VSFOUWXLnqKY11ljWB2bXS3cJVPYJp0jTWudgRp6OTW4vReNAeVZaY4kVr2ud/I/Zn9mjix3g==} 344 - 345 345 '@atcute/lexicons@1.2.9': 346 346 resolution: {integrity: sha512-/RRHm2Cw9o8Mcsrq0eo8fjS9okKYLGfuFwrQ0YoP/6sdSDsXshaTLJsvLlcUcaDaSJ1YFOuHIo3zr2Om2F/16g==} 347 347 348 + '@atcute/lexicons@1.3.0': 349 + resolution: {integrity: sha512-Eq5y+9onnCXNVUlNiMf31beSXHKqptB7lUo/68YbhlmxdaR7ooywHmahya9goP5AsmlYEA1z+dRPXIDAa9O7cg==} 350 + 348 351 '@atcute/mst@1.0.0': 349 352 resolution: {integrity: sha512-pMce2efib+dmKtnGnIvJZitVncJkpr3AmhyfgfYllni8KzsaDGsJmuGavSVpuojAhQe+6jYwHFtpm/beiiH4uw==} 350 353 ··· 953 956 '@napi-rs/wasm-runtime@1.1.1': 954 957 resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} 955 958 956 - '@noble/secp256k1@3.0.0': 957 - resolution: {integrity: sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==} 959 + '@noble/secp256k1@3.1.0': 960 + resolution: {integrity: sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g==} 958 961 959 962 '@number-flow/svelte@0.4.0': 960 963 resolution: {integrity: sha512-9tnowrlZlBV3IVe3Gm1V7yXSf4Ugag2k7iW45xqb04HXSa1ApEImopvGWAjJpHDvS849o+UCb0YH461Mtde9lA==} ··· 1506 1509 '@tybys/wasm-util@0.10.1': 1507 1510 resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} 1508 1511 1512 + '@types/chai@5.2.3': 1513 + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} 1514 + 1509 1515 '@types/cookie@0.6.0': 1510 1516 resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} 1511 1517 1518 + '@types/deep-eql@4.0.2': 1519 + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} 1520 + 1512 1521 '@types/esrecurse@4.3.1': 1513 1522 resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} 1514 1523 ··· 1622 1631 '@use-gesture/vanilla@10.3.1': 1623 1632 resolution: {integrity: sha512-lT4scGLu59ovA3zmtUonukAGcA0AdOOh+iwNDS05Bsu7Lq9aZToDHhI6D8Q2qvsVraovtsLLYwPrWdG/noMAKw==} 1624 1633 1634 + '@vitest/expect@4.1.4': 1635 + resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==} 1636 + 1637 + '@vitest/mocker@4.1.4': 1638 + resolution: {integrity: sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==} 1639 + peerDependencies: 1640 + msw: ^2.4.9 1641 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 1642 + peerDependenciesMeta: 1643 + msw: 1644 + optional: true 1645 + vite: 1646 + optional: true 1647 + 1648 + '@vitest/pretty-format@4.1.4': 1649 + resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==} 1650 + 1651 + '@vitest/runner@4.1.4': 1652 + resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==} 1653 + 1654 + '@vitest/snapshot@4.1.4': 1655 + resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==} 1656 + 1657 + '@vitest/spy@4.1.4': 1658 + resolution: {integrity: sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==} 1659 + 1660 + '@vitest/utils@4.1.4': 1661 + resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} 1662 + 1625 1663 '@webgpu/types@0.1.69': 1626 1664 resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} 1627 1665 ··· 1644 1682 aria-query@5.3.1: 1645 1683 resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} 1646 1684 engines: {node: '>= 0.4'} 1685 + 1686 + assertion-error@2.0.1: 1687 + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} 1688 + engines: {node: '>=12'} 1647 1689 1648 1690 axobject-query@4.1.0: 1649 1691 resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} ··· 1688 1730 1689 1731 canvas-confetti@1.9.4: 1690 1732 resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==} 1733 + 1734 + chai@6.2.2: 1735 + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} 1736 + engines: {node: '>=18'} 1691 1737 1692 1738 cheerio-select@2.1.0: 1693 1739 resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} ··· 1717 1763 confbox@0.2.4: 1718 1764 resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} 1719 1765 1766 + convert-source-map@2.0.0: 1767 + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} 1768 + 1720 1769 cookie@0.6.0: 1721 1770 resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} 1722 1771 engines: {node: '>= 0.6'} ··· 1846 1895 error-stack-parser-es@1.0.5: 1847 1896 resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} 1848 1897 1898 + es-module-lexer@2.0.0: 1899 + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} 1900 + 1849 1901 esbuild@0.27.3: 1850 1902 resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} 1851 1903 engines: {node: '>=18'} ··· 1929 1981 estraverse@5.3.0: 1930 1982 resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} 1931 1983 engines: {node: '>=4.0'} 1984 + 1985 + estree-walker@3.0.3: 1986 + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} 1932 1987 1933 1988 esutils@2.0.3: 1934 1989 resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} ··· 1937 1992 event-target-polyfill@0.0.4: 1938 1993 resolution: {integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==} 1939 1994 1995 + expect-type@1.3.0: 1996 + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} 1997 + engines: {node: '>=12.0.0'} 1998 + 1940 1999 exsolve@1.0.8: 1941 2000 resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} 1942 2001 ··· 2019 2078 hls.js@1.6.15: 2020 2079 resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==} 2021 2080 2022 - hono@4.12.12: 2023 - resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==} 2081 + hono@4.12.14: 2082 + resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} 2024 2083 engines: {node: '>=16.9.0'} 2025 2084 2026 2085 htmlparser2@10.1.0: ··· 2567 2626 engines: {node: '>=14'} 2568 2627 hasBin: true 2569 2628 2629 + prettier@3.8.3: 2630 + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} 2631 + engines: {node: '>=14'} 2632 + hasBin: true 2633 + 2570 2634 prop-types@15.8.1: 2571 2635 resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} 2572 2636 ··· 2772 2836 resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} 2773 2837 engines: {node: '>=8'} 2774 2838 2839 + siginfo@2.0.0: 2840 + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} 2841 + 2775 2842 simple-icons@16.11.0: 2776 2843 resolution: {integrity: sha512-6vqbcdaT6PsgUXud9rrP9w+nrmRzzStMEvyDavMeGwDgZSYM4uJ3tH7zurgTLHJO0RnMqU3Q09Vgo7WdTXV1eA==} 2777 2844 engines: {node: '>=0.12.18'} ··· 2784 2851 resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 2785 2852 engines: {node: '>=0.10.0'} 2786 2853 2854 + stackback@0.0.2: 2855 + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} 2856 + 2787 2857 std-env@3.10.0: 2788 2858 resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} 2789 2859 2860 + std-env@4.0.0: 2861 + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} 2862 + 2790 2863 string.prototype.codepointat@0.2.1: 2791 2864 resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} 2792 2865 ··· 2911 2984 tiny-inflate@1.0.3: 2912 2985 resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} 2913 2986 2987 + tinybench@2.9.0: 2988 + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} 2989 + 2990 + tinyexec@1.1.1: 2991 + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} 2992 + engines: {node: '>=18'} 2993 + 2914 2994 tinyglobby@0.2.15: 2915 2995 resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 2916 2996 engines: {node: '>=12.0.0'} 2917 2997 2918 2998 tinyqueue@3.0.0: 2919 2999 resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} 3000 + 3001 + tinyrainbow@3.1.0: 3002 + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} 3003 + engines: {node: '>=14.0.0'} 2920 3004 2921 3005 totalist@3.0.1: 2922 3006 resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} ··· 3070 3154 vite: 3071 3155 optional: true 3072 3156 3157 + vitest@4.1.4: 3158 + resolution: {integrity: sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==} 3159 + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} 3160 + hasBin: true 3161 + peerDependencies: 3162 + '@edge-runtime/vm': '*' 3163 + '@opentelemetry/api': ^1.9.0 3164 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 3165 + '@vitest/browser-playwright': 4.1.4 3166 + '@vitest/browser-preview': 4.1.4 3167 + '@vitest/browser-webdriverio': 4.1.4 3168 + '@vitest/coverage-istanbul': 4.1.4 3169 + '@vitest/coverage-v8': 4.1.4 3170 + '@vitest/ui': 4.1.4 3171 + happy-dom: '*' 3172 + jsdom: '*' 3173 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 3174 + peerDependenciesMeta: 3175 + '@edge-runtime/vm': 3176 + optional: true 3177 + '@opentelemetry/api': 3178 + optional: true 3179 + '@types/node': 3180 + optional: true 3181 + '@vitest/browser-playwright': 3182 + optional: true 3183 + '@vitest/browser-preview': 3184 + optional: true 3185 + '@vitest/browser-webdriverio': 3186 + optional: true 3187 + '@vitest/coverage-istanbul': 3188 + optional: true 3189 + '@vitest/coverage-v8': 3190 + optional: true 3191 + '@vitest/ui': 3192 + optional: true 3193 + happy-dom: 3194 + optional: true 3195 + jsdom: 3196 + optional: true 3197 + 3073 3198 w3c-keyname@2.2.8: 3074 3199 resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} 3075 3200 ··· 3107 3232 engines: {node: '>= 8'} 3108 3233 hasBin: true 3109 3234 3235 + why-is-node-running@2.3.0: 3236 + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} 3237 + engines: {node: '>=8'} 3238 + hasBin: true 3239 + 3110 3240 word-wrap@1.2.5: 3111 3241 resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} 3112 3242 engines: {node: '>=0.10.0'} ··· 3208 3338 dependencies: 3209 3339 '@atcute/multibase': 1.2.0 3210 3340 '@atcute/uint8array': 1.1.1 3211 - '@noble/secp256k1': 3.0.0 3341 + '@noble/secp256k1': 3.1.0 3212 3342 3213 3343 '@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.3)': 3214 3344 dependencies: ··· 3231 3361 3232 3362 '@atcute/identity@1.1.4': 3233 3363 dependencies: 3234 - '@atcute/lexicons': 1.2.10 3364 + '@atcute/lexicons': 1.3.0 3235 3365 '@badrap/valita': 0.4.6 3236 3366 3237 3367 '@atcute/jetstream@1.1.2(react@19.2.4)': 3238 3368 dependencies: 3239 - '@atcute/lexicons': 1.2.10 3369 + '@atcute/lexicons': 1.2.9 3240 3370 '@badrap/valita': 0.4.6 3241 3371 '@mary-ext/event-iterator': 1.0.0 3242 3372 '@mary-ext/simple-event-emitter': 1.0.1 ··· 3246 3376 transitivePeerDependencies: 3247 3377 - react 3248 3378 3249 - '@atcute/lex-cli@2.6.1': 3379 + '@atcute/lex-cli@2.8.0': 3250 3380 dependencies: 3251 3381 '@atcute/identity': 1.1.4 3252 3382 '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) 3253 - '@atcute/lexicon-doc': 2.1.2 3383 + '@atcute/lexicon-doc': 2.2.0 3254 3384 '@atcute/lexicon-resolver': 0.1.6(@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.3))(@atcute/identity@1.1.4) 3255 - '@atcute/lexicons': 1.2.10 3385 + '@atcute/lexicons': 1.3.0 3256 3386 '@badrap/valita': 0.4.6 3257 3387 '@optique/core': 0.10.7 3258 3388 '@optique/run': 0.10.7 3259 3389 picocolors: 1.1.1 3260 - prettier: 3.8.1 3390 + prettier: 3.8.3 3261 3391 3262 - '@atcute/lexicon-doc@2.1.2': 3392 + '@atcute/lexicon-doc@2.2.0': 3263 3393 dependencies: 3264 - '@atcute/identity': 1.1.3 3265 - '@atcute/lexicons': 1.2.9 3394 + '@atcute/identity': 1.1.4 3395 + '@atcute/lexicons': 1.3.0 3266 3396 '@atcute/uint8array': 1.1.1 3267 - '@atcute/util-text': 1.1.1 3397 + '@atcute/util-text': 1.2.0 3268 3398 '@badrap/valita': 0.4.6 3269 3399 3270 3400 '@atcute/lexicon-resolver@0.1.6(@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.3))(@atcute/identity@1.1.4)': ··· 3272 3402 '@atcute/crypto': 2.4.1 3273 3403 '@atcute/identity': 1.1.4 3274 3404 '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.3) 3275 - '@atcute/lexicon-doc': 2.1.2 3276 - '@atcute/lexicons': 1.2.10 3405 + '@atcute/lexicon-doc': 2.2.0 3406 + '@atcute/lexicons': 1.3.0 3277 3407 '@atcute/repo': 0.1.4 3278 3408 '@atcute/util-fetch': 1.0.5 3279 3409 '@badrap/valita': 0.4.6 3280 3410 3281 - '@atcute/lexicons@1.2.10': 3411 + '@atcute/lexicons@1.2.9': 3282 3412 dependencies: 3283 3413 '@atcute/uint8array': 1.1.1 3284 - '@atcute/util-text': 1.2.0 3414 + '@atcute/util-text': 1.1.1 3285 3415 '@standard-schema/spec': 1.1.0 3286 3416 esm-env: 1.2.2 3287 3417 3288 - '@atcute/lexicons@1.2.9': 3418 + '@atcute/lexicons@1.3.0': 3289 3419 dependencies: 3290 3420 '@atcute/uint8array': 1.1.1 3291 - '@atcute/util-text': 1.1.1 3421 + '@atcute/util-text': 1.2.0 3292 3422 '@standard-schema/spec': 1.1.0 3293 3423 esm-env: 1.2.2 3294 3424 ··· 3355 3485 '@atcute/cbor': 2.3.2 3356 3486 '@atcute/cid': 2.4.1 3357 3487 '@atcute/crypto': 2.4.1 3358 - '@atcute/lexicons': 1.2.10 3488 + '@atcute/lexicons': 1.3.0 3359 3489 '@atcute/mst': 1.0.0 3360 3490 '@atcute/uint8array': 1.1.1 3361 3491 ··· 3392 3522 '@atcute/client': 4.2.1 3393 3523 '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.3) 3394 3524 '@atcute/jetstream': 1.1.2(react@19.2.4) 3395 - '@atcute/lexicons': 1.2.10 3396 - hono: 4.12.12 3525 + '@atcute/lexicons': 1.2.9 3526 + hono: 4.12.14 3397 3527 transitivePeerDependencies: 3398 3528 - '@atcute/identity' 3399 3529 - react ··· 3930 4060 '@tybys/wasm-util': 0.10.1 3931 4061 optional: true 3932 4062 3933 - '@noble/secp256k1@3.0.0': {} 4063 + '@noble/secp256k1@3.1.0': {} 3934 4064 3935 4065 '@number-flow/svelte@0.4.0(svelte@5.53.11)': 3936 4066 dependencies: ··· 4424 4554 dependencies: 4425 4555 tslib: 2.8.1 4426 4556 optional: true 4557 + 4558 + '@types/chai@5.2.3': 4559 + dependencies: 4560 + '@types/deep-eql': 4.0.2 4561 + assertion-error: 2.0.1 4427 4562 4428 4563 '@types/cookie@0.6.0': {} 4429 4564 4565 + '@types/deep-eql@4.0.2': {} 4566 + 4430 4567 '@types/esrecurse@4.3.1': {} 4431 4568 4432 4569 '@types/estree@1.0.8': {} ··· 4574 4711 dependencies: 4575 4712 '@use-gesture/core': 10.3.1 4576 4713 4714 + '@vitest/expect@4.1.4': 4715 + dependencies: 4716 + '@standard-schema/spec': 1.1.0 4717 + '@types/chai': 5.2.3 4718 + '@vitest/spy': 4.1.4 4719 + '@vitest/utils': 4.1.4 4720 + chai: 6.2.2 4721 + tinyrainbow: 3.1.0 4722 + 4723 + '@vitest/mocker@4.1.4(vite@8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1))': 4724 + dependencies: 4725 + '@vitest/spy': 4.1.4 4726 + estree-walker: 3.0.3 4727 + magic-string: 0.30.21 4728 + optionalDependencies: 4729 + vite: 8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1) 4730 + 4731 + '@vitest/pretty-format@4.1.4': 4732 + dependencies: 4733 + tinyrainbow: 3.1.0 4734 + 4735 + '@vitest/runner@4.1.4': 4736 + dependencies: 4737 + '@vitest/utils': 4.1.4 4738 + pathe: 2.0.3 4739 + 4740 + '@vitest/snapshot@4.1.4': 4741 + dependencies: 4742 + '@vitest/pretty-format': 4.1.4 4743 + '@vitest/utils': 4.1.4 4744 + magic-string: 0.30.21 4745 + pathe: 2.0.3 4746 + 4747 + '@vitest/spy@4.1.4': {} 4748 + 4749 + '@vitest/utils@4.1.4': 4750 + dependencies: 4751 + '@vitest/pretty-format': 4.1.4 4752 + convert-source-map: 2.0.0 4753 + tinyrainbow: 3.1.0 4754 + 4577 4755 '@webgpu/types@0.1.69': {} 4578 4756 4579 4757 acorn-jsx@5.3.2(acorn@8.16.0): ··· 4593 4771 4594 4772 aria-query@5.3.1: {} 4595 4773 4774 + assertion-error@2.0.1: {} 4775 + 4596 4776 axobject-query@4.1.0: {} 4597 4777 4598 4778 balanced-match@4.0.4: {} ··· 4631 4811 three: 0.183.2 4632 4812 4633 4813 canvas-confetti@1.9.4: {} 4814 + 4815 + chai@6.2.2: {} 4634 4816 4635 4817 cheerio-select@2.1.0: 4636 4818 dependencies: ··· 4678 4860 4679 4861 confbox@0.2.4: {} 4680 4862 4863 + convert-source-map@2.0.0: {} 4864 + 4681 4865 cookie@0.6.0: {} 4682 4866 4683 4867 cookie@1.1.1: {} ··· 4785 4969 entities@7.0.1: {} 4786 4970 4787 4971 error-stack-parser-es@1.0.5: {} 4972 + 4973 + es-module-lexer@2.0.0: {} 4788 4974 4789 4975 esbuild@0.27.3: 4790 4976 optionalDependencies: ··· 4925 5111 4926 5112 estraverse@5.3.0: {} 4927 5113 5114 + estree-walker@3.0.3: 5115 + dependencies: 5116 + '@types/estree': 1.0.8 5117 + 4928 5118 esutils@2.0.3: {} 4929 5119 4930 5120 event-target-polyfill@0.0.4: {} 5121 + 5122 + expect-type@1.3.0: {} 4931 5123 4932 5124 exsolve@1.0.8: {} 4933 5125 ··· 4986 5178 4987 5179 hls.js@1.6.15: {} 4988 5180 4989 - hono@4.12.12: {} 5181 + hono@4.12.14: {} 4990 5182 4991 5183 htmlparser2@10.1.0: 4992 5184 dependencies: ··· 5434 5626 5435 5627 prettier@3.8.1: {} 5436 5628 5629 + prettier@3.8.3: {} 5630 + 5437 5631 prop-types@15.8.1: 5438 5632 dependencies: 5439 5633 loose-envify: 1.4.0 ··· 5720 5914 5721 5915 shebang-regex@3.0.0: {} 5722 5916 5917 + siginfo@2.0.0: {} 5918 + 5723 5919 simple-icons@16.11.0: {} 5724 5920 5725 5921 sirv@3.0.2: ··· 5730 5926 5731 5927 source-map-js@1.2.1: {} 5732 5928 5929 + stackback@0.0.2: {} 5930 + 5733 5931 std-env@3.10.0: {} 5932 + 5933 + std-env@4.0.0: {} 5734 5934 5735 5935 string.prototype.codepointat@0.2.1: {} 5736 5936 ··· 5865 6065 5866 6066 tiny-inflate@1.0.3: {} 5867 6067 6068 + tinybench@2.9.0: {} 6069 + 6070 + tinyexec@1.1.1: {} 6071 + 5868 6072 tinyglobby@0.2.15: 5869 6073 dependencies: 5870 6074 fdir: 6.5.0(picomatch@4.0.3) 5871 6075 picomatch: 4.0.3 5872 6076 5873 6077 tinyqueue@3.0.0: {} 6078 + 6079 + tinyrainbow@3.1.0: {} 5874 6080 5875 6081 totalist@3.0.1: {} 5876 6082 ··· 5982 6188 optionalDependencies: 5983 6189 vite: 8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1) 5984 6190 6191 + vitest@4.1.4(@types/node@25.0.10)(vite@8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1)): 6192 + dependencies: 6193 + '@vitest/expect': 4.1.4 6194 + '@vitest/mocker': 4.1.4(vite@8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1)) 6195 + '@vitest/pretty-format': 4.1.4 6196 + '@vitest/runner': 4.1.4 6197 + '@vitest/snapshot': 4.1.4 6198 + '@vitest/spy': 4.1.4 6199 + '@vitest/utils': 4.1.4 6200 + es-module-lexer: 2.0.0 6201 + expect-type: 1.3.0 6202 + magic-string: 0.30.21 6203 + obug: 2.1.1 6204 + pathe: 2.0.3 6205 + picomatch: 4.0.3 6206 + std-env: 4.0.0 6207 + tinybench: 2.9.0 6208 + tinyexec: 1.1.1 6209 + tinyglobby: 0.2.15 6210 + tinyrainbow: 3.1.0 6211 + vite: 8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1) 6212 + why-is-node-running: 2.3.0 6213 + optionalDependencies: 6214 + '@types/node': 25.0.10 6215 + transitivePeerDependencies: 6216 + - msw 6217 + 5985 6218 w3c-keyname@2.2.8: {} 5986 6219 5987 6220 web-haptics@0.0.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(svelte@5.53.11): ··· 6001 6234 which@2.0.2: 6002 6235 dependencies: 6003 6236 isexe: 2.0.0 6237 + 6238 + why-is-node-running@2.3.0: 6239 + dependencies: 6240 + siginfo: 2.0.0 6241 + stackback: 0.0.2 6004 6242 6005 6243 word-wrap@1.2.5: {} 6006 6244
+133
scripts/atproto.ts
··· 1 + /** 2 + * ATProto helper script for debugging. 3 + * 4 + * Usage: 5 + * npx tsx scripts/atproto.ts listRecords <handle> <collection> 6 + * npx tsx scripts/atproto.ts getRecord <handle> <collection> <rkey> 7 + * 8 + * Examples: 9 + * npx tsx scripts/atproto.ts listRecords japan.selfhosted.social app.blento.card 10 + * npx tsx scripts/atproto.ts getRecord japan.selfhosted.social app.blento.card self 11 + */ 12 + 13 + async function resolveHandle(handle: string): Promise<string> { 14 + // Try DNS-based resolution via DoH 15 + const dnsRes = await fetch( 16 + `https://mozilla.cloudflare-dns.com/dns-query?name=_atproto.${handle}&type=TXT`, 17 + { headers: { Accept: 'application/dns-json' } } 18 + ); 19 + const dns = await dnsRes.json(); 20 + for (const answer of dns.Answer ?? []) { 21 + const match = answer.data?.replace(/"/g, '').match(/^did=(.+)$/); 22 + if (match) return match[1]; 23 + } 24 + 25 + // Fallback: HTTP well-known 26 + const httpRes = await fetch(`https://${handle}/.well-known/atproto-did`); 27 + if (httpRes.ok) { 28 + const did = (await httpRes.text()).trim(); 29 + if (did.startsWith('did:')) return did; 30 + } 31 + 32 + throw new Error(`Could not resolve handle: ${handle}`); 33 + } 34 + 35 + async function resolvePDS(did: string): Promise<string> { 36 + let docUrl: string; 37 + if (did.startsWith('did:plc:')) { 38 + docUrl = `https://plc.directory/${did}`; 39 + } else if (did.startsWith('did:web:')) { 40 + const host = did.replace('did:web:', ''); 41 + docUrl = `https://${host}/.well-known/did.json`; 42 + } else { 43 + throw new Error(`Unsupported DID method: ${did}`); 44 + } 45 + 46 + const res = await fetch(docUrl); 47 + if (!res.ok) throw new Error(`Failed to fetch DID document: ${res.status}`); 48 + const doc = await res.json(); 49 + 50 + for (const service of doc.service ?? []) { 51 + if (service.id === '#atproto_pds') { 52 + return service.serviceEndpoint; 53 + } 54 + } 55 + throw new Error('No #atproto_pds service found in DID document'); 56 + } 57 + 58 + async function listRecords(handle: string, collection: string) { 59 + const did = await resolveHandle(handle); 60 + const pds = await resolvePDS(did); 61 + console.error(`Resolved: ${handle} → ${did} @ ${pds}`); 62 + 63 + const allRecords: any[] = []; 64 + let cursor: string | undefined; 65 + 66 + do { 67 + const params = new URLSearchParams({ 68 + repo: did, 69 + collection, 70 + limit: '100' 71 + }); 72 + if (cursor) params.set('cursor', cursor); 73 + 74 + const res = await fetch(`${pds}/xrpc/com.atproto.repo.listRecords?${params}`); 75 + if (!res.ok) { 76 + const body = await res.text(); 77 + throw new Error(`listRecords failed: ${res.status} ${body}`); 78 + } 79 + const data = await res.json(); 80 + allRecords.push(...data.records); 81 + cursor = data.cursor; 82 + } while (cursor); 83 + 84 + console.log(JSON.stringify(allRecords, null, 2)); 85 + } 86 + 87 + async function getRecord(handle: string, collection: string, rkey: string) { 88 + const did = await resolveHandle(handle); 89 + const pds = await resolvePDS(did); 90 + console.error(`Resolved: ${handle} → ${did} @ ${pds}`); 91 + 92 + const params = new URLSearchParams({ repo: did, collection, rkey }); 93 + const res = await fetch(`${pds}/xrpc/com.atproto.repo.getRecord?${params}`); 94 + if (!res.ok) { 95 + const body = await res.text(); 96 + throw new Error(`getRecord failed: ${res.status} ${body}`); 97 + } 98 + const data = await res.json(); 99 + console.log(JSON.stringify(data, null, 2)); 100 + } 101 + 102 + // CLI 103 + const [, , command, ...args] = process.argv; 104 + 105 + switch (command) { 106 + case 'listRecords': 107 + if (args.length < 2) { 108 + console.error('Usage: listRecords <handle> <collection>'); 109 + process.exit(1); 110 + } 111 + listRecords(args[0], args[1]).catch((e) => { 112 + console.error(e.message); 113 + process.exit(1); 114 + }); 115 + break; 116 + 117 + case 'getRecord': 118 + if (args.length < 3) { 119 + console.error('Usage: getRecord <handle> <collection> <rkey>'); 120 + process.exit(1); 121 + } 122 + getRecord(args[0], args[1], args[2]).catch((e) => { 123 + console.error(e.message); 124 + process.exit(1); 125 + }); 126 + break; 127 + 128 + default: 129 + console.error('Commands: listRecords, getRecord'); 130 + console.error(' listRecords <handle> <collection>'); 131 + console.error(' getRecord <handle> <collection> <rkey>'); 132 + process.exit(1); 133 + }
+138
scripts/simulate-load.ts
··· 1 + /** 2 + * Simulate what checkData() does to a user's layout on load. 3 + * Uses react-grid-layout directly to avoid Svelte import chain. 4 + * 5 + * Usage: npx tsx scripts/atproto.ts listRecords <handle> app.blento.card 2>/dev/null | npx tsx scripts/simulate-load.ts 6 + */ 7 + import { correctBounds, verticalCompactor, type LayoutItem } from 'react-grid-layout/core'; 8 + import * as fs from 'fs'; 9 + 10 + const COLUMNS = 8; 11 + 12 + type Item = { 13 + id: string; 14 + x: number; 15 + y: number; 16 + w: number; 17 + h: number; 18 + mobileX: number; 19 + mobileY: number; 20 + mobileW: number; 21 + mobileH: number; 22 + cardType: string; 23 + }; 24 + 25 + function toLayout(items: Item[], mobile: boolean): LayoutItem[] { 26 + return items.map((item) => 27 + mobile 28 + ? { x: item.mobileX, y: item.mobileY, w: item.mobileW, h: item.mobileH, i: item.id } 29 + : { x: item.x, y: item.y, w: item.w, h: item.h, i: item.id } 30 + ); 31 + } 32 + 33 + function applyLayout(items: Item[], layout: LayoutItem[], mobile: boolean) { 34 + const map = new Map(items.map((i) => [i.id, i])); 35 + for (const l of layout) { 36 + const item = map.get(l.i); 37 + if (!item) continue; 38 + if (mobile) { 39 + item.mobileX = l.x; 40 + item.mobileY = l.y; 41 + } else { 42 + item.x = l.x; 43 + item.y = l.y; 44 + } 45 + } 46 + } 47 + 48 + function fixAllCollisions(items: Item[], mobile: boolean) { 49 + let layout = toLayout(items, mobile); 50 + correctBounds(layout as any, { cols: COLUMNS }); 51 + layout = verticalCompactor.compact(layout, COLUMNS) as LayoutItem[]; 52 + applyLayout(items, layout, mobile); 53 + } 54 + 55 + function compactItems(items: Item[], mobile: boolean) { 56 + const layout = toLayout(items, mobile); 57 + const compacted = verticalCompactor.compact(layout, COLUMNS) as LayoutItem[]; 58 + applyLayout(items, compacted, mobile); 59 + } 60 + 61 + // --- 62 + 63 + const input = fs.readFileSync('/dev/stdin', 'utf8'); 64 + const records = JSON.parse(input); 65 + 66 + const cards: Item[] = records 67 + .filter((r: any) => r.value.cardType && (!r.value.page || r.value.page === 'blento.self')) 68 + .map((r: any) => ({ 69 + id: r.value.id, 70 + x: r.value.x, 71 + y: r.value.y, 72 + w: r.value.w, 73 + h: r.value.h, 74 + mobileX: r.value.mobileX, 75 + mobileY: r.value.mobileY, 76 + mobileW: r.value.mobileW, 77 + mobileH: r.value.mobileH, 78 + cardType: r.value.cardType 79 + })); 80 + 81 + // Save original positions 82 + const originals = cards.map((c) => ({ ...c })); 83 + 84 + // Simulate checkData — fixAllCollisions only (no separate compactItems) 85 + fixAllCollisions(cards, false); 86 + fixAllCollisions(cards, true); 87 + 88 + // Compare 89 + let desktopChanges = 0; 90 + let mobileChanges = 0; 91 + 92 + for (const card of cards) { 93 + const orig = originals.find((o) => o.id === card.id)!; 94 + const dChanged = card.x !== orig.x || card.y !== orig.y; 95 + const mChanged = card.mobileX !== orig.mobileX || card.mobileY !== orig.mobileY; 96 + 97 + if (dChanged || mChanged) { 98 + console.log( 99 + `${orig.cardType.padEnd(20)} id=${orig.id}` + 100 + (dChanged ? ` DESKTOP: (${orig.x},${orig.y}) → (${card.x},${card.y})` : '') + 101 + (mChanged 102 + ? ` MOBILE: (${orig.mobileX},${orig.mobileY}) → (${card.mobileX},${card.mobileY})` 103 + : '') 104 + ); 105 + if (dChanged) desktopChanges++; 106 + if (mChanged) mobileChanges++; 107 + } 108 + } 109 + 110 + if (desktopChanges === 0 && mobileChanges === 0) { 111 + console.log('No layout changes on load — checkData is not the culprit.'); 112 + } else { 113 + console.log(`\n${desktopChanges} desktop changes, ${mobileChanges} mobile changes on load.`); 114 + } 115 + 116 + // Check for ORDER changes in mobile layout 117 + console.log('\n=== Mobile reading order (y, then x) ==='); 118 + const sortByMobile = ( 119 + items: { id: string; mobileX: number; mobileY: number; cardType: string }[] 120 + ) => [...items].sort((a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX); 121 + 122 + const origOrder = sortByMobile(originals); 123 + const newOrder = sortByMobile(cards); 124 + 125 + let orderChanges = 0; 126 + for (let i = 0; i < origOrder.length; i++) { 127 + const same = origOrder[i].id === newOrder[i].id; 128 + if (!same) orderChanges++; 129 + const orig = originals.find((o) => o.id === newOrder[i].id)!; 130 + const card = cards.find((c) => c.id === newOrder[i].id)!; 131 + console.log( 132 + `${i.toString().padStart(2)}: ${same ? ' ' : '!!'} ` + 133 + `${card.cardType.padEnd(20)} ` + 134 + `was (${orig.mobileX},${orig.mobileY}) → now (${card.mobileX},${card.mobileY})` + 135 + (!same ? ` [was #${origOrder.findIndex((o) => o.id === newOrder[i].id)}]` : '') 136 + ); 137 + } 138 + console.log(`\n${orderChanges} order changes in mobile layout.`);
+3 -2
src/lib/cards/_base/BaseCard/BaseCard.svelte
··· 45 45 draggable={false} 46 46 class={[ 47 47 fillPage 48 - ? 'card group/card selection:bg-accent-600/50 focus-within:outline-accent-500 @container/card relative isolate z-0 h-full w-full outline-offset-2 transition-[outline] duration-200 focus-within:outline-2' 49 - : 'card group/card selection:bg-accent-600/50 focus-within:outline-accent-500 @container/card absolute isolate z-0 rounded-3xl outline-offset-2 transition-[outline] duration-200 focus-within:outline-2', 48 + ? 'card group/card selection:bg-accent-600/50 @container/card relative isolate z-0 h-full w-full outline-offset-2 transition-[outline] duration-200' 49 + : 'card group/card selection:bg-accent-600/50 @container/card absolute isolate z-0 rounded-3xl outline-offset-2 transition-[outline] duration-200', 50 + isEditing ? 'transition-all' : '', 50 51 !fillPage ? (color ? (colors[color] ?? colors.accent) : colors.base) : '', 51 52 color !== 'accent' && item.color !== 'base' && item.color !== 'transparent' ? color : '', 52 53 showOutline ? 'outline-2' : '',
+24 -12
src/lib/cards/_base/BaseCard/BaseEditingCard.svelte
··· 63 63 let selectedCardId = getSelectedCardId(); 64 64 let selectCard = getSelectCard(); 65 65 let isSelected = $derived(selectedCardId?.() === item.id); 66 - let isDimmed = $derived(isCoarse?.() && selectedCardId?.() != null && !isSelected); 66 + 67 + // Track pointer down position so we only select on click, not on drag 68 + let overlayDownX = 0; 69 + let overlayDownY = 0; 70 + function handleOverlayPointerDown(e: PointerEvent) { 71 + overlayDownX = e.clientX; 72 + overlayDownY = e.clientY; 73 + } 74 + function handleOverlayPointerUp(e: PointerEvent) { 75 + const dx = Math.abs(e.clientX - overlayDownX); 76 + const dy = Math.abs(e.clientY - overlayDownY); 77 + if (dx < 5 && dy < 5) { 78 + selectCard?.(item.id); 79 + } 80 + } 67 81 68 82 let colorPopoverOpen = $state(false); 69 83 ··· 184 198 {item} 185 199 isEditing={true} 186 200 bind:ref 187 - showOutline={isResizing || (isCoarse?.() && isSelected)} 201 + showOutline={isResizing || isSelected} 188 202 locked={item.cardData?.locked} 189 203 class={[ 190 204 'scale-100 starting:scale-0 starting:opacity-0', 191 - isCoarse?.() && isSelected ? 'ring-accent-500 z-10 ring-2 ring-offset-2' : '', 192 - isDimmed ? 'opacity-70' : 'opacity-100' 205 + isSelected ? 'outline-accent-500 z-10' : '' 193 206 ]} 194 207 {...rest} 195 208 > 196 - {#if isCoarse?.() && !isSelected} 197 - <!-- svelte-ignore a11y_click_events_have_key_events --> 209 + {#if !isSelected} 210 + <!-- Overlay captures pointer events so dragging cards doesn't accidentally 211 + select text / trigger inner content. Click (no drag) selects the card. --> 198 212 <div 199 213 role="button" 200 214 tabindex="-1" 201 - class="absolute inset-0 z-20 cursor-pointer" 202 - onclick={(e) => { 203 - e.stopPropagation(); 204 - selectCard?.(item.id); 205 - }} 215 + class="absolute inset-0 z-20 cursor-pointer focus:outline-none" 216 + onpointerdown={handleOverlayPointerDown} 217 + onpointerup={handleOverlayPointerUp} 206 218 ></div> 207 219 {/if} 208 220 {@render children?.()} ··· 299 311 <div 300 312 class={[ 301 313 'translate absolute -bottom-13 w-full items-center justify-center pt-2.5 text-xs lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex', 302 - colorPopoverOpen || settingsPopoverOpen ? 'inline-flex' : 'hidden' 314 + colorPopoverOpen || settingsPopoverOpen || isSelected ? 'inline-flex' : 'hidden' 303 315 ]} 304 316 > 305 317 <div
+22 -30
src/lib/helper.ts
··· 187 187 export async function savePage( 188 188 data: WebsiteData, 189 189 currentItems: Item[], 190 + originalCards: Item[], 190 191 originalPublication: string 191 192 ) { 192 193 const promises = []; 193 194 194 - // Build a lookup of original cards by ID for O(1) access 195 - const originalCardsById = new Map<string, Item>(); 196 - for (const card of data.cards) { 197 - originalCardsById.set(card.id, card); 198 - } 199 - 200 - // find all cards that have been updated (where items differ from originalItems) 195 + // Save all current cards. We don't diff against originals because the 196 + // server-side load can modify cards (e.g. fixing overlaps), so the 197 + // "original" the client sees is already the post-fix version — there's 198 + // nothing reliable to diff against. 201 199 for (let item of currentItems) { 202 - const orig = originalCardsById.get(item.id); 203 - const originalItem = orig && cardsEqual(orig, item) ? orig : undefined; 200 + item.updatedAt = new Date().toISOString(); 201 + // run optional upload function for this card type 202 + const cardDef = CardDefinitionsByType[item.cardType]; 204 203 205 - if (!originalItem) { 206 - console.log('updated or new item', item); 207 - item.updatedAt = new Date().toISOString(); 208 - // run optional upload function for this card type 209 - const cardDef = CardDefinitionsByType[item.cardType]; 210 - 211 - if (cardDef?.upload) { 212 - item = await cardDef?.upload(item); 213 - } 204 + if (cardDef?.upload) { 205 + item = await cardDef?.upload(item); 206 + } 214 207 215 - const parsedItem = JSON.parse(JSON.stringify(item)); 208 + const parsedItem = JSON.parse(JSON.stringify(item)); 216 209 217 - parsedItem.page = data.page; 218 - parsedItem.version = 2; 210 + parsedItem.page = data.page; 211 + parsedItem.version = 2; 219 212 220 - promises.push( 221 - putRecord({ 222 - collection: 'app.blento.card', 223 - rkey: parsedItem.id, 224 - record: parsedItem 225 - }) 226 - ); 227 - } 213 + promises.push( 214 + putRecord({ 215 + collection: 'app.blento.card', 216 + rkey: parsedItem.id, 217 + record: parsedItem 218 + }) 219 + ); 228 220 } 229 221 230 222 // delete items that are in originalItems but not in items 231 - for (const originalItem of data.cards) { 223 + for (const originalItem of originalCards) { 232 224 const item = currentItems.find((i) => i.id === originalItem.id); 233 225 if (!item) { 234 226 console.log('deleting item', originalItem);
+13 -4
src/lib/layout/EditableGrid.svelte
··· 32 32 ref = container; 33 33 }); 34 34 35 - const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 36 - const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 37 - let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 35 + let maxHeight = $derived( 36 + items.reduce((max, item) => { 37 + const y = isMobile ? (item.mobileY ?? item.y) : item.y; 38 + const h = isMobile ? (item.mobileH ?? item.h) : item.h; 39 + return Math.max(max, y + h); 40 + }, 0) 41 + ); 38 42 39 43 // --- Drag state --- 40 44 type Phase = 'idle' | 'pending' | 'active'; ··· 387 391 > 388 392 {@render children()} 389 393 390 - <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div> 394 + <!-- 395 + padding-top % is based on the parent's inline size (width), so this grows 396 + proportionally with the grid width. Using cqw here caused stale resolution 397 + when the grid container resized (e.g. toggling mobile view). 398 + --> 399 + <div style="padding-top: {((maxHeight + 2) / 8) * 100}%;"></div> 391 400 </div> 392 401 393 402 <style>
+20
src/lib/layout/algorithms.ts
··· 62 62 return collides(toLayoutItem(a, mobile), toLayoutItem(b, mobile)); 63 63 } 64 64 65 + /** Returns true if any two items overlap in the given layout. */ 66 + export function hasOverlaps(items: Item[], mobile: boolean): boolean { 67 + for (let i = 0; i < items.length; i++) { 68 + for (let j = i + 1; j < items.length; j++) { 69 + if (overlaps(items[i], items[j], mobile)) return true; 70 + } 71 + } 72 + return false; 73 + } 74 + 65 75 export function fixCollisions( 66 76 items: Item[], 67 77 item: Item, ··· 102 112 let layout = toLayout(items, mobile); 103 113 correctBounds(layout as any, { cols: COLUMNS }); 104 114 layout = verticalCompactor.compact(layout, COLUMNS) as LayoutItem[]; 115 + applyLayout(items, layout, mobile); 116 + } 117 + 118 + /** 119 + * Only fix items that are out of grid bounds, without compacting or resolving overlaps. 120 + * This is safe to call on load — it won't shift already-valid layouts. 121 + */ 122 + export function sanitizeBounds(items: Item[], mobile: boolean) { 123 + const layout = toLayout(items, mobile); 124 + correctBounds(layout as any, { cols: COLUMNS }); 105 125 applyLayout(items, layout, mobile); 106 126 } 107 127
+4 -1
src/lib/layout/index.ts
··· 4 4 fixAllCollisions, 5 5 compactItems, 6 6 setPositionOfNewItem, 7 - findValidPosition 7 + findValidPosition, 8 + sanitizeBounds, 9 + hasOverlaps 8 10 } from './algorithms'; 9 11 10 12 export { shouldMirror, mirrorItemSize, mirrorLayout } from './mirror'; 13 + export type { LayoutMode } from './mirror'; 11 14 12 15 export { getGridPosition, getViewportCenterGridY, pixelToGrid } from './grid'; 13 16 export type { GridPosition, DragState } from './grid';
+184
src/lib/layout/mirror.test.ts
··· 1 + import { describe, it, expect, vi } from 'vitest'; 2 + import type { Item } from '$lib/types'; 3 + 4 + // Mock CardDefinitionsByType — tests don't need real card definitions 5 + vi.mock('$lib/cards', () => ({ 6 + CardDefinitionsByType: {} 7 + })); 8 + 9 + import { mirrorItemSize, mirrorLayout, shouldMirror } from './mirror'; 10 + 11 + function makeItem(overrides: Partial<Item> & { id: string }): Item { 12 + return { 13 + w: 2, 14 + h: 2, 15 + x: 0, 16 + y: 0, 17 + mobileW: 4, 18 + mobileH: 4, 19 + mobileX: 0, 20 + mobileY: 0, 21 + cardType: 'text', 22 + cardData: {}, 23 + ...overrides 24 + }; 25 + } 26 + 27 + describe('shouldMirror', () => { 28 + it('mirrors when editedOn is 0 (never edited) and no layoutMode', () => { 29 + expect(shouldMirror(0, undefined, false)).toBe(true); 30 + expect(shouldMirror(0, undefined, true)).toBe(true); 31 + }); 32 + 33 + it('mirrors when only one layout edited and no layoutMode', () => { 34 + expect(shouldMirror(1, undefined, false)).toBe(true); 35 + expect(shouldMirror(2, undefined, true)).toBe(true); 36 + }); 37 + 38 + it('does not mirror when both layouts edited and no layoutMode', () => { 39 + expect(shouldMirror(3, undefined, false)).toBe(false); 40 + expect(shouldMirror(3, undefined, true)).toBe(false); 41 + }); 42 + 43 + it('desktop-leads: mirrors only when editing desktop', () => { 44 + expect(shouldMirror(3, 'desktop-leads', false)).toBe(true); 45 + expect(shouldMirror(3, 'desktop-leads', true)).toBe(false); 46 + }); 47 + 48 + it('mobile-leads: mirrors only when editing mobile', () => { 49 + expect(shouldMirror(3, 'mobile-leads', true)).toBe(true); 50 + expect(shouldMirror(3, 'mobile-leads', false)).toBe(false); 51 + }); 52 + 53 + it('independent: never mirrors', () => { 54 + expect(shouldMirror(0, 'independent', false)).toBe(false); 55 + expect(shouldMirror(0, 'independent', true)).toBe(false); 56 + }); 57 + }); 58 + 59 + describe('mirrorItemSize', () => { 60 + it('desktop → mobile: doubles dimensions', () => { 61 + const item = makeItem({ id: 'a', w: 3, h: 2 }); 62 + mirrorItemSize(item, false); 63 + expect(item.mobileW).toBe(6); 64 + expect(item.mobileH).toBe(4); 65 + }); 66 + 67 + it('desktop → mobile: caps width at COLUMNS (8)', () => { 68 + const item = makeItem({ id: 'a', w: 6, h: 2 }); 69 + mirrorItemSize(item, false); 70 + expect(item.mobileW).toBe(8); 71 + expect(item.mobileH).toBe(4); 72 + }); 73 + 74 + it('mobile → desktop: halves dimensions with snap-even', () => { 75 + const item = makeItem({ id: 'a', mobileW: 6, mobileH: 4 }); 76 + mirrorItemSize(item, true); 77 + // snapEven(6/2) = snapEven(3) = max(2, round(1.5)*2) = max(2, 4) = 4 78 + expect(item.w).toBe(4); 79 + expect(item.h).toBe(2); 80 + }); 81 + 82 + it('mobile → desktop: exact halves', () => { 83 + const item = makeItem({ id: 'a', mobileW: 4, mobileH: 4 }); 84 + mirrorItemSize(item, true); 85 + // snapEven(4/2) = snapEven(2) = max(2, round(1)*2) = 2 86 + expect(item.w).toBe(2); 87 + expect(item.h).toBe(2); 88 + }); 89 + 90 + it('mobile → desktop: minimum width is 2', () => { 91 + const item = makeItem({ id: 'a', mobileW: 2, mobileH: 2 }); 92 + mirrorItemSize(item, true); 93 + expect(item.w).toBe(2); // snapEven(1) = max(2, round(0.5)*2) = max(2, 1*2) = 2 94 + expect(item.h).toBe(1); // round(2/2) = 1 95 + }); 96 + }); 97 + 98 + describe('mirrorLayout desktop → mobile', () => { 99 + it('two items that fit side-by-side on mobile stay side-by-side', () => { 100 + // Two 2-wide items next to each other on desktop → 4-wide on mobile, should fit in 8 cols 101 + const a = makeItem({ id: 'a', x: 0, y: 0, w: 2, h: 2 }); 102 + const b = makeItem({ id: 'b', x: 2, y: 0, w: 2, h: 2 }); 103 + const items = [a, b]; 104 + 105 + mirrorLayout(items, false); 106 + 107 + // Both should be on the same row 108 + expect(a.mobileY).toBe(b.mobileY); 109 + // They should not overlap 110 + expect(a.mobileX + a.mobileW).toBeLessThanOrEqual(b.mobileX); 111 + }); 112 + 113 + it('preserves reading order', () => { 114 + const a = makeItem({ id: 'a', x: 0, y: 0, w: 4, h: 2 }); 115 + const b = makeItem({ id: 'b', x: 4, y: 0, w: 4, h: 2 }); 116 + const c = makeItem({ id: 'c', x: 0, y: 2, w: 4, h: 2 }); 117 + const items = [a, b, c]; 118 + 119 + mirrorLayout(items, false); 120 + 121 + // a and b are on the same desktop row but become full-width on mobile (8 cols each) 122 + // So they must stack. a should come before b, and b before c. 123 + expect(a.mobileY).toBeLessThan(b.mobileY); 124 + expect(b.mobileY).toBeLessThan(c.mobileY); 125 + }); 126 + 127 + it('does not leave gaps when items fit next to each other', () => { 128 + // Three 2-wide items on one desktop row → 4-wide on mobile, two fit per row 129 + const a = makeItem({ id: 'a', x: 0, y: 0, w: 2, h: 2 }); 130 + const b = makeItem({ id: 'b', x: 2, y: 0, w: 2, h: 2 }); 131 + const c = makeItem({ id: 'c', x: 4, y: 0, w: 2, h: 2 }); 132 + const items = [a, b, c]; 133 + 134 + mirrorLayout(items, false); 135 + 136 + // a and b should be on the same row 137 + expect(a.mobileY).toBe(b.mobileY); 138 + // c should be on the next row (only 4 cols left isn't enough... wait, 8 - 4 - 4 = 0) 139 + // Actually a(4) + b(4) = 8, c must go to next row 140 + expect(c.mobileY).toBe(a.mobileY + a.mobileH); 141 + // c should start at x=0 142 + expect(c.mobileX).toBe(0); 143 + }); 144 + 145 + it('full-width items stack vertically', () => { 146 + const a = makeItem({ id: 'a', x: 0, y: 0, w: 8, h: 2 }); 147 + const b = makeItem({ id: 'b', x: 0, y: 2, w: 8, h: 2 }); 148 + const items = [a, b]; 149 + 150 + mirrorLayout(items, false); 151 + 152 + expect(a.mobileW).toBe(8); 153 + expect(b.mobileW).toBe(8); 154 + expect(a.mobileY).toBe(0); 155 + expect(b.mobileY).toBe(a.mobileH); 156 + }); 157 + }); 158 + 159 + describe('mirrorLayout mobile → desktop', () => { 160 + it('two mobile items that fit side-by-side on desktop stay side-by-side', () => { 161 + const a = makeItem({ id: 'a', mobileX: 0, mobileY: 0, mobileW: 4, mobileH: 4 }); 162 + const b = makeItem({ id: 'b', mobileX: 4, mobileY: 0, mobileW: 4, mobileH: 4 }); 163 + const items = [a, b]; 164 + 165 + mirrorLayout(items, true); 166 + 167 + // Both should be on the same desktop row 168 + expect(a.y).toBe(b.y); 169 + expect(a.x + a.w).toBeLessThanOrEqual(b.x); 170 + }); 171 + 172 + it('preserves reading order from mobile layout', () => { 173 + const a = makeItem({ id: 'a', mobileX: 0, mobileY: 0, mobileW: 8, mobileH: 4 }); 174 + const b = makeItem({ id: 'b', mobileX: 0, mobileY: 4, mobileW: 8, mobileH: 4 }); 175 + const c = makeItem({ id: 'c', mobileX: 0, mobileY: 8, mobileW: 8, mobileH: 4 }); 176 + const items = [a, b, c]; 177 + 178 + mirrorLayout(items, true); 179 + 180 + // a before b before c in desktop Y 181 + expect(a.y).toBeLessThanOrEqual(b.y); 182 + expect(b.y).toBeLessThanOrEqual(c.y); 183 + }); 184 + });
+31 -14
src/lib/layout/mirror.ts
··· 1 1 import { COLUMNS } from '$lib'; 2 2 import { CardDefinitionsByType } from '$lib/cards'; 3 3 import { clamp } from '$lib/helper'; 4 - import { fixAllCollisions, findValidPosition } from './algorithms'; 4 + import { findValidPosition } from './algorithms'; 5 5 import type { Item } from '$lib/types'; 6 6 7 + export type LayoutMode = 'desktop-leads' | 'mobile-leads' | 'independent'; 8 + 7 9 /** 8 - * Returns true when mirroring should still happen (i.e. user hasn't edited both layouts). 9 - * editedOn: 0/undefined = never, 1 = desktop only, 2 = mobile only, 3 = both 10 + * Determine whether mirroring should happen and in which direction. 11 + * Returns 'desktop' or 'mobile' for the source layout, or false if no mirroring. 12 + * 13 + * @param editedOn - bitflag: 0=never, 1=desktop, 2=mobile, 3=both 14 + * @param layoutMode - explicit override (takes precedence when set) 15 + * @param editingMobile - true if the current edit is on mobile 10 16 */ 11 - export function shouldMirror(editedOn: number | undefined): boolean { 17 + export function shouldMirror( 18 + editedOn: number | undefined, 19 + layoutMode: LayoutMode | undefined, 20 + editingMobile: boolean 21 + ): boolean { 22 + if (layoutMode) { 23 + if (layoutMode === 'independent') return false; 24 + if (layoutMode === 'desktop-leads') return !editingMobile; 25 + if (layoutMode === 'mobile-leads') return editingMobile; 26 + return false; 27 + } 28 + // Legacy behavior: mirror as long as both layouts haven't been edited 12 29 return (editedOn ?? 0) !== 3; 13 30 } 14 31 ··· 40 57 41 58 /** 42 59 * Mirror the full layout from one view to the other. 43 - * Copies sizes proportionally and maps positions, then resolves collisions. 60 + * Copies sizes proportionally and reflows items in reading order, then resolves collisions. 44 61 * Mutates items in-place. 45 62 */ 46 63 export function mirrorLayout(items: Item[], fromMobile: boolean): void { ··· 50 67 } 51 68 52 69 if (fromMobile) { 53 - // Mobile → Desktop: reflow items to use the full grid width. 54 - // Sort by mobile position so items are placed in reading order. 70 + // Mobile → Desktop: reflow items in mobile reading order 55 71 const sorted = items.toSorted((a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX); 56 - 57 - // Place each item into the first available spot on the desktop grid 58 72 const placed: Item[] = []; 59 73 for (const item of sorted) { 60 74 item.x = 0; ··· 63 77 placed.push(item); 64 78 } 65 79 } else { 66 - // Desktop → Mobile: proportional positions 67 - for (const item of items) { 68 - item.mobileX = clamp(Math.floor((item.x * 2) / 2) * 2, 0, COLUMNS - item.mobileW); 69 - item.mobileY = Math.max(0, Math.round(item.y * 2)); 80 + // Desktop → Mobile: reflow items in desktop reading order 81 + const sorted = items.toSorted((a, b) => a.y - b.y || a.x - b.x); 82 + const placed: Item[] = []; 83 + for (const item of sorted) { 84 + item.mobileX = 0; 85 + item.mobileY = 0; 86 + findValidPosition(item, placed, true); 87 + placed.push(item); 70 88 } 71 - fixAllCollisions(items, true); 72 89 } 73 90 }
+6
src/lib/types.ts
··· 71 71 72 72 // layout mirroring: 0/undefined=never edited, 1=desktop only, 2=mobile only, 3=both 73 73 editedOn?: number; 74 + 75 + // explicit layout sync mode (overrides editedOn when set) 76 + layoutMode?: 'desktop-leads' | 'mobile-leads' | 'independent'; 74 77 }; 75 78 }; 76 79 profile: AppBskyActorDefs.ProfileViewDetailed; ··· 80 83 additionalData: Record<string, unknown>; 81 84 updatedAt: number; 82 85 version?: number; 86 + 87 + /** Set by checkData when overlapping cards are detected before fixing. */ 88 + hasLayoutIssue?: boolean; 83 89 };
+11 -1
src/lib/website/Account.svelte
··· 5 5 import type { WebsiteData } from '$lib/types'; 6 6 import { Avatar, Button, Popover } from '@foxui/core'; 7 7 import CustomDomainModal, { customDomainModalState } from '$lib/website/CustomDomainModal.svelte'; 8 + import SettingsModal, { settingsModalState } from '$lib/website/SettingsModal.svelte'; 8 9 9 10 let { 10 - data 11 + data = $bindable() 11 12 }: { 12 13 data: WebsiteData; 13 14 } = $props(); ··· 38 39 variant="ghost" 39 40 onclick={() => { 40 41 settingsPopoverOpen = false; 42 + settingsModalState.show(); 43 + }}>Settings</Button 44 + > 45 + 46 + <Button 47 + variant="ghost" 48 + onclick={() => { 49 + settingsPopoverOpen = false; 41 50 customDomainModalState.show(); 42 51 }}>Custom Domain</Button 43 52 > ··· 48 57 </div> 49 58 50 59 <CustomDomainModal publicationUrl={data.publication?.url} /> 60 + <SettingsModal bind:data /> 51 61 {/if}
+55 -4
src/lib/website/EditableWebsite.svelte
··· 56 56 // Check if floating login button will be visible (to hide MadeWithBlento) 57 57 const showLoginOnEditPage = $derived(!user.isLoggedIn); 58 58 59 + // Snapshot the original cards so savePage can detect deletions. 60 + const originalCards: Item[] = structuredClone(data.cards); 61 + 59 62 // svelte-ignore state_referenced_locally 60 63 let items: Item[] = $state(data.cards); 64 + 65 + // Flag set by checkData when overlapping cards were auto-fixed on load 66 + let showLayoutFixModal = $state(data.hasLayoutIssue ?? false); 67 + 68 + function acknowledgeLayoutFix() { 69 + hasUnsavedChanges = true; 70 + showLayoutFixModal = false; 71 + } 61 72 62 73 // svelte-ignore state_referenced_locally 63 74 let publication = $state(JSON.stringify(data.publication)); ··· 98 109 return () => window.removeEventListener('beforeunload', handleBeforeUnload); 99 110 }); 100 111 112 + // Press Escape to deselect the currently selected card. 113 + $effect(() => { 114 + function handleKeydown(e: KeyboardEvent) { 115 + if (e.key === 'Escape' && selectedCardId) { 116 + selectedCardId = null; 117 + } 118 + } 119 + 120 + window.addEventListener('keydown', handleKeydown); 121 + return () => window.removeEventListener('keydown', handleKeydown); 122 + }); 123 + 101 124 let gridContainer: HTMLDivElement | undefined = $state(); 102 125 103 126 let showingMobileView = $state(false); ··· 109 132 // svelte-ignore state_referenced_locally 110 133 let editedOn = $state(data.publication.preferences?.editedOn ?? 0); 111 134 135 + let layoutMode = $derived(data.publication.preferences?.layoutMode); 136 + 112 137 function onLayoutChanged() { 113 138 hasUnsavedChanges = true; 114 139 // Set the bit for the current layout: desktop=1, mobile=2 115 140 editedOn = editedOn | (isMobile ? 2 : 1); 116 - if (shouldMirror(editedOn)) { 141 + if (shouldMirror(editedOn, layoutMode, isMobile)) { 117 142 mirrorLayout(items, isMobile); 118 143 } 119 144 } ··· 227 252 data.publication.preferences ??= {}; 228 253 data.publication.preferences.editedOn = editedOn; 229 254 230 - await savePage(data, items, publication); 255 + await savePage(data, items, originalCards, publication); 231 256 232 257 publication = JSON.stringify(data.publication); 233 258 savedPronouns = JSON.stringify(data.pronounsRecord); ··· 497 522 baseColor={data.publication?.preferences?.baseColor} 498 523 /> 499 524 500 - <Account {data} /> 525 + <Account bind:data /> 501 526 502 527 <Context {data} isEditing={true}> 503 528 <ImageViewerProvider /> ··· 554 579 page={data.page} 555 580 /> 556 581 582 + <Modal open={showLayoutFixModal} closeButton={false}> 583 + <div class="flex flex-col items-center gap-4 text-center"> 584 + <svg 585 + xmlns="http://www.w3.org/2000/svg" 586 + fill="none" 587 + viewBox="0 0 24 24" 588 + stroke-width="1.5" 589 + stroke="currentColor" 590 + class="size-10 text-amber-500" 591 + > 592 + <path 593 + stroke-linecap="round" 594 + stroke-linejoin="round" 595 + d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" 596 + /> 597 + </svg> 598 + <p class="text-base-700 dark:text-base-300 text-xl font-bold">Layout Auto-Fixed</p> 599 + <p class="text-base-500 dark:text-base-400 text-sm"> 600 + Your card layout had overlapping cards from an older version. This has been automatically 601 + fixed, but some cards may have moved. Please check your layout and rearrange if needed, then 602 + save to keep the changes. 603 + </p> 604 + <Button class="w-full" onclick={acknowledgeLayoutFix}>Got it</Button> 605 + </div> 606 + </Modal> 607 + 557 608 <Modal open={showMobileWarning} closeButton={false}> 558 609 <div class="flex flex-col items-center gap-4 text-center"> 559 610 <svg ··· 582 633 class={[ 583 634 '@container/wrapper relative w-full', 584 635 showingMobileView 585 - ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-90' 636 + ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dvh-2em)] rounded-2xl lg:mx-auto lg:w-90' 586 637 : '' 587 638 ]} 588 639 >
+97
src/lib/website/SettingsModal.svelte
··· 1 + <script lang="ts" module> 2 + export const settingsModalState = $state({ 3 + visible: false, 4 + show: () => (settingsModalState.visible = true), 5 + hide: () => (settingsModalState.visible = false) 6 + }); 7 + </script> 8 + 9 + <script lang="ts"> 10 + import type { WebsiteData } from '$lib/types'; 11 + import Modal from '$lib/components/modal/Modal.svelte'; 12 + 13 + let { data = $bindable() }: { data: WebsiteData } = $props(); 14 + 15 + type LayoutMode = NonNullable<WebsiteData['publication']['preferences']>['layoutMode']; 16 + 17 + type LayoutOption = { 18 + key: string; 19 + value: LayoutMode; 20 + label: string; 21 + description: string; 22 + }; 23 + 24 + const options: LayoutOption[] = [ 25 + { 26 + key: 'automatic', 27 + value: undefined, 28 + label: 'Automatic', 29 + description: 'Automatically syncs layouts until both are edited independently' 30 + }, 31 + { 32 + key: 'desktop-leads', 33 + value: 'desktop-leads', 34 + label: 'Desktop drives mobile', 35 + description: 'Desktop edits update mobile, mobile edits are independent' 36 + }, 37 + { 38 + key: 'mobile-leads', 39 + value: 'mobile-leads', 40 + label: 'Mobile drives desktop', 41 + description: 'Mobile edits update desktop, desktop edits are independent' 42 + }, 43 + { 44 + key: 'independent', 45 + value: 'independent', 46 + label: 'Independent', 47 + description: 'Desktop and mobile layouts are fully independent' 48 + } 49 + ]; 50 + 51 + let selected = $derived(data.publication.preferences?.layoutMode ?? 'automatic'); 52 + 53 + function selectOption(option: LayoutOption) { 54 + data.publication.preferences ??= {}; 55 + data.publication.preferences.layoutMode = option.value; 56 + data = { ...data }; 57 + } 58 + </script> 59 + 60 + <Modal bind:open={settingsModalState.visible}> 61 + <h3 class="text-base-900 dark:text-base-100 text-lg font-semibold">Settings</h3> 62 + 63 + <div class="mt-4"> 64 + <h4 class="text-base-800 dark:text-base-200 text-sm font-medium">Layout Sync</h4> 65 + <p class="text-base-500 dark:text-base-400 mt-1 text-xs"> 66 + Control how desktop and mobile layouts stay in sync. 67 + </p> 68 + 69 + <div class="mt-3 flex flex-col gap-2"> 70 + {#each options as option (option.key)} 71 + {@const isSelected = option.key === (selected === undefined ? 'automatic' : selected)} 72 + <button 73 + type="button" 74 + class="border-base-200 dark:border-base-700 hover:border-base-300 dark:hover:border-base-600 rounded-xl border-2 px-4 py-3 text-left transition-colors {isSelected 75 + ? 'border-accent-500 bg-accent-50 dark:bg-accent-950/20' 76 + : 'bg-base-50 dark:bg-base-800/50'}" 77 + onclick={() => selectOption(option)} 78 + > 79 + <span 80 + class="text-sm font-semibold {isSelected 81 + ? 'text-accent-700 dark:text-accent-300' 82 + : 'text-base-900 dark:text-base-100'}" 83 + > 84 + {option.label} 85 + </span> 86 + <p 87 + class="mt-0.5 text-xs {isSelected 88 + ? 'text-accent-600 dark:text-accent-400' 89 + : 'text-base-500 dark:text-base-400'}" 90 + > 91 + {option.description} 92 + </p> 93 + </button> 94 + {/each} 95 + </div> 96 + </div> 97 + </Modal>
+5 -2
src/lib/website/load.ts
··· 9 9 10 10 import type { D1Database } from '@cloudflare/workers-types'; 11 11 import { isDid, isHandle } from '@atcute/lexicons/syntax'; 12 - import { fixAllCollisions, compactItems } from '$lib/layout'; 12 + import { fixAllCollisions, compactItems, hasOverlaps } from '$lib/layout'; 13 13 import { getServerClient } from '$lib/contrail'; 14 14 import type { AppBskyActorDefs } from '@atcute/bluesky'; 15 15 ··· 456 456 const cards = data.cards.filter((v) => v.page === data.page); 457 457 458 458 if (cards.length > 0) { 459 + // Detect overlaps before fixing — flag is surfaced by the edit UI 460 + // so the user knows their layout was auto-adjusted. 461 + data.hasLayoutIssue = hasOverlaps(cards, false) || hasOverlaps(cards, true); 462 + 459 463 fixAllCollisions(cards, false); 460 464 fixAllCollisions(cards, true); 461 - 462 465 compactItems(cards, false); 463 466 compactItems(cards, true); 464 467 }
+9
vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config'; 2 + import { sveltekit } from '@sveltejs/kit/vite'; 3 + 4 + export default defineConfig({ 5 + plugins: [sveltekit()], 6 + test: { 7 + include: ['src/**/*.test.ts'] 8 + } 9 + });