Feature/audio upload (#120)

* Audio upload 
* Audio player Article View
This commit is contained in:
Ilya Y 2023-07-14 16:06:21 +03:00 committed by GitHub
parent e6b1550056
commit cd83807204
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1222 additions and 237 deletions

363
package-lock.json generated
View File

@ -13,11 +13,14 @@
"@aws-sdk/client-s3": "3.303.0",
"@aws-sdk/lib-storage": "3.303.0",
"@hocuspocus/provider": "2.0.6",
"@rnwonder/solid-date-picker": "0.7.7",
"@solid-primitives/media": "2.2.3",
"@thisbeyond/solid-dnd": "0.7.4",
"form-data": "4.0.0",
"formidable": "2.1.1",
"i18next": "22.4.15",
"mailgun.js": "8.2.1",
"music-metadata-browser": "2.5.10",
"node-fetch": "3.3.1",
"solid-popper": "0.3.0",
"typograf": "7.1.0"
@ -5612,6 +5615,17 @@
"integrity": "sha512-AW8PKd6iX3vAZ0vA43nOUOnbq/X5ihgU+mSXXqunMkeQADGiqw/PY0JNeYtD5sr0PAy51YPgAPbDoeapv9r8WA==",
"dev": true
},
"node_modules/@rnwonder/solid-date-picker": {
"version": "0.7.7",
"resolved": "https://registry.npmjs.org/@rnwonder/solid-date-picker/-/solid-date-picker-0.7.7.tgz",
"integrity": "sha512-GZDd0zNJNfQoUF18oxdsv6Vo2/7G/zFH2xMrJUg4gBe9tqhT0MLBzAUr+ZRBBmViFVYfanSc2iCLN81xXsMOIg==",
"engines": {
"node": ">=16.0.0"
},
"peerDependencies": {
"solid-js": "^1.6.12"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.25.24",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz",
@ -5827,6 +5841,14 @@
"solid-js": ">=1.4.0"
}
},
"node_modules/@thisbeyond/solid-dnd": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.4.tgz",
"integrity": "sha512-jgV9EtR3gAtVsILG8p1OAGrhHIgnK4W04YxpyLgJRCDKEFYQWuDrMdUe8F5Kc6pcVXlC4IMXr4cB8fS2Ut3/Ow==",
"peerDependencies": {
"solid-js": "^1.5"
}
},
"node_modules/@thisbeyond/solid-select": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@thisbeyond/solid-select/-/solid-select-0.14.0.tgz",
@ -6269,6 +6291,11 @@
"@tiptap/core": "^2.0.0"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="
},
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@ -6975,6 +7002,17 @@
"integrity": "sha512-IqnKIDWfXBJkvy/k6tzskWTc2NK3LcqHlb+KHGCrjOCH4jfQckRX0NAiIcC/vIqQkzLYw2r2CTSwAxcrtcD6lA==",
"dev": true
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/abstract-leveldown": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz",
@ -8305,6 +8343,14 @@
"upper-case": "^2.0.2"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
@ -8509,7 +8555,6 @@
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"dev": true,
"dependencies": {
"ms": "2.1.2"
},
@ -9973,6 +10018,14 @@
"node": ">=0.10.0"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@ -10231,6 +10284,22 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/file-type": {
"version": "16.5.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz",
"integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==",
"dependencies": {
"readable-web-to-node-stream": "^3.0.0",
"strtok3": "^6.2.4",
"token-types": "^4.1.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@ -15960,6 +16029,14 @@
"integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==",
"dev": true
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/meow": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz",
@ -16223,8 +16300,82 @@
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/music-metadata": {
"version": "7.13.4",
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-7.13.4.tgz",
"integrity": "sha512-eRRoEMhhYdth2Ws24FmkvIqrtkIBE9sqjHbrRNpkg2Iux3zc37PQKRv2/r/mTtELb7XlB1uWC2UcKKX7BzNMGA==",
"dependencies": {
"@tokenizer/token": "^0.3.0",
"content-type": "^1.0.5",
"debug": "^4.3.4",
"file-type": "^16.5.4",
"media-typer": "^1.1.0",
"strtok3": "^6.3.0",
"token-types": "^4.2.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/music-metadata-browser": {
"version": "2.5.10",
"resolved": "https://registry.npmjs.org/music-metadata-browser/-/music-metadata-browser-2.5.10.tgz",
"integrity": "sha512-03UnAmsSJoZZ5kK2BnEnd2zpH8LXRWQ6xlc7akKudhc2d9FT+yAiqapnmOzjW3g4cxxvIsSK5MVBO2Gi+Ymjfw==",
"dependencies": {
"buffer": "^6.0.3",
"debug": "^4.3.4",
"music-metadata": "^7.13.3",
"readable-stream": "^4.3.0",
"readable-web-to-node-stream": "^3.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/music-metadata-browser/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/music-metadata-browser/node_modules/readable-stream": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.4.2.tgz",
"integrity": "sha512-Lk/fICSyIhodxy1IDK2HazkeGjSmezAWX2egdtJnYhtzKEsBPJowlI6F6LPb5tqIQILrMbx22S5o3GuJavPusA==",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/mute-stream": {
"version": "0.0.8",
@ -16845,6 +16996,18 @@
"node": ">=8"
}
},
"node_modules/peek-readable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",
"integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==",
"engines": {
"node": ">=8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
@ -17189,6 +17352,14 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/promise": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
@ -17679,6 +17850,21 @@
"node": ">= 6"
}
},
"node_modules/readable-web-to-node-stream": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz",
"integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==",
"dependencies": {
"readable-stream": "^3.6.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -18812,6 +18998,22 @@
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz",
"integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA=="
},
"node_modules/strtok3": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
"integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==",
"dependencies": {
"@tokenizer/token": "^0.3.0",
"peek-readable": "^4.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/style-search": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz",
@ -19456,6 +19658,22 @@
"node": ">=8.0"
}
},
"node_modules/token-types": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz",
"integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==",
"dependencies": {
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/totalist": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.0.tgz",
@ -24799,6 +25017,12 @@
"integrity": "sha512-AW8PKd6iX3vAZ0vA43nOUOnbq/X5ihgU+mSXXqunMkeQADGiqw/PY0JNeYtD5sr0PAy51YPgAPbDoeapv9r8WA==",
"dev": true
},
"@rnwonder/solid-date-picker": {
"version": "0.7.7",
"resolved": "https://registry.npmjs.org/@rnwonder/solid-date-picker/-/solid-date-picker-0.7.7.tgz",
"integrity": "sha512-GZDd0zNJNfQoUF18oxdsv6Vo2/7G/zFH2xMrJUg4gBe9tqhT0MLBzAUr+ZRBBmViFVYfanSc2iCLN81xXsMOIg==",
"requires": {}
},
"@sinclair/typebox": {
"version": "0.25.24",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz",
@ -24980,6 +25204,12 @@
"dev": true,
"requires": {}
},
"@thisbeyond/solid-dnd": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.4.tgz",
"integrity": "sha512-jgV9EtR3gAtVsILG8p1OAGrhHIgnK4W04YxpyLgJRCDKEFYQWuDrMdUe8F5Kc6pcVXlC4IMXr4cB8fS2Ut3/Ow==",
"requires": {}
},
"@thisbeyond/solid-select": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@thisbeyond/solid-select/-/solid-select-0.14.0.tgz",
@ -25223,6 +25453,11 @@
"prosemirror-view": "^1.28.2"
}
},
"@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="
},
"@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@ -25806,6 +26041,14 @@
}
}
},
"abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"requires": {
"event-target-shim": "^5.0.0"
}
},
"abstract-leveldown": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz",
@ -26788,6 +27031,11 @@
"upper-case": "^2.0.2"
}
},
"content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="
},
"convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
@ -26947,7 +27195,6 @@
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"dev": true,
"requires": {
"ms": "2.1.2"
}
@ -28012,6 +28259,11 @@
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true
},
"event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
},
"events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@ -28213,6 +28465,16 @@
"flat-cache": "^3.0.4"
}
},
"file-type": {
"version": "16.5.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz",
"integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==",
"requires": {
"readable-web-to-node-stream": "^3.0.0",
"strtok3": "^6.2.4",
"token-types": "^4.1.1"
}
},
"filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@ -32470,6 +32732,11 @@
"integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==",
"dev": true
},
"media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="
},
"meow": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz",
@ -32655,8 +32922,56 @@
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"music-metadata": {
"version": "7.13.4",
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-7.13.4.tgz",
"integrity": "sha512-eRRoEMhhYdth2Ws24FmkvIqrtkIBE9sqjHbrRNpkg2Iux3zc37PQKRv2/r/mTtELb7XlB1uWC2UcKKX7BzNMGA==",
"requires": {
"@tokenizer/token": "^0.3.0",
"content-type": "^1.0.5",
"debug": "^4.3.4",
"file-type": "^16.5.4",
"media-typer": "^1.1.0",
"strtok3": "^6.3.0",
"token-types": "^4.2.1"
}
},
"music-metadata-browser": {
"version": "2.5.10",
"resolved": "https://registry.npmjs.org/music-metadata-browser/-/music-metadata-browser-2.5.10.tgz",
"integrity": "sha512-03UnAmsSJoZZ5kK2BnEnd2zpH8LXRWQ6xlc7akKudhc2d9FT+yAiqapnmOzjW3g4cxxvIsSK5MVBO2Gi+Ymjfw==",
"requires": {
"buffer": "^6.0.3",
"debug": "^4.3.4",
"music-metadata": "^7.13.3",
"readable-stream": "^4.3.0",
"readable-web-to-node-stream": "^3.0.2"
},
"dependencies": {
"buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"requires": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"readable-stream": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.4.2.tgz",
"integrity": "sha512-Lk/fICSyIhodxy1IDK2HazkeGjSmezAWX2egdtJnYhtzKEsBPJowlI6F6LPb5tqIQILrMbx22S5o3GuJavPusA==",
"requires": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
}
}
}
},
"mute-stream": {
"version": "0.0.8",
@ -33102,6 +33417,11 @@
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true
},
"peek-readable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",
"integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="
},
"picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
@ -33326,6 +33646,11 @@
}
}
},
"process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="
},
"promise": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
@ -33732,6 +34057,14 @@
"util-deprecate": "^1.0.1"
}
},
"readable-web-to-node-stream": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz",
"integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==",
"requires": {
"readable-stream": "^3.6.0"
}
},
"readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -34574,6 +34907,15 @@
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz",
"integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA=="
},
"strtok3": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
"integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==",
"requires": {
"@tokenizer/token": "^0.3.0",
"peek-readable": "^4.1.0"
}
},
"style-search": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz",
@ -35068,6 +35410,15 @@
"is-number": "^7.0.0"
}
},
"token-types": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz",
"integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==",
"requires": {
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
}
},
"totalist": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.0.tgz",

View File

@ -0,0 +1,5 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.3">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 5H13V6.6H17V5H19V6.6H21H23V8.6V20.6V22.6H21H9H7V20.6V8.6V6.6H9H11V5ZM17 8.6V9.8H19V8.6H21V11.4H9V8.6H11V9.8H13V8.6H17ZM9 13.4V20.6H21V13.4H9Z" fill="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 337 B

View File

@ -0,0 +1,9 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="insert_link">
<circle id="Ellipse 13" opacity="0.5" cx="16" cy="16" r="16" fill="black"/>
<g id="Group">
<path id="Vector" d="M9 22.9999H13.3243V21.7027H11.2054L15.4 17.5297L14.4704 16.6001L10.2974 20.7947V18.6758H9.00019L9 22.9999Z" fill="white"/>
<path id="Vector_2" d="M17.5744 15.3568L21.7041 11.2054V13.3243H23.0013V9H18.677V10.2972H20.7959L16.6445 14.4269L17.5744 15.3568Z" fill="white"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 515 B

10
public/icons/list.svg Normal file
View File

@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="List plus">
<g id="Group">
<path id="Vector" d="M4.5 2.99988V20.9999H19.5V8.39988L13.875 2.99988H4.5ZM17.625 19.1999H6.375V4.79988H12V10.1999H17.625V19.1999ZM13.8751 8.39988V5.54563L16.8483 8.39988H13.8751Z" fill="#141414"/>
<path id="Vector_2" d="M8.25 8.39978H10.125V10.1998H8.25V8.39978Z" fill="#141414"/>
<path id="Vector_3" d="M8.25 11.9999H15.75V13.7999H8.25V11.9999Z" fill="#141414"/>
<path id="Vector_4" d="M8.25 15.6H15.75V17.4H8.25V15.6Z" fill="#141414"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 586 B

View File

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="bookmark">
<path id="Vector" d="M19.0195 4.90048C17.8189 3.69984 15.7378 3.69984 14.4571 4.90048L6.13272 13.2249C6.05265 13.3049 5.97259 13.465 5.97259 13.545L4.0516 18.9079C3.81152 19.5481 4.45181 20.1886 5.09209 19.9484L10.3749 17.9473C10.455 17.9473 10.615 17.7872 10.695 17.7872L19.0194 9.38272C20.3001 8.10213 20.3001 6.10098 19.0194 4.90035L19.0195 4.90048ZM6.13272 17.7873L7.01321 15.2259C7.97378 16.1865 7.6535 15.8662 8.61406 16.8267L6.13272 17.7873ZM17.8989 8.26226L10.0548 16.1064L7.81367 13.8653L15.6578 6.02113C16.2981 5.38085 17.3387 5.38085 17.8989 6.02113C18.5393 6.58137 18.5393 7.62197 17.8989 8.26226V8.26226Z" fill="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 760 B

View File

@ -3,13 +3,16 @@
"About myself": "About myself",
"About the project": "About the project",
"Add another image": "Add another image",
"Add audio": "Add audio",
"Add comment": "Comment",
"Add cover": "Add cover",
"Add image": "Add image",
"Add images": "Add images",
"Add link": "Add link",
"Add signature": "Add signature",
"Add url": "Add url",
"Address on Discourse": "Address on Discourse",
"Album name": "Название aльбома",
"Alignment center": "Alignment center",
"Alignment left": "Alignment left",
"Alignment right": "Alignment right",
@ -18,6 +21,7 @@
"All posts": "All posts",
"All topics": "All topics",
"Almost done! Check your email.": "Almost done! Just checking your email.",
"Artist": "Artist",
"Artworks": "Artworks",
"Audio": "Audio",
"Author": "Author",
@ -62,9 +66,12 @@
"Create account from follow": "Create an account to subscribe",
"Create account from subscribe": "Create an account to subscribe to new publications",
"Create account from vote": "Create an account to vote",
"Create gallery": "Create gallery",
"Create post": "Create post",
"Create video": "Create video",
"Date of Birth": "Date of Birth",
"Delete": "Delete",
"Description": "Description...",
"Discours": "Discours",
"Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects": "Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects",
"Discours is created with our common effort": "Discours exists because of our common effort",
@ -101,6 +108,7 @@
"Forgot password?": "Forgot your password?",
"Forward": "Forward",
"Full name": "First and last name",
"Gallery name": "Gallery name",
"Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine": "Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine",
"Go to main page": "Go to main page",
"Group Chat": "Group Chat",
@ -162,6 +170,7 @@
"My feed": "My feed",
"My subscriptions": "Subscriptions",
"Name": "Name",
"New literary work": "New literary work",
"New only": "New only",
"New password": "New password",
"New stories every day and even more!": "New stories and more are waiting for you every day!",
@ -199,6 +208,7 @@
"Profile": "Profile",
"Profile settings": "Profile settings",
"Publications": "Publications",
"Publish Album": "Publish Album",
"Publish Settings": "Publish Settings",
"Punchline": "Punchline",
"Quit": "Quit",
@ -226,9 +236,12 @@
"Share": "Share",
"Short opening": "Short opening",
"Show": "Show",
"Show lyrics": "Текст песни",
"Social networks": "Social networks",
"Something went wrong, check email and password": "Something went wrong. Check your email and password",
"Something went wrong, please try again": "Something went wrong, please try again",
"Song lyrics": "Song lyrics...",
"Song title": "Song title",
"Sorry, this address is already taken, please choose another one.": "Sorry, this address is already taken, please choose another one",
"Special projects": "Special projects",
"Specify the source and the name of the author": "Specify the source and the name of the author",
@ -268,6 +281,7 @@
"Unfollow the topic": "Unfollow the topic",
"Unnamed draft": "Unnamed draft",
"Upload": "Upload",
"Upload error": "Upload error",
"Upload video": "Upload video",
"Username": "Username",
"Userpic": "Userpic",
@ -290,6 +304,7 @@
"Write message": "Write a message",
"Write to us": "Write to us",
"You are subscribed": "You are subscribed",
"You can download multiple tracks at once in .mp3, .wav or .flac formats": "You can download multiple tracks at once in .mp3, .wav or .flac formats",
"You were successfully authorized": "You were successfully authorized",
"You've confirmed email": "You've confirmed email",
"You've reached a non-existed page": "You've reached a non-existed page",
@ -322,8 +337,10 @@
"images": "images",
"invalid password": "invalid password",
"italic": "italic",
"jpg, .png, max. 10 mb.": "jpg, .png, макс. 10 мб.",
"literature": "literature",
"marker list": "marker list",
"min. 1400×1400 pix": "мин. 1400×1400 пикс.",
"music": "music",
"my feed": "my ribbon",
"number list": "number list",

View File

@ -4,9 +4,10 @@
"About myself": "О себе",
"About the project": "О проекте",
"Accomplices": "Соучастники",
"Accomplices": "Соучастники",
"Add another image": "Добавить другое изображение",
"Add audio": "Добавить аудио",
"Add comment": "Комментировать",
"Add cover": "Добавить обложку",
"Add image": "Добавить изображение",
"Add images": "Добавить изображения",
"Add link": "Добавить ссылку",
@ -14,6 +15,7 @@
"Add to bookmarks": "Добавить в закладки",
"Add url": "Добавить ссылку",
"Address on Discourse": "Адрес на Дискурсе",
"Album name": "Название альбома",
"Alignment center": "По центру",
"Alignment left": "По левому краю",
"Alignment right": "По правому краю",
@ -22,6 +24,8 @@
"All posts": "Все публикации",
"All topics": "Все темы",
"Almost done! Check your email.": "Почти готово! Осталось подтвердить вашу почту.",
"Artist": "Исполнитель",
"Artist...": "Исполнитель...",
"Artworks": "Артворки",
"Audio": "Аудио",
"Author": "Автор",
@ -66,9 +70,12 @@
"Create account from follow": "Создайте аккаунт, чтобы подписаться",
"Create account from subscribe": "Создайте аккаунт для подписки на новые публикации",
"Create account from vote": "Создайте аккаунт, чтобы голосовать",
"Create gallery": "Создать галерею",
"Create post": "Создать публикацию",
"Create video": "Создать видео",
"Date of Birth": "Дата рождения",
"Delete": "Удалить",
"Description": "Описание...",
"Discours": "Дискурс",
"Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects": "Дискурс — это интеллектуальная среда, веб-пространство и инструменты, которые позволяют авторам сотрудничать с читателями и объединяться для совместного создания публикаций и медиапроектов",
"Discours is created with our common effort": "Дискурс существует благодаря нашему общему вкладу",
@ -106,6 +113,8 @@
"Forgot password?": "Забыли пароль?",
"Forward": "Переслать",
"Full name": "Имя и фамилия",
"Gallery name": "Название галереи",
"Genre...": "Жанр...",
"Get notifications": "Получать уведомления",
"Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine": "Познакомитесь с выдающимися людьми нашего времени, участвуйте в редактировании и обсуждении статей, выступайте экспертом, оценивайте материалы других авторов со всего мира и определяйте, какие статьи будут опубликованы в журнале",
"Go to main page": "Перейти на главную",
@ -171,6 +180,7 @@
"My feed": "Новое",
"My subscriptions": "Подписки",
"Name": "Имя",
"New literary work": "Новое произведение",
"New only": "Только новые",
"New password": "Новый пароль",
"New stories every day and even more!": "Каждый день вас ждут новые истории и ещё много всего интересного!",
@ -212,6 +222,7 @@
"Publication settings": "Настройки публикации",
"Publications": "Публикации",
"Publish": "Опубликовать",
"Publish Album": "Опубликовать альбом",
"Publish Settings": "Настройки публикации",
"Punchline": "Панчлайн",
"Quit": "Выйти",
@ -219,6 +230,7 @@
"Quotes": "Цитаты",
"Reason uknown": "Причина неизвестна",
"Recent": "Свежее",
"Release date...": "Дата выхода...",
"Reply": "Ответить",
"Report": "Пожаловаться",
"Required": "Поле обязательно для заполнения",
@ -240,9 +252,12 @@
"Share": "Поделиться",
"Short opening": "Небольшое вступление, чтобы заинтересовать читателя",
"Show": "Показать",
"Show lyrics": "Текст песни",
"Social networks": "Социальные сети",
"Something went wrong, check email and password": "Что-то пошло не так. Проверьте адрес электронной почты и пароль",
"Something went wrong, please try again": "Что-то пошло не так, попробуйте еще раз",
"Song lyrics": "Текст песни...",
"Song title": "Название песни",
"Sorry, this address is already taken, please choose another one.": "Увы, этот адрес уже занят, выберите другой",
"Special projects": "Спецпроекты",
"Specify the source and the name of the author": "Укажите источник и имя автора",
@ -283,6 +298,7 @@
"Unfollow the topic": "Отписаться от темы",
"Unnamed draft": "Unnamed draft",
"Upload": "Загрузить",
"Upload error": "Ошибка загрузки",
"Upload video": "Загрузить видео",
"Username": "Имя пользователя",
"Userpic": "Аватар",
@ -306,6 +322,7 @@
"Write message": "Написать сообщение",
"Write to us": "Напишите нам",
"You are subscribed": "Вы подписаны",
"You can download multiple tracks at once in .mp3, .wav or .flac formats": "Можно загрузить сразу несколько треков в форматах .mp3, .wav или .flac",
"You was successfully authorized": "Вы были успешно авторизованы",
"You've confirmed email": "Вы подтвердили почту",
"You've reached a non-existed page": "Вы попали на несуществующую страницу",
@ -341,8 +358,10 @@
"images": "изображения",
"invalid password": "некорректный пароль",
"italic": "курсив",
"jpg, .png, max. 10 mb.": "jpg, .png, макс. 10 мб.",
"literature": "литература",
"marker list": "маркир. список",
"min. 1400×1400 pix": "мин. 1400×1400 пикс.",
"music": "музыка",
"my feed": "моя лента",
"number list": "нумер. список",

View File

@ -291,8 +291,9 @@ img {
}
.shoutStatsItem {
align-items: center;
@include font-size(1.5rem);
align-items: center;
font-weight: 500;
display: flex;
margin: 0 6% 1em 0;

View File

@ -0,0 +1,79 @@
.AudioHeader {
overflow: hidden;
margin-bottom: 32px;
.albumInfo {
margin-right: 224px;
.topic {
.link {
@include font-size(1.6rem);
color: var(--blue-link);
border: none;
&:hover {
text-decoration: underline;
}
}
}
& > h1 {
margin: 16px 0 0;
}
.artistData {
margin: 18px 0 0;
display: flex;
flex-direction: row;
.item {
@include font-size(1.6rem);
font-weight: 500;
padding: 2px 12px;
border-left: 2px solid #e9e9ee;
&:first-child {
border-left: none;
padding-left: 0;
}
}
}
}
.cover {
display: block;
position: relative;
float: right;
width: 200px;
height: 200px;
transition: all 0.2s ease-in-out;
background: var(--placeholder-color-semi) url('icons/create-music.svg') no-repeat 50% 50%;
.image {
object-fit: cover;
width: 100%;
height: 100%;
}
.expand {
position: absolute;
top: 8px;
right: 8px;
}
}
&.expandedImage {
.cover {
width: 100%;
height: auto;
margin-bottom: 32px;
}
.albumInfo {
margin-right: 0;
clear: both;
}
}
}

View File

@ -0,0 +1,53 @@
import { clsx } from 'clsx'
import styles from './AudioHeader.module.scss'
import { imageProxy } from '../../../utils/imageProxy'
import { MediaItem } from '../../../pages/types'
import { createSignal, Show } from 'solid-js'
import { Icon } from '../../_shared/Icon'
import { Topic } from '../../../graphql/types.gen'
import { getPagePath } from '@nanostores/router'
import { router } from '../../../stores/router'
type Props = {
title: string
cover?: string
artistData?: MediaItem
topic: Topic
}
export const AudioHeader = (props: Props) => {
const [expandedImage, setExpandedImage] = createSignal(false)
return (
<div class={clsx(styles.AudioHeader, { [styles.expandedImage]: expandedImage() })}>
<div class={styles.cover}>
<img class={styles.image} src={imageProxy(props.cover)} alt={props.title} />
<Show when={props.cover}>
<button type="button" class={styles.expand} onClick={() => setExpandedImage(!expandedImage())}>
<Icon name="expand-circle" />
</button>
</Show>
</div>
<div class={styles.albumInfo}>
<Show when={props.topic}>
<div class={styles.topic}>
<a href={getPagePath(router, 'topic', { slug: props.topic.slug })} class={styles.link}>
{props.topic.title}
</a>
</div>
</Show>
<h1>{props.title}</h1>
<div class={styles.artistData}>
<Show when={props.artistData.artist}>
<div class={styles.item}>{props.artistData.artist}</div>
</Show>
<Show when={props.artistData.date}>
<div class={styles.item}>{props.artistData.date}</div>
</Show>
<Show when={props.artistData.genre}>
<div class={styles.item}>{props.artistData.genre}</div>
</Show>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1 @@
export { AudioHeader } from './AudioHeader'

View File

@ -46,20 +46,20 @@
margin-top: 20px;
margin-left: 0;
}
}
.playButton {
display: flex;
align-items: center;
justify-content: center;
.playButton {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background-color: #141414;
width: 40px;
height: 40px;
background: #141414;
& img {
width: 14px;
height: auto;
& img {
width: 14px;
height: auto;
}
}
}
@ -113,22 +113,25 @@
.progressFilled {
position: absolute;
top: -1px;
left: 0;
z-index: 2;
box-sizing: border-box;
border-bottom: 4px solid #141414;
border-bottom: 4px solid var(--default-color);
transition: width 0.3s linear;
&::after {
content: '';
display: block;
position: absolute;
bottom: -8px;
bottom: -10px;
right: -8px;
width: 8px;
height: 8px;
border-radius: 50%;
border: 4px solid #141414;
background-color: #fff;
border: 4px solid var(--default-color);
background-color: var(--background-color);
}
}
@ -227,7 +230,7 @@
flex-direction: column;
list-style-type: none;
margin: 32px 0 58px;
margin: 32px 0 16px;
padding: 0;
& > li {
@ -243,6 +246,11 @@
padding: 16px 0;
}
.description {
display: flex;
flex-direction: column;
}
.playlistItemPlayButton {
border: none;
cursor: pointer;
@ -250,18 +258,42 @@
height: auto;
}
.playlistItemTitle {
max-width: 254px;
.playlistItemText {
@include font-size(1.6rem);
display: flex;
flex-direction: row;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0 16px;
gap: 16px;
color: var(--default-color);
margin-left: 17px;
font-weight: 400;
font-size: 16px;
line-height: 22px;
letter-spacing: -0.01em;
color: #000000;
.artist,
.title {
@include font-size(1.6rem);
overflow: hidden;
max-width: calc(50% - 16px);
text-overflow: ellipsis;
padding: 0;
margin: 0;
border: none;
&:focus {
outline: none;
}
&::placeholder {
font-weight: 400;
color: var(--secondary-color);
}
}
.title {
font-weight: 500;
}
}
.playlistItemControls {
@ -275,7 +307,7 @@
font-size: 16px;
line-height: 22px;
letter-spacing: -0.01em;
color: #000000;
color: var(--default-color);
}
.timelinePlaceholder {
@ -293,6 +325,29 @@
height: 67px;
}
.shareMedia {
.actions {
margin-left: auto;
display: flex;
flex-direction: row;
gap: 16px;
}
.descriptionBlock {
display: flex;
flex-direction: column;
gap: 16px;
padding: 8px 0 24px 0;
.description,
.lyrics {
@include font-size(1.4rem);
}
.description {
font-weight: 500;
& > textarea::placeholder {
font-weight: 400;
}
}
}

View File

@ -1,54 +1,63 @@
import { createEffect, createSignal, onMount, Show } from 'solid-js'
import { createEffect, createSignal, on, onMount, Show } from 'solid-js'
import { PlayerHeader } from './PlayerHeader'
import { PlayerPlaylist } from './PlayerPlaylist'
import styles from './AudioPlayer.module.scss'
import { MediaItem } from '../../../pages/types'
export type MediaItem = {
id?: number
body: string
pic: string
title: string
url: string
isCurrent: boolean
isPlaying: boolean
export type Audio = {
pic?: string
index?: number
isCurrent?: boolean
isPlaying?: boolean
} & MediaItem
type Props = {
media: Audio[]
articleSlug?: string
body?: string
editorMode?: boolean
onAudioChange?: (index: number, field: string, value: string) => void
}
const prepareMedia = (media: MediaItem[]) =>
const prepareMedia = (media: Audio[]) =>
media.map((item, index) => ({
...item,
id: index,
index: index,
isCurrent: false,
isPlaying: false
}))
const progressUpdate = (audioRef, progressFilledRef, duration) => {
progressFilledRef.style.width = `${(audioRef.currentTime / duration) * 100 || 0}%`
progressFilledRef.current.style.width = `${(audioRef.current.currentTime / duration) * 100 || 0}%`
}
const scrub = (event, progressRef, duration, audioRef) => {
audioRef.currentTime = (event.offsetX / progressRef.offsetWidth) * duration
audioRef.current.currentTime = (event.offsetX / progressRef.current.offsetWidth) * duration
}
const getFormattedTime = (point) => new Date(point * 1000).toISOString().slice(14, -5)
export default (props: { media: MediaItem[]; articleSlug: string; body: string }) => {
let audioRef: HTMLAudioElement
let progressRef: HTMLDivElement
let progressFilledRef: HTMLDivElement
export const AudioPlayer = (props: Props) => {
const audioRef: { current: HTMLAudioElement } = { current: null }
const progressRef: { current: HTMLDivElement } = { current: null }
const progressFilledRef: { current: HTMLDivElement } = { current: null }
const [audioContext, setAudioContext] = createSignal<AudioContext>()
const [gainNode, setGainNode] = createSignal<GainNode>()
const [tracks, setTracks] = createSignal<MediaItem[] | null>(prepareMedia(props.media))
const [tracks, setTracks] = createSignal<Audio[] | null>(prepareMedia(props.media))
const [duration, setDuration] = createSignal<number>(0)
const [currentTimeContent, setCurrentTimeContent] = createSignal<string>('00:00')
const [currentDurationContent, setCurrentDurationContent] = createSignal<string>('00:00')
const [mousedown, setMousedown] = createSignal<boolean>(false)
createEffect(
on(
() => props.media,
() => {
setTracks(prepareMedia(props.media))
}
)
)
const getCurrentTrack = () =>
tracks().find((track) => track.isCurrent) ||
(() => {
@ -63,10 +72,10 @@ export default (props: { media: MediaItem[]; articleSlug: string; body: string }
})()
createEffect(() => {
if (audioRef.src !== getCurrentTrack().url) {
audioRef.src = getCurrentTrack().url
if (audioRef.current.src !== getCurrentTrack().url) {
audioRef.current.src = getCurrentTrack().url
audioRef.load()
audioRef.current.load()
}
})
@ -76,33 +85,33 @@ export default (props: { media: MediaItem[]; articleSlug: string; body: string }
}
})
const playMedia = async (m: MediaItem) => {
const playMedia = async (m: Audio) => {
setTracks(
tracks().map((track) => ({
...track,
isCurrent: track.id === m.id,
isPlaying: track.id === m.id ? !track.isPlaying : false
isCurrent: track.index === m.index,
isPlaying: track.index === m.index ? !track.isPlaying : false
}))
)
progressUpdate(audioRef, progressFilledRef, duration())
if (audioContext().state === 'suspended') audioContext().resume()
if (audioContext().state === 'suspended') await audioContext().resume()
if (getCurrentTrack().isPlaying) {
await audioRef.play()
await audioRef.current.play()
} else {
audioRef.pause()
audioRef.current.pause()
}
}
const setTimes = () => {
setCurrentTimeContent(getFormattedTime(audioRef.currentTime))
setCurrentTimeContent(getFormattedTime(audioRef.current.currentTime))
}
const handleAudioEnd = () => {
progressFilledRef.style.width = '0%'
audioRef.currentTime = 0
progressFilledRef.current.style.width = '0%'
audioRef.current.currentTime = 0
}
const handleAudioTimeUpdate = () => {
@ -117,42 +126,42 @@ export default (props: { media: MediaItem[]; articleSlug: string; body: string }
setTimes()
const track = audioContext().createMediaElementSource(audioRef)
const track = audioContext().createMediaElementSource(audioRef.current)
track.connect(gainNode()).connect(audioContext().destination)
})
const playPrevTrack = () => {
const { id } = getCurrentTrack()
const currIndex = tracks().findIndex((track) => track.id === id)
const { index } = getCurrentTrack()
const currIndex = tracks().findIndex((track) => track.index === index)
const getUpdatedStatus = (trackId) =>
currIndex === 0
? trackId === tracks()[tracks().length - 1].id
: trackId === tracks()[currIndex - 1].id
? trackId === tracks()[tracks().length - 1].index
: trackId === tracks()[currIndex - 1].index
setTracks(
tracks().map((track) => ({
...track,
isCurrent: getUpdatedStatus(track.id),
isPlaying: getUpdatedStatus(track.id)
isCurrent: getUpdatedStatus(track.index),
isPlaying: getUpdatedStatus(track.index)
}))
)
}
const playNextTrack = () => {
const { id } = getCurrentTrack()
const currIndex = tracks().findIndex((track) => track.id === id)
const { index } = getCurrentTrack()
const currIndex = tracks().findIndex((track) => track.index === index)
const getUpdatedStatus = (trackId) =>
currIndex === tracks().length - 1
? trackId === tracks()[0].id
: trackId === tracks()[currIndex + 1].id
? trackId === tracks()[0].index
: trackId === tracks()[currIndex + 1].index
setTracks(
tracks().map((track) => ({
...track,
isCurrent: getUpdatedStatus(track.id),
isPlaying: getUpdatedStatus(track.id)
isCurrent: getUpdatedStatus(track.index),
isPlaying: getUpdatedStatus(track.index)
}))
)
}
@ -161,6 +170,15 @@ export default (props: { media: MediaItem[]; articleSlug: string; body: string }
setDuration(target.duration)
}
const handleAudioDescriptionChange = (index: number, field: string, value) => {
props.onAudioChange(index, field, value)
setTracks(
tracks().map((track, idx) => {
return idx === index ? { ...track, [field]: value } : track
})
)
}
return (
<div>
<Show when={getCurrentTrack()}>
@ -177,24 +195,24 @@ export default (props: { media: MediaItem[]; articleSlug: string; body: string }
<div class={styles.timeline}>
<div
class={styles.progress}
ref={progressRef}
ref={(el) => (progressRef.current = el)}
onClick={(e) => scrub(e, progressRef, duration(), audioRef)}
onMouseMove={(e) => mousedown() && scrub(e, progressRef, duration(), audioRef)}
onMouseDown={() => setMousedown(true)}
onMouseUp={() => setMousedown(false)}
>
<div class={styles.progressFilled} ref={progressFilledRef}></div>
<div class={styles.progressFilled} ref={(el) => (progressFilledRef.current = el)} />
</div>
<div class={styles.progressTiming}>
<span>{currentTimeContent()}</span>
<span>{currentDurationContent()}</span>
</div>
<audio
ref={audioRef}
ref={(el) => (audioRef.current = el)}
onTimeUpdate={handleAudioTimeUpdate}
onCanPlay={() => {
if (getCurrentTrack().isPlaying) {
audioRef.play()
audioRef.current.play()
}
}}
onLoadedMetadata={handleOnAudioMetadataLoad}
@ -203,14 +221,15 @@ export default (props: { media: MediaItem[]; articleSlug: string; body: string }
/>
</div>
</Show>
<Show when={tracks()}>
<PlayerPlaylist
editorMode={props.editorMode}
playMedia={playMedia}
tracks={tracks()}
getCurrentTrack={getCurrentTrack}
currentTrack={getCurrentTrack()}
articleSlug={props.articleSlug}
body={props.body}
onAudioChange={handleAudioDescriptionChange}
/>
</Show>
</div>

View File

@ -35,29 +35,29 @@ export const PlayerHeader = (props) => {
<div class={styles.playerTitle}>{getCurrentTrack().title}</div>
<div class={styles.playerControls}>
<button
type="button"
onClick={onPlayMedia}
class={clsx(
styles.playButton,
getCurrentTrack().isPlaying ? styles.playButtonInvertPause : styles.playButtonInvertPlay
)}
role="button"
aria-label="Play"
data-playing="false"
>
<Icon name={getCurrentTrack().isPlaying ? 'pause' : 'play'} />
</button>
<button
type="button"
onClick={playPrevTrack}
class={clsx(styles.controlsButton)}
role="button"
aria-label="Previous"
>
<Icon name="player-arrow" />
</button>
<button
type="button"
onClick={playNextTrack}
class={clsx(styles.controlsButton, styles.controlsButtonNext)}
role="button"
aria-label="Next"
>
<Icon name="player-arrow" />

View File

@ -1,61 +1,159 @@
import { For } from 'solid-js'
import { createEffect, createSignal, For, Show } from 'solid-js'
import { SharePopup, getShareUrl } from '../SharePopup'
import { getDescription } from '../../../utils/meta'
import { useLocalize } from '../../../context/localize'
import type { MediaItem } from './AudioPlayer'
import type { Audio } from './AudioPlayer'
import { Popover } from '../../_shared/Popover'
import { Icon } from '../../_shared/Icon'
import styles from './AudioPlayer.module.scss'
import { GrowingTextarea } from '../../_shared/GrowingTextarea'
import MD from '../MD'
export const PlayerPlaylist = (props) => {
type Props = {
tracks: Audio[]
currentTrack: Audio
playMedia: (audio: Audio) => void
articleSlug?: string
body?: string
editorMode?: boolean
onAudioChange?: (index: number, field: string, value: string) => void
}
export const PlayerPlaylist = (props: Props) => {
const { t } = useLocalize()
const [activeEditIndex, setActiveEditIndex] = createSignal(-1)
const { tracks, getCurrentTrack, playMedia, articleSlug, body } = props
const toggleDropDown = (index) => {
setActiveEditIndex(activeEditIndex() === index ? -1 : index)
}
const updateData = (key, value) => {
props.onAudioChange(activeEditIndex(), key, value)
}
return (
<ul class={styles.playlist}>
<For each={tracks}>
{(m: MediaItem) => (
<li class={styles.playlistItem}>
<button
class={styles.playlistItemPlayButton}
onClick={() => playMedia(m)}
role="button"
aria-label="Play"
>
<Icon
name={
getCurrentTrack() && getCurrentTrack().id === m.id && getCurrentTrack().isPlaying
? 'pause'
: 'play'
}
/>
</button>
<div class={styles.playlistItemTitle}>{m.title}</div>
<div class={styles.shareMedia}>
<Popover content={t('Share')}>
{(triggerRef: (el) => void) => (
<div ref={triggerRef}>
<SharePopup
title={m.title}
description={getDescription(body)}
imageUrl={m.pic}
shareUrl={getShareUrl({ pathname: `/${articleSlug}` })}
trigger={
<div>
<Icon name="share-media" />
</div>
}
/>
</div>
)}
</Popover>
<For each={props.tracks}>
{(m: Audio, index) => (
<li>
<div class={styles.playlistItem}>
<button
class={styles.playlistItemPlayButton}
onClick={() => props.playMedia(m)}
type="button"
aria-label="Play"
>
<Icon
name={
props.currentTrack &&
props.currentTrack.index === m.index &&
props.currentTrack.isPlaying
? 'pause'
: 'play'
}
/>
</button>
<div class={styles.playlistItemText}>
<Show
when={activeEditIndex() === index() && props.editorMode}
fallback={
<>
<div class={styles.title}>
{m.title.replace(/\.(wav|flac|mp3|aac)$/i, '') || t('Song title')}
</div>
<div class={styles.artist}>{m.artist || t('Artist')}</div>
</>
}
>
<input
type="text"
value={m.title}
class={styles.title}
placeholder={t('Song title')}
onChange={(e) => updateData('title', e.target.value)}
/>
<input
type="text"
value={m.artist}
class={styles.artist}
placeholder={t('Artist')}
onChange={(e) => updateData('artist', e.target.value)}
/>
</Show>
</div>
<div class={styles.actions}>
<Show when={(m.lyrics || m.body) && !props.editorMode}>
<Popover content={t('Show lyrics')}>
{(triggerRef: (el) => void) => (
<button ref={triggerRef} type="button" onClick={() => toggleDropDown(index())}>
<Icon name="list" />
</button>
)}
</Popover>
</Show>
<Popover content={props.editorMode ? t('Edit') : t('Share')}>
{(triggerRef: (el) => void) => (
<div ref={triggerRef}>
<Show
when={!props.editorMode}
fallback={
<button type="button" onClick={() => toggleDropDown(index())}>
<Icon name="pencil-stroke" />
</button>
}
>
<SharePopup
title={m.title}
description={getDescription(props.body)}
imageUrl={m.pic}
shareUrl={getShareUrl({ pathname: `/${props.articleSlug}` })}
trigger={
<div>
<Icon name="share-media" />
</div>
}
/>
</Show>
</div>
)}
</Popover>
</div>
</div>
<Show when={activeEditIndex() === index()}>
<Show
when={props.editorMode}
fallback={
<div class={styles.descriptionBlock}>
<Show when={m.body}>
<div class={styles.description}>
<MD body={m.body} />
</div>
</Show>
<Show when={m.lyrics}>
<div class={styles.lyrics}>
<MD body={m.lyrics} />
</div>
</Show>
</div>
}
>
<div class={styles.descriptionBlock}>
<GrowingTextarea
allowEnterKey={true}
class={styles.description}
placeholder={t('Description')}
value={(value) => updateData('body', value)}
initialValue={m.body || ''}
/>
<GrowingTextarea
allowEnterKey={true}
class={styles.lyrics}
placeholder={t('Song lyrics')}
value={(value) => updateData('lyrics', value)}
initialValue={m.lyrics || ''}
/>
</div>
</Show>
</Show>
</li>
)}
</For>

View File

@ -0,0 +1 @@
export { AudioPlayer } from './AudioPlayer'

View File

@ -1,7 +1,7 @@
import { capitalize, formatDate } from '../../utils'
import { Icon } from '../_shared/Icon'
import { AuthorCard } from '../Author/AuthorCard'
import AudioPlayer from './AudioPlayer/AudioPlayer'
import { AudioPlayer } from './AudioPlayer'
import type { Author, Shout } from '../../graphql/types.gen'
import MD from './MD'
import { SharePopup } from './SharePopup'
@ -21,20 +21,15 @@ import styles from './Article.module.scss'
import { imageProxy } from '../../utils/imageProxy'
import { Popover } from '../_shared/Popover'
import article from '../Editor/extensions/Article'
import { SolidSwiper } from '../_shared/SolidSwiper'
import { createEffect, For, createMemo, Match, onMount, Show, Switch, createSignal } from 'solid-js'
import { createEffect, For, createMemo, onMount, Show, createSignal, Switch, Match } from 'solid-js'
import { MediaItem } from '../../pages/types'
import { AudioHeader } from './AudioHeader'
interface ArticleProps {
article: Shout
scrollToComments?: boolean
}
interface MediaItem {
url?: string
title?: string
body?: string
}
export const FullArticle = (props: ArticleProps) => {
const { t } = useLocalize()
const {
@ -117,40 +112,52 @@ export const FullArticle = (props: ArticleProps) => {
<div class="row">
<article class="col-md-16 col-lg-14 col-xl-12 offset-md-5">
{/*TODO: Check styles.shoutTopic*/}
<div class={styles.shoutHeader}>
<Show when={mainTopic()}>
<div class={styles.shoutTopic}>
<a
href={getPagePath(router, 'topic', { slug: props.article.mainTopic })}
class={styles.mainTopicLink}
>
{mainTopic().title}
</a>
<Switch>
<Match when={props.article.layout !== 'audio'}>
<div class={styles.shoutHeader}>
<Show when={mainTopic()}>
<div class={styles.shoutTopic}>
<a
href={getPagePath(router, 'topic', { slug: props.article.mainTopic })}
class={styles.mainTopicLink}
>
{mainTopic().title}
</a>
</div>
</Show>
<h1>{props.article.title}</h1>
<Show when={props.article.subtitle}>
<h4>{capitalize(props.article.subtitle, false)}</h4>
</Show>
<div class={styles.shoutAuthor}>
<For each={props.article.authors}>
{(a: Author, index) => (
<>
<Show when={index() > 0}>, </Show>
<a href={getPagePath(router, 'author', { slug: a.slug })}>{a.name}</a>
</>
)}
</For>
</div>
<Show when={props.article.cover && props.article.layout !== 'video'}>
<div
class={styles.shoutCover}
style={{ 'background-image': `url('${imageProxy(props.article.cover)}')` }}
/>
</Show>
</div>
</Show>
<h1>{props.article.title}</h1>
<Show when={props.article.subtitle}>
<h4>{capitalize(props.article.subtitle, false)}</h4>
</Show>
<div class={styles.shoutAuthor}>
<For each={props.article.authors}>
{(a: Author, index) => (
<>
<Show when={index() > 0}>, </Show>
<a href={getPagePath(router, 'author', { slug: a.slug })}>{a.name}</a>
</>
)}
</For>
</div>
<Show when={props.article.cover && props.article.layout !== 'video'}>
<div
class={styles.shoutCover}
style={{ 'background-image': `url('${imageProxy(props.article.cover)}')` }}
</Match>
<Match when={props.article.layout === 'audio'}>
<AudioHeader
title={props.article.title}
cover={props.article.cover}
artistData={media()[0]}
topic={mainTopic()}
/>
</Show>
</div>
</Match>
</Switch>
<Show when={media() && props.article.layout === 'video'}>
<div class="media-items">

View File

@ -0,0 +1,17 @@
.AudioUploader {
display: block;
margin-top: 2rem;
.draggable {
margin: 8px 0;
padding: 8px 0;
&:hover {
background: var(--placeholder-color-semi);
}
}
}
.sortable {
background: red;
padding: 8px 0;
}

View File

@ -0,0 +1,41 @@
import { clsx } from 'clsx'
import styles from './AudioUploader.module.scss'
import { DropArea } from '../../_shared/DropArea'
import { useLocalize } from '../../../context/localize'
import { createEffect, createSignal, on, Show } from 'solid-js'
import { MediaItem } from '../../../pages/types'
import { composeMediaItems } from '../../../utils/composeMediaItems'
import { AudioPlayer } from '../../Article/AudioPlayer'
import { Buffer } from 'buffer'
window.Buffer = Buffer
type Props = {
class?: string
audio: MediaItem[]
onAudioChange: (index: number, value: MediaItem) => void
onAudioAdd: (value: MediaItem[]) => void
}
export const AudioUploader = (props: Props) => {
const { t } = useLocalize()
const handleAudioDescriptionChange = (index: number, field: string, value) => {
props.onAudioChange(index, { ...props.audio[index], [field]: value })
}
return (
<div class={clsx(styles.AudioUploader, props.class)}>
<Show when={props.audio.length > 0}>
<AudioPlayer editorMode={true} media={props.audio} onAudioChange={handleAudioDescriptionChange} />
</Show>
<DropArea
isMultiply={true}
placeholder={t('Add audio')}
description={t('You can download multiple tracks at once in .mp3, .wav or .flac formats')}
fileType={'audio'}
onUpload={(value) => props.onAudioAdd(composeMediaItems(value))}
/>
</div>
)
}

View File

@ -0,0 +1 @@
export { AudioUploader } from './AudioUploader'

View File

@ -90,6 +90,34 @@
.titleInput {
font-weight: 700;
}
.additional {
margin-top: auto;
.additionalInput {
@include font-size(1.4rem);
font-weight: 600;
padding: 0;
margin: 14px 0 0;
border: none;
outline: none;
&::placeholder {
color: var(--secondary-color);
}
}
.datepicker {
display: flex;
justify-content: flex-start;
align-items: center;
margin: 14px 0 0;
.additionalInput {
margin-top: 0;
}
}
}
}
// Grow input
@ -215,13 +243,36 @@
.inputContainer {
position: relative;
flex: 1;
display: flex;
flex-flow: column;
.validationError {
position: absolute;
z-index: 1;
top: 100%;
top: calc(100% + 4px);
font-size: small;
color: #f00;
color: var(--danger-color);
}
}
.audioHeader {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 24px;
.inputContainer {
flex: 1;
}
.cover {
width: 228px;
height: 228px;
flex-basis: 228px;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
}
}

View File

@ -1,4 +1,4 @@
import { createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
import { Accessor, createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
import { useLocalize } from '../../context/localize'
import { clsx } from 'clsx'
import { Title } from '@solidjs/meta'
@ -16,9 +16,12 @@ import { hideModal, showModal } from '../../stores/ui'
import { imageProxy } from '../../utils/imageProxy'
import { GrowingTextarea } from '../_shared/GrowingTextarea'
import { VideoUploader } from '../Editor/VideoUploader'
import { AudioUploader } from '../Editor/AudioUploader'
import { VideoPlayer } from '../_shared/VideoPlayer'
import { slugify } from '../../utils/slugify'
import { SolidSwiper } from '../_shared/SolidSwiper'
import { DropArea } from '../_shared/DropArea'
import { LayoutType, MediaItem } from '../../pages/types'
type Props = {
shout: Shout
@ -66,7 +69,7 @@ export const EditView = (props: Props) => {
layout: props.shout.layout
})
const mediaItems = createMemo(() => {
const mediaItems: Accessor<MediaItem[]> = createMemo(() => {
return JSON.parse(form.media || '[]')
})
@ -125,9 +128,9 @@ export const EditView = (props: Props) => {
setForm('selectedTopics', newSelectedTopics)
}
const handleAddImages = (data) => {
const newImages = [...mediaItems(), ...data]
setForm('media', JSON.stringify(newImages))
const handleAddMedia = (data) => {
const newMedia = [...mediaItems(), ...data]
setForm('media', JSON.stringify(newMedia))
}
const handleSortedImages = (data) => {
setForm('media', JSON.stringify(data))
@ -139,15 +142,54 @@ export const EditView = (props: Props) => {
setForm('media', JSON.stringify(copy))
}
const handleImageChange = (index, value) => {
const handleMediaChange = (index, value) => {
const updated = mediaItems().map((item, idx) => (idx === index ? value : item))
setForm('media', JSON.stringify(updated))
}
const handleBaseFieldsChange = (key, value) => {
const updated = mediaItems().map((media) => ({ ...media, [key]: value }))
setForm('media', JSON.stringify(updated))
}
const articleTitle = () => {
switch (props.shout.layout as LayoutType) {
case 'audio': {
return t('Album name')
}
case 'image': {
return t('Gallery name')
}
default: {
return t('Header')
}
}
}
const pageTitle = () => {
switch (props.shout.layout as LayoutType) {
case 'audio': {
return t('Publish Album')
}
case 'image': {
return t('Create gallery')
}
case 'video': {
return t('Create video')
}
case 'literature': {
return t('New literary work')
}
default: {
return t('Write an article')
}
}
}
return (
<>
<div class={styles.container}>
<Title>{t('Write an article')}</Title>
<Title>{pageTitle()}</Title>
<form>
<div class="wide-container">
<button
@ -167,33 +209,88 @@ export const EditView = (props: Props) => {
[styles.visible]: page().route === 'edit'
})}
>
<div class={styles.inputContainer}>
<GrowingTextarea
value={(value) => handleTitleInputChange(value)}
class={styles.titleInput}
placeholder={t('Header')}
initialValue={form.title}
maxLength={100}
/>
<Show when={formErrors.title}>
<div class={styles.validationError}>{formErrors.title}</div>
<div class={clsx({ [styles.audioHeader]: props.shout.layout === 'audio' })}>
<div class={styles.inputContainer}>
<GrowingTextarea
allowEnterKey={true}
value={(value) => handleTitleInputChange(value)}
class={styles.titleInput}
placeholder={articleTitle()}
initialValue={form.title}
maxLength={100}
/>
<Show when={formErrors.title}>
<div class={styles.validationError}>{formErrors.title}</div>
</Show>
<Show when={props.shout.layout === 'audio'}>
<div class={styles.additional}>
<input
type="text"
placeholder={t('Artist...')}
class={styles.additionalInput}
value={mediaItems()[0]?.artist || t('Artist')}
onChange={(event) => handleBaseFieldsChange('artist', event.target.value)}
/>
<input
class={styles.additionalInput}
placeholder={t('Release date...')}
onChange={(event) => handleBaseFieldsChange('date', event.target.value)}
/>
<input
type="text"
placeholder={t('Genre...')}
class={styles.additionalInput}
onChange={(event) => handleBaseFieldsChange('genre', event.target.value)}
/>
</div>
</Show>
<Show when={props.shout.layout !== 'audio'}>
<GrowingTextarea
allowEnterKey={false}
value={(value) => setForm('subtitle', value)}
class={styles.subtitleInput}
placeholder={t('Subheader')}
initialValue={form.subtitle}
maxLength={100}
/>
</Show>
</div>
<Show
when={form.coverImageUrl}
fallback={
<DropArea
isSquare={true}
placeholder={t('Add cover')}
description={
<>
{t('min. 1400×1400 pix')}
<br />
{t('jpg, .png, max. 10 mb.')}
</>
}
isMultiply={false}
fileType={'image'}
onUpload={(val) => setForm('coverImageUrl', val[0].url)}
/>
}
>
<div
class={styles.cover}
style={{ 'background-image': `url(${imageProxy(form.coverImageUrl)})` }}
/>
</Show>
</div>
<GrowingTextarea
value={(value) => setForm('subtitle', value)}
class={styles.subtitleInput}
placeholder={t('Subheader')}
initialValue={form.subtitle}
maxLength={100}
/>
<Show when={props.shout.layout === 'image'}>
<SolidSwiper
editorMode={true}
images={mediaItems()}
onImageChange={handleImageChange}
onImageChange={handleMediaChange}
onImageDelete={(index) => handleImageDelete(index)}
onImagesAdd={(value) => handleAddImages(value)}
onImagesAdd={(value) => handleAddMedia(value)}
onImagesSorted={(value) => handleSortedImages(value)}
/>
</Show>
@ -204,7 +301,7 @@ export const EditView = (props: Props) => {
fallback={
<VideoUploader
data={(data) => {
handleAddImages(data)
handleAddMedia(data)
}}
/>
}
@ -224,6 +321,14 @@ export const EditView = (props: Props) => {
</Show>
</Show>
<Show when={props.shout.layout === 'audio'}>
<AudioUploader
audio={mediaItems()}
onAudioAdd={(value) => handleAddMedia(value)}
onAudioChange={handleMediaChange}
/>
</Show>
<Editor
shoutId={props.shout.id}
initialContent={props.shout.body}

View File

@ -58,6 +58,24 @@
text-align: center;
padding: 1rem;
}
&.square {
.field {
@include font-size(1.4rem);
flex-direction: column;
padding: 0;
width: 228px;
height: 228px;
text-align: center;
}
.description {
margin-top: 8px;
opacity: 0.3;
color: var(--default-color);
}
}
}
@keyframes slide {

View File

@ -6,14 +6,16 @@ import { useLocalize } from '../../../context/localize'
import { validateFiles } from '../../../utils/validateFile'
import type { FileTypeToUpload } from '../../../pages/types'
import { handleFileUpload } from '../../../utils/handleFileUpload'
import { UploadedFile } from '../../../pages/types'
type Props = {
class?: string
placeholder: string
description?: string | JSX.Element
fileType: FileTypeToUpload
isMultiply: boolean
onUpload: (value: string[]) => void
fileType: FileTypeToUpload
onUpload: (value: UploadedFile[]) => void
description?: string | JSX.Element
isSquare?: boolean
}
export const DropArea = (props: Props) => {
@ -26,15 +28,16 @@ export const DropArea = (props: Props) => {
try {
setLoading(true)
const results: string[] = []
const results: UploadedFile[] = []
for (const file of files) {
const result = await handleFileUpload(file)
results.push(result.url)
results.push(result)
}
props.onUpload(results)
setLoading(false)
} catch (error) {
setDropAreaError('Error')
setLoading(false)
setDropAreaError(t('Upload error'))
console.error('[runUpload]', error)
}
}
@ -81,7 +84,7 @@ export const DropArea = (props: Props) => {
}
return (
<div class={clsx(styles.DropArea, props.class)}>
<div class={clsx(styles.DropArea, props.class, props.isSquare && styles['square'])}>
<div
class={clsx(styles.field, { [styles.active]: dragActive() })}
onDragEnter={handleDrag}
@ -91,11 +94,14 @@ export const DropArea = (props: Props) => {
onClick={handleDropFieldClick}
>
<div class={styles.text}>{loading() ? 'Loading...' : props.placeholder}</div>
<Show when={!loading() && props.isSquare && props.description}>
<div class={styles.description}>{props.description}</div>
</Show>
</div>
<Show when={dropAreaError()}>
<div class={styles.error}>{dropAreaError()}</div>
</Show>
<Show when={!dropAreaError() && props.description}>
<Show when={!dropAreaError() && props.description && !props.isSquare}>
<div class={styles.description}>{props.description}</div>
</Show>
</div>

View File

@ -8,6 +8,7 @@ type Props = {
initialValue?: string
value: (string) => void
maxLength?: number
allowEnterKey: boolean
}
export const GrowingTextarea = (props: Props) => {
@ -36,7 +37,7 @@ export const GrowingTextarea = (props: Props) => {
autocomplete="off"
class={clsx(styles.textInput, props.class)}
value={props.initialValue}
onKeyDown={handleKeyDown}
onKeyDown={props.allowEnterKey ? handleKeyDown : null}
onInput={(event) => handleChangeValue(event)}
onChange={(event) => props.value(event.target.value)}
placeholder={props.placeholder}

View File

@ -1,5 +1,5 @@
import { createEffect, createSignal, For, Match, Show, Switch, on } from 'solid-js'
import { MediaItem } from '../../../pages/types'
import { MediaItem, UploadedFile } from '../../../pages/types'
import { Icon } from '../Icon'
import { Popover } from '../Popover'
import { useLocalize } from '../../../context/localize'
@ -17,6 +17,7 @@ import { Loading } from '../Loading'
import { imageProxy } from '../../../utils/imageProxy'
import { clsx } from 'clsx'
import styles from './Swiper.module.scss'
import { composeMediaItems } from '../../../utils/composeMediaItems'
type Props = {
images: MediaItem[]
@ -27,17 +28,6 @@ type Props = {
onImageChange?: (index: number, value: MediaItem) => void
}
const composeMediaItem = (value) => {
return value.map((url) => {
return {
url: url,
source: '',
title: '',
body: ''
}
})
}
register()
SwiperCore.use([Pagination, Navigation, Manipulation])
@ -47,7 +37,6 @@ export const SolidSwiper = (props: Props) => {
const [loading, setLoading] = createSignal(false)
const [slideIndex, setSlideIndex] = createSignal(0)
const dropAreaRef: { current: HTMLElement } = { current: null }
const mainSwipeRef: { current: SwiperRef } = { current: null }
const thumbSwipeRef: { current: SwiperRef } = { current: null }
@ -78,8 +67,8 @@ export const SolidSwiper = (props: Props) => {
)
)
const handleDropAreaUpload = (value: string[]) => {
props.onImagesAdd(composeMediaItem(value))
const handleDropAreaUpload = (value: UploadedFile[]) => {
props.onImagesAdd(composeMediaItems(value))
swipeToUploaded()
}
@ -108,7 +97,7 @@ export const SolidSwiper = (props: Props) => {
const result = await handleFileUpload(file)
results.push(result.url)
}
props.onImagesAdd(composeMediaItem(results))
props.onImagesAdd(composeMediaItems(results))
setLoading(false)
swipeToUploaded()
} catch (error) {
@ -146,7 +135,6 @@ export const SolidSwiper = (props: Props) => {
<div class={styles.container}>
<Show when={props.editorMode && props.images.length === 0}>
<DropArea
ref={(el) => (dropAreaRef.current = el)}
fileType="image"
isMultiply={true}
placeholder={t('Add images')}
@ -212,6 +200,7 @@ export const SolidSwiper = (props: Props) => {
}
/>
<GrowingTextarea
allowEnterKey={true}
class={styles.descriptionText}
placeholder={t('Enter image description')}
initialValue={slide.body}

View File

@ -42,10 +42,10 @@ export const CreatePage = () => {
</div>
</li>
<li>
<a href="#">
<div class={styles.link} onClick={() => handleCreate('audio')}>
<Icon name="create-music" class={styles.icon} />
<div>{t('music')}</div>
</a>
</div>
</li>
<li>
<div class={styles.link} onClick={() => handleCreate('video')}>

View File

@ -34,11 +34,23 @@ export type UploadFile = {
export type LayoutType = 'article' | 'audio' | 'video' | 'image' | 'literature'
export type FileTypeToUpload = 'image' | 'video' | 'doc'
export type FileTypeToUpload = 'image' | 'video' | 'doc' | 'audio'
export type AudioDescription = {
date?: string
genre?: string
artist?: string
lyrics?: string
}
export type MediaItem = {
url: string
title: string
body: string
source?: string
} & AudioDescription
export type UploadedFile = {
url: string
originalFilename: string
}

View File

@ -27,6 +27,7 @@
--icon-filter: invert(0);
--icon-filter-hover: invert(1);
--editor-bubble-menu-background: #fff;
--blue-link: #2638d9;
}
[data-editor-dark-mode='true'] {

View File

@ -246,7 +246,6 @@ export const apiClient = {
},
createArticle: async ({ article }: { article: ShoutInput }): Promise<Shout> => {
const response = await privateGraphQLClient.mutation(createArticle, { shout: article }).toPromise()
console.log('!!! [createArticle]:', response.data)
return response.data.createShout.shout
},
updateArticle: async ({

View File

@ -0,0 +1,10 @@
export const composeMediaItems = (value) => {
return value.map((fileData) => {
return {
url: fileData.url,
source: '',
title: fileData.originalFilename,
body: ''
}
})
}

View File

@ -23,6 +23,10 @@ export const validateFiles = (fileType: FileTypeToUpload, files: UploadFile[]):
isValid = docExtension ? docExtensions.has(docExtension) : false
break
}
case 'audio': {
isValid = file.file.type.startsWith('audio/')
break
}
default: {
isValid = false
}