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:
Brian Fertig 2026-04-11 10:08:05 -06:00
parent 3143369377
commit be5857fe3b
13 changed files with 959 additions and 63 deletions

View File

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN avatar_path TEXT;

505
package-lock.json generated
View File

@ -11,6 +11,7 @@
"argon2": "^0.40.1",
"better-sqlite3": "^11.3.0",
"better-sqlite3-session-store": "^0.1.0",
"cropperjs": "^1.6.2",
"csrf-sync": "^4.0.3",
"dotenv": "^16.4.5",
"ejs": "^3.1.10",
@ -22,6 +23,7 @@
"nodemailer": "^8.0.5",
"pino": "^9.4.0",
"pino-http": "^10.3.0",
"sharp": "^0.34.5",
"zod": "^3.23.8"
},
"devDependencies": {
@ -37,6 +39,455 @@
"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": {
"version": "1.0.0",
"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",
"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": {
"version": "4.2.1",
"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",
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@ -1982,6 +2481,12 @@
"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": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",

View File

@ -12,6 +12,7 @@
"argon2": "^0.40.1",
"better-sqlite3": "^11.3.0",
"better-sqlite3-session-store": "^0.1.0",
"cropperjs": "^1.6.2",
"csrf-sync": "^4.0.3",
"dotenv": "^16.4.5",
"ejs": "^3.1.10",
@ -23,6 +24,7 @@
"nodemailer": "^8.0.5",
"pino": "^9.4.0",
"pino-http": "^10.3.0",
"sharp": "^0.34.5",
"zod": "^3.23.8"
},
"devDependencies": {

View File

@ -338,12 +338,37 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
}
/* ---------- 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 {
width: 220px; height: 220px; border-radius: var(--radius); object-fit: cover;
border: 1px solid var(--neon-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 ---------- */
.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; }
.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 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); }
@ -617,6 +745,9 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
#player-audio { width: 100%; order: 3; }
.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-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; }
}

62
public/js/lightbox.js Normal file
View File

@ -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();
});
})();

View File

@ -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.
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/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({
store: new SqliteStore({

View File

@ -1,8 +1,25 @@
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const express = require('express');
const { z } = require('zod');
const multer = require('multer');
const sharp = require('sharp');
const users = require('../services/users');
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({
display_name: z.string().trim().min(1).max(80),
});
@ -65,5 +82,30 @@ module.exports = function accountRoutes(csrfProtection) {
} 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;
};

View File

@ -5,6 +5,7 @@ const express = require('express');
const songs = require('../services/songs');
const playlists = require('../services/playlists');
const social = require('../services/social');
const users = require('../services/users');
const router = express.Router();
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;
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) => {
@ -98,7 +100,8 @@ router.get('/playlists/:slug', (req, res, next) => {
social.enrichPlaylists([playlist], userId);
const tracks = playlists.getTracks(playlist.id).filter(t => canView(t.visibility, t.uploadedBy, 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.

View File

@ -28,6 +28,7 @@ const songsByUserStmt = db.prepare('SELECT * FROM songs WHERE uploaded
const playlistsByUserStmt = db.prepare('SELECT * FROM playlists WHERE created_by = ?');
const deleteSongsByUserStmt = db.prepare('DELETE FROM songs WHERE uploaded_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) {
if (!row) return null;
@ -36,6 +37,7 @@ function publicView(row) {
displayName: row.display_name, role: row.role,
emailVerified: row.email_verified === 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) {
const userSongs = songsByUserStmt.all(id);
const userPlaylists = playlistsByUserStmt.all(id);
@ -142,4 +149,5 @@ module.exports = {
setDisabled,
markVerifiedAdmin,
deleteUserWithContent,
updateAvatar,
};

View File

@ -1,6 +1,33 @@
<h1>Account</h1>
<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">
<h2>Profile</h2>
<form method="post" action="/account" class="stacked">
@ -31,3 +58,85 @@
<button type="submit" class="btn-primary">Change password</button>
</form>
</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>

View File

@ -36,6 +36,7 @@
<script src="/static/js/navigate.js" defer></script>
<script src="/static/js/player.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/player-viz.js"></script>
<script type="module" src="/static/js/boot-sequence.js"></script>

View File

@ -1,38 +1,52 @@
<div class="song-detail">
<% 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 { %>
<div class="song-detail-cover cover-fallback">🎧</div>
<% } %>
<div>
<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><% } %>
<p class="muted small"><%= tracks.length %> track<%= tracks.length === 1 ? '' : 's' %></p>
<div class="social-row">
<button type="button"
class="btn-social<%= playlist.userLiked ? ' active' : '' %>"
data-social-action="like" data-social-type="playlist" data-social-id="<%= playlist.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= playlist.userLiked ? 'true' : 'false' %>"
title="Like">👍 <span class="social-count"><%= playlist.likeCount || 0 %></span></button>
<button type="button"
class="btn-social<%= playlist.userFavorited ? ' active' : '' %>"
data-social-action="favorite" data-social-type="playlist" data-social-id="<%= playlist.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= playlist.userFavorited ? 'true' : 'false' %>"
title="Favorite">⭐ <span class="social-count"><%= playlist.favoriteCount || 0 %></span></button>
<div class="song-detail-info">
<div class="song-detail-meta">
<h1><%= playlist.title %></h1>
<% if (playlist.description) { %><p class="muted"><%= playlist.description %></p><% } %>
<p class="muted small"><%= tracks.length %> track<%= tracks.length === 1 ? '' : 's' %></p>
<div class="social-row">
<button type="button"
class="btn-social<%= playlist.userLiked ? ' active' : '' %>"
data-social-action="like" data-social-type="playlist" data-social-id="<%= playlist.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= playlist.userLiked ? 'true' : 'false' %>"
title="Like">👍 <span class="social-count"><%= playlist.likeCount || 0 %></span></button>
<button type="button"
class="btn-social<%= playlist.userFavorited ? ' active' : '' %>"
data-social-action="favorite" data-social-type="playlist" data-social-id="<%= playlist.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= playlist.userFavorited ? 'true' : 'false' %>"
title="Favorite">⭐ <span class="social-count"><%= playlist.favoriteCount || 0 %></span></button>
</div>
<button type="button" class="btn-primary"
id="play-playlist-btn"
data-play-playlist="<%= JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? '/media/' + t.coverPath : null, userLiked: !!t.userLiked, userFavorited: !!t.userFavorited, likeCount: t.likeCount || 0, favoriteCount: t.favoriteCount || 0 }))) %>">
▶ Play all
</button>
<button type="button" class="btn-primary"
data-shuffle-playlist="<%= JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? '/media/' + t.coverPath : null, userLiked: !!t.userLiked, userFavorited: !!t.userFavorited, likeCount: t.likeCount || 0, favoriteCount: t.favoriteCount || 0 }))) %>">
⇄ Shuffle all
</button>
<a href="/playlists" class="btn-link">← Back</a>
</div>
<button type="button" class="btn-primary"
id="play-playlist-btn"
data-play-playlist="<%= JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? '/media/' + t.coverPath : null, userLiked: !!t.userLiked, userFavorited: !!t.userFavorited, likeCount: t.likeCount || 0, favoriteCount: t.favoriteCount || 0 }))) %>">
▶ Play all
</button>
<button type="button" class="btn-primary"
data-shuffle-playlist="<%= JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? '/media/' + t.coverPath : null, userLiked: !!t.userLiked, userFavorited: !!t.userFavorited, likeCount: t.likeCount || 0, favoriteCount: t.favoriteCount || 0 }))) %>">
⇄ Shuffle all
</button>
<a href="/playlists" class="btn-link">← Back</a>
<% 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>

View File

@ -1,39 +1,53 @@
<div class="song-detail">
<% 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 { %>
<div class="song-detail-cover cover-fallback">♪</div>
<% } %>
<div>
<h1><%= song.title %>
<% 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><% } %>
</h1>
<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">
<button type="button"
class="btn-social<%= song.userLiked ? ' active' : '' %>"
data-social-action="like" data-social-type="song" data-social-id="<%= song.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= song.userLiked ? 'true' : 'false' %>"
title="Like">👍 <span class="social-count"><%= song.likeCount || 0 %></span></button>
<button type="button"
class="btn-social<%= song.userFavorited ? ' active' : '' %>"
data-social-action="favorite" data-social-type="song" data-social-id="<%= song.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= song.userFavorited ? 'true' : 'false' %>"
title="Favorite">⭐ <span class="social-count"><%= song.favoriteCount || 0 %></span></button>
<div class="song-detail-info">
<div class="song-detail-meta">
<h1><%= song.title %>
<% 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><% } %>
</h1>
<p class="muted"><%= song.artist %><% if (song.album) { %> · <%= song.album %><% } %></p>
<div class="social-row">
<button type="button"
class="btn-social<%= song.userLiked ? ' active' : '' %>"
data-social-action="like" data-social-type="song" data-social-id="<%= song.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= song.userLiked ? 'true' : 'false' %>"
title="Like">👍 <span class="social-count"><%= song.likeCount || 0 %></span></button>
<button type="button"
class="btn-social<%= song.userFavorited ? ' active' : '' %>"
data-social-action="favorite" data-social-type="song" data-social-id="<%= song.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= song.userFavorited ? 'true' : 'false' %>"
title="Favorite">⭐ <span class="social-count"><%= song.favoriteCount || 0 %></span></button>
</div>
<p class="muted small">
<% if (song.genre) { %><%= song.genre %> · <% } %>
<% if (song.year) { %><%= song.year %> · <% } %>
<% if (song.durationSeconds) { %><%= Math.floor(song.durationSeconds/60) %>:<%= String(song.durationSeconds%60).padStart(2,'0') %><% } %>
</p>
<button type="button" class="btn-play btn-primary"
data-play-song="<%= JSON.stringify({ id: song.id, title: song.title, artist: song.artist, cover: song.coverPath ? '/media/' + song.coverPath : null, userLiked: !!song.userLiked, userFavorited: !!song.userFavorited, likeCount: song.likeCount || 0, favoriteCount: song.favoriteCount || 0 }) %>">
▶ Play
</button>
<a href="/songs" class="btn-link">← Back to songs</a>
</div>
<p class="muted small">
<% if (song.genre) { %><%= song.genre %> · <% } %>
<% if (song.year) { %><%= song.year %> · <% } %>
<% if (song.durationSeconds) { %><%= Math.floor(song.durationSeconds/60) %>:<%= String(song.durationSeconds%60).padStart(2,'0') %><% } %>
</p>
<button type="button" class="btn-play btn-primary"
data-play-song="<%= JSON.stringify({ id: song.id, title: song.title, artist: song.artist, cover: song.coverPath ? '/media/' + song.coverPath : null, userLiked: !!song.userLiked, userFavorited: !!song.userFavorited, likeCount: song.likeCount || 0, favoriteCount: song.favoriteCount || 0 }) %>">
▶ Play
</button>
<a href="/songs" class="btn-link">← Back to songs</a>
<% 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>