your personal website on atproto - mirror blento.app

Merge pull request #210 from flo-bit/more-events-stuff-maybe-blogs

events stuff, blogs pt1

authored by

Florian and committed by
GitHub
f18ade51 cc0dcf2b

+4367 -438
+18 -8
package.json
··· 51 51 "@atcute/tid": "^1.1.1", 52 52 "@cloudflare/workers-types": "^4.20260123.0", 53 53 "@ethercorps/sveltekit-og": "^4.2.1", 54 + "@floating-ui/dom": "^1.7.5", 54 55 "@foxui/3d": "^0.4.7", 55 56 "@foxui/colors": "^0.4.7", 56 57 "@foxui/core": "^0.4.7", ··· 61 62 "@tailwindcss/typography": "^0.5.19", 62 63 "@threlte/core": "^8.3.1", 63 64 "@threlte/extras": "^9.7.1", 64 - "@tiptap/core": "^3.16.0", 65 - "@tiptap/extension-document": "^3.16.0", 66 - "@tiptap/extension-image": "^3.16.0", 67 - "@tiptap/extension-link": "^3.16.0", 68 - "@tiptap/extension-paragraph": "^3.16.0", 69 - "@tiptap/extension-placeholder": "^3.16.0", 70 - "@tiptap/extension-text": "^3.16.0", 71 - "@tiptap/starter-kit": "^3.16.0", 65 + "@tiptap/core": "^3.19.0", 66 + "@tiptap/extension-bubble-menu": "^3.19.0", 67 + "@tiptap/extension-code-block-lowlight": "^3.19.0", 68 + "@tiptap/extension-document": "^3.19.0", 69 + "@tiptap/extension-image": "^3.19.0", 70 + "@tiptap/extension-link": "^3.19.0", 71 + "@tiptap/extension-paragraph": "^3.19.0", 72 + "@tiptap/extension-placeholder": "^3.19.0", 73 + "@tiptap/extension-text": "^3.19.0", 74 + "@tiptap/extension-typography": "^3.19.0", 75 + "@tiptap/extension-underline": "^3.19.0", 76 + "@tiptap/markdown": "^3.19.0", 77 + "@tiptap/pm": "^3.19.0", 78 + "@tiptap/starter-kit": "^3.19.0", 79 + "@tiptap/suggestion": "^3.19.0", 72 80 "@types/three": "^0.176.0", 73 81 "bits-ui": "^2.15.4", 74 82 "clsx": "^2.1.1", ··· 77 85 "hls.js": "^1.6.15", 78 86 "leaflet": "^1.9.4", 79 87 "link-preview-js": "^4.0.0", 88 + "lowlight": "^3.3.0", 80 89 "maplibre-gl": "^5.17.0", 81 90 "marked": "^17.0.1", 82 91 "perfect-freehand": "^1.2.2", ··· 86 95 "simple-icons": "^16.6.0", 87 96 "svelte-boring-avatars": "^1.2.6", 88 97 "svelte-sonner": "^1.0.7", 98 + "svelte-tiptap": "^3.0.1", 89 99 "tailwind-merge": "^3.4.0", 90 100 "tailwind-variants": "^3.2.2", 91 101 "tailwindcss-animate": "^1.0.7",
+383 -219
pnpm-lock.yaml
··· 41 41 '@ethercorps/sveltekit-og': 42 42 specifier: ^4.2.1 43 43 version: 4.2.1(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2))) 44 + '@floating-ui/dom': 45 + specifier: ^1.7.5 46 + version: 1.7.5 44 47 '@foxui/3d': 45 48 specifier: ^0.4.7 46 49 version: 0.4.7(svelte@5.48.0)(tailwindcss@4.1.18) ··· 72 75 specifier: ^9.7.1 73 76 version: 9.7.1(@types/three@0.176.0)(svelte@5.48.0)(three@0.176.0) 74 77 '@tiptap/core': 75 - specifier: ^3.16.0 76 - version: 3.16.0(@tiptap/pm@3.16.0) 78 + specifier: ^3.19.0 79 + version: 3.19.0(@tiptap/pm@3.19.0) 80 + '@tiptap/extension-bubble-menu': 81 + specifier: ^3.19.0 82 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 83 + '@tiptap/extension-code-block-lowlight': 84 + specifier: ^3.19.0 85 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-code-block@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(highlight.js@11.11.1)(lowlight@3.3.0) 77 86 '@tiptap/extension-document': 78 - specifier: ^3.16.0 79 - version: 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0)) 87 + specifier: ^3.19.0 88 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) 80 89 '@tiptap/extension-image': 81 - specifier: ^3.16.0 82 - version: 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0)) 90 + specifier: ^3.19.0 91 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) 83 92 '@tiptap/extension-link': 84 - specifier: ^3.16.0 85 - version: 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0) 93 + specifier: ^3.19.0 94 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 86 95 '@tiptap/extension-paragraph': 87 - specifier: ^3.16.0 88 - version: 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0)) 96 + specifier: ^3.19.0 97 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) 89 98 '@tiptap/extension-placeholder': 90 - specifier: ^3.16.0 91 - version: 3.16.0(@tiptap/extensions@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0)) 99 + specifier: ^3.19.0 100 + version: 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) 92 101 '@tiptap/extension-text': 93 - specifier: ^3.16.0 94 - version: 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0)) 102 + specifier: ^3.19.0 103 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) 104 + '@tiptap/extension-typography': 105 + specifier: ^3.19.0 106 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) 107 + '@tiptap/extension-underline': 108 + specifier: ^3.19.0 109 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) 110 + '@tiptap/markdown': 111 + specifier: ^3.19.0 112 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 113 + '@tiptap/pm': 114 + specifier: ^3.19.0 115 + version: 3.19.0 95 116 '@tiptap/starter-kit': 96 - specifier: ^3.16.0 97 - version: 3.16.0 117 + specifier: ^3.19.0 118 + version: 3.19.0 119 + '@tiptap/suggestion': 120 + specifier: ^3.19.0 121 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 98 122 '@types/three': 99 123 specifier: ^0.176.0 100 124 version: 0.176.0 ··· 119 143 link-preview-js: 120 144 specifier: ^4.0.0 121 145 version: 4.0.0 146 + lowlight: 147 + specifier: ^3.3.0 148 + version: 3.3.0 122 149 maplibre-gl: 123 150 specifier: ^5.17.0 124 151 version: 5.17.0 ··· 146 173 svelte-sonner: 147 174 specifier: ^1.0.7 148 175 version: 1.0.7(svelte@5.48.0) 176 + svelte-tiptap: 177 + specifier: ^3.0.1 178 + version: 3.0.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-bubble-menu@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(svelte@5.48.0) 149 179 tailwind-merge: 150 180 specifier: ^3.4.0 151 181 version: 3.4.0 ··· 736 766 '@floating-ui/core@1.7.3': 737 767 resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} 738 768 739 - '@floating-ui/dom@1.7.4': 740 - resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} 769 + '@floating-ui/core@1.7.4': 770 + resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==, tarball: https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz} 771 + 772 + '@floating-ui/dom@1.7.5': 773 + resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==, tarball: https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz} 741 774 742 775 '@floating-ui/utils@0.2.10': 743 776 resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} ··· 1011 1044 resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} 1012 1045 1013 1046 '@remirror/core-constants@3.0.0': 1014 - resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} 1047 + resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==, tarball: https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz} 1015 1048 1016 1049 '@resvg/resvg-wasm@2.6.2': 1017 1050 resolution: {integrity: sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==} ··· 1383 1416 svelte: '>=5' 1384 1417 three: '>=0.160' 1385 1418 1386 - '@tiptap/core@3.16.0': 1387 - resolution: {integrity: sha512-XegRaNuoQ/guzBQU2xHxOwFXXrtoXW9tiyXDhssSqylvZmBVSlRIPNHA6ArkHBKm6ehLf6+6Y9fF3uky1yCXYQ==} 1419 + '@tiptap/core@3.19.0': 1420 + resolution: {integrity: sha512-bpqELwPW+DG8gWiD8iiFtSl4vIBooG5uVJod92Qxn3rA9nFatyXRr4kNbMJmOZ66ezUvmCjXVe/5/G4i5cyzKA==, tarball: https://registry.npmjs.org/@tiptap/core/-/core-3.19.0.tgz} 1421 + peerDependencies: 1422 + '@tiptap/pm': ^3.19.0 1423 + 1424 + '@tiptap/extension-blockquote@3.19.0': 1425 + resolution: {integrity: sha512-y3UfqY9KD5XwWz3ndiiJ089Ij2QKeiXy/g1/tlAN/F1AaWsnkHEHMLxCP1BIqmMpwsX7rZjMLN7G5Lp7c9682A==, tarball: https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.19.0.tgz} 1388 1426 peerDependencies: 1389 - '@tiptap/pm': ^3.16.0 1427 + '@tiptap/core': ^3.19.0 1428 + 1429 + '@tiptap/extension-bold@3.19.0': 1430 + resolution: {integrity: sha512-UZgb1d0XK4J/JRIZ7jW+s4S6KjuEDT2z1PPM6ugcgofgJkWQvRZelCPbmtSFd3kwsD+zr9UPVgTh9YIuGQ8t+Q==, tarball: https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.19.0.tgz} 1431 + peerDependencies: 1432 + '@tiptap/core': ^3.19.0 1390 1433 1391 - '@tiptap/extension-blockquote@3.16.0': 1392 - resolution: {integrity: sha512-c1bhJ3KDFXyNcMweiBzu0LouBXfUC/sUMtaEafQePR98BVu+d0tmWXcGlfVarGVoRyCYFa1mHpkgtxp4SS3lag==} 1434 + '@tiptap/extension-bubble-menu@3.19.0': 1435 + resolution: {integrity: sha512-klNVIYGCdznhFkrRokzGd6cwzoi8J7E5KbuOfZBwFwhMKZhlz/gJfKmYg9TJopeUhrr2Z9yHgWTk8dh/YIJCdQ==, tarball: https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.19.0.tgz} 1393 1436 peerDependencies: 1394 - '@tiptap/core': ^3.16.0 1437 + '@tiptap/core': ^3.19.0 1438 + '@tiptap/pm': ^3.19.0 1439 + 1440 + '@tiptap/extension-bullet-list@3.19.0': 1441 + resolution: {integrity: sha512-F9uNnqd0xkJbMmRxVI5RuVxwB9JaCH/xtRqOUNQZnRBt7IdAElCY+Dvb4hMCtiNv+enGM/RFGJuFHR9TxmI7rw==, tarball: https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.19.0.tgz} 1442 + peerDependencies: 1443 + '@tiptap/extension-list': ^3.19.0 1444 + 1445 + '@tiptap/extension-code-block-lowlight@3.19.0': 1446 + resolution: {integrity: sha512-P8O8i1J+XozEVA7bF/Ijwf/r1rVqrh1DBQ7dXxBcrUvLpIGyVjtxX228jBF/kD4kf2xOlphvjDhV2fLa8XOVsg==, tarball: https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.19.0.tgz} 1447 + peerDependencies: 1448 + '@tiptap/core': ^3.19.0 1449 + '@tiptap/extension-code-block': ^3.19.0 1450 + '@tiptap/pm': ^3.19.0 1451 + highlight.js: ^11 1452 + lowlight: ^2 || ^3 1453 + 1454 + '@tiptap/extension-code-block@3.19.0': 1455 + resolution: {integrity: sha512-b/2qR+tMn8MQb+eaFYgVk4qXnLNkkRYmwELQ8LEtEDQPxa5Vl7J3eu8+4OyoIFhZrNDZvvoEp80kHMCP8sI6rg==, tarball: https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.19.0.tgz} 1456 + peerDependencies: 1457 + '@tiptap/core': ^3.19.0 1458 + '@tiptap/pm': ^3.19.0 1395 1459 1396 - '@tiptap/extension-bold@3.16.0': 1397 - resolution: {integrity: sha512-S61wtChbOigk2bklCJ2uEa8jbAnI9ChbW4d1z/Uv/Hr6eWo42vVBtjNZKFOsiBPDajFZbOfnvekGs731jNrHKg==} 1460 + '@tiptap/extension-code@3.19.0': 1461 + resolution: {integrity: sha512-2kqqQIXBXj2Or+4qeY3WoE7msK+XaHKL6EKOcKlOP2BW8eYqNTPzNSL+PfBDQ3snA7ljZQkTs/j4GYDj90vR1A==, tarball: https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.19.0.tgz} 1398 1462 peerDependencies: 1399 - '@tiptap/core': ^3.16.0 1463 + '@tiptap/core': ^3.19.0 1400 1464 1401 - '@tiptap/extension-bullet-list@3.16.0': 1402 - resolution: {integrity: sha512-GjKssVf9241GLdshdYRzPPApWQIB+7GJy0TZgx7bWmFUVgypYxDoE/rQRmvb3Fhup836bgfpfUzStevJ6eIClw==} 1465 + '@tiptap/extension-document@3.19.0': 1466 + resolution: {integrity: sha512-AOf0kHKSFO0ymjVgYSYDncRXTITdTcrj1tqxVazrmO60KNl1Rc2dAggDvIVTEBy5NvceF0scc7q3sE/5ZtVV7A==, tarball: https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.19.0.tgz} 1403 1467 peerDependencies: 1404 - '@tiptap/extension-list': ^3.16.0 1468 + '@tiptap/core': ^3.19.0 1405 1469 1406 - '@tiptap/extension-code-block@3.16.0': 1407 - resolution: {integrity: sha512-hAsXe6fIBsvIMWlVEXKLEzFQ8h6VUEBWqEEFIQgq+SpZCkGX+KzVmFXd5V2aDqb+BoOyqYiA2w1d/frBBxVEpw==} 1470 + '@tiptap/extension-dropcursor@3.19.0': 1471 + resolution: {integrity: sha512-sf3dEZXiLvsGqVK2maUIzXY6qtYYCvBumag7+VPTMGQ0D4hiZ1X/4ukt4+6VXDg5R2WP1CoIt/QvUetUjWNhbQ==, tarball: https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.19.0.tgz} 1408 1472 peerDependencies: 1409 - '@tiptap/core': ^3.16.0 1410 - '@tiptap/pm': ^3.16.0 1473 + '@tiptap/extensions': ^3.19.0 1411 1474 1412 - '@tiptap/extension-code@3.16.0': 1413 - resolution: {integrity: sha512-U8/bz/1BhQ39LJgUqJ8u1HzLcYdtubUWVAVC8seteLz1vIhXkTyfAC8478KQ+YdIDkMzAs+0vxk5BsWcWG16zQ==} 1475 + '@tiptap/extension-floating-menu@3.19.0': 1476 + resolution: {integrity: sha512-JaoEkVRkt+Slq3tySlIsxnMnCjS0L5n1CA1hctjLy0iah8edetj3XD5mVv5iKqDzE+LIjF4nwLRRVKJPc8hFBg==, tarball: https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.19.0.tgz} 1414 1477 peerDependencies: 1415 - '@tiptap/core': ^3.16.0 1478 + '@floating-ui/dom': ^1.0.0 1479 + '@tiptap/core': ^3.19.0 1480 + '@tiptap/pm': ^3.19.0 1416 1481 1417 - '@tiptap/extension-document@3.16.0': 1418 - resolution: {integrity: sha512-vOwBnJIonYmmFVMEnnE1jwoUMq0P/9BcaUocIG9o5iFRTV38I8YGn8n6DiE1pjSeLXRpLrXl6LLwdOMBJewhBg==} 1482 + '@tiptap/extension-gapcursor@3.19.0': 1483 + resolution: {integrity: sha512-w7DACS4oSZaDWjz7gropZHPc9oXqC9yERZTcjWxyORuuIh1JFf0TRYspleK+OK28plK/IftojD/yUDn1MTRhvA==, tarball: https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.19.0.tgz} 1419 1484 peerDependencies: 1420 - '@tiptap/core': ^3.16.0 1485 + '@tiptap/extensions': ^3.19.0 1421 1486 1422 - '@tiptap/extension-dropcursor@3.16.0': 1423 - resolution: {integrity: sha512-n9Gbt99K9oBChjp8puF0ffAJtBF6ZVjydG5u5QO2Z8sHNE+Hn6ARfgZqLjr11ZF4b+mLShqsmyROmITNf73W+A==} 1487 + '@tiptap/extension-hard-break@3.19.0': 1488 + resolution: {integrity: sha512-lAmQraYhPS5hafvCl74xDB5+bLuNwBKIEsVoim35I0sDJj5nTrfhaZgMJ91VamMvT+6FF5f1dvBlxBxAWa8jew==, tarball: https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.19.0.tgz} 1424 1489 peerDependencies: 1425 - '@tiptap/extensions': ^3.16.0 1490 + '@tiptap/core': ^3.19.0 1426 1491 1427 - '@tiptap/extension-gapcursor@3.16.0': 1428 - resolution: {integrity: sha512-8dxE4bkfn6Jog/JHDxN/kzcRbyJB7HyFqCKdiTq0f4atzysmnEUuMswwlwMPaErkzlETD6B8NEEtMknEUqowGA==} 1492 + '@tiptap/extension-heading@3.19.0': 1493 + resolution: {integrity: sha512-uLpLlfyp086WYNOc0ekm1gIZNlEDfmzOhKzB0Hbyi6jDagTS+p9mxUNYeYOn9jPUxpFov43+Wm/4E24oY6B+TQ==, tarball: https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.19.0.tgz} 1429 1494 peerDependencies: 1430 - '@tiptap/extensions': ^3.16.0 1495 + '@tiptap/core': ^3.19.0 1431 1496 1432 - '@tiptap/extension-hard-break@3.16.0': 1433 - resolution: {integrity: sha512-nwUTixlHYo9V1lfOYsRi2JiAYCRC7pObB3Kt7rEeMxB3XmcRcSpHtxYs6r+TvifsLFys8RG5wOFXIV/YXZHcDg==} 1497 + '@tiptap/extension-horizontal-rule@3.19.0': 1498 + resolution: {integrity: sha512-iqUHmgMGhMgYGwG6L/4JdelVQ5Mstb4qHcgTGd/4dkcUOepILvhdxajPle7OEdf9sRgjQO6uoAU5BVZVC26+ng==, tarball: https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.19.0.tgz} 1434 1499 peerDependencies: 1435 - '@tiptap/core': ^3.16.0 1500 + '@tiptap/core': ^3.19.0 1501 + '@tiptap/pm': ^3.19.0 1436 1502 1437 - '@tiptap/extension-heading@3.16.0': 1438 - resolution: {integrity: sha512-du4d1Ukvhr1zvPWlU/HS3NMlRswzGRSNDNfCFUhdYgQoHOSnUXshnlKD3E5H0EHfL9UwT4JFyqAT3+1ZnahkdA==} 1503 + '@tiptap/extension-image@3.19.0': 1504 + resolution: {integrity: sha512-/rGl8nBziBPVJJ/9639eQWFDKcI3RQsDM3s+cqYQMFQfMqc7sQB9h4o4sHCBpmKxk3Y0FV/0NjnjLbBVm8OKdQ==, tarball: https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.19.0.tgz} 1439 1505 peerDependencies: 1440 - '@tiptap/core': ^3.16.0 1506 + '@tiptap/core': ^3.19.0 1441 1507 1442 - '@tiptap/extension-horizontal-rule@3.16.0': 1443 - resolution: {integrity: sha512-yyKl45UCH55pIf8G4bHiUNFxggipRVT276c3t9vrkXU6BkJhzfxxcIc5svWkiThDjdYmJs1FfVCYAtGSuKiSyA==} 1508 + '@tiptap/extension-italic@3.19.0': 1509 + resolution: {integrity: sha512-6GffxOnS/tWyCbDkirWNZITiXRta9wrCmrfa4rh+v32wfaOL1RRQNyqo9qN6Wjyl1R42Js+yXTzTTzZsOaLMYA==, tarball: https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.19.0.tgz} 1444 1510 peerDependencies: 1445 - '@tiptap/core': ^3.16.0 1446 - '@tiptap/pm': ^3.16.0 1511 + '@tiptap/core': ^3.19.0 1447 1512 1448 - '@tiptap/extension-image@3.16.0': 1449 - resolution: {integrity: sha512-mTjt4kdyVtY/2dJcfxAgBae/dkH+r6GwARl7NlPtnI3EzpELFR65FNuOQyTxFXP3yfV9uMtPpq6Wevk8aLTsxQ==} 1513 + '@tiptap/extension-link@3.19.0': 1514 + resolution: {integrity: sha512-HEGDJnnCPfr7KWu7Dsq+eRRe/mBCsv6DuI+7fhOCLDJjjKzNgrX2abbo/zG3D/4lCVFaVb+qawgJubgqXR/Smw==, tarball: https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.19.0.tgz} 1450 1515 peerDependencies: 1451 - '@tiptap/core': ^3.16.0 1516 + '@tiptap/core': ^3.19.0 1517 + '@tiptap/pm': ^3.19.0 1452 1518 1453 - '@tiptap/extension-italic@3.16.0': 1454 - resolution: {integrity: sha512-SVNnkRUK6G+dQse5Ms8Q/wudSTh37O94p02RDc3KneEtBk6wkokqCLuwKnWLPhlEqsuOku+wTD9DSJdvoRlq9w==} 1519 + '@tiptap/extension-list-item@3.19.0': 1520 + resolution: {integrity: sha512-VsSKuJz4/Tb6ZmFkXqWpDYkRzmaLTyE6dNSEpNmUpmZ32sMqo58mt11/huADNwfBFB0Ve7siH/VnFNIJYY3xvg==, tarball: https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.19.0.tgz} 1455 1521 peerDependencies: 1456 - '@tiptap/core': ^3.16.0 1522 + '@tiptap/extension-list': ^3.19.0 1457 1523 1458 - '@tiptap/extension-link@3.16.0': 1459 - resolution: {integrity: sha512-WPPJLtGXQadBVVwH6gcMpaXIgfvFF9NGpE2IVqleVKR3Epv2Rd4aWd4oyAdrT8KU9G6dzMXZfkrB8aArTDKxYQ==} 1524 + '@tiptap/extension-list-keymap@3.19.0': 1525 + resolution: {integrity: sha512-bxgmAgA3RzBGA0GyTwS2CC1c+QjkJJq9hC+S6PSOWELGRiTbwDN3MANksFXLjntkTa0N5fOnL27vBHtMStURqw==, tarball: https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.19.0.tgz} 1460 1526 peerDependencies: 1461 - '@tiptap/core': ^3.16.0 1462 - '@tiptap/pm': ^3.16.0 1527 + '@tiptap/extension-list': ^3.19.0 1463 1528 1464 - '@tiptap/extension-list-item@3.16.0': 1465 - resolution: {integrity: sha512-kshssUZEPoosPWbJNQEFJnVV3iPwsDU9l/RCdHJB5SE+aNWJyUk5hQ/YwngEHjV7rS+RnAuhbrcB5swgyzROuA==} 1529 + '@tiptap/extension-list@3.19.0': 1530 + resolution: {integrity: sha512-N6nKbFB2VwMsPlCw67RlAtYSK48TAsAUgjnD+vd3ieSlIufdQnLXDFUP6hFKx9mwoUVUgZGz02RA6bkxOdYyTw==, tarball: https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.19.0.tgz} 1466 1531 peerDependencies: 1467 - '@tiptap/extension-list': ^3.16.0 1532 + '@tiptap/core': ^3.19.0 1533 + '@tiptap/pm': ^3.19.0 1468 1534 1469 - '@tiptap/extension-list-keymap@3.16.0': 1470 - resolution: {integrity: sha512-AU3J9W6uo835ZdxiGmrYx1KUymzvfkU4d278X0OBAfujORXkbDNlo9er8pOrOpgXNxgtnlH32lWR4bWyKdUgwA==} 1535 + '@tiptap/extension-ordered-list@3.19.0': 1536 + resolution: {integrity: sha512-cxGsINquwHYE1kmhAcLNLHAofmoDEG6jbesR5ybl7tU5JwtKVO7S/xZatll2DU1dsDAXWPWEeeMl4e/9svYjCg==, tarball: https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.19.0.tgz} 1471 1537 peerDependencies: 1472 - '@tiptap/extension-list': ^3.16.0 1538 + '@tiptap/extension-list': ^3.19.0 1473 1539 1474 - '@tiptap/extension-list@3.16.0': 1475 - resolution: {integrity: sha512-tpjWGugfI0XYR9iG/QlYYtCY35TFWHNwGKc94wN4s7NmAjB4xlwdTkTZQ6PdZ39x1SeHkRjxAka+6GcBIoOHGQ==} 1540 + '@tiptap/extension-paragraph@3.19.0': 1541 + resolution: {integrity: sha512-xWa6gj82l5+AzdYyrSk9P4ynySaDzg/SlR1FarXE5yPXibYzpS95IWaVR0m2Qaz7Rrk+IiYOTGxGRxcHLOelNg==, tarball: https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.19.0.tgz} 1476 1542 peerDependencies: 1477 - '@tiptap/core': ^3.16.0 1478 - '@tiptap/pm': ^3.16.0 1543 + '@tiptap/core': ^3.19.0 1479 1544 1480 - '@tiptap/extension-ordered-list@3.16.0': 1481 - resolution: {integrity: sha512-mNKqwEgiXSMi5afGtnodsptveukpr3GqcGsw2fqJFyNq9SITznjiiuQfULtzVnayC8qHsk0Zzbpzf0zvdHlypg==} 1545 + '@tiptap/extension-placeholder@3.19.0': 1546 + resolution: {integrity: sha512-i15OfgyI4IDCYAcYSKUMnuZkYuUInfanjf9zquH8J2BETiomf/jZldVCp/QycMJ8DOXZ38fXDc99wOygnSNySg==, tarball: https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.19.0.tgz} 1482 1547 peerDependencies: 1483 - '@tiptap/extension-list': ^3.16.0 1548 + '@tiptap/extensions': ^3.19.0 1484 1549 1485 - '@tiptap/extension-paragraph@3.16.0': 1486 - resolution: {integrity: sha512-JHn3ev7US5FxtQFyEOeQ8XfvKcR5NiHkwDH2Gcwe+0ttpA/Qrrr5XN3tJIgI3rXfR5DjxArq/QO0OTVBm3xlJA==} 1550 + '@tiptap/extension-strike@3.19.0': 1551 + resolution: {integrity: sha512-xYpabHsv7PccLUBQaP8AYiFCnYbx6P93RHPd0lgNwhdOjYFd931Zy38RyoxPHAgbYVmhf1iyx7lpuLtBnhS5dA==, tarball: https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.19.0.tgz} 1487 1552 peerDependencies: 1488 - '@tiptap/core': ^3.16.0 1553 + '@tiptap/core': ^3.19.0 1489 1554 1490 - '@tiptap/extension-placeholder@3.16.0': 1491 - resolution: {integrity: sha512-sbffATC2fwyRF9i483fSRj5MTADCqD1QUl4LCAt8VO+cVEQbV19WV5J7EQ8wIjDEoFoOKIUXdU/0CEcF4IpjDQ==} 1555 + '@tiptap/extension-text@3.19.0': 1556 + resolution: {integrity: sha512-K95+SnbZy0h6hNFtfy23n8t/nOcTFEf69In9TSFVVmwn/Nwlke+IfiESAkqbt1/7sKJeegRXYO7WzFEmFl9Q/g==, tarball: https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.19.0.tgz} 1492 1557 peerDependencies: 1493 - '@tiptap/extensions': ^3.16.0 1558 + '@tiptap/core': ^3.19.0 1494 1559 1495 - '@tiptap/extension-strike@3.16.0': 1496 - resolution: {integrity: sha512-l5/4+gii53kET7ETyYpbTumoQdZ6HwJLUcDlGHutLZlBCaZPxFTi5qgHQBhNq5KAzRH3LVJeb0fEeMi+yCZBQA==} 1560 + '@tiptap/extension-typography@3.19.0': 1561 + resolution: {integrity: sha512-2Rwwz1ErNhqUcXPzPX2u4frdyrK4Yj6ZMvCLPxLt5lQXj9Eq9YEoD9isw8abR105ko3BCidvfElQYSFu6dWPSw==, tarball: https://registry.npmjs.org/@tiptap/extension-typography/-/extension-typography-3.19.0.tgz} 1497 1562 peerDependencies: 1498 - '@tiptap/core': ^3.16.0 1563 + '@tiptap/core': ^3.19.0 1499 1564 1500 - '@tiptap/extension-text@3.16.0': 1501 - resolution: {integrity: sha512-KTewoX4wZq95cKnjBbogRwBFoGgM6qUg1yjCQ/M6Ajkp4Mtp8Iki9EiAxtfk76b/wtXFf3DsDhFOeVqgKyYbYg==} 1565 + '@tiptap/extension-underline@3.19.0': 1566 + resolution: {integrity: sha512-800MGEWfG49j10wQzAFiW/ele1HT04MamcL8iyuPNu7ZbjbGN2yknvdrJlRy7hZlzIrVkZMr/1tz62KN33VHIw==, tarball: https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.19.0.tgz} 1502 1567 peerDependencies: 1503 - '@tiptap/core': ^3.16.0 1568 + '@tiptap/core': ^3.19.0 1504 1569 1505 - '@tiptap/extension-underline@3.16.0': 1506 - resolution: {integrity: sha512-obXAPgHVZocMaW6HtKyCYsN4CxHogWr23gioyEQcpIX0LeegHDqxkoPrjIPX6Tn1isDyvXchcSKWHEfiHO3ZOA==} 1570 + '@tiptap/extensions@3.19.0': 1571 + resolution: {integrity: sha512-ZmGUhLbMWaGqnJh2Bry+6V4M6gMpUDYo4D1xNux5Gng/E/eYtc+PMxMZ/6F7tNTAuujLBOQKj6D+4SsSm457jw==, tarball: https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.19.0.tgz} 1507 1572 peerDependencies: 1508 - '@tiptap/core': ^3.16.0 1573 + '@tiptap/core': ^3.19.0 1574 + '@tiptap/pm': ^3.19.0 1509 1575 1510 - '@tiptap/extensions@3.16.0': 1511 - resolution: {integrity: sha512-0iVrn0FHcHIRMdsQLQbf16NgYrKz+Sup/8dDMVBy1QoHn5Hb51QZABqXJTZ6u7My34b4fNZrSggzBAE7l7N/pA==} 1576 + '@tiptap/markdown@3.19.0': 1577 + resolution: {integrity: sha512-Pnfacq2FHky1rqwmGwEmUJxuZu8VZ8XjaJIqsQC34S3CQWiOU+PukC9In2odzcooiVncLWT9s97jKuYpbmF1tQ==, tarball: https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.19.0.tgz} 1512 1578 peerDependencies: 1513 - '@tiptap/core': ^3.16.0 1514 - '@tiptap/pm': ^3.16.0 1579 + '@tiptap/core': ^3.19.0 1580 + '@tiptap/pm': ^3.19.0 1581 + 1582 + '@tiptap/pm@3.19.0': 1583 + resolution: {integrity: sha512-789zcnM4a8OWzvbD2DL31d0wbSm9BVeO/R7PLQwLIGysDI3qzrcclyZ8yhqOEVuvPitRRwYLq+mY14jz7kY4cw==, tarball: https://registry.npmjs.org/@tiptap/pm/-/pm-3.19.0.tgz} 1515 1584 1516 - '@tiptap/pm@3.16.0': 1517 - resolution: {integrity: sha512-FMxZ6Tc5ONKa/EByDV8lswct6YW2lF/wn11zqXmrfBZhdG7UQPTijpSwb6TCqaO5GOHmixaIaDPj+zimUREHQA==} 1585 + '@tiptap/starter-kit@3.19.0': 1586 + resolution: {integrity: sha512-dTCkHEz+Y8ADxX7h+xvl6caAj+3nII/wMB1rTQchSuNKqJTOrzyUsCWm094+IoZmLT738wANE0fRIgziNHs/ug==, tarball: https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.19.0.tgz} 1518 1587 1519 - '@tiptap/starter-kit@3.16.0': 1520 - resolution: {integrity: sha512-eWi+77SgKyhSx91Hmn32ER+gPN6FfInGtod4A+XxSG+LqS/sn6kpUEdowYrnqiZzhUXZCSTSJvC+UcMUZHOkxQ==} 1588 + '@tiptap/suggestion@3.19.0': 1589 + resolution: {integrity: sha512-tUZwMRFqTVPIo566ZmHNRteyZxJy2EE4FA+S3IeIUOOvY6AW0h1imhbpBO7sXV8CeEQvpa+2DWwLvy7L3vmstA==, tarball: https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.19.0.tgz} 1590 + peerDependencies: 1591 + '@tiptap/core': ^3.19.0 1592 + '@tiptap/pm': ^3.19.0 1521 1593 1522 1594 '@tweenjs/tween.js@23.1.3': 1523 1595 resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} ··· 1534 1606 '@types/geojson@7946.0.16': 1535 1607 resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==, tarball: https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz} 1536 1608 1609 + '@types/hast@3.0.4': 1610 + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==, tarball: https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz} 1611 + 1537 1612 '@types/json-schema@7.0.15': 1538 1613 resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} 1539 1614 1540 1615 '@types/linkify-it@5.0.0': 1541 - resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} 1616 + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==, tarball: https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz} 1542 1617 1543 1618 '@types/markdown-it@14.1.2': 1544 - resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} 1619 + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==, tarball: https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz} 1545 1620 1546 1621 '@types/mdurl@2.0.0': 1547 - resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} 1622 + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==, tarball: https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz} 1548 1623 1549 1624 '@types/node@25.0.10': 1550 1625 resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==} ··· 1563 1638 1564 1639 '@types/turndown@5.0.6': 1565 1640 resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} 1641 + 1642 + '@types/unist@3.0.3': 1643 + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==, tarball: https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz} 1566 1644 1567 1645 '@types/webxr@0.5.24': 1568 1646 resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} ··· 1771 1849 resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} 1772 1850 1773 1851 crelt@1.0.6: 1774 - resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} 1852 + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==, tarball: https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz} 1775 1853 1776 1854 cross-spawn@7.0.6: 1777 1855 resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} ··· 1835 1913 devalue@5.6.2: 1836 1914 resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==} 1837 1915 1916 + devlop@1.1.0: 1917 + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==, tarball: https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz} 1918 + 1838 1919 diet-sprite@0.0.1: 1839 1920 resolution: {integrity: sha512-zSHI2WDAn1wJqJYxcmjWfJv3Iw8oL9reQIbEyx2x2/EZ4/qmUTIo8/5qOCurnAcq61EwtJJaZ0XTy2NRYqpB5A==} 1840 1921 ··· 2056 2137 resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} 2057 2138 engines: {node: '>=6'} 2058 2139 2140 + highlight.js@11.11.1: 2141 + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==, tarball: https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz} 2142 + engines: {node: '>=12.0.0'} 2143 + 2059 2144 hls.js@1.6.15: 2060 2145 resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==} 2061 2146 ··· 2236 2321 engines: {node: '>=18'} 2237 2322 2238 2323 linkify-it@5.0.0: 2239 - resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} 2324 + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==, tarball: https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz} 2240 2325 2241 2326 linkifyjs@4.3.2: 2242 - resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} 2327 + resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==, tarball: https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz} 2243 2328 2244 2329 loadjs@4.3.0: 2245 2330 resolution: {integrity: sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==} ··· 2257 2342 loose-envify@1.4.0: 2258 2343 resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==, tarball: https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz} 2259 2344 hasBin: true 2345 + 2346 + lowlight@3.3.0: 2347 + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==, tarball: https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz} 2260 2348 2261 2349 lz-string@1.5.0: 2262 2350 resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} ··· 2276 2364 engines: {node: '>=16.14.0', npm: '>=8.1.0'} 2277 2365 2278 2366 markdown-it@14.1.0: 2279 - resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} 2367 + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==, tarball: https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz} 2280 2368 hasBin: true 2281 2369 2282 2370 marked@17.0.1: ··· 2285 2373 hasBin: true 2286 2374 2287 2375 mdurl@2.0.0: 2288 - resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} 2376 + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==, tarball: https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz} 2289 2377 2290 2378 meshoptimizer@0.18.1: 2291 2379 resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==} ··· 2372 2460 engines: {node: '>= 0.8.0'} 2373 2461 2374 2462 orderedmap@2.1.1: 2375 - resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} 2463 + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==, tarball: https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz} 2376 2464 2377 2465 p-limit@3.1.0: 2378 2466 resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} ··· 2554 2642 resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==, tarball: https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz} 2555 2643 2556 2644 prosemirror-changeset@2.3.1: 2557 - resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==} 2645 + resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==, tarball: https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz} 2558 2646 2559 2647 prosemirror-collab@1.3.1: 2560 - resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==} 2648 + resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==, tarball: https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz} 2561 2649 2562 2650 prosemirror-commands@1.7.1: 2563 - resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} 2651 + resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==, tarball: https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz} 2564 2652 2565 2653 prosemirror-dropcursor@1.8.2: 2566 - resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==} 2654 + resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==, tarball: https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz} 2567 2655 2568 2656 prosemirror-gapcursor@1.4.0: 2569 - resolution: {integrity: sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==} 2657 + resolution: {integrity: sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==, tarball: https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz} 2570 2658 2571 2659 prosemirror-history@1.5.0: 2572 - resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} 2660 + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==, tarball: https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz} 2573 2661 2574 2662 prosemirror-inputrules@1.5.1: 2575 - resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} 2663 + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==, tarball: https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz} 2576 2664 2577 2665 prosemirror-keymap@1.2.3: 2578 - resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} 2666 + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==, tarball: https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz} 2579 2667 2580 2668 prosemirror-markdown@1.13.3: 2581 - resolution: {integrity: sha512-3E+Et6cdXIH0EgN2tGYQ+EBT7N4kMiZFsW+hzx+aPtOmADDHWCdd2uUQb7yklJrfUYUOjEEu22BiN6UFgPe4cQ==} 2669 + resolution: {integrity: sha512-3E+Et6cdXIH0EgN2tGYQ+EBT7N4kMiZFsW+hzx+aPtOmADDHWCdd2uUQb7yklJrfUYUOjEEu22BiN6UFgPe4cQ==, tarball: https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.3.tgz} 2582 2670 2583 2671 prosemirror-menu@1.2.5: 2584 - resolution: {integrity: sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==} 2672 + resolution: {integrity: sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==, tarball: https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz} 2585 2673 2586 2674 prosemirror-model@1.25.4: 2587 - resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} 2675 + resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==, tarball: https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz} 2588 2676 2589 2677 prosemirror-schema-basic@1.2.4: 2590 - resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==} 2678 + resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==, tarball: https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz} 2591 2679 2592 2680 prosemirror-schema-list@1.5.1: 2593 - resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} 2681 + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==, tarball: https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz} 2594 2682 2595 2683 prosemirror-state@1.4.4: 2596 - resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} 2684 + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==, tarball: https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz} 2597 2685 2598 2686 prosemirror-tables@1.8.5: 2599 - resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} 2687 + resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==, tarball: https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz} 2600 2688 2601 2689 prosemirror-trailing-node@3.0.0: 2602 - resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==} 2690 + resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==, tarball: https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz} 2603 2691 peerDependencies: 2604 2692 prosemirror-model: ^1.22.1 2605 2693 prosemirror-state: ^1.4.2 2606 2694 prosemirror-view: ^1.33.8 2607 2695 2608 2696 prosemirror-transform@1.11.0: 2609 - resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==} 2697 + resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==, tarball: https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz} 2610 2698 2611 2699 prosemirror-view@1.41.5: 2612 - resolution: {integrity: sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==} 2700 + resolution: {integrity: sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==, tarball: https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.5.tgz} 2613 2701 2614 2702 protocol-buffers-schema@3.6.0: 2615 2703 resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==, tarball: https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz} 2616 2704 2617 2705 punycode.js@2.3.1: 2618 - resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} 2706 + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==, tarball: https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz} 2619 2707 engines: {node: '>=6'} 2620 2708 2621 2709 punycode@2.3.1: ··· 2693 2781 hasBin: true 2694 2782 2695 2783 rope-sequence@1.3.4: 2696 - resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} 2784 + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==, tarball: https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz} 2697 2785 2698 2786 runed@0.23.4: 2699 2787 resolution: {integrity: sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==} ··· 2831 2919 peerDependencies: 2832 2920 svelte: ^5.0.0 2833 2921 2922 + svelte-tiptap@3.0.1: 2923 + resolution: {integrity: sha512-Vi3kVGOd01f7mslOxGbJB7z2QavdvH+6WffhB+Y5fleTiZaW0YWqIboyO2u/uh4BQeosiINmmuRJ+Qwb7mYP+A==, tarball: https://registry.npmjs.org/svelte-tiptap/-/svelte-tiptap-3.0.1.tgz} 2924 + peerDependencies: 2925 + '@floating-ui/dom': ^1.0.0 2926 + '@tiptap/core': ^3.0.0 2927 + '@tiptap/extension-bubble-menu': ^3.0.0 2928 + '@tiptap/extension-floating-menu': ^3.0.0 2929 + '@tiptap/pm': ^3.0.0 2930 + svelte: ^5.0.0 2931 + 2834 2932 svelte-toolbelt@0.10.6: 2835 2933 resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==} 2836 2934 engines: {node: '>=18', pnpm: '>=8.7.0'} ··· 2970 3068 hasBin: true 2971 3069 2972 3070 uc.micro@2.1.0: 2973 - resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} 3071 + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==, tarball: https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz} 2974 3072 2975 3073 ufo@1.6.3: 2976 3074 resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} ··· 3062 3160 optional: true 3063 3161 3064 3162 w3c-keyname@2.2.8: 3065 - resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} 3163 + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==, tarball: https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz} 3066 3164 3067 3165 webgl-sdf-generator@1.1.1: 3068 3166 resolution: {integrity: sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==} ··· 3531 3629 dependencies: 3532 3630 '@floating-ui/utils': 0.2.10 3533 3631 3534 - '@floating-ui/dom@1.7.4': 3632 + '@floating-ui/core@1.7.4': 3633 + dependencies: 3634 + '@floating-ui/utils': 0.2.10 3635 + 3636 + '@floating-ui/dom@1.7.5': 3535 3637 dependencies: 3536 - '@floating-ui/core': 1.7.3 3638 + '@floating-ui/core': 1.7.4 3537 3639 '@floating-ui/utils': 0.2.10 3538 3640 3539 3641 '@floating-ui/utils@0.2.10': {} ··· 4104 4206 transitivePeerDependencies: 4105 4207 - '@types/three' 4106 4208 4107 - '@tiptap/core@3.16.0(@tiptap/pm@3.16.0)': 4209 + '@tiptap/core@3.19.0(@tiptap/pm@3.19.0)': 4210 + dependencies: 4211 + '@tiptap/pm': 3.19.0 4212 + 4213 + '@tiptap/extension-blockquote@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': 4108 4214 dependencies: 4109 - '@tiptap/pm': 3.16.0 4215 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4110 4216 4111 - '@tiptap/extension-blockquote@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))': 4217 + '@tiptap/extension-bold@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': 4112 4218 dependencies: 4113 - '@tiptap/core': 3.16.0(@tiptap/pm@3.16.0) 4219 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4114 4220 4115 - '@tiptap/extension-bold@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))': 4221 + '@tiptap/extension-bubble-menu@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': 4116 4222 dependencies: 4117 - '@tiptap/core': 3.16.0(@tiptap/pm@3.16.0) 4223 + '@floating-ui/dom': 1.7.5 4224 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4225 + '@tiptap/pm': 3.19.0 4118 4226 4119 - '@tiptap/extension-bullet-list@3.16.0(@tiptap/extension-list@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0))': 4227 + '@tiptap/extension-bullet-list@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': 4120 4228 dependencies: 4121 - '@tiptap/extension-list': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0) 4229 + '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 4122 4230 4123 - '@tiptap/extension-code-block@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0)': 4231 + '@tiptap/extension-code-block-lowlight@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-code-block@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(highlight.js@11.11.1)(lowlight@3.3.0)': 4232 + dependencies: 4233 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4234 + '@tiptap/extension-code-block': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 4235 + '@tiptap/pm': 3.19.0 4236 + highlight.js: 11.11.1 4237 + lowlight: 3.3.0 4238 + 4239 + '@tiptap/extension-code-block@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': 4240 + dependencies: 4241 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4242 + '@tiptap/pm': 3.19.0 4243 + 4244 + '@tiptap/extension-code@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': 4124 4245 dependencies: 4125 - '@tiptap/core': 3.16.0(@tiptap/pm@3.16.0) 4126 - '@tiptap/pm': 3.16.0 4246 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4127 4247 4128 - '@tiptap/extension-code@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))': 4248 + '@tiptap/extension-document@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': 4129 4249 dependencies: 4130 - '@tiptap/core': 3.16.0(@tiptap/pm@3.16.0) 4250 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4131 4251 4132 - '@tiptap/extension-document@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))': 4252 + '@tiptap/extension-dropcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': 4133 4253 dependencies: 4134 - '@tiptap/core': 3.16.0(@tiptap/pm@3.16.0) 4254 + '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 4135 4255 4136 - '@tiptap/extension-dropcursor@3.16.0(@tiptap/extensions@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0))': 4256 + '@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': 4137 4257 dependencies: 4138 - '@tiptap/extensions': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0) 4258 + '@floating-ui/dom': 1.7.5 4259 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4260 + '@tiptap/pm': 3.19.0 4139 4261 4140 - '@tiptap/extension-gapcursor@3.16.0(@tiptap/extensions@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0))': 4262 + '@tiptap/extension-gapcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': 4141 4263 dependencies: 4142 - '@tiptap/extensions': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0) 4264 + '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 4143 4265 4144 - '@tiptap/extension-hard-break@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))': 4266 + '@tiptap/extension-hard-break@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': 4145 4267 dependencies: 4146 - '@tiptap/core': 3.16.0(@tiptap/pm@3.16.0) 4268 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4147 4269 4148 - '@tiptap/extension-heading@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))': 4270 + '@tiptap/extension-heading@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': 4149 4271 dependencies: 4150 - '@tiptap/core': 3.16.0(@tiptap/pm@3.16.0) 4272 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4151 4273 4152 - '@tiptap/extension-horizontal-rule@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0)': 4274 + '@tiptap/extension-horizontal-rule@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': 4153 4275 dependencies: 4154 - '@tiptap/core': 3.16.0(@tiptap/pm@3.16.0) 4155 - '@tiptap/pm': 3.16.0 4276 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4277 + '@tiptap/pm': 3.19.0 4156 4278 4157 - '@tiptap/extension-image@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))': 4279 + '@tiptap/extension-image@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': 4158 4280 dependencies: 4159 - '@tiptap/core': 3.16.0(@tiptap/pm@3.16.0) 4281 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4160 4282 4161 - '@tiptap/extension-italic@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))': 4283 + '@tiptap/extension-italic@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': 4162 4284 dependencies: 4163 - '@tiptap/core': 3.16.0(@tiptap/pm@3.16.0) 4285 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4164 4286 4165 - '@tiptap/extension-link@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0)': 4287 + '@tiptap/extension-link@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': 4166 4288 dependencies: 4167 - '@tiptap/core': 3.16.0(@tiptap/pm@3.16.0) 4168 - '@tiptap/pm': 3.16.0 4289 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4290 + '@tiptap/pm': 3.19.0 4169 4291 linkifyjs: 4.3.2 4170 4292 4171 - '@tiptap/extension-list-item@3.16.0(@tiptap/extension-list@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0))': 4293 + '@tiptap/extension-list-item@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': 4172 4294 dependencies: 4173 - '@tiptap/extension-list': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0) 4295 + '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 4174 4296 4175 - '@tiptap/extension-list-keymap@3.16.0(@tiptap/extension-list@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0))': 4297 + '@tiptap/extension-list-keymap@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': 4176 4298 dependencies: 4177 - '@tiptap/extension-list': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0) 4299 + '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 4178 4300 4179 - '@tiptap/extension-list@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0)': 4301 + '@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': 4180 4302 dependencies: 4181 - '@tiptap/core': 3.16.0(@tiptap/pm@3.16.0) 4182 - '@tiptap/pm': 3.16.0 4303 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4304 + '@tiptap/pm': 3.19.0 4305 + 4306 + '@tiptap/extension-ordered-list@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': 4307 + dependencies: 4308 + '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 4309 + 4310 + '@tiptap/extension-paragraph@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': 4311 + dependencies: 4312 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4183 4313 4184 - '@tiptap/extension-ordered-list@3.16.0(@tiptap/extension-list@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0))': 4314 + '@tiptap/extension-placeholder@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': 4185 4315 dependencies: 4186 - '@tiptap/extension-list': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0) 4316 + '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 4187 4317 4188 - '@tiptap/extension-paragraph@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))': 4318 + '@tiptap/extension-strike@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': 4189 4319 dependencies: 4190 - '@tiptap/core': 3.16.0(@tiptap/pm@3.16.0) 4320 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4191 4321 4192 - '@tiptap/extension-placeholder@3.16.0(@tiptap/extensions@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0))': 4322 + '@tiptap/extension-text@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': 4193 4323 dependencies: 4194 - '@tiptap/extensions': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0) 4324 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4195 4325 4196 - '@tiptap/extension-strike@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))': 4326 + '@tiptap/extension-typography@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': 4197 4327 dependencies: 4198 - '@tiptap/core': 3.16.0(@tiptap/pm@3.16.0) 4328 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4199 4329 4200 - '@tiptap/extension-text@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))': 4330 + '@tiptap/extension-underline@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': 4201 4331 dependencies: 4202 - '@tiptap/core': 3.16.0(@tiptap/pm@3.16.0) 4332 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4203 4333 4204 - '@tiptap/extension-underline@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))': 4334 + '@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': 4205 4335 dependencies: 4206 - '@tiptap/core': 3.16.0(@tiptap/pm@3.16.0) 4336 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4337 + '@tiptap/pm': 3.19.0 4207 4338 4208 - '@tiptap/extensions@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0)': 4339 + '@tiptap/markdown@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': 4209 4340 dependencies: 4210 - '@tiptap/core': 3.16.0(@tiptap/pm@3.16.0) 4211 - '@tiptap/pm': 3.16.0 4341 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4342 + '@tiptap/pm': 3.19.0 4343 + marked: 17.0.1 4212 4344 4213 - '@tiptap/pm@3.16.0': 4345 + '@tiptap/pm@3.19.0': 4214 4346 dependencies: 4215 4347 prosemirror-changeset: 2.3.1 4216 4348 prosemirror-collab: 1.3.1 ··· 4231 4363 prosemirror-transform: 1.11.0 4232 4364 prosemirror-view: 1.41.5 4233 4365 4234 - '@tiptap/starter-kit@3.16.0': 4366 + '@tiptap/starter-kit@3.19.0': 4235 4367 dependencies: 4236 - '@tiptap/core': 3.16.0(@tiptap/pm@3.16.0) 4237 - '@tiptap/extension-blockquote': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0)) 4238 - '@tiptap/extension-bold': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0)) 4239 - '@tiptap/extension-bullet-list': 3.16.0(@tiptap/extension-list@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0)) 4240 - '@tiptap/extension-code': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0)) 4241 - '@tiptap/extension-code-block': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0) 4242 - '@tiptap/extension-document': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0)) 4243 - '@tiptap/extension-dropcursor': 3.16.0(@tiptap/extensions@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0)) 4244 - '@tiptap/extension-gapcursor': 3.16.0(@tiptap/extensions@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0)) 4245 - '@tiptap/extension-hard-break': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0)) 4246 - '@tiptap/extension-heading': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0)) 4247 - '@tiptap/extension-horizontal-rule': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0) 4248 - '@tiptap/extension-italic': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0)) 4249 - '@tiptap/extension-link': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0) 4250 - '@tiptap/extension-list': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0) 4251 - '@tiptap/extension-list-item': 3.16.0(@tiptap/extension-list@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0)) 4252 - '@tiptap/extension-list-keymap': 3.16.0(@tiptap/extension-list@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0)) 4253 - '@tiptap/extension-ordered-list': 3.16.0(@tiptap/extension-list@3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0)) 4254 - '@tiptap/extension-paragraph': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0)) 4255 - '@tiptap/extension-strike': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0)) 4256 - '@tiptap/extension-text': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0)) 4257 - '@tiptap/extension-underline': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0)) 4258 - '@tiptap/extensions': 3.16.0(@tiptap/core@3.16.0(@tiptap/pm@3.16.0))(@tiptap/pm@3.16.0) 4259 - '@tiptap/pm': 3.16.0 4368 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4369 + '@tiptap/extension-blockquote': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) 4370 + '@tiptap/extension-bold': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) 4371 + '@tiptap/extension-bullet-list': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) 4372 + '@tiptap/extension-code': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) 4373 + '@tiptap/extension-code-block': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 4374 + '@tiptap/extension-document': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) 4375 + '@tiptap/extension-dropcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) 4376 + '@tiptap/extension-gapcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) 4377 + '@tiptap/extension-hard-break': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) 4378 + '@tiptap/extension-heading': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) 4379 + '@tiptap/extension-horizontal-rule': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 4380 + '@tiptap/extension-italic': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) 4381 + '@tiptap/extension-link': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 4382 + '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 4383 + '@tiptap/extension-list-item': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) 4384 + '@tiptap/extension-list-keymap': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) 4385 + '@tiptap/extension-ordered-list': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) 4386 + '@tiptap/extension-paragraph': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) 4387 + '@tiptap/extension-strike': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) 4388 + '@tiptap/extension-text': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) 4389 + '@tiptap/extension-underline': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) 4390 + '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 4391 + '@tiptap/pm': 3.19.0 4392 + 4393 + '@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': 4394 + dependencies: 4395 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 4396 + '@tiptap/pm': 3.19.0 4260 4397 4261 4398 '@tweenjs/tween.js@23.1.3': {} 4262 4399 ··· 4269 4406 '@types/estree@1.0.8': {} 4270 4407 4271 4408 '@types/geojson@7946.0.16': {} 4409 + 4410 + '@types/hast@3.0.4': 4411 + dependencies: 4412 + '@types/unist': 3.0.3 4272 4413 4273 4414 '@types/json-schema@7.0.15': {} 4274 4415 ··· 4306 4447 4307 4448 '@types/turndown@5.0.6': {} 4308 4449 4450 + '@types/unist@3.0.3': {} 4451 + 4309 4452 '@types/webxr@0.5.24': {} 4310 4453 4311 4454 '@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': ··· 4443 4586 bits-ui@1.8.0(svelte@5.48.0): 4444 4587 dependencies: 4445 4588 '@floating-ui/core': 1.7.3 4446 - '@floating-ui/dom': 1.7.4 4589 + '@floating-ui/dom': 1.7.5 4447 4590 '@internationalized/date': 3.10.1 4448 4591 css.escape: 1.5.1 4449 4592 esm-env: 1.2.2 ··· 4455 4598 bits-ui@2.15.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0): 4456 4599 dependencies: 4457 4600 '@floating-ui/core': 1.7.3 4458 - '@floating-ui/dom': 1.7.4 4601 + '@floating-ui/dom': 1.7.5 4459 4602 '@internationalized/date': 3.10.1 4460 4603 esm-env: 1.2.2 4461 4604 runed: 0.35.1(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0) ··· 4605 4748 4606 4749 devalue@5.6.2: {} 4607 4750 4751 + devlop@1.1.0: 4752 + dependencies: 4753 + dequal: 2.0.3 4754 + 4608 4755 diet-sprite@0.0.1: {} 4609 4756 4610 4757 dom-serializer@2.0.0: ··· 4872 5019 4873 5020 hex-rgb@4.3.0: {} 4874 5021 5022 + highlight.js@11.11.1: {} 5023 + 4875 5024 hls.js@1.6.15: {} 4876 5025 4877 5026 htmlparser2@10.1.0: ··· 5035 5184 loose-envify@1.4.0: 5036 5185 dependencies: 5037 5186 js-tokens: 4.0.0 5187 + 5188 + lowlight@3.3.0: 5189 + dependencies: 5190 + '@types/hast': 3.0.4 5191 + devlop: 1.1.0 5192 + highlight.js: 11.11.1 5038 5193 5039 5194 lz-string@1.5.0: {} 5040 5195 ··· 5653 5808 svelte-sonner@1.0.7(svelte@5.48.0): 5654 5809 dependencies: 5655 5810 runed: 0.28.0(svelte@5.48.0) 5811 + svelte: 5.48.0 5812 + 5813 + svelte-tiptap@3.0.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-bubble-menu@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(svelte@5.48.0): 5814 + dependencies: 5815 + '@floating-ui/dom': 1.7.5 5816 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) 5817 + '@tiptap/extension-bubble-menu': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 5818 + '@tiptap/extension-floating-menu': 3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) 5819 + '@tiptap/pm': 3.19.0 5656 5820 svelte: 5.48.0 5657 5821 5658 5822 svelte-toolbelt@0.10.6(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)))(svelte@5.48.0):
+2 -1
src/lib/atproto/settings.ts
··· 24 24 'site.standard.publication', 25 25 'site.standard.document', 26 26 'xyz.statusphere.status', 27 - 'community.lexicon.calendar.rsvp' 27 + 'community.lexicon.calendar.rsvp', 28 + 'community.lexicon.calendar.event' 28 29 ], 29 30 30 31 // what types of authenticated proxied requests you can make to services
+2 -2
src/lib/cards/content/StandardSiteDocumentListCard/index.ts
··· 18 18 for (const record of records) { 19 19 const site = record.value.site as string; 20 20 21 - if (site.startsWith('at://')) { 21 + if (site && site.startsWith('at://')) { 22 22 if (!publications[site]) { 23 23 const siteParts = parseUri(site); 24 24 ··· 37 37 38 38 record.value.href = publications[site] + record.value.path; 39 39 } else { 40 - record.value.href = site + record.value.path; 40 + record.value.href = (site ?? '') + record.value.path; 41 41 } 42 42 } 43 43
+13 -13
src/lib/cards/social/EventCard/CreateEventCardModal.svelte
··· 2 2 import { Alert, Button, Input, Subheading } from '@foxui/core'; 3 3 import Modal from '$lib/components/modal/Modal.svelte'; 4 4 import type { CreationModalComponentProps } from '../../types'; 5 + import { getRecord } from '$lib/atproto/methods'; 6 + import type { Did } from '@atcute/lexicons'; 5 7 6 8 const EVENT_COLLECTION = 'community.lexicon.calendar.event'; 7 9 ··· 38 40 throw new Error('Invalid URL format'); 39 41 } 40 42 41 - // Validate the event exists by fetching it 42 - const response = await fetch( 43 - `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(parsed.did)}&record_key=${encodeURIComponent(parsed.rkey)}` 44 - ); 43 + // Validate the event exists by fetching the record directly 44 + const record = await getRecord({ 45 + did: parsed.did as Did, 46 + collection: EVENT_COLLECTION, 47 + rkey: parsed.rkey 48 + }); 45 49 46 - if (!response.ok) { 50 + if (!record?.value) { 47 51 throw new Error('Event not found'); 48 52 } 49 53 ··· 55 59 errorMessage = 56 60 err instanceof Error && err.message === 'Event not found' 57 61 ? "Couldn't find that event. Please check the URL and try again." 58 - : 'Invalid URL. Please enter a valid smokesignal.events URL or AT URI.'; 62 + : 'Invalid URL. Please enter a valid event AT URI or smokesignal.events URL.'; 59 63 return false; 60 64 } finally { 61 65 isValidating = false; ··· 70 74 }} 71 75 class="flex flex-col gap-2" 72 76 > 73 - <Subheading>Enter a Smoke Signal event URL</Subheading> 77 + <Subheading>Enter an event URL</Subheading> 74 78 <Input 75 79 bind:value={eventUrl} 76 - placeholder="https://smokesignal.events/did:.../..." 80 + placeholder="at://did:.../community.lexicon.calendar.event/..." 77 81 class="mt-4" 78 82 /> 79 83 ··· 82 86 {/if} 83 87 84 88 <p class="text-base-500 dark:text-base-400 mt-2 text-xs"> 85 - Paste a URL from <a 86 - href="https://smokesignal.events" 87 - class="text-accent-800 dark:text-accent-300" 88 - target="_blank">smokesignal.events</a 89 - > or an AT URI for a calendar event. 89 + Paste an AT URI for a calendar event or a smokesignal.events URL. 90 90 </p> 91 91 92 92 <div class="mt-4 flex justify-end gap-2">
+4 -53
src/lib/cards/social/EventCard/EventCard.svelte
··· 91 91 } 92 92 93 93 let eventUrl = $derived(() => { 94 - if (eventData?.url) return eventData.url; 95 94 if (parsedUri) { 96 - return `https://smokesignal.events/${parsedUri.repo}/${parsedUri.rkey}`; 95 + return `/${parsedUri.repo}/events/${parsedUri.rkey}`; 97 96 } 98 97 return '#'; 99 98 }); ··· 144 143 </div> 145 144 146 145 {#if isMobile() ? item.mobileW > 4 : item.w > 2} 147 - <Button href={eventUrl()} target="_blank" rel="noopener noreferrer" class="z-50" 148 - >View event</Button 149 - > 146 + <Button href={eventUrl()} target="_blank" class="z-50">View event</Button> 150 147 {/if} 151 148 </div> 152 149 ··· 211 208 {eventData.description} 212 209 </p> 213 210 {/if} 214 - 215 - {#if (eventData.countGoing !== undefined || eventData.countInterested !== undefined) && ((isMobile() && item.mobileH >= 4) || (!isMobile() && item.h >= 3))} 216 - <div 217 - class="text-base-600 dark:text-base-400 accent:text-base-800 flex flex-wrap gap-3 text-xs" 218 - > 219 - {#if eventData.countGoing !== undefined} 220 - <div class="flex items-center gap-1"> 221 - <svg 222 - xmlns="http://www.w3.org/2000/svg" 223 - fill="none" 224 - viewBox="0 0 24 24" 225 - stroke-width="1.5" 226 - stroke="currentColor" 227 - class="size-4" 228 - > 229 - <path 230 - stroke-linecap="round" 231 - stroke-linejoin="round" 232 - d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" 233 - /> 234 - </svg> 235 - <span>{eventData.countGoing} going</span> 236 - </div> 237 - {/if} 238 - {#if eventData.countInterested !== undefined} 239 - <div class="flex items-center gap-1"> 240 - <svg 241 - xmlns="http://www.w3.org/2000/svg" 242 - fill="none" 243 - viewBox="0 0 24 24" 244 - stroke-width="1.5" 245 - stroke="currentColor" 246 - class="size-4" 247 - > 248 - <path 249 - stroke-linecap="round" 250 - stroke-linejoin="round" 251 - d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" 252 - /> 253 - </svg> 254 - <span>{eventData.countInterested} interested</span> 255 - </div> 256 - {/if} 257 - </div> 258 - {/if} 259 211 </div> 260 212 261 213 {#if showImage} ··· 267 219 268 220 <a 269 221 href={eventUrl()} 270 - class="absolute inset-0 h-full w-full" 271 222 target="_blank" 272 - rel="noopener noreferrer" 223 + class="absolute inset-0 h-full w-full" 273 224 use:qrOverlay={{ 274 225 context: { 275 226 title: eventData?.name ?? '' 276 227 } 277 228 }} 278 229 > 279 - <span class="sr-only">View event on smokesignal.events</span> 230 + <span class="sr-only">View event</span> 280 231 </a> 281 232 {:else if isLoaded} 282 233 <div class="flex h-full w-full items-center justify-center">
+15 -11
src/lib/cards/social/EventCard/index.ts
··· 1 - import { parseUri } from '$lib/atproto'; 1 + import { parseUri, getRecord } from '$lib/atproto'; 2 2 import type { CardDefinition } from '../../types'; 3 3 import CreateEventCardModal from './CreateEventCardModal.svelte'; 4 4 import EventCard from './EventCard.svelte'; 5 + import type { Did } from '@atcute/lexicons'; 5 6 6 7 const EVENT_COLLECTION = 'community.lexicon.calendar.event'; 7 8 ··· 34 35 width: number; 35 36 height: number; 36 37 }; 38 + }>; 39 + facets?: Array<{ 40 + index: { byteStart: number; byteEnd: number }; 41 + features: Array<{ $type: string; [key: string]: unknown }>; 37 42 }>; 38 43 uris?: Array<{ 39 44 uri: string; 40 45 name?: string; 41 46 }>; 42 - countGoing?: number; 43 - countInterested?: number; 44 - url: string; 47 + url?: string; 45 48 }; 46 49 47 50 export const EventCardDefinition = { ··· 66 69 if (!parsedUri || !parsedUri.rkey || !parsedUri.repo) continue; 67 70 68 71 try { 69 - const response = await fetch( 70 - `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(parsedUri.repo)}&record_key=${encodeURIComponent(parsedUri.rkey)}` 71 - ); 72 + const record = await getRecord({ 73 + did: parsedUri.repo as Did, 74 + collection: EVENT_COLLECTION, 75 + rkey: parsedUri.rkey 76 + }); 72 77 73 - if (response.ok) { 74 - const data = await response.json(); 75 - eventDataMap[item.id] = data as EventData; 78 + if (record?.value) { 79 + eventDataMap[item.id] = record.value as EventData; 76 80 } 77 81 } catch (error) { 78 82 console.error('Failed to fetch event data:', error); ··· 118 122 119 123 name: 'Event', 120 124 121 - keywords: ['calendar', 'meetup', 'schedule', 'date', 'rsvp'], 125 + keywords: ['calendar', 'meetup', 'schedule', 'date', 'rsvp', 'smokesignal'], 122 126 groups: ['Social'], 123 127 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" /></svg>` 124 128 } as CardDefinition & { type: 'event' };
+58
src/lib/components/image-store.ts
··· 1 + const DB_NAME = 'blento-images'; 2 + const STORE_NAME = 'images'; 3 + 4 + function openDB(): Promise<IDBDatabase> { 5 + return new Promise((resolve, reject) => { 6 + const request = indexedDB.open(DB_NAME, 1); 7 + request.onupgradeneeded = () => { 8 + request.result.createObjectStore(STORE_NAME); 9 + }; 10 + request.onsuccess = () => resolve(request.result); 11 + request.onerror = () => reject(request.error); 12 + }); 13 + } 14 + 15 + export async function putImage(key: string, blob: Blob, name: string): Promise<void> { 16 + const db = await openDB(); 17 + const data = await blob.arrayBuffer(); 18 + return new Promise((resolve, reject) => { 19 + const tx = db.transaction(STORE_NAME, 'readwrite'); 20 + tx.objectStore(STORE_NAME).put({ data, type: blob.type, name }, key); 21 + tx.oncomplete = () => resolve(); 22 + tx.onerror = () => reject(tx.error); 23 + }); 24 + } 25 + 26 + export async function getImage(key: string): Promise<{ blob: Blob; name: string } | null> { 27 + const db = await openDB(); 28 + return new Promise((resolve, reject) => { 29 + const tx = db.transaction(STORE_NAME, 'readonly'); 30 + const request = tx.objectStore(STORE_NAME).get(key); 31 + request.onsuccess = () => { 32 + if (!request.result) return resolve(null); 33 + const { data, type, name } = request.result; 34 + resolve({ blob: new Blob([data], { type }), name }); 35 + }; 36 + request.onerror = () => reject(request.error); 37 + }); 38 + } 39 + 40 + export async function deleteImage(key: string): Promise<void> { 41 + const db = await openDB(); 42 + return new Promise((resolve, reject) => { 43 + const tx = db.transaction(STORE_NAME, 'readwrite'); 44 + tx.objectStore(STORE_NAME).delete(key); 45 + tx.oncomplete = () => resolve(); 46 + tx.onerror = () => reject(tx.error); 47 + }); 48 + } 49 + 50 + export async function clearImages(): Promise<void> { 51 + const db = await openDB(); 52 + return new Promise((resolve, reject) => { 53 + const tx = db.transaction(STORE_NAME, 'readwrite'); 54 + tx.objectStore(STORE_NAME).clear(); 55 + tx.oncomplete = () => resolve(); 56 + tx.onerror = () => reject(tx.error); 57 + }); 58 + }
+120
src/lib/components/rich-text-editor/Icon.svelte
··· 1 + <script lang="ts"> 2 + import type { RichTextTypes } from '.'; 3 + 4 + let { 5 + name 6 + }: { 7 + name: RichTextTypes; 8 + } = $props(); 9 + </script> 10 + 11 + {#if name === 'paragraph'} 12 + <svg 13 + xmlns="http://www.w3.org/2000/svg" 14 + viewBox="0 0 24 24" 15 + fill="none" 16 + stroke="currentColor" 17 + stroke-width="2" 18 + stroke-linecap="round" 19 + stroke-linejoin="round" 20 + ><path d="M13 4v16" /><path d="M17 4v16" /><path d="M19 4H9.5a4.5 4.5 0 0 0 0 9H13" /></svg 21 + > 22 + {:else if name === 'heading-1'} 23 + <svg 24 + xmlns="http://www.w3.org/2000/svg" 25 + viewBox="0 0 24 24" 26 + fill="none" 27 + stroke="currentColor" 28 + stroke-width="2" 29 + stroke-linecap="round" 30 + stroke-linejoin="round" 31 + ><path d="M4 12h8" /><path d="M4 18V6" /><path d="M12 18V6" /><path d="m17 12 3-2v8" /></svg 32 + > 33 + {:else if name === 'heading-2'} 34 + <svg 35 + xmlns="http://www.w3.org/2000/svg" 36 + viewBox="0 0 24 24" 37 + fill="none" 38 + stroke="currentColor" 39 + stroke-width="2" 40 + stroke-linecap="round" 41 + stroke-linejoin="round" 42 + ><path d="M4 12h8" /><path d="M4 18V6" /><path d="M12 18V6" /><path 43 + d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1" 44 + /></svg 45 + > 46 + {:else if name === 'heading-3'} 47 + <svg 48 + xmlns="http://www.w3.org/2000/svg" 49 + viewBox="0 0 24 24" 50 + fill="none" 51 + stroke="currentColor" 52 + stroke-width="2" 53 + stroke-linecap="round" 54 + stroke-linejoin="round" 55 + ><path d="M4 12h8" /><path d="M4 18V6" /><path d="M12 18V6" /><path 56 + d="M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2" 57 + /><path d="M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2" /></svg 58 + > 59 + {:else if name === 'blockquote'} 60 + <svg 61 + xmlns="http://www.w3.org/2000/svg" 62 + viewBox="0 0 24 24" 63 + fill="none" 64 + stroke="currentColor" 65 + stroke-width="2" 66 + stroke-linecap="round" 67 + stroke-linejoin="round" 68 + ><path d="M17 6H3" /><path d="M21 12H8" /><path d="M21 18H8" /><path d="M3 12v6" /></svg 69 + > 70 + {:else if name === 'code'} 71 + <svg 72 + xmlns="http://www.w3.org/2000/svg" 73 + viewBox="0 0 24 24" 74 + fill="none" 75 + stroke="currentColor" 76 + stroke-width="2" 77 + stroke-linecap="round" 78 + stroke-linejoin="round" 79 + ><polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" /></svg 80 + > 81 + {:else if name === 'bullet-list'} 82 + <svg 83 + xmlns="http://www.w3.org/2000/svg" 84 + viewBox="0 0 24 24" 85 + fill="none" 86 + stroke="currentColor" 87 + stroke-width="2" 88 + stroke-linecap="round" 89 + stroke-linejoin="round" 90 + ><path d="M3 12h.01" /><path d="M3 18h.01" /><path d="M3 6h.01" /><path d="M8 12h13" /><path 91 + d="M8 18h13" 92 + /><path d="M8 6h13" /></svg 93 + > 94 + {:else if name === 'ordered-list'} 95 + <svg 96 + xmlns="http://www.w3.org/2000/svg" 97 + viewBox="0 0 24 24" 98 + fill="none" 99 + stroke="currentColor" 100 + stroke-width="2" 101 + stroke-linecap="round" 102 + stroke-linejoin="round" 103 + ><path d="M10 12h11" /><path d="M10 18h11" /><path d="M10 6h11" /><path d="M4 10h2" /><path 104 + d="M4 6h1v4" 105 + /><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" /></svg 106 + > 107 + {:else if name === 'image'} 108 + <svg 109 + xmlns="http://www.w3.org/2000/svg" 110 + viewBox="0 0 24 24" 111 + fill="none" 112 + stroke="currentColor" 113 + stroke-width="2" 114 + stroke-linecap="round" 115 + stroke-linejoin="round" 116 + ><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /><circle cx="9" cy="9" r="2" /><path 117 + d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" 118 + /></svg 119 + > 120 + {/if}
+415
src/lib/components/rich-text-editor/RichTextEditor.svelte
··· 1 + <script lang="ts"> 2 + import { onMount, onDestroy } from 'svelte'; 3 + import { Editor, mergeAttributes, type Content } from '@tiptap/core'; 4 + import StarterKit from '@tiptap/starter-kit'; 5 + import Placeholder from '@tiptap/extension-placeholder'; 6 + import Image from '@tiptap/extension-image'; 7 + import { all, createLowlight } from 'lowlight'; 8 + import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; 9 + import BubbleMenu from '@tiptap/extension-bubble-menu'; 10 + import Underline from '@tiptap/extension-underline'; 11 + import RichTextEditorMenu from './RichTextEditorMenu.svelte'; 12 + import type { RichTextTypes } from '.'; 13 + import RichTextEditorLinkMenu from './RichTextEditorLinkMenu.svelte'; 14 + import Slash, { suggestion } from './slash-menu'; 15 + import Typography from '@tiptap/extension-typography'; 16 + import { Markdown } from '@tiptap/markdown'; 17 + import { RichTextLink } from './RichTextLink'; 18 + 19 + import './code.css'; 20 + import { cn } from '@foxui/core'; 21 + import { ImageUploadNode } from './image-upload/ImageUploadNode'; 22 + import { Transaction } from '@tiptap/pm/state'; 23 + 24 + let { 25 + content = $bindable({}), 26 + placeholder = 'Write or press / for commands', 27 + editor = $bindable(null), 28 + ref = $bindable(null), 29 + class: className, 30 + onupdate, 31 + ontransaction 32 + }: { 33 + content?: Content; 34 + placeholder?: string; 35 + editor?: Editor | null; 36 + ref?: HTMLDivElement | null; 37 + class?: string; 38 + onupdate?: (content: Content, context: { editor: Editor; transaction: Transaction }) => void; 39 + ontransaction?: () => void; 40 + } = $props(); 41 + 42 + const lowlight = createLowlight(all); 43 + 44 + let hasFocus = true; 45 + 46 + let menu: HTMLElement | null = $state(null); 47 + let menuLink: HTMLElement | null = $state(null); 48 + 49 + let selectedType: RichTextTypes = $state('paragraph'); 50 + 51 + let isBold = $state(false); 52 + let isItalic = $state(false); 53 + let isUnderline = $state(false); 54 + let isStrikethrough = $state(false); 55 + let isLink = $state(false); 56 + let isImage = $state(false); 57 + 58 + const CustomImage = Image.extend({ 59 + // addAttributes(this) { 60 + // return { 61 + // inline: true, 62 + // allowBase64: true, 63 + // HTMLAttributes: {}, 64 + // uploadImageHandler: { default: undefined } 65 + // }; 66 + // }, 67 + renderHTML({ HTMLAttributes }) { 68 + const attrs = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 69 + uploadImageHandler: undefined 70 + }); 71 + const isLocal = attrs['data-local'] === 'true'; 72 + 73 + // if (isLocal) { 74 + // // For local images, wrap in a container with a label 75 + // return [ 76 + // 'div', 77 + // { class: 'image-container' }, 78 + // ['img', { ...attrs, class: `${attrs.class || ''} local-image` }], 79 + // ['span', { class: 'local-image-label' }, 'Local preview'] 80 + // ]; 81 + // } 82 + 83 + console.log('attrs', attrs); 84 + 85 + // For regular images, just return the img tag 86 + return ['img', attrs]; 87 + } 88 + }); 89 + 90 + onMount(() => { 91 + if (!ref) return; 92 + 93 + let extensions = [ 94 + StarterKit.configure({ 95 + dropcursor: { 96 + class: 'text-accent-500/30 rounded-2xl', 97 + width: 2 98 + }, 99 + codeBlock: false, 100 + heading: { 101 + levels: [1, 2, 3] 102 + } 103 + }), 104 + Placeholder.configure({ 105 + placeholder: ({ node }) => { 106 + // only show in paragraphs 107 + if (node.type.name === 'paragraph' || node.type.name === 'heading') { 108 + return placeholder; 109 + } 110 + return ''; 111 + } 112 + }), 113 + CustomImage.configure({ 114 + HTMLAttributes: { 115 + class: 'max-w-full object-contain relative rounded-2xl' 116 + }, 117 + allowBase64: true 118 + }), 119 + CodeBlockLowlight.configure({ 120 + lowlight, 121 + defaultLanguage: 'js' 122 + }), 123 + BubbleMenu.configure({ 124 + element: menu, 125 + shouldShow: ({ editor }) => { 126 + // dont show if image selected or no selection or is code block 127 + return ( 128 + !editor.isActive('image') && 129 + !editor.view.state.selection.empty && 130 + !editor.isActive('codeBlock') && 131 + !editor.isActive('link') && 132 + !editor.isActive('imageUpload') 133 + ); 134 + }, 135 + pluginKey: 'bubble-menu-marks' 136 + }), 137 + BubbleMenu.configure({ 138 + element: menuLink, 139 + shouldShow: ({ editor }) => { 140 + // only show if link is selected 141 + return editor.isActive('link') && !editor.view.state.selection.empty; 142 + }, 143 + pluginKey: 'bubble-menu-links' 144 + }), 145 + Underline.configure({}), 146 + RichTextLink.configure({ 147 + openOnClick: false, 148 + autolink: true, 149 + defaultProtocol: 'https' 150 + }), 151 + Slash.configure({ 152 + suggestion: suggestion({ 153 + char: '/', 154 + pluginKey: 'slash', 155 + switchTo, 156 + processImageFile 157 + }) 158 + }), 159 + Typography.configure(), 160 + Markdown.configure(), 161 + ImageUploadNode.configure({}) 162 + ]; 163 + 164 + editor = new Editor({ 165 + element: ref, 166 + extensions, 167 + editorProps: { 168 + attributes: { 169 + class: 'outline-none' 170 + } 171 + }, 172 + onUpdate: (ctx) => { 173 + content = ctx.editor.getJSON(); 174 + onupdate?.(content, ctx); 175 + }, 176 + onFocus: () => { 177 + hasFocus = true; 178 + }, 179 + onBlur: () => { 180 + hasFocus = false; 181 + }, 182 + onTransaction: (ctx) => { 183 + isBold = ctx.editor.isActive('bold'); 184 + isItalic = ctx.editor.isActive('italic'); 185 + isUnderline = ctx.editor.isActive('underline'); 186 + isStrikethrough = ctx.editor.isActive('strike'); 187 + isLink = ctx.editor.isActive('link'); 188 + isImage = ctx.editor.isActive('image'); 189 + 190 + if (ctx.editor.isActive('heading', { level: 1 })) { 191 + selectedType = 'heading-1'; 192 + } else if (ctx.editor.isActive('heading', { level: 2 })) { 193 + selectedType = 'heading-2'; 194 + } else if (ctx.editor.isActive('heading', { level: 3 })) { 195 + selectedType = 'heading-3'; 196 + } else if (ctx.editor.isActive('blockquote')) { 197 + selectedType = 'blockquote'; 198 + } else if (ctx.editor.isActive('code')) { 199 + selectedType = 'code'; 200 + } else if (ctx.editor.isActive('bulletList')) { 201 + selectedType = 'bullet-list'; 202 + } else if (ctx.editor.isActive('orderedList')) { 203 + selectedType = 'ordered-list'; 204 + } else { 205 + selectedType = 'paragraph'; 206 + } 207 + ontransaction?.(); 208 + }, 209 + content 210 + }); 211 + }); 212 + 213 + // Flag to track whether a file is being dragged over the drop area 214 + let isDragOver = $state(false); 215 + 216 + // Store local image files for later upload 217 + let localImages: Map<string, File> = $state(new Map()); 218 + 219 + // Track which image URLs in the editor are local previews 220 + let localImageUrls: Set<string> = $state(new Set()); 221 + 222 + // Process image file to create a local preview 223 + async function processImageFile(file: File) { 224 + if (!editor) { 225 + console.warn('Tiptap editor not initialized'); 226 + return; 227 + } 228 + 229 + try { 230 + const localUrl = URL.createObjectURL(file); 231 + 232 + localImages.set(localUrl, file); 233 + localImageUrls.add(localUrl); 234 + 235 + //editor.commands.setImageUploadNode(); 236 + editor 237 + .chain() 238 + .focus() 239 + .setImageUploadNode({ 240 + preview: localUrl 241 + }) 242 + .run(); 243 + 244 + // wait 2 seconds 245 + // await new Promise((resolve) => setTimeout(resolve, 500)); 246 + 247 + // content = editor.getJSON(); 248 + 249 + // console.log('replacing image url in content'); 250 + // replaceImageUrlInContent(content, localUrl, 'https://picsum.photos/200/300'); 251 + // editor.commands.setContent(content); 252 + } catch (error) { 253 + console.error('Error creating image preview:', error); 254 + } 255 + } 256 + 257 + const handlePaste = (event: ClipboardEvent) => { 258 + const items = event.clipboardData?.items; 259 + if (!items) return; 260 + // Check for image data in clipboard 261 + for (const item of Array.from(items)) { 262 + if (!item.type.startsWith('image/')) continue; 263 + const file = item.getAsFile(); 264 + if (!file) continue; 265 + event.preventDefault(); 266 + processImageFile(file); 267 + return; 268 + } 269 + }; 270 + 271 + function handleDragOver(event: DragEvent) { 272 + event.preventDefault(); 273 + event.stopPropagation(); 274 + isDragOver = true; 275 + } 276 + function handleDragLeave(event: DragEvent) { 277 + event.preventDefault(); 278 + event.stopPropagation(); 279 + isDragOver = false; 280 + } 281 + function handleDrop(event: DragEvent) { 282 + event.preventDefault(); 283 + event.stopPropagation(); 284 + isDragOver = false; 285 + if (!event.dataTransfer?.files?.length) return; 286 + const file = event.dataTransfer.files[0]; 287 + if (file?.type.startsWith('image/')) { 288 + processImageFile(file); 289 + } 290 + } 291 + 292 + onDestroy(() => { 293 + for (const localUrl of localImageUrls) { 294 + URL.revokeObjectURL(localUrl); 295 + } 296 + 297 + editor?.destroy(); 298 + }); 299 + 300 + let link = $state(''); 301 + 302 + let linkInput: HTMLInputElement | null = $state(null); 303 + 304 + function clickedLink() { 305 + if (isLink) { 306 + //tiptap?.chain().focus().unsetLink().run(); 307 + // get current link 308 + link = editor?.getAttributes('link').href; 309 + 310 + setTimeout(() => { 311 + linkInput?.focus(); 312 + }, 100); 313 + } else { 314 + link = ''; 315 + // set link 316 + editor?.chain().focus().setLink({ href: link }).run(); 317 + 318 + setTimeout(() => { 319 + linkInput?.focus(); 320 + }, 100); 321 + } 322 + } 323 + 324 + function switchTo(value: RichTextTypes) { 325 + editor?.chain().focus().setParagraph().run(); 326 + 327 + if (value === 'heading-1') { 328 + editor?.chain().focus().setNode('heading', { level: 1 }).run(); 329 + } else if (value === 'heading-2') { 330 + editor?.chain().focus().setNode('heading', { level: 2 }).run(); 331 + } else if (value === 'heading-3') { 332 + editor?.chain().focus().setNode('heading', { level: 3 }).run(); 333 + } else if (value === 'blockquote') { 334 + editor?.chain().focus().setBlockquote().run(); 335 + } else if (value === 'code') { 336 + editor?.chain().focus().setCodeBlock().run(); 337 + } else if (value === 'bullet-list') { 338 + editor?.chain().focus().toggleBulletList().run(); 339 + } else if (value === 'ordered-list') { 340 + editor?.chain().focus().toggleOrderedList().run(); 341 + } 342 + } 343 + </script> 344 + 345 + <div 346 + bind:this={ref} 347 + class={cn('relative flex-1', className)} 348 + onpaste={handlePaste} 349 + ondragover={handleDragOver} 350 + ondragleave={handleDragLeave} 351 + ondrop={handleDrop} 352 + role="region" 353 + ></div> 354 + 355 + <RichTextEditorMenu 356 + bind:ref={menu} 357 + {editor} 358 + {isBold} 359 + {isItalic} 360 + {isUnderline} 361 + {isStrikethrough} 362 + {isLink} 363 + {isImage} 364 + {clickedLink} 365 + {processImageFile} 366 + {switchTo} 367 + bind:selectedType 368 + /> 369 + 370 + <RichTextEditorLinkMenu bind:ref={menuLink} {editor} bind:link bind:linkInput /> 371 + 372 + <style> 373 + :global(.tiptap) { 374 + :first-child { 375 + margin-top: 0; 376 + } 377 + 378 + :global(img) { 379 + display: block; 380 + height: auto; 381 + margin: 1.5rem 0; 382 + max-width: 100%; 383 + 384 + &.ProseMirror-selectednode { 385 + outline: 3px solid var(--color-accent-500); 386 + } 387 + } 388 + 389 + :global(div[data-type='image-upload']) { 390 + &.ProseMirror-selectednode { 391 + outline: 3px solid var(--color-accent-500); 392 + } 393 + } 394 + 395 + :global(blockquote p:first-of-type::before) { 396 + content: none; 397 + } 398 + 399 + :global(blockquote p:last-of-type::after) { 400 + content: none; 401 + } 402 + 403 + :global(blockquote p) { 404 + font-style: normal; 405 + } 406 + } 407 + 408 + :global(.tiptap .is-empty::before) { 409 + color: var(--color-base-500); 410 + content: attr(data-placeholder); 411 + float: left; 412 + height: 0; 413 + pointer-events: none; 414 + } 415 + </style>
+111
src/lib/components/rich-text-editor/RichTextEditorLinkMenu.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Input } from '@foxui/core'; 3 + import type { Editor } from '@tiptap/core'; 4 + 5 + let { 6 + editor, 7 + link = $bindable(''), 8 + ref = $bindable(null), 9 + linkInput = $bindable(null) 10 + }: { 11 + editor: Editor | null; 12 + link: string; 13 + ref: HTMLElement | null; 14 + linkInput: HTMLInputElement | null; 15 + } = $props(); 16 + 17 + function processLink(link: string) { 18 + return link.includes(':') ? link : `http://${link}`; 19 + } 20 + </script> 21 + 22 + <div 23 + bind:this={ref} 24 + style="visibility: hidden; opacity: 0;" 25 + class="menu bg-base-50 dark:bg-base-900 relative w-fit rounded-2xl px-1 py-1 shadow-lg backdrop-blur-sm" 26 + > 27 + <div class="flex items-center gap-1"> 28 + <Input 29 + bind:ref={linkInput} 30 + sizeVariant="sm" 31 + bind:value={link} 32 + placeholder="Enter link" 33 + onblur={() => { 34 + if (link === '') { 35 + editor?.chain().focus().extendMarkRange('link').unsetLink().run(); 36 + } else { 37 + editor 38 + ?.chain() 39 + .focus() 40 + .extendMarkRange('link') 41 + .setLink({ href: processLink(link) }) 42 + .run(); 43 + } 44 + }} 45 + onkeydown={(e: KeyboardEvent) => { 46 + if (e.key === 'Enter') { 47 + if (link === '') { 48 + editor?.chain().focus().extendMarkRange('link').unsetLink().run(); 49 + } else { 50 + editor 51 + ?.chain() 52 + .focus() 53 + .extendMarkRange('link') 54 + .setLink({ href: processLink(link) }) 55 + .run(); 56 + } 57 + } 58 + }} 59 + /> 60 + <Button 61 + size="iconSm" 62 + onclick={() => { 63 + if (link === '') { 64 + editor?.chain().focus().extendMarkRange('link').unsetLink().run(); 65 + } else { 66 + editor 67 + ?.chain() 68 + .focus() 69 + .extendMarkRange('link') 70 + .setLink({ href: processLink(link) }) 71 + .run(); 72 + } 73 + }} 74 + > 75 + <svg 76 + xmlns="http://www.w3.org/2000/svg" 77 + fill="none" 78 + viewBox="0 0 24 24" 79 + stroke-width="1.5" 80 + stroke="currentColor" 81 + class="size-6" 82 + > 83 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 84 + </svg> 85 + 86 + <span class="sr-only">save link</span> 87 + </Button> 88 + <Button 89 + size="iconSm" 90 + onclick={() => { 91 + editor?.chain().focus().extendMarkRange('link').unsetLink().run(); 92 + }} 93 + variant="ghost" 94 + > 95 + <svg 96 + xmlns="http://www.w3.org/2000/svg" 97 + fill="none" 98 + viewBox="0 0 24 24" 99 + stroke-width="1.5" 100 + stroke="currentColor" 101 + > 102 + <path 103 + stroke-linecap="round" 104 + stroke-linejoin="round" 105 + d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" 106 + /> 107 + </svg> 108 + <span class="sr-only">remove link</span> 109 + </Button> 110 + </div> 111 + </div>
+182
src/lib/components/rich-text-editor/RichTextEditorMenu.svelte
··· 1 + <script lang="ts"> 2 + import { cn, Toggle, toggleVariants, Tooltip } from '@foxui/core'; 3 + import type { Editor } from '@tiptap/core'; 4 + import Select from './Select.svelte'; 5 + import type { RichTextTypes } from '.'; 6 + 7 + let { 8 + editor, 9 + isBold, 10 + isImage, 11 + isItalic, 12 + isUnderline, 13 + isStrikethrough, 14 + isLink, 15 + clickedLink, 16 + selectedType = $bindable('paragraph'), 17 + ref = $bindable(null), 18 + processImageFile, 19 + switchTo 20 + }: { 21 + editor: Editor | null; 22 + isBold: boolean; 23 + isImage: boolean; 24 + isItalic: boolean; 25 + isUnderline: boolean; 26 + isStrikethrough: boolean; 27 + isLink: boolean; 28 + clickedLink: () => void; 29 + selectedType: RichTextTypes; 30 + ref: HTMLElement | null; 31 + processImageFile: (file: File, input: HTMLInputElement) => void; 32 + switchTo: (value: RichTextTypes) => void; 33 + } = $props(); 34 + 35 + function handleFileProcess(event: Event) { 36 + const input = event.target as HTMLInputElement; 37 + if (!input.files?.length) return; 38 + const file = input.files[0]; 39 + if (!file || !file.type.startsWith('image/')) return; 40 + processImageFile(file, input); 41 + } 42 + 43 + let fileInput = $state<HTMLInputElement | null>(null); 44 + </script> 45 + 46 + <div 47 + bind:this={ref} 48 + style="visibility: hidden; opacity: 0;" 49 + class="bg-base-50 dark:bg-base-900 border-base-500/20 dark:border-base-700/20 relative w-fit rounded-2xl border px-1 py-1 shadow-lg backdrop-blur-sm" 50 + > 51 + <Select 52 + onValueChange={(value) => { 53 + switchTo(value as RichTextTypes); 54 + }} 55 + type="single" 56 + items={[ 57 + { value: 'paragraph', label: 'Text' }, 58 + { value: 'heading-1', label: 'Heading 1' }, 59 + { value: 'heading-2', label: 'Heading 2' }, 60 + { value: 'heading-3', label: 'Heading 3' }, 61 + { value: 'blockquote', label: 'Blockquote' }, 62 + { value: 'code', label: 'Code Block' }, 63 + { value: 'bullet-list', label: 'Bullet List' }, 64 + { value: 'ordered-list', label: 'Ordered List' } 65 + ]} 66 + bind:value={selectedType} 67 + /> 68 + <!-- <Tooltip withContext text="Bold" delayDuration={0}> 69 + {#snippet child({ props })} --> 70 + <Toggle 71 + size="sm" 72 + onclick={() => editor?.chain().focus().toggleBold().run()} 73 + bind:pressed={() => isBold, (bold) => {}} 74 + > 75 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 76 + <path 77 + fill-rule="evenodd" 78 + d="M5.246 3.744a.75.75 0 0 1 .75-.75h7.125a4.875 4.875 0 0 1 3.346 8.422 5.25 5.25 0 0 1-2.97 9.58h-7.5a.75.75 0 0 1-.75-.75V3.744Zm7.125 6.75a2.625 2.625 0 0 0 0-5.25H8.246v5.25h4.125Zm-4.125 2.251v6h4.5a3 3 0 0 0 0-6h-4.5Z" 79 + clip-rule="evenodd" 80 + /> 81 + </svg> 82 + 83 + <span class="sr-only">Bold</span> 84 + </Toggle> 85 + <!-- {/snippet} 86 + </Tooltip> --> 87 + <Toggle 88 + size="sm" 89 + onclick={() => editor?.chain().focus().toggleItalic().run()} 90 + bind:pressed={() => isItalic, (italic) => {}} 91 + > 92 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 93 + <path 94 + fill-rule="evenodd" 95 + d="M10.497 3.744a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-3.275l-5.357 15.002h2.632a.75.75 0 1 1 0 1.5h-7.5a.75.75 0 1 1 0-1.5h3.275l5.357-15.002h-2.632a.75.75 0 0 1-.75-.75Z" 96 + clip-rule="evenodd" 97 + /> 98 + </svg> 99 + 100 + <span class="sr-only">Italic</span> 101 + </Toggle> 102 + 103 + <Toggle 104 + size="sm" 105 + onclick={() => editor?.chain().focus().toggleUnderline().run()} 106 + bind:pressed={() => isUnderline, (underline) => {}} 107 + > 108 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 109 + <path 110 + fill-rule="evenodd" 111 + d="M5.995 2.994a.75.75 0 0 1 .75.75v7.5a5.25 5.25 0 1 0 10.5 0v-7.5a.75.75 0 0 1 1.5 0v7.5a6.75 6.75 0 1 1-13.5 0v-7.5a.75.75 0 0 1 .75-.75Zm-3 17.252a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5h-16.5a.75.75 0 0 1-.75-.75Z" 112 + clip-rule="evenodd" 113 + /> 114 + </svg> 115 + 116 + <span class="sr-only">Underline</span> 117 + </Toggle> 118 + 119 + <Toggle 120 + size="sm" 121 + onclick={() => editor?.chain().focus().toggleStrike().run()} 122 + bind:pressed={() => isStrikethrough, (strikethrough) => {}} 123 + > 124 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 125 + <path 126 + fill-rule="evenodd" 127 + d="M9.657 4.728c-1.086.385-1.766 1.057-1.979 1.85-.214.8.046 1.733.81 2.616.746.862 1.93 1.612 3.388 2.003.07.019.14.037.21.053h8.163a.75.75 0 0 1 0 1.5h-8.24a.66.66 0 0 1-.02 0H3.75a.75.75 0 0 1 0-1.5h4.78a7.108 7.108 0 0 1-1.175-1.074C6.372 9.042 5.849 7.61 6.229 6.19c.377-1.408 1.528-2.38 2.927-2.876 1.402-.497 3.127-.55 4.855-.086A8.937 8.937 0 0 1 16.94 4.6a.75.75 0 0 1-.881 1.215 7.437 7.437 0 0 0-2.436-1.14c-1.473-.394-2.885-.331-3.966.052Zm6.533 9.632a.75.75 0 0 1 1.03.25c.592.974.846 2.094.55 3.2-.378 1.408-1.529 2.38-2.927 2.876-1.402.497-3.127.55-4.855.087-1.712-.46-3.168-1.354-4.134-2.47a.75.75 0 0 1 1.134-.982c.746.862 1.93 1.612 3.388 2.003 1.473.394 2.884.331 3.966-.052 1.085-.384 1.766-1.056 1.978-1.85.169-.628.046-1.33-.381-2.032a.75.75 0 0 1 .25-1.03Z" 128 + clip-rule="evenodd" 129 + /> 130 + </svg> 131 + 132 + <span class="sr-only">Strikethrough</span> 133 + </Toggle> 134 + 135 + <Toggle 136 + size="sm" 137 + onclick={() => { 138 + clickedLink(); 139 + }} 140 + bind:pressed={() => isLink, (link) => {}} 141 + > 142 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 143 + <path 144 + fill-rule="evenodd" 145 + d="M19.902 4.098a3.75 3.75 0 0 0-5.304 0l-4.5 4.5a3.75 3.75 0 0 0 1.035 6.037.75.75 0 0 1-.646 1.353 5.25 5.25 0 0 1-1.449-8.45l4.5-4.5a5.25 5.25 0 1 1 7.424 7.424l-1.757 1.757a.75.75 0 1 1-1.06-1.06l1.757-1.757a3.75 3.75 0 0 0 0-5.304Zm-7.389 4.267a.75.75 0 0 1 1-.353 5.25 5.25 0 0 1 1.449 8.45l-4.5 4.5a5.25 5.25 0 1 1-7.424-7.424l1.757-1.757a.75.75 0 1 1 1.06 1.06l-1.757 1.757a3.75 3.75 0 1 0 5.304 5.304l4.5-4.5a3.75 3.75 0 0 0-1.035-6.037.75.75 0 0 1-.354-1Z" 146 + clip-rule="evenodd" 147 + /> 148 + </svg> 149 + 150 + <span class="sr-only">Link</span> 151 + </Toggle> 152 + 153 + <!-- <Toggle 154 + size="sm" 155 + onclick={() => { 156 + fileInput?.click(); 157 + }} 158 + bind:pressed={() => isImage, (image) => {}} 159 + > 160 + <svg 161 + xmlns="http://www.w3.org/2000/svg" 162 + viewBox="0 0 24 24" 163 + fill="none" 164 + stroke="currentColor" 165 + stroke-width="2" 166 + stroke-linecap="round" 167 + stroke-linejoin="round" 168 + ><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /><circle cx="9" cy="9" r="2" /><path 169 + d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" 170 + /></svg 171 + > 172 + </Toggle> --> 173 + 174 + <input 175 + type="file" 176 + accept="image/*" 177 + class="hidden" 178 + onchange={handleFileProcess} 179 + tabindex="-1" 180 + bind:this={fileInput} 181 + /> 182 + </div>
+126
src/lib/components/rich-text-editor/RichTextLink.ts
··· 1 + // from https://github.com/Doist/typist/blob/main/src/extensions/rich-text/rich-text-link.ts 2 + import { InputRule, markInputRule, markPasteRule, PasteRule } from '@tiptap/core'; 3 + import { Link } from '@tiptap/extension-link'; 4 + 5 + import type { LinkOptions } from '@tiptap/extension-link'; 6 + 7 + /** 8 + * The input regex for Markdown links with title support, and multiple quotation marks (required 9 + * in case the `Typography` extension is being included). 10 + */ 11 + const inputRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)$/i; 12 + 13 + /** 14 + * The paste regex for Markdown links with title support, and multiple quotation marks (required 15 + * in case the `Typography` extension is being included). 16 + */ 17 + const pasteRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)/gi; 18 + 19 + /** 20 + * Input rule built specifically for the `Link` extension, which ignores the auto-linked URL in 21 + * parentheses (e.g., `(https://doist.dev)`). 22 + * 23 + * @see https://github.com/ueberdosis/tiptap/discussions/1865 24 + */ 25 + function linkInputRule(config: Parameters<typeof markInputRule>[0]) { 26 + const defaultMarkInputRule = markInputRule(config); 27 + 28 + return new InputRule({ 29 + find: config.find, 30 + handler(props) { 31 + const { tr } = props.state; 32 + 33 + defaultMarkInputRule.handler(props); 34 + tr.setMeta('preventAutolink', true); 35 + } 36 + }); 37 + } 38 + 39 + /** 40 + * Paste rule built specifically for the `Link` extension, which ignores the auto-linked URL in 41 + * parentheses (e.g., `(https://doist.dev)`). This extension was inspired from the multiple 42 + * implementations found in a Tiptap discussion at GitHub. 43 + * 44 + * @see https://github.com/ueberdosis/tiptap/discussions/1865 45 + */ 46 + function linkPasteRule(config: Parameters<typeof markPasteRule>[0]) { 47 + const defaultMarkPasteRule = markPasteRule(config); 48 + 49 + return new PasteRule({ 50 + find: config.find, 51 + handler(props) { 52 + const { tr } = props.state; 53 + 54 + defaultMarkPasteRule.handler(props); 55 + tr.setMeta('preventAutolink', true); 56 + } 57 + }); 58 + } 59 + 60 + /** 61 + * The options available to customize the `RichTextLink` extension. 62 + */ 63 + type RichTextLinkOptions = LinkOptions; 64 + 65 + /** 66 + * Custom extension that extends the built-in `Link` extension to add additional input/paste rules 67 + * for converting the Markdown link syntax (i.e. `[Doist](https://doist.com)`) into links, and also 68 + * adds support for the `title` attribute. 69 + */ 70 + const RichTextLink = Link.extend<RichTextLinkOptions>({ 71 + inclusive: false, 72 + addOptions() { 73 + return { 74 + ...this.parent?.(), 75 + openOnClick: 'whenNotEditable' as const 76 + } as RichTextLinkOptions; 77 + }, 78 + addAttributes() { 79 + return { 80 + ...this.parent?.(), 81 + title: { 82 + default: null 83 + } 84 + }; 85 + }, 86 + addInputRules() { 87 + return [ 88 + linkInputRule({ 89 + find: inputRegex, 90 + type: this.type, 91 + 92 + // We need to use `pop()` to remove the last capture groups from the match to 93 + // satisfy Tiptap's `markPasteRule` expectation of having the content as the last 94 + // capture group in the match (this makes the attribute order important) 95 + getAttributes(match) { 96 + return { 97 + title: match.pop()?.trim(), 98 + href: match.pop()?.trim() 99 + }; 100 + } 101 + }) 102 + ]; 103 + }, 104 + addPasteRules() { 105 + return [ 106 + linkPasteRule({ 107 + find: pasteRegex, 108 + type: this.type, 109 + 110 + // We need to use `pop()` to remove the last capture groups from the match to 111 + // satisfy Tiptap's `markInputRule` expectation of having the content as the last 112 + // capture group in the match (this makes the attribute order important) 113 + getAttributes(match) { 114 + return { 115 + title: match.pop()?.trim(), 116 + href: match.pop()?.trim() 117 + }; 118 + } 119 + }) 120 + ]; 121 + } 122 + }); 123 + 124 + export { RichTextLink }; 125 + 126 + export type { RichTextLinkOptions };
+72
src/lib/components/rich-text-editor/Select.svelte
··· 1 + <script lang="ts"> 2 + import { Button, cn, toggleVariants } from '@foxui/core'; 3 + import { Select, type WithoutChildren } from 'bits-ui'; 4 + import Icon from './Icon.svelte'; 5 + 6 + type Props = WithoutChildren<Select.RootProps> & { 7 + placeholder?: string; 8 + items: { value: string; label: string; disabled?: boolean }[]; 9 + contentProps?: WithoutChildren<Select.ContentProps>; 10 + }; 11 + 12 + let { value = $bindable(), items, contentProps, placeholder, ...restProps }: Props = $props(); 13 + </script> 14 + 15 + <Select.Root bind:value={value as never} {...restProps}> 16 + <Select.Trigger> 17 + {#snippet child({ props })} 18 + <button {...props} class={cn(toggleVariants({ size: 'sm' }), 'gap-1')}> 19 + {#if value} 20 + <Icon name={value as any} /> 21 + {:else} 22 + <span class="size-3.5">?</span> 23 + {/if} 24 + 25 + <svg 26 + xmlns="http://www.w3.org/2000/svg" 27 + fill="none" 28 + viewBox="0 0 24 24" 29 + stroke-width="1.5" 30 + stroke="currentColor" 31 + class="!size-2.5" 32 + > 33 + <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /> 34 + </svg> 35 + </button> 36 + {/snippet} 37 + </Select.Trigger> 38 + <Select.Portal> 39 + <Select.Content 40 + {...contentProps} 41 + class={cn( 42 + 'bg-base-50/50 border-base-500/20 overflow-hidden rounded-2xl border shadow-lg backdrop-blur-xl', 43 + 'dark:bg-base-900/50 dark:border-base-500/10', 44 + 'motion-safe:animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 45 + contentProps?.class 46 + )} 47 + sideOffset={6} 48 + > 49 + <Select.ScrollUpButton>up</Select.ScrollUpButton> 50 + <Select.Viewport class="divide-base-300/30 dark:divide-base-800 divide-y text-sm"> 51 + {#each items as { value, label, disabled } (value)} 52 + <Select.Item {value} {label} {disabled}> 53 + {#snippet children({ selected })} 54 + <div 55 + class={cn( 56 + 'text-base-900 dark:text-base-200 group relative isolate flex min-w-28 cursor-pointer items-center gap-3 px-3 py-2 font-medium [&_svg]:size-3.5', 57 + selected 58 + ? 'text-accent-700 dark:text-accent-400 bg-accent-500/10' 59 + : 'hover:bg-accent-500/10' 60 + )} 61 + > 62 + <Icon name={value as any} /> 63 + {label} 64 + </div> 65 + {/snippet} 66 + </Select.Item> 67 + {/each} 68 + </Select.Viewport> 69 + <Select.ScrollDownButton>down</Select.ScrollDownButton> 70 + </Select.Content> 71 + </Select.Portal> 72 + </Select.Root>
+175
src/lib/components/rich-text-editor/code.css
··· 1 + /* GitHub Light theme for code blocks */ 2 + .tiptap { 3 + :first-child { 4 + margin-top: 0; 5 + } 6 + 7 + pre { 8 + background: #f6f8fa; 9 + border-radius: 1rem; 10 + color: #1f2328; 11 + font-family: 'JetBrainsMono', monospace; 12 + border: 1px solid #d1d9e0; 13 + margin: 1.5rem 0; 14 + padding: 0.75rem 1rem; 15 + 16 + code { 17 + background: none; 18 + color: inherit; 19 + font-size: 0.8rem; 20 + padding: 0; 21 + } 22 + 23 + .hljs-comment, 24 + .hljs-quote { 25 + color: #59636e; 26 + font-style: italic; 27 + } 28 + 29 + .hljs-keyword, 30 + .hljs-selector-tag { 31 + color: #cf222e; 32 + } 33 + 34 + .hljs-tag, 35 + .hljs-name { 36 + color: #116329; 37 + } 38 + 39 + .hljs-attribute { 40 + color: #0550ae; 41 + } 42 + 43 + .hljs-variable, 44 + .hljs-template-variable, 45 + .hljs-regexp, 46 + .hljs-link { 47 + color: #0550ae; 48 + } 49 + 50 + .hljs-number, 51 + .hljs-literal { 52 + color: #0550ae; 53 + } 54 + 55 + .hljs-string, 56 + .hljs-symbol, 57 + .hljs-bullet { 58 + color: #0a3069; 59 + } 60 + 61 + .hljs-title, 62 + .hljs-section { 63 + color: #0550ae; 64 + font-weight: 600; 65 + } 66 + 67 + .hljs-type, 68 + .hljs-built_in, 69 + .hljs-builtin-name, 70 + .hljs-params { 71 + color: #953800; 72 + } 73 + 74 + .hljs-meta { 75 + color: #0550ae; 76 + } 77 + 78 + .hljs-selector-id, 79 + .hljs-selector-class { 80 + color: #0550ae; 81 + } 82 + 83 + .hljs-emphasis { 84 + font-style: italic; 85 + } 86 + 87 + .hljs-strong { 88 + font-weight: 700; 89 + } 90 + } 91 + } 92 + 93 + /* GitHub Dark theme for code blocks */ 94 + .dark .tiptap { 95 + pre { 96 + background: #161b22; 97 + color: #e6edf3; 98 + border: 1px solid #30363d; 99 + 100 + code { 101 + background: none; 102 + color: inherit; 103 + font-size: 0.8rem; 104 + padding: 0; 105 + } 106 + 107 + .hljs-comment, 108 + .hljs-quote { 109 + color: #8b949e; 110 + font-style: italic; 111 + } 112 + 113 + .hljs-keyword, 114 + .hljs-selector-tag { 115 + color: #ff7b72; 116 + } 117 + 118 + .hljs-tag, 119 + .hljs-name { 120 + color: #7ee787; 121 + } 122 + 123 + .hljs-attribute { 124 + color: #79c0ff; 125 + } 126 + 127 + .hljs-variable, 128 + .hljs-template-variable, 129 + .hljs-regexp, 130 + .hljs-link { 131 + color: #79c0ff; 132 + } 133 + 134 + .hljs-number, 135 + .hljs-literal { 136 + color: #79c0ff; 137 + } 138 + 139 + .hljs-string, 140 + .hljs-symbol, 141 + .hljs-bullet { 142 + color: #a5d6ff; 143 + } 144 + 145 + .hljs-title, 146 + .hljs-section { 147 + color: #d2a8ff; 148 + font-weight: 600; 149 + } 150 + 151 + .hljs-type, 152 + .hljs-built_in, 153 + .hljs-builtin-name, 154 + .hljs-params { 155 + color: #ffa657; 156 + } 157 + 158 + .hljs-meta { 159 + color: #79c0ff; 160 + } 161 + 162 + .hljs-selector-id, 163 + .hljs-selector-class { 164 + color: #79c0ff; 165 + } 166 + 167 + .hljs-emphasis { 168 + font-style: italic; 169 + } 170 + 171 + .hljs-strong { 172 + font-weight: 700; 173 + } 174 + } 175 + }
+28
src/lib/components/rich-text-editor/image-upload/ImageUploadComponent.svelte
··· 1 + <script lang="ts"> 2 + import type { NodeViewProps } from '@tiptap/core'; 3 + import { onMount } from 'svelte'; 4 + import { NodeViewWrapper } from 'svelte-tiptap'; 5 + 6 + let props: NodeViewProps = $props(); 7 + 8 + onMount(() => { 9 + const pos = props.getPos(); 10 + if (pos == null) return; 11 + 12 + props.deleteNode(); 13 + props.editor 14 + .chain() 15 + .focus() 16 + .insertContentAt(pos, [ 17 + { 18 + type: 'image', 19 + attrs: { src: props.node.attrs.preview, alt: 'image', title: 'image' } 20 + } 21 + ]) 22 + .run(); 23 + }); 24 + </script> 25 + 26 + <NodeViewWrapper> 27 + <img src={props.node.attrs.preview} alt="Upload preview" /> 28 + </NodeViewWrapper>
+115
src/lib/components/rich-text-editor/image-upload/ImageUploadNode.ts
··· 1 + import { Node, mergeAttributes } from '@tiptap/core'; 2 + import { SvelteNodeViewRenderer } from 'svelte-tiptap'; 3 + 4 + import ImageUploadComponent from './ImageUploadComponent.svelte'; 5 + 6 + export type UploadFunction = ( 7 + file: File, 8 + onProgress?: (event: { progress: number }) => void, 9 + abortSignal?: AbortSignal 10 + ) => Promise<string>; 11 + 12 + export interface ImageUploadNodeOptions { 13 + /** 14 + * Acceptable file types for upload. 15 + * @default 'image/*' 16 + */ 17 + accept?: string; 18 + /** 19 + * Maximum number of files that can be uploaded. 20 + * @default 1 21 + */ 22 + limit?: number; 23 + /** 24 + * Maximum file size in bytes (0 for unlimited). 25 + * @default 0 26 + */ 27 + maxSize?: number; 28 + 29 + /** 30 + * Preview image URL. 31 + */ 32 + preview?: string; 33 + /** 34 + * Function to handle the upload process. 35 + */ 36 + upload?: UploadFunction; 37 + /** 38 + * Callback for upload errors. 39 + */ 40 + onError?: (error: Error) => void; 41 + /** 42 + * Callback for successful uploads. 43 + */ 44 + onSuccess?: (url: string) => void; 45 + } 46 + 47 + declare module '@tiptap/core' { 48 + interface Commands<ReturnType> { 49 + imageUpload: { 50 + setImageUploadNode: (options?: ImageUploadNodeOptions) => ReturnType; 51 + }; 52 + } 53 + } 54 + 55 + export const ImageUploadNode = Node.create<ImageUploadNodeOptions>({ 56 + name: 'imageUpload', 57 + group: 'block', 58 + atom: true, 59 + draggable: true, 60 + selectable: false, 61 + inline: false, 62 + 63 + addAttributes() { 64 + return { 65 + accept: { 66 + default: this.options.accept 67 + }, 68 + limit: { 69 + default: this.options.limit 70 + }, 71 + maxSize: { 72 + default: this.options.maxSize 73 + }, 74 + preview: { 75 + default: this.options.preview 76 + } 77 + }; 78 + }, 79 + 80 + addOptions() { 81 + return { 82 + accept: 'image/*', 83 + limit: 1, 84 + maxSize: 0, 85 + upload: undefined, 86 + onError: undefined, 87 + onSuccess: undefined 88 + }; 89 + }, 90 + 91 + addCommands() { 92 + return { 93 + setImageUploadNode: 94 + (options = {}) => 95 + ({ commands }) => { 96 + return commands.insertContent({ 97 + type: this.name, 98 + attrs: options 99 + }); 100 + } 101 + }; 102 + }, 103 + 104 + parseHTML() { 105 + return [{ tag: 'div[data-type="image-upload"]' }]; 106 + }, 107 + 108 + renderHTML({ HTMLAttributes }) { 109 + return ['div', mergeAttributes({ 'data-type': 'image-upload' }, HTMLAttributes)]; 110 + }, 111 + 112 + addNodeView() { 113 + return SvelteNodeViewRenderer(ImageUploadComponent); 114 + } 115 + });
+11
src/lib/components/rich-text-editor/index.ts
··· 1 + export { default as RichTextEditor } from './RichTextEditor.svelte'; 2 + 3 + export type RichTextTypes = 4 + | 'paragraph' 5 + | 'heading-1' 6 + | 'heading-2' 7 + | 'heading-3' 8 + | 'blockquote' 9 + | 'code' 10 + | 'bullet-list' 11 + | 'ordered-list';
+87
src/lib/components/rich-text-editor/slash-menu/SuggestionSelect.svelte
··· 1 + <script lang="ts"> 2 + import { cn } from '@foxui/core'; 3 + import type { Editor, Range } from '@tiptap/core'; 4 + import Icon from '../Icon.svelte'; 5 + import type { RichTextTypes } from '..'; 6 + 7 + type Props = { 8 + items: { 9 + value: RichTextTypes; 10 + label: string; 11 + command: ({ editor, range }: { editor: Editor; range: Range }) => void; 12 + }[]; 13 + range: Range; 14 + editor: Editor; 15 + active?: number; 16 + }; 17 + 18 + let { items, range, editor, active = 0 }: Props = $props(); 19 + 20 + let activeIndex = $state(0); 21 + 22 + export function setItems(value: any[]) { 23 + items = value; 24 + } 25 + 26 + export function setRange(value: Range) { 27 + range = value; 28 + } 29 + 30 + export function onKeyDown(event: KeyboardEvent) { 31 + if (event.repeat) { 32 + return false; 33 + } 34 + switch (event.key) { 35 + case 'ArrowUp': { 36 + if (activeIndex <= 0) { 37 + activeIndex = items.length - 1; 38 + } else { 39 + activeIndex--; 40 + } 41 + return true; 42 + } 43 + case 'ArrowDown': { 44 + if (activeIndex >= items.length - 1) { 45 + activeIndex = 0; 46 + } else { 47 + activeIndex++; 48 + } 49 + return true; 50 + } 51 + case 'Enter': { 52 + const selected = items[activeIndex]; 53 + 54 + if (selected) { 55 + selected.command({ editor, range }); 56 + return true; 57 + } 58 + } 59 + } 60 + 61 + return false; 62 + } 63 + </script> 64 + 65 + <menu 66 + class={cn( 67 + 'bg-base-50/50 border-base-500/20 overflow-hidden rounded-2xl border shadow-lg backdrop-blur-xl', 68 + 'dark:bg-base-900/50 dark:border-base-500/10', 69 + 'motion-safe:animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 70 + 'divide-base-300/30 dark:divide-base-800 divide-y text-sm' 71 + )} 72 + > 73 + {#each items as item, index (item.value)} 74 + <button 75 + onclick={() => item.command({ editor, range })} 76 + class={cn( 77 + 'text-base-900 dark:text-base-200 group relative isolate flex w-full min-w-28 cursor-pointer items-center gap-3 px-3 py-2 font-medium [&_svg]:size-3.5', 78 + activeIndex === index 79 + ? 'text-accent-700 dark:text-accent-400 bg-accent-500/10' 80 + : 'hover:bg-accent-500/10' 81 + )} 82 + > 83 + <Icon name={item.value} /> 84 + {item.label} 85 + </button> 86 + {/each} 87 + </menu>
+199
src/lib/components/rich-text-editor/slash-menu/index.ts
··· 1 + import { Extension } from '@tiptap/core'; 2 + import Suggestion from '@tiptap/suggestion'; 3 + import type { Editor, Range } from '@tiptap/core'; 4 + import { PluginKey } from '@tiptap/pm/state'; 5 + import type { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion'; 6 + import SuggestionSelect from './SuggestionSelect.svelte'; 7 + import { mount, unmount } from 'svelte'; 8 + import { computePosition, flip, shift, offset } from '@floating-ui/dom'; 9 + import type { RichTextTypes } from '..'; 10 + 11 + export default Extension.create({ 12 + name: 'slash', 13 + 14 + addOptions() { 15 + return { 16 + suggestion: { 17 + char: '/', 18 + 19 + command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { 20 + props.command({ editor, range }); 21 + } 22 + } 23 + }; 24 + }, 25 + 26 + addProseMirrorPlugins() { 27 + return [ 28 + Suggestion({ 29 + editor: this.editor, 30 + ...this.options.suggestion 31 + }) 32 + ]; 33 + } 34 + }); 35 + 36 + export function suggestion({ 37 + char, 38 + pluginKey, 39 + switchTo, 40 + processImageFile 41 + }: { 42 + char: string; 43 + pluginKey: string; 44 + switchTo: (value: RichTextTypes) => void; 45 + processImageFile: (file: File) => void; 46 + }) { 47 + return { 48 + char, 49 + pluginKey: new PluginKey(pluginKey), 50 + 51 + items: ({ query }: { query: string }) => { 52 + return [ 53 + { 54 + value: 'paragraph', 55 + label: 'Paragraph', 56 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 57 + editor.chain().focus().deleteRange(range).run(); 58 + switchTo('paragraph'); 59 + } 60 + }, 61 + { 62 + value: 'heading-1', 63 + label: 'Heading 1', 64 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 65 + editor.chain().focus().deleteRange(range).run(); 66 + switchTo('heading-1'); 67 + } 68 + }, 69 + { 70 + value: 'heading-2', 71 + label: 'Heading 2', 72 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 73 + editor.chain().focus().deleteRange(range).run(); 74 + switchTo('heading-2'); 75 + } 76 + }, 77 + { 78 + value: 'heading-3', 79 + label: 'Heading 3', 80 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 81 + editor.chain().focus().deleteRange(range).run(); 82 + switchTo('heading-3'); 83 + } 84 + }, 85 + { 86 + value: 'blockquote', 87 + label: 'Blockquote', 88 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 89 + editor.chain().focus().deleteRange(range).run(); 90 + switchTo('blockquote'); 91 + } 92 + }, 93 + { 94 + value: 'code', 95 + label: 'Code Block', 96 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 97 + editor.chain().focus().deleteRange(range).run(); 98 + switchTo('code'); 99 + } 100 + }, 101 + { 102 + value: 'bullet-list', 103 + label: 'Bullet List', 104 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 105 + editor.chain().focus().deleteRange(range).run(); 106 + switchTo('bullet-list'); 107 + } 108 + }, 109 + { 110 + value: 'ordered-list', 111 + label: 'Ordered List', 112 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 113 + editor.chain().focus().deleteRange(range).run(); 114 + switchTo('ordered-list'); 115 + } 116 + }, 117 + { 118 + value: 'image', 119 + label: 'Add Image', 120 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 121 + editor.chain().focus().deleteRange(range).run(); 122 + 123 + const fileInput = document.createElement('input'); 124 + fileInput.type = 'file'; 125 + fileInput.click(); 126 + fileInput.addEventListener('change', (event) => { 127 + const input = event.target as HTMLInputElement; 128 + if (!input.files?.length) return; 129 + const file = input.files[0]; 130 + if (!file?.type.startsWith('image/')) return; 131 + processImageFile(file); 132 + 133 + input.remove(); 134 + }); 135 + } 136 + } 137 + ].filter((item) => item.label.toLowerCase().includes(query.toLowerCase())); 138 + }, 139 + 140 + render: () => { 141 + let component: ReturnType<typeof SuggestionSelect>; 142 + let floatingEl: HTMLElement; 143 + 144 + function updatePosition(clientRect: (() => DOMRect | null) | null | undefined) { 145 + if (!clientRect || !floatingEl) return; 146 + const rect = clientRect(); 147 + if (!rect) return; 148 + 149 + // Create a virtual reference element for floating-ui 150 + const virtualRef = { 151 + getBoundingClientRect: () => rect 152 + }; 153 + 154 + computePosition(virtualRef, floatingEl, { 155 + placement: 'bottom-start', 156 + middleware: [offset(8), flip(), shift({ padding: 8 })] 157 + }).then(({ x, y }) => { 158 + Object.assign(floatingEl.style, { 159 + left: `${x}px`, 160 + top: `${y}px` 161 + }); 162 + }); 163 + } 164 + 165 + return { 166 + onStart: (props: SuggestionProps) => { 167 + floatingEl = document.createElement('div'); 168 + floatingEl.style.position = 'absolute'; 169 + floatingEl.style.zIndex = '50'; 170 + document.body.appendChild(floatingEl); 171 + 172 + component = mount(SuggestionSelect, { 173 + target: floatingEl, 174 + props 175 + }); 176 + 177 + updatePosition(props.clientRect); 178 + }, 179 + onUpdate: (props: SuggestionProps) => { 180 + component.setItems(props.items); 181 + component.setRange(props.range); 182 + updatePosition(props.clientRect); 183 + }, 184 + onKeyDown: (props: SuggestionKeyDownProps) => { 185 + if (props.event.key === 'Escape') { 186 + floatingEl.style.display = 'none'; 187 + return true; 188 + } 189 + 190 + return component.onKeyDown(props.event); 191 + }, 192 + onExit: () => { 193 + unmount(component); 194 + floatingEl.remove(); 195 + } 196 + }; 197 + } 198 + }; 199 + }
+28
src/routes/[[actor=actor]]/blog/+layout.server.ts
··· 1 + import { getRecord } from '$lib/atproto/methods.js'; 2 + import type { Did } from '@atcute/lexicons'; 3 + import { getActor } from '$lib/actor.js'; 4 + 5 + export async function load({ params, platform, request }) { 6 + const did = await getActor({ request, paramActor: params.actor, platform }); 7 + 8 + if (!did) return { accentColor: undefined, baseColor: undefined }; 9 + 10 + try { 11 + const publication = await getRecord({ 12 + did: did as Did, 13 + collection: 'site.standard.publication', 14 + rkey: 'blento.self' 15 + }); 16 + 17 + const preferences = publication?.value?.preferences as 18 + | { accentColor?: string; baseColor?: string } 19 + | undefined; 20 + 21 + return { 22 + accentColor: preferences?.accentColor, 23 + baseColor: preferences?.baseColor 24 + }; 25 + } catch { 26 + return { accentColor: undefined, baseColor: undefined }; 27 + } 28 + }
+9
src/routes/[[actor=actor]]/blog/+layout.svelte
··· 1 + <script lang="ts"> 2 + import ThemeScript from '$lib/website/ThemeScript.svelte'; 3 + 4 + let { data, children } = $props(); 5 + </script> 6 + 7 + <ThemeScript accentColor={data.accentColor} baseColor={data.baseColor} /> 8 + 9 + {@render children()}
+106
src/routes/[[actor=actor]]/blog/+page.server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import { getBlentoOrBskyProfile, getRecord, listRecords, parseUri } from '$lib/atproto/methods.js'; 3 + import { createCache, type CachedProfile } from '$lib/cache'; 4 + import type { Did } from '@atcute/lexicons'; 5 + import { getActor } from '$lib/actor.js'; 6 + 7 + export async function load({ params, platform, request }) { 8 + const cache = createCache(platform); 9 + 10 + const did = await getActor({ request, paramActor: params.actor, platform }); 11 + 12 + if (!did) { 13 + throw error(404, 'Blog not found'); 14 + } 15 + 16 + try { 17 + const [records, hostProfile] = await Promise.all([ 18 + listRecords({ 19 + did: did as Did, 20 + collection: 'site.standard.document', 21 + limit: 100 22 + }), 23 + cache 24 + ? cache.getProfile(did as Did).catch(() => null) 25 + : getBlentoOrBskyProfile({ did: did as Did }) 26 + .then( 27 + (p): CachedProfile => ({ 28 + did: p.did as string, 29 + handle: p.handle as string, 30 + displayName: p.displayName as string | undefined, 31 + avatar: p.avatar as string | undefined, 32 + hasBlento: p.hasBlento, 33 + url: p.url 34 + }) 35 + ) 36 + .catch(() => null) 37 + ]); 38 + 39 + // Resolve publication URLs for site fields 40 + const publications: Record<string, string> = {}; 41 + 42 + for (const record of records) { 43 + const site = record.value.site as string; 44 + if (!site) continue; 45 + 46 + const rkey = record.uri.split('/').pop(); 47 + 48 + if (site === `at://${did}/site.standard.publication/blento.self`) { 49 + record.value.href = `./blog/${rkey}`; 50 + } else if (site.startsWith('at://')) { 51 + if (!publications[site]) { 52 + const siteParts = parseUri(site); 53 + if (!siteParts) continue; 54 + 55 + try { 56 + const publicationRecord = await getRecord({ 57 + did: siteParts.repo as Did, 58 + collection: siteParts.collection!, 59 + rkey: siteParts.rkey 60 + }); 61 + 62 + if (publicationRecord.value?.url) { 63 + publications[site] = publicationRecord.value.url as string; 64 + } 65 + } catch { 66 + continue; 67 + } 68 + } 69 + 70 + if (publications[site]) { 71 + record.value.href = publications[site] + record.value.path; 72 + } 73 + } else { 74 + record.value.href = site + record.value.path; 75 + } 76 + } 77 + 78 + const posts = records 79 + .filter((r) => r.value?.href) 80 + .map((r) => { 81 + const value = r.value as Record<string, unknown>; 82 + return { 83 + title: value.title as string, 84 + description: value.description as string | undefined, 85 + publishedAt: value.publishedAt as string | undefined, 86 + href: value.href as string, 87 + coverImage: value.coverImage as { $type: 'blob'; ref: { $link: string } } | undefined, 88 + rkey: r.uri.split('/').pop() as string 89 + }; 90 + }) 91 + .sort((a, b) => { 92 + const dateA = a.publishedAt || ''; 93 + const dateB = b.publishedAt || ''; 94 + return dateB.localeCompare(dateA); 95 + }); 96 + 97 + return { 98 + posts, 99 + did, 100 + hostProfile: hostProfile ?? null 101 + }; 102 + } catch (e) { 103 + if (e && typeof e === 'object' && 'status' in e) throw e; 104 + throw error(404, 'Blog not found'); 105 + } 106 + }
+114
src/routes/[[actor=actor]]/blog/+page.svelte
··· 1 + <script lang="ts"> 2 + import { getCDNImageBlobUrl } from '$lib/atproto'; 3 + import { user } from '$lib/atproto/auth.svelte'; 4 + import { Avatar as FoxAvatar, Button } from '@foxui/core'; 5 + 6 + let { data } = $props(); 7 + 8 + let posts = $derived(data.posts); 9 + let did: string = $derived(data.did); 10 + let hostProfile = $derived(data.hostProfile); 11 + 12 + let hostName = $derived(hostProfile?.displayName || hostProfile?.handle || did); 13 + let hostUrl = $derived( 14 + hostProfile?.url ?? `https://bsky.app/profile/${hostProfile?.handle || did}` 15 + ); 16 + 17 + function formatDate(dateStr: string): string { 18 + const date = new Date(dateStr); 19 + const options: Intl.DateTimeFormatOptions = { 20 + year: 'numeric', 21 + month: 'long', 22 + day: 'numeric' 23 + }; 24 + return date.toLocaleDateString('en-US', options); 25 + } 26 + 27 + let actorPrefix = $derived(hostProfile?.handle ? `/${hostProfile.handle}` : `/${did}`); 28 + let isOwner = $derived(user.isLoggedIn && user.did === did); 29 + 30 + function getCoverUrl( 31 + coverImage: { $type: 'blob'; ref: { $link: string } } | undefined 32 + ): string | undefined { 33 + if (!coverImage) return undefined; 34 + return getCDNImageBlobUrl({ did, blob: coverImage, type: 'jpeg' }); 35 + } 36 + </script> 37 + 38 + <svelte:head> 39 + <title>{hostName} - Blog</title> 40 + <meta name="description" content="Blog posts by {hostName}" /> 41 + <meta property="og:title" content="{hostName} - Blog" /> 42 + <meta property="og:description" content="Blog posts by {hostName}" /> 43 + <meta name="twitter:card" content="summary" /> 44 + <meta name="twitter:title" content="{hostName} - Blog" /> 45 + <meta name="twitter:description" content="Blog posts by {hostName}" /> 46 + </svelte:head> 47 + 48 + <div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12"> 49 + <div class="mx-auto max-w-4xl"> 50 + <!-- Header --> 51 + <div class="mb-8"> 52 + <div class="flex items-center justify-between gap-4"> 53 + <h1 class="text-base-900 dark:text-base-50 mb-2 text-2xl font-bold sm:text-3xl">Blog</h1> 54 + {#if isOwner} 55 + <Button href="{actorPrefix}/blog/new">New post</Button> 56 + {/if} 57 + </div> 58 + <div class="mt-4 flex items-center gap-2"> 59 + <span class="text-base-500 dark:text-base-400 text-sm">Written by</span> 60 + <a 61 + href={hostUrl} 62 + target={hostProfile?.hasBlento ? undefined : '_blank'} 63 + rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'} 64 + class="flex items-center gap-1.5 hover:underline" 65 + > 66 + <FoxAvatar src={hostProfile?.avatar} alt={hostName} class="size-5 shrink-0" /> 67 + <span class="text-base-900 dark:text-base-100 text-sm font-medium">{hostName}</span> 68 + </a> 69 + </div> 70 + </div> 71 + 72 + {#if posts.length === 0} 73 + <p class="text-base-500 dark:text-base-400 py-12 text-center">No blog posts found.</p> 74 + {:else} 75 + <div class="divide-base-100 dark:divide-base-900 divide-y"> 76 + {#each posts as post (post.rkey)} 77 + {@const coverUrl = getCoverUrl(post.coverImage)} 78 + <a 79 + href={post.href} 80 + target={post.href.startsWith('./') ? undefined : '_blank'} 81 + rel={post.href.startsWith('./') ? undefined : 'noopener noreferrer'} 82 + class="group flex items-start gap-4 py-6" 83 + > 84 + <div class="min-w-0 flex-1"> 85 + {#if post.publishedAt} 86 + <p class="text-base-500 dark:text-base-400 mb-1 text-sm"> 87 + {formatDate(post.publishedAt)} 88 + </p> 89 + {/if} 90 + <h2 91 + class="text-base-900 dark:text-base-50 group-hover:text-base-700 dark:group-hover:text-base-200 mb-2 text-lg leading-snug font-semibold" 92 + > 93 + {post.title} 94 + </h2> 95 + {#if post.description} 96 + <p class="text-base-600 dark:text-base-400 line-clamp-3 text-sm leading-relaxed"> 97 + {post.description} 98 + </p> 99 + {/if} 100 + </div> 101 + 102 + {#if coverUrl} 103 + <img 104 + src={coverUrl} 105 + alt={post.title} 106 + class="aspect-video w-32 shrink-0 rounded-lg object-cover" 107 + /> 108 + {/if} 109 + </a> 110 + {/each} 111 + </div> 112 + {/if} 113 + </div> 114 + </div>
+86
src/routes/[[actor=actor]]/blog/[rkey]/+page.server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import { getBlentoOrBskyProfile, getRecord, parseUri } from '$lib/atproto/methods.js'; 3 + import { createCache, type CachedProfile } from '$lib/cache'; 4 + import type { Did } from '@atcute/lexicons'; 5 + import { getActor } from '$lib/actor'; 6 + 7 + export async function load({ params, platform, request }) { 8 + const { rkey } = params; 9 + 10 + const cache = createCache(platform); 11 + 12 + const did = await getActor({ request, paramActor: params.actor, platform }); 13 + 14 + if (!did || !rkey) { 15 + throw error(404, 'Post not found'); 16 + } 17 + 18 + try { 19 + const [postRecord, hostProfile] = await Promise.all([ 20 + getRecord({ 21 + did: did as Did, 22 + collection: 'site.standard.document', 23 + rkey 24 + }), 25 + cache 26 + ? cache.getProfile(did as Did).catch(() => null) 27 + : getBlentoOrBskyProfile({ did: did as Did }) 28 + .then( 29 + (p): CachedProfile => ({ 30 + did: p.did as string, 31 + handle: p.handle as string, 32 + displayName: p.displayName as string | undefined, 33 + avatar: p.avatar as string | undefined, 34 + hasBlento: p.hasBlento, 35 + url: p.url 36 + }) 37 + ) 38 + .catch(() => null) 39 + ]); 40 + 41 + if (!postRecord?.value) { 42 + throw error(404, 'Post not found'); 43 + } 44 + 45 + const post = postRecord.value as Record<string, unknown>; 46 + 47 + // Resolve external URL 48 + let externalUrl: string | null = null; 49 + const site = post.site as string | undefined; 50 + const path = post.path as string | undefined; 51 + 52 + if (site && path && site !== `at://${did}/site.standard.publication/blento.self`) { 53 + if (site.startsWith('at://')) { 54 + const siteParts = parseUri(site); 55 + if (siteParts) { 56 + try { 57 + const publicationRecord = await getRecord({ 58 + did: siteParts.repo as Did, 59 + collection: siteParts.collection!, 60 + rkey: siteParts.rkey 61 + }); 62 + 63 + if (publicationRecord.value?.url) { 64 + externalUrl = (publicationRecord.value.url as string) + path; 65 + } 66 + } catch { 67 + // Could not resolve publication URL 68 + } 69 + } 70 + } else { 71 + externalUrl = site + path; 72 + } 73 + } 74 + 75 + return { 76 + post, 77 + did, 78 + rkey, 79 + hostProfile: hostProfile ?? null, 80 + externalUrl 81 + }; 82 + } catch (e) { 83 + if (e && typeof e === 'object' && 'status' in e) throw e; 84 + throw error(404, 'Post not found'); 85 + } 86 + }
+409
src/routes/[[actor=actor]]/blog/[rkey]/+page.svelte
··· 1 + <script lang="ts"> 2 + import { getCDNImageBlobUrl } from '$lib/atproto'; 3 + import { Avatar as FoxAvatar } from '@foxui/core'; 4 + import { marked } from 'marked'; 5 + import { sanitize } from '$lib/sanitize'; 6 + import { all, createLowlight } from 'lowlight'; 7 + 8 + const lowlight = createLowlight(all); 9 + 10 + function hastToHtml(node: any): string { 11 + if (node.type === 'text') return escapeHtml(node.value); 12 + if (node.type === 'element') { 13 + const cls = node.properties?.className?.join(' '); 14 + const children = (node.children || []).map(hastToHtml).join(''); 15 + return cls ? `<span class="${cls}">${children}</span>` : `<span>${children}</span>`; 16 + } 17 + if (node.type === 'root' || node.children) { 18 + return (node.children || []).map(hastToHtml).join(''); 19 + } 20 + return ''; 21 + } 22 + 23 + function escapeHtml(str: string): string { 24 + return str 25 + .replace(/&/g, '&amp;') 26 + .replace(/</g, '&lt;') 27 + .replace(/>/g, '&gt;') 28 + .replace(/"/g, '&quot;'); 29 + } 30 + 31 + let { data } = $props(); 32 + 33 + let post = $derived(data.post as Record<string, unknown>); 34 + let did: string = $derived(data.did); 35 + let hostProfile = $derived(data.hostProfile); 36 + let externalUrl = $derived(data.externalUrl as string | null); 37 + 38 + let title = $derived((post.title as string) || ''); 39 + let description = $derived((post.description as string) || ''); 40 + let publishedAt = $derived(post.publishedAt as string | undefined); 41 + 42 + let hostName = $derived(hostProfile?.displayName || hostProfile?.handle || did); 43 + let hostUrl = $derived( 44 + hostProfile?.url ?? `https://bsky.app/profile/${hostProfile?.handle || did}` 45 + ); 46 + 47 + let actorPrefix = $derived(hostProfile?.handle ? `/${hostProfile.handle}` : `/${did}`); 48 + 49 + let coverUrl = $derived.by(() => { 50 + const coverImage = post.coverImage as { $type: 'blob'; ref: { $link: string } } | undefined; 51 + if (!coverImage) return undefined; 52 + return getCDNImageBlobUrl({ did, blob: coverImage, type: 'jpeg' }); 53 + }); 54 + 55 + let content = $derived(post.content as { $type: string; value: string } | undefined); 56 + let isMarkdown = $derived(content?.$type === 'app.blento.markdown'); 57 + 58 + let tags = $derived((post.tags as string[]) || []); 59 + let bskyPostRef = $derived(post.bskyPostRef as { uri: string; cid: string } | undefined); 60 + 61 + let bskyDiscussUrl = $derived.by(() => { 62 + if (!bskyPostRef?.uri) return undefined; 63 + const parts = bskyPostRef.uri.split('/'); 64 + const postDid = parts[2]; 65 + const postRkey = parts[parts.length - 1]; 66 + return `https://bsky.app/profile/${postDid}/post/${postRkey}`; 67 + }); 68 + 69 + function formatDate(dateStr: string): string { 70 + const date = new Date(dateStr); 71 + return date.toLocaleDateString('en-US', { 72 + year: 'numeric', 73 + month: 'long', 74 + day: 'numeric' 75 + }); 76 + } 77 + 78 + const renderer = new marked.Renderer(); 79 + renderer.link = ({ href, title, text }) => 80 + `<a target="_blank" rel="noopener noreferrer" href="${href}" title="${title ?? ''}">${text}</a>`; 81 + renderer.code = ({ text, lang }) => { 82 + let highlighted: string; 83 + try { 84 + const tree = 85 + lang && lowlight.registered(lang) 86 + ? lowlight.highlight(lang, text) 87 + : lowlight.highlightAuto(text); 88 + highlighted = hastToHtml(tree); 89 + } catch { 90 + highlighted = escapeHtml(text); 91 + } 92 + return `<pre><code class="hljs${lang ? ` language-${lang}` : ''}">${highlighted}</code></pre>`; 93 + }; 94 + </script> 95 + 96 + <svelte:head> 97 + <title>{title}</title> 98 + <meta name="description" content={description || `Blog post: ${title}`} /> 99 + <meta property="og:title" content={title} /> 100 + <meta property="og:description" content={description || `Blog post: ${title}`} /> 101 + {#if coverUrl} 102 + <meta property="og:image" content={coverUrl} /> 103 + {/if} 104 + <meta name="twitter:card" content={coverUrl ? 'summary_large_image' : 'summary'} /> 105 + <meta name="twitter:title" content={title} /> 106 + <meta name="twitter:description" content={description || `Blog post: ${title}`} /> 107 + {#if coverUrl} 108 + <meta name="twitter:image" content={coverUrl} /> 109 + {/if} 110 + </svelte:head> 111 + 112 + <div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12"> 113 + <div class="mx-auto max-w-3xl"> 114 + <!-- Cover image --> 115 + {#if coverUrl} 116 + <img src={coverUrl} alt={title} class="mb-8 aspect-video w-full rounded-2xl object-cover" /> 117 + {/if} 118 + 119 + <!-- Title & meta --> 120 + <header class="mb-8"> 121 + <h1 class="text-base-900 dark:text-base-50 mb-4 text-3xl leading-tight font-bold sm:text-4xl"> 122 + {title} 123 + </h1> 124 + 125 + <div class="flex flex-wrap items-center gap-4"> 126 + {#if publishedAt} 127 + <span class="text-base-500 dark:text-base-400 text-sm"> 128 + {formatDate(publishedAt)} 129 + </span> 130 + {/if} 131 + <a 132 + href={hostUrl} 133 + target={hostProfile?.hasBlento ? undefined : '_blank'} 134 + rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'} 135 + class="flex items-center gap-1.5 hover:underline" 136 + > 137 + <FoxAvatar src={hostProfile?.avatar} alt={hostName} class="size-5 shrink-0" /> 138 + <span class="text-base-900 dark:text-base-100 text-sm font-medium">{hostName}</span> 139 + </a> 140 + </div> 141 + 142 + {#if tags.length > 0} 143 + <div class="mt-4 flex flex-wrap gap-2"> 144 + {#each tags as tag (tag)} 145 + <span 146 + class="bg-base-100 dark:bg-base-800 text-base-600 dark:text-base-300 rounded-full px-3 py-1 text-xs font-medium" 147 + > 148 + {tag} 149 + </span> 150 + {/each} 151 + </div> 152 + {/if} 153 + </header> 154 + 155 + <!-- Content --> 156 + {#if isMarkdown && content} 157 + <article 158 + class="prose dark:prose-invert prose-base prose-neutral prose-a:text-accent-600 dark:prose-a:text-accent-400 prose-img:rounded-xl max-w-none" 159 + > 160 + {@html sanitize(marked.parse(content.value, { renderer }) as string, { 161 + ADD_ATTR: ['target'] 162 + })} 163 + </article> 164 + {:else} 165 + <div class="py-4"> 166 + {#if description} 167 + <p class="text-base-700 dark:text-base-300 mb-6 text-lg leading-relaxed"> 168 + {description} 169 + </p> 170 + {/if} 171 + 172 + {#if externalUrl} 173 + <a 174 + href={externalUrl} 175 + target="_blank" 176 + rel="noopener noreferrer" 177 + class="bg-base-900 dark:bg-base-50 text-base-50 dark:text-base-900 hover:bg-base-800 dark:hover:bg-base-200 inline-flex items-center gap-2 rounded-lg px-5 py-3 text-sm font-medium transition-colors" 178 + > 179 + Read on original page 180 + <svg 181 + xmlns="http://www.w3.org/2000/svg" 182 + fill="none" 183 + viewBox="0 0 24 24" 184 + stroke-width="2" 185 + stroke="currentColor" 186 + class="size-4" 187 + > 188 + <path 189 + stroke-linecap="round" 190 + stroke-linejoin="round" 191 + d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 192 + /> 193 + </svg> 194 + </a> 195 + {/if} 196 + </div> 197 + {/if} 198 + 199 + <!-- Footer --> 200 + <footer 201 + class="border-base-200 dark:border-base-800 mt-12 flex flex-wrap items-center gap-4 border-t pt-6" 202 + > 203 + <a 204 + href="{actorPrefix}/blog" 205 + class="text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 inline-flex items-center gap-1.5 text-sm transition-colors" 206 + > 207 + <svg 208 + xmlns="http://www.w3.org/2000/svg" 209 + fill="none" 210 + viewBox="0 0 24 24" 211 + stroke-width="2" 212 + stroke="currentColor" 213 + class="size-4" 214 + > 215 + <path 216 + stroke-linecap="round" 217 + stroke-linejoin="round" 218 + d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" 219 + /> 220 + </svg> 221 + Back to blog 222 + </a> 223 + 224 + {#if bskyDiscussUrl} 225 + <a 226 + href={bskyDiscussUrl} 227 + target="_blank" 228 + rel="noopener noreferrer" 229 + class="text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 inline-flex items-center gap-1.5 text-sm transition-colors" 230 + > 231 + Discuss on Bluesky 232 + <svg 233 + xmlns="http://www.w3.org/2000/svg" 234 + fill="none" 235 + viewBox="0 0 24 24" 236 + stroke-width="2" 237 + stroke="currentColor" 238 + class="size-3.5" 239 + > 240 + <path 241 + stroke-linecap="round" 242 + stroke-linejoin="round" 243 + d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 244 + /> 245 + </svg> 246 + </a> 247 + {/if} 248 + 249 + {#if externalUrl && isMarkdown} 250 + <a 251 + href={externalUrl} 252 + target="_blank" 253 + rel="noopener noreferrer" 254 + class="text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 inline-flex items-center gap-1.5 text-sm transition-colors" 255 + > 256 + View on original page 257 + <svg 258 + xmlns="http://www.w3.org/2000/svg" 259 + fill="none" 260 + viewBox="0 0 24 24" 261 + stroke-width="2" 262 + stroke="currentColor" 263 + class="size-3.5" 264 + > 265 + <path 266 + stroke-linecap="round" 267 + stroke-linejoin="round" 268 + d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 269 + /> 270 + </svg> 271 + </a> 272 + {/if} 273 + </footer> 274 + </div> 275 + </div> 276 + 277 + <style> 278 + :global(.prose pre) { 279 + background: #f6f8fa; 280 + border-radius: 1rem; 281 + color: #1f2328; 282 + font-family: 'JetBrainsMono', monospace; 283 + border: 1px solid #d1d9e0; 284 + } 285 + 286 + :global(.prose pre code) { 287 + background: none; 288 + color: inherit; 289 + font-size: 0.8rem; 290 + padding: 0; 291 + } 292 + 293 + :global(.prose pre .hljs-comment), 294 + :global(.prose pre .hljs-quote) { 295 + color: #59636e; 296 + font-style: italic; 297 + } 298 + 299 + :global(.prose pre .hljs-keyword), 300 + :global(.prose pre .hljs-selector-tag) { 301 + color: #cf222e; 302 + } 303 + 304 + :global(.prose pre .hljs-tag), 305 + :global(.prose pre .hljs-name) { 306 + color: #116329; 307 + } 308 + 309 + :global(.prose pre .hljs-attribute) { 310 + color: #0550ae; 311 + } 312 + 313 + :global(.prose pre .hljs-variable), 314 + :global(.prose pre .hljs-template-variable), 315 + :global(.prose pre .hljs-regexp), 316 + :global(.prose pre .hljs-link) { 317 + color: #0550ae; 318 + } 319 + 320 + :global(.prose pre .hljs-number), 321 + :global(.prose pre .hljs-literal) { 322 + color: #0550ae; 323 + } 324 + 325 + :global(.prose pre .hljs-string), 326 + :global(.prose pre .hljs-symbol), 327 + :global(.prose pre .hljs-bullet) { 328 + color: #0a3069; 329 + } 330 + 331 + :global(.prose pre .hljs-title), 332 + :global(.prose pre .hljs-section) { 333 + color: #0550ae; 334 + font-weight: 600; 335 + } 336 + 337 + :global(.prose pre .hljs-type), 338 + :global(.prose pre .hljs-built_in), 339 + :global(.prose pre .hljs-builtin-name), 340 + :global(.prose pre .hljs-params) { 341 + color: #953800; 342 + } 343 + 344 + :global(.prose pre .hljs-meta) { 345 + color: #0550ae; 346 + } 347 + 348 + /* Dark mode */ 349 + :global(.dark .prose pre) { 350 + background: #161b22; 351 + color: #e6edf3; 352 + border-color: #30363d; 353 + } 354 + 355 + :global(.dark .prose pre .hljs-comment), 356 + :global(.dark .prose pre .hljs-quote) { 357 + color: #8b949e; 358 + font-style: italic; 359 + } 360 + 361 + :global(.dark .prose pre .hljs-keyword), 362 + :global(.dark .prose pre .hljs-selector-tag) { 363 + color: #ff7b72; 364 + } 365 + 366 + :global(.dark .prose pre .hljs-tag), 367 + :global(.dark .prose pre .hljs-name) { 368 + color: #7ee787; 369 + } 370 + 371 + :global(.dark .prose pre .hljs-attribute) { 372 + color: #79c0ff; 373 + } 374 + 375 + :global(.dark .prose pre .hljs-variable), 376 + :global(.dark .prose pre .hljs-template-variable), 377 + :global(.dark .prose pre .hljs-regexp), 378 + :global(.dark .prose pre .hljs-link) { 379 + color: #79c0ff; 380 + } 381 + 382 + :global(.dark .prose pre .hljs-number), 383 + :global(.dark .prose pre .hljs-literal) { 384 + color: #79c0ff; 385 + } 386 + 387 + :global(.dark .prose pre .hljs-string), 388 + :global(.dark .prose pre .hljs-symbol), 389 + :global(.dark .prose pre .hljs-bullet) { 390 + color: #a5d6ff; 391 + } 392 + 393 + :global(.dark .prose pre .hljs-title), 394 + :global(.dark .prose pre .hljs-section) { 395 + color: #d2a8ff; 396 + font-weight: 600; 397 + } 398 + 399 + :global(.dark .prose pre .hljs-type), 400 + :global(.dark .prose pre .hljs-built_in), 401 + :global(.dark .prose pre .hljs-builtin-name), 402 + :global(.dark .prose pre .hljs-params) { 403 + color: #ffa657; 404 + } 405 + 406 + :global(.dark .prose pre .hljs-meta) { 407 + color: #79c0ff; 408 + } 409 + </style>
+536
src/routes/[[actor=actor]]/blog/new/+page.svelte
··· 1 + <script lang="ts"> 2 + import { user } from '$lib/atproto/auth.svelte'; 3 + import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 4 + import { uploadBlob, createTID } from '$lib/atproto/methods'; 5 + import { compressImage } from '$lib/atproto/image-helper'; 6 + import { Badge, Button } from '@foxui/core'; 7 + import { goto } from '$app/navigation'; 8 + import { onMount, onDestroy } from 'svelte'; 9 + import { SvelteMap } from 'svelte/reactivity'; 10 + import { RichTextEditor } from '$lib/components/rich-text-editor'; 11 + import { putImage, getImage, clearImages } from '$lib/components/image-store'; 12 + import type { Editor, Content } from '@tiptap/core'; 13 + 14 + const DRAFT_KEY = 'blog-draft'; 15 + 16 + let title = $state(''); 17 + let description = $state(''); 18 + let coverFile: File | null = $state(null); 19 + let coverPreview: string | null = $state(null); 20 + let submitting = $state(false); 21 + let error: string | null = $state(null); 22 + let hasDraft = $state(false); 23 + let editorInstance: Editor | null = $state(null); 24 + let editorContent: Content = $state({}); 25 + let draftRestored = $state(false); 26 + 27 + let fileInput: HTMLInputElement | undefined = $state(); 28 + let titleEl: HTMLTextAreaElement | undefined = $state(); 29 + let descriptionEl: HTMLTextAreaElement | undefined = $state(); 30 + let draggingOver = $state(false); 31 + 32 + function handleDrop(e: DragEvent) { 33 + e.preventDefault(); 34 + draggingOver = false; 35 + const file = e.dataTransfer?.files?.[0]; 36 + if (!file || !file.type.startsWith('image/')) return; 37 + coverFile = file; 38 + if (coverPreview) URL.revokeObjectURL(coverPreview); 39 + coverPreview = URL.createObjectURL(file); 40 + 41 + const key = crypto.randomUUID(); 42 + blobToKeyMap.set(coverPreview, key); 43 + keyToBlobMap.set(key, coverPreview); 44 + putImage(key, file, file.name); 45 + scheduleSaveDraft(); 46 + } 47 + 48 + // blob URL <-> IndexedDB key mappings 49 + const blobToKeyMap = new SvelteMap<string, string>(); 50 + const keyToBlobMap = new SvelteMap<string, string>(); 51 + 52 + // ── Image tracking ── 53 + 54 + function findImageSrcs(node: unknown): string[] { 55 + const srcs: string[] = []; 56 + function walk(n: Record<string, unknown>) { 57 + if (!n) return; 58 + if (n.type === 'image' && typeof (n.attrs as Record<string, unknown>)?.src === 'string') { 59 + srcs.push((n.attrs as Record<string, string>).src); 60 + } 61 + if (Array.isArray(n.content)) (n.content as Record<string, unknown>[]).forEach(walk); 62 + } 63 + walk(node as Record<string, unknown>); 64 + return srcs; 65 + } 66 + 67 + async function trackNewImages(content: Content) { 68 + const srcs = findImageSrcs(content); 69 + for (const src of srcs) { 70 + if (src.startsWith('blob:') && !blobToKeyMap.has(src)) { 71 + try { 72 + const response = await fetch(src); 73 + const blob = await response.blob(); 74 + const key = crypto.randomUUID(); 75 + await putImage(key, blob, 'image'); 76 + blobToKeyMap.set(src, key); 77 + keyToBlobMap.set(key, src); 78 + } catch (e) { 79 + console.error('Failed to store image in IndexedDB:', e); 80 + } 81 + } 82 + } 83 + } 84 + 85 + // ── Draft management ── 86 + 87 + function serializeContent(content: Content): Content { 88 + const json = JSON.parse(JSON.stringify(content)); 89 + function walk(node: Record<string, unknown>) { 90 + if (!node) return; 91 + const attrs = node.attrs as Record<string, string> | undefined; 92 + if (node.type === 'image' && attrs?.src && blobToKeyMap.has(attrs.src)) { 93 + attrs.src = `idb://${blobToKeyMap.get(attrs.src)}`; 94 + } 95 + if (Array.isArray(node.content)) (node.content as Record<string, unknown>[]).forEach(walk); 96 + } 97 + walk(json as Record<string, unknown>); 98 + return json; 99 + } 100 + 101 + let saveTimeout: ReturnType<typeof setTimeout> | undefined; 102 + 103 + function scheduleSaveDraft() { 104 + clearTimeout(saveTimeout); 105 + saveTimeout = setTimeout(saveDraft, 500); 106 + } 107 + 108 + function saveDraft() { 109 + const draft: Record<string, unknown> = { 110 + title, 111 + description, 112 + content: serializeContent(editorContent), 113 + updatedAt: Date.now() 114 + }; 115 + if (coverPreview && blobToKeyMap.has(coverPreview)) { 116 + draft.coverImageKey = blobToKeyMap.get(coverPreview); 117 + } 118 + localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); 119 + hasDraft = true; 120 + } 121 + 122 + async function restoreDraft() { 123 + const raw = localStorage.getItem(DRAFT_KEY); 124 + if (!raw) return; 125 + 126 + try { 127 + const draft = JSON.parse(raw); 128 + title = draft.title || ''; 129 + description = draft.description || ''; 130 + 131 + const content = draft.content; 132 + if (content) { 133 + // Collect all idb:// keys needed 134 + const idbKeys: string[] = []; 135 + function collectKeys(node: Record<string, unknown>) { 136 + if (!node) return; 137 + const attrs = node.attrs as Record<string, string> | undefined; 138 + if (node.type === 'image' && attrs?.src?.startsWith('idb://')) { 139 + idbKeys.push(attrs.src.replace('idb://', '')); 140 + } 141 + if (Array.isArray(node.content)) 142 + (node.content as Record<string, unknown>[]).forEach(collectKeys); 143 + } 144 + collectKeys(content); 145 + if (draft.coverImageKey) idbKeys.push(draft.coverImageKey); 146 + 147 + // Load images from IndexedDB and create blob URLs 148 + for (const key of idbKeys) { 149 + const img = await getImage(key); 150 + if (img) { 151 + const blobUrl = URL.createObjectURL(img.blob); 152 + blobToKeyMap.set(blobUrl, key); 153 + keyToBlobMap.set(key, blobUrl); 154 + } 155 + } 156 + 157 + // Replace idb:// references with new blob URLs 158 + function replaceRefs(node: Record<string, unknown>) { 159 + if (!node) return; 160 + const attrs = node.attrs as Record<string, string> | undefined; 161 + if (node.type === 'image' && attrs?.src?.startsWith('idb://')) { 162 + const key = attrs.src.replace('idb://', ''); 163 + const blobUrl = keyToBlobMap.get(key); 164 + if (blobUrl) attrs.src = blobUrl; 165 + } 166 + if (Array.isArray(node.content)) 167 + (node.content as Record<string, unknown>[]).forEach(replaceRefs); 168 + } 169 + replaceRefs(content); 170 + editorContent = content; 171 + } 172 + 173 + // Restore cover image 174 + if (draft.coverImageKey) { 175 + const blobUrl = keyToBlobMap.get(draft.coverImageKey); 176 + if (blobUrl) { 177 + coverPreview = blobUrl; 178 + const img = await getImage(draft.coverImageKey); 179 + if (img) { 180 + coverFile = new File([img.blob], img.name, { type: img.blob.type }); 181 + } 182 + } 183 + } 184 + 185 + hasDraft = true; 186 + } catch (e) { 187 + console.error('Failed to restore draft:', e); 188 + } 189 + } 190 + 191 + function clearDraft() { 192 + localStorage.removeItem(DRAFT_KEY); 193 + clearImages(); 194 + hasDraft = false; 195 + } 196 + 197 + function discardDraft() { 198 + clearDraft(); 199 + title = ''; 200 + description = ''; 201 + coverFile = null; 202 + if (coverPreview) URL.revokeObjectURL(coverPreview); 203 + coverPreview = null; 204 + editorContent = {}; 205 + editorInstance?.commands.clearContent(); 206 + } 207 + 208 + // ── Cover image ── 209 + 210 + function onFileChange(e: Event) { 211 + const input = e.target as HTMLInputElement; 212 + const file = input.files?.[0]; 213 + if (!file) return; 214 + coverFile = file; 215 + if (coverPreview) URL.revokeObjectURL(coverPreview); 216 + coverPreview = URL.createObjectURL(file); 217 + 218 + const key = crypto.randomUUID(); 219 + blobToKeyMap.set(coverPreview, key); 220 + keyToBlobMap.set(key, coverPreview); 221 + putImage(key, file, file.name); 222 + scheduleSaveDraft(); 223 + } 224 + 225 + function removeCover() { 226 + coverFile = null; 227 + if (coverPreview) { 228 + URL.revokeObjectURL(coverPreview); 229 + coverPreview = null; 230 + } 231 + if (fileInput) fileInput.value = ''; 232 + scheduleSaveDraft(); 233 + } 234 + 235 + // ── Publish ── 236 + 237 + async function handleSubmit() { 238 + error = null; 239 + 240 + if (!title.trim()) { 241 + error = 'Title is required.'; 242 + return; 243 + } 244 + if (!user.client || !user.did) { 245 + error = 'You must be logged in.'; 246 + return; 247 + } 248 + 249 + submitting = true; 250 + 251 + try { 252 + // Upload cover image 253 + let coverImageBlob: unknown | undefined; 254 + if (coverFile) { 255 + const compressed = await compressImage(coverFile); 256 + const blobRef = await uploadBlob({ blob: compressed.blob }); 257 + if (blobRef) coverImageBlob = blobRef; 258 + } 259 + 260 + // Convert content to markdown using tiptap's built-in markdown support 261 + let markdown = editorInstance?.getMarkdown() ?? ''; 262 + 263 + // Upload all blob:// images and replace with CDN URLs 264 + const blobUrlRegex = /!\[([^\]]*)\]\((blob:[^\s)]+)(?:\s+"[^"]*")?\)/g; 265 + const matches = [...markdown.matchAll(blobUrlRegex)]; 266 + const imageBlobs: unknown[] = []; 267 + 268 + for (const match of matches) { 269 + const blobUrl = match[2]; 270 + try { 271 + const response = await fetch(blobUrl); 272 + const blob = await response.blob(); 273 + const file = new File([blob], 'image.jpg', { type: blob.type }); 274 + const compressed = await compressImage(file); 275 + const blobRef = await uploadBlob({ blob: compressed.blob }); 276 + if (blobRef) { 277 + imageBlobs.push(blobRef); 278 + const cdnUrl = `https://cdn.bsky.app/img/feed_fullsize/plain/${user.did}/${blobRef.ref.$link}@jpeg`; 279 + markdown = markdown.replaceAll(blobUrl, cdnUrl); 280 + } 281 + } catch (e) { 282 + console.error('Failed to upload inline image:', e); 283 + } 284 + } 285 + 286 + const rkey = createTID(); 287 + 288 + const record: Record<string, unknown> = { 289 + $type: 'site.standard.document', 290 + title: title.trim(), 291 + content: { 292 + $type: 'app.blento.markdown', 293 + value: markdown, 294 + images: imageBlobs.length > 0 ? imageBlobs : undefined 295 + }, 296 + site: `at://${user.did}/site.standard.publication/blento.self`, 297 + path: `/blog/${rkey}`, 298 + publishedAt: new Date().toISOString() 299 + }; 300 + 301 + if (description.trim()) record.description = description.trim(); 302 + if (coverImageBlob) record.coverImage = coverImageBlob; 303 + 304 + const response = await user.client.post('com.atproto.repo.createRecord', { 305 + input: { 306 + collection: 'site.standard.document', 307 + repo: user.did, 308 + rkey, 309 + record 310 + } 311 + }); 312 + 313 + if (response.ok) { 314 + clearDraft(); 315 + const handle = 316 + user.profile?.handle && user.profile.handle !== 'handle.invalid' 317 + ? user.profile.handle 318 + : user.did; 319 + goto(`/${handle}/blog/${rkey}`); 320 + } else { 321 + error = 'Failed to create post. Please try again.'; 322 + } 323 + } catch (e) { 324 + console.error('Failed to create post:', e); 325 + error = 'Failed to create post. Please try again.'; 326 + } finally { 327 + submitting = false; 328 + } 329 + } 330 + 331 + // ── Editor callbacks ── 332 + 333 + function onEditorUpdate(content: Content) { 334 + editorContent = content; 335 + trackNewImages(content); 336 + scheduleSaveDraft(); 337 + } 338 + 339 + function autoResize(el: HTMLTextAreaElement) { 340 + const resize = () => { 341 + el.style.height = 'auto'; 342 + el.style.height = el.scrollHeight + 'px'; 343 + }; 344 + resize(); 345 + return { update: resize }; 346 + } 347 + 348 + function onTitleInput() { 349 + scheduleSaveDraft(); 350 + } 351 + 352 + function onDescriptionInput() { 353 + scheduleSaveDraft(); 354 + } 355 + 356 + // ── Lifecycle ── 357 + 358 + onMount(async () => { 359 + await restoreDraft(); 360 + draftRestored = true; 361 + }); 362 + 363 + onDestroy(() => { 364 + clearTimeout(saveTimeout); 365 + for (const blobUrl of blobToKeyMap.keys()) { 366 + URL.revokeObjectURL(blobUrl); 367 + } 368 + }); 369 + </script> 370 + 371 + <svelte:head> 372 + <title>Create Blog Post</title> 373 + </svelte:head> 374 + 375 + <div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12"> 376 + <div class="mx-auto max-w-3xl"> 377 + {#if user.isInitializing || !draftRestored} 378 + <div class="flex items-center gap-3"> 379 + <div class="bg-base-300 dark:bg-base-700 size-5 animate-pulse rounded-full"></div> 380 + <div class="bg-base-300 dark:bg-base-700 h-4 w-48 animate-pulse rounded"></div> 381 + </div> 382 + {:else if !user.isLoggedIn} 383 + <div 384 + class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-900/50 rounded-2xl border p-8 text-center" 385 + > 386 + <p class="text-base-600 dark:text-base-400 mb-4">Log in to create a blog post.</p> 387 + <Button onclick={() => loginModalState.show()}>Log in</Button> 388 + </div> 389 + {:else} 390 + <!-- Draft badge --> 391 + <div class="mb-6 flex items-center gap-3"> 392 + <Badge>Local draft</Badge> 393 + {#if hasDraft} 394 + <button 395 + type="button" 396 + onclick={discardDraft} 397 + class="text-base-400 dark:text-base-500 cursor-pointer text-xs transition-colors hover:text-red-500 dark:hover:text-red-400" 398 + > 399 + Discard draft 400 + </button> 401 + {/if} 402 + </div> 403 + 404 + <!-- Cover image — full-width like the blog post view --> 405 + <input 406 + bind:this={fileInput} 407 + type="file" 408 + id="cover" 409 + accept="image/*" 410 + onchange={onFileChange} 411 + class="hidden" 412 + /> 413 + {#if coverPreview} 414 + <div class="group relative mb-8"> 415 + <img 416 + src={coverPreview} 417 + alt="Cover preview" 418 + class="aspect-video w-full rounded-2xl object-cover" 419 + /> 420 + <div 421 + class="absolute inset-0 flex items-center justify-center gap-2 rounded-2xl bg-black/0 opacity-0 transition-all group-hover:bg-black/30 group-hover:opacity-100" 422 + > 423 + <button 424 + type="button" 425 + onclick={() => fileInput?.click()} 426 + class="rounded-lg bg-white/90 px-3 py-1.5 text-sm font-medium text-black transition-colors hover:bg-white" 427 + > 428 + Replace 429 + </button> 430 + <button 431 + type="button" 432 + onclick={removeCover} 433 + class="rounded-lg bg-white/90 px-3 py-1.5 text-sm font-medium text-red-600 transition-colors hover:bg-white" 434 + > 435 + Remove 436 + </button> 437 + </div> 438 + </div> 439 + {:else} 440 + <button 441 + type="button" 442 + onclick={() => fileInput?.click()} 443 + ondragover={(e) => { 444 + e.preventDefault(); 445 + draggingOver = true; 446 + }} 447 + ondragleave={() => (draggingOver = false)} 448 + ondrop={handleDrop} 449 + class="mb-8 flex h-20 w-full cursor-pointer items-center gap-3 rounded-xl border-2 border-dashed px-5 transition-colors {draggingOver 450 + ? 'border-accent-400 bg-accent-50 dark:border-accent-500 dark:bg-accent-950' 451 + : 'border-base-300 dark:border-base-700 hover:border-base-400 dark:hover:border-base-600'}" 452 + > 453 + <svg 454 + xmlns="http://www.w3.org/2000/svg" 455 + fill="none" 456 + viewBox="0 0 24 24" 457 + stroke-width="1.5" 458 + stroke="currentColor" 459 + class="text-base-400 dark:text-base-500 size-6 shrink-0" 460 + > 461 + <path 462 + stroke-linecap="round" 463 + stroke-linejoin="round" 464 + d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z" 465 + /> 466 + </svg> 467 + <span class="text-base-400 dark:text-base-500 text-sm"> 468 + {draggingOver ? 'Drop image here' : 'Add cover image or drag & drop'} 469 + </span> 470 + </button> 471 + {/if} 472 + 473 + <!-- Title & description — styled like the blog post header --> 474 + <header class="mb-8"> 475 + <textarea 476 + bind:this={titleEl} 477 + bind:value={title} 478 + oninput={(e) => { 479 + onTitleInput(); 480 + autoResize(e.currentTarget); 481 + }} 482 + onkeydown={(e) => { 483 + if (e.key === 'Enter' && !e.shiftKey) { 484 + e.preventDefault(); 485 + descriptionEl?.focus(); 486 + } 487 + }} 488 + use:autoResize 489 + placeholder="Post title" 490 + rows={1} 491 + class="text-base-900 dark:text-base-50 placeholder:text-base-500 mb-4 w-full resize-none border-0 border-none bg-transparent text-3xl leading-tight font-bold outline-none focus:border-0 focus:ring-0 focus:outline-0 sm:text-4xl" 492 + ></textarea> 493 + <textarea 494 + bind:this={descriptionEl} 495 + bind:value={description} 496 + oninput={(e) => { 497 + onDescriptionInput(); 498 + autoResize(e.currentTarget); 499 + }} 500 + onkeydown={(e) => { 501 + if (e.key === 'Enter' && !e.shiftKey) { 502 + e.preventDefault(); 503 + editorInstance?.commands.focus(); 504 + } 505 + }} 506 + use:autoResize 507 + placeholder="A short description (optional)" 508 + rows={1} 509 + class="text-base-600 dark:text-base-400 placeholder:text-base-400 dark:placeholder:text-base-600 w-full resize-none border-0 border-none bg-transparent text-sm leading-relaxed outline-none focus:border-0 focus:ring-0 focus:outline-0" 510 + ></textarea> 511 + </header> 512 + 513 + <!-- Rich text editor — styled like the blog post content area --> 514 + <article 515 + class="prose dark:prose-invert prose-base prose-neutral prose-a:text-accent-600 dark:prose-a:text-accent-400 prose-img:rounded-xl mb-12 max-w-none px-4" 516 + > 517 + <RichTextEditor 518 + bind:editor={editorInstance} 519 + content={editorContent} 520 + placeholder="Start writing..." 521 + onupdate={onEditorUpdate} 522 + /> 523 + </article> 524 + 525 + {#if error} 526 + <p class="mb-4 text-sm text-red-600 dark:text-red-400">{error}</p> 527 + {/if} 528 + 529 + <div class="border-base-200 dark:border-base-800 border-t pt-6"> 530 + <Button onclick={handleSubmit} disabled={submitting} class="w-full"> 531 + {submitting ? 'Publishing...' : 'Publish Post'} 532 + </Button> 533 + </div> 534 + {/if} 535 + </div> 536 + </div>
+11 -11
src/routes/[[actor=actor]]/e/+page.server.ts src/routes/[[actor=actor]]/events/+page.server.ts
··· 1 1 import { error } from '@sveltejs/kit'; 2 2 import type { EventData } from '$lib/cards/social/EventCard'; 3 - import { getBlentoOrBskyProfile } from '$lib/atproto/methods.js'; 3 + import { getBlentoOrBskyProfile, listRecords } from '$lib/atproto/methods.js'; 4 4 import { createCache, type CachedProfile } from '$lib/cache'; 5 5 import type { Did } from '@atcute/lexicons'; 6 6 import { getActor } from '$lib/actor.js'; ··· 15 15 } 16 16 17 17 try { 18 - const [eventsResponse, hostProfile] = await Promise.all([ 19 - fetch( 20 - `https://smokesignal.events/xrpc/community.lexicon.calendar.searchEvents?repository=${encodeURIComponent(did)}&query=upcoming` 21 - ), 18 + const [records, hostProfile] = await Promise.all([ 19 + listRecords({ 20 + did: did as Did, 21 + collection: 'community.lexicon.calendar.event', 22 + limit: 100 23 + }), 22 24 cache 23 25 ? cache.getProfile(did as Did).catch(() => null) 24 26 : getBlentoOrBskyProfile({ did: did as Did }) ··· 35 37 .catch(() => null) 36 38 ]); 37 39 38 - if (!eventsResponse.ok) { 39 - throw error(404, 'Events not found'); 40 - } 41 - 42 - const data: { results: EventData[] } = await eventsResponse.json(); 43 - const events = data.results; 40 + const events = records.map((r) => ({ 41 + ...(r.value as EventData), 42 + rkey: r.uri.split('/').pop() as string 43 + })); 44 44 45 45 return { 46 46 events,
+48 -49
src/routes/[[actor=actor]]/e/+page.svelte src/routes/[[actor=actor]]/events/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import type { EventData } from '$lib/cards/social/EventCard'; 3 3 import { getCDNImageBlobUrl } from '$lib/atproto'; 4 - import { Avatar as FoxAvatar, Badge } from '@foxui/core'; 4 + import { user } from '$lib/atproto/auth.svelte'; 5 + import { Avatar as FoxAvatar, Badge, Button } from '@foxui/core'; 5 6 import Avatar from 'svelte-boring-avatars'; 6 7 7 8 let { data } = $props(); 8 9 9 - let events: EventData[] = $derived(data.events); 10 + let events: (EventData & { rkey: string })[] = $derived(data.events); 10 11 let did: string = $derived(data.did); 11 12 let hostProfile = $derived(data.hostProfile); 12 13 ··· 74 75 return { url, alt: media.alt || event.name }; 75 76 } 76 77 77 - function getRkey(event: EventData): string { 78 - return event.url.split('/').pop() || ''; 79 - } 80 - 81 78 let actorPrefix = $derived(data.hostProfile?.handle ? `/${data.hostProfile.handle}` : `/${did}`); 79 + let isOwner = $derived(user.isLoggedIn && user.did === did); 82 80 </script> 83 81 84 82 <svelte:head> ··· 94 92 <div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12 sm:py-12"> 95 93 <div class="mx-auto max-w-4xl"> 96 94 <!-- Header --> 97 - <div class="mb-8"> 98 - <h1 class="text-base-900 dark:text-base-50 mb-2 text-2xl font-bold sm:text-3xl"> 99 - Upcoming events 100 - </h1> 101 - <div class="flex items-center gap-2 mt-4"> 102 - <span class="text-base-500 dark:text-base-400 text-sm">Hosted by</span> 103 - <a 104 - href={hostUrl} 105 - target={hostProfile?.hasBlento ? undefined : '_blank'} 106 - rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'} 107 - class="flex items-center gap-1.5 hover:underline" 108 - > 109 - <FoxAvatar src={hostProfile?.avatar} alt={hostName} class="size-5 shrink-0" /> 110 - <span class="text-base-900 dark:text-base-100 text-sm font-medium">{hostName}</span> 111 - </a> 95 + <div class="mb-8 flex items-start justify-between"> 96 + <div> 97 + <h1 class="text-base-900 dark:text-base-50 mb-2 text-2xl font-bold sm:text-3xl"> 98 + Upcoming events 99 + </h1> 100 + <div class="mt-4 flex items-center gap-2"> 101 + <span class="text-base-500 dark:text-base-400 text-sm">Hosted by</span> 102 + <a 103 + href={hostUrl} 104 + target={hostProfile?.hasBlento ? undefined : '_blank'} 105 + rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'} 106 + class="flex items-center gap-1.5 hover:underline" 107 + > 108 + <FoxAvatar src={hostProfile?.avatar} alt={hostName} class="size-5 shrink-0" /> 109 + <span class="text-base-900 dark:text-base-100 text-sm font-medium">{hostName}</span> 110 + </a> 111 + </div> 112 112 </div> 113 + {#if isOwner} 114 + <Button href="{actorPrefix}/events/new" variant="primary">New event</Button> 115 + {/if} 113 116 </div> 114 117 115 118 {#if events.length === 0} 116 119 <p class="text-base-500 dark:text-base-400 py-12 text-center">No events found.</p> 117 120 {:else} 118 121 <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> 119 - {#each events as event (event.url)} 122 + {#each events as event (event.rkey)} 120 123 {@const thumbnail = getThumbnail(event)} 121 124 {@const location = getLocationString(event.locations)} 122 - {@const rkey = getRkey(event)} 125 + {@const rkey = event.rkey} 123 126 <a 124 - href="{actorPrefix}/e/{rkey}" 125 - class="border-base-200 dark:border-base-800 hover:border-base-300 dark:hover:border-base-700 group block overflow-hidden rounded-xl border transition-colors" 127 + href="{actorPrefix}/events/{rkey}" 128 + class="border-base-200 dark:border-base-800 hover:border-base-300 dark:hover:border-base-700 group bg-base-100 dark:bg-base-900 block overflow-hidden rounded-2xl border transition-colors" 126 129 > 127 130 <!-- Thumbnail --> 128 - {#if thumbnail} 129 - <img 130 - src={thumbnail.url} 131 - alt={thumbnail.alt} 132 - class="aspect-square w-full object-cover" 133 - /> 134 - {:else} 135 - <div 136 - class="bg-base-100 dark:bg-base-900 aspect-square w-full [&>svg]:h-full [&>svg]:w-full" 137 - > 138 - <Avatar 139 - size={400} 140 - name={rkey} 141 - variant="marble" 142 - colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 143 - square 131 + <div class="p-4"> 132 + {#if thumbnail} 133 + <img 134 + src={thumbnail.url} 135 + alt={thumbnail.alt} 136 + class="aspect-square w-full rounded-2xl object-cover" 144 137 /> 145 - </div> 146 - {/if} 138 + {:else} 139 + <div 140 + class="bg-base-100 dark:bg-base-900 aspect-square w-full overflow-hidden rounded-2xl [&>svg]:h-full [&>svg]:w-full" 141 + > 142 + <Avatar 143 + size={400} 144 + name={rkey} 145 + variant="marble" 146 + colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 147 + square 148 + /> 149 + </div> 150 + {/if} 151 + </div> 147 152 148 153 <!-- Content --> 149 154 <div class="p-4"> ··· 168 173 <span class="text-base-500 dark:text-base-400 truncate text-xs">{location}</span> 169 174 {/if} 170 175 </div> 171 - 172 - {#if event.countGoing && event.countGoing > 0} 173 - <p class="text-base-500 dark:text-base-400 mt-2 text-xs"> 174 - {event.countGoing} going 175 - </p> 176 - {/if} 177 176 </div> 178 177 </a> 179 178 {/each}
+13 -17
src/routes/[[actor=actor]]/e/[rkey]/+page.server.ts src/routes/[[actor=actor]]/events/[rkey]/+page.server.ts
··· 1 1 import { error } from '@sveltejs/kit'; 2 2 import type { EventData } from '$lib/cards/social/EventCard'; 3 - import { getBlentoOrBskyProfile, getRecord, resolveHandle } from '$lib/atproto/methods.js'; 4 - import { isHandle } from '@atcute/lexicons/syntax'; 3 + import { getBlentoOrBskyProfile, getRecord } from '$lib/atproto/methods.js'; 5 4 import { createCache, type CachedProfile } from '$lib/cache'; 6 - import type { ActorIdentifier, Did } from '@atcute/lexicons'; 7 - import { env as publicEnv } from '$env/dynamic/public'; 5 + import type { Did } from '@atcute/lexicons'; 8 6 import { getActor } from '$lib/actor'; 9 7 10 8 export async function load({ params, platform, request }) { ··· 19 17 } 20 18 21 19 try { 22 - const [eventResponse, hostProfile, eventRecord] = await Promise.all([ 23 - fetch( 24 - `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(did)}&record_key=${encodeURIComponent(rkey)}` 25 - ), 20 + const [eventRecord, hostProfile] = await Promise.all([ 21 + getRecord({ 22 + did: did as Did, 23 + collection: 'community.lexicon.calendar.event', 24 + rkey 25 + }), 26 26 cache 27 27 ? cache.getProfile(did as Did).catch(() => null) 28 28 : getBlentoOrBskyProfile({ did: did as Did }) ··· 36 36 url: p.url 37 37 }) 38 38 ) 39 - .catch(() => null), 40 - getRecord({ 41 - did: did as Did, 42 - collection: 'community.lexicon.calendar.event', 43 - rkey 44 - }).catch(() => null) 39 + .catch(() => null) 45 40 ]); 46 41 47 - if (!eventResponse.ok) { 42 + if (!eventRecord?.value) { 48 43 throw error(404, 'Event not found'); 49 44 } 50 45 51 - const eventData: EventData = await eventResponse.json(); 46 + const eventData: EventData = eventRecord.value as EventData; 47 + console.log(eventData); 52 48 53 49 return { 54 50 eventData, 55 51 did, 56 52 rkey, 57 53 hostProfile: hostProfile ?? null, 58 - eventCid: (eventRecord?.cid as string) ?? null 54 + eventCid: (eventRecord.cid as string) ?? null 59 55 }; 60 56 } catch (e) { 61 57 if (e && typeof e === 'object' && 'status' in e) throw e;
+109 -43
src/routes/[[actor=actor]]/e/[rkey]/+page.svelte src/routes/[[actor=actor]]/events/[rkey]/+page.svelte
··· 5 5 import Avatar from 'svelte-boring-avatars'; 6 6 import EventRsvp from './EventRsvp.svelte'; 7 7 import { page } from '$app/state'; 8 + import { segmentize, type Facet } from '@atcute/bluesky-richtext-segmenter'; 9 + import { sanitize } from '$lib/sanitize'; 8 10 9 11 let { data } = $props(); 10 12 ··· 79 81 80 82 let location = $derived(getLocationString(eventData.locations)); 81 83 82 - let headerImage = $derived.by(() => { 84 + let thumbnailImage = $derived.by(() => { 83 85 if (!eventData.media || eventData.media.length === 0) return null; 84 86 const media = eventData.media.find((m) => m.role === 'thumbnail'); 85 87 if (!media?.content) return null; ··· 88 90 return { url, alt: media.alt || eventData.name }; 89 91 }); 90 92 91 - let eventUrl = $derived(eventData.url || `https://smokesignal.events/${did}/${rkey}`); 93 + let bannerImage = $derived.by(() => { 94 + if (!eventData.media || eventData.media.length === 0) return null; 95 + const media = eventData.media.find((m) => m.role === 'header'); 96 + if (!media?.content) return null; 97 + const url = getCDNImageBlobUrl({ did, blob: media.content, type: 'jpeg' }); 98 + if (!url) return null; 99 + return { url, alt: media.alt || eventData.name }; 100 + }); 101 + 102 + // Prefer thumbnail; fall back to header/banner image 103 + let displayImage = $derived(thumbnailImage ?? bannerImage); 104 + let isBannerOnly = $derived(!thumbnailImage && !!bannerImage); 105 + 106 + let isSameDay = $derived( 107 + endDate && 108 + startDate.getFullYear() === endDate.getFullYear() && 109 + startDate.getMonth() === endDate.getMonth() && 110 + startDate.getDate() === endDate.getDate() 111 + ); 112 + 113 + function escapeHtml(str: string): string { 114 + return str 115 + .replace(/&/g, '&amp;') 116 + .replace(/</g, '&lt;') 117 + .replace(/>/g, '&gt;') 118 + .replace(/"/g, '&quot;') 119 + .replace(/'/g, '&#39;'); 120 + } 121 + 122 + function renderDescription(text: string, facets?: Facet[]): string { 123 + const segments = segmentize(text, facets); 124 + const html = segments 125 + .map((segment) => { 126 + const escaped = escapeHtml(segment.text); 127 + const feature = segment.features?.[0] as 128 + | { $type: string; did?: string; uri?: string; tag?: string } 129 + | undefined; 130 + if (!feature) return `<span>${escaped}</span>`; 131 + 132 + const link = (href: string) => 133 + `<a target="_blank" rel="noopener noreferrer nofollow" href="${encodeURI(href)}" class="text-accent-600 dark:text-accent-400 hover:underline">${escaped}</a>`; 134 + 135 + switch (feature.$type) { 136 + case 'app.bsky.richtext.facet#mention': 137 + return link(`https://bsky.app/profile/${feature.did}`); 138 + case 'app.bsky.richtext.facet#link': 139 + return link(feature.uri!); 140 + case 'app.bsky.richtext.facet#tag': 141 + return link(`https://bsky.app/hashtag/${feature.tag}`); 142 + default: 143 + return `<span>${escaped}</span>`; 144 + } 145 + }) 146 + .join(''); 147 + return html.replace(/\n/g, '<br>'); 148 + } 149 + 150 + let descriptionHtml = $derived( 151 + eventData.description 152 + ? sanitize(renderDescription(eventData.description, eventData.facets as Facet[] | undefined)) 153 + : null 154 + ); 155 + 156 + let smokesignalUrl = $derived(`https://smokesignal.events/${did}/${rkey}`); 92 157 let eventUri = $derived(`at://${did}/community.lexicon.calendar.event/${rkey}`); 93 158 94 159 let ogImageUrl = $derived(`${page.url.origin}${page.url.pathname}/og.png`); ··· 108 173 109 174 <div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12 sm:py-12"> 110 175 <div class="mx-auto max-w-4xl"> 176 + <!-- Banner image (full width, only when no thumbnail) --> 177 + {#if isBannerOnly && displayImage} 178 + <img 179 + src={displayImage.url} 180 + alt={displayImage.alt} 181 + class="border-base-200 dark:border-base-800 mb-8 aspect-3/1 w-full rounded-2xl border object-cover" 182 + /> 183 + {/if} 184 + 111 185 <!-- Two-column layout: image left, details right --> 112 186 <div 113 187 class="grid grid-cols-1 gap-8 md:grid-cols-[14rem_1fr] md:gap-x-10 md:gap-y-6 lg:grid-cols-[16rem_1fr]" 114 188 > 115 - <!-- Image --> 116 - <div class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none"> 117 - {#if headerImage} 118 - <img 119 - src={headerImage.url} 120 - alt={headerImage.alt} 121 - class="border-base-200 dark:border-base-800 aspect-square w-full rounded-2xl border object-cover" 122 - /> 123 - {:else} 124 - <div 125 - class="border-base-200 dark:border-base-800 aspect-square w-full overflow-hidden rounded-2xl border [&>svg]:h-full [&>svg]:w-full" 126 - > 127 - <Avatar 128 - size={256} 129 - name={data.rkey} 130 - variant="marble" 131 - colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 132 - square 189 + <!-- Thumbnail image (left column) --> 190 + {#if !isBannerOnly} 191 + <div class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none"> 192 + {#if displayImage} 193 + <img 194 + src={displayImage.url} 195 + alt={displayImage.alt} 196 + class="border-base-200 dark:border-base-800 aspect-square w-full rounded-2xl border object-cover" 133 197 /> 134 - </div> 135 - {/if} 136 - </div> 198 + {:else} 199 + <div 200 + class="border-base-200 dark:border-base-800 aspect-square w-full overflow-hidden rounded-2xl border [&>svg]:h-full [&>svg]:w-full" 201 + > 202 + <Avatar 203 + size={256} 204 + name={data.rkey} 205 + variant="marble" 206 + colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 207 + square 208 + /> 209 + </div> 210 + {/if} 211 + </div> 212 + {/if} 137 213 138 214 <!-- Right column: event details --> 139 215 <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1"> ··· 167 243 <div> 168 244 <p class="text-base-900 dark:text-base-50 font-semibold"> 169 245 {formatWeekday(startDate)}, {formatFullDate(startDate)} 246 + {#if endDate && !isSameDay} 247 + - {formatWeekday(endDate)}, {formatFullDate(endDate)} 248 + {/if} 170 249 </p> 171 250 <p class="text-base-500 dark:text-base-400 text-sm"> 172 251 {formatTime(startDate)} 173 - {#if endDate} 174 - - {formatTime(endDate)}{/if} 252 + {#if endDate && isSameDay} 253 + - {formatTime(endDate)} 254 + {/if} 175 255 </p> 176 256 </div> 177 257 </div> ··· 209 289 <EventRsvp {eventUri} eventCid={data.eventCid} /> 210 290 211 291 <!-- About Event --> 212 - {#if eventData.description} 292 + {#if descriptionHtml} 213 293 <div class="mt-8 mb-8"> 214 294 <p 215 295 class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 216 296 > 217 297 About 218 298 </p> 219 - <p class="text-base-700 dark:text-base-300 leading-relaxed whitespace-pre-wrap"> 220 - {eventData.description} 299 + <p class="text-base-700 dark:text-base-300 leading-relaxed"> 300 + {@html descriptionHtml} 221 301 </p> 222 302 </div> 223 303 {/if} ··· 247 327 </a> 248 328 </div> 249 329 250 - {#if (eventData.countGoing && eventData.countGoing > 0) || (eventData.countInterested && eventData.countInterested > 0)} 251 - <!-- Counts --> 252 - <div 253 - class="text-base-900 dark:text-base-100 order-4 space-y-2.5 text-base font-medium md:order-0 md:col-start-1" 254 - > 255 - {#if eventData.countGoing && eventData.countGoing > 0} 256 - <p>{eventData.countGoing} Going</p> 257 - {/if} 258 - {#if eventData.countInterested && eventData.countInterested > 0} 259 - <p>{eventData.countInterested} Interested</p> 260 - {/if} 261 - </div> 262 - {/if} 263 - 264 330 {#if eventData.uris && eventData.uris.length > 0} 265 331 <!-- Links --> 266 332 <div class="order-5 md:order-0 md:col-start-1"> ··· 300 366 301 367 <!-- View on Smoke Signal link --> 302 368 <a 303 - href={eventUrl} 369 + href={smokesignalUrl} 304 370 target="_blank" 305 371 rel="noopener noreferrer" 306 372 class="text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 order-6 inline-flex items-center gap-1.5 text-sm transition-colors md:order-0 md:col-start-2"
+1 -1
src/routes/[[actor=actor]]/e/[rkey]/EventRsvp.svelte src/routes/[[actor=actor]]/events/[rkey]/EventRsvp.svelte
··· 120 120 </script> 121 121 122 122 <div 123 - class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-900/50 mt-8 mb-2 rounded-2xl border p-4" 123 + class="border-base-200 dark:border-base-800 bg-base-100 items-between dark:bg-base-900/50 mt-8 mb-2 flex h-25 flex-col justify-center rounded-2xl border p-4" 124 124 > 125 125 {#if user.isInitializing || rsvpLoading} 126 126 <div class="flex items-center gap-3">
+9 -9
src/routes/[[actor=actor]]/e/[rkey]/og.png/+server.ts src/routes/[[actor=actor]]/events/[rkey]/og.png/+server.ts
··· 1 - import { getCDNImageBlobUrl, resolveHandle } from '$lib/atproto/methods.js'; 2 - import { env as publicEnv } from '$env/dynamic/public'; 1 + import { getCDNImageBlobUrl, getRecord } from '$lib/atproto/methods.js'; 3 2 4 - import type { ActorIdentifier } from '@atcute/lexicons'; 5 - import { isHandle } from '@atcute/lexicons/syntax'; 3 + import type { Did } from '@atcute/lexicons'; 6 4 import type { EventData } from '$lib/cards/social/EventCard'; 7 5 import { ImageResponse } from '@ethercorps/sveltekit-og'; 8 6 import { error } from '@sveltejs/kit'; ··· 29 27 let eventData: EventData; 30 28 31 29 try { 32 - const eventResponse = await fetch( 33 - `https://smokesignal.events/xrpc/community.lexicon.calendar.GetEvent?repository=${encodeURIComponent(did)}&record_key=${encodeURIComponent(rkey)}` 34 - ); 30 + const eventRecord = await getRecord({ 31 + did: did as Did, 32 + collection: 'community.lexicon.calendar.event', 33 + rkey 34 + }); 35 35 36 - if (!eventResponse.ok) { 36 + if (!eventRecord?.value) { 37 37 throw error(404, 'Event not found'); 38 38 } 39 39 40 - eventData = await eventResponse.json(); 40 + eventData = eventRecord.value as EventData; 41 41 } catch (e) { 42 42 if (e && typeof e === 'object' && 'status' in e) throw e; 43 43 throw error(404, 'Event not found');
-1
src/routes/[[actor=actor]]/e/[rkey]/og.png/EventOgImage.svelte src/routes/[[actor=actor]]/events/[rkey]/og.png/EventOgImage.svelte
··· 58 58 </svg> 59 59 <span class="ml-3 text-2xl text-neutral-300">{dateStr}</span> 60 60 </div> 61 - 62 61 </div> 63 62 </div>
+28
src/routes/[[actor=actor]]/events/+layout.server.ts
··· 1 + import { getRecord } from '$lib/atproto/methods.js'; 2 + import type { Did } from '@atcute/lexicons'; 3 + import { getActor } from '$lib/actor.js'; 4 + 5 + export async function load({ params, platform, request }) { 6 + const did = await getActor({ request, paramActor: params.actor, platform }); 7 + 8 + if (!did) return { accentColor: undefined, baseColor: undefined }; 9 + 10 + try { 11 + const publication = await getRecord({ 12 + did: did as Did, 13 + collection: 'site.standard.publication', 14 + rkey: 'blento.self' 15 + }); 16 + 17 + const preferences = publication?.value?.preferences as 18 + | { accentColor?: string; baseColor?: string } 19 + | undefined; 20 + 21 + return { 22 + accentColor: preferences?.accentColor, 23 + baseColor: preferences?.baseColor 24 + }; 25 + } catch { 26 + return { accentColor: undefined, baseColor: undefined }; 27 + } 28 + }
+9
src/routes/[[actor=actor]]/events/+layout.svelte
··· 1 + <script lang="ts"> 2 + import ThemeScript from '$lib/website/ThemeScript.svelte'; 3 + 4 + let { data, children } = $props(); 5 + </script> 6 + 7 + <ThemeScript accentColor={data.accentColor} baseColor={data.baseColor} /> 8 + 9 + {@render children()}
+715
src/routes/[[actor=actor]]/events/new/+page.svelte
··· 1 + <script lang="ts"> 2 + import { user } from '$lib/atproto/auth.svelte'; 3 + import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 4 + import { uploadBlob, resolveHandle } from '$lib/atproto/methods'; 5 + import { compressImage } from '$lib/atproto/image-helper'; 6 + import { Avatar as FoxAvatar, Badge, Button, ToggleGroup, ToggleGroupItem } from '@foxui/core'; 7 + import { goto } from '$app/navigation'; 8 + import { tokenize, type Token } from '@atcute/bluesky-richtext-parser'; 9 + import type { Handle } from '@atcute/lexicons'; 10 + import { onMount } from 'svelte'; 11 + import { browser } from '$app/environment'; 12 + import { putImage, getImage, deleteImage } from '$lib/components/image-store'; 13 + 14 + const DRAFT_KEY = 'blento-event-draft'; 15 + 16 + type EventMode = 'inperson' | 'virtual' | 'hybrid'; 17 + 18 + interface EventDraft { 19 + name: string; 20 + description: string; 21 + startsAt: string; 22 + endsAt: string; 23 + links: Array<{ uri: string; name: string }>; 24 + mode?: EventMode; 25 + thumbnailKey?: string; 26 + } 27 + 28 + let thumbnailKey: string | null = $state(null); 29 + 30 + let name = $state(''); 31 + let description = $state(''); 32 + let startsAt = $state(''); 33 + let endsAt = $state(''); 34 + let mode: EventMode = $state('inperson'); 35 + let thumbnailFile: File | null = $state(null); 36 + let thumbnailPreview: string | null = $state(null); 37 + let submitting = $state(false); 38 + let error: string | null = $state(null); 39 + 40 + let links: Array<{ uri: string; name: string }> = $state([]); 41 + let showLinkPopup = $state(false); 42 + let newLinkUri = $state(''); 43 + let newLinkName = $state(''); 44 + 45 + let hasDraft = $state(false); 46 + let draftLoaded = $state(false); 47 + 48 + onMount(async () => { 49 + const saved = localStorage.getItem(DRAFT_KEY); 50 + if (saved) { 51 + try { 52 + const draft: EventDraft = JSON.parse(saved); 53 + name = draft.name || ''; 54 + description = draft.description || ''; 55 + startsAt = draft.startsAt || ''; 56 + endsAt = draft.endsAt || ''; 57 + links = draft.links || []; 58 + mode = draft.mode || 'inperson'; 59 + 60 + if (draft.thumbnailKey) { 61 + const img = await getImage(draft.thumbnailKey); 62 + if (img) { 63 + thumbnailKey = draft.thumbnailKey; 64 + thumbnailFile = new File([img.blob], img.name, { type: img.blob.type }); 65 + thumbnailPreview = URL.createObjectURL(img.blob); 66 + } 67 + } 68 + 69 + hasDraft = true; 70 + } catch { 71 + localStorage.removeItem(DRAFT_KEY); 72 + } 73 + } 74 + draftLoaded = true; 75 + }); 76 + 77 + let saveDraftTimeout: ReturnType<typeof setTimeout> | undefined; 78 + 79 + function saveDraft() { 80 + if (!draftLoaded || !browser) return; 81 + clearTimeout(saveDraftTimeout); 82 + saveDraftTimeout = setTimeout(() => { 83 + const draft: EventDraft = { name, description, startsAt, endsAt, links, mode }; 84 + if (thumbnailKey) draft.thumbnailKey = thumbnailKey; 85 + const hasContent = name || description || startsAt || endsAt || links.length > 0; 86 + if (hasContent) { 87 + localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); 88 + hasDraft = true; 89 + } else { 90 + localStorage.removeItem(DRAFT_KEY); 91 + hasDraft = false; 92 + } 93 + }, 500); 94 + } 95 + 96 + $effect(() => { 97 + // track all draft fields by reading them 98 + void [name, description, startsAt, endsAt, mode, JSON.stringify(links)]; 99 + saveDraft(); 100 + }); 101 + 102 + function deleteDraft() { 103 + localStorage.removeItem(DRAFT_KEY); 104 + if (thumbnailKey) deleteImage(thumbnailKey); 105 + name = ''; 106 + description = ''; 107 + startsAt = ''; 108 + endsAt = ''; 109 + links = []; 110 + mode = 'inperson'; 111 + thumbnailFile = null; 112 + if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview); 113 + thumbnailPreview = null; 114 + thumbnailKey = null; 115 + hasDraft = false; 116 + } 117 + 118 + function addLink() { 119 + const uri = newLinkUri.trim(); 120 + if (!uri) return; 121 + links.push({ uri, name: newLinkName.trim() }); 122 + newLinkUri = ''; 123 + newLinkName = ''; 124 + showLinkPopup = false; 125 + } 126 + 127 + function removeLink(index: number) { 128 + links.splice(index, 1); 129 + } 130 + 131 + let fileInput: HTMLInputElement | undefined = $state(); 132 + 133 + let hostName = $derived(user.profile?.displayName || user.profile?.handle || user.did || ''); 134 + 135 + async function setThumbnail(file: File) { 136 + thumbnailFile = file; 137 + if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview); 138 + thumbnailPreview = URL.createObjectURL(file); 139 + 140 + if (thumbnailKey) await deleteImage(thumbnailKey); 141 + thumbnailKey = crypto.randomUUID(); 142 + await putImage(thumbnailKey, file, file.name); 143 + saveDraft(); 144 + } 145 + 146 + async function onFileChange(e: Event) { 147 + const input = e.target as HTMLInputElement; 148 + const file = input.files?.[0]; 149 + if (!file) return; 150 + setThumbnail(file); 151 + } 152 + 153 + let isDragOver = $state(false); 154 + 155 + function onDragOver(e: DragEvent) { 156 + e.preventDefault(); 157 + isDragOver = true; 158 + } 159 + 160 + function onDragLeave(e: DragEvent) { 161 + e.preventDefault(); 162 + isDragOver = false; 163 + } 164 + 165 + function onDrop(e: DragEvent) { 166 + e.preventDefault(); 167 + isDragOver = false; 168 + const file = e.dataTransfer?.files?.[0]; 169 + if (file?.type.startsWith('image/')) { 170 + setThumbnail(file); 171 + } 172 + } 173 + 174 + function removeThumbnail() { 175 + thumbnailFile = null; 176 + if (thumbnailPreview) { 177 + URL.revokeObjectURL(thumbnailPreview); 178 + thumbnailPreview = null; 179 + } 180 + if (thumbnailKey) { 181 + deleteImage(thumbnailKey); 182 + thumbnailKey = null; 183 + } 184 + if (fileInput) fileInput.value = ''; 185 + saveDraft(); 186 + } 187 + 188 + function formatMonth(date: Date): string { 189 + return date.toLocaleDateString('en-US', { month: 'short' }).toUpperCase(); 190 + } 191 + 192 + function formatDay(date: Date): number { 193 + return date.getDate(); 194 + } 195 + 196 + function formatWeekday(date: Date): string { 197 + return date.toLocaleDateString('en-US', { weekday: 'long' }); 198 + } 199 + 200 + function formatFullDate(date: Date): string { 201 + const options: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' }; 202 + if (date.getFullYear() !== new Date().getFullYear()) { 203 + options.year = 'numeric'; 204 + } 205 + return date.toLocaleDateString('en-US', options); 206 + } 207 + 208 + function formatTime(date: Date): string { 209 + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); 210 + } 211 + 212 + let startDate = $derived(startsAt ? new Date(startsAt) : null); 213 + let endDate = $derived(endsAt ? new Date(endsAt) : null); 214 + let isSameDay = $derived( 215 + startDate && 216 + endDate && 217 + startDate.getFullYear() === endDate.getFullYear() && 218 + startDate.getMonth() === endDate.getMonth() && 219 + startDate.getDate() === endDate.getDate() 220 + ); 221 + 222 + async function tokensToFacets(tokens: Token[]): Promise<Record<string, unknown>[]> { 223 + const encoder = new TextEncoder(); 224 + const facets: Record<string, unknown>[] = []; 225 + let byteOffset = 0; 226 + 227 + for (const token of tokens) { 228 + const tokenBytes = encoder.encode(token.raw); 229 + const byteStart = byteOffset; 230 + const byteEnd = byteOffset + tokenBytes.length; 231 + 232 + if (token.type === 'mention') { 233 + try { 234 + const did = await resolveHandle({ handle: token.handle as Handle }); 235 + if (did) { 236 + facets.push({ 237 + index: { byteStart, byteEnd }, 238 + features: [{ $type: 'app.bsky.richtext.facet#mention', did }] 239 + }); 240 + } 241 + } catch { 242 + // skip unresolvable mentions 243 + } 244 + } else if (token.type === 'autolink') { 245 + facets.push({ 246 + index: { byteStart, byteEnd }, 247 + features: [{ $type: 'app.bsky.richtext.facet#link', uri: token.url }] 248 + }); 249 + } else if (token.type === 'topic') { 250 + facets.push({ 251 + index: { byteStart, byteEnd }, 252 + features: [{ $type: 'app.bsky.richtext.facet#tag', tag: token.name }] 253 + }); 254 + } 255 + 256 + byteOffset = byteEnd; 257 + } 258 + 259 + return facets; 260 + } 261 + 262 + async function handleSubmit() { 263 + error = null; 264 + 265 + if (!name.trim()) { 266 + error = 'Name is required.'; 267 + return; 268 + } 269 + if (!startsAt) { 270 + error = 'Start date is required.'; 271 + return; 272 + } 273 + if (!user.client || !user.did) { 274 + error = 'You must be logged in.'; 275 + return; 276 + } 277 + 278 + submitting = true; 279 + 280 + try { 281 + let media: Array<Record<string, unknown>> | undefined; 282 + 283 + if (thumbnailFile) { 284 + const compressed = await compressImage(thumbnailFile); 285 + const blobRef = await uploadBlob({ blob: compressed.blob }); 286 + if (blobRef) { 287 + media = [ 288 + { 289 + role: 'thumbnail', 290 + content: blobRef, 291 + aspect_ratio: { 292 + width: compressed.aspectRatio.width, 293 + height: compressed.aspectRatio.height 294 + } 295 + } 296 + ]; 297 + } 298 + } 299 + 300 + const record: Record<string, unknown> = { 301 + $type: 'community.lexicon.calendar.event', 302 + name: name.trim(), 303 + mode: `community.lexicon.calendar.event#${mode}`, 304 + status: 'community.lexicon.calendar.event#scheduled', 305 + startsAt: new Date(startsAt).toISOString(), 306 + createdAt: new Date().toISOString() 307 + }; 308 + 309 + const trimmedDescription = description.trim(); 310 + if (trimmedDescription) { 311 + record.description = trimmedDescription; 312 + const tokens = tokenize(trimmedDescription); 313 + const facets = await tokensToFacets(tokens); 314 + if (facets.length > 0) { 315 + record.facets = facets; 316 + } 317 + } 318 + if (endsAt) { 319 + record.endsAt = new Date(endsAt).toISOString(); 320 + } 321 + if (media) { 322 + record.media = media; 323 + } 324 + if (links.length > 0) { 325 + record.uris = links; 326 + } 327 + 328 + const response = await user.client.post('com.atproto.repo.createRecord', { 329 + input: { 330 + collection: 'community.lexicon.calendar.event', 331 + repo: user.did, 332 + record 333 + } 334 + }); 335 + 336 + if (response.ok) { 337 + localStorage.removeItem(DRAFT_KEY); 338 + if (thumbnailKey) deleteImage(thumbnailKey); 339 + const parts = response.data.uri.split('/'); 340 + const rkey = parts[parts.length - 1]; 341 + const handle = 342 + user.profile?.handle && user.profile.handle !== 'handle.invalid' 343 + ? user.profile.handle 344 + : user.did; 345 + goto(`/${handle}/events/${rkey}`); 346 + } else { 347 + error = 'Failed to create event. Please try again.'; 348 + } 349 + } catch (e) { 350 + console.error('Failed to create event:', e); 351 + error = 'Failed to create event. Please try again.'; 352 + } finally { 353 + submitting = false; 354 + } 355 + } 356 + </script> 357 + 358 + <svelte:head> 359 + <title>Create Event</title> 360 + </svelte:head> 361 + 362 + <div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12 sm:py-12"> 363 + <div class="mx-auto max-w-4xl"> 364 + {#if user.isInitializing} 365 + <div class="flex items-center gap-3"> 366 + <div class="bg-base-300 dark:bg-base-700 size-5 animate-pulse rounded-full"></div> 367 + <div class="bg-base-300 dark:bg-base-700 h-4 w-48 animate-pulse rounded"></div> 368 + </div> 369 + {:else if !user.isLoggedIn} 370 + <div 371 + class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-900/50 rounded-2xl border p-8 text-center" 372 + > 373 + <p class="text-base-600 dark:text-base-400 mb-4">Log in to create an event.</p> 374 + <Button onclick={() => loginModalState.show()}>Log in</Button> 375 + </div> 376 + {:else} 377 + <div class="mb-6 flex items-center gap-3"> 378 + <Badge size="sm">Local draft</Badge> 379 + {#if hasDraft} 380 + <button 381 + type="button" 382 + onclick={deleteDraft} 383 + class="text-base-500 dark:text-base-400 cursor-pointer text-xs hover:text-red-500 hover:underline" 384 + > 385 + Delete draft 386 + </button> 387 + {/if} 388 + </div> 389 + 390 + <form 391 + onsubmit={(e) => { 392 + e.preventDefault(); 393 + handleSubmit(); 394 + }} 395 + > 396 + <!-- Two-column layout mirroring detail page --> 397 + <div 398 + class="grid grid-cols-1 gap-8 md:grid-cols-[14rem_1fr] md:gap-x-10 md:gap-y-6 lg:grid-cols-[16rem_1fr]" 399 + > 400 + <!-- Thumbnail (left column) --> 401 + <!-- svelte-ignore a11y_no_static_element_interactions --> 402 + <div 403 + class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none" 404 + ondragover={onDragOver} 405 + ondragleave={onDragLeave} 406 + ondrop={onDrop} 407 + > 408 + <input 409 + bind:this={fileInput} 410 + type="file" 411 + accept="image/*" 412 + onchange={onFileChange} 413 + class="hidden" 414 + /> 415 + {#if thumbnailPreview} 416 + <div class="relative"> 417 + <button type="button" onclick={() => fileInput?.click()} class="w-full"> 418 + <img 419 + src={thumbnailPreview} 420 + alt="Thumbnail preview" 421 + class="border-base-200 dark:border-base-800 aspect-square w-full cursor-pointer rounded-2xl border object-cover" 422 + /> 423 + </button> 424 + <button 425 + type="button" 426 + onclick={removeThumbnail} 427 + aria-label="Remove thumbnail" 428 + class="bg-base-900/70 absolute top-2 right-2 flex size-7 items-center justify-center rounded-full text-white hover:bg-red-600" 429 + > 430 + <svg 431 + xmlns="http://www.w3.org/2000/svg" 432 + viewBox="0 0 20 20" 433 + fill="currentColor" 434 + class="size-4" 435 + > 436 + <path 437 + d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 438 + /> 439 + </svg> 440 + </button> 441 + </div> 442 + {:else} 443 + <button 444 + type="button" 445 + onclick={() => fileInput?.click()} 446 + class="border-base-300 dark:border-base-700 hover:border-base-400 dark:hover:border-base-600 text-base-500 dark:text-base-400 flex aspect-square w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed transition-colors {isDragOver 447 + ? 'border-accent-500 bg-accent-50 dark:bg-accent-950 text-accent-500' 448 + : ''}" 449 + > 450 + <svg 451 + xmlns="http://www.w3.org/2000/svg" 452 + fill="none" 453 + viewBox="0 0 24 24" 454 + stroke-width="1.5" 455 + stroke="currentColor" 456 + class="mb-1 size-6" 457 + > 458 + <path 459 + stroke-linecap="round" 460 + stroke-linejoin="round" 461 + d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z" 462 + /> 463 + </svg> 464 + <span class="text-sm">Add image</span> 465 + </button> 466 + {/if} 467 + </div> 468 + 469 + <!-- Right column: event details --> 470 + <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1"> 471 + <!-- Name --> 472 + <input 473 + type="text" 474 + bind:value={name} 475 + required 476 + placeholder="Event name" 477 + class="text-base-900 dark:text-base-50 placeholder:text-base-300 dark:placeholder:text-base-700 mb-2 w-full border-0 bg-transparent text-4xl leading-tight font-bold focus:border-0 focus:ring-0 focus:outline-none sm:text-5xl" 478 + /> 479 + 480 + <!-- Mode toggle --> 481 + <div class="mb-8"> 482 + <ToggleGroup 483 + type="single" 484 + bind:value={ 485 + () => { 486 + return mode; 487 + }, 488 + (val) => { 489 + if (val) mode = val; 490 + } 491 + } 492 + class="w-fit" 493 + > 494 + <ToggleGroupItem size="sm" value="inperson">In Person</ToggleGroupItem> 495 + <ToggleGroupItem size="sm" value="virtual">Virtual</ToggleGroupItem> 496 + <ToggleGroupItem size="sm" value="hybrid">Hybrid</ToggleGroupItem> 497 + </ToggleGroup> 498 + </div> 499 + 500 + <!-- Date row --> 501 + <div class="mb-4 flex items-center gap-4"> 502 + <div 503 + class="border-base-200 dark:border-base-700 flex size-12 shrink-0 flex-col items-center justify-center overflow-hidden rounded-xl border" 504 + > 505 + {#if startDate} 506 + <span 507 + class="text-base-500 dark:text-base-400 text-[9px] leading-none font-semibold" 508 + > 509 + {formatMonth(startDate)} 510 + </span> 511 + <span class="text-base-900 dark:text-base-50 text-lg leading-tight font-bold"> 512 + {formatDay(startDate)} 513 + </span> 514 + {:else} 515 + <svg 516 + xmlns="http://www.w3.org/2000/svg" 517 + fill="none" 518 + viewBox="0 0 24 24" 519 + stroke-width="1.5" 520 + stroke="currentColor" 521 + class="text-base-400 dark:text-base-500 size-5" 522 + > 523 + <path 524 + stroke-linecap="round" 525 + stroke-linejoin="round" 526 + d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" 527 + /> 528 + </svg> 529 + {/if} 530 + </div> 531 + <div class="flex-1"> 532 + {#if startDate} 533 + <p class="text-base-900 dark:text-base-50 font-semibold"> 534 + {formatWeekday(startDate)}, {formatFullDate(startDate)} 535 + {#if endDate && !isSameDay} 536 + - {formatWeekday(endDate)}, {formatFullDate(endDate)} 537 + {/if} 538 + </p> 539 + <p class="text-base-500 dark:text-base-400 text-sm"> 540 + {formatTime(startDate)} 541 + {#if endDate && isSameDay} 542 + - {formatTime(endDate)} 543 + {/if} 544 + </p> 545 + {/if} 546 + <div class="mt-1 flex flex-wrap gap-3"> 547 + <label class="flex items-center gap-1.5"> 548 + <span class="text-base-500 dark:text-base-400 text-xs">Start</span> 549 + <input 550 + type="datetime-local" 551 + bind:value={startsAt} 552 + required 553 + class="text-base-700 dark:text-base-300 bg-transparent text-xs focus:outline-none" 554 + /> 555 + </label> 556 + <label class="flex items-center gap-1.5"> 557 + <span class="text-base-500 dark:text-base-400 text-xs">End</span> 558 + <input 559 + type="datetime-local" 560 + bind:value={endsAt} 561 + class="text-base-700 dark:text-base-300 bg-transparent text-xs focus:outline-none" 562 + /> 563 + </label> 564 + </div> 565 + </div> 566 + </div> 567 + 568 + <!-- About Event --> 569 + <div class="mt-8 mb-8"> 570 + <p 571 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 572 + > 573 + About 574 + </p> 575 + <textarea 576 + bind:value={description} 577 + rows={4} 578 + placeholder="What's this event about? @mentions, #hashtags and links will be detected automatically." 579 + class="text-base-700 dark:text-base-300 placeholder:text-base-300 dark:placeholder:text-base-700 w-full resize-none border-0 bg-transparent leading-relaxed focus:border-0 focus:ring-0 focus:outline-none" 580 + ></textarea> 581 + </div> 582 + 583 + {#if error} 584 + <p class="mb-4 text-sm text-red-600 dark:text-red-400">{error}</p> 585 + {/if} 586 + 587 + <Button type="submit" disabled={submitting}> 588 + {submitting ? 'Creating...' : 'Create Event'} 589 + </Button> 590 + </div> 591 + 592 + <!-- Hosted By --> 593 + <div class="order-3 md:order-0 md:col-start-1"> 594 + <p 595 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 596 + > 597 + Hosted By 598 + </p> 599 + <div class="flex items-center gap-2.5"> 600 + <FoxAvatar src={user.profile?.avatar} alt={hostName} class="size-8 shrink-0" /> 601 + <span class="text-base-900 dark:text-base-100 truncate text-sm font-medium"> 602 + {hostName} 603 + </span> 604 + </div> 605 + </div> 606 + 607 + <!-- Links --> 608 + <div class="order-4 md:order-0 md:col-start-1"> 609 + <p 610 + class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase" 611 + > 612 + Links 613 + </p> 614 + <div class="space-y-3"> 615 + {#each links as link, i (i)} 616 + <div class="group flex items-center gap-1.5"> 617 + <svg 618 + xmlns="http://www.w3.org/2000/svg" 619 + fill="none" 620 + viewBox="0 0 24 24" 621 + stroke-width="1.5" 622 + stroke="currentColor" 623 + class="text-base-700 dark:text-base-300 size-3.5 shrink-0" 624 + > 625 + <path 626 + stroke-linecap="round" 627 + stroke-linejoin="round" 628 + d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 629 + /> 630 + </svg> 631 + <span class="text-base-700 dark:text-base-300 truncate text-sm"> 632 + {link.name || link.uri.replace(/^https?:\/\//, '')} 633 + </span> 634 + <button 635 + type="button" 636 + onclick={() => removeLink(i)} 637 + class="text-base-400 ml-auto shrink-0 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-500" 638 + aria-label="Remove link" 639 + > 640 + <svg 641 + xmlns="http://www.w3.org/2000/svg" 642 + viewBox="0 0 20 20" 643 + fill="currentColor" 644 + class="size-3.5" 645 + > 646 + <path 647 + d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 648 + /> 649 + </svg> 650 + </button> 651 + </div> 652 + {/each} 653 + </div> 654 + 655 + <div class="relative mt-3"> 656 + <button 657 + type="button" 658 + onclick={() => (showLinkPopup = !showLinkPopup)} 659 + class="text-base-500 dark:text-base-400 hover:text-base-700 dark:hover:text-base-200 flex items-center gap-1.5 text-sm transition-colors" 660 + > 661 + <svg 662 + xmlns="http://www.w3.org/2000/svg" 663 + fill="none" 664 + viewBox="0 0 24 24" 665 + stroke-width="1.5" 666 + stroke="currentColor" 667 + class="size-4" 668 + > 669 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 670 + </svg> 671 + Add link 672 + </button> 673 + 674 + {#if showLinkPopup} 675 + <div 676 + class="border-base-200 dark:border-base-700 bg-base-50 dark:bg-base-900 absolute top-full left-0 z-10 mt-2 w-64 rounded-xl border p-3 shadow-lg" 677 + > 678 + <input 679 + type="url" 680 + bind:value={newLinkUri} 681 + placeholder="https://..." 682 + class="border-base-300 dark:border-base-700 bg-base-100 dark:bg-base-800 text-base-900 dark:text-base-100 placeholder:text-base-400 dark:placeholder:text-base-600 mb-2 w-full rounded-lg border px-2.5 py-1.5 text-sm focus:outline-none" 683 + /> 684 + <input 685 + type="text" 686 + bind:value={newLinkName} 687 + placeholder="Label (optional)" 688 + class="border-base-300 dark:border-base-700 bg-base-100 dark:bg-base-800 text-base-900 dark:text-base-100 placeholder:text-base-400 dark:placeholder:text-base-600 mb-3 w-full rounded-lg border px-2.5 py-1.5 text-sm focus:outline-none" 689 + /> 690 + <div class="flex justify-end gap-2"> 691 + <button 692 + type="button" 693 + onclick={() => (showLinkPopup = false)} 694 + class="text-base-500 dark:text-base-400 text-xs hover:underline" 695 + > 696 + Cancel 697 + </button> 698 + <button 699 + type="button" 700 + onclick={addLink} 701 + disabled={!newLinkUri.trim()} 702 + class="bg-base-900 dark:bg-base-100 text-base-50 dark:text-base-900 disabled:bg-base-300 dark:disabled:bg-base-700 rounded-lg px-3 py-1 text-xs font-medium disabled:cursor-not-allowed" 703 + > 704 + Add 705 + </button> 706 + </div> 707 + </div> 708 + {/if} 709 + </div> 710 + </div> 711 + </div> 712 + </form> 713 + {/if} 714 + </div> 715 + </div>