Add sequoia-recommend component
fee58dda
Resolves #31
16 file(s) · +1897 −537
Resolves #31
| 1 | + | # Recommend |
|
| 2 | + | ||
| 3 | + | Sequoia provides a recommend button web component that lets your readers recommend a document directly from your site using their AT Protocol account. |
|
| 4 | + | ||
| 5 | + | ## Setup |
|
| 6 | + | ||
| 7 | + | The recommend component is bundled in the same file as the subscribe component. Run the following command to install it if you haven't already: |
|
| 8 | + | ||
| 9 | + | ```bash [Terminal] |
|
| 10 | + | sequoia add sequoia-subscribe |
|
| 11 | + | ``` |
|
| 12 | + | ||
| 13 | + | The component will look for your document AT URI from a `<link rel="site.standard.document">` tag in the page `<head>` automatically, so no additional configuration is required for most setups. |
|
| 14 | + | ||
| 15 | + | :::tip |
|
| 16 | + | `sequoia-subscribe.js` registers both the `<sequoia-subscribe>` and `<sequoia-recommend>` custom elements. You only need to import it once even if you use both components on the same page. |
|
| 17 | + | ::: |
|
| 18 | + | ||
| 19 | + | ## Usage |
|
| 20 | + | ||
| 21 | + | Since `sequoia-recommend` is a standard Web Component, it works with any framework. Choose your setup below: |
|
| 22 | + | ||
| 23 | + | :::code-group |
|
| 24 | + | ||
| 25 | + | ```html [HTML] |
|
| 26 | + | <head> |
|
| 27 | + | <!-- Optional: set the document URI via a link tag --> |
|
| 28 | + | <link rel="site.standard.document" href="at://did:plc:example/app.bsky.feed.post/rkey" /> |
|
| 29 | + | </head> |
|
| 30 | + | <body> |
|
| 31 | + | <h1>My Post</h1> |
|
| 32 | + | <!--Content--> |
|
| 33 | + | ||
| 34 | + | <sequoia-recommend></sequoia-recommend> |
|
| 35 | + | <script type="module" src="./src/components/sequoia-subscribe.js"></script> |
|
| 36 | + | </body> |
|
| 37 | + | ``` |
|
| 38 | + | ||
| 39 | + | ```tsx [React] |
|
| 40 | + | // Import the component (registers both custom elements) |
|
| 41 | + | import './components/sequoia-subscribe.js'; |
|
| 42 | + | ||
| 43 | + | function PostPage() { |
|
| 44 | + | return ( |
|
| 45 | + | <main> |
|
| 46 | + | <h1>My Post</h1> |
|
| 47 | + | {/* Content */} |
|
| 48 | + | <sequoia-recommend document-uri="at://did:plc:example/app.bsky.feed.post/rkey" /> |
|
| 49 | + | </main> |
|
| 50 | + | ); |
|
| 51 | + | } |
|
| 52 | + | ``` |
|
| 53 | + | ||
| 54 | + | ```vue [Vue] |
|
| 55 | + | <script setup> |
|
| 56 | + | import './components/sequoia-subscribe.js'; |
|
| 57 | + | </script> |
|
| 58 | + | ||
| 59 | + | <template> |
|
| 60 | + | <main> |
|
| 61 | + | <h1>My Post</h1> |
|
| 62 | + | <!-- Content --> |
|
| 63 | + | <sequoia-recommend document-uri="at://did:plc:example/app.bsky.feed.post/rkey" /> |
|
| 64 | + | </main> |
|
| 65 | + | </template> |
|
| 66 | + | ``` |
|
| 67 | + | ||
| 68 | + | ```svelte [Svelte] |
|
| 69 | + | <script> |
|
| 70 | + | import './components/sequoia-subscribe.js'; |
|
| 71 | + | </script> |
|
| 72 | + | ||
| 73 | + | <main> |
|
| 74 | + | <h1>My Post</h1> |
|
| 75 | + | <!-- Content --> |
|
| 76 | + | <sequoia-recommend document-uri="at://did:plc:example/app.bsky.feed.post/rkey" /> |
|
| 77 | + | </main> |
|
| 78 | + | ``` |
|
| 79 | + | ||
| 80 | + | ```astro [Astro] |
|
| 81 | + | <main> |
|
| 82 | + | <h1>My Post</h1> |
|
| 83 | + | <!-- Content --> |
|
| 84 | + | <sequoia-recommend document-uri="at://did:plc:example/app.bsky.feed.post/rkey" /> |
|
| 85 | + | <script> |
|
| 86 | + | import './components/sequoia-subscribe.js'; |
|
| 87 | + | </script> |
|
| 88 | + | </main> |
|
| 89 | + | ``` |
|
| 90 | + | ||
| 91 | + | ::: |
|
| 92 | + | ||
| 93 | + | ### TypeScript Support |
|
| 94 | + | ||
| 95 | + | If you're using TypeScript with React, add this type declaration to avoid JSX errors: |
|
| 96 | + | ||
| 97 | + | ```ts [custom-elements.d.ts] |
|
| 98 | + | declare namespace JSX { |
|
| 99 | + | interface IntrinsicElements { |
|
| 100 | + | 'sequoia-recommend': React.DetailedHTMLProps< |
|
| 101 | + | React.HTMLAttributes<HTMLElement> & { |
|
| 102 | + | 'document-uri'?: string; |
|
| 103 | + | 'callback-uri'?: string; |
|
| 104 | + | 'button-type'?: 'heart' | 'star' | 'thumbs-up'; |
|
| 105 | + | hide?: string; |
|
| 106 | + | }, |
|
| 107 | + | HTMLElement |
|
| 108 | + | >; |
|
| 109 | + | } |
|
| 110 | + | } |
|
| 111 | + | ``` |
|
| 112 | + | ||
| 113 | + | ### Vue Configuration |
|
| 114 | + | ||
| 115 | + | For Vue, you may need to configure the compiler to recognize custom elements: |
|
| 116 | + | ||
| 117 | + | ```ts [vite.config.ts] |
|
| 118 | + | export default defineConfig({ |
|
| 119 | + | plugins: [ |
|
| 120 | + | vue({ |
|
| 121 | + | template: { |
|
| 122 | + | compilerOptions: { |
|
| 123 | + | isCustomElement: (tag) => tag === 'sequoia-recommend' |
|
| 124 | + | } |
|
| 125 | + | } |
|
| 126 | + | }) |
|
| 127 | + | ] |
|
| 128 | + | }); |
|
| 129 | + | ``` |
|
| 130 | + | ||
| 131 | + | ## Configuration |
|
| 132 | + | ||
| 133 | + | The recommend web component has several configuration options available. |
|
| 134 | + | ||
| 135 | + | ### Attributes |
|
| 136 | + | ||
| 137 | + | The `<sequoia-recommend>` component accepts the following attributes: |
|
| 138 | + | ||
| 139 | + | | Attribute | Type | Default | Description | |
|
| 140 | + | |-----------|------|---------|-------------| |
|
| 141 | + | | `document-uri` | `string` | - | AT Protocol URI for the document to recommend. Optional if a `<link rel="site.standard.document">` tag exists in the page `<head>`. | |
|
| 142 | + | | `callback-uri` | `string` | `https://sequoia.pub/recommend` | Redirect URI used for the OAuth authentication flow. | |
|
| 143 | + | | `button-type` | `string` | `heart` | Icon style for the button. Accepted values: `heart`, `star`, `thumbs-up`. | |
|
| 144 | + | | `hide` | `string` | - | Set to `"auto"` to hide the component if no document URI is detected. | |
|
| 145 | + | ||
| 146 | + | #### Button Types |
|
| 147 | + | ||
| 148 | + | The `button-type` attribute controls which icon the button uses. Icons are outlined when not yet recommended and filled once recommended. |
|
| 149 | + | ||
| 150 | + | | Type | Icon | Aria Label | |
|
| 151 | + | |------|------|-----------| |
|
| 152 | + | | `heart` | Heart | Recommend / Unrecommend | |
|
| 153 | + | | `star` | Star | Recommend / Unrecommend | |
|
| 154 | + | | `thumbs-up` | Thumbs up | Recommend / Unrecommend | |
|
| 155 | + | ||
| 156 | + | ```html |
|
| 157 | + | <!-- Default heart icon --> |
|
| 158 | + | <sequoia-recommend></sequoia-recommend> |
|
| 159 | + | ||
| 160 | + | <!-- Star icon --> |
|
| 161 | + | <sequoia-recommend button-type="star"></sequoia-recommend> |
|
| 162 | + | ||
| 163 | + | <!-- Thumbs-up icon --> |
|
| 164 | + | <sequoia-recommend button-type="thumbs-up"></sequoia-recommend> |
|
| 165 | + | ||
| 166 | + | <!-- Explicit document URI --> |
|
| 167 | + | <sequoia-recommend |
|
| 168 | + | document-uri="at://did:plc:example/app.bsky.feed.post/rkey"> |
|
| 169 | + | </sequoia-recommend> |
|
| 170 | + | ``` |
|
| 171 | + | ||
| 172 | + | #### Resolving the Document URI |
|
| 173 | + | ||
| 174 | + | If `document-uri` is not set on the element, the component looks for a `<link>` tag in the page `<head>`: |
|
| 175 | + | ||
| 176 | + | ```html |
|
| 177 | + | <link rel="site.standard.document" href="at://did:plc:example/app.bsky.feed.post/rkey" /> |
|
| 178 | + | ``` |
|
| 179 | + | ||
| 180 | + | This lets you set the document URI once per page without having to pass it to every component instance. |
|
| 181 | + | ||
| 182 | + | ### Events |
|
| 183 | + | ||
| 184 | + | The component dispatches custom events you can listen to: |
|
| 185 | + | ||
| 186 | + | | Event | Description | Detail | |
|
| 187 | + | |-------|-------------|--------| |
|
| 188 | + | | `sequoia-recommended` | Fired when the recommendation is created successfully. | `{ documentUri: string, recordUri: string }` | |
|
| 189 | + | | `sequoia-recommend-error` | Fired when the recommendation fails. | `{ message: string }` | |
|
| 190 | + | ||
| 191 | + | ```js |
|
| 192 | + | const btn = document.querySelector('sequoia-recommend'); |
|
| 193 | + | ||
| 194 | + | btn.addEventListener('sequoia-recommended', (e) => { |
|
| 195 | + | console.log('Recommended!', e.detail.recordUri); |
|
| 196 | + | }); |
|
| 197 | + | ||
| 198 | + | btn.addEventListener('sequoia-recommend-error', (e) => { |
|
| 199 | + | console.error('Recommendation failed:', e.detail.message); |
|
| 200 | + | }); |
|
| 201 | + | ``` |
|
| 202 | + | ||
| 203 | + | ### Styling |
|
| 204 | + | ||
| 205 | + | The component uses the same CSS custom properties as `<sequoia-subscribe>` for theming, plus a `part="button"` attribute for direct CSS targeting: |
|
| 206 | + | ||
| 207 | + | | CSS Property | Default | Description | |
|
| 208 | + | |--------------|---------|-------------| |
|
| 209 | + | | `--sequoia-fg-color` | `#1f2937` | Text color | |
|
| 210 | + | | `--sequoia-bg-color` | `#ffffff` | Background color | |
|
| 211 | + | | `--sequoia-border-color` | `#e5e7eb` | Border color | |
|
| 212 | + | | `--sequoia-accent-color` | `#2563eb` | Button background color | |
|
| 213 | + | | `--sequoia-secondary-color` | `#6b7280` | Secondary text color | |
|
| 214 | + | | `--sequoia-border-radius` | `8px` | Border radius for the button | |
|
| 215 | + | | `--sequoia-icon-display` | `inline-block` | Set to `none` to hide the button icon | |
|
| 216 | + | ||
| 217 | + | ### Example: Match Site Theme |
|
| 218 | + | ||
| 219 | + | ```css |
|
| 220 | + | :root { |
|
| 221 | + | --sequoia-accent-color: #3A5A40; |
|
| 222 | + | --sequoia-border-radius: 6px; |
|
| 223 | + | --sequoia-bg-color: #F5F3EF; |
|
| 224 | + | --sequoia-fg-color: #2C2C2C; |
|
| 225 | + | --sequoia-border-color: #D5D1C8; |
|
| 226 | + | --sequoia-secondary-color: #8B7355; |
|
| 227 | + | } |
|
| 228 | + | ``` |
|
| 229 | + | ||
| 230 | + | ### Using Both Components Together |
|
| 231 | + | ||
| 232 | + | Because `sequoia-subscribe.js` only needs to be imported once, you can use both components on the same page with a single script tag: |
|
| 233 | + | ||
| 234 | + | ```html |
|
| 235 | + | <sequoia-subscribe></sequoia-subscribe> |
|
| 236 | + | <sequoia-recommend></sequoia-recommend> |
|
| 237 | + | <script type="module" src="./src/components/sequoia-subscribe.js"></script> |
|
| 238 | + | ``` |
| 12 | 12 | ||
| 13 | 13 | The component will look for your publication AT URI from your site's `/.well-known/site.standard.publication` endpoint automatically, so no additional configuration is required for most setups. |
|
| 14 | 14 | ||
| 15 | + | :::tip |
|
| 16 | + | `sequoia-subscribe.js` registers both the `<sequoia-subscribe>` and `<sequoia-recommend>` custom elements. You only need to import it once even if you use both components on the same page. |
|
| 17 | + | ::: |
|
| 18 | + | ||
| 15 | 19 | ## Usage |
|
| 16 | 20 | ||
| 17 | 21 | Since `sequoia-subscribe` is a standard Web Component, it works with any framework. Choose your setup below: |
| 2 | 2 | import { cors } from "hono/cors"; |
|
| 3 | 3 | import auth from "./routes/auth"; |
|
| 4 | 4 | import subscribe from "./routes/subscribe"; |
|
| 5 | + | import recommend from "./routes/recommend"; |
|
| 5 | 6 | import "./lib/path-redirect"; |
|
| 6 | 7 | ||
| 7 | 8 | type Bindings = { |
|
| 29 | 30 | ||
| 30 | 31 | app.route("/oauth", auth); |
|
| 31 | 32 | app.route("/subscribe", subscribe); |
|
| 33 | + | ||
| 34 | + | app.use( |
|
| 35 | + | "/recommend", |
|
| 36 | + | cors({ |
|
| 37 | + | origin: (origin) => origin, |
|
| 38 | + | credentials: true, |
|
| 39 | + | }), |
|
| 40 | + | ); |
|
| 41 | + | app.use( |
|
| 42 | + | "/recommend/*", |
|
| 43 | + | cors({ |
|
| 44 | + | origin: (origin) => origin, |
|
| 45 | + | credentials: true, |
|
| 46 | + | }), |
|
| 47 | + | ); |
|
| 48 | + | app.route("/recommend", recommend); |
|
| 32 | 49 | ||
| 33 | 50 | app.get("/api/health", (c) => { |
|
| 34 | 51 | return c.json({ status: "ok" }); |
|
| 4 | 4 | import { createStateStore, createSessionStore } from "./kv-stores"; |
|
| 5 | 5 | ||
| 6 | 6 | export const OAUTH_SCOPE = |
|
| 7 | - | "atproto repo:site.standard.graph.subscription?action=create&action=delete"; |
|
| 7 | + | "atproto repo:site.standard.graph.recommend?action=create&action=delete repo:site.standard.graph.subscription?action=create&action=delete"; |
|
| 8 | 8 | ||
| 9 | 9 | export function createOAuthClient(kv: KVNamespace, clientUrl: string) { |
|
| 10 | 10 | const clientId = `${clientUrl}/oauth/client-metadata.json`; |
|
| 22 | 22 | redirect_uris: [redirectUri], |
|
| 23 | 23 | grant_types: ["authorization_code", "refresh_token"], |
|
| 24 | 24 | response_types: ["code"], |
|
| 25 | - | scope: "atproto repo:site.standard.graph.subscription?action=create", |
|
| 25 | + | scope: OAUTH_SCOPE, |
|
| 26 | 26 | token_endpoint_auth_method: "none", |
|
| 27 | 27 | application_type: "web", |
|
| 28 | 28 | dpop_bound_access_tokens: true, |
|
| 1 | + | import type { Agent } from "@atproto/api"; |
|
| 2 | + | ||
| 3 | + | export const REDIRECT_DELAY_SECONDS = 5; |
|
| 4 | + | ||
| 5 | + | // ============================================================================ |
|
| 6 | + | // Helpers |
|
| 7 | + | // ============================================================================ |
|
| 8 | + | ||
| 9 | + | export function withReturnToParam( |
|
| 10 | + | returnTo: string | undefined, |
|
| 11 | + | key: string, |
|
| 12 | + | value: string, |
|
| 13 | + | ): string | undefined { |
|
| 14 | + | if (!returnTo) return undefined; |
|
| 15 | + | try { |
|
| 16 | + | const url = new URL(returnTo); |
|
| 17 | + | url.searchParams.set(key, value); |
|
| 18 | + | return url.toString(); |
|
| 19 | + | } catch { |
|
| 20 | + | return returnTo; |
|
| 21 | + | } |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | /** |
|
| 25 | + | * Scan a repo for a record in `collection` where `record[field] === uri`. |
|
| 26 | + | * Returns the record AT-URI, or null if not found. |
|
| 27 | + | */ |
|
| 28 | + | export async function findExistingRecord( |
|
| 29 | + | agent: Agent, |
|
| 30 | + | did: string, |
|
| 31 | + | collection: string, |
|
| 32 | + | field: string, |
|
| 33 | + | uri: string, |
|
| 34 | + | ): Promise<string | null> { |
|
| 35 | + | let cursor: string | undefined; |
|
| 36 | + | ||
| 37 | + | do { |
|
| 38 | + | const result = await agent.com.atproto.repo.listRecords({ |
|
| 39 | + | repo: did, |
|
| 40 | + | collection, |
|
| 41 | + | limit: 100, |
|
| 42 | + | cursor, |
|
| 43 | + | }); |
|
| 44 | + | ||
| 45 | + | for (const record of result.data.records) { |
|
| 46 | + | const value = record.value as Record<string, unknown>; |
|
| 47 | + | if (value[field] === uri) { |
|
| 48 | + | return record.uri; |
|
| 49 | + | } |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | cursor = result.data.cursor; |
|
| 53 | + | } while (cursor); |
|
| 54 | + | ||
| 55 | + | return null; |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | // ============================================================================ |
|
| 59 | + | // HTML rendering |
|
| 60 | + | // ============================================================================ |
|
| 61 | + | ||
| 62 | + | export function renderHandleForm( |
|
| 63 | + | params: { |
|
| 64 | + | resourceUri: string; |
|
| 65 | + | resourceField: string; |
|
| 66 | + | loginPath: string; |
|
| 67 | + | title: string; |
|
| 68 | + | description: string; |
|
| 69 | + | buttonLabel: string; |
|
| 70 | + | returnTo?: string; |
|
| 71 | + | error?: string; |
|
| 72 | + | action?: string; |
|
| 73 | + | }, |
|
| 74 | + | styleHref: string, |
|
| 75 | + | ): string { |
|
| 76 | + | const { |
|
| 77 | + | resourceUri, |
|
| 78 | + | resourceField, |
|
| 79 | + | loginPath, |
|
| 80 | + | title, |
|
| 81 | + | description, |
|
| 82 | + | buttonLabel, |
|
| 83 | + | returnTo, |
|
| 84 | + | error, |
|
| 85 | + | action, |
|
| 86 | + | } = params; |
|
| 87 | + | ||
| 88 | + | const errorHtml = error |
|
| 89 | + | ? `<p class="vocs_Paragraph error">${escapeHtml(error)}</p>` |
|
| 90 | + | : ""; |
|
| 91 | + | const returnToInput = returnTo |
|
| 92 | + | ? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />` |
|
| 93 | + | : ""; |
|
| 94 | + | const actionInput = action |
|
| 95 | + | ? `<input type="hidden" name="action" value="${escapeHtml(action)}" />` |
|
| 96 | + | : ""; |
|
| 97 | + | ||
| 98 | + | return page( |
|
| 99 | + | ` |
|
| 100 | + | <h1 class="vocs_H1 vocs_Heading">${escapeHtml(title)}</h1> |
|
| 101 | + | <p class="vocs_Paragraph">${escapeHtml(description)}</p> |
|
| 102 | + | ${errorHtml} |
|
| 103 | + | <form method="POST" action="${escapeHtml(loginPath)}"> |
|
| 104 | + | <input type="hidden" name="${escapeHtml(resourceField)}" value="${escapeHtml(resourceUri)}" /> |
|
| 105 | + | ${returnToInput} |
|
| 106 | + | ${actionInput} |
|
| 107 | + | <input |
|
| 108 | + | type="text" |
|
| 109 | + | name="handle" |
|
| 110 | + | placeholder="you.bsky.social" |
|
| 111 | + | autocomplete="username" |
|
| 112 | + | required |
|
| 113 | + | autofocus |
|
| 114 | + | /> |
|
| 115 | + | <button type="submit" class="vocs_Button_button vocs_Button_button_accent">${escapeHtml(buttonLabel)}</button> |
|
| 116 | + | </form> |
|
| 117 | + | `, |
|
| 118 | + | styleHref, |
|
| 119 | + | ); |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | export function renderSuccess( |
|
| 123 | + | params: { |
|
| 124 | + | resourceUri: string; |
|
| 125 | + | resourceLabel: string; |
|
| 126 | + | recordUri: string | null; |
|
| 127 | + | heading: string; |
|
| 128 | + | msg: string; |
|
| 129 | + | returnTo?: string; |
|
| 130 | + | }, |
|
| 131 | + | styleHref: string, |
|
| 132 | + | ): string { |
|
| 133 | + | const { resourceUri, resourceLabel, recordUri, heading, msg, returnTo } = |
|
| 134 | + | params; |
|
| 135 | + | const escapedResourceUri = escapeHtml(resourceUri); |
|
| 136 | + | const escapedReturnTo = returnTo ? escapeHtml(returnTo) : ""; |
|
| 137 | + | ||
| 138 | + | const redirectHtml = returnTo |
|
| 139 | + | ? `<p class="vocs_Paragraph" id="redirect-msg">Redirecting to <a class="vocs_Anchor" href="${escapedReturnTo}">${escapedReturnTo}</a> in <span id="countdown">${REDIRECT_DELAY_SECONDS}</span>\u00a0seconds\u2026</p> |
|
| 140 | + | <script> |
|
| 141 | + | (function(){ |
|
| 142 | + | var secs = ${REDIRECT_DELAY_SECONDS}; |
|
| 143 | + | var el = document.getElementById('countdown'); |
|
| 144 | + | var iv = setInterval(function(){ |
|
| 145 | + | secs--; |
|
| 146 | + | if (el) el.textContent = String(secs); |
|
| 147 | + | if (secs <= 0) { clearInterval(iv); location.href = ${JSON.stringify(returnTo)}; } |
|
| 148 | + | }, 1000); |
|
| 149 | + | })(); |
|
| 150 | + | </script>` |
|
| 151 | + | : ""; |
|
| 152 | + | const headExtra = returnTo |
|
| 153 | + | ? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />` |
|
| 154 | + | : ""; |
|
| 155 | + | ||
| 156 | + | return page( |
|
| 157 | + | ` |
|
| 158 | + | <h1 class="vocs_H1 vocs_Heading">${escapeHtml(heading)}</h1> |
|
| 159 | + | <p class="vocs_Paragraph">${msg}</p> |
|
| 160 | + | ${redirectHtml} |
|
| 161 | + | <table class="vocs_Table" style="display:table;table-layout:fixed;width:100%;overflow:hidden;"> |
|
| 162 | + | <colgroup><col style="width:7rem;"><col></colgroup> |
|
| 163 | + | <tbody> |
|
| 164 | + | <tr class="vocs_TableRow"> |
|
| 165 | + | <td class="vocs_TableCell">${escapeHtml(resourceLabel)}</td> |
|
| 166 | + | <td class="vocs_TableCell" style="overflow:hidden;"> |
|
| 167 | + | <div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapedResourceUri}">${escapedResourceUri}</a></code></div> |
|
| 168 | + | </td> |
|
| 169 | + | </tr> |
|
| 170 | + | ${ |
|
| 171 | + | recordUri |
|
| 172 | + | ? `<tr class="vocs_TableRow"> |
|
| 173 | + | <td class="vocs_TableCell">Record</td> |
|
| 174 | + | <td class="vocs_TableCell" style="overflow:hidden;"> |
|
| 175 | + | <div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapeHtml(recordUri)}">${escapeHtml(recordUri)}</a></code></div> |
|
| 176 | + | </td> |
|
| 177 | + | </tr>` |
|
| 178 | + | : "" |
|
| 179 | + | } |
|
| 180 | + | </tbody> |
|
| 181 | + | </table> |
|
| 182 | + | `, |
|
| 183 | + | styleHref, |
|
| 184 | + | headExtra, |
|
| 185 | + | ); |
|
| 186 | + | } |
|
| 187 | + | ||
| 188 | + | export function renderError(message: string, styleHref: string): string { |
|
| 189 | + | return page( |
|
| 190 | + | `<h1 class="vocs_H1 vocs_Heading">Error</h1><p class="vocs_Paragraph error">${escapeHtml(message)}</p>`, |
|
| 191 | + | styleHref, |
|
| 192 | + | ); |
|
| 193 | + | } |
|
| 194 | + | ||
| 195 | + | export function page(body: string, styleHref: string, headExtra = ""): string { |
|
| 196 | + | return `<!DOCTYPE html> |
|
| 197 | + | <html lang="en"> |
|
| 198 | + | <head> |
|
| 199 | + | <meta charset="UTF-8" /> |
|
| 200 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 201 | + | <title>Sequoia</title> |
|
| 202 | + | <link rel="stylesheet" href="${styleHref}" /> |
|
| 203 | + | <script>if(window.matchMedia('(prefers-color-scheme: dark)').matches)document.documentElement.classList.add('dark')</script> |
|
| 204 | + | ${headExtra} |
|
| 205 | + | <style> |
|
| 206 | + | .page-container { |
|
| 207 | + | max-width: calc(var(--vocs-content_width, 480px) / 1.6); |
|
| 208 | + | margin: 4rem auto; |
|
| 209 | + | padding: 0 var(--vocs-space_20, 1.25rem); |
|
| 210 | + | } |
|
| 211 | + | .vocs_Heading { margin-bottom: var(--vocs-space_12, .75rem); } |
|
| 212 | + | .vocs_Paragraph { margin-bottom: var(--vocs-space_16, 1rem); } |
|
| 213 | + | input[type="text"] { |
|
| 214 | + | padding: var(--vocs-space_8, .5rem) var(--vocs-space_12, .75rem); |
|
| 215 | + | border: 1px solid var(--vocs-color_border, #D5D1C8); |
|
| 216 | + | border-radius: var(--vocs-borderRadius_6, 6px); |
|
| 217 | + | margin-bottom: var(--vocs-space_20, 1.25rem); |
|
| 218 | + | min-width: 30vh; |
|
| 219 | + | width: 100%; |
|
| 220 | + | font-size: var(--vocs-fontSize_16, 1rem); |
|
| 221 | + | font-family: inherit; |
|
| 222 | + | background: var(--vocs-color_background, #F5F3EF); |
|
| 223 | + | color: var(--vocs-color_text, #2C2C2C); |
|
| 224 | + | } |
|
| 225 | + | input[type="text"]:focus { |
|
| 226 | + | border-color: var(--vocs-color_borderAccent, #3A5A40); |
|
| 227 | + | outline: 2px solid var(--vocs-color_borderAccent, #3A5A40); |
|
| 228 | + | outline-offset: 2px; |
|
| 229 | + | } |
|
| 230 | + | .error { color: var(--vocs-color_dangerText, #8B3A3A); } |
|
| 231 | + | </style> |
|
| 232 | + | </head> |
|
| 233 | + | <body> |
|
| 234 | + | <div class="page-container"> |
|
| 235 | + | ${body} |
|
| 236 | + | </div> |
|
| 237 | + | </body> |
|
| 238 | + | </html>`; |
|
| 239 | + | } |
|
| 240 | + | ||
| 241 | + | export function escapeHtml(text: string): string { |
|
| 242 | + | return text |
|
| 243 | + | .replace(/&/g, "&") |
|
| 244 | + | .replace(/</g, "<") |
|
| 245 | + | .replace(/>/g, ">") |
|
| 246 | + | .replace(/"/g, """); |
|
| 247 | + | } |
| 1 | + | import { Agent } from "@atproto/api"; |
|
| 2 | + | import { Hono } from "hono"; |
|
| 3 | + | import { createOAuthClient } from "../lib/oauth-client"; |
|
| 4 | + | import { getSessionDid, setReturnToCookie } from "../lib/session"; |
|
| 5 | + | import { |
|
| 6 | + | findExistingRecord, |
|
| 7 | + | renderError, |
|
| 8 | + | renderHandleForm, |
|
| 9 | + | renderSuccess, |
|
| 10 | + | withReturnToParam, |
|
| 11 | + | } from "./lib"; |
|
| 12 | + | ||
| 13 | + | interface Env { |
|
| 14 | + | ASSETS: Fetcher; |
|
| 15 | + | SEQUOIA_SESSIONS: KVNamespace; |
|
| 16 | + | CLIENT_URL: string; |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | // Cache the vocs-generated stylesheet href across requests (changes on rebuild). |
|
| 20 | + | let _vocsStyleHref: string | null = null; |
|
| 21 | + | ||
| 22 | + | async function getVocsStyleHref( |
|
| 23 | + | assets: Fetcher, |
|
| 24 | + | baseUrl: string, |
|
| 25 | + | ): Promise<string> { |
|
| 26 | + | if (_vocsStyleHref) return _vocsStyleHref; |
|
| 27 | + | try { |
|
| 28 | + | const indexUrl = new URL("/", baseUrl).toString(); |
|
| 29 | + | const res = await assets.fetch(indexUrl); |
|
| 30 | + | const html = await res.text(); |
|
| 31 | + | const match = html.match(/<link[^>]+href="(\/assets\/style[^"]+\.css)"/); |
|
| 32 | + | if (match?.[1]) { |
|
| 33 | + | _vocsStyleHref = match[1]; |
|
| 34 | + | return match[1]; |
|
| 35 | + | } |
|
| 36 | + | } catch { |
|
| 37 | + | // Fall back to the custom stylesheet which at least provides --sequoia-* vars |
|
| 38 | + | } |
|
| 39 | + | return "/styles.css"; |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | const recommend = new Hono<{ Bindings: Env }>(); |
|
| 43 | + | ||
| 44 | + | const COLLECTION = "site.standard.graph.recommend"; |
|
| 45 | + | ||
| 46 | + | // ============================================================================ |
|
| 47 | + | // POST /recommend |
|
| 48 | + | // ============================================================================ |
|
| 49 | + | ||
| 50 | + | recommend.post("/", async (c) => { |
|
| 51 | + | let documentUri: string; |
|
| 52 | + | try { |
|
| 53 | + | const body = await c.req.json<{ documentUri?: string }>(); |
|
| 54 | + | documentUri = body.documentUri ?? ""; |
|
| 55 | + | } catch { |
|
| 56 | + | return c.json({ error: "Invalid JSON body" }, 400); |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | if (!documentUri || !documentUri.startsWith("at://")) { |
|
| 60 | + | return c.json({ error: "Missing or invalid documentUri" }, 400); |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | const did = getSessionDid(c); |
|
| 64 | + | if (!did) { |
|
| 65 | + | const subscribeUrl = `${c.env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}`; |
|
| 66 | + | return c.json({ authenticated: false, subscribeUrl }, 401); |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | try { |
|
| 70 | + | const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); |
|
| 71 | + | const session = await client.restore(did); |
|
| 72 | + | const agent = new Agent(session); |
|
| 73 | + | ||
| 74 | + | const existingUri = await findExistingRecord( |
|
| 75 | + | agent, |
|
| 76 | + | did, |
|
| 77 | + | COLLECTION, |
|
| 78 | + | "document", |
|
| 79 | + | documentUri, |
|
| 80 | + | ); |
|
| 81 | + | if (existingUri) { |
|
| 82 | + | return c.json({ |
|
| 83 | + | recommended: true, |
|
| 84 | + | existing: true, |
|
| 85 | + | recordUri: existingUri, |
|
| 86 | + | }); |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | const result = await agent.com.atproto.repo.createRecord({ |
|
| 90 | + | repo: did, |
|
| 91 | + | collection: COLLECTION, |
|
| 92 | + | record: { |
|
| 93 | + | $type: COLLECTION, |
|
| 94 | + | document: documentUri, |
|
| 95 | + | createdAt: new Date().toISOString(), |
|
| 96 | + | }, |
|
| 97 | + | }); |
|
| 98 | + | ||
| 99 | + | return c.json({ |
|
| 100 | + | recommended: true, |
|
| 101 | + | existing: false, |
|
| 102 | + | recordUri: result.data.uri, |
|
| 103 | + | }); |
|
| 104 | + | } catch (error) { |
|
| 105 | + | console.error("Recommend POST error:", error); |
|
| 106 | + | const subscribeUrl = `${c.env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}`; |
|
| 107 | + | return c.json({ authenticated: false, subscribeUrl }, 401); |
|
| 108 | + | } |
|
| 109 | + | }); |
|
| 110 | + | ||
| 111 | + | // ============================================================================ |
|
| 112 | + | // GET /recommend?documentUri=at://... |
|
| 113 | + | // |
|
| 114 | + | // Full-page OAuth + recommendation flow. Unauthenticated users land here after |
|
| 115 | + | // the component redirects them, and authenticated users land here after the |
|
| 116 | + | // OAuth callback (via the login_return_to cookie set in POST /recommend/login). |
|
| 117 | + | // ============================================================================ |
|
| 118 | + | ||
| 119 | + | recommend.get("/", async (c) => { |
|
| 120 | + | const documentUri = c.req.query("documentUri"); |
|
| 121 | + | const action = c.req.query("action"); |
|
| 122 | + | const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); |
|
| 123 | + | ||
| 124 | + | if (action && action !== "remove") { |
|
| 125 | + | return c.html(renderError(`Unsupported action: ${action}`, styleHref), 400); |
|
| 126 | + | } |
|
| 127 | + | ||
| 128 | + | if (!documentUri || !documentUri.startsWith("at://")) { |
|
| 129 | + | return c.html( |
|
| 130 | + | renderError("Missing or invalid document URI.", styleHref), |
|
| 131 | + | 400, |
|
| 132 | + | ); |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | // Prefer an explicit returnTo query param (survives the OAuth round-trip); |
|
| 136 | + | // fall back to the Referer header on the first visit, ignoring self-referrals. |
|
| 137 | + | const referer = c.req.header("referer"); |
|
| 138 | + | const returnTo = |
|
| 139 | + | c.req.query("returnTo") ?? |
|
| 140 | + | (referer && !referer.includes("/recommend") ? referer : undefined); |
|
| 141 | + | ||
| 142 | + | const did = getSessionDid(c); |
|
| 143 | + | if (!did) { |
|
| 144 | + | return c.html( |
|
| 145 | + | renderHandleForm( |
|
| 146 | + | { |
|
| 147 | + | resourceUri: documentUri, |
|
| 148 | + | resourceField: "documentUri", |
|
| 149 | + | loginPath: "/recommend/login", |
|
| 150 | + | title: "Recommend on Sequoia", |
|
| 151 | + | description: "Enter your Bluesky handle to recommend this document.", |
|
| 152 | + | buttonLabel: "Continue on Bluesky", |
|
| 153 | + | returnTo, |
|
| 154 | + | action, |
|
| 155 | + | }, |
|
| 156 | + | styleHref, |
|
| 157 | + | ), |
|
| 158 | + | ); |
|
| 159 | + | } |
|
| 160 | + | ||
| 161 | + | try { |
|
| 162 | + | const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); |
|
| 163 | + | const session = await client.restore(did); |
|
| 164 | + | const agent = new Agent(session); |
|
| 165 | + | ||
| 166 | + | if (action === "remove") { |
|
| 167 | + | const existingUri = await findExistingRecord( |
|
| 168 | + | agent, |
|
| 169 | + | did, |
|
| 170 | + | COLLECTION, |
|
| 171 | + | "document", |
|
| 172 | + | documentUri, |
|
| 173 | + | ); |
|
| 174 | + | if (existingUri) { |
|
| 175 | + | const rkey = existingUri.split("/").pop()!; |
|
| 176 | + | await agent.com.atproto.repo.deleteRecord({ |
|
| 177 | + | repo: did, |
|
| 178 | + | collection: COLLECTION, |
|
| 179 | + | rkey, |
|
| 180 | + | }); |
|
| 181 | + | } |
|
| 182 | + | ||
| 183 | + | return c.html( |
|
| 184 | + | renderSuccess( |
|
| 185 | + | { |
|
| 186 | + | resourceUri: documentUri, |
|
| 187 | + | resourceLabel: "Document", |
|
| 188 | + | recordUri: null, |
|
| 189 | + | heading: "Recommendation Removed \u2713", |
|
| 190 | + | msg: existingUri |
|
| 191 | + | ? "You've successfully removed your recommendation." |
|
| 192 | + | : "You hadn't recommended this document.", |
|
| 193 | + | returnTo: withReturnToParam(returnTo, "sequoia_did", did), |
|
| 194 | + | }, |
|
| 195 | + | styleHref, |
|
| 196 | + | ), |
|
| 197 | + | ); |
|
| 198 | + | } |
|
| 199 | + | ||
| 200 | + | const existingUri = await findExistingRecord( |
|
| 201 | + | agent, |
|
| 202 | + | did, |
|
| 203 | + | COLLECTION, |
|
| 204 | + | "document", |
|
| 205 | + | documentUri, |
|
| 206 | + | ); |
|
| 207 | + | const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did); |
|
| 208 | + | ||
| 209 | + | if (existingUri) { |
|
| 210 | + | return c.html( |
|
| 211 | + | renderSuccess( |
|
| 212 | + | { |
|
| 213 | + | resourceUri: documentUri, |
|
| 214 | + | resourceLabel: "Document", |
|
| 215 | + | recordUri: existingUri, |
|
| 216 | + | heading: "Recommended \u2713", |
|
| 217 | + | msg: "You've already recommended this document.", |
|
| 218 | + | returnTo: returnToWithDid, |
|
| 219 | + | }, |
|
| 220 | + | styleHref, |
|
| 221 | + | ), |
|
| 222 | + | ); |
|
| 223 | + | } |
|
| 224 | + | ||
| 225 | + | const result = await agent.com.atproto.repo.createRecord({ |
|
| 226 | + | repo: did, |
|
| 227 | + | collection: COLLECTION, |
|
| 228 | + | record: { |
|
| 229 | + | $type: COLLECTION, |
|
| 230 | + | document: documentUri, |
|
| 231 | + | createdAt: new Date().toISOString(), |
|
| 232 | + | }, |
|
| 233 | + | }); |
|
| 234 | + | ||
| 235 | + | return c.html( |
|
| 236 | + | renderSuccess( |
|
| 237 | + | { |
|
| 238 | + | resourceUri: documentUri, |
|
| 239 | + | resourceLabel: "Document", |
|
| 240 | + | recordUri: result.data.uri, |
|
| 241 | + | heading: "Recommended \u2713", |
|
| 242 | + | msg: "You've successfully recommended this document!", |
|
| 243 | + | returnTo: returnToWithDid, |
|
| 244 | + | }, |
|
| 245 | + | styleHref, |
|
| 246 | + | ), |
|
| 247 | + | ); |
|
| 248 | + | } catch (error) { |
|
| 249 | + | console.error("Recommend GET error:", error); |
|
| 250 | + | // Session expired - ask the user to sign in again |
|
| 251 | + | return c.html( |
|
| 252 | + | renderHandleForm( |
|
| 253 | + | { |
|
| 254 | + | resourceUri: documentUri, |
|
| 255 | + | resourceField: "documentUri", |
|
| 256 | + | loginPath: "/recommend/login", |
|
| 257 | + | title: "Recommend on Sequoia", |
|
| 258 | + | description: "Enter your Bluesky handle to recommend this document.", |
|
| 259 | + | buttonLabel: "Continue on Bluesky", |
|
| 260 | + | returnTo, |
|
| 261 | + | error: "Session expired. Please sign in again.", |
|
| 262 | + | action, |
|
| 263 | + | }, |
|
| 264 | + | styleHref, |
|
| 265 | + | ), |
|
| 266 | + | ); |
|
| 267 | + | } |
|
| 268 | + | }); |
|
| 269 | + | ||
| 270 | + | // ============================================================================ |
|
| 271 | + | // GET /recommend/check?documentUri=at://... |
|
| 272 | + | // |
|
| 273 | + | // JSON-only endpoint for the web component to check recommendation status. |
|
| 274 | + | // |
|
| 275 | + | // Responses: |
|
| 276 | + | // 200 { recommended: true, recordUri: string } |
|
| 277 | + | // 200 { recommended: false } |
|
| 278 | + | // 400 { error: string } |
|
| 279 | + | // 401 { authenticated: false } |
|
| 280 | + | // ============================================================================ |
|
| 281 | + | ||
| 282 | + | recommend.get("/check", async (c) => { |
|
| 283 | + | const documentUri = c.req.query("documentUri"); |
|
| 284 | + | ||
| 285 | + | if (!documentUri || !documentUri.startsWith("at://")) { |
|
| 286 | + | return c.json({ error: "Missing or invalid documentUri" }, 400); |
|
| 287 | + | } |
|
| 288 | + | ||
| 289 | + | // Prefer the server-side session DID; fall back to a client-provided DID |
|
| 290 | + | // (stored by the web component from a previous recommend flow). |
|
| 291 | + | const did = getSessionDid(c) ?? c.req.query("did") ?? null; |
|
| 292 | + | if (!did || !did.startsWith("did:")) { |
|
| 293 | + | return c.json({ authenticated: false }, 401); |
|
| 294 | + | } |
|
| 295 | + | ||
| 296 | + | try { |
|
| 297 | + | const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); |
|
| 298 | + | const session = await client.restore(did); |
|
| 299 | + | const agent = new Agent(session); |
|
| 300 | + | const recordUri = await findExistingRecord( |
|
| 301 | + | agent, |
|
| 302 | + | did, |
|
| 303 | + | COLLECTION, |
|
| 304 | + | "document", |
|
| 305 | + | documentUri, |
|
| 306 | + | ); |
|
| 307 | + | return recordUri |
|
| 308 | + | ? c.json({ recommended: true, recordUri }) |
|
| 309 | + | : c.json({ recommended: false }); |
|
| 310 | + | } catch { |
|
| 311 | + | return c.json({ authenticated: false }, 401); |
|
| 312 | + | } |
|
| 313 | + | }); |
|
| 314 | + | ||
| 315 | + | // ============================================================================ |
|
| 316 | + | // POST /recommend/login |
|
| 317 | + | // |
|
| 318 | + | // Handles the handle-entry form submission. Stores the return URL in a cookie |
|
| 319 | + | // so the OAuth callback in auth.ts can redirect back to /recommend after auth. |
|
| 320 | + | // ============================================================================ |
|
| 321 | + | ||
| 322 | + | recommend.post("/login", async (c) => { |
|
| 323 | + | const body = await c.req.parseBody(); |
|
| 324 | + | const handle = (body.handle as string | undefined)?.trim(); |
|
| 325 | + | const documentUri = body.documentUri as string | undefined; |
|
| 326 | + | const formReturnTo = (body.returnTo as string | undefined) || undefined; |
|
| 327 | + | const formAction = (body.action as string | undefined) || undefined; |
|
| 328 | + | ||
| 329 | + | if (!handle || !documentUri) { |
|
| 330 | + | const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); |
|
| 331 | + | return c.html( |
|
| 332 | + | renderError("Missing handle or document URI.", styleHref), |
|
| 333 | + | 400, |
|
| 334 | + | ); |
|
| 335 | + | } |
|
| 336 | + | ||
| 337 | + | const returnTo = |
|
| 338 | + | `${c.env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}` + |
|
| 339 | + | (formAction ? `&action=${encodeURIComponent(formAction)}` : "") + |
|
| 340 | + | (formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : ""); |
|
| 341 | + | setReturnToCookie(c, returnTo, c.env.CLIENT_URL); |
|
| 342 | + | ||
| 343 | + | return c.redirect( |
|
| 344 | + | `${c.env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`, |
|
| 345 | + | ); |
|
| 346 | + | }); |
|
| 347 | + | ||
| 348 | + | export default recommend; |
| 2 | 2 | import { Hono } from "hono"; |
|
| 3 | 3 | import { createOAuthClient } from "../lib/oauth-client"; |
|
| 4 | 4 | import { getSessionDid, setReturnToCookie } from "../lib/session"; |
|
| 5 | + | import { |
|
| 6 | + | findExistingRecord, |
|
| 7 | + | renderError, |
|
| 8 | + | renderHandleForm, |
|
| 9 | + | renderSuccess, |
|
| 10 | + | withReturnToParam, |
|
| 11 | + | } from "./lib"; |
|
| 5 | 12 | ||
| 6 | 13 | interface Env { |
|
| 7 | 14 | ASSETS: Fetcher; |
|
| 35 | 42 | const subscribe = new Hono<{ Bindings: Env }>(); |
|
| 36 | 43 | ||
| 37 | 44 | const COLLECTION = "site.standard.graph.subscription"; |
|
| 38 | - | const REDIRECT_DELAY_SECONDS = 5; |
|
| 39 | 45 | ||
| 40 | 46 | // ============================================================================ |
|
| 41 | 47 | // Helpers |
|
| 42 | 48 | // ============================================================================ |
|
| 43 | 49 | ||
| 44 | - | /** |
|
| 45 | - | * Append a query parameter to a returnTo URL, preserving existing params. |
|
| 46 | - | */ |
|
| 47 | - | function withReturnToParam( |
|
| 48 | - | returnTo: string | undefined, |
|
| 49 | - | key: string, |
|
| 50 | - | value: string, |
|
| 51 | - | ): string | undefined { |
|
| 52 | - | if (!returnTo) return undefined; |
|
| 53 | - | try { |
|
| 54 | - | const url = new URL(returnTo); |
|
| 55 | - | url.searchParams.set(key, value); |
|
| 56 | - | return url.toString(); |
|
| 57 | - | } catch { |
|
| 58 | - | return returnTo; |
|
| 59 | - | } |
|
| 60 | - | } |
|
| 61 | - | ||
| 62 | - | /** |
|
| 63 | - | * Scan the user's repo for an existing site.standard.graph.subscription |
|
| 64 | - | * matching the given publication URI. Returns the record AT-URI if found. |
|
| 65 | - | */ |
|
| 66 | - | async function findExistingSubscription( |
|
| 67 | - | agent: Agent, |
|
| 68 | - | did: string, |
|
| 69 | - | publicationUri: string, |
|
| 70 | - | ): Promise<string | null> { |
|
| 71 | - | let cursor: string | undefined; |
|
| 72 | - | ||
| 73 | - | do { |
|
| 74 | - | const result = await agent.com.atproto.repo.listRecords({ |
|
| 75 | - | repo: did, |
|
| 76 | - | collection: COLLECTION, |
|
| 77 | - | limit: 100, |
|
| 78 | - | cursor, |
|
| 79 | - | }); |
|
| 80 | - | ||
| 81 | - | for (const record of result.data.records) { |
|
| 82 | - | const value = record.value as { publication?: string }; |
|
| 83 | - | if (value.publication === publicationUri) { |
|
| 84 | - | return record.uri; |
|
| 85 | - | } |
|
| 86 | - | } |
|
| 87 | - | ||
| 88 | - | cursor = result.data.cursor; |
|
| 89 | - | } while (cursor); |
|
| 90 | - | ||
| 91 | - | return null; |
|
| 92 | - | } |
|
| 93 | - | ||
| 94 | 50 | // ============================================================================ |
|
| 95 | 51 | // POST /subscribe |
|
| 96 | 52 | // |
|
| 127 | 83 | const session = await client.restore(did); |
|
| 128 | 84 | const agent = new Agent(session); |
|
| 129 | 85 | ||
| 130 | - | const existingUri = await findExistingSubscription( |
|
| 86 | + | const existingUri = await findExistingRecord( |
|
| 131 | 87 | agent, |
|
| 132 | 88 | did, |
|
| 89 | + | COLLECTION, |
|
| 90 | + | "publication", |
|
| 133 | 91 | publicationUri, |
|
| 134 | 92 | ); |
|
| 135 | 93 | if (existingUri) { |
|
| 196 | 154 | const did = getSessionDid(c); |
|
| 197 | 155 | if (!did) { |
|
| 198 | 156 | return c.html( |
|
| 199 | - | renderHandleForm(publicationUri, styleHref, returnTo, undefined, action), |
|
| 157 | + | renderHandleForm( |
|
| 158 | + | { |
|
| 159 | + | resourceUri: publicationUri, |
|
| 160 | + | resourceField: "publicationUri", |
|
| 161 | + | loginPath: "/subscribe/login", |
|
| 162 | + | title: "Subscribe on Sequoia", |
|
| 163 | + | description: |
|
| 164 | + | "Enter your Bluesky handle to subscribe to this publication.", |
|
| 165 | + | buttonLabel: "Continue on Bluesky", |
|
| 166 | + | returnTo, |
|
| 167 | + | action, |
|
| 168 | + | }, |
|
| 169 | + | styleHref, |
|
| 170 | + | ), |
|
| 200 | 171 | ); |
|
| 201 | 172 | } |
|
| 202 | 173 | ||
| 206 | 177 | const agent = new Agent(session); |
|
| 207 | 178 | ||
| 208 | 179 | if (action === "unsubscribe") { |
|
| 209 | - | const existingUri = await findExistingSubscription( |
|
| 180 | + | const existingUri = await findExistingRecord( |
|
| 210 | 181 | agent, |
|
| 211 | 182 | did, |
|
| 183 | + | COLLECTION, |
|
| 184 | + | "publication", |
|
| 212 | 185 | publicationUri, |
|
| 213 | 186 | ); |
|
| 214 | 187 | if (existingUri) { |
|
| 234 | 207 | ||
| 235 | 208 | return c.html( |
|
| 236 | 209 | renderSuccess( |
|
| 237 | - | publicationUri, |
|
| 238 | - | null, |
|
| 239 | - | "Unsubscribed ✓", |
|
| 240 | - | existingUri |
|
| 241 | - | ? "You've successfully unsubscribed!" |
|
| 242 | - | : "You weren't subscribed to this publication.", |
|
| 210 | + | { |
|
| 211 | + | resourceUri: publicationUri, |
|
| 212 | + | resourceLabel: "Publication", |
|
| 213 | + | recordUri: null, |
|
| 214 | + | heading: "Unsubscribed ✓", |
|
| 215 | + | msg: existingUri |
|
| 216 | + | ? "You've successfully unsubscribed!" |
|
| 217 | + | : "You weren't subscribed to this publication.", |
|
| 218 | + | returnTo: withReturnToParam( |
|
| 219 | + | cleanReturnTo, |
|
| 220 | + | "sequoia_unsubscribed", |
|
| 221 | + | "1", |
|
| 222 | + | ), |
|
| 223 | + | }, |
|
| 243 | 224 | styleHref, |
|
| 244 | - | withReturnToParam(cleanReturnTo, "sequoia_unsubscribed", "1"), |
|
| 245 | 225 | ), |
|
| 246 | 226 | ); |
|
| 247 | 227 | } |
|
| 248 | 228 | ||
| 249 | - | const existingUri = await findExistingSubscription( |
|
| 229 | + | const existingUri = await findExistingRecord( |
|
| 250 | 230 | agent, |
|
| 251 | 231 | did, |
|
| 232 | + | COLLECTION, |
|
| 233 | + | "publication", |
|
| 252 | 234 | publicationUri, |
|
| 253 | 235 | ); |
|
| 254 | 236 | const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did); |
|
| 256 | 238 | if (existingUri) { |
|
| 257 | 239 | return c.html( |
|
| 258 | 240 | renderSuccess( |
|
| 259 | - | publicationUri, |
|
| 260 | - | existingUri, |
|
| 261 | - | "Subscribed ✓", |
|
| 262 | - | "You're already subscribed to this publication.", |
|
| 241 | + | { |
|
| 242 | + | resourceUri: publicationUri, |
|
| 243 | + | resourceLabel: "Publication", |
|
| 244 | + | recordUri: existingUri, |
|
| 245 | + | heading: "Subscribed ✓", |
|
| 246 | + | msg: "You're already subscribed to this publication.", |
|
| 247 | + | returnTo: returnToWithDid, |
|
| 248 | + | }, |
|
| 263 | 249 | styleHref, |
|
| 264 | - | returnToWithDid, |
|
| 265 | 250 | ), |
|
| 266 | 251 | ); |
|
| 267 | 252 | } |
|
| 277 | 262 | ||
| 278 | 263 | return c.html( |
|
| 279 | 264 | renderSuccess( |
|
| 280 | - | publicationUri, |
|
| 281 | - | result.data.uri, |
|
| 282 | - | "Subscribed ✓", |
|
| 283 | - | "You've successfully subscribed!", |
|
| 265 | + | { |
|
| 266 | + | resourceUri: publicationUri, |
|
| 267 | + | resourceLabel: "Publication", |
|
| 268 | + | recordUri: result.data.uri, |
|
| 269 | + | heading: "Subscribed ✓", |
|
| 270 | + | msg: "You've successfully subscribed!", |
|
| 271 | + | returnTo: returnToWithDid, |
|
| 272 | + | }, |
|
| 284 | 273 | styleHref, |
|
| 285 | - | returnToWithDid, |
|
| 286 | 274 | ), |
|
| 287 | 275 | ); |
|
| 288 | 276 | } catch (error) { |
|
| 290 | 278 | // Session expired - ask the user to sign in again |
|
| 291 | 279 | return c.html( |
|
| 292 | 280 | renderHandleForm( |
|
| 293 | - | publicationUri, |
|
| 281 | + | { |
|
| 282 | + | resourceUri: publicationUri, |
|
| 283 | + | resourceField: "publicationUri", |
|
| 284 | + | loginPath: "/subscribe/login", |
|
| 285 | + | title: "Subscribe on Sequoia", |
|
| 286 | + | description: |
|
| 287 | + | "Enter your Bluesky handle to subscribe to this publication.", |
|
| 288 | + | buttonLabel: "Continue on Bluesky", |
|
| 289 | + | returnTo, |
|
| 290 | + | error: "Session expired. Please sign in again.", |
|
| 291 | + | action, |
|
| 292 | + | }, |
|
| 294 | 293 | styleHref, |
|
| 295 | - | returnTo, |
|
| 296 | - | "Session expired. Please sign in again.", |
|
| 297 | - | action, |
|
| 298 | 294 | ), |
|
| 299 | 295 | ); |
|
| 300 | 296 | } |
|
| 330 | 326 | const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); |
|
| 331 | 327 | const session = await client.restore(did); |
|
| 332 | 328 | const agent = new Agent(session); |
|
| 333 | - | const recordUri = await findExistingSubscription( |
|
| 329 | + | const recordUri = await findExistingRecord( |
|
| 334 | 330 | agent, |
|
| 335 | 331 | did, |
|
| 332 | + | COLLECTION, |
|
| 333 | + | "publication", |
|
| 336 | 334 | publicationUri, |
|
| 337 | 335 | ); |
|
| 338 | 336 | return recordUri |
|
| 375 | 373 | `${c.env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`, |
|
| 376 | 374 | ); |
|
| 377 | 375 | }); |
|
| 378 | - | ||
| 379 | - | // ============================================================================ |
|
| 380 | - | // HTML rendering |
|
| 381 | - | // ============================================================================ |
|
| 382 | - | ||
| 383 | - | function renderHandleForm( |
|
| 384 | - | publicationUri: string, |
|
| 385 | - | styleHref: string, |
|
| 386 | - | returnTo?: string, |
|
| 387 | - | error?: string, |
|
| 388 | - | action?: string, |
|
| 389 | - | ): string { |
|
| 390 | - | const errorHtml = error |
|
| 391 | - | ? `<p class="vocs_Paragraph error">${escapeHtml(error)}</p>` |
|
| 392 | - | : ""; |
|
| 393 | - | const returnToInput = returnTo |
|
| 394 | - | ? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />` |
|
| 395 | - | : ""; |
|
| 396 | - | const actionInput = action |
|
| 397 | - | ? `<input type="hidden" name="action" value="${escapeHtml(action)}" />` |
|
| 398 | - | : ""; |
|
| 399 | - | ||
| 400 | - | return page( |
|
| 401 | - | ` |
|
| 402 | - | <h1 class="vocs_H1 vocs_Heading">Subscribe on Bluesky</h1> |
|
| 403 | - | <p class="vocs_Paragraph">Enter your Bluesky handle to subscribe to this publication.</p> |
|
| 404 | - | ${errorHtml} |
|
| 405 | - | <form method="POST" action="/subscribe/login"> |
|
| 406 | - | <input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" /> |
|
| 407 | - | ${returnToInput} |
|
| 408 | - | ${actionInput} |
|
| 409 | - | <input |
|
| 410 | - | type="text" |
|
| 411 | - | name="handle" |
|
| 412 | - | placeholder="you.bsky.social" |
|
| 413 | - | autocomplete="username" |
|
| 414 | - | required |
|
| 415 | - | autofocus |
|
| 416 | - | /> |
|
| 417 | - | <button type="submit" class="vocs_Button_button vocs_Button_button_accent">Continue on Bluesky</button> |
|
| 418 | - | </form> |
|
| 419 | - | `, |
|
| 420 | - | styleHref, |
|
| 421 | - | ); |
|
| 422 | - | } |
|
| 423 | - | ||
| 424 | - | function renderSuccess( |
|
| 425 | - | publicationUri: string, |
|
| 426 | - | recordUri: string | null, |
|
| 427 | - | heading: string, |
|
| 428 | - | msg: string, |
|
| 429 | - | styleHref: string, |
|
| 430 | - | returnTo?: string, |
|
| 431 | - | ): string { |
|
| 432 | - | const escapedPublicationUri = escapeHtml(publicationUri); |
|
| 433 | - | const escapedReturnTo = returnTo ? escapeHtml(returnTo) : ""; |
|
| 434 | - | ||
| 435 | - | const redirectHtml = returnTo |
|
| 436 | - | ? `<p class="vocs_Paragraph" id="redirect-msg">Redirecting to <a class="vocs_Anchor" href="${escapedReturnTo}">${escapedReturnTo}</a> in <span id="countdown">${REDIRECT_DELAY_SECONDS}</span>\u00a0seconds\u2026</p> |
|
| 437 | - | <script> |
|
| 438 | - | (function(){ |
|
| 439 | - | var secs = ${REDIRECT_DELAY_SECONDS}; |
|
| 440 | - | var el = document.getElementById('countdown'); |
|
| 441 | - | var iv = setInterval(function(){ |
|
| 442 | - | secs--; |
|
| 443 | - | if (el) el.textContent = String(secs); |
|
| 444 | - | if (secs <= 0) { clearInterval(iv); location.href = ${JSON.stringify(returnTo)}; } |
|
| 445 | - | }, 1000); |
|
| 446 | - | })(); |
|
| 447 | - | </script>` |
|
| 448 | - | : ""; |
|
| 449 | - | const headExtra = returnTo |
|
| 450 | - | ? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />` |
|
| 451 | - | : ""; |
|
| 452 | - | ||
| 453 | - | return page( |
|
| 454 | - | ` |
|
| 455 | - | <h1 class="vocs_H1 vocs_Heading">${escapeHtml(heading)}</h1> |
|
| 456 | - | <p class="vocs_Paragraph">${msg}</p> |
|
| 457 | - | ${redirectHtml} |
|
| 458 | - | <table class="vocs_Table" style="display:table;table-layout:fixed;width:100%;overflow:hidden;"> |
|
| 459 | - | <colgroup><col style="width:7rem;"><col></colgroup> |
|
| 460 | - | <tbody> |
|
| 461 | - | <tr class="vocs_TableRow"> |
|
| 462 | - | <td class="vocs_TableCell">Publication</td> |
|
| 463 | - | <td class="vocs_TableCell" style="overflow:hidden;"> |
|
| 464 | - | <div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapedPublicationUri}">${escapedPublicationUri}</a></code></div> |
|
| 465 | - | </td> |
|
| 466 | - | </tr> |
|
| 467 | - | ${ |
|
| 468 | - | recordUri |
|
| 469 | - | ? `<tr class="vocs_TableRow"> |
|
| 470 | - | <td class="vocs_TableCell">Record</td> |
|
| 471 | - | <td class="vocs_TableCell" style="overflow:hidden;"> |
|
| 472 | - | <div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapeHtml(recordUri)}">${escapeHtml(recordUri)}</a></code></div> |
|
| 473 | - | </td> |
|
| 474 | - | </tr>` |
|
| 475 | - | : "" |
|
| 476 | - | } |
|
| 477 | - | </tbody> |
|
| 478 | - | </table> |
|
| 479 | - | `, |
|
| 480 | - | styleHref, |
|
| 481 | - | headExtra, |
|
| 482 | - | ); |
|
| 483 | - | } |
|
| 484 | - | ||
| 485 | - | function renderError(message: string, styleHref: string): string { |
|
| 486 | - | return page( |
|
| 487 | - | `<h1 class="vocs_H1 vocs_Heading">Error</h1><p class="vocs_Paragraph error">${escapeHtml(message)}</p>`, |
|
| 488 | - | styleHref, |
|
| 489 | - | ); |
|
| 490 | - | } |
|
| 491 | - | ||
| 492 | - | function page(body: string, styleHref: string, headExtra = ""): string { |
|
| 493 | - | return `<!DOCTYPE html> |
|
| 494 | - | <html lang="en"> |
|
| 495 | - | <head> |
|
| 496 | - | <meta charset="UTF-8" /> |
|
| 497 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 498 | - | <title>Sequoia · Subscribe</title> |
|
| 499 | - | <link rel="stylesheet" href="${styleHref}" /> |
|
| 500 | - | <script>if(window.matchMedia('(prefers-color-scheme: dark)').matches)document.documentElement.classList.add('dark')</script> |
|
| 501 | - | ${headExtra} |
|
| 502 | - | <style> |
|
| 503 | - | .page-container { |
|
| 504 | - | max-width: calc(var(--vocs-content_width, 480px) / 1.6); |
|
| 505 | - | margin: 4rem auto; |
|
| 506 | - | padding: 0 var(--vocs-space_20, 1.25rem); |
|
| 507 | - | } |
|
| 508 | - | .vocs_Heading { margin-bottom: var(--vocs-space_12, .75rem); } |
|
| 509 | - | .vocs_Paragraph { margin-bottom: var(--vocs-space_16, 1rem); } |
|
| 510 | - | input[type="text"] { |
|
| 511 | - | padding: var(--vocs-space_8, .5rem) var(--vocs-space_12, .75rem); |
|
| 512 | - | border: 1px solid var(--vocs-color_border, #D5D1C8); |
|
| 513 | - | border-radius: var(--vocs-borderRadius_6, 6px); |
|
| 514 | - | margin-bottom: var(--vocs-space_20, 1.25rem); |
|
| 515 | - | min-width: 30vh; |
|
| 516 | - | width: 100%; |
|
| 517 | - | font-size: var(--vocs-fontSize_16, 1rem); |
|
| 518 | - | font-family: inherit; |
|
| 519 | - | background: var(--vocs-color_background, #F5F3EF); |
|
| 520 | - | color: var(--vocs-color_text, #2C2C2C); |
|
| 521 | - | } |
|
| 522 | - | input[type="text"]:focus { |
|
| 523 | - | border-color: var(--vocs-color_borderAccent, #3A5A40); |
|
| 524 | - | outline: 2px solid var(--vocs-color_borderAccent, #3A5A40); |
|
| 525 | - | outline-offset: 2px; |
|
| 526 | - | } |
|
| 527 | - | .error { color: var(--vocs-color_dangerText, #8B3A3A); } |
|
| 528 | - | </style> |
|
| 529 | - | </head> |
|
| 530 | - | <body> |
|
| 531 | - | <div class="page-container"> |
|
| 532 | - | ${body} |
|
| 533 | - | </div> |
|
| 534 | - | </body> |
|
| 535 | - | </html>`; |
|
| 536 | - | } |
|
| 537 | - | ||
| 538 | - | function escapeHtml(text: string): string { |
|
| 539 | - | return text |
|
| 540 | - | .replace(/&/g, "&") |
|
| 541 | - | .replace(/</g, "<") |
|
| 542 | - | .replace(/>/g, ">") |
|
| 543 | - | .replace(/"/g, """); |
|
| 544 | - | } |
|
| 545 | 376 | ||
| 546 | 377 | export default subscribe; |
|
| 34 | 34 | { text: "Setup", link: "/setup" }, |
|
| 35 | 35 | { text: "Publishing", link: "/publishing" }, |
|
| 36 | 36 | { text: "Comments", link: "/comments" }, |
|
| 37 | + | { text: "Recommend", link: "/recommend" }, |
|
| 37 | 38 | { text: "Subscribe", link: "/subscribe" }, |
|
| 38 | 39 | { text: "Verifying", link: "/verifying" }, |
|
| 39 | 40 | { text: "Workflows", link: "/workflows" }, |
| 8 | 8 | binding = "ASSETS" |
|
| 9 | 9 | not_found_handling = "single-page-application" |
|
| 10 | 10 | html_handling = "auto-trailing-slash" |
|
| 11 | - | run_worker_first = ["/api/*", "/oauth/*", "/subscribe", "/subscribe/*"] |
|
| 11 | + | run_worker_first = ["/api/*", "/oauth/*", "/recommend", "/recommend/*", "/subscribe", "/subscribe/*"] |
|
| 12 | 12 | ||
| 13 | 13 | [[kv_namespaces]] |
|
| 14 | 14 | binding = "SEQUOIA_SESSIONS" |
| 1 | 1 | /** |
|
| 2 | - | * Sequoia Subscribe - An AT Protocol-powered subscribe component |
|
| 3 | - | * |
|
| 4 | - | * A self-contained Web Component that lets users subscribe to a publication |
|
| 5 | - | * via the AT Protocol by creating a site.standard.graph.subscription record. |
|
| 6 | - | * |
|
| 7 | - | * Usage: |
|
| 8 | - | * <sequoia-subscribe></sequoia-subscribe> |
|
| 2 | + | * Sequoia Web Components — AT Protocol-powered engagement components |
|
| 9 | 3 | * |
|
| 10 | - | * The component resolves the publication AT URI from the host site's |
|
| 11 | - | * /.well-known/site.standard.publication endpoint. |
|
| 4 | + | * Self-contained Web Components for subscribing to publications and |
|
| 5 | + | * recommending documents via the AT Protocol. |
|
| 12 | 6 | * |
|
| 13 | - | * Attributes: |
|
| 14 | - | * - publication-uri: Override the publication AT URI (optional) |
|
| 15 | - | * - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/subscribe") |
|
| 16 | - | * - button-type: Branding style — "sequoia" (default), "bluesky", "blacksky", "atmosphere", or "plain" |
|
| 17 | - | * - label: Override the subscribe button label text |
|
| 18 | - | * - unsubscribe-label: Override the unsubscribe button label text |
|
| 19 | - | * - hide: Set to "auto" to hide if no publication URI is detected |
|
| 7 | + | * Both components share: |
|
| 8 | + | * - OAuth redirect flow via a hosted callback endpoint |
|
| 9 | + | * - DID caching in a cookie (primary) and localStorage (fallback) |
|
| 10 | + | * - A common visual style driven by CSS custom properties |
|
| 20 | 11 | * |
|
| 21 | - | * CSS Custom Properties: |
|
| 12 | + | * CSS Custom Properties (apply to both components): |
|
| 22 | 13 | * - --sequoia-fg-color: Text color (default: #1f2937) |
|
| 23 | 14 | * - --sequoia-bg-color: Background color (default: #ffffff) |
|
| 24 | 15 | * - --sequoia-border-color: Border color (default: #e5e7eb) |
|
| 26 | 17 | * - --sequoia-secondary-color: Secondary text color (default: #6b7280) |
|
| 27 | 18 | * - --sequoia-border-radius: Border radius (default: 8px) |
|
| 28 | 19 | * - --sequoia-icon-display: Icon display mode (default: inline-block) — set to "none" to hide |
|
| 29 | - | * |
|
| 30 | - | * Events: |
|
| 31 | - | * - sequoia-subscribed: Fired when the subscription is created successfully. |
|
| 32 | - | * detail: { publicationUri: string, recordUri: string } |
|
| 33 | - | * - sequoia-subscribe-error: Fired when the subscription fails. |
|
| 34 | - | * detail: { message: string } |
|
| 35 | 20 | */ |
|
| 36 | 21 | ||
| 37 | 22 | // ============================================================================ |
|
| 50 | 35 | box-sizing: border-box; |
|
| 51 | 36 | } |
|
| 52 | 37 | ||
| 53 | - | .sequoia-subscribe-button { |
|
| 38 | + | .sequoia-button { |
|
| 54 | 39 | display: inline-flex; |
|
| 55 | 40 | align-items: center; |
|
| 56 | 41 | gap: 0.375rem; |
|
| 67 | 52 | font-family: inherit; |
|
| 68 | 53 | } |
|
| 69 | 54 | ||
| 70 | - | .sequoia-subscribe-button:hover:not(:disabled) { |
|
| 55 | + | .sequoia-button:hover:not(:disabled) { |
|
| 71 | 56 | background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); |
|
| 72 | 57 | } |
|
| 73 | 58 | ||
| 74 | - | .sequoia-subscribe-button:disabled { |
|
| 59 | + | .sequoia-button:disabled { |
|
| 75 | 60 | opacity: 0.6; |
|
| 76 | 61 | cursor: not-allowed; |
|
| 77 | 62 | } |
|
| 78 | 63 | ||
| 79 | - | .sequoia-subscribe-button svg { |
|
| 64 | + | .sequoia-button svg { |
|
| 80 | 65 | display: var(--sequoia-icon-display, inline-block); |
|
| 81 | 66 | width: 1rem; |
|
| 82 | 67 | height: 1rem; |
|
| 149 | 134 | }; |
|
| 150 | 135 | ||
| 151 | 136 | // ============================================================================ |
|
| 137 | + | // Recommend Icon Configuration |
|
| 138 | + | // ============================================================================ |
|
| 139 | + | ||
| 140 | + | const HEART_PATH = |
|
| 141 | + | "M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"; |
|
| 142 | + | const HEART_ICON_OUTLINED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${HEART_PATH}"/></svg>`; |
|
| 143 | + | const HEART_ICON_FILLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="${HEART_PATH}"/></svg>`; |
|
| 144 | + | ||
| 145 | + | const STAR_PATH = |
|
| 146 | + | "M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"; |
|
| 147 | + | const STAR_ICON_OUTLINED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="${STAR_PATH}"/></svg>`; |
|
| 148 | + | const STAR_ICON_FILLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"><path d="${STAR_PATH}"/></svg>`; |
|
| 149 | + | ||
| 150 | + | const THUMBS_UP_RECT_PATH = "M1 21h4V9H1v12z"; |
|
| 151 | + | const THUMBS_UP_HAND_PATH = |
|
| 152 | + | "M23 10c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"; |
|
| 153 | + | const THUMBS_UP_ICON_OUTLINED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="${THUMBS_UP_RECT_PATH}" fill="currentColor"/><path d="${THUMBS_UP_HAND_PATH}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>`; |
|
| 154 | + | const THUMBS_UP_ICON_FILLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="${THUMBS_UP_RECT_PATH}"/><path d="${THUMBS_UP_HAND_PATH}"/></svg>`; |
|
| 155 | + | ||
| 156 | + | const RECOMMEND_ICON_TYPES = { |
|
| 157 | + | heart: { |
|
| 158 | + | icon: HEART_ICON_OUTLINED, |
|
| 159 | + | iconActioned: HEART_ICON_FILLED, |
|
| 160 | + | action: "Recommend", |
|
| 161 | + | unaction: "Unrecommend", |
|
| 162 | + | }, |
|
| 163 | + | star: { |
|
| 164 | + | icon: STAR_ICON_OUTLINED, |
|
| 165 | + | iconActioned: STAR_ICON_FILLED, |
|
| 166 | + | action: "Recommend", |
|
| 167 | + | unaction: "Unrecommend", |
|
| 168 | + | }, |
|
| 169 | + | "thumbs-up": { |
|
| 170 | + | icon: THUMBS_UP_ICON_OUTLINED, |
|
| 171 | + | iconActioned: THUMBS_UP_ICON_FILLED, |
|
| 172 | + | action: "Recommend", |
|
| 173 | + | unaction: "Unrecommend", |
|
| 174 | + | }, |
|
| 175 | + | }; |
|
| 176 | + | ||
| 177 | + | // ============================================================================ |
|
| 152 | 178 | // DID Storage |
|
| 153 | 179 | // ============================================================================ |
|
| 154 | 180 | ||
| 289 | 315 | // SSR-safe base class - use HTMLElement in browser, empty class in Node.js |
|
| 290 | 316 | const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; |
|
| 291 | 317 | ||
| 292 | - | class SequoiaSubscribe extends BaseElement { |
|
| 318 | + | /** |
|
| 319 | + | * Abstract base class shared by SequoiaSubscribe and SequoiaRecommend. |
|
| 320 | + | * Handles shadow DOM setup, state management, the OAuth redirect flow, |
|
| 321 | + | * DID storage, and button rendering. Subclasses implement template methods |
|
| 322 | + | * to provide resource-specific behaviour. |
|
| 323 | + | */ |
|
| 324 | + | class SequoiaActionBase extends BaseElement { |
|
| 293 | 325 | constructor() { |
|
| 294 | 326 | super(); |
|
| 295 | 327 | const shadow = this.attachShadow({ mode: "open" }); |
|
| 303 | 335 | wrapper.part = "container"; |
|
| 304 | 336 | ||
| 305 | 337 | this.wrapper = wrapper; |
|
| 306 | - | this.subscribed = false; |
|
| 338 | + | this.actioned = false; |
|
| 307 | 339 | this.state = { type: "idle" }; |
|
| 308 | 340 | this.abortController = null; |
|
| 309 | 341 | this.render(); |
|
| 310 | 342 | } |
|
| 311 | 343 | ||
| 312 | - | static get observedAttributes() { |
|
| 313 | - | return [ |
|
| 314 | - | "publication-uri", |
|
| 315 | - | "callback-uri", |
|
| 316 | - | "label", |
|
| 317 | - | "unsubscribe-label", |
|
| 318 | - | "button-type", |
|
| 319 | - | "hide", |
|
| 320 | - | ]; |
|
| 321 | - | } |
|
| 322 | - | ||
| 323 | - | connectedCallback() { |
|
| 324 | - | consumeReturnParams(); |
|
| 325 | - | this.checkPublication(); |
|
| 326 | - | } |
|
| 327 | - | ||
| 328 | 344 | disconnectedCallback() { |
|
| 329 | 345 | this.abortController?.abort(); |
|
| 330 | 346 | } |
|
| 331 | 347 | ||
| 332 | 348 | attributeChangedCallback() { |
|
| 333 | - | if (this.state.type === "error" || this.state.type === "no-publication") { |
|
| 349 | + | if (this.state.type === "error" || this.state.type === "no-resource") { |
|
| 334 | 350 | this.state = { type: "idle" }; |
|
| 335 | 351 | } |
|
| 336 | 352 | this.render(); |
|
| 337 | 353 | } |
|
| 338 | 354 | ||
| 339 | - | get publicationUri() { |
|
| 340 | - | return this.getAttribute("publication-uri") ?? null; |
|
| 355 | + | // ── Shared getters ─────────────────────────────────────────────────────── |
|
| 356 | + | ||
| 357 | + | get callbackUri() { |
|
| 358 | + | return this.getAttribute("callback-uri") ?? this.defaultCallbackUri; |
|
| 359 | + | } |
|
| 360 | + | ||
| 361 | + | get hide() { |
|
| 362 | + | return this.getAttribute("hide") === "auto"; |
|
| 363 | + | } |
|
| 364 | + | ||
| 365 | + | // ── Template methods (override in subclasses) ──────────────────────────── |
|
| 366 | + | ||
| 367 | + | /** @returns {string} Default callback URI when the attribute is absent */ |
|
| 368 | + | get defaultCallbackUri() { |
|
| 369 | + | return ""; |
|
| 370 | + | } |
|
| 371 | + | ||
| 372 | + | /** @returns {string} Query-parameter name for the resource URI */ |
|
| 373 | + | get resourceParam() { |
|
| 374 | + | return "resourceUri"; |
|
| 341 | 375 | } |
|
| 342 | 376 | ||
| 343 | - | get callbackUri() { |
|
| 344 | - | return this.getAttribute("callback-uri") ?? "https://sequoia.pub/subscribe"; |
|
| 377 | + | /** |
|
| 378 | + | * Value of the `action` query-parameter used in the unaction redirect. |
|
| 379 | + | * @returns {string} |
|
| 380 | + | */ |
|
| 381 | + | get unactionValue() { |
|
| 382 | + | return "unaction"; |
|
| 345 | 383 | } |
|
| 346 | 384 | ||
| 347 | - | get label() { |
|
| 348 | - | return this.getAttribute("label") ?? null; |
|
| 385 | + | /** @returns {string} Key in the /check response that signals the action was taken */ |
|
| 386 | + | get actionedKey() { |
|
| 387 | + | return "actioned"; |
|
| 349 | 388 | } |
|
| 350 | 389 | ||
| 351 | - | get unsubscribeLabel() { |
|
| 352 | - | return this.getAttribute("unsubscribe-label") ?? null; |
|
| 390 | + | /** @returns {string} CustomEvent name dispatched on success */ |
|
| 391 | + | get actionedEventName() { |
|
| 392 | + | return "sequoia-actioned"; |
|
| 353 | 393 | } |
|
| 354 | 394 | ||
| 355 | - | get buttonType() { |
|
| 356 | - | const val = this.getAttribute("button-type"); |
|
| 357 | - | return val && val in BUTTON_TYPES ? val : "sequoia"; |
|
| 395 | + | /** @returns {string} CustomEvent name dispatched on error */ |
|
| 396 | + | get errorEventName() { |
|
| 397 | + | return "sequoia-action-error"; |
|
| 398 | + | } |
|
| 399 | + | ||
| 400 | + | /** @returns {string} Fallback error message when the thrown value has no message */ |
|
| 401 | + | get defaultErrorMessage() { |
|
| 402 | + | return "Action failed"; |
|
| 358 | 403 | } |
|
| 359 | 404 | ||
| 360 | - | get hide() { |
|
| 361 | - | const hideAttr = this.getAttribute("hide"); |
|
| 362 | - | return hideAttr === "auto"; |
|
| 405 | + | /** @returns {string} SVG string for the button icon */ |
|
| 406 | + | getIcon() { |
|
| 407 | + | return ""; |
|
| 363 | 408 | } |
|
| 364 | 409 | ||
| 365 | - | async checkPublication() { |
|
| 366 | - | this.abortController?.abort(); |
|
| 367 | - | this.abortController = new AbortController(); |
|
| 410 | + | /** @returns {string} Accessible label for the button (defaults to the visible label) */ |
|
| 411 | + | getAriaLabel() { |
|
| 412 | + | return this.actioned |
|
| 413 | + | ? (this.getUnactionLabel?.() ?? this.getDefaultUnactionLabel?.() ?? "") |
|
| 414 | + | : (this.label ?? this.getDefaultActionLabel?.() ?? ""); |
|
| 415 | + | } |
|
| 368 | 416 | ||
| 369 | - | try { |
|
| 370 | - | const uri = this.publicationUri ?? (await fetchPublicationUri()); |
|
| 371 | - | this.checkSubscription(uri); |
|
| 372 | - | } catch { |
|
| 373 | - | this.state = { type: "no-publication" }; |
|
| 374 | - | this.render(); |
|
| 375 | - | } |
|
| 417 | + | /** |
|
| 418 | + | * Resolve the resource URI for this action. May perform async network calls. |
|
| 419 | + | * @returns {Promise<string>} |
|
| 420 | + | */ |
|
| 421 | + | async resolveResourceUri() { |
|
| 422 | + | throw new Error("resolveResourceUri() must be implemented by subclass"); |
|
| 376 | 423 | } |
|
| 377 | 424 | ||
| 378 | - | async checkSubscription(publicationUri) { |
|
| 425 | + | // ── Shared logic ───────────────────────────────────────────────────────── |
|
| 426 | + | ||
| 427 | + | /** |
|
| 428 | + | * Check whether the current user has already taken this action for the |
|
| 429 | + | * given resource URI. Updates this.actioned and re-renders on success. |
|
| 430 | + | * @param {string} resourceUri |
|
| 431 | + | */ |
|
| 432 | + | async checkStatusFor(resourceUri) { |
|
| 379 | 433 | try { |
|
| 380 | 434 | const checkUrl = new URL(`${this.callbackUri}/check`); |
|
| 381 | - | checkUrl.searchParams.set("publicationUri", publicationUri); |
|
| 435 | + | checkUrl.searchParams.set(this.resourceParam, resourceUri); |
|
| 382 | 436 | ||
| 383 | 437 | // Pass the stored DID so the server can check without a session cookie |
|
| 384 | 438 | const storedDid = getStoredSubscriberDid(); |
|
| 391 | 445 | }); |
|
| 392 | 446 | if (!res.ok) return; |
|
| 393 | 447 | const data = await res.json(); |
|
| 394 | - | if (data.subscribed) { |
|
| 395 | - | this.subscribed = true; |
|
| 448 | + | if (data[this.actionedKey]) { |
|
| 449 | + | this.actioned = true; |
|
| 396 | 450 | this.render(); |
|
| 397 | 451 | } |
|
| 398 | 452 | } catch { |
|
| 399 | - | // Ignore errors — show default subscribe button |
|
| 453 | + | // Ignore errors — show default action button |
|
| 400 | 454 | } |
|
| 401 | 455 | } |
|
| 402 | 456 | ||
| 405 | 459 | return; |
|
| 406 | 460 | } |
|
| 407 | 461 | ||
| 408 | - | // Unsubscribe: redirect to full-page unsubscribe flow |
|
| 409 | - | if (this.subscribed) { |
|
| 410 | - | const publicationUri = |
|
| 411 | - | this.publicationUri ?? (await fetchPublicationUri()); |
|
| 412 | - | window.location.href = `${this.callbackUri}?publicationUri=${encodeURIComponent(publicationUri)}&action=unsubscribe`; |
|
| 462 | + | // Unaction: redirect to the full-page unaction flow |
|
| 463 | + | if (this.actioned) { |
|
| 464 | + | const resourceUri = await this.resolveResourceUri(); |
|
| 465 | + | window.location.href = `${this.callbackUri}?${this.resourceParam}=${encodeURIComponent(resourceUri)}&action=${this.unactionValue}`; |
|
| 413 | 466 | return; |
|
| 414 | 467 | } |
|
| 415 | 468 | ||
| 417 | 470 | this.render(); |
|
| 418 | 471 | ||
| 419 | 472 | try { |
|
| 420 | - | const publicationUri = |
|
| 421 | - | this.publicationUri ?? (await fetchPublicationUri()); |
|
| 473 | + | const resourceUri = await this.resolveResourceUri(); |
|
| 422 | 474 | ||
| 423 | 475 | const response = await fetch(this.callbackUri, { |
|
| 424 | 476 | method: "POST", |
|
| 425 | 477 | headers: { "Content-Type": "application/json" }, |
|
| 426 | 478 | credentials: "include", |
|
| 427 | 479 | referrerPolicy: "no-referrer-when-downgrade", |
|
| 428 | - | body: JSON.stringify({ publicationUri }), |
|
| 480 | + | body: JSON.stringify({ [this.resourceParam]: resourceUri }), |
|
| 429 | 481 | }); |
|
| 430 | 482 | ||
| 431 | 483 | const data = await response.json(); |
|
| 432 | 484 | ||
| 433 | 485 | if (response.status === 401 && data.authenticated === false) { |
|
| 434 | - | // Redirect to the hosted subscribe page to complete OAuth, |
|
| 486 | + | // Redirect to the hosted action page to complete OAuth, |
|
| 435 | 487 | // passing the current page URL (without credentials) as returnTo. |
|
| 436 | - | const subscribeUrl = new URL(data.subscribeUrl); |
|
| 488 | + | const actionUrl = new URL(data.subscribeUrl); |
|
| 437 | 489 | const pageUrl = new URL(window.location.href); |
|
| 438 | 490 | pageUrl.username = ""; |
|
| 439 | 491 | pageUrl.password = ""; |
|
| 440 | - | subscribeUrl.searchParams.set("returnTo", pageUrl.toString()); |
|
| 441 | - | window.location.href = subscribeUrl.toString(); |
|
| 492 | + | actionUrl.searchParams.set("returnTo", pageUrl.toString()); |
|
| 493 | + | window.location.href = actionUrl.toString(); |
|
| 442 | 494 | return; |
|
| 443 | 495 | } |
|
| 444 | 496 | ||
| 456 | 508 | } |
|
| 457 | 509 | } |
|
| 458 | 510 | ||
| 459 | - | this.subscribed = true; |
|
| 511 | + | this.actioned = true; |
|
| 460 | 512 | this.state = { type: "idle" }; |
|
| 461 | 513 | this.render(); |
|
| 462 | 514 | ||
| 463 | 515 | this.dispatchEvent( |
|
| 464 | - | new CustomEvent("sequoia-subscribed", { |
|
| 516 | + | new CustomEvent(this.actionedEventName, { |
|
| 465 | 517 | bubbles: true, |
|
| 466 | 518 | composed: true, |
|
| 467 | - | detail: { publicationUri, recordUri }, |
|
| 519 | + | detail: { [this.resourceParam]: resourceUri, recordUri }, |
|
| 468 | 520 | }), |
|
| 469 | 521 | ); |
|
| 470 | 522 | } catch (error) { |
|
| 471 | 523 | if (this.state.type !== "loading") return; |
|
| 472 | 524 | ||
| 473 | 525 | const message = |
|
| 474 | - | error instanceof Error ? error.message : "Failed to subscribe"; |
|
| 526 | + | error instanceof Error ? error.message : this.defaultErrorMessage; |
|
| 475 | 527 | this.state = { type: "error", message }; |
|
| 476 | 528 | this.render(); |
|
| 477 | 529 | ||
| 478 | 530 | this.dispatchEvent( |
|
| 479 | - | new CustomEvent("sequoia-subscribe-error", { |
|
| 531 | + | new CustomEvent(this.errorEventName, { |
|
| 480 | 532 | bubbles: true, |
|
| 481 | 533 | composed: true, |
|
| 482 | 534 | detail: { message }, |
|
| 488 | 540 | render() { |
|
| 489 | 541 | const { type } = this.state; |
|
| 490 | 542 | ||
| 491 | - | if (type === "no-publication") { |
|
| 543 | + | if (type === "no-resource") { |
|
| 492 | 544 | if (this.hide) { |
|
| 493 | 545 | this.wrapper.innerHTML = ""; |
|
| 494 | 546 | this.wrapper.style.display = "none"; |
|
| 497 | 549 | } |
|
| 498 | 550 | ||
| 499 | 551 | const isLoading = type === "loading"; |
|
| 500 | - | const config = BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia; |
|
| 501 | - | ||
| 502 | 552 | const icon = isLoading |
|
| 503 | 553 | ? `<span class="sequoia-loading-spinner"></span>` |
|
| 504 | - | : config.icon; |
|
| 554 | + | : this.getIcon(); |
|
| 555 | + | ||
| 556 | + | const label = this.actioned |
|
| 557 | + | ? (this.getUnactionLabel?.() ?? this.getDefaultUnactionLabel?.() ?? "") |
|
| 558 | + | : (this.label ?? this.getDefaultActionLabel?.() ?? ""); |
|
| 505 | 559 | ||
| 506 | - | const label = this.subscribed |
|
| 507 | - | ? (this.unsubscribeLabel ?? config.unsubscribe) |
|
| 508 | - | : (this.label ?? config.subscribe); |
|
| 560 | + | const ariaLabel = this.getAriaLabel(); |
|
| 509 | 561 | ||
| 510 | 562 | const errorHtml = |
|
| 511 | 563 | type === "error" |
|
| 514 | 566 | ||
| 515 | 567 | this.wrapper.innerHTML = ` |
|
| 516 | 568 | <button |
|
| 517 | - | class="sequoia-subscribe-button" |
|
| 569 | + | class="sequoia-button" |
|
| 518 | 570 | type="button" |
|
| 519 | 571 | part="button" |
|
| 520 | 572 | ${isLoading ? "disabled" : ""} |
|
| 521 | - | aria-label="${label}" |
|
| 573 | + | aria-label="${ariaLabel}" |
|
| 522 | 574 | > |
|
| 523 | 575 | ${icon} |
|
| 524 | 576 | ${label} |
|
| 531 | 583 | } |
|
| 532 | 584 | } |
|
| 533 | 585 | ||
| 586 | + | class SequoiaSubscribe extends SequoiaActionBase { |
|
| 587 | + | static get observedAttributes() { |
|
| 588 | + | return [ |
|
| 589 | + | "publication-uri", |
|
| 590 | + | "callback-uri", |
|
| 591 | + | "label", |
|
| 592 | + | "unsubscribe-label", |
|
| 593 | + | "button-type", |
|
| 594 | + | "hide", |
|
| 595 | + | ]; |
|
| 596 | + | } |
|
| 597 | + | ||
| 598 | + | connectedCallback() { |
|
| 599 | + | consumeReturnParams(); |
|
| 600 | + | this.checkPublication(); |
|
| 601 | + | } |
|
| 602 | + | ||
| 603 | + | get publicationUri() { |
|
| 604 | + | return this.getAttribute("publication-uri") ?? null; |
|
| 605 | + | } |
|
| 606 | + | ||
| 607 | + | get label() { |
|
| 608 | + | return this.getAttribute("label") ?? null; |
|
| 609 | + | } |
|
| 610 | + | ||
| 611 | + | get buttonType() { |
|
| 612 | + | const val = this.getAttribute("button-type"); |
|
| 613 | + | return val && val in BUTTON_TYPES ? val : "sequoia"; |
|
| 614 | + | } |
|
| 615 | + | ||
| 616 | + | get unsubscribeLabel() { |
|
| 617 | + | return this.getAttribute("unsubscribe-label") ?? null; |
|
| 618 | + | } |
|
| 619 | + | ||
| 620 | + | // ── Template method overrides ──────────────────────────────────────────── |
|
| 621 | + | ||
| 622 | + | get defaultCallbackUri() { |
|
| 623 | + | return "https://sequoia.pub/subscribe"; |
|
| 624 | + | } |
|
| 625 | + | get resourceParam() { |
|
| 626 | + | return "publicationUri"; |
|
| 627 | + | } |
|
| 628 | + | get unactionValue() { |
|
| 629 | + | return "unsubscribe"; |
|
| 630 | + | } |
|
| 631 | + | get actionedKey() { |
|
| 632 | + | return "subscribed"; |
|
| 633 | + | } |
|
| 634 | + | get actionedEventName() { |
|
| 635 | + | return "sequoia-subscribed"; |
|
| 636 | + | } |
|
| 637 | + | get errorEventName() { |
|
| 638 | + | return "sequoia-subscribe-error"; |
|
| 639 | + | } |
|
| 640 | + | get defaultErrorMessage() { |
|
| 641 | + | return "Failed to subscribe"; |
|
| 642 | + | } |
|
| 643 | + | ||
| 644 | + | getDefaultActionLabel() { |
|
| 645 | + | return (BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia).subscribe; |
|
| 646 | + | } |
|
| 647 | + | ||
| 648 | + | getDefaultUnactionLabel() { |
|
| 649 | + | return (BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia).unsubscribe; |
|
| 650 | + | } |
|
| 651 | + | ||
| 652 | + | getUnactionLabel() { |
|
| 653 | + | return this.unsubscribeLabel; |
|
| 654 | + | } |
|
| 655 | + | ||
| 656 | + | getIcon() { |
|
| 657 | + | return (BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia).icon; |
|
| 658 | + | } |
|
| 659 | + | ||
| 660 | + | async resolveResourceUri() { |
|
| 661 | + | return this.publicationUri ?? (await fetchPublicationUri()); |
|
| 662 | + | } |
|
| 663 | + | ||
| 664 | + | // ── SequoiaSubscribe-specific logic ────────────────────────────────────── |
|
| 665 | + | ||
| 666 | + | /** @returns {boolean} Whether the user is currently subscribed. Alias for this.actioned. */ |
|
| 667 | + | get subscribed() { |
|
| 668 | + | return this.actioned; |
|
| 669 | + | } |
|
| 670 | + | ||
| 671 | + | /** |
|
| 672 | + | * Check whether the current user is subscribed to the given publication URI. |
|
| 673 | + | * Forwards to the shared checkStatusFor() method. |
|
| 674 | + | * @param {string} publicationUri |
|
| 675 | + | */ |
|
| 676 | + | checkSubscription(publicationUri) { |
|
| 677 | + | return this.checkStatusFor(publicationUri); |
|
| 678 | + | } |
|
| 679 | + | ||
| 680 | + | async checkPublication() { |
|
| 681 | + | this.abortController?.abort(); |
|
| 682 | + | this.abortController = new AbortController(); |
|
| 683 | + | ||
| 684 | + | try { |
|
| 685 | + | const uri = await this.resolveResourceUri(); |
|
| 686 | + | this.checkStatusFor(uri); |
|
| 687 | + | } catch { |
|
| 688 | + | this.state = { type: "no-resource" }; |
|
| 689 | + | this.render(); |
|
| 690 | + | } |
|
| 691 | + | } |
|
| 692 | + | } |
|
| 693 | + | ||
| 694 | + | class SequoiaRecommend extends SequoiaActionBase { |
|
| 695 | + | static get observedAttributes() { |
|
| 696 | + | return ["document-uri", "callback-uri", "button-type", "hide"]; |
|
| 697 | + | } |
|
| 698 | + | ||
| 699 | + | connectedCallback() { |
|
| 700 | + | consumeReturnParams(); |
|
| 701 | + | this.checkDocument(); |
|
| 702 | + | } |
|
| 703 | + | ||
| 704 | + | get documentUri() { |
|
| 705 | + | const attrUri = this.getAttribute("document-uri"); |
|
| 706 | + | if (attrUri) return attrUri; |
|
| 707 | + | const linkTag = document.querySelector( |
|
| 708 | + | 'link[rel="site.standard.document"]', |
|
| 709 | + | ); |
|
| 710 | + | return linkTag?.href ?? null; |
|
| 711 | + | } |
|
| 712 | + | ||
| 713 | + | get buttonType() { |
|
| 714 | + | const val = this.getAttribute("button-type"); |
|
| 715 | + | return val && val in RECOMMEND_ICON_TYPES ? val : "heart"; |
|
| 716 | + | } |
|
| 717 | + | ||
| 718 | + | // ── Template method overrides ──────────────────────────────────────────── |
|
| 719 | + | ||
| 720 | + | get defaultCallbackUri() { |
|
| 721 | + | return "https://sequoia.pub/recommend"; |
|
| 722 | + | } |
|
| 723 | + | get resourceParam() { |
|
| 724 | + | return "documentUri"; |
|
| 725 | + | } |
|
| 726 | + | get unactionValue() { |
|
| 727 | + | return "remove"; |
|
| 728 | + | } |
|
| 729 | + | get actionedKey() { |
|
| 730 | + | return "recommended"; |
|
| 731 | + | } |
|
| 732 | + | get actionedEventName() { |
|
| 733 | + | return "sequoia-recommended"; |
|
| 734 | + | } |
|
| 735 | + | get errorEventName() { |
|
| 736 | + | return "sequoia-recommend-error"; |
|
| 737 | + | } |
|
| 738 | + | get defaultErrorMessage() { |
|
| 739 | + | return "Failed to recommend"; |
|
| 740 | + | } |
|
| 741 | + | ||
| 742 | + | getAriaLabel() { |
|
| 743 | + | const config = |
|
| 744 | + | RECOMMEND_ICON_TYPES[this.buttonType] ?? RECOMMEND_ICON_TYPES.heart; |
|
| 745 | + | return this.actioned ? config.unaction : config.action; |
|
| 746 | + | } |
|
| 747 | + | ||
| 748 | + | getIcon() { |
|
| 749 | + | const config = |
|
| 750 | + | RECOMMEND_ICON_TYPES[this.buttonType] ?? RECOMMEND_ICON_TYPES.heart; |
|
| 751 | + | return this.actioned ? config.iconActioned : config.icon; |
|
| 752 | + | } |
|
| 753 | + | ||
| 754 | + | async resolveResourceUri() { |
|
| 755 | + | const uri = this.documentUri; |
|
| 756 | + | if (!uri) throw new Error("No document URI found"); |
|
| 757 | + | return uri; |
|
| 758 | + | } |
|
| 759 | + | ||
| 760 | + | // ── SequoiaRecommend-specific logic ────────────────────────────────────── |
|
| 761 | + | ||
| 762 | + | async checkDocument() { |
|
| 763 | + | this.abortController?.abort(); |
|
| 764 | + | this.abortController = new AbortController(); |
|
| 765 | + | ||
| 766 | + | const uri = this.documentUri; |
|
| 767 | + | if (!uri) { |
|
| 768 | + | this.state = { type: "no-resource" }; |
|
| 769 | + | this.render(); |
|
| 770 | + | return; |
|
| 771 | + | } |
|
| 772 | + | ||
| 773 | + | this.checkStatusFor(uri); |
|
| 774 | + | } |
|
| 775 | + | } |
|
| 776 | + | ||
| 534 | 777 | /** |
|
| 535 | 778 | * Escape HTML special characters (no DOM dependency for SSR). |
|
| 536 | 779 | * @param {string} text |
|
| 544 | 787 | .replace(/"/g, """); |
|
| 545 | 788 | } |
|
| 546 | 789 | ||
| 547 | - | // Register the custom element |
|
| 790 | + | // Register the custom elements |
|
| 548 | 791 | if (typeof customElements !== "undefined") { |
|
| 549 | 792 | customElements.define("sequoia-subscribe", SequoiaSubscribe); |
|
| 793 | + | customElements.define("sequoia-recommend", SequoiaRecommend); |
|
| 550 | 794 | } |
|
| 551 | 795 | ||
| 552 | - | // Export for module usage |
|
| 796 | + | /** |
|
| 797 | + | * Sequoia Subscribe - An AT Protocol-powered subscribe component |
|
| 798 | + | * |
|
| 799 | + | * A self-contained Web Component that lets users subscribe to a publication |
|
| 800 | + | * via the AT Protocol by creating a site.standard.graph.subscription record. |
|
| 801 | + | * |
|
| 802 | + | * Usage: |
|
| 803 | + | * <sequoia-subscribe></sequoia-subscribe> |
|
| 804 | + | * |
|
| 805 | + | * The component resolves the publication AT URI from the host site's |
|
| 806 | + | * /.well-known/site.standard.publication endpoint. |
|
| 807 | + | * |
|
| 808 | + | * Attributes: |
|
| 809 | + | * - publication-uri: Override the publication AT URI (optional) |
|
| 810 | + | * - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/subscribe") |
|
| 811 | + | * - button-type: Branding style — "sequoia" (default), "bluesky", "blacksky", "atmosphere", or "plain" |
|
| 812 | + | * - label: Override the subscribe button label text |
|
| 813 | + | * - unsubscribe-label: Override the unsubscribe button label text |
|
| 814 | + | * - hide: Set to "auto" to hide if no publication URI is detected |
|
| 815 | + | * |
|
| 816 | + | * Events: |
|
| 817 | + | * - sequoia-subscribed: Fired when the subscription is created successfully. |
|
| 818 | + | * detail: { publicationUri: string, recordUri: string } |
|
| 819 | + | * - sequoia-subscribe-error: Fired when the subscription fails. |
|
| 820 | + | * detail: { message: string } |
|
| 821 | + | */ |
|
| 553 | 822 | export { SequoiaSubscribe }; |
|
| 823 | + | ||
| 824 | + | /** |
|
| 825 | + | * Sequoia Recommend - An AT Protocol-powered recommend component |
|
| 826 | + | * |
|
| 827 | + | * A self-contained Web Component that lets users recommend a document |
|
| 828 | + | * via the AT Protocol by creating a site.standard.graph.recommend record. |
|
| 829 | + | * |
|
| 830 | + | * Usage: |
|
| 831 | + | * <sequoia-recommend></sequoia-recommend> |
|
| 832 | + | * |
|
| 833 | + | * The component resolves the document AT URI from the `document-uri` attribute |
|
| 834 | + | * or a <link rel="site.standard.document" href="at://..."> tag in the page head. |
|
| 835 | + | * |
|
| 836 | + | * Attributes: |
|
| 837 | + | * - document-uri: AT Protocol URI of the document to recommend (optional if link tag present) |
|
| 838 | + | * - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/recommend") |
|
| 839 | + | * - button-type: Icon style — "heart" (default), "star", or "thumbs-up" |
|
| 840 | + | * - hide: Set to "auto" to hide if no document URI is detected |
|
| 841 | + | * |
|
| 842 | + | * Events: |
|
| 843 | + | * - sequoia-recommended: Fired when the recommendation is created successfully. |
|
| 844 | + | * detail: { documentUri: string, recordUri: string } |
|
| 845 | + | * - sequoia-recommend-error: Fired when the recommendation fails. |
|
| 846 | + | * detail: { message: string } |
|
| 847 | + | */ |
|
| 848 | + | export { SequoiaRecommend }; |
|
| 1 | 1 | # Sequoia Server |
|
| 2 | 2 | ||
| 3 | - | Self-hostable AT Protocol OAuth and subscription server. Handles Bluesky login and manages `site.standard.graph.subscription` records on behalf of users. Built with Bun, Hono, and Redis. |
|
| 3 | + | Self-hostable AT Protocol OAuth and subscription/recommendation server. Handles Bluesky login and manages `site.standard.graph.subscription` and `site.standard.graph.recommend` records on behalf of users. Built with Bun, Hono, and Redis. |
|
| 4 | 4 | ||
| 5 | 5 | ## Quickstart |
|
| 6 | 6 | ||
| 23 | 23 | ||
| 24 | 24 | ## How it works |
|
| 25 | 25 | ||
| 26 | - | 1. A user visits `/subscribe?publicationUri=at://...` and enters their Bluesky handle |
|
| 26 | + | 1. A user visits `/subscribe?publicationUri=at://...` or `/recommend?documentUri=at://...` and enters their Bluesky handle |
|
| 27 | 27 | 2. The server initiates an AT Protocol OAuth flow — the user authorizes on Bluesky |
|
| 28 | - | 3. After callback, the server creates a `site.standard.graph.subscription` record in the user's repo |
|
| 29 | - | 4. The [sequoia-subscribe](https://github.com/standard-schema/sequoia) web component can point to this server for the full flow |
|
| 28 | + | 3. After callback, the server creates a `site.standard.graph.subscription` or `site.standard.graph.recommend` record in the user's repo |
|
| 29 | + | 4. The [sequoia-subscribe](https://github.com/standard-schema/sequoia) web components can point to this server for the full flow |
|
| 30 | 30 | ||
| 31 | 31 | ### Routes |
|
| 32 | 32 | ||
| 42 | 42 | | `/subscribe` | POST | Subscribe via API (JSON) | |
|
| 43 | 43 | | `/subscribe/check` | GET | Check subscription status | |
|
| 44 | 44 | | `/subscribe/login` | POST | Handle form submission | |
|
| 45 | + | | `/recommend` | GET | Recommend page (HTML) | |
|
| 46 | + | | `/recommend` | POST | Recommend via API (JSON) | |
|
| 47 | + | | `/recommend/check` | GET | Check recommendation status | |
|
| 48 | + | | `/recommend/login` | POST | Handle form submission | |
|
| 45 | 49 | ||
| 46 | 50 | ## Configuration |
|
| 47 | 51 | ||
| 54 | 58 | ||
| 55 | 59 | ### Theming |
|
| 56 | 60 | ||
| 57 | - | The subscribe pages use CSS custom properties that can be overridden via environment variables: |
|
| 61 | + | The subscribe and recommend pages use CSS custom properties that can be overridden via environment variables: |
|
| 58 | 62 | ||
| 59 | 63 | | Variable | Default | |
|
| 60 | 64 | |----------|---------| |
|
| 5 | 5 | import { openDatabase } from "./lib/db"; |
|
| 6 | 6 | import auth from "./routes/auth"; |
|
| 7 | 7 | import subscribe from "./routes/subscribe"; |
|
| 8 | + | import recommend from "./routes/recommend"; |
|
| 8 | 9 | ||
| 9 | 10 | const env = loadEnv(); |
|
| 10 | 11 | ||
| 43 | 44 | }), |
|
| 44 | 45 | ); |
|
| 45 | 46 | app.route("/subscribe", subscribe); |
|
| 47 | + | ||
| 48 | + | // Recommend routes with CORS |
|
| 49 | + | app.use( |
|
| 50 | + | "/recommend/*", |
|
| 51 | + | cors({ |
|
| 52 | + | origin: (origin) => origin, |
|
| 53 | + | credentials: true, |
|
| 54 | + | }), |
|
| 55 | + | ); |
|
| 56 | + | app.use( |
|
| 57 | + | "/recommend", |
|
| 58 | + | cors({ |
|
| 59 | + | origin: (origin) => origin, |
|
| 60 | + | credentials: true, |
|
| 61 | + | }), |
|
| 62 | + | ); |
|
| 63 | + | app.route("/recommend", recommend); |
|
| 46 | 64 | ||
| 47 | 65 | console.log(`Sequoia server listening on port ${env.PORT}`); |
|
| 48 | 66 | ||
| 5 | 5 | import { createStateStore, createSessionStore } from "./stores"; |
|
| 6 | 6 | ||
| 7 | 7 | export const OAUTH_SCOPE = |
|
| 8 | - | "atproto repo:site.standard.graph.subscription?action=create&action=delete"; |
|
| 8 | + | "atproto repo:site.standard.graph.recommend?action=create&action=delete repo:site.standard.graph.subscription?action=create&action=delete"; |
|
| 9 | 9 | ||
| 10 | 10 | export function createOAuthClient( |
|
| 11 | 11 | db: Database, |
| 1 | + | import type { Agent } from "@atproto/api"; |
|
| 2 | + | import { escapeHtml, page } from "../lib/theme"; |
|
| 3 | + | ||
| 4 | + | export const REDIRECT_DELAY_SECONDS = 5; |
|
| 5 | + | ||
| 6 | + | // ============================================================================ |
|
| 7 | + | // Helpers |
|
| 8 | + | // ============================================================================ |
|
| 9 | + | ||
| 10 | + | export function withReturnToParam( |
|
| 11 | + | returnTo: string | undefined, |
|
| 12 | + | key: string, |
|
| 13 | + | value: string, |
|
| 14 | + | ): string | undefined { |
|
| 15 | + | if (!returnTo) return undefined; |
|
| 16 | + | try { |
|
| 17 | + | const url = new URL(returnTo); |
|
| 18 | + | url.searchParams.set(key, value); |
|
| 19 | + | return url.toString(); |
|
| 20 | + | } catch { |
|
| 21 | + | return returnTo; |
|
| 22 | + | } |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | /** |
|
| 26 | + | * Scan a repo for a record in `collection` where `record[field] === uri`. |
|
| 27 | + | * Returns the record AT-URI, or null if not found. |
|
| 28 | + | */ |
|
| 29 | + | export async function findExistingRecord( |
|
| 30 | + | agent: Agent, |
|
| 31 | + | did: string, |
|
| 32 | + | collection: string, |
|
| 33 | + | field: string, |
|
| 34 | + | uri: string, |
|
| 35 | + | ): Promise<string | null> { |
|
| 36 | + | let cursor: string | undefined; |
|
| 37 | + | ||
| 38 | + | do { |
|
| 39 | + | const result = await agent.com.atproto.repo.listRecords({ |
|
| 40 | + | repo: did, |
|
| 41 | + | collection, |
|
| 42 | + | limit: 100, |
|
| 43 | + | cursor, |
|
| 44 | + | }); |
|
| 45 | + | ||
| 46 | + | for (const record of result.data.records) { |
|
| 47 | + | const value = record.value as Record<string, unknown>; |
|
| 48 | + | if (value[field] === uri) { |
|
| 49 | + | return record.uri; |
|
| 50 | + | } |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | cursor = result.data.cursor; |
|
| 54 | + | } while (cursor); |
|
| 55 | + | ||
| 56 | + | return null; |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | // ============================================================================ |
|
| 60 | + | // HTML rendering |
|
| 61 | + | // ============================================================================ |
|
| 62 | + | ||
| 63 | + | export function renderHandleForm(params: { |
|
| 64 | + | resourceUri: string; |
|
| 65 | + | resourceField: string; |
|
| 66 | + | loginPath: string; |
|
| 67 | + | title: string; |
|
| 68 | + | description: string; |
|
| 69 | + | buttonLabel: string; |
|
| 70 | + | returnTo?: string; |
|
| 71 | + | error?: string; |
|
| 72 | + | action?: string; |
|
| 73 | + | }): string { |
|
| 74 | + | const { |
|
| 75 | + | resourceUri, |
|
| 76 | + | resourceField, |
|
| 77 | + | loginPath, |
|
| 78 | + | title, |
|
| 79 | + | description, |
|
| 80 | + | buttonLabel, |
|
| 81 | + | returnTo, |
|
| 82 | + | error, |
|
| 83 | + | action, |
|
| 84 | + | } = params; |
|
| 85 | + | ||
| 86 | + | const errorHtml = error ? `<p class="error">${escapeHtml(error)}</p>` : ""; |
|
| 87 | + | const returnToInput = returnTo |
|
| 88 | + | ? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />` |
|
| 89 | + | : ""; |
|
| 90 | + | const actionInput = action |
|
| 91 | + | ? `<input type="hidden" name="action" value="${escapeHtml(action)}" />` |
|
| 92 | + | : ""; |
|
| 93 | + | ||
| 94 | + | return page(` |
|
| 95 | + | <h1>${escapeHtml(title)}</h1> |
|
| 96 | + | <p>${escapeHtml(description)}</p> |
|
| 97 | + | ${errorHtml} |
|
| 98 | + | <form method="POST" action="${escapeHtml(loginPath)}"> |
|
| 99 | + | <input type="hidden" name="${escapeHtml(resourceField)}" value="${escapeHtml(resourceUri)}" /> |
|
| 100 | + | ${returnToInput} |
|
| 101 | + | ${actionInput} |
|
| 102 | + | <input |
|
| 103 | + | type="text" |
|
| 104 | + | name="handle" |
|
| 105 | + | placeholder="you.bsky.social" |
|
| 106 | + | autocomplete="username" |
|
| 107 | + | required |
|
| 108 | + | autofocus |
|
| 109 | + | /> |
|
| 110 | + | <button type="submit">${escapeHtml(buttonLabel)}</button> |
|
| 111 | + | </form> |
|
| 112 | + | `); |
|
| 113 | + | } |
|
| 114 | + | ||
| 115 | + | export function renderSuccess(params: { |
|
| 116 | + | resourceUri: string; |
|
| 117 | + | resourceLabel: string; |
|
| 118 | + | recordUri: string | null; |
|
| 119 | + | heading: string; |
|
| 120 | + | msg: string; |
|
| 121 | + | returnTo?: string; |
|
| 122 | + | }): string { |
|
| 123 | + | const { resourceUri, resourceLabel, recordUri, heading, msg, returnTo } = |
|
| 124 | + | params; |
|
| 125 | + | const escapedResourceUri = escapeHtml(resourceUri); |
|
| 126 | + | const escapedReturnTo = returnTo ? escapeHtml(returnTo) : ""; |
|
| 127 | + | ||
| 128 | + | const redirectHtml = returnTo |
|
| 129 | + | ? `<p id="redirect-msg">Redirecting to <a href="${escapedReturnTo}">${escapedReturnTo}</a> in <span id="countdown">${REDIRECT_DELAY_SECONDS}</span>\u00a0seconds\u2026</p> |
|
| 130 | + | <script> |
|
| 131 | + | (function(){ |
|
| 132 | + | var secs = ${REDIRECT_DELAY_SECONDS}; |
|
| 133 | + | var el = document.getElementById('countdown'); |
|
| 134 | + | var iv = setInterval(function(){ |
|
| 135 | + | secs--; |
|
| 136 | + | if (el) el.textContent = String(secs); |
|
| 137 | + | if (secs <= 0) { clearInterval(iv); location.href = ${JSON.stringify(returnTo)}; } |
|
| 138 | + | }, 1000); |
|
| 139 | + | })(); |
|
| 140 | + | </script>` |
|
| 141 | + | : ""; |
|
| 142 | + | const headExtra = returnTo |
|
| 143 | + | ? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />` |
|
| 144 | + | : ""; |
|
| 145 | + | ||
| 146 | + | return page( |
|
| 147 | + | ` |
|
| 148 | + | <h1>${escapeHtml(heading)}</h1> |
|
| 149 | + | <p>${msg}</p> |
|
| 150 | + | ${redirectHtml} |
|
| 151 | + | <table> |
|
| 152 | + | <colgroup><col style="width:7rem;"><col></colgroup> |
|
| 153 | + | <tbody> |
|
| 154 | + | <tr> |
|
| 155 | + | <td>${escapeHtml(resourceLabel)}</td> |
|
| 156 | + | <td> |
|
| 157 | + | <div><code><a href="https://pds.ls/${escapedResourceUri}">${escapedResourceUri}</a></code></div> |
|
| 158 | + | </td> |
|
| 159 | + | </tr> |
|
| 160 | + | ${ |
|
| 161 | + | recordUri |
|
| 162 | + | ? `<tr> |
|
| 163 | + | <td>Record</td> |
|
| 164 | + | <td> |
|
| 165 | + | <div><code><a href="https://pds.ls/${escapeHtml(recordUri)}">${escapeHtml(recordUri)}</a></code></div> |
|
| 166 | + | </td> |
|
| 167 | + | </tr>` |
|
| 168 | + | : "" |
|
| 169 | + | } |
|
| 170 | + | </tbody> |
|
| 171 | + | </table> |
|
| 172 | + | `, |
|
| 173 | + | headExtra, |
|
| 174 | + | ); |
|
| 175 | + | } |
|
| 176 | + | ||
| 177 | + | export function renderError(message: string): string { |
|
| 178 | + | return page(`<h1>Error</h1><p class="error">${escapeHtml(message)}</p>`); |
|
| 179 | + | } |
| 1 | + | import { Agent } from "@atproto/api"; |
|
| 2 | + | import { Hono } from "hono"; |
|
| 3 | + | import type { Database } from "bun:sqlite"; |
|
| 4 | + | import { createOAuthClient } from "../lib/oauth-client"; |
|
| 5 | + | import { getSessionDid, setReturnToCookie } from "../lib/session"; |
|
| 6 | + | import type { Env } from "../env"; |
|
| 7 | + | import { |
|
| 8 | + | findExistingRecord, |
|
| 9 | + | renderError, |
|
| 10 | + | renderHandleForm, |
|
| 11 | + | renderSuccess, |
|
| 12 | + | withReturnToParam, |
|
| 13 | + | } from "./lib"; |
|
| 14 | + | ||
| 15 | + | type Variables = { env: Env; db: Database }; |
|
| 16 | + | ||
| 17 | + | const recommend = new Hono<{ Variables: Variables }>(); |
|
| 18 | + | ||
| 19 | + | const COLLECTION = "site.standard.graph.recommend"; |
|
| 20 | + | ||
| 21 | + | // ============================================================================ |
|
| 22 | + | // POST /recommend |
|
| 23 | + | // ============================================================================ |
|
| 24 | + | ||
| 25 | + | recommend.post("/", async (c) => { |
|
| 26 | + | const env = c.get("env"); |
|
| 27 | + | const db = c.get("db"); |
|
| 28 | + | ||
| 29 | + | let documentUri: string; |
|
| 30 | + | try { |
|
| 31 | + | const body = await c.req.json<{ documentUri?: string }>(); |
|
| 32 | + | documentUri = body.documentUri ?? ""; |
|
| 33 | + | } catch { |
|
| 34 | + | return c.json({ error: "Invalid JSON body" }, 400); |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | if (!documentUri || !documentUri.startsWith("at://")) { |
|
| 38 | + | return c.json({ error: "Missing or invalid documentUri" }, 400); |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | const did = getSessionDid(c); |
|
| 42 | + | if (!did) { |
|
| 43 | + | const subscribeUrl = `${env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}`; |
|
| 44 | + | return c.json({ authenticated: false, subscribeUrl }, 401); |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | try { |
|
| 48 | + | const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); |
|
| 49 | + | const session = await client.restore(did); |
|
| 50 | + | const agent = new Agent(session); |
|
| 51 | + | ||
| 52 | + | const existingUri = await findExistingRecord( |
|
| 53 | + | agent, |
|
| 54 | + | did, |
|
| 55 | + | COLLECTION, |
|
| 56 | + | "document", |
|
| 57 | + | documentUri, |
|
| 58 | + | ); |
|
| 59 | + | if (existingUri) { |
|
| 60 | + | return c.json({ |
|
| 61 | + | recommended: true, |
|
| 62 | + | existing: true, |
|
| 63 | + | recordUri: existingUri, |
|
| 64 | + | }); |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | const result = await agent.com.atproto.repo.createRecord({ |
|
| 68 | + | repo: did, |
|
| 69 | + | collection: COLLECTION, |
|
| 70 | + | record: { |
|
| 71 | + | $type: COLLECTION, |
|
| 72 | + | document: documentUri, |
|
| 73 | + | createdAt: new Date().toISOString(), |
|
| 74 | + | }, |
|
| 75 | + | }); |
|
| 76 | + | ||
| 77 | + | return c.json({ |
|
| 78 | + | recommended: true, |
|
| 79 | + | existing: false, |
|
| 80 | + | recordUri: result.data.uri, |
|
| 81 | + | }); |
|
| 82 | + | } catch (error) { |
|
| 83 | + | console.error("Recommend POST error:", error); |
|
| 84 | + | const subscribeUrl = `${env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}`; |
|
| 85 | + | return c.json({ authenticated: false, subscribeUrl }, 401); |
|
| 86 | + | } |
|
| 87 | + | }); |
|
| 88 | + | ||
| 89 | + | // ============================================================================ |
|
| 90 | + | // GET /recommend |
|
| 91 | + | // ============================================================================ |
|
| 92 | + | ||
| 93 | + | recommend.get("/", async (c) => { |
|
| 94 | + | const env = c.get("env"); |
|
| 95 | + | const db = c.get("db"); |
|
| 96 | + | ||
| 97 | + | const documentUri = c.req.query("documentUri"); |
|
| 98 | + | const action = c.req.query("action"); |
|
| 99 | + | ||
| 100 | + | if (action && action !== "remove") { |
|
| 101 | + | return c.html(renderError(`Unsupported action: ${action}`), 400); |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | if (!documentUri || !documentUri.startsWith("at://")) { |
|
| 105 | + | return c.html(renderError("Missing or invalid document URI."), 400); |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | const referer = c.req.header("referer"); |
|
| 109 | + | const returnTo = |
|
| 110 | + | c.req.query("returnTo") ?? |
|
| 111 | + | (referer && !referer.includes("/recommend") ? referer : undefined); |
|
| 112 | + | ||
| 113 | + | const did = getSessionDid(c); |
|
| 114 | + | if (!did) { |
|
| 115 | + | return c.html( |
|
| 116 | + | renderHandleForm({ |
|
| 117 | + | resourceUri: documentUri, |
|
| 118 | + | resourceField: "documentUri", |
|
| 119 | + | loginPath: "/recommend/login", |
|
| 120 | + | title: "Recommend on Sequoia", |
|
| 121 | + | description: "Enter your Bluesky handle to recommend this document.", |
|
| 122 | + | buttonLabel: "Continue on Bluesky", |
|
| 123 | + | returnTo, |
|
| 124 | + | action, |
|
| 125 | + | }), |
|
| 126 | + | ); |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | try { |
|
| 130 | + | const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); |
|
| 131 | + | const session = await client.restore(did); |
|
| 132 | + | const agent = new Agent(session); |
|
| 133 | + | ||
| 134 | + | if (action === "remove") { |
|
| 135 | + | const existingUri = await findExistingRecord( |
|
| 136 | + | agent, |
|
| 137 | + | did, |
|
| 138 | + | COLLECTION, |
|
| 139 | + | "document", |
|
| 140 | + | documentUri, |
|
| 141 | + | ); |
|
| 142 | + | if (existingUri) { |
|
| 143 | + | const rkey = existingUri.split("/").pop()!; |
|
| 144 | + | await agent.com.atproto.repo.deleteRecord({ |
|
| 145 | + | repo: did, |
|
| 146 | + | collection: COLLECTION, |
|
| 147 | + | rkey, |
|
| 148 | + | }); |
|
| 149 | + | } |
|
| 150 | + | ||
| 151 | + | return c.html( |
|
| 152 | + | renderSuccess({ |
|
| 153 | + | resourceUri: documentUri, |
|
| 154 | + | resourceLabel: "Document", |
|
| 155 | + | recordUri: null, |
|
| 156 | + | heading: "Recommendation Removed", |
|
| 157 | + | msg: existingUri |
|
| 158 | + | ? "You've successfully removed your recommendation." |
|
| 159 | + | : "You hadn't recommended this document.", |
|
| 160 | + | returnTo: withReturnToParam(returnTo, "sequoia_did", did), |
|
| 161 | + | }), |
|
| 162 | + | ); |
|
| 163 | + | } |
|
| 164 | + | ||
| 165 | + | const existingUri = await findExistingRecord( |
|
| 166 | + | agent, |
|
| 167 | + | did, |
|
| 168 | + | COLLECTION, |
|
| 169 | + | "document", |
|
| 170 | + | documentUri, |
|
| 171 | + | ); |
|
| 172 | + | const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did); |
|
| 173 | + | ||
| 174 | + | if (existingUri) { |
|
| 175 | + | return c.html( |
|
| 176 | + | renderSuccess({ |
|
| 177 | + | resourceUri: documentUri, |
|
| 178 | + | resourceLabel: "Document", |
|
| 179 | + | recordUri: existingUri, |
|
| 180 | + | heading: "Recommended", |
|
| 181 | + | msg: "You've already recommended this document.", |
|
| 182 | + | returnTo: returnToWithDid, |
|
| 183 | + | }), |
|
| 184 | + | ); |
|
| 185 | + | } |
|
| 186 | + | ||
| 187 | + | const result = await agent.com.atproto.repo.createRecord({ |
|
| 188 | + | repo: did, |
|
| 189 | + | collection: COLLECTION, |
|
| 190 | + | record: { |
|
| 191 | + | $type: COLLECTION, |
|
| 192 | + | document: documentUri, |
|
| 193 | + | createdAt: new Date().toISOString(), |
|
| 194 | + | }, |
|
| 195 | + | }); |
|
| 196 | + | ||
| 197 | + | return c.html( |
|
| 198 | + | renderSuccess({ |
|
| 199 | + | resourceUri: documentUri, |
|
| 200 | + | resourceLabel: "Document", |
|
| 201 | + | recordUri: result.data.uri, |
|
| 202 | + | heading: "Recommended", |
|
| 203 | + | msg: "You've successfully recommended this document!", |
|
| 204 | + | returnTo: returnToWithDid, |
|
| 205 | + | }), |
|
| 206 | + | ); |
|
| 207 | + | } catch (error) { |
|
| 208 | + | console.error("Recommend GET error:", error); |
|
| 209 | + | return c.html( |
|
| 210 | + | renderHandleForm({ |
|
| 211 | + | resourceUri: documentUri, |
|
| 212 | + | resourceField: "documentUri", |
|
| 213 | + | loginPath: "/recommend/login", |
|
| 214 | + | title: "Recommend on Sequoia", |
|
| 215 | + | description: "Enter your Bluesky handle to recommend this document.", |
|
| 216 | + | buttonLabel: "Continue on Bluesky", |
|
| 217 | + | returnTo, |
|
| 218 | + | error: "Session expired. Please sign in again.", |
|
| 219 | + | action, |
|
| 220 | + | }), |
|
| 221 | + | ); |
|
| 222 | + | } |
|
| 223 | + | }); |
|
| 224 | + | ||
| 225 | + | // ============================================================================ |
|
| 226 | + | // GET /recommend/check |
|
| 227 | + | // ============================================================================ |
|
| 228 | + | ||
| 229 | + | recommend.get("/check", async (c) => { |
|
| 230 | + | const env = c.get("env"); |
|
| 231 | + | const db = c.get("db"); |
|
| 232 | + | ||
| 233 | + | const documentUri = c.req.query("documentUri"); |
|
| 234 | + | ||
| 235 | + | if (!documentUri || !documentUri.startsWith("at://")) { |
|
| 236 | + | return c.json({ error: "Missing or invalid documentUri" }, 400); |
|
| 237 | + | } |
|
| 238 | + | ||
| 239 | + | const did = getSessionDid(c) ?? c.req.query("did") ?? null; |
|
| 240 | + | if (!did || !did.startsWith("did:")) { |
|
| 241 | + | return c.json({ authenticated: false }, 401); |
|
| 242 | + | } |
|
| 243 | + | ||
| 244 | + | try { |
|
| 245 | + | const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); |
|
| 246 | + | const session = await client.restore(did); |
|
| 247 | + | const agent = new Agent(session); |
|
| 248 | + | const recordUri = await findExistingRecord( |
|
| 249 | + | agent, |
|
| 250 | + | did, |
|
| 251 | + | COLLECTION, |
|
| 252 | + | "document", |
|
| 253 | + | documentUri, |
|
| 254 | + | ); |
|
| 255 | + | return recordUri |
|
| 256 | + | ? c.json({ recommended: true, recordUri }) |
|
| 257 | + | : c.json({ recommended: false }); |
|
| 258 | + | } catch { |
|
| 259 | + | return c.json({ authenticated: false }, 401); |
|
| 260 | + | } |
|
| 261 | + | }); |
|
| 262 | + | ||
| 263 | + | // ============================================================================ |
|
| 264 | + | // POST /recommend/login |
|
| 265 | + | // ============================================================================ |
|
| 266 | + | ||
| 267 | + | recommend.post("/login", async (c) => { |
|
| 268 | + | const env = c.get("env"); |
|
| 269 | + | ||
| 270 | + | const body = await c.req.parseBody(); |
|
| 271 | + | const handle = (body.handle as string | undefined)?.trim(); |
|
| 272 | + | const documentUri = body.documentUri as string | undefined; |
|
| 273 | + | const formReturnTo = (body.returnTo as string | undefined) || undefined; |
|
| 274 | + | const formAction = (body.action as string | undefined) || undefined; |
|
| 275 | + | ||
| 276 | + | if (!handle || !documentUri) { |
|
| 277 | + | return c.html(renderError("Missing handle or document URI."), 400); |
|
| 278 | + | } |
|
| 279 | + | ||
| 280 | + | const returnTo = |
|
| 281 | + | `${env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}` + |
|
| 282 | + | (formAction ? `&action=${encodeURIComponent(formAction)}` : "") + |
|
| 283 | + | (formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : ""); |
|
| 284 | + | setReturnToCookie(c, returnTo, env.CLIENT_URL); |
|
| 285 | + | ||
| 286 | + | return c.redirect( |
|
| 287 | + | `${env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`, |
|
| 288 | + | ); |
|
| 289 | + | }); |
|
| 290 | + | ||
| 291 | + | export default recommend; |
| 3 | 3 | import type { Database } from "bun:sqlite"; |
|
| 4 | 4 | import { createOAuthClient } from "../lib/oauth-client"; |
|
| 5 | 5 | import { getSessionDid, setReturnToCookie } from "../lib/session"; |
|
| 6 | - | import { page, escapeHtml } from "../lib/theme"; |
|
| 7 | 6 | import type { Env } from "../env"; |
|
| 7 | + | import { |
|
| 8 | + | findExistingRecord, |
|
| 9 | + | renderError, |
|
| 10 | + | renderHandleForm, |
|
| 11 | + | renderSuccess, |
|
| 12 | + | withReturnToParam, |
|
| 13 | + | } from "./lib"; |
|
| 8 | 14 | ||
| 9 | 15 | type Variables = { env: Env; db: Database }; |
|
| 10 | 16 | ||
| 11 | 17 | const subscribe = new Hono<{ Variables: Variables }>(); |
|
| 12 | 18 | ||
| 13 | 19 | const COLLECTION = "site.standard.graph.subscription"; |
|
| 14 | - | const REDIRECT_DELAY_SECONDS = 5; |
|
| 15 | - | ||
| 16 | - | // ============================================================================ |
|
| 17 | - | // Helpers |
|
| 18 | - | // ============================================================================ |
|
| 19 | - | ||
| 20 | - | function withReturnToParam( |
|
| 21 | - | returnTo: string | undefined, |
|
| 22 | - | key: string, |
|
| 23 | - | value: string, |
|
| 24 | - | ): string | undefined { |
|
| 25 | - | if (!returnTo) return undefined; |
|
| 26 | - | try { |
|
| 27 | - | const url = new URL(returnTo); |
|
| 28 | - | url.searchParams.set(key, value); |
|
| 29 | - | return url.toString(); |
|
| 30 | - | } catch { |
|
| 31 | - | return returnTo; |
|
| 32 | - | } |
|
| 33 | - | } |
|
| 34 | - | ||
| 35 | - | async function findExistingSubscription( |
|
| 36 | - | agent: Agent, |
|
| 37 | - | did: string, |
|
| 38 | - | publicationUri: string, |
|
| 39 | - | ): Promise<string | null> { |
|
| 40 | - | let cursor: string | undefined; |
|
| 41 | - | ||
| 42 | - | do { |
|
| 43 | - | const result = await agent.com.atproto.repo.listRecords({ |
|
| 44 | - | repo: did, |
|
| 45 | - | collection: COLLECTION, |
|
| 46 | - | limit: 100, |
|
| 47 | - | cursor, |
|
| 48 | - | }); |
|
| 49 | - | ||
| 50 | - | for (const record of result.data.records) { |
|
| 51 | - | const value = record.value as { publication?: string }; |
|
| 52 | - | if (value.publication === publicationUri) { |
|
| 53 | - | return record.uri; |
|
| 54 | - | } |
|
| 55 | - | } |
|
| 56 | - | ||
| 57 | - | cursor = result.data.cursor; |
|
| 58 | - | } while (cursor); |
|
| 59 | - | ||
| 60 | - | return null; |
|
| 61 | - | } |
|
| 62 | 20 | ||
| 63 | 21 | // ============================================================================ |
|
| 64 | 22 | // POST /subscribe |
|
| 91 | 49 | const session = await client.restore(did); |
|
| 92 | 50 | const agent = new Agent(session); |
|
| 93 | 51 | ||
| 94 | - | const existingUri = await findExistingSubscription( |
|
| 52 | + | const existingUri = await findExistingRecord( |
|
| 95 | 53 | agent, |
|
| 96 | 54 | did, |
|
| 55 | + | COLLECTION, |
|
| 56 | + | "publication", |
|
| 97 | 57 | publicationUri, |
|
| 98 | 58 | ); |
|
| 99 | 59 | if (existingUri) { |
|
| 152 | 112 | const did = getSessionDid(c); |
|
| 153 | 113 | if (!did) { |
|
| 154 | 114 | return c.html( |
|
| 155 | - | renderHandleForm(publicationUri, returnTo, undefined, action), |
|
| 115 | + | renderHandleForm({ |
|
| 116 | + | resourceUri: publicationUri, |
|
| 117 | + | resourceField: "publicationUri", |
|
| 118 | + | loginPath: "/subscribe/login", |
|
| 119 | + | title: "Subscribe on Sequoia", |
|
| 120 | + | description: |
|
| 121 | + | "Enter your Bluesky handle to subscribe to this publication.", |
|
| 122 | + | buttonLabel: "Continue on Bluesky", |
|
| 123 | + | returnTo, |
|
| 124 | + | action, |
|
| 125 | + | }), |
|
| 156 | 126 | ); |
|
| 157 | 127 | } |
|
| 158 | 128 | ||
| 162 | 132 | const agent = new Agent(session); |
|
| 163 | 133 | ||
| 164 | 134 | if (action === "unsubscribe") { |
|
| 165 | - | const existingUri = await findExistingSubscription( |
|
| 135 | + | const existingUri = await findExistingRecord( |
|
| 166 | 136 | agent, |
|
| 167 | 137 | did, |
|
| 138 | + | COLLECTION, |
|
| 139 | + | "publication", |
|
| 168 | 140 | publicationUri, |
|
| 169 | 141 | ); |
|
| 170 | 142 | if (existingUri) { |
|
| 188 | 160 | } |
|
| 189 | 161 | ||
| 190 | 162 | return c.html( |
|
| 191 | - | renderSuccess( |
|
| 192 | - | publicationUri, |
|
| 193 | - | null, |
|
| 194 | - | "Unsubscribed", |
|
| 195 | - | existingUri |
|
| 163 | + | renderSuccess({ |
|
| 164 | + | resourceUri: publicationUri, |
|
| 165 | + | resourceLabel: "Publication", |
|
| 166 | + | recordUri: null, |
|
| 167 | + | heading: "Unsubscribed", |
|
| 168 | + | msg: existingUri |
|
| 196 | 169 | ? "You've successfully unsubscribed!" |
|
| 197 | 170 | : "You weren't subscribed to this publication.", |
|
| 198 | - | withReturnToParam(cleanReturnTo, "sequoia_unsubscribed", "1"), |
|
| 199 | - | ), |
|
| 171 | + | returnTo: withReturnToParam( |
|
| 172 | + | cleanReturnTo, |
|
| 173 | + | "sequoia_unsubscribed", |
|
| 174 | + | "1", |
|
| 175 | + | ), |
|
| 176 | + | }), |
|
| 200 | 177 | ); |
|
| 201 | 178 | } |
|
| 202 | 179 | ||
| 203 | - | const existingUri = await findExistingSubscription( |
|
| 180 | + | const existingUri = await findExistingRecord( |
|
| 204 | 181 | agent, |
|
| 205 | 182 | did, |
|
| 183 | + | COLLECTION, |
|
| 184 | + | "publication", |
|
| 206 | 185 | publicationUri, |
|
| 207 | 186 | ); |
|
| 208 | 187 | const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did); |
|
| 209 | 188 | ||
| 210 | 189 | if (existingUri) { |
|
| 211 | 190 | return c.html( |
|
| 212 | - | renderSuccess( |
|
| 213 | - | publicationUri, |
|
| 214 | - | existingUri, |
|
| 215 | - | "Subscribed", |
|
| 216 | - | "You're already subscribed to this publication.", |
|
| 217 | - | returnToWithDid, |
|
| 218 | - | ), |
|
| 191 | + | renderSuccess({ |
|
| 192 | + | resourceUri: publicationUri, |
|
| 193 | + | resourceLabel: "Publication", |
|
| 194 | + | recordUri: existingUri, |
|
| 195 | + | heading: "Subscribed", |
|
| 196 | + | msg: "You're already subscribed to this publication.", |
|
| 197 | + | returnTo: returnToWithDid, |
|
| 198 | + | }), |
|
| 219 | 199 | ); |
|
| 220 | 200 | } |
|
| 221 | 201 | ||
| 229 | 209 | }); |
|
| 230 | 210 | ||
| 231 | 211 | return c.html( |
|
| 232 | - | renderSuccess( |
|
| 233 | - | publicationUri, |
|
| 234 | - | result.data.uri, |
|
| 235 | - | "Subscribed", |
|
| 236 | - | "You've successfully subscribed!", |
|
| 237 | - | returnToWithDid, |
|
| 238 | - | ), |
|
| 212 | + | renderSuccess({ |
|
| 213 | + | resourceUri: publicationUri, |
|
| 214 | + | resourceLabel: "Publication", |
|
| 215 | + | recordUri: result.data.uri, |
|
| 216 | + | heading: "Subscribed", |
|
| 217 | + | msg: "You've successfully subscribed!", |
|
| 218 | + | returnTo: returnToWithDid, |
|
| 219 | + | }), |
|
| 239 | 220 | ); |
|
| 240 | 221 | } catch (error) { |
|
| 241 | 222 | console.error("Subscribe GET error:", error); |
|
| 242 | 223 | return c.html( |
|
| 243 | - | renderHandleForm( |
|
| 244 | - | publicationUri, |
|
| 224 | + | renderHandleForm({ |
|
| 225 | + | resourceUri: publicationUri, |
|
| 226 | + | resourceField: "publicationUri", |
|
| 227 | + | loginPath: "/subscribe/login", |
|
| 228 | + | title: "Subscribe on Sequoia", |
|
| 229 | + | description: |
|
| 230 | + | "Enter your Bluesky handle to subscribe to this publication.", |
|
| 231 | + | buttonLabel: "Continue on Bluesky", |
|
| 245 | 232 | returnTo, |
|
| 246 | - | "Session expired. Please sign in again.", |
|
| 233 | + | error: "Session expired. Please sign in again.", |
|
| 247 | 234 | action, |
|
| 248 | - | ), |
|
| 235 | + | }), |
|
| 249 | 236 | ); |
|
| 250 | 237 | } |
|
| 251 | 238 | }); |
|
| 273 | 260 | const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); |
|
| 274 | 261 | const session = await client.restore(did); |
|
| 275 | 262 | const agent = new Agent(session); |
|
| 276 | - | const recordUri = await findExistingSubscription( |
|
| 263 | + | const recordUri = await findExistingRecord( |
|
| 277 | 264 | agent, |
|
| 278 | 265 | did, |
|
| 266 | + | COLLECTION, |
|
| 267 | + | "publication", |
|
| 279 | 268 | publicationUri, |
|
| 280 | 269 | ); |
|
| 281 | 270 | return recordUri |
|
| 313 | 302 | `${env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`, |
|
| 314 | 303 | ); |
|
| 315 | 304 | }); |
|
| 316 | - | ||
| 317 | - | // ============================================================================ |
|
| 318 | - | // HTML rendering |
|
| 319 | - | // ============================================================================ |
|
| 320 | - | ||
| 321 | - | function renderHandleForm( |
|
| 322 | - | publicationUri: string, |
|
| 323 | - | returnTo?: string, |
|
| 324 | - | error?: string, |
|
| 325 | - | action?: string, |
|
| 326 | - | ): string { |
|
| 327 | - | const errorHtml = error ? `<p class="error">${escapeHtml(error)}</p>` : ""; |
|
| 328 | - | const returnToInput = returnTo |
|
| 329 | - | ? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />` |
|
| 330 | - | : ""; |
|
| 331 | - | const actionInput = action |
|
| 332 | - | ? `<input type="hidden" name="action" value="${escapeHtml(action)}" />` |
|
| 333 | - | : ""; |
|
| 334 | - | ||
| 335 | - | return page(` |
|
| 336 | - | <h1>Subscribe on Bluesky</h1> |
|
| 337 | - | <p>Enter your Bluesky handle to subscribe to this publication.</p> |
|
| 338 | - | ${errorHtml} |
|
| 339 | - | <form method="POST" action="/subscribe/login"> |
|
| 340 | - | <input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" /> |
|
| 341 | - | ${returnToInput} |
|
| 342 | - | ${actionInput} |
|
| 343 | - | <input |
|
| 344 | - | type="text" |
|
| 345 | - | name="handle" |
|
| 346 | - | placeholder="you.bsky.social" |
|
| 347 | - | autocomplete="username" |
|
| 348 | - | required |
|
| 349 | - | autofocus |
|
| 350 | - | /> |
|
| 351 | - | <button type="submit">Continue on Bluesky</button> |
|
| 352 | - | </form> |
|
| 353 | - | `); |
|
| 354 | - | } |
|
| 355 | - | ||
| 356 | - | function renderSuccess( |
|
| 357 | - | publicationUri: string, |
|
| 358 | - | recordUri: string | null, |
|
| 359 | - | heading: string, |
|
| 360 | - | msg: string, |
|
| 361 | - | returnTo?: string, |
|
| 362 | - | ): string { |
|
| 363 | - | const escapedPublicationUri = escapeHtml(publicationUri); |
|
| 364 | - | const escapedReturnTo = returnTo ? escapeHtml(returnTo) : ""; |
|
| 365 | - | ||
| 366 | - | const redirectHtml = returnTo |
|
| 367 | - | ? `<p id="redirect-msg">Redirecting to <a href="${escapedReturnTo}">${escapedReturnTo}</a> in <span id="countdown">${REDIRECT_DELAY_SECONDS}</span>\u00a0seconds\u2026</p> |
|
| 368 | - | <script> |
|
| 369 | - | (function(){ |
|
| 370 | - | var secs = ${REDIRECT_DELAY_SECONDS}; |
|
| 371 | - | var el = document.getElementById('countdown'); |
|
| 372 | - | var iv = setInterval(function(){ |
|
| 373 | - | secs--; |
|
| 374 | - | if (el) el.textContent = String(secs); |
|
| 375 | - | if (secs <= 0) { clearInterval(iv); location.href = ${JSON.stringify(returnTo)}; } |
|
| 376 | - | }, 1000); |
|
| 377 | - | })(); |
|
| 378 | - | </script>` |
|
| 379 | - | : ""; |
|
| 380 | - | const headExtra = returnTo |
|
| 381 | - | ? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />` |
|
| 382 | - | : ""; |
|
| 383 | - | ||
| 384 | - | return page( |
|
| 385 | - | ` |
|
| 386 | - | <h1>${escapeHtml(heading)}</h1> |
|
| 387 | - | <p>${msg}</p> |
|
| 388 | - | ${redirectHtml} |
|
| 389 | - | <table> |
|
| 390 | - | <colgroup><col style="width:7rem;"><col></colgroup> |
|
| 391 | - | <tbody> |
|
| 392 | - | <tr> |
|
| 393 | - | <td>Publication</td> |
|
| 394 | - | <td> |
|
| 395 | - | <div><code><a href="https://pds.ls/${escapedPublicationUri}">${escapedPublicationUri}</a></code></div> |
|
| 396 | - | </td> |
|
| 397 | - | </tr> |
|
| 398 | - | ${ |
|
| 399 | - | recordUri |
|
| 400 | - | ? `<tr> |
|
| 401 | - | <td>Record</td> |
|
| 402 | - | <td> |
|
| 403 | - | <div><code><a href="https://pds.ls/${escapeHtml(recordUri)}">${escapeHtml(recordUri)}</a></code></div> |
|
| 404 | - | </td> |
|
| 405 | - | </tr>` |
|
| 406 | - | : "" |
|
| 407 | - | } |
|
| 408 | - | </tbody> |
|
| 409 | - | </table> |
|
| 410 | - | `, |
|
| 411 | - | headExtra, |
|
| 412 | - | ); |
|
| 413 | - | } |
|
| 414 | - | ||
| 415 | - | function renderError(message: string): string { |
|
| 416 | - | return page(`<h1>Error</h1><p class="error">${escapeHtml(message)}</p>`); |
|
| 417 | - | } |
|
| 418 | 305 | ||
| 419 | 306 | export default subscribe; |
|