tangled
alpha
login
or
join now
flo-bit.dev
/
blento
your personal website on atproto - mirror
blento.app
17
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
record-visualizer-card
qr-codes
profile-stuff-2
profile-stuff
product-hunt
polijn/main
next
new-og-image-wip
move-qr-click
mobile-editing
map
main-favicon
main
mail-icon
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-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
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
record-visualizer-card
qr-codes
profile-stuff-2
profile-stuff
product-hunt
polijn/main
next
new-og-image-wip
move-qr-click
mobile-editing
map
main-favicon
main
mail-icon
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-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
bluesky-post-nsfw-labels
bluesky-post-card
bluesky-feed-card
apple-music-playlist
no tags found
go
+372
-31
7 changed files
expand all
collapse all
unified
split
src
lib
cards
social
BigSocialCard
BigSocialCard.svelte
index.ts
special
UpdatedBlentos
index.ts
visual
FluidTextCard
FluidTextCard.svelte
website
EditableProfile.svelte
EditableWebsite.svelte
routes
[handle=handle]
[[page]]
copy
+page.svelte
+9
-1
src/lib/cards/social/BigSocialCard/BigSocialCard.svelte
···
7
8
const platform = $derived(item.cardData.platform as string);
9
const platformData = $derived(platformsData[platform]);
0
0
0
0
0
0
0
0
10
</script>
11
12
<div
13
class="flex h-full w-full items-center justify-center p-10"
14
-
style={`background-color: #${item.cardData.color}`}
15
>
16
<div
17
class="flex aspect-square max-h-full max-w-full items-center justify-center [&_svg]:size-full [&_svg]:max-w-60 [&_svg]:fill-white"
···
7
8
const platform = $derived(item.cardData.platform as string);
9
const platformData = $derived(platformsData[platform]);
10
+
11
+
// Color logic:
12
+
// - base/transparent/undefined: background = brand color, icon = white
13
+
// - other: background = that color (from BaseCard), icon = white
14
+
const useBrandBackground = $derived(
15
+
!item.color || item.color === 'base' || item.color === 'transparent'
16
+
);
17
+
const brandColor = $derived(`#${item.cardData.color}`);
18
</script>
19
20
<div
21
class="flex h-full w-full items-center justify-center p-10"
22
+
style={useBrandBackground ? `background-color: ${brandColor}` : ''}
23
>
24
<div
25
class="flex aspect-square max-h-full max-w-full items-center justify-center [&_svg]:size-full [&_svg]:max-w-60 [&_svg]:fill-white"
+24
-3
src/lib/cards/social/BigSocialCard/index.ts
···
36
return item;
37
},
38
name: 'Social Icon',
39
-
allowSetColor: false,
40
-
defaultColor: 'transparent',
41
minW: 2,
42
minH: 2,
43
onUrlHandler: (url, item) => {
···
167
168
tangled: /(?:tangled\.org)/i,
169
170
-
mail: /(?:mailto:)/i
0
0
171
};
172
173
export const platformsData: Record<string, SimpleIcon> = {
···
277
</g>
278
<defs>
279
<clipPath id="clip0_0_3">
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
280
<rect width="24" height="24" fill="white"/>
281
</clipPath>
282
</defs>
···
36
return item;
37
},
38
name: 'Social Icon',
39
+
allowSetColor: true,
40
+
defaultColor: 'base',
41
minW: 2,
42
minH: 2,
43
onUrlHandler: (url, item) => {
···
167
168
tangled: /(?:tangled\.org)/i,
169
170
+
mail: /(?:mailto:)/i,
171
+
172
+
npmx: /(?:npmx\.dev)/i
173
};
174
175
export const platformsData: Record<string, SimpleIcon> = {
···
279
</g>
280
<defs>
281
<clipPath id="clip0_0_3">
282
+
<rect width="24" height="24" fill="white"/>
283
+
</clipPath>
284
+
</defs>
285
+
</svg>`
286
+
},
287
+
288
+
npmx: {
289
+
slug: 'npmx',
290
+
path: '',
291
+
title: 'npmx',
292
+
source: '',
293
+
hex: '0A0A0A',
294
+
svg: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
295
+
<g clip-path="url(#clip0_4_2)">
296
+
<path d="M6.12765 16.4049H2V20.5326H6.12765V16.4049Z" fill="#525252"/>
297
+
<path d="M10.9049 23.8485L19.6885 -1H22L13.2164 23.8485H10.9049Z" fill="#FAFAFA"/>
298
+
</g>
299
+
<defs>
300
+
<clipPath id="clip0_4_2">
301
<rect width="24" height="24" fill="white"/>
302
</clipPath>
303
</defs>
+4
-2
src/lib/cards/special/UpdatedBlentos/index.ts
···
46
47
let result = [...(await Promise.all(profiles)), ...existingUsersArray];
48
49
-
result = result.filter((v) => v && v.handle !== 'handle.invalid');
0
0
50
51
if (cache) {
52
await cache?.put('updatedBlentos', JSON.stringify(result));
53
}
54
-
return JSON.parse(JSON.stringify(result));
55
} catch (error) {
56
console.error('error fetching updated blentos', error);
57
return [];
···
46
47
let result = [...(await Promise.all(profiles)), ...existingUsersArray];
48
49
+
result = result.filter(
50
+
(v) => v && v.handle !== 'handle.invalid' && !v.handle.endsWith('.pds.rip')
51
+
);
52
53
if (cache) {
54
await cache?.put('updatedBlentos', JSON.stringify(result));
55
}
56
+
return JSON.parse(JSON.stringify(result.slice(0, 20)));
57
} catch (error) {
58
console.error('error fetching updated blentos', error);
59
return [];
+82
-10
src/lib/cards/visual/FluidTextCard/FluidTextCard.svelte
···
1
<script lang="ts">
2
-
import { colorToHue, getCSSVar, getHexOfCardColor } from '../../helper';
3
import type { ContentComponentProps } from '../../types';
4
import { onMount, onDestroy, tick } from 'svelte';
5
let { item }: ContentComponentProps = $props();
···
7
let container: HTMLDivElement;
8
let fluidCanvas: HTMLCanvasElement;
9
let maskCanvas: HTMLCanvasElement;
0
10
let animationId: number;
11
let splatIntervalId: ReturnType<typeof setInterval>;
12
let maskDrawRaf = 0;
13
let maskReady = false;
14
let isInitialized = $state(false);
15
let resizeObserver: ResizeObserver | null = null;
0
16
17
// Pure hash function for shader keyword caching
18
function hashCode(s: string) {
···
122
if (width === 0 || height === 0) return;
123
124
const dpr = window.devicePixelRatio || 1;
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
125
126
maskCanvas.width = width * dpr;
127
maskCanvas.height = height * dpr;
···
132
ctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
133
ctx.scale(dpr, dpr);
134
135
-
//const color = getCSSVar('--color-base-900');
136
-
137
-
ctx.fillStyle = 'black';
0
0
138
ctx.fillRect(0, 0, width, height);
139
140
// Font size as percentage of container width
141
const textFontSize = Math.round(width * fontSize);
142
ctx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`;
143
144
-
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
145
-
ctx.lineWidth = 2;
146
ctx.textAlign = 'center';
147
148
const metrics = ctx.measureText(text);
···
157
ctx.textBaseline = 'middle';
158
}
159
160
-
ctx.strokeText(text, width / 2, textY);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
161
ctx.globalCompositeOperation = 'destination-out';
162
ctx.fillText(text, width / 2, textY);
163
ctx.globalCompositeOperation = 'source-over';
···
214
if (isInitialized) scheduleMaskDraw();
215
});
216
}
0
0
0
0
0
0
0
0
0
0
0
217
});
218
219
onDestroy(() => {
···
221
if (splatIntervalId) clearInterval(splatIntervalId);
222
if (maskDrawRaf) cancelAnimationFrame(maskDrawRaf);
223
if (resizeObserver) resizeObserver.disconnect();
0
224
});
225
226
function initFluidSimulation(startHue: number, endHue: number) {
···
246
COLOR_UPDATE_SPEED: 10,
247
PAUSED: false,
248
BACK_COLOR: { r: 0, g: 0, b: 0 },
249
-
TRANSPARENT: false,
250
BLOOM: false,
251
BLOOM_ITERATIONS: 8,
252
BLOOM_RESOLUTION: 256,
···
1701
}
1702
</script>
1703
1704
-
<div bind:this={container} class="relative h-full w-full overflow-hidden bg-black">
1705
-
<canvas bind:this={fluidCanvas} class="absolute h-full w-full"></canvas>
0
0
0
0
0
0
1706
<canvas bind:this={maskCanvas} class="absolute h-full w-full"></canvas>
1707
</div>
···
1
<script lang="ts">
2
+
import { colorToHue, getHexCSSVar, getHexOfCardColor } from '../../helper';
3
import type { ContentComponentProps } from '../../types';
4
import { onMount, onDestroy, tick } from 'svelte';
5
let { item }: ContentComponentProps = $props();
···
7
let container: HTMLDivElement;
8
let fluidCanvas: HTMLCanvasElement;
9
let maskCanvas: HTMLCanvasElement;
10
+
let shadowCanvas: HTMLCanvasElement;
11
let animationId: number;
12
let splatIntervalId: ReturnType<typeof setInterval>;
13
let maskDrawRaf = 0;
14
let maskReady = false;
15
let isInitialized = $state(false);
16
let resizeObserver: ResizeObserver | null = null;
17
+
let themeObserver: MutationObserver | null = null;
18
19
// Pure hash function for shader keyword caching
20
function hashCode(s: string) {
···
124
if (width === 0 || height === 0) return;
125
126
const dpr = window.devicePixelRatio || 1;
127
+
const isDark = document.documentElement.classList.contains('dark');
128
+
129
+
// Draw shadow behind fluid (light mode only, transparent only)
130
+
if (shadowCanvas && item.color === 'transparent') {
131
+
shadowCanvas.width = width * dpr;
132
+
shadowCanvas.height = height * dpr;
133
+
const shadowCtx = shadowCanvas.getContext('2d')!;
134
+
shadowCtx.setTransform(1, 0, 0, 1, 0, 0);
135
+
shadowCtx.clearRect(0, 0, shadowCanvas.width, shadowCanvas.height);
136
+
shadowCtx.scale(dpr, dpr);
137
+
138
+
const textFontSize = Math.round(width * fontSize);
139
+
shadowCtx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`;
140
+
shadowCtx.textAlign = 'center';
141
+
142
+
const metrics = shadowCtx.measureText(text);
143
+
let textY = height / 2;
144
+
if (
145
+
metrics.actualBoundingBoxAscent !== undefined &&
146
+
metrics.actualBoundingBoxDescent !== undefined
147
+
) {
148
+
shadowCtx.textBaseline = 'alphabetic';
149
+
textY = (height + metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2;
150
+
} else {
151
+
shadowCtx.textBaseline = 'middle';
152
+
}
153
+
154
+
// Draw darkened text shape behind fluid
155
+
shadowCtx.fillStyle = getHexCSSVar(isDark ? '--color-base-950' : '--color-base-200');
156
+
shadowCtx.fillText(text, width / 2, textY);
157
+
} else if (shadowCanvas) {
158
+
// Clear shadow canvas when not transparent
159
+
shadowCanvas.width = 1;
160
+
shadowCanvas.height = 1;
161
+
}
162
163
maskCanvas.width = width * dpr;
164
maskCanvas.height = height * dpr;
···
169
ctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
170
ctx.scale(dpr, dpr);
171
172
+
const bgColor =
173
+
item.color === 'transparent'
174
+
? getHexCSSVar(isDark ? '--color-base-900' : '--color-base-50')
175
+
: 'black';
176
+
ctx.fillStyle = bgColor;
177
ctx.fillRect(0, 0, width, height);
178
179
// Font size as percentage of container width
180
const textFontSize = Math.round(width * fontSize);
181
ctx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`;
182
183
+
ctx.lineWidth = 3;
0
184
ctx.textAlign = 'center';
185
186
const metrics = ctx.measureText(text);
···
195
ctx.textBaseline = 'middle';
196
}
197
198
+
if (item.color === 'transparent') {
199
+
// Partially cut out the stroke area so fluid shows through
200
+
ctx.globalCompositeOperation = 'destination-out';
201
+
ctx.globalAlpha = 0.7;
202
+
ctx.strokeStyle = 'white';
203
+
ctx.strokeText(text, width / 2, textY);
204
+
ctx.globalAlpha = 1;
205
+
ctx.globalCompositeOperation = 'source-over';
206
+
207
+
// Add overlay: brighten in dark mode, darken in light mode
208
+
ctx.strokeStyle = isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)';
209
+
ctx.strokeText(text, width / 2, textY);
210
+
} else {
211
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
212
+
ctx.strokeText(text, width / 2, textY);
213
+
}
214
+
215
ctx.globalCompositeOperation = 'destination-out';
216
ctx.fillText(text, width / 2, textY);
217
ctx.globalCompositeOperation = 'source-over';
···
268
if (isInitialized) scheduleMaskDraw();
269
});
270
}
271
+
272
+
// Watch for dark mode changes to redraw mask with correct background
273
+
if (item.color === 'transparent') {
274
+
themeObserver = new MutationObserver(() => {
275
+
if (isInitialized) scheduleMaskDraw();
276
+
});
277
+
themeObserver.observe(document.documentElement, {
278
+
attributes: true,
279
+
attributeFilter: ['class']
280
+
});
281
+
}
282
});
283
284
onDestroy(() => {
···
286
if (splatIntervalId) clearInterval(splatIntervalId);
287
if (maskDrawRaf) cancelAnimationFrame(maskDrawRaf);
288
if (resizeObserver) resizeObserver.disconnect();
289
+
if (themeObserver) themeObserver.disconnect();
290
});
291
292
function initFluidSimulation(startHue: number, endHue: number) {
···
312
COLOR_UPDATE_SPEED: 10,
313
PAUSED: false,
314
BACK_COLOR: { r: 0, g: 0, b: 0 },
315
+
TRANSPARENT: item.color === 'transparent',
316
BLOOM: false,
317
BLOOM_ITERATIONS: 8,
318
BLOOM_RESOLUTION: 256,
···
1767
}
1768
</script>
1769
1770
+
<div
1771
+
bind:this={container}
1772
+
class="relative h-full w-full overflow-hidden rounded-[inherit] {item.color === 'transparent'
1773
+
? 'bg-base-50 dark:bg-base-900'
1774
+
: 'bg-black'}"
1775
+
>
1776
+
<canvas bind:this={shadowCanvas} class="absolute inset-0.5 h-[calc(100%-4px)] w-[calc(100%-4px)]"></canvas>
1777
+
<canvas bind:this={fluidCanvas} class="absolute inset-0.5 h-[calc(100%-4px)] w-[calc(100%-4px)]"></canvas>
1778
<canvas bind:this={maskCanvas} class="absolute h-full w-full"></canvas>
1779
</div>
+1
-3
src/lib/website/EditableProfile.svelte
···
3
import { getImage, compressImage, getProfilePosition } from '$lib/helper';
4
import PlainTextEditor from '$lib/components/PlainTextEditor.svelte';
5
import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte';
6
-
import { Avatar, Button } from '@foxui/core';
7
-
import { getIsMobile } from './context';
8
import MadeWithBlento from './MadeWithBlento.svelte';
9
-
import { SelectThemePopover } from '$lib/components/select-theme';
10
11
let { data = $bindable(), hideBlento = false }: { data: WebsiteData; hideBlento?: boolean } =
12
$props();
···
3
import { getImage, compressImage, getProfilePosition } from '$lib/helper';
4
import PlainTextEditor from '$lib/components/PlainTextEditor.svelte';
5
import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte';
6
+
import { Avatar } from '@foxui/core';
0
7
import MadeWithBlento from './MadeWithBlento.svelte';
0
8
9
let { data = $bindable(), hideBlento = false }: { data: WebsiteData; hideBlento?: boolean } =
10
$props();
-12
src/lib/website/EditableWebsite.svelte
···
961
class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 flex items-center gap-2 rounded px-2 py-1 font-mono text-xs"
962
>
963
<span>editedOn: {editedOn}</span>
964
-
<button class="underline" onclick={addAllCardTypes}>+ all cards</button>
965
-
<input
966
-
bind:value={copyInput}
967
-
placeholder="handle/page"
968
-
class="bg-base-800 text-base-100 w-32 rounded px-1 py-0.5"
969
-
onkeydown={(e) => {
970
-
if (e.key === 'Enter') copyPageFrom();
971
-
}}
972
-
/>
973
-
<button class="underline" onclick={copyPageFrom} disabled={isCopying}>
974
-
{isCopying ? 'copying...' : 'copy'}
975
-
</button>
976
</div>
977
{/if}
978
</Context>
···
961
class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 flex items-center gap-2 rounded px-2 py-1 font-mono text-xs"
962
>
963
<span>editedOn: {editedOn}</span>
0
0
0
0
0
0
0
0
0
0
0
0
964
</div>
965
{/if}
966
</Context>
+252
src/routes/[handle=handle]/[[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>