This commit is contained in:
2025-10-04 01:41:35 -07:00
parent 995b9127c1
commit b87f2397d9
115 changed files with 75552 additions and 64066 deletions
+496 -125
View File
@@ -8,7 +8,10 @@
"name": "FurryPlace-frontend",
"version": "0.0.1",
"dependencies": {
"leaflet": "^1.9.4"
"@rollup/rollup-win32-x64-msvc": "^4.52.3",
"leaflet": "^1.9.4",
"maplibre-gl": "^4.0.0",
"minidenticons": "^4.2.1"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.1",
@@ -25,7 +28,7 @@
"vite": "^5.0.3"
},
"optionalDependencies": {
"@rollup/rollup-win32-x64-msvc": "^4.52.3"
"@rollup/rollup-win32-x64-msvc": "^4.52.4"
}
},
"node_modules/@alloc/quick-lru": {
@@ -503,6 +506,89 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mapbox/geojson-rewind": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
"integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==",
"license": "ISC",
"dependencies": {
"get-stream": "^6.0.1",
"minimist": "^1.2.6"
},
"bin": {
"geojson-rewind": "geojson-rewind"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
"integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz",
"integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz",
"integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~0.1.0"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "20.4.0",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz",
"integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^0.0.1",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^2.0.0",
"rw": "^1.3.3",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
"license": "ISC"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -560,9 +646,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz",
"integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz",
"integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==",
"cpu": [
"arm"
],
@@ -574,9 +660,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz",
"integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz",
"integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==",
"cpu": [
"arm64"
],
@@ -588,9 +674,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz",
"integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz",
"integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==",
"cpu": [
"arm64"
],
@@ -602,9 +688,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz",
"integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz",
"integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==",
"cpu": [
"x64"
],
@@ -616,9 +702,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz",
"integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz",
"integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==",
"cpu": [
"arm64"
],
@@ -630,9 +716,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz",
"integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz",
"integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==",
"cpu": [
"x64"
],
@@ -644,9 +730,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz",
"integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz",
"integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==",
"cpu": [
"arm"
],
@@ -658,9 +744,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz",
"integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz",
"integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==",
"cpu": [
"arm"
],
@@ -672,9 +758,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz",
"integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz",
"integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==",
"cpu": [
"arm64"
],
@@ -686,9 +772,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz",
"integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz",
"integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==",
"cpu": [
"arm64"
],
@@ -700,9 +786,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz",
"integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz",
"integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==",
"cpu": [
"loong64"
],
@@ -714,9 +800,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz",
"integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz",
"integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==",
"cpu": [
"ppc64"
],
@@ -728,9 +814,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz",
"integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz",
"integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==",
"cpu": [
"riscv64"
],
@@ -742,9 +828,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz",
"integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz",
"integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==",
"cpu": [
"riscv64"
],
@@ -756,9 +842,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz",
"integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz",
"integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==",
"cpu": [
"s390x"
],
@@ -770,9 +856,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz",
"integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz",
"integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==",
"cpu": [
"x64"
],
@@ -784,9 +870,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz",
"integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz",
"integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==",
"cpu": [
"x64"
],
@@ -798,9 +884,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz",
"integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz",
"integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==",
"cpu": [
"arm64"
],
@@ -812,9 +898,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz",
"integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz",
"integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==",
"cpu": [
"arm64"
],
@@ -826,9 +912,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz",
"integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz",
"integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==",
"cpu": [
"ia32"
],
@@ -840,9 +926,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz",
"integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz",
"integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==",
"cpu": [
"x64"
],
@@ -854,9 +940,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz",
"integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz",
"integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==",
"cpu": [
"x64"
],
@@ -884,9 +970,9 @@
}
},
"node_modules/@sveltejs/adapter-static": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.9.tgz",
"integrity": "sha512-aytHXcMi7lb9ljsWUzXYQ0p5X1z9oWud2olu/EpmH7aCu4m84h7QLvb5Wp+CFirKcwoNnYvYWhyP/L8Vh1ztdw==",
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
"integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -894,9 +980,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.43.7",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.43.7.tgz",
"integrity": "sha512-6trpyltB9XZNkM8cfVHG9U2urAH4NPD7UeO0wiBvZjD8gHj6w9bVeWnBQgnO8LPNpzOhSlwnZDk355OOAa/9Zw==",
"version": "2.43.8",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.43.8.tgz",
"integrity": "sha512-z21dG8W4g6XtAnK8bMpaSahtPOV6JVhghhco1+GR4H39XEgIxrjIpRoT1Js84c7TmhBzbTkVpZVVPFNNPFsXkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -991,9 +1077,17 @@
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson-vt": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
"integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/leaflet": {
"version": "1.9.20",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.20.tgz",
@@ -1004,6 +1098,29 @@
"@types/geojson": "*"
}
},
"node_modules/@types/mapbox__point-geometry": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz",
"integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==",
"license": "MIT"
},
"node_modules/@types/mapbox__vector-tile": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz",
"integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*",
"@types/mapbox__point-geometry": "*",
"@types/pbf": "*"
}
},
"node_modules/@types/pbf": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
"license": "MIT"
},
"node_modules/@types/pug": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz",
@@ -1011,6 +1128,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -1144,9 +1270,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.10",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.10.tgz",
"integrity": "sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==",
"version": "2.8.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.11.tgz",
"integrity": "sha512-i+sRXGhz4+QW8aACZ3+r1GAKMt0wlFpeA8M5rOQd0HEYw9zhDrlx9Wc8uQ0IdXakjJRthzglEwfB/yqIjO6iDg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -1245,9 +1371,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001746",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001746.tgz",
"integrity": "sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA==",
"version": "1.0.30001747",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001747.tgz",
"integrity": "sha512-mzFa2DGIhuc5490Nd/G31xN1pnBnYMadtkyTjefPI7wzypqgCEpeWu9bJr0OnDsyKrW75zA9ZAt7pbQFmwLsQg==",
"dev": true,
"funding": [
{
@@ -1366,6 +1492,29 @@
"node": ">= 8"
}
},
"node_modules/cross-spawn/node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/cross-spawn/node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/css-selector-tokenizer": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz",
@@ -1493,6 +1642,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/earcut": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"license": "ISC"
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -1501,9 +1656,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.228",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.228.tgz",
"integrity": "sha512-nxkiyuqAn4MJ1QbobwqJILiDtu/jk14hEAWaMiJmNPh1Z+jqoFlBFZjdXwLWGeVSeu9hGLg6+2G9yJaW8rBIFA==",
"version": "1.5.230",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.230.tgz",
"integrity": "sha512-A6A6Fd3+gMdaed9wX83CvHYJb4UuapPD5X5SLq72VZJzxHSY0/LUweGXRWmQlh2ln7KV7iw7jnwXK7dlPoOnHQ==",
"dev": true,
"license": "ISC"
},
@@ -1697,6 +1852,30 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/geojson-vt": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
"integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==",
"license": "ISC"
},
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -1732,6 +1911,20 @@
"node": ">= 6"
}
},
"node_modules/global-prefix": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz",
"integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==",
"license": "MIT",
"dependencies": {
"ini": "^4.1.3",
"kind-of": "^6.0.3",
"which": "^4.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1752,6 +1945,26 @@
"node": ">= 0.4"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -1771,6 +1984,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/ini": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz",
"integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==",
"license": "ISC",
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -1854,11 +2076,13 @@
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
"integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
"license": "ISC",
"engines": {
"node": ">=16"
}
},
"node_modules/jackspeak": {
"version": "3.4.3",
@@ -1886,6 +2110,27 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/kdbush": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
"license": "ISC"
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -1946,6 +2191,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/maplibre-gl": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz",
"integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^0.1.0",
"@mapbox/tiny-sdf": "^2.0.6",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^1.3.1",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/maplibre-gl-style-spec": "^20.3.1",
"@types/geojson": "^7946.0.14",
"@types/geojson-vt": "3.2.5",
"@types/mapbox__point-geometry": "^0.1.4",
"@types/mapbox__vector-tile": "^1.3.4",
"@types/pbf": "^3.0.5",
"@types/supercluster": "^7.1.3",
"earcut": "^3.0.0",
"geojson-vt": "^4.0.2",
"gl-matrix": "^3.4.3",
"global-prefix": "^4.0.0",
"kdbush": "^4.0.2",
"murmurhash-js": "^1.0.0",
"pbf": "^3.3.0",
"potpack": "^2.0.0",
"quickselect": "^3.0.0",
"supercluster": "^8.0.1",
"tinyqueue": "^3.0.0",
"vt-pbf": "^3.1.3"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/mdn-data": {
"version": "2.0.30",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
@@ -1987,6 +2273,15 @@
"node": ">=4"
}
},
"node_modules/minidenticons": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/minidenticons/-/minidenticons-4.2.1.tgz",
"integrity": "sha512-oWfFivA0lOx/V/bO/YIJbthB26lV8JXYvhnv9zM2hNd3fzsHTXQ6c6bWZPcvhD3nnOB+lQk/D9lF43BXixrN8g==",
"license": "MIT",
"engines": {
"node": ">=15.14.0"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -2004,7 +2299,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -2060,6 +2354,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -2092,9 +2392,9 @@
}
},
"node_modules/node-releases": {
"version": "2.0.21",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz",
"integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==",
"version": "2.0.23",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
"integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==",
"dev": true,
"license": "MIT"
},
@@ -2199,6 +2499,19 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/pbf": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz",
"integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==",
"license": "BSD-3-Clause",
"dependencies": {
"ieee754": "^1.1.12",
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/periscopic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
@@ -2411,6 +2724,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -2432,6 +2757,12 @@
],
"license": "MIT"
},
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -2476,6 +2807,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -2502,9 +2842,9 @@
}
},
"node_modules/rollup": {
"version": "4.52.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz",
"integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==",
"version": "4.52.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2518,28 +2858,28 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.52.3",
"@rollup/rollup-android-arm64": "4.52.3",
"@rollup/rollup-darwin-arm64": "4.52.3",
"@rollup/rollup-darwin-x64": "4.52.3",
"@rollup/rollup-freebsd-arm64": "4.52.3",
"@rollup/rollup-freebsd-x64": "4.52.3",
"@rollup/rollup-linux-arm-gnueabihf": "4.52.3",
"@rollup/rollup-linux-arm-musleabihf": "4.52.3",
"@rollup/rollup-linux-arm64-gnu": "4.52.3",
"@rollup/rollup-linux-arm64-musl": "4.52.3",
"@rollup/rollup-linux-loong64-gnu": "4.52.3",
"@rollup/rollup-linux-ppc64-gnu": "4.52.3",
"@rollup/rollup-linux-riscv64-gnu": "4.52.3",
"@rollup/rollup-linux-riscv64-musl": "4.52.3",
"@rollup/rollup-linux-s390x-gnu": "4.52.3",
"@rollup/rollup-linux-x64-gnu": "4.52.3",
"@rollup/rollup-linux-x64-musl": "4.52.3",
"@rollup/rollup-openharmony-arm64": "4.52.3",
"@rollup/rollup-win32-arm64-msvc": "4.52.3",
"@rollup/rollup-win32-ia32-msvc": "4.52.3",
"@rollup/rollup-win32-x64-gnu": "4.52.3",
"@rollup/rollup-win32-x64-msvc": "4.52.3",
"@rollup/rollup-android-arm-eabi": "4.52.4",
"@rollup/rollup-android-arm64": "4.52.4",
"@rollup/rollup-darwin-arm64": "4.52.4",
"@rollup/rollup-darwin-x64": "4.52.4",
"@rollup/rollup-freebsd-arm64": "4.52.4",
"@rollup/rollup-freebsd-x64": "4.52.4",
"@rollup/rollup-linux-arm-gnueabihf": "4.52.4",
"@rollup/rollup-linux-arm-musleabihf": "4.52.4",
"@rollup/rollup-linux-arm64-gnu": "4.52.4",
"@rollup/rollup-linux-arm64-musl": "4.52.4",
"@rollup/rollup-linux-loong64-gnu": "4.52.4",
"@rollup/rollup-linux-ppc64-gnu": "4.52.4",
"@rollup/rollup-linux-riscv64-gnu": "4.52.4",
"@rollup/rollup-linux-riscv64-musl": "4.52.4",
"@rollup/rollup-linux-s390x-gnu": "4.52.4",
"@rollup/rollup-linux-x64-gnu": "4.52.4",
"@rollup/rollup-linux-x64-musl": "4.52.4",
"@rollup/rollup-openharmony-arm64": "4.52.4",
"@rollup/rollup-win32-arm64-msvc": "4.52.4",
"@rollup/rollup-win32-ia32-msvc": "4.52.4",
"@rollup/rollup-win32-x64-gnu": "4.52.4",
"@rollup/rollup-win32-x64-msvc": "4.52.4",
"fsevents": "~2.3.2"
}
},
@@ -2567,6 +2907,12 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@@ -2864,6 +3210,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -3074,6 +3429,12 @@
"node": ">=0.8"
}
},
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3231,20 +3592,30 @@
}
}
},
"node_modules/vt-pbf": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz",
"integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "0.1.0",
"@mapbox/vector-tile": "^1.3.1",
"pbf": "^3.2.1"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
"integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
"isexe": "^3.1.1"
},
"bin": {
"node-which": "bin/node-which"
"node-which": "bin/which.js"
},
"engines": {
"node": ">= 8"
"node": "^16.13.0 || >=18.0.0"
}
},
"node_modules/wrap-ansi": {
+4 -2
View File
@@ -23,10 +23,12 @@
"vite": "^5.0.3"
},
"dependencies": {
"leaflet": "^1.9.4"
"leaflet": "^1.9.4",
"maplibre-gl": "^4.0.0",
"minidenticons": "^4.2.1"
},
"type": "module",
"optionalDependencies": {
"@rollup/rollup-win32-x64-msvc": "^4.52.3"
"@rollup/rollup-win32-x64-msvc": "^4.52.4"
}
}
+4
View File
@@ -16,6 +16,10 @@
image-rendering: crisp-edges;
}
.font-flag {
font-family: 'Segoe UI Emoji', 'Noto Color Emoji', 'Apple Color Emoji', sans-serif;
}
/* Leaflet overrides */
.leaflet-container {
background: #f8f4f0;
@@ -2,8 +2,8 @@
import { API_URL, ENABLE_TURNSTILE } from '$lib/constants/config';
import { captcha } from '$lib/stores/global';
import { t } from '$lib/i18n';
import Turnstile from './Turnstile.svelte';
import Logo from '../layout/Logo.svelte';
import Turnstile from './Turnstile.svelte';
export let redirect: string | undefined = undefined;
@@ -34,13 +34,11 @@
<Logo size="lg" hasText={true} />
</div>
<form class="w-full">
{#if ENABLE_TURNSTILE}
<div class="mt-6 flex flex-col items-center gap-2">
<Turnstile />
</div>
{/if}
</form>
{#if ENABLE_TURNSTILE}
<div class="mt-6 flex flex-col items-center gap-2">
<Turnstile />
</div>
{/if}
<div class="mt-6 flex flex-col items-center gap-2 w-full">
<a
@@ -1,119 +1,112 @@
<script lang="ts">
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
import { MAP_URL } from '../../constants/config';
import { api } from '../../api/client';
import { BACKEND_URL } from '../../constants/config';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
const TILE_ZOOM = 11;
const TILE_PIXELS = 1024;
const SEASON = 's0';
import type { Map as LeafletMap, GridLayer, CircleMarker, LatLng, Coords } from 'leaflet';
type LeafletModule = typeof import('leaflet');
type PixelEventDetail = { tileX: number; tileY: number; x: number; y: number; latlng: LatLng };
type PixelEventDetail = { tileX: number; tileY: number; x: number; y: number; latlng: { lat: number; lng: number } };
const dispatch = createEventDispatcher<{ pixel: PixelEventDetail }>();
let mapContainer: HTMLDivElement;
let map: LeafletMap | null = null;
let tiles: GridLayer | null = null;
let marker: CircleMarker | null = null;
let L: LeafletModule | null = null;
let map: maplibregl.Map | null = null;
let marker: maplibregl.Marker | null = null;
export let center: [number, number] = [0, 0];
onMount(async () => {
L = await import('leaflet');
await import('leaflet/dist/leaflet.css');
// Load the liberty style from static files
const styleResponse = await fetch('/maps/styles/liberty');
const baseStyle = await styleResponse.json();
map = L.map(mapContainer, {
center,
zoom: TILE_ZOOM,
minZoom: TILE_ZOOM,
maxZoom: TILE_ZOOM,
zoomControl: false,
maxBounds: [
[-85, -180],
[85, 180]
],
maxBoundsViscosity: 1.0
});
L.tileLayer(MAP_URL + '/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: TILE_ZOOM,
minZoom: TILE_ZOOM
}).addTo(map);
const gridLayer = L.gridLayer({
minZoom: TILE_ZOOM,
maxZoom: TILE_ZOOM,
// Add pixel tiles source and layer to the style
baseStyle.sources['pixel-tiles'] = {
type: 'raster',
tiles: [`${BACKEND_URL}/files/${SEASON}/tiles/{x}/{y}.png`],
tileSize: 256,
className: 'pixelated'
}) as GridLayer & { createTile: (coords: Coords) => HTMLImageElement };
gridLayer.createTile = (coords) => {
const img = document.createElement('img');
img.width = 256;
img.height = 256;
img.decoding = 'async';
img.loading = 'lazy';
img.referrerPolicy = 'no-referrer';
img.src = api.getTileImageUrl(SEASON, coords.x, coords.y);
return img;
minzoom: TILE_ZOOM,
maxzoom: TILE_ZOOM
};
tiles = gridLayer;
tiles.addTo(map);
baseStyle.layers.push({
id: 'pixel-layer',
type: 'raster',
source: 'pixel-tiles',
minzoom: TILE_ZOOM,
maxzoom: TILE_ZOOM,
paint: {
'raster-opacity': 0.8
}
});
map = new maplibregl.Map({
container: mapContainer,
style: baseStyle,
center: center as [number, number],
zoom: 2,
minZoom: 0,
maxZoom: 22
});
map.on('click', (event) => {
if (!L) return;
const latlng = event.latlng;
const info = latLngToTile(latlng.lat, latlng.lng);
if (!map) return;
const { lng, lat } = event.lngLat;
const info = latLngToTile(lat, lng);
if (!info) return;
if (!marker) {
marker = L.circleMarker(latlng, {
radius: 4,
color: '#ffffff',
weight: 2,
fillColor: '#ed1c24',
fillOpacity: 1
}).addTo(map!);
const el = document.createElement('div');
el.className = 'w-3 h-3 bg-red-500 border-2 border-white rounded-full';
marker = new maplibregl.Marker({ element: el })
.setLngLat([lng, lat])
.addTo(map!);
} else {
marker.setLatLng(latlng);
marker.setLngLat([lng, lat]);
}
dispatch('pixel', { ...info, latlng });
dispatch('pixel', { ...info, latlng: { lat, lng } });
});
});
onDestroy(() => {
marker?.remove();
map?.remove();
map = null;
tiles = null;
marker = null;
});
export function focusPixel(tileX: number, tileY: number, x: number, y: number) {
if (!map || !L) return;
const latlng = tileToLatLng(tileX, tileY, x, y);
map.panTo(latlng, { animate: true });
if (!map) return;
const { lat, lng } = tileToLatLng(tileX, tileY, x, y);
map.flyTo({ center: [lng, lat], essential: true });
if (!marker) {
marker = L.circleMarker(latlng, {
radius: 4,
color: '#ffffff',
weight: 2,
fillColor: '#ed1c24',
fillOpacity: 1
}).addTo(map);
const el = document.createElement('div');
el.className = 'w-3 h-3 bg-red-500 border-2 border-white rounded-full';
marker = new maplibregl.Marker({ element: el })
.setLngLat([lng, lat])
.addTo(map);
} else {
marker.setLatLng(latlng);
marker.setLngLat([lng, lat]);
}
}
export function refreshTiles() {
tiles?.redraw();
if (!map) return;
const source = map.getSource('pixel-tiles') as maplibregl.RasterTileSource;
if (source) {
// Force tile refresh by clearing cache
map.style.sourceCaches['pixel-tiles']?.clearTiles();
map.triggerRepaint();
}
}
export function getMap() {
return map;
}
function latLngToTile(lat: number, lng: number) {
@@ -138,15 +131,8 @@ function tileToLatLng(tileX: number, tileY: number, x: number, y: number) {
const lng = (fx / n) * 360 - 180;
const latRad = Math.atan(Math.sinh(Math.PI * (1 - (2 * fy) / n)));
const lat = (latRad * 180) / Math.PI;
return L!.latLng(lat, lng);
return { lat, lng };
}
</script>
<style>
:global(.leaflet-container) {
width: 100%;
height: 100%;
}
</style>
<div bind:this={mapContainer} class="w-full h-full rounded-box overflow-hidden shadow-lg border border-base-300"></div>
@@ -0,0 +1,45 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import ColorPalette from './ColorPalette.svelte';
const dispatch = createEventDispatcher();
function close() {
dispatch('close');
}
</script>
<!-- Bottom sheet drawer -->
<div class="fixed inset-0 z-50 flex items-end justify-center" on:click={close}>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/20"></div>
<!-- Drawer -->
<div
class="relative w-full bg-base-100 rounded-t-2xl shadow-xl p-4 pb-6"
on:click|stopPropagation
>
<button
on:click={close}
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
>
</button>
<div class="flex items-center gap-2 mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5"
>
<path
d="M240-120q-45 0-89-22t-71-58q26 0 53-20.5t27-59.5q0-50 35-85t85-35q50 0 85 35t35 85q0 66-47 113t-113 47Zm230-240L360-470l358-358q11-11 27.5-11.5T774-828l54 54q12 12 12 28t-12 28L470-360Z"
/>
</svg>
<h3 class="font-bold text-lg">Paint pixel (#)</h3>
</div>
<ColorPalette />
</div>
</div>
@@ -0,0 +1,94 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { currentUser, currentCharges } from '$lib/stores/user';
import { selectedColor } from '$lib/stores/canvas';
import { COLORS } from '$lib/constants/colors';
export let disabled = false;
export let painting = false;
const dispatch = createEventDispatcher();
let chargeCanvas: HTMLCanvasElement;
$: selectedColorData = COLORS.find((c) => c.id === $selectedColor);
$: chargePercentage = $currentUser ? ($currentCharges / $currentUser.maxCharges) * 100 : 0;
// Draw charge bar whenever charges change
$: if (chargeCanvas && $currentUser) {
drawChargeBar(chargeCanvas, $currentCharges, $currentUser.maxCharges);
}
function drawChargeBar(canvas: HTMLCanvasElement, charges: number, maxCharges: number) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const width = 112;
const height = 20;
const barHeight = 12;
const barY = (height - barHeight) / 2;
const filledWidth = (charges / maxCharges) * width;
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Draw background (empty bar)
ctx.fillStyle = '#444';
ctx.fillRect(0, barY, width, barHeight);
// Draw filled portion
ctx.fillStyle = '#4ade80'; // Green color
ctx.fillRect(0, barY, filledWidth, barHeight);
// Draw border
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.strokeRect(1, barY + 1, width - 2, barHeight - 2);
}
function handleClick() {
if (!disabled) {
dispatch('click');
}
}
function openPalette() {
dispatch('openPalette');
}
</script>
<button
class="btn btn-primary btn-lg sm:btn-xl relative z-30"
on:click={handleClick}
{disabled}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-6"
>
<path
d="M240-120q-45 0-89-22t-71-58q26 0 53-20.5t27-59.5q0-50 35-85t85-35q50 0 85 35t35 85q0 66-47 113t-113 47Zm230-240L360-470l358-358q11-11 27.5-11.5T774-828l54 54q12 12 12 28t-12 28L470-360Z"
/>
</svg>
<div class="flex items-center gap-2">
Paint
{#if $currentUser}
<span class="font-semibold">{Math.floor($currentCharges)}/{$currentUser.maxCharges}</span>
{/if}
</div>
<!-- Color preview - click to open palette -->
{#if selectedColorData}
<button
type="button"
class="absolute -right-2 -top-2 size-8 rounded-full border-2 border-white shadow-md"
style="background: {selectedColorData.hex}"
on:click|stopPropagation={openPalette}
title="Change color"
>
</button>
{/if}
</button>
@@ -0,0 +1,101 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { COLORS } from '$lib/constants/colors';
export let pixelInfo: any;
export let coords: { tileX: number; tileY: number; x: number; y: number };
const dispatch = createEventDispatcher();
$: color = pixelInfo?.colorId ? COLORS.find((c) => c.id === pixelInfo.colorId) : null;
function handlePaint() {
dispatch('paint');
}
function handleFavorite() {
dispatch('favorite');
}
function handleShare() {
dispatch('share');
}
</script>
<!-- Popup card -->
<div class="bg-base-100 rounded-2xl shadow-xl p-4 min-w-[280px]">
<!-- Pixel coordinates -->
<div class="flex items-center gap-2 mb-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5 text-primary"
>
<path
d="M480-480q33 0 56.5-23.5T560-560q0-33-23.5-56.5T480-640q-33 0-56.5 23.5T400-560q0 33 23.5 56.5T480-480Zm0 294q122-112 181-203.5T720-552q0-109-69.5-178.5T480-800q-101 0-170.5 69.5T240-552q0 71 59 162.5T480-186Zm0 106Q319-217 239.5-334.5T160-552q0-150 96.5-239T480-880q127 0 223.5 89T800-552q0 100-79.5 217.5T480-80Zm0-480Z"
/>
</svg>
<span class="font-semibold">
Pixel: {coords.x},{coords.y}
</span>
{#if color}
<span
class="ml-auto size-6 rounded border-2 border-base-300"
style="background: {color.hex}"
title={color.name}
></span>
{/if}
</div>
<!-- Painted by -->
{#if pixelInfo?.paintedBy}
<div class="text-sm mb-4">
Painted by:
<button class="link link-primary font-semibold">
{pixelInfo.paintedBy.name} #{pixelInfo.paintedBy.id}
</button>
</div>
{/if}
<!-- Action buttons -->
<div class="flex gap-2">
<button class="btn btn-primary flex-1" on:click={handlePaint}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5"
>
<path
d="M240-120q-45 0-89-22t-71-58q26 0 53-20.5t27-59.5q0-50 35-85t85-35q50 0 85 35t35 85q0 66-47 113t-113 47Zm230-240L360-470l358-358q11-11 27.5-11.5T774-828l54 54q12 12 12 28t-12 28L470-360Z"
/>
</svg>
Paint
</button>
<button class="btn btn-ghost" on:click={handleFavorite} title="Favorite">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5"
>
<path
d="m354-287 126-76 126 77-33-144 111-96-146-13-58-136-58 135-146 13 111 97-33 143ZM233-120l65-281L80-590l288-25 112-265 112 265 288 25-218 189 65 281-247-149-247 149Zm247-350Z"
/>
</svg>
</button>
<button class="btn btn-ghost" on:click={handleShare} title="Share">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5"
>
<path
d="M720-80q-50 0-85-35t-35-85q0-7 1-14.5t3-13.5L322-392q-17 15-38 23.5t-44 8.5q-50 0-85-35t-35-85q0-50 35-85t85-35q23 0 44 8.5t38 23.5l282-164q-2-6-3-13.5t-1-14.5q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35q-23 0-44-8.5T638-672L356-508q2 6 3 13.5t1 14.5q0 7-1 14.5t-3 13.5l282 164q17-15 38-23.5t44-8.5q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-640q17 0 28.5-11.5T760-760q0-17-11.5-28.5T720-800q-17 0-28.5 11.5T680-760q0 17 11.5 28.5T720-720ZM240-440q17 0 28.5-11.5T280-480q0-17-11.5-28.5T240-520q-17 0-28.5 11.5T200-480q0 17 11.5 28.5T240-440Zm480 280q17 0 28.5-11.5T760-200q0-17-11.5-28.5T720-240q-17 0-28.5 11.5T680-200q0 17 11.5 28.5T720-160Zm0-600ZM240-480Zm480 280Z"
/>
</svg>
</button>
</div>
</div>
@@ -0,0 +1,73 @@
<script lang="ts">
import { createEventDispatcher, onMount } from "svelte";
type Props = {
open?: boolean;
title?: string;
description?: string;
};
export let open = false;
export let title: string = "Sample Modal";
export let description: string = "Use this dialog as a starting point for your own content.";
const dispatch = createEventDispatcher<{ close: void }>();
let dialogElement: HTMLDialogElement | null = null;
onMount(() => {
if (open) {
show();
}
});
$: if (dialogElement) {
open ? dialogElement.showModal() : dialogElement.close();
}
function show() {
dialogElement?.showModal();
open = true;
}
function hide() {
dialogElement?.close();
open = false;
dispatch("close");
}
</script>
<dialog bind:this={dialogElement} class="modal" on:cancel|preventDefault={hide}>
<div class="modal-box sm:max-w-xl">
<header class="flex items-start justify-between gap-4">
<div>
<h2 class="text-2xl font-semibold">{title}</h2>
<p class="text-base-content/70 mt-2 text-sm">{description}</p>
</div>
<button class="btn btn-circle btn-ghost" type="button" on:click={hide}>
</button>
</header>
<slot />
<footer class="mt-6 flex justify-end gap-3">
<button class="btn btn-ghost" type="button" on:click={hide}>
Cancel
</button>
<button class="btn btn-primary" type="button" on:click={hide}>
Okay
</button>
</footer>
</div>
<form method="dialog" class="modal-backdrop" aria-label="Close modal">
<button type="submit">Close</button>
</form>
</dialog>
<style>
:global(.modal.is-open) {
display: flex;
}
</style>
@@ -0,0 +1,61 @@
<script lang="ts">
// Action buttons for Store, Alliance, Explore, Leaderboard
</script>
<div class="flex flex-col items-center gap-3">
<!-- Store -->
<a href="/store" class="btn btn-square shadow-md" title="Store">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5"
>
<path
d="M280-80q-33 0-56.5-23.5T200-160q0-33 23.5-56.5T280-240q33 0 56.5 23.5T360-160q0 33-23.5 56.5T280-80Zm400 0q-33 0-56.5-23.5T600-160q0-33 23.5-56.5T680-240q33 0 56.5 23.5T760-160q0 33-23.5 56.5T680-80ZM246-720l96 200h280l110-200H246Zm-38-80h590q23 0 35 20.5t1 41.5L692-482q-11 20-29.5 31T622-440H324l-44 80h480v80H280q-45 0-68-39.5t-2-78.5l54-98-144-304H40v-80h130l38 80Zm134 280h280-280Z"
/>
</svg>
</a>
<!-- Alliance -->
<a href="/alliance" class="btn btn-square shadow-md" title="Alliance">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5"
>
<path
d="M40-160v-160q0-34 23.5-57t56.5-23h131q20 0 38 10t29 27q29 39 71.5 61t90.5 22q49 0 91.5-22t70.5-61q13-17 30.5-27t36.5-10h131q34 0 57 23t23 57v160H640v-91q-35 25-75.5 38T480-200q-43 0-84-13.5T320-252v92H40Zm440-160q-38 0-72-17.5T351-386q-17-25-42.5-39.5T253-440q22-37 93-58.5T480-520q63 0 134 21.5t93 58.5q-29 0-55 14.5T609-386q-22 32-56 49t-73 17ZM160-440q-50 0-85-35t-35-85q0-51 35-85.5t85-34.5q51 0 85.5 34.5T280-560q0 50-34.5 85T160-440Zm640 0q-50 0-85-35t-35-85q0-51 35-85.5t85-34.5q51 0 85.5 34.5T920-560q0 50-34.5 85T800-440ZM480-560q-50 0-85-35t-35-85q0-51 35-85.5t85-34.5q51 0 85.5 34.5T600-680q0 50-34.5 85T480-560Z"
/>
</svg>
</a>
<!-- Explore -->
<a href="/explore" class="btn btn-square shadow-md" title="Explore">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5"
>
<path
d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q146 0 255.5 91.5T872-559h-82q-19-73-68.5-130.5T600-776v16q0 33-23.5 56.5T520-680h-80v80q0 17-11.5 28.5T400-560h-80v80h80v120h-40L168-552q-3 18-5.5 36t-2.5 36q0 131 92 225t228 95v80Zm364-20L716-228q-21 12-45 20t-51 8q-75 0-127.5-52.5T440-380q0-75 52.5-127.5T620-560q75 0 127.5 52.5T800-380q0 27-8 51t-20 45l128 128-56 56ZM620-280q42 0 71-29t29-71q0-42-29-71t-71-29q-42 0-71 29t-29 71q0 42 29 71t71 29Z"
/>
</svg>
</a>
<!-- Leaderboard -->
<a href="/leaderboard" class="btn btn-square shadow-md" title="Leaderboard">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5"
>
<path
d="M160-200h160v-320H160v320Zm240 0h160v-560H400v560Zm240 0h160v-240H640v240ZM80-120v-480h240v-240h320v320h240v400H80Z"
/>
</svg>
</a>
</div>
@@ -0,0 +1,64 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
function zoomIn() {
dispatch('zoom', { direction: 'in' });
}
function zoomOut() {
dispatch('zoom', { direction: 'out' });
}
function showInfo() {
dispatch('info');
}
</script>
<div class="flex flex-col gap-3">
<!-- Info button -->
<button title="Info" class="btn btn-sm btn-circle" on:click={showInfo}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-3.5"
>
<path
d="M480-680q-33 0-56.5-23.5T400-760q0-33 23.5-56.5T480-840q33 0 56.5 23.5T560-760q0 33-23.5 56.5T480-680Zm-60 560v-480h120v480H420Z"
/>
</svg>
</button>
<!-- Zoom controls -->
<div class="flex flex-col gap-1 max-sm:hidden">
<button title="Zoom in" class="btn btn-sm btn-circle" on:click={zoomIn}>+</button>
<button title="Zoom out" class="btn btn-sm btn-circle" on:click={zoomOut}>-</button>
</div>
<!-- Livestreams link -->
<div class="max-sm:hidden">
<a
href="https://www.twitch.tv/directory/category/wplace"
class="btn btn-sm btn-circle"
target="_blank"
title="Livestreams"
>
<svg
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
viewBox="0 0 2400 2800"
class="size-4"
>
<path fill="#fff" d="m2200 1300-400 400h-400l-350 350v-350H600V200h1600z" />
<g fill="#9146ff">
<path
d="M500 0 0 500v1800h600v500l500-500h400l900-900V0H500zm1700 1300-400 400h-400l-350 350v-350H600V200h1600v1100z"
/>
<path d="M1700 550h200v600h-200zm-550 0h200v600h-200z" />
</g>
</svg>
</a>
</div>
</div>
@@ -0,0 +1,61 @@
<script lang="ts">
import { currentUser } from '$lib/stores/user';
import { t } from '$lib/i18n';
$: alliance = $currentUser?.alliance;
$: role = $currentUser?.allianceRole;
</script>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Alliance</h2>
{#if alliance}
<div class="space-y-3">
<div>
<p class="text-xs uppercase text-base-content/50">Alliance Name</p>
<p class="text-lg font-semibold">{alliance.name}</p>
</div>
{#if alliance.description}
<div>
<p class="text-xs uppercase text-base-content/50">Description</p>
<p class="text-sm">{alliance.description}</p>
</div>
{/if}
<div>
<p class="text-xs uppercase text-base-content/50">Your Role</p>
<p>
<span class="badge badge-primary">
{role === 'owner' ? 'Owner' : role === 'admin' ? 'Admin' : 'Member'}
</span>
</p>
</div>
<div>
<p class="text-xs uppercase text-base-content/50">Alliance Pixels</p>
<p class="text-lg font-semibold">{alliance.pixelsPainted.toLocaleString()}</p>
</div>
<div class="card-actions justify-end mt-4">
<a href="/alliance" class="btn btn-primary btn-sm">
Manage Alliance
</a>
</div>
</div>
{:else}
<div class="space-y-4">
<p class="text-sm text-base-content/60">
You are not part of an alliance. Join one to collaborate with other players!
</p>
<div class="card-actions justify-end">
<a href="/alliance" class="btn btn-primary btn-sm">
Create or Join Alliance
</a>
</div>
</div>
{/if}
</div>
</div>
@@ -0,0 +1,209 @@
<script lang="ts">
import { currentUser } from '$lib/stores/user';
import { api } from '$lib/api/client';
import { t } from '$lib/i18n';
interface FavoriteLocation {
id: number;
name: string;
latitude: number;
longitude: number;
}
let locations: FavoriteLocation[] = [];
let loading = true;
let error = '';
let addingNew = false;
let editingId: number | null = null;
let newName = '';
let newLat = 0;
let newLng = 0;
$: if ($currentUser) {
loadLocations();
}
async function loadLocations() {
if (!$currentUser) return;
loading = true;
error = '';
try {
const userData = await api.getMe();
// Assuming favorite locations are part of user data
// The API might need to be extended if not available
locations = (userData as any).favoriteLocations || [];
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load locations';
} finally {
loading = false;
}
}
function startAdd() {
addingNew = true;
newName = '';
newLat = 0;
newLng = 0;
error = '';
}
function cancelAdd() {
addingNew = false;
editingId = null;
}
async function saveLocation() {
if (!newName.trim()) return;
error = '';
try {
if (editingId) {
await api.updateFavoriteLocation(editingId, {
name: newName.trim(),
latitude: newLat,
longitude: newLng
});
} else {
await api.addFavoriteLocation({
name: newName.trim(),
latitude: newLat,
longitude: newLng
});
}
await loadLocations();
addingNew = false;
editingId = null;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to save location';
}
}
function editLocation(location: FavoriteLocation) {
editingId = location.id;
addingNew = true;
newName = location.name;
newLat = location.latitude;
newLng = location.longitude;
}
function goToLocation(lat: number, lng: number) {
// This would need to communicate with the map component
// For now, just show coordinates
console.log('Navigate to:', lat, lng);
}
</script>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Favorite Locations</h2>
{#if loading}
<div class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if error && !addingNew}
<div class="alert alert-error">
<span>{error}</span>
</div>
{:else}
<div class="space-y-2">
{#each locations as location (location.id)}
<div class="flex items-center justify-between p-3 rounded-box bg-base-200 hover:bg-base-300 transition">
<div class="flex-1">
<p class="font-medium">{location.name}</p>
<p class="text-xs text-base-content/60">
{location.latitude.toFixed(4)}, {location.longitude.toFixed(4)}
</p>
</div>
<div class="flex gap-2">
<button
class="btn btn-ghost btn-xs"
on:click={() => goToLocation(location.latitude, location.longitude)}
>
Go
</button>
<button class="btn btn-ghost btn-xs" on:click={() => editLocation(location)}>
Edit
</button>
</div>
</div>
{:else}
<p class="text-sm text-base-content/60 py-4 text-center">
No favorite locations yet. Add one to save your favorite spots!
</p>
{/each}
</div>
{#if addingNew}
<div class="divider"></div>
<div class="space-y-3">
<div class="form-control">
<label class="label" for="loc-name">
<span class="label-text">Location Name</span>
</label>
<input
id="loc-name"
type="text"
class="input input-bordered input-sm"
bind:value={newName}
placeholder="My favorite spot"
maxlength="50"
/>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="form-control">
<label class="label" for="loc-lat">
<span class="label-text text-xs">Latitude</span>
</label>
<input
id="loc-lat"
type="number"
class="input input-bordered input-sm"
bind:value={newLat}
step="0.0001"
min="-90"
max="90"
/>
</div>
<div class="form-control">
<label class="label" for="loc-lng">
<span class="label-text text-xs">Longitude</span>
</label>
<input
id="loc-lng"
type="number"
class="input input-bordered input-sm"
bind:value={newLng}
step="0.0001"
min="-180"
max="180"
/>
</div>
</div>
{#if error}
<div class="alert alert-error alert-sm">
<span class="text-xs">{error}</span>
</div>
{/if}
<div class="flex gap-2 justify-end">
<button class="btn btn-ghost btn-sm" on:click={cancelAdd}>
{$t('cancel')}
</button>
<button class="btn btn-primary btn-sm" on:click={saveLocation} disabled={!newName.trim()}>
{$t('save')}
</button>
</div>
</div>
{:else}
<button class="btn btn-primary btn-sm btn-block mt-4" on:click={startAdd}>
Add Location
</button>
{/if}
{/if}
</div>
</div>
@@ -0,0 +1,177 @@
<script lang="ts">
import { currentUser, userLevel } from '$lib/stores/user';
import { api } from '$lib/api/client';
import { t } from '$lib/i18n';
import { getLevelProgress } from '$lib/utils/level';
let editing = false;
let name = '';
let discord = '';
let showLastPixel = false;
let saving = false;
let error = '';
$: if ($currentUser && !editing) {
name = $currentUser.name;
discord = $currentUser.discord || '';
showLastPixel = $currentUser.showLastPixel;
}
$: levelProgress = $currentUser ? getLevelProgress($currentUser.pixelsPainted) : 0;
function startEdit() {
editing = true;
error = '';
}
function cancelEdit() {
editing = false;
error = '';
if ($currentUser) {
name = $currentUser.name;
discord = $currentUser.discord || '';
showLastPixel = $currentUser.showLastPixel;
}
}
async function saveProfile() {
if (!$currentUser) return;
saving = true;
error = '';
try {
await currentUser.updateProfile({
name: name.trim(),
discord: discord.trim() || undefined,
showLastPixel
});
editing = false;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update profile';
} finally {
saving = false;
}
}
</script>
{#if $currentUser}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">User Profile</h2>
<div class="flex items-center gap-4 mb-4">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-16 h-16">
{#if $currentUser.picture}
<img src={$currentUser.picture} alt={$currentUser.name} />
{:else}
<span class="text-2xl">{$currentUser.name[0].toUpperCase()}</span>
{/if}
</div>
</div>
<div>
<h3 class="text-xl font-semibold">{$currentUser.name}</h3>
<p class="text-sm text-base-content/60">Level {$userLevel}</p>
</div>
</div>
{#if !editing}
<div class="space-y-3">
<div>
<p class="text-xs uppercase text-base-content/50">Username</p>
<p>{$currentUser.name}</p>
</div>
{#if $currentUser.discord}
<div>
<p class="text-xs uppercase text-base-content/50">Discord</p>
<p>{$currentUser.discord}</p>
</div>
{/if}
<div>
<p class="text-xs uppercase text-base-content/50">Pixels Painted</p>
<p>{$currentUser.pixelsPainted.toLocaleString()}</p>
</div>
<div>
<p class="text-xs uppercase text-base-content/50">Level Progress</p>
<progress class="progress progress-primary w-full" value={levelProgress} max="100"></progress>
<p class="text-xs text-base-content/60 mt-1">{levelProgress.toFixed(1)}% to Level {$userLevel + 1}</p>
</div>
<div>
<p class="text-xs uppercase text-base-content/50">Droplets</p>
<p class="text-lg font-semibold">{$currentUser.droplets.toLocaleString()}</p>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" class="checkbox checkbox-sm" bind:checked={$currentUser.showLastPixel} disabled />
<span class="label-text">Show last painted pixel</span>
</label>
</div>
</div>
<div class="card-actions justify-end mt-4">
<button class="btn btn-primary btn-sm" on:click={startEdit}>
{$t('edit')}
</button>
</div>
{:else}
<div class="space-y-3">
<div class="form-control">
<label class="label" for="name">
<span class="label-text">Username</span>
</label>
<input
id="name"
type="text"
class="input input-bordered"
bind:value={name}
maxlength="32"
required
/>
</div>
<div class="form-control">
<label class="label" for="discord">
<span class="label-text">Discord (optional)</span>
</label>
<input
id="discord"
type="text"
class="input input-bordered"
bind:value={discord}
maxlength="32"
placeholder="username#0000"
/>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" class="checkbox" bind:checked={showLastPixel} />
<span class="label-text">Show last painted pixel</span>
</label>
</div>
{#if error}
<div class="alert alert-error">
<span>{error}</span>
</div>
{/if}
</div>
<div class="card-actions justify-end mt-4 gap-2">
<button class="btn btn-ghost btn-sm" on:click={cancelEdit} disabled={saving}>
{$t('cancel')}
</button>
<button class="btn btn-primary btn-sm" on:click={saveProfile} disabled={saving || !name.trim()}>
{#if saving}Saving...{:else}{$t('save')}{/if}
</button>
</div>
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,408 @@
<script lang="ts">
import { currentUser, userLevel } from '$lib/stores/user';
import { getLevelProgress } from '$lib/utils/level';
import { minidenticon } from 'minidenticons';
let dropdownOpen = false;
let showEditProfile = false;
let isMuted = false;
$: levelProgress = $currentUser ? getLevelProgress($currentUser.pixelsPainted) : 0;
$: svgURI = $currentUser
? 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon($currentUser.name, 90, 50))
: '';
function toggleDropdown() {
dropdownOpen = !dropdownOpen;
}
function closeDropdown() {
dropdownOpen = false;
}
function toggleMute() {
isMuted = !isMuted;
}
</script>
{#if $currentUser}
<div class="relative dropdown dropdown-end">
<!-- Profile Button -->
<button
on:click={toggleDropdown}
class="btn size-12 p-0 shadow-md bg-primary"
title="Show profile"
>
<div class="relative w-max">
<!-- Background circle -->
<div class="bg-base-content/20 size-12 rounded-full"></div>
<!-- Level progress ring -->
<div
class="level-fill center-absolute absolute size-12 rotate-[215deg] rounded-full"
style="--angle: {(levelProgress / 100) * 360}deg; --color: var(--color-secondary);"
></div>
<!-- Avatar -->
<div class="avatar center-absolute absolute">
<div class="size-10 rounded-full">
<div class="bg-base-200 minidenticon">
{@html minidenticon($currentUser.name, 90, 50)}
</div>
</div>
<!-- Edit profile button -->
<button
class="btn btn-circle btn-sm absolute -bottom-1 -right-1"
on:click|stopPropagation={() => (showEditProfile = true)}
title="Edit profile"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-4"
>
<path
d="M200-200h57l391-391-57-57-391 391v57Zm-80 80v-170l528-527q12-11 26.5-17t30.5-6q16 0 31 6t26 18l55 56q12 11 17.5 26t5.5 30q0 16-5.5 30.5T817-647L290-120H120Zm640-584-56-56 56 56Zm-141 85-28-29 57 57-29-28Z"
/>
</svg>
</button>
</div>
<!-- Level badge -->
<div
class="text-primary-content bg-secondary absolute bottom-0 left-0 -left-1 flex items-center justify-center rounded-full px-[5px] py-0 text-xs font-bold"
>
{$userLevel}
</div>
</div>
</button>
<!-- Dropdown Menu -->
{#if dropdownOpen}
<div
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-50 relative right-1 w-[min(100vw-24px,400px)] translate-y-2 border p-4 shadow-md"
>
<!-- Close button -->
<button on:click={closeDropdown} class="btn btn-ghost btn-circle absolute right-2 top-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5"
>
<path
d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"
/>
</svg>
</button>
<!-- Profile Info -->
<section class="flex gap-2">
<div class="relative">
<div class="avatar relative rounded-full border-3 border-primary">
<div class="border-base-300 size-20 rounded-full border-2">
<div class="bg-base-200 minidenticon">
{@html minidenticon($currentUser.name, 90, 50)}
</div>
</div>
</div>
</div>
<div>
<div class="flex items-center gap-1.5 pr-8 text-lg font-medium">
<h3 class="line-clamp-1 text-ellipsis text-lg" title={$currentUser.name}>
{$currentUser.name}
</h3>
<span class="text-rose-500">#{$currentUser.id}</span>
{#if $currentUser.country}
<span class="tooltip font-flag ml-0.5" data-tip={$currentUser.country}>
{$currentUser.country === 'US' ? '🇺🇸' : '🇧🇷'}
</span>
{/if}
</div>
{#if $currentUser.discord}
<div class="mt-1">
<span class="tooltip h-4">
<div class="tooltip-content">
<span>Discord: {$currentUser.discord}</span>
</div>
<button class="flex items-center gap-1 text-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 127.14 96.36"
fill="currentColor"
class="size-4"
>
<path
d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"
/>
</svg>
</button>
</span>
</div>
{/if}
<div class="flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="inline size-4"
>
<path
d="M240-120q-45 0-89-22t-71-58q26 0 53-20.5t27-59.5q0-50 35-85t85-35q50 0 85 35t35 85q0 66-47 113t-113 47Zm230-240L360-470l358-358q11-11 27.5-11.5T774-828l54 54q12 12 12 28t-12 28L470-360Z"
/>
</svg>
<span
>Pixels painted: <span class="text-primary font-semibold"
>{$currentUser.pixelsPainted.toLocaleString()}</span
></span
>
</div>
<div class="flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="inline size-4"
>
<path
d="M440-160v-487L216-423l-56-57 320-320 320 320-56 57-224-224v487h-80Z"
/>
</svg>
<span class="text-secondary">
<span class="font-semibold">Level {$userLevel}</span> ({levelProgress.toFixed(0)}%)
</span>
</div>
</div>
</section>
<!-- Menu Items -->
<section class="mt-3 flex flex-col gap-2">
<div class="mb-1 flex items-center justify-between">
<h3 class="text-lg font-semibold">Menu</h3>
<div class="flex items-center gap-1">
<!-- Language selector -->
<div class="dropdown dropdown-end">
<button class="btn btn-sm btn-circle" tabindex="0" title="Language">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5"
>
<path
d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-156t86-127Q252-817 325-848.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 82-31.5 155T763-197.5q-54 54.5-127 86T480-80Zm0-82q26-36 45-75t31-83H404q12 44 31 83t45 75Zm-104-16q-18-33-31.5-68.5T322-320H204q29 50 72.5 87t99.5 55Zm208 0q56-18 99.5-55t72.5-87H638q-9 38-22.5 73.5T584-178ZM170-400h136q-3-20-4.5-39.5T300-480q0-21 1.5-40.5T306-560H170q-5 20-7.5 39.5T160-480q0 21 2.5 40.5T170-400Zm216 0h188q3-20 4.5-39.5T580-480q0-21-1.5-40.5T574-560H386q-3 20-4.5 39.5T380-480q0 21 1.5 40.5T386-400Zm268 0h136q5-20 7.5-39.5T800-480q0-21-2.5-40.5T790-560H654q3 20 4.5 39.5T660-480q0 21-1.5 40.5T654-400Zm-16-240h118q-29-50-72.5-87T584-782q18 33 31.5 68.5T638-640Zm-234 0h152q-12-44-31-83t-45-75q-26 36-45 75t-31 83Zm-200 0h118q9-38 22.5-73.5T376-782q-56 18-99.5 55T204-640Z"
/>
</svg>
</button>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 border border-base-300 p-2 shadow"
>
<li>
<button class="font-flag">🇺🇸 English</button>
</li>
<li>
<button class="font-flag">🇧🇷 Português</button>
</li>
</ul>
</div>
<!-- Mute button -->
<button
class="btn btn-sm btn-circle"
on:click={toggleMute}
title={isMuted ? 'Unmute' : 'Mute'}
>
{#if isMuted}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5"
>
<path
d="M792-56 671-177q-25 16-53 27.5T560-131v-82q14-5 27.5-10t25.5-12L480-368v208L280-360H120v-240h128L56-792l56-56 736 736-56 56Zm-8-232-58-58q17-31 25.5-65t8.5-70q0-94-55-168T560-749v-82q124 28 202 125.5T840-481q0 53-14.5 102T784-288ZM650-422l-90-90v-130q47 22 73.5 66t26.5 96q0 15-2.5 29.5T650-422ZM480-592 376-696l104-104v208Zm-80 238v-94l-72-72H200v80h114l86 86Zm-36-130Z"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5"
>
<path
d="M560-131v-82q90-26 145-100t55-168q0-94-55-168T560-749v-82q124 28 202 125.5T840-481q0 127-78 224.5T560-131ZM120-360v-240h160l200-200v640L280-360H120Zm440 40v-322q47 22 73.5 66t26.5 96q0 51-26.5 94.5T560-320ZM400-606l-86 86H200v80h114l86 86v-252ZM300-480Z"
/>
</svg>
{/if}
</button>
</div>
</div>
<a class="btn w-full" href="/profile">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5"
>
<path
d="M234-276q51-39 114-61.5T480-360q69 0 132 22.5T726-276q35-41 54.5-93T800-480q0-133-93.5-226.5T480-800q-133 0-226.5 93.5T160-480q0 59 19.5 111t54.5 93Zm246-164q-59 0-99.5-40.5T340-580q0-59 40.5-99.5T480-720q59 0 99.5 40.5T620-580q0 59-40.5 99.5T480-440Zm0 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q53 0 100-15.5t86-44.5q-39-29-86-44.5T480-280q-53 0-100 15.5T294-220q39 29 86 44.5T480-160Zm0-360q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm0-60Zm0 360Z"
/>
</svg>
Profile
</a>
<a class="btn w-full" href="/alliance">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5"
>
<path
d="M40-160v-160q0-34 23.5-57t56.5-23h131q20 0 38 10t29 27q29 39 71.5 61t90.5 22q49 0 91.5-22t70.5-61q13-17 30.5-27t36.5-10h131q34 0 57 23t23 57v160H640v-91q-35 25-75.5 38T480-200q-43 0-84-13.5T320-252v92H40Zm440-160q-38 0-72-17.5T351-386q-17-25-42.5-39.5T253-440q22-37 93-58.5T480-520q63 0 134 21.5t93 58.5q-29 0-55 14.5T609-386q-22 32-56 49t-73 17Z"
/>
</svg>
Alliance
</a>
<a class="btn w-full" href="/store">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5"
>
<path
d="M280-80q-33 0-56.5-23.5T200-160q0-33 23.5-56.5T280-240q33 0 56.5 23.5T360-160q0 33-23.5 56.5T280-80Zm400 0q-33 0-56.5-23.5T600-160q0-33 23.5-56.5T680-240q33 0 56.5 23.5T760-160q0 33-23.5 56.5T680-80ZM246-720l96 200h280l110-200H246Zm-38-80h590q23 0 35 20.5t1 41.5L692-482q-11 20-29.5 31T622-440H324l-44 80h480v80H280q-45 0-68-39.5t-2-78.5l54-98-144-304H40v-80h130l38 80Z"
/>
</svg>
Store
</a>
<a class="btn w-full" href="/leaderboard">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5"
>
<path
d="M160-200h160v-320H160v320Zm240 0h160v-560H400v560Zm240 0h160v-240H640v240ZM80-120v-480h240v-240h320v320h240v400H80Z"
/>
</svg>
Leaderboard
</a>
<!-- Social Media Buttons -->
<a
class="btn w-full"
href="https://www.twitch.tv/directory/category/wplace"
target="_blank"
rel="noopener noreferrer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 268"
fill="currentColor"
class="size-5"
>
<path
d="M17.458 0L0 46.556v186.201h63.983v34.934h34.931l34.898-34.934h52.36L256 162.954V0H17.458zm23.259 23.263H232.73v128.029l-40.739 40.741H128L93.113 226.92v-34.886H40.717V23.263zm64.008 116.405H128V69.844h-23.275v69.824zm63.997 0h23.27V69.844h-23.27v69.824z"
/>
</svg>
Livestreams
</a>
<a
class="btn w-full"
href="https://discord.gg/wplace"
target="_blank"
rel="noopener noreferrer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 127.14 96.36"
fill="currentColor"
class="size-5"
>
<path
d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"
/>
</svg>
Discord
</a>
<a
class="btn w-full"
href="https://www.reddit.com/r/wplace_unofficial"
target="_blank"
rel="noopener noreferrer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-5"
>
<path
d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0zm4.388 3.199c.605 0 1.126.21 1.551.628.426.418.64.948.64 1.59 0 .613-.213 1.128-.64 1.546-.425.418-.946.628-1.551.628-.578 0-1.075-.21-1.486-.628-.412-.418-.619-.933-.619-1.546 0-.642.207-1.172.619-1.59.411-.418.908-.628 1.486-.628zM5.423 9.636c.853 0 1.573.3 2.16.902.586.6.879 1.334.879 2.201 0 .866-.293 1.6-.879 2.2-.587.6-1.307.9-2.16.9-.852 0-1.573-.3-2.159-.9-.587-.6-.88-1.334-.88-2.2 0-.867.293-1.601.88-2.2.586-.602 1.307-.903 2.159-.903zm13.154 0c.853 0 1.574.3 2.161.902.586.6.879 1.334.879 2.201 0 .866-.293 1.6-.879 2.2-.587.6-1.308.9-2.161.9-.852 0-1.573-.3-2.159-.9-.587-.6-.88-1.334-.88-2.2 0-.867.293-1.601.88-2.2.586-.602 1.307-.903 2.159-.903zM12 15.314c1.777 0 3.309.48 4.597 1.44.964.72 1.446 1.584 1.446 2.592H5.957c0-1.008.482-1.872 1.445-2.592 1.289-.96 2.821-1.44 4.598-1.44z"
/>
</svg>
Reddit
</a>
<button class="btn w-full" on:click={() => currentUser.logout()}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5"
>
<path
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h280v80H200Zm440-160-55-58 102-102H360v-80h327L585-622l55-58 200 200-200 200Z"
/>
</svg>
Log Out
</button>
</section>
</div>
{/if}
</div>
{:else}
<a href="/join" class="btn btn-primary shadow-md">Login</a>
{/if}
<style>
.level-fill {
background: conic-gradient(
var(--color) 0deg,
var(--color) var(--angle),
transparent var(--angle)
);
-webkit-mask: radial-gradient(
farthest-side,
transparent calc(100% - 3px),
white calc(100% - 2px)
);
mask: radial-gradient(farthest-side, transparent calc(100% - 3px), white calc(100% - 2px));
}
.center-absolute {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.minidenticon :global(svg) {
width: 100%;
height: 100%;
}
</style>
+247 -194
View File
@@ -1,210 +1,263 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import Header from '../lib/components/layout/Header.svelte';
import CanvasMap from '../lib/components/canvas/CanvasMap.svelte';
import ColorPalette from '../lib/components/canvas/ColorPalette.svelte';
import { api, ApiError } from '../lib/api/client';
import { currentUser, currentCharges } from '../lib/stores/user';
import { selectedColor } from '../lib/stores/canvas';
import { online } from '../lib/stores/global';
import { calculateLevel } from '../lib/utils/level';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import CanvasMap from '../lib/components/canvas/CanvasMap.svelte';
import UserProfileButton from '../lib/components/user/UserProfileButton.svelte';
import PaintButton from '../lib/components/canvas/PaintButton.svelte';
import ActionButtons from '../lib/components/layout/ActionButtons.svelte';
import MapControls from '../lib/components/layout/MapControls.svelte';
import ColorPaletteModal from '../lib/components/canvas/ColorPaletteModal.svelte';
import PixelInfoPopup from '../lib/components/canvas/PixelInfoPopup.svelte';
import { api, ApiError } from '../lib/api/client';
import { currentUser, currentCharges } from '../lib/stores/user';
import { selectedColor } from '../lib/stores/canvas';
import { online } from '../lib/stores/global';
interface SelectedPixel {
tileX: number;
tileY: number;
x: number;
y: number;
latlng: { lat: number; lng: number };
}
interface SelectedPixel {
tileX: number;
tileY: number;
x: number;
y: number;
latlng: { lat: number; lng: number };
}
interface PixelInfo {
paintedBy: {
id: number;
name: string;
allianceId: number;
allianceName: string;
equippedFlag: number;
};
region: Record<string, unknown> | null;
}
const SEASON = 's0';
const SEASON = 's0';
let mapRef: CanvasMap | null = null;
let selected: SelectedPixel | null = null;
let pixelInfo: any = null;
let loadingPixel = false;
let painting = false;
let errorMessage = '';
let successMessage = '';
let showColorPalette = false;
let mapRef: CanvasMap | null = null;
let selected: SelectedPixel | null = null;
let pixelInfo: PixelInfo | null = null;
let loadingPixel = false;
let painting = false;
let errorMessage = '';
let successMessage = '';
onMount(() => {
currentUser.fetch();
});
onMount(() => {
currentUser.fetch();
});
async function handlePixel(event: CustomEvent<SelectedPixel>) {
selected = event.detail;
errorMessage = '';
successMessage = '';
pixelInfo = null;
await loadPixelInfo(event.detail);
}
async function handlePixel(event: CustomEvent<SelectedPixel>) {
selected = event.detail;
errorMessage = '';
successMessage = '';
pixelInfo = null;
await loadPixelInfo(event.detail);
}
async function loadPixelInfo(selection: SelectedPixel) {
loadingPixel = true;
try {
pixelInfo = await api.getPixelInfo(
SEASON,
selection.tileX,
selection.tileY,
selection.x,
selection.y
);
} catch (error) {
console.error('Failed to load pixel info:', error);
} finally {
loadingPixel = false;
}
}
async function loadPixelInfo(selection: SelectedPixel) {
loadingPixel = true;
try {
pixelInfo = await api.getPixelInfo(SEASON, selection.tileX, selection.tileY, selection.x, selection.y);
} catch (error) {
if (error instanceof ApiError) {
errorMessage = error.message;
} else {
errorMessage = 'Failed to load pixel info.';
}
} finally {
loadingPixel = false;
}
}
function canPaint(): boolean {
const user = get(currentUser);
if (!user || !selected) return false;
const color = get(selectedColor);
if (color === 0) return false;
const charges = get(currentCharges);
return charges > 0 && get(online);
}
function canPaint(): boolean {
const user = get(currentUser);
if (!user || !selected) return false;
const color = get(selectedColor);
if (color === 0) return false;
const charges = get(currentCharges);
return charges > 0;
}
async function paint() {
if (!selected) return;
const user = get(currentUser);
if (!user) {
errorMessage = 'Login required to paint.';
return;
}
const color = get(selectedColor);
if (color === 0) {
errorMessage = 'Select a color to paint.';
return;
}
const charges = get(currentCharges);
if (charges <= 0) {
errorMessage = 'Not enough charges.';
return;
}
async function paint() {
if (!selected) return;
const user = get(currentUser);
if (!user) {
errorMessage = 'Login required to paint.';
return;
}
const color = get(selectedColor);
if (color === 0) {
errorMessage = 'Select a color to paint.';
return;
}
const charges = get(currentCharges);
if (charges <= 0) {
errorMessage = 'Not enough charges.';
return;
}
painting = true;
errorMessage = '';
successMessage = '';
painting = true;
errorMessage = '';
successMessage = '';
try {
await api.paintPixels(SEASON, selected.tileX, selected.tileY, {
colors: [color],
coords: [selected.x, selected.y]
});
await currentUser.fetch();
await loadPixelInfo(selected);
mapRef?.refreshTiles();
successMessage = 'Pixel painted!';
} catch (error) {
if (error instanceof ApiError) {
errorMessage = error.message;
} else {
errorMessage = 'Failed to paint pixel.';
}
} finally {
painting = false;
}
}
try {
await api.paintPixels(SEASON, selected.tileX, selected.tileY, {
colors: [color],
coords: [selected.x, selected.y]
});
await currentUser.fetch();
await loadPixelInfo(selected);
mapRef?.refreshTiles();
successMessage = 'Pixel painted!';
setTimeout(() => (successMessage = ''), 2000);
} catch (error) {
if (error instanceof ApiError) {
errorMessage = error.message;
} else {
errorMessage = 'Failed to paint pixel.';
}
} finally {
painting = false;
}
}
</script>
<Header />
<svelte:head>
<title>wplace - Paint the World</title>
</svelte:head>
<main class="mx-auto w-full max-w-7xl px-4 py-6 gap-6 flex flex-col lg:flex-row">
<section class="flex-1 h-[70vh] lg:h-[calc(100vh-8rem)]">
<CanvasMap bind:this={mapRef} on:pixel={handlePixel} />
</section>
<aside class="w-full lg:max-w-md flex flex-col gap-6">
<section class="rounded-box border border-base-300 bg-base-100 p-4 shadow">
{#if $currentUser}
<div class="flex items-start justify-between">
<div>
<h2 class="text-lg font-semibold">Hello, {$currentUser.name}</h2>
<p class="text-sm text-base-content/60">Level {calculateLevel($currentUser.pixelsPainted)} · {$currentUser.pixelsPainted.toLocaleString()} pixels</p>
</div>
</div>
<div class="mt-4 grid grid-cols-2 gap-3 text-sm">
<div class="rounded-box bg-base-200 p-3">
<p class="text-xs uppercase text-base-content/60">Charges</p>
<p class="text-lg font-semibold">{$currentCharges}/{$currentUser.maxCharges}</p>
</div>
<div class="rounded-box bg-base-200 p-3">
<p class="text-xs uppercase text-base-content/60">Droplets</p>
<p class="text-lg font-semibold">{$currentUser.droplets.toLocaleString()}</p>
</div>
</div>
{:else}
<p class="text-sm text-base-content/70">
Login to start painting the world.
<a href="/join" class="btn btn-sm btn-primary mt-3">Login</a>
</p>
{/if}
{#if !$online}
<p class="mt-4 rounded-box bg-warning/20 px-3 py-2 text-sm text-warning">You are offline. Painting is disabled.</p>
{/if}
</section>
<!-- Full-screen map container -->
<div class="disable-pinch-zoom relative h-screen w-screen overflow-hidden">
<!-- Map -->
<div class="h-screen w-screen">
<CanvasMap bind:this={mapRef} on:pixel={handlePixel} />
</div>
<section class="rounded-box border border-base-300 bg-base-100 p-4 shadow">
<h2 class="text-base font-semibold">Palette</h2>
<ColorPalette />
<button
class="btn btn-primary btn-block mt-4"
on:click={paint}
disabled={!canPaint() || painting || !$online}
>
{#if painting}Painting...{:else}Paint Pixel{/if}
</button>
{#if errorMessage}
<p class="mt-3 rounded-box bg-error/10 px-3 py-2 text-sm text-error">{errorMessage}</p>
{/if}
{#if successMessage}
<p class="mt-3 rounded-box bg-success/10 px-3 py-2 text-sm text-success">{successMessage}</p>
{/if}
</section>
<!-- Top-right: User profile & actions -->
<div class="absolute right-2 top-2 z-30">
<div class="flex flex-col gap-4 items-center">
<UserProfileButton />
<ActionButtons />
</div>
</div>
<section class="rounded-box border border-base-300 bg-base-100 p-4 shadow">
<h2 class="text-base font-semibold">Pixel details</h2>
{#if !selected}
<p class="mt-2 text-sm text-base-content/60">Click the map to inspect a pixel.</p>
{:else if loadingPixel}
<p class="mt-2 text-sm text-base-content/60">Loading...</p>
{:else if pixelInfo}
<div class="mt-3 space-y-3 text-sm">
<div>
<p class="text-xs uppercase text-base-content/50">Coordinates</p>
<p>{selected.tileX}, {selected.tileY} · {selected.x}, {selected.y}</p>
</div>
<div>
<p class="text-xs uppercase text-base-content/50">Painter</p>
{#if pixelInfo.paintedBy.id !== 0}
<p>{pixelInfo.paintedBy.name}</p>
{#if pixelInfo.paintedBy.allianceName}
<p class="text-base-content/60">Alliance: {pixelInfo.paintedBy.allianceName}</p>
{/if}
{:else}
<p>Unpainted</p>
{/if}
</div>
{#if pixelInfo.region}
<div>
<p class="text-xs uppercase text-base-content/50">Region</p>
<p>{pixelInfo.region?.name ?? 'Unknown'}</p>
</div>
{/if}
</div>
{:else}
<p class="mt-2 text-sm text-base-content/60">No data for this pixel.</p>
{/if}
</section>
</aside>
</main>
<!-- Top-left: Map controls -->
<div class="absolute left-2 top-2 z-30">
<MapControls
on:zoom={(e) => {
if (e.detail.direction === 'in') mapRef?.getMap()?.zoomIn();
else mapRef?.getMap()?.zoomOut();
}}
on:info={() => alert('wplace - Collaborative pixel art canvas')}
/>
</div>
<!-- Bottom-center: Paint button and tooltip -->
<div class="absolute bottom-3 left-1/2 z-30 -translate-x-1/2 flex flex-col items-center gap-2">
<!-- Tooltip message -->
{#if selected && !painting}
<div class="bg-base-100 rounded-full px-4 py-2 shadow-md flex items-center gap-2 text-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-4"
>
<path
d="M240-120q-45 0-89-22t-71-58q26 0 53-20.5t27-59.5q0-50 35-85t85-35q50 0 85 35t35 85q0 66-47 113t-113 47Zm230-240L360-470l358-358q11-11 27.5-11.5T774-828l54 54q12 12 12 28t-12 28L470-360Z"
/>
</svg>
Click or hold <kbd class="kbd kbd-sm">SPACE</kbd> to paint.
</div>
{/if}
<PaintButton
disabled={!canPaint() || painting}
{painting}
on:click={paint}
on:openPalette={() => (showColorPalette = true)}
/>
</div>
<!-- Bottom-left: Toggle art opacity -->
<div class="absolute bottom-3 left-3 z-30">
<button
title="Toggle art opacity"
class="btn btn-lg btn-square sm:btn-xl shadow-md text-base-content/80"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5"
>
<path
d="M440-440v-80h80v80h-80Zm-80 80v-80h80v80h-80Zm160 0v-80h80v80h-80Zm80-80v-80h80v80h-80Zm-320 0v-80h80v80h-80Zm-80 320q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm80-80h80v-80h-80v80Zm160 0h80v-80h-80v80Zm320 0v-80 80Zm-560-80h80v-80h80v80h80v-80h80v80h80v-80h80v80h80v-80h-80v-80h80v-320H200v320h80v80h-80v80Zm0 80v-560 560Zm560-240v80-80ZM600-280v80h80v-80h-80Z"
/>
</svg>
</button>
</div>
<!-- Bottom-right: My location -->
<div class="absolute bottom-3 right-3 z-30">
<button title="My location" class="btn btn-lg btn-square sm:btn-xl shadow-md">
<div class="relative">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
class="size-5.5 fill-red-400"
>
<path
d="M440-40v-80q-125-14-214.5-103.5T122-438H42v-80h80q14-125 103.5-214.5T440-836v-80h80v80q125 14 214.5 103.5T838-518h80v80h-80q-14 125-103.5 214.5T520-120v80h-80Zm40-158q116 0 198-82t82-198q0-116-82-198t-198-82q-116 0-198 82t-82 198q0 116 82 198t198 82Z"
/>
</svg>
<span class="center-absolute absolute text-[10px] text-red-400">?</span>
</div>
</button>
</div>
<!-- Pixel Info Popup -->
{#if selected && pixelInfo && !showColorPalette}
<div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-40">
<PixelInfoPopup
{pixelInfo}
coords={{ tileX: selected.tileX, tileY: selected.tileY, x: selected.x, y: selected.y }}
on:paint={paint}
on:favorite={() => console.log('Favorite')}
on:share={() => console.log('Share')}
/>
</div>
{/if}
<!-- Color Palette Modal -->
{#if showColorPalette}
<ColorPaletteModal on:close={() => (showColorPalette = false)} />
{/if}
<!-- Success/Error Messages -->
{#if successMessage}
<div class="absolute top-20 left-1/2 -translate-x-1/2 z-40">
<div class="alert alert-success shadow-lg">
<span>{successMessage}</span>
</div>
</div>
{/if}
{#if errorMessage}
<div class="absolute top-20 left-1/2 -translate-x-1/2 z-40">
<div class="alert alert-error shadow-lg">
<span>{errorMessage}</span>
</div>
</div>
{/if}
</div>
<style>
.disable-pinch-zoom {
touch-action: pan-x pan-y;
}
.center-absolute {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
:global(.size-5\.5) {
width: 1.375rem;
height: 1.375rem;
}
</style>
@@ -0,0 +1,329 @@
<script lang="ts">
import { onMount } from 'svelte';
import { currentUser } from '$lib/stores/user';
import { api } from '$lib/api/client';
import { t } from '$lib/i18n';
let loading = true;
let error = '';
let success = '';
// Alliance creation
let creatingAlliance = false;
let newAllianceName = '';
// Join alliance
let joiningAlliance = false;
let inviteCode = '';
// Alliance details
let allianceDetails: any = null;
let members: any[] = [];
let invites: any[] = [];
// Editing
let editingDescription = false;
let newDescription = '';
$: hasAlliance = $currentUser?.allianceId !== null;
$: isOwnerOrAdmin = $currentUser?.allianceRole === 'owner' || $currentUser?.allianceRole === 'admin';
onMount(async () => {
await loadAllianceData();
});
async function loadAllianceData() {
if (!$currentUser) return;
loading = true;
error = '';
try {
if (hasAlliance) {
allianceDetails = await api.getAlliance();
const membersResult = await api.getAllianceMembers(1);
members = membersResult?.members || [];
if (isOwnerOrAdmin) {
const invitesResult = await api.getAllianceInvites();
invites = invitesResult?.invites || [];
}
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load alliance data';
} finally {
loading = false;
}
}
async function createAlliance() {
if (!newAllianceName.trim()) return;
creatingAlliance = true;
error = '';
success = '';
try {
await api.createAlliance(newAllianceName.trim());
await currentUser.fetch();
await loadAllianceData();
success = 'Alliance created successfully!';
newAllianceName = '';
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create alliance';
} finally {
creatingAlliance = false;
}
}
async function joinAlliance() {
if (!inviteCode.trim()) return;
joiningAlliance = true;
error = '';
success = '';
try {
await api.joinAlliance(inviteCode.trim());
await currentUser.fetch();
await loadAllianceData();
success = 'Joined alliance successfully!';
inviteCode = '';
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to join alliance';
} finally {
joiningAlliance = false;
}
}
async function updateDescription() {
if (!newDescription.trim()) return;
error = '';
success = '';
try {
await api.updateAllianceDescription(newDescription.trim());
await loadAllianceData();
editingDescription = false;
success = 'Description updated successfully!';
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update description';
}
}
function startEditDescription() {
newDescription = allianceDetails?.description || '';
editingDescription = true;
}
</script>
<svelte:head>
<title>Alliance - FurryPlace</title>
</svelte:head>
<div class="mx-auto max-w-6xl px-4 py-6">
<h1 class="text-3xl font-bold mb-6">Alliance</h1>
{#if error}
<div class="alert alert-error mb-6">
<span>{error}</span>
</div>
{/if}
{#if success}
<div class="alert alert-success mb-6">
<span>{success}</span>
</div>
{/if}
{#if loading}
<div class="flex justify-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if !hasAlliance}
<!-- Create or Join Alliance -->
<div class="grid gap-6 md:grid-cols-2">
<!-- Create Alliance -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Create Alliance</h2>
<p class="text-sm text-base-content/60">
Start your own alliance and invite other players to join you.
</p>
<div class="form-control mt-4">
<label class="label" for="alliance-name">
<span class="label-text">Alliance Name</span>
</label>
<input
id="alliance-name"
type="text"
class="input input-bordered"
bind:value={newAllianceName}
placeholder="My Awesome Alliance"
maxlength="32"
/>
</div>
<div class="card-actions justify-end mt-4">
<button
class="btn btn-primary"
on:click={createAlliance}
disabled={creatingAlliance || !newAllianceName.trim()}
>
{creatingAlliance ? 'Creating...' : 'Create Alliance'}
</button>
</div>
</div>
</div>
<!-- Join Alliance -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Join Alliance</h2>
<p class="text-sm text-base-content/60">
Have an invite code? Join an existing alliance here.
</p>
<div class="form-control mt-4">
<label class="label" for="invite-code">
<span class="label-text">Invite Code</span>
</label>
<input
id="invite-code"
type="text"
class="input input-bordered"
bind:value={inviteCode}
placeholder="ABC123"
maxlength="20"
/>
</div>
<div class="card-actions justify-end mt-4">
<button
class="btn btn-primary"
on:click={joinAlliance}
disabled={joiningAlliance || !inviteCode.trim()}
>
{joiningAlliance ? 'Joining...' : 'Join Alliance'}
</button>
</div>
</div>
</div>
</div>
{:else if allianceDetails}
<!-- Alliance Management -->
<div class="space-y-6">
<!-- Alliance Info -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex justify-between items-start">
<div>
<h2 class="card-title text-2xl">{allianceDetails.name}</h2>
<p class="text-sm text-base-content/60">
{members.length} member{members.length !== 1 ? 's' : ''}
</p>
</div>
<div class="text-right">
<p class="text-xs text-base-content/50">Total Pixels</p>
<p class="text-2xl font-bold text-primary">{allianceDetails.pixelsPainted.toLocaleString()}</p>
</div>
</div>
<div class="divider"></div>
{#if !editingDescription}
<div>
<div class="flex justify-between items-center mb-2">
<p class="text-xs uppercase text-base-content/50">Description</p>
{#if isOwnerOrAdmin}
<button class="btn btn-ghost btn-xs" on:click={startEditDescription}>Edit</button>
{/if}
</div>
<p class="text-sm">{allianceDetails.description || 'No description yet.'}</p>
</div>
{:else}
<div>
<textarea
class="textarea textarea-bordered w-full"
bind:value={newDescription}
placeholder="Alliance description..."
rows="3"
maxlength="500"
></textarea>
<div class="flex gap-2 justify-end mt-2">
<button class="btn btn-ghost btn-sm" on:click={() => (editingDescription = false)}>
Cancel
</button>
<button class="btn btn-primary btn-sm" on:click={updateDescription}>
Save
</button>
</div>
</div>
{/if}
</div>
</div>
<!-- Members -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title">Members</h3>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th>Pixels</th>
{#if isOwnerOrAdmin}
<th>Actions</th>
{/if}
</tr>
</thead>
<tbody>
{#each members as member}
<tr>
<td>{member.name}</td>
<td>
<span class="badge" class:badge-primary={member.role === 'owner'}>
{member.role}
</span>
</td>
<td>{member.pixelsPainted?.toLocaleString() || '0'}</td>
{#if isOwnerOrAdmin && member.id !== $currentUser?.id}
<td>
<button class="btn btn-ghost btn-xs">Manage</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
<!-- Invites (Owner/Admin only) -->
{#if isOwnerOrAdmin && invites.length > 0}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title">Pending Invites</h3>
<div class="space-y-2">
{#each invites as invite}
<div class="flex justify-between items-center p-3 rounded-box bg-base-200">
<div>
<p class="font-medium">Code: {invite.code}</p>
<p class="text-xs text-base-content/60">
Created {new Date(invite.createdAt).toLocaleDateString()}
</p>
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,216 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api/client';
import { t } from '$lib/i18n';
import { calculateLevel } from '$lib/utils/level';
import type { LeaderboardMode } from '$lib/constants/config';
type TabType = 'player' | 'alliance' | 'country';
let activeTab: TabType = 'player';
let activeMode: LeaderboardMode = 'all-time';
let loading = true;
let error = '';
let playerData: any[] = [];
let allianceData: any[] = [];
let countryData: any[] = [];
const modes: LeaderboardMode[] = ['today', 'week', 'month', 'all-time'];
onMount(() => {
loadLeaderboard();
});
$: {
activeTab;
activeMode;
loadLeaderboard();
}
async function loadLeaderboard() {
loading = true;
error = '';
try {
if (activeTab === 'player') {
const result = await api.getPlayerLeaderboard(activeMode);
playerData = result?.players || [];
} else if (activeTab === 'alliance') {
const result = await api.getAllianceLeaderboardGlobal(activeMode);
allianceData = result?.alliances || [];
} else if (activeTab === 'country') {
const result = await api.getCountryLeaderboard(activeMode);
countryData = result?.countries || [];
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load leaderboard';
} finally {
loading = false;
}
}
function getModeLabel(mode: LeaderboardMode): string {
const labels: Record<LeaderboardMode, string> = {
'today': 'Today',
'week': 'This Week',
'month': 'This Month',
'all-time': 'All Time'
};
return labels[mode];
}
</script>
<svelte:head>
<title>Leaderboard - FurryPlace</title>
</svelte:head>
<div class="mx-auto max-w-6xl px-4 py-6">
<h1 class="text-3xl font-bold mb-6">Leaderboard</h1>
<!-- Tabs -->
<div class="tabs tabs-boxed mb-6">
<button class="tab" class:tab-active={activeTab === 'player'} on:click={() => (activeTab = 'player')}>
Players
</button>
<button class="tab" class:tab-active={activeTab === 'alliance'} on:click={() => (activeTab = 'alliance')}>
Alliances
</button>
<button class="tab" class:tab-active={activeTab === 'country'} on:click={() => (activeTab = 'country')}>
Countries
</button>
</div>
<!-- Mode Selection -->
<div class="flex gap-2 mb-6 flex-wrap">
{#each modes as mode}
<button
class="btn btn-sm"
class:btn-primary={activeMode === mode}
class:btn-ghost={activeMode !== mode}
on:click={() => (activeMode = mode)}
>
{getModeLabel(mode)}
</button>
{/each}
</div>
{#if error}
<div class="alert alert-error">
<span>{error}</span>
</div>
{:else if loading}
<div class="flex justify-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-0">
{#if activeTab === 'player'}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Rank</th>
<th>Player</th>
<th>Level</th>
<th>Pixels</th>
{#if playerData[0]?.alliance}
<th>Alliance</th>
{/if}
</tr>
</thead>
<tbody>
{#each playerData as player, i}
<tr>
<td>
<span class="font-bold">#{i + 1}</span>
</td>
<td>
<div class="flex items-center gap-2">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-8">
<span class="text-xs">{player.name[0].toUpperCase()}</span>
</div>
</div>
<span>{player.name}</span>
</div>
</td>
<td>{calculateLevel(player.pixelsPainted)}</td>
<td>{player.pixelsPainted.toLocaleString()}</td>
{#if player.alliance}
<td>{player.alliance.name || '-'}</td>
{/if}
</tr>
{:else}
<tr>
<td colspan="5" class="text-center text-base-content/60">No data available</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if activeTab === 'alliance'}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Rank</th>
<th>Alliance</th>
<th>Members</th>
<th>Pixels</th>
</tr>
</thead>
<tbody>
{#each allianceData as alliance, i}
<tr>
<td>
<span class="font-bold">#{i + 1}</span>
</td>
<td>
<span class="font-semibold">{alliance.name}</span>
</td>
<td>{alliance.memberCount || alliance._count?.members || '-'}</td>
<td>{alliance.pixelsPainted.toLocaleString()}</td>
</tr>
{:else}
<tr>
<td colspan="4" class="text-center text-base-content/60">No data available</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if activeTab === 'country'}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Rank</th>
<th>Country</th>
<th>Pixels</th>
</tr>
</thead>
<tbody>
{#each countryData as country, i}
<tr>
<td>
<span class="font-bold">#{i + 1}</span>
</td>
<td>
<span class="font-semibold">{country.country}</span>
</td>
<td>{country.pixelsPainted.toLocaleString()}</td>
</tr>
{:else}
<tr>
<td colspan="3" class="text-center text-base-content/60">No data available</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,48 @@
<script lang="ts">
import Logo from '$lib/components/layout/Logo.svelte';
</script>
<svelte:head>
<title>Offline - FurryPlace</title>
</svelte:head>
<div class="min-h-screen bg-base-200 flex items-center justify-center p-4">
<div class="text-center max-w-md">
<div class="flex justify-center mb-6">
<Logo size="lg" hasText={true} />
</div>
<div class="bg-base-100 rounded-box shadow-xl p-8">
<svg
class="mx-auto mb-4 h-24 w-24 text-base-content/30"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414"
/>
</svg>
<h1 class="text-2xl font-bold mb-2">You're Offline</h1>
<p class="text-base-content/70 mb-6">
It looks like you've lost your internet connection. Please check your network and try again.
</p>
<button
class="btn btn-primary btn-block"
on:click={() => window.location.reload()}
>
Try Again
</button>
<p class="text-sm text-base-content/50 mt-4">
Once you're back online, you'll be able to paint pixels again!
</p>
</div>
</div>
</div>
@@ -0,0 +1,41 @@
<script lang="ts">
import { onMount } from 'svelte';
import { currentUser } from '$lib/stores/user';
import { goto } from '$app/navigation';
import UserProfile from '$lib/components/user/UserProfile.svelte';
import FavoriteLocations from '$lib/components/user/FavoriteLocations.svelte';
import AllianceCard from '$lib/components/user/AllianceCard.svelte';
onMount(() => {
if (!$currentUser) {
goto('/join?r=/profile');
}
});
</script>
<svelte:head>
<title>Profile - FurryPlace</title>
</svelte:head>
{#if $currentUser}
<div class="mx-auto max-w-6xl px-4 py-6">
<h1 class="text-3xl font-bold mb-6">Profile</h1>
<div class="grid gap-6 lg:grid-cols-2">
<div class="space-y-6">
<UserProfile />
<AllianceCard />
</div>
<div>
<FavoriteLocations />
</div>
</div>
</div>
{:else}
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<p class="text-lg">Loading...</p>
</div>
</div>
{/if}
+179
View File
@@ -0,0 +1,179 @@
<script lang="ts">
import { onMount } from 'svelte';
import { currentUser } from '$lib/stores/user';
import { goto } from '$app/navigation';
import { api } from '$lib/api/client';
import { t } from '$lib/i18n';
import { STORE_ITEMS } from '$lib/constants/config';
let purchasing = false;
let error = '';
let success = '';
onMount(() => {
if (!$currentUser) {
goto('/join?r=/store');
}
});
async function purchase(itemId: number, amount?: number, variant?: number) {
if (!$currentUser) return;
purchasing = true;
error = '';
success = '';
try {
await api.purchase({ id: itemId, amount, variant });
await currentUser.fetch();
success = 'Purchase successful!';
setTimeout(() => (success = ''), 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Purchase failed';
} finally {
purchasing = false;
}
}
function canAfford(price: number): boolean {
return $currentUser ? $currentUser.droplets >= price : false;
}
</script>
<svelte:head>
<title>Store - FurryPlace</title>
</svelte:head>
{#if $currentUser}
<div class="mx-auto max-w-5xl px-4 py-6">
<div class="mb-6">
<h1 class="text-3xl font-bold">Store</h1>
<p class="text-base-content/60 mt-2">
You have <span class="font-semibold text-primary">{$currentUser.droplets.toLocaleString()}</span> droplets
</p>
</div>
{#if error}
<div class="alert alert-error mb-6">
<span>{error}</span>
</div>
{/if}
{#if success}
<div class="alert alert-success mb-6">
<span>{success}</span>
</div>
{/if}
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<!-- Max Charges -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">+5 Max Charges</h2>
<p class="text-sm text-base-content/60">
Increase your maximum charge capacity by 5. Stack multiple purchases.
</p>
<div class="mt-4">
<p class="text-xs text-base-content/50">Current: {$currentUser.maxCharges}</p>
</div>
<div class="card-actions justify-between items-center mt-4">
<span class="text-lg font-bold text-primary">500 droplets</span>
<button
class="btn btn-primary btn-sm"
on:click={() => purchase(70)}
disabled={purchasing || !canAfford(500)}
>
{purchasing ? 'Processing...' : 'Purchase'}
</button>
</div>
</div>
</div>
<!-- Paint Charges -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">+30 Charges</h2>
<p class="text-sm text-base-content/60">
Instantly refill 30 paint charges. Great for quick painting sessions!
</p>
<div class="mt-4">
<p class="text-xs text-base-content/50">
Current: {$currentUser.currentCharges}/{$currentUser.maxCharges}
</p>
</div>
<div class="card-actions justify-between items-center mt-4">
<span class="text-lg font-bold text-primary">500 droplets</span>
<button
class="btn btn-primary btn-sm"
on:click={() => purchase(80)}
disabled={purchasing || !canAfford(500)}
>
{purchasing ? 'Processing...' : 'Purchase'}
</button>
</div>
</div>
</div>
<!-- Paid Colors -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Unlock Paid Colors</h2>
<p class="text-sm text-base-content/60">
Unlock all premium colors permanently. Expand your artistic palette!
</p>
<div class="mt-4">
<p class="text-xs text-base-content/50">32 additional colors</p>
</div>
<div class="card-actions justify-between items-center mt-4">
<span class="text-lg font-bold text-primary">2,000 droplets</span>
<button
class="btn btn-primary btn-sm"
on:click={() => purchase(100)}
disabled={purchasing || !canAfford(2000)}
>
{purchasing ? 'Processing...' : 'Purchase'}
</button>
</div>
</div>
</div>
<!-- Flags -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Unlock Country Flag</h2>
<p class="text-sm text-base-content/60">
Unlock a country flag to represent your nation. Get 10% charge discount in your region!
</p>
<div class="mt-4">
<p class="text-xs text-base-content/50">Choose any country flag</p>
</div>
<div class="card-actions justify-between items-center mt-4">
<span class="text-lg font-bold text-primary">20,000 droplets</span>
<button
class="btn btn-primary btn-sm"
on:click={() => purchase(110)}
disabled={purchasing || !canAfford(20000)}
>
{purchasing ? 'Processing...' : 'Purchase'}
</button>
</div>
</div>
</div>
</div>
<div class="mt-8 p-4 rounded-box bg-base-200">
<h3 class="font-semibold mb-2">How to earn droplets?</h3>
<ul class="text-sm text-base-content/70 space-y-1 list-disc list-inside">
<li>Paint pixels to earn droplets</li>
<li>Participate in community events</li>
<li>Complete daily challenges</li>
</ul>
</div>
</div>
{:else}
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<p class="text-lg">Loading...</p>
</div>
</div>
{/if}