your personal website on atproto - mirror blento.app

Compare changes

Choose any two refs to compare.

+1763 -426
+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",
+9 -1
src/lib/cards/index.ts
··· 42 42 import { KickstarterCardDefinition } from './social/KickstarterCard'; 43 43 import { NpmxLikesCardDefinition } from './social/NpmxLikesCard'; 44 44 import { NpmxLikesLeaderboardCardDefinition } from './social/NpmxLikesLeaderboardCard'; 45 + import { LastFMRecentTracksCardDefinition } from './media/LastFMCard/LastFMRecentTracksCard'; 46 + import { LastFMTopTracksCardDefinition } from './media/LastFMCard/LastFMTopTracksCard'; 47 + import { LastFMTopAlbumsCardDefinition } from './media/LastFMCard/LastFMTopAlbumsCard'; 48 + import { LastFMProfileCardDefinition } from './media/LastFMCard/LastFMProfileCard'; 45 49 // import { Model3DCardDefinition } from './visual/Model3DCard'; 46 50 47 51 export const AllCardDefinitions = [ ··· 88 92 ProductHuntCardDefinition, 89 93 KickstarterCardDefinition, 90 94 NpmxLikesCardDefinition, 91 - NpmxLikesLeaderboardCardDefinition 95 + NpmxLikesLeaderboardCardDefinition, 96 + LastFMRecentTracksCardDefinition, 97 + LastFMTopTracksCardDefinition, 98 + LastFMTopAlbumsCardDefinition, 99 + LastFMProfileCardDefinition 92 100 ] as const; 93 101 94 102 export const CardDefinitionsByType = AllCardDefinitions.reduce(
+60
src/lib/cards/media/LastFMCard/CreateLastFMCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../../types'; 4 + 5 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 + 7 + let errorMessage = $state(''); 8 + </script> 9 + 10 + <Modal open={true} closeButton={false}> 11 + <form 12 + onsubmit={() => { 13 + let input = item.cardData.href?.trim(); 14 + if (!input) return; 15 + 16 + let username: string | undefined; 17 + 18 + try { 19 + const parsed = new URL(input); 20 + if (/^(www\.)?last\.fm$/.test(parsed.hostname)) { 21 + const segments = parsed.pathname.split('/').filter(Boolean); 22 + if (segments.length >= 2 && segments[0] === 'user') { 23 + username = segments[1]; 24 + } 25 + } 26 + } catch { 27 + if (/^[a-zA-Z0-9_-]{2,15}$/.test(input)) { 28 + username = input; 29 + } 30 + } 31 + 32 + if (!username) { 33 + errorMessage = 'Please enter a valid Last.fm username or profile URL'; 34 + return; 35 + } 36 + 37 + item.cardData.lastfmUsername = username; 38 + item.cardData.href = `https://www.last.fm/user/${username}`; 39 + 40 + oncreate?.(); 41 + }} 42 + class="flex flex-col gap-2" 43 + > 44 + <Subheading>Enter a Last.fm username or profile URL</Subheading> 45 + <Input 46 + bind:value={item.cardData.href} 47 + placeholder="username or https://www.last.fm/user/username" 48 + class="mt-4" 49 + /> 50 + 51 + {#if errorMessage} 52 + <p class="mt-2 text-sm text-red-600">{errorMessage}</p> 53 + {/if} 54 + 55 + <div class="mt-4 flex justify-end gap-2"> 56 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 57 + <Button type="submit">Create</Button> 58 + </div> 59 + </form> 60 + </Modal>
+50
src/lib/cards/media/LastFMCard/LastFMAlbumArt.svelte
··· 1 + <script lang="ts"> 2 + let { 3 + images, 4 + alt = '', 5 + size = 'medium' 6 + }: { images?: { '#text': string; size: string }[]; alt?: string; size?: string } = $props(); 7 + 8 + let isLoading = $state(true); 9 + let hasError = $state(false); 10 + 11 + const imageUrl = $derived.by(() => { 12 + if (!images || images.length === 0) return ''; 13 + const preferred = ['extralarge', 'large', 'medium', 'small']; 14 + for (const pref of preferred) { 15 + const img = images.find((i) => i.size === pref); 16 + if (img?.['#text']) return img['#text']; 17 + } 18 + return images[images.length - 1]?.['#text'] || ''; 19 + }); 20 + </script> 21 + 22 + {#if !imageUrl || hasError} 23 + <div 24 + class="bg-base-200 dark:bg-base-700 accent:bg-accent-700/50 flex h-full w-full items-center justify-center rounded-lg" 25 + > 26 + <svg 27 + class="text-base-500 dark:text-base-400 accent:text-accent-200 h-5 w-5" 28 + fill="currentColor" 29 + viewBox="0 0 20 20" 30 + > 31 + <path 32 + d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" 33 + /> 34 + </svg> 35 + </div> 36 + {:else} 37 + {#if isLoading} 38 + <div class="bg-base-200 dark:bg-base-800 h-full w-full animate-pulse rounded-lg"></div> 39 + {/if} 40 + <img 41 + src={imageUrl} 42 + {alt} 43 + class="h-full w-full rounded-lg object-cover {isLoading && 'hidden'}" 44 + onload={() => (isLoading = false)} 45 + onerror={() => { 46 + isLoading = false; 47 + hasError = true; 48 + }} 49 + /> 50 + {/if}
+36
src/lib/cards/media/LastFMCard/LastFMPeriodSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { SettingsComponentProps } from '../../types'; 3 + import { Label } from '@foxui/core'; 4 + 5 + let { item = $bindable() }: SettingsComponentProps = $props(); 6 + 7 + const periodOptions = [ 8 + { value: '7day', label: '7 Days' }, 9 + { value: '1month', label: '1 Month' }, 10 + { value: '3month', label: '3 Months' }, 11 + { value: '6month', label: '6 Months' }, 12 + { value: '12month', label: '12 Months' }, 13 + { value: 'overall', label: 'All Time' } 14 + ]; 15 + 16 + let period = $derived(item.cardData.period ?? '7day'); 17 + </script> 18 + 19 + <div class="flex flex-col gap-2"> 20 + <Label>Time Period</Label> 21 + <div class="flex flex-wrap gap-2"> 22 + {#each periodOptions as opt (opt.value)} 23 + <button 24 + class={[ 25 + 'rounded-xl border px-3 py-2 text-sm transition-colors', 26 + period === opt.value 27 + ? 'bg-accent-500 border-accent-500 text-white' 28 + : 'bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 text-base-900 dark:text-base-100 hover:border-accent-400' 29 + ]} 30 + onclick={() => (item.cardData.period = opt.value)} 31 + > 32 + {opt.label} 33 + </button> 34 + {/each} 35 + </div> 36 + </div>
+113
src/lib/cards/media/LastFMCard/LastFMProfileCard/LastFMProfileCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { siLastdotfm } from 'simple-icons'; 4 + import { getAdditionalUserData } from '$lib/website/context'; 5 + import type { ContentComponentProps } from '../../../types'; 6 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 7 + 8 + interface UserInfo { 9 + name: string; 10 + realname: string; 11 + url: string; 12 + image: { '#text': string; size: string }[]; 13 + playcount: string; 14 + registered: { unixtime: string }; 15 + } 16 + 17 + let { item, isEditing }: ContentComponentProps = $props(); 18 + 19 + const data = getAdditionalUserData(); 20 + const cacheKey = $derived(`lastfmProfile:${item.cardData.lastfmUsername}`); 21 + 22 + // svelte-ignore state_referenced_locally 23 + let userInfo = $state(data[cacheKey] as UserInfo | undefined); 24 + 25 + onMount(async () => { 26 + if (userInfo) return; 27 + if (!item.cardData.lastfmUsername) return; 28 + 29 + try { 30 + const response = await fetch( 31 + `/api/lastfm?method=user.getInfo&user=${encodeURIComponent(item.cardData.lastfmUsername)}` 32 + ); 33 + if (response.ok) { 34 + const result = await response.json(); 35 + userInfo = result?.user; 36 + data[cacheKey] = userInfo; 37 + } 38 + } catch (error) { 39 + console.error('Failed to fetch Last.fm profile:', error); 40 + } 41 + }); 42 + 43 + const profileUrl = $derived(`https://www.last.fm/user/${item.cardData.lastfmUsername}`); 44 + 45 + const avatarUrl = $derived.by(() => { 46 + if (!userInfo?.image) return ''; 47 + const preferred = ['extralarge', 'large', 'medium']; 48 + for (const pref of preferred) { 49 + const img = userInfo.image.find((i) => i.size === pref); 50 + if (img?.['#text']) return img['#text']; 51 + } 52 + return ''; 53 + }); 54 + 55 + const memberSince = $derived.by(() => { 56 + if (!userInfo?.registered?.unixtime) return ''; 57 + const date = new Date(parseInt(userInfo.registered.unixtime) * 1000); 58 + return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); 59 + }); 60 + </script> 61 + 62 + <div class="h-full overflow-hidden p-4"> 63 + <div class="flex h-full flex-col justify-between"> 64 + <div class="flex items-center gap-3"> 65 + <div 66 + class="fill-base-950 accent:fill-white size-6 shrink-0 dark:fill-white [&_svg]:size-full" 67 + > 68 + {@html siLastdotfm.svg} 69 + </div> 70 + <span class="truncate text-2xl font-bold"> 71 + {item.cardData.lastfmUsername} 72 + </span> 73 + </div> 74 + 75 + {#if userInfo} 76 + <div class="flex items-center gap-4"> 77 + {#if avatarUrl} 78 + <img src={avatarUrl} alt={userInfo.name} class="size-12 rounded-full object-cover" /> 79 + {/if} 80 + <div class="min-w-0 flex-1"> 81 + <div class="text-lg font-semibold"> 82 + {parseInt(userInfo.playcount).toLocaleString()} scrobbles 83 + </div> 84 + {#if memberSince} 85 + <div class="text-sm opacity-60"> 86 + Since {memberSince} 87 + </div> 88 + {/if} 89 + </div> 90 + </div> 91 + {:else} 92 + <div class="text-sm opacity-60">Loading profile...</div> 93 + {/if} 94 + </div> 95 + </div> 96 + 97 + {#if !isEditing} 98 + <a 99 + href={profileUrl} 100 + class="absolute inset-0 h-full w-full" 101 + target="_blank" 102 + rel="noopener noreferrer" 103 + use:qrOverlay={{ 104 + context: { 105 + title: item.cardData.lastfmUsername, 106 + icon: siLastdotfm.svg, 107 + iconColor: '#' + siLastdotfm.hex 108 + } 109 + }} 110 + > 111 + <span class="sr-only">View on Last.fm</span> 112 + </a> 113 + {/if}
+40
src/lib/cards/media/LastFMCard/LastFMProfileCard/index.ts
··· 1 + import type { CardDefinition } from '../../../types'; 2 + import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte'; 3 + import LastFMProfileCard from './LastFMProfileCard.svelte'; 4 + 5 + export const LastFMProfileCardDefinition = { 6 + type: 'lastfmProfile', 7 + contentComponent: LastFMProfileCard, 8 + creationModalComponent: CreateLastFMCardModal, 9 + createNew: (card) => { 10 + card.w = 4; 11 + card.mobileW = 8; 12 + card.h = 2; 13 + card.mobileH = 3; 14 + }, 15 + loadData: async (items) => { 16 + const allData: Record<string, unknown> = {}; 17 + for (const item of items) { 18 + const username = item.cardData.lastfmUsername; 19 + if (!username) continue; 20 + try { 21 + const response = await fetch( 22 + `https://blento.app/api/lastfm?method=user.getInfo&user=${encodeURIComponent(username)}` 23 + ); 24 + if (!response.ok) continue; 25 + const text = await response.text(); 26 + const result = JSON.parse(text); 27 + allData[`lastfmProfile:${username}`] = result?.user; 28 + } catch (error) { 29 + console.error('Failed to fetch Last.fm profile:', error); 30 + } 31 + } 32 + return allData; 33 + }, 34 + minW: 2, 35 + minH: 2, 36 + name: 'Last.fm Profile', 37 + keywords: ['music', 'scrobble', 'profile', 'lastfm', 'last.fm'], 38 + groups: ['Media'], 39 + 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="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>` 40 + } as CardDefinition & { type: 'lastfmProfile' };
+103
src/lib/cards/media/LastFMCard/LastFMRecentTracksCard/LastFMRecentTracksCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { getAdditionalUserData } from '$lib/website/context'; 4 + import type { ContentComponentProps } from '../../../types'; 5 + import LastFMAlbumArt from '../LastFMAlbumArt.svelte'; 6 + import { RelativeTime } from '@foxui/time'; 7 + 8 + interface Track { 9 + name: string; 10 + artist: { '#text': string }; 11 + album: { '#text': string }; 12 + image: { '#text': string; size: string }[]; 13 + url: string; 14 + date?: { uts: string }; 15 + '@attr'?: { nowplaying: string }; 16 + } 17 + 18 + let { item }: ContentComponentProps = $props(); 19 + 20 + const data = getAdditionalUserData(); 21 + const cacheKey = $derived(`lastfmRecentTracks:${item.cardData.lastfmUsername}`); 22 + 23 + // svelte-ignore state_referenced_locally 24 + let tracks = $state(data[cacheKey] as Track[] | undefined); 25 + let error = $state(false); 26 + 27 + onMount(async () => { 28 + if (tracks) return; 29 + if (!item.cardData.lastfmUsername) return; 30 + 31 + try { 32 + const response = await fetch( 33 + `/api/lastfm?method=user.getRecentTracks&user=${encodeURIComponent(item.cardData.lastfmUsername)}&limit=50` 34 + ); 35 + if (response.ok) { 36 + const result = await response.json(); 37 + tracks = result?.recenttracks?.track ?? []; 38 + data[cacheKey] = tracks; 39 + } else { 40 + error = true; 41 + } 42 + } catch { 43 + error = true; 44 + } 45 + }); 46 + </script> 47 + 48 + <div class="z-10 flex h-full w-full flex-col gap-3 overflow-y-scroll p-4"> 49 + {#if tracks && tracks.length > 0} 50 + {#each tracks as track, i (track.url + i)} 51 + <a 52 + href={track.url} 53 + target="_blank" 54 + rel="noopener noreferrer" 55 + class="flex w-full items-center gap-3" 56 + > 57 + <div class="size-10 shrink-0"> 58 + <LastFMAlbumArt images={track.image} alt={track.album?.['#text']} /> 59 + </div> 60 + <div class="min-w-0 flex-1"> 61 + <div class="inline-flex w-full max-w-full justify-between gap-2"> 62 + <div 63 + class="text-accent-500 accent:text-accent-950 min-w-0 flex-1 shrink truncate font-semibold" 64 + > 65 + {track.name} 66 + </div> 67 + {#if track['@attr']?.nowplaying === 'true'} 68 + <div class="flex shrink-0 items-center gap-1 text-xs text-green-500"> 69 + <span class="inline-block size-2 animate-pulse rounded-full bg-green-500"></span> 70 + Now 71 + </div> 72 + {:else if track.date?.uts} 73 + <div class="shrink-0 text-xs"> 74 + <RelativeTime date={new Date(parseInt(track.date.uts) * 1000)} locale="en-US" /> ago 75 + </div> 76 + {/if} 77 + </div> 78 + <div class="my-1 min-w-0 truncate text-xs whitespace-nowrap"> 79 + {track.artist?.['#text']} 80 + </div> 81 + </div> 82 + </a> 83 + {/each} 84 + {:else if error} 85 + <div 86 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 87 + > 88 + Failed to load tracks. 89 + </div> 90 + {:else if tracks} 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 recent tracks 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 tracks... 101 + </div> 102 + {/if} 103 + </div>
+70
src/lib/cards/media/LastFMCard/LastFMRecentTracksCard/index.ts
··· 1 + import type { CardDefinition } from '../../../types'; 2 + import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte'; 3 + import LastFMRecentTracksCard from './LastFMRecentTracksCard.svelte'; 4 + 5 + export const LastFMRecentTracksCardDefinition = { 6 + type: 'lastfmRecentTracks', 7 + contentComponent: LastFMRecentTracksCard, 8 + creationModalComponent: CreateLastFMCardModal, 9 + createNew: (card) => { 10 + card.w = 4; 11 + card.mobileW = 8; 12 + card.h = 3; 13 + card.mobileH = 6; 14 + }, 15 + loadData: async (items) => { 16 + const allData: Record<string, unknown> = {}; 17 + for (const item of items) { 18 + const username = item.cardData.lastfmUsername; 19 + if (!username) continue; 20 + try { 21 + const response = await fetch( 22 + `https://blento.app/api/lastfm?method=user.getRecentTracks&user=${encodeURIComponent(username)}&limit=50` 23 + ); 24 + if (!response.ok) continue; 25 + const text = await response.text(); 26 + const result = JSON.parse(text); 27 + allData[`lastfmRecentTracks:${username}`] = result?.recenttracks?.track ?? []; 28 + } catch (error) { 29 + console.error('Failed to fetch Last.fm recent tracks:', error); 30 + } 31 + } 32 + return allData; 33 + }, 34 + onUrlHandler: (url, item) => { 35 + const username = getLastFMUsername(url); 36 + if (!username) return null; 37 + 38 + item.cardData.lastfmUsername = username; 39 + item.cardData.href = `https://www.last.fm/user/${username}`; 40 + item.w = 4; 41 + item.mobileW = 8; 42 + item.h = 3; 43 + item.mobileH = 6; 44 + item.cardType = 'lastfmRecentTracks'; 45 + return item; 46 + }, 47 + urlHandlerPriority: 5, 48 + minW: 3, 49 + minH: 2, 50 + canHaveLabel: true, 51 + name: 'Last.fm Recent Tracks', 52 + keywords: ['music', 'scrobble', 'listening', 'songs', 'lastfm', 'last.fm'], 53 + groups: ['Media'], 54 + 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="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>` 55 + } as CardDefinition & { type: 'lastfmRecentTracks' }; 56 + 57 + function getLastFMUsername(url: string | undefined): string | undefined { 58 + if (!url) return; 59 + try { 60 + const parsed = new URL(url); 61 + if (!/^(www\.)?last\.fm$/.test(parsed.hostname)) return undefined; 62 + const segments = parsed.pathname.split('/').filter(Boolean); 63 + if (segments.length >= 2 && segments[0] === 'user') { 64 + return segments[1]; 65 + } 66 + return undefined; 67 + } catch { 68 + return undefined; 69 + } 70 + }
+103
src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/LastFMTopAlbumsCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import type { ContentComponentProps } from '../../../types'; 4 + import { getAdditionalUserData } from '$lib/website/context'; 5 + import ImageGrid from '$lib/components/ImageGrid.svelte'; 6 + 7 + interface Album { 8 + name: string; 9 + playcount: string; 10 + url: string; 11 + artist: { name: string; url: string }; 12 + image: { '#text': string; size: string }[]; 13 + } 14 + 15 + let { item }: ContentComponentProps = $props(); 16 + 17 + const data = getAdditionalUserData(); 18 + 19 + let period = $derived(item.cardData.period ?? '7day'); 20 + let layout: 'grid' | 'cinema' = $derived(item.cardData.layout ?? 'grid'); 21 + const cacheKey = $derived(`lastfmTopAlbums:${item.cardData.lastfmUsername}:${period}`); 22 + 23 + // svelte-ignore state_referenced_locally 24 + let albums = $state(data[cacheKey] as Album[] | undefined); 25 + let loading = $state(false); 26 + let error = $state(false); 27 + 28 + async function fetchAlbums() { 29 + if (!item.cardData.lastfmUsername) return; 30 + loading = true; 31 + 32 + try { 33 + const response = await fetch( 34 + `/api/lastfm?method=user.getTopAlbums&user=${encodeURIComponent(item.cardData.lastfmUsername)}&period=${period}&limit=50` 35 + ); 36 + if (response.ok) { 37 + const result = await response.json(); 38 + albums = result?.topalbums?.album ?? []; 39 + data[cacheKey] = albums; 40 + } else { 41 + error = true; 42 + } 43 + } catch { 44 + error = true; 45 + } finally { 46 + loading = false; 47 + } 48 + } 49 + 50 + onMount(() => { 51 + if (!albums) fetchAlbums(); 52 + }); 53 + 54 + $effect(() => { 55 + const _period = period; 56 + const cached = data[cacheKey] as Album[] | undefined; 57 + if (cached) { 58 + albums = cached; 59 + } else { 60 + fetchAlbums(); 61 + } 62 + }); 63 + 64 + function getImageUrl(album: Album): string | null { 65 + if (!album.image || album.image.length === 0) return null; 66 + const preferred = ['extralarge', 'large', 'medium', 'small']; 67 + for (const pref of preferred) { 68 + const img = album.image.find((i) => i.size === pref); 69 + if (img?.['#text']) return img['#text']; 70 + } 71 + return album.image[album.image.length - 1]?.['#text'] || null; 72 + } 73 + 74 + let gridItems = $derived( 75 + (albums ?? []).map((album) => ({ 76 + imageUrl: getImageUrl(album), 77 + link: album.url, 78 + label: `${album.name} - ${album.artist.name}` 79 + })) 80 + ); 81 + </script> 82 + 83 + {#if error} 84 + <div class="flex h-full w-full items-center justify-center"> 85 + <span class="text-base-500 dark:text-base-400 accent:text-white/60 text-sm"> 86 + Failed to load albums. 87 + </span> 88 + </div> 89 + {:else if albums && gridItems.length > 0} 90 + <ImageGrid items={gridItems} {layout} tooltip /> 91 + {:else if loading || !albums} 92 + <div class="flex h-full w-full items-center justify-center"> 93 + <span class="text-base-500 dark:text-base-400 accent:text-white/60 text-sm"> 94 + Loading albums... 95 + </span> 96 + </div> 97 + {:else} 98 + <div class="flex h-full w-full items-center justify-center"> 99 + <span class="text-base-500 dark:text-base-400 accent:text-white/60 text-sm"> 100 + No top albums found. 101 + </span> 102 + </div> 103 + {/if}
+63
src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/LastFMTopAlbumsCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { SettingsComponentProps } from '../../../types'; 3 + import { Label } from '@foxui/core'; 4 + 5 + let { item = $bindable() }: SettingsComponentProps = $props(); 6 + 7 + const periodOptions = [ 8 + { value: '7day', label: '7 Days' }, 9 + { value: '1month', label: '1 Month' }, 10 + { value: '3month', label: '3 Months' }, 11 + { value: '6month', label: '6 Months' }, 12 + { value: '12month', label: '12 Months' }, 13 + { value: 'overall', label: 'All Time' } 14 + ]; 15 + 16 + const layoutOptions = [ 17 + { value: 'grid', label: 'Grid' }, 18 + { value: 'cinema', label: 'Cinema' } 19 + ]; 20 + 21 + let period = $derived(item.cardData.period ?? '7day'); 22 + let layout = $derived(item.cardData.layout ?? 'grid'); 23 + </script> 24 + 25 + <div class="flex flex-col gap-4"> 26 + <div class="flex flex-col gap-2"> 27 + <Label>Time Period</Label> 28 + <div class="flex flex-wrap gap-2"> 29 + {#each periodOptions as opt (opt.value)} 30 + <button 31 + class={[ 32 + 'rounded-xl border px-3 py-2 text-sm transition-colors', 33 + period === opt.value 34 + ? 'bg-accent-500 border-accent-500 text-white' 35 + : 'bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 text-base-900 dark:text-base-100 hover:border-accent-400' 36 + ]} 37 + onclick={() => (item.cardData.period = opt.value)} 38 + > 39 + {opt.label} 40 + </button> 41 + {/each} 42 + </div> 43 + </div> 44 + 45 + <div class="flex flex-col gap-2"> 46 + <Label>Layout</Label> 47 + <div class="flex gap-2"> 48 + {#each layoutOptions as opt (opt.value)} 49 + <button 50 + class={[ 51 + 'flex-1 rounded-xl border px-3 py-2 text-sm transition-colors', 52 + layout === opt.value 53 + ? 'bg-accent-500 border-accent-500 text-white' 54 + : 'bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 text-base-900 dark:text-base-100 hover:border-accent-400' 55 + ]} 56 + onclick={() => (item.cardData.layout = opt.value)} 57 + > 58 + {opt.label} 59 + </button> 60 + {/each} 61 + </div> 62 + </div> 63 + </div>
+47
src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/index.ts
··· 1 + import type { CardDefinition } from '../../../types'; 2 + import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte'; 3 + import LastFMTopAlbumsCard from './LastFMTopAlbumsCard.svelte'; 4 + import LastFMTopAlbumsCardSettings from './LastFMTopAlbumsCardSettings.svelte'; 5 + 6 + export const LastFMTopAlbumsCardDefinition = { 7 + type: 'lastfmTopAlbums', 8 + contentComponent: LastFMTopAlbumsCard, 9 + creationModalComponent: CreateLastFMCardModal, 10 + settingsComponent: LastFMTopAlbumsCardSettings, 11 + createNew: (card) => { 12 + card.w = 4; 13 + card.h = 3; 14 + card.mobileW = 8; 15 + card.mobileH = 4; 16 + card.cardData.period = '7day'; 17 + }, 18 + loadData: async (items) => { 19 + const allData: Record<string, unknown> = {}; 20 + for (const item of items) { 21 + const username = item.cardData.lastfmUsername; 22 + const period = item.cardData.period ?? '7day'; 23 + if (!username) continue; 24 + try { 25 + const response = await fetch( 26 + `https://blento.app/api/lastfm?method=user.getTopAlbums&user=${encodeURIComponent(username)}&period=${period}&limit=50` 27 + ); 28 + if (!response.ok) continue; 29 + const text = await response.text(); 30 + const result = JSON.parse(text); 31 + allData[`lastfmTopAlbums:${username}:${period}`] = result?.topalbums?.album ?? []; 32 + } catch (error) { 33 + console.error('Failed to fetch Last.fm top albums:', error); 34 + } 35 + } 36 + return allData; 37 + }, 38 + allowSetColor: true, 39 + defaultColor: 'base', 40 + minW: 2, 41 + minH: 2, 42 + canHaveLabel: true, 43 + name: 'Last.fm Top Albums', 44 + keywords: ['music', 'scrobble', 'albums', 'lastfm', 'last.fm', 'top'], 45 + groups: ['Media'], 46 + 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="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>` 47 + } as CardDefinition & { type: 'lastfmTopAlbums' };
+117
src/lib/cards/media/LastFMCard/LastFMTopTracksCard/LastFMTopTracksCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { getAdditionalUserData } from '$lib/website/context'; 4 + import type { ContentComponentProps } from '../../../types'; 5 + import LastFMAlbumArt from '../LastFMAlbumArt.svelte'; 6 + 7 + interface Track { 8 + name: string; 9 + playcount: string; 10 + artist: { name: string; url: string }; 11 + image: { '#text': string; size: string }[]; 12 + url: string; 13 + } 14 + 15 + let { item }: ContentComponentProps = $props(); 16 + 17 + const data = getAdditionalUserData(); 18 + 19 + let period = $derived(item.cardData.period ?? '7day'); 20 + const cacheKey = $derived(`lastfmTopTracks:${item.cardData.lastfmUsername}:${period}`); 21 + 22 + // svelte-ignore state_referenced_locally 23 + let tracks = $state(data[cacheKey] as Track[] | undefined); 24 + let error = $state(false); 25 + let loading = $state(false); 26 + 27 + async function fetchTracks() { 28 + if (!item.cardData.lastfmUsername) return; 29 + loading = true; 30 + 31 + try { 32 + const response = await fetch( 33 + `/api/lastfm?method=user.getTopTracks&user=${encodeURIComponent(item.cardData.lastfmUsername)}&period=${period}&limit=50` 34 + ); 35 + if (response.ok) { 36 + const result = await response.json(); 37 + tracks = result?.toptracks?.track ?? []; 38 + data[cacheKey] = tracks; 39 + } else { 40 + error = true; 41 + } 42 + } catch { 43 + error = true; 44 + } finally { 45 + loading = false; 46 + } 47 + } 48 + 49 + onMount(() => { 50 + if (!tracks) fetchTracks(); 51 + }); 52 + 53 + $effect(() => { 54 + const _period = period; 55 + const cached = data[cacheKey] as Track[] | undefined; 56 + if (cached) { 57 + tracks = cached; 58 + } else { 59 + fetchTracks(); 60 + } 61 + }); 62 + </script> 63 + 64 + <div class="z-10 flex h-full w-full flex-col gap-3 overflow-y-scroll p-4"> 65 + {#if tracks && tracks.length > 0} 66 + {#each tracks as track, i (track.url)} 67 + <a 68 + href={track.url} 69 + target="_blank" 70 + rel="noopener noreferrer" 71 + class="flex w-full items-center gap-3" 72 + > 73 + <div 74 + class="text-base-400 dark:text-base-500 accent:text-white/40 w-5 shrink-0 text-right text-xs font-bold" 75 + > 76 + {i + 1} 77 + </div> 78 + <div class="size-10 shrink-0"> 79 + <LastFMAlbumArt images={track.image} alt={track.name} /> 80 + </div> 81 + <div class="min-w-0 flex-1"> 82 + <div class="inline-flex w-full max-w-full justify-between gap-2"> 83 + <div 84 + class="text-accent-500 accent:text-accent-950 min-w-0 flex-1 shrink truncate font-semibold" 85 + > 86 + {track.name} 87 + </div> 88 + <div class="shrink-0 text-xs"> 89 + {parseInt(track.playcount).toLocaleString()} plays 90 + </div> 91 + </div> 92 + <div class="my-1 min-w-0 truncate text-xs whitespace-nowrap"> 93 + {track.artist?.name} 94 + </div> 95 + </div> 96 + </a> 97 + {/each} 98 + {:else if error} 99 + <div 100 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 101 + > 102 + Failed to load tracks. 103 + </div> 104 + {:else if tracks || loading} 105 + <div 106 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 107 + > 108 + {tracks?.length === 0 ? 'No top tracks found.' : 'Loading tracks...'} 109 + </div> 110 + {:else} 111 + <div 112 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 113 + > 114 + Loading tracks... 115 + </div> 116 + {/if} 117 + </div>
+45
src/lib/cards/media/LastFMCard/LastFMTopTracksCard/index.ts
··· 1 + import type { CardDefinition } from '../../../types'; 2 + import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte'; 3 + import LastFMPeriodSettings from '../LastFMPeriodSettings.svelte'; 4 + import LastFMTopTracksCard from './LastFMTopTracksCard.svelte'; 5 + 6 + export const LastFMTopTracksCardDefinition = { 7 + type: 'lastfmTopTracks', 8 + contentComponent: LastFMTopTracksCard, 9 + creationModalComponent: CreateLastFMCardModal, 10 + settingsComponent: LastFMPeriodSettings, 11 + createNew: (card) => { 12 + card.w = 4; 13 + card.mobileW = 8; 14 + card.h = 3; 15 + card.mobileH = 6; 16 + card.cardData.period = '7day'; 17 + }, 18 + loadData: async (items) => { 19 + const allData: Record<string, unknown> = {}; 20 + for (const item of items) { 21 + const username = item.cardData.lastfmUsername; 22 + const period = item.cardData.period ?? '7day'; 23 + if (!username) continue; 24 + try { 25 + const response = await fetch( 26 + `https://blento.app/api/lastfm?method=user.getTopTracks&user=${encodeURIComponent(username)}&period=${period}&limit=50` 27 + ); 28 + if (!response.ok) continue; 29 + const text = await response.text(); 30 + const result = JSON.parse(text); 31 + allData[`lastfmTopTracks:${username}:${period}`] = result?.toptracks?.track ?? []; 32 + } catch (error) { 33 + console.error('Failed to fetch Last.fm top tracks:', error); 34 + } 35 + } 36 + return allData; 37 + }, 38 + minW: 3, 39 + minH: 2, 40 + canHaveLabel: true, 41 + name: 'Last.fm Top Tracks', 42 + keywords: ['music', 'scrobble', 'songs', 'lastfm', 'last.fm', 'top'], 43 + groups: ['Media'], 44 + 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="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>` 45 + } as CardDefinition & { type: 'lastfmTopTracks' };
+14 -135
src/lib/cards/social/GitHubContributorsCard/GitHubContributorsCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import type { ContentComponentProps } from '../../types'; 4 - import { getAdditionalUserData, getCanEdit, getIsMobile } from '$lib/website/context'; 4 + import { getAdditionalUserData, getCanEdit } from '$lib/website/context'; 5 5 import type { GitHubContributor, GitHubContributorsLoadedData } from '.'; 6 + import ImageGrid from '$lib/components/ImageGrid.svelte'; 6 7 7 8 let { item }: ContentComponentProps = $props(); 8 9 9 - const isMobile = getIsMobile(); 10 10 const canEdit = getCanEdit(); 11 11 const additionalData = getAdditionalUserData(); 12 12 ··· 53 53 } 54 54 } 55 55 56 - let containerWidth = $state(0); 57 - let containerHeight = $state(0); 58 - 59 - let totalItems = $derived(namedContributors.length); 60 - 61 - const GAP = 6; 62 - const MIN_SIZE = 16; 63 - const MAX_SIZE = 120; 64 - 65 - function cinemaCapacity(size: number, availW: number, availH: number): number { 66 - const colsWide = Math.floor((availW + GAP) / (size + GAP)); 67 - if (colsWide < 1) return 0; 68 - const colsNarrow = Math.max(1, colsWide - 1); 69 - const maxRows = Math.floor((availH + GAP) / (size + GAP)); 70 - let capacity = 0; 71 - // Pattern: narrow, wide, narrow, wide... (row 0 is narrow) 72 - for (let r = 0; r < maxRows; r++) { 73 - capacity += r % 2 === 0 ? colsNarrow : colsWide; 74 - } 75 - return capacity; 76 - } 77 - 78 - function gridCapacity(size: number, availW: number, availH: number): number { 79 - const cols = Math.floor((availW + GAP) / (size + GAP)); 80 - const rows = Math.floor((availH + GAP) / (size + GAP)); 81 - return cols * rows; 82 - } 83 - 84 - let computedSize = $derived.by(() => { 85 - if (!containerWidth || !containerHeight || totalItems === 0) return 40; 86 - 87 - let lo = MIN_SIZE; 88 - let hi = MAX_SIZE; 89 - const capacityFn = layout === 'cinema' ? cinemaCapacity : gridCapacity; 90 - 91 - while (lo <= hi) { 92 - const mid = Math.floor((lo + hi) / 2); 93 - const availW = containerWidth - (layout === 'cinema' ? mid / 2 : 0); 94 - const availH = containerHeight - (layout === 'cinema' ? mid / 2 : 0); 95 - if (availW <= 0 || availH <= 0) { 96 - hi = mid - 1; 97 - continue; 98 - } 99 - if (capacityFn(mid, availW, availH) >= totalItems) { 100 - lo = mid + 1; 101 - } else { 102 - hi = mid - 1; 103 - } 104 - } 105 - 106 - return Math.max(MIN_SIZE, hi); 107 - }); 108 - 109 - let padding = $derived(layout === 'cinema' ? computedSize / 4 : 0); 110 - 111 - let rows = $derived.by(() => { 112 - const availW = containerWidth - (layout === 'cinema' ? computedSize / 4 : 0); 113 - if (availW <= 0) return [] as GitHubContributor[][]; 114 - 115 - const colsWide = Math.floor((availW + GAP) / (computedSize + GAP)); 116 - const colsNarrow = layout === 'cinema' ? Math.max(1, colsWide - 1) : colsWide; 117 - 118 - // Calculate row sizes from bottom up, then reverse for incomplete row at top 119 - const rowSizes: number[] = []; 120 - let remaining = namedContributors.length; 121 - let rowNum = 0; 122 - while (remaining > 0) { 123 - const cols = layout === 'cinema' && rowNum % 2 === 0 ? colsNarrow : colsWide; 124 - rowSizes.push(Math.min(cols, remaining)); 125 - remaining -= cols; 126 - rowNum++; 127 - } 128 - rowSizes.reverse(); 129 - 130 - // Fill rows with contributors in order 131 - const result: GitHubContributor[][] = []; 132 - let idx = 0; 133 - for (const size of rowSizes) { 134 - result.push(namedContributors.slice(idx, idx + size)); 135 - idx += size; 136 - } 137 - return result; 138 - }); 139 - 140 - let textSize = $derived( 141 - computedSize < 24 ? 'text-[10px]' : computedSize < 40 ? 'text-xs' : 'text-sm' 56 + let gridItems = $derived( 57 + namedContributors.map((c) => ({ 58 + imageUrl: c.avatarUrl, 59 + link: `https://github.com/${c.username}`, 60 + label: c.username 61 + })) 142 62 ); 143 - 144 - let shapeClass = $derived(shape === 'circle' ? 'rounded-full' : 'rounded-lg'); 145 63 </script> 146 64 147 - <div 148 - class="flex h-full w-full items-center justify-center overflow-hidden px-2" 149 - bind:clientWidth={containerWidth} 150 - bind:clientHeight={containerHeight} 151 - > 152 - {#if !owner || !repo} 153 - {#if canEdit()} 65 + {#if !owner || !repo} 66 + {#if canEdit()} 67 + <div class="flex h-full w-full items-center justify-center"> 154 68 <span class="text-base-400 dark:text-base-500 accent:text-accent-300 text-sm"> 155 69 Enter a repository 156 70 </span> 157 - {/if} 158 - {:else if totalItems > 0} 159 - <div style="padding: {padding}px;"> 160 - <div class="flex flex-col items-center" style="gap: {GAP}px;"> 161 - {#each rows as row, rowIdx (rowIdx)} 162 - <div class="flex justify-center" style="gap: {GAP}px;"> 163 - {#each row as contributor (contributor.username)} 164 - <a 165 - href="https://github.com/{contributor.username}" 166 - target="_blank" 167 - rel="noopener noreferrer" 168 - class="accent:ring-accent-500 block {shapeClass} ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900" 169 - > 170 - {#if contributor.avatarUrl} 171 - <img 172 - src={contributor.avatarUrl} 173 - alt={contributor.username} 174 - class="{shapeClass} object-cover" 175 - style="width: {computedSize}px; height: {computedSize}px;" 176 - /> 177 - {:else} 178 - <div 179 - class="bg-base-200 dark:bg-base-700 accent:bg-accent-400 flex items-center justify-center {shapeClass}" 180 - style="width: {computedSize}px; height: {computedSize}px;" 181 - > 182 - <span 183 - class="text-base-500 dark:text-base-400 accent:text-accent-100 {textSize} font-medium" 184 - > 185 - {contributor.username.charAt(0).toUpperCase()} 186 - </span> 187 - </div> 188 - {/if} 189 - </a> 190 - {/each} 191 - </div> 192 - {/each} 193 - </div> 194 71 </div> 195 72 {/if} 196 - </div> 73 + {:else} 74 + <ImageGrid items={gridItems} {layout} {shape} tooltip /> 75 + {/if}
+1 -1
src/lib/cards/social/NpmxLikesLeaderboardCard/NpmxLikesLeaderboardCard.svelte
··· 91 91 {/each} 92 92 </div> 93 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" 94 + class="from-base-200 dark:from-base-950 accent:from-accent-500 pointer-events-none absolute inset-x-0 bottom-0 z-10 h-12 bg-linear-to-t from-40% to-transparent" 95 95 ></div> 96 96 <div 97 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"
+180
src/lib/components/ImageGrid.svelte
··· 1 + <script lang="ts"> 2 + import { Tooltip } from 'bits-ui'; 3 + 4 + export type ImageGridItem = { 5 + imageUrl: string | null; 6 + link: string; 7 + label: string; 8 + }; 9 + 10 + let { 11 + items, 12 + layout = 'grid', 13 + shape = 'square', 14 + tooltip = false 15 + }: { 16 + items: ImageGridItem[]; 17 + layout?: 'grid' | 'cinema'; 18 + shape?: 'square' | 'circle'; 19 + tooltip?: boolean; 20 + } = $props(); 21 + 22 + let containerWidth = $state(0); 23 + let containerHeight = $state(0); 24 + 25 + let totalItems = $derived(items.length); 26 + 27 + const GAP = 6; 28 + const MIN_SIZE = 16; 29 + const MAX_SIZE = 120; 30 + 31 + function cinemaCapacity(size: number, availW: number, availH: number): number { 32 + const colsWide = Math.floor((availW + GAP) / (size + GAP)); 33 + if (colsWide < 1) return 0; 34 + const colsNarrow = Math.max(1, colsWide - 1); 35 + const maxRows = Math.floor((availH + GAP) / (size + GAP)); 36 + let capacity = 0; 37 + for (let r = 0; r < maxRows; r++) { 38 + capacity += r % 2 === 0 ? colsNarrow : colsWide; 39 + } 40 + return capacity; 41 + } 42 + 43 + function gridCapacity(size: number, availW: number, availH: number): number { 44 + const cols = Math.floor((availW + GAP) / (size + GAP)); 45 + const rows = Math.floor((availH + GAP) / (size + GAP)); 46 + return cols * rows; 47 + } 48 + 49 + let computedSize = $derived.by(() => { 50 + if (!containerWidth || !containerHeight || totalItems === 0) return 40; 51 + 52 + let lo = MIN_SIZE; 53 + let hi = MAX_SIZE; 54 + const capacityFn = layout === 'cinema' ? cinemaCapacity : gridCapacity; 55 + 56 + while (lo <= hi) { 57 + const mid = Math.floor((lo + hi) / 2); 58 + const availW = containerWidth - (layout === 'cinema' ? mid / 2 : 0); 59 + const availH = containerHeight - (layout === 'cinema' ? mid / 2 : 0); 60 + if (availW <= 0 || availH <= 0) { 61 + hi = mid - 1; 62 + continue; 63 + } 64 + if (capacityFn(mid, availW, availH) >= totalItems) { 65 + lo = mid + 1; 66 + } else { 67 + hi = mid - 1; 68 + } 69 + } 70 + 71 + return Math.max(MIN_SIZE, hi); 72 + }); 73 + 74 + let padding = $derived(layout === 'cinema' ? computedSize / 4 : 0); 75 + 76 + let rows = $derived.by(() => { 77 + const availW = containerWidth - (layout === 'cinema' ? computedSize / 4 : 0); 78 + if (availW <= 0) return [] as ImageGridItem[][]; 79 + 80 + const colsWide = Math.floor((availW + GAP) / (computedSize + GAP)); 81 + const colsNarrow = layout === 'cinema' ? Math.max(1, colsWide - 1) : colsWide; 82 + 83 + const rowSizes: number[] = []; 84 + let remaining = items.length; 85 + let rowNum = 0; 86 + while (remaining > 0) { 87 + const cols = layout === 'cinema' && rowNum % 2 === 0 ? colsNarrow : colsWide; 88 + rowSizes.push(Math.min(cols, remaining)); 89 + remaining -= cols; 90 + rowNum++; 91 + } 92 + rowSizes.reverse(); 93 + 94 + const result: ImageGridItem[][] = []; 95 + let idx = 0; 96 + for (const size of rowSizes) { 97 + result.push(items.slice(idx, idx + size)); 98 + idx += size; 99 + } 100 + return result; 101 + }); 102 + 103 + let textSize = $derived( 104 + computedSize < 24 ? 'text-[10px]' : computedSize < 40 ? 'text-xs' : 'text-sm' 105 + ); 106 + 107 + let shapeClass = $derived(shape === 'circle' ? 'rounded-full' : 'rounded-lg'); 108 + </script> 109 + 110 + {#snippet gridItem(item: ImageGridItem)} 111 + {#if item.imageUrl} 112 + <img 113 + src={item.imageUrl} 114 + alt={item.label} 115 + class="{shapeClass} object-cover" 116 + style="width: {computedSize}px; height: {computedSize}px;" 117 + /> 118 + {:else} 119 + <div 120 + class="bg-base-200 dark:bg-base-700 accent:bg-accent-400 flex items-center justify-center {shapeClass}" 121 + style="width: {computedSize}px; height: {computedSize}px;" 122 + > 123 + <span class="text-base-500 dark:text-base-400 accent:text-accent-100 {textSize} font-medium"> 124 + {item.label.charAt(0).toUpperCase()} 125 + </span> 126 + </div> 127 + {/if} 128 + {/snippet} 129 + 130 + <div 131 + class="flex h-full w-full items-center justify-center overflow-hidden px-2" 132 + bind:clientWidth={containerWidth} 133 + bind:clientHeight={containerHeight} 134 + > 135 + {#if totalItems > 0} 136 + <div style="padding: {padding}px;"> 137 + <div class="flex flex-col items-center" style="gap: {GAP}px;"> 138 + {#each rows as row, rowIdx (rowIdx)} 139 + <div class="flex justify-center" style="gap: {GAP}px;"> 140 + {#each row as item (item.link)} 141 + {#if tooltip} 142 + <Tooltip.Root> 143 + <Tooltip.Trigger> 144 + <a 145 + href={item.link} 146 + target="_blank" 147 + rel="noopener noreferrer" 148 + class="accent:ring-accent-500 block {shapeClass} ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900" 149 + > 150 + {@render gridItem(item)} 151 + </a> 152 + </Tooltip.Trigger> 153 + <Tooltip.Portal> 154 + <Tooltip.Content 155 + side="top" 156 + sideOffset={4} 157 + class="bg-base-900 dark:bg-base-800 text-base-100 z-50 rounded-lg px-3 py-1.5 text-xs font-medium shadow-md" 158 + > 159 + {item.label} 160 + </Tooltip.Content> 161 + </Tooltip.Portal> 162 + </Tooltip.Root> 163 + {:else} 164 + <a 165 + href={item.link} 166 + target="_blank" 167 + rel="noopener noreferrer" 168 + class="accent:ring-accent-500 block {shapeClass} ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900" 169 + title={item.label} 170 + > 171 + {@render gridItem(item)} 172 + </a> 173 + {/if} 174 + {/each} 175 + </div> 176 + {/each} 177 + </div> 178 + </div> 179 + {/if} 180 + </div>
+17 -2
src/lib/website/ThemeScript.svelte
··· 10 10 } = $props(); 11 11 12 12 const allAccentColors = [ 13 - 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 14 - 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose' 13 + 'red', 14 + 'orange', 15 + 'amber', 16 + 'yellow', 17 + 'lime', 18 + 'green', 19 + 'emerald', 20 + 'teal', 21 + 'cyan', 22 + 'sky', 23 + 'blue', 24 + 'indigo', 25 + 'violet', 26 + 'purple', 27 + 'fuchsia', 28 + 'pink', 29 + 'rose' 15 30 ]; 16 31 const allBaseColors = ['gray', 'stone', 'zinc', 'neutral', 'slate']; 17 32
+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;
+4 -1
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 3 4 + import { Tooltip } from 'bits-ui'; 4 5 import { ThemeToggle, Toaster, toast } from '@foxui/core'; 5 6 import { onMount } from 'svelte'; 6 7 import { initClient } from '$lib/atproto'; ··· 28 29 }); 29 30 </script> 30 31 31 - {@render children()} 32 + <Tooltip.Provider delayDuration={300}> 33 + {@render children()} 34 + </Tooltip.Provider> 32 35 33 36 <ThemeToggle class="fixed top-2 left-2 z-10" /> 34 37 <Toaster />
+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} />
+89
src/routes/api/lastfm/+server.ts
··· 1 + import { json } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { env } from '$env/dynamic/private'; 4 + 5 + const LASTFM_API_URL = 'https://ws.audioscrobbler.com/2.0/'; 6 + 7 + const ALLOWED_METHODS = [ 8 + 'user.getRecentTracks', 9 + 'user.getTopTracks', 10 + 'user.getTopAlbums', 11 + 'user.getInfo' 12 + ]; 13 + 14 + const CACHE_TTL: Record<string, number> = { 15 + 'user.getRecentTracks': 15 * 60 * 1000, 16 + 'user.getTopTracks': 60 * 60 * 1000, 17 + 'user.getTopAlbums': 60 * 60 * 1000, 18 + 'user.getInfo': 12 * 60 * 60 * 1000 19 + }; 20 + 21 + export const GET: RequestHandler = async ({ url, platform }) => { 22 + const method = url.searchParams.get('method'); 23 + const user = url.searchParams.get('user'); 24 + const period = url.searchParams.get('period') || '7day'; 25 + const limit = url.searchParams.get('limit') || '50'; 26 + 27 + if (!method || !user) { 28 + return json({ error: 'Missing method or user parameter' }, { status: 400 }); 29 + } 30 + 31 + if (!ALLOWED_METHODS.includes(method)) { 32 + return json({ error: 'Method not allowed' }, { status: 400 }); 33 + } 34 + 35 + const cacheKey = `#lastfm:${method}:${user}:${period}:${limit}`; 36 + const cachedData = await platform?.env?.USER_DATA_CACHE?.get(cacheKey); 37 + 38 + if (cachedData) { 39 + const parsed = JSON.parse(cachedData); 40 + const ttl = CACHE_TTL[method] || 60 * 60 * 1000; 41 + 42 + if (Date.now() - (parsed._cachedAt || 0) < ttl) { 43 + return json(parsed); 44 + } 45 + } 46 + 47 + const apiKey = env?.LASTFM_API_KEY; 48 + if (!apiKey) { 49 + return json({ error: 'Last.fm API key not configured' }, { status: 500 }); 50 + } 51 + 52 + try { 53 + const params = new URLSearchParams({ 54 + method, 55 + user, 56 + api_key: apiKey, 57 + format: 'json', 58 + limit 59 + }); 60 + 61 + if (method === 'user.getTopTracks' || method === 'user.getTopAlbums') { 62 + params.set('period', period); 63 + } 64 + 65 + const response = await fetch(`${LASTFM_API_URL}?${params}`); 66 + 67 + if (!response.ok) { 68 + return json( 69 + { error: 'Failed to fetch Last.fm data: ' + response.statusText }, 70 + { status: response.status } 71 + ); 72 + } 73 + 74 + const data = await response.json(); 75 + 76 + if (data.error) { 77 + return json({ error: data.message || 'Last.fm API error' }, { status: 400 }); 78 + } 79 + 80 + data._cachedAt = Date.now(); 81 + 82 + await platform?.env?.USER_DATA_CACHE?.put(cacheKey, JSON.stringify(data)); 83 + 84 + return json(data); 85 + } catch (error) { 86 + console.error('Error fetching Last.fm data:', error); 87 + return json({ error: 'Failed to fetch Last.fm data' }, { status: 500 }); 88 + } 89 + };
+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} />