A server-side link shortening service powered by Linkat

refactor(QR, iconography): add `@lucide/svelte` and `qrcode` for various fixes/improvements

ewancroft.uk ea68e705 b9bb1c6b

verified
+345 -18
package-lock.json
··· 9 9 "version": "0.0.1", 10 10 "dependencies": { 11 11 "@atproto/api": "^0.18.1", 12 + "@lucide/svelte": "^0.555.0", 13 + "qrcode": "^1.5.4", 12 14 "tldts": "^7.0.19" 13 15 }, 14 16 "devDependencies": { ··· 16 18 "@sveltejs/kit": "^2.49.0", 17 19 "@sveltejs/vite-plugin-svelte": "^6.2.1", 18 20 "@tailwindcss/vite": "^4.0.0", 21 + "@types/qrcode": "^1.5.6", 19 22 "prettier": "^3.6.2", 20 23 "prettier-plugin-svelte": "^3.4.0", 21 24 "svelte": "^5.43.14", ··· 550 553 "version": "0.3.13", 551 554 "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", 552 555 "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", 553 - "dev": true, 554 556 "license": "MIT", 555 557 "dependencies": { 556 558 "@jridgewell/sourcemap-codec": "^1.5.0", ··· 561 563 "version": "2.3.5", 562 564 "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", 563 565 "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", 564 - "dev": true, 565 566 "license": "MIT", 566 567 "dependencies": { 567 568 "@jridgewell/gen-mapping": "^0.3.5", ··· 572 573 "version": "3.1.2", 573 574 "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", 574 575 "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", 575 - "dev": true, 576 576 "license": "MIT", 577 577 "engines": { 578 578 "node": ">=6.0.0" ··· 582 582 "version": "1.5.5", 583 583 "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", 584 584 "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", 585 - "dev": true, 586 585 "license": "MIT" 587 586 }, 588 587 "node_modules/@jridgewell/trace-mapping": { 589 588 "version": "0.3.31", 590 589 "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", 591 590 "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", 592 - "dev": true, 593 591 "license": "MIT", 594 592 "dependencies": { 595 593 "@jridgewell/resolve-uri": "^3.1.0", 596 594 "@jridgewell/sourcemap-codec": "^1.4.14" 597 595 } 598 596 }, 597 + "node_modules/@lucide/svelte": { 598 + "version": "0.555.0", 599 + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.555.0.tgz", 600 + "integrity": "sha512-aqTOMjBjf/HNwrhggRdb83T0QslZdpJTyTwr/chtXTGw7u4Hcu4zQb/5uA+csF0KKawKWVnsNI1MdHEHeEXTcQ==", 601 + "license": "ISC", 602 + "peerDependencies": { 603 + "svelte": "^5" 604 + } 605 + }, 599 606 "node_modules/@polka/url": { 600 607 "version": "1.0.0-next.29", 601 608 "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", ··· 922 929 "version": "1.0.7", 923 930 "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.7.tgz", 924 931 "integrity": "sha512-znp1A/Y1Jj4l/Zy7PX5DZKBE0ZNY+5QBngiE21NJkfSTyzzC5iKNWOtwFXKtIrn7MXEFBck4jD95iBNkGjK92Q==", 925 - "dev": true, 926 932 "license": "MIT", 927 933 "peerDependencies": { 928 934 "acorn": "^8.9.0" ··· 1301 1307 "version": "1.0.8", 1302 1308 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", 1303 1309 "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", 1304 - "dev": true, 1305 1310 "license": "MIT" 1306 1311 }, 1312 + "node_modules/@types/node": { 1313 + "version": "24.10.1", 1314 + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", 1315 + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", 1316 + "dev": true, 1317 + "license": "MIT", 1318 + "peer": true, 1319 + "dependencies": { 1320 + "undici-types": "~7.16.0" 1321 + } 1322 + }, 1323 + "node_modules/@types/qrcode": { 1324 + "version": "1.5.6", 1325 + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", 1326 + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", 1327 + "dev": true, 1328 + "license": "MIT", 1329 + "dependencies": { 1330 + "@types/node": "*" 1331 + } 1332 + }, 1307 1333 "node_modules/acorn": { 1308 1334 "version": "8.15.0", 1309 1335 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", 1310 1336 "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", 1311 - "dev": true, 1312 1337 "license": "MIT", 1313 1338 "peer": true, 1314 1339 "bin": { ··· 1318 1343 "node": ">=0.4.0" 1319 1344 } 1320 1345 }, 1346 + "node_modules/ansi-regex": { 1347 + "version": "5.0.1", 1348 + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 1349 + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 1350 + "license": "MIT", 1351 + "engines": { 1352 + "node": ">=8" 1353 + } 1354 + }, 1355 + "node_modules/ansi-styles": { 1356 + "version": "4.3.0", 1357 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 1358 + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 1359 + "license": "MIT", 1360 + "dependencies": { 1361 + "color-convert": "^2.0.1" 1362 + }, 1363 + "engines": { 1364 + "node": ">=8" 1365 + }, 1366 + "funding": { 1367 + "url": "https://github.com/chalk/ansi-styles?sponsor=1" 1368 + } 1369 + }, 1321 1370 "node_modules/aria-query": { 1322 1371 "version": "5.3.2", 1323 1372 "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", 1324 1373 "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", 1325 - "dev": true, 1326 1374 "license": "Apache-2.0", 1327 1375 "engines": { 1328 1376 "node": ">= 0.4" ··· 1338 1386 "version": "4.1.0", 1339 1387 "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", 1340 1388 "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", 1341 - "dev": true, 1342 1389 "license": "Apache-2.0", 1343 1390 "engines": { 1344 1391 "node": ">= 0.4" 1345 1392 } 1346 1393 }, 1394 + "node_modules/camelcase": { 1395 + "version": "5.3.1", 1396 + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", 1397 + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", 1398 + "license": "MIT", 1399 + "engines": { 1400 + "node": ">=6" 1401 + } 1402 + }, 1347 1403 "node_modules/chokidar": { 1348 1404 "version": "4.0.3", 1349 1405 "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", ··· 1360 1416 "url": "https://paulmillr.com/funding/" 1361 1417 } 1362 1418 }, 1419 + "node_modules/cliui": { 1420 + "version": "6.0.0", 1421 + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", 1422 + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", 1423 + "license": "ISC", 1424 + "dependencies": { 1425 + "string-width": "^4.2.0", 1426 + "strip-ansi": "^6.0.0", 1427 + "wrap-ansi": "^6.2.0" 1428 + } 1429 + }, 1363 1430 "node_modules/clsx": { 1364 1431 "version": "2.1.1", 1365 1432 "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", 1366 1433 "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", 1367 - "dev": true, 1368 1434 "license": "MIT", 1369 1435 "engines": { 1370 1436 "node": ">=6" 1371 1437 } 1372 1438 }, 1439 + "node_modules/color-convert": { 1440 + "version": "2.0.1", 1441 + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 1442 + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 1443 + "license": "MIT", 1444 + "dependencies": { 1445 + "color-name": "~1.1.4" 1446 + }, 1447 + "engines": { 1448 + "node": ">=7.0.0" 1449 + } 1450 + }, 1451 + "node_modules/color-name": { 1452 + "version": "1.1.4", 1453 + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 1454 + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 1455 + "license": "MIT" 1456 + }, 1373 1457 "node_modules/cookie": { 1374 1458 "version": "0.6.0", 1375 1459 "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", ··· 1398 1482 } 1399 1483 } 1400 1484 }, 1485 + "node_modules/decamelize": { 1486 + "version": "1.2.0", 1487 + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", 1488 + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", 1489 + "license": "MIT", 1490 + "engines": { 1491 + "node": ">=0.10.0" 1492 + } 1493 + }, 1401 1494 "node_modules/deepmerge": { 1402 1495 "version": "4.3.1", 1403 1496 "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", ··· 1425 1518 "dev": true, 1426 1519 "license": "MIT" 1427 1520 }, 1521 + "node_modules/dijkstrajs": { 1522 + "version": "1.0.3", 1523 + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", 1524 + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", 1525 + "license": "MIT" 1526 + }, 1527 + "node_modules/emoji-regex": { 1528 + "version": "8.0.0", 1529 + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 1530 + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 1531 + "license": "MIT" 1532 + }, 1428 1533 "node_modules/enhanced-resolve": { 1429 1534 "version": "5.18.3", 1430 1535 "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", ··· 1485 1590 "version": "1.2.2", 1486 1591 "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", 1487 1592 "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", 1488 - "dev": true, 1489 1593 "license": "MIT" 1490 1594 }, 1491 1595 "node_modules/esrap": { 1492 1596 "version": "2.1.3", 1493 1597 "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.3.tgz", 1494 1598 "integrity": "sha512-T/Dhhv/QH+yYmiaLz9SA3PW+YyenlnRKDNdtlYJrSOBmNsH4nvPux+mTwx7p+wAedlJrGoZtXNI0a0MjQ2QkVg==", 1495 - "dev": true, 1496 1599 "license": "MIT", 1497 1600 "dependencies": { 1498 1601 "@jridgewell/sourcemap-codec": "^1.4.15" ··· 1516 1619 } 1517 1620 } 1518 1621 }, 1622 + "node_modules/find-up": { 1623 + "version": "4.1.0", 1624 + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", 1625 + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", 1626 + "license": "MIT", 1627 + "dependencies": { 1628 + "locate-path": "^5.0.0", 1629 + "path-exists": "^4.0.0" 1630 + }, 1631 + "engines": { 1632 + "node": ">=8" 1633 + } 1634 + }, 1519 1635 "node_modules/fsevents": { 1520 1636 "version": "2.3.3", 1521 1637 "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", ··· 1531 1647 "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 1532 1648 } 1533 1649 }, 1650 + "node_modules/get-caller-file": { 1651 + "version": "2.0.5", 1652 + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", 1653 + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", 1654 + "license": "ISC", 1655 + "engines": { 1656 + "node": "6.* || 8.* || >= 10.*" 1657 + } 1658 + }, 1534 1659 "node_modules/graceful-fs": { 1535 1660 "version": "4.2.11", 1536 1661 "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", ··· 1538 1663 "dev": true, 1539 1664 "license": "ISC" 1540 1665 }, 1666 + "node_modules/is-fullwidth-code-point": { 1667 + "version": "3.0.0", 1668 + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", 1669 + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", 1670 + "license": "MIT", 1671 + "engines": { 1672 + "node": ">=8" 1673 + } 1674 + }, 1541 1675 "node_modules/is-reference": { 1542 1676 "version": "3.0.3", 1543 1677 "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", 1544 1678 "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", 1545 - "dev": true, 1546 1679 "license": "MIT", 1547 1680 "dependencies": { 1548 1681 "@types/estree": "^1.0.6" ··· 1839 1972 "version": "3.0.0", 1840 1973 "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", 1841 1974 "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", 1842 - "dev": true, 1843 1975 "license": "MIT" 1844 1976 }, 1977 + "node_modules/locate-path": { 1978 + "version": "5.0.0", 1979 + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", 1980 + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", 1981 + "license": "MIT", 1982 + "dependencies": { 1983 + "p-locate": "^4.1.0" 1984 + }, 1985 + "engines": { 1986 + "node": ">=8" 1987 + } 1988 + }, 1845 1989 "node_modules/magic-string": { 1846 1990 "version": "0.30.21", 1847 1991 "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", 1848 1992 "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", 1849 - "dev": true, 1850 1993 "license": "MIT", 1851 1994 "dependencies": { 1852 1995 "@jridgewell/sourcemap-codec": "^1.5.5" ··· 1904 2047 "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 1905 2048 } 1906 2049 }, 2050 + "node_modules/p-limit": { 2051 + "version": "2.3.0", 2052 + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", 2053 + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", 2054 + "license": "MIT", 2055 + "dependencies": { 2056 + "p-try": "^2.0.0" 2057 + }, 2058 + "engines": { 2059 + "node": ">=6" 2060 + }, 2061 + "funding": { 2062 + "url": "https://github.com/sponsors/sindresorhus" 2063 + } 2064 + }, 2065 + "node_modules/p-locate": { 2066 + "version": "4.1.0", 2067 + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", 2068 + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", 2069 + "license": "MIT", 2070 + "dependencies": { 2071 + "p-limit": "^2.2.0" 2072 + }, 2073 + "engines": { 2074 + "node": ">=8" 2075 + } 2076 + }, 2077 + "node_modules/p-try": { 2078 + "version": "2.2.0", 2079 + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", 2080 + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", 2081 + "license": "MIT", 2082 + "engines": { 2083 + "node": ">=6" 2084 + } 2085 + }, 2086 + "node_modules/path-exists": { 2087 + "version": "4.0.0", 2088 + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", 2089 + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", 2090 + "license": "MIT", 2091 + "engines": { 2092 + "node": ">=8" 2093 + } 2094 + }, 1907 2095 "node_modules/picocolors": { 1908 2096 "version": "1.1.1", 1909 2097 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", ··· 1925 2113 "url": "https://github.com/sponsors/jonschlinkert" 1926 2114 } 1927 2115 }, 2116 + "node_modules/pngjs": { 2117 + "version": "5.0.0", 2118 + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", 2119 + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", 2120 + "license": "MIT", 2121 + "engines": { 2122 + "node": ">=10.13.0" 2123 + } 2124 + }, 1928 2125 "node_modules/postcss": { 1929 2126 "version": "8.5.6", 1930 2127 "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", ··· 1982 2179 "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" 1983 2180 } 1984 2181 }, 2182 + "node_modules/qrcode": { 2183 + "version": "1.5.4", 2184 + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", 2185 + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", 2186 + "license": "MIT", 2187 + "dependencies": { 2188 + "dijkstrajs": "^1.0.1", 2189 + "pngjs": "^5.0.0", 2190 + "yargs": "^15.3.1" 2191 + }, 2192 + "bin": { 2193 + "qrcode": "bin/qrcode" 2194 + }, 2195 + "engines": { 2196 + "node": ">=10.13.0" 2197 + } 2198 + }, 1985 2199 "node_modules/readdirp": { 1986 2200 "version": "4.1.2", 1987 2201 "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", ··· 1996 2210 "url": "https://paulmillr.com/funding/" 1997 2211 } 1998 2212 }, 2213 + "node_modules/require-directory": { 2214 + "version": "2.1.1", 2215 + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", 2216 + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", 2217 + "license": "MIT", 2218 + "engines": { 2219 + "node": ">=0.10.0" 2220 + } 2221 + }, 2222 + "node_modules/require-main-filename": { 2223 + "version": "2.0.0", 2224 + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", 2225 + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", 2226 + "license": "ISC" 2227 + }, 1999 2228 "node_modules/rollup": { 2000 2229 "version": "4.53.3", 2001 2230 "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", ··· 2051 2280 "node": ">=6" 2052 2281 } 2053 2282 }, 2283 + "node_modules/set-blocking": { 2284 + "version": "2.0.0", 2285 + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", 2286 + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", 2287 + "license": "ISC" 2288 + }, 2054 2289 "node_modules/set-cookie-parser": { 2055 2290 "version": "2.7.2", 2056 2291 "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", ··· 2083 2318 "node": ">=0.10.0" 2084 2319 } 2085 2320 }, 2321 + "node_modules/string-width": { 2322 + "version": "4.2.3", 2323 + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 2324 + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 2325 + "license": "MIT", 2326 + "dependencies": { 2327 + "emoji-regex": "^8.0.0", 2328 + "is-fullwidth-code-point": "^3.0.0", 2329 + "strip-ansi": "^6.0.1" 2330 + }, 2331 + "engines": { 2332 + "node": ">=8" 2333 + } 2334 + }, 2335 + "node_modules/strip-ansi": { 2336 + "version": "6.0.1", 2337 + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 2338 + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 2339 + "license": "MIT", 2340 + "dependencies": { 2341 + "ansi-regex": "^5.0.1" 2342 + }, 2343 + "engines": { 2344 + "node": ">=8" 2345 + } 2346 + }, 2086 2347 "node_modules/svelte": { 2087 2348 "version": "5.43.15", 2088 2349 "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.15.tgz", 2089 2350 "integrity": "sha512-FYlfm3oyLBNUy2NGqaWfKPiGOamS6YB8BJwAcF9xSXVFUjfcl9Ded1YSMu1vXEf0y0lcmBj45UgnOY2ZxhW0Cw==", 2090 - "dev": true, 2091 2351 "license": "MIT", 2092 2352 "peer": true, 2093 2353 "dependencies": { ··· 2239 2499 "multiformats": "^9.4.2" 2240 2500 } 2241 2501 }, 2502 + "node_modules/undici-types": { 2503 + "version": "7.16.0", 2504 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", 2505 + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", 2506 + "dev": true, 2507 + "license": "MIT" 2508 + }, 2242 2509 "node_modules/unicode-segmenter": { 2243 2510 "version": "0.14.0", 2244 2511 "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.0.tgz", ··· 2341 2608 } 2342 2609 } 2343 2610 }, 2611 + "node_modules/which-module": { 2612 + "version": "2.0.1", 2613 + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", 2614 + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", 2615 + "license": "ISC" 2616 + }, 2617 + "node_modules/wrap-ansi": { 2618 + "version": "6.2.0", 2619 + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", 2620 + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", 2621 + "license": "MIT", 2622 + "dependencies": { 2623 + "ansi-styles": "^4.0.0", 2624 + "string-width": "^4.1.0", 2625 + "strip-ansi": "^6.0.0" 2626 + }, 2627 + "engines": { 2628 + "node": ">=8" 2629 + } 2630 + }, 2631 + "node_modules/y18n": { 2632 + "version": "4.0.3", 2633 + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", 2634 + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", 2635 + "license": "ISC" 2636 + }, 2637 + "node_modules/yargs": { 2638 + "version": "15.4.1", 2639 + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", 2640 + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", 2641 + "license": "MIT", 2642 + "dependencies": { 2643 + "cliui": "^6.0.0", 2644 + "decamelize": "^1.2.0", 2645 + "find-up": "^4.1.0", 2646 + "get-caller-file": "^2.0.1", 2647 + "require-directory": "^2.1.1", 2648 + "require-main-filename": "^2.0.0", 2649 + "set-blocking": "^2.0.0", 2650 + "string-width": "^4.2.0", 2651 + "which-module": "^2.0.0", 2652 + "y18n": "^4.0.0", 2653 + "yargs-parser": "^18.1.2" 2654 + }, 2655 + "engines": { 2656 + "node": ">=8" 2657 + } 2658 + }, 2659 + "node_modules/yargs-parser": { 2660 + "version": "18.1.3", 2661 + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", 2662 + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", 2663 + "license": "ISC", 2664 + "dependencies": { 2665 + "camelcase": "^5.0.0", 2666 + "decamelize": "^1.2.0" 2667 + }, 2668 + "engines": { 2669 + "node": ">=6" 2670 + } 2671 + }, 2344 2672 "node_modules/zimmerframe": { 2345 2673 "version": "1.1.4", 2346 2674 "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", 2347 2675 "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", 2348 - "dev": true, 2349 2676 "license": "MIT" 2350 2677 }, 2351 2678 "node_modules/zod": {
+3
package.json
··· 16 16 }, 17 17 "dependencies": { 18 18 "@atproto/api": "^0.18.1", 19 + "@lucide/svelte": "^0.555.0", 20 + "qrcode": "^1.5.4", 19 21 "tldts": "^7.0.19" 20 22 }, 21 23 "devDependencies": { ··· 23 25 "@sveltejs/kit": "^2.49.0", 24 26 "@sveltejs/vite-plugin-svelte": "^6.2.1", 25 27 "@tailwindcss/vite": "^4.0.0", 28 + "@types/qrcode": "^1.5.6", 26 29 "prettier": "^3.6.2", 27 30 "prettier-plugin-svelte": "^3.4.0", 28 31 "svelte": "^5.43.14",
+1 -1
scripts/test-config.js
··· 28 28 } 29 29 30 30 function success(message) { 31 - log(`✓ ${message}`, 'green'); 31 + log(`${message}`, 'green'); 32 32 } 33 33 34 34 function error(message) {
+15
src/app.css
··· 72 72 background-color: rgb(var(--color-background)); 73 73 color: rgb(var(--color-text-primary)); 74 74 min-height: 100vh; 75 + transition: 76 + background-color 0.3s ease, 77 + color 0.3s ease; 75 78 } 76 79 77 80 body { ··· 79 82 color: rgb(var(--color-text-primary)); 80 83 min-height: 100vh; 81 84 } 85 + 86 + /* Smooth transitions for color changes */ 87 + * { 88 + transition-property: background-color, border-color, color, fill, stroke; 89 + transition-duration: 0.2s; 90 + transition-timing-function: ease; 91 + } 92 + 93 + /* Preserve transform transitions */ 94 + *:where([style*='transform']) { 95 + transition-property: background-color, border-color, color, fill, stroke, transform; 96 + }
+57
src/lib/components/ApiEndpoint.svelte
··· 1 + <script lang="ts"> 2 + import { Globe } from '@lucide/svelte'; 3 + import CodeBlock from './CodeBlock.svelte'; 4 + 5 + interface Props { 6 + method: 'GET' | 'POST' | 'PUT' | 'DELETE'; 7 + path: string; 8 + description: string; 9 + href?: string; 10 + } 11 + 12 + let { method, path, description, href }: Props = $props(); 13 + 14 + let isHovered = $state(false); 15 + 16 + const methodColors = { 17 + GET: 'rgb(var(--color-success))', 18 + POST: 'rgb(var(--color-primary))', 19 + PUT: 'rgb(var(--color-primary))', 20 + DELETE: 'rgb(var(--color-error))' 21 + }; 22 + </script> 23 + 24 + <li class="rounded-lg p-3" style="background-color: rgb(var(--color-surface))"> 25 + {#if href} 26 + <a 27 + {href} 28 + class="flex items-center gap-4 no-underline" 29 + style="color: rgb(var(--color-text-primary))" 30 + onmouseenter={() => (isHovered = true)} 31 + onmouseleave={() => (isHovered = false)} 32 + > 33 + <span 34 + class="shrink-0 rounded px-2 py-1 text-xs font-bold" 35 + style="background-color: {methodColors[method]}; color: white" 36 + > 37 + {method} 38 + </span> 39 + <CodeBlock>{path}</CodeBlock> 40 + <span style="color: rgb(var(--color-text-secondary))">{description}</span> 41 + {#if isHovered} 42 + <Globe size={16} class="ml-auto shrink-0" style="color: rgb(var(--color-text-tertiary))" /> 43 + {/if} 44 + </a> 45 + {:else} 46 + <div class="flex items-center gap-4" style="color: rgb(var(--color-text-primary))"> 47 + <span 48 + class="shrink-0 rounded px-2 py-1 text-xs font-bold" 49 + style="background-color: {methodColors[method]}; color: white" 50 + > 51 + {method} 52 + </span> 53 + <CodeBlock>{path}</CodeBlock> 54 + <span style="color: rgb(var(--color-text-secondary))">{description}</span> 55 + </div> 56 + {/if} 57 + </li>
+17
src/lib/components/CodeBlock.svelte
··· 1 + <script lang="ts"> 2 + import { Code2 } from '@lucide/svelte'; 3 + 4 + interface Props { 5 + children: import('svelte').Snippet; 6 + } 7 + 8 + let { children }: Props = $props(); 9 + </script> 10 + 11 + <code 12 + class="inline-flex items-center gap-1.5 rounded px-2 py-1 font-mono text-sm" 13 + style="background-color: rgb(var(--color-code-bg))" 14 + > 15 + <Code2 size={14} style="color: rgb(var(--color-text-secondary))" /> 16 + {@render children()} 17 + </code>
+44
src/lib/components/CopyButton.svelte
··· 1 + <script lang="ts"> 2 + import { Copy, Check } from '@lucide/svelte'; 3 + 4 + interface Props { 5 + text: string; 6 + size?: number; 7 + } 8 + 9 + let { text, size = 16 }: Props = $props(); 10 + 11 + let copied = $state(false); 12 + let timeout: ReturnType<typeof setTimeout> | undefined; 13 + 14 + async function copyToClipboard() { 15 + try { 16 + await navigator.clipboard.writeText(text); 17 + copied = true; 18 + 19 + // Clear existing timeout 20 + if (timeout) clearTimeout(timeout); 21 + 22 + // Reset after 2 seconds 23 + timeout = setTimeout(() => { 24 + copied = false; 25 + }, 2000); 26 + } catch (err) { 27 + console.error('Failed to copy:', err); 28 + } 29 + } 30 + </script> 31 + 32 + <button 33 + onclick={copyToClipboard} 34 + class="rounded p-1 transition-colors hover:bg-opacity-20" 35 + style="color: rgb(var(--color-text-secondary))" 36 + aria-label={copied ? 'Copied!' : 'Copy to clipboard'} 37 + title={copied ? 'Copied!' : 'Copy to clipboard'} 38 + > 39 + {#if copied} 40 + <Check {size} style="color: rgb(var(--color-success))" /> 41 + {:else} 42 + <Copy {size} /> 43 + {/if} 44 + </button>
+28
src/lib/components/Link.svelte
··· 1 + <script lang="ts"> 2 + import { ExternalLink } from '@lucide/svelte'; 3 + 4 + interface Props { 5 + href: string; 6 + children: import('svelte').Snippet; 7 + external?: boolean; 8 + } 9 + 10 + let { href, children, external = false }: Props = $props(); 11 + 12 + let isHovered = $state(false); 13 + </script> 14 + 15 + <a 16 + {href} 17 + class="inline-flex items-center gap-1.5 no-underline transition-colors" 18 + style="color: {isHovered ? 'rgb(var(--color-primary-hover))' : 'rgb(var(--color-primary))'}" 19 + onmouseenter={() => (isHovered = true)} 20 + onmouseleave={() => (isHovered = false)} 21 + target={external ? '_blank' : undefined} 22 + rel={external ? 'noopener noreferrer' : undefined} 23 + > 24 + {@render children()} 25 + {#if external} 26 + <ExternalLink size={14} class="shrink-0" /> 27 + {/if} 28 + </a>
+107
src/lib/components/QRCodeModal.svelte
··· 1 + <script lang="ts"> 2 + import { X } from '@lucide/svelte'; 3 + import { onMount } from 'svelte'; 4 + 5 + interface Props { 6 + url: string; 7 + isOpen: boolean; 8 + onClose: () => void; 9 + } 10 + 11 + let { url, isOpen, onClose }: Props = $props(); 12 + 13 + let qrCodeContainer: HTMLDivElement; 14 + 15 + $effect(() => { 16 + if (isOpen && qrCodeContainer && typeof window !== 'undefined') { 17 + import('qrcode').then(({ default: QRCode }) => { 18 + qrCodeContainer.innerHTML = ''; 19 + QRCode.toCanvas(url, { 20 + errorCorrectionLevel: 'H', 21 + margin: 2, 22 + width: 256, 23 + color: { 24 + dark: '#000000', 25 + light: '#FFFFFF' 26 + } 27 + }, (error: Error | null | undefined, canvas: HTMLCanvasElement) => { 28 + if (error) { 29 + console.error('QR Code generation error:', error); 30 + return; 31 + } 32 + if (qrCodeContainer) { 33 + qrCodeContainer.appendChild(canvas); 34 + } 35 + }); 36 + }); 37 + } 38 + }); 39 + </script> 40 + 41 + {#if isOpen} 42 + <div 43 + style=" 44 + position: fixed; 45 + top: 0; 46 + left: 0; 47 + right: 0; 48 + bottom: 0; 49 + width: 100vw; 50 + height: 100vh; 51 + background-color: rgba(0, 0, 0, 0.75); 52 + display: flex; 53 + align-items: center; 54 + justify-content: center; 55 + padding: 1rem; 56 + z-index: 9999; 57 + " 58 + onclick={onClose} 59 + role="dialog" 60 + aria-modal="true" 61 + aria-labelledby="qr-modal-title" 62 + > 63 + <div 64 + class="relative w-full max-w-md rounded-lg p-8 shadow-2xl" 65 + style="background-color: rgb(var(--color-surface)); color: rgb(var(--color-text-primary))" 66 + onclick={(e) => e.stopPropagation()} 67 + role="document" 68 + > 69 + <button 70 + onclick={onClose} 71 + class="absolute right-4 top-4 rounded-lg p-2 transition-colors" 72 + style="color: rgb(var(--color-text-secondary)); background-color: transparent;" 73 + onmouseenter={(e) => { 74 + e.currentTarget.style.backgroundColor = 'rgb(var(--color-surface-elevated))'; 75 + }} 76 + onmouseleave={(e) => { 77 + e.currentTarget.style.backgroundColor = 'transparent'; 78 + }} 79 + aria-label="Close modal" 80 + > 81 + <X size={20} /> 82 + </button> 83 + 84 + <h2 85 + id="qr-modal-title" 86 + class="mb-6 text-xl font-semibold" 87 + style="color: rgb(var(--color-text-primary))" 88 + > 89 + QR Code 90 + </h2> 91 + 92 + <div class="flex flex-col items-center gap-4"> 93 + <div 94 + bind:this={qrCodeContainer} 95 + class="rounded-lg p-4" 96 + style="background-color: white;" 97 + ></div> 98 + <p 99 + class="max-w-xs break-all text-center text-sm" 100 + style="color: rgb(var(--color-text-secondary))" 101 + > 102 + {url} 103 + </p> 104 + </div> 105 + </div> 106 + </div> 107 + {/if}
+15
src/lib/components/Section.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + title: string; 4 + children: import('svelte').Snippet; 5 + } 6 + 7 + let { title, children }: Props = $props(); 8 + </script> 9 + 10 + <section class="mb-12"> 11 + <h2 class="mb-4 mt-8 text-2xl font-semibold" style="color: rgb(var(--color-text-primary))"> 12 + {title} 13 + </h2> 14 + {@render children()} 15 + </section>
+72
src/lib/components/ShortLinkItem.svelte
··· 1 + <script lang="ts"> 2 + import { Link as LinkIcon, ArrowRight, QrCode } from '@lucide/svelte'; 3 + import CodeBlock from './CodeBlock.svelte'; 4 + import CopyButton from './CopyButton.svelte'; 5 + import QRCodeModal from './QRCodeModal.svelte'; 6 + 7 + interface Props { 8 + shortcode: string; 9 + title: string; 10 + emoji?: string; 11 + } 12 + 13 + let { shortcode, title, emoji }: Props = $props(); 14 + 15 + let isHovered = $state(false); 16 + let showQRModal = $state(false); 17 + 18 + // Get the full URL for copying 19 + const fullUrl = typeof window !== 'undefined' ? `${window.location.origin}/${shortcode}` : ''; 20 + </script> 21 + 22 + <li> 23 + <div 24 + class="flex items-center gap-3 rounded-lg p-3 transition-all" 25 + style="background-color: {isHovered 26 + ? 'rgb(var(--color-surface-elevated))' 27 + : 'rgb(var(--color-surface))'}; color: rgb(var(--color-text-primary))" 28 + role="group" 29 + onmouseenter={() => (isHovered = true)} 30 + onmouseleave={() => (isHovered = false)} 31 + > 32 + {#if emoji} 33 + <span class="shrink-0 text-2xl">{emoji}</span> 34 + {:else} 35 + <LinkIcon size={20} class="shrink-0" style="color: rgb(var(--color-text-secondary))" /> 36 + {/if} 37 + 38 + <a href="/{shortcode}" class="flex items-center gap-2 no-underline"> 39 + <CodeBlock>/{shortcode}</CodeBlock> 40 + </a> 41 + 42 + <span class="flex-1" style="color: rgb(var(--color-text-secondary))">{title}</span> 43 + 44 + {#if fullUrl} 45 + <button 46 + onclick={() => (showQRModal = true)} 47 + class="flex items-center gap-1 rounded-lg px-2 py-1 transition-colors hover:bg-gray-100 dark:hover:bg-gray-700" 48 + style="color: rgb(var(--color-text-tertiary))" 49 + aria-label="Show QR code" 50 + title="Show QR code" 51 + > 52 + <QrCode size={16} class="shrink-0" /> 53 + </button> 54 + 55 + <CopyButton text={fullUrl} /> 56 + {/if} 57 + 58 + <a 59 + href="/{shortcode}" 60 + class="flex items-center gap-1 no-underline transition-colors" 61 + style="color: rgb(var(--color-text-tertiary))" 62 + > 63 + <ArrowRight 64 + size={16} 65 + class="shrink-0 transition-transform" 66 + style="transform: translateX({isHovered ? '4px' : '0'})" 67 + /> 68 + </a> 69 + </div> 70 + </li> 71 + 72 + <QRCodeModal url={fullUrl} isOpen={showQRModal} onClose={() => (showQRModal = false)} />
+32
src/lib/components/Spinner.svelte
··· 1 + <script lang="ts"> 2 + import { Loader2 } from '@lucide/svelte'; 3 + 4 + interface Props { 5 + size?: number; 6 + text?: string; 7 + } 8 + 9 + let { size = 24, text }: Props = $props(); 10 + </script> 11 + 12 + <div class="flex items-center gap-3" style="color: rgb(var(--color-text-secondary))"> 13 + <Loader2 {size} class="animate-spin" /> 14 + {#if text} 15 + <span>{text}</span> 16 + {/if} 17 + </div> 18 + 19 + <style> 20 + @keyframes spin { 21 + from { 22 + transform: rotate(0deg); 23 + } 24 + to { 25 + transform: rotate(360deg); 26 + } 27 + } 28 + 29 + :global(.animate-spin) { 30 + animation: spin 1s linear infinite; 31 + } 32 + </style>
+38
src/lib/components/StatusCard.svelte
··· 1 + <script lang="ts"> 2 + import { CheckCircle2, AlertCircle } from '@lucide/svelte'; 3 + 4 + interface Props { 5 + type: 'success' | 'error'; 6 + children: import('svelte').Snippet; 7 + } 8 + 9 + let { type, children }: Props = $props(); 10 + 11 + const styles = { 12 + success: { 13 + bg: 'rgb(var(--color-success-bg))', 14 + border: 'rgb(var(--color-success-border))', 15 + icon: CheckCircle2 16 + }, 17 + error: { 18 + bg: 'rgb(var(--color-error-bg))', 19 + border: 'rgb(var(--color-error-border))', 20 + icon: AlertCircle 21 + } 22 + }; 23 + 24 + const config = styles[type]; 25 + const Icon = config.icon; 26 + </script> 27 + 28 + <div 29 + class="mb-4 rounded-lg border p-4" 30 + style="background-color: {config.bg}; border-color: {config.border}" 31 + > 32 + <div class="flex items-start gap-3"> 33 + <Icon size={24} class="shrink-0" style="color: rgb(var(--color-text-primary))" /> 34 + <div class="flex-1"> 35 + {@render children()} 36 + </div> 37 + </div> 38 + </div>
+39
src/lib/components/ThemeToggle.svelte
··· 1 + <script lang="ts"> 2 + import { Sun, Moon } from '@lucide/svelte'; 3 + import { onMount } from 'svelte'; 4 + 5 + let isDark = $state(false); 6 + let mounted = $state(false); 7 + 8 + onMount(() => { 9 + // Check initial theme 10 + isDark = document.documentElement.classList.contains('dark'); 11 + mounted = true; 12 + }); 13 + 14 + function toggleTheme() { 15 + isDark = !isDark; 16 + if (isDark) { 17 + document.documentElement.classList.add('dark'); 18 + localStorage.setItem('theme', 'dark'); 19 + } else { 20 + document.documentElement.classList.remove('dark'); 21 + localStorage.setItem('theme', 'light'); 22 + } 23 + } 24 + </script> 25 + 26 + {#if mounted} 27 + <button 28 + onclick={toggleTheme} 29 + class="fixed bottom-6 right-6 rounded-full p-3 shadow-lg transition-all hover:scale-110" 30 + style="background-color: rgb(var(--color-surface-elevated)); color: rgb(var(--color-text-primary))" 31 + aria-label="Toggle theme" 32 + > 33 + {#if isDark} 34 + <Sun size={20} /> 35 + {:else} 36 + <Moon size={20} /> 37 + {/if} 38 + </button> 39 + {/if}
+9
src/lib/components/index.ts
··· 1 + export { default as StatusCard } from './StatusCard.svelte'; 2 + export { default as CodeBlock } from './CodeBlock.svelte'; 3 + export { default as Link } from './Link.svelte'; 4 + export { default as ShortLinkItem } from './ShortLinkItem.svelte'; 5 + export { default as ApiEndpoint } from './ApiEndpoint.svelte'; 6 + export { default as Section } from './Section.svelte'; 7 + export { default as ThemeToggle } from './ThemeToggle.svelte'; 8 + export { default as Spinner } from './Spinner.svelte'; 9 + export { default as CopyButton } from './CopyButton.svelte';
+69
src/lib/components/types.ts
··· 1 + /** 2 + * Component prop types for the AT Protocol Shortlink application 3 + */ 4 + 5 + import type { Snippet } from 'svelte'; 6 + 7 + /** 8 + * StatusCard component props 9 + */ 10 + export interface StatusCardProps { 11 + type: 'success' | 'error'; 12 + children: Snippet; 13 + } 14 + 15 + /** 16 + * CodeBlock component props 17 + */ 18 + export interface CodeBlockProps { 19 + children: Snippet; 20 + } 21 + 22 + /** 23 + * Link component props 24 + */ 25 + export interface LinkProps { 26 + href: string; 27 + children: Snippet; 28 + external?: boolean; 29 + } 30 + 31 + /** 32 + * ShortLinkItem component props 33 + */ 34 + export interface ShortLinkItemProps { 35 + shortcode: string; 36 + title: string; 37 + emoji?: string; 38 + } 39 + 40 + /** 41 + * ApiEndpoint component props 42 + */ 43 + export interface ApiEndpointProps { 44 + method: 'GET' | 'POST' | 'PUT' | 'DELETE'; 45 + path: string; 46 + description: string; 47 + href?: string; 48 + } 49 + 50 + /** 51 + * Section component props 52 + */ 53 + export interface SectionProps { 54 + title: string; 55 + children: Snippet; 56 + } 57 + 58 + /** 59 + * Spinner component props 60 + */ 61 + export interface SpinnerProps { 62 + size?: number; 63 + text?: string; 64 + } 65 + 66 + /** 67 + * ThemeToggle component has no props 68 + */ 69 + export interface ThemeToggleProps {}
+32 -32
src/lib/utils/encoding.ts
··· 1 - import { SHORTCODE } from '$lib/constants'; // Import constants for shortcode configuration 2 - import { parse, getDomain } from 'tldts'; // Import utility functions for domain parsing 1 + import { SHORTCODE } from '$lib/constants'; // Import constants for shortcode configuration 2 + import { parse, getDomain } from 'tldts'; // Import utility functions for domain parsing 3 3 4 4 // Constants related to the character set for encoding 5 - const BASE_CHARS = SHORTCODE.CHARS; // Charset for the shortcode encoding 6 - const BASE = BASE_CHARS.length; // Base (the number of unique characters in the charset) 5 + const BASE_CHARS = SHORTCODE.CHARS; // Charset for the shortcode encoding 6 + const BASE = BASE_CHARS.length; // Base (the number of unique characters in the charset) 7 7 8 8 // Hashes a given string into a bigint value 9 9 function hashString(text: string): bigint { 10 - let hash = 1469598103934665603n; // FNV-1a hash initialisation value 10 + let hash = 1469598103934665603n; // FNV-1a hash initialisation value 11 11 for (let i = 0; i < text.length; i++) { 12 - const char = BigInt(text.charCodeAt(i)); // Convert each character to a bigint 13 - hash = (hash ^ char) * 1099511628211n; // FNV-1a hashing algorithm 12 + const char = BigInt(text.charCodeAt(i)); // Convert each character to a bigint 13 + hash = (hash ^ char) * 1099511628211n; // FNV-1a hashing algorithm 14 14 } 15 - return hash < 0n ? -hash : hash; // Ensure the hash is positive 15 + return hash < 0n ? -hash : hash; // Ensure the hash is positive 16 16 } 17 17 18 18 // Converts a number (bigint) into a base encoded string with a given length 19 19 function toBase(num: bigint, length: number, seed = ''): string { 20 - let encoded = ''; // The resulting encoded string 20 + let encoded = ''; // The resulting encoded string 21 21 let n = num; 22 22 for (let i = 0; i < length; i++) { 23 23 let rem: bigint; ··· 30 30 const fallback = hashString(num.toString() + '::' + seed + '::' + i.toString()); 31 31 rem = fallback % BigInt(BASE); 32 32 } 33 - encoded = BASE_CHARS[Number(rem)] + encoded; // Prepend the character for this base value 33 + encoded = BASE_CHARS[Number(rem)] + encoded; // Prepend the character for this base value 34 34 } 35 35 return encoded; 36 36 } ··· 40 40 try { 41 41 // Ensure the URL starts with 'https://' and parse it 42 42 const parsed = new URL(url.startsWith('http') ? url : `https://${url}`); 43 - parsed.hash = ''; // Remove hash fragment 43 + parsed.hash = ''; // Remove hash fragment 44 44 45 45 // Sort URL query parameters alphabetically 46 46 const sortedParams = [...parsed.searchParams.entries()].sort((a, b) => 47 47 a[0].localeCompare(b[0]) 48 48 ); 49 - parsed.search = ''; // Clear existing search parameters 50 - for (const [key, value] of sortedParams) parsed.searchParams.append(key, value); // Rebuild query string 49 + parsed.search = ''; // Clear existing search parameters 50 + for (const [key, value] of sortedParams) parsed.searchParams.append(key, value); // Rebuild query string 51 51 52 - parsed.hostname = parsed.hostname.toLowerCase(); // Convert hostname to lowercase 53 - parsed.protocol = 'https:'; // Ensure HTTPS protocol is used 54 - return parsed.toString(); // Return the normalised URL as a string 52 + parsed.hostname = parsed.hostname.toLowerCase(); // Convert hostname to lowercase 53 + parsed.protocol = 'https:'; // Ensure HTTPS protocol is used 54 + return parsed.toString(); // Return the normalised URL as a string 55 55 } catch (e) { 56 56 // If URL parsing fails, return the original URL (trimmed) 57 57 return url.trim(); ··· 79 79 // Validate and adjust the length of the shortcode 80 80 if (!Number.isInteger(length) || length < 3) length = SHORTCODE.DEFAULT_LENGTH; 81 81 82 - const DOMAIN_PREFIX_LENGTH = 2; // Number of characters used for the domain prefix 82 + const DOMAIN_PREFIX_LENGTH = 2; // Number of characters used for the domain prefix 83 83 84 84 // Normalise the URL and extract the base domain 85 85 const normalised = normaliseUrl(url); ··· 92 92 // Calculate the remaining length for the URL core and tail 93 93 const remaining = Math.max(1, length - DOMAIN_PREFIX_LENGTH); 94 94 95 - let hostname = ''; // The hostname portion of the URL 95 + let hostname = ''; // The hostname portion of the URL 96 96 try { 97 - hostname = new URL(normalised).hostname.toLowerCase(); // Try to extract hostname from normalised URL 97 + hostname = new URL(normalised).hostname.toLowerCase(); // Try to extract hostname from normalised URL 98 98 } catch (e) { 99 99 // Fallback if URL parsing fails 100 100 try { 101 101 hostname = new URL(url.startsWith('http') ? url : `https://${url}`).hostname.toLowerCase(); 102 102 } catch { 103 - hostname = ''; // If both parsing attempts fail, leave hostname empty 103 + hostname = ''; // If both parsing attempts fail, leave hostname empty 104 104 } 105 105 } 106 106 107 107 let subLevels: string[] = []; 108 108 // If there is a subdomain, split it into separate levels 109 109 if (apex && hostname && hostname !== apex) { 110 - const sub = hostname.replace(new RegExp(`\.${apex}$`), ''); // Remove the apex domain 111 - subLevels = sub.split('.'); // Split subdomains by '.' 110 + const sub = hostname.replace(new RegExp(`\.${apex}$`), ''); // Remove the apex domain 111 + subLevels = sub.split('.'); // Split subdomains by '.' 112 112 } 113 113 114 114 // URL core length is determined based on the remaining space after the domain prefix 115 115 const MIN_URL_CORE = 1; 116 116 const MIN_TAIL = 1; 117 - const tailLength = remaining; // Length allocated to the tail portion of the shortcode 117 + const tailLength = remaining; // Length allocated to the tail portion of the shortcode 118 118 119 119 // Hash the normalised URL for the URL core portion of the shortcode 120 120 const urlHash = hashString(normalised + '::url'); 121 - const urlCoreLength = remaining - subLevels.length; // Account for subdomain levels 121 + const urlCoreLength = remaining - subLevels.length; // Account for subdomain levels 122 122 const urlCore = toBase(urlHash, Math.max(MIN_URL_CORE, urlCoreLength), 'url'); 123 123 124 124 // Generate subdomain-based tail (if applicable) 125 125 const subTail: string[] = []; 126 - const reversedSubLevels = subLevels.slice().reverse(); // Reverse the subdomain levels for encoding 126 + const reversedSubLevels = subLevels.slice().reverse(); // Reverse the subdomain levels for encoding 127 127 for (let i = 0; i < reversedSubLevels.length; i++) { 128 - const h = hashString(reversedSubLevels[i] + '::sub'); // Hash the subdomain level 129 - subTail.push(toBase(h, 1, 'sub' + i)); // Add to subTail 128 + const h = hashString(reversedSubLevels[i] + '::sub'); // Hash the subdomain level 129 + subTail.push(toBase(h, 1, 'sub' + i)); // Add to subTail 130 130 } 131 131 132 132 // If no subdomain tail is generated, use a fallback hash for the tail ··· 138 138 139 139 // Combine domain prefix, URL core, and tail to form the final shortcode 140 140 let out = domainPrefix + urlCore + tail; 141 - if (out.length > length) out = out.slice(0, length); // Trim to the desired length 141 + if (out.length > length) out = out.slice(0, length); // Trim to the desired length 142 142 if (out.length < length) { 143 143 // Pad the shortcode if it is too short 144 144 let pad = ''; ··· 148 148 pad += toBase(h, Math.min(4, length - out.length - pad.length), 'pad2' + i); 149 149 i++; 150 150 } 151 - out += pad.slice(0, length - out.length); // Append padding to reach the correct length 151 + out += pad.slice(0, length - out.length); // Append padding to reach the correct length 152 152 } 153 153 154 154 // --- LOGGING MAX COMBINATIONS --- (for debugging purposes) 155 - const maxCombinations = BigInt(BASE) ** BigInt(length); // Calculate the max possible combinations for the shortcode 155 + const maxCombinations = BigInt(BASE) ** BigInt(length); // Calculate the max possible combinations for the shortcode 156 156 console.log(`[Shortcode Info] URL: ${url}`); 157 157 console.log(`[Shortcode Info] Length: ${length}, Charset: ${BASE} chars`); 158 158 console.log(`[Shortcode Info] Max possible combinations: ${maxCombinations.toString()}`); ··· 160 160 `[Shortcode Info] Domain prefix: ${domainPrefix}, URL core: ${urlCore}, Subdomain tail: ${tail}` 161 161 ); 162 162 163 - return out; // Return the final encoded shortcode 163 + return out; // Return the final encoded shortcode 164 164 } 165 165 166 166 // Function to validate if a given shortcode is valid (contains only alphanumeric characters) ··· 170 170 171 171 // Function to calculate the maximum number of possible combinations for a shortcode of a given length 172 172 export function getMaxCombinations(length: number): number { 173 - return Math.pow(BASE, length); // BASE raised to the power of length 173 + return Math.pow(BASE, length); // BASE raised to the power of length 174 174 }
+7 -1
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 + import { ThemeToggle } from '$lib/components'; 3 4 4 5 let { children } = $props(); 5 6 </script> ··· 8 9 <script> 9 10 // Prevent flash of unstyled content (FOUC) by applying theme before page renders 10 11 (function () { 12 + const stored = localStorage.getItem('theme'); 11 13 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 12 14 const htmlElement = document.documentElement; 13 15 14 - if (prefersDark) { 16 + // Use stored theme if available, otherwise use system preference 17 + const shouldBeDark = stored === 'dark' || (!stored && prefersDark); 18 + 19 + if (shouldBeDark) { 15 20 htmlElement.classList.add('dark'); 16 21 } else { 17 22 htmlElement.classList.remove('dark'); ··· 24 29 style="min-height: 100vh; background-color: rgb(var(--color-background)); color: rgb(var(--color-text-primary))" 25 30 > 26 31 {@render children()} 32 + <ThemeToggle /> 27 33 </div>
+76 -177
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 + import { Github, Link as LinkIcon } from '@lucide/svelte'; 3 + import { 4 + StatusCard, 5 + CodeBlock, 6 + Link, 7 + ShortLinkItem, 8 + ApiEndpoint, 9 + Section 10 + } from '$lib/components'; 2 11 import type { PageData } from './$types'; 3 12 4 13 export let data: PageData; ··· 10 19 </svelte:head> 11 20 12 21 <main class="mx-auto max-w-3xl px-4 py-8 font-sans leading-relaxed"> 13 - <h1 class="mb-8 text-3xl font-bold" style="color: rgb(var(--color-text-primary))"> 14 - AT Protocol Link Shortener 15 - </h1> 22 + <header class="mb-8"> 23 + <h1 class="mb-2 text-3xl font-bold" style="color: rgb(var(--color-text-primary))"> 24 + AT Protocol Link Shortener 25 + </h1> 26 + <p style="color: rgb(var(--color-text-secondary))"> 27 + A self-hosted link shortening service powered by Linkat and AT Protocol 28 + </p> 29 + </header> 16 30 17 - <section class="mb-12"> 18 - <h2 class="mb-4 mt-8 text-2xl font-semibold" style="color: rgb(var(--color-text-primary))"> 19 - Service Status 20 - </h2> 31 + <Section title="Service Status"> 21 32 {#if data.error} 22 - <div 23 - class="mb-4 rounded-lg border p-4" 24 - style="background-color: rgb(var(--color-error-bg)); border-color: rgb(var(--color-error-border))" 25 - > 26 - <p class="font-bold" style="color: rgb(var(--color-text-primary))"> 27 - ⚠️ Configuration Error 33 + <StatusCard type="error"> 34 + <p class="mb-2 font-bold" style="color: rgb(var(--color-text-primary))"> 35 + Configuration Error 28 36 </p> 29 - <p style="color: rgb(var(--color-text-primary))">{data.error}</p> 37 + <p class="mb-2" style="color: rgb(var(--color-text-primary))">{data.error}</p> 38 + 30 39 {#if data.did === 'NOT_CONFIGURED'} 31 40 <div class="mt-4 border-t pt-4" style="border-color: rgb(var(--color-error-border))"> 32 - <p class="font-bold" style="color: rgb(var(--color-text-primary))">Quick Fix:</p> 33 - <ol 34 - class="ml-6 mt-2 list-decimal space-y-2 leading-8" 35 - style="color: rgb(var(--color-text-primary))" 36 - > 41 + <p class="mb-3 font-bold" style="color: rgb(var(--color-text-primary))">Quick Fix:</p> 42 + <ol class="ml-6 space-y-2" style="color: rgb(var(--color-text-primary))"> 37 43 <li> 38 - Create a <code 39 - class="rounded px-2 py-1 font-mono text-sm" 40 - style="background-color: rgb(var(--color-code-bg))">. env</code 41 - > file in your project root 44 + Create a <CodeBlock>.env</CodeBlock> file in your project root 42 45 </li> 43 46 <li> 44 - Add: <code 45 - class="rounded px-2 py-1 font-mono text-sm" 46 - style="background-color: rgb(var(--color-code-bg))" 47 - >ATPROTO_DID=did:plc:your-did-here</code 48 - > 47 + Add: <CodeBlock>ATPROTO_DID=did:plc:your-did-here</CodeBlock> 49 48 </li> 50 49 <li> 51 - Find your DID at <a 52 - href="https://pdsls.dev/" 53 - target="_blank" 54 - class="underline" 55 - style="color: rgb(var(--color-error))">pdsls.dev</a 56 - > 50 + Find your DID at <Link href="https://pdsls.dev/" external>pdsls.dev</Link> 57 51 </li> 58 52 <li> 59 - Run <code 60 - class="rounded px-2 py-1 font-mono text-sm" 61 - style="background-color: rgb(var(--color-code-bg))">npm run test:config</code 62 - > to verify 53 + Run <CodeBlock>npm run test:config</CodeBlock> to verify 63 54 </li> 64 55 <li>Restart the server</li> 65 56 </ol> 66 57 </div> 67 58 {/if} 68 - </div> 59 + </StatusCard> 69 60 {:else} 70 - <div 71 - class="mb-4 rounded-lg border p-4" 72 - style="background-color: rgb(var(--color-success-bg)); border-color: rgb(var(--color-success-border))" 73 - > 74 - <p style="color: rgb(var(--color-text-primary))">✓ Service is running</p> 75 - <p style="color: rgb(var(--color-text-primary))"> 76 - ✓ Configured DID: <code 77 - class="rounded px-2 py-1 font-mono text-sm" 78 - style="background-color: rgb(var(--color-code-bg))">{data.did}</code 79 - > 80 - </p> 81 - <p style="color: rgb(var(--color-text-primary))">✓ Active links: {data.linkCount}</p> 82 - </div> 61 + <StatusCard type="success"> 62 + <div class="space-y-1" style="color: rgb(var(--color-text-primary))"> 63 + <p>Service is running</p> 64 + <p class="flex items-center gap-2"> 65 + Configured DID: <CodeBlock>{data.did}</CodeBlock> 66 + </p> 67 + <p>Active links: {data.linkCount}</p> 68 + </div> 69 + </StatusCard> 83 70 {/if} 84 - </section> 71 + </Section> 85 72 86 - <section class="mb-12"> 87 - <h2 class="mb-4 mt-8 text-2xl font-semibold" style="color: rgb(var(--color-text-primary))"> 88 - Available Short Links 89 - </h2> 73 + <Section title="Available Short Links"> 90 74 {#if data.links && data.links.length > 0} 91 - <ul class="m-0 list-none p-0"> 75 + <ul class="m-0 space-y-2 list-none p-0"> 92 76 {#each data.links as link} 93 - <li class="mb-2"> 94 - <a 95 - href="/{link.shortcode}" 96 - class="flex items-center gap-3 rounded-lg p-3 no-underline transition-colors" 97 - style="background-color: rgb(var(--color-surface)); color: rgb(var(--color-text-primary))" 98 - onmouseenter={(e) => { 99 - e.currentTarget.style.backgroundColor = `rgb(var(--color-surface-elevated))`; 100 - }} 101 - onmouseleave={(e) => { 102 - e.currentTarget.style.backgroundColor = `rgb(var(--color-surface))`; 103 - }} 104 - > 105 - {#if link.emoji} 106 - <span class="shrink-0 text-2xl">{link.emoji}</span> 107 - {/if} 108 - <code 109 - class="rounded px-2 py-1 font-mono text-sm" 110 - style="background-color: rgb(var(--color-code-bg))">/{link.shortcode}</code 111 - > 112 - <span style="color: rgb(var(--color-text-secondary))">{link.title}</span> 113 - </a> 114 - </li> 77 + <ShortLinkItem shortcode={link.shortcode} title={link.title} emoji={link.emoji} /> 115 78 {/each} 116 79 </ul> 117 80 {:else if !data.error} 118 - <p class="italic" style="color: rgb(var(--color-text-secondary))"> 119 - No short links configured yet. Add links to your <a 120 - href="https://linkat.blue" 121 - target="_blank" 122 - class="no-underline hover:underline" 123 - style="color: rgb(var(--color-primary))" 124 - onmouseenter={(e) => { 125 - e.currentTarget.style.color = `rgb(var(--color-primary-hover))`; 126 - }} 127 - onmouseleave={(e) => { 128 - e.currentTarget.style.color = `rgb(var(--color-primary))`; 129 - }}>Linkat board</a 130 - >! 131 - </p> 81 + <div 82 + class="rounded-lg border p-6 text-center" 83 + style="background-color: rgb(var(--color-surface)); border-color: rgb(var(--color-border))" 84 + > 85 + <LinkIcon size={48} class="mx-auto mb-4" style="color: rgb(var(--color-text-tertiary))" /> 86 + <p class="mb-2" style="color: rgb(var(--color-text-primary))"> 87 + No short links configured yet 88 + </p> 89 + <p style="color: rgb(var(--color-text-secondary))"> 90 + Add links to your <Link href="https://linkat.blue" external>Linkat board</Link> to get started! 91 + </p> 92 + </div> 132 93 {/if} 133 - </section> 94 + </Section> 134 95 135 - <section class="mb-12"> 136 - <h2 class="mb-4 mt-8 text-2xl font-semibold" style="color: rgb(var(--color-text-primary))"> 137 - API Endpoints 138 - </h2> 139 - <ul class="m-0 list-none p-0"> 140 - <li class="mb-3 rounded-lg p-3" style="background-color: rgb(var(--color-surface))"> 141 - <a 142 - href="/api/links" 143 - class="flex items-center gap-4 no-underline hover:underline" 144 - style="color: rgb(var(--color-text-primary))" 145 - > 146 - <code 147 - class="rounded px-2 py-1 font-mono text-sm" 148 - style="background-color: rgb(var(--color-code-bg))">GET /api/links</code 149 - > 150 - <span style="color: rgb(var(--color-text-secondary))">List all short links (JSON)</span> 151 - </a> 152 - </li> 153 - <li class="mb-3 rounded-lg p-3" style="background-color: rgb(var(--color-surface))"> 154 - <div class="flex items-center gap-4" style="color: rgb(var(--color-text-primary))"> 155 - <code 156 - class="rounded px-2 py-1 font-mono text-sm" 157 - style="background-color: rgb(var(--color-code-bg))">GET /:shortcode</code 158 - > 159 - <span style="color: rgb(var(--color-text-secondary))">Redirect to target URL (301)</span> 160 - </div> 161 - </li> 96 + <Section title="API Endpoints"> 97 + <ul class="m-0 space-y-3 list-none p-0"> 98 + <ApiEndpoint 99 + method="GET" 100 + path="/api/links" 101 + description="List all short links (JSON)" 102 + href="/api/links" 103 + /> 104 + <ApiEndpoint method="GET" path="/:shortcode" description="Redirect to target URL (301)" /> 162 105 </ul> 163 - </section> 106 + </Section> 164 107 165 108 <footer 166 - class="mt-16 border-t pt-8 text-center text-sm" 109 + class="mt-16 border-t pt-8 space-y-4 text-center text-sm" 167 110 style="border-color: rgb(var(--color-border)); color: rgb(var(--color-text-secondary))" 168 111 > 169 - <p class="mb-2"> 170 - Powered by <a 171 - href="https://linkat.blue" 172 - target="_blank" 173 - rel="noopener noreferrer" 174 - class="no-underline hover:underline" 175 - style="color: rgb(var(--color-primary))" 176 - onmouseenter={(e) => { 177 - e.currentTarget.style.color = `rgb(var(--color-primary-hover))`; 178 - }} 179 - onmouseleave={(e) => { 180 - e.currentTarget.style.color = `rgb(var(--color-primary))`; 181 - }}>Linkat</a 182 - >, 183 - <a 184 - href="https://atproto.com" 185 - target="_blank" 186 - rel="noopener noreferrer" 187 - class="no-underline hover:underline" 188 - style="color: rgb(var(--color-primary))" 189 - onmouseenter={(e) => { 190 - e.currentTarget.style.color = `rgb(var(--color-primary-hover))`; 191 - }} 192 - onmouseleave={(e) => { 193 - e.currentTarget.style.color = `rgb(var(--color-primary))`; 194 - }}>AT Protocol</a 195 - >, and 196 - <a 197 - href="https://slingshot.microcosm.blue" 198 - target="_blank" 199 - rel="noopener noreferrer" 200 - class="no-underline hover:underline" 201 - style="color: rgb(var(--color-primary))" 202 - onmouseenter={(e) => { 203 - e.currentTarget.style.color = `rgb(var(--color-primary-hover))`; 204 - }} 205 - onmouseleave={(e) => { 206 - e.currentTarget.style.color = `rgb(var(--color-primary))`; 207 - }}>Slingshot</a 208 - > 112 + <p> 113 + Powered by 114 + <Link href="https://linkat.blue" external>Linkat</Link>, 115 + <Link href="https://atproto.com" external>AT Protocol</Link>, and 116 + <Link href="https://slingshot.microcosm.blue" external>Slingshot</Link> 209 117 </p> 210 118 <p> 211 - <a 212 - href="https://github.com/ewanc26/atproto-shortlink" 213 - target="_blank" 214 - rel="noopener noreferrer" 215 - class="no-underline hover:underline" 216 - style="color: rgb(var(--color-primary))" 217 - onmouseenter={(e) => { 218 - e.currentTarget.style.color = `rgb(var(--color-primary-hover))`; 219 - }} 220 - onmouseleave={(e) => { 221 - e.currentTarget.style.color = `rgb(var(--color-primary))`; 222 - }}>Source Code on GitHub</a 223 - > 119 + <Link href="https://github.com/ewanc26/atproto-shortlink" external> 120 + <Github size={14} /> 121 + Source Code on GitHub 122 + </Link> 224 123 </p> 225 124 </footer> 226 125 </main>