tangled
alpha
login
or
join now
flo-bit.dev
/
blento
your personal website on atproto - mirror
blento.app
20
fork
atom
overview
issues
pulls
pipelines
Compare changes
Choose any two refs to compare.
base:
various-fixes
updated-blentos
update-docs
timer-card-tiny-fix
theme-colors
switch-map
switch-grid-layout
statusphere-fix
small-fixes
signup
show-login-error
section-settings
section-fix-undo
remove-extra-buttons
refactor-cards
refactor
record-visualizer-card
qr-codes
profile-stuff-2
profile-stuff
product-hunt
polijn/main
pages
npmx
next
new-og-image-wip
move-qr-click
mobile-editing
map
main-favicon
main
mail-icon
lastfm
kickstarter-card
invalid-handle-fix
improve-saving
improve-oauth
improve-link-card
improve-fluid-text
image-fixes
hide-friends
github-contribs
gifs-heypster
funding
fuck-another-fix
floating-button
fixes
fix-xss
fix-timer-stuff
fix-signup-pds
fix-package-manager
fix-other-handles-on-custom-domain
fix-invalid-site.standard.documents
fix-formatting
fix-favicon
fix-build
event-card
edit-profile
drawing-card
custom-domains-editing
custom-domains
copy-page
card-label
card-command-bar-v2
card-command-bar
button
bump-text-padding
bluesky-post-nsfw-labels
bluesky-post-card
bluesky-feed-card
apple-music-playlist
no tags found
compare:
various-fixes
updated-blentos
update-docs
timer-card-tiny-fix
theme-colors
switch-map
switch-grid-layout
statusphere-fix
small-fixes
signup
show-login-error
section-settings
section-fix-undo
remove-extra-buttons
refactor-cards
refactor
record-visualizer-card
qr-codes
profile-stuff-2
profile-stuff
product-hunt
polijn/main
pages
npmx
next
new-og-image-wip
move-qr-click
mobile-editing
map
main-favicon
main
mail-icon
lastfm
kickstarter-card
invalid-handle-fix
improve-saving
improve-oauth
improve-link-card
improve-fluid-text
image-fixes
hide-friends
github-contribs
gifs-heypster
funding
fuck-another-fix
floating-button
fixes
fix-xss
fix-timer-stuff
fix-signup-pds
fix-package-manager
fix-other-handles-on-custom-domain
fix-invalid-site.standard.documents
fix-formatting
fix-favicon
fix-build
event-card
edit-profile
drawing-card
custom-domains-editing
custom-domains
copy-page
card-label
card-command-bar-v2
card-command-bar
button
bump-text-padding
bluesky-post-nsfw-labels
bluesky-post-card
bluesky-feed-card
apple-music-playlist
no tags found
go
+611
-22
12 changed files
expand all
collapse all
unified
split
src
lib
cards
index.ts
social
BigSocialCard
index.ts
NpmxLikesCard
NpmxLikesCard.svelte
index.ts
NpmxLikesLeaderboardCard
NpmxLikesLeaderboardCard.svelte
index.ts
special
UpdatedBlentos
index.ts
visual
FluidTextCard
FluidTextCard.svelte
website
EditableProfile.svelte
EditableWebsite.svelte
routes
[handle=handle]
[[page]]
copy
+page.svelte
api
npmx-leaderboard
+server.ts
+5
-1
src/lib/cards/index.ts
···
40
import { GitHubContributorsCardDefinition } from './social/GitHubContributorsCard';
41
import { ProductHuntCardDefinition } from './social/ProductHuntCard';
42
import { KickstarterCardDefinition } from './social/KickstarterCard';
0
0
43
// import { Model3DCardDefinition } from './visual/Model3DCard';
44
45
export const AllCardDefinitions = [
···
84
FriendsCardDefinition,
85
GitHubContributorsCardDefinition,
86
ProductHuntCardDefinition,
87
-
KickstarterCardDefinition
0
0
88
] as const;
89
90
export const CardDefinitionsByType = AllCardDefinitions.reduce(
···
40
import { GitHubContributorsCardDefinition } from './social/GitHubContributorsCard';
41
import { ProductHuntCardDefinition } from './social/ProductHuntCard';
42
import { KickstarterCardDefinition } from './social/KickstarterCard';
43
+
import { NpmxLikesCardDefinition } from './social/NpmxLikesCard';
44
+
import { NpmxLikesLeaderboardCardDefinition } from './social/NpmxLikesLeaderboardCard';
45
// import { Model3DCardDefinition } from './visual/Model3DCard';
46
47
export const AllCardDefinitions = [
···
86
FriendsCardDefinition,
87
GitHubContributorsCardDefinition,
88
ProductHuntCardDefinition,
89
+
KickstarterCardDefinition,
90
+
NpmxLikesCardDefinition,
91
+
NpmxLikesLeaderboardCardDefinition
92
] as const;
93
94
export const CardDefinitionsByType = AllCardDefinitions.reduce(
+22
-1
src/lib/cards/social/BigSocialCard/index.ts
···
167
168
tangled: /(?:tangled\.org)/i,
169
170
-
mail: /(?:mailto:)/i
0
0
171
};
172
173
export const platformsData: Record<string, SimpleIcon> = {
···
277
</g>
278
<defs>
279
<clipPath id="clip0_0_3">
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
280
<rect width="24" height="24" fill="white"/>
281
</clipPath>
282
</defs>
···
167
168
tangled: /(?:tangled\.org)/i,
169
170
+
mail: /(?:mailto:)/i,
171
+
172
+
npmx: /(?:npmx\.dev)/i
173
};
174
175
export const platformsData: Record<string, SimpleIcon> = {
···
279
</g>
280
<defs>
281
<clipPath id="clip0_0_3">
282
+
<rect width="24" height="24" fill="white"/>
283
+
</clipPath>
284
+
</defs>
285
+
</svg>`
286
+
},
287
+
288
+
npmx: {
289
+
slug: 'npmx',
290
+
path: '',
291
+
title: 'npmx',
292
+
source: '',
293
+
hex: '0A0A0A',
294
+
svg: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
295
+
<g clip-path="url(#clip0_4_2)">
296
+
<path d="M6.12765 16.4049H2V20.5326H6.12765V16.4049Z" fill="#525252"/>
297
+
<path d="M10.9049 23.8485L19.6885 -1H22L13.2164 23.8485H10.9049Z" fill="#FAFAFA"/>
298
+
</g>
299
+
<defs>
300
+
<clipPath id="clip0_4_2">
301
<rect width="24" height="24" fill="white"/>
302
</clipPath>
303
</defs>
+103
src/lib/cards/social/NpmxLikesCard/NpmxLikesCard.svelte
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<script lang="ts">
2
+
import 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">·</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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
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
47
let result = [...(await Promise.all(profiles)), ...existingUsersArray];
48
49
-
result = result.filter((v) => v && v.handle !== 'handle.invalid');
0
0
50
51
if (cache) {
52
await cache?.put('updatedBlentos', JSON.stringify(result));
53
}
54
-
return JSON.parse(JSON.stringify(result));
55
} catch (error) {
56
console.error('error fetching updated blentos', error);
57
return [];
···
46
47
let result = [...(await Promise.all(profiles)), ...existingUsersArray];
48
49
+
result = result.filter(
50
+
(v) => v && v.handle !== 'handle.invalid' && !v.handle.endsWith('.pds.rip')
51
+
);
52
53
if (cache) {
54
await cache?.put('updatedBlentos', JSON.stringify(result));
55
}
56
+
return JSON.parse(JSON.stringify(result.slice(0, 20)));
57
} catch (error) {
58
console.error('error fetching updated blentos', error);
59
return [];
+7
-3
src/lib/cards/visual/FluidTextCard/FluidTextCard.svelte
···
1769
1770
<div
1771
bind:this={container}
1772
-
class="relative h-full w-full overflow-hidden {item.color === 'transparent'
1773
? 'bg-base-50 dark:bg-base-900'
1774
: 'bg-black'}"
1775
>
1776
-
<canvas bind:this={shadowCanvas} class="absolute h-full w-full"></canvas>
1777
-
<canvas bind:this={fluidCanvas} class="absolute h-full w-full"></canvas>
0
0
0
0
1778
<canvas bind:this={maskCanvas} class="absolute h-full w-full"></canvas>
1779
</div>
···
1769
1770
<div
1771
bind:this={container}
1772
+
class="relative h-full w-full overflow-hidden rounded-[inherit] {item.color === 'transparent'
1773
? 'bg-base-50 dark:bg-base-900'
1774
: 'bg-black'}"
1775
>
1776
+
<canvas
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>
1782
<canvas bind:this={maskCanvas} class="absolute h-full w-full"></canvas>
1783
</div>
+1
-3
src/lib/website/EditableProfile.svelte
···
3
import { getImage, compressImage, getProfilePosition } from '$lib/helper';
4
import PlainTextEditor from '$lib/components/PlainTextEditor.svelte';
5
import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte';
6
-
import { Avatar, Button } from '@foxui/core';
7
-
import { getIsMobile } from './context';
8
import MadeWithBlento from './MadeWithBlento.svelte';
9
-
import { SelectThemePopover } from '$lib/components/select-theme';
10
11
let { data = $bindable(), hideBlento = false }: { data: WebsiteData; hideBlento?: boolean } =
12
$props();
···
3
import { getImage, compressImage, getProfilePosition } from '$lib/helper';
4
import PlainTextEditor from '$lib/components/PlainTextEditor.svelte';
5
import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte';
6
+
import { Avatar } from '@foxui/core';
0
7
import MadeWithBlento from './MadeWithBlento.svelte';
0
8
9
let { data = $bindable(), hideBlento = false }: { data: WebsiteData; hideBlento?: boolean } =
10
$props();
-12
src/lib/website/EditableWebsite.svelte
···
961
class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 flex items-center gap-2 rounded px-2 py-1 font-mono text-xs"
962
>
963
<span>editedOn: {editedOn}</span>
964
-
<button class="underline" onclick={addAllCardTypes}>+ all cards</button>
965
-
<input
966
-
bind:value={copyInput}
967
-
placeholder="handle/page"
968
-
class="bg-base-800 text-base-100 w-32 rounded px-1 py-0.5"
969
-
onkeydown={(e) => {
970
-
if (e.key === 'Enter') copyPageFrom();
971
-
}}
972
-
/>
973
-
<button class="underline" onclick={copyPageFrom} disabled={isCopying}>
974
-
{isCopying ? 'copying...' : 'copy'}
975
-
</button>
976
</div>
977
{/if}
978
</Context>
···
961
class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 flex items-center gap-2 rounded px-2 py-1 font-mono text-xs"
962
>
963
<span>editedOn: {editedOn}</span>
0
0
0
0
0
0
0
0
0
0
0
0
964
</div>
965
{/if}
966
</Context>
+252
src/routes/[handle=handle]/[[page]]/copy/+page.svelte
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<script lang="ts">
2
+
import {
3
+
putRecord,
4
+
deleteRecord,
5
+
listRecords,
6
+
uploadBlob,
7
+
getCDNImageBlobUrl
8
+
} from '$lib/atproto/methods';
9
+
import { user } from '$lib/atproto/auth.svelte';
10
+
import { goto } from '$app/navigation';
11
+
import * as TID from '@atcute/tid';
12
+
import { Button } from '@foxui/core';
13
+
import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte';
14
+
15
+
let { data } = $props();
16
+
17
+
let destinationPage = $state('');
18
+
let copying = $state(false);
19
+
let error = $state('');
20
+
let success = $state(false);
21
+
22
+
const sourceHandle = $derived(data.handle);
23
+
24
+
const sourcePage = $derived(
25
+
data.page === 'blento.self' ? 'main' : data.page.replace('blento.', '')
26
+
);
27
+
const sourceCards = $derived(data.cards);
28
+
29
+
// Re-upload blobs from source repo to current user's repo
30
+
async function reuploadBlobs(obj: any, sourceDid: string): Promise<void> {
31
+
if (!obj || typeof obj !== 'object') return;
32
+
33
+
for (const key of Object.keys(obj)) {
34
+
const value = obj[key];
35
+
36
+
if (value && typeof value === 'object') {
37
+
// Check if this is a blob reference
38
+
if (value.$type === 'blob' && value.ref?.$link) {
39
+
try {
40
+
// Get the blob URL from source repo
41
+
const blobUrl = getCDNImageBlobUrl({ did: sourceDid, blob: value });
42
+
if (!blobUrl) continue;
43
+
44
+
// Fetch the blob via proxy to avoid CORS
45
+
const response = await fetch(`/api/image-proxy?url=${encodeURIComponent(blobUrl)}`);
46
+
if (!response.ok) {
47
+
console.error('Failed to fetch blob:', blobUrl);
48
+
continue;
49
+
}
50
+
51
+
// Upload to current user's repo
52
+
const blob = await response.blob();
53
+
const newBlobRef = await uploadBlob({ blob });
54
+
55
+
if (newBlobRef) {
56
+
// Replace with new blob reference
57
+
obj[key] = newBlobRef;
58
+
}
59
+
} catch (err) {
60
+
console.error('Failed to re-upload blob:', err);
61
+
}
62
+
} else {
63
+
// Recursively check nested objects
64
+
await reuploadBlobs(value, sourceDid);
65
+
}
66
+
}
67
+
}
68
+
}
69
+
70
+
async function copyPage() {
71
+
if (!user.isLoggedIn || !user.did) {
72
+
error = 'You must be logged in to copy pages';
73
+
return;
74
+
}
75
+
76
+
copying = true;
77
+
error = '';
78
+
79
+
try {
80
+
const targetPage =
81
+
destinationPage.trim() === '' ? 'blento.self' : `blento.${destinationPage.trim()}`;
82
+
83
+
// Fetch existing cards from destination page and delete them
84
+
const existingCards = await listRecords({
85
+
did: user.did,
86
+
collection: 'app.blento.card'
87
+
});
88
+
89
+
const cardsToDelete = existingCards.filter(
90
+
(card: { value: { page?: string } }) => card.value.page === targetPage
91
+
);
92
+
93
+
// Delete existing cards from destination page
94
+
const deletePromises = cardsToDelete.map((card: { uri: string }) => {
95
+
const rkey = card.uri.split('/').pop()!;
96
+
return deleteRecord({
97
+
collection: 'app.blento.card',
98
+
rkey
99
+
});
100
+
});
101
+
102
+
await Promise.all(deletePromises);
103
+
104
+
// Copy each card with a new ID to the destination page
105
+
// Re-upload blobs from source repo to current user's repo
106
+
for (const card of sourceCards) {
107
+
const newCard = {
108
+
...structuredClone(card),
109
+
id: TID.now(),
110
+
page: targetPage,
111
+
updatedAt: new Date().toISOString(),
112
+
version: 2
113
+
};
114
+
115
+
// Re-upload any blobs in cardData
116
+
await reuploadBlobs(newCard.cardData, data.did);
117
+
118
+
await putRecord({
119
+
collection: 'app.blento.card',
120
+
rkey: newCard.id,
121
+
record: newCard
122
+
});
123
+
}
124
+
125
+
const userHandle = user.profile?.handle ?? data.handle;
126
+
127
+
// Copy publication data if it exists
128
+
if (data.publication) {
129
+
const publicationCopy = structuredClone(data.publication) as Record<string, unknown>;
130
+
131
+
// Re-upload any blobs in publication (e.g., icon)
132
+
await reuploadBlobs(publicationCopy, data.did);
133
+
134
+
// Update the URL to point to the user's page
135
+
publicationCopy.url = `https://blento.app/${userHandle}`;
136
+
if (targetPage !== 'blento.self') {
137
+
publicationCopy.url += '/' + targetPage.replace('blento.', '');
138
+
}
139
+
140
+
// Save to appropriate collection based on destination page type
141
+
if (targetPage === 'blento.self') {
142
+
await putRecord({
143
+
collection: 'site.standard.publication',
144
+
rkey: targetPage,
145
+
record: publicationCopy
146
+
});
147
+
} else {
148
+
await putRecord({
149
+
collection: 'app.blento.page',
150
+
rkey: targetPage,
151
+
record: publicationCopy
152
+
});
153
+
}
154
+
}
155
+
156
+
// Refresh the logged-in user's cache
157
+
await fetch(`/${userHandle}/api/refresh`);
158
+
159
+
success = true;
160
+
161
+
// Redirect to the logged-in user's destination page edit
162
+
const destPath = destinationPage.trim() === '' ? '' : `/${destinationPage.trim()}`;
163
+
setTimeout(() => {
164
+
goto(`/${userHandle}${destPath}/edit`);
165
+
}, 1000);
166
+
} catch (e) {
167
+
error = e instanceof Error ? e.message : 'Failed to copy page';
168
+
} finally {
169
+
copying = false;
170
+
}
171
+
}
172
+
</script>
173
+
174
+
<div class="bg-base-50 dark:bg-base-900 flex min-h-screen items-center justify-center p-4">
175
+
<div class="bg-base-100 dark:bg-base-800 w-full max-w-md rounded-2xl p-6 shadow-lg">
176
+
{#if user.isLoggedIn}
177
+
<h1 class="text-base-900 dark:text-base-50 mb-6 text-2xl font-bold">Copy Page</h1>
178
+
179
+
<div class="mb-4">
180
+
<div class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium">
181
+
Source Page
182
+
</div>
183
+
<div
184
+
class="bg-base-200 dark:bg-base-700 text-base-900 dark:text-base-100 rounded-lg px-3 py-2"
185
+
>
186
+
{sourceHandle}/{sourcePage || 'main'}
187
+
</div>
188
+
<p class="text-base-500 mt-1 text-sm">{sourceCards.length} cards</p>
189
+
</div>
190
+
191
+
<div class="mb-6">
192
+
<label
193
+
for="destination"
194
+
class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium"
195
+
>
196
+
Destination Page (on your profile: {user.profile?.handle})
197
+
</label>
198
+
<input
199
+
id="destination"
200
+
type="text"
201
+
bind:value={destinationPage}
202
+
placeholder="Leave empty for main page"
203
+
class="bg-base-50 dark:bg-base-700 border-base-300 dark:border-base-600 text-base-900 dark:text-base-100 focus:ring-accent-500 w-full rounded-lg border px-3 py-2 focus:ring-2 focus:outline-none"
204
+
/>
205
+
</div>
206
+
207
+
{#if error}
208
+
<div
209
+
class="mb-4 rounded-lg bg-red-100 p-3 text-red-700 dark:bg-red-900/30 dark:text-red-400"
210
+
>
211
+
{error}
212
+
</div>
213
+
{/if}
214
+
215
+
{#if success}
216
+
<div
217
+
class="mb-4 rounded-lg bg-green-100 p-3 text-green-700 dark:bg-green-900/30 dark:text-green-400"
218
+
>
219
+
Page copied successfully! Redirecting...
220
+
</div>
221
+
{/if}
222
+
223
+
<div class="flex gap-3">
224
+
<a
225
+
href="/{data.handle}/{sourcePage === 'main' ? '' : sourcePage}"
226
+
class="bg-base-200 hover:bg-base-300 dark:bg-base-700 dark:hover:bg-base-600 text-base-700 dark:text-base-300 flex-1 rounded-lg px-4 py-2 text-center font-medium transition-colors"
227
+
>
228
+
Cancel
229
+
</a>
230
+
<button
231
+
onclick={copyPage}
232
+
disabled={copying || success}
233
+
class="bg-accent-500 hover:bg-accent-600 flex-1 rounded-lg px-4 py-2 font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
234
+
>
235
+
{#if copying}
236
+
Copying...
237
+
{:else}
238
+
Copy {sourceCards.length} cards
239
+
{/if}
240
+
</button>
241
+
</div>
242
+
{:else}
243
+
<h1 class="text-base-900 dark:text-base-50 mb-6 text-2xl font-bold">
244
+
You must be signed in to copy a page!
245
+
</h1>
246
+
247
+
<div class="flex w-full justify-center">
248
+
<Button size="lg" onclick={() => loginModalState.show()}>Login</Button>
249
+
</div>
250
+
{/if}
251
+
</div>
252
+
</div>
+44
src/routes/api/npmx-leaderboard/+server.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
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
+
};