your personal website on atproto - mirror blento.app

Compare changes

Choose any two refs to compare.

+604 -305
+1 -1
package.json
··· 5 5 "type": "module", 6 6 "scripts": { 7 7 "dev": "vite dev", 8 - "build": "vite build", 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 - import { NpmxLikesCardDefinition } from '.'; 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 - feed = (await NpmxLikesCardDefinition.loadData?.([], { 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 - import { browser } from '$app/environment'; 3 - 4 2 let { 5 3 accentColor = 'pink', 6 4 baseColor = 'stone' ··· 9 7 baseColor?: string; 10 8 } = $props(); 11 9 12 - const allAccentColors = [ 13 - 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 14 - 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose' 15 - ]; 16 - const allBaseColors = ['gray', 'stone', 'zinc', 'neutral', 'slate']; 17 - 18 10 const safeJson = (v: string) => JSON.stringify(v).replace(/</g, '\\u003c'); 19 11 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 - 26 - // Client: reactive effect for client-side navigations 27 - $effect(() => { 28 - if (!browser) return; 29 - const el = document.documentElement; 30 - el.classList.remove(...allAccentColors, ...allBaseColors); 31 - el.classList.add(accentColor, baseColor); 32 - }); 33 16 </script> 34 17 35 18 <svelte:head>
+2 -1
src/params/handle.ts
··· 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 - return param.includes('.') || param.startsWith('did:'); 5 + return isActorIdentifier(param); 5 6 }) satisfies ParamMatcher;
+13
src/routes/[handle=handle]/(pages)/+layout.server.ts
··· 1 + import { loadData } from '$lib/website/load'; 2 + import { env } from '$env/dynamic/private'; 3 + import { error } from '@sveltejs/kit'; 4 + import type { UserCache } from '$lib/types'; 5 + import type { Handle } from '@atcute/lexicons'; 6 + 7 + export async function load({ params, platform }) { 8 + if (env.PUBLIC_IS_SELFHOSTED) error(404); 9 + 10 + const cache = platform?.env?.USER_DATA_CACHE as unknown; 11 + 12 + return await loadData(params.handle as Handle, cache as UserCache, false, params.page); 13 + }
+13
src/routes/[handle=handle]/(pages)/+page.svelte
··· 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} />
+6
src/routes/[handle=handle]/(pages)/edit/+page.svelte
··· 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
··· 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/[handle=handle]/(pages)/p/[[page]]/copy/+page.svelte
··· 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/[handle=handle]/(pages)/p/[[page]]/edit/+page.svelte
··· 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]/[[page]]/+layout.server.ts
··· 1 - import { loadData } from '$lib/website/load'; 2 - import { env } from '$env/dynamic/private'; 3 - import { error } from '@sveltejs/kit'; 4 - import type { UserCache } from '$lib/types'; 5 - import type { Handle } from '@atcute/lexicons'; 6 - 7 - export async function load({ params, platform }) { 8 - if (env.PUBLIC_IS_SELFHOSTED) error(404); 9 - 10 - const cache = platform?.env?.USER_DATA_CACHE as unknown; 11 - 12 - return await loadData(params.handle as Handle, cache as UserCache, false, params.page); 13 - }
-13
src/routes/[handle=handle]/[[page]]/+page.svelte
··· 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/[handle=handle]/[[page]]/copy/+page.svelte
··· 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/[handle=handle]/[[page]]/edit/+page.svelte
··· 1 - <script lang="ts"> 2 - import EditableWebsite from '$lib/website/EditableWebsite.svelte'; 3 - let { data } = $props(); 4 - </script> 5 - 6 - <EditableWebsite {data} />
+25
src/routes/p/[[page]]/+layout.server.ts
··· 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
··· 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
··· 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
··· 1 + <script lang="ts"> 2 + import EditableWebsite from '$lib/website/EditableWebsite.svelte'; 3 + let { data } = $props(); 4 + </script> 5 + 6 + <EditableWebsite {data} />