tangled
alpha
login
or
join now
flo-bit.dev
/
blento
your personal website on atproto - mirror
blento.app
20
fork
atom
overview
issues
pulls
pipelines
move pages to handle/p/..., make it work on custom domains
Florian
3 days ago
8f503224
f7f0d652
+317
-1
11 changed files
expand all
collapse all
unified
split
src
params
handle.ts
routes
[handle=handle]
(pages)
+layout.server.ts
+page.svelte
edit
+page.svelte
p
[[page]]
+page.svelte
copy
+page.svelte
edit
+page.svelte
p
[[page]]
+layout.server.ts
+page.svelte
copy
+page.svelte
edit
+page.svelte
+2
-1
src/params/handle.ts
···
0
1
import type { ParamMatcher } from '@sveltejs/kit';
2
3
export const match = ((param: string) => {
4
-
return param.includes('.') || param.startsWith('did:');
5
}) satisfies ParamMatcher;
···
1
+
import { isActorIdentifier } from '@atcute/lexicons/syntax';
2
import type { ParamMatcher } from '@sveltejs/kit';
3
4
export const match = ((param: string) => {
5
+
return isActorIdentifier(param);
6
}) satisfies ParamMatcher;
+6
src/routes/[handle=handle]/(pages)/edit/+page.svelte
···
0
0
0
0
0
0
···
1
+
<script lang="ts">
2
+
import EditableWebsite from '$lib/website/EditableWebsite.svelte';
3
+
let { data } = $props();
4
+
</script>
5
+
6
+
<EditableWebsite {data} />
+13
src/routes/[handle=handle]/(pages)/p/[[page]]/+page.svelte
···
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<script lang="ts">
2
+
import { refreshData } from '$lib/helper.js';
3
+
import Website from '$lib/website/Website.svelte';
4
+
import { onMount } from 'svelte';
5
+
6
+
let { data } = $props();
7
+
8
+
onMount(() => {
9
+
refreshData(data);
10
+
});
11
+
</script>
12
+
13
+
<Website {data} />
src/routes/[handle=handle]/[[page]]/+layout.server.ts
src/routes/[handle=handle]/(pages)/+layout.server.ts
src/routes/[handle=handle]/[[page]]/+page.svelte
src/routes/[handle=handle]/(pages)/+page.svelte
src/routes/[handle=handle]/[[page]]/copy/+page.svelte
src/routes/[handle=handle]/(pages)/p/[[page]]/copy/+page.svelte
src/routes/[handle=handle]/[[page]]/edit/+page.svelte
src/routes/[handle=handle]/(pages)/p/[[page]]/edit/+page.svelte
+25
src/routes/p/[[page]]/+layout.server.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import { loadData } from '$lib/website/load';
2
+
import { env } from '$env/dynamic/public';
3
+
import type { UserCache } from '$lib/types';
4
+
import type { Did, Handle } from '@atcute/lexicons';
5
+
6
+
export async function load({ params, platform, request }) {
7
+
const cache = platform?.env?.USER_DATA_CACHE as unknown;
8
+
9
+
const handle = env.PUBLIC_HANDLE;
10
+
11
+
const kv = platform?.env?.CUSTOM_DOMAINS;
12
+
13
+
const customDomain = request.headers.get('X-Custom-Domain')?.toLocaleLowerCase();
14
+
15
+
if (kv && customDomain) {
16
+
try {
17
+
const did = await kv.get(customDomain);
18
+
return await loadData(did as Did, cache as UserCache, false, params.page);
19
+
} catch {
20
+
console.error('failed');
21
+
}
22
+
}
23
+
24
+
return await loadData(handle as Handle, cache as UserCache, false, params.page);
25
+
}
+13
src/routes/p/[[page]]/+page.svelte
···
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<script lang="ts">
2
+
import { refreshData } from '$lib/helper.js';
3
+
import Website from '$lib/website/Website.svelte';
4
+
import { onMount } from 'svelte';
5
+
6
+
let { data } = $props();
7
+
8
+
onMount(() => {
9
+
refreshData(data);
10
+
});
11
+
</script>
12
+
13
+
<Website {data} />
+252
src/routes/p/[[page]]/copy/+page.svelte
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<script lang="ts">
2
+
import {
3
+
putRecord,
4
+
deleteRecord,
5
+
listRecords,
6
+
uploadBlob,
7
+
getCDNImageBlobUrl
8
+
} from '$lib/atproto/methods';
9
+
import { user } from '$lib/atproto/auth.svelte';
10
+
import { goto } from '$app/navigation';
11
+
import * as TID from '@atcute/tid';
12
+
import { Button } from '@foxui/core';
13
+
import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte';
14
+
15
+
let { data } = $props();
16
+
17
+
let destinationPage = $state('');
18
+
let copying = $state(false);
19
+
let error = $state('');
20
+
let success = $state(false);
21
+
22
+
const sourceHandle = $derived(data.handle);
23
+
24
+
const sourcePage = $derived(
25
+
data.page === 'blento.self' ? 'main' : data.page.replace('blento.', '')
26
+
);
27
+
const sourceCards = $derived(data.cards);
28
+
29
+
// Re-upload blobs from source repo to current user's repo
30
+
async function reuploadBlobs(obj: any, sourceDid: string): Promise<void> {
31
+
if (!obj || typeof obj !== 'object') return;
32
+
33
+
for (const key of Object.keys(obj)) {
34
+
const value = obj[key];
35
+
36
+
if (value && typeof value === 'object') {
37
+
// Check if this is a blob reference
38
+
if (value.$type === 'blob' && value.ref?.$link) {
39
+
try {
40
+
// Get the blob URL from source repo
41
+
const blobUrl = getCDNImageBlobUrl({ did: sourceDid, blob: value });
42
+
if (!blobUrl) continue;
43
+
44
+
// Fetch the blob via proxy to avoid CORS
45
+
const response = await fetch(`/api/image-proxy?url=${encodeURIComponent(blobUrl)}`);
46
+
if (!response.ok) {
47
+
console.error('Failed to fetch blob:', blobUrl);
48
+
continue;
49
+
}
50
+
51
+
// Upload to current user's repo
52
+
const blob = await response.blob();
53
+
const newBlobRef = await uploadBlob({ blob });
54
+
55
+
if (newBlobRef) {
56
+
// Replace with new blob reference
57
+
obj[key] = newBlobRef;
58
+
}
59
+
} catch (err) {
60
+
console.error('Failed to re-upload blob:', err);
61
+
}
62
+
} else {
63
+
// Recursively check nested objects
64
+
await reuploadBlobs(value, sourceDid);
65
+
}
66
+
}
67
+
}
68
+
}
69
+
70
+
async function copyPage() {
71
+
if (!user.isLoggedIn || !user.did) {
72
+
error = 'You must be logged in to copy pages';
73
+
return;
74
+
}
75
+
76
+
copying = true;
77
+
error = '';
78
+
79
+
try {
80
+
const targetPage =
81
+
destinationPage.trim() === '' ? 'blento.self' : `blento.${destinationPage.trim()}`;
82
+
83
+
// Fetch existing cards from destination page and delete them
84
+
const existingCards = await listRecords({
85
+
did: user.did,
86
+
collection: 'app.blento.card'
87
+
});
88
+
89
+
const cardsToDelete = existingCards.filter(
90
+
(card: { value: { page?: string } }) => card.value.page === targetPage
91
+
);
92
+
93
+
// Delete existing cards from destination page
94
+
const deletePromises = cardsToDelete.map((card: { uri: string }) => {
95
+
const rkey = card.uri.split('/').pop()!;
96
+
return deleteRecord({
97
+
collection: 'app.blento.card',
98
+
rkey
99
+
});
100
+
});
101
+
102
+
await Promise.all(deletePromises);
103
+
104
+
// Copy each card with a new ID to the destination page
105
+
// Re-upload blobs from source repo to current user's repo
106
+
for (const card of sourceCards) {
107
+
const newCard = {
108
+
...structuredClone(card),
109
+
id: TID.now(),
110
+
page: targetPage,
111
+
updatedAt: new Date().toISOString(),
112
+
version: 2
113
+
};
114
+
115
+
// Re-upload any blobs in cardData
116
+
await reuploadBlobs(newCard.cardData, data.did);
117
+
118
+
await putRecord({
119
+
collection: 'app.blento.card',
120
+
rkey: newCard.id,
121
+
record: newCard
122
+
});
123
+
}
124
+
125
+
const userHandle = user.profile?.handle ?? data.handle;
126
+
127
+
// Copy publication data if it exists
128
+
if (data.publication) {
129
+
const publicationCopy = structuredClone(data.publication) as Record<string, unknown>;
130
+
131
+
// Re-upload any blobs in publication (e.g., icon)
132
+
await reuploadBlobs(publicationCopy, data.did);
133
+
134
+
// Update the URL to point to the user's page
135
+
publicationCopy.url = `https://blento.app/${userHandle}`;
136
+
if (targetPage !== 'blento.self') {
137
+
publicationCopy.url += '/' + targetPage.replace('blento.', '');
138
+
}
139
+
140
+
// Save to appropriate collection based on destination page type
141
+
if (targetPage === 'blento.self') {
142
+
await putRecord({
143
+
collection: 'site.standard.publication',
144
+
rkey: targetPage,
145
+
record: publicationCopy
146
+
});
147
+
} else {
148
+
await putRecord({
149
+
collection: 'app.blento.page',
150
+
rkey: targetPage,
151
+
record: publicationCopy
152
+
});
153
+
}
154
+
}
155
+
156
+
// Refresh the logged-in user's cache
157
+
await fetch(`/${userHandle}/api/refresh`);
158
+
159
+
success = true;
160
+
161
+
// Redirect to the logged-in user's destination page edit
162
+
const destPath = destinationPage.trim() === '' ? '' : `/${destinationPage.trim()}`;
163
+
setTimeout(() => {
164
+
goto(`/${userHandle}${destPath}/edit`);
165
+
}, 1000);
166
+
} catch (e) {
167
+
error = e instanceof Error ? e.message : 'Failed to copy page';
168
+
} finally {
169
+
copying = false;
170
+
}
171
+
}
172
+
</script>
173
+
174
+
<div class="bg-base-50 dark:bg-base-900 flex min-h-screen items-center justify-center p-4">
175
+
<div class="bg-base-100 dark:bg-base-800 w-full max-w-md rounded-2xl p-6 shadow-lg">
176
+
{#if user.isLoggedIn}
177
+
<h1 class="text-base-900 dark:text-base-50 mb-6 text-2xl font-bold">Copy Page</h1>
178
+
179
+
<div class="mb-4">
180
+
<div class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium">
181
+
Source Page
182
+
</div>
183
+
<div
184
+
class="bg-base-200 dark:bg-base-700 text-base-900 dark:text-base-100 rounded-lg px-3 py-2"
185
+
>
186
+
{sourceHandle}/{sourcePage || 'main'}
187
+
</div>
188
+
<p class="text-base-500 mt-1 text-sm">{sourceCards.length} cards</p>
189
+
</div>
190
+
191
+
<div class="mb-6">
192
+
<label
193
+
for="destination"
194
+
class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium"
195
+
>
196
+
Destination Page (on your profile: {user.profile?.handle})
197
+
</label>
198
+
<input
199
+
id="destination"
200
+
type="text"
201
+
bind:value={destinationPage}
202
+
placeholder="Leave empty for main page"
203
+
class="bg-base-50 dark:bg-base-700 border-base-300 dark:border-base-600 text-base-900 dark:text-base-100 focus:ring-accent-500 w-full rounded-lg border px-3 py-2 focus:ring-2 focus:outline-none"
204
+
/>
205
+
</div>
206
+
207
+
{#if error}
208
+
<div
209
+
class="mb-4 rounded-lg bg-red-100 p-3 text-red-700 dark:bg-red-900/30 dark:text-red-400"
210
+
>
211
+
{error}
212
+
</div>
213
+
{/if}
214
+
215
+
{#if success}
216
+
<div
217
+
class="mb-4 rounded-lg bg-green-100 p-3 text-green-700 dark:bg-green-900/30 dark:text-green-400"
218
+
>
219
+
Page copied successfully! Redirecting...
220
+
</div>
221
+
{/if}
222
+
223
+
<div class="flex gap-3">
224
+
<a
225
+
href="/{data.handle}/{sourcePage === 'main' ? '' : sourcePage}"
226
+
class="bg-base-200 hover:bg-base-300 dark:bg-base-700 dark:hover:bg-base-600 text-base-700 dark:text-base-300 flex-1 rounded-lg px-4 py-2 text-center font-medium transition-colors"
227
+
>
228
+
Cancel
229
+
</a>
230
+
<button
231
+
onclick={copyPage}
232
+
disabled={copying || success}
233
+
class="bg-accent-500 hover:bg-accent-600 flex-1 rounded-lg px-4 py-2 font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
234
+
>
235
+
{#if copying}
236
+
Copying...
237
+
{:else}
238
+
Copy {sourceCards.length} cards
239
+
{/if}
240
+
</button>
241
+
</div>
242
+
{:else}
243
+
<h1 class="text-base-900 dark:text-base-50 mb-6 text-2xl font-bold">
244
+
You must be signed in to copy a page!
245
+
</h1>
246
+
247
+
<div class="flex w-full justify-center">
248
+
<Button size="lg" onclick={() => loginModalState.show()}>Login</Button>
249
+
</div>
250
+
{/if}
251
+
</div>
252
+
</div>
+6
src/routes/p/[[page]]/edit/+page.svelte
···
0
0
0
0
0
0
···
1
+
<script lang="ts">
2
+
import EditableWebsite from '$lib/website/EditableWebsite.svelte';
3
+
let { data } = $props();
4
+
</script>
5
+
6
+
<EditableWebsite {data} />