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