docs/src/routes/lib.ts 6.8 K raw
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, "&amp;")
244
		.replace(/</g, "&lt;")
245
		.replace(/>/g, "&gt;")
246
		.replace(/"/g, "&quot;");
247
}