your personal website on atproto - mirror blento.app

Compare changes

Choose any two refs to compare.

+701 -32
+5 -1
src/lib/cards/index.ts
··· 40 40 import { GitHubContributorsCardDefinition } from './social/GitHubContributorsCard'; 41 41 import { ProductHuntCardDefinition } from './social/ProductHuntCard'; 42 42 import { KickstarterCardDefinition } from './social/KickstarterCard'; 43 + import { NpmxLikesCardDefinition } from './social/NpmxLikesCard'; 44 + import { NpmxLikesLeaderboardCardDefinition } from './social/NpmxLikesLeaderboardCard'; 43 45 // import { Model3DCardDefinition } from './visual/Model3DCard'; 44 46 45 47 export const AllCardDefinitions = [ ··· 84 86 FriendsCardDefinition, 85 87 GitHubContributorsCardDefinition, 86 88 ProductHuntCardDefinition, 87 - KickstarterCardDefinition 89 + KickstarterCardDefinition, 90 + NpmxLikesCardDefinition, 91 + NpmxLikesLeaderboardCardDefinition 88 92 ] as const; 89 93 90 94 export const CardDefinitionsByType = AllCardDefinitions.reduce(
+9 -1
src/lib/cards/social/BigSocialCard/BigSocialCard.svelte
··· 7 7 8 8 const platform = $derived(item.cardData.platform as string); 9 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}`); 10 18 </script> 11 19 12 20 <div 13 21 class="flex h-full w-full items-center justify-center p-10" 14 - style={`background-color: #${item.cardData.color}`} 22 + style={useBrandBackground ? `background-color: ${brandColor}` : ''} 15 23 > 16 24 <div 17 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 36 return item; 37 37 }, 38 38 name: 'Social Icon', 39 - allowSetColor: false, 40 - defaultColor: 'transparent', 39 + allowSetColor: true, 40 + defaultColor: 'base', 41 41 minW: 2, 42 42 minH: 2, 43 43 onUrlHandler: (url, item) => { ··· 167 167 168 168 tangled: /(?:tangled\.org)/i, 169 169 170 - mail: /(?:mailto:)/i 170 + mail: /(?:mailto:)/i, 171 + 172 + npmx: /(?:npmx\.dev)/i 171 173 }; 172 174 173 175 export const platformsData: Record<string, SimpleIcon> = { ··· 277 279 </g> 278 280 <defs> 279 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"> 280 301 <rect width="24" height="24" fill="white"/> 281 302 </clipPath> 282 303 </defs>
+103
src/lib/cards/social/NpmxLikesCard/NpmxLikesCard.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { onMount } from 'svelte'; 4 + import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 5 + import { CardDefinitionsByType } from '../..'; 6 + import { RelativeTime } from '@foxui/time'; 7 + 8 + interface NpmxLike { 9 + uri: string; 10 + value: { 11 + subjectRef: string; 12 + createdAt: string; 13 + }; 14 + } 15 + 16 + let { item }: { item: Item } = $props(); 17 + 18 + const data = getAdditionalUserData(); 19 + // svelte-ignore state_referenced_locally 20 + let feed = $state(data[item.cardType] as NpmxLike[] | undefined); 21 + 22 + let did = getDidContext(); 23 + let handle = getHandleContext(); 24 + 25 + onMount(async () => { 26 + if (feed) return; 27 + 28 + feed = (await CardDefinitionsByType[item.cardType]?.loadData?.([], { 29 + did, 30 + handle 31 + })) as NpmxLike[] | undefined; 32 + 33 + data[item.cardType] = feed; 34 + }); 35 + 36 + function getPackageName(like: NpmxLike): string { 37 + return like.value.subjectRef.split('/package/')[1] ?? like.value.subjectRef; 38 + } 39 + </script> 40 + 41 + {#snippet likeItem(like: NpmxLike)} 42 + <div class="flex w-full items-center gap-3"> 43 + <div 44 + class="text-accent-500 accent:text-accent-950 flex size-8 shrink-0 items-center justify-center" 45 + > 46 + <svg 47 + xmlns="http://www.w3.org/2000/svg" 48 + fill="none" 49 + viewBox="0 0 24 24" 50 + stroke-width="1.5" 51 + stroke="currentColor" 52 + class="size-5" 53 + > 54 + <path 55 + stroke-linecap="round" 56 + stroke-linejoin="round" 57 + d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" 58 + /> 59 + </svg> 60 + </div> 61 + <div class="min-w-0 flex-1"> 62 + <div class="inline-flex w-full max-w-full items-baseline justify-between gap-2"> 63 + <div 64 + class="text-accent-500 accent:text-accent-950 min-w-0 flex-1 shrink truncate text-sm font-semibold" 65 + > 66 + {getPackageName(like)} 67 + </div> 68 + {#if like.value.createdAt} 69 + <div class="text-base-500 dark:text-base-400 accent:text-white/60 shrink-0 text-xs"> 70 + <RelativeTime date={new Date(like.value.createdAt)} locale="en-US" /> ago 71 + </div> 72 + {/if} 73 + </div> 74 + </div> 75 + </div> 76 + {/snippet} 77 + 78 + <div class="z-10 flex h-full w-full flex-col gap-3 overflow-y-scroll p-4"> 79 + {#if feed && feed.length > 0} 80 + {#each feed as like (like.uri)} 81 + <a 82 + href="https://npmx.dev/package/{getPackageName(like)}" 83 + target="_blank" 84 + rel="noopener noreferrer" 85 + class="w-full" 86 + > 87 + {@render likeItem(like)} 88 + </a> 89 + {/each} 90 + {:else if feed} 91 + <div 92 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 93 + > 94 + No liked packages found. 95 + </div> 96 + {:else} 97 + <div 98 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 99 + > 100 + Loading likes... 101 + </div> 102 + {/if} 103 + </div>
+31
src/lib/cards/social/NpmxLikesCard/index.ts
··· 1 + import type { CardDefinition } from '../../types'; 2 + import { listRecords } from '$lib/atproto'; 3 + import NpmxLikesCard from './NpmxLikesCard.svelte'; 4 + 5 + export const NpmxLikesCardDefinition = { 6 + type: 'npmxLikes', 7 + contentComponent: NpmxLikesCard, 8 + createNew: (card) => { 9 + card.w = 4; 10 + card.mobileW = 8; 11 + card.h = 3; 12 + card.mobileH = 6; 13 + }, 14 + loadData: async (items, { did }) => { 15 + const data = await listRecords({ 16 + did, 17 + collection: 'dev.npmx.feed.like', 18 + limit: 99 19 + }); 20 + 21 + return data; 22 + }, 23 + minW: 4, 24 + canHaveLabel: true, 25 + 26 + keywords: ['npm', 'package', 'npmx', 'likes'], 27 + name: 'npmx Likes', 28 + 29 + groups: ['Social'], 30 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" /></svg>` 31 + } as CardDefinition & { type: 'npmxLikes' };
+116
src/lib/cards/social/NpmxLikesLeaderboardCard/NpmxLikesLeaderboardCard.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { onMount } from 'svelte'; 4 + import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 5 + import { CardDefinitionsByType } from '../..'; 6 + 7 + interface LeaderboardEntry { 8 + subjectRef: string; 9 + totalLikes: number; 10 + } 11 + 12 + interface LeaderboardData { 13 + totalLikes: number; 14 + totalUniqueLikers: number; 15 + leaderBoard: LeaderboardEntry[]; 16 + } 17 + 18 + let { item }: { item: Item } = $props(); 19 + 20 + const data = getAdditionalUserData(); 21 + // svelte-ignore state_referenced_locally 22 + let leaderboard = $state(data[item.cardType] as LeaderboardData | undefined); 23 + 24 + let did = getDidContext(); 25 + let handle = getHandleContext(); 26 + 27 + onMount(async () => { 28 + if (leaderboard) return; 29 + 30 + leaderboard = (await CardDefinitionsByType[item.cardType]?.loadData?.([], { 31 + did, 32 + handle 33 + })) as LeaderboardData | undefined; 34 + 35 + data[item.cardType] = leaderboard; 36 + }); 37 + 38 + function getPackageName(entry: LeaderboardEntry): string { 39 + return entry.subjectRef.split('/package/')[1] ?? entry.subjectRef; 40 + } 41 + </script> 42 + 43 + {#snippet leaderboardRow(entry: LeaderboardEntry, index: number)} 44 + <div 45 + class="hover:bg-base-100 dark:hover:bg-base-800 accent:hover:bg-white/10 flex w-full items-center gap-3 rounded-lg px-2 py-1.5 transition-colors" 46 + > 47 + <div 48 + class="text-base-600 dark:text-base-400 accent:text-white/60 w-6 shrink-0 text-right text-xs font-medium" 49 + > 50 + #{index + 1} 51 + </div> 52 + <div class="min-w-0 flex-1"> 53 + <div class="inline-flex w-full max-w-full items-center justify-between gap-2"> 54 + <div 55 + class="text-accent-500 accent:text-accent-50 dark:text-accent-400 min-w-0 flex-1 shrink truncate text-sm font-semibold" 56 + > 57 + {getPackageName(entry)} 58 + </div> 59 + <div 60 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex shrink-0 items-center gap-1 text-xs" 61 + > 62 + <svg 63 + xmlns="http://www.w3.org/2000/svg" 64 + viewBox="0 0 24 24" 65 + fill="currentColor" 66 + class="accent:text-accent-200 text-accent-400 size-3.5" 67 + > 68 + <path 69 + d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" 70 + /> 71 + </svg> 72 + {entry.totalLikes} 73 + </div> 74 + </div> 75 + </div> 76 + </div> 77 + {/snippet} 78 + 79 + <div class="z-10 flex h-full w-full flex-col overflow-hidden"> 80 + {#if leaderboard && leaderboard.leaderBoard.length > 0} 81 + <div class="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-4 pb-10"> 82 + {#each leaderboard.leaderBoard as entry, index (entry.subjectRef)} 83 + <a 84 + href="https://npmx.dev/package/{getPackageName(entry)}" 85 + target="_blank" 86 + rel="noopener noreferrer" 87 + class="w-full" 88 + > 89 + {@render leaderboardRow(entry, index)} 90 + </a> 91 + {/each} 92 + </div> 93 + <div 94 + class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-12 bg-linear-to-t from-base-200 from-40% to-transparent dark:from-base-950 accent:from-accent-500" 95 + ></div> 96 + <div 97 + class="text-base-500 dark:text-base-400 accent:text-white/60 bg-base-200 dark:bg-base-950/50 accent:bg-accent-500/20 relative z-10 flex shrink-0 items-center justify-center gap-3 px-4 pb-3 text-xs" 98 + > 99 + <span>{leaderboard.totalLikes} likes</span> 100 + <span class="text-base-300 dark:text-base-600 accent:text-white/20">&middot;</span> 101 + <span>{leaderboard.totalUniqueLikers} unique likers</span> 102 + </div> 103 + {:else if leaderboard} 104 + <div 105 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center p-4 text-center text-sm" 106 + > 107 + No leaderboard data. 108 + </div> 109 + {:else} 110 + <div 111 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center p-4 text-center text-sm" 112 + > 113 + Loading leaderboard... 114 + </div> 115 + {/if} 116 + </div>
+26
src/lib/cards/social/NpmxLikesLeaderboardCard/index.ts
··· 1 + import type { CardDefinition } from '../../types'; 2 + import NpmxLikesLeaderboardCard from './NpmxLikesLeaderboardCard.svelte'; 3 + 4 + export const NpmxLikesLeaderboardCardDefinition = { 5 + type: 'npmxLikesLeaderboard', 6 + contentComponent: NpmxLikesLeaderboardCard, 7 + createNew: (card) => { 8 + card.w = 4; 9 + card.mobileW = 8; 10 + card.h = 4; 11 + card.mobileH = 6; 12 + }, 13 + loadData: async () => { 14 + const res = await fetch('https://blento.app/api/npmx-leaderboard'); 15 + const data = await res.json(); 16 + return data; 17 + }, 18 + minW: 3, 19 + canHaveLabel: true, 20 + 21 + keywords: ['npm', 'package', 'npmx', 'likes', 'leaderboard', 'ranking'], 22 + name: 'npmx Likes Leaderboard', 23 + 24 + //groups: ['Social'], 25 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 18.75h-9m9 0a3 3 0 0 1 3 3h-15a3 3 0 0 1 3-3m9 0v-4.5A3.375 3.375 0 0 0 13.125 10.875h-2.25A3.375 3.375 0 0 0 7.5 14.25v4.5m6-6V6.375a3.375 3.375 0 0 0-3-3.353A3.375 3.375 0 0 0 7.5 6.375v1.5" /></svg>` 26 + } as CardDefinition & { type: 'npmxLikesLeaderboard' };
+4 -2
src/lib/cards/special/UpdatedBlentos/index.ts
··· 46 46 47 47 let result = [...(await Promise.all(profiles)), ...existingUsersArray]; 48 48 49 - result = result.filter((v) => v && v.handle !== 'handle.invalid'); 49 + result = result.filter( 50 + (v) => v && v.handle !== 'handle.invalid' && !v.handle.endsWith('.pds.rip') 51 + ); 50 52 51 53 if (cache) { 52 54 await cache?.put('updatedBlentos', JSON.stringify(result)); 53 55 } 54 - return JSON.parse(JSON.stringify(result)); 56 + return JSON.parse(JSON.stringify(result.slice(0, 20))); 55 57 } catch (error) { 56 58 console.error('error fetching updated blentos', error); 57 59 return [];
+86 -10
src/lib/cards/visual/FluidTextCard/FluidTextCard.svelte
··· 1 1 <script lang="ts"> 2 - import { colorToHue, getCSSVar, getHexOfCardColor } from '../../helper'; 2 + import { colorToHue, getHexCSSVar, getHexOfCardColor } from '../../helper'; 3 3 import type { ContentComponentProps } from '../../types'; 4 4 import { onMount, onDestroy, tick } from 'svelte'; 5 5 let { item }: ContentComponentProps = $props(); ··· 7 7 let container: HTMLDivElement; 8 8 let fluidCanvas: HTMLCanvasElement; 9 9 let maskCanvas: HTMLCanvasElement; 10 + let shadowCanvas: HTMLCanvasElement; 10 11 let animationId: number; 11 12 let splatIntervalId: ReturnType<typeof setInterval>; 12 13 let maskDrawRaf = 0; 13 14 let maskReady = false; 14 15 let isInitialized = $state(false); 15 16 let resizeObserver: ResizeObserver | null = null; 17 + let themeObserver: MutationObserver | null = null; 16 18 17 19 // Pure hash function for shader keyword caching 18 20 function hashCode(s: string) { ··· 122 124 if (width === 0 || height === 0) return; 123 125 124 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 + } 125 162 126 163 maskCanvas.width = width * dpr; 127 164 maskCanvas.height = height * dpr; ··· 132 169 ctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); 133 170 ctx.scale(dpr, dpr); 134 171 135 - //const color = getCSSVar('--color-base-900'); 136 - 137 - ctx.fillStyle = 'black'; 172 + const bgColor = 173 + item.color === 'transparent' 174 + ? getHexCSSVar(isDark ? '--color-base-900' : '--color-base-50') 175 + : 'black'; 176 + ctx.fillStyle = bgColor; 138 177 ctx.fillRect(0, 0, width, height); 139 178 140 179 // Font size as percentage of container width 141 180 const textFontSize = Math.round(width * fontSize); 142 181 ctx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`; 143 182 144 - ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; 145 - ctx.lineWidth = 2; 183 + ctx.lineWidth = 3; 146 184 ctx.textAlign = 'center'; 147 185 148 186 const metrics = ctx.measureText(text); ··· 157 195 ctx.textBaseline = 'middle'; 158 196 } 159 197 160 - ctx.strokeText(text, width / 2, textY); 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 + 161 215 ctx.globalCompositeOperation = 'destination-out'; 162 216 ctx.fillText(text, width / 2, textY); 163 217 ctx.globalCompositeOperation = 'source-over'; ··· 214 268 if (isInitialized) scheduleMaskDraw(); 215 269 }); 216 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 + } 217 282 }); 218 283 219 284 onDestroy(() => { ··· 221 286 if (splatIntervalId) clearInterval(splatIntervalId); 222 287 if (maskDrawRaf) cancelAnimationFrame(maskDrawRaf); 223 288 if (resizeObserver) resizeObserver.disconnect(); 289 + if (themeObserver) themeObserver.disconnect(); 224 290 }); 225 291 226 292 function initFluidSimulation(startHue: number, endHue: number) { ··· 246 312 COLOR_UPDATE_SPEED: 10, 247 313 PAUSED: false, 248 314 BACK_COLOR: { r: 0, g: 0, b: 0 }, 249 - TRANSPARENT: false, 315 + TRANSPARENT: item.color === 'transparent', 250 316 BLOOM: false, 251 317 BLOOM_ITERATIONS: 8, 252 318 BLOOM_RESOLUTION: 256, ··· 1701 1767 } 1702 1768 </script> 1703 1769 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> 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 1777 + bind:this={shadowCanvas} 1778 + class="absolute inset-0.5 h-[calc(100%-4px)] w-[calc(100%-4px)] rounded-[inherit]" 1779 + ></canvas> 1780 + <canvas bind:this={fluidCanvas} class="absolute inset-0.5 h-[calc(100%-4px)] w-[calc(100%-4px)]" 1781 + ></canvas> 1706 1782 <canvas bind:this={maskCanvas} class="absolute h-full w-full"></canvas> 1707 1783 </div>
+1 -3
src/lib/website/EditableProfile.svelte
··· 3 3 import { getImage, compressImage, getProfilePosition } from '$lib/helper'; 4 4 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 5 5 import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte'; 6 - import { Avatar, Button } from '@foxui/core'; 7 - import { getIsMobile } from './context'; 6 + import { Avatar } from '@foxui/core'; 8 7 import MadeWithBlento from './MadeWithBlento.svelte'; 9 - import { SelectThemePopover } from '$lib/components/select-theme'; 10 8 11 9 let { data = $bindable(), hideBlento = false }: { data: WebsiteData; hideBlento?: boolean } = 12 10 $props();
-12
src/lib/website/EditableWebsite.svelte
··· 961 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 962 > 963 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 964 </div> 977 965 {/if} 978 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>
+44
src/routes/api/npmx-leaderboard/+server.ts
··· 1 + import { json } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + 4 + const LEADERBOARD_API_URL = 5 + 'https://npmx-likes-leaderboard-api-production.up.railway.app/api/leaderboard/likes?limit=20'; 6 + 7 + export const GET: RequestHandler = async ({ platform }) => { 8 + const cacheKey = '#npmx-leaderboard:likes'; 9 + const cachedData = await platform?.env?.USER_DATA_CACHE?.get(cacheKey); 10 + 11 + if (cachedData) { 12 + const parsedCache = JSON.parse(cachedData); 13 + 14 + const TWELVE_HOURS = 12 * 60 * 60 * 1000; 15 + const now = Date.now(); 16 + 17 + if (now - (parsedCache.updatedAt || 0) < TWELVE_HOURS) { 18 + return json(parsedCache.data); 19 + } 20 + } 21 + 22 + try { 23 + const response = await fetch(LEADERBOARD_API_URL); 24 + 25 + if (!response.ok) { 26 + return json( 27 + { error: 'Failed to fetch npmx leaderboard ' + response.statusText }, 28 + { status: response.status } 29 + ); 30 + } 31 + 32 + const data = await response.json(); 33 + 34 + await platform?.env?.USER_DATA_CACHE?.put( 35 + cacheKey, 36 + JSON.stringify({ data, updatedAt: Date.now() }) 37 + ); 38 + 39 + return json(data); 40 + } catch (error) { 41 + console.error('Error fetching npmx leaderboard:', error); 42 + return json({ error: 'Failed to fetch npmx leaderboard' }, { status: 500 }); 43 + } 44 + };