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
+2254
-657
49 changed files
expand all
collapse all
unified
split
package.json
pnpm-lock.yaml
src
lib
cards
index.ts
media
LastFMCard
CreateLastFMCardModal.svelte
LastFMAlbumArt.svelte
LastFMPeriodSettings.svelte
LastFMProfileCard
LastFMProfileCard.svelte
index.ts
LastFMRecentTracksCard
LastFMRecentTracksCard.svelte
index.ts
LastFMTopAlbumsCard
LastFMTopAlbumsCard.svelte
LastFMTopAlbumsCardSettings.svelte
index.ts
LastFMTopTracksCard
LastFMTopTracksCard.svelte
index.ts
social
BigSocialCard
BigSocialCard.svelte
index.ts
GitHubContributorsCard
GitHubContributorsCard.svelte
NpmxLikesCard
NpmxLikesCard.svelte
index.ts
NpmxLikesLeaderboardCard
NpmxLikesLeaderboardCard.svelte
index.ts
special
UpdatedBlentos
index.ts
visual
FluidTextCard
FluidTextCard.svelte
RecordVisualizerCard
RecordVisualizerCard.svelte
RecordVisualizerSettings.svelte
index.ts
components
ImageGrid.svelte
qr
qrOverlay.svelte.ts
website
EditableProfile.svelte
EditableWebsite.svelte
ThemeScript.svelte
params
handle.ts
routes
+layout.svelte
[handle=handle]
(pages)
+layout.server.ts
+page.svelte
edit
+page.svelte
p
[[page]]
+page.svelte
copy
+page.svelte
edit
+page.svelte
[[page]]
+layout.server.ts
+page.svelte
edit
+page.svelte
api
lastfm
+server.ts
npmx-leaderboard
+server.ts
p
[[page]]
+layout.server.ts
+page.svelte
copy
+page.svelte
edit
+page.svelte
+1
-2
package.json
···
5
"type": "module",
6
"scripts": {
7
"dev": "vite dev",
8
-
"build": "vite build",
9
"preview": "pnpm run build && wrangler dev",
10
"prepare": "svelte-kit sync || echo ''",
11
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
···
79
"mapbox-gl": "^3.18.1",
80
"marked": "^17.0.1",
81
"perfect-freehand": "^1.2.2",
82
-
"pixi.js": "^8.16.0",
83
"plyr": "^3.8.4",
84
"qr-code-styling": "^1.8.6",
85
"react-grid-layout": "^2.2.2",
···
5
"type": "module",
6
"scripts": {
7
"dev": "vite dev",
8
+
"build": "NODE_OPTIONS='--max-old-space-size=4096' vite build",
9
"preview": "pnpm run build && wrangler dev",
10
"prepare": "svelte-kit sync || echo ''",
11
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
···
79
"mapbox-gl": "^3.18.1",
80
"marked": "^17.0.1",
81
"perfect-freehand": "^1.2.2",
0
82
"plyr": "^3.8.4",
83
"qr-code-styling": "^1.8.6",
84
"react-grid-layout": "^2.2.2",
+1
-69
pnpm-lock.yaml
···
128
perfect-freehand:
129
specifier: ^1.2.2
130
version: 1.2.2
131
-
pixi.js:
132
-
specifier: ^8.16.0
133
-
version: 8.16.0
134
plyr:
135
specifier: ^3.8.4
136
version: 3.8.4
···
981
peerDependencies:
982
svelte: ^4 || ^5
983
984
-
'@pixi/colord@2.9.6':
985
-
resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==, tarball: https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz}
986
-
987
'@polka/url@1.0.0-next.29':
988
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
989
···
1513
1514
'@types/cookie@0.6.0':
1515
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
1516
-
1517
-
'@types/earcut@3.0.0':
1518
-
resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==, tarball: https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz}
1519
1520
'@types/estree@1.0.8':
1521
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
···
1633
'@webgpu/types@0.1.69':
1634
resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==}
1635
1636
-
'@xmldom/xmldom@0.8.11':
1637
-
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==, tarball: https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz}
1638
-
engines: {node: '>=10.0.0'}
1639
-
1640
acorn-jsx@5.3.2:
1641
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
1642
peerDependencies:
···
1866
resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==}
1867
1868
earcut@3.0.2:
1869
-
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==, tarball: https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz}
1870
1871
emoji-picker-element@1.28.1:
1872
resolution: {integrity: sha512-8c64IPish2PWoV9oYCo2pvuPHwIv+uK9bO0dfpPyMupDAvaWL9ZvYhWNTAR+2sx7BhfRjciImqP6CIUgNX+DMg==}
···
1976
esutils@2.0.3:
1977
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
1978
engines: {node: '>=0.10.0'}
1979
-
1980
-
eventemitter3@5.0.4:
1981
-
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==, tarball: https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz}
1982
1983
exsolve@1.0.8:
1984
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
···
2033
geojson-vt@4.0.2:
2034
resolution: {integrity: sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==}
2035
2036
-
gifuct-js@2.1.2:
2037
-
resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==, tarball: https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz}
2038
-
2039
gl-matrix@3.4.4:
2040
resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==}
2041
···
2121
isexe@2.0.0:
2122
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
2123
2124
-
ismobilejs@1.1.1:
2125
-
resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==, tarball: https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz}
2126
-
2127
iso-datestring-validator@2.2.2:
2128
resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==}
2129
2130
jiti@2.6.1:
2131
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
2132
hasBin: true
2133
-
2134
-
js-binary-schema-parser@2.0.3:
2135
-
resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==, tarball: https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz}
2136
2137
js-tokens@4.0.0:
2138
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, tarball: https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz}
···
2410
parse-css-color@0.2.1:
2411
resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==}
2412
2413
-
parse-svg-path@0.1.2:
2414
-
resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==, tarball: https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz}
2415
-
2416
parse5-htmlparser2-tree-adapter@7.1.0:
2417
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
2418
···
2449
picomatch@4.0.3:
2450
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
2451
engines: {node: '>=12'}
2452
-
2453
-
pixi.js@8.16.0:
2454
-
resolution: {integrity: sha512-gu2xw3sZGAn3cWBtk0HqTQT+v19YAfiaYXwUGgWoJl5NKz4cEZJUgWrwkmdfDszGyYBAGqOvJNbd2M9+vzLLMg==, tarball: https://registry.npmjs.org/pixi.js/-/pixi.js-8.16.0.tgz}
2455
2456
pkg-types@1.3.1:
2457
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
···
2928
tiny-inflate@1.0.3:
2929
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
2930
2931
-
tiny-lru@11.4.7:
2932
-
resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==, tarball: https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz}
2933
-
engines: {node: '>=12'}
2934
-
2935
tinyglobby@0.2.15:
2936
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
2937
engines: {node: '>=12.0.0'}
···
3785
number-flow: 0.5.9
3786
svelte: 5.48.0
3787
3788
-
'@pixi/colord@2.9.6': {}
3789
-
3790
'@polka/url@1.0.0-next.29': {}
3791
3792
'@poppinss/colors@4.1.6':
···
4261
4262
'@types/cookie@0.6.0': {}
4263
4264
-
'@types/earcut@3.0.0': {}
4265
-
4266
'@types/estree@1.0.8': {}
4267
4268
'@types/geojson-vt@3.2.5':
···
4411
'@use-gesture/core': 10.3.1
4412
4413
'@webgpu/types@0.1.69': {}
4414
-
4415
-
'@xmldom/xmldom@0.8.11': {}
4416
4417
acorn-jsx@5.3.2(acorn@8.15.0):
4418
dependencies:
···
4824
4825
esutils@2.0.3: {}
4826
4827
-
eventemitter3@5.0.4: {}
4828
-
4829
exsolve@1.0.8: {}
4830
4831
fast-deep-equal@3.1.3: {}
···
4864
optional: true
4865
4866
geojson-vt@4.0.2: {}
4867
-
4868
-
gifuct-js@2.1.2:
4869
-
dependencies:
4870
-
js-binary-schema-parser: 2.0.3
4871
4872
gl-matrix@3.4.4: {}
4873
···
4938
4939
isexe@2.0.0: {}
4940
4941
-
ismobilejs@1.1.1: {}
4942
-
4943
iso-datestring-validator@2.2.2: {}
4944
4945
jiti@2.6.1: {}
4946
-
4947
-
js-binary-schema-parser@2.0.3: {}
4948
4949
js-tokens@4.0.0: {}
4950
···
5216
color-name: 1.1.4
5217
hex-rgb: 4.3.0
5218
5219
-
parse-svg-path@0.1.2: {}
5220
-
5221
parse5-htmlparser2-tree-adapter@7.1.0:
5222
dependencies:
5223
domhandler: 5.0.3
···
5248
picocolors@1.1.1: {}
5249
5250
picomatch@4.0.3: {}
5251
-
5252
-
pixi.js@8.16.0:
5253
-
dependencies:
5254
-
'@pixi/colord': 2.9.6
5255
-
'@types/earcut': 3.0.0
5256
-
'@webgpu/types': 0.1.69
5257
-
'@xmldom/xmldom': 0.8.11
5258
-
earcut: 3.0.2
5259
-
eventemitter3: 5.0.4
5260
-
gifuct-js: 2.1.2
5261
-
ismobilejs: 1.1.1
5262
-
parse-svg-path: 0.1.2
5263
-
tiny-lru: 11.4.7
5264
5265
pkg-types@1.3.1:
5266
dependencies:
···
5775
three@0.176.0: {}
5776
5777
tiny-inflate@1.0.3: {}
5778
-
5779
-
tiny-lru@11.4.7: {}
5780
5781
tinyglobby@0.2.15:
5782
dependencies:
···
128
perfect-freehand:
129
specifier: ^1.2.2
130
version: 1.2.2
0
0
0
131
plyr:
132
specifier: ^3.8.4
133
version: 3.8.4
···
978
peerDependencies:
979
svelte: ^4 || ^5
980
0
0
0
981
'@polka/url@1.0.0-next.29':
982
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
983
···
1507
1508
'@types/cookie@0.6.0':
1509
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
0
0
0
1510
1511
'@types/estree@1.0.8':
1512
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
···
1624
'@webgpu/types@0.1.69':
1625
resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==}
1626
0
0
0
0
1627
acorn-jsx@5.3.2:
1628
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
1629
peerDependencies:
···
1853
resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==}
1854
1855
earcut@3.0.2:
1856
+
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==}
1857
1858
emoji-picker-element@1.28.1:
1859
resolution: {integrity: sha512-8c64IPish2PWoV9oYCo2pvuPHwIv+uK9bO0dfpPyMupDAvaWL9ZvYhWNTAR+2sx7BhfRjciImqP6CIUgNX+DMg==}
···
1963
esutils@2.0.3:
1964
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
1965
engines: {node: '>=0.10.0'}
0
0
0
1966
1967
exsolve@1.0.8:
1968
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
···
2017
geojson-vt@4.0.2:
2018
resolution: {integrity: sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==}
2019
0
0
0
2020
gl-matrix@3.4.4:
2021
resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==}
2022
···
2102
isexe@2.0.0:
2103
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
2104
0
0
0
2105
iso-datestring-validator@2.2.2:
2106
resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==}
2107
2108
jiti@2.6.1:
2109
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
2110
hasBin: true
0
0
0
2111
2112
js-tokens@4.0.0:
2113
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, tarball: https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz}
···
2385
parse-css-color@0.2.1:
2386
resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==}
2387
0
0
0
2388
parse5-htmlparser2-tree-adapter@7.1.0:
2389
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
2390
···
2421
picomatch@4.0.3:
2422
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
2423
engines: {node: '>=12'}
0
0
0
2424
2425
pkg-types@1.3.1:
2426
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
···
2897
tiny-inflate@1.0.3:
2898
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
2899
0
0
0
0
2900
tinyglobby@0.2.15:
2901
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
2902
engines: {node: '>=12.0.0'}
···
3750
number-flow: 0.5.9
3751
svelte: 5.48.0
3752
0
0
3753
'@polka/url@1.0.0-next.29': {}
3754
3755
'@poppinss/colors@4.1.6':
···
4224
4225
'@types/cookie@0.6.0': {}
4226
0
0
4227
'@types/estree@1.0.8': {}
4228
4229
'@types/geojson-vt@3.2.5':
···
4372
'@use-gesture/core': 10.3.1
4373
4374
'@webgpu/types@0.1.69': {}
0
0
4375
4376
acorn-jsx@5.3.2(acorn@8.15.0):
4377
dependencies:
···
4783
4784
esutils@2.0.3: {}
4785
0
0
4786
exsolve@1.0.8: {}
4787
4788
fast-deep-equal@3.1.3: {}
···
4821
optional: true
4822
4823
geojson-vt@4.0.2: {}
0
0
0
0
4824
4825
gl-matrix@3.4.4: {}
4826
···
4891
4892
isexe@2.0.0: {}
4893
0
0
4894
iso-datestring-validator@2.2.2: {}
4895
4896
jiti@2.6.1: {}
0
0
4897
4898
js-tokens@4.0.0: {}
4899
···
5165
color-name: 1.1.4
5166
hex-rgb: 4.3.0
5167
0
0
5168
parse5-htmlparser2-tree-adapter@7.1.0:
5169
dependencies:
5170
domhandler: 5.0.3
···
5195
picocolors@1.1.1: {}
5196
5197
picomatch@4.0.3: {}
0
0
0
0
0
0
0
0
0
0
0
0
0
5198
5199
pkg-types@1.3.1:
5200
dependencies:
···
5709
three@0.176.0: {}
5710
5711
tiny-inflate@1.0.3: {}
0
0
5712
5713
tinyglobby@0.2.15:
5714
dependencies:
+13
-3
src/lib/cards/index.ts
···
29
import { EventCardDefinition } from './social/EventCard';
30
import { VCardCardDefinition } from './social/VCardCard';
31
import { DrawCardDefinition } from './visual/DrawCard';
32
-
import { RecordVisualizerCardDefinition } from './visual/RecordVisualizerCard';
33
import { TimerCardDefinition } from './utilities/TimerCard';
34
import { ClockCardDefinition } from './utilities/ClockCard';
35
import { CountdownCardDefinition } from './utilities/CountdownCard';
···
41
import { GitHubContributorsCardDefinition } from './social/GitHubContributorsCard';
42
import { ProductHuntCardDefinition } from './social/ProductHuntCard';
43
import { KickstarterCardDefinition } from './social/KickstarterCard';
0
0
0
0
0
0
44
// import { Model3DCardDefinition } from './visual/Model3DCard';
45
46
export const AllCardDefinitions = [
···
76
EventCardDefinition,
77
VCardCardDefinition,
78
DrawCardDefinition,
79
-
RecordVisualizerCardDefinition,
80
TimerCardDefinition,
81
ClockCardDefinition,
82
CountdownCardDefinition,
···
86
FriendsCardDefinition,
87
GitHubContributorsCardDefinition,
88
ProductHuntCardDefinition,
89
-
KickstarterCardDefinition
0
0
0
0
0
0
90
] as const;
91
92
export const CardDefinitionsByType = AllCardDefinitions.reduce(
···
29
import { EventCardDefinition } from './social/EventCard';
30
import { VCardCardDefinition } from './social/VCardCard';
31
import { DrawCardDefinition } from './visual/DrawCard';
0
32
import { TimerCardDefinition } from './utilities/TimerCard';
33
import { ClockCardDefinition } from './utilities/ClockCard';
34
import { CountdownCardDefinition } from './utilities/CountdownCard';
···
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 { 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';
49
// import { Model3DCardDefinition } from './visual/Model3DCard';
50
51
export const AllCardDefinitions = [
···
81
EventCardDefinition,
82
VCardCardDefinition,
83
DrawCardDefinition,
0
84
TimerCardDefinition,
85
ClockCardDefinition,
86
CountdownCardDefinition,
···
90
FriendsCardDefinition,
91
GitHubContributorsCardDefinition,
92
ProductHuntCardDefinition,
93
+
KickstarterCardDefinition,
94
+
NpmxLikesCardDefinition,
95
+
NpmxLikesLeaderboardCardDefinition,
96
+
LastFMRecentTracksCardDefinition,
97
+
LastFMTopTracksCardDefinition,
98
+
LastFMTopAlbumsCardDefinition,
99
+
LastFMProfileCardDefinition
100
] as const;
101
102
export const CardDefinitionsByType = AllCardDefinitions.reduce(
+60
src/lib/cards/media/LastFMCard/CreateLastFMCardModal.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
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
+
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
···
0
0
0
0
0
0
0
0
0
0
0
0
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 { 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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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 { 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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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 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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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 { 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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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 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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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 { 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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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 { 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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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 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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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 { 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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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 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' };
+9
-1
src/lib/cards/social/BigSocialCard/BigSocialCard.svelte
···
7
8
const platform = $derived(item.cardData.platform as string);
9
const platformData = $derived(platformsData[platform]);
0
0
0
0
0
0
0
0
10
</script>
11
12
<div
13
class="flex h-full w-full items-center justify-center p-10"
14
-
style={`background-color: #${item.cardData.color}`}
15
>
16
<div
17
class="flex aspect-square max-h-full max-w-full items-center justify-center [&_svg]:size-full [&_svg]:max-w-60 [&_svg]:fill-white"
···
7
8
const platform = $derived(item.cardData.platform as string);
9
const platformData = $derived(platformsData[platform]);
10
+
11
+
// Color logic:
12
+
// - base/transparent/undefined: background = brand color, icon = white
13
+
// - other: background = that color (from BaseCard), icon = white
14
+
const useBrandBackground = $derived(
15
+
!item.color || item.color === 'base' || item.color === 'transparent'
16
+
);
17
+
const brandColor = $derived(`#${item.cardData.color}`);
18
</script>
19
20
<div
21
class="flex h-full w-full items-center justify-center p-10"
22
+
style={useBrandBackground ? `background-color: ${brandColor}` : ''}
23
>
24
<div
25
class="flex aspect-square max-h-full max-w-full items-center justify-center [&_svg]:size-full [&_svg]:max-w-60 [&_svg]:fill-white"
+24
-3
src/lib/cards/social/BigSocialCard/index.ts
···
36
return item;
37
},
38
name: 'Social Icon',
39
-
allowSetColor: false,
40
-
defaultColor: 'transparent',
41
minW: 2,
42
minH: 2,
43
onUrlHandler: (url, item) => {
···
167
168
tangled: /(?:tangled\.org)/i,
169
170
-
mail: /(?:mailto:)/i
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>
···
36
return item;
37
},
38
name: 'Social Icon',
39
+
allowSetColor: true,
40
+
defaultColor: 'base',
41
minW: 2,
42
minH: 2,
43
onUrlHandler: (url, item) => {
···
167
168
tangled: /(?:tangled\.org)/i,
169
170
+
mail: /(?:mailto:)/i,
171
+
172
+
npmx: /(?:npmx\.dev)/i
173
};
174
175
export const platformsData: Record<string, SimpleIcon> = {
···
279
</g>
280
<defs>
281
<clipPath id="clip0_0_3">
282
+
<rect width="24" height="24" fill="white"/>
283
+
</clipPath>
284
+
</defs>
285
+
</svg>`
286
+
},
287
+
288
+
npmx: {
289
+
slug: 'npmx',
290
+
path: '',
291
+
title: 'npmx',
292
+
source: '',
293
+
hex: '0A0A0A',
294
+
svg: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
295
+
<g clip-path="url(#clip0_4_2)">
296
+
<path d="M6.12765 16.4049H2V20.5326H6.12765V16.4049Z" fill="#525252"/>
297
+
<path d="M10.9049 23.8485L19.6885 -1H22L13.2164 23.8485H10.9049Z" fill="#FAFAFA"/>
298
+
</g>
299
+
<defs>
300
+
<clipPath id="clip0_4_2">
301
<rect width="24" height="24" fill="white"/>
302
</clipPath>
303
</defs>
+14
-135
src/lib/cards/social/GitHubContributorsCard/GitHubContributorsCard.svelte
···
1
<script lang="ts">
2
import { onMount } from 'svelte';
3
import type { ContentComponentProps } from '../../types';
4
-
import { getAdditionalUserData, getCanEdit, getIsMobile } from '$lib/website/context';
5
import type { GitHubContributor, GitHubContributorsLoadedData } from '.';
0
6
7
let { item }: ContentComponentProps = $props();
8
9
-
const isMobile = getIsMobile();
10
const canEdit = getCanEdit();
11
const additionalData = getAdditionalUserData();
12
···
53
}
54
}
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'
142
);
143
-
144
-
let shapeClass = $derived(shape === 'circle' ? 'rounded-full' : 'rounded-lg');
145
</script>
146
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()}
154
<span class="text-base-400 dark:text-base-500 accent:text-accent-300 text-sm">
155
Enter a repository
156
</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
</div>
195
{/if}
196
-
</div>
0
0
···
1
<script lang="ts">
2
import { onMount } from 'svelte';
3
import type { ContentComponentProps } from '../../types';
4
+
import { getAdditionalUserData, getCanEdit } from '$lib/website/context';
5
import type { GitHubContributor, GitHubContributorsLoadedData } from '.';
6
+
import ImageGrid from '$lib/components/ImageGrid.svelte';
7
8
let { item }: ContentComponentProps = $props();
9
0
10
const canEdit = getCanEdit();
11
const additionalData = getAdditionalUserData();
12
···
53
}
54
}
55
56
+
let gridItems = $derived(
57
+
namedContributors.map((c) => ({
58
+
imageUrl: c.avatarUrl,
59
+
link: `https://github.com/${c.username}`,
60
+
label: c.username
61
+
}))
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
62
);
0
0
63
</script>
64
65
+
{#if !owner || !repo}
66
+
{#if canEdit()}
67
+
<div class="flex h-full w-full items-center justify-center">
0
0
0
0
68
<span class="text-base-400 dark:text-base-500 accent:text-accent-300 text-sm">
69
Enter a repository
70
</span>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
71
</div>
72
{/if}
73
+
{:else}
74
+
<ImageGrid items={gridItems} {layout} {shape} tooltip />
75
+
{/if}
+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 { NpmxLikesCardDefinition } 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 NpmxLikesCardDefinition.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 { NpmxLikesLeaderboardCardDefinition } 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 NpmxLikesLeaderboardCardDefinition.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="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
+
></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 [];
+86
-10
src/lib/cards/visual/FluidTextCard/FluidTextCard.svelte
···
1
<script lang="ts">
2
-
import { colorToHue, getCSSVar, getHexOfCardColor } from '../../helper';
3
import type { ContentComponentProps } from '../../types';
4
import { onMount, onDestroy, tick } from 'svelte';
5
let { item }: ContentComponentProps = $props();
···
7
let container: HTMLDivElement;
8
let fluidCanvas: HTMLCanvasElement;
9
let maskCanvas: HTMLCanvasElement;
0
10
let animationId: number;
11
let splatIntervalId: ReturnType<typeof setInterval>;
12
let maskDrawRaf = 0;
13
let maskReady = false;
14
let isInitialized = $state(false);
15
let resizeObserver: ResizeObserver | null = null;
0
16
17
// Pure hash function for shader keyword caching
18
function hashCode(s: string) {
···
122
if (width === 0 || height === 0) return;
123
124
const dpr = window.devicePixelRatio || 1;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
125
126
maskCanvas.width = width * dpr;
127
maskCanvas.height = height * dpr;
···
132
ctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
133
ctx.scale(dpr, dpr);
134
135
-
//const color = getCSSVar('--color-base-900');
136
-
137
-
ctx.fillStyle = 'black';
0
0
138
ctx.fillRect(0, 0, width, height);
139
140
// Font size as percentage of container width
141
const textFontSize = Math.round(width * fontSize);
142
ctx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`;
143
144
-
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
145
-
ctx.lineWidth = 2;
146
ctx.textAlign = 'center';
147
148
const metrics = ctx.measureText(text);
···
157
ctx.textBaseline = 'middle';
158
}
159
160
-
ctx.strokeText(text, width / 2, textY);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
161
ctx.globalCompositeOperation = 'destination-out';
162
ctx.fillText(text, width / 2, textY);
163
ctx.globalCompositeOperation = 'source-over';
···
214
if (isInitialized) scheduleMaskDraw();
215
});
216
}
0
0
0
0
0
0
0
0
0
0
0
217
});
218
219
onDestroy(() => {
···
221
if (splatIntervalId) clearInterval(splatIntervalId);
222
if (maskDrawRaf) cancelAnimationFrame(maskDrawRaf);
223
if (resizeObserver) resizeObserver.disconnect();
0
224
});
225
226
function initFluidSimulation(startHue: number, endHue: number) {
···
246
COLOR_UPDATE_SPEED: 10,
247
PAUSED: false,
248
BACK_COLOR: { r: 0, g: 0, b: 0 },
249
-
TRANSPARENT: false,
250
BLOOM: false,
251
BLOOM_ITERATIONS: 8,
252
BLOOM_RESOLUTION: 256,
···
1701
}
1702
</script>
1703
1704
-
<div bind:this={container} class="relative h-full w-full overflow-hidden bg-black">
1705
-
<canvas bind:this={fluidCanvas} class="absolute h-full w-full"></canvas>
0
0
0
0
0
0
0
0
0
0
1706
<canvas bind:this={maskCanvas} class="absolute h-full w-full"></canvas>
1707
</div>
···
1
<script lang="ts">
2
+
import { colorToHue, getHexCSSVar, getHexOfCardColor } from '../../helper';
3
import type { ContentComponentProps } from '../../types';
4
import { onMount, onDestroy, tick } from 'svelte';
5
let { item }: ContentComponentProps = $props();
···
7
let container: HTMLDivElement;
8
let fluidCanvas: HTMLCanvasElement;
9
let maskCanvas: HTMLCanvasElement;
10
+
let shadowCanvas: HTMLCanvasElement;
11
let animationId: number;
12
let splatIntervalId: ReturnType<typeof setInterval>;
13
let maskDrawRaf = 0;
14
let maskReady = false;
15
let isInitialized = $state(false);
16
let resizeObserver: ResizeObserver | null = null;
17
+
let themeObserver: MutationObserver | null = null;
18
19
// Pure hash function for shader keyword caching
20
function hashCode(s: string) {
···
124
if (width === 0 || height === 0) return;
125
126
const dpr = window.devicePixelRatio || 1;
127
+
const isDark = document.documentElement.classList.contains('dark');
128
+
129
+
// Draw shadow behind fluid (light mode only, transparent only)
130
+
if (shadowCanvas && item.color === 'transparent') {
131
+
shadowCanvas.width = width * dpr;
132
+
shadowCanvas.height = height * dpr;
133
+
const shadowCtx = shadowCanvas.getContext('2d')!;
134
+
shadowCtx.setTransform(1, 0, 0, 1, 0, 0);
135
+
shadowCtx.clearRect(0, 0, shadowCanvas.width, shadowCanvas.height);
136
+
shadowCtx.scale(dpr, dpr);
137
+
138
+
const textFontSize = Math.round(width * fontSize);
139
+
shadowCtx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`;
140
+
shadowCtx.textAlign = 'center';
141
+
142
+
const metrics = shadowCtx.measureText(text);
143
+
let textY = height / 2;
144
+
if (
145
+
metrics.actualBoundingBoxAscent !== undefined &&
146
+
metrics.actualBoundingBoxDescent !== undefined
147
+
) {
148
+
shadowCtx.textBaseline = 'alphabetic';
149
+
textY = (height + metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2;
150
+
} else {
151
+
shadowCtx.textBaseline = 'middle';
152
+
}
153
+
154
+
// Draw darkened text shape behind fluid
155
+
shadowCtx.fillStyle = getHexCSSVar(isDark ? '--color-base-950' : '--color-base-200');
156
+
shadowCtx.fillText(text, width / 2, textY);
157
+
} else if (shadowCanvas) {
158
+
// Clear shadow canvas when not transparent
159
+
shadowCanvas.width = 1;
160
+
shadowCanvas.height = 1;
161
+
}
162
163
maskCanvas.width = width * dpr;
164
maskCanvas.height = height * dpr;
···
169
ctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
170
ctx.scale(dpr, dpr);
171
172
+
const bgColor =
173
+
item.color === 'transparent'
174
+
? getHexCSSVar(isDark ? '--color-base-900' : '--color-base-50')
175
+
: 'black';
176
+
ctx.fillStyle = bgColor;
177
ctx.fillRect(0, 0, width, height);
178
179
// Font size as percentage of container width
180
const textFontSize = Math.round(width * fontSize);
181
ctx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`;
182
183
+
ctx.lineWidth = 3;
0
184
ctx.textAlign = 'center';
185
186
const metrics = ctx.measureText(text);
···
195
ctx.textBaseline = 'middle';
196
}
197
198
+
if (item.color === 'transparent') {
199
+
// Partially cut out the stroke area so fluid shows through
200
+
ctx.globalCompositeOperation = 'destination-out';
201
+
ctx.globalAlpha = 0.7;
202
+
ctx.strokeStyle = 'white';
203
+
ctx.strokeText(text, width / 2, textY);
204
+
ctx.globalAlpha = 1;
205
+
ctx.globalCompositeOperation = 'source-over';
206
+
207
+
// Add overlay: brighten in dark mode, darken in light mode
208
+
ctx.strokeStyle = isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)';
209
+
ctx.strokeText(text, width / 2, textY);
210
+
} else {
211
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
212
+
ctx.strokeText(text, width / 2, textY);
213
+
}
214
+
215
ctx.globalCompositeOperation = 'destination-out';
216
ctx.fillText(text, width / 2, textY);
217
ctx.globalCompositeOperation = 'source-over';
···
268
if (isInitialized) scheduleMaskDraw();
269
});
270
}
271
+
272
+
// Watch for dark mode changes to redraw mask with correct background
273
+
if (item.color === 'transparent') {
274
+
themeObserver = new MutationObserver(() => {
275
+
if (isInitialized) scheduleMaskDraw();
276
+
});
277
+
themeObserver.observe(document.documentElement, {
278
+
attributes: true,
279
+
attributeFilter: ['class']
280
+
});
281
+
}
282
});
283
284
onDestroy(() => {
···
286
if (splatIntervalId) clearInterval(splatIntervalId);
287
if (maskDrawRaf) cancelAnimationFrame(maskDrawRaf);
288
if (resizeObserver) resizeObserver.disconnect();
289
+
if (themeObserver) themeObserver.disconnect();
290
});
291
292
function initFluidSimulation(startHue: number, endHue: number) {
···
312
COLOR_UPDATE_SPEED: 10,
313
PAUSED: false,
314
BACK_COLOR: { r: 0, g: 0, b: 0 },
315
+
TRANSPARENT: item.color === 'transparent',
316
BLOOM: false,
317
BLOOM_ITERATIONS: 8,
318
BLOOM_RESOLUTION: 256,
···
1767
}
1768
</script>
1769
1770
+
<div
1771
+
bind:this={container}
1772
+
class="relative h-full w-full overflow-hidden rounded-[inherit] {item.color === 'transparent'
1773
+
? 'bg-base-50 dark:bg-base-900'
1774
+
: 'bg-black'}"
1775
+
>
1776
+
<canvas
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>
-277
src/lib/cards/visual/RecordVisualizerCard/RecordVisualizerCard.svelte
···
1
-
<script lang="ts">
2
-
import { onMount } from 'svelte';
3
-
import { browser } from '$app/environment';
4
-
import * as PIXI from 'pixi.js';
5
-
import type { ContentComponentProps } from '../../types';
6
-
7
-
let { item }: ContentComponentProps = $props();
8
-
9
-
type RecordVisualizerCardData = {
10
-
emoji?: string;
11
-
collection?: string;
12
-
direction?: 'down' | 'up';
13
-
speed?: number;
14
-
};
15
-
16
-
let cardData = $derived(item.cardData as RecordVisualizerCardData);
17
-
18
-
let emoji = $derived(cardData.emoji || '๐');
19
-
let collection = $derived(cardData.collection || 'app.bsky.feed.like');
20
-
let direction = $derived(cardData.direction || 'down');
21
-
let speed = $derived(Math.max(0.5, Math.min(2, cardData.speed || 1)));
22
-
23
-
let containerEl: HTMLDivElement | null = null;
24
-
let canvasEl: HTMLCanvasElement | null = null;
25
-
let app: PIXI.Application | null = null;
26
-
let ws: WebSocket | null = null;
27
-
let prevCollection = $state<string | null>(null);
28
-
let prevEmoji = $state<string | null>(null);
29
-
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
30
-
31
-
const RECONNECT_DEBOUNCE = 1000;
32
-
const MAX_PARTICLES = 10000;
33
-
34
-
// Particle system
35
-
interface ParticleSprite extends PIXI.Sprite {
36
-
speedX: number;
37
-
speedY: number;
38
-
age: number;
39
-
maxAge: number;
40
-
initialSize: number;
41
-
}
42
-
43
-
let particles: ParticleSprite[] = [];
44
-
let particlePool: ParticleSprite[] = [];
45
-
let particleContainer: PIXI.Container | null = null;
46
-
let emojiTexture: PIXI.Texture | null = null;
47
-
48
-
function createEmojiTexture(emojiChar: string): PIXI.Texture {
49
-
const canvas = document.createElement('canvas');
50
-
const size = 64;
51
-
canvas.width = size;
52
-
canvas.height = size;
53
-
const ctx = canvas.getContext('2d')!;
54
-
ctx.font = `${size * 0.8}px serif`;
55
-
ctx.textAlign = 'center';
56
-
ctx.textBaseline = 'middle';
57
-
ctx.fillText(emojiChar, size / 2, size / 2);
58
-
return PIXI.Texture.from(canvas);
59
-
}
60
-
61
-
function spawnParticle() {
62
-
if (!app || !particleContainer || !emojiTexture) return;
63
-
64
-
let particle: ParticleSprite;
65
-
if (particlePool.length > 0) {
66
-
particle = particlePool.pop()!;
67
-
particle.texture = emojiTexture;
68
-
} else if (particles.length < MAX_PARTICLES) {
69
-
particle = new PIXI.Sprite(emojiTexture) as ParticleSprite;
70
-
particle.anchor.set(0.5, 0.5);
71
-
particleContainer.addChild(particle);
72
-
} else {
73
-
return;
74
-
}
75
-
76
-
const w = app.screen.width;
77
-
const h = app.screen.height;
78
-
79
-
// Parallax: random scale from 0.3 (far/small) to 1.0 (near/large)
80
-
const scale = Math.random() * 0.7 + 0.3;
81
-
const baseSize = (Math.random() * 30 + 15) * scale;
82
-
83
-
particle.visible = true;
84
-
particle.x = Math.random() * w;
85
-
particle.y = direction === 'down' ? -baseSize : h + baseSize;
86
-
particle.width = particle.height = baseSize;
87
-
particle.alpha = 0.4 + scale * 0.6;
88
-
particle.rotation = (Math.random() - 0.5) * 0.3;
89
-
particle.zIndex = Math.round(scale * 10);
90
-
91
-
// Speed based on scale (smaller = slower for parallax)
92
-
const baseSpeed = 80 * speed;
93
-
const effectiveSpeed = baseSpeed * scale;
94
-
particle.speedX = (Math.random() - 0.5) * 20;
95
-
particle.speedY = direction === 'down' ? effectiveSpeed : -effectiveSpeed;
96
-
97
-
particle.age = 0;
98
-
particle.maxAge = (h + baseSize * 2) / effectiveSpeed + 2;
99
-
particle.initialSize = baseSize;
100
-
101
-
particles.push(particle);
102
-
}
103
-
104
-
function removeParticle(particle: ParticleSprite) {
105
-
const index = particles.indexOf(particle);
106
-
if (index !== -1) {
107
-
particle.visible = false;
108
-
particles.splice(index, 1);
109
-
particlePool.push(particle);
110
-
}
111
-
}
112
-
113
-
function updateParticles(deltaTime: number) {
114
-
if (!app) return;
115
-
const h = app.screen.height;
116
-
117
-
for (let i = particles.length - 1; i >= 0; i--) {
118
-
const particle = particles[i];
119
-
particle.x += particle.speedX * deltaTime;
120
-
particle.y += particle.speedY * deltaTime;
121
-
particle.age += deltaTime;
122
-
123
-
// Remove if off screen or too old
124
-
const isOffScreen =
125
-
direction === 'down'
126
-
? particle.y > h + particle.initialSize
127
-
: particle.y < -particle.initialSize;
128
-
129
-
if (particle.age >= particle.maxAge || isOffScreen) {
130
-
removeParticle(particle);
131
-
}
132
-
}
133
-
}
134
-
135
-
async function initPixi() {
136
-
if (!browser || !containerEl || !canvasEl) return;
137
-
138
-
// Clean up existing app
139
-
if (app) {
140
-
app.destroy(true, { children: true, texture: true });
141
-
app = null;
142
-
}
143
-
144
-
particles = [];
145
-
particlePool = [];
146
-
147
-
app = new PIXI.Application();
148
-
await app.init({
149
-
canvas: canvasEl,
150
-
width: containerEl.clientWidth,
151
-
height: containerEl.clientHeight,
152
-
backgroundAlpha: 0,
153
-
antialias: true,
154
-
resolution: window.devicePixelRatio || 1,
155
-
autoDensity: true
156
-
});
157
-
158
-
particleContainer = new PIXI.Container();
159
-
particleContainer.sortableChildren = true;
160
-
app.stage.addChild(particleContainer);
161
-
162
-
emojiTexture = createEmojiTexture(emoji);
163
-
164
-
app.ticker.add((ticker) => {
165
-
updateParticles(ticker.deltaMS * 0.001);
166
-
});
167
-
168
-
// Handle resize
169
-
const resizeObserver = new ResizeObserver(() => {
170
-
if (app && containerEl) {
171
-
app.renderer.resize(containerEl.clientWidth, containerEl.clientHeight);
172
-
}
173
-
});
174
-
resizeObserver.observe(containerEl);
175
-
176
-
return () => {
177
-
resizeObserver.disconnect();
178
-
};
179
-
}
180
-
181
-
function connectWebSocket() {
182
-
if (!browser) return;
183
-
184
-
if (ws) {
185
-
ws.close();
186
-
}
187
-
188
-
const wsUrl = `wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=${encodeURIComponent(collection)}`;
189
-
190
-
try {
191
-
ws = new WebSocket(wsUrl);
192
-
193
-
ws.onmessage = (event) => {
194
-
try {
195
-
const data = JSON.parse(event.data);
196
-
if (data.kind === 'commit' && data.commit?.operation === 'create') {
197
-
spawnParticle();
198
-
}
199
-
} catch {
200
-
// Ignore parse errors
201
-
}
202
-
};
203
-
204
-
ws.onerror = () => {
205
-
// Silently handle errors
206
-
};
207
-
208
-
ws.onclose = () => {
209
-
setTimeout(() => {
210
-
if (containerEl) {
211
-
connectWebSocket();
212
-
}
213
-
}, 5000);
214
-
};
215
-
} catch {
216
-
// Failed to create WebSocket
217
-
}
218
-
}
219
-
220
-
onMount(() => {
221
-
let cleanupResize: (() => void) | undefined;
222
-
223
-
initPixi().then((cleanup) => {
224
-
cleanupResize = cleanup;
225
-
});
226
-
connectWebSocket();
227
-
228
-
return () => {
229
-
if (ws) {
230
-
ws.close();
231
-
ws = null;
232
-
}
233
-
if (reconnectTimeout) {
234
-
clearTimeout(reconnectTimeout);
235
-
}
236
-
if (app) {
237
-
app.destroy(true, { children: true, texture: true });
238
-
app = null;
239
-
}
240
-
cleanupResize?.();
241
-
};
242
-
});
243
-
244
-
// Reconnect when collection changes (debounced)
245
-
$effect(() => {
246
-
const currentCollection = collection;
247
-
248
-
if (prevCollection !== null && prevCollection !== currentCollection) {
249
-
if (reconnectTimeout) {
250
-
clearTimeout(reconnectTimeout);
251
-
}
252
-
reconnectTimeout = setTimeout(() => {
253
-
if (ws) {
254
-
ws.close();
255
-
}
256
-
connectWebSocket();
257
-
}, RECONNECT_DEBOUNCE);
258
-
}
259
-
260
-
prevCollection = currentCollection;
261
-
});
262
-
263
-
// Update emoji texture when emoji changes
264
-
$effect(() => {
265
-
const currentEmoji = emoji;
266
-
267
-
if (prevEmoji !== null && prevEmoji !== currentEmoji && app) {
268
-
emojiTexture = createEmojiTexture(currentEmoji);
269
-
}
270
-
271
-
prevEmoji = currentEmoji;
272
-
});
273
-
</script>
274
-
275
-
<div bind:this={containerEl} class="h-full w-full overflow-hidden">
276
-
<canvas bind:this={canvasEl} class="h-full w-full"></canvas>
277
-
</div>
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
-71
src/lib/cards/visual/RecordVisualizerCard/RecordVisualizerSettings.svelte
···
1
-
<script lang="ts">
2
-
import type { Item } from '$lib/types';
3
-
import type { SettingsComponentProps } from '../../types';
4
-
import { Input, Label } from '@foxui/core';
5
-
6
-
let { item = $bindable<Item>() }: SettingsComponentProps = $props();
7
-
8
-
type RecordVisualizerCardData = {
9
-
emoji?: string;
10
-
collection?: string;
11
-
direction?: 'down' | 'up';
12
-
speed?: number;
13
-
};
14
-
15
-
let cardData = $derived(item.cardData as RecordVisualizerCardData);
16
-
17
-
// Initialize defaults if not set
18
-
if (item.cardData.emoji === undefined) {
19
-
item.cardData.emoji = '๐';
20
-
}
21
-
if (item.cardData.collection === undefined) {
22
-
item.cardData.collection = 'app.bsky.feed.like';
23
-
}
24
-
if (item.cardData.direction === undefined) {
25
-
item.cardData.direction = 'down';
26
-
}
27
-
if (item.cardData.speed === undefined) {
28
-
item.cardData.speed = 1;
29
-
}
30
-
</script>
31
-
32
-
<div class="flex flex-col gap-3">
33
-
<div>
34
-
<Label class="mb-1 text-xs">Emoji</Label>
35
-
<Input bind:value={item.cardData.emoji} placeholder="๐" class="w-full" />
36
-
</div>
37
-
38
-
<div>
39
-
<Label class="mb-1 text-xs">Collection</Label>
40
-
<Input bind:value={item.cardData.collection} placeholder="app.bsky.feed.like" class="w-full" />
41
-
</div>
42
-
43
-
<div>
44
-
<Label class="mb-1 text-xs">Direction</Label>
45
-
<select
46
-
value={cardData.direction ?? 'down'}
47
-
onchange={(e) => {
48
-
item.cardData.direction = (e.target as HTMLSelectElement).value;
49
-
}}
50
-
class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-600 w-full rounded-md border px-3 py-2 text-sm"
51
-
>
52
-
<option value="down">Down</option>
53
-
<option value="up">Up</option>
54
-
</select>
55
-
</div>
56
-
57
-
<div>
58
-
<Label class="mb-1 text-xs">Speed ({cardData.speed?.toFixed(1) ?? '1.0'}x)</Label>
59
-
<input
60
-
type="range"
61
-
min="0.5"
62
-
max="2"
63
-
step="0.1"
64
-
value={cardData.speed ?? 1}
65
-
oninput={(e) => {
66
-
item.cardData.speed = parseFloat(e.currentTarget.value);
67
-
}}
68
-
class="bg-base-200 dark:bg-base-700 h-2 w-full cursor-pointer appearance-none rounded-lg"
69
-
/>
70
-
</div>
71
-
</div>
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
-30
src/lib/cards/visual/RecordVisualizerCard/index.ts
···
1
-
import type { CardDefinition } from '../../types';
2
-
import RecordVisualizerCard from './RecordVisualizerCard.svelte';
3
-
import RecordVisualizerSettings from './RecordVisualizerSettings.svelte';
4
-
5
-
export const RecordVisualizerCardDefinition = {
6
-
type: 'record-visualizer',
7
-
contentComponent: RecordVisualizerCard,
8
-
createNew: (card) => {
9
-
card.cardType = 'record-visualizer';
10
-
card.cardData = {
11
-
emoji: '๐',
12
-
collection: 'app.bsky.feed.like',
13
-
direction: 'down',
14
-
speed: 1
15
-
};
16
-
card.w = 2;
17
-
card.h = 2;
18
-
card.mobileW = 4;
19
-
card.mobileH = 4;
20
-
},
21
-
settingsComponent: RecordVisualizerSettings,
22
-
minW: 1,
23
-
minH: 2,
24
-
canHaveLabel: true,
25
-
26
-
keywords: ['emoji', 'particles', 'animation', 'bluesky', 'atproto', 'live', 'realtime', 'stream'],
27
-
groups: ['Visual'],
28
-
name: 'Record Visualizer',
29
-
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.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" /></svg>`
30
-
} as CardDefinition & { type: 'record-visualizer' };
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
+180
src/lib/components/ImageGrid.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
···
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>
+28
-5
src/lib/components/qr/qrOverlay.svelte.ts
···
18
const LONG_PRESS_DURATION = 500;
19
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
20
let isLongPress = false;
0
0
0
0
0
21
22
function getHref() {
23
return params.href || (node as HTMLAnchorElement).href || '';
24
}
25
26
-
function startLongPress() {
27
if (params.disabled) return;
0
0
0
28
isLongPress = false;
29
longPressTimer = setTimeout(() => {
30
isLongPress = true;
···
37
clearTimeout(longPressTimer);
38
longPressTimer = null;
39
}
0
40
}
41
42
function handleClick(e: MouseEvent) {
43
if (isLongPress) {
44
e.preventDefault();
45
isLongPress = false;
0
0
0
0
0
0
0
46
}
47
}
48
49
-
function handleContextMenu(e: MouseEvent) {
50
-
if (params.disabled) return;
51
-
e.preventDefault();
52
-
openModal?.(getHref(), params.context ?? {});
0
53
}
54
55
node.addEventListener('pointerdown', startLongPress);
···
71
node.removeEventListener('click', handleClick);
72
node.removeEventListener('contextmenu', handleContextMenu);
73
cancelLongPress();
0
0
0
0
0
0
74
}
75
};
76
}
···
18
const LONG_PRESS_DURATION = 500;
19
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
20
let isLongPress = false;
21
+
let touchActive = false;
22
+
23
+
// Prevent iOS link preview on long-press
24
+
const originalCallout = node.style.getPropertyValue('-webkit-touch-callout');
25
+
node.style.setProperty('-webkit-touch-callout', 'none');
26
27
function getHref() {
28
return params.href || (node as HTMLAnchorElement).href || '';
29
}
30
31
+
function startLongPress(e: PointerEvent) {
32
if (params.disabled) return;
33
+
// Only start long press for primary button (touch/left-click), not right-click
34
+
if (e.button !== 0) return;
35
+
touchActive = e.pointerType === 'touch';
36
isLongPress = false;
37
longPressTimer = setTimeout(() => {
38
isLongPress = true;
···
45
clearTimeout(longPressTimer);
46
longPressTimer = null;
47
}
48
+
touchActive = false;
49
}
50
51
function handleClick(e: MouseEvent) {
52
if (isLongPress) {
53
e.preventDefault();
54
isLongPress = false;
55
+
return;
56
+
}
57
+
58
+
// Shift-click opens QR modal
59
+
if (e.shiftKey && !params.disabled) {
60
+
e.preventDefault();
61
+
openModal?.(getHref(), params.context ?? {});
62
}
63
}
64
65
+
function handleContextMenu(e: Event) {
66
+
// Prevent context menu during touch to avoid iOS preview
67
+
if (touchActive || isLongPress) {
68
+
e.preventDefault();
69
+
}
70
}
71
72
node.addEventListener('pointerdown', startLongPress);
···
88
node.removeEventListener('click', handleClick);
89
node.removeEventListener('contextmenu', handleContextMenu);
90
cancelLongPress();
91
+
// Restore original style
92
+
if (originalCallout) {
93
+
node.style.setProperty('-webkit-touch-callout', originalCallout);
94
+
} else {
95
+
node.style.removeProperty('-webkit-touch-callout');
96
+
}
97
}
98
};
99
}
+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>
+32
src/lib/website/ThemeScript.svelte
···
1
<script lang="ts">
0
0
2
let {
3
accentColor = 'pink',
4
baseColor = 'stone'
···
7
baseColor?: string;
8
} = $props();
9
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
10
const safeJson = (v: string) => JSON.stringify(v).replace(/</g, '\\u003c');
11
0
12
let script = $derived(
13
`<script>(function(){document.documentElement.classList.add(${safeJson(accentColor)},${safeJson(baseColor)});})();<` +
14
'/script>'
15
);
0
0
0
0
0
0
0
0
16
</script>
17
18
<svelte:head>
···
1
<script lang="ts">
2
+
import { browser } from '$app/environment';
3
+
4
let {
5
accentColor = 'pink',
6
baseColor = 'stone'
···
9
baseColor?: string;
10
} = $props();
11
12
+
const allAccentColors = [
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'
30
+
];
31
+
const allBaseColors = ['gray', 'stone', 'zinc', 'neutral', 'slate'];
32
+
33
const safeJson = (v: string) => JSON.stringify(v).replace(/</g, '\\u003c');
34
35
+
// SSR: inline script for initial page load (no FOUC)
36
let script = $derived(
37
`<script>(function(){document.documentElement.classList.add(${safeJson(accentColor)},${safeJson(baseColor)});})();<` +
38
'/script>'
39
);
40
+
41
+
// Client: reactive effect for client-side navigations
42
+
$effect(() => {
43
+
if (!browser) return;
44
+
const el = document.documentElement;
45
+
el.classList.remove(...allAccentColors, ...allBaseColors);
46
+
el.classList.add(accentColor, baseColor);
47
+
});
48
</script>
49
50
<svelte:head>
+2
-1
src/params/handle.ts
···
0
1
import type { ParamMatcher } from '@sveltejs/kit';
2
3
export const match = ((param: string) => {
4
-
return param.includes('.') || param.startsWith('did:');
5
}) satisfies ParamMatcher;
···
1
+
import { isActorIdentifier } from '@atcute/lexicons/syntax';
2
import type { ParamMatcher } from '@sveltejs/kit';
3
4
export const match = ((param: string) => {
5
+
return isActorIdentifier(param);
6
}) satisfies ParamMatcher;
+4
-1
src/routes/+layout.svelte
···
1
<script lang="ts">
2
import '../app.css';
3
0
4
import { ThemeToggle, Toaster, toast } from '@foxui/core';
5
import { onMount } from 'svelte';
6
import { initClient } from '$lib/atproto';
···
28
});
29
</script>
30
31
-
{@render children()}
0
0
32
33
<ThemeToggle class="fixed top-2 left-2 z-10" />
34
<Toaster />
···
1
<script lang="ts">
2
import '../app.css';
3
4
+
import { Tooltip } from 'bits-ui';
5
import { ThemeToggle, Toaster, toast } from '@foxui/core';
6
import { onMount } from 'svelte';
7
import { initClient } from '$lib/atproto';
···
29
});
30
</script>
31
32
+
<Tooltip.Provider delayDuration={300}>
33
+
{@render children()}
34
+
</Tooltip.Provider>
35
36
<ThemeToggle class="fixed top-2 left-2 z-10" />
37
<Toaster />
+13
src/routes/[handle=handle]/(pages)/+layout.server.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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>
+6
src/routes/[handle=handle]/(pages)/p/[[page]]/edit/+page.svelte
···
0
0
0
0
0
0
···
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
-
}
···
0
0
0
0
0
0
0
0
0
0
0
0
0
-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} />
···
0
0
0
0
0
0
0
0
0
0
0
0
0
-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} />
···
0
0
0
0
0
0
+89
src/routes/api/lastfm/+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
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
+
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
+
};
+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
+
};
+25
src/routes/p/[[page]]/+layout.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
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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>
+6
src/routes/p/[[page]]/edit/+page.svelte
···
0
0
0
0
0
0
···
1
+
<script lang="ts">
2
+
import EditableWebsite from '$lib/website/EditableWebsite.svelte';
3
+
let { data } = $props();
4
+
</script>
5
+
6
+
<EditableWebsite {data} />