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
5
"type": "module",
6
6
"scripts": {
7
7
"dev": "vite dev",
8
8
-
"build": "vite build",
8
8
+
"build": "NODE_OPTIONS='--max-old-space-size=4096' vite build",
9
9
"preview": "pnpm run build && wrangler dev",
10
10
"prepare": "svelte-kit sync || echo ''",
11
11
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
···
79
79
"mapbox-gl": "^3.18.1",
80
80
"marked": "^17.0.1",
81
81
"perfect-freehand": "^1.2.2",
82
82
-
"pixi.js": "^8.16.0",
83
82
"plyr": "^3.8.4",
84
83
"qr-code-styling": "^1.8.6",
85
84
"react-grid-layout": "^2.2.2",
+1
-69
pnpm-lock.yaml
···
128
128
perfect-freehand:
129
129
specifier: ^1.2.2
130
130
version: 1.2.2
131
131
-
pixi.js:
132
132
-
specifier: ^8.16.0
133
133
-
version: 8.16.0
134
131
plyr:
135
132
specifier: ^3.8.4
136
133
version: 3.8.4
···
981
978
peerDependencies:
982
979
svelte: ^4 || ^5
983
980
984
984
-
'@pixi/colord@2.9.6':
985
985
-
resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==, tarball: https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz}
986
986
-
987
981
'@polka/url@1.0.0-next.29':
988
982
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
989
983
···
1513
1507
1514
1508
'@types/cookie@0.6.0':
1515
1509
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
1516
1516
-
1517
1517
-
'@types/earcut@3.0.0':
1518
1518
-
resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==, tarball: https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz}
1519
1510
1520
1511
'@types/estree@1.0.8':
1521
1512
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
···
1633
1624
'@webgpu/types@0.1.69':
1634
1625
resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==}
1635
1626
1636
1636
-
'@xmldom/xmldom@0.8.11':
1637
1637
-
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==, tarball: https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz}
1638
1638
-
engines: {node: '>=10.0.0'}
1639
1639
-
1640
1627
acorn-jsx@5.3.2:
1641
1628
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
1642
1629
peerDependencies:
···
1866
1853
resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==}
1867
1854
1868
1855
earcut@3.0.2:
1869
1869
-
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==, tarball: https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz}
1856
1856
+
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==}
1870
1857
1871
1858
emoji-picker-element@1.28.1:
1872
1859
resolution: {integrity: sha512-8c64IPish2PWoV9oYCo2pvuPHwIv+uK9bO0dfpPyMupDAvaWL9ZvYhWNTAR+2sx7BhfRjciImqP6CIUgNX+DMg==}
···
1976
1963
esutils@2.0.3:
1977
1964
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
1978
1965
engines: {node: '>=0.10.0'}
1979
1979
-
1980
1980
-
eventemitter3@5.0.4:
1981
1981
-
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==, tarball: https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz}
1982
1966
1983
1967
exsolve@1.0.8:
1984
1968
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
···
2033
2017
geojson-vt@4.0.2:
2034
2018
resolution: {integrity: sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==}
2035
2019
2036
2036
-
gifuct-js@2.1.2:
2037
2037
-
resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==, tarball: https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz}
2038
2038
-
2039
2020
gl-matrix@3.4.4:
2040
2021
resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==}
2041
2022
···
2121
2102
isexe@2.0.0:
2122
2103
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
2123
2104
2124
2124
-
ismobilejs@1.1.1:
2125
2125
-
resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==, tarball: https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz}
2126
2126
-
2127
2105
iso-datestring-validator@2.2.2:
2128
2106
resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==}
2129
2107
2130
2108
jiti@2.6.1:
2131
2109
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
2132
2110
hasBin: true
2133
2133
-
2134
2134
-
js-binary-schema-parser@2.0.3:
2135
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
2111
2137
2112
js-tokens@4.0.0:
2138
2113
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, tarball: https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz}
···
2410
2385
parse-css-color@0.2.1:
2411
2386
resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==}
2412
2387
2413
2413
-
parse-svg-path@0.1.2:
2414
2414
-
resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==, tarball: https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz}
2415
2415
-
2416
2388
parse5-htmlparser2-tree-adapter@7.1.0:
2417
2389
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
2418
2390
···
2449
2421
picomatch@4.0.3:
2450
2422
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
2451
2423
engines: {node: '>=12'}
2452
2452
-
2453
2453
-
pixi.js@8.16.0:
2454
2454
-
resolution: {integrity: sha512-gu2xw3sZGAn3cWBtk0HqTQT+v19YAfiaYXwUGgWoJl5NKz4cEZJUgWrwkmdfDszGyYBAGqOvJNbd2M9+vzLLMg==, tarball: https://registry.npmjs.org/pixi.js/-/pixi.js-8.16.0.tgz}
2455
2424
2456
2425
pkg-types@1.3.1:
2457
2426
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
···
2928
2897
tiny-inflate@1.0.3:
2929
2898
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
2930
2899
2931
2931
-
tiny-lru@11.4.7:
2932
2932
-
resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==, tarball: https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz}
2933
2933
-
engines: {node: '>=12'}
2934
2934
-
2935
2900
tinyglobby@0.2.15:
2936
2901
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
2937
2902
engines: {node: '>=12.0.0'}
···
3785
3750
number-flow: 0.5.9
3786
3751
svelte: 5.48.0
3787
3752
3788
3788
-
'@pixi/colord@2.9.6': {}
3789
3789
-
3790
3753
'@polka/url@1.0.0-next.29': {}
3791
3754
3792
3755
'@poppinss/colors@4.1.6':
···
4261
4224
4262
4225
'@types/cookie@0.6.0': {}
4263
4226
4264
4264
-
'@types/earcut@3.0.0': {}
4265
4265
-
4266
4227
'@types/estree@1.0.8': {}
4267
4228
4268
4229
'@types/geojson-vt@3.2.5':
···
4411
4372
'@use-gesture/core': 10.3.1
4412
4373
4413
4374
'@webgpu/types@0.1.69': {}
4414
4414
-
4415
4415
-
'@xmldom/xmldom@0.8.11': {}
4416
4375
4417
4376
acorn-jsx@5.3.2(acorn@8.15.0):
4418
4377
dependencies:
···
4824
4783
4825
4784
esutils@2.0.3: {}
4826
4785
4827
4827
-
eventemitter3@5.0.4: {}
4828
4828
-
4829
4786
exsolve@1.0.8: {}
4830
4787
4831
4788
fast-deep-equal@3.1.3: {}
···
4864
4821
optional: true
4865
4822
4866
4823
geojson-vt@4.0.2: {}
4867
4867
-
4868
4868
-
gifuct-js@2.1.2:
4869
4869
-
dependencies:
4870
4870
-
js-binary-schema-parser: 2.0.3
4871
4824
4872
4825
gl-matrix@3.4.4: {}
4873
4826
···
4938
4891
4939
4892
isexe@2.0.0: {}
4940
4893
4941
4941
-
ismobilejs@1.1.1: {}
4942
4942
-
4943
4894
iso-datestring-validator@2.2.2: {}
4944
4895
4945
4896
jiti@2.6.1: {}
4946
4946
-
4947
4947
-
js-binary-schema-parser@2.0.3: {}
4948
4897
4949
4898
js-tokens@4.0.0: {}
4950
4899
···
5216
5165
color-name: 1.1.4
5217
5166
hex-rgb: 4.3.0
5218
5167
5219
5219
-
parse-svg-path@0.1.2: {}
5220
5220
-
5221
5168
parse5-htmlparser2-tree-adapter@7.1.0:
5222
5169
dependencies:
5223
5170
domhandler: 5.0.3
···
5248
5195
picocolors@1.1.1: {}
5249
5196
5250
5197
picomatch@4.0.3: {}
5251
5251
-
5252
5252
-
pixi.js@8.16.0:
5253
5253
-
dependencies:
5254
5254
-
'@pixi/colord': 2.9.6
5255
5255
-
'@types/earcut': 3.0.0
5256
5256
-
'@webgpu/types': 0.1.69
5257
5257
-
'@xmldom/xmldom': 0.8.11
5258
5258
-
earcut: 3.0.2
5259
5259
-
eventemitter3: 5.0.4
5260
5260
-
gifuct-js: 2.1.2
5261
5261
-
ismobilejs: 1.1.1
5262
5262
-
parse-svg-path: 0.1.2
5263
5263
-
tiny-lru: 11.4.7
5264
5198
5265
5199
pkg-types@1.3.1:
5266
5200
dependencies:
···
5775
5709
three@0.176.0: {}
5776
5710
5777
5711
tiny-inflate@1.0.3: {}
5778
5778
-
5779
5779
-
tiny-lru@11.4.7: {}
5780
5712
5781
5713
tinyglobby@0.2.15:
5782
5714
dependencies:
+13
-3
src/lib/cards/index.ts
···
29
29
import { EventCardDefinition } from './social/EventCard';
30
30
import { VCardCardDefinition } from './social/VCardCard';
31
31
import { DrawCardDefinition } from './visual/DrawCard';
32
32
-
import { RecordVisualizerCardDefinition } from './visual/RecordVisualizerCard';
33
32
import { TimerCardDefinition } from './utilities/TimerCard';
34
33
import { ClockCardDefinition } from './utilities/ClockCard';
35
34
import { CountdownCardDefinition } from './utilities/CountdownCard';
···
41
40
import { GitHubContributorsCardDefinition } from './social/GitHubContributorsCard';
42
41
import { ProductHuntCardDefinition } from './social/ProductHuntCard';
43
42
import { KickstarterCardDefinition } from './social/KickstarterCard';
43
43
+
import { NpmxLikesCardDefinition } from './social/NpmxLikesCard';
44
44
+
import { NpmxLikesLeaderboardCardDefinition } from './social/NpmxLikesLeaderboardCard';
45
45
+
import { LastFMRecentTracksCardDefinition } from './media/LastFMCard/LastFMRecentTracksCard';
46
46
+
import { LastFMTopTracksCardDefinition } from './media/LastFMCard/LastFMTopTracksCard';
47
47
+
import { LastFMTopAlbumsCardDefinition } from './media/LastFMCard/LastFMTopAlbumsCard';
48
48
+
import { LastFMProfileCardDefinition } from './media/LastFMCard/LastFMProfileCard';
44
49
// import { Model3DCardDefinition } from './visual/Model3DCard';
45
50
46
51
export const AllCardDefinitions = [
···
76
81
EventCardDefinition,
77
82
VCardCardDefinition,
78
83
DrawCardDefinition,
79
79
-
RecordVisualizerCardDefinition,
80
84
TimerCardDefinition,
81
85
ClockCardDefinition,
82
86
CountdownCardDefinition,
···
86
90
FriendsCardDefinition,
87
91
GitHubContributorsCardDefinition,
88
92
ProductHuntCardDefinition,
89
89
-
KickstarterCardDefinition
93
93
+
KickstarterCardDefinition,
94
94
+
NpmxLikesCardDefinition,
95
95
+
NpmxLikesLeaderboardCardDefinition,
96
96
+
LastFMRecentTracksCardDefinition,
97
97
+
LastFMTopTracksCardDefinition,
98
98
+
LastFMTopAlbumsCardDefinition,
99
99
+
LastFMProfileCardDefinition
90
100
] as const;
91
101
92
102
export const CardDefinitionsByType = AllCardDefinitions.reduce(
+60
src/lib/cards/media/LastFMCard/CreateLastFMCardModal.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { Button, Input, Modal, Subheading } from '@foxui/core';
3
3
+
import type { CreationModalComponentProps } from '../../types';
4
4
+
5
5
+
let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props();
6
6
+
7
7
+
let errorMessage = $state('');
8
8
+
</script>
9
9
+
10
10
+
<Modal open={true} closeButton={false}>
11
11
+
<form
12
12
+
onsubmit={() => {
13
13
+
let input = item.cardData.href?.trim();
14
14
+
if (!input) return;
15
15
+
16
16
+
let username: string | undefined;
17
17
+
18
18
+
try {
19
19
+
const parsed = new URL(input);
20
20
+
if (/^(www\.)?last\.fm$/.test(parsed.hostname)) {
21
21
+
const segments = parsed.pathname.split('/').filter(Boolean);
22
22
+
if (segments.length >= 2 && segments[0] === 'user') {
23
23
+
username = segments[1];
24
24
+
}
25
25
+
}
26
26
+
} catch {
27
27
+
if (/^[a-zA-Z0-9_-]{2,15}$/.test(input)) {
28
28
+
username = input;
29
29
+
}
30
30
+
}
31
31
+
32
32
+
if (!username) {
33
33
+
errorMessage = 'Please enter a valid Last.fm username or profile URL';
34
34
+
return;
35
35
+
}
36
36
+
37
37
+
item.cardData.lastfmUsername = username;
38
38
+
item.cardData.href = `https://www.last.fm/user/${username}`;
39
39
+
40
40
+
oncreate?.();
41
41
+
}}
42
42
+
class="flex flex-col gap-2"
43
43
+
>
44
44
+
<Subheading>Enter a Last.fm username or profile URL</Subheading>
45
45
+
<Input
46
46
+
bind:value={item.cardData.href}
47
47
+
placeholder="username or https://www.last.fm/user/username"
48
48
+
class="mt-4"
49
49
+
/>
50
50
+
51
51
+
{#if errorMessage}
52
52
+
<p class="mt-2 text-sm text-red-600">{errorMessage}</p>
53
53
+
{/if}
54
54
+
55
55
+
<div class="mt-4 flex justify-end gap-2">
56
56
+
<Button onclick={oncancel} variant="ghost">Cancel</Button>
57
57
+
<Button type="submit">Create</Button>
58
58
+
</div>
59
59
+
</form>
60
60
+
</Modal>
+50
src/lib/cards/media/LastFMCard/LastFMAlbumArt.svelte
···
1
1
+
<script lang="ts">
2
2
+
let {
3
3
+
images,
4
4
+
alt = '',
5
5
+
size = 'medium'
6
6
+
}: { images?: { '#text': string; size: string }[]; alt?: string; size?: string } = $props();
7
7
+
8
8
+
let isLoading = $state(true);
9
9
+
let hasError = $state(false);
10
10
+
11
11
+
const imageUrl = $derived.by(() => {
12
12
+
if (!images || images.length === 0) return '';
13
13
+
const preferred = ['extralarge', 'large', 'medium', 'small'];
14
14
+
for (const pref of preferred) {
15
15
+
const img = images.find((i) => i.size === pref);
16
16
+
if (img?.['#text']) return img['#text'];
17
17
+
}
18
18
+
return images[images.length - 1]?.['#text'] || '';
19
19
+
});
20
20
+
</script>
21
21
+
22
22
+
{#if !imageUrl || hasError}
23
23
+
<div
24
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
25
+
>
26
26
+
<svg
27
27
+
class="text-base-500 dark:text-base-400 accent:text-accent-200 h-5 w-5"
28
28
+
fill="currentColor"
29
29
+
viewBox="0 0 20 20"
30
30
+
>
31
31
+
<path
32
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
33
+
/>
34
34
+
</svg>
35
35
+
</div>
36
36
+
{:else}
37
37
+
{#if isLoading}
38
38
+
<div class="bg-base-200 dark:bg-base-800 h-full w-full animate-pulse rounded-lg"></div>
39
39
+
{/if}
40
40
+
<img
41
41
+
src={imageUrl}
42
42
+
{alt}
43
43
+
class="h-full w-full rounded-lg object-cover {isLoading && 'hidden'}"
44
44
+
onload={() => (isLoading = false)}
45
45
+
onerror={() => {
46
46
+
isLoading = false;
47
47
+
hasError = true;
48
48
+
}}
49
49
+
/>
50
50
+
{/if}
+36
src/lib/cards/media/LastFMCard/LastFMPeriodSettings.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { SettingsComponentProps } from '../../types';
3
3
+
import { Label } from '@foxui/core';
4
4
+
5
5
+
let { item = $bindable() }: SettingsComponentProps = $props();
6
6
+
7
7
+
const periodOptions = [
8
8
+
{ value: '7day', label: '7 Days' },
9
9
+
{ value: '1month', label: '1 Month' },
10
10
+
{ value: '3month', label: '3 Months' },
11
11
+
{ value: '6month', label: '6 Months' },
12
12
+
{ value: '12month', label: '12 Months' },
13
13
+
{ value: 'overall', label: 'All Time' }
14
14
+
];
15
15
+
16
16
+
let period = $derived(item.cardData.period ?? '7day');
17
17
+
</script>
18
18
+
19
19
+
<div class="flex flex-col gap-2">
20
20
+
<Label>Time Period</Label>
21
21
+
<div class="flex flex-wrap gap-2">
22
22
+
{#each periodOptions as opt (opt.value)}
23
23
+
<button
24
24
+
class={[
25
25
+
'rounded-xl border px-3 py-2 text-sm transition-colors',
26
26
+
period === opt.value
27
27
+
? 'bg-accent-500 border-accent-500 text-white'
28
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
29
+
]}
30
30
+
onclick={() => (item.cardData.period = opt.value)}
31
31
+
>
32
32
+
{opt.label}
33
33
+
</button>
34
34
+
{/each}
35
35
+
</div>
36
36
+
</div>
+113
src/lib/cards/media/LastFMCard/LastFMProfileCard/LastFMProfileCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { onMount } from 'svelte';
3
3
+
import { siLastdotfm } from 'simple-icons';
4
4
+
import { getAdditionalUserData } from '$lib/website/context';
5
5
+
import type { ContentComponentProps } from '../../../types';
6
6
+
import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte';
7
7
+
8
8
+
interface UserInfo {
9
9
+
name: string;
10
10
+
realname: string;
11
11
+
url: string;
12
12
+
image: { '#text': string; size: string }[];
13
13
+
playcount: string;
14
14
+
registered: { unixtime: string };
15
15
+
}
16
16
+
17
17
+
let { item, isEditing }: ContentComponentProps = $props();
18
18
+
19
19
+
const data = getAdditionalUserData();
20
20
+
const cacheKey = $derived(`lastfmProfile:${item.cardData.lastfmUsername}`);
21
21
+
22
22
+
// svelte-ignore state_referenced_locally
23
23
+
let userInfo = $state(data[cacheKey] as UserInfo | undefined);
24
24
+
25
25
+
onMount(async () => {
26
26
+
if (userInfo) return;
27
27
+
if (!item.cardData.lastfmUsername) return;
28
28
+
29
29
+
try {
30
30
+
const response = await fetch(
31
31
+
`/api/lastfm?method=user.getInfo&user=${encodeURIComponent(item.cardData.lastfmUsername)}`
32
32
+
);
33
33
+
if (response.ok) {
34
34
+
const result = await response.json();
35
35
+
userInfo = result?.user;
36
36
+
data[cacheKey] = userInfo;
37
37
+
}
38
38
+
} catch (error) {
39
39
+
console.error('Failed to fetch Last.fm profile:', error);
40
40
+
}
41
41
+
});
42
42
+
43
43
+
const profileUrl = $derived(`https://www.last.fm/user/${item.cardData.lastfmUsername}`);
44
44
+
45
45
+
const avatarUrl = $derived.by(() => {
46
46
+
if (!userInfo?.image) return '';
47
47
+
const preferred = ['extralarge', 'large', 'medium'];
48
48
+
for (const pref of preferred) {
49
49
+
const img = userInfo.image.find((i) => i.size === pref);
50
50
+
if (img?.['#text']) return img['#text'];
51
51
+
}
52
52
+
return '';
53
53
+
});
54
54
+
55
55
+
const memberSince = $derived.by(() => {
56
56
+
if (!userInfo?.registered?.unixtime) return '';
57
57
+
const date = new Date(parseInt(userInfo.registered.unixtime) * 1000);
58
58
+
return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
59
59
+
});
60
60
+
</script>
61
61
+
62
62
+
<div class="h-full overflow-hidden p-4">
63
63
+
<div class="flex h-full flex-col justify-between">
64
64
+
<div class="flex items-center gap-3">
65
65
+
<div
66
66
+
class="fill-base-950 accent:fill-white size-6 shrink-0 dark:fill-white [&_svg]:size-full"
67
67
+
>
68
68
+
{@html siLastdotfm.svg}
69
69
+
</div>
70
70
+
<span class="truncate text-2xl font-bold">
71
71
+
{item.cardData.lastfmUsername}
72
72
+
</span>
73
73
+
</div>
74
74
+
75
75
+
{#if userInfo}
76
76
+
<div class="flex items-center gap-4">
77
77
+
{#if avatarUrl}
78
78
+
<img src={avatarUrl} alt={userInfo.name} class="size-12 rounded-full object-cover" />
79
79
+
{/if}
80
80
+
<div class="min-w-0 flex-1">
81
81
+
<div class="text-lg font-semibold">
82
82
+
{parseInt(userInfo.playcount).toLocaleString()} scrobbles
83
83
+
</div>
84
84
+
{#if memberSince}
85
85
+
<div class="text-sm opacity-60">
86
86
+
Since {memberSince}
87
87
+
</div>
88
88
+
{/if}
89
89
+
</div>
90
90
+
</div>
91
91
+
{:else}
92
92
+
<div class="text-sm opacity-60">Loading profile...</div>
93
93
+
{/if}
94
94
+
</div>
95
95
+
</div>
96
96
+
97
97
+
{#if !isEditing}
98
98
+
<a
99
99
+
href={profileUrl}
100
100
+
class="absolute inset-0 h-full w-full"
101
101
+
target="_blank"
102
102
+
rel="noopener noreferrer"
103
103
+
use:qrOverlay={{
104
104
+
context: {
105
105
+
title: item.cardData.lastfmUsername,
106
106
+
icon: siLastdotfm.svg,
107
107
+
iconColor: '#' + siLastdotfm.hex
108
108
+
}
109
109
+
}}
110
110
+
>
111
111
+
<span class="sr-only">View on Last.fm</span>
112
112
+
</a>
113
113
+
{/if}
+40
src/lib/cards/media/LastFMCard/LastFMProfileCard/index.ts
···
1
1
+
import type { CardDefinition } from '../../../types';
2
2
+
import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte';
3
3
+
import LastFMProfileCard from './LastFMProfileCard.svelte';
4
4
+
5
5
+
export const LastFMProfileCardDefinition = {
6
6
+
type: 'lastfmProfile',
7
7
+
contentComponent: LastFMProfileCard,
8
8
+
creationModalComponent: CreateLastFMCardModal,
9
9
+
createNew: (card) => {
10
10
+
card.w = 4;
11
11
+
card.mobileW = 8;
12
12
+
card.h = 2;
13
13
+
card.mobileH = 3;
14
14
+
},
15
15
+
loadData: async (items) => {
16
16
+
const allData: Record<string, unknown> = {};
17
17
+
for (const item of items) {
18
18
+
const username = item.cardData.lastfmUsername;
19
19
+
if (!username) continue;
20
20
+
try {
21
21
+
const response = await fetch(
22
22
+
`https://blento.app/api/lastfm?method=user.getInfo&user=${encodeURIComponent(username)}`
23
23
+
);
24
24
+
if (!response.ok) continue;
25
25
+
const text = await response.text();
26
26
+
const result = JSON.parse(text);
27
27
+
allData[`lastfmProfile:${username}`] = result?.user;
28
28
+
} catch (error) {
29
29
+
console.error('Failed to fetch Last.fm profile:', error);
30
30
+
}
31
31
+
}
32
32
+
return allData;
33
33
+
},
34
34
+
minW: 2,
35
35
+
minH: 2,
36
36
+
name: 'Last.fm Profile',
37
37
+
keywords: ['music', 'scrobble', 'profile', 'lastfm', 'last.fm'],
38
38
+
groups: ['Media'],
39
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
40
+
} as CardDefinition & { type: 'lastfmProfile' };
+103
src/lib/cards/media/LastFMCard/LastFMRecentTracksCard/LastFMRecentTracksCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { onMount } from 'svelte';
3
3
+
import { getAdditionalUserData } from '$lib/website/context';
4
4
+
import type { ContentComponentProps } from '../../../types';
5
5
+
import LastFMAlbumArt from '../LastFMAlbumArt.svelte';
6
6
+
import { RelativeTime } from '@foxui/time';
7
7
+
8
8
+
interface Track {
9
9
+
name: string;
10
10
+
artist: { '#text': string };
11
11
+
album: { '#text': string };
12
12
+
image: { '#text': string; size: string }[];
13
13
+
url: string;
14
14
+
date?: { uts: string };
15
15
+
'@attr'?: { nowplaying: string };
16
16
+
}
17
17
+
18
18
+
let { item }: ContentComponentProps = $props();
19
19
+
20
20
+
const data = getAdditionalUserData();
21
21
+
const cacheKey = $derived(`lastfmRecentTracks:${item.cardData.lastfmUsername}`);
22
22
+
23
23
+
// svelte-ignore state_referenced_locally
24
24
+
let tracks = $state(data[cacheKey] as Track[] | undefined);
25
25
+
let error = $state(false);
26
26
+
27
27
+
onMount(async () => {
28
28
+
if (tracks) return;
29
29
+
if (!item.cardData.lastfmUsername) return;
30
30
+
31
31
+
try {
32
32
+
const response = await fetch(
33
33
+
`/api/lastfm?method=user.getRecentTracks&user=${encodeURIComponent(item.cardData.lastfmUsername)}&limit=50`
34
34
+
);
35
35
+
if (response.ok) {
36
36
+
const result = await response.json();
37
37
+
tracks = result?.recenttracks?.track ?? [];
38
38
+
data[cacheKey] = tracks;
39
39
+
} else {
40
40
+
error = true;
41
41
+
}
42
42
+
} catch {
43
43
+
error = true;
44
44
+
}
45
45
+
});
46
46
+
</script>
47
47
+
48
48
+
<div class="z-10 flex h-full w-full flex-col gap-3 overflow-y-scroll p-4">
49
49
+
{#if tracks && tracks.length > 0}
50
50
+
{#each tracks as track, i (track.url + i)}
51
51
+
<a
52
52
+
href={track.url}
53
53
+
target="_blank"
54
54
+
rel="noopener noreferrer"
55
55
+
class="flex w-full items-center gap-3"
56
56
+
>
57
57
+
<div class="size-10 shrink-0">
58
58
+
<LastFMAlbumArt images={track.image} alt={track.album?.['#text']} />
59
59
+
</div>
60
60
+
<div class="min-w-0 flex-1">
61
61
+
<div class="inline-flex w-full max-w-full justify-between gap-2">
62
62
+
<div
63
63
+
class="text-accent-500 accent:text-accent-950 min-w-0 flex-1 shrink truncate font-semibold"
64
64
+
>
65
65
+
{track.name}
66
66
+
</div>
67
67
+
{#if track['@attr']?.nowplaying === 'true'}
68
68
+
<div class="flex shrink-0 items-center gap-1 text-xs text-green-500">
69
69
+
<span class="inline-block size-2 animate-pulse rounded-full bg-green-500"></span>
70
70
+
Now
71
71
+
</div>
72
72
+
{:else if track.date?.uts}
73
73
+
<div class="shrink-0 text-xs">
74
74
+
<RelativeTime date={new Date(parseInt(track.date.uts) * 1000)} locale="en-US" /> ago
75
75
+
</div>
76
76
+
{/if}
77
77
+
</div>
78
78
+
<div class="my-1 min-w-0 truncate text-xs whitespace-nowrap">
79
79
+
{track.artist?.['#text']}
80
80
+
</div>
81
81
+
</div>
82
82
+
</a>
83
83
+
{/each}
84
84
+
{:else if error}
85
85
+
<div
86
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
87
+
>
88
88
+
Failed to load tracks.
89
89
+
</div>
90
90
+
{:else if tracks}
91
91
+
<div
92
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
93
+
>
94
94
+
No recent tracks found.
95
95
+
</div>
96
96
+
{:else}
97
97
+
<div
98
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
99
+
>
100
100
+
Loading tracks...
101
101
+
</div>
102
102
+
{/if}
103
103
+
</div>
+70
src/lib/cards/media/LastFMCard/LastFMRecentTracksCard/index.ts
···
1
1
+
import type { CardDefinition } from '../../../types';
2
2
+
import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte';
3
3
+
import LastFMRecentTracksCard from './LastFMRecentTracksCard.svelte';
4
4
+
5
5
+
export const LastFMRecentTracksCardDefinition = {
6
6
+
type: 'lastfmRecentTracks',
7
7
+
contentComponent: LastFMRecentTracksCard,
8
8
+
creationModalComponent: CreateLastFMCardModal,
9
9
+
createNew: (card) => {
10
10
+
card.w = 4;
11
11
+
card.mobileW = 8;
12
12
+
card.h = 3;
13
13
+
card.mobileH = 6;
14
14
+
},
15
15
+
loadData: async (items) => {
16
16
+
const allData: Record<string, unknown> = {};
17
17
+
for (const item of items) {
18
18
+
const username = item.cardData.lastfmUsername;
19
19
+
if (!username) continue;
20
20
+
try {
21
21
+
const response = await fetch(
22
22
+
`https://blento.app/api/lastfm?method=user.getRecentTracks&user=${encodeURIComponent(username)}&limit=50`
23
23
+
);
24
24
+
if (!response.ok) continue;
25
25
+
const text = await response.text();
26
26
+
const result = JSON.parse(text);
27
27
+
allData[`lastfmRecentTracks:${username}`] = result?.recenttracks?.track ?? [];
28
28
+
} catch (error) {
29
29
+
console.error('Failed to fetch Last.fm recent tracks:', error);
30
30
+
}
31
31
+
}
32
32
+
return allData;
33
33
+
},
34
34
+
onUrlHandler: (url, item) => {
35
35
+
const username = getLastFMUsername(url);
36
36
+
if (!username) return null;
37
37
+
38
38
+
item.cardData.lastfmUsername = username;
39
39
+
item.cardData.href = `https://www.last.fm/user/${username}`;
40
40
+
item.w = 4;
41
41
+
item.mobileW = 8;
42
42
+
item.h = 3;
43
43
+
item.mobileH = 6;
44
44
+
item.cardType = 'lastfmRecentTracks';
45
45
+
return item;
46
46
+
},
47
47
+
urlHandlerPriority: 5,
48
48
+
minW: 3,
49
49
+
minH: 2,
50
50
+
canHaveLabel: true,
51
51
+
name: 'Last.fm Recent Tracks',
52
52
+
keywords: ['music', 'scrobble', 'listening', 'songs', 'lastfm', 'last.fm'],
53
53
+
groups: ['Media'],
54
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
55
+
} as CardDefinition & { type: 'lastfmRecentTracks' };
56
56
+
57
57
+
function getLastFMUsername(url: string | undefined): string | undefined {
58
58
+
if (!url) return;
59
59
+
try {
60
60
+
const parsed = new URL(url);
61
61
+
if (!/^(www\.)?last\.fm$/.test(parsed.hostname)) return undefined;
62
62
+
const segments = parsed.pathname.split('/').filter(Boolean);
63
63
+
if (segments.length >= 2 && segments[0] === 'user') {
64
64
+
return segments[1];
65
65
+
}
66
66
+
return undefined;
67
67
+
} catch {
68
68
+
return undefined;
69
69
+
}
70
70
+
}
+103
src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/LastFMTopAlbumsCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { onMount } from 'svelte';
3
3
+
import type { ContentComponentProps } from '../../../types';
4
4
+
import { getAdditionalUserData } from '$lib/website/context';
5
5
+
import ImageGrid from '$lib/components/ImageGrid.svelte';
6
6
+
7
7
+
interface Album {
8
8
+
name: string;
9
9
+
playcount: string;
10
10
+
url: string;
11
11
+
artist: { name: string; url: string };
12
12
+
image: { '#text': string; size: string }[];
13
13
+
}
14
14
+
15
15
+
let { item }: ContentComponentProps = $props();
16
16
+
17
17
+
const data = getAdditionalUserData();
18
18
+
19
19
+
let period = $derived(item.cardData.period ?? '7day');
20
20
+
let layout: 'grid' | 'cinema' = $derived(item.cardData.layout ?? 'grid');
21
21
+
const cacheKey = $derived(`lastfmTopAlbums:${item.cardData.lastfmUsername}:${period}`);
22
22
+
23
23
+
// svelte-ignore state_referenced_locally
24
24
+
let albums = $state(data[cacheKey] as Album[] | undefined);
25
25
+
let loading = $state(false);
26
26
+
let error = $state(false);
27
27
+
28
28
+
async function fetchAlbums() {
29
29
+
if (!item.cardData.lastfmUsername) return;
30
30
+
loading = true;
31
31
+
32
32
+
try {
33
33
+
const response = await fetch(
34
34
+
`/api/lastfm?method=user.getTopAlbums&user=${encodeURIComponent(item.cardData.lastfmUsername)}&period=${period}&limit=50`
35
35
+
);
36
36
+
if (response.ok) {
37
37
+
const result = await response.json();
38
38
+
albums = result?.topalbums?.album ?? [];
39
39
+
data[cacheKey] = albums;
40
40
+
} else {
41
41
+
error = true;
42
42
+
}
43
43
+
} catch {
44
44
+
error = true;
45
45
+
} finally {
46
46
+
loading = false;
47
47
+
}
48
48
+
}
49
49
+
50
50
+
onMount(() => {
51
51
+
if (!albums) fetchAlbums();
52
52
+
});
53
53
+
54
54
+
$effect(() => {
55
55
+
const _period = period;
56
56
+
const cached = data[cacheKey] as Album[] | undefined;
57
57
+
if (cached) {
58
58
+
albums = cached;
59
59
+
} else {
60
60
+
fetchAlbums();
61
61
+
}
62
62
+
});
63
63
+
64
64
+
function getImageUrl(album: Album): string | null {
65
65
+
if (!album.image || album.image.length === 0) return null;
66
66
+
const preferred = ['extralarge', 'large', 'medium', 'small'];
67
67
+
for (const pref of preferred) {
68
68
+
const img = album.image.find((i) => i.size === pref);
69
69
+
if (img?.['#text']) return img['#text'];
70
70
+
}
71
71
+
return album.image[album.image.length - 1]?.['#text'] || null;
72
72
+
}
73
73
+
74
74
+
let gridItems = $derived(
75
75
+
(albums ?? []).map((album) => ({
76
76
+
imageUrl: getImageUrl(album),
77
77
+
link: album.url,
78
78
+
label: `${album.name} - ${album.artist.name}`
79
79
+
}))
80
80
+
);
81
81
+
</script>
82
82
+
83
83
+
{#if error}
84
84
+
<div class="flex h-full w-full items-center justify-center">
85
85
+
<span class="text-base-500 dark:text-base-400 accent:text-white/60 text-sm">
86
86
+
Failed to load albums.
87
87
+
</span>
88
88
+
</div>
89
89
+
{:else if albums && gridItems.length > 0}
90
90
+
<ImageGrid items={gridItems} {layout} tooltip />
91
91
+
{:else if loading || !albums}
92
92
+
<div class="flex h-full w-full items-center justify-center">
93
93
+
<span class="text-base-500 dark:text-base-400 accent:text-white/60 text-sm">
94
94
+
Loading albums...
95
95
+
</span>
96
96
+
</div>
97
97
+
{:else}
98
98
+
<div class="flex h-full w-full items-center justify-center">
99
99
+
<span class="text-base-500 dark:text-base-400 accent:text-white/60 text-sm">
100
100
+
No top albums found.
101
101
+
</span>
102
102
+
</div>
103
103
+
{/if}
+63
src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/LastFMTopAlbumsCardSettings.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { SettingsComponentProps } from '../../../types';
3
3
+
import { Label } from '@foxui/core';
4
4
+
5
5
+
let { item = $bindable() }: SettingsComponentProps = $props();
6
6
+
7
7
+
const periodOptions = [
8
8
+
{ value: '7day', label: '7 Days' },
9
9
+
{ value: '1month', label: '1 Month' },
10
10
+
{ value: '3month', label: '3 Months' },
11
11
+
{ value: '6month', label: '6 Months' },
12
12
+
{ value: '12month', label: '12 Months' },
13
13
+
{ value: 'overall', label: 'All Time' }
14
14
+
];
15
15
+
16
16
+
const layoutOptions = [
17
17
+
{ value: 'grid', label: 'Grid' },
18
18
+
{ value: 'cinema', label: 'Cinema' }
19
19
+
];
20
20
+
21
21
+
let period = $derived(item.cardData.period ?? '7day');
22
22
+
let layout = $derived(item.cardData.layout ?? 'grid');
23
23
+
</script>
24
24
+
25
25
+
<div class="flex flex-col gap-4">
26
26
+
<div class="flex flex-col gap-2">
27
27
+
<Label>Time Period</Label>
28
28
+
<div class="flex flex-wrap gap-2">
29
29
+
{#each periodOptions as opt (opt.value)}
30
30
+
<button
31
31
+
class={[
32
32
+
'rounded-xl border px-3 py-2 text-sm transition-colors',
33
33
+
period === opt.value
34
34
+
? 'bg-accent-500 border-accent-500 text-white'
35
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
36
+
]}
37
37
+
onclick={() => (item.cardData.period = opt.value)}
38
38
+
>
39
39
+
{opt.label}
40
40
+
</button>
41
41
+
{/each}
42
42
+
</div>
43
43
+
</div>
44
44
+
45
45
+
<div class="flex flex-col gap-2">
46
46
+
<Label>Layout</Label>
47
47
+
<div class="flex gap-2">
48
48
+
{#each layoutOptions as opt (opt.value)}
49
49
+
<button
50
50
+
class={[
51
51
+
'flex-1 rounded-xl border px-3 py-2 text-sm transition-colors',
52
52
+
layout === opt.value
53
53
+
? 'bg-accent-500 border-accent-500 text-white'
54
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
55
+
]}
56
56
+
onclick={() => (item.cardData.layout = opt.value)}
57
57
+
>
58
58
+
{opt.label}
59
59
+
</button>
60
60
+
{/each}
61
61
+
</div>
62
62
+
</div>
63
63
+
</div>
+47
src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/index.ts
···
1
1
+
import type { CardDefinition } from '../../../types';
2
2
+
import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte';
3
3
+
import LastFMTopAlbumsCard from './LastFMTopAlbumsCard.svelte';
4
4
+
import LastFMTopAlbumsCardSettings from './LastFMTopAlbumsCardSettings.svelte';
5
5
+
6
6
+
export const LastFMTopAlbumsCardDefinition = {
7
7
+
type: 'lastfmTopAlbums',
8
8
+
contentComponent: LastFMTopAlbumsCard,
9
9
+
creationModalComponent: CreateLastFMCardModal,
10
10
+
settingsComponent: LastFMTopAlbumsCardSettings,
11
11
+
createNew: (card) => {
12
12
+
card.w = 4;
13
13
+
card.h = 3;
14
14
+
card.mobileW = 8;
15
15
+
card.mobileH = 4;
16
16
+
card.cardData.period = '7day';
17
17
+
},
18
18
+
loadData: async (items) => {
19
19
+
const allData: Record<string, unknown> = {};
20
20
+
for (const item of items) {
21
21
+
const username = item.cardData.lastfmUsername;
22
22
+
const period = item.cardData.period ?? '7day';
23
23
+
if (!username) continue;
24
24
+
try {
25
25
+
const response = await fetch(
26
26
+
`https://blento.app/api/lastfm?method=user.getTopAlbums&user=${encodeURIComponent(username)}&period=${period}&limit=50`
27
27
+
);
28
28
+
if (!response.ok) continue;
29
29
+
const text = await response.text();
30
30
+
const result = JSON.parse(text);
31
31
+
allData[`lastfmTopAlbums:${username}:${period}`] = result?.topalbums?.album ?? [];
32
32
+
} catch (error) {
33
33
+
console.error('Failed to fetch Last.fm top albums:', error);
34
34
+
}
35
35
+
}
36
36
+
return allData;
37
37
+
},
38
38
+
allowSetColor: true,
39
39
+
defaultColor: 'base',
40
40
+
minW: 2,
41
41
+
minH: 2,
42
42
+
canHaveLabel: true,
43
43
+
name: 'Last.fm Top Albums',
44
44
+
keywords: ['music', 'scrobble', 'albums', 'lastfm', 'last.fm', 'top'],
45
45
+
groups: ['Media'],
46
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
47
+
} as CardDefinition & { type: 'lastfmTopAlbums' };
+117
src/lib/cards/media/LastFMCard/LastFMTopTracksCard/LastFMTopTracksCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { onMount } from 'svelte';
3
3
+
import { getAdditionalUserData } from '$lib/website/context';
4
4
+
import type { ContentComponentProps } from '../../../types';
5
5
+
import LastFMAlbumArt from '../LastFMAlbumArt.svelte';
6
6
+
7
7
+
interface Track {
8
8
+
name: string;
9
9
+
playcount: string;
10
10
+
artist: { name: string; url: string };
11
11
+
image: { '#text': string; size: string }[];
12
12
+
url: string;
13
13
+
}
14
14
+
15
15
+
let { item }: ContentComponentProps = $props();
16
16
+
17
17
+
const data = getAdditionalUserData();
18
18
+
19
19
+
let period = $derived(item.cardData.period ?? '7day');
20
20
+
const cacheKey = $derived(`lastfmTopTracks:${item.cardData.lastfmUsername}:${period}`);
21
21
+
22
22
+
// svelte-ignore state_referenced_locally
23
23
+
let tracks = $state(data[cacheKey] as Track[] | undefined);
24
24
+
let error = $state(false);
25
25
+
let loading = $state(false);
26
26
+
27
27
+
async function fetchTracks() {
28
28
+
if (!item.cardData.lastfmUsername) return;
29
29
+
loading = true;
30
30
+
31
31
+
try {
32
32
+
const response = await fetch(
33
33
+
`/api/lastfm?method=user.getTopTracks&user=${encodeURIComponent(item.cardData.lastfmUsername)}&period=${period}&limit=50`
34
34
+
);
35
35
+
if (response.ok) {
36
36
+
const result = await response.json();
37
37
+
tracks = result?.toptracks?.track ?? [];
38
38
+
data[cacheKey] = tracks;
39
39
+
} else {
40
40
+
error = true;
41
41
+
}
42
42
+
} catch {
43
43
+
error = true;
44
44
+
} finally {
45
45
+
loading = false;
46
46
+
}
47
47
+
}
48
48
+
49
49
+
onMount(() => {
50
50
+
if (!tracks) fetchTracks();
51
51
+
});
52
52
+
53
53
+
$effect(() => {
54
54
+
const _period = period;
55
55
+
const cached = data[cacheKey] as Track[] | undefined;
56
56
+
if (cached) {
57
57
+
tracks = cached;
58
58
+
} else {
59
59
+
fetchTracks();
60
60
+
}
61
61
+
});
62
62
+
</script>
63
63
+
64
64
+
<div class="z-10 flex h-full w-full flex-col gap-3 overflow-y-scroll p-4">
65
65
+
{#if tracks && tracks.length > 0}
66
66
+
{#each tracks as track, i (track.url)}
67
67
+
<a
68
68
+
href={track.url}
69
69
+
target="_blank"
70
70
+
rel="noopener noreferrer"
71
71
+
class="flex w-full items-center gap-3"
72
72
+
>
73
73
+
<div
74
74
+
class="text-base-400 dark:text-base-500 accent:text-white/40 w-5 shrink-0 text-right text-xs font-bold"
75
75
+
>
76
76
+
{i + 1}
77
77
+
</div>
78
78
+
<div class="size-10 shrink-0">
79
79
+
<LastFMAlbumArt images={track.image} alt={track.name} />
80
80
+
</div>
81
81
+
<div class="min-w-0 flex-1">
82
82
+
<div class="inline-flex w-full max-w-full justify-between gap-2">
83
83
+
<div
84
84
+
class="text-accent-500 accent:text-accent-950 min-w-0 flex-1 shrink truncate font-semibold"
85
85
+
>
86
86
+
{track.name}
87
87
+
</div>
88
88
+
<div class="shrink-0 text-xs">
89
89
+
{parseInt(track.playcount).toLocaleString()} plays
90
90
+
</div>
91
91
+
</div>
92
92
+
<div class="my-1 min-w-0 truncate text-xs whitespace-nowrap">
93
93
+
{track.artist?.name}
94
94
+
</div>
95
95
+
</div>
96
96
+
</a>
97
97
+
{/each}
98
98
+
{:else if error}
99
99
+
<div
100
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
101
+
>
102
102
+
Failed to load tracks.
103
103
+
</div>
104
104
+
{:else if tracks || loading}
105
105
+
<div
106
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
107
+
>
108
108
+
{tracks?.length === 0 ? 'No top tracks found.' : 'Loading tracks...'}
109
109
+
</div>
110
110
+
{:else}
111
111
+
<div
112
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
113
+
>
114
114
+
Loading tracks...
115
115
+
</div>
116
116
+
{/if}
117
117
+
</div>
+45
src/lib/cards/media/LastFMCard/LastFMTopTracksCard/index.ts
···
1
1
+
import type { CardDefinition } from '../../../types';
2
2
+
import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte';
3
3
+
import LastFMPeriodSettings from '../LastFMPeriodSettings.svelte';
4
4
+
import LastFMTopTracksCard from './LastFMTopTracksCard.svelte';
5
5
+
6
6
+
export const LastFMTopTracksCardDefinition = {
7
7
+
type: 'lastfmTopTracks',
8
8
+
contentComponent: LastFMTopTracksCard,
9
9
+
creationModalComponent: CreateLastFMCardModal,
10
10
+
settingsComponent: LastFMPeriodSettings,
11
11
+
createNew: (card) => {
12
12
+
card.w = 4;
13
13
+
card.mobileW = 8;
14
14
+
card.h = 3;
15
15
+
card.mobileH = 6;
16
16
+
card.cardData.period = '7day';
17
17
+
},
18
18
+
loadData: async (items) => {
19
19
+
const allData: Record<string, unknown> = {};
20
20
+
for (const item of items) {
21
21
+
const username = item.cardData.lastfmUsername;
22
22
+
const period = item.cardData.period ?? '7day';
23
23
+
if (!username) continue;
24
24
+
try {
25
25
+
const response = await fetch(
26
26
+
`https://blento.app/api/lastfm?method=user.getTopTracks&user=${encodeURIComponent(username)}&period=${period}&limit=50`
27
27
+
);
28
28
+
if (!response.ok) continue;
29
29
+
const text = await response.text();
30
30
+
const result = JSON.parse(text);
31
31
+
allData[`lastfmTopTracks:${username}:${period}`] = result?.toptracks?.track ?? [];
32
32
+
} catch (error) {
33
33
+
console.error('Failed to fetch Last.fm top tracks:', error);
34
34
+
}
35
35
+
}
36
36
+
return allData;
37
37
+
},
38
38
+
minW: 3,
39
39
+
minH: 2,
40
40
+
canHaveLabel: true,
41
41
+
name: 'Last.fm Top Tracks',
42
42
+
keywords: ['music', 'scrobble', 'songs', 'lastfm', 'last.fm', 'top'],
43
43
+
groups: ['Media'],
44
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
45
+
} as CardDefinition & { type: 'lastfmTopTracks' };
+9
-1
src/lib/cards/social/BigSocialCard/BigSocialCard.svelte
···
7
7
8
8
const platform = $derived(item.cardData.platform as string);
9
9
const platformData = $derived(platformsData[platform]);
10
10
+
11
11
+
// Color logic:
12
12
+
// - base/transparent/undefined: background = brand color, icon = white
13
13
+
// - other: background = that color (from BaseCard), icon = white
14
14
+
const useBrandBackground = $derived(
15
15
+
!item.color || item.color === 'base' || item.color === 'transparent'
16
16
+
);
17
17
+
const brandColor = $derived(`#${item.cardData.color}`);
10
18
</script>
11
19
12
20
<div
13
21
class="flex h-full w-full items-center justify-center p-10"
14
14
-
style={`background-color: #${item.cardData.color}`}
22
22
+
style={useBrandBackground ? `background-color: ${brandColor}` : ''}
15
23
>
16
24
<div
17
25
class="flex aspect-square max-h-full max-w-full items-center justify-center [&_svg]:size-full [&_svg]:max-w-60 [&_svg]:fill-white"
+24
-3
src/lib/cards/social/BigSocialCard/index.ts
···
36
36
return item;
37
37
},
38
38
name: 'Social Icon',
39
39
-
allowSetColor: false,
40
40
-
defaultColor: 'transparent',
39
39
+
allowSetColor: true,
40
40
+
defaultColor: 'base',
41
41
minW: 2,
42
42
minH: 2,
43
43
onUrlHandler: (url, item) => {
···
167
167
168
168
tangled: /(?:tangled\.org)/i,
169
169
170
170
-
mail: /(?:mailto:)/i
170
170
+
mail: /(?:mailto:)/i,
171
171
+
172
172
+
npmx: /(?:npmx\.dev)/i
171
173
};
172
174
173
175
export const platformsData: Record<string, SimpleIcon> = {
···
277
279
</g>
278
280
<defs>
279
281
<clipPath id="clip0_0_3">
282
282
+
<rect width="24" height="24" fill="white"/>
283
283
+
</clipPath>
284
284
+
</defs>
285
285
+
</svg>`
286
286
+
},
287
287
+
288
288
+
npmx: {
289
289
+
slug: 'npmx',
290
290
+
path: '',
291
291
+
title: 'npmx',
292
292
+
source: '',
293
293
+
hex: '0A0A0A',
294
294
+
svg: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
295
295
+
<g clip-path="url(#clip0_4_2)">
296
296
+
<path d="M6.12765 16.4049H2V20.5326H6.12765V16.4049Z" fill="#525252"/>
297
297
+
<path d="M10.9049 23.8485L19.6885 -1H22L13.2164 23.8485H10.9049Z" fill="#FAFAFA"/>
298
298
+
</g>
299
299
+
<defs>
300
300
+
<clipPath id="clip0_4_2">
280
301
<rect width="24" height="24" fill="white"/>
281
302
</clipPath>
282
303
</defs>
+14
-135
src/lib/cards/social/GitHubContributorsCard/GitHubContributorsCard.svelte
···
1
1
<script lang="ts">
2
2
import { onMount } from 'svelte';
3
3
import type { ContentComponentProps } from '../../types';
4
4
-
import { getAdditionalUserData, getCanEdit, getIsMobile } from '$lib/website/context';
4
4
+
import { getAdditionalUserData, getCanEdit } from '$lib/website/context';
5
5
import type { GitHubContributor, GitHubContributorsLoadedData } from '.';
6
6
+
import ImageGrid from '$lib/components/ImageGrid.svelte';
6
7
7
8
let { item }: ContentComponentProps = $props();
8
9
9
9
-
const isMobile = getIsMobile();
10
10
const canEdit = getCanEdit();
11
11
const additionalData = getAdditionalUserData();
12
12
···
53
53
}
54
54
}
55
55
56
56
-
let containerWidth = $state(0);
57
57
-
let containerHeight = $state(0);
58
58
-
59
59
-
let totalItems = $derived(namedContributors.length);
60
60
-
61
61
-
const GAP = 6;
62
62
-
const MIN_SIZE = 16;
63
63
-
const MAX_SIZE = 120;
64
64
-
65
65
-
function cinemaCapacity(size: number, availW: number, availH: number): number {
66
66
-
const colsWide = Math.floor((availW + GAP) / (size + GAP));
67
67
-
if (colsWide < 1) return 0;
68
68
-
const colsNarrow = Math.max(1, colsWide - 1);
69
69
-
const maxRows = Math.floor((availH + GAP) / (size + GAP));
70
70
-
let capacity = 0;
71
71
-
// Pattern: narrow, wide, narrow, wide... (row 0 is narrow)
72
72
-
for (let r = 0; r < maxRows; r++) {
73
73
-
capacity += r % 2 === 0 ? colsNarrow : colsWide;
74
74
-
}
75
75
-
return capacity;
76
76
-
}
77
77
-
78
78
-
function gridCapacity(size: number, availW: number, availH: number): number {
79
79
-
const cols = Math.floor((availW + GAP) / (size + GAP));
80
80
-
const rows = Math.floor((availH + GAP) / (size + GAP));
81
81
-
return cols * rows;
82
82
-
}
83
83
-
84
84
-
let computedSize = $derived.by(() => {
85
85
-
if (!containerWidth || !containerHeight || totalItems === 0) return 40;
86
86
-
87
87
-
let lo = MIN_SIZE;
88
88
-
let hi = MAX_SIZE;
89
89
-
const capacityFn = layout === 'cinema' ? cinemaCapacity : gridCapacity;
90
90
-
91
91
-
while (lo <= hi) {
92
92
-
const mid = Math.floor((lo + hi) / 2);
93
93
-
const availW = containerWidth - (layout === 'cinema' ? mid / 2 : 0);
94
94
-
const availH = containerHeight - (layout === 'cinema' ? mid / 2 : 0);
95
95
-
if (availW <= 0 || availH <= 0) {
96
96
-
hi = mid - 1;
97
97
-
continue;
98
98
-
}
99
99
-
if (capacityFn(mid, availW, availH) >= totalItems) {
100
100
-
lo = mid + 1;
101
101
-
} else {
102
102
-
hi = mid - 1;
103
103
-
}
104
104
-
}
105
105
-
106
106
-
return Math.max(MIN_SIZE, hi);
107
107
-
});
108
108
-
109
109
-
let padding = $derived(layout === 'cinema' ? computedSize / 4 : 0);
110
110
-
111
111
-
let rows = $derived.by(() => {
112
112
-
const availW = containerWidth - (layout === 'cinema' ? computedSize / 4 : 0);
113
113
-
if (availW <= 0) return [] as GitHubContributor[][];
114
114
-
115
115
-
const colsWide = Math.floor((availW + GAP) / (computedSize + GAP));
116
116
-
const colsNarrow = layout === 'cinema' ? Math.max(1, colsWide - 1) : colsWide;
117
117
-
118
118
-
// Calculate row sizes from bottom up, then reverse for incomplete row at top
119
119
-
const rowSizes: number[] = [];
120
120
-
let remaining = namedContributors.length;
121
121
-
let rowNum = 0;
122
122
-
while (remaining > 0) {
123
123
-
const cols = layout === 'cinema' && rowNum % 2 === 0 ? colsNarrow : colsWide;
124
124
-
rowSizes.push(Math.min(cols, remaining));
125
125
-
remaining -= cols;
126
126
-
rowNum++;
127
127
-
}
128
128
-
rowSizes.reverse();
129
129
-
130
130
-
// Fill rows with contributors in order
131
131
-
const result: GitHubContributor[][] = [];
132
132
-
let idx = 0;
133
133
-
for (const size of rowSizes) {
134
134
-
result.push(namedContributors.slice(idx, idx + size));
135
135
-
idx += size;
136
136
-
}
137
137
-
return result;
138
138
-
});
139
139
-
140
140
-
let textSize = $derived(
141
141
-
computedSize < 24 ? 'text-[10px]' : computedSize < 40 ? 'text-xs' : 'text-sm'
56
56
+
let gridItems = $derived(
57
57
+
namedContributors.map((c) => ({
58
58
+
imageUrl: c.avatarUrl,
59
59
+
link: `https://github.com/${c.username}`,
60
60
+
label: c.username
61
61
+
}))
142
62
);
143
143
-
144
144
-
let shapeClass = $derived(shape === 'circle' ? 'rounded-full' : 'rounded-lg');
145
63
</script>
146
64
147
147
-
<div
148
148
-
class="flex h-full w-full items-center justify-center overflow-hidden px-2"
149
149
-
bind:clientWidth={containerWidth}
150
150
-
bind:clientHeight={containerHeight}
151
151
-
>
152
152
-
{#if !owner || !repo}
153
153
-
{#if canEdit()}
65
65
+
{#if !owner || !repo}
66
66
+
{#if canEdit()}
67
67
+
<div class="flex h-full w-full items-center justify-center">
154
68
<span class="text-base-400 dark:text-base-500 accent:text-accent-300 text-sm">
155
69
Enter a repository
156
70
</span>
157
157
-
{/if}
158
158
-
{:else if totalItems > 0}
159
159
-
<div style="padding: {padding}px;">
160
160
-
<div class="flex flex-col items-center" style="gap: {GAP}px;">
161
161
-
{#each rows as row, rowIdx (rowIdx)}
162
162
-
<div class="flex justify-center" style="gap: {GAP}px;">
163
163
-
{#each row as contributor (contributor.username)}
164
164
-
<a
165
165
-
href="https://github.com/{contributor.username}"
166
166
-
target="_blank"
167
167
-
rel="noopener noreferrer"
168
168
-
class="accent:ring-accent-500 block {shapeClass} ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900"
169
169
-
>
170
170
-
{#if contributor.avatarUrl}
171
171
-
<img
172
172
-
src={contributor.avatarUrl}
173
173
-
alt={contributor.username}
174
174
-
class="{shapeClass} object-cover"
175
175
-
style="width: {computedSize}px; height: {computedSize}px;"
176
176
-
/>
177
177
-
{:else}
178
178
-
<div
179
179
-
class="bg-base-200 dark:bg-base-700 accent:bg-accent-400 flex items-center justify-center {shapeClass}"
180
180
-
style="width: {computedSize}px; height: {computedSize}px;"
181
181
-
>
182
182
-
<span
183
183
-
class="text-base-500 dark:text-base-400 accent:text-accent-100 {textSize} font-medium"
184
184
-
>
185
185
-
{contributor.username.charAt(0).toUpperCase()}
186
186
-
</span>
187
187
-
</div>
188
188
-
{/if}
189
189
-
</a>
190
190
-
{/each}
191
191
-
</div>
192
192
-
{/each}
193
193
-
</div>
194
71
</div>
195
72
{/if}
196
196
-
</div>
73
73
+
{:else}
74
74
+
<ImageGrid items={gridItems} {layout} {shape} tooltip />
75
75
+
{/if}
+103
src/lib/cards/social/NpmxLikesCard/NpmxLikesCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { Item } from '$lib/types';
3
3
+
import { onMount } from 'svelte';
4
4
+
import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context';
5
5
+
import { NpmxLikesCardDefinition } from '.';
6
6
+
import { RelativeTime } from '@foxui/time';
7
7
+
8
8
+
interface NpmxLike {
9
9
+
uri: string;
10
10
+
value: {
11
11
+
subjectRef: string;
12
12
+
createdAt: string;
13
13
+
};
14
14
+
}
15
15
+
16
16
+
let { item }: { item: Item } = $props();
17
17
+
18
18
+
const data = getAdditionalUserData();
19
19
+
// svelte-ignore state_referenced_locally
20
20
+
let feed = $state(data[item.cardType] as NpmxLike[] | undefined);
21
21
+
22
22
+
let did = getDidContext();
23
23
+
let handle = getHandleContext();
24
24
+
25
25
+
onMount(async () => {
26
26
+
if (feed) return;
27
27
+
28
28
+
feed = (await NpmxLikesCardDefinition.loadData?.([], {
29
29
+
did,
30
30
+
handle
31
31
+
})) as NpmxLike[] | undefined;
32
32
+
33
33
+
data[item.cardType] = feed;
34
34
+
});
35
35
+
36
36
+
function getPackageName(like: NpmxLike): string {
37
37
+
return like.value.subjectRef.split('/package/')[1] ?? like.value.subjectRef;
38
38
+
}
39
39
+
</script>
40
40
+
41
41
+
{#snippet likeItem(like: NpmxLike)}
42
42
+
<div class="flex w-full items-center gap-3">
43
43
+
<div
44
44
+
class="text-accent-500 accent:text-accent-950 flex size-8 shrink-0 items-center justify-center"
45
45
+
>
46
46
+
<svg
47
47
+
xmlns="http://www.w3.org/2000/svg"
48
48
+
fill="none"
49
49
+
viewBox="0 0 24 24"
50
50
+
stroke-width="1.5"
51
51
+
stroke="currentColor"
52
52
+
class="size-5"
53
53
+
>
54
54
+
<path
55
55
+
stroke-linecap="round"
56
56
+
stroke-linejoin="round"
57
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
58
+
/>
59
59
+
</svg>
60
60
+
</div>
61
61
+
<div class="min-w-0 flex-1">
62
62
+
<div class="inline-flex w-full max-w-full items-baseline justify-between gap-2">
63
63
+
<div
64
64
+
class="text-accent-500 accent:text-accent-950 min-w-0 flex-1 shrink truncate text-sm font-semibold"
65
65
+
>
66
66
+
{getPackageName(like)}
67
67
+
</div>
68
68
+
{#if like.value.createdAt}
69
69
+
<div class="text-base-500 dark:text-base-400 accent:text-white/60 shrink-0 text-xs">
70
70
+
<RelativeTime date={new Date(like.value.createdAt)} locale="en-US" /> ago
71
71
+
</div>
72
72
+
{/if}
73
73
+
</div>
74
74
+
</div>
75
75
+
</div>
76
76
+
{/snippet}
77
77
+
78
78
+
<div class="z-10 flex h-full w-full flex-col gap-3 overflow-y-scroll p-4">
79
79
+
{#if feed && feed.length > 0}
80
80
+
{#each feed as like (like.uri)}
81
81
+
<a
82
82
+
href="https://npmx.dev/package/{getPackageName(like)}"
83
83
+
target="_blank"
84
84
+
rel="noopener noreferrer"
85
85
+
class="w-full"
86
86
+
>
87
87
+
{@render likeItem(like)}
88
88
+
</a>
89
89
+
{/each}
90
90
+
{:else if feed}
91
91
+
<div
92
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
93
+
>
94
94
+
No liked packages found.
95
95
+
</div>
96
96
+
{:else}
97
97
+
<div
98
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
99
+
>
100
100
+
Loading likes...
101
101
+
</div>
102
102
+
{/if}
103
103
+
</div>
+31
src/lib/cards/social/NpmxLikesCard/index.ts
···
1
1
+
import type { CardDefinition } from '../../types';
2
2
+
import { listRecords } from '$lib/atproto';
3
3
+
import NpmxLikesCard from './NpmxLikesCard.svelte';
4
4
+
5
5
+
export const NpmxLikesCardDefinition = {
6
6
+
type: 'npmxLikes',
7
7
+
contentComponent: NpmxLikesCard,
8
8
+
createNew: (card) => {
9
9
+
card.w = 4;
10
10
+
card.mobileW = 8;
11
11
+
card.h = 3;
12
12
+
card.mobileH = 6;
13
13
+
},
14
14
+
loadData: async (items, { did }) => {
15
15
+
const data = await listRecords({
16
16
+
did,
17
17
+
collection: 'dev.npmx.feed.like',
18
18
+
limit: 99
19
19
+
});
20
20
+
21
21
+
return data;
22
22
+
},
23
23
+
minW: 4,
24
24
+
canHaveLabel: true,
25
25
+
26
26
+
keywords: ['npm', 'package', 'npmx', 'likes'],
27
27
+
name: 'npmx Likes',
28
28
+
29
29
+
groups: ['Social'],
30
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
31
+
} as CardDefinition & { type: 'npmxLikes' };
+116
src/lib/cards/social/NpmxLikesLeaderboardCard/NpmxLikesLeaderboardCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { Item } from '$lib/types';
3
3
+
import { onMount } from 'svelte';
4
4
+
import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context';
5
5
+
import { NpmxLikesLeaderboardCardDefinition } from '.';
6
6
+
7
7
+
interface LeaderboardEntry {
8
8
+
subjectRef: string;
9
9
+
totalLikes: number;
10
10
+
}
11
11
+
12
12
+
interface LeaderboardData {
13
13
+
totalLikes: number;
14
14
+
totalUniqueLikers: number;
15
15
+
leaderBoard: LeaderboardEntry[];
16
16
+
}
17
17
+
18
18
+
let { item }: { item: Item } = $props();
19
19
+
20
20
+
const data = getAdditionalUserData();
21
21
+
// svelte-ignore state_referenced_locally
22
22
+
let leaderboard = $state(data[item.cardType] as LeaderboardData | undefined);
23
23
+
24
24
+
let did = getDidContext();
25
25
+
let handle = getHandleContext();
26
26
+
27
27
+
onMount(async () => {
28
28
+
if (leaderboard) return;
29
29
+
30
30
+
leaderboard = (await NpmxLikesLeaderboardCardDefinition.loadData?.([], {
31
31
+
did,
32
32
+
handle
33
33
+
})) as LeaderboardData | undefined;
34
34
+
35
35
+
data[item.cardType] = leaderboard;
36
36
+
});
37
37
+
38
38
+
function getPackageName(entry: LeaderboardEntry): string {
39
39
+
return entry.subjectRef.split('/package/')[1] ?? entry.subjectRef;
40
40
+
}
41
41
+
</script>
42
42
+
43
43
+
{#snippet leaderboardRow(entry: LeaderboardEntry, index: number)}
44
44
+
<div
45
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
46
+
>
47
47
+
<div
48
48
+
class="text-base-600 dark:text-base-400 accent:text-white/60 w-6 shrink-0 text-right text-xs font-medium"
49
49
+
>
50
50
+
#{index + 1}
51
51
+
</div>
52
52
+
<div class="min-w-0 flex-1">
53
53
+
<div class="inline-flex w-full max-w-full items-center justify-between gap-2">
54
54
+
<div
55
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
56
+
>
57
57
+
{getPackageName(entry)}
58
58
+
</div>
59
59
+
<div
60
60
+
class="text-base-500 dark:text-base-400 accent:text-white/60 flex shrink-0 items-center gap-1 text-xs"
61
61
+
>
62
62
+
<svg
63
63
+
xmlns="http://www.w3.org/2000/svg"
64
64
+
viewBox="0 0 24 24"
65
65
+
fill="currentColor"
66
66
+
class="accent:text-accent-200 text-accent-400 size-3.5"
67
67
+
>
68
68
+
<path
69
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
70
+
/>
71
71
+
</svg>
72
72
+
{entry.totalLikes}
73
73
+
</div>
74
74
+
</div>
75
75
+
</div>
76
76
+
</div>
77
77
+
{/snippet}
78
78
+
79
79
+
<div class="z-10 flex h-full w-full flex-col overflow-hidden">
80
80
+
{#if leaderboard && leaderboard.leaderBoard.length > 0}
81
81
+
<div class="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-4 pb-10">
82
82
+
{#each leaderboard.leaderBoard as entry, index (entry.subjectRef)}
83
83
+
<a
84
84
+
href="https://npmx.dev/package/{getPackageName(entry)}"
85
85
+
target="_blank"
86
86
+
rel="noopener noreferrer"
87
87
+
class="w-full"
88
88
+
>
89
89
+
{@render leaderboardRow(entry, index)}
90
90
+
</a>
91
91
+
{/each}
92
92
+
</div>
93
93
+
<div
94
94
+
class="from-base-200 dark:from-base-950 accent:from-accent-500 pointer-events-none absolute inset-x-0 bottom-0 z-10 h-12 bg-linear-to-t from-40% to-transparent"
95
95
+
></div>
96
96
+
<div
97
97
+
class="text-base-500 dark:text-base-400 accent:text-white/60 bg-base-200 dark:bg-base-950/50 accent:bg-accent-500/20 relative z-10 flex shrink-0 items-center justify-center gap-3 px-4 pb-3 text-xs"
98
98
+
>
99
99
+
<span>{leaderboard.totalLikes} likes</span>
100
100
+
<span class="text-base-300 dark:text-base-600 accent:text-white/20">·</span>
101
101
+
<span>{leaderboard.totalUniqueLikers} unique likers</span>
102
102
+
</div>
103
103
+
{:else if leaderboard}
104
104
+
<div
105
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
106
+
>
107
107
+
No leaderboard data.
108
108
+
</div>
109
109
+
{:else}
110
110
+
<div
111
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
112
+
>
113
113
+
Loading leaderboard...
114
114
+
</div>
115
115
+
{/if}
116
116
+
</div>
+26
src/lib/cards/social/NpmxLikesLeaderboardCard/index.ts
···
1
1
+
import type { CardDefinition } from '../../types';
2
2
+
import NpmxLikesLeaderboardCard from './NpmxLikesLeaderboardCard.svelte';
3
3
+
4
4
+
export const NpmxLikesLeaderboardCardDefinition = {
5
5
+
type: 'npmxLikesLeaderboard',
6
6
+
contentComponent: NpmxLikesLeaderboardCard,
7
7
+
createNew: (card) => {
8
8
+
card.w = 4;
9
9
+
card.mobileW = 8;
10
10
+
card.h = 4;
11
11
+
card.mobileH = 6;
12
12
+
},
13
13
+
loadData: async () => {
14
14
+
const res = await fetch('https://blento.app/api/npmx-leaderboard');
15
15
+
const data = await res.json();
16
16
+
return data;
17
17
+
},
18
18
+
minW: 3,
19
19
+
canHaveLabel: true,
20
20
+
21
21
+
keywords: ['npm', 'package', 'npmx', 'likes', 'leaderboard', 'ranking'],
22
22
+
name: 'npmx Likes Leaderboard',
23
23
+
24
24
+
//groups: ['Social'],
25
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
26
+
} as CardDefinition & { type: 'npmxLikesLeaderboard' };
+4
-2
src/lib/cards/special/UpdatedBlentos/index.ts
···
46
46
47
47
let result = [...(await Promise.all(profiles)), ...existingUsersArray];
48
48
49
49
-
result = result.filter((v) => v && v.handle !== 'handle.invalid');
49
49
+
result = result.filter(
50
50
+
(v) => v && v.handle !== 'handle.invalid' && !v.handle.endsWith('.pds.rip')
51
51
+
);
50
52
51
53
if (cache) {
52
54
await cache?.put('updatedBlentos', JSON.stringify(result));
53
55
}
54
54
-
return JSON.parse(JSON.stringify(result));
56
56
+
return JSON.parse(JSON.stringify(result.slice(0, 20)));
55
57
} catch (error) {
56
58
console.error('error fetching updated blentos', error);
57
59
return [];
+86
-10
src/lib/cards/visual/FluidTextCard/FluidTextCard.svelte
···
1
1
<script lang="ts">
2
2
-
import { colorToHue, getCSSVar, getHexOfCardColor } from '../../helper';
2
2
+
import { colorToHue, getHexCSSVar, getHexOfCardColor } from '../../helper';
3
3
import type { ContentComponentProps } from '../../types';
4
4
import { onMount, onDestroy, tick } from 'svelte';
5
5
let { item }: ContentComponentProps = $props();
···
7
7
let container: HTMLDivElement;
8
8
let fluidCanvas: HTMLCanvasElement;
9
9
let maskCanvas: HTMLCanvasElement;
10
10
+
let shadowCanvas: HTMLCanvasElement;
10
11
let animationId: number;
11
12
let splatIntervalId: ReturnType<typeof setInterval>;
12
13
let maskDrawRaf = 0;
13
14
let maskReady = false;
14
15
let isInitialized = $state(false);
15
16
let resizeObserver: ResizeObserver | null = null;
17
17
+
let themeObserver: MutationObserver | null = null;
16
18
17
19
// Pure hash function for shader keyword caching
18
20
function hashCode(s: string) {
···
122
124
if (width === 0 || height === 0) return;
123
125
124
126
const dpr = window.devicePixelRatio || 1;
127
127
+
const isDark = document.documentElement.classList.contains('dark');
128
128
+
129
129
+
// Draw shadow behind fluid (light mode only, transparent only)
130
130
+
if (shadowCanvas && item.color === 'transparent') {
131
131
+
shadowCanvas.width = width * dpr;
132
132
+
shadowCanvas.height = height * dpr;
133
133
+
const shadowCtx = shadowCanvas.getContext('2d')!;
134
134
+
shadowCtx.setTransform(1, 0, 0, 1, 0, 0);
135
135
+
shadowCtx.clearRect(0, 0, shadowCanvas.width, shadowCanvas.height);
136
136
+
shadowCtx.scale(dpr, dpr);
137
137
+
138
138
+
const textFontSize = Math.round(width * fontSize);
139
139
+
shadowCtx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`;
140
140
+
shadowCtx.textAlign = 'center';
141
141
+
142
142
+
const metrics = shadowCtx.measureText(text);
143
143
+
let textY = height / 2;
144
144
+
if (
145
145
+
metrics.actualBoundingBoxAscent !== undefined &&
146
146
+
metrics.actualBoundingBoxDescent !== undefined
147
147
+
) {
148
148
+
shadowCtx.textBaseline = 'alphabetic';
149
149
+
textY = (height + metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2;
150
150
+
} else {
151
151
+
shadowCtx.textBaseline = 'middle';
152
152
+
}
153
153
+
154
154
+
// Draw darkened text shape behind fluid
155
155
+
shadowCtx.fillStyle = getHexCSSVar(isDark ? '--color-base-950' : '--color-base-200');
156
156
+
shadowCtx.fillText(text, width / 2, textY);
157
157
+
} else if (shadowCanvas) {
158
158
+
// Clear shadow canvas when not transparent
159
159
+
shadowCanvas.width = 1;
160
160
+
shadowCanvas.height = 1;
161
161
+
}
125
162
126
163
maskCanvas.width = width * dpr;
127
164
maskCanvas.height = height * dpr;
···
132
169
ctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
133
170
ctx.scale(dpr, dpr);
134
171
135
135
-
//const color = getCSSVar('--color-base-900');
136
136
-
137
137
-
ctx.fillStyle = 'black';
172
172
+
const bgColor =
173
173
+
item.color === 'transparent'
174
174
+
? getHexCSSVar(isDark ? '--color-base-900' : '--color-base-50')
175
175
+
: 'black';
176
176
+
ctx.fillStyle = bgColor;
138
177
ctx.fillRect(0, 0, width, height);
139
178
140
179
// Font size as percentage of container width
141
180
const textFontSize = Math.round(width * fontSize);
142
181
ctx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`;
143
182
144
144
-
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
145
145
-
ctx.lineWidth = 2;
183
183
+
ctx.lineWidth = 3;
146
184
ctx.textAlign = 'center';
147
185
148
186
const metrics = ctx.measureText(text);
···
157
195
ctx.textBaseline = 'middle';
158
196
}
159
197
160
160
-
ctx.strokeText(text, width / 2, textY);
198
198
+
if (item.color === 'transparent') {
199
199
+
// Partially cut out the stroke area so fluid shows through
200
200
+
ctx.globalCompositeOperation = 'destination-out';
201
201
+
ctx.globalAlpha = 0.7;
202
202
+
ctx.strokeStyle = 'white';
203
203
+
ctx.strokeText(text, width / 2, textY);
204
204
+
ctx.globalAlpha = 1;
205
205
+
ctx.globalCompositeOperation = 'source-over';
206
206
+
207
207
+
// Add overlay: brighten in dark mode, darken in light mode
208
208
+
ctx.strokeStyle = isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)';
209
209
+
ctx.strokeText(text, width / 2, textY);
210
210
+
} else {
211
211
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
212
212
+
ctx.strokeText(text, width / 2, textY);
213
213
+
}
214
214
+
161
215
ctx.globalCompositeOperation = 'destination-out';
162
216
ctx.fillText(text, width / 2, textY);
163
217
ctx.globalCompositeOperation = 'source-over';
···
214
268
if (isInitialized) scheduleMaskDraw();
215
269
});
216
270
}
271
271
+
272
272
+
// Watch for dark mode changes to redraw mask with correct background
273
273
+
if (item.color === 'transparent') {
274
274
+
themeObserver = new MutationObserver(() => {
275
275
+
if (isInitialized) scheduleMaskDraw();
276
276
+
});
277
277
+
themeObserver.observe(document.documentElement, {
278
278
+
attributes: true,
279
279
+
attributeFilter: ['class']
280
280
+
});
281
281
+
}
217
282
});
218
283
219
284
onDestroy(() => {
···
221
286
if (splatIntervalId) clearInterval(splatIntervalId);
222
287
if (maskDrawRaf) cancelAnimationFrame(maskDrawRaf);
223
288
if (resizeObserver) resizeObserver.disconnect();
289
289
+
if (themeObserver) themeObserver.disconnect();
224
290
});
225
291
226
292
function initFluidSimulation(startHue: number, endHue: number) {
···
246
312
COLOR_UPDATE_SPEED: 10,
247
313
PAUSED: false,
248
314
BACK_COLOR: { r: 0, g: 0, b: 0 },
249
249
-
TRANSPARENT: false,
315
315
+
TRANSPARENT: item.color === 'transparent',
250
316
BLOOM: false,
251
317
BLOOM_ITERATIONS: 8,
252
318
BLOOM_RESOLUTION: 256,
···
1701
1767
}
1702
1768
</script>
1703
1769
1704
1704
-
<div bind:this={container} class="relative h-full w-full overflow-hidden bg-black">
1705
1705
-
<canvas bind:this={fluidCanvas} class="absolute h-full w-full"></canvas>
1770
1770
+
<div
1771
1771
+
bind:this={container}
1772
1772
+
class="relative h-full w-full overflow-hidden rounded-[inherit] {item.color === 'transparent'
1773
1773
+
? 'bg-base-50 dark:bg-base-900'
1774
1774
+
: 'bg-black'}"
1775
1775
+
>
1776
1776
+
<canvas
1777
1777
+
bind:this={shadowCanvas}
1778
1778
+
class="absolute inset-0.5 h-[calc(100%-4px)] w-[calc(100%-4px)] rounded-[inherit]"
1779
1779
+
></canvas>
1780
1780
+
<canvas bind:this={fluidCanvas} class="absolute inset-0.5 h-[calc(100%-4px)] w-[calc(100%-4px)]"
1781
1781
+
></canvas>
1706
1782
<canvas bind:this={maskCanvas} class="absolute h-full w-full"></canvas>
1707
1783
</div>
-277
src/lib/cards/visual/RecordVisualizerCard/RecordVisualizerCard.svelte
···
1
1
-
<script lang="ts">
2
2
-
import { onMount } from 'svelte';
3
3
-
import { browser } from '$app/environment';
4
4
-
import * as PIXI from 'pixi.js';
5
5
-
import type { ContentComponentProps } from '../../types';
6
6
-
7
7
-
let { item }: ContentComponentProps = $props();
8
8
-
9
9
-
type RecordVisualizerCardData = {
10
10
-
emoji?: string;
11
11
-
collection?: string;
12
12
-
direction?: 'down' | 'up';
13
13
-
speed?: number;
14
14
-
};
15
15
-
16
16
-
let cardData = $derived(item.cardData as RecordVisualizerCardData);
17
17
-
18
18
-
let emoji = $derived(cardData.emoji || '๐');
19
19
-
let collection = $derived(cardData.collection || 'app.bsky.feed.like');
20
20
-
let direction = $derived(cardData.direction || 'down');
21
21
-
let speed = $derived(Math.max(0.5, Math.min(2, cardData.speed || 1)));
22
22
-
23
23
-
let containerEl: HTMLDivElement | null = null;
24
24
-
let canvasEl: HTMLCanvasElement | null = null;
25
25
-
let app: PIXI.Application | null = null;
26
26
-
let ws: WebSocket | null = null;
27
27
-
let prevCollection = $state<string | null>(null);
28
28
-
let prevEmoji = $state<string | null>(null);
29
29
-
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
30
30
-
31
31
-
const RECONNECT_DEBOUNCE = 1000;
32
32
-
const MAX_PARTICLES = 10000;
33
33
-
34
34
-
// Particle system
35
35
-
interface ParticleSprite extends PIXI.Sprite {
36
36
-
speedX: number;
37
37
-
speedY: number;
38
38
-
age: number;
39
39
-
maxAge: number;
40
40
-
initialSize: number;
41
41
-
}
42
42
-
43
43
-
let particles: ParticleSprite[] = [];
44
44
-
let particlePool: ParticleSprite[] = [];
45
45
-
let particleContainer: PIXI.Container | null = null;
46
46
-
let emojiTexture: PIXI.Texture | null = null;
47
47
-
48
48
-
function createEmojiTexture(emojiChar: string): PIXI.Texture {
49
49
-
const canvas = document.createElement('canvas');
50
50
-
const size = 64;
51
51
-
canvas.width = size;
52
52
-
canvas.height = size;
53
53
-
const ctx = canvas.getContext('2d')!;
54
54
-
ctx.font = `${size * 0.8}px serif`;
55
55
-
ctx.textAlign = 'center';
56
56
-
ctx.textBaseline = 'middle';
57
57
-
ctx.fillText(emojiChar, size / 2, size / 2);
58
58
-
return PIXI.Texture.from(canvas);
59
59
-
}
60
60
-
61
61
-
function spawnParticle() {
62
62
-
if (!app || !particleContainer || !emojiTexture) return;
63
63
-
64
64
-
let particle: ParticleSprite;
65
65
-
if (particlePool.length > 0) {
66
66
-
particle = particlePool.pop()!;
67
67
-
particle.texture = emojiTexture;
68
68
-
} else if (particles.length < MAX_PARTICLES) {
69
69
-
particle = new PIXI.Sprite(emojiTexture) as ParticleSprite;
70
70
-
particle.anchor.set(0.5, 0.5);
71
71
-
particleContainer.addChild(particle);
72
72
-
} else {
73
73
-
return;
74
74
-
}
75
75
-
76
76
-
const w = app.screen.width;
77
77
-
const h = app.screen.height;
78
78
-
79
79
-
// Parallax: random scale from 0.3 (far/small) to 1.0 (near/large)
80
80
-
const scale = Math.random() * 0.7 + 0.3;
81
81
-
const baseSize = (Math.random() * 30 + 15) * scale;
82
82
-
83
83
-
particle.visible = true;
84
84
-
particle.x = Math.random() * w;
85
85
-
particle.y = direction === 'down' ? -baseSize : h + baseSize;
86
86
-
particle.width = particle.height = baseSize;
87
87
-
particle.alpha = 0.4 + scale * 0.6;
88
88
-
particle.rotation = (Math.random() - 0.5) * 0.3;
89
89
-
particle.zIndex = Math.round(scale * 10);
90
90
-
91
91
-
// Speed based on scale (smaller = slower for parallax)
92
92
-
const baseSpeed = 80 * speed;
93
93
-
const effectiveSpeed = baseSpeed * scale;
94
94
-
particle.speedX = (Math.random() - 0.5) * 20;
95
95
-
particle.speedY = direction === 'down' ? effectiveSpeed : -effectiveSpeed;
96
96
-
97
97
-
particle.age = 0;
98
98
-
particle.maxAge = (h + baseSize * 2) / effectiveSpeed + 2;
99
99
-
particle.initialSize = baseSize;
100
100
-
101
101
-
particles.push(particle);
102
102
-
}
103
103
-
104
104
-
function removeParticle(particle: ParticleSprite) {
105
105
-
const index = particles.indexOf(particle);
106
106
-
if (index !== -1) {
107
107
-
particle.visible = false;
108
108
-
particles.splice(index, 1);
109
109
-
particlePool.push(particle);
110
110
-
}
111
111
-
}
112
112
-
113
113
-
function updateParticles(deltaTime: number) {
114
114
-
if (!app) return;
115
115
-
const h = app.screen.height;
116
116
-
117
117
-
for (let i = particles.length - 1; i >= 0; i--) {
118
118
-
const particle = particles[i];
119
119
-
particle.x += particle.speedX * deltaTime;
120
120
-
particle.y += particle.speedY * deltaTime;
121
121
-
particle.age += deltaTime;
122
122
-
123
123
-
// Remove if off screen or too old
124
124
-
const isOffScreen =
125
125
-
direction === 'down'
126
126
-
? particle.y > h + particle.initialSize
127
127
-
: particle.y < -particle.initialSize;
128
128
-
129
129
-
if (particle.age >= particle.maxAge || isOffScreen) {
130
130
-
removeParticle(particle);
131
131
-
}
132
132
-
}
133
133
-
}
134
134
-
135
135
-
async function initPixi() {
136
136
-
if (!browser || !containerEl || !canvasEl) return;
137
137
-
138
138
-
// Clean up existing app
139
139
-
if (app) {
140
140
-
app.destroy(true, { children: true, texture: true });
141
141
-
app = null;
142
142
-
}
143
143
-
144
144
-
particles = [];
145
145
-
particlePool = [];
146
146
-
147
147
-
app = new PIXI.Application();
148
148
-
await app.init({
149
149
-
canvas: canvasEl,
150
150
-
width: containerEl.clientWidth,
151
151
-
height: containerEl.clientHeight,
152
152
-
backgroundAlpha: 0,
153
153
-
antialias: true,
154
154
-
resolution: window.devicePixelRatio || 1,
155
155
-
autoDensity: true
156
156
-
});
157
157
-
158
158
-
particleContainer = new PIXI.Container();
159
159
-
particleContainer.sortableChildren = true;
160
160
-
app.stage.addChild(particleContainer);
161
161
-
162
162
-
emojiTexture = createEmojiTexture(emoji);
163
163
-
164
164
-
app.ticker.add((ticker) => {
165
165
-
updateParticles(ticker.deltaMS * 0.001);
166
166
-
});
167
167
-
168
168
-
// Handle resize
169
169
-
const resizeObserver = new ResizeObserver(() => {
170
170
-
if (app && containerEl) {
171
171
-
app.renderer.resize(containerEl.clientWidth, containerEl.clientHeight);
172
172
-
}
173
173
-
});
174
174
-
resizeObserver.observe(containerEl);
175
175
-
176
176
-
return () => {
177
177
-
resizeObserver.disconnect();
178
178
-
};
179
179
-
}
180
180
-
181
181
-
function connectWebSocket() {
182
182
-
if (!browser) return;
183
183
-
184
184
-
if (ws) {
185
185
-
ws.close();
186
186
-
}
187
187
-
188
188
-
const wsUrl = `wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=${encodeURIComponent(collection)}`;
189
189
-
190
190
-
try {
191
191
-
ws = new WebSocket(wsUrl);
192
192
-
193
193
-
ws.onmessage = (event) => {
194
194
-
try {
195
195
-
const data = JSON.parse(event.data);
196
196
-
if (data.kind === 'commit' && data.commit?.operation === 'create') {
197
197
-
spawnParticle();
198
198
-
}
199
199
-
} catch {
200
200
-
// Ignore parse errors
201
201
-
}
202
202
-
};
203
203
-
204
204
-
ws.onerror = () => {
205
205
-
// Silently handle errors
206
206
-
};
207
207
-
208
208
-
ws.onclose = () => {
209
209
-
setTimeout(() => {
210
210
-
if (containerEl) {
211
211
-
connectWebSocket();
212
212
-
}
213
213
-
}, 5000);
214
214
-
};
215
215
-
} catch {
216
216
-
// Failed to create WebSocket
217
217
-
}
218
218
-
}
219
219
-
220
220
-
onMount(() => {
221
221
-
let cleanupResize: (() => void) | undefined;
222
222
-
223
223
-
initPixi().then((cleanup) => {
224
224
-
cleanupResize = cleanup;
225
225
-
});
226
226
-
connectWebSocket();
227
227
-
228
228
-
return () => {
229
229
-
if (ws) {
230
230
-
ws.close();
231
231
-
ws = null;
232
232
-
}
233
233
-
if (reconnectTimeout) {
234
234
-
clearTimeout(reconnectTimeout);
235
235
-
}
236
236
-
if (app) {
237
237
-
app.destroy(true, { children: true, texture: true });
238
238
-
app = null;
239
239
-
}
240
240
-
cleanupResize?.();
241
241
-
};
242
242
-
});
243
243
-
244
244
-
// Reconnect when collection changes (debounced)
245
245
-
$effect(() => {
246
246
-
const currentCollection = collection;
247
247
-
248
248
-
if (prevCollection !== null && prevCollection !== currentCollection) {
249
249
-
if (reconnectTimeout) {
250
250
-
clearTimeout(reconnectTimeout);
251
251
-
}
252
252
-
reconnectTimeout = setTimeout(() => {
253
253
-
if (ws) {
254
254
-
ws.close();
255
255
-
}
256
256
-
connectWebSocket();
257
257
-
}, RECONNECT_DEBOUNCE);
258
258
-
}
259
259
-
260
260
-
prevCollection = currentCollection;
261
261
-
});
262
262
-
263
263
-
// Update emoji texture when emoji changes
264
264
-
$effect(() => {
265
265
-
const currentEmoji = emoji;
266
266
-
267
267
-
if (prevEmoji !== null && prevEmoji !== currentEmoji && app) {
268
268
-
emojiTexture = createEmojiTexture(currentEmoji);
269
269
-
}
270
270
-
271
271
-
prevEmoji = currentEmoji;
272
272
-
});
273
273
-
</script>
274
274
-
275
275
-
<div bind:this={containerEl} class="h-full w-full overflow-hidden">
276
276
-
<canvas bind:this={canvasEl} class="h-full w-full"></canvas>
277
277
-
</div>
-71
src/lib/cards/visual/RecordVisualizerCard/RecordVisualizerSettings.svelte
···
1
1
-
<script lang="ts">
2
2
-
import type { Item } from '$lib/types';
3
3
-
import type { SettingsComponentProps } from '../../types';
4
4
-
import { Input, Label } from '@foxui/core';
5
5
-
6
6
-
let { item = $bindable<Item>() }: SettingsComponentProps = $props();
7
7
-
8
8
-
type RecordVisualizerCardData = {
9
9
-
emoji?: string;
10
10
-
collection?: string;
11
11
-
direction?: 'down' | 'up';
12
12
-
speed?: number;
13
13
-
};
14
14
-
15
15
-
let cardData = $derived(item.cardData as RecordVisualizerCardData);
16
16
-
17
17
-
// Initialize defaults if not set
18
18
-
if (item.cardData.emoji === undefined) {
19
19
-
item.cardData.emoji = '๐';
20
20
-
}
21
21
-
if (item.cardData.collection === undefined) {
22
22
-
item.cardData.collection = 'app.bsky.feed.like';
23
23
-
}
24
24
-
if (item.cardData.direction === undefined) {
25
25
-
item.cardData.direction = 'down';
26
26
-
}
27
27
-
if (item.cardData.speed === undefined) {
28
28
-
item.cardData.speed = 1;
29
29
-
}
30
30
-
</script>
31
31
-
32
32
-
<div class="flex flex-col gap-3">
33
33
-
<div>
34
34
-
<Label class="mb-1 text-xs">Emoji</Label>
35
35
-
<Input bind:value={item.cardData.emoji} placeholder="๐" class="w-full" />
36
36
-
</div>
37
37
-
38
38
-
<div>
39
39
-
<Label class="mb-1 text-xs">Collection</Label>
40
40
-
<Input bind:value={item.cardData.collection} placeholder="app.bsky.feed.like" class="w-full" />
41
41
-
</div>
42
42
-
43
43
-
<div>
44
44
-
<Label class="mb-1 text-xs">Direction</Label>
45
45
-
<select
46
46
-
value={cardData.direction ?? 'down'}
47
47
-
onchange={(e) => {
48
48
-
item.cardData.direction = (e.target as HTMLSelectElement).value;
49
49
-
}}
50
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
51
-
>
52
52
-
<option value="down">Down</option>
53
53
-
<option value="up">Up</option>
54
54
-
</select>
55
55
-
</div>
56
56
-
57
57
-
<div>
58
58
-
<Label class="mb-1 text-xs">Speed ({cardData.speed?.toFixed(1) ?? '1.0'}x)</Label>
59
59
-
<input
60
60
-
type="range"
61
61
-
min="0.5"
62
62
-
max="2"
63
63
-
step="0.1"
64
64
-
value={cardData.speed ?? 1}
65
65
-
oninput={(e) => {
66
66
-
item.cardData.speed = parseFloat(e.currentTarget.value);
67
67
-
}}
68
68
-
class="bg-base-200 dark:bg-base-700 h-2 w-full cursor-pointer appearance-none rounded-lg"
69
69
-
/>
70
70
-
</div>
71
71
-
</div>
-30
src/lib/cards/visual/RecordVisualizerCard/index.ts
···
1
1
-
import type { CardDefinition } from '../../types';
2
2
-
import RecordVisualizerCard from './RecordVisualizerCard.svelte';
3
3
-
import RecordVisualizerSettings from './RecordVisualizerSettings.svelte';
4
4
-
5
5
-
export const RecordVisualizerCardDefinition = {
6
6
-
type: 'record-visualizer',
7
7
-
contentComponent: RecordVisualizerCard,
8
8
-
createNew: (card) => {
9
9
-
card.cardType = 'record-visualizer';
10
10
-
card.cardData = {
11
11
-
emoji: '๐',
12
12
-
collection: 'app.bsky.feed.like',
13
13
-
direction: 'down',
14
14
-
speed: 1
15
15
-
};
16
16
-
card.w = 2;
17
17
-
card.h = 2;
18
18
-
card.mobileW = 4;
19
19
-
card.mobileH = 4;
20
20
-
},
21
21
-
settingsComponent: RecordVisualizerSettings,
22
22
-
minW: 1,
23
23
-
minH: 2,
24
24
-
canHaveLabel: true,
25
25
-
26
26
-
keywords: ['emoji', 'particles', 'animation', 'bluesky', 'atproto', 'live', 'realtime', 'stream'],
27
27
-
groups: ['Visual'],
28
28
-
name: 'Record Visualizer',
29
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
30
-
} as CardDefinition & { type: 'record-visualizer' };
+180
src/lib/components/ImageGrid.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { Tooltip } from 'bits-ui';
3
3
+
4
4
+
export type ImageGridItem = {
5
5
+
imageUrl: string | null;
6
6
+
link: string;
7
7
+
label: string;
8
8
+
};
9
9
+
10
10
+
let {
11
11
+
items,
12
12
+
layout = 'grid',
13
13
+
shape = 'square',
14
14
+
tooltip = false
15
15
+
}: {
16
16
+
items: ImageGridItem[];
17
17
+
layout?: 'grid' | 'cinema';
18
18
+
shape?: 'square' | 'circle';
19
19
+
tooltip?: boolean;
20
20
+
} = $props();
21
21
+
22
22
+
let containerWidth = $state(0);
23
23
+
let containerHeight = $state(0);
24
24
+
25
25
+
let totalItems = $derived(items.length);
26
26
+
27
27
+
const GAP = 6;
28
28
+
const MIN_SIZE = 16;
29
29
+
const MAX_SIZE = 120;
30
30
+
31
31
+
function cinemaCapacity(size: number, availW: number, availH: number): number {
32
32
+
const colsWide = Math.floor((availW + GAP) / (size + GAP));
33
33
+
if (colsWide < 1) return 0;
34
34
+
const colsNarrow = Math.max(1, colsWide - 1);
35
35
+
const maxRows = Math.floor((availH + GAP) / (size + GAP));
36
36
+
let capacity = 0;
37
37
+
for (let r = 0; r < maxRows; r++) {
38
38
+
capacity += r % 2 === 0 ? colsNarrow : colsWide;
39
39
+
}
40
40
+
return capacity;
41
41
+
}
42
42
+
43
43
+
function gridCapacity(size: number, availW: number, availH: number): number {
44
44
+
const cols = Math.floor((availW + GAP) / (size + GAP));
45
45
+
const rows = Math.floor((availH + GAP) / (size + GAP));
46
46
+
return cols * rows;
47
47
+
}
48
48
+
49
49
+
let computedSize = $derived.by(() => {
50
50
+
if (!containerWidth || !containerHeight || totalItems === 0) return 40;
51
51
+
52
52
+
let lo = MIN_SIZE;
53
53
+
let hi = MAX_SIZE;
54
54
+
const capacityFn = layout === 'cinema' ? cinemaCapacity : gridCapacity;
55
55
+
56
56
+
while (lo <= hi) {
57
57
+
const mid = Math.floor((lo + hi) / 2);
58
58
+
const availW = containerWidth - (layout === 'cinema' ? mid / 2 : 0);
59
59
+
const availH = containerHeight - (layout === 'cinema' ? mid / 2 : 0);
60
60
+
if (availW <= 0 || availH <= 0) {
61
61
+
hi = mid - 1;
62
62
+
continue;
63
63
+
}
64
64
+
if (capacityFn(mid, availW, availH) >= totalItems) {
65
65
+
lo = mid + 1;
66
66
+
} else {
67
67
+
hi = mid - 1;
68
68
+
}
69
69
+
}
70
70
+
71
71
+
return Math.max(MIN_SIZE, hi);
72
72
+
});
73
73
+
74
74
+
let padding = $derived(layout === 'cinema' ? computedSize / 4 : 0);
75
75
+
76
76
+
let rows = $derived.by(() => {
77
77
+
const availW = containerWidth - (layout === 'cinema' ? computedSize / 4 : 0);
78
78
+
if (availW <= 0) return [] as ImageGridItem[][];
79
79
+
80
80
+
const colsWide = Math.floor((availW + GAP) / (computedSize + GAP));
81
81
+
const colsNarrow = layout === 'cinema' ? Math.max(1, colsWide - 1) : colsWide;
82
82
+
83
83
+
const rowSizes: number[] = [];
84
84
+
let remaining = items.length;
85
85
+
let rowNum = 0;
86
86
+
while (remaining > 0) {
87
87
+
const cols = layout === 'cinema' && rowNum % 2 === 0 ? colsNarrow : colsWide;
88
88
+
rowSizes.push(Math.min(cols, remaining));
89
89
+
remaining -= cols;
90
90
+
rowNum++;
91
91
+
}
92
92
+
rowSizes.reverse();
93
93
+
94
94
+
const result: ImageGridItem[][] = [];
95
95
+
let idx = 0;
96
96
+
for (const size of rowSizes) {
97
97
+
result.push(items.slice(idx, idx + size));
98
98
+
idx += size;
99
99
+
}
100
100
+
return result;
101
101
+
});
102
102
+
103
103
+
let textSize = $derived(
104
104
+
computedSize < 24 ? 'text-[10px]' : computedSize < 40 ? 'text-xs' : 'text-sm'
105
105
+
);
106
106
+
107
107
+
let shapeClass = $derived(shape === 'circle' ? 'rounded-full' : 'rounded-lg');
108
108
+
</script>
109
109
+
110
110
+
{#snippet gridItem(item: ImageGridItem)}
111
111
+
{#if item.imageUrl}
112
112
+
<img
113
113
+
src={item.imageUrl}
114
114
+
alt={item.label}
115
115
+
class="{shapeClass} object-cover"
116
116
+
style="width: {computedSize}px; height: {computedSize}px;"
117
117
+
/>
118
118
+
{:else}
119
119
+
<div
120
120
+
class="bg-base-200 dark:bg-base-700 accent:bg-accent-400 flex items-center justify-center {shapeClass}"
121
121
+
style="width: {computedSize}px; height: {computedSize}px;"
122
122
+
>
123
123
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-100 {textSize} font-medium">
124
124
+
{item.label.charAt(0).toUpperCase()}
125
125
+
</span>
126
126
+
</div>
127
127
+
{/if}
128
128
+
{/snippet}
129
129
+
130
130
+
<div
131
131
+
class="flex h-full w-full items-center justify-center overflow-hidden px-2"
132
132
+
bind:clientWidth={containerWidth}
133
133
+
bind:clientHeight={containerHeight}
134
134
+
>
135
135
+
{#if totalItems > 0}
136
136
+
<div style="padding: {padding}px;">
137
137
+
<div class="flex flex-col items-center" style="gap: {GAP}px;">
138
138
+
{#each rows as row, rowIdx (rowIdx)}
139
139
+
<div class="flex justify-center" style="gap: {GAP}px;">
140
140
+
{#each row as item (item.link)}
141
141
+
{#if tooltip}
142
142
+
<Tooltip.Root>
143
143
+
<Tooltip.Trigger>
144
144
+
<a
145
145
+
href={item.link}
146
146
+
target="_blank"
147
147
+
rel="noopener noreferrer"
148
148
+
class="accent:ring-accent-500 block {shapeClass} ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900"
149
149
+
>
150
150
+
{@render gridItem(item)}
151
151
+
</a>
152
152
+
</Tooltip.Trigger>
153
153
+
<Tooltip.Portal>
154
154
+
<Tooltip.Content
155
155
+
side="top"
156
156
+
sideOffset={4}
157
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
158
+
>
159
159
+
{item.label}
160
160
+
</Tooltip.Content>
161
161
+
</Tooltip.Portal>
162
162
+
</Tooltip.Root>
163
163
+
{:else}
164
164
+
<a
165
165
+
href={item.link}
166
166
+
target="_blank"
167
167
+
rel="noopener noreferrer"
168
168
+
class="accent:ring-accent-500 block {shapeClass} ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900"
169
169
+
title={item.label}
170
170
+
>
171
171
+
{@render gridItem(item)}
172
172
+
</a>
173
173
+
{/if}
174
174
+
{/each}
175
175
+
</div>
176
176
+
{/each}
177
177
+
</div>
178
178
+
</div>
179
179
+
{/if}
180
180
+
</div>
+28
-5
src/lib/components/qr/qrOverlay.svelte.ts
···
18
18
const LONG_PRESS_DURATION = 500;
19
19
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
20
20
let isLongPress = false;
21
21
+
let touchActive = false;
22
22
+
23
23
+
// Prevent iOS link preview on long-press
24
24
+
const originalCallout = node.style.getPropertyValue('-webkit-touch-callout');
25
25
+
node.style.setProperty('-webkit-touch-callout', 'none');
21
26
22
27
function getHref() {
23
28
return params.href || (node as HTMLAnchorElement).href || '';
24
29
}
25
30
26
26
-
function startLongPress() {
31
31
+
function startLongPress(e: PointerEvent) {
27
32
if (params.disabled) return;
33
33
+
// Only start long press for primary button (touch/left-click), not right-click
34
34
+
if (e.button !== 0) return;
35
35
+
touchActive = e.pointerType === 'touch';
28
36
isLongPress = false;
29
37
longPressTimer = setTimeout(() => {
30
38
isLongPress = true;
···
37
45
clearTimeout(longPressTimer);
38
46
longPressTimer = null;
39
47
}
48
48
+
touchActive = false;
40
49
}
41
50
42
51
function handleClick(e: MouseEvent) {
43
52
if (isLongPress) {
44
53
e.preventDefault();
45
54
isLongPress = false;
55
55
+
return;
56
56
+
}
57
57
+
58
58
+
// Shift-click opens QR modal
59
59
+
if (e.shiftKey && !params.disabled) {
60
60
+
e.preventDefault();
61
61
+
openModal?.(getHref(), params.context ?? {});
46
62
}
47
63
}
48
64
49
49
-
function handleContextMenu(e: MouseEvent) {
50
50
-
if (params.disabled) return;
51
51
-
e.preventDefault();
52
52
-
openModal?.(getHref(), params.context ?? {});
65
65
+
function handleContextMenu(e: Event) {
66
66
+
// Prevent context menu during touch to avoid iOS preview
67
67
+
if (touchActive || isLongPress) {
68
68
+
e.preventDefault();
69
69
+
}
53
70
}
54
71
55
72
node.addEventListener('pointerdown', startLongPress);
···
71
88
node.removeEventListener('click', handleClick);
72
89
node.removeEventListener('contextmenu', handleContextMenu);
73
90
cancelLongPress();
91
91
+
// Restore original style
92
92
+
if (originalCallout) {
93
93
+
node.style.setProperty('-webkit-touch-callout', originalCallout);
94
94
+
} else {
95
95
+
node.style.removeProperty('-webkit-touch-callout');
96
96
+
}
74
97
}
75
98
};
76
99
}
+1
-3
src/lib/website/EditableProfile.svelte
···
3
3
import { getImage, compressImage, getProfilePosition } from '$lib/helper';
4
4
import PlainTextEditor from '$lib/components/PlainTextEditor.svelte';
5
5
import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte';
6
6
-
import { Avatar, Button } from '@foxui/core';
7
7
-
import { getIsMobile } from './context';
6
6
+
import { Avatar } from '@foxui/core';
8
7
import MadeWithBlento from './MadeWithBlento.svelte';
9
9
-
import { SelectThemePopover } from '$lib/components/select-theme';
10
8
11
9
let { data = $bindable(), hideBlento = false }: { data: WebsiteData; hideBlento?: boolean } =
12
10
$props();
-12
src/lib/website/EditableWebsite.svelte
···
961
961
class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 flex items-center gap-2 rounded px-2 py-1 font-mono text-xs"
962
962
>
963
963
<span>editedOn: {editedOn}</span>
964
964
-
<button class="underline" onclick={addAllCardTypes}>+ all cards</button>
965
965
-
<input
966
966
-
bind:value={copyInput}
967
967
-
placeholder="handle/page"
968
968
-
class="bg-base-800 text-base-100 w-32 rounded px-1 py-0.5"
969
969
-
onkeydown={(e) => {
970
970
-
if (e.key === 'Enter') copyPageFrom();
971
971
-
}}
972
972
-
/>
973
973
-
<button class="underline" onclick={copyPageFrom} disabled={isCopying}>
974
974
-
{isCopying ? 'copying...' : 'copy'}
975
975
-
</button>
976
964
</div>
977
965
{/if}
978
966
</Context>
+32
src/lib/website/ThemeScript.svelte
···
1
1
<script lang="ts">
2
2
+
import { browser } from '$app/environment';
3
3
+
2
4
let {
3
5
accentColor = 'pink',
4
6
baseColor = 'stone'
···
7
9
baseColor?: string;
8
10
} = $props();
9
11
12
12
+
const allAccentColors = [
13
13
+
'red',
14
14
+
'orange',
15
15
+
'amber',
16
16
+
'yellow',
17
17
+
'lime',
18
18
+
'green',
19
19
+
'emerald',
20
20
+
'teal',
21
21
+
'cyan',
22
22
+
'sky',
23
23
+
'blue',
24
24
+
'indigo',
25
25
+
'violet',
26
26
+
'purple',
27
27
+
'fuchsia',
28
28
+
'pink',
29
29
+
'rose'
30
30
+
];
31
31
+
const allBaseColors = ['gray', 'stone', 'zinc', 'neutral', 'slate'];
32
32
+
10
33
const safeJson = (v: string) => JSON.stringify(v).replace(/</g, '\\u003c');
11
34
35
35
+
// SSR: inline script for initial page load (no FOUC)
12
36
let script = $derived(
13
37
`<script>(function(){document.documentElement.classList.add(${safeJson(accentColor)},${safeJson(baseColor)});})();<` +
14
38
'/script>'
15
39
);
40
40
+
41
41
+
// Client: reactive effect for client-side navigations
42
42
+
$effect(() => {
43
43
+
if (!browser) return;
44
44
+
const el = document.documentElement;
45
45
+
el.classList.remove(...allAccentColors, ...allBaseColors);
46
46
+
el.classList.add(accentColor, baseColor);
47
47
+
});
16
48
</script>
17
49
18
50
<svelte:head>
+2
-1
src/params/handle.ts
···
1
1
+
import { isActorIdentifier } from '@atcute/lexicons/syntax';
1
2
import type { ParamMatcher } from '@sveltejs/kit';
2
3
3
4
export const match = ((param: string) => {
4
4
-
return param.includes('.') || param.startsWith('did:');
5
5
+
return isActorIdentifier(param);
5
6
}) satisfies ParamMatcher;
+4
-1
src/routes/+layout.svelte
···
1
1
<script lang="ts">
2
2
import '../app.css';
3
3
4
4
+
import { Tooltip } from 'bits-ui';
4
5
import { ThemeToggle, Toaster, toast } from '@foxui/core';
5
6
import { onMount } from 'svelte';
6
7
import { initClient } from '$lib/atproto';
···
28
29
});
29
30
</script>
30
31
31
31
-
{@render children()}
32
32
+
<Tooltip.Provider delayDuration={300}>
33
33
+
{@render children()}
34
34
+
</Tooltip.Provider>
32
35
33
36
<ThemeToggle class="fixed top-2 left-2 z-10" />
34
37
<Toaster />
+13
src/routes/[handle=handle]/(pages)/+layout.server.ts
···
1
1
+
import { loadData } from '$lib/website/load';
2
2
+
import { env } from '$env/dynamic/private';
3
3
+
import { error } from '@sveltejs/kit';
4
4
+
import type { UserCache } from '$lib/types';
5
5
+
import type { Handle } from '@atcute/lexicons';
6
6
+
7
7
+
export async function load({ params, platform }) {
8
8
+
if (env.PUBLIC_IS_SELFHOSTED) error(404);
9
9
+
10
10
+
const cache = platform?.env?.USER_DATA_CACHE as unknown;
11
11
+
12
12
+
return await loadData(params.handle as Handle, cache as UserCache, false, params.page);
13
13
+
}
+13
src/routes/[handle=handle]/(pages)/+page.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { refreshData } from '$lib/helper.js';
3
3
+
import Website from '$lib/website/Website.svelte';
4
4
+
import { onMount } from 'svelte';
5
5
+
6
6
+
let { data } = $props();
7
7
+
8
8
+
onMount(() => {
9
9
+
refreshData(data);
10
10
+
});
11
11
+
</script>
12
12
+
13
13
+
<Website {data} />
+6
src/routes/[handle=handle]/(pages)/edit/+page.svelte
···
1
1
+
<script lang="ts">
2
2
+
import EditableWebsite from '$lib/website/EditableWebsite.svelte';
3
3
+
let { data } = $props();
4
4
+
</script>
5
5
+
6
6
+
<EditableWebsite {data} />
+13
src/routes/[handle=handle]/(pages)/p/[[page]]/+page.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { refreshData } from '$lib/helper.js';
3
3
+
import Website from '$lib/website/Website.svelte';
4
4
+
import { onMount } from 'svelte';
5
5
+
6
6
+
let { data } = $props();
7
7
+
8
8
+
onMount(() => {
9
9
+
refreshData(data);
10
10
+
});
11
11
+
</script>
12
12
+
13
13
+
<Website {data} />
+252
src/routes/[handle=handle]/(pages)/p/[[page]]/copy/+page.svelte
···
1
1
+
<script lang="ts">
2
2
+
import {
3
3
+
putRecord,
4
4
+
deleteRecord,
5
5
+
listRecords,
6
6
+
uploadBlob,
7
7
+
getCDNImageBlobUrl
8
8
+
} from '$lib/atproto/methods';
9
9
+
import { user } from '$lib/atproto/auth.svelte';
10
10
+
import { goto } from '$app/navigation';
11
11
+
import * as TID from '@atcute/tid';
12
12
+
import { Button } from '@foxui/core';
13
13
+
import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte';
14
14
+
15
15
+
let { data } = $props();
16
16
+
17
17
+
let destinationPage = $state('');
18
18
+
let copying = $state(false);
19
19
+
let error = $state('');
20
20
+
let success = $state(false);
21
21
+
22
22
+
const sourceHandle = $derived(data.handle);
23
23
+
24
24
+
const sourcePage = $derived(
25
25
+
data.page === 'blento.self' ? 'main' : data.page.replace('blento.', '')
26
26
+
);
27
27
+
const sourceCards = $derived(data.cards);
28
28
+
29
29
+
// Re-upload blobs from source repo to current user's repo
30
30
+
async function reuploadBlobs(obj: any, sourceDid: string): Promise<void> {
31
31
+
if (!obj || typeof obj !== 'object') return;
32
32
+
33
33
+
for (const key of Object.keys(obj)) {
34
34
+
const value = obj[key];
35
35
+
36
36
+
if (value && typeof value === 'object') {
37
37
+
// Check if this is a blob reference
38
38
+
if (value.$type === 'blob' && value.ref?.$link) {
39
39
+
try {
40
40
+
// Get the blob URL from source repo
41
41
+
const blobUrl = getCDNImageBlobUrl({ did: sourceDid, blob: value });
42
42
+
if (!blobUrl) continue;
43
43
+
44
44
+
// Fetch the blob via proxy to avoid CORS
45
45
+
const response = await fetch(`/api/image-proxy?url=${encodeURIComponent(blobUrl)}`);
46
46
+
if (!response.ok) {
47
47
+
console.error('Failed to fetch blob:', blobUrl);
48
48
+
continue;
49
49
+
}
50
50
+
51
51
+
// Upload to current user's repo
52
52
+
const blob = await response.blob();
53
53
+
const newBlobRef = await uploadBlob({ blob });
54
54
+
55
55
+
if (newBlobRef) {
56
56
+
// Replace with new blob reference
57
57
+
obj[key] = newBlobRef;
58
58
+
}
59
59
+
} catch (err) {
60
60
+
console.error('Failed to re-upload blob:', err);
61
61
+
}
62
62
+
} else {
63
63
+
// Recursively check nested objects
64
64
+
await reuploadBlobs(value, sourceDid);
65
65
+
}
66
66
+
}
67
67
+
}
68
68
+
}
69
69
+
70
70
+
async function copyPage() {
71
71
+
if (!user.isLoggedIn || !user.did) {
72
72
+
error = 'You must be logged in to copy pages';
73
73
+
return;
74
74
+
}
75
75
+
76
76
+
copying = true;
77
77
+
error = '';
78
78
+
79
79
+
try {
80
80
+
const targetPage =
81
81
+
destinationPage.trim() === '' ? 'blento.self' : `blento.${destinationPage.trim()}`;
82
82
+
83
83
+
// Fetch existing cards from destination page and delete them
84
84
+
const existingCards = await listRecords({
85
85
+
did: user.did,
86
86
+
collection: 'app.blento.card'
87
87
+
});
88
88
+
89
89
+
const cardsToDelete = existingCards.filter(
90
90
+
(card: { value: { page?: string } }) => card.value.page === targetPage
91
91
+
);
92
92
+
93
93
+
// Delete existing cards from destination page
94
94
+
const deletePromises = cardsToDelete.map((card: { uri: string }) => {
95
95
+
const rkey = card.uri.split('/').pop()!;
96
96
+
return deleteRecord({
97
97
+
collection: 'app.blento.card',
98
98
+
rkey
99
99
+
});
100
100
+
});
101
101
+
102
102
+
await Promise.all(deletePromises);
103
103
+
104
104
+
// Copy each card with a new ID to the destination page
105
105
+
// Re-upload blobs from source repo to current user's repo
106
106
+
for (const card of sourceCards) {
107
107
+
const newCard = {
108
108
+
...structuredClone(card),
109
109
+
id: TID.now(),
110
110
+
page: targetPage,
111
111
+
updatedAt: new Date().toISOString(),
112
112
+
version: 2
113
113
+
};
114
114
+
115
115
+
// Re-upload any blobs in cardData
116
116
+
await reuploadBlobs(newCard.cardData, data.did);
117
117
+
118
118
+
await putRecord({
119
119
+
collection: 'app.blento.card',
120
120
+
rkey: newCard.id,
121
121
+
record: newCard
122
122
+
});
123
123
+
}
124
124
+
125
125
+
const userHandle = user.profile?.handle ?? data.handle;
126
126
+
127
127
+
// Copy publication data if it exists
128
128
+
if (data.publication) {
129
129
+
const publicationCopy = structuredClone(data.publication) as Record<string, unknown>;
130
130
+
131
131
+
// Re-upload any blobs in publication (e.g., icon)
132
132
+
await reuploadBlobs(publicationCopy, data.did);
133
133
+
134
134
+
// Update the URL to point to the user's page
135
135
+
publicationCopy.url = `https://blento.app/${userHandle}`;
136
136
+
if (targetPage !== 'blento.self') {
137
137
+
publicationCopy.url += '/' + targetPage.replace('blento.', '');
138
138
+
}
139
139
+
140
140
+
// Save to appropriate collection based on destination page type
141
141
+
if (targetPage === 'blento.self') {
142
142
+
await putRecord({
143
143
+
collection: 'site.standard.publication',
144
144
+
rkey: targetPage,
145
145
+
record: publicationCopy
146
146
+
});
147
147
+
} else {
148
148
+
await putRecord({
149
149
+
collection: 'app.blento.page',
150
150
+
rkey: targetPage,
151
151
+
record: publicationCopy
152
152
+
});
153
153
+
}
154
154
+
}
155
155
+
156
156
+
// Refresh the logged-in user's cache
157
157
+
await fetch(`/${userHandle}/api/refresh`);
158
158
+
159
159
+
success = true;
160
160
+
161
161
+
// Redirect to the logged-in user's destination page edit
162
162
+
const destPath = destinationPage.trim() === '' ? '' : `/${destinationPage.trim()}`;
163
163
+
setTimeout(() => {
164
164
+
goto(`/${userHandle}${destPath}/edit`);
165
165
+
}, 1000);
166
166
+
} catch (e) {
167
167
+
error = e instanceof Error ? e.message : 'Failed to copy page';
168
168
+
} finally {
169
169
+
copying = false;
170
170
+
}
171
171
+
}
172
172
+
</script>
173
173
+
174
174
+
<div class="bg-base-50 dark:bg-base-900 flex min-h-screen items-center justify-center p-4">
175
175
+
<div class="bg-base-100 dark:bg-base-800 w-full max-w-md rounded-2xl p-6 shadow-lg">
176
176
+
{#if user.isLoggedIn}
177
177
+
<h1 class="text-base-900 dark:text-base-50 mb-6 text-2xl font-bold">Copy Page</h1>
178
178
+
179
179
+
<div class="mb-4">
180
180
+
<div class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium">
181
181
+
Source Page
182
182
+
</div>
183
183
+
<div
184
184
+
class="bg-base-200 dark:bg-base-700 text-base-900 dark:text-base-100 rounded-lg px-3 py-2"
185
185
+
>
186
186
+
{sourceHandle}/{sourcePage || 'main'}
187
187
+
</div>
188
188
+
<p class="text-base-500 mt-1 text-sm">{sourceCards.length} cards</p>
189
189
+
</div>
190
190
+
191
191
+
<div class="mb-6">
192
192
+
<label
193
193
+
for="destination"
194
194
+
class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium"
195
195
+
>
196
196
+
Destination Page (on your profile: {user.profile?.handle})
197
197
+
</label>
198
198
+
<input
199
199
+
id="destination"
200
200
+
type="text"
201
201
+
bind:value={destinationPage}
202
202
+
placeholder="Leave empty for main page"
203
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
204
+
/>
205
205
+
</div>
206
206
+
207
207
+
{#if error}
208
208
+
<div
209
209
+
class="mb-4 rounded-lg bg-red-100 p-3 text-red-700 dark:bg-red-900/30 dark:text-red-400"
210
210
+
>
211
211
+
{error}
212
212
+
</div>
213
213
+
{/if}
214
214
+
215
215
+
{#if success}
216
216
+
<div
217
217
+
class="mb-4 rounded-lg bg-green-100 p-3 text-green-700 dark:bg-green-900/30 dark:text-green-400"
218
218
+
>
219
219
+
Page copied successfully! Redirecting...
220
220
+
</div>
221
221
+
{/if}
222
222
+
223
223
+
<div class="flex gap-3">
224
224
+
<a
225
225
+
href="/{data.handle}/{sourcePage === 'main' ? '' : sourcePage}"
226
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
227
+
>
228
228
+
Cancel
229
229
+
</a>
230
230
+
<button
231
231
+
onclick={copyPage}
232
232
+
disabled={copying || success}
233
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
234
+
>
235
235
+
{#if copying}
236
236
+
Copying...
237
237
+
{:else}
238
238
+
Copy {sourceCards.length} cards
239
239
+
{/if}
240
240
+
</button>
241
241
+
</div>
242
242
+
{:else}
243
243
+
<h1 class="text-base-900 dark:text-base-50 mb-6 text-2xl font-bold">
244
244
+
You must be signed in to copy a page!
245
245
+
</h1>
246
246
+
247
247
+
<div class="flex w-full justify-center">
248
248
+
<Button size="lg" onclick={() => loginModalState.show()}>Login</Button>
249
249
+
</div>
250
250
+
{/if}
251
251
+
</div>
252
252
+
</div>
+6
src/routes/[handle=handle]/(pages)/p/[[page]]/edit/+page.svelte
···
1
1
+
<script lang="ts">
2
2
+
import EditableWebsite from '$lib/website/EditableWebsite.svelte';
3
3
+
let { data } = $props();
4
4
+
</script>
5
5
+
6
6
+
<EditableWebsite {data} />
-13
src/routes/[handle=handle]/[[page]]/+layout.server.ts
···
1
1
-
import { loadData } from '$lib/website/load';
2
2
-
import { env } from '$env/dynamic/private';
3
3
-
import { error } from '@sveltejs/kit';
4
4
-
import type { UserCache } from '$lib/types';
5
5
-
import type { Handle } from '@atcute/lexicons';
6
6
-
7
7
-
export async function load({ params, platform }) {
8
8
-
if (env.PUBLIC_IS_SELFHOSTED) error(404);
9
9
-
10
10
-
const cache = platform?.env?.USER_DATA_CACHE as unknown;
11
11
-
12
12
-
return await loadData(params.handle as Handle, cache as UserCache, false, params.page);
13
13
-
}
-13
src/routes/[handle=handle]/[[page]]/+page.svelte
···
1
1
-
<script lang="ts">
2
2
-
import { refreshData } from '$lib/helper.js';
3
3
-
import Website from '$lib/website/Website.svelte';
4
4
-
import { onMount } from 'svelte';
5
5
-
6
6
-
let { data } = $props();
7
7
-
8
8
-
onMount(() => {
9
9
-
refreshData(data);
10
10
-
});
11
11
-
</script>
12
12
-
13
13
-
<Website {data} />
-6
src/routes/[handle=handle]/[[page]]/edit/+page.svelte
···
1
1
-
<script lang="ts">
2
2
-
import EditableWebsite from '$lib/website/EditableWebsite.svelte';
3
3
-
let { data } = $props();
4
4
-
</script>
5
5
-
6
6
-
<EditableWebsite {data} />
+89
src/routes/api/lastfm/+server.ts
···
1
1
+
import { json } from '@sveltejs/kit';
2
2
+
import type { RequestHandler } from './$types';
3
3
+
import { env } from '$env/dynamic/private';
4
4
+
5
5
+
const LASTFM_API_URL = 'https://ws.audioscrobbler.com/2.0/';
6
6
+
7
7
+
const ALLOWED_METHODS = [
8
8
+
'user.getRecentTracks',
9
9
+
'user.getTopTracks',
10
10
+
'user.getTopAlbums',
11
11
+
'user.getInfo'
12
12
+
];
13
13
+
14
14
+
const CACHE_TTL: Record<string, number> = {
15
15
+
'user.getRecentTracks': 15 * 60 * 1000,
16
16
+
'user.getTopTracks': 60 * 60 * 1000,
17
17
+
'user.getTopAlbums': 60 * 60 * 1000,
18
18
+
'user.getInfo': 12 * 60 * 60 * 1000
19
19
+
};
20
20
+
21
21
+
export const GET: RequestHandler = async ({ url, platform }) => {
22
22
+
const method = url.searchParams.get('method');
23
23
+
const user = url.searchParams.get('user');
24
24
+
const period = url.searchParams.get('period') || '7day';
25
25
+
const limit = url.searchParams.get('limit') || '50';
26
26
+
27
27
+
if (!method || !user) {
28
28
+
return json({ error: 'Missing method or user parameter' }, { status: 400 });
29
29
+
}
30
30
+
31
31
+
if (!ALLOWED_METHODS.includes(method)) {
32
32
+
return json({ error: 'Method not allowed' }, { status: 400 });
33
33
+
}
34
34
+
35
35
+
const cacheKey = `#lastfm:${method}:${user}:${period}:${limit}`;
36
36
+
const cachedData = await platform?.env?.USER_DATA_CACHE?.get(cacheKey);
37
37
+
38
38
+
if (cachedData) {
39
39
+
const parsed = JSON.parse(cachedData);
40
40
+
const ttl = CACHE_TTL[method] || 60 * 60 * 1000;
41
41
+
42
42
+
if (Date.now() - (parsed._cachedAt || 0) < ttl) {
43
43
+
return json(parsed);
44
44
+
}
45
45
+
}
46
46
+
47
47
+
const apiKey = env?.LASTFM_API_KEY;
48
48
+
if (!apiKey) {
49
49
+
return json({ error: 'Last.fm API key not configured' }, { status: 500 });
50
50
+
}
51
51
+
52
52
+
try {
53
53
+
const params = new URLSearchParams({
54
54
+
method,
55
55
+
user,
56
56
+
api_key: apiKey,
57
57
+
format: 'json',
58
58
+
limit
59
59
+
});
60
60
+
61
61
+
if (method === 'user.getTopTracks' || method === 'user.getTopAlbums') {
62
62
+
params.set('period', period);
63
63
+
}
64
64
+
65
65
+
const response = await fetch(`${LASTFM_API_URL}?${params}`);
66
66
+
67
67
+
if (!response.ok) {
68
68
+
return json(
69
69
+
{ error: 'Failed to fetch Last.fm data: ' + response.statusText },
70
70
+
{ status: response.status }
71
71
+
);
72
72
+
}
73
73
+
74
74
+
const data = await response.json();
75
75
+
76
76
+
if (data.error) {
77
77
+
return json({ error: data.message || 'Last.fm API error' }, { status: 400 });
78
78
+
}
79
79
+
80
80
+
data._cachedAt = Date.now();
81
81
+
82
82
+
await platform?.env?.USER_DATA_CACHE?.put(cacheKey, JSON.stringify(data));
83
83
+
84
84
+
return json(data);
85
85
+
} catch (error) {
86
86
+
console.error('Error fetching Last.fm data:', error);
87
87
+
return json({ error: 'Failed to fetch Last.fm data' }, { status: 500 });
88
88
+
}
89
89
+
};
+44
src/routes/api/npmx-leaderboard/+server.ts
···
1
1
+
import { json } from '@sveltejs/kit';
2
2
+
import type { RequestHandler } from './$types';
3
3
+
4
4
+
const LEADERBOARD_API_URL =
5
5
+
'https://npmx-likes-leaderboard-api-production.up.railway.app/api/leaderboard/likes?limit=20';
6
6
+
7
7
+
export const GET: RequestHandler = async ({ platform }) => {
8
8
+
const cacheKey = '#npmx-leaderboard:likes';
9
9
+
const cachedData = await platform?.env?.USER_DATA_CACHE?.get(cacheKey);
10
10
+
11
11
+
if (cachedData) {
12
12
+
const parsedCache = JSON.parse(cachedData);
13
13
+
14
14
+
const TWELVE_HOURS = 12 * 60 * 60 * 1000;
15
15
+
const now = Date.now();
16
16
+
17
17
+
if (now - (parsedCache.updatedAt || 0) < TWELVE_HOURS) {
18
18
+
return json(parsedCache.data);
19
19
+
}
20
20
+
}
21
21
+
22
22
+
try {
23
23
+
const response = await fetch(LEADERBOARD_API_URL);
24
24
+
25
25
+
if (!response.ok) {
26
26
+
return json(
27
27
+
{ error: 'Failed to fetch npmx leaderboard ' + response.statusText },
28
28
+
{ status: response.status }
29
29
+
);
30
30
+
}
31
31
+
32
32
+
const data = await response.json();
33
33
+
34
34
+
await platform?.env?.USER_DATA_CACHE?.put(
35
35
+
cacheKey,
36
36
+
JSON.stringify({ data, updatedAt: Date.now() })
37
37
+
);
38
38
+
39
39
+
return json(data);
40
40
+
} catch (error) {
41
41
+
console.error('Error fetching npmx leaderboard:', error);
42
42
+
return json({ error: 'Failed to fetch npmx leaderboard' }, { status: 500 });
43
43
+
}
44
44
+
};
+25
src/routes/p/[[page]]/+layout.server.ts
···
1
1
+
import { loadData } from '$lib/website/load';
2
2
+
import { env } from '$env/dynamic/public';
3
3
+
import type { UserCache } from '$lib/types';
4
4
+
import type { Did, Handle } from '@atcute/lexicons';
5
5
+
6
6
+
export async function load({ params, platform, request }) {
7
7
+
const cache = platform?.env?.USER_DATA_CACHE as unknown;
8
8
+
9
9
+
const handle = env.PUBLIC_HANDLE;
10
10
+
11
11
+
const kv = platform?.env?.CUSTOM_DOMAINS;
12
12
+
13
13
+
const customDomain = request.headers.get('X-Custom-Domain')?.toLocaleLowerCase();
14
14
+
15
15
+
if (kv && customDomain) {
16
16
+
try {
17
17
+
const did = await kv.get(customDomain);
18
18
+
return await loadData(did as Did, cache as UserCache, false, params.page);
19
19
+
} catch {
20
20
+
console.error('failed');
21
21
+
}
22
22
+
}
23
23
+
24
24
+
return await loadData(handle as Handle, cache as UserCache, false, params.page);
25
25
+
}
+13
src/routes/p/[[page]]/+page.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { refreshData } from '$lib/helper.js';
3
3
+
import Website from '$lib/website/Website.svelte';
4
4
+
import { onMount } from 'svelte';
5
5
+
6
6
+
let { data } = $props();
7
7
+
8
8
+
onMount(() => {
9
9
+
refreshData(data);
10
10
+
});
11
11
+
</script>
12
12
+
13
13
+
<Website {data} />
+252
src/routes/p/[[page]]/copy/+page.svelte
···
1
1
+
<script lang="ts">
2
2
+
import {
3
3
+
putRecord,
4
4
+
deleteRecord,
5
5
+
listRecords,
6
6
+
uploadBlob,
7
7
+
getCDNImageBlobUrl
8
8
+
} from '$lib/atproto/methods';
9
9
+
import { user } from '$lib/atproto/auth.svelte';
10
10
+
import { goto } from '$app/navigation';
11
11
+
import * as TID from '@atcute/tid';
12
12
+
import { Button } from '@foxui/core';
13
13
+
import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte';
14
14
+
15
15
+
let { data } = $props();
16
16
+
17
17
+
let destinationPage = $state('');
18
18
+
let copying = $state(false);
19
19
+
let error = $state('');
20
20
+
let success = $state(false);
21
21
+
22
22
+
const sourceHandle = $derived(data.handle);
23
23
+
24
24
+
const sourcePage = $derived(
25
25
+
data.page === 'blento.self' ? 'main' : data.page.replace('blento.', '')
26
26
+
);
27
27
+
const sourceCards = $derived(data.cards);
28
28
+
29
29
+
// Re-upload blobs from source repo to current user's repo
30
30
+
async function reuploadBlobs(obj: any, sourceDid: string): Promise<void> {
31
31
+
if (!obj || typeof obj !== 'object') return;
32
32
+
33
33
+
for (const key of Object.keys(obj)) {
34
34
+
const value = obj[key];
35
35
+
36
36
+
if (value && typeof value === 'object') {
37
37
+
// Check if this is a blob reference
38
38
+
if (value.$type === 'blob' && value.ref?.$link) {
39
39
+
try {
40
40
+
// Get the blob URL from source repo
41
41
+
const blobUrl = getCDNImageBlobUrl({ did: sourceDid, blob: value });
42
42
+
if (!blobUrl) continue;
43
43
+
44
44
+
// Fetch the blob via proxy to avoid CORS
45
45
+
const response = await fetch(`/api/image-proxy?url=${encodeURIComponent(blobUrl)}`);
46
46
+
if (!response.ok) {
47
47
+
console.error('Failed to fetch blob:', blobUrl);
48
48
+
continue;
49
49
+
}
50
50
+
51
51
+
// Upload to current user's repo
52
52
+
const blob = await response.blob();
53
53
+
const newBlobRef = await uploadBlob({ blob });
54
54
+
55
55
+
if (newBlobRef) {
56
56
+
// Replace with new blob reference
57
57
+
obj[key] = newBlobRef;
58
58
+
}
59
59
+
} catch (err) {
60
60
+
console.error('Failed to re-upload blob:', err);
61
61
+
}
62
62
+
} else {
63
63
+
// Recursively check nested objects
64
64
+
await reuploadBlobs(value, sourceDid);
65
65
+
}
66
66
+
}
67
67
+
}
68
68
+
}
69
69
+
70
70
+
async function copyPage() {
71
71
+
if (!user.isLoggedIn || !user.did) {
72
72
+
error = 'You must be logged in to copy pages';
73
73
+
return;
74
74
+
}
75
75
+
76
76
+
copying = true;
77
77
+
error = '';
78
78
+
79
79
+
try {
80
80
+
const targetPage =
81
81
+
destinationPage.trim() === '' ? 'blento.self' : `blento.${destinationPage.trim()}`;
82
82
+
83
83
+
// Fetch existing cards from destination page and delete them
84
84
+
const existingCards = await listRecords({
85
85
+
did: user.did,
86
86
+
collection: 'app.blento.card'
87
87
+
});
88
88
+
89
89
+
const cardsToDelete = existingCards.filter(
90
90
+
(card: { value: { page?: string } }) => card.value.page === targetPage
91
91
+
);
92
92
+
93
93
+
// Delete existing cards from destination page
94
94
+
const deletePromises = cardsToDelete.map((card: { uri: string }) => {
95
95
+
const rkey = card.uri.split('/').pop()!;
96
96
+
return deleteRecord({
97
97
+
collection: 'app.blento.card',
98
98
+
rkey
99
99
+
});
100
100
+
});
101
101
+
102
102
+
await Promise.all(deletePromises);
103
103
+
104
104
+
// Copy each card with a new ID to the destination page
105
105
+
// Re-upload blobs from source repo to current user's repo
106
106
+
for (const card of sourceCards) {
107
107
+
const newCard = {
108
108
+
...structuredClone(card),
109
109
+
id: TID.now(),
110
110
+
page: targetPage,
111
111
+
updatedAt: new Date().toISOString(),
112
112
+
version: 2
113
113
+
};
114
114
+
115
115
+
// Re-upload any blobs in cardData
116
116
+
await reuploadBlobs(newCard.cardData, data.did);
117
117
+
118
118
+
await putRecord({
119
119
+
collection: 'app.blento.card',
120
120
+
rkey: newCard.id,
121
121
+
record: newCard
122
122
+
});
123
123
+
}
124
124
+
125
125
+
const userHandle = user.profile?.handle ?? data.handle;
126
126
+
127
127
+
// Copy publication data if it exists
128
128
+
if (data.publication) {
129
129
+
const publicationCopy = structuredClone(data.publication) as Record<string, unknown>;
130
130
+
131
131
+
// Re-upload any blobs in publication (e.g., icon)
132
132
+
await reuploadBlobs(publicationCopy, data.did);
133
133
+
134
134
+
// Update the URL to point to the user's page
135
135
+
publicationCopy.url = `https://blento.app/${userHandle}`;
136
136
+
if (targetPage !== 'blento.self') {
137
137
+
publicationCopy.url += '/' + targetPage.replace('blento.', '');
138
138
+
}
139
139
+
140
140
+
// Save to appropriate collection based on destination page type
141
141
+
if (targetPage === 'blento.self') {
142
142
+
await putRecord({
143
143
+
collection: 'site.standard.publication',
144
144
+
rkey: targetPage,
145
145
+
record: publicationCopy
146
146
+
});
147
147
+
} else {
148
148
+
await putRecord({
149
149
+
collection: 'app.blento.page',
150
150
+
rkey: targetPage,
151
151
+
record: publicationCopy
152
152
+
});
153
153
+
}
154
154
+
}
155
155
+
156
156
+
// Refresh the logged-in user's cache
157
157
+
await fetch(`/${userHandle}/api/refresh`);
158
158
+
159
159
+
success = true;
160
160
+
161
161
+
// Redirect to the logged-in user's destination page edit
162
162
+
const destPath = destinationPage.trim() === '' ? '' : `/${destinationPage.trim()}`;
163
163
+
setTimeout(() => {
164
164
+
goto(`/${userHandle}${destPath}/edit`);
165
165
+
}, 1000);
166
166
+
} catch (e) {
167
167
+
error = e instanceof Error ? e.message : 'Failed to copy page';
168
168
+
} finally {
169
169
+
copying = false;
170
170
+
}
171
171
+
}
172
172
+
</script>
173
173
+
174
174
+
<div class="bg-base-50 dark:bg-base-900 flex min-h-screen items-center justify-center p-4">
175
175
+
<div class="bg-base-100 dark:bg-base-800 w-full max-w-md rounded-2xl p-6 shadow-lg">
176
176
+
{#if user.isLoggedIn}
177
177
+
<h1 class="text-base-900 dark:text-base-50 mb-6 text-2xl font-bold">Copy Page</h1>
178
178
+
179
179
+
<div class="mb-4">
180
180
+
<div class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium">
181
181
+
Source Page
182
182
+
</div>
183
183
+
<div
184
184
+
class="bg-base-200 dark:bg-base-700 text-base-900 dark:text-base-100 rounded-lg px-3 py-2"
185
185
+
>
186
186
+
{sourceHandle}/{sourcePage || 'main'}
187
187
+
</div>
188
188
+
<p class="text-base-500 mt-1 text-sm">{sourceCards.length} cards</p>
189
189
+
</div>
190
190
+
191
191
+
<div class="mb-6">
192
192
+
<label
193
193
+
for="destination"
194
194
+
class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium"
195
195
+
>
196
196
+
Destination Page (on your profile: {user.profile?.handle})
197
197
+
</label>
198
198
+
<input
199
199
+
id="destination"
200
200
+
type="text"
201
201
+
bind:value={destinationPage}
202
202
+
placeholder="Leave empty for main page"
203
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
204
+
/>
205
205
+
</div>
206
206
+
207
207
+
{#if error}
208
208
+
<div
209
209
+
class="mb-4 rounded-lg bg-red-100 p-3 text-red-700 dark:bg-red-900/30 dark:text-red-400"
210
210
+
>
211
211
+
{error}
212
212
+
</div>
213
213
+
{/if}
214
214
+
215
215
+
{#if success}
216
216
+
<div
217
217
+
class="mb-4 rounded-lg bg-green-100 p-3 text-green-700 dark:bg-green-900/30 dark:text-green-400"
218
218
+
>
219
219
+
Page copied successfully! Redirecting...
220
220
+
</div>
221
221
+
{/if}
222
222
+
223
223
+
<div class="flex gap-3">
224
224
+
<a
225
225
+
href="/{data.handle}/{sourcePage === 'main' ? '' : sourcePage}"
226
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
227
+
>
228
228
+
Cancel
229
229
+
</a>
230
230
+
<button
231
231
+
onclick={copyPage}
232
232
+
disabled={copying || success}
233
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
234
+
>
235
235
+
{#if copying}
236
236
+
Copying...
237
237
+
{:else}
238
238
+
Copy {sourceCards.length} cards
239
239
+
{/if}
240
240
+
</button>
241
241
+
</div>
242
242
+
{:else}
243
243
+
<h1 class="text-base-900 dark:text-base-50 mb-6 text-2xl font-bold">
244
244
+
You must be signed in to copy a page!
245
245
+
</h1>
246
246
+
247
247
+
<div class="flex w-full justify-center">
248
248
+
<Button size="lg" onclick={() => loginModalState.show()}>Login</Button>
249
249
+
</div>
250
250
+
{/if}
251
251
+
</div>
252
252
+
</div>
+6
src/routes/p/[[page]]/edit/+page.svelte
···
1
1
+
<script lang="ts">
2
2
+
import EditableWebsite from '$lib/website/EditableWebsite.svelte';
3
3
+
let { data } = $props();
4
4
+
</script>
5
5
+
6
6
+
<EditableWebsite {data} />