Attic is a cozy space with lofty ambitions. attic.social

bookmark lexicon + route

dbushell.com f6b9e62b 1c3b18c7

verified
+516 -41
+41
lexicons/social/attic/bookmark/entity.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.attic.bookmark.post", 4 + "defs": { 5 + "main": { 6 + "key": "tid", 7 + "type": "record", 8 + "description": "A web bookmark.", 9 + "record": { 10 + "type": "object", 11 + "required": ["url", "title", "createdAt"], 12 + "properties": { 13 + "url": { 14 + "type": "string", 15 + "format": "uri", 16 + "maxLength": 10240, 17 + "maxGraphemes": 1024, 18 + "description": "Web URL to an HTML document." 19 + }, 20 + "title": { 21 + "type": "string", 22 + "maxLength": 2560, 23 + "maxGraphemes": 256, 24 + "description": "Original or edited title of the HTML document." 25 + }, 26 + "tags": { 27 + "type": "array", 28 + "description": ".", 29 + "maxLength": 8, 30 + "items": { "type": "string", "maxLength": 320, "maxGraphemes": 32 } 31 + }, 32 + "createdAt": { 33 + "type": "string", 34 + "format": "datetime", 35 + "description": "Client-declared timestamp when this bookmark was originally created." 36 + } 37 + } 38 + } 39 + } 40 + } 41 + }
+1
package.json
··· 20 "@atcute/identity-resolver-node": "^1.0.3", 21 "@atcute/lexicons": "^1.2.9", 22 "@atcute/oauth-node-client": "^1.1.0", 23 "valibot": "^1.2.0" 24 }, 25 "devDependencies": {
··· 20 "@atcute/identity-resolver-node": "^1.0.3", 21 "@atcute/lexicons": "^1.2.9", 22 "@atcute/oauth-node-client": "^1.1.0", 23 + "@atcute/tid": "^1.1.2", 24 "valibot": "^1.2.0" 25 }, 26 "devDependencies": {
+38
pnpm-lock.yaml
··· 29 '@atcute/oauth-node-client': 30 specifier: ^1.1.0 31 version: 1.1.0 32 valibot: 33 specifier: ^1.2.0 34 version: 1.2.0(typescript@5.9.3) ··· 140 141 '@atcute/repo@0.1.2': 142 resolution: {integrity: sha512-mX/k8Nv7XFBbahcz5+qsdY91DVwKe8wbut/BrrmzClmSaUgKpztsHjtNfBCamcvIUKc18Lyv8WcVWzlH9wSf5w==} 143 144 '@atcute/uint8array@1.1.1': 145 resolution: {integrity: sha512-3LsC8XB8TKe9q/5hOA5sFuzGaIFdJZJNewC5OKa3o/eU6+K7JR6see9Zy2JbQERNVnRl11EzbNov1efgLMAs4g==} ··· 756 svelte: ^5.0.0 757 vite: ^6.3.0 || ^7.0.0 758 759 '@types/cookie@0.6.0': 760 resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} 761 ··· 783 784 blake3-wasm@2.1.5: 785 resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} 786 787 chokidar@4.0.3: 788 resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} ··· 873 nanoid@5.1.6: 874 resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} 875 engines: {node: ^18 || >=20} 876 hasBin: true 877 878 obug@2.1.1: ··· 1226 '@atcute/mst': 0.1.2 1227 '@atcute/uint8array': 1.1.1 1228 1229 '@atcute/uint8array@1.1.1': {} 1230 1231 '@atcute/util-fetch@1.0.5': ··· 1627 vite: 7.3.1(@types/node@25.3.2) 1628 vitefu: 1.1.2(vite@7.3.1(@types/node@25.3.2)) 1629 1630 '@types/cookie@0.6.0': {} 1631 1632 '@types/estree@1.0.8': {} ··· 1644 axobject-query@4.1.0: {} 1645 1646 blake3-wasm@2.1.5: {} 1647 1648 chokidar@4.0.3: 1649 dependencies: ··· 1736 nanoid@3.3.11: {} 1737 1738 nanoid@5.1.6: {} 1739 1740 obug@2.1.1: {} 1741
··· 29 '@atcute/oauth-node-client': 30 specifier: ^1.1.0 31 version: 1.1.0 32 + '@atcute/tid': 33 + specifier: ^1.1.2 34 + version: 1.1.2 35 valibot: 36 specifier: ^1.2.0 37 version: 1.2.0(typescript@5.9.3) ··· 143 144 '@atcute/repo@0.1.2': 145 resolution: {integrity: sha512-mX/k8Nv7XFBbahcz5+qsdY91DVwKe8wbut/BrrmzClmSaUgKpztsHjtNfBCamcvIUKc18Lyv8WcVWzlH9wSf5w==} 146 + 147 + '@atcute/tid@1.1.2': 148 + resolution: {integrity: sha512-bmPuOX/TOfcm/vsK9vM98spjkcx2wgd9S2PeK5oLgEr8IbNRPq7iMCAPzOL1nu5XAW3LlkOYQEbYRcw5vcQ37w==} 149 + 150 + '@atcute/time-ms@1.2.3': 151 + resolution: {integrity: sha512-pRrkYSVyPDCWHKp77Ygwg3lxgvfwnh52J3kOIWI1z93kM2jWQDSezbTNDLdJFAJT9xm4rnHsA+toN9Rdhrnidw==} 152 153 '@atcute/uint8array@1.1.1': 154 resolution: {integrity: sha512-3LsC8XB8TKe9q/5hOA5sFuzGaIFdJZJNewC5OKa3o/eU6+K7JR6see9Zy2JbQERNVnRl11EzbNov1efgLMAs4g==} ··· 765 svelte: ^5.0.0 766 vite: ^6.3.0 || ^7.0.0 767 768 + '@types/bun@1.3.9': 769 + resolution: {integrity: sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==} 770 + 771 '@types/cookie@0.6.0': 772 resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} 773 ··· 795 796 blake3-wasm@2.1.5: 797 resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} 798 + 799 + bun-types@1.3.9: 800 + resolution: {integrity: sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==} 801 802 chokidar@4.0.3: 803 resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} ··· 888 nanoid@5.1.6: 889 resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} 890 engines: {node: ^18 || >=20} 891 + hasBin: true 892 + 893 + node-gyp-build@4.8.4: 894 + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} 895 hasBin: true 896 897 obug@2.1.1: ··· 1245 '@atcute/mst': 0.1.2 1246 '@atcute/uint8array': 1.1.1 1247 1248 + '@atcute/tid@1.1.2': 1249 + dependencies: 1250 + '@atcute/time-ms': 1.2.3 1251 + 1252 + '@atcute/time-ms@1.2.3': 1253 + dependencies: 1254 + '@types/bun': 1.3.9 1255 + node-gyp-build: 4.8.4 1256 + 1257 '@atcute/uint8array@1.1.1': {} 1258 1259 '@atcute/util-fetch@1.0.5': ··· 1655 vite: 7.3.1(@types/node@25.3.2) 1656 vitefu: 1.1.2(vite@7.3.1(@types/node@25.3.2)) 1657 1658 + '@types/bun@1.3.9': 1659 + dependencies: 1660 + bun-types: 1.3.9 1661 + 1662 '@types/cookie@0.6.0': {} 1663 1664 '@types/estree@1.0.8': {} ··· 1676 axobject-query@4.1.0: {} 1677 1678 blake3-wasm@2.1.5: {} 1679 + 1680 + bun-types@1.3.9: 1681 + dependencies: 1682 + '@types/node': 25.3.2 1683 1684 chokidar@4.0.3: 1685 dependencies: ··· 1772 nanoid@3.3.11: {} 1773 1774 nanoid@5.1.6: {} 1775 + 1776 + node-gyp-build@4.8.4: {} 1777 1778 obug@2.1.1: {} 1779
+31 -5
src/css/base/global.css
··· 1 html { 2 - background: #402622; 3 - color: #fff; 4 } 5 6 body { ··· 26 [margin-start] 27 minmax(20px, min(5vi, 100px)) 28 [main-start] 29 - minmax(260px, 800px) 30 [main-end] 31 minmax(20px, min(5vi, 100px)) 32 [margin-end] ··· 40 41 & > header { 42 padding-block: 30px; 43 } 44 45 & > footer { 46 - padding-block: 30px; 47 margin-block-start: auto; 48 } 49 50 & > main { ··· 53 } 54 55 :focus-visible { 56 - anchor-name: --pointer; 57 outline: 4px solid magenta; 58 outline-offset: 2px; 59 }
··· 1 html { 2 + background: rgb(var(--color-brown)); 3 + color: rgb(var(--color-white)); 4 } 5 6 body { ··· 26 [margin-start] 27 minmax(20px, min(5vi, 100px)) 28 [main-start] 29 + minmax(260px, calc((900 / 16) * 1rem)) 30 [main-end] 31 minmax(20px, min(5vi, 100px)) 32 [margin-end] ··· 40 41 & > header { 42 padding-block: 30px; 43 + 44 + & > nav { 45 + display: flex; 46 + flex-wrap: wrap; 47 + gap: 20px; 48 + 49 + & a { 50 + display: block; 51 + } 52 + } 53 } 54 55 & > footer { 56 + color: rgb(var(--color-white) / 0.7); 57 margin-block-start: auto; 58 + padding-block: 60px 30px; 59 + 60 + & a { 61 + color: currentColor; 62 + } 63 } 64 65 & > main { ··· 68 } 69 70 :focus-visible { 71 outline: 4px solid magenta; 72 outline-offset: 2px; 73 } 74 + 75 + :is(a[href], button) { 76 + &:hover { 77 + anchor-name: --pointer; 78 + } 79 + } 80 + 81 + :where(body:not(:has(a[href]:hover, button:hover))) { 82 + & :focus-visible { 83 + anchor-name: --pointer; 84 + } 85 + }
+8
src/css/base/properties.css
··· 8 --font-size-4: calc(30 / 16 * 1rem); 9 --font-size-5: calc(40 / 16 * 1rem); 10 --font-size-button: calc(24 / 16 * 1rem); 11 }
··· 8 --font-size-4: calc(30 / 16 * 1rem); 9 --font-size-5: calc(40 / 16 * 1rem); 10 --font-size-button: calc(24 / 16 * 1rem); 11 + 12 + --color-white: 255 255 255; 13 + --color-black: 0 0 0; 14 + --color-brown: 64 38 34; 15 + --color-yellow: 230 160 0; 16 + --color-light-yellow: 255 190 50; 17 + --color-off-white: 255 234 188; 18 + --color-red: 222 34 68; 19 }
+15 -2
src/css/base/typography.css
··· 13 font-size: var(--font-size-2); 14 } 15 16 - a[href] { 17 - color: #e6a000; 18 }
··· 13 font-size: var(--font-size-2); 14 } 15 16 + a:where([href]) { 17 + --anchor-underline-color: oklch(from currentColor l c h / 0.6); 18 + --anchor-underline-offset: 0.2em; 19 + --anchor-underline-thickness: 2px; 20 + color: rgb(var(--color-yellow)); 21 + text-decoration: underline; 22 + text-decoration-color: var(--anchor-underline-color); 23 + text-decoration-thickness: var(--anchor-underline-thickness); 24 + text-decoration-skip-ink: none; 25 + text-underline-offset: var(--anchor-underline-offset); 26 + 27 + &:hover { 28 + --anchor-underline-color: oklch(from currentColor l c h / 0.1); 29 + color: rgb(var(--color-light-yellow)); 30 + } 31 }
+54
src/css/components/bookmark.css
···
··· 1 + .Bookmarks { 2 + display: grid; 3 + gap: 20px; 4 + } 5 + 6 + .Bookmark { 7 + border: 5px solid rgb(var(--color-black)); 8 + box-shadow: inset 0 0 0 4px rgb(var(--color-yellow) / 0.1); 9 + border-radius: 10px; 10 + border-start-start-radius: 20px; 11 + border-end-end-radius: 20px; 12 + corner-shape: bevel; 13 + display: grid; 14 + gap: 10px; 15 + grid-template-columns: auto 1fr; 16 + padding: 20px; 17 + position: relative; 18 + 19 + &:has(a:hover) { 20 + box-shadow: inset 0 0 0 4px rgb(var(--color-yellow) / 0.5); 21 + } 22 + 23 + & > :is(h2, h3) { 24 + font-size: var(--font-size-3); 25 + grid-column: 1/ -1; 26 + 27 + & a { 28 + &::after { 29 + content: ""; 30 + display: block; 31 + inset: 0; 32 + position: absolute; 33 + } 34 + } 35 + } 36 + 37 + & > time { 38 + align-self: baseline; 39 + font-size: var(--font-size-2); 40 + font-weight: 700; 41 + grid-column: 1; 42 + } 43 + 44 + & > code { 45 + align-self: baseline; 46 + font-size: var(--font-size-1); 47 + grid-template-columns: 2; 48 + text-overflow: ellipsis; 49 + opacity: 0.8; 50 + overflow: hidden; 51 + white-space: nowrap; 52 + pointer-events: none; 53 + } 54 + }
+1 -1
src/css/components/button.css
··· 12 padding: 0 5px; 13 text-box: trim-both ex alphabetic; 14 text-transform: uppercase; 15 - text-shadow: 2px 2px #40262244; 16 transition: border-image 200ms; 17 18 &:hover {
··· 12 padding: 0 5px; 13 text-box: trim-both ex alphabetic; 14 text-transform: uppercase; 15 + text-shadow: 2px 2px rgb(var(--color-brown) / 0.3); 16 transition: border-image 200ms; 17 18 &:hover {
+28 -7
src/css/components/form.css
··· 1 form { 2 - background: #ffeabc; 3 - border: 5px solid black; 4 - box-shadow: inset 0 0 0 4px #e6a000; 5 border-radius: 10px; 6 border-start-start-radius: 20px; 7 border-end-end-radius: 20px; 8 corner-shape: bevel; 9 - color: #000; 10 display: grid; 11 gap: 10px; 12 justify-items: start; ··· 25 grid-column: 1 / -1; 26 } 27 28 &[action*="login"] { 29 grid-template-columns: 1fr auto; 30 - inline-size: min(100%, 600px); 31 position: relative; 32 33 &::before { 34 - background: #40262233; 35 content: "@" / ""; 36 display: grid; 37 place-items: center; ··· 65 & button { 66 &:not(:hover) { 67 border-image-source: url("/images/button-danger.svg"); 68 - color: #dd2244; 69 } 70 } 71 }
··· 1 form { 2 + background: rgb(var(--color-off-white)); 3 + border: 5px solid rgb(var(--color-black)); 4 + box-shadow: inset 0 0 0 4px rgb(var(--color-yellow)); 5 border-radius: 10px; 6 border-start-start-radius: 20px; 7 border-end-end-radius: 20px; 8 corner-shape: bevel; 9 + color: rgb(var(--color-black)); 10 display: grid; 11 gap: 10px; 12 justify-items: start; ··· 25 grid-column: 1 / -1; 26 } 27 28 + &[action*="create"] { 29 + & input { 30 + inline-size: 100%; 31 + } 32 + } 33 + 34 + @media (width >= 600px) { 35 + &[action*="logout"] { 36 + grid-template-columns: 1fr auto; 37 + 38 + & > * { 39 + grid-column: 1 / 2; 40 + } 41 + 42 + & > button { 43 + grid-column: 2; 44 + grid-row: 1 / 3; 45 + } 46 + } 47 + } 48 + 49 &[action*="login"] { 50 grid-template-columns: 1fr auto; 51 + /*inline-size: min(100%, 600px);*/ 52 position: relative; 53 54 &::before { 55 + background: rgb(var(--color-brown) / 0.3); 56 content: "@" / ""; 57 display: grid; 58 place-items: center; ··· 86 & button { 87 &:not(:hover) { 88 border-image-source: url("/images/button-danger.svg"); 89 + color: rgb(var(--color-red)); 90 } 91 } 92 }
+1 -1
src/css/components/input.css
··· 6 font-family: var(--font-family-1); 7 font-size: var(--font-size-3); 8 font-weight: 400; 9 - inline-size: min(100%, 300px); 10 line-height: calc(22 / 16 * 1rem); 11 padding: 0; 12 }
··· 6 font-family: var(--font-family-1); 7 font-size: var(--font-size-3); 8 font-weight: 400; 9 + inline-size: min(100%, 400px); 10 line-height: calc(22 / 16 * 1rem); 11 padding: 0; 12 }
+11 -8
src/css/main.css
··· 9 @import "components/button.css" layer(base); 10 @import "components/input.css" layer(base); 11 @import "components/form.css" layer(base); 12 13 .error { 14 - color: #dd2244; 15 font-weight: 700; 16 } 17 ··· 19 --size: 50px; 20 background: url("/images/pointer.svg") center / 100% auto no-repeat; 21 block-size: var(--size); 22 - filter: drop-shadow(2px 2px 0px #40262233); 23 inset-block-start: calc( 24 anchor(start) + ((0.5 * anchor-size(block) - (0.5 * var(--size)))) 25 ); ··· 73 } 74 75 #handle-listbox { 76 - background: white; 77 - border: 5px solid black; 78 display: grid; 79 inline-size: anchor-size(--handle inline); 80 inset-block-start: -5px; ··· 92 } 93 94 & [aria-selected="true"] { 95 - background: #ffc133; 96 } 97 98 & > p { 99 - background: #40262233; 100 - color: #402622; 101 font-size: var(--font-size-1); 102 padding: 5px 10px; 103 } ··· 106 cursor: pointer; 107 108 &:hover { 109 - background: #e6a000; 110 } 111 } 112
··· 9 @import "components/button.css" layer(base); 10 @import "components/input.css" layer(base); 11 @import "components/form.css" layer(base); 12 + @import "components/bookmark.css" layer(base); 13 + 14 + @import "utility/hidden.css" layer(base); 15 16 .error { 17 + color: rgb(var(--color-red)); 18 font-weight: 700; 19 } 20 ··· 22 --size: 50px; 23 background: url("/images/pointer.svg") center / 100% auto no-repeat; 24 block-size: var(--size); 25 + filter: drop-shadow(2px 2px 0px rgb(var(--color-brown) / 0.3)); 26 inset-block-start: calc( 27 anchor(start) + ((0.5 * anchor-size(block) - (0.5 * var(--size)))) 28 ); ··· 76 } 77 78 #handle-listbox { 79 + background: rgb(var(--color-white)); 80 + border: 5px solid rgb(var(--color-black)); 81 display: grid; 82 inline-size: anchor-size(--handle inline); 83 inset-block-start: -5px; ··· 95 } 96 97 & [aria-selected="true"] { 98 + background: rgb(var(--color-light-yellow)); 99 } 100 101 & > p { 102 + background: rgb(var(--color-brown) / 0.3); 103 + color: rgb(var(--color-brown)); 104 font-size: var(--font-size-1); 105 padding: 5px 10px; 106 } ··· 109 cursor: pointer; 110 111 &:hover { 112 + background: rgb(var(--color-yellow)); 113 } 114 } 115
+11
src/css/utility/hidden.css
···
··· 1 + .visually-hidden { 2 + border: 0; 3 + block-size: 1px; 4 + clip-path: inset(50%); 5 + inline-size: 1px; 6 + margin: -1px; 7 + overflow: hidden; 8 + padding: 0; 9 + position: absolute; 10 + white-space: nowrap; 11 + }
+28
src/lib/atproto.ts
···
··· 1 + import { 2 + CompositeDidDocumentResolver, 3 + PlcDidDocumentResolver, 4 + WebDidDocumentResolver, 5 + } from "@atcute/identity-resolver"; 6 + import type { Did } from "@atcute/lexicons"; 7 + 8 + const didResolver = new CompositeDidDocumentResolver({ 9 + methods: { 10 + plc: new PlcDidDocumentResolver(), 11 + web: new WebDidDocumentResolver(), 12 + }, 13 + }); 14 + 15 + export const resolvePDS = async (did: Did): Promise<URL | null> => { 16 + try { 17 + const document = await didResolver.resolve(did as Did<"plc"> | Did<"web">); 18 + if (Array.isArray(document.service) === false) { 19 + return null; 20 + } 21 + for (const service of document.service) { 22 + if (service.id === "#atproto_pds") { 23 + return URL.parse(service.serviceEndpoint.toString()); 24 + } 25 + } 26 + } catch {} 27 + return null; 28 + };
+4 -1
src/lib/server/oauth.ts
··· 115 const scopes = [ 116 scope.rpc({ lxm: ["app.bsky.actor.getProfile"], aud: "*" }), 117 scope.repo({ 118 - collection: ["social.attic.actor.profile"], 119 }), 120 ]; 121
··· 115 const scopes = [ 116 scope.rpc({ lxm: ["app.bsky.actor.getProfile"], aud: "*" }), 117 scope.repo({ 118 + collection: [ 119 + "social.attic.actor.profile", 120 + "social.attic.bookmark.entity", 121 + ], 122 }), 123 ]; 124
+33
src/lib/valibot.ts
··· 44 export function parseActorProfile(data: unknown): ActorProfileData { 45 return v.parse(ActorProfileSchema, data); 46 }
··· 44 export function parseActorProfile(data: unknown): ActorProfileData { 45 return v.parse(ActorProfileSchema, data); 46 } 47 + 48 + const BookmarkSchema = v.object({ 49 + url: v.pipe( 50 + v.string(), 51 + v.trim(), 52 + v.url(), 53 + v.maxLength(10240), 54 + v.maxGraphemes(1024), 55 + ), 56 + title: v.pipe( 57 + v.string(), 58 + v.trim(), 59 + v.maxLength(2560), 60 + v.maxGraphemes(256), 61 + ), 62 + tags: v.optional(v.array( 63 + v.pipe( 64 + v.string(), 65 + v.trim(), 66 + v.maxLength(320), 67 + v.maxGraphemes(32), 68 + ), 69 + )), 70 + createdAt: v.pipe( 71 + v.string(), 72 + v.isoTimestamp(), 73 + ), 74 + }); 75 + export type BookmarkData = v.InferOutput<typeof BookmarkSchema>; 76 + 77 + export function parseBookmark(data: unknown): BookmarkData { 78 + return v.parse(BookmarkSchema, data); 79 + }
+7
src/params/did.ts
···
··· 1 + import type { Did } from "@atcute/lexicons"; 2 + import { isDid } from "@atcute/lexicons/syntax"; 3 + import type { ParamMatcher } from "@sveltejs/kit"; 4 + 5 + export const match = ((param: string): param is Did => { 6 + return isDid(param); 7 + }) satisfies ParamMatcher;
+7 -5
src/routes/+layout.svelte
··· 1 <script lang="ts"> 2 import "$css/main.css"; 3 4 - let { children } = $props(); 5 </script> 6 7 <header> 8 - <hgroup> 9 - <h1>attic.social</h1> 10 - <p>Attic is a cozy space with lofty ambitions.</p> 11 - </hgroup> 12 </header> 13 14 <main>
··· 1 <script lang="ts"> 2 import "$css/main.css"; 3 4 + let { data, children } = $props(); 5 </script> 6 7 <header> 8 + <nav> 9 + <a href="/">attic.social</a> 10 + {#if data.user} 11 + <a href="/bookmarks/{data.user.did}">bookmarks</a> 12 + {/if} 13 + </nav> 14 </header> 15 16 <main>
+1
src/routes/+page.server.ts
··· 85 rkey: "self", 86 }, 87 }); 88 if (result.ok) { 89 await destroySession(event); 90 redirect(303, "/");
··· 85 rkey: "self", 86 }, 87 }); 88 + // [TODO] delete all bookmarks 89 if (result.ok) { 90 await destroySession(event); 91 redirect(303, "/");
+17 -11
src/routes/+page.svelte
··· 139 <title>Attic</title> 140 </svelte:head> 141 142 {#if data.user} 143 - <h2>Signed in as:</h2> 144 - <div class="avatar"> 145 - <img alt="avatar" src="/avatar/{data.user.did}" width="50" height="50" /> 146 - <p>{data.user.displayName}</p> 147 - <p>@{data.user.handle}</p> 148 - </div> 149 <form method="POST" action="?/displayName"> 150 <h2>Attic settings</h2> 151 {#if form?.action === "displayName" && form?.error} ··· 156 type="text" 157 id="displayName" 158 name="displayName" 159 value={data.user.displayName} 160 /> 161 <button type="submit">Update</button> 162 </form> 163 - <form method="POST" action="?/logout"> 164 - <h2>Bye!</h2> 165 - <button type="submit">Sign out</button> 166 - </form> 167 <form method="POST" action="?/purge" onsubmit={confirmPurge}> 168 <h2>Purge data</h2> 169 - <p>Delete all Attic records and sign out.</p> 170 {#if form?.action === "purge" && form?.error} 171 <p class="error">{form.error}</p> 172 {/if}
··· 139 <title>Attic</title> 140 </svelte:head> 141 142 + <h1>Attic is a cozy space with lofty ambitions.</h1> 143 + 144 {#if data.user} 145 + <form method="POST" action="?/logout"> 146 + <h2>Signed in as:</h2> 147 + <div class="avatar"> 148 + <img alt="avatar" src="/avatar/{data.user.did}" width="50" height="50" /> 149 + <p>{data.user.displayName}</p> 150 + <p>@{data.user.handle}</p> 151 + </div> 152 + <button type="submit">Sign out</button> 153 + </form> 154 <form method="POST" action="?/displayName"> 155 <h2>Attic settings</h2> 156 {#if form?.action === "displayName" && form?.error} ··· 161 type="text" 162 id="displayName" 163 name="displayName" 164 + maxlength="640" 165 value={data.user.displayName} 166 /> 167 <button type="submit">Update</button> 168 </form> 169 <form method="POST" action="?/purge" onsubmit={confirmPurge}> 170 <h2>Purge data</h2> 171 + <p> 172 + Delete all Attic records and sign out. 173 + <strong>This cannot be reversed.</strong> 174 + </p> 175 + <p class="error">Bookmark purge not implemented yet.</p> 176 {#if form?.action === "purge" && form?.error} 177 <p class="error">{form.error}</p> 178 {/if}
src/routes/avatar/[did]/+server.ts src/routes/avatar/[did=did]/+server.ts
+44
src/routes/bookmarks/[did=did]/+page.server.ts
···
··· 1 + import { isAuthEvent } from "$lib/types"; 2 + import { parseBookmark } from "$lib/valibot"; 3 + import { Client } from "@atcute/client"; 4 + import * as TID from "@atcute/tid"; 5 + import { type Actions, fail } from "@sveltejs/kit"; 6 + 7 + export const actions = { 8 + create: async (event) => { 9 + if (isAuthEvent(event) === false) { 10 + throw new Error(); 11 + } 12 + if (event.locals.user === undefined) { 13 + return; 14 + } 15 + const { user } = event.locals; 16 + const formData = await event.request.formData(); 17 + formData.set("createdAt", new Date().toISOString()); 18 + const data = Object.fromEntries(formData); 19 + try { 20 + const record = parseBookmark(data); 21 + const rpc = new Client({ handler: user.session }); 22 + const result = await rpc.post("com.atproto.repo.putRecord", { 23 + input: { 24 + repo: user.did, 25 + collection: "social.attic.bookmark.entity", 26 + rkey: TID.now(), 27 + record, 28 + }, 29 + }); 30 + console.log(result); 31 + if (result.ok === false) { 32 + throw new Error(); 33 + } 34 + return { success: true }; 35 + } catch (err) { 36 + console.log(err); 37 + return fail(400, { 38 + data, 39 + action: "create", 40 + error: "Failed to create bookmark.", 41 + }); 42 + } 43 + }, 44 + } satisfies Actions;
+70
src/routes/bookmarks/[did=did]/+page.svelte
···
··· 1 + <script lang="ts"> 2 + import type { PageProps } from "./$types"; 3 + 4 + let { data, form, params }: PageProps = $props(); 5 + 6 + const isSelf = $derived(data.user && params.did === data.user.did); 7 + 8 + const dateFormat = new Intl.DateTimeFormat(undefined, { 9 + dateStyle: "medium", 10 + timeStyle: "short", 11 + }); 12 + </script> 13 + 14 + <svelte:head> 15 + {#if isSelf} 16 + <title>Bookmarks - Attic</title> 17 + {:else} 18 + <title>{data.profile.displayName} - Bookmarks - Attic</title> 19 + {/if} 20 + </svelte:head> 21 + 22 + <h1>{data.profile.displayName}</h1> 23 + 24 + {#if isSelf} 25 + <form method="POST" action="?/create"> 26 + <h2>Create bookmark</h2> 27 + <p>Please remember all atproto data is public.</p> 28 + {#if form?.action === "create" && form?.error} 29 + <p class="error">{form.error}</p> 30 + {/if} 31 + <label for="url">URL</label> 32 + <input 33 + type="url" 34 + id="url" 35 + name="url" 36 + maxlength="1280" 37 + value={form?.action === "create" ? form.data.url : ""} 38 + required 39 + /> 40 + <label for="title">Title</label> 41 + <input 42 + type="text" 43 + id="title" 44 + name="title" 45 + maxlength="1280" 46 + value={form?.action === "create" ? form.data.title : ""} 47 + required 48 + /> 49 + <button type="submit">Create</button> 50 + </form> 51 + {/if} 52 + 53 + {#if data.bookmarks.length} 54 + <div class="Bookmarks"> 55 + <h2>Bookmarks</h2> 56 + {#each data.bookmarks as entry (entry.cid)} 57 + <article id={entry.cid} class="Bookmark"> 58 + <h3> 59 + <a href={entry.url} rel="noopener noreferrer" target="_blank"> 60 + {entry.title} 61 + </a> 62 + </h3> 63 + <time datetime={entry.createdAt}> 64 + {dateFormat.format(new Date(entry.createdAt))} 65 + </time> 66 + <code aria-hidden="true">{entry.url}</code> 67 + </article> 68 + {/each} 69 + </div> 70 + {/if}
+65
src/routes/bookmarks/[did=did]/+page.ts
···
··· 1 + import { resolvePDS } from "$lib/atproto"; 2 + import { 3 + type BookmarkData, 4 + parseActorProfile, 5 + parseBookmark, 6 + } from "$lib/valibot"; 7 + import { Client, simpleFetchHandler } from "@atcute/client"; 8 + import { error } from "@sveltejs/kit"; 9 + import type { PageLoad } from "./$types"; 10 + 11 + export const load: PageLoad = async ({ params }) => { 12 + const pds = await resolvePDS(params.did); 13 + if (pds === null) { 14 + error(404); 15 + } 16 + const rpc = new Client({ 17 + handler: simpleFetchHandler({ service: pds }), 18 + }); 19 + const response = await rpc.get("com.atproto.repo.getRecord", { 20 + params: { 21 + repo: params.did, 22 + collection: "social.attic.actor.profile", 23 + rkey: "self", 24 + }, 25 + }); 26 + if (response.ok === false) { 27 + error(404); 28 + } 29 + const bookmarks: Array<BookmarkData & { cid: string; uri: string }> = []; 30 + // [TODO] pagination? 31 + let cursor: string | undefined; 32 + do { 33 + const response = await rpc.get("com.atproto.repo.listRecords", { 34 + params: { 35 + repo: params.did, 36 + collection: "social.attic.bookmark.entity", 37 + cursor, 38 + }, 39 + }); 40 + if (response.ok === false) { 41 + break; 42 + } 43 + cursor = response.data.cursor; 44 + for (const data of response.data.records) { 45 + try { 46 + bookmarks.push({ 47 + cid: data.cid, 48 + uri: data.uri, 49 + ...parseBookmark(data.value), 50 + }); 51 + } catch { 52 + // [TODO] delete invalid data? 53 + } 54 + } 55 + } while (cursor); 56 + try { 57 + const profile = parseActorProfile(response.data.value); 58 + return { 59 + profile, 60 + bookmarks, 61 + }; 62 + } catch { 63 + error(404); 64 + } 65 + };