feat: add user avatars with cropping and image lightbox
- Add `avatar_path` column to users table via migration 008 - Integrate `cropperjs` and `sharp` for avatar upload, cropping (600×600), and PNG conversion - Serve avatars at `/media/avatars` with static caching; expose `/static/vendor/cropperjs` - Add avatar management UI in account settings with modal cropping workflow - Display creator avatars on song/playlist detail pages (fallback to initial) - Implement generic image lightbox (`lightbox.js`) for cover art with zoom animations and keyboard support - Refactor song/playlist detail views to use responsive layout and lightbox-enabled covers
This commit is contained in:
parent
3143369377
commit
be5857fe3b
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE users ADD COLUMN avatar_path TEXT;
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"argon2": "^0.40.1",
|
"argon2": "^0.40.1",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^11.3.0",
|
||||||
"better-sqlite3-session-store": "^0.1.0",
|
"better-sqlite3-session-store": "^0.1.0",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"csrf-sync": "^4.0.3",
|
"csrf-sync": "^4.0.3",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
|
|
@ -22,6 +23,7 @@
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"pino-http": "^10.3.0",
|
"pino-http": "^10.3.0",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -37,6 +39,455 @@
|
||||||
"url": "https://github.com/sponsors/Borewit"
|
"url": "https://github.com/sponsors/Borewit"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
||||||
|
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/colour": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@phc/format": {
|
"node_modules/@phc/format": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
||||||
|
|
@ -418,6 +869,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/cropperjs": {
|
||||||
|
"version": "1.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
|
||||||
|
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA=="
|
||||||
|
},
|
||||||
"node_modules/csrf-sync": {
|
"node_modules/csrf-sync": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/csrf-sync/-/csrf-sync-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/csrf-sync/-/csrf-sync-4.2.1.tgz",
|
||||||
|
|
@ -1699,6 +2155,49 @@
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@img/colour": "^1.0.0",
|
||||||
|
"detect-libc": "^2.1.2",
|
||||||
|
"semver": "^7.7.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.34.5",
|
||||||
|
"@img/sharp-darwin-x64": "0.34.5",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||||
|
"@img/sharp-linux-arm": "0.34.5",
|
||||||
|
"@img/sharp-linux-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linux-ppc64": "0.34.5",
|
||||||
|
"@img/sharp-linux-riscv64": "0.34.5",
|
||||||
|
"@img/sharp-linux-s390x": "0.34.5",
|
||||||
|
"@img/sharp-linux-x64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||||
|
"@img/sharp-wasm32": "0.34.5",
|
||||||
|
"@img/sharp-win32-arm64": "0.34.5",
|
||||||
|
"@img/sharp-win32-ia32": "0.34.5",
|
||||||
|
"@img/sharp-win32-x64": "0.34.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
|
|
@ -1982,6 +2481,12 @@
|
||||||
"nodetouch": "bin/nodetouch.js"
|
"nodetouch": "bin/nodetouch.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/tunnel-agent": {
|
"node_modules/tunnel-agent": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"argon2": "^0.40.1",
|
"argon2": "^0.40.1",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^11.3.0",
|
||||||
"better-sqlite3-session-store": "^0.1.0",
|
"better-sqlite3-session-store": "^0.1.0",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"csrf-sync": "^4.0.3",
|
"csrf-sync": "^4.0.3",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
|
|
@ -23,6 +24,7 @@
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"pino-http": "^10.3.0",
|
"pino-http": "^10.3.0",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -338,12 +338,37 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Song detail ---------- */
|
/* ---------- Song detail ---------- */
|
||||||
.song-detail { display: flex; gap: 1.5rem; align-items: center; padding: 1rem 0 2rem; }
|
.song-detail { display: flex; gap: 1.5rem; align-items: flex-start; padding: 1rem 0 2rem; }
|
||||||
.song-detail-cover {
|
.song-detail-cover {
|
||||||
width: 220px; height: 220px; border-radius: var(--radius); object-fit: cover;
|
width: 220px; height: 220px; border-radius: var(--radius); object-fit: cover;
|
||||||
border: 1px solid var(--neon-violet);
|
border: 1px solid var(--neon-violet);
|
||||||
box-shadow: var(--glow-violet);
|
box-shadow: var(--glow-violet);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.song-detail-info {
|
||||||
|
display: flex; justify-content: space-between; align-items: flex-start;
|
||||||
|
gap: 1.5rem; flex: 1; min-width: 0;
|
||||||
|
}
|
||||||
|
.song-detail-meta { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
/* Creator card on detail pages */
|
||||||
|
.detail-creator-card {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: 0.35rem; flex-shrink: 0; text-align: center;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
.detail-creator-avatar {
|
||||||
|
width: 72px; height: 72px;
|
||||||
|
border-radius: 50%; object-fit: cover;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
background: var(--bg-inset);
|
||||||
|
}
|
||||||
|
.detail-creator-placeholder {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 1.75rem; font-weight: bold; color: var(--muted);
|
||||||
|
}
|
||||||
|
.detail-creator-label { font-size: 0.7rem; color: var(--muted); margin: 0; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.detail-creator-name { font-size: 0.82rem; color: var(--fg); margin: 0; font-weight: 600; }
|
||||||
|
|
||||||
/* ---------- Forms ---------- */
|
/* ---------- Forms ---------- */
|
||||||
.form-card {
|
.form-card {
|
||||||
|
|
@ -511,6 +536,109 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
||||||
.social-row { display: flex; gap: 4px; margin: 0.4rem 0 0.6rem; }
|
.social-row { display: flex; gap: 4px; margin: 0.4rem 0 0.6rem; }
|
||||||
.card-social { display: flex; gap: 4px; padding-top: 0.4rem; border-top: 1px solid rgba(255,255,255,0.06); }
|
.card-social { display: flex; gap: 4px; padding-top: 0.4rem; border-top: 1px solid rgba(255,255,255,0.06); }
|
||||||
|
|
||||||
|
/* ---------- Lightbox ---------- */
|
||||||
|
.lb-trigger {
|
||||||
|
cursor: zoom-in;
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
.lb-trigger:hover {
|
||||||
|
transform: scale(1.04);
|
||||||
|
box-shadow: 0 0 0 3px var(--neon-cyan), var(--glow-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
#lb-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0);
|
||||||
|
z-index: 2000;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
|
#lb-overlay.lb-open {
|
||||||
|
display: flex;
|
||||||
|
animation: lb-fade-in 0.22s ease forwards;
|
||||||
|
}
|
||||||
|
#lb-overlay.lb-closing {
|
||||||
|
animation: lb-fade-out 0.2s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lb-fade-in { from { background: rgba(0,0,0,0); } to { background: rgba(0,0,0,0.88); } }
|
||||||
|
@keyframes lb-fade-out { from { background: rgba(0,0,0,0.88); } to { background: rgba(0,0,0,0); } }
|
||||||
|
|
||||||
|
.lb-container {
|
||||||
|
position: relative;
|
||||||
|
cursor: default;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: min(90vw, 90vh);
|
||||||
|
max-height: 90vh;
|
||||||
|
box-shadow: 0 0 0 2px var(--neon-violet), 0 0 40px rgba(143,91,255,0.5);
|
||||||
|
}
|
||||||
|
#lb-overlay.lb-open .lb-container {
|
||||||
|
animation: lb-zoom-in 0.28s cubic-bezier(0.34, 1.45, 0.64, 1) forwards;
|
||||||
|
}
|
||||||
|
#lb-overlay.lb-closing .lb-container {
|
||||||
|
animation: lb-zoom-out 0.2s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lb-zoom-in { from { transform: scale(0.15); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||||
|
@keyframes lb-zoom-out { from { transform: scale(1); opacity: 1; } to { transform: scale(0.15); opacity: 0; } }
|
||||||
|
|
||||||
|
.lb-img {
|
||||||
|
display: block;
|
||||||
|
max-width: min(90vw, 90vh);
|
||||||
|
max-height: 90vh;
|
||||||
|
width: auto; height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.lb-close {
|
||||||
|
position: absolute; top: 10px; right: 10px;
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
background: rgba(0,0,0,0.65);
|
||||||
|
border: 1px solid rgba(255,255,255,0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.1rem; line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 1;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.lb-close:hover { background: rgba(255,255,255,0.15); border-color: var(--neon-cyan); }
|
||||||
|
|
||||||
|
/* ---------- Avatar ---------- */
|
||||||
|
.avatar-section { display: flex; align-items: center; gap: 1rem; }
|
||||||
|
.avatar-preview {
|
||||||
|
width: 80px; height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.avatar-placeholder {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 2rem; font-weight: bold; color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Crop modal ---------- */
|
||||||
|
.crop-modal {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.85);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.crop-modal-inner {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90vw;
|
||||||
|
}
|
||||||
|
.crop-container { width: 100%; height: 380px; }
|
||||||
|
.crop-actions { display: flex; gap: 0.75rem; margin-top: 1rem; align-items: center; }
|
||||||
|
|
||||||
.track-list { padding-left: 0; list-style: none; }
|
.track-list { padding-left: 0; list-style: none; }
|
||||||
.track-list li { margin: 0; display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.35rem 0.5rem; border-radius: 4px; border-top: 2px solid transparent; border-bottom: 2px solid transparent; transition: background 0.15s; }
|
.track-list li { margin: 0; display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.35rem 0.5rem; border-radius: 4px; border-top: 2px solid transparent; border-bottom: 2px solid transparent; transition: background 0.15s; }
|
||||||
.track-list li:hover { background: rgba(255,255,255,0.04); }
|
.track-list li:hover { background: rgba(255,255,255,0.04); }
|
||||||
|
|
@ -617,6 +745,9 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
||||||
#player-audio { width: 100%; order: 3; }
|
#player-audio { width: 100%; order: 3; }
|
||||||
.song-detail { flex-direction: column; align-items: flex-start; }
|
.song-detail { flex-direction: column; align-items: flex-start; }
|
||||||
.song-detail-cover { width: 100%; height: auto; max-width: 300px; aspect-ratio: 1 / 1; object-fit: cover; }
|
.song-detail-cover { width: 100%; height: auto; max-width: 300px; aspect-ratio: 1 / 1; object-fit: cover; }
|
||||||
|
.song-detail-info { flex-wrap: wrap; }
|
||||||
|
.detail-creator-card { flex-direction: row; align-items: center; gap: 0.6rem; text-align: left; width: 100%; }
|
||||||
|
.detail-creator-avatar { width: 48px; height: 48px; }
|
||||||
.hero h1 { font-size: 1.6rem; }
|
.hero h1 { font-size: 1.6rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Build overlay DOM once, append to body.
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'lb-overlay';
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.innerHTML =
|
||||||
|
'<div class="lb-container">' +
|
||||||
|
'<button class="lb-close" aria-label="Close">\u00d7</button>' +
|
||||||
|
'<img class="lb-img" src="" alt="">' +
|
||||||
|
'</div>';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const container = overlay.querySelector('.lb-container');
|
||||||
|
const img = overlay.querySelector('.lb-img');
|
||||||
|
const closeBtn = overlay.querySelector('.lb-close');
|
||||||
|
|
||||||
|
let closing = false;
|
||||||
|
|
||||||
|
function open(src, alt) {
|
||||||
|
if (closing) return;
|
||||||
|
img.src = src;
|
||||||
|
img.alt = alt || '';
|
||||||
|
overlay.classList.remove('lb-closing');
|
||||||
|
overlay.classList.add('lb-open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!overlay.classList.contains('lb-open') || closing) return;
|
||||||
|
closing = true;
|
||||||
|
overlay.classList.add('lb-closing');
|
||||||
|
// Wait for the shrink animation to finish before hiding.
|
||||||
|
overlay.addEventListener('animationend', function onEnd() {
|
||||||
|
overlay.removeEventListener('animationend', onEnd);
|
||||||
|
overlay.classList.remove('lb-open', 'lb-closing');
|
||||||
|
img.src = '';
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
closing = false;
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open on any [data-lightbox] image click.
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const trigger = e.target.closest('[data-lightbox]');
|
||||||
|
if (trigger) {
|
||||||
|
open(trigger.src || trigger.dataset.src, trigger.alt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Close when clicking the backdrop (overlay itself, not the container).
|
||||||
|
if (e.target === overlay) close();
|
||||||
|
});
|
||||||
|
|
||||||
|
closeBtn.addEventListener('click', close);
|
||||||
|
|
||||||
|
// Keyboard: Escape closes.
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') close();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -28,6 +28,10 @@ app.use('/static', express.static(path.join(__dirname, '..', 'public'), { maxAge
|
||||||
// Cover art is served statically; audio goes through /stream/:id for Range support + access control later.
|
// Cover art is served statically; audio goes through /stream/:id for Range support + access control later.
|
||||||
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', 'media');
|
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', 'media');
|
||||||
app.use('/media/covers', express.static(path.join(mediaDir, 'covers'), { maxAge: '1d' }));
|
app.use('/media/covers', express.static(path.join(mediaDir, 'covers'), { maxAge: '1d' }));
|
||||||
|
app.use('/media/avatars', express.static(path.join(mediaDir, 'avatars'), { maxAge: '1d' }));
|
||||||
|
app.use('/static/vendor/cropperjs', express.static(
|
||||||
|
path.join(__dirname, '..', 'node_modules', 'cropperjs', 'dist')
|
||||||
|
));
|
||||||
|
|
||||||
app.use(session({
|
app.use(session({
|
||||||
store: new SqliteStore({
|
store: new SqliteStore({
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,25 @@
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const crypto = require('crypto');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { z } = require('zod');
|
const { z } = require('zod');
|
||||||
|
const multer = require('multer');
|
||||||
|
const sharp = require('sharp');
|
||||||
const users = require('../services/users');
|
const users = require('../services/users');
|
||||||
const { requireUser } = require('../middleware/auth');
|
const { requireUser } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
|
||||||
|
const avatarDir = path.join(mediaDir, 'avatars');
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 8 * 1024 * 1024 },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
const ok = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(file.mimetype);
|
||||||
|
cb(null, ok);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const profileSchema = z.object({
|
const profileSchema = z.object({
|
||||||
display_name: z.string().trim().min(1).max(80),
|
display_name: z.string().trim().min(1).max(80),
|
||||||
});
|
});
|
||||||
|
|
@ -65,5 +82,30 @@ module.exports = function accountRoutes(csrfProtection) {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/avatar', csrfProtection, upload.single('avatar'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'No image provided.' });
|
||||||
|
|
||||||
|
fs.mkdirSync(avatarDir, { recursive: true });
|
||||||
|
|
||||||
|
// Delete old avatar if one exists
|
||||||
|
const old = req.session.user.avatarPath;
|
||||||
|
if (old) fs.unlink(path.join(mediaDir, old), () => {});
|
||||||
|
|
||||||
|
const filename = `${crypto.randomUUID()}.png`;
|
||||||
|
const relPath = `avatars/${filename}`;
|
||||||
|
const outPath = path.join(avatarDir, filename);
|
||||||
|
|
||||||
|
await sharp(req.file.buffer)
|
||||||
|
.resize(600, 600, { fit: 'cover', position: 'centre' })
|
||||||
|
.png()
|
||||||
|
.toFile(outPath);
|
||||||
|
|
||||||
|
const updated = users.updateAvatar(req.session.user.id, relPath);
|
||||||
|
req.session.user = updated;
|
||||||
|
res.json({ ok: true, avatarUrl: `/media/${relPath}` });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ const express = require('express');
|
||||||
const songs = require('../services/songs');
|
const songs = require('../services/songs');
|
||||||
const playlists = require('../services/playlists');
|
const playlists = require('../services/playlists');
|
||||||
const social = require('../services/social');
|
const social = require('../services/social');
|
||||||
|
const users = require('../services/users');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
|
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
|
||||||
|
|
@ -76,7 +77,8 @@ router.get('/songs/:slug', (req, res, next) => {
|
||||||
}
|
}
|
||||||
const userId = req.session.user?.id || null;
|
const userId = req.session.user?.id || null;
|
||||||
social.enrichSongs([song], userId);
|
social.enrichSongs([song], userId);
|
||||||
res.render('public/song', { title: song.title, song });
|
const creator = song.uploadedBy ? users.findById(song.uploadedBy) : null;
|
||||||
|
res.render('public/song', { title: song.title, song, creator });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/playlists', (req, res) => {
|
router.get('/playlists', (req, res) => {
|
||||||
|
|
@ -98,7 +100,8 @@ router.get('/playlists/:slug', (req, res, next) => {
|
||||||
social.enrichPlaylists([playlist], userId);
|
social.enrichPlaylists([playlist], userId);
|
||||||
const tracks = playlists.getTracks(playlist.id).filter(t => canView(t.visibility, t.uploadedBy, userId));
|
const tracks = playlists.getTracks(playlist.id).filter(t => canView(t.visibility, t.uploadedBy, userId));
|
||||||
social.enrichSongs(tracks, userId);
|
social.enrichSongs(tracks, userId);
|
||||||
res.render('public/playlist', { title: playlist.title, playlist, tracks });
|
const creator = playlist.createdBy ? users.findById(playlist.createdBy) : null;
|
||||||
|
res.render('public/playlist', { title: playlist.title, playlist, tracks, creator });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Audio streaming with HTTP Range support.
|
// Audio streaming with HTTP Range support.
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ const songsByUserStmt = db.prepare('SELECT * FROM songs WHERE uploaded
|
||||||
const playlistsByUserStmt = db.prepare('SELECT * FROM playlists WHERE created_by = ?');
|
const playlistsByUserStmt = db.prepare('SELECT * FROM playlists WHERE created_by = ?');
|
||||||
const deleteSongsByUserStmt = db.prepare('DELETE FROM songs WHERE uploaded_by = ?');
|
const deleteSongsByUserStmt = db.prepare('DELETE FROM songs WHERE uploaded_by = ?');
|
||||||
const deletePlaylistsByUserStmt = db.prepare('DELETE FROM playlists WHERE created_by = ?');
|
const deletePlaylistsByUserStmt = db.prepare('DELETE FROM playlists WHERE created_by = ?');
|
||||||
|
const updateAvatarStmt = db.prepare('UPDATE users SET avatar_path = ? WHERE id = ?');
|
||||||
|
|
||||||
function publicView(row) {
|
function publicView(row) {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
|
@ -36,6 +37,7 @@ function publicView(row) {
|
||||||
displayName: row.display_name, role: row.role,
|
displayName: row.display_name, role: row.role,
|
||||||
emailVerified: row.email_verified === 1,
|
emailVerified: row.email_verified === 1,
|
||||||
disabled: row.disabled === 1,
|
disabled: row.disabled === 1,
|
||||||
|
avatarPath: row.avatar_path || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,6 +120,11 @@ function markVerifiedAdmin(id) {
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateAvatar(id, avatarPath) {
|
||||||
|
updateAvatarStmt.run(avatarPath, id);
|
||||||
|
return findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
function deleteUserWithContent(id) {
|
function deleteUserWithContent(id) {
|
||||||
const userSongs = songsByUserStmt.all(id);
|
const userSongs = songsByUserStmt.all(id);
|
||||||
const userPlaylists = playlistsByUserStmt.all(id);
|
const userPlaylists = playlistsByUserStmt.all(id);
|
||||||
|
|
@ -142,4 +149,5 @@ module.exports = {
|
||||||
setDisabled,
|
setDisabled,
|
||||||
markVerifiedAdmin,
|
markVerifiedAdmin,
|
||||||
deleteUserWithContent,
|
deleteUserWithContent,
|
||||||
|
updateAvatar,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,33 @@
|
||||||
<h1>Account</h1>
|
<h1>Account</h1>
|
||||||
<p class="muted"><%= user.email %> · <%= user.role %></p>
|
<p class="muted"><%= user.email %> · <%= user.role %></p>
|
||||||
|
|
||||||
|
<section class="form-card">
|
||||||
|
<h2>Profile picture</h2>
|
||||||
|
<div class="avatar-section">
|
||||||
|
<% if (user.avatarPath) { %>
|
||||||
|
<img src="/media/<%= user.avatarPath %>" alt="Profile picture" class="avatar-preview" id="current-avatar">
|
||||||
|
<% } else { %>
|
||||||
|
<div class="avatar-preview avatar-placeholder" id="current-avatar">
|
||||||
|
<%= (user.displayName || user.email).charAt(0).toUpperCase() %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<button type="button" class="btn-primary" id="change-avatar-btn">Change photo</button>
|
||||||
|
<input type="file" id="avatar-file-input" accept="image/*" style="display:none">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="crop-modal" class="crop-modal" style="display:none">
|
||||||
|
<div class="crop-modal-inner">
|
||||||
|
<div class="crop-container">
|
||||||
|
<img id="crop-image" src="" alt="">
|
||||||
|
</div>
|
||||||
|
<div class="crop-actions">
|
||||||
|
<button type="button" class="btn-primary" id="crop-save-btn">Save photo</button>
|
||||||
|
<button type="button" class="btn-link" id="crop-cancel-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="form-card">
|
<section class="form-card">
|
||||||
<h2>Profile</h2>
|
<h2>Profile</h2>
|
||||||
<form method="post" action="/account" class="stacked">
|
<form method="post" action="/account" class="stacked">
|
||||||
|
|
@ -31,3 +58,85 @@
|
||||||
<button type="submit" class="btn-primary">Change password</button>
|
<button type="submit" class="btn-primary">Change password</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/static/vendor/cropperjs/cropper.min.css">
|
||||||
|
<script src="/static/vendor/cropperjs/cropper.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const fileInput = document.getElementById('avatar-file-input');
|
||||||
|
const changeBtn = document.getElementById('change-avatar-btn');
|
||||||
|
const modal = document.getElementById('crop-modal');
|
||||||
|
const cropImg = document.getElementById('crop-image');
|
||||||
|
const saveBtn = document.getElementById('crop-save-btn');
|
||||||
|
const cancelBtn = document.getElementById('crop-cancel-btn');
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||||
|
let cropper = null;
|
||||||
|
|
||||||
|
changeBtn.addEventListener('click', () => fileInput.click());
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', () => {
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
cropImg.src = e.target.result;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
if (cropper) cropper.destroy();
|
||||||
|
cropper = new Cropper(cropImg, {
|
||||||
|
aspectRatio: 1,
|
||||||
|
viewMode: 1,
|
||||||
|
dragMode: 'move',
|
||||||
|
autoCropArea: 1,
|
||||||
|
restore: false,
|
||||||
|
guides: false,
|
||||||
|
center: false,
|
||||||
|
highlight: false,
|
||||||
|
cropBoxMovable: false,
|
||||||
|
cropBoxResizable: false,
|
||||||
|
toggleDragModeOnDblclick: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelBtn.addEventListener('click', () => {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
if (cropper) { cropper.destroy(); cropper = null; }
|
||||||
|
fileInput.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
saveBtn.addEventListener('click', () => {
|
||||||
|
if (!cropper) return;
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Saving\u2026';
|
||||||
|
cropper.getCroppedCanvas({ width: 600, height: 600 }).toBlob(async (blob) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('avatar', blob, 'avatar.png');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/account/avatar', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': csrfToken },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Upload failed');
|
||||||
|
const current = document.getElementById('current-avatar');
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = data.avatarUrl + '?t=' + Date.now();
|
||||||
|
img.alt = 'Profile picture';
|
||||||
|
img.className = 'avatar-preview';
|
||||||
|
img.id = 'current-avatar';
|
||||||
|
current.replaceWith(img);
|
||||||
|
modal.style.display = 'none';
|
||||||
|
if (cropper) { cropper.destroy(); cropper = null; }
|
||||||
|
fileInput.value = '';
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = 'Save photo';
|
||||||
|
}
|
||||||
|
}, 'image/png');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
<script src="/static/js/navigate.js" defer></script>
|
<script src="/static/js/navigate.js" defer></script>
|
||||||
<script src="/static/js/player.js" defer></script>
|
<script src="/static/js/player.js" defer></script>
|
||||||
<script src="/static/js/social.js" defer></script>
|
<script src="/static/js/social.js" defer></script>
|
||||||
|
<script src="/static/js/lightbox.js" defer></script>
|
||||||
<script type="module" src="/static/js/bg-grid.js"></script>
|
<script type="module" src="/static/js/bg-grid.js"></script>
|
||||||
<script type="module" src="/static/js/player-viz.js"></script>
|
<script type="module" src="/static/js/player-viz.js"></script>
|
||||||
<script type="module" src="/static/js/boot-sequence.js"></script>
|
<script type="module" src="/static/js/boot-sequence.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
<div class="song-detail">
|
<div class="song-detail">
|
||||||
<% if (playlist.coverPath) { %>
|
<% if (playlist.coverPath) { %>
|
||||||
<img src="/media/<%= playlist.coverPath %>" alt="" class="song-detail-cover" />
|
<img src="/media/<%= playlist.coverPath %>" alt="" class="song-detail-cover lb-trigger" data-lightbox />
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<div class="song-detail-cover cover-fallback">🎧</div>
|
<div class="song-detail-cover cover-fallback">🎧</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
<div>
|
<div class="song-detail-info">
|
||||||
|
<div class="song-detail-meta">
|
||||||
<h1><%= playlist.title %></h1>
|
<h1><%= playlist.title %></h1>
|
||||||
<% if (playlist.createdByName) { %><p class="card-creator">Created by: <%= playlist.createdByName %></p><% } %>
|
|
||||||
<% if (playlist.description) { %><p class="muted"><%= playlist.description %></p><% } %>
|
<% if (playlist.description) { %><p class="muted"><%= playlist.description %></p><% } %>
|
||||||
<p class="muted small"><%= tracks.length %> track<%= tracks.length === 1 ? '' : 's' %></p>
|
<p class="muted small"><%= tracks.length %> track<%= tracks.length === 1 ? '' : 's' %></p>
|
||||||
<div class="social-row">
|
<div class="social-row">
|
||||||
|
|
@ -34,6 +34,20 @@
|
||||||
</button>
|
</button>
|
||||||
<a href="/playlists" class="btn-link">← Back</a>
|
<a href="/playlists" class="btn-link">← Back</a>
|
||||||
</div>
|
</div>
|
||||||
|
<% if (creator) { %>
|
||||||
|
<div class="detail-creator-card">
|
||||||
|
<% if (creator.avatarPath) { %>
|
||||||
|
<img src="/media/<%= creator.avatarPath %>" alt="" class="detail-creator-avatar">
|
||||||
|
<% } else { %>
|
||||||
|
<div class="detail-creator-avatar detail-creator-placeholder">
|
||||||
|
<%= (creator.displayName || creator.email).charAt(0).toUpperCase() %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<p class="detail-creator-label">Created by</p>
|
||||||
|
<p class="detail-creator-name"><%= creator.displayName %></p>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if (tracks.length > 0) { %>
|
<% if (tracks.length > 0) { %>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
<div class="song-detail">
|
<div class="song-detail">
|
||||||
<% if (song.coverPath) { %>
|
<% if (song.coverPath) { %>
|
||||||
<img src="/media/<%= song.coverPath %>" alt="" class="song-detail-cover" />
|
<img src="/media/<%= song.coverPath %>" alt="" class="song-detail-cover lb-trigger" data-lightbox />
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<div class="song-detail-cover cover-fallback">♪</div>
|
<div class="song-detail-cover cover-fallback">♪</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
<div>
|
<div class="song-detail-info">
|
||||||
|
<div class="song-detail-meta">
|
||||||
<h1><%= song.title %>
|
<h1><%= song.title %>
|
||||||
<% if (song.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members only</span><% } %>
|
<% if (song.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members only</span><% } %>
|
||||||
<% if (song.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %>
|
<% if (song.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="muted"><%= song.artist %><% if (song.album) { %> · <%= song.album %><% } %></p>
|
<p class="muted"><%= song.artist %><% if (song.album) { %> · <%= song.album %><% } %></p>
|
||||||
<% if (song.uploadedByName) { %><p class="card-creator">Added by: <%= song.uploadedByName %></p><% } %>
|
|
||||||
<div class="social-row">
|
<div class="social-row">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn-social<%= song.userLiked ? ' active' : '' %>"
|
class="btn-social<%= song.userLiked ? ' active' : '' %>"
|
||||||
|
|
@ -36,4 +36,18 @@
|
||||||
</button>
|
</button>
|
||||||
<a href="/songs" class="btn-link">← Back to songs</a>
|
<a href="/songs" class="btn-link">← Back to songs</a>
|
||||||
</div>
|
</div>
|
||||||
|
<% if (creator) { %>
|
||||||
|
<div class="detail-creator-card">
|
||||||
|
<% if (creator.avatarPath) { %>
|
||||||
|
<img src="/media/<%= creator.avatarPath %>" alt="" class="detail-creator-avatar">
|
||||||
|
<% } else { %>
|
||||||
|
<div class="detail-creator-avatar detail-creator-placeholder">
|
||||||
|
<%= (creator.displayName || creator.email).charAt(0).toUpperCase() %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<p class="detail-creator-label">Added by</p>
|
||||||
|
<p class="detail-creator-name"><%= creator.displayName %></p>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue