diff --git a/.gitignore b/.gitignore index 818622d..5ac2f3c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,6 @@ Thumbs.db vite.config.js.timestamp-* vite.config.ts.timestamp-* -references \ No newline at end of file +references + +sveltekit-cloudflare-workers \ No newline at end of file diff --git a/package.json b/package.json index 97bb541..1c826df 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --check . && eslint .", "format": "eslint --fix . && prettier --write .", + "test": "vitest run", "deploy": "pnpm run build && wrangler deploy", "cf-typegen": "wrangler types ./src/worker-configuration.d.ts" }, @@ -38,7 +39,8 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.57.0", "valibot": "^1.3.1", - "vite": "^8.0.0" + "vite": "^8.0.0", + "vitest": "^4.1.4" }, "dependencies": { "@atcute/atproto": "^3.1.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2623af0..13251a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -270,6 +270,9 @@ importers: vite: specifier: ^8.0.0 version: 8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1) + vitest: + specifier: ^4.1.4 + 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)) packages: @@ -1417,9 +1420,15 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -1533,6 +1542,35 @@ packages: '@use-gesture/vanilla@10.3.1': resolution: {integrity: sha512-lT4scGLu59ovA3zmtUonukAGcA0AdOOh+iwNDS05Bsu7Lq9aZToDHhI6D8Q2qvsVraovtsLLYwPrWdG/noMAKw==} + '@vitest/expect@4.1.4': + resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==} + + '@vitest/mocker@4.1.4': + resolution: {integrity: sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.4': + resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==} + + '@vitest/runner@4.1.4': + resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==} + + '@vitest/snapshot@4.1.4': + resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==} + + '@vitest/spy@4.1.4': + resolution: {integrity: sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==} + + '@vitest/utils@4.1.4': + resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} + '@webgpu/types@0.1.69': resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} @@ -1556,6 +1594,10 @@ packages: resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -1600,6 +1642,10 @@ packages: canvas-confetti@1.9.4: resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} @@ -1628,6 +1674,9 @@ packages: confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -1757,6 +1806,9 @@ packages: error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -1841,10 +1893,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -2668,6 +2727,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + simple-icons@16.11.0: resolution: {integrity: sha512-6vqbcdaT6PsgUXud9rrP9w+nrmRzzStMEvyDavMeGwDgZSYM4uJ3tH7zurgTLHJO0RnMqU3Q09Vgo7WdTXV1eA==} engines: {node: '>=0.12.18'} @@ -2680,9 +2742,15 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + string.prototype.codepointat@0.2.1: resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} @@ -2807,6 +2875,13 @@ packages: tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -2814,6 +2889,10 @@ packages: tinyqueue@3.0.0: resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -2962,6 +3041,47 @@ packages: vite: optional: true + vitest@4.1.4: + resolution: {integrity: sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.4 + '@vitest/browser-preview': 4.1.4 + '@vitest/browser-webdriverio': 4.1.4 + '@vitest/coverage-istanbul': 4.1.4 + '@vitest/coverage-v8': 4.1.4 + '@vitest/ui': 4.1.4 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -2999,6 +3119,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -4161,8 +4286,15 @@ snapshots: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/cookie@0.6.0': {} + '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.8': {} @@ -4310,6 +4442,47 @@ snapshots: dependencies: '@use-gesture/core': 10.3.1 + '@vitest/expect@4.1.4': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.4 + '@vitest/utils': 4.1.4 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.4(vite@8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1))': + dependencies: + '@vitest/spy': 4.1.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1) + + '@vitest/pretty-format@4.1.4': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.4': + dependencies: + '@vitest/utils': 4.1.4 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.4': + dependencies: + '@vitest/pretty-format': 4.1.4 + '@vitest/utils': 4.1.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.4': {} + + '@vitest/utils@4.1.4': + dependencies: + '@vitest/pretty-format': 4.1.4 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@webgpu/types@0.1.69': {} acorn-jsx@5.3.2(acorn@8.16.0): @@ -4329,6 +4502,8 @@ snapshots: aria-query@5.3.1: {} + assertion-error@2.0.1: {} + axobject-query@4.1.0: {} balanced-match@4.0.4: {} @@ -4368,6 +4543,8 @@ snapshots: canvas-confetti@1.9.4: {} + chai@6.2.2: {} + cheerio-select@2.1.0: dependencies: boolbase: 1.0.0 @@ -4414,6 +4591,8 @@ snapshots: confbox@0.2.4: {} + convert-source-map@2.0.0: {} + cookie@0.6.0: {} cookie@1.1.1: {} @@ -4522,6 +4701,8 @@ snapshots: error-stack-parser-es@1.0.5: {} + es-module-lexer@2.0.0: {} + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -4661,8 +4842,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.3.0: {} + exsolve@1.0.8: {} fast-deep-equal@3.1.3: {} @@ -5446,6 +5633,8 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + simple-icons@16.11.0: {} sirv@3.0.2: @@ -5456,8 +5645,12 @@ snapshots: source-map-js@1.2.1: {} + stackback@0.0.2: {} + std-env@3.10.0: {} + std-env@4.0.0: {} + string.prototype.codepointat@0.2.1: {} style-to-object@1.0.14: @@ -5591,6 +5784,10 @@ snapshots: tiny-inflate@1.0.3: {} + tinybench@2.9.0: {} + + tinyexec@1.1.1: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -5598,6 +5795,8 @@ snapshots: tinyqueue@3.0.0: {} + tinyrainbow@3.1.0: {} + totalist@3.0.1: {} troika-three-text@0.52.4(three@0.183.2): @@ -5706,6 +5905,33 @@ snapshots: optionalDependencies: vite: 8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1) + 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)): + dependencies: + '@vitest/expect': 4.1.4 + '@vitest/mocker': 4.1.4(vite@8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1)) + '@vitest/pretty-format': 4.1.4 + '@vitest/runner': 4.1.4 + '@vitest/snapshot': 4.1.4 + '@vitest/spy': 4.1.4 + '@vitest/utils': 4.1.4 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.0.10 + transitivePeerDependencies: + - msw + w3c-keyname@2.2.8: {} web-haptics@0.0.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(svelte@5.53.11): @@ -5726,6 +5952,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} workerd@1.20260312.1: diff --git a/scripts/atproto.ts b/scripts/atproto.ts new file mode 100644 index 0000000..8dd1363 --- /dev/null +++ b/scripts/atproto.ts @@ -0,0 +1,133 @@ +/** + * ATProto helper script for debugging. + * + * Usage: + * npx tsx scripts/atproto.ts listRecords + * npx tsx scripts/atproto.ts getRecord + * + * Examples: + * npx tsx scripts/atproto.ts listRecords japan.selfhosted.social app.blento.card + * npx tsx scripts/atproto.ts getRecord japan.selfhosted.social app.blento.card self + */ + +async function resolveHandle(handle: string): Promise { + // Try DNS-based resolution via DoH + const dnsRes = await fetch( + `https://mozilla.cloudflare-dns.com/dns-query?name=_atproto.${handle}&type=TXT`, + { headers: { Accept: 'application/dns-json' } } + ); + const dns = await dnsRes.json(); + for (const answer of dns.Answer ?? []) { + const match = answer.data?.replace(/"/g, '').match(/^did=(.+)$/); + if (match) return match[1]; + } + + // Fallback: HTTP well-known + const httpRes = await fetch(`https://${handle}/.well-known/atproto-did`); + if (httpRes.ok) { + const did = (await httpRes.text()).trim(); + if (did.startsWith('did:')) return did; + } + + throw new Error(`Could not resolve handle: ${handle}`); +} + +async function resolvePDS(did: string): Promise { + let docUrl: string; + if (did.startsWith('did:plc:')) { + docUrl = `https://plc.directory/${did}`; + } else if (did.startsWith('did:web:')) { + const host = did.replace('did:web:', ''); + docUrl = `https://${host}/.well-known/did.json`; + } else { + throw new Error(`Unsupported DID method: ${did}`); + } + + const res = await fetch(docUrl); + if (!res.ok) throw new Error(`Failed to fetch DID document: ${res.status}`); + const doc = await res.json(); + + for (const service of doc.service ?? []) { + if (service.id === '#atproto_pds') { + return service.serviceEndpoint; + } + } + throw new Error('No #atproto_pds service found in DID document'); +} + +async function listRecords(handle: string, collection: string) { + const did = await resolveHandle(handle); + const pds = await resolvePDS(did); + console.error(`Resolved: ${handle} → ${did} @ ${pds}`); + + const allRecords: any[] = []; + let cursor: string | undefined; + + do { + const params = new URLSearchParams({ + repo: did, + collection, + limit: '100' + }); + if (cursor) params.set('cursor', cursor); + + const res = await fetch(`${pds}/xrpc/com.atproto.repo.listRecords?${params}`); + if (!res.ok) { + const body = await res.text(); + throw new Error(`listRecords failed: ${res.status} ${body}`); + } + const data = await res.json(); + allRecords.push(...data.records); + cursor = data.cursor; + } while (cursor); + + console.log(JSON.stringify(allRecords, null, 2)); +} + +async function getRecord(handle: string, collection: string, rkey: string) { + const did = await resolveHandle(handle); + const pds = await resolvePDS(did); + console.error(`Resolved: ${handle} → ${did} @ ${pds}`); + + const params = new URLSearchParams({ repo: did, collection, rkey }); + const res = await fetch(`${pds}/xrpc/com.atproto.repo.getRecord?${params}`); + if (!res.ok) { + const body = await res.text(); + throw new Error(`getRecord failed: ${res.status} ${body}`); + } + const data = await res.json(); + console.log(JSON.stringify(data, null, 2)); +} + +// CLI +const [, , command, ...args] = process.argv; + +switch (command) { + case 'listRecords': + if (args.length < 2) { + console.error('Usage: listRecords '); + process.exit(1); + } + listRecords(args[0], args[1]).catch((e) => { + console.error(e.message); + process.exit(1); + }); + break; + + case 'getRecord': + if (args.length < 3) { + console.error('Usage: getRecord '); + process.exit(1); + } + getRecord(args[0], args[1], args[2]).catch((e) => { + console.error(e.message); + process.exit(1); + }); + break; + + default: + console.error('Commands: listRecords, getRecord'); + console.error(' listRecords '); + console.error(' getRecord '); + process.exit(1); +} diff --git a/scripts/simulate-load.ts b/scripts/simulate-load.ts new file mode 100644 index 0000000..8925d3e --- /dev/null +++ b/scripts/simulate-load.ts @@ -0,0 +1,123 @@ +/** + * Simulate what checkData() does to a user's layout on load. + * Uses react-grid-layout directly to avoid Svelte import chain. + * + * Usage: npx tsx scripts/atproto.ts listRecords app.blento.card 2>/dev/null | npx tsx scripts/simulate-load.ts + */ +import { + correctBounds, + verticalCompactor, + type LayoutItem +} from 'react-grid-layout/core'; +import * as fs from 'fs'; + +const COLUMNS = 8; + +type Item = { + id: string; + x: number; y: number; w: number; h: number; + mobileX: number; mobileY: number; mobileW: number; mobileH: number; + cardType: string; +}; + +function toLayout(items: Item[], mobile: boolean): LayoutItem[] { + return items.map((item) => + mobile + ? { x: item.mobileX, y: item.mobileY, w: item.mobileW, h: item.mobileH, i: item.id } + : { x: item.x, y: item.y, w: item.w, h: item.h, i: item.id } + ); +} + +function applyLayout(items: Item[], layout: LayoutItem[], mobile: boolean) { + const map = new Map(items.map((i) => [i.id, i])); + for (const l of layout) { + const item = map.get(l.i); + if (!item) continue; + if (mobile) { item.mobileX = l.x; item.mobileY = l.y; } + else { item.x = l.x; item.y = l.y; } + } +} + +function fixAllCollisions(items: Item[], mobile: boolean) { + let layout = toLayout(items, mobile); + correctBounds(layout as any, { cols: COLUMNS }); + layout = verticalCompactor.compact(layout, COLUMNS) as LayoutItem[]; + applyLayout(items, layout, mobile); +} + +function compactItems(items: Item[], mobile: boolean) { + const layout = toLayout(items, mobile); + const compacted = verticalCompactor.compact(layout, COLUMNS) as LayoutItem[]; + applyLayout(items, compacted, mobile); +} + +// --- + +const input = fs.readFileSync('/dev/stdin', 'utf8'); +const records = JSON.parse(input); + +const cards: Item[] = records + .filter((r: any) => r.value.cardType && (!r.value.page || r.value.page === 'blento.self')) + .map((r: any) => ({ + id: r.value.id, + x: r.value.x, y: r.value.y, w: r.value.w, h: r.value.h, + mobileX: r.value.mobileX, mobileY: r.value.mobileY, + mobileW: r.value.mobileW, mobileH: r.value.mobileH, + cardType: r.value.cardType + })); + +// Save original positions +const originals = cards.map((c) => ({ ...c })); + +// Simulate checkData — fixAllCollisions only (no separate compactItems) +fixAllCollisions(cards, false); +fixAllCollisions(cards, true); + +// Compare +let desktopChanges = 0; +let mobileChanges = 0; + +for (const card of cards) { + const orig = originals.find((o) => o.id === card.id)!; + const dChanged = card.x !== orig.x || card.y !== orig.y; + const mChanged = card.mobileX !== orig.mobileX || card.mobileY !== orig.mobileY; + + if (dChanged || mChanged) { + console.log( + `${orig.cardType.padEnd(20)} id=${orig.id}` + + (dChanged ? ` DESKTOP: (${orig.x},${orig.y}) → (${card.x},${card.y})` : '') + + (mChanged ? ` MOBILE: (${orig.mobileX},${orig.mobileY}) → (${card.mobileX},${card.mobileY})` : '') + ); + if (dChanged) desktopChanges++; + if (mChanged) mobileChanges++; + } +} + +if (desktopChanges === 0 && mobileChanges === 0) { + console.log('No layout changes on load — checkData is not the culprit.'); +} else { + console.log(`\n${desktopChanges} desktop changes, ${mobileChanges} mobile changes on load.`); +} + +// Check for ORDER changes in mobile layout +console.log('\n=== Mobile reading order (y, then x) ==='); +const sortByMobile = (items: { id: string; mobileX: number; mobileY: number; cardType: string }[]) => + [...items].sort((a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX); + +const origOrder = sortByMobile(originals); +const newOrder = sortByMobile(cards); + +let orderChanges = 0; +for (let i = 0; i < origOrder.length; i++) { + const same = origOrder[i].id === newOrder[i].id; + if (!same) orderChanges++; + const orig = originals.find(o => o.id === newOrder[i].id)!; + const card = cards.find(c => c.id === newOrder[i].id)!; + console.log( + `${i.toString().padStart(2)}: ${same ? ' ' : '!!'} ` + + `${card.cardType.padEnd(20)} ` + + `was (${orig.mobileX},${orig.mobileY}) → now (${card.mobileX},${card.mobileY})` + + (!same ? ` [was #${origOrder.findIndex(o => o.id === newOrder[i].id)}]` : '') + ); +} +console.log(`\n${orderChanges} order changes in mobile layout.`); diff --git a/src/lib/layout/algorithms.ts b/src/lib/layout/algorithms.ts index 50b852a..de38fa9 100644 --- a/src/lib/layout/algorithms.ts +++ b/src/lib/layout/algorithms.ts @@ -62,6 +62,16 @@ export function overlaps(a: Item, b: Item, mobile: boolean) { return collides(toLayoutItem(a, mobile), toLayoutItem(b, mobile)); } +/** Returns true if any two items overlap in the given layout. */ +export function hasOverlaps(items: Item[], mobile: boolean): boolean { + for (let i = 0; i < items.length; i++) { + for (let j = i + 1; j < items.length; j++) { + if (overlaps(items[i], items[j], mobile)) return true; + } + } + return false; +} + export function fixCollisions( items: Item[], item: Item, @@ -105,6 +115,16 @@ export function fixAllCollisions(items: Item[], mobile: boolean) { applyLayout(items, layout, mobile); } +/** + * Only fix items that are out of grid bounds, without compacting or resolving overlaps. + * This is safe to call on load — it won't shift already-valid layouts. + */ +export function sanitizeBounds(items: Item[], mobile: boolean) { + const layout = toLayout(items, mobile); + correctBounds(layout as any, { cols: COLUMNS }); + applyLayout(items, layout, mobile); +} + export function compactItems(items: Item[], mobile: boolean) { const layout = toLayout(items, mobile); const compacted = verticalCompactor.compact(layout, COLUMNS) as LayoutItem[]; diff --git a/src/lib/layout/index.ts b/src/lib/layout/index.ts index 13b724d..0561ab1 100644 --- a/src/lib/layout/index.ts +++ b/src/lib/layout/index.ts @@ -4,10 +4,13 @@ export { fixAllCollisions, compactItems, setPositionOfNewItem, - findValidPosition + findValidPosition, + sanitizeBounds, + hasOverlaps } from './algorithms'; export { shouldMirror, mirrorItemSize, mirrorLayout } from './mirror'; +export type { LayoutMode } from './mirror'; export { getGridPosition, getViewportCenterGridY, pixelToGrid } from './grid'; export type { GridPosition, DragState } from './grid'; diff --git a/src/lib/layout/mirror.test.ts b/src/lib/layout/mirror.test.ts new file mode 100644 index 0000000..087ad10 --- /dev/null +++ b/src/lib/layout/mirror.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Item } from '$lib/types'; + +// Mock CardDefinitionsByType — tests don't need real card definitions +vi.mock('$lib/cards', () => ({ + CardDefinitionsByType: {} +})); + +import { mirrorItemSize, mirrorLayout, shouldMirror } from './mirror'; + +function makeItem(overrides: Partial & { id: string }): Item { + return { + w: 2, + h: 2, + x: 0, + y: 0, + mobileW: 4, + mobileH: 4, + mobileX: 0, + mobileY: 0, + cardType: 'text', + cardData: {}, + ...overrides + }; +} + +describe('shouldMirror', () => { + it('mirrors when editedOn is 0 (never edited) and no layoutMode', () => { + expect(shouldMirror(0, undefined, false)).toBe(true); + expect(shouldMirror(0, undefined, true)).toBe(true); + }); + + it('mirrors when only one layout edited and no layoutMode', () => { + expect(shouldMirror(1, undefined, false)).toBe(true); + expect(shouldMirror(2, undefined, true)).toBe(true); + }); + + it('does not mirror when both layouts edited and no layoutMode', () => { + expect(shouldMirror(3, undefined, false)).toBe(false); + expect(shouldMirror(3, undefined, true)).toBe(false); + }); + + it('desktop-leads: mirrors only when editing desktop', () => { + expect(shouldMirror(3, 'desktop-leads', false)).toBe(true); + expect(shouldMirror(3, 'desktop-leads', true)).toBe(false); + }); + + it('mobile-leads: mirrors only when editing mobile', () => { + expect(shouldMirror(3, 'mobile-leads', true)).toBe(true); + expect(shouldMirror(3, 'mobile-leads', false)).toBe(false); + }); + + it('independent: never mirrors', () => { + expect(shouldMirror(0, 'independent', false)).toBe(false); + expect(shouldMirror(0, 'independent', true)).toBe(false); + }); +}); + +describe('mirrorItemSize', () => { + it('desktop → mobile: doubles dimensions', () => { + const item = makeItem({ id: 'a', w: 3, h: 2 }); + mirrorItemSize(item, false); + expect(item.mobileW).toBe(6); + expect(item.mobileH).toBe(4); + }); + + it('desktop → mobile: caps width at COLUMNS (8)', () => { + const item = makeItem({ id: 'a', w: 6, h: 2 }); + mirrorItemSize(item, false); + expect(item.mobileW).toBe(8); + expect(item.mobileH).toBe(4); + }); + + it('mobile → desktop: halves dimensions with snap-even', () => { + const item = makeItem({ id: 'a', mobileW: 6, mobileH: 4 }); + mirrorItemSize(item, true); + // snapEven(6/2) = snapEven(3) = max(2, round(1.5)*2) = max(2, 4) = 4 + expect(item.w).toBe(4); + expect(item.h).toBe(2); + }); + + it('mobile → desktop: exact halves', () => { + const item = makeItem({ id: 'a', mobileW: 4, mobileH: 4 }); + mirrorItemSize(item, true); + // snapEven(4/2) = snapEven(2) = max(2, round(1)*2) = 2 + expect(item.w).toBe(2); + expect(item.h).toBe(2); + }); + + it('mobile → desktop: minimum width is 2', () => { + const item = makeItem({ id: 'a', mobileW: 2, mobileH: 2 }); + mirrorItemSize(item, true); + expect(item.w).toBe(2); // snapEven(1) = max(2, round(0.5)*2) = max(2, 1*2) = 2 + expect(item.h).toBe(1); // round(2/2) = 1 + }); +}); + +describe('mirrorLayout desktop → mobile', () => { + it('two items that fit side-by-side on mobile stay side-by-side', () => { + // Two 2-wide items next to each other on desktop → 4-wide on mobile, should fit in 8 cols + const a = makeItem({ id: 'a', x: 0, y: 0, w: 2, h: 2 }); + const b = makeItem({ id: 'b', x: 2, y: 0, w: 2, h: 2 }); + const items = [a, b]; + + mirrorLayout(items, false); + + // Both should be on the same row + expect(a.mobileY).toBe(b.mobileY); + // They should not overlap + expect(a.mobileX + a.mobileW).toBeLessThanOrEqual(b.mobileX); + }); + + it('preserves reading order', () => { + const a = makeItem({ id: 'a', x: 0, y: 0, w: 4, h: 2 }); + const b = makeItem({ id: 'b', x: 4, y: 0, w: 4, h: 2 }); + const c = makeItem({ id: 'c', x: 0, y: 2, w: 4, h: 2 }); + const items = [a, b, c]; + + mirrorLayout(items, false); + + // a and b are on the same desktop row but become full-width on mobile (8 cols each) + // So they must stack. a should come before b, and b before c. + expect(a.mobileY).toBeLessThan(b.mobileY); + expect(b.mobileY).toBeLessThan(c.mobileY); + }); + + it('does not leave gaps when items fit next to each other', () => { + // Three 2-wide items on one desktop row → 4-wide on mobile, two fit per row + const a = makeItem({ id: 'a', x: 0, y: 0, w: 2, h: 2 }); + const b = makeItem({ id: 'b', x: 2, y: 0, w: 2, h: 2 }); + const c = makeItem({ id: 'c', x: 4, y: 0, w: 2, h: 2 }); + const items = [a, b, c]; + + mirrorLayout(items, false); + + // a and b should be on the same row + expect(a.mobileY).toBe(b.mobileY); + // c should be on the next row (only 4 cols left isn't enough... wait, 8 - 4 - 4 = 0) + // Actually a(4) + b(4) = 8, c must go to next row + expect(c.mobileY).toBe(a.mobileY + a.mobileH); + // c should start at x=0 + expect(c.mobileX).toBe(0); + }); + + it('full-width items stack vertically', () => { + const a = makeItem({ id: 'a', x: 0, y: 0, w: 8, h: 2 }); + const b = makeItem({ id: 'b', x: 0, y: 2, w: 8, h: 2 }); + const items = [a, b]; + + mirrorLayout(items, false); + + expect(a.mobileW).toBe(8); + expect(b.mobileW).toBe(8); + expect(a.mobileY).toBe(0); + expect(b.mobileY).toBe(a.mobileH); + }); +}); + +describe('mirrorLayout mobile → desktop', () => { + it('two mobile items that fit side-by-side on desktop stay side-by-side', () => { + const a = makeItem({ id: 'a', mobileX: 0, mobileY: 0, mobileW: 4, mobileH: 4 }); + const b = makeItem({ id: 'b', mobileX: 4, mobileY: 0, mobileW: 4, mobileH: 4 }); + const items = [a, b]; + + mirrorLayout(items, true); + + // Both should be on the same desktop row + expect(a.y).toBe(b.y); + expect(a.x + a.w).toBeLessThanOrEqual(b.x); + }); + + it('preserves reading order from mobile layout', () => { + const a = makeItem({ id: 'a', mobileX: 0, mobileY: 0, mobileW: 8, mobileH: 4 }); + const b = makeItem({ id: 'b', mobileX: 0, mobileY: 4, mobileW: 8, mobileH: 4 }); + const c = makeItem({ id: 'c', mobileX: 0, mobileY: 8, mobileW: 8, mobileH: 4 }); + const items = [a, b, c]; + + mirrorLayout(items, true); + + // a before b before c in desktop Y + expect(a.y).toBeLessThanOrEqual(b.y); + expect(b.y).toBeLessThanOrEqual(c.y); + }); +}); diff --git a/src/lib/layout/mirror.ts b/src/lib/layout/mirror.ts index 2d26add..1da6163 100644 --- a/src/lib/layout/mirror.ts +++ b/src/lib/layout/mirror.ts @@ -1,14 +1,31 @@ import { COLUMNS } from '$lib'; import { CardDefinitionsByType } from '$lib/cards'; import { clamp } from '$lib/helper'; -import { fixAllCollisions, findValidPosition } from './algorithms'; +import { findValidPosition } from './algorithms'; import type { Item } from '$lib/types'; +export type LayoutMode = 'desktop-leads' | 'mobile-leads' | 'independent'; + /** - * Returns true when mirroring should still happen (i.e. user hasn't edited both layouts). - * editedOn: 0/undefined = never, 1 = desktop only, 2 = mobile only, 3 = both + * Determine whether mirroring should happen and in which direction. + * Returns 'desktop' or 'mobile' for the source layout, or false if no mirroring. + * + * @param editedOn - bitflag: 0=never, 1=desktop, 2=mobile, 3=both + * @param layoutMode - explicit override (takes precedence when set) + * @param editingMobile - true if the current edit is on mobile */ -export function shouldMirror(editedOn: number | undefined): boolean { +export function shouldMirror( + editedOn: number | undefined, + layoutMode: LayoutMode | undefined, + editingMobile: boolean +): boolean { + if (layoutMode) { + if (layoutMode === 'independent') return false; + if (layoutMode === 'desktop-leads') return !editingMobile; + if (layoutMode === 'mobile-leads') return editingMobile; + return false; + } + // Legacy behavior: mirror as long as both layouts haven't been edited return (editedOn ?? 0) !== 3; } @@ -40,7 +57,7 @@ export function mirrorItemSize(item: Item, fromMobile: boolean): void { /** * Mirror the full layout from one view to the other. - * Copies sizes proportionally and maps positions, then resolves collisions. + * Copies sizes proportionally and reflows items in reading order, then resolves collisions. * Mutates items in-place. */ export function mirrorLayout(items: Item[], fromMobile: boolean): void { @@ -50,11 +67,8 @@ export function mirrorLayout(items: Item[], fromMobile: boolean): void { } if (fromMobile) { - // Mobile → Desktop: reflow items to use the full grid width. - // Sort by mobile position so items are placed in reading order. + // Mobile → Desktop: reflow items in mobile reading order const sorted = items.toSorted((a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX); - - // Place each item into the first available spot on the desktop grid const placed: Item[] = []; for (const item of sorted) { item.x = 0; @@ -63,11 +77,14 @@ export function mirrorLayout(items: Item[], fromMobile: boolean): void { placed.push(item); } } else { - // Desktop → Mobile: proportional positions - for (const item of items) { - item.mobileX = clamp(Math.floor((item.x * 2) / 2) * 2, 0, COLUMNS - item.mobileW); - item.mobileY = Math.max(0, Math.round(item.y * 2)); + // Desktop → Mobile: reflow items in desktop reading order + const sorted = items.toSorted((a, b) => a.y - b.y || a.x - b.x); + const placed: Item[] = []; + for (const item of sorted) { + item.mobileX = 0; + item.mobileY = 0; + findValidPosition(item, placed, true); + placed.push(item); } - fixAllCollisions(items, true); } } diff --git a/src/lib/types.ts b/src/lib/types.ts index 56394f7..d6728f6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -71,6 +71,9 @@ export type WebsiteData = { // layout mirroring: 0/undefined=never edited, 1=desktop only, 2=mobile only, 3=both editedOn?: number; + + // explicit layout sync mode (overrides editedOn when set) + layoutMode?: 'desktop-leads' | 'mobile-leads' | 'independent'; }; }; profile: AppBskyActorDefs.ProfileViewDetailed; @@ -80,4 +83,7 @@ export type WebsiteData = { additionalData: Record; updatedAt: number; version?: number; + + /** Set by checkData when overlapping cards are detected before fixing. */ + hasLayoutIssue?: boolean; }; diff --git a/src/lib/website/Account.svelte b/src/lib/website/Account.svelte index 93b9255..582e81d 100644 --- a/src/lib/website/Account.svelte +++ b/src/lib/website/Account.svelte @@ -5,9 +5,10 @@ import type { WebsiteData } from '$lib/types'; import { Avatar, Button, Popover } from '@foxui/core'; import CustomDomainModal, { customDomainModalState } from '$lib/website/CustomDomainModal.svelte'; + import SettingsModal, { settingsModalState } from '$lib/website/SettingsModal.svelte'; let { - data + data = $bindable() }: { data: WebsiteData; } = $props(); @@ -34,6 +35,14 @@ > {/if} + + + + +
+ export const settingsModalState = $state({ + visible: false, + show: () => (settingsModalState.visible = true), + hide: () => (settingsModalState.visible = false) + }); + + + + + +

Settings

+ +
+

Layout Sync

+

+ Control how desktop and mobile layouts stay in sync. +

+ +
+ {#each options as option (option.key)} + {@const isSelected = option.key === (selected === undefined ? 'automatic' : selected)} + + {/each} +
+
+ diff --git a/src/lib/website/load.ts b/src/lib/website/load.ts index c65a075..72e862b 100644 --- a/src/lib/website/load.ts +++ b/src/lib/website/load.ts @@ -7,7 +7,7 @@ import { error } from '@sveltejs/kit'; import type { ActorIdentifier, Did } from '@atcute/lexicons'; import { isDid, isHandle } from '@atcute/lexicons/syntax'; -import { fixAllCollisions, compactItems } from '$lib/layout'; +import { fixAllCollisions, compactItems, hasOverlaps } from '$lib/layout'; const CURRENT_CACHE_VERSION = 1; @@ -378,11 +378,49 @@ function checkData(data: WebsiteData): WebsiteData { const cards = data.cards.filter((v) => v.page === data.page); if (cards.length > 0) { - fixAllCollisions(cards, false); - fixAllCollisions(cards, true); - - compactItems(cards, false); - compactItems(cards, true); + // Detect overlaps before fixing — flag is used by the edit UI + const desktopOverlaps = hasOverlaps(cards, false); + const mobileOverlaps = hasOverlaps(cards, true); + data.hasLayoutIssue = desktopOverlaps || mobileOverlaps; + + if (data.hasLayoutIssue) { + console.log('[checkData] Layout issues detected:'); + if (desktopOverlaps) console.log(' - Desktop has overlapping cards'); + if (mobileOverlaps) console.log(' - Mobile has overlapping cards'); + + // Log before positions + const before = cards.map((c) => ({ + id: c.id, + type: c.cardType, + desktop: `(${c.x},${c.y},${c.w}x${c.h})`, + mobile: `(${c.mobileX},${c.mobileY},${c.mobileW}x${c.mobileH})` + })); + + fixAllCollisions(cards, false); + fixAllCollisions(cards, true); + compactItems(cards, false); + compactItems(cards, true); + + // Log changes + for (let i = 0; i < cards.length; i++) { + const c = cards[i]; + const b = before[i]; + const newDesktop = `(${c.x},${c.y},${c.w}x${c.h})`; + const newMobile = `(${c.mobileX},${c.mobileY},${c.mobileW}x${c.mobileH})`; + if (newDesktop !== b.desktop || newMobile !== b.mobile) { + console.log( + ` ${b.type} ${b.id}: ` + + (newDesktop !== b.desktop ? `desktop ${b.desktop} → ${newDesktop} ` : '') + + (newMobile !== b.mobile ? `mobile ${b.mobile} → ${newMobile}` : '') + ); + } + } + } else { + fixAllCollisions(cards, false); + fixAllCollisions(cards, true); + compactItems(cards, false); + compactItems(cards, true); + } } data.cards = cards; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..1ccc1b6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; +import { sveltekit } from '@sveltejs/kit/vite'; + +export default defineConfig({ + plugins: [sveltekit()], + test: { + include: ['src/**/*.test.ts'] + } +}); -- 2.51.2 From 13af93ea9214c737c9692ff42f40019830960c3e Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Mon, 13 Apr 2026 04:23:29 +0200 Subject: [PATCH 2/5] fix tangled mirror --- .github/workflows/mirror.yml | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/mirror.yml diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml new file mode 100644 index 0000000..fd19056 --- /dev/null +++ b/.github/workflows/mirror.yml @@ -0,0 +1,37 @@ +name: mirror + +on: + push: + branches: + - main + tags: + - '*' + +permissions: + contents: read + +jobs: + mirror: + name: 🕸️ Mirror to Tangled + if: ${{ github.repository == 'flo-bit/blento' }} + runs-on: ubuntu-24.04-arm + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: 🔑 Configure SSH + env: + TANGLED_SSH_KEY: ${{ secrets.TANGLED_SSH_KEY }} + run: | + mkdir -p ~/.ssh + echo "$TANGLED_SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -t ed25519 tangled.org >> ~/.ssh/known_hosts 2>/dev/null + + - name: ⬆︎ Push to Tangled + run: | + git remote add tangled git@tangled.org:flo-bit.dev/blento + git push tangled main --force + git push tangled --tags --force -- 2.51.2 From aadccfa9603d9df6a271b1d87a85066512622341 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Mon, 13 Apr 2026 04:28:24 +0200 Subject: [PATCH 3/5] fix formatting --- .gitignore | 4 +- .prettierignore | 3 + eslint.config.js | 1 + scripts/simulate-load.ts | 55 ++++++++++++------- .../EditingSecretImageCard.svelte | 1 - .../EditStatusphereCard.svelte | 4 +- .../StatusphereCard/StatusphereCard.svelte | 4 +- .../KichRecipeCard/KichRecipeCard.svelte | 2 +- .../KichRecipeCollectionCard.svelte | 4 +- src/lib/website/EditableWebsite.svelte | 4 +- 10 files changed, 49 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index 5ac2f3c..0f5009b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,6 @@ vite.config.ts.timestamp-* references -sveltekit-cloudflare-workers \ No newline at end of file +sveltekit-cloudflare-workers + +inlay \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index ab78a95..74f7945 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,6 @@ package-lock.json pnpm-lock.yaml yarn.lock + +# Unrelated sub-project +inlay diff --git a/eslint.config.js b/eslint.config.js index 2033331..ddef03e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -44,6 +44,7 @@ export default [ 'svelte/no-at-html-tags': 'off', '@typescript-eslint/no-explicit-any': 'off', 'no-unused-vars': 'off', + 'no-useless-assignment': 'off', '@typescript-eslint/no-unused-vars': [ 'warn', { diff --git a/scripts/simulate-load.ts b/scripts/simulate-load.ts index 8925d3e..c0ce408 100644 --- a/scripts/simulate-load.ts +++ b/scripts/simulate-load.ts @@ -4,19 +4,21 @@ * * Usage: npx tsx scripts/atproto.ts listRecords app.blento.card 2>/dev/null | npx tsx scripts/simulate-load.ts */ -import { - correctBounds, - verticalCompactor, - type LayoutItem -} from 'react-grid-layout/core'; +import { correctBounds, verticalCompactor, type LayoutItem } from 'react-grid-layout/core'; import * as fs from 'fs'; const COLUMNS = 8; type Item = { id: string; - x: number; y: number; w: number; h: number; - mobileX: number; mobileY: number; mobileW: number; mobileH: number; + x: number; + y: number; + w: number; + h: number; + mobileX: number; + mobileY: number; + mobileW: number; + mobileH: number; cardType: string; }; @@ -33,8 +35,13 @@ function applyLayout(items: Item[], layout: LayoutItem[], mobile: boolean) { for (const l of layout) { const item = map.get(l.i); if (!item) continue; - if (mobile) { item.mobileX = l.x; item.mobileY = l.y; } - else { item.x = l.x; item.y = l.y; } + if (mobile) { + item.mobileX = l.x; + item.mobileY = l.y; + } else { + item.x = l.x; + item.y = l.y; + } } } @@ -60,9 +67,14 @@ const cards: Item[] = records .filter((r: any) => r.value.cardType && (!r.value.page || r.value.page === 'blento.self')) .map((r: any) => ({ id: r.value.id, - x: r.value.x, y: r.value.y, w: r.value.w, h: r.value.h, - mobileX: r.value.mobileX, mobileY: r.value.mobileY, - mobileW: r.value.mobileW, mobileH: r.value.mobileH, + x: r.value.x, + y: r.value.y, + w: r.value.w, + h: r.value.h, + mobileX: r.value.mobileX, + mobileY: r.value.mobileY, + mobileW: r.value.mobileW, + mobileH: r.value.mobileH, cardType: r.value.cardType })); @@ -86,7 +98,9 @@ for (const card of cards) { console.log( `${orig.cardType.padEnd(20)} id=${orig.id}` + (dChanged ? ` DESKTOP: (${orig.x},${orig.y}) → (${card.x},${card.y})` : '') + - (mChanged ? ` MOBILE: (${orig.mobileX},${orig.mobileY}) → (${card.mobileX},${card.mobileY})` : '') + (mChanged + ? ` MOBILE: (${orig.mobileX},${orig.mobileY}) → (${card.mobileX},${card.mobileY})` + : '') ); if (dChanged) desktopChanges++; if (mChanged) mobileChanges++; @@ -101,8 +115,9 @@ if (desktopChanges === 0 && mobileChanges === 0) { // Check for ORDER changes in mobile layout console.log('\n=== Mobile reading order (y, then x) ==='); -const sortByMobile = (items: { id: string; mobileX: number; mobileY: number; cardType: string }[]) => - [...items].sort((a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX); +const sortByMobile = ( + items: { id: string; mobileX: number; mobileY: number; cardType: string }[] +) => [...items].sort((a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX); const origOrder = sortByMobile(originals); const newOrder = sortByMobile(cards); @@ -111,13 +126,13 @@ let orderChanges = 0; for (let i = 0; i < origOrder.length; i++) { const same = origOrder[i].id === newOrder[i].id; if (!same) orderChanges++; - const orig = originals.find(o => o.id === newOrder[i].id)!; - const card = cards.find(c => c.id === newOrder[i].id)!; + const orig = originals.find((o) => o.id === newOrder[i].id)!; + const card = cards.find((c) => c.id === newOrder[i].id)!; console.log( `${i.toString().padStart(2)}: ${same ? ' ' : '!!'} ` + - `${card.cardType.padEnd(20)} ` + - `was (${orig.mobileX},${orig.mobileY}) → now (${card.mobileX},${card.mobileY})` + - (!same ? ` [was #${origOrder.findIndex(o => o.id === newOrder[i].id)}]` : '') + `${card.cardType.padEnd(20)} ` + + `was (${orig.mobileX},${orig.mobileY}) → now (${card.mobileX},${card.mobileY})` + + (!same ? ` [was #${origOrder.findIndex((o) => o.id === newOrder[i].id)}]` : '') ); } console.log(`\n${orderChanges} order changes in mobile layout.`); diff --git a/src/lib/cards/media/SecretImageCard/EditingSecretImageCard.svelte b/src/lib/cards/media/SecretImageCard/EditingSecretImageCard.svelte index 8c8fe89..42d2cb6 100644 --- a/src/lib/cards/media/SecretImageCard/EditingSecretImageCard.svelte +++ b/src/lib/cards/media/SecretImageCard/EditingSecretImageCard.svelte @@ -102,5 +102,4 @@
{/if} - diff --git a/src/lib/cards/media/StatusphereCard/EditStatusphereCard.svelte b/src/lib/cards/media/StatusphereCard/EditStatusphereCard.svelte index 22d8c6a..e376b57 100644 --- a/src/lib/cards/media/StatusphereCard/EditStatusphereCard.svelte +++ b/src/lib/cards/media/StatusphereCard/EditStatusphereCard.svelte @@ -29,9 +29,7 @@ let mode = $derived(item.cardData?.mode ?? 'emoji'); // Emoji mode: use cardData. Statusphere mode: use latest record or preview. let emoji = $derived( - mode === 'statusphere' - ? (item.cardData?.emoji ?? record?.value?.status) - : item.cardData?.emoji + mode === 'statusphere' ? (item.cardData?.emoji ?? record?.value?.status) : item.cardData?.emoji ); let showPopover = $state(false); diff --git a/src/lib/cards/media/StatusphereCard/StatusphereCard.svelte b/src/lib/cards/media/StatusphereCard/StatusphereCard.svelte index d251c4d..4dcd3d8 100644 --- a/src/lib/cards/media/StatusphereCard/StatusphereCard.svelte +++ b/src/lib/cards/media/StatusphereCard/StatusphereCard.svelte @@ -11,9 +11,7 @@ let mode = $derived(item.cardData?.mode ?? 'emoji'); // Emoji mode: use cardData. Statusphere mode: use latest record from PDS. - let emoji = $derived( - mode === 'statusphere' ? record?.value?.status : item.cardData?.emoji - ); + let emoji = $derived(mode === 'statusphere' ? record?.value?.status : item.cardData?.emoji); let animated = $derived(emojiToNotoAnimatedWebp(emoji)); diff --git a/src/lib/cards/social/KichRecipeCard/KichRecipeCard.svelte b/src/lib/cards/social/KichRecipeCard/KichRecipeCard.svelte index d9bb75a..84c1ffc 100644 --- a/src/lib/cards/social/KichRecipeCard/KichRecipeCard.svelte +++ b/src/lib/cards/social/KichRecipeCard/KichRecipeCard.svelte @@ -121,7 +121,7 @@ {title}
diff --git a/src/lib/cards/social/KichRecipeCollectionCard/KichRecipeCollectionCard.svelte b/src/lib/cards/social/KichRecipeCollectionCard/KichRecipeCollectionCard.svelte index 3942e39..ba48a39 100644 --- a/src/lib/cards/social/KichRecipeCollectionCard/KichRecipeCollectionCard.svelte +++ b/src/lib/cards/social/KichRecipeCollectionCard/KichRecipeCollectionCard.svelte @@ -84,10 +84,10 @@ class="relative block min-h-0 flex-1" > {#if imageUrl} - {title} + {title} {:else}
{/if} diff --git a/src/lib/website/EditableWebsite.svelte b/src/lib/website/EditableWebsite.svelte index 34da2b4..28f4b8e 100644 --- a/src/lib/website/EditableWebsite.svelte +++ b/src/lib/website/EditableWebsite.svelte @@ -586,8 +586,8 @@

Layout Auto-Fixed

Your card layout had overlapping cards from an older version. This has been automatically - fixed, but some cards may have moved. Please check your layout and rearrange if needed, - then save to keep the changes. + fixed, but some cards may have moved. Please check your layout and rearrange if needed, then + save to keep the changes.

-- 2.51.2 From 2bc1607f21b0dc23f3ab8ed803424bb630274b5b Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:17:32 +0200 Subject: [PATCH 4/5] fix card layout version 24 --- .gitignore | 4 +- src/lib/cards/_base/BaseCard/BaseCard.svelte | 5 +- .../_base/BaseCard/BaseEditingCard.svelte | 36 ++++++++----- src/lib/helper.ts | 52 ++++++++----------- src/lib/layout/EditableGrid.svelte | 18 +++++-- src/lib/website/EditableWebsite.svelte | 20 +++++-- src/lib/website/load.ts | 51 +++--------------- 7 files changed, 91 insertions(+), 95 deletions(-) diff --git a/.gitignore b/.gitignore index 0f5009b..687c8f9 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,6 @@ references sveltekit-cloudflare-workers -inlay \ No newline at end of file +inlay + +scripts/backups \ No newline at end of file diff --git a/src/lib/cards/_base/BaseCard/BaseCard.svelte b/src/lib/cards/_base/BaseCard/BaseCard.svelte index 6c04aaa..a0f2597 100644 --- a/src/lib/cards/_base/BaseCard/BaseCard.svelte +++ b/src/lib/cards/_base/BaseCard/BaseCard.svelte @@ -45,8 +45,9 @@ draggable={false} class={[ fillPage - ? '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' - : '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', + ? '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' + : 'card group/card selection:bg-accent-600/50 @container/card absolute isolate z-0 rounded-3xl outline-offset-2 transition-[outline] duration-200', + isEditing ? 'transition-all' : '', !fillPage ? (color ? (colors[color] ?? colors.accent) : colors.base) : '', color !== 'accent' && item.color !== 'base' && item.color !== 'transparent' ? color : '', showOutline ? 'outline-2' : '', diff --git a/src/lib/cards/_base/BaseCard/BaseEditingCard.svelte b/src/lib/cards/_base/BaseCard/BaseEditingCard.svelte index 3235d5c..5cba4d4 100644 --- a/src/lib/cards/_base/BaseCard/BaseEditingCard.svelte +++ b/src/lib/cards/_base/BaseCard/BaseEditingCard.svelte @@ -63,7 +63,21 @@ let selectedCardId = getSelectedCardId(); let selectCard = getSelectCard(); let isSelected = $derived(selectedCardId?.() === item.id); - let isDimmed = $derived(isCoarse?.() && selectedCardId?.() != null && !isSelected); + + // Track pointer down position so we only select on click, not on drag + let overlayDownX = 0; + let overlayDownY = 0; + function handleOverlayPointerDown(e: PointerEvent) { + overlayDownX = e.clientX; + overlayDownY = e.clientY; + } + function handleOverlayPointerUp(e: PointerEvent) { + const dx = Math.abs(e.clientX - overlayDownX); + const dy = Math.abs(e.clientY - overlayDownY); + if (dx < 5 && dy < 5) { + selectCard?.(item.id); + } + } let colorPopoverOpen = $state(false); @@ -184,25 +198,23 @@ {item} isEditing={true} bind:ref - showOutline={isResizing || (isCoarse?.() && isSelected)} + showOutline={isResizing || isSelected} locked={item.cardData?.locked} class={[ 'scale-100 starting:scale-0 starting:opacity-0', - isCoarse?.() && isSelected ? 'ring-accent-500 z-10 ring-2 ring-offset-2' : '', - isDimmed ? 'opacity-70' : 'opacity-100' + isSelected ? 'outline-accent-500 z-10' : '' ]} {...rest} > - {#if isCoarse?.() && !isSelected} - + {#if !isSelected} +
{ - e.stopPropagation(); - selectCard?.(item.id); - }} + class="absolute inset-0 z-20 cursor-pointer focus:outline-none" + onpointerdown={handleOverlayPointerDown} + onpointerup={handleOverlayPointerUp} >
{/if} {@render children?.()} @@ -299,7 +311,7 @@
(); - for (const card of data.cards) { - originalCardsById.set(card.id, card); - } - - // find all cards that have been updated (where items differ from originalItems) + // Save all current cards. We don't diff against originals because the + // server-side load can modify cards (e.g. fixing overlaps), so the + // "original" the client sees is already the post-fix version — there's + // nothing reliable to diff against. for (let item of currentItems) { - const orig = originalCardsById.get(item.id); - const originalItem = orig && cardsEqual(orig, item) ? orig : undefined; + item.updatedAt = new Date().toISOString(); + // run optional upload function for this card type + const cardDef = CardDefinitionsByType[item.cardType]; - if (!originalItem) { - console.log('updated or new item', item); - item.updatedAt = new Date().toISOString(); - // run optional upload function for this card type - const cardDef = CardDefinitionsByType[item.cardType]; - - if (cardDef?.upload) { - item = await cardDef?.upload(item); - } + if (cardDef?.upload) { + item = await cardDef?.upload(item); + } - const parsedItem = JSON.parse(JSON.stringify(item)); + const parsedItem = JSON.parse(JSON.stringify(item)); - parsedItem.page = data.page; - parsedItem.version = 2; + parsedItem.page = data.page; + parsedItem.version = 2; - promises.push( - putRecord({ - collection: 'app.blento.card', - rkey: parsedItem.id, - record: parsedItem - }) - ); - } + promises.push( + putRecord({ + collection: 'app.blento.card', + rkey: parsedItem.id, + record: parsedItem + }) + ); } // delete items that are in originalItems but not in items - for (const originalItem of data.cards) { + for (const originalItem of originalCards) { const item = currentItems.find((i) => i.id === originalItem.id); if (!item) { console.log('deleting item', originalItem); diff --git a/src/lib/layout/EditableGrid.svelte b/src/lib/layout/EditableGrid.svelte index 99522bf..73b129d 100644 --- a/src/lib/layout/EditableGrid.svelte +++ b/src/lib/layout/EditableGrid.svelte @@ -32,9 +32,14 @@ ref = container; }); - const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); - const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); - let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); + let maxHeight = $derived( + items.reduce((max, item) => { + const y = isMobile ? (item.mobileY ?? item.y) : item.y; + const h = isMobile ? (item.mobileH ?? item.h) : item.h; + return Math.max(max, y + h); + }, 0) + ); + // --- Drag state --- type Phase = 'idle' | 'pending' | 'active'; @@ -387,7 +392,12 @@ > {@render children()} -
+ +