This commit is contained in:
Untone 2024-07-26 18:49:15 +03:00
parent 219e3e2325
commit 1e4138e40e
36 changed files with 875 additions and 1058 deletions

1
.gitignore vendored
View File

@ -28,3 +28,4 @@ target
.output
.vinxi
*.pem
edge.*

14
.vscode/launch.json vendored
View File

@ -1,14 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch browser against localhost",
"type": "chrome",
"request": "launch",
"url": "https://localhost:3000",
"webRoot": "${workspaceFolder}/src",
"sourceMaps": true,
"trace": true
}
]
}

View File

@ -30,12 +30,11 @@ export default defineConfig({
https: true
},
devOverlay: true,
build: {
chunkSizeWarningLimit: 1024,
target: 'esnext'
},
vite: {
build: {
sourcemap: true,
chunkSizeWarningLimit: 1024,
target: 'esnext'
},
envPrefix: 'PUBLIC_',
plugins: [!isVercel && mkcert(), nodePolyfills(polyfillOptions), sassDts()],
css: {

View File

@ -11,7 +11,7 @@ generates:
skipTypename: true
useTypeImports: true
outputPath: './src/graphql/types/chat.gen.ts'
# namingConvention: lodash#pascalCase
# namingConvention: change-case#CamelCase # for generated types
# Generate types for core
src/graphql/schema/core.gen.ts:
@ -24,7 +24,7 @@ generates:
skipTypename: true
useTypeImports: true
outputPath: './src/graphql/types/core.gen.ts'
# namingConvention: lodash#pascalCase
# namingConvention: change-case#CamelCase # for generated types
hooks:
afterAllFileWrite:
- prettier --ignore-path .gitignore --write --plugin-search-dir=. src/graphql/schema/*.gen.ts

380
package-lock.json generated
View File

@ -21,7 +21,7 @@
"@graphql-codegen/typescript-operations": "^4.2.3",
"@graphql-codegen/typescript-urql": "^4.0.0",
"@hocuspocus/provider": "^2.13.5",
"@playwright/test": "^1.45.2",
"@playwright/test": "^1.45.3",
"@popperjs/core": "^2.11.8",
"@solid-primitives/media": "^2.2.9",
"@solid-primitives/memo": "^1.3.9",
@ -33,35 +33,35 @@
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.14.1",
"@solidjs/start": "^1.0.6",
"@tiptap/core": "^2.5.4",
"@tiptap/extension-blockquote": "^2.5.4",
"@tiptap/extension-bold": "^2.5.4",
"@tiptap/extension-bubble-menu": "^2.5.4",
"@tiptap/extension-bullet-list": "^2.5.4",
"@tiptap/extension-character-count": "^2.5.4",
"@tiptap/extension-collaboration": "^2.5.4",
"@tiptap/extension-collaboration-cursor": "^2.5.4",
"@tiptap/extension-document": "^2.5.4",
"@tiptap/extension-dropcursor": "^2.5.4",
"@tiptap/extension-floating-menu": "^2.5.4",
"@tiptap/extension-focus": "^2.5.4",
"@tiptap/extension-gapcursor": "^2.5.4",
"@tiptap/extension-hard-break": "^2.5.4",
"@tiptap/extension-heading": "^2.5.4",
"@tiptap/extension-highlight": "^2.5.4",
"@tiptap/extension-history": "^2.5.4",
"@tiptap/extension-horizontal-rule": "^2.5.4",
"@tiptap/extension-image": "^2.5.4",
"@tiptap/extension-italic": "^2.5.4",
"@tiptap/extension-link": "^2.5.4",
"@tiptap/extension-list-item": "^2.5.4",
"@tiptap/extension-ordered-list": "^2.5.4",
"@tiptap/extension-paragraph": "^2.5.4",
"@tiptap/extension-placeholder": "^2.5.4",
"@tiptap/extension-strike": "^2.5.4",
"@tiptap/extension-text": "^2.5.4",
"@tiptap/extension-underline": "^2.5.4",
"@tiptap/extension-youtube": "^2.5.4",
"@tiptap/core": "^2.5.5",
"@tiptap/extension-blockquote": "^2.5.5",
"@tiptap/extension-bold": "^2.5.5",
"@tiptap/extension-bubble-menu": "^2.5.5",
"@tiptap/extension-bullet-list": "^2.5.5",
"@tiptap/extension-character-count": "^2.5.5",
"@tiptap/extension-collaboration": "^2.5.5",
"@tiptap/extension-collaboration-cursor": "^2.5.5",
"@tiptap/extension-document": "^2.5.5",
"@tiptap/extension-dropcursor": "^2.5.5",
"@tiptap/extension-floating-menu": "^2.5.5",
"@tiptap/extension-focus": "^2.5.5",
"@tiptap/extension-gapcursor": "^2.5.5",
"@tiptap/extension-hard-break": "^2.5.5",
"@tiptap/extension-heading": "^2.5.5",
"@tiptap/extension-highlight": "^2.5.5",
"@tiptap/extension-history": "^2.5.5",
"@tiptap/extension-horizontal-rule": "^2.5.5",
"@tiptap/extension-image": "^2.5.5",
"@tiptap/extension-italic": "^2.5.5",
"@tiptap/extension-link": "^2.5.5",
"@tiptap/extension-list-item": "^2.5.5",
"@tiptap/extension-ordered-list": "^2.5.5",
"@tiptap/extension-paragraph": "^2.5.5",
"@tiptap/extension-placeholder": "^2.5.5",
"@tiptap/extension-strike": "^2.5.5",
"@tiptap/extension-text": "^2.5.5",
"@tiptap/extension-underline": "^2.5.5",
"@tiptap/extension-youtube": "^2.5.5",
"@types/cookie": "^0.6.0",
"@types/cookie-signature": "^1.1.2",
"@types/node": "^20.14.11",
@ -98,7 +98,7 @@
"swiper": "^11.1.5",
"throttle-debounce": "^5.0.2",
"tslib": "^2.6.3",
"typescript": "^5.5.3",
"typescript": "^5.5.4",
"typograf": "^7.4.1",
"uniqolor": "^1.1.1",
"vinxi": "^0.4.1",
@ -2604,9 +2604,9 @@
}
},
"node_modules/@graphql-tools/delegate": {
"version": "10.0.14",
"resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-10.0.14.tgz",
"integrity": "sha512-mYrLtwVKTHg5F4OFrJbiL5F7dzopzGiac5ezkVrnlGNPBQ8GNCr1zo32c1rYyIbsa8fJSUvAJfJfFj6ipnutnw==",
"version": "10.0.15",
"resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-10.0.15.tgz",
"integrity": "sha512-18R4vcJWz/6pk6K9SslijR0jCSe0mAnSs0sd1eioTvSSCWiagPdCOOhaM9dPNfEnxp3TRHg3cnYqywRtJgKHvw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4148,13 +4148,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.45.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.2.tgz",
"integrity": "sha512-JxG9eq92ET75EbVi3s+4sYbcG7q72ECeZNbdBlaMkGcNbiDQ4cAi8U2QP5oKkOx+1gpaiL1LDStmzCaEM1Z6fQ==",
"version": "1.45.3",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.3.tgz",
"integrity": "sha512-UKF4XsBfy+u3MFWEH44hva1Q8Da28G6RFtR2+5saw+jgAFQV5yYnB1fu68Mz7fO+5GJF3wgwAIs0UelU8TxFrA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.45.2"
"playwright": "1.45.3"
},
"bin": {
"playwright": "cli.js"
@ -4956,9 +4956,9 @@
}
},
"node_modules/@tiptap/core": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.5.4.tgz",
"integrity": "sha512-Zs/hShr4+W02+0nOlpmr5cS2YjDRLqd+XMt+jsiQH0QNr3s1Lc82pfF6C3CjgLEZtdUzImZrW2ABtLlpvbogaA==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.5.5.tgz",
"integrity": "sha512-VnAnyWnsqN65QijtUFHbe7EPSJCkhNEAwlatsG/HvrZvUv9KmoWWbMsHAU73wozKzPXR3nHRbCxN+LuxP5bADg==",
"dev": true,
"license": "MIT",
"funding": {
@ -4966,13 +4966,13 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^2.5.4"
"@tiptap/pm": "^2.5.5"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.5.4.tgz",
"integrity": "sha512-UqeJunZM3IiCQGZE0X5YNUOWYkuIieqrwPgOEghAIjnhDcQizQcouRQ5R7cwwv/scNr2JvZHncOTLrALV3Janw==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.5.5.tgz",
"integrity": "sha512-K+fc++ASlgDRHN6i3j3JBGzWiDhhoZv0jCUB/l7Jzut4UfjIoWqKhmJajnp95Qu9tmwQUy9LMzHqG4G5wUsIsQ==",
"dev": true,
"license": "MIT",
"funding": {
@ -4980,13 +4980,13 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4"
"@tiptap/core": "^2.5.5"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.5.4.tgz",
"integrity": "sha512-H5sjqloFMjq7VOSfE+U4T7dqGoflOiF6RW6/gZm/U6KYeHG2/bG0ktq7mWAnnhbiKiy7gUcxyJCV+ILdGX9C5g==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.5.5.tgz",
"integrity": "sha512-vXqaeTKy4nf4X+s7NkFt0OsuS1eKMQhrdt7SzACf0gWi3M761WGkaKHy8XUlo7zhWhqHtkgey53Gaw0nbEY54Q==",
"dev": true,
"license": "MIT",
"funding": {
@ -4994,13 +4994,13 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4"
"@tiptap/core": "^2.5.5"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.5.4.tgz",
"integrity": "sha512-GHwef912K1yd75pp9JGDnKSp1DvdOHH8BcHQv0no+a3q2ePFPYcgaSwVRR59jHRX9WzdVfoLcqDSAeoNGOrISw==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.5.5.tgz",
"integrity": "sha512-7k0HqrnhQGVZk86MEc5vt8stNRxIY65AMjZfszY/mQw0Dza7EQig/9b/AEmi9n+TNW5/8Qu+OMJD9ln92d/Eog==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -5011,14 +5011,14 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4",
"@tiptap/pm": "^2.5.4"
"@tiptap/core": "^2.5.5",
"@tiptap/pm": "^2.5.5"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.5.4.tgz",
"integrity": "sha512-aAfpALeD6OxymkbtrzDqbgkAkzVVHudxOb8GsK1N6m42nFL7Q9JzHJ5/8KzB+xi25CcIbS+HmXJkRIQJXgNbSA==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.5.5.tgz",
"integrity": "sha512-p89cTmGUoq3OEFzcS49iQ/tyQjDoKW1J0c7EghS7eU3wHVxeo/Ke110cY2W5o1e4KMFowo3a4jVsxKuCQJkWrA==",
"dev": true,
"license": "MIT",
"funding": {
@ -5026,13 +5026,13 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4"
"@tiptap/core": "^2.5.5"
}
},
"node_modules/@tiptap/extension-character-count": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-2.5.4.tgz",
"integrity": "sha512-6qwt+81I+y+t3eoFPmCG2ouQce2RccwyiUC0ZOPTG1eUB+5yXmyIwBYI4aOM4TEfxNizyaZtQw32CDdAhMr3YA==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-2.5.5.tgz",
"integrity": "sha512-rh6q3YeuLV8PnaKUqQbnOQ16obXPcqsqnQ+y1XLWH74lHwdvbOvE1BCvSZD0ULPI9EcOtvhdZEZkDxlqQ9H3jg==",
"dev": true,
"license": "MIT",
"funding": {
@ -5040,14 +5040,14 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4",
"@tiptap/pm": "^2.5.4"
"@tiptap/core": "^2.5.5",
"@tiptap/pm": "^2.5.5"
}
},
"node_modules/@tiptap/extension-collaboration": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-2.5.4.tgz",
"integrity": "sha512-CpQdbr7XpQaVqRFo/A1DchrQZMDb8vrkP+FcUIgvHN0b8hwKDmXRAHDtuk8yTTEatW1EqpX8lx8UxaUTcDNbIg==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-2.5.5.tgz",
"integrity": "sha512-HpDW+1VTKdtK7BglQNLFv2UzJIxtzZ9zvT+wdYDWPB3ZstoL8drpp4wGP2xt3tbki6wzGpUFkDCpVNl0oOunXQ==",
"dev": true,
"license": "MIT",
"funding": {
@ -5055,15 +5055,15 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4",
"@tiptap/pm": "^2.5.4",
"@tiptap/core": "^2.5.5",
"@tiptap/pm": "^2.5.5",
"y-prosemirror": "^1.2.6"
}
},
"node_modules/@tiptap/extension-collaboration-cursor": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration-cursor/-/extension-collaboration-cursor-2.5.4.tgz",
"integrity": "sha512-M32JChnP5RVdr1n+Tf0gF9bxx0gHvc0uV4SDxCMN3uaNH5YpcofmvKElS60rDGVfCdRTId/aj7P3AtwrvRlYdQ==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration-cursor/-/extension-collaboration-cursor-2.5.5.tgz",
"integrity": "sha512-DWX3eOplWyLegOWeZa0CAVbb9/UYbngiZyKjVMpDlx5qzhUuLL+Df54/UGKqB1ZrBZrxKCVQE3APMyXkxI/2VQ==",
"dev": true,
"license": "MIT",
"funding": {
@ -5071,14 +5071,14 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4",
"@tiptap/core": "^2.5.5",
"y-prosemirror": "^1.2.6"
}
},
"node_modules/@tiptap/extension-document": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.5.4.tgz",
"integrity": "sha512-4RDrhASxCTOZETYhIhEW1TfZqx3Tm+LQxouvBMFyODmT1PSgsg5Xz1FYpDPr+J49bGAK0Pr9ae0XcGW011L3sA==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.5.5.tgz",
"integrity": "sha512-MIjYO63JepcJW37PQuKVmYuZFqkQOZ/12tV0YLU4o6gmGVdqJS0+3md9CdnyUFUDIo7x6TBh8r5i5L2xQpm3Sg==",
"dev": true,
"license": "MIT",
"funding": {
@ -5086,13 +5086,13 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4"
"@tiptap/core": "^2.5.5"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.5.4.tgz",
"integrity": "sha512-jzSnuuYhlc0SsHvAteWkE9TJy3eRwkxQs4MO2JxALOzJECN4G82nlX8vciihBD6xf7lVgVSBACejK9+rsTHqCg==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.5.5.tgz",
"integrity": "sha512-+K/qd115c3zFgHdvxtOkZhSTKNyPpjM0Np2v4cehqn0j+/3stOMGlAH2Jm/b2L8RylFKGtQP1b/1wsKY5feuAg==",
"dev": true,
"license": "MIT",
"funding": {
@ -5100,14 +5100,14 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4",
"@tiptap/pm": "^2.5.4"
"@tiptap/core": "^2.5.5",
"@tiptap/pm": "^2.5.5"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.5.4.tgz",
"integrity": "sha512-EqD4rgi3UhnDcV3H1+ndAS4Ue2zpsU7hFKoevOIV6GS7xVnWN70AGt6swH24QzuHKKISFtWoLpKjrwRORNIxuA==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.5.5.tgz",
"integrity": "sha512-1mgpxZGfy1ziNSvWz6m1nGb9ZF9fVVz4X4XwrIqwGw1Vqt9oXflm6puglnzwVLDeaMDT014VUfczJ4My3wDZzA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -5118,14 +5118,14 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4",
"@tiptap/pm": "^2.5.4"
"@tiptap/core": "^2.5.5",
"@tiptap/pm": "^2.5.5"
}
},
"node_modules/@tiptap/extension-focus": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-focus/-/extension-focus-2.5.4.tgz",
"integrity": "sha512-/Iq++93f9S+bNJzj3OmgOydCO58VfAhmnsImbGK/GmxV39hHbgJdazxMugwdQlvrY/oe3+Y+WY8ZI1WlWwTJ4g==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-focus/-/extension-focus-2.5.5.tgz",
"integrity": "sha512-c5ul5PNl/2HcYwEPu1kjjs/u8N5BtLnreeUyb223y8i4BEcjydVlnCfVVUdonQIWnj0mKQ8KZbyLTSYdijDsVA==",
"dev": true,
"license": "MIT",
"funding": {
@ -5133,14 +5133,14 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4",
"@tiptap/pm": "^2.5.4"
"@tiptap/core": "^2.5.5",
"@tiptap/pm": "^2.5.5"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.5.4.tgz",
"integrity": "sha512-wzTh1piODZBS0wmuDgPjjg8PQwclYa5LssnxDIo9pDSnt4l3AfHSAJIJSGIfgt96KnzF1wqRTRpe08qNa1n7/g==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.5.5.tgz",
"integrity": "sha512-An/HwTheUP+D4UU1GVy2e4ypqA1TanZ7haNcm5WB+wSZQo6UNPIszIa49TTGenkk86hP2DH9cQSlTREsyAW6wg==",
"dev": true,
"license": "MIT",
"funding": {
@ -5148,14 +5148,14 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4",
"@tiptap/pm": "^2.5.4"
"@tiptap/core": "^2.5.5",
"@tiptap/pm": "^2.5.5"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.5.4.tgz",
"integrity": "sha512-nLn6HP9tqgdGGwbMORXVtcY30DTGctYFaWADRthvBjVgacYSeKlhUcsSu3YgaxtbxZp6BhfRvD2kKrxyQsSjnQ==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.5.5.tgz",
"integrity": "sha512-VtrwKU0LYS/0rfH5rGz8ztKwA0bsHRyBF53G7aP2FS4BiN8aOEu8t7VkvBZAewXDITDah9K6rqfXk+MNwoul2Q==",
"dev": true,
"license": "MIT",
"funding": {
@ -5163,13 +5163,13 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4"
"@tiptap/core": "^2.5.5"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.5.4.tgz",
"integrity": "sha512-DuAB58/e7eho1rkyad0Z/SjW+EB+H2hRqHlswEeZZYhBTjzey5UmBwkMWTGC/SQiRisx1xYQYTd8T0fiABi5hw==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.5.5.tgz",
"integrity": "sha512-NDnXOR6HmnkBA68oZTVf0BT5t8ikVFv9X6Ft/O5oU6IuzCswS8BUb5MJIhKBWQXJTsCNbC6EYl5jhJ3hukLcHw==",
"dev": true,
"license": "MIT",
"funding": {
@ -5177,13 +5177,13 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4"
"@tiptap/core": "^2.5.5"
}
},
"node_modules/@tiptap/extension-highlight": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-2.5.4.tgz",
"integrity": "sha512-TSYnFBluZu1YQdTCyXl2wuxFuhFUYFzbaV0f1wq2P2Nc8U2OiiuaNz+QggHw5Hf3ILzkRxQCUQnq97Q/5smMwQ==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-2.5.5.tgz",
"integrity": "sha512-NqMmL9/82288DI1trnuxB3hcf61x+iDKFvNAE+thW6MmY6ZWi47bEnfUQGwDeInxH81NfMhTTSxuXmnuO10noQ==",
"dev": true,
"license": "MIT",
"funding": {
@ -5191,13 +5191,13 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4"
"@tiptap/core": "^2.5.5"
}
},
"node_modules/@tiptap/extension-history": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.5.4.tgz",
"integrity": "sha512-WB1fZYGIlpahAD6Ba+mj9vIb1tk8S3TsADXDFKxLVpZWZPQ+B7duGJP7g/vRH2XAXEs836JzC2oxjKeaop3k7A==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.5.5.tgz",
"integrity": "sha512-CYxFpE9wayc+iZQIlXd3cbq47WP+KqjDhprbKF5Tb7+WoWLS2FB5WK3n+r/SrcoIaslIt5SYDRQPzx4fS3N7LA==",
"dev": true,
"license": "MIT",
"funding": {
@ -5205,14 +5205,14 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4",
"@tiptap/pm": "^2.5.4"
"@tiptap/core": "^2.5.5",
"@tiptap/pm": "^2.5.5"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.5.4.tgz",
"integrity": "sha512-uXLDe/iyzQbyfDkJ8kE5XaAkY3EOcbTFLjbueqGlkbWtjJgy+3LysGvh8fQj8PAOaIBMaFRFhTq7GMbW2ebRog==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.5.5.tgz",
"integrity": "sha512-8oV0oLgGwJqr44wk7+bHxTAenR0bvk9aVdmE/owg1oy2tkSX0bwtvQEOnwwxtfPJGTwq8JGhefUGYcpHfG2YYg==",
"dev": true,
"license": "MIT",
"funding": {
@ -5220,14 +5220,14 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4",
"@tiptap/pm": "^2.5.4"
"@tiptap/core": "^2.5.5",
"@tiptap/pm": "^2.5.5"
}
},
"node_modules/@tiptap/extension-image": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.5.4.tgz",
"integrity": "sha512-4ySSP7iPsbbo1SlPJYj546TKettuO6FGY5MQKxH8AGnZWyQGZYl89GpU1iGFAaeHq4dKUemM5D3ikgSynEQLow==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.5.5.tgz",
"integrity": "sha512-DvnKf3XCGf/2GQrqtwgKwgaeqIn2dXgHTire0E2aPj8T939jA4ApX5qLPumndHX0rAckX5VAbnJjQeoxtEmMFw==",
"dev": true,
"license": "MIT",
"funding": {
@ -5235,13 +5235,13 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4"
"@tiptap/core": "^2.5.5"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.5.4.tgz",
"integrity": "sha512-TAhtl/fNBgv1elzF3HWES8uwVdpKBSYrq1e6yeYfj74mQn//3ksvdhWQrLzc1e+zcoHbk1PeOp/5ODdPuZ6tkg==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.5.5.tgz",
"integrity": "sha512-PEeI68/u7Bm4n4xIcxVAV12jPhEa72fpHRnYfJe4CGp4x8mJfz/dowKN/P0/6CfjROB7Q8rY26u5E9fS+Cg73w==",
"dev": true,
"license": "MIT",
"funding": {
@ -5249,13 +5249,13 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4"
"@tiptap/core": "^2.5.5"
}
},
"node_modules/@tiptap/extension-link": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.5.4.tgz",
"integrity": "sha512-xTB/+T6SHHCXInJni8WdqOfF40a/MiFUf5OoWW9cPrApx3I7TzJ9j8/WDshM0BOnDDw80w1bl9F2zkUQjC0Y2A==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.5.5.tgz",
"integrity": "sha512-zVpNvMD8R9uW1SX1PJoj3fLyOHwuFWqiqEHN2KWfLbEnbL/KXNnpIyKdpHnI9lqFrsMf2dmyZCS3R6xIrynviQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -5266,14 +5266,14 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4",
"@tiptap/pm": "^2.5.4"
"@tiptap/core": "^2.5.5",
"@tiptap/pm": "^2.5.5"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.5.4.tgz",
"integrity": "sha512-bPxUCFt9HnAfoaZQgwqCfRAZ6L3QlYhIRDDbOvZag7IxCdQuZmeY4k5OZfQIGijNDTag7CN9cdL4fl9rnm6/sQ==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.5.5.tgz",
"integrity": "sha512-CfNVCP8Pqqgr7fAQAuRvZikzXT9vCEogcW7/C16cyGykbUJBqBmpsyHcAlj7XwsBFUuJ5MCeULtk/0frUI5fMQ==",
"dev": true,
"license": "MIT",
"funding": {
@ -5281,13 +5281,13 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4"
"@tiptap/core": "^2.5.5"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.5.4.tgz",
"integrity": "sha512-cl3cTJitY6yDUmxqgjDUtDWCyX1VVsZNJ6i9yiPeARcxvzFc81KmUJxTGl8WPT5TjqmM+TleRkZjsxgvXX57+Q==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.5.5.tgz",
"integrity": "sha512-wElnGQJhKznayP7tVGl/r42mj1dLEeU+Ln1Y3wF/m+nFwKl2Gpsy01PjBy5sXPUgskGSWgMlOgJrQyMvH9AuAw==",
"dev": true,
"license": "MIT",
"funding": {
@ -5295,13 +5295,13 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4"
"@tiptap/core": "^2.5.5"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.5.4.tgz",
"integrity": "sha512-pC1YIkkRPXoU0eDrhfAf8ZrFJQzvw2ftP6KRhLnnSw/Ot1DOjT1r95l7zsFefS9oCDMT/L4HghTAiPZ4rcpPbg==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.5.5.tgz",
"integrity": "sha512-XZO1rqsU1vlt9qeG2pVVAt2gXjD0twl2D+uxy4Nw6gxqbhSgfbNq3RP72mmtcS4KyFJi7ETANpcRpb8ZNvXfmg==",
"dev": true,
"license": "MIT",
"funding": {
@ -5309,13 +5309,13 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4"
"@tiptap/core": "^2.5.5"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.5.4.tgz",
"integrity": "sha512-mcj4j2Z/L1H5dzWHbbWChuAdJK9F2p06fcjqL4iyJtVx38QQFzCdVmGaTAim8CLp/EynbAOYJ5gk9w2PTdv7+w==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.5.5.tgz",
"integrity": "sha512-SwWLYdyrMeoVUQdivkIJ4kkAcb38pykxSetlrXitfUmnkwv0/fi+p76Rickf+roudWPsfzqvgvJ4gT6OAOJrGA==",
"dev": true,
"license": "MIT",
"funding": {
@ -5323,14 +5323,14 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4",
"@tiptap/pm": "^2.5.4"
"@tiptap/core": "^2.5.5",
"@tiptap/pm": "^2.5.5"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.5.4.tgz",
"integrity": "sha512-OSN6ePbCwEhi3hYZZOPow/P9Ym2Kv3NhVbUvasjZCiqQuk8TGc33xirPWl9DTjb/BLfL66TtJ2tKUEVOKl5dKg==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.5.5.tgz",
"integrity": "sha512-xnVdSsP7+4yQ1E+rI77ZHvzDH1Gwe2Ty1tgXeOaLjt3RfeVx4xy75o09yHzab6J4hgPebonoXKbZV0JVTGnjtQ==",
"dev": true,
"license": "MIT",
"funding": {
@ -5338,13 +5338,13 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4"
"@tiptap/core": "^2.5.5"
}
},
"node_modules/@tiptap/extension-text": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.5.4.tgz",
"integrity": "sha512-+3x/hYqhmCYbvedCcQzQHFtZ5MAcMOlKuczomZtygf8AfDfuQVrG1m4GoJyNzJdqxjN80/xq4e2vDVvqQxYTCw==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.5.5.tgz",
"integrity": "sha512-8c/hxcw7t/S3iKGSFwGNxC2I6AkKpRiySQJ95ML2miwSOAxWhnltoYYV7gobWCRgm25lnvzX/Z6BdpFzXBrBKA==",
"dev": true,
"license": "MIT",
"funding": {
@ -5352,13 +5352,13 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4"
"@tiptap/core": "^2.5.5"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.5.4.tgz",
"integrity": "sha512-o8T3oWbniA3rLo6LkslPRF8pwdjsaHXJCeK4KmKeCyYhTpMfjypT3uptd+VSSJ4iQkaiFInKeIUOBqqEQ9cADw==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.5.5.tgz",
"integrity": "sha512-3uog8d4G/AdqaJC8qutIIgkYnU2TfXW3QbtEy0Yg2WdjCz97bWXkFkNhhVZM/hvXjFCbYboRN5HLcIHl8+Zgmg==",
"dev": true,
"license": "MIT",
"funding": {
@ -5366,13 +5366,13 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4"
"@tiptap/core": "^2.5.5"
}
},
"node_modules/@tiptap/extension-youtube": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-youtube/-/extension-youtube-2.5.4.tgz",
"integrity": "sha512-iHcvXOA32MZsVJTT7mvZ1CWKUo2quQMQXfBniizLm0lUG1ftSioqnDuXy4kEjeCBR2cnZr3yph6tbG/pF0RcHg==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-youtube/-/extension-youtube-2.5.5.tgz",
"integrity": "sha512-dPLSLsEiMdXB5q0YDRJKWiiTqdFiSeyaC5qWLio4SHYfyTYT1+M2Wwox+5Dm/OSgCHpxpT2W8JRt+H4+P38t9A==",
"dev": true,
"license": "MIT",
"funding": {
@ -5380,13 +5380,13 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.5.4"
"@tiptap/core": "^2.5.5"
}
},
"node_modules/@tiptap/pm": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.5.4.tgz",
"integrity": "sha512-oFIsuniptdUXn93x4aM2sVN3hYKo9Fj55zAkYrWhwxFYUYcPxd5ibra2we+wRK5TaiPu098wpC+yMSTZ/KKMpA==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.5.5.tgz",
"integrity": "sha512-ppePiLaeG6IKkm8Yq+mRENT4LIAS4qQyLT8EnKadznaTL6SNj/72mm0MjD44URkM38ySzIyvt/vqHDapNK0Hww==",
"dev": true,
"license": "MIT",
"peer": true,
@ -7057,9 +7057,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001642",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001642.tgz",
"integrity": "sha512-3XQ0DoRgLijXJErLSl+bLnJ+Et4KqV1PY6JJBGAFlsNsz31zeAIncyeZfLCabHK/jtSh+671RM9YMldxjUPZtA==",
"version": "1.0.30001643",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001643.tgz",
"integrity": "sha512-ERgWGNleEilSrHM6iUz/zJNSQTP8Mr21wDWpdgvRwcTXGAq6jMtOUPP4dqFPTdKqZ2wKTdtB+uucZ3MRpAUSmg==",
"dev": true,
"funding": [
{
@ -8441,9 +8441,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.4.832",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.832.tgz",
"integrity": "sha512-cTen3SB0H2SGU7x467NRe1eVcQgcuS6jckKfWJHia2eo0cHIGOqHoAxevIYZD4eRHcWjkvFzo93bi3vJ9W+1lA==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.0.tgz",
"integrity": "sha512-Vb3xHHYnLseK8vlMJQKJYXJ++t4u1/qJ3vykuVrVjvdiOEhYyT1AuP4x03G8EnPmYvYOhe9T+dADTmthjRQMkA==",
"dev": true,
"license": "ISC"
},
@ -11898,9 +11898,9 @@
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.17.tgz",
"integrity": "sha512-Ww6ZlOiEQfPfXM45v17oabk77Z7mg5bOt7AjDyzy7RjK9OrLrLC8dyZQoAPEOtFX9SaNf1Tdvr5gRJWdTJj7GA==",
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
"integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
"dev": true,
"license": "MIT"
},
@ -12682,13 +12682,13 @@
}
},
"node_modules/playwright": {
"version": "1.45.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.2.tgz",
"integrity": "sha512-ReywF2t/0teRvNBpfIgh5e4wnrI/8Su8ssdo5XsQKpjxJj+jspm00jSoz9BTg91TT0c9HRjXO7LBNVrgYj9X0g==",
"version": "1.45.3",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.3.tgz",
"integrity": "sha512-QhVaS+lpluxCaioejDZ95l4Y4jSFCsBvl2UZkpeXlzxmqS+aABr5c82YmfMHrL6x27nvrvykJAFpkzT2eWdJww==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.45.2"
"playwright-core": "1.45.3"
},
"bin": {
"playwright": "cli.js"
@ -12701,9 +12701,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.45.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.2.tgz",
"integrity": "sha512-ha175tAWb0dTK0X4orvBIqi3jGEt701SMxMhyujxNrgd8K0Uy5wMSwwcQHtyB4om7INUkfndx02XnQ2p6dvLDw==",
"version": "1.45.3",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.3.tgz",
"integrity": "sha512-+ym0jNbcjikaOwwSZycFbwkWgfruWvYlJfThKYAlImbxUgdWFO2oW70ojPm4OpE4t6TAo2FY/smM+hpVTtkhDA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -13891,9 +13891,9 @@
}
},
"node_modules/sass/node_modules/immutable": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz",
"integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==",
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz",
"integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==",
"dev": true,
"license": "MIT"
},
@ -15196,9 +15196,9 @@
}
},
"node_modules/typescript": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz",
"integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==",
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View File

@ -1,7 +1,7 @@
{
"name": "discoursio-webapp",
"private": true,
"version": "0.9.6",
"version": "0.9.5",
"type": "module",
"scripts": {
"dev": "vinxi dev",
@ -25,7 +25,7 @@
"@graphql-codegen/typescript-operations": "^4.2.3",
"@graphql-codegen/typescript-urql": "^4.0.0",
"@hocuspocus/provider": "^2.13.5",
"@playwright/test": "^1.45.2",
"@playwright/test": "^1.45.3",
"@popperjs/core": "^2.11.8",
"@solid-primitives/media": "^2.2.9",
"@solid-primitives/memo": "^1.3.9",
@ -37,35 +37,35 @@
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.14.1",
"@solidjs/start": "^1.0.6",
"@tiptap/core": "^2.5.4",
"@tiptap/extension-blockquote": "^2.5.4",
"@tiptap/extension-bold": "^2.5.4",
"@tiptap/extension-bubble-menu": "^2.5.4",
"@tiptap/extension-bullet-list": "^2.5.4",
"@tiptap/extension-character-count": "^2.5.4",
"@tiptap/extension-collaboration": "^2.5.4",
"@tiptap/extension-collaboration-cursor": "^2.5.4",
"@tiptap/extension-document": "^2.5.4",
"@tiptap/extension-dropcursor": "^2.5.4",
"@tiptap/extension-floating-menu": "^2.5.4",
"@tiptap/extension-focus": "^2.5.4",
"@tiptap/extension-gapcursor": "^2.5.4",
"@tiptap/extension-hard-break": "^2.5.4",
"@tiptap/extension-heading": "^2.5.4",
"@tiptap/extension-highlight": "^2.5.4",
"@tiptap/extension-history": "^2.5.4",
"@tiptap/extension-horizontal-rule": "^2.5.4",
"@tiptap/extension-image": "^2.5.4",
"@tiptap/extension-italic": "^2.5.4",
"@tiptap/extension-link": "^2.5.4",
"@tiptap/extension-list-item": "^2.5.4",
"@tiptap/extension-ordered-list": "^2.5.4",
"@tiptap/extension-paragraph": "^2.5.4",
"@tiptap/extension-placeholder": "^2.5.4",
"@tiptap/extension-strike": "^2.5.4",
"@tiptap/extension-text": "^2.5.4",
"@tiptap/extension-underline": "^2.5.4",
"@tiptap/extension-youtube": "^2.5.4",
"@tiptap/core": "^2.5.5",
"@tiptap/extension-blockquote": "^2.5.5",
"@tiptap/extension-bold": "^2.5.5",
"@tiptap/extension-bubble-menu": "^2.5.5",
"@tiptap/extension-bullet-list": "^2.5.5",
"@tiptap/extension-character-count": "^2.5.5",
"@tiptap/extension-collaboration": "^2.5.5",
"@tiptap/extension-collaboration-cursor": "^2.5.5",
"@tiptap/extension-document": "^2.5.5",
"@tiptap/extension-dropcursor": "^2.5.5",
"@tiptap/extension-floating-menu": "^2.5.5",
"@tiptap/extension-focus": "^2.5.5",
"@tiptap/extension-gapcursor": "^2.5.5",
"@tiptap/extension-hard-break": "^2.5.5",
"@tiptap/extension-heading": "^2.5.5",
"@tiptap/extension-highlight": "^2.5.5",
"@tiptap/extension-history": "^2.5.5",
"@tiptap/extension-horizontal-rule": "^2.5.5",
"@tiptap/extension-image": "^2.5.5",
"@tiptap/extension-italic": "^2.5.5",
"@tiptap/extension-link": "^2.5.5",
"@tiptap/extension-list-item": "^2.5.5",
"@tiptap/extension-ordered-list": "^2.5.5",
"@tiptap/extension-paragraph": "^2.5.5",
"@tiptap/extension-placeholder": "^2.5.5",
"@tiptap/extension-strike": "^2.5.5",
"@tiptap/extension-text": "^2.5.5",
"@tiptap/extension-underline": "^2.5.5",
"@tiptap/extension-youtube": "^2.5.5",
"@types/cookie": "^0.6.0",
"@types/cookie-signature": "^1.1.2",
"@types/node": "^20.14.11",
@ -102,7 +102,7 @@
"swiper": "^11.1.5",
"throttle-debounce": "^5.0.2",
"tslib": "^2.6.3",
"typescript": "^5.5.3",
"typescript": "^5.5.4",
"typograf": "^7.4.1",
"uniqolor": "^1.1.1",
"vinxi": "^0.4.1",

View File

@ -19,8 +19,7 @@ import {
import { AuthorLink } from '../../Author/AuthorLink'
import { Userpic } from '../../Author/Userpic'
import { CommentDate } from '../CommentDate'
import { RatingControl as CommentRatingControl } from '../RatingControl'
import { CommentRatingControl } from '../CommentRatingControl'
import styles from './Comment.module.scss'
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))

View File

@ -0,0 +1,122 @@
import { clsx } from 'clsx'
import { createMemo } from 'solid-js'
import { useFeed } from '~/context/feed'
import { useLocalize } from '~/context/localize'
import { useReactions } from '~/context/reactions'
import { useSession } from '~/context/session'
import { useSnackbar } from '~/context/ui'
import { Reaction, ReactionKind } from '~/graphql/schema/core.gen'
import { Popup } from '../_shared/Popup'
import { VotersList } from '../_shared/VotersList'
import styles from './CommentRatingControl.module.scss'
type Props = {
comment: Reaction
}
export const CommentRatingControl = (props: Props) => {
const { t } = useLocalize()
const { loadShout } = useFeed()
const { session } = useSession()
const uid = createMemo<number>(() => session()?.user?.app_data?.profile?.id || 0)
const { showSnackbar } = useSnackbar()
const { reactionEntities, createReaction, deleteReaction, loadReactionsBy } = useReactions()
const checkReaction = (reactionKind: ReactionKind) =>
Object.values(reactionEntities).some(
(r) =>
r.kind === reactionKind &&
r.created_by.id === uid() &&
r.shout.id === props.comment.shout.id &&
r.reply_to === props.comment.id
)
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
const canVote = createMemo(() => uid() !== props.comment.created_by.id)
const commentRatingReactions = createMemo(() =>
Object.values(reactionEntities).filter(
(r) =>
[ReactionKind.Like, ReactionKind.Dislike].includes(r.kind) &&
r.shout.id === props.comment.shout.id &&
r.reply_to === props.comment.id
)
)
const deleteCommentReaction = async (reactionKind: ReactionKind) => {
const reactionToDelete = Object.values(reactionEntities).find(
(r) =>
r.kind === reactionKind &&
r.created_by.id === uid() &&
r.shout.id === props.comment.shout.id &&
r.reply_to === props.comment.id
)
if (reactionToDelete) return deleteReaction(reactionToDelete.id)
}
const handleRatingChange = async (isUpvote: boolean) => {
try {
if (isUpvoted()) {
await deleteCommentReaction(ReactionKind.Like)
} else if (isDownvoted()) {
await deleteCommentReaction(ReactionKind.Dislike)
} else {
await createReaction({
reaction: {
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
shout: props.comment.shout.id,
reply_to: props.comment.id
}
})
}
} catch {
showSnackbar({ type: 'error', body: t('Error') })
}
await loadShout(props.comment.shout.slug)
await loadReactionsBy({
by: { shout: props.comment.shout.slug }
})
}
return (
<div class={styles.commentRating}>
<button
role="button"
disabled={!(canVote() && uid())}
onClick={() => handleRatingChange(true)}
class={clsx(styles.commentRatingControl, styles.commentRatingControlUp, {
[styles.voted]: isUpvoted()
})}
/>
<Popup
trigger={
<div
class={clsx(styles.commentRatingValue, {
[styles.commentRatingPositive]: (props.comment?.stat?.rating || 0) > 0,
[styles.commentRatingNegative]: (props.comment?.stat?.rating || 0) < 0
})}
>
{props.comment?.stat?.rating || 0}
</div>
}
variant="tiny"
>
<VotersList
reactions={commentRatingReactions()}
fallbackMessage={t('This comment has not yet been rated')}
/>
</Popup>
<button
role="button"
disabled={!(canVote() && uid())}
onClick={() => handleRatingChange(false)}
class={clsx(styles.commentRatingControl, styles.commentRatingControlDown, {
[styles.voted]: isDownvoted()
})}
/>
</div>
)
}

View File

@ -1,15 +1,14 @@
import { clsx } from 'clsx'
import { For, Show, createMemo, createSignal, lazy, onMount } from 'solid-js'
import { useFeed } from '~/context/feed'
import { useLocalize } from '~/context/localize'
import { COMMENTS_PER_PAGE, useReactions } from '~/context/reactions'
import { useReactions } from '~/context/reactions'
import { useSession } from '~/context/session'
import { Reaction, ReactionKind, ReactionSort, Shout } from '~/graphql/schema/core.gen'
import { Author, Reaction, ReactionKind, ReactionSort } from '~/graphql/schema/core.gen'
import { byCreated, byStat } from '~/lib/sort'
import { SortFunction } from '~/types/common'
import { Button } from '../_shared/Button'
import { InlineLoader } from '../_shared/InlineLoader'
import { LoadMoreItems, LoadMoreWrapper } from '../_shared/LoadMoreWrapper'
import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated'
import styles from './Article.module.scss'
import { Comment } from './Comment'
@ -17,21 +16,21 @@ import { Comment } from './Comment'
const SimplifiedEditor = lazy(() => import('../Editor/SimplifiedEditor'))
type Props = {
shout: Shout
articleAuthors: Author[]
shoutSlug: string
shoutId: number
}
export const CommentsTree = (props: Props) => {
const { session } = useSession()
const { t } = useLocalize()
const { reactionEntities, createReaction, loadShoutComments } = useReactions()
const { seen } = useFeed()
const [commentsOrder, setCommentsOrder] = createSignal<ReactionSort>(ReactionSort.Newest)
const [onlyNew, setOnlyNew] = createSignal(false)
const [newReactions, setNewReactions] = createSignal<Reaction[]>([])
const [clearEditor, setClearEditor] = createSignal(false)
const [clickedReplyId, setClickedReplyId] = createSignal<number>()
const { reactionEntities, createReaction, loadReactionsBy } = useReactions()
const shoutLastSeen = createMemo(() => seen()[props.shout.slug] ?? 0)
const comments = createMemo(() =>
Object.values(reactionEntities).filter((reaction) => reaction.kind === 'COMMENT')
)
@ -49,9 +48,12 @@ export const CommentsTree = (props: Props) => {
}
return newSortedComments
})
const { seen } = useFeed()
const shoutLastSeen = createMemo(() => seen()[props.shoutSlug] ?? 0)
onMount(() => {
const currentDate = new Date()
const setCookie = () => localStorage?.setItem(`${props.shout.slug}`, `${currentDate}`)
const setCookie = () => localStorage?.setItem(`${props.shoutSlug}`, `${currentDate}`)
if (!shoutLastSeen()) {
setCookie()
} else if (currentDate.getTime() > shoutLastSeen()) {
@ -69,18 +71,6 @@ export const CommentsTree = (props: Props) => {
}
})
const [posting, setPosting] = createSignal(false)
const [commentsLoading, setCommentsLoading] = createSignal(false)
const [pagination, setPagination] = createSignal(0)
const loadMoreComments = async () => {
setCommentsLoading(true)
const next = pagination() + 1
const offset = next * COMMENTS_PER_PAGE
const rrr = await loadShoutComments(props.shout.id, COMMENTS_PER_PAGE, offset)
rrr && setPagination(next)
setCommentsLoading(false)
return rrr as LoadMoreItems
}
const handleSubmitComment = async (value: string) => {
setPosting(true)
try {
@ -88,17 +78,18 @@ export const CommentsTree = (props: Props) => {
reaction: {
kind: ReactionKind.Comment,
body: value,
shout: props.shout.id
shout: props.shoutId
}
})
setClearEditor(true)
await loadMoreComments()
await loadReactionsBy({ by: { shout: props.shoutSlug } })
} catch (error) {
console.error('[handleCreate reaction]:', error)
}
setClearEditor(false)
setPosting(false)
}
return (
<>
<div class={styles.commentsHeaderWrapper}>
@ -136,33 +127,20 @@ export const CommentsTree = (props: Props) => {
</ul>
</Show>
</div>
<Show when={commentsLoading()}>
<InlineLoader />
</Show>
<LoadMoreWrapper
loadFunction={loadMoreComments}
pageSize={COMMENTS_PER_PAGE}
hidden={
props.shout?.stat?.commented === 0 ||
commentsLoading() ||
comments().length >= (props.shout?.stat?.commented || 0)
}
>
<ul class={styles.comments}>
<For each={sortedComments().filter((r) => !r.reply_to)}>
{(reaction) => (
<Comment
sortedComments={sortedComments()}
isArticleAuthor={props.shout.authors?.some((a) => a && reaction.created_by.id === a.id)}
comment={reaction}
clickedReply={(id) => setClickedReplyId(id)}
clickedReplyId={clickedReplyId()}
lastSeen={shoutLastSeen()}
/>
)}
</For>
</ul>
</LoadMoreWrapper>
<ul class={styles.comments}>
<For each={sortedComments().filter((r) => !r.reply_to)}>
{(reaction) => (
<Comment
sortedComments={sortedComments()}
isArticleAuthor={Boolean(props.articleAuthors.some((a) => a?.id === reaction.created_by.id))}
comment={reaction}
clickedReply={(id) => setClickedReplyId(id)}
clickedReplyId={clickedReplyId()}
lastSeen={shoutLastSeen()}
/>
)}
</For>
</ul>
<ShowIfAuthenticated
fallback={
<div class={styles.signInMessage}>

View File

@ -6,9 +6,10 @@ import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMou
import { isServer } from 'solid-js/web'
import { useFeed } from '~/context/feed'
import { useLocalize } from '~/context/localize'
import { useReactions } from '~/context/reactions'
import { useSession } from '~/context/session'
import { DEFAULT_HEADER_OFFSET, useUI } from '~/context/ui'
import { type Author, type Maybe, type Shout, type Topic } from '~/graphql/schema/core.gen'
import type { Author, Maybe, Shout, Topic } from '~/graphql/schema/core.gen'
import { processPrepositions } from '~/intl/prepositions'
import { isCyrillic } from '~/intl/translate'
import { getImageUrl } from '~/lib/getThumbUrl'
@ -32,8 +33,8 @@ import styles from './Article.module.scss'
import { AudioHeader } from './AudioHeader'
import { AudioPlayer } from './AudioPlayer'
import { CommentsTree } from './CommentsTree'
import { RatingControl as ShoutRatingControl } from './RatingControl'
import { SharePopup, getShareUrl } from './SharePopup'
import { ShoutRatingControl } from './ShoutRatingControl'
type Props = {
article: Shout
@ -62,11 +63,15 @@ const scrollTo = (el: HTMLElement) => {
}
const imgSrcRegExp = /<img[^>]+src\s*=\s*["']([^"']+)["']/gi
const COMMENTS_PER_PAGE = 30
const VOTES_PER_PAGE = 50
export const FullArticle = (props: Props) => {
const [searchParams, changeSearchParams] = useSearchParams<ArticlePageSearchParams>()
const { showModal } = useUI()
const { loadReactionsBy } = useReactions()
const [selectedImage, setSelectedImage] = createSignal('')
const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false)
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
const { t, formatDate, lang } = useLocalize()
const { session, requireAuthentication } = useSession()
@ -74,6 +79,27 @@ export const FullArticle = (props: Props) => {
const { addSeen } = useFeed()
const formattedDate = createMemo(() => formatDate(new Date((props.article.published_at || 0) * 1000)))
const [pages, setPages] = createSignal<Record<string, number>>({})
createEffect(
on(
pages,
async (p: Record<string, number>) => {
await loadReactionsBy({
by: { shout: props.article.slug, comment: true },
limit: COMMENTS_PER_PAGE,
offset: COMMENTS_PER_PAGE * p.comments || 0
})
await loadReactionsBy({
by: { shout: props.article.slug, rating: true },
limit: VOTES_PER_PAGE,
offset: VOTES_PER_PAGE * p.rating || 0
})
setIsReactionsLoaded(true)
},
{ defer: true }
)
)
const canEdit = createMemo(
() =>
Boolean(author()?.id) &&
@ -141,7 +167,7 @@ export const FullArticle = (props: Props) => {
let commentsRef: HTMLDivElement | undefined
createEffect(() => {
if (searchParams?.commentId) {
if (searchParams?.commentId && isReactionsLoaded()) {
const commentElement = document.querySelector<HTMLElement>(
`[id='comment_${searchParams?.commentId}']`
)
@ -280,16 +306,9 @@ export const FullArticle = (props: Props) => {
})
}
createEffect(
on(
() => props.article,
() => {
updateIframeSizes()
}
)
)
onMount(async () => {
onMount(() => {
console.debug(props.article)
setPages((_) => ({ comments: 0, rating: 0 }))
addSeen(props.article.slug)
document.title = props.article.title
updateIframeSizes()
@ -560,7 +579,13 @@ export const FullArticle = (props: Props) => {
</For>
</div>
<div id="comments" ref={(el) => (commentsRef = el)}>
<CommentsTree shout={props.article} />
<Show when={isReactionsLoaded()}>
<CommentsTree
shoutId={props.article.id}
shoutSlug={props.article.slug}
articleAuthors={props.article.authors as Author[]}
/>
</Show>
</div>
</div>
</div>

View File

@ -1,254 +0,0 @@
import { useSearchParams } from '@solidjs/router'
import { clsx } from 'clsx'
import { Show, createEffect, createMemo, createSignal, on } from 'solid-js'
import { byCreated } from '~/lib/sort'
import { useLocalize } from '../../context/localize'
import { RATINGS_PER_PAGE, useReactions } from '../../context/reactions'
import { useSession } from '../../context/session'
import { useSnackbar } from '../../context/ui'
import { Reaction, ReactionKind, Shout } from '../../graphql/schema/core.gen'
import { Icon } from '../_shared/Icon'
import { InlineLoader } from '../_shared/InlineLoader'
import { LoadMoreItems, LoadMoreWrapper } from '../_shared/LoadMoreWrapper'
import { Popup } from '../_shared/Popup'
import { VotersList } from '../_shared/VotersList'
import stylesComment from './CommentRatingControl.module.scss'
import stylesShout from './ShoutRatingControl.module.scss'
interface RatingControlProps {
shout?: Shout
comment?: Reaction
class?: string
}
export const RatingControl = (props: RatingControlProps) => {
const { t, lang } = useLocalize()
const [_, changeSearchParams] = useSearchParams()
const snackbar = useSnackbar()
const { session } = useSession()
const {
reactionEntities,
reactionsByShout,
createReaction,
deleteReaction,
loadShoutRatings,
loadCommentRatings
} = useReactions()
const [myRate, setMyRate] = createSignal<Reaction | undefined>()
const [ratingReactions, setRatingReactions] = createSignal<Reaction[]>([])
const [isLoading, setIsLoading] = createSignal(false)
// reaction kind
const checkReaction = (reactionKind: ReactionKind) =>
Object.values(reactionEntities).some(
(r) =>
r.kind === reactionKind &&
r.created_by.slug === session()?.user?.app_data?.profile?.slug &&
r.shout.id === props.comment?.shout.id &&
r.reply_to === props.comment?.id
)
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
createEffect(() => {
const shout = props.comment?.shout.id || props.shout?.id
if (shout && !ratingReactions()) {
let result = Object.values(reactionEntities).filter(
(r) => [ReactionKind.Like, ReactionKind.Dislike].includes(r.kind) && r.shout.id === shout
)
if (props.comment?.id) result = result.filter((r) => r.reply_to === props.comment?.id)
setRatingReactions(result)
}
})
const deleteRating = async (reactionKind: ReactionKind) => {
const reactionToDelete = Object.values(reactionEntities).find(
(r) =>
r.kind === reactionKind &&
r.created_by.slug === session()?.user?.nickname &&
r.shout.id === props.comment?.shout.id &&
r.reply_to === props.comment?.id
)
return reactionToDelete && deleteReaction(reactionToDelete.id)
}
// rating change
const handleRatingChange = async (isUpvote: boolean) => {
setIsLoading(true)
let error = ''
try {
if (isUpvoted() && isUpvote) return
if (isDownvoted() && !isUpvote) return
if (isUpvoted() && !isUpvote) error = (await deleteRating(ReactionKind.Like))?.error || ''
if (isDownvoted() && isUpvote) error = (await deleteRating(ReactionKind.Dislike))?.error || ''
if (!(isUpvoted() || isDownvoted())) {
props.comment?.shout.id &&
(await createReaction({
reaction: {
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
shout: props.comment.shout.id,
reply_to: props.comment?.id
}
}))
}
} catch (err) {
snackbar?.showSnackbar({ type: 'error', body: `${t('Error')}: ${error || err || ''}` })
}
setIsLoading(false)
}
const total = createMemo<number>(() =>
props.comment?.stat?.rating ? props.comment.stat.rating : props.shout?.stat?.rating || 0
)
createEffect(
on(
[ratingReactions, () => session()?.user?.app_data?.profile],
([reactions, me]) => {
console.debug('[RatingControl] on reactions update')
const ratingVotes = Object.values(reactions).filter((r) => !r.reply_to)
setRatingReactions((_) => ratingVotes.sort(byCreated))
const myReaction = reactions.find((r) => r.created_by.id === me?.id)
setMyRate((_) => myReaction)
},
{ defer: true }
)
)
const getTrigger = createMemo(() => {
return (
<div
class={clsx(stylesComment.commentRatingValue, {
[stylesComment.commentRatingPositive]: total() > 0 && Boolean(props.comment?.id),
[stylesComment.commentRatingNegative]: total() < 0 && Boolean(props.comment?.id),
[stylesShout.ratingValue]: !props.comment?.id
})}
>
{total()}
</div>
)
})
const VOTERS_PER_PAGE = 10
const [ratingPage, setRatingPage] = createSignal(0)
const [ratingLoading, setRatingLoading] = createSignal(false) // FIXME: use loading indication
const ratings = createMemo(() =>
props.shout
? reactionsByShout[props.shout?.slug]?.filter(
(r) => r.kind === ReactionKind.Like || r.kind === ReactionKind.Dislike
)
: []
)
const loadMoreReactions = async () => {
if (!(props.shout?.id || props.comment?.id)) return [] as LoadMoreItems
setRatingLoading(true)
const next = ratingPage() + 1
const offset = RATINGS_PER_PAGE * next
const loader = props.comment ? loadCommentRatings : loadShoutRatings
const rrr = await loader(props.shout?.id || 0, RATINGS_PER_PAGE, offset)
rrr && setRatingPage(next)
setRatingLoading(false)
return rrr as LoadMoreItems
}
return props.comment?.id ? (
<div class={stylesComment.commentRating}>
<button
role="button"
disabled={!session()?.user?.app_data?.profile}
onClick={() => handleRatingChange(true)}
class={clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlUp, {
[stylesComment.voted]: isUpvoted()
})}
/>
<Popup
trigger={
<div
class={clsx(stylesComment.commentRatingValue, {
[stylesComment.commentRatingPositive]: (props.comment?.stat?.rating || 0) > 0,
[stylesComment.commentRatingNegative]: (props.comment?.stat?.rating || 0) < 0
})}
>
{props.comment?.stat?.rating || 0}
</div>
}
variant="tiny"
>
<Show when={ratingLoading()}>
<InlineLoader />
</Show>
<LoadMoreWrapper
loadFunction={loadMoreReactions}
pageSize={VOTERS_PER_PAGE}
hidden={ratingLoading()}
>
<VotersList reactions={ratings()} fallbackMessage={t('This comment has not been rated yet')} />
</LoadMoreWrapper>
</Popup>
<button
role="button"
disabled={!session()?.user?.app_data?.profile}
onClick={() => handleRatingChange(false)}
class={clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlDown, {
[stylesComment.voted]: isDownvoted()
})}
/>
</div>
) : (
<div class={clsx(props.comment ? stylesComment.commentRating : stylesShout.rating, props.class)}>
<button
onClick={() => handleRatingChange(false)}
disabled={isLoading()}
class={
props.comment
? clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlUp, {
[stylesComment.voted]: myRate()?.kind === 'LIKE'
})
: ''
}
>
<Show when={!props.comment}>
<Icon
name={isDownvoted() ? 'rating-control-checked' : 'rating-control-less'}
class={isLoading() ? 'rotating' : ''}
/>
</Show>
</button>
<Popup trigger={getTrigger()} variant="tiny">
<Show
when={!!session()?.user?.app_data?.profile}
fallback={
<>
<span class="link" onClick={() => changeSearchParams({ mode: 'login', m: 'auth' })}>
{t('Enter')}
</span>
{lang() === 'ru' ? ', ' : ' '}
{t('to see the voters')}
</>
}
>
<VotersList
reactions={ratingReactions()}
fallbackMessage={isLoading() ? t('Loading') : t('No one rated yet')}
/>
</Show>
</Popup>
<button
onClick={() => handleRatingChange(true)}
disabled={isLoading()}
class={
props.comment
? clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlDown, {
[stylesComment.voted]: myRate()?.kind === 'DISLIKE'
})
: ''
}
>
<Show when={!props.comment}>
<Icon
name={isUpvoted() ? 'rating-control-checked' : 'rating-control-more'}
class={isLoading() ? 'rotating' : ''}
/>
</Show>
</button>
</div>
)
}

View File

@ -0,0 +1,107 @@
import { clsx } from 'clsx'
import { Show, createMemo, createSignal } from 'solid-js'
import { useFeed } from '~/context/feed'
import { useLocalize } from '~/context/localize'
import { useReactions } from '~/context/reactions'
import { useSession } from '~/context/session'
import type { Author } from '~/graphql/schema/core.gen'
import { ReactionKind, Shout } from '~/graphql/schema/core.gen'
import { Icon } from '../_shared/Icon'
import { Popup } from '../_shared/Popup'
import { VotersList } from '../_shared/VotersList'
import styles from './ShoutRatingControl.module.scss'
interface ShoutRatingControlProps {
shout: Shout
class?: string
}
export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
const { t } = useLocalize()
const { loadShout } = useFeed()
const { requireAuthentication, session } = useSession()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
const { reactionEntities, createReaction, deleteReaction, loadReactionsBy } = useReactions()
const [isLoading, setIsLoading] = createSignal(false)
const checkReaction = (reactionKind: ReactionKind) =>
Object.values(reactionEntities).some(
(r) =>
r.kind === reactionKind &&
r.created_by.id === author()?.id &&
r.shout.id === props.shout.id &&
!r.reply_to
)
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
const shoutRatingReactions = createMemo(() =>
Object.values(reactionEntities).filter(
(r) => ['LIKE', 'DISLIKE'].includes(r.kind) && r.shout.id === props.shout.id && !r.reply_to
)
)
const deleteShoutReaction = async (reactionKind: ReactionKind) => {
const reactionToDelete = Object.values(reactionEntities).find(
(r) =>
r.kind === reactionKind &&
r.created_by.id === author()?.id &&
r.shout.id === props.shout.id &&
!r.reply_to
)
if (reactionToDelete) return deleteReaction(reactionToDelete.id)
}
const handleRatingChange = (isUpvote: boolean) => {
requireAuthentication(async () => {
setIsLoading(true)
if (isUpvoted()) {
await deleteShoutReaction(ReactionKind.Like)
} else if (isDownvoted()) {
await deleteShoutReaction(ReactionKind.Dislike)
} else {
await createReaction({
reaction: {
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
shout: props.shout.id
}
})
}
loadShout(props.shout.slug)
loadReactionsBy({
by: { shout: props.shout.slug }
})
setIsLoading(false)
}, 'vote')
}
return (
<div class={clsx(styles.rating, props.class)}>
<button onClick={() => handleRatingChange(false)} disabled={isLoading()}>
<Show when={!isDownvoted()} fallback={<Icon name="rating-control-checked" />}>
<Icon name="rating-control-less" />
</Show>
</button>
<Popup
trigger={<span class={styles.ratingValue}>{props.shout.stat?.rating || 0}</span>}
variant="tiny"
>
<VotersList
reactions={shoutRatingReactions()}
fallbackMessage={t('This post has not been rated yet')}
/>
</Popup>
<button onClick={() => handleRatingChange(true)} disabled={isLoading()}>
<Show when={!isUpvoted()} fallback={<Icon name="rating-control-checked" />}>
<Icon name="rating-control-more" />
</Show>
</button>
</div>
)
}

View File

@ -26,6 +26,7 @@ type Props = {
inviteView?: boolean
onInvite?: (id: number) => void
selected?: boolean
subscriptionsMode?: boolean
}
export const AuthorBadge = (props: Props) => {
const { session, requireAuthentication } = useSession()
@ -116,7 +117,7 @@ export const AuthorBadge = (props: Props) => {
<div class={clsx('text-truncate', styles.bio)} innerHTML={props.author.bio || ''} />
</Match>
</Switch>
<Show when={props.author?.stat}>
<Show when={props.author?.stat && !props.subscriptionsMode}>
<div class={styles.bio}>
<Show when={(props.author?.stat?.shouts || 0) > 0}>
<div>{t('some posts', { count: props.author.stat?.shouts ?? 0 })}</div>

View File

@ -162,7 +162,7 @@ export const AuthorCard = (props: Props) => {
<For each={authorSubs()}>
{(subscription) =>
'name' in subscription ? (
<AuthorBadge author={subscription as Author} nameOnly={true} />
<AuthorBadge author={subscription as Author} subscriptionsMode={true} />
) : (
<TopicBadge topic={subscription as Topic} subscriptionsMode={true} />
)

View File

@ -1,4 +1,3 @@
import { Editor } from '@tiptap/core'
import { Blockquote } from '@tiptap/extension-blockquote'
import { Bold } from '@tiptap/extension-bold'
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
@ -11,7 +10,7 @@ import { Paragraph } from '@tiptap/extension-paragraph'
import { Placeholder } from '@tiptap/extension-placeholder'
import { Text } from '@tiptap/extension-text'
import { clsx } from 'clsx'
import { Show, Suspense, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js'
import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
import { Portal } from 'solid-js/web'
import {
createEditorTransaction,
@ -20,23 +19,26 @@ import {
useEditorIsEmpty,
useEditorIsFocused
} from 'solid-tiptap'
import { Modal } from '~/components/_shared/Modal'
import { useUI } from '~/context/ui'
import { useEditorContext } from '~/context/editor'
import { useLocalize } from '~/context/localize'
import { UploadedFile } from '~/types/upload'
import { useEditorContext } from '../../context/editor'
import { useLocalize } from '../../context/localize'
import { Button } from '../_shared/Button'
import { Icon } from '../_shared/Icon'
import { Loading } from '../_shared/Loading'
import { Modal } from '../_shared/Modal'
import { Popover } from '../_shared/Popover'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
import { LinkBubbleMenuModule } from './LinkBubbleMenu'
import styles from './SimplifiedEditor.module.scss'
import { TextBubbleMenu } from './TextBubbleMenu'
import { UploadModalContent } from './UploadModalContent'
import { Figcaption } from './extensions/Figcaption'
import { Figure } from './extensions/Figure'
import { Editor } from '@tiptap/core'
import { useUI } from '~/context/ui'
import styles from './SimplifiedEditor.module.scss'
type Props = {
placeholder: string
initialContent?: string
@ -65,87 +67,98 @@ type Props = {
const DEFAULT_MAX_LENGTH = 400
const SimplifiedEditor = (props: Props) => {
const maxLength = props.maxLength ?? DEFAULT_MAX_LENGTH
let wrapperEditorElRef: HTMLElement | undefined
let editorElRef: HTMLElement | undefined
let textBubbleMenuRef: HTMLDivElement | undefined
let linkBubbleMenuRef: HTMLDivElement | undefined
const { showModal, hideModal } = useUI()
const { t } = useLocalize()
const { showModal, hideModal } = useUI()
const [counter, setCounter] = createSignal<number>(0)
const [shouldShowLinkBubbleMenu, setShouldShowLinkBubbleMenu] = createSignal(false)
const isCancelButtonVisible = createMemo(() => props.isCancelButtonVisible !== false)
const { setEditor, editor } = useEditorContext()
const [editorElement, setEditorElement] = createSignal<HTMLDivElement>()
const { editor, setEditor } = useEditorContext()
const maxLength = props.maxLength ?? DEFAULT_MAX_LENGTH
let wrapperEditorElRef: HTMLElement | undefined
let textBubbleMenuRef: HTMLDivElement | undefined
let linkBubbleMenuRef: HTMLDivElement | undefined
const ImageFigure = Figure.extend({
name: 'capturedImage',
content: 'figcaption image'
})
createEffect(() => {
const e = createTiptapEditor(() => ({
element: editorElRef as HTMLElement,
editorProps: {
attributes: {
class: styles.simplifiedEditorField
createEffect(
on(
() => editorElement(),
(ee: HTMLDivElement | undefined) => {
if (ee && textBubbleMenuRef && linkBubbleMenuRef) {
const freshEditor = createTiptapEditor<HTMLElement>(() => ({
element: ee,
editorProps: {
attributes: {
class: styles.simplifiedEditorField
}
},
extensions: [
Document,
Text,
Paragraph,
Bold,
Italic,
Link.extend({
inclusive: false
}).configure({
autolink: true,
openOnClick: false
}),
CharacterCount.configure({
limit: props.noLimits ? null : maxLength
}),
Blockquote.configure({
HTMLAttributes: {
class: styles.blockQuote
}
}),
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef,
shouldShow: ({ view, state }) => {
if (!props.onlyBubbleControls) return false
const { selection } = state
const { empty } = selection
return view.hasFocus() && !empty
}
}),
BubbleMenu.configure({
pluginKey: 'linkBubbleMenu',
element: linkBubbleMenuRef,
shouldShow: ({ state }) => {
const { selection } = state
const { empty } = selection
return !empty && shouldShowLinkBubbleMenu()
},
tippyOptions: {
placement: 'bottom'
}
}),
ImageFigure,
Image,
Figcaption,
Placeholder.configure({
emptyNodeClass: styles.emptyNode,
placeholder: props.placeholder
})
],
autofocus: props.autoFocus,
content: props.initialContent || null
}))
const editorInstance = freshEditor()
if (!editorInstance) return
setEditor(editorInstance)
}
},
extensions: [
Document,
Text,
Paragraph,
Bold,
Italic,
Link.extend({
inclusive: false
}).configure({
autolink: true,
openOnClick: false
}),
CharacterCount.configure({
limit: props.noLimits ? null : maxLength
}),
Blockquote.configure({
HTMLAttributes: {
class: styles.blockQuote
}
}),
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef,
shouldShow: ({ view, state }) => {
if (!props.onlyBubbleControls) return false
const { selection } = state
const { empty } = selection
return view.hasFocus() && !empty
}
}),
BubbleMenu.configure({
pluginKey: 'linkBubbleMenu',
element: linkBubbleMenuRef,
shouldShow: ({ state }) => {
const { selection } = state
const { empty } = selection
return !empty && shouldShowLinkBubbleMenu()
},
tippyOptions: {
placement: 'bottom'
}
}),
ImageFigure,
Image,
Figcaption,
Placeholder.configure({
emptyNodeClass: styles.emptyNode,
placeholder: props.placeholder
})
],
autofocus: props.autoFocus,
content: content ?? null
}))
{ defer: true }
)
)
e() && setEditor(e() as Editor)
})
const content = props.initialContent
const isEmpty = useEditorIsEmpty(() => editor())
const isFocused = useEditorIsFocused(() => editor())
@ -198,7 +211,7 @@ const SimplifiedEditor = (props: Props) => {
}
if (props.resetToInitial) {
editor()?.commands.clearContent(true)
props.initialContent && editor()?.commands.setContent(props.initialContent)
if (props.initialContent) editor()?.commands.setContent(props.initialContent)
}
})
@ -261,137 +274,133 @@ const SimplifiedEditor = (props: Props) => {
return (
<ShowOnlyOnClient>
<Suspense>
<div
ref={(el) => (wrapperEditorElRef = el)}
class={clsx(styles.SimplifiedEditor, {
[styles.smallHeight]: props.smallHeight,
[styles.minimal]: props.variant === 'minimal',
[styles.bordered]: props.variant === 'bordered',
[styles.isFocused]: isFocused() || !isEmpty(),
[styles.labelVisible]: props.label && counter() > 0
})}
>
<Show when={props.maxLength && editor()}>
<div class={styles.limit}>{maxLength - counter()}</div>
</Show>
<Show when={props.label && counter() > 0}>
<div class={styles.label}>{props.label}</div>
</Show>
<Show when={props.maxHeight} fallback={<div ref={(el) => (editorElRef = el)} />}>
<div style={maxHeightStyle} ref={(el) => (editorElRef = el)} />
</Show>
<Show when={!props.onlyBubbleControls}>
<div class={clsx(styles.controls, { [styles.alwaysVisible]: props.controlsAlwaysVisible })}>
<div class={styles.actions}>
<Popover content={t('Bold')}>
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.actionButton, { [styles.active]: isBold() })}
onClick={() => editor()?.chain().focus().toggleBold().run()}
>
<Icon name="editor-bold" />
</button>
)}
</Popover>
<Popover content={t('Italic')}>
<div
ref={(el) => (wrapperEditorElRef = el)}
class={clsx(styles.SimplifiedEditor, {
[styles.smallHeight]: props.smallHeight,
[styles.minimal]: props.variant === 'minimal',
[styles.bordered]: props.variant === 'bordered',
[styles.isFocused]: isFocused() || !isEmpty(),
[styles.labelVisible]: props.label && counter() > 0
})}
>
<Show when={props.maxLength && editor()}>
<div class={styles.limit}>{maxLength - counter()}</div>
</Show>
<Show when={props.label && counter() > 0}>
<div class={styles.label}>{props.label}</div>
</Show>
<div style={props.maxHeight ? maxHeightStyle : undefined} ref={setEditorElement} />
<Show when={!props.onlyBubbleControls}>
<div class={clsx(styles.controls, { [styles.alwaysVisible]: props.controlsAlwaysVisible })}>
<div class={styles.actions}>
<Popover content={t('Bold')}>
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.actionButton, { [styles.active]: isBold() })}
onClick={() => editor()?.chain().focus().toggleBold().run()}
>
<Icon name="editor-bold" />
</button>
)}
</Popover>
<Popover content={t('Italic')}>
{(triggerRef) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.actionButton, { [styles.active]: isItalic() })}
onClick={() => editor()?.chain().focus().toggleItalic().run()}
>
<Icon name="editor-italic" />
</button>
)}
</Popover>
<Popover content={t('Add url')}>
{(triggerRef) => (
<button
ref={triggerRef}
type="button"
onClick={handleShowLinkBubble}
class={clsx(styles.actionButton, { [styles.active]: isLink() })}
>
<Icon name="editor-link" />
</button>
)}
</Popover>
<Show when={props.quoteEnabled}>
<Popover content={t('Add blockquote')}>
{(triggerRef) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.actionButton, { [styles.active]: isItalic() })}
onClick={() => editor()?.chain().focus().toggleItalic().run()}
onClick={() => editor()?.chain().focus().toggleBlockquote().run()}
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
>
<Icon name="editor-italic" />
<Icon name="editor-quote" />
</button>
)}
</Popover>
<Popover content={t('Add url')}>
</Show>
<Show when={props.imageEnabled}>
<Popover content={t('Add image')}>
{(triggerRef) => (
<button
ref={triggerRef}
type="button"
onClick={handleShowLinkBubble}
class={clsx(styles.actionButton, { [styles.active]: isLink() })}
onClick={() => showModal('simplifiedEditorUploadImage')}
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
>
<Icon name="editor-link" />
<Icon name="editor-image-dd-full" />
</button>
)}
</Popover>
<Show when={props.quoteEnabled}>
<Popover content={t('Add blockquote')}>
{(triggerRef) => (
<button
ref={triggerRef}
type="button"
onClick={() => editor()?.chain().focus().toggleBlockquote().run()}
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
>
<Icon name="editor-quote" />
</button>
)}
</Popover>
</Show>
<Show when={props.imageEnabled}>
<Popover content={t('Add image')}>
{(triggerRef) => (
<button
ref={triggerRef}
type="button"
onClick={() => showModal('simplifiedEditorUploadImage')}
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
>
<Icon name="editor-image-dd-full" />
</button>
)}
</Popover>
</Show>
</div>
<Show when={!props.onChange}>
<div class={styles.buttons}>
<Show when={isCancelButtonVisible()}>
<Button value={t('Cancel')} variant="secondary" onClick={handleClear} />
</Show>
<Show when={!props.isPosting} fallback={<Loading />}>
<Button
value={props.submitButtonText ?? t('Send')}
variant="primary"
disabled={isEmpty()}
onClick={() => props.onSubmit?.(html() || '')}
/>
</Show>
</div>
</Show>
</div>
</Show>
<Show when={props.imageEnabled}>
<Portal>
<Modal variant="narrow" name="simplifiedEditorUploadImage">
<UploadModalContent onClose={(value) => value && renderImage(value)} />
</Modal>
</Portal>
</Show>
<Show when={!!editor()}>
<Show when={props.onlyBubbleControls}>
<TextBubbleMenu
shouldShow={true}
isCommonMarkup={true}
editor={editor() as Editor}
ref={(el) => (textBubbleMenuRef = el)}
/>
<Show when={!props.onChange}>
<div class={styles.buttons}>
<Show when={isCancelButtonVisible()}>
<Button value={t('Cancel')} variant="secondary" onClick={handleClear} />
</Show>
<Show when={!props.isPosting} fallback={<Loading />}>
<Button
value={props.submitButtonText ?? t('Send')}
variant="primary"
disabled={isEmpty()}
onClick={() => props.onSubmit?.(html() || '')}
/>
</Show>
</div>
</Show>
<LinkBubbleMenuModule
editor={editor() as Editor}
ref={(el) => (linkBubbleMenuRef = el)}
onClose={handleHideLinkBubble}
/>
</Show>
</div>
</Suspense>
</div>
</Show>
<Show when={props.imageEnabled}>
<Portal>
<Modal variant="narrow" name="simplifiedEditorUploadImage">
<UploadModalContent
onClose={(value) => {
renderImage(value as UploadedFile)
}}
/>
</Modal>
</Portal>
</Show>
<Show when={props.onlyBubbleControls}>
<TextBubbleMenu
shouldShow={true}
isCommonMarkup={true}
editor={editor() as Editor}
ref={(el) => (textBubbleMenuRef = el)}
/>
</Show>
<LinkBubbleMenuModule
editor={editor() as Editor}
ref={(el) => (linkBubbleMenuRef = el)}
onClose={handleHideLinkBubble}
/>
</div>
</ShowOnlyOnClient>
)
}

View File

@ -10,8 +10,8 @@ import type { Author, Maybe, Shout, Topic } from '~/graphql/schema/core.gen'
import { capitalize } from '~/utils/capitalize'
import { descFromBody } from '~/utils/meta'
import { CoverImage } from '../../Article/CoverImage'
import { RatingControl as ShoutRatingControl } from '../../Article/RatingControl'
import { SharePopup, getShareUrl } from '../../Article/SharePopup'
import { ShoutRatingControl } from '../../Article/ShoutRatingControl'
import { AuthorLink } from '../../Author/AuthorLink'
import stylesHeader from '../../HeaderNav/Header.module.scss'
import { CardTopic } from '../CardTopic'

View File

@ -753,12 +753,7 @@
white-space: nowrap;
}
.rightItem {
margin-right: 0;
position: absolute;
right: 0;
top: 0;
}
}
a:link,
@ -801,13 +796,6 @@
}
}
.rightItemIcon {
display: inline-block;
margin-left: 0.3em;
position: relative;
top: 0.15em;
}
.editorPopup {
border: 1px solid rgb(0 0 0 / 15%) !important;
border-radius: 1.6rem;

View File

@ -18,6 +18,8 @@ import stylesAuthorList from './AuthorsList.module.scss'
type Props = {
authors: Author[]
authorsByFollowers?: Author[]
authorsByShouts?: Author[]
isLoaded: boolean
}
@ -33,43 +35,48 @@ export const AllAuthors = (props: Props) => {
const [searchParams, changeSearchParams] = useSearchParams<{ by?: string }>()
const { authorsSorted, setAuthorsSort, loadAuthors } = useAuthors()
const [loading, setLoading] = createSignal<boolean>(false)
const [searchQuery, setSearchQuery] = createSignal('')
const [filteredAuthors, setFilteredAuthors] = createSignal<Author[]>([])
const [_currentAuthors, setCurrentAuthors] = createSignal<Author[]>([])
// UPDATE Fetch authors initially and when searchParams.by changes
createEffect(() => {
// Load all authors initially
fetchAuthors(searchParams.by || 'name', 0)
})
/* const authors = createMemo(() => {
let sortedAuthors = [...props.authors]
sortedAuthors = authorsSorted()
if (!searchParams.by || searchParams.by === 'name') {
const authors = createMemo(() => {
let sortedAuthors = [...(props.authors || authorsSorted())] // Clone the array to avoid mutating the original
console.log('Before Sorting:', sortedAuthors.slice(0, 5)) // Log the first 5 authors for comparison
if (searchParams.by === 'name') {
sortedAuthors = sortedAuthors.sort(byFirstChar)
console.log('Sorted by Name:', sortedAuthors.slice(0, 5))
} else if (searchParams.by === 'shouts') {
sortedAuthors = sortedAuthors.sort(byStat('shouts'))
console.log('Sorted by Shouts:', sortedAuthors.slice(0, 5))
} else if (searchParams.by === 'followers') {
sortedAuthors = sortedAuthors.sort(byStat('followers'))
console.log('Sorted by Followers:', sortedAuthors.slice(0, 5))
}
return sortedAuthors
}) */
const authors = createMemo(() => {
let sortedAuthors: Author[] = []
if (!searchParams.by || searchParams.by === 'name') {
sortedAuthors = [...props.authors].sort(byFirstChar)
} else {
sortedAuthors = authorsSorted().sort(byStat(searchParams.by || 'shouts'))
}
console.log('After Sorting:', sortedAuthors.slice(0, 5))
return sortedAuthors
})
// Log authors data and searchParams for debugging
createEffect(() => {
setFilteredAuthors(dummyFilter(authors(), searchQuery(), lang()) as Author[])
console.log('Authors:', props.authors.slice(0, 5)) // Log the first 5 authors
console.log('Sorted Authors:', authors().slice(0, 5)) // Log the first 5 sorted authors
console.log('Search Params "by":', searchParams.by)
})
// filter
const [searchQuery, setSearchQuery] = createSignal('')
const [filteredAuthors, setFilteredAuthors] = createSignal<Author[]>([])
createEffect(
() => authors() && setFilteredAuthors(dummyFilter(authors(), searchQuery(), lang()) as Author[])
)
// store by first char
const byLetterFiltered = createMemo<{ [letter: string]: Author[] }>(() => {
if (!(filteredAuthors()?.length > 0)) return {}
console.debug('[components.AllAuthors] update byLetterFiltered', filteredAuthors()?.length)
return filteredAuthors().reduce(
(acc, author: Author) => authorLetterReduce(acc, author, lang()),
{} as { [letter: string]: Author[] }
@ -86,6 +93,7 @@ export const AllAuthors = (props: Props) => {
const fetchAuthors = async (queryType: string, page: number) => {
try {
console.debug('[components.AuthorsList] fetching authors...')
setLoading(true)
setAuthorsSort?.(queryType)
const offset = AUTHORS_PER_PAGE * page
@ -94,6 +102,8 @@ export const AllAuthors = (props: Props) => {
limit: AUTHORS_PER_PAGE,
offset
})
// UPDATE authors to currentAuthors state
setCurrentAuthors((prev) => [...prev, ...authorsSorted()])
} catch (error) {
console.error('[components.AuthorsList] error fetching authors:', error)
} finally {
@ -121,7 +131,7 @@ export const AllAuthors = (props: Props) => {
<ul class={clsx(styles.viewSwitcher, 'view-switcher')}>
<li
class={clsx({
['view-switcher__item--selected']: searchParams?.by === 'shouts'
['view-switcher__item--selected']: !searchParams?.by || searchParams?.by === 'shouts'
})}
>
<a href="#" onClick={() => changeSearchParams({ by: 'shouts' })}>
@ -139,7 +149,7 @@ export const AllAuthors = (props: Props) => {
</li>
<li
class={clsx({
['view-switcher__item--selected']: !searchParams?.by || searchParams?.by === 'name'
['view-switcher__item--selected']: searchParams?.by === 'name'
})}
>
<a href="#" onClick={() => changeSearchParams({ by: 'name' })}>
@ -237,13 +247,12 @@ export const AllAuthors = (props: Props) => {
</div>
</div>
)
return (
<>
<Show when={props.isLoaded} fallback={<Loading />}>
<div class="offset-md-5">
<TabNavigator />
<Show when={!searchParams.by || searchParams.by === 'name'} fallback={<AuthorsSortedList />}>
<Show when={searchParams?.by === 'name'} fallback={<AuthorsSortedList />}>
<AbcNavigator />
<AbcAuthorsList />
</Show>

View File

@ -61,7 +61,7 @@ export const AuthorView = (props: AuthorViewProps) => {
on(
[() => session()?.user?.app_data?.profile, () => props.authorSlug || ''],
async ([me, slug]) => {
console.debug('[AuthorView] checking if my profile')
console.debug('check if my profile')
const my = slug && me?.slug === slug
if (my) {
console.debug('[Author] my profile precached')
@ -86,7 +86,7 @@ export const AuthorView = (props: AuthorViewProps) => {
() => authorsEntities()[props.author?.slug || props.authorSlug || ''],
async (found) => {
if (!found) return
console.debug('[AuthorView] ')
setAuthor(found)
console.info(`[Author] profile for @${found.slug} fetched`)
const followsResp = await query(getAuthorFollowsQuery, { slug: found.slug }).toPromise()
const follows = followsResp?.data?.get_author_followers || {}
@ -96,7 +96,6 @@ export const AuthorView = (props: AuthorViewProps) => {
setFollowers(followersResp?.data?.get_author_followers || [])
console.info(`[Author] followers for @${found.slug} fetched`)
setIsFetching(false)
setTimeout(() => setAuthor(found), 1)
},
{ defer: true }
)
@ -124,37 +123,7 @@ export const AuthorView = (props: AuthorViewProps) => {
(tab) => tab && console.log('[views.Author] profile tab switched')
)
)
const AuthorFeed = () => (
<Show when={Array.isArray(props.shouts) && props.shouts.length > 0 && props.shouts[0]}>
<Row1 article={props.shouts?.[0] as Shout} noauthor={true} nodate={true} />
<Show when={props.shouts?.length || 0}>
<Show when={props.shouts?.length === 1}>
<Row1 article={props.shouts?.[0] as Shout} noauthor={true} nodate={true} />
</Show>
<Show when={props.shouts?.length === 2}>
<Row2 articles={props.shouts as Shout[]} isEqual={true} noauthor={true} nodate={true} />
</Show>
<Show when={props.shouts?.length === 3}>
<Row3 articles={props.shouts as Shout[]} noauthor={true} nodate={true} />
</Show>
<Show when={props.shouts && props.shouts.length > 3}>
<For each={pages()}>
{(page) => (
<>
<Row1 article={page[0]} noauthor={true} nodate={true} />
<Row2 articles={page.slice(1, 3)} isEqual={true} noauthor={true} />
<Row1 article={page[3]} noauthor={true} nodate={true} />
<Row2 articles={page.slice(4, 6)} isEqual={true} noauthor={true} />
<Row1 article={page[6]} noauthor={true} nodate={true} />
<Row2 articles={page.slice(7, 9)} isEqual={true} noauthor={true} />
</>
)}
</For>
</Show>
</Show>
</Show>
)
return (
<div class={styles.authorPage}>
<div class="wide-container">
@ -260,7 +229,34 @@ export const AuthorView = (props: AuthorViewProps) => {
</div>
</Show>
<AuthorFeed />
<Show when={Array.isArray(props.shouts) && props.shouts.length > 0 && props.shouts[0]}>
<Row1 article={props.shouts?.[0] as Shout} noauthor={true} nodate={true} />
<Show when={props.shouts && props.shouts.length > 1}>
<Switch>
<Match when={props.shouts && props.shouts.length === 2}>
<Row2 articles={props.shouts as Shout[]} isEqual={true} noauthor={true} nodate={true} />
</Match>
<Match when={props.shouts && props.shouts.length === 3}>
<Row3 articles={props.shouts as Shout[]} noauthor={true} nodate={true} />
</Match>
<Match when={props.shouts && props.shouts.length > 3}>
<For each={pages()}>
{(page) => (
<>
<Row1 article={page[0]} noauthor={true} nodate={true} />
<Row2 articles={page.slice(1, 3)} isEqual={true} noauthor={true} />
<Row1 article={page[3]} noauthor={true} nodate={true} />
<Row2 articles={page.slice(4, 6)} isEqual={true} noauthor={true} />
<Row1 article={page[6]} noauthor={true} nodate={true} />
<Row2 articles={page.slice(7, 9)} isEqual={true} noauthor={true} />
</>
)}
</For>
</Match>
</Switch>
</Show>
</Show>
</Match>
</Switch>
</div>

View File

@ -9,27 +9,6 @@
}
}
.invert {
filter: invert(100%);
}
.rotating {
/* Define the keyframes for the animation */
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Apply the animation to the element */
animation: rotate .7s ease-out infinite; /* Rotate infinitely over 2 seconds using a linear timing function */
}
.notificationsCounter {
@include media-breakpoint-up(md) {
left: 1.8rem;

View File

@ -1,6 +1,8 @@
import { Link } from '@solidjs/meta'
import type { JSX } from 'solid-js'
import { Link } from '@solidjs/meta'
import { splitProps } from 'solid-js'
import { getImageUrl } from '~/lib/getThumbUrl'
type Props = JSX.ImgHTMLAttributes<HTMLImageElement> & {

View File

@ -5,7 +5,6 @@ import { Author, Reaction, Shout } from '~/graphql/schema/core.gen'
import { byCreated } from '~/lib/sort'
import { SortFunction } from '~/types/common'
import { restoreScrollPosition, saveScrollPosition } from '~/utils/scroll'
import { Loading } from './Loading'
export type LoadMoreItems = Shout[] | Author[] | Reaction[]
@ -53,12 +52,14 @@ export const LoadMoreWrapper = (props: LoadMoreProps) => {
return (
<>
{props.children}
<Show when={isLoading()}>
<Loading />
</Show>
<Show when={isLoadMoreButtonVisible() && !props.hidden && !isLoading()}>
<Show when={isLoadMoreButtonVisible() && !props.hidden}>
<div class="load-more-container">
<Button onClick={loadItems} value={t('Load more')} title={`${items().length} ${t('loaded')}`} />
<Button
onClick={loadItems}
disabled={isLoading()}
value={t('Load more')}
title={`${items().length} ${t('loaded')}`}
/>
</div>
</Show>
</>

View File

@ -125,7 +125,7 @@ export const AuthorsProvider = (props: { children: JSX.Element }) => {
}))
// Определяем функцию сортировки по рейтингу
const sortByRating: SortFunction<{ slug: string; rating: number }> = (a, b) => a.rating - b.rating
const sortByRating: SortFunction<{ slug: string; rating: number }> = (a, b) => b.rating - a.rating
// Фильтруем и сортируем авторов
const sortedTopAuthors = filterAndSort(authors, sortByRating)

View File

@ -2,12 +2,7 @@ import type { JSX } from 'solid-js'
import { createContext, onCleanup, useContext } from 'solid-js'
import { createStore, reconcile } from 'solid-js/store'
import {
loadCommentRatings,
loadReactions,
loadShoutComments,
loadShoutRatings
} from '~/graphql/api/public'
import { loadReactions } from '~/graphql/api/public'
import createReactionMutation from '~/graphql/mutation/core/reaction-create'
import destroyReactionMutation from '~/graphql/mutation/core/reaction-destroy'
import updateReactionMutation from '~/graphql/mutation/core/reaction-update'
@ -22,16 +17,10 @@ import { useGraphQL } from './graphql'
import { useLocalize } from './localize'
import { useSnackbar } from './ui'
export const COMMENTS_PER_PAGE = 50
export const RATINGS_PER_PAGE = 100
type ReactionsContextType = {
reactionEntities: Record<number, Reaction>
reactionsByShout: Record<string, Reaction[]>
loadReactionsBy: (args: QueryLoad_Reactions_ByArgs) => Promise<Reaction[]>
loadShoutComments: (shout: number, limit?: number, offset?: number) => Promise<Reaction[]>
loadShoutRatings: (shout: number, limit?: number, offset?: number) => Promise<Reaction[]>
loadCommentRatings: (comment: number, limit?: number, offset?: number) => Promise<Reaction[]>
createReaction: (reaction: MutationCreate_ReactionArgs) => Promise<void>
updateReaction: (reaction: MutationUpdate_ReactionArgs) => Promise<Reaction>
deleteReaction: (id: number) => Promise<{ error: string } | null>
@ -74,42 +63,6 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
return result
}
const loadShoutRatingsAdding = async (
shout: number,
limit = RATINGS_PER_PAGE,
offset = 0
): Promise<Reaction[]> => {
const fetcher = await loadShoutRatings({ shout, limit, offset })
const result = (await fetcher()) || []
console.debug('[context.reactions] shout ratings loaded', result)
result && addReactions(result)
return result
}
const loadCommentRatingsAdding = async (
comment: number,
limit = RATINGS_PER_PAGE,
offset = 0
): Promise<Reaction[]> => {
const fetcher = await loadCommentRatings({ comment, limit, offset })
const result = (await fetcher()) || []
console.debug('[context.reactions] shout ratings loaded', result)
result && addReactions(result)
return result
}
const loadShoutCommentsAdding = async (
shout: number,
limit = COMMENTS_PER_PAGE,
offset = 0
): Promise<Reaction[]> => {
const fetcher = await loadShoutComments({ shout, limit, offset })
const result = (await fetcher()) || []
console.debug('[context.reactions] shout comments loaded', result)
result && addReactions(result)
return result
}
const createReaction = async (input: MutationCreate_ReactionArgs): Promise<void> => {
const resp = await mutation(createReactionMutation, input).toPromise()
const { error, reaction } = resp?.data?.create_reaction || {}
@ -169,9 +122,6 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
const actions = {
loadReactionsBy,
loadShoutComments: loadShoutCommentsAdding,
loadShoutRatings: loadShoutRatingsAdding,
loadCommentRatings: loadCommentRatingsAdding,
createReaction,
updateReaction,
deleteReaction,

View File

@ -1,14 +1,11 @@
import { cache } from '@solidjs/router'
import { defaultClient } from '~/context/graphql'
import loadShoutCommentsQuery from '~/graphql/query/core/article-comments-load'
import getShoutQuery from '~/graphql/query/core/article-load'
import loadShoutRatingsQuery from '~/graphql/query/core/article-ratings-load'
import loadShoutsByQuery from '~/graphql/query/core/articles-load-by'
import loadShoutsSearchQuery from '~/graphql/query/core/articles-load-search'
import getAuthorQuery from '~/graphql/query/core/author-by'
import loadAuthorsAllQuery from '~/graphql/query/core/authors-all'
import loadAuthorsByQuery from '~/graphql/query/core/authors-load-by'
import loadCommentRatingsQuery from '~/graphql/query/core/comment-ratings-load'
import loadReactionsByQuery from '~/graphql/query/core/reactions-load-by'
import loadFollowersByTopicQuery from '~/graphql/query/core/topic-followers'
import loadTopicsQuery from '~/graphql/query/core/topics-all'
@ -19,7 +16,6 @@ import {
QueryGet_ShoutArgs,
QueryLoad_Authors_ByArgs,
QueryLoad_Reactions_ByArgs,
QueryLoad_Shout_RatingsArgs,
QueryLoad_Shouts_SearchArgs,
Reaction,
Shout,
@ -61,39 +57,16 @@ export const loadShouts = (options: LoadShoutsOptions) => {
}, `shouts-${filter}-${page}`)
}
export const loadShoutComments = (options: QueryLoad_Shout_RatingsArgs) => {
const page = `${options.offset || 0}-${(options.limit || 1) + (options.offset || 0)}`
return cache(async () => {
const resp = await defaultClient.query(loadShoutCommentsQuery, options).toPromise()
const result = resp?.data?.load_reactions_by
if (result) return result as Reaction[]
}, `shout-${options.shout}-comments-${page}`)
}
export const loadShoutRatings = (options: QueryLoad_Shout_RatingsArgs) => {
const page = `${options.offset || 0}-${(options.limit || 1) + (options.offset || 0)}`
return cache(async () => {
const resp = await defaultClient.query(loadShoutRatingsQuery, options).toPromise()
const result = resp?.data?.load_reactions_by
if (result) return result as Reaction[]
}, `shout-${options.shout}-ratings-${page}`)
}
// biome-ignore lint/suspicious/noExplicitAny: FIXME: wait backend
export const loadCommentRatings = (options: any) => {
const page = `${options.offset || 0}-${(options.limit || 1) + (options.offset || 0)}`
return cache(async () => {
const resp = await defaultClient.query(loadCommentRatingsQuery, options).toPromise()
const result = resp?.data?.load_reactions_by
if (result) return result as Reaction[]
}, `comment-${options.comment}-ratings-${page}`)
}
export const loadReactions = (options: QueryLoad_Reactions_ByArgs) => {
if (!options.by) {
console.debug(options)
throw new Error('[api] wrong loadReactions call')
}
const kind = options.by?.comment ? 'comments' : options.by?.rating ? 'votes' : 'reactions'
const allorone = options.by?.shout ? `shout-${options.by.shout}` : 'all'
const page = `${options.offset || 0}-${(options?.limit || 0) + (options.offset || 0)}`
const filter = new URLSearchParams(options.by as Record<string, string>)
// console.debug(options)
return cache(async () => {
const resp = await defaultClient.query(loadReactionsByQuery, options).toPromise()
const result = resp?.data?.load_reactions_by
@ -102,6 +75,7 @@ export const loadReactions = (options: QueryLoad_Reactions_ByArgs) => {
}
export const getShout = (options: QueryGet_ShoutArgs) => {
// console.debug('[lib.api] get shout options', options)
return cache(
async () => {
const resp = await defaultClient.query(getShoutQuery, { ...options }).toPromise()

View File

@ -18,7 +18,6 @@ export default gql`
slug
}
created_by {
id
name
slug
pic

View File

@ -1,8 +1,8 @@
import { gql } from '@urql/core'
export default gql`
mutation DeleteReactionMutation($reaction: Int!) {
delete_reaction(reaction_id: $reaction) {
mutation DeleteReactionMutation($reaction_id: Int!) {
delete_reaction(reaction_id: $reaction_id) {
error
reaction {
id

View File

@ -1,29 +0,0 @@
import { gql } from '@urql/core'
export default gql`
query LoadReactions($shout: Int!, $limit: Int, $offset: Int) {
load_shout_comments(shout: $shout, limit: $limit, offset: $offset) {
id
kind
body
reply_to
shout {
id
slug
title
}
created_by {
id
name
slug
pic
created_at
}
created_at
updated_at
stat {
rating
}
}
}
`

View File

@ -1,29 +0,0 @@
import { gql } from '@urql/core'
export default gql`
query LoadReactions($shout: Int!, $limit: Int, $offset: Int) {
load_shout_ratings(shout: $shout, limit: $limit, offset: $offset) {
id
kind
body
reply_to
shout {
id
slug
title
}
created_by {
id
name
slug
pic
created_at
}
created_at
updated_at
stat {
rating
}
}
}
`

View File

@ -1,29 +0,0 @@
import { gql } from '@urql/core'
export default gql`
query LoadReactions($comment: Int!, $limit: Int, $offset: Int) {
load_comment_ratings(comment: $comment, limit: $limit, offset: $offset) {
id
kind
body
reply_to
shout {
id
slug
title
}
created_by {
id
name
slug
pic
created_at
}
created_at
updated_at
stat {
rating
}
}
}
`

View File

@ -13,7 +13,6 @@ export default gql`
title
}
created_by {
id
name
slug
pic

View File

@ -456,7 +456,7 @@
"Theory": "Теории",
"There are unsaved changes in your profile settings. Are you sure you want to leave the page without saving?": "В настройках вашего профиля есть несохраненные изменения. Уверены, что хотите покинуть страницу без сохранения?",
"There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?": "В настройках публикации есть несохраненные изменения. Уверены, что хотите покинуть страницу без сохранения?",
"This comment has not been rated yet": "Этот комментарий еще пока никто не оценил",
"This comment has not yet been rated": "Этот комментарий еще пока никто не оценил",
"This content is not published yet": "Содержимое ещё не опубликовано",
"This email is": "Этот email",
"This email is not verified": "Этот email не подтвержден",

View File

@ -34,7 +34,7 @@ export const byStat = (metric: string) => {
return (a: { stat?: SomeStat }, b: { stat?: SomeStat }) => {
const aStat = a.stat?.[metric] ?? 0
const bStat = b.stat?.[metric] ?? 0
return bStat - aStat
return aStat - bStat
}
}

View File

@ -1,29 +1,38 @@
import { RouteDefinition, RoutePreloadFuncArgs, type RouteSectionProps, createAsync } from '@solidjs/router'
import { RouteDefinition, RouteLoadFuncArgs, type RouteSectionProps, createAsync } from '@solidjs/router'
import { Suspense, createEffect, on } from 'solid-js'
import { AllAuthors } from '~/components/Views/AllAuthors'
import { AUTHORS_PER_PAGE } from '~/components/Views/AllAuthors/AllAuthors'
import { Loading } from '~/components/_shared/Loading'
import { PageLayout } from '~/components/_shared/PageLayout'
import { useAuthors } from '~/context/authors'
import { useLocalize } from '~/context/localize'
import { loadAuthorsAll } from '~/graphql/api/public'
import { Author } from '~/graphql/schema/core.gen'
import { loadAuthors, loadAuthorsAll } from '~/graphql/api/public'
import { Author, AuthorsBy } from '~/graphql/schema/core.gen'
const fetchAuthorsWithStat = async (offset = 0, order?: string) => {
const by: AuthorsBy = { order }
const authorsFetcher = loadAuthors({ by, offset, limit: AUTHORS_PER_PAGE })
return await authorsFetcher()
}
// Fetch Function
const fetchAllAuthors = async () => {
const authorsAllFetcher = loadAuthorsAll()
return await authorsAllFetcher()
}
//Route Defenition
export const route = {
load: async ({ location: { query: _q } }: RoutePreloadFuncArgs) => {
load: async ({ location: { query } }: RouteLoadFuncArgs) => {
const by = query.by
const isAll = !by || by === 'name'
return {
authors: await fetchAllAuthors()
}
authors: isAll && (await fetchAllAuthors()),
authorsByFollowers: await fetchAuthorsWithStat(10, 'followers'),
authorsByShouts: await fetchAuthorsWithStat(10, 'shouts')
} as AllAuthorsData
}
} satisfies RouteDefinition
type AllAuthorsData = { authors: Author[] }
type AllAuthorsData = { authors: Author[]; authorsByFollowers: Author[]; authorsByShouts: Author[] }
// addAuthors to context
@ -31,20 +40,25 @@ export default function AllAuthorsPage(props: RouteSectionProps<AllAuthorsData>)
const { t } = useLocalize()
const { addAuthors } = useAuthors()
// async load data: from ssr or fetch
const data = createAsync<AllAuthorsData>(async () => {
if (props.data) return props.data
const authors = await fetchAllAuthors()
return {
authors: authors || []
}
authors: await fetchAllAuthors(),
authorsByFollowers: await fetchAuthorsWithStat(10, 'followers'),
authorsByShouts: await fetchAuthorsWithStat(10, 'shouts')
} as AllAuthorsData
})
// update context when data is loaded
createEffect(
on(
[data, () => addAuthors],
([data, aa]) => {
if (data && aa) {
aa(data.authors as Author[])
aa(data.authorsByFollowers as Author[])
aa(data.authorsByShouts as Author[])
console.debug('[routes.author] added all authors:', data.authors)
}
},
@ -59,7 +73,12 @@ export default function AllAuthorsPage(props: RouteSectionProps<AllAuthorsData>)
desc="List of authors of the open editorial community"
>
<Suspense fallback={<Loading />}>
<AllAuthors isLoaded={Boolean(data()?.authors)} authors={data()?.authors || []} />
<AllAuthors
isLoaded={Boolean(data()?.authors)}
authors={data()?.authors || []}
authorsByFollowers={data()?.authorsByFollowers}
authorsByShouts={data()?.authorsByShouts}
/>
</Suspense>
</PageLayout>
)

View File

@ -1,5 +1,5 @@
import { RouteSectionProps, createAsync } from '@solidjs/router'
import { ErrorBoundary, createEffect, createMemo } from 'solid-js'
import { RouteSectionProps } from '@solidjs/router'
import { ErrorBoundary, createEffect, createMemo, createSignal, on } from 'solid-js'
import { AuthorView } from '~/components/Views/Author'
import { FourOuFourView } from '~/components/Views/FourOuFour'
import { LoadMoreItems, LoadMoreWrapper } from '~/components/_shared/LoadMoreWrapper'
@ -32,7 +32,6 @@ const fetchAllTopics = async () => {
const fetchAuthor = async (slug: string) => {
const authorFetcher = loadAuthors({ by: { slug }, limit: 1, offset: 0 } as QueryLoad_Authors_ByArgs)
const aaa = await authorFetcher()
console.debug(aaa)
return aaa?.[0]
}
@ -50,20 +49,11 @@ export const route = {
export type AuthorPageProps = { articles?: Shout[]; author?: Author; topics?: Topic[] }
export default function AuthorPage(props: RouteSectionProps<AuthorPageProps>) {
const { authorsEntities } = useAuthors()
const { addFeed, feedByAuthor } = useFeed()
const { addAuthor, authorsEntities } = useAuthors()
const [author, setAuthor] = createSignal<Author | undefined>(undefined)
const { t } = useLocalize()
const author = createAsync(
async () =>
props.data.author || authorsEntities()[props.params.slug] || (await fetchAuthor(props.params.slug))
)
const shoutsByAuthor = createMemo(() => feedByAuthor()[props.params.slug])
const title = createMemo(() => `${author()?.name || ''}`)
const cover = createMemo(() =>
author()?.pic
? getImageUrl(author()?.pic || '', { width: 1200 })
: getImageUrl('production/image/logo_image.png')
)
createEffect(() => {
if (author()) {
@ -76,7 +66,32 @@ export default function AuthorPage(props: RouteSectionProps<AuthorPageProps>) {
}
})
const cover = createMemo(() =>
author()?.pic
? getImageUrl(author()?.pic || '', { width: 1200 })
: getImageUrl('production/image/logo_image.png')
)
// author shouts
const { addFeed, feedByAuthor } = useFeed()
const shoutsByAuthor = createMemo(() => feedByAuthor()[props.params.slug])
createEffect(
on(
[() => props.params.slug || '', author],
async ([slug, profile]) => {
if (!profile) {
const loadedAuthor = authorsEntities()[slug] || (await fetchAuthor(slug))
if (loadedAuthor) {
addAuthor(loadedAuthor)
setAuthor(loadedAuthor)
}
}
},
{ defer: true }
)
)
const loadAuthorShoutsMore = async (offset: number) => {
const loadedShouts = await fetchAuthorShouts(props.params.slug, offset)
loadedShouts && addFeed(loadedShouts)

View File

@ -125,7 +125,7 @@ export default (props: RouteSectionProps<{ shouts: Shout[]; topics: Topic[] }>)
key="feed"
desc="Independent media project about culture, science, art and society with horizontal editing"
>
<LoadMoreWrapper loadFunction={loadMoreFeed} pageSize={AUTHORS_PER_PAGE} hidden={!feed()}>
<LoadMoreWrapper loadFunction={loadMoreFeed} pageSize={AUTHORS_PER_PAGE}>
<ReactionsProvider>
<Feed shouts={feed() || (shouts() as Shout[])} order={order() as FeedProps['order']} />
</ReactionsProvider>