diff --git a/lexicons/social/attic/bookmark/entity.json b/lexicons/social/attic/bookmark/entity.json new file mode 100644 index 0000000..6ed614e --- /dev/null +++ b/lexicons/social/attic/bookmark/entity.json @@ -0,0 +1,41 @@ +{ + "lexicon": 1, + "id": "social.attic.bookmark.post", + "defs": { + "main": { + "key": "tid", + "type": "record", + "description": "A web bookmark.", + "record": { + "type": "object", + "required": ["url", "title", "createdAt"], + "properties": { + "url": { + "type": "string", + "format": "uri", + "maxLength": 10240, + "maxGraphemes": 1024, + "description": "Web URL to an HTML document." + }, + "title": { + "type": "string", + "maxLength": 2560, + "maxGraphemes": 256, + "description": "Original or edited title of the HTML document." + }, + "tags": { + "type": "array", + "description": ".", + "maxLength": 8, + "items": { "type": "string", "maxLength": 320, "maxGraphemes": 32 } + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this bookmark was originally created." + } + } + } + } + } +} diff --git a/package.json b/package.json index 7e8c567..26247d7 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@atcute/identity-resolver-node": "^1.0.3", "@atcute/lexicons": "^1.2.9", "@atcute/oauth-node-client": "^1.1.0", + "@atcute/tid": "^1.1.2", "valibot": "^1.2.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95930bf..34d7150 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@atcute/oauth-node-client': specifier: ^1.1.0 version: 1.1.0 + '@atcute/tid': + specifier: ^1.1.2 + version: 1.1.2 valibot: specifier: ^1.2.0 version: 1.2.0(typescript@5.9.3) @@ -141,6 +144,12 @@ packages: '@atcute/repo@0.1.2': resolution: {integrity: sha512-mX/k8Nv7XFBbahcz5+qsdY91DVwKe8wbut/BrrmzClmSaUgKpztsHjtNfBCamcvIUKc18Lyv8WcVWzlH9wSf5w==} + '@atcute/tid@1.1.2': + resolution: {integrity: sha512-bmPuOX/TOfcm/vsK9vM98spjkcx2wgd9S2PeK5oLgEr8IbNRPq7iMCAPzOL1nu5XAW3LlkOYQEbYRcw5vcQ37w==} + + '@atcute/time-ms@1.2.3': + resolution: {integrity: sha512-pRrkYSVyPDCWHKp77Ygwg3lxgvfwnh52J3kOIWI1z93kM2jWQDSezbTNDLdJFAJT9xm4rnHsA+toN9Rdhrnidw==} + '@atcute/uint8array@1.1.1': resolution: {integrity: sha512-3LsC8XB8TKe9q/5hOA5sFuzGaIFdJZJNewC5OKa3o/eU6+K7JR6see9Zy2JbQERNVnRl11EzbNov1efgLMAs4g==} @@ -756,6 +765,9 @@ packages: svelte: ^5.0.0 vite: ^6.3.0 || ^7.0.0 + '@types/bun@1.3.9': + resolution: {integrity: sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} @@ -784,6 +796,9 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + bun-types@1.3.9: + resolution: {integrity: sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -875,6 +890,10 @@ packages: engines: {node: ^18 || >=20} hasBin: true + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -1226,6 +1245,15 @@ snapshots: '@atcute/mst': 0.1.2 '@atcute/uint8array': 1.1.1 + '@atcute/tid@1.1.2': + dependencies: + '@atcute/time-ms': 1.2.3 + + '@atcute/time-ms@1.2.3': + dependencies: + '@types/bun': 1.3.9 + node-gyp-build: 4.8.4 + '@atcute/uint8array@1.1.1': {} '@atcute/util-fetch@1.0.5': @@ -1627,6 +1655,10 @@ snapshots: vite: 7.3.1(@types/node@25.3.2) vitefu: 1.1.2(vite@7.3.1(@types/node@25.3.2)) + '@types/bun@1.3.9': + dependencies: + bun-types: 1.3.9 + '@types/cookie@0.6.0': {} '@types/estree@1.0.8': {} @@ -1645,6 +1677,10 @@ snapshots: blake3-wasm@2.1.5: {} + bun-types@1.3.9: + dependencies: + '@types/node': 25.3.2 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -1737,6 +1773,8 @@ snapshots: nanoid@5.1.6: {} + node-gyp-build@4.8.4: {} + obug@2.1.1: {} path-to-regexp@6.3.0: {} diff --git a/src/css/base/global.css b/src/css/base/global.css index 6d3739f..fe8be45 100644 --- a/src/css/base/global.css +++ b/src/css/base/global.css @@ -1,6 +1,6 @@ html { - background: #402622; - color: #fff; + background: rgb(var(--color-brown)); + color: rgb(var(--color-white)); } body { @@ -26,7 +26,7 @@ attic-app { [margin-start] minmax(20px, min(5vi, 100px)) [main-start] - minmax(260px, 800px) + minmax(260px, calc((900 / 16) * 1rem)) [main-end] minmax(20px, min(5vi, 100px)) [margin-end] @@ -40,11 +40,26 @@ attic-app { & > header { padding-block: 30px; + + & > nav { + display: flex; + flex-wrap: wrap; + gap: 20px; + + & a { + display: block; + } + } } & > footer { - padding-block: 30px; + color: rgb(var(--color-white) / 0.7); margin-block-start: auto; + padding-block: 60px 30px; + + & a { + color: currentColor; + } } & > main { @@ -53,7 +68,18 @@ attic-app { } :focus-visible { - anchor-name: --pointer; outline: 4px solid magenta; outline-offset: 2px; } + +:is(a[href], button) { + &:hover { + anchor-name: --pointer; + } +} + +:where(body:not(:has(a[href]:hover, button:hover))) { + & :focus-visible { + anchor-name: --pointer; + } +} diff --git a/src/css/base/properties.css b/src/css/base/properties.css index da7ef0c..e0bd83f 100644 --- a/src/css/base/properties.css +++ b/src/css/base/properties.css @@ -8,4 +8,12 @@ --font-size-4: calc(30 / 16 * 1rem); --font-size-5: calc(40 / 16 * 1rem); --font-size-button: calc(24 / 16 * 1rem); + + --color-white: 255 255 255; + --color-black: 0 0 0; + --color-brown: 64 38 34; + --color-yellow: 230 160 0; + --color-light-yellow: 255 190 50; + --color-off-white: 255 234 188; + --color-red: 222 34 68; } diff --git a/src/css/base/typography.css b/src/css/base/typography.css index 103d2ac..a7cacc9 100644 --- a/src/css/base/typography.css +++ b/src/css/base/typography.css @@ -13,6 +13,19 @@ p:has(small:read-only) { font-size: var(--font-size-2); } -a[href] { - color: #e6a000; +a:where([href]) { + --anchor-underline-color: oklch(from currentColor l c h / 0.6); + --anchor-underline-offset: 0.2em; + --anchor-underline-thickness: 2px; + color: rgb(var(--color-yellow)); + text-decoration: underline; + text-decoration-color: var(--anchor-underline-color); + text-decoration-thickness: var(--anchor-underline-thickness); + text-decoration-skip-ink: none; + text-underline-offset: var(--anchor-underline-offset); + + &:hover { + --anchor-underline-color: oklch(from currentColor l c h / 0.1); + color: rgb(var(--color-light-yellow)); + } } diff --git a/src/css/components/bookmark.css b/src/css/components/bookmark.css new file mode 100644 index 0000000..cfaa4e5 --- /dev/null +++ b/src/css/components/bookmark.css @@ -0,0 +1,54 @@ +.Bookmarks { + display: grid; + gap: 20px; +} + +.Bookmark { + border: 5px solid rgb(var(--color-black)); + box-shadow: inset 0 0 0 4px rgb(var(--color-yellow) / 0.1); + border-radius: 10px; + border-start-start-radius: 20px; + border-end-end-radius: 20px; + corner-shape: bevel; + display: grid; + gap: 10px; + grid-template-columns: auto 1fr; + padding: 20px; + position: relative; + + &:has(a:hover) { + box-shadow: inset 0 0 0 4px rgb(var(--color-yellow) / 0.5); + } + + & > :is(h2, h3) { + font-size: var(--font-size-3); + grid-column: 1/ -1; + + & a { + &::after { + content: ""; + display: block; + inset: 0; + position: absolute; + } + } + } + + & > time { + align-self: baseline; + font-size: var(--font-size-2); + font-weight: 700; + grid-column: 1; + } + + & > code { + align-self: baseline; + font-size: var(--font-size-1); + grid-template-columns: 2; + text-overflow: ellipsis; + opacity: 0.8; + overflow: hidden; + white-space: nowrap; + pointer-events: none; + } +} diff --git a/src/css/components/button.css b/src/css/components/button.css index 1d6954b..a1a883d 100644 --- a/src/css/components/button.css +++ b/src/css/components/button.css @@ -12,7 +12,7 @@ button[type] { padding: 0 5px; text-box: trim-both ex alphabetic; text-transform: uppercase; - text-shadow: 2px 2px #40262244; + text-shadow: 2px 2px rgb(var(--color-brown) / 0.3); transition: border-image 200ms; &:hover { diff --git a/src/css/components/form.css b/src/css/components/form.css index 1b03b5d..2d5bbcd 100644 --- a/src/css/components/form.css +++ b/src/css/components/form.css @@ -1,12 +1,12 @@ form { - background: #ffeabc; - border: 5px solid black; - box-shadow: inset 0 0 0 4px #e6a000; + background: rgb(var(--color-off-white)); + border: 5px solid rgb(var(--color-black)); + box-shadow: inset 0 0 0 4px rgb(var(--color-yellow)); border-radius: 10px; border-start-start-radius: 20px; border-end-end-radius: 20px; corner-shape: bevel; - color: #000; + color: rgb(var(--color-black)); display: grid; gap: 10px; justify-items: start; @@ -25,13 +25,34 @@ form { grid-column: 1 / -1; } + &[action*="create"] { + & input { + inline-size: 100%; + } + } + + @media (width >= 600px) { + &[action*="logout"] { + grid-template-columns: 1fr auto; + + & > * { + grid-column: 1 / 2; + } + + & > button { + grid-column: 2; + grid-row: 1 / 3; + } + } + } + &[action*="login"] { grid-template-columns: 1fr auto; - inline-size: min(100%, 600px); + /*inline-size: min(100%, 600px);*/ position: relative; &::before { - background: #40262233; + background: rgb(var(--color-brown) / 0.3); content: "@" / ""; display: grid; place-items: center; @@ -65,7 +86,7 @@ form { & button { &:not(:hover) { border-image-source: url("/images/button-danger.svg"); - color: #dd2244; + color: rgb(var(--color-red)); } } } diff --git a/src/css/components/input.css b/src/css/components/input.css index 552b1f7..dd920e3 100644 --- a/src/css/components/input.css +++ b/src/css/components/input.css @@ -6,7 +6,7 @@ input { font-family: var(--font-family-1); font-size: var(--font-size-3); font-weight: 400; - inline-size: min(100%, 300px); + inline-size: min(100%, 400px); line-height: calc(22 / 16 * 1rem); padding: 0; } diff --git a/src/css/main.css b/src/css/main.css index 61e898f..116c55a 100644 --- a/src/css/main.css +++ b/src/css/main.css @@ -9,9 +9,12 @@ @import "components/button.css" layer(base); @import "components/input.css" layer(base); @import "components/form.css" layer(base); +@import "components/bookmark.css" layer(base); + +@import "utility/hidden.css" layer(base); .error { - color: #dd2244; + color: rgb(var(--color-red)); font-weight: 700; } @@ -19,7 +22,7 @@ --size: 50px; background: url("/images/pointer.svg") center / 100% auto no-repeat; block-size: var(--size); - filter: drop-shadow(2px 2px 0px #40262233); + filter: drop-shadow(2px 2px 0px rgb(var(--color-brown) / 0.3)); inset-block-start: calc( anchor(start) + ((0.5 * anchor-size(block) - (0.5 * var(--size)))) ); @@ -73,8 +76,8 @@ } #handle-listbox { - background: white; - border: 5px solid black; + background: rgb(var(--color-white)); + border: 5px solid rgb(var(--color-black)); display: grid; inline-size: anchor-size(--handle inline); inset-block-start: -5px; @@ -92,12 +95,12 @@ } & [aria-selected="true"] { - background: #ffc133; + background: rgb(var(--color-light-yellow)); } & > p { - background: #40262233; - color: #402622; + background: rgb(var(--color-brown) / 0.3); + color: rgb(var(--color-brown)); font-size: var(--font-size-1); padding: 5px 10px; } @@ -106,7 +109,7 @@ cursor: pointer; &:hover { - background: #e6a000; + background: rgb(var(--color-yellow)); } } diff --git a/src/css/utility/hidden.css b/src/css/utility/hidden.css new file mode 100644 index 0000000..6d60d41 --- /dev/null +++ b/src/css/utility/hidden.css @@ -0,0 +1,11 @@ +.visually-hidden { + border: 0; + block-size: 1px; + clip-path: inset(50%); + inline-size: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; +} diff --git a/src/lib/atproto.ts b/src/lib/atproto.ts new file mode 100644 index 0000000..8243792 --- /dev/null +++ b/src/lib/atproto.ts @@ -0,0 +1,28 @@ +import { + CompositeDidDocumentResolver, + PlcDidDocumentResolver, + WebDidDocumentResolver, +} from "@atcute/identity-resolver"; +import type { Did } from "@atcute/lexicons"; + +const didResolver = new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver(), + web: new WebDidDocumentResolver(), + }, +}); + +export const resolvePDS = async (did: Did): Promise => { + try { + const document = await didResolver.resolve(did as Did<"plc"> | Did<"web">); + if (Array.isArray(document.service) === false) { + return null; + } + for (const service of document.service) { + if (service.id === "#atproto_pds") { + return URL.parse(service.serviceEndpoint.toString()); + } + } + } catch {} + return null; +}; diff --git a/src/lib/server/oauth.ts b/src/lib/server/oauth.ts index 354ce09..2666229 100644 --- a/src/lib/server/oauth.ts +++ b/src/lib/server/oauth.ts @@ -115,7 +115,10 @@ export function createOAuthClient(event: AuthEvent): OAuthClient { const scopes = [ scope.rpc({ lxm: ["app.bsky.actor.getProfile"], aud: "*" }), scope.repo({ - collection: ["social.attic.actor.profile"], + collection: [ + "social.attic.actor.profile", + "social.attic.bookmark.entity", + ], }), ]; diff --git a/src/lib/valibot.ts b/src/lib/valibot.ts index aac218c..b87dbaa 100644 --- a/src/lib/valibot.ts +++ b/src/lib/valibot.ts @@ -44,3 +44,36 @@ export type ActorProfileData = v.InferOutput; export function parseActorProfile(data: unknown): ActorProfileData { return v.parse(ActorProfileSchema, data); } + +const BookmarkSchema = v.object({ + url: v.pipe( + v.string(), + v.trim(), + v.url(), + v.maxLength(10240), + v.maxGraphemes(1024), + ), + title: v.pipe( + v.string(), + v.trim(), + v.maxLength(2560), + v.maxGraphemes(256), + ), + tags: v.optional(v.array( + v.pipe( + v.string(), + v.trim(), + v.maxLength(320), + v.maxGraphemes(32), + ), + )), + createdAt: v.pipe( + v.string(), + v.isoTimestamp(), + ), +}); +export type BookmarkData = v.InferOutput; + +export function parseBookmark(data: unknown): BookmarkData { + return v.parse(BookmarkSchema, data); +} diff --git a/src/params/did.ts b/src/params/did.ts new file mode 100644 index 0000000..93f20ca --- /dev/null +++ b/src/params/did.ts @@ -0,0 +1,7 @@ +import type { Did } from "@atcute/lexicons"; +import { isDid } from "@atcute/lexicons/syntax"; +import type { ParamMatcher } from "@sveltejs/kit"; + +export const match = ((param: string): param is Did => { + return isDid(param); +}) satisfies ParamMatcher; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index bc0fbf9..c717b17 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,14 +1,16 @@
-
-

attic.social

-

Attic is a cozy space with lofty ambitions.

-
+
diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 7092d34..29a311b 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -85,6 +85,7 @@ export const actions = { rkey: "self", }, }); + // [TODO] delete all bookmarks if (result.ok) { await destroySession(event); redirect(303, "/"); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c3db673..4bdf471 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -139,13 +139,18 @@ Attic +

Attic is a cozy space with lofty ambitions.

+ {#if data.user} -

Signed in as:

-
- avatar -

{data.user.displayName}

-

@{data.user.handle}

-
+
+

Signed in as:

+
+ avatar +

{data.user.displayName}

+

@{data.user.handle}

+
+ +

Attic settings

{#if form?.action === "displayName" && form?.error} @@ -156,17 +161,18 @@ type="text" id="displayName" name="displayName" + maxlength="640" value={data.user.displayName} />
-
-

Bye!

- -

Purge data

-

Delete all Attic records and sign out.

+

+ Delete all Attic records and sign out. + This cannot be reversed. +

+

Bookmark purge not implemented yet.

{#if form?.action === "purge" && form?.error}

{form.error}

{/if} diff --git a/src/routes/avatar/[did]/+server.ts b/src/routes/avatar/[did=did]/+server.ts similarity index 100% rename from src/routes/avatar/[did]/+server.ts rename to src/routes/avatar/[did=did]/+server.ts diff --git a/src/routes/bookmarks/[did=did]/+page.server.ts b/src/routes/bookmarks/[did=did]/+page.server.ts new file mode 100644 index 0000000..50a91fe --- /dev/null +++ b/src/routes/bookmarks/[did=did]/+page.server.ts @@ -0,0 +1,44 @@ +import { isAuthEvent } from "$lib/types"; +import { parseBookmark } from "$lib/valibot"; +import { Client } from "@atcute/client"; +import * as TID from "@atcute/tid"; +import { type Actions, fail } from "@sveltejs/kit"; + +export const actions = { + create: async (event) => { + if (isAuthEvent(event) === false) { + throw new Error(); + } + if (event.locals.user === undefined) { + return; + } + const { user } = event.locals; + const formData = await event.request.formData(); + formData.set("createdAt", new Date().toISOString()); + const data = Object.fromEntries(formData); + try { + const record = parseBookmark(data); + const rpc = new Client({ handler: user.session }); + const result = await rpc.post("com.atproto.repo.putRecord", { + input: { + repo: user.did, + collection: "social.attic.bookmark.entity", + rkey: TID.now(), + record, + }, + }); + console.log(result); + if (result.ok === false) { + throw new Error(); + } + return { success: true }; + } catch (err) { + console.log(err); + return fail(400, { + data, + action: "create", + error: "Failed to create bookmark.", + }); + } + }, +} satisfies Actions; diff --git a/src/routes/bookmarks/[did=did]/+page.svelte b/src/routes/bookmarks/[did=did]/+page.svelte new file mode 100644 index 0000000..849be03 --- /dev/null +++ b/src/routes/bookmarks/[did=did]/+page.svelte @@ -0,0 +1,70 @@ + + + + {#if isSelf} + Bookmarks - Attic + {:else} + {data.profile.displayName} - Bookmarks - Attic + {/if} + + +

{data.profile.displayName}

+ +{#if isSelf} + +

Create bookmark

+

Please remember all atproto data is public.

+ {#if form?.action === "create" && form?.error} +

{form.error}

+ {/if} + + + + + +
+{/if} + +{#if data.bookmarks.length} +
+

Bookmarks

+ {#each data.bookmarks as entry (entry.cid)} + + {/each} +
+{/if} diff --git a/src/routes/bookmarks/[did=did]/+page.ts b/src/routes/bookmarks/[did=did]/+page.ts new file mode 100644 index 0000000..2f37276 --- /dev/null +++ b/src/routes/bookmarks/[did=did]/+page.ts @@ -0,0 +1,65 @@ +import { resolvePDS } from "$lib/atproto"; +import { + type BookmarkData, + parseActorProfile, + parseBookmark, +} from "$lib/valibot"; +import { Client, simpleFetchHandler } from "@atcute/client"; +import { error } from "@sveltejs/kit"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async ({ params }) => { + const pds = await resolvePDS(params.did); + if (pds === null) { + error(404); + } + const rpc = new Client({ + handler: simpleFetchHandler({ service: pds }), + }); + const response = await rpc.get("com.atproto.repo.getRecord", { + params: { + repo: params.did, + collection: "social.attic.actor.profile", + rkey: "self", + }, + }); + if (response.ok === false) { + error(404); + } + const bookmarks: Array = []; + // [TODO] pagination? + let cursor: string | undefined; + do { + const response = await rpc.get("com.atproto.repo.listRecords", { + params: { + repo: params.did, + collection: "social.attic.bookmark.entity", + cursor, + }, + }); + if (response.ok === false) { + break; + } + cursor = response.data.cursor; + for (const data of response.data.records) { + try { + bookmarks.push({ + cid: data.cid, + uri: data.uri, + ...parseBookmark(data.value), + }); + } catch { + // [TODO] delete invalid data? + } + } + } while (cursor); + try { + const profile = parseActorProfile(response.data.value); + return { + profile, + bookmarks, + }; + } catch { + error(404); + } +};