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
Compare changes
Choose any two refs to compare.
base:
various-fixes
updated-blentos
update-docs
timer-card-tiny-fix
theme-colors
switch-map
switch-grid-layout
statusphere-fix
small-fixes
signup
show-login-error
section-settings
section-fix-undo
remove-extra-buttons
refactor-cards
refactor
record-visualizer-card
qr-codes
profile-stuff-2
profile-stuff
product-hunt
polijn/main
pages
npmx
next
new-og-image-wip
move-qr-click
mobile-editing
map
main-favicon
main
mail-icon
lastfm
kickstarter-card
invalid-handle-fix
improve-saving
improve-oauth
improve-link-card
improve-fluid-text
image-fixes
hide-friends
github-contribs
gifs-heypster
funding
fuck-another-fix
floating-button
fixes
fix-xss
fix-timer-stuff
fix-signup-pds
fix-package-manager
fix-other-handles-on-custom-domain
fix-invalid-site.standard.documents
fix-formatting
fix-favicon
fix-build
event-card
edit-profile
drawing-card
custom-domains-editing
custom-domains
copy-page
card-label
card-command-bar-v2
card-command-bar
button
bump-text-padding
bluesky-post-nsfw-labels
bluesky-post-card
bluesky-feed-card
apple-music-playlist
no tags found
compare:
various-fixes
updated-blentos
update-docs
timer-card-tiny-fix
theme-colors
switch-map
switch-grid-layout
statusphere-fix
small-fixes
signup
show-login-error
section-settings
section-fix-undo
remove-extra-buttons
refactor-cards
refactor
record-visualizer-card
qr-codes
profile-stuff-2
profile-stuff
product-hunt
polijn/main
pages
npmx
next
new-og-image-wip
move-qr-click
mobile-editing
map
main-favicon
main
mail-icon
lastfm
kickstarter-card
invalid-handle-fix
improve-saving
improve-oauth
improve-link-card
improve-fluid-text
image-fixes
hide-friends
github-contribs
gifs-heypster
funding
fuck-another-fix
floating-button
fixes
fix-xss
fix-timer-stuff
fix-signup-pds
fix-package-manager
fix-other-handles-on-custom-domain
fix-invalid-site.standard.documents
fix-formatting
fix-favicon
fix-build
event-card
edit-profile
drawing-card
custom-domains-editing
custom-domains
copy-page
card-label
card-command-bar-v2
card-command-bar
button
bump-text-padding
bluesky-post-nsfw-labels
bluesky-post-card
bluesky-feed-card
apple-music-playlist
no tags found
go
+604
-305
18 changed files
expand all
collapse all
unified
split
package.json
src
lib
cards
social
NpmxLikesCard
NpmxLikesCard.svelte
website
ThemeScript.svelte
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
[[page]]
+layout.server.ts
+page.svelte
copy
+page.svelte
edit
+page.svelte
p
[[page]]
+layout.server.ts
+page.svelte
copy
+page.svelte
edit
+page.svelte
+1
-1
package.json
···
5
5
"type": "module",
6
6
"scripts": {
7
7
"dev": "vite dev",
8
8
-
"build": "vite build",
8
8
+
"build": "NODE_OPTIONS='--max-old-space-size=4096' vite build",
9
9
"preview": "pnpm run build && wrangler dev",
10
10
"prepare": "svelte-kit sync || echo ''",
11
11
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+2
-2
src/lib/cards/social/NpmxLikesCard/NpmxLikesCard.svelte
···
2
2
import type { Item } from '$lib/types';
3
3
import { onMount } from 'svelte';
4
4
import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context';
5
5
-
import { NpmxLikesCardDefinition } from '.';
5
5
+
import { CardDefinitionsByType } from '../..';
6
6
import { RelativeTime } from '@foxui/time';
7
7
8
8
interface NpmxLike {
···
25
25
onMount(async () => {
26
26
if (feed) return;
27
27
28
28
-
feed = (await NpmxLikesCardDefinition.loadData?.([], {
28
28
+
feed = (await CardDefinitionsByType[item.cardType]?.loadData?.([], {
29
29
did,
30
30
handle
31
31
})) as NpmxLike[] | undefined;
-17
src/lib/website/ThemeScript.svelte
···
1
1
<script lang="ts">
2
2
-
import { browser } from '$app/environment';
3
3
-
4
2
let {
5
3
accentColor = 'pink',
6
4
baseColor = 'stone'
···
9
7
baseColor?: string;
10
8
} = $props();
11
9
12
12
-
const allAccentColors = [
13
13
-
'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal',
14
14
-
'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose'
15
15
-
];
16
16
-
const allBaseColors = ['gray', 'stone', 'zinc', 'neutral', 'slate'];
17
17
-
18
10
const safeJson = (v: string) => JSON.stringify(v).replace(/</g, '\\u003c');
19
11
20
20
-
// SSR: inline script for initial page load (no FOUC)
21
12
let script = $derived(
22
13
`<script>(function(){document.documentElement.classList.add(${safeJson(accentColor)},${safeJson(baseColor)});})();<` +
23
14
'/script>'
24
15
);
25
25
-
26
26
-
// Client: reactive effect for client-side navigations
27
27
-
$effect(() => {
28
28
-
if (!browser) return;
29
29
-
const el = document.documentElement;
30
30
-
el.classList.remove(...allAccentColors, ...allBaseColors);
31
31
-
el.classList.add(accentColor, baseColor);
32
32
-
});
33
16
</script>
34
17
35
18
<svelte:head>
+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;
+13
src/routes/[handle=handle]/(pages)/+layout.server.ts
···
1
1
+
import { loadData } from '$lib/website/load';
2
2
+
import { env } from '$env/dynamic/private';
3
3
+
import { error } from '@sveltejs/kit';
4
4
+
import type { UserCache } from '$lib/types';
5
5
+
import type { Handle } from '@atcute/lexicons';
6
6
+
7
7
+
export async function load({ params, platform }) {
8
8
+
if (env.PUBLIC_IS_SELFHOSTED) error(404);
9
9
+
10
10
+
const cache = platform?.env?.USER_DATA_CACHE as unknown;
11
11
+
12
12
+
return await loadData(params.handle as Handle, cache as UserCache, false, params.page);
13
13
+
}
+13
src/routes/[handle=handle]/(pages)/+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} />
+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} />
+252
src/routes/[handle=handle]/(pages)/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/[handle=handle]/(pages)/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} />
-13
src/routes/[handle=handle]/[[page]]/+layout.server.ts
···
1
1
-
import { loadData } from '$lib/website/load';
2
2
-
import { env } from '$env/dynamic/private';
3
3
-
import { error } from '@sveltejs/kit';
4
4
-
import type { UserCache } from '$lib/types';
5
5
-
import type { Handle } from '@atcute/lexicons';
6
6
-
7
7
-
export async function load({ params, platform }) {
8
8
-
if (env.PUBLIC_IS_SELFHOSTED) error(404);
9
9
-
10
10
-
const cache = platform?.env?.USER_DATA_CACHE as unknown;
11
11
-
12
12
-
return await loadData(params.handle as Handle, cache as UserCache, false, params.page);
13
13
-
}
-13
src/routes/[handle=handle]/[[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/[handle=handle]/[[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/[handle=handle]/[[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} />
+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} />