your personal website on atproto - mirror blento.app

Compare changes

Choose any two refs to compare.

+372 -31
+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]); 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 171 }; 172 173 export const platformsData: Record<string, SimpleIcon> = { ··· 277 </g> 278 <defs> 279 <clipPath id="clip0_0_3"> 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'); 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; 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; 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; 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'; 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); 161 ctx.globalCompositeOperation = 'destination-out'; 162 ctx.fillText(text, width / 2, textY); 163 ctx.globalCompositeOperation = 'source-over'; ··· 214 if (isInitialized) scheduleMaskDraw(); 215 }); 216 } 217 }); 218 219 onDestroy(() => { ··· 221 if (splatIntervalId) clearInterval(splatIntervalId); 222 if (maskDrawRaf) cancelAnimationFrame(maskDrawRaf); 223 if (resizeObserver) resizeObserver.disconnect(); 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> 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; 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'; 7 import MadeWithBlento from './MadeWithBlento.svelte'; 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> 964 </div> 965 {/if} 966 </Context>
+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>