gigantic-wip
This commit is contained in:
parent
3e214d0352
commit
8d39c74242
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -22,4 +22,6 @@ bun.lockb
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/plawright-report/
|
/plawright-report/
|
||||||
target
|
|
||||||
|
.output
|
||||||
|
.vinxi
|
||||||
|
|
15
README.md
15
README.md
|
@ -1,20 +1,17 @@
|
||||||
## How to start
|
## How to start
|
||||||
|
|
||||||
|
Use Bun to manage packages.
|
||||||
|
|
||||||
```
|
```
|
||||||
npm install
|
bun i
|
||||||
npm start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Useful commands
|
## Useful commands
|
||||||
run checks
|
run checks
|
||||||
```
|
```
|
||||||
npm run check
|
bun run typecheck
|
||||||
```
|
|
||||||
type checking with watch
|
|
||||||
```
|
|
||||||
npm run typecheck:watch
|
|
||||||
```
|
```
|
||||||
fix styles, imports, formatting and autofixable linting errors:
|
fix styles, imports, formatting and autofixable linting errors:
|
||||||
```
|
```
|
||||||
npm run fix
|
bun run fix
|
||||||
npm run format
|
|
||||||
```
|
```
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
import { renderPage } from 'vike/server'
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
runtime: 'edge',
|
|
||||||
}
|
|
||||||
export default async function handler(request) {
|
|
||||||
const { url, cookies } = request
|
|
||||||
|
|
||||||
const pageContext = await renderPage({ urlOriginal: url, cookies })
|
|
||||||
|
|
||||||
const { httpResponse, errorWhileRendering, is404 } = pageContext
|
|
||||||
|
|
||||||
if (errorWhileRendering && !is404) {
|
|
||||||
console.error(errorWhileRendering)
|
|
||||||
return new Response('', { status: 500 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!httpResponse) {
|
|
||||||
return new Response()
|
|
||||||
}
|
|
||||||
|
|
||||||
const { body, statusCode, headers: headersArray } = httpResponse
|
|
||||||
|
|
||||||
const headers = headersArray.reduce((acc, [name, value]) => {
|
|
||||||
acc[name] = value
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
|
|
||||||
headers['Cache-Control'] = 's-maxage=1, stale-while-revalidate'
|
|
||||||
|
|
||||||
return new Response(body, { status: statusCode, headers })
|
|
||||||
}
|
|
45
app.config.ts
Normal file
45
app.config.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import { SolidStartInlineConfig, defineConfig } from '@solidjs/start/config'
|
||||||
|
import { nodePolyfills } from 'vite-plugin-node-polyfills'
|
||||||
|
import sassDts from 'vite-plugin-sass-dts'
|
||||||
|
|
||||||
|
const isVercel = Boolean(process?.env.VERCEL)
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
preset: isVercel ? 'vercel' : 'bun',
|
||||||
|
port: 3000,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
chunkSizeWarningLimit: 1024,
|
||||||
|
target: 'esnext',
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
envPrefix: 'PUBLIC_',
|
||||||
|
plugins: [
|
||||||
|
nodePolyfills({
|
||||||
|
include: ['path', 'stream', 'util'],
|
||||||
|
exclude: ['http'],
|
||||||
|
globals: {
|
||||||
|
Buffer: true,
|
||||||
|
},
|
||||||
|
overrides: {
|
||||||
|
fs: 'memfs',
|
||||||
|
},
|
||||||
|
protocolImports: true,
|
||||||
|
}),
|
||||||
|
sassDts()
|
||||||
|
],
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
additionalData: '@import "src/styles/imports";\n',
|
||||||
|
includePaths: ['public', 'src/styles']
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
chunkSizeWarningLimit: 1024,
|
||||||
|
target: 'esnext',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as SolidStartInlineConfig)
|
38
biome.json
38
biome.json
|
@ -1,8 +1,21 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/1.7.2/schema.json",
|
"$schema": "https://biomejs.dev/schemas/1.8.2/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"include": ["*.tsx", "*.ts", "*.js", "*.json"],
|
"include": [
|
||||||
"ignore": ["./dist", "./node_modules", ".husky", "docs", "gen", "*.gen.ts", "*.d.ts"]
|
"*.tsx",
|
||||||
|
"*.ts",
|
||||||
|
"*.js",
|
||||||
|
"*.json"
|
||||||
|
],
|
||||||
|
"ignore": [
|
||||||
|
"./dist",
|
||||||
|
"./node_modules",
|
||||||
|
".husky",
|
||||||
|
"docs",
|
||||||
|
"gen",
|
||||||
|
"*.gen.ts",
|
||||||
|
"*.d.ts"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"defaultBranch": "dev",
|
"defaultBranch": "dev",
|
||||||
|
@ -10,26 +23,37 @@
|
||||||
},
|
},
|
||||||
"organizeImports": {
|
"organizeImports": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"ignore": ["./api", "./gen"]
|
"ignore": [
|
||||||
|
"./api",
|
||||||
|
"./gen"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"indentStyle": "space",
|
"indentStyle": "space",
|
||||||
"indentWidth": 2,
|
"indentWidth": 2,
|
||||||
"lineWidth": 108,
|
"lineWidth": 108,
|
||||||
"ignore": ["./src/graphql/schema", "./gen"]
|
"ignore": [
|
||||||
|
"./src/graphql/schema",
|
||||||
|
"./gen"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"semicolons": "asNeeded",
|
"semicolons": "asNeeded",
|
||||||
"quoteStyle": "single",
|
"quoteStyle": "single",
|
||||||
"trailingComma": "all",
|
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"jsxQuoteStyle": "double",
|
"jsxQuoteStyle": "double",
|
||||||
"arrowParentheses": "always"
|
"arrowParentheses": "always"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"ignore": ["*.scss", "*.md", ".DS_Store", "*.svg", "*.d.ts"],
|
"ignore": [
|
||||||
|
"*.scss",
|
||||||
|
"*.md",
|
||||||
|
".DS_Store",
|
||||||
|
"*.svg",
|
||||||
|
"*.d.ts"
|
||||||
|
],
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"all": true,
|
"all": true,
|
||||||
|
|
12285
package-lock.json
generated
12285
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
221
package.json
221
package.json
|
@ -1,140 +1,123 @@
|
||||||
{
|
{
|
||||||
"name": "discoursio-webapp",
|
"name": "discoursio-webapp",
|
||||||
"version": "0.9.2",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"version": "0.9.5",
|
||||||
|
"contributors": [],
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"dev": "vinxi dev",
|
||||||
"check": "npm run lint && npm run typecheck",
|
"build": "vinxi build",
|
||||||
|
"start": "vinxi start",
|
||||||
"codegen": "graphql-codegen",
|
"codegen": "graphql-codegen",
|
||||||
"deploy": "graphql-codegen && npm run typecheck && vite build && vercel",
|
"deploy": "graphql-codegen && bun run typecheck && bun run build && vercel",
|
||||||
"dev": "vite",
|
"e2e": "bunx playwright test --project=webkit",
|
||||||
"e2e": "npx playwright test --project=webkit",
|
"fix": "bunx @biomejs/biome check src/. --write && stylelint **/*.{scss,css} --fix",
|
||||||
"fix": "npm run check:code:fix && stylelint **/*.{scss,css} --fix",
|
"format": "bunx @biomejs/biome format src/. --write",
|
||||||
"format": "npx @biomejs/biome format src/. --write",
|
"postinstall": "bun run codegen && bunx patch-package",
|
||||||
"postinstall": "npm run codegen && npx patch-package",
|
"typecheck": "tsc --noEmit"
|
||||||
"check:code": "npx @biomejs/biome check src --log-kind=compact --verbose",
|
|
||||||
"check:code:fix": "npx @biomejs/biome check . --apply",
|
|
||||||
"lint": "npm run lint:code && stylelint **/*.{scss,css}",
|
|
||||||
"lint:code": "npx @biomejs/biome lint src --log-kind=compact --verbose",
|
|
||||||
"lint:code:fix": "npx @biomejs/biome lint src --apply-unsafe --log-kind=compact --verbose",
|
|
||||||
"lint:styles": "stylelint **/*.{scss,css}",
|
|
||||||
"lint:styles:fix": "stylelint **/*.{scss,css} --fix",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"start": "vite",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"typecheck:watch": "tsc --noEmit --watch"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"form-data": "4.0.0",
|
|
||||||
"idb": "8.0.0",
|
|
||||||
"mailgun.js": "10.1.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@authorizerdev/authorizer-js": "^2.0.0",
|
"@authorizerdev/authorizer-js": "^2.0.3",
|
||||||
"@babel/core": "^7.24.5",
|
"@biomejs/biome": "^1.8.2",
|
||||||
"@biomejs/biome": "^1.7.2",
|
"@graphql-codegen/cli": "^5.0.2",
|
||||||
"@graphql-codegen/cli": "^5.0.0",
|
"@graphql-codegen/typescript": "^4.0.7",
|
||||||
"@graphql-codegen/typescript": "^4.0.1",
|
"@graphql-codegen/typescript-operations": "^4.2.1",
|
||||||
"@graphql-codegen/typescript-operations": "^4.0.1",
|
|
||||||
"@graphql-codegen/typescript-urql": "^4.0.0",
|
"@graphql-codegen/typescript-urql": "^4.0.0",
|
||||||
"@graphql-tools/url-loader": "8.0.1",
|
"@hocuspocus/provider": "^2.13.2",
|
||||||
"@hocuspocus/provider": "2.11.0",
|
"@playwright/test": "^1.44.1",
|
||||||
"@microsoft/fetch-event-source": "^2.0.1",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@nanostores/router": "0.13.0",
|
"@solid-devtools/transform": "^0.10.4",
|
||||||
"@nanostores/solid": "0.4.2",
|
"@solid-primitives/media": "^2.2.9",
|
||||||
"@playwright/test": "^1.44.0",
|
"@solid-primitives/memo": "^1.3.8",
|
||||||
"@popperjs/core": "2.11.8",
|
"@solid-primitives/pagination": "^0.3.0",
|
||||||
"@sentry/browser": "^7.113.0",
|
"@solid-primitives/share": "^2.0.6",
|
||||||
"@solid-primitives/media": "2.2.3",
|
"@solid-primitives/storage": "^3.7.1",
|
||||||
"@solid-primitives/memo": "1.2.4",
|
"@solid-primitives/upload": "^0.0.117",
|
||||||
"@solid-primitives/pagination": "0.2.10",
|
"@solidjs/meta": "^0.29.4",
|
||||||
"@solid-primitives/share": "2.0.4",
|
"@solidjs/router": "^0.13.6",
|
||||||
"@solid-primitives/storage": "^3.5.0",
|
"@solidjs/start": "^1.0.2",
|
||||||
"@solid-primitives/upload": "0.0.115",
|
"@tiptap/core": "^2.4.0",
|
||||||
"@thisbeyond/solid-select": "0.14.0",
|
"@tiptap/extension-blockquote": "^2.4.0",
|
||||||
"@tiptap/core": "2.4.0",
|
"@tiptap/extension-bold": "^2.4.0",
|
||||||
"@tiptap/extension-blockquote": "2.4.0",
|
"@tiptap/extension-bubble-menu": "^2.4.0",
|
||||||
"@tiptap/extension-bold": "2.4.0",
|
"@tiptap/extension-bullet-list": "^2.4.0",
|
||||||
"@tiptap/extension-bubble-menu": "2.4.0",
|
"@tiptap/extension-character-count": "^2.4.0",
|
||||||
"@tiptap/extension-bullet-list": "2.4.0",
|
"@tiptap/extension-collaboration": "^2.4.0",
|
||||||
"@tiptap/extension-character-count": "2.4.0",
|
"@tiptap/extension-collaboration-cursor": "^2.4.0",
|
||||||
"@tiptap/extension-collaboration": "2.4.0",
|
"@tiptap/extension-document": "^2.4.0",
|
||||||
"@tiptap/extension-collaboration-cursor": "2.4.0",
|
"@tiptap/extension-dropcursor": "^2.4.0",
|
||||||
"@tiptap/extension-document": "2.4.0",
|
"@tiptap/extension-floating-menu": "^2.4.0",
|
||||||
"@tiptap/extension-dropcursor": "2.4.0",
|
"@tiptap/extension-focus": "^2.4.0",
|
||||||
"@tiptap/extension-floating-menu": "2.4.0",
|
"@tiptap/extension-gapcursor": "^2.4.0",
|
||||||
"@tiptap/extension-focus": "2.4.0",
|
"@tiptap/extension-hard-break": "^2.4.0",
|
||||||
"@tiptap/extension-gapcursor": "2.4.0",
|
"@tiptap/extension-heading": "^2.4.0",
|
||||||
"@tiptap/extension-hard-break": "2.4.0",
|
"@tiptap/extension-highlight": "^2.4.0",
|
||||||
"@tiptap/extension-heading": "2.4.0",
|
"@tiptap/extension-history": "^2.4.0",
|
||||||
"@tiptap/extension-highlight": "2.4.0",
|
"@tiptap/extension-horizontal-rule": "^2.4.0",
|
||||||
"@tiptap/extension-history": "2.4.0",
|
"@tiptap/extension-image": "^2.4.0",
|
||||||
"@tiptap/extension-horizontal-rule": "2.4.0",
|
"@tiptap/extension-italic": "^2.4.0",
|
||||||
"@tiptap/extension-image": "2.4.0",
|
"@tiptap/extension-link": "^2.4.0",
|
||||||
"@tiptap/extension-italic": "2.4.0",
|
"@tiptap/extension-list-item": "^2.4.0",
|
||||||
"@tiptap/extension-link": "2.4.0",
|
"@tiptap/extension-ordered-list": "^2.4.0",
|
||||||
"@tiptap/extension-list-item": "2.4.0",
|
"@tiptap/extension-paragraph": "^2.4.0",
|
||||||
"@tiptap/extension-ordered-list": "2.4.0",
|
"@tiptap/extension-placeholder": "^2.4.0",
|
||||||
"@tiptap/extension-paragraph": "2.4.0",
|
"@tiptap/extension-strike": "^2.4.0",
|
||||||
"@tiptap/extension-placeholder": "2.4.0",
|
"@tiptap/extension-text": "^2.4.0",
|
||||||
"@tiptap/extension-strike": "2.4.0",
|
"@tiptap/extension-underline": "^2.4.0",
|
||||||
"@tiptap/extension-text": "2.4.0",
|
"@tiptap/extension-youtube": "^2.4.0",
|
||||||
"@tiptap/extension-underline": "2.4.0",
|
"@types/cookie": "^0.6.0",
|
||||||
"@tiptap/extension-youtube": "2.4.0",
|
"@types/cookie-signature": "^1.1.2",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/node": "^20.14.8",
|
||||||
"@types/node": "^20.11.0",
|
"@types/throttle-debounce": "^5.0.2",
|
||||||
"@urql/core": "4.2.3",
|
"@urql/core": "^5.0.4",
|
||||||
"@urql/devtools": "^2.0.3",
|
"bootstrap": "^5.3.3",
|
||||||
"babel-preset-solid": "1.8.17",
|
"clsx": "^2.1.1",
|
||||||
"bootstrap": "5.3.2",
|
"cookie": "^0.6.0",
|
||||||
"clsx": "2.0.0",
|
"cookie-signature": "^1.2.1",
|
||||||
"cropperjs": "1.6.1",
|
"cropperjs": "^1.6.2",
|
||||||
"fast-deep-equal": "3.1.3",
|
"extended-eventsource": "^1.4.9",
|
||||||
"ga-gtag": "1.2.0",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"graphql": "16.8.1",
|
"graphql": "^16.9.0",
|
||||||
"graphql-tag": "^2.12.6",
|
"i18next": "^23.11.5",
|
||||||
"i18next": "22.4.15",
|
"i18next-http-backend": "^2.5.2",
|
||||||
"i18next-http-backend": "2.2.0",
|
"i18next-icu": "^2.3.0",
|
||||||
"i18next-icu": "2.3.0",
|
|
||||||
"intl-messageformat": "^10.5.14",
|
"intl-messageformat": "^10.5.14",
|
||||||
"javascript-time-ago": "^2.5.10",
|
"javascript-time-ago": "^2.5.10",
|
||||||
"js-cookie": "3.0.5",
|
|
||||||
"loglevel": "^1.9.1",
|
|
||||||
"loglevel-plugin-prefix": "^0.8.4",
|
|
||||||
"nanostores": "^0.9.0",
|
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"prosemirror-history": "1.3.2",
|
"prosemirror-history": "^1.4.0",
|
||||||
"prosemirror-trailing-node": "2.0.7",
|
"prosemirror-trailing-node": "^2.0.8",
|
||||||
"prosemirror-view": "1.32.7",
|
"prosemirror-view": "^1.33.8",
|
||||||
"rollup": "4.17.2",
|
"sass": "^1.77.6",
|
||||||
"sass": "1.77.2",
|
|
||||||
"solid-js": "1.8.17",
|
"solid-js": "1.8.17",
|
||||||
"solid-popper": "0.3.0",
|
"solid-popper": "^0.3.0",
|
||||||
"solid-tiptap": "0.7.0",
|
"solid-tiptap": "0.7.0",
|
||||||
"solid-transition-group": "0.2.3",
|
"solid-transition-group": "^0.2.3",
|
||||||
"stylelint": "^16.5.0",
|
"stylelint": "^16.6.1",
|
||||||
"stylelint-config-standard-scss": "^13.1.0",
|
"stylelint-config-standard-scss": "^13.1.0",
|
||||||
"stylelint-order": "^6.0.3",
|
"stylelint-order": "^6.0.4",
|
||||||
"stylelint-scss": "^6.1.0",
|
"stylelint-scss": "^6.3.2",
|
||||||
"swiper": "11.0.5",
|
"swiper": "^11.1.4",
|
||||||
"throttle-debounce": "5.0.0",
|
"throttle-debounce": "^5.0.2",
|
||||||
"typescript": "5.4.5",
|
"tslib": "^2.6.3",
|
||||||
"typograf": "7.3.0",
|
"typescript": "^5.5.2",
|
||||||
"uniqolor": "1.1.0",
|
"typograf": "^7.4.1",
|
||||||
"vike": "0.4.148",
|
"uniqolor": "^1.1.1",
|
||||||
"vite": "5.2.11",
|
"vinxi": "^0.3.12",
|
||||||
"vite-plugin-mkcert": "^1.17.5",
|
|
||||||
"vite-plugin-node-polyfills": "^0.22.0",
|
"vite-plugin-node-polyfills": "^0.22.0",
|
||||||
"vite-plugin-sass-dts": "^1.3.22",
|
"vite-plugin-sass-dts": "^1.3.22",
|
||||||
"vite-plugin-solid": "^2.10.2",
|
"y-prosemirror": "1.2.9",
|
||||||
"y-prosemirror": "1.2.5",
|
"yjs": "13.6.18"
|
||||||
"yjs": "13.6.15"
|
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"y-prosemirror": "1.2.5",
|
"yjs": "13.6.18",
|
||||||
"yjs": "13.6.15"
|
"y-prosemirror": "1.2.9"
|
||||||
},
|
},
|
||||||
"trustedDependencies": ["@biomejs/biome"]
|
"trustedDependencies": [
|
||||||
|
"@biomejs/biome",
|
||||||
|
"esbuild",
|
||||||
|
"protobufjs"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"idb": "^8.0.0"
|
||||||
|
}
|
||||||
}
|
}
|
Before Width: | Height: | Size: 245 B After Width: | Height: | Size: 245 B |
45
src/app.tsx
Normal file
45
src/app.tsx
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import { MetaProvider } from '@solidjs/meta'
|
||||||
|
import { Router } from '@solidjs/router'
|
||||||
|
import { FileRoutes } from '@solidjs/start/router'
|
||||||
|
import { type JSX, Suspense } from 'solid-js'
|
||||||
|
|
||||||
|
import { Loading } from './components/_shared/Loading'
|
||||||
|
import { PageLayout } from './components/_shared/PageLayout'
|
||||||
|
import { FeedProvider } from './context/feed'
|
||||||
|
import { GraphQLClientProvider } from './context/graphql'
|
||||||
|
import { LocalizeProvider, useLocalize } from './context/localize'
|
||||||
|
import { SessionProvider } from './context/session'
|
||||||
|
import { TopicsProvider } from './context/topics'
|
||||||
|
import { UIProvider } from './context/ui' // snackbar included
|
||||||
|
import '~/styles/app.scss'
|
||||||
|
|
||||||
|
export const Providers = (props: { children?: JSX.Element }) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
|
return (
|
||||||
|
<LocalizeProvider>
|
||||||
|
<SessionProvider onStateChangeCallback={console.info}>
|
||||||
|
<GraphQLClientProvider>
|
||||||
|
<TopicsProvider>
|
||||||
|
<FeedProvider>
|
||||||
|
<MetaProvider>
|
||||||
|
<UIProvider>
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<PageLayout title={t('Discours')}>{props.children}</PageLayout>
|
||||||
|
</Suspense>
|
||||||
|
</UIProvider>
|
||||||
|
</MetaProvider>
|
||||||
|
</FeedProvider>
|
||||||
|
</TopicsProvider>
|
||||||
|
</GraphQLClientProvider>
|
||||||
|
</SessionProvider>
|
||||||
|
</LocalizeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const App = () => (
|
||||||
|
<Router root={Providers}>
|
||||||
|
<FileRoutes />
|
||||||
|
</Router>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default App
|
|
@ -1,8 +1,8 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show, createSignal } from 'solid-js'
|
import { Show, createSignal } from 'solid-js'
|
||||||
|
|
||||||
|
import { MediaItem } from '~/types/mediaitem'
|
||||||
import { Topic } from '../../../graphql/schema/core.gen'
|
import { Topic } from '../../../graphql/schema/core.gen'
|
||||||
import { MediaItem } from '../../../pages/types'
|
|
||||||
import { CardTopic } from '../../Feed/CardTopic'
|
import { CardTopic } from '../../Feed/CardTopic'
|
||||||
import { Icon } from '../../_shared/Icon'
|
import { Icon } from '../../_shared/Icon'
|
||||||
import { Image } from '../../_shared/Image'
|
import { Image } from '../../_shared/Image'
|
||||||
|
@ -30,19 +30,19 @@ export const AudioHeader = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
<div class={styles.albumInfo}>
|
<div class={styles.albumInfo}>
|
||||||
<Show when={props.topic}>
|
<Show when={props.topic}>
|
||||||
<CardTopic title={props.topic.title} slug={props.topic.slug} />
|
<CardTopic title={props.topic.title || ''} slug={props.topic.slug} />
|
||||||
</Show>
|
</Show>
|
||||||
<h1>{props.title}</h1>
|
<h1>{props.title}</h1>
|
||||||
<Show when={props.artistData}>
|
<Show when={props.artistData}>
|
||||||
<div class={styles.artistData}>
|
<div class={styles.artistData}>
|
||||||
<Show when={props.artistData?.artist}>
|
<Show when={props.artistData?.artist}>
|
||||||
<div class={styles.item}>{props.artistData.artist}</div>
|
<div class={styles.item}>{props.artistData?.artist || ''}</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.artistData?.date}>
|
<Show when={props.artistData?.date}>
|
||||||
<div class={styles.item}>{props.artistData.date}</div>
|
<div class={styles.item}>{props.artistData?.date || ''}</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.artistData?.genre}>
|
<Show when={props.artistData?.genre}>
|
||||||
<div class={styles.item}>{props.artistData.genre}</div>
|
<div class={styles.item}>{props.artistData?.genre || ''}</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Show, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'
|
import { Show, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'
|
||||||
|
|
||||||
import { MediaItem } from '../../../pages/types'
|
import { MediaItem } from '~/types/mediaitem'
|
||||||
|
|
||||||
import { PlayerHeader } from './PlayerHeader'
|
import { PlayerHeader } from './PlayerHeader'
|
||||||
import { PlayerPlaylist } from './PlayerPlaylist'
|
import { PlayerPlaylist } from './PlayerPlaylist'
|
||||||
|
@ -12,18 +12,22 @@ type Props = {
|
||||||
articleSlug?: string
|
articleSlug?: string
|
||||||
body?: string
|
body?: string
|
||||||
editorMode?: boolean
|
editorMode?: boolean
|
||||||
onMediaItemFieldChange?: (index: number, field: keyof MediaItem, value: string) => void
|
onMediaItemFieldChange?: (
|
||||||
onChangeMediaIndex?: (direction: 'up' | 'down', index) => void
|
index: number,
|
||||||
|
field: keyof MediaItem | string | number | symbol,
|
||||||
|
value: string,
|
||||||
|
) => void
|
||||||
|
onChangeMediaIndex?: (direction: 'up' | 'down', index: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFormattedTime = (point: number) => new Date(point * 1000).toISOString().slice(14, -5)
|
const getFormattedTime = (point: number) => new Date(point * 1000).toISOString().slice(14, -5)
|
||||||
|
|
||||||
export const AudioPlayer = (props: Props) => {
|
export const AudioPlayer = (props: Props) => {
|
||||||
const audioRef: { current: HTMLAudioElement } = { current: null }
|
let audioRef: HTMLAudioElement | undefined
|
||||||
const gainNodeRef: { current: GainNode } = { current: null }
|
let gainNodeRef: GainNode | undefined
|
||||||
const progressRef: { current: HTMLDivElement } = { current: null }
|
let progressRef: HTMLDivElement | undefined
|
||||||
const audioContextRef: { current: AudioContext } = { current: null }
|
let audioContextRef: AudioContext | undefined
|
||||||
const mouseDownRef: { current: boolean } = { current: false }
|
let mouseDownRef: boolean | undefined
|
||||||
|
|
||||||
const [currentTrackDuration, setCurrentTrackDuration] = createSignal(0)
|
const [currentTrackDuration, setCurrentTrackDuration] = createSignal(0)
|
||||||
const [currentTime, setCurrentTime] = createSignal(0)
|
const [currentTime, setCurrentTime] = createSignal(0)
|
||||||
|
@ -37,19 +41,19 @@ export const AudioPlayer = (props: Props) => {
|
||||||
setIsPlaying(!isPlaying() || trackIndex !== currentTrackIndex())
|
setIsPlaying(!isPlaying() || trackIndex !== currentTrackIndex())
|
||||||
setCurrentTrackIndex(trackIndex)
|
setCurrentTrackIndex(trackIndex)
|
||||||
|
|
||||||
if (audioContextRef.current.state === 'suspended') {
|
if (audioContextRef?.state === 'suspended') {
|
||||||
await audioContextRef.current.resume()
|
await audioContextRef?.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlaying()) {
|
if (isPlaying()) {
|
||||||
await audioRef.current.play()
|
await audioRef?.play()
|
||||||
} else {
|
} else {
|
||||||
audioRef.current.pause()
|
audioRef?.pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleVolumeChange = (volume: number) => {
|
const handleVolumeChange = (volume: number) => {
|
||||||
gainNodeRef.current.gain.value = volume
|
if (gainNodeRef) gainNodeRef.gain.value = volume
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAudioEnd = () => {
|
const handleAudioEnd = () => {
|
||||||
|
@ -58,21 +62,22 @@ export const AudioPlayer = (props: Props) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
audioRef.current.currentTime = 0
|
if (audioRef) audioRef.currentTime = 0
|
||||||
setIsPlaying(false)
|
setIsPlaying(false)
|
||||||
setCurrentTrackIndex(0)
|
setCurrentTrackIndex(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAudioTimeUpdate = () => {
|
const handleAudioTimeUpdate = () => {
|
||||||
setCurrentTime(audioRef.current.currentTime)
|
setCurrentTime(audioRef?.currentTime || 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
audioContextRef.current = new AudioContext()
|
audioContextRef = new AudioContext()
|
||||||
gainNodeRef.current = audioContextRef.current.createGain()
|
gainNodeRef = audioContextRef.createGain()
|
||||||
|
if (audioRef) {
|
||||||
const track = audioContextRef.current.createMediaElementSource(audioRef.current)
|
const track = audioContextRef?.createMediaElementSource(audioRef)
|
||||||
track.connect(gainNodeRef.current).connect(audioContextRef.current.destination)
|
track.connect(gainNodeRef).connect(audioContextRef?.destination)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const playPrevTrack = () => {
|
const playPrevTrack = () => {
|
||||||
|
@ -93,13 +98,18 @@ export const AudioPlayer = (props: Props) => {
|
||||||
setCurrentTrackIndex(newCurrentTrackIndex)
|
setCurrentTrackIndex(newCurrentTrackIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMediaItemFieldChange = (index: number, field: keyof MediaItem, value) => {
|
const handleMediaItemFieldChange = (
|
||||||
props.onMediaItemFieldChange(index, field, value)
|
index: number,
|
||||||
|
field: keyof MediaItem | string | number | symbol,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
props.onMediaItemFieldChange?.(index, field, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrub = (event) => {
|
const scrub = (event: MouseEvent | undefined) => {
|
||||||
audioRef.current.currentTime =
|
if (progressRef && audioRef) {
|
||||||
(event.offsetX / progressRef.current.offsetWidth) * currentTrackDuration()
|
audioRef.currentTime = (event?.offsetX || 0 / progressRef.offsetWidth) * currentTrackDuration()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -116,11 +126,11 @@ export const AudioPlayer = (props: Props) => {
|
||||||
<div class={styles.timeline}>
|
<div class={styles.timeline}>
|
||||||
<div
|
<div
|
||||||
class={styles.progress}
|
class={styles.progress}
|
||||||
ref={(el) => (progressRef.current = el)}
|
ref={(el) => (progressRef = el)}
|
||||||
onClick={(e) => scrub(e)}
|
onClick={scrub}
|
||||||
onMouseMove={(e) => mouseDownRef.current && scrub(e)}
|
onMouseMove={(e) => mouseDownRef && scrub(e)}
|
||||||
onMouseDown={() => (mouseDownRef.current = true)}
|
onMouseDown={() => (mouseDownRef = true)}
|
||||||
onMouseUp={() => (mouseDownRef.current = false)}
|
onMouseUp={() => (mouseDownRef = false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class={styles.progressFilled}
|
class={styles.progressFilled}
|
||||||
|
@ -136,13 +146,13 @@ export const AudioPlayer = (props: Props) => {
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<audio
|
<audio
|
||||||
ref={(el) => (audioRef.current = el)}
|
ref={(el) => (audioRef = el)}
|
||||||
onTimeUpdate={handleAudioTimeUpdate}
|
onTimeUpdate={handleAudioTimeUpdate}
|
||||||
src={currentTack().url.replace('images.discours.io', 'cdn.discours.io')}
|
src={currentTack().url.replace('images.discours.io', 'cdn.discours.io')}
|
||||||
onCanPlay={() => {
|
onCanPlay={() => {
|
||||||
// start to play the next track on src change
|
// start to play the next track on src change
|
||||||
if (isPlaying()) {
|
if (isPlaying() && audioRef) {
|
||||||
audioRef.current.play()
|
audioRef.play()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLoadedMetadata={({ currentTarget }) => setCurrentTrackDuration(currentTarget.duration)}
|
onLoadedMetadata={({ currentTarget }) => setCurrentTrackDuration(currentTarget.duration)}
|
||||||
|
@ -153,7 +163,7 @@ export const AudioPlayer = (props: Props) => {
|
||||||
<PlayerPlaylist
|
<PlayerPlaylist
|
||||||
editorMode={props.editorMode}
|
editorMode={props.editorMode}
|
||||||
onPlayMedia={handlePlayMedia}
|
onPlayMedia={handlePlayMedia}
|
||||||
onChangeMediaIndex={(direction, index) => props.onChangeMediaIndex(direction, index)}
|
onChangeMediaIndex={(direction, index) => props.onChangeMediaIndex?.(direction, index)}
|
||||||
isPlaying={isPlaying()}
|
isPlaying={isPlaying()}
|
||||||
media={props.media}
|
media={props.media}
|
||||||
currentTrackIndex={currentTrackIndex()}
|
currentTrackIndex={currentTrackIndex()}
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show, createSignal } from 'solid-js'
|
import { Show, createSignal } from 'solid-js'
|
||||||
|
|
||||||
import { MediaItem } from '../../../pages/types'
|
|
||||||
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
|
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
|
||||||
import { Icon } from '../../_shared/Icon'
|
import { Icon } from '../../_shared/Icon'
|
||||||
|
|
||||||
|
import { MediaItem } from '~/types/mediaitem'
|
||||||
import styles from './AudioPlayer.module.scss'
|
import styles from './AudioPlayer.module.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -17,10 +16,7 @@ type Props = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayerHeader = (props: Props) => {
|
export const PlayerHeader = (props: Props) => {
|
||||||
const volumeContainerRef: { current: HTMLDivElement } = {
|
let volumeContainerRef: HTMLDivElement | undefined
|
||||||
current: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const [isVolumeBarOpened, setIsVolumeBarOpened] = createSignal(false)
|
const [isVolumeBarOpened, setIsVolumeBarOpened] = createSignal(false)
|
||||||
|
|
||||||
const toggleVolumeBar = () => {
|
const toggleVolumeBar = () => {
|
||||||
|
@ -65,7 +61,7 @@ export const PlayerHeader = (props: Props) => {
|
||||||
>
|
>
|
||||||
<Icon name="player-arrow" />
|
<Icon name="player-arrow" />
|
||||||
</button>
|
</button>
|
||||||
<div ref={(el) => (volumeContainerRef.current = el)} class={styles.volumeContainer}>
|
<div ref={(el) => (volumeContainerRef = el)} class={styles.volumeContainer}>
|
||||||
<Show when={isVolumeBarOpened()}>
|
<Show when={isVolumeBarOpened()}>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import { gtag } from 'ga-gtag'
|
|
||||||
import { For, Show, createSignal, lazy } from 'solid-js'
|
import { For, Show, createSignal, lazy } from 'solid-js'
|
||||||
|
|
||||||
|
import { MediaItem } from '~/types/mediaitem'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { MediaItem } from '../../../pages/types'
|
|
||||||
import { getDescription } from '../../../utils/meta'
|
import { getDescription } from '../../../utils/meta'
|
||||||
import { Icon } from '../../_shared/Icon'
|
import { Icon } from '../../_shared/Icon'
|
||||||
import { Popover } from '../../_shared/Popover'
|
import { Popover } from '../../_shared/Popover'
|
||||||
|
@ -22,30 +21,30 @@ type Props = {
|
||||||
body?: string
|
body?: string
|
||||||
editorMode?: boolean
|
editorMode?: boolean
|
||||||
onMediaItemFieldChange?: (index: number, field: keyof MediaItem, value: string) => void
|
onMediaItemFieldChange?: (index: number, field: keyof MediaItem, value: string) => void
|
||||||
onChangeMediaIndex?: (direction: 'up' | 'down', index) => void
|
onChangeMediaIndex?: (direction: 'up' | 'down', index: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMediaTitle = (itm: MediaItem, idx: number) => `${idx}. ${itm.artist} - ${itm.title}`
|
const _getMediaTitle = (itm: MediaItem, idx: number) => `${idx}. ${itm.artist} - ${itm.title}`
|
||||||
|
|
||||||
export const PlayerPlaylist = (props: Props) => {
|
export const PlayerPlaylist = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const [activeEditIndex, setActiveEditIndex] = createSignal(-1)
|
const [activeEditIndex, setActiveEditIndex] = createSignal(-1)
|
||||||
|
|
||||||
const toggleDropDown = (index) => {
|
const toggleDropDown = (index: number) => {
|
||||||
setActiveEditIndex(activeEditIndex() === index ? -1 : index)
|
setActiveEditIndex(activeEditIndex() === index ? -1 : index)
|
||||||
}
|
}
|
||||||
const handleMediaItemFieldChange = (field: keyof MediaItem, value: string) => {
|
const handleMediaItemFieldChange = (field: keyof MediaItem, value: string) => {
|
||||||
props.onMediaItemFieldChange(activeEditIndex(), field, value)
|
props.onMediaItemFieldChange?.(activeEditIndex(), field, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const play = (index: number) => {
|
const play = (index: number) => {
|
||||||
props.onPlayMedia(index)
|
props.onPlayMedia(index)
|
||||||
const mi = props.media[index]
|
//const mi = props.media[index]
|
||||||
gtag('event', 'select_item', {
|
//gtag('event', 'select_item', {
|
||||||
item_list_id: props.articleSlug,
|
//item_list_id: props.articleSlug,
|
||||||
item_list_name: getMediaTitle(mi, index),
|
//item_list_name: getMediaTitle(mi, index),
|
||||||
items: props.media.map((it, ix) => getMediaTitle(it, ix)),
|
//items: props.media.map((it, ix) => getMediaTitle(it, ix)),
|
||||||
})
|
//})
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ul class={styles.playlist}>
|
<ul class={styles.playlist}>
|
||||||
|
@ -90,26 +89,26 @@ export const PlayerPlaylist = (props: Props) => {
|
||||||
<div class={styles.actions}>
|
<div class={styles.actions}>
|
||||||
<Show when={props.editorMode}>
|
<Show when={props.editorMode}>
|
||||||
<Popover content={t('Move up')}>
|
<Popover content={t('Move up')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
class={styles.action}
|
class={styles.action}
|
||||||
disabled={index() === 0}
|
disabled={index() === 0}
|
||||||
onClick={() => props.onChangeMediaIndex('up', index())}
|
onClick={() => props.onChangeMediaIndex?.('up', index())}
|
||||||
>
|
>
|
||||||
<Icon name="up-button" />
|
<Icon name="up-button" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={t('Move down')}>
|
<Popover content={t('Move down')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
class={styles.action}
|
class={styles.action}
|
||||||
disabled={index() === props.media.length - 1}
|
disabled={index() === props.media.length - 1}
|
||||||
onClick={() => props.onChangeMediaIndex('down', index())}
|
onClick={() => props.onChangeMediaIndex?.('down', index())}
|
||||||
>
|
>
|
||||||
<Icon name="up-button" class={styles.moveIconDown} />
|
<Icon name="up-button" class={styles.moveIconDown} />
|
||||||
</button>
|
</button>
|
||||||
|
@ -118,7 +117,7 @@ export const PlayerPlaylist = (props: Props) => {
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={(mi.lyrics || mi.body) && !props.editorMode}>
|
<Show when={(mi.lyrics || mi.body) && !props.editorMode}>
|
||||||
<Popover content={t('Show lyrics')}>
|
<Popover content={t('Show lyrics')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button ref={triggerRef} type="button" onClick={() => toggleDropDown(index())}>
|
<button ref={triggerRef} type="button" onClick={() => toggleDropDown(index())}>
|
||||||
<Icon name="list" />
|
<Icon name="list" />
|
||||||
</button>
|
</button>
|
||||||
|
@ -126,7 +125,7 @@ export const PlayerPlaylist = (props: Props) => {
|
||||||
</Popover>
|
</Popover>
|
||||||
</Show>
|
</Show>
|
||||||
<Popover content={props.editorMode ? t('Edit') : t('Share')}>
|
<Popover content={props.editorMode ? t('Edit') : t('Share')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<div ref={triggerRef}>
|
<div ref={triggerRef}>
|
||||||
<Show
|
<Show
|
||||||
when={!props.editorMode}
|
when={!props.editorMode}
|
||||||
|
@ -138,8 +137,8 @@ export const PlayerPlaylist = (props: Props) => {
|
||||||
>
|
>
|
||||||
<SharePopup
|
<SharePopup
|
||||||
title={mi.title}
|
title={mi.title}
|
||||||
description={getDescription(props.body)}
|
description={getDescription(props.body || '')}
|
||||||
imageUrl={mi.pic}
|
imageUrl={mi.pic || ''}
|
||||||
shareUrl={getShareUrl({ pathname: `/${props.articleSlug}` })}
|
shareUrl={getShareUrl({ pathname: `/${props.articleSlug}` })}
|
||||||
trigger={
|
trigger={
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -1,21 +1,25 @@
|
||||||
import { getPagePath } from '@nanostores/router'
|
import { A } from '@solidjs/router'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { For, Show, Suspense, createMemo, createSignal, lazy } from 'solid-js'
|
import { For, Show, Suspense, createMemo, createSignal, lazy } from 'solid-js'
|
||||||
|
import { useGraphQL } from '~/context/graphql'
|
||||||
import { useConfirm } from '../../../context/confirm'
|
import { useSnackbar, useUI } from '~/context/ui'
|
||||||
|
import deleteReactionMutation from '~/graphql/mutation/core/reaction-destroy'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { useReactions } from '../../../context/reactions'
|
import { useReactions } from '../../../context/reactions'
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '../../../context/session'
|
||||||
import { useSnackbar } from '../../../context/snackbar'
|
import {
|
||||||
import { Author, Reaction, ReactionKind } from '../../../graphql/schema/core.gen'
|
Author,
|
||||||
import { router } from '../../../stores/router'
|
MutationCreate_ReactionArgs,
|
||||||
|
MutationUpdate_ReactionArgs,
|
||||||
|
Reaction,
|
||||||
|
ReactionKind,
|
||||||
|
} from '../../../graphql/schema/core.gen'
|
||||||
import { AuthorLink } from '../../Author/AuthorLink'
|
import { AuthorLink } from '../../Author/AuthorLink'
|
||||||
import { Userpic } from '../../Author/Userpic'
|
import { Userpic } from '../../Author/Userpic'
|
||||||
import { Icon } from '../../_shared/Icon'
|
import { Icon } from '../../_shared/Icon'
|
||||||
import { ShowIfAuthenticated } from '../../_shared/ShowIfAuthenticated'
|
import { ShowIfAuthenticated } from '../../_shared/ShowIfAuthenticated'
|
||||||
import { CommentDate } from '../CommentDate'
|
import { CommentDate } from '../CommentDate'
|
||||||
import { CommentRatingControl } from '../CommentRatingControl'
|
import { CommentRatingControl } from '../CommentRatingControl'
|
||||||
|
|
||||||
import styles from './Comment.module.scss'
|
import styles from './Comment.module.scss'
|
||||||
|
|
||||||
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))
|
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))
|
||||||
|
@ -40,18 +44,20 @@ export const Comment = (props: Props) => {
|
||||||
const [editMode, setEditMode] = createSignal(false)
|
const [editMode, setEditMode] = createSignal(false)
|
||||||
const [clearEditor, setClearEditor] = createSignal(false)
|
const [clearEditor, setClearEditor] = createSignal(false)
|
||||||
const [editedBody, setEditedBody] = createSignal<string>()
|
const [editedBody, setEditedBody] = createSignal<string>()
|
||||||
const { author, session } = useSession()
|
const { session } = useSession()
|
||||||
const { createReaction, deleteReaction, updateReaction } = useReactions()
|
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
||||||
const { showConfirm } = useConfirm()
|
const { createReaction, updateReaction } = useReactions()
|
||||||
|
const { showConfirm } = useUI()
|
||||||
const { showSnackbar } = useSnackbar()
|
const { showSnackbar } = useSnackbar()
|
||||||
|
const { mutation } = useGraphQL()
|
||||||
|
|
||||||
const canEdit = createMemo(
|
const canEdit = createMemo(
|
||||||
() =>
|
() =>
|
||||||
Boolean(author()?.id) &&
|
Boolean(author()?.id) &&
|
||||||
(props.comment?.created_by?.slug === author()?.slug || session()?.user?.roles.includes('editor')),
|
(props.comment?.created_by?.slug === author()?.slug || session()?.user?.roles?.includes('editor')),
|
||||||
)
|
)
|
||||||
|
|
||||||
const body = createMemo(() => (editedBody() ? editedBody().trim() : props.comment.body.trim() || ''))
|
const body = createMemo(() => (editedBody() ? editedBody()?.trim() : props.comment.body?.trim() || ''))
|
||||||
|
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
if (props.comment?.id) {
|
if (props.comment?.id) {
|
||||||
|
@ -64,12 +70,18 @@ export const Comment = (props: Props) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isConfirmed) {
|
if (isConfirmed) {
|
||||||
const { error } = await deleteReaction(props.comment.id)
|
const resp = await mutation(deleteReactionMutation, { id: props.comment.id }).toPromise()
|
||||||
|
const result = resp?.data?.delete_reaction
|
||||||
|
const { error } = result
|
||||||
const notificationType = error ? 'error' : 'success'
|
const notificationType = error ? 'error' : 'success'
|
||||||
const notificationMessage = error
|
const notificationMessage = error
|
||||||
? t('Failed to delete comment')
|
? t('Failed to delete comment')
|
||||||
: t('Comment successfully deleted')
|
: t('Comment successfully deleted')
|
||||||
await showSnackbar({ type: notificationType, body: notificationMessage })
|
await showSnackbar({
|
||||||
|
type: notificationType,
|
||||||
|
body: notificationMessage,
|
||||||
|
duration: 3,
|
||||||
|
})
|
||||||
|
|
||||||
if (!error && props.onDelete) {
|
if (!error && props.onDelete) {
|
||||||
props.onDelete(props.comment.id)
|
props.onDelete(props.comment.id)
|
||||||
|
@ -82,15 +94,17 @@ export const Comment = (props: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = async (value) => {
|
const handleCreate = async (value: string) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
await createReaction({
|
await createReaction({
|
||||||
|
reaction: {
|
||||||
kind: ReactionKind.Comment,
|
kind: ReactionKind.Comment,
|
||||||
reply_to: props.comment.id,
|
reply_to: props.comment.id,
|
||||||
body: value,
|
body: value,
|
||||||
shout: props.comment.shout.id,
|
shout: props.comment.shout.id,
|
||||||
})
|
},
|
||||||
|
} as MutationCreate_ReactionArgs)
|
||||||
setClearEditor(true)
|
setClearEditor(true)
|
||||||
setIsReplyVisible(false)
|
setIsReplyVisible(false)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
@ -104,15 +118,17 @@ export const Comment = (props: Props) => {
|
||||||
setEditMode((oldEditMode) => !oldEditMode)
|
setEditMode((oldEditMode) => !oldEditMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = async (value) => {
|
const handleUpdate = async (value: string) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const reaction = await updateReaction({
|
const reaction = await updateReaction({
|
||||||
id: props.comment.id,
|
reaction: {
|
||||||
|
id: props.comment.id || 0,
|
||||||
kind: ReactionKind.Comment,
|
kind: ReactionKind.Comment,
|
||||||
body: value,
|
body: value,
|
||||||
shout: props.comment.shout.id,
|
shout: props.comment.shout.id,
|
||||||
})
|
},
|
||||||
|
} as MutationUpdate_ReactionArgs)
|
||||||
if (reaction) {
|
if (reaction) {
|
||||||
setEditedBody(value)
|
setEditedBody(value)
|
||||||
}
|
}
|
||||||
|
@ -127,7 +143,8 @@ export const Comment = (props: Props) => {
|
||||||
<li
|
<li
|
||||||
id={`comment_${props.comment.id}`}
|
id={`comment_${props.comment.id}`}
|
||||||
class={clsx(styles.comment, props.class, {
|
class={clsx(styles.comment, props.class, {
|
||||||
[styles.isNew]: props.lastSeen > (props.comment.updated_at || props.comment.created_at),
|
[styles.isNew]:
|
||||||
|
(props.lastSeen || Date.now()) > (props.comment.updated_at || props.comment.created_at),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Show when={!!body()}>
|
<Show when={!!body()}>
|
||||||
|
@ -137,8 +154,8 @@ export const Comment = (props: Props) => {
|
||||||
fallback={
|
fallback={
|
||||||
<div>
|
<div>
|
||||||
<Userpic
|
<Userpic
|
||||||
name={props.comment.created_by.name}
|
name={props.comment.created_by.name || ''}
|
||||||
userpic={props.comment.created_by.pic}
|
userpic={props.comment.created_by.pic || ''}
|
||||||
class={clsx({
|
class={clsx({
|
||||||
[styles.compactUserpic]: props.compact,
|
[styles.compactUserpic]: props.compact,
|
||||||
})}
|
})}
|
||||||
|
@ -161,13 +178,9 @@ export const Comment = (props: Props) => {
|
||||||
<Show when={props.showArticleLink}>
|
<Show when={props.showArticleLink}>
|
||||||
<div class={styles.articleLink}>
|
<div class={styles.articleLink}>
|
||||||
<Icon name="arrow-right" class={styles.articleLinkIcon} />
|
<Icon name="arrow-right" class={styles.articleLinkIcon} />
|
||||||
<a
|
<A href={`${props.comment.shout.slug}?commentId=${props.comment.id}`}>
|
||||||
href={`${getPagePath(router, 'article', {
|
|
||||||
slug: props.comment.shout.slug,
|
|
||||||
})}?commentId=${props.comment.id}`}
|
|
||||||
>
|
|
||||||
{props.comment.shout.title}
|
{props.comment.shout.title}
|
||||||
</a>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<CommentDate showOnHover={true} comment={props.comment} isShort={true} />
|
<CommentDate showOnHover={true} comment={props.comment} isShort={true} />
|
||||||
|
@ -178,7 +191,7 @@ export const Comment = (props: Props) => {
|
||||||
<Show when={editMode()} fallback={<div innerHTML={body()} />}>
|
<Show when={editMode()} fallback={<div innerHTML={body()} />}>
|
||||||
<Suspense fallback={<p>{t('Loading')}</p>}>
|
<Suspense fallback={<p>{t('Loading')}</p>}>
|
||||||
<SimplifiedEditor
|
<SimplifiedEditor
|
||||||
initialContent={editedBody() || props.comment.body}
|
initialContent={editedBody() || props.comment.body || ''}
|
||||||
submitButtonText={t('Save')}
|
submitButtonText={t('Save')}
|
||||||
quoteEnabled={true}
|
quoteEnabled={true}
|
||||||
imageEnabled={true}
|
imageEnabled={true}
|
||||||
|
@ -199,7 +212,7 @@ export const Comment = (props: Props) => {
|
||||||
disabled={loading()}
|
disabled={loading()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsReplyVisible(!isReplyVisible())
|
setIsReplyVisible(!isReplyVisible())
|
||||||
props.clickedReply(props.comment.id)
|
props.clickedReply?.(props.comment.id)
|
||||||
}}
|
}}
|
||||||
class={clsx(styles.commentControl, styles.commentControlReply)}
|
class={clsx(styles.commentControl, styles.commentControlReply)}
|
||||||
>
|
>
|
||||||
|
@ -260,7 +273,7 @@ export const Comment = (props: Props) => {
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.sortedComments}>
|
<Show when={props.sortedComments}>
|
||||||
<ul>
|
<ul>
|
||||||
<For each={props.sortedComments.filter((r) => r.reply_to === props.comment.id)}>
|
<For each={props.sortedComments?.filter((r) => r.reply_to === props.comment.id)}>
|
||||||
{(c) => (
|
{(c) => (
|
||||||
<Comment
|
<Comment
|
||||||
sortedComments={props.sortedComments}
|
sortedComments={props.sortedComments}
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { createMemo } from 'solid-js'
|
import { createMemo } from 'solid-js'
|
||||||
|
|
||||||
|
import { useFeed } from '~/context/feed'
|
||||||
|
import { useSnackbar } from '~/context/ui'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { useReactions } from '../../context/reactions'
|
import { useReactions } from '../../context/reactions'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
import { useSnackbar } from '../../context/snackbar'
|
|
||||||
import { Reaction, ReactionKind } from '../../graphql/schema/core.gen'
|
import { Reaction, ReactionKind } from '../../graphql/schema/core.gen'
|
||||||
import { loadShout } from '../../stores/zine/articles'
|
|
||||||
import { Popup } from '../_shared/Popup'
|
import { Popup } from '../_shared/Popup'
|
||||||
import { VotersList } from '../_shared/VotersList'
|
import { VotersList } from '../_shared/VotersList'
|
||||||
|
|
||||||
|
@ -18,7 +18,9 @@ type Props = {
|
||||||
|
|
||||||
export const CommentRatingControl = (props: Props) => {
|
export const CommentRatingControl = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { author } = useSession()
|
const { loadShout } = useFeed()
|
||||||
|
const { session } = useSession()
|
||||||
|
const uid = createMemo<number>(() => session()?.user?.app_data?.profile?.id || 0)
|
||||||
const { showSnackbar } = useSnackbar()
|
const { showSnackbar } = useSnackbar()
|
||||||
const { reactionEntities, createReaction, deleteReaction, loadReactionsBy } = useReactions()
|
const { reactionEntities, createReaction, deleteReaction, loadReactionsBy } = useReactions()
|
||||||
|
|
||||||
|
@ -26,13 +28,13 @@ export const CommentRatingControl = (props: Props) => {
|
||||||
Object.values(reactionEntities).some(
|
Object.values(reactionEntities).some(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.kind === reactionKind &&
|
r.kind === reactionKind &&
|
||||||
r.created_by.slug === author()?.slug &&
|
r.created_by.id === uid() &&
|
||||||
r.shout.id === props.comment.shout.id &&
|
r.shout.id === props.comment.shout.id &&
|
||||||
r.reply_to === props.comment.id,
|
r.reply_to === props.comment.id,
|
||||||
)
|
)
|
||||||
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
|
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
|
||||||
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
|
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
|
||||||
const canVote = createMemo(() => author()?.slug !== props.comment.created_by.slug)
|
const canVote = createMemo(() => uid() !== props.comment.created_by.id)
|
||||||
|
|
||||||
const commentRatingReactions = createMemo(() =>
|
const commentRatingReactions = createMemo(() =>
|
||||||
Object.values(reactionEntities).filter(
|
Object.values(reactionEntities).filter(
|
||||||
|
@ -47,11 +49,11 @@ export const CommentRatingControl = (props: Props) => {
|
||||||
const reactionToDelete = Object.values(reactionEntities).find(
|
const reactionToDelete = Object.values(reactionEntities).find(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.kind === reactionKind &&
|
r.kind === reactionKind &&
|
||||||
r.created_by.slug === author()?.slug &&
|
r.created_by.id === uid() &&
|
||||||
r.shout.id === props.comment.shout.id &&
|
r.shout.id === props.comment.shout.id &&
|
||||||
r.reply_to === props.comment.id,
|
r.reply_to === props.comment.id,
|
||||||
)
|
)
|
||||||
return deleteReaction(reactionToDelete.id)
|
if (reactionToDelete) return deleteReaction(reactionToDelete.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRatingChange = async (isUpvote: boolean) => {
|
const handleRatingChange = async (isUpvote: boolean) => {
|
||||||
|
@ -62,9 +64,11 @@ export const CommentRatingControl = (props: Props) => {
|
||||||
await deleteCommentReaction(ReactionKind.Dislike)
|
await deleteCommentReaction(ReactionKind.Dislike)
|
||||||
} else {
|
} else {
|
||||||
await createReaction({
|
await createReaction({
|
||||||
|
reaction: {
|
||||||
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
|
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
|
||||||
shout: props.comment.shout.id,
|
shout: props.comment.shout.id,
|
||||||
reply_to: props.comment.id,
|
reply_to: props.comment.id,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -81,7 +85,7 @@ export const CommentRatingControl = (props: Props) => {
|
||||||
<div class={styles.commentRating}>
|
<div class={styles.commentRating}>
|
||||||
<button
|
<button
|
||||||
role="button"
|
role="button"
|
||||||
disabled={!(canVote() && author())}
|
disabled={!(canVote() && uid())}
|
||||||
onClick={() => handleRatingChange(true)}
|
onClick={() => handleRatingChange(true)}
|
||||||
class={clsx(styles.commentRatingControl, styles.commentRatingControlUp, {
|
class={clsx(styles.commentRatingControl, styles.commentRatingControlUp, {
|
||||||
[styles.voted]: isUpvoted(),
|
[styles.voted]: isUpvoted(),
|
||||||
|
@ -91,11 +95,11 @@ export const CommentRatingControl = (props: Props) => {
|
||||||
trigger={
|
trigger={
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.commentRatingValue, {
|
class={clsx(styles.commentRatingValue, {
|
||||||
[styles.commentRatingPositive]: props.comment.stat.rating > 0,
|
[styles.commentRatingPositive]: (props.comment?.stat?.rating || 0) > 0,
|
||||||
[styles.commentRatingNegative]: props.comment.stat.rating < 0,
|
[styles.commentRatingNegative]: (props.comment?.stat?.rating || 0) < 0,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{props.comment.stat.rating || 0}
|
{props.comment?.stat?.rating || 0}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
variant="tiny"
|
variant="tiny"
|
||||||
|
@ -107,7 +111,7 @@ export const CommentRatingControl = (props: Props) => {
|
||||||
</Popup>
|
</Popup>
|
||||||
<button
|
<button
|
||||||
role="button"
|
role="button"
|
||||||
disabled={!(canVote() && author())}
|
disabled={!(canVote() && uid())}
|
||||||
onClick={() => handleRatingChange(false)}
|
onClick={() => handleRatingChange(false)}
|
||||||
class={clsx(styles.commentRatingControl, styles.commentRatingControlDown, {
|
class={clsx(styles.commentRatingControl, styles.commentRatingControlDown, {
|
||||||
[styles.voted]: isDownvoted(),
|
[styles.voted]: isDownvoted(),
|
||||||
|
|
|
@ -11,7 +11,8 @@ import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated'
|
||||||
|
|
||||||
import { Comment } from './Comment'
|
import { Comment } from './Comment'
|
||||||
|
|
||||||
import { useSeen } from '../../context/seen'
|
import { SortFunction } from '~/context/authors'
|
||||||
|
import { useFeed } from '../../context/feed'
|
||||||
import styles from './Article.module.scss'
|
import styles from './Article.module.scss'
|
||||||
|
|
||||||
const SimplifiedEditor = lazy(() => import('../Editor/SimplifiedEditor'))
|
const SimplifiedEditor = lazy(() => import('../Editor/SimplifiedEditor'))
|
||||||
|
@ -23,7 +24,7 @@ type Props = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CommentsTree = (props: Props) => {
|
export const CommentsTree = (props: Props) => {
|
||||||
const { author } = useSession()
|
const { session } = useSession()
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const [commentsOrder, setCommentsOrder] = createSignal<ReactionSort>(ReactionSort.Newest)
|
const [commentsOrder, setCommentsOrder] = createSignal<ReactionSort>(ReactionSort.Newest)
|
||||||
const [onlyNew, setOnlyNew] = createSignal(false)
|
const [onlyNew, setOnlyNew] = createSignal(false)
|
||||||
|
@ -45,11 +46,11 @@ export const CommentsTree = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (commentsOrder() === ReactionSort.Like) {
|
if (commentsOrder() === ReactionSort.Like) {
|
||||||
newSortedComments = newSortedComments.sort(byStat('rating'))
|
newSortedComments = newSortedComments.sort(byStat('rating') as SortFunction<Reaction>)
|
||||||
}
|
}
|
||||||
return newSortedComments
|
return newSortedComments
|
||||||
})
|
})
|
||||||
const { seen } = useSeen()
|
const { seen } = useFeed()
|
||||||
const shoutLastSeen = createMemo(() => seen()[props.shoutSlug] ?? 0)
|
const shoutLastSeen = createMemo(() => seen()[props.shoutSlug] ?? 0)
|
||||||
const currentDate = new Date()
|
const currentDate = new Date()
|
||||||
const setCookie = () => localStorage.setItem(`${props.shoutSlug}`, `${currentDate}`)
|
const setCookie = () => localStorage.setItem(`${props.shoutSlug}`, `${currentDate}`)
|
||||||
|
@ -59,7 +60,10 @@ export const CommentsTree = (props: Props) => {
|
||||||
setCookie()
|
setCookie()
|
||||||
} else if (currentDate.getTime() > shoutLastSeen()) {
|
} else if (currentDate.getTime() > shoutLastSeen()) {
|
||||||
const newComments = comments().filter((c) => {
|
const newComments = comments().filter((c) => {
|
||||||
if (c.reply_to || c.created_by.slug === author()?.slug) {
|
if (
|
||||||
|
(session()?.user?.app_data?.profile?.id && c.reply_to) ||
|
||||||
|
c.created_by.id === session()?.user?.app_data?.profile?.id
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
return (c.updated_at || c.created_at) > shoutLastSeen()
|
return (c.updated_at || c.created_at) > shoutLastSeen()
|
||||||
|
@ -73,9 +77,11 @@ export const CommentsTree = (props: Props) => {
|
||||||
setPosting(true)
|
setPosting(true)
|
||||||
try {
|
try {
|
||||||
await createReaction({
|
await createReaction({
|
||||||
|
reaction: {
|
||||||
kind: ReactionKind.Comment,
|
kind: ReactionKind.Comment,
|
||||||
body: value,
|
body: value,
|
||||||
shout: props.shoutId,
|
shout: props.shoutId,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
setClearEditor(true)
|
setClearEditor(true)
|
||||||
await loadReactionsBy({ by: { shout: props.shoutSlug } })
|
await loadReactionsBy({ by: { shout: props.shoutSlug } })
|
||||||
|
@ -128,9 +134,7 @@ export const CommentsTree = (props: Props) => {
|
||||||
{(reaction) => (
|
{(reaction) => (
|
||||||
<Comment
|
<Comment
|
||||||
sortedComments={sortedComments()}
|
sortedComments={sortedComments()}
|
||||||
isArticleAuthor={Boolean(
|
isArticleAuthor={Boolean(props.articleAuthors.some((a) => a?.id === reaction.created_by.id))}
|
||||||
props.articleAuthors.some((a) => a?.slug === reaction.created_by.slug),
|
|
||||||
)}
|
|
||||||
comment={reaction}
|
comment={reaction}
|
||||||
clickedReply={(id) => setClickedReplyId(id)}
|
clickedReply={(id) => setClickedReplyId(id)}
|
||||||
clickedReplyId={clickedReplyId()}
|
clickedReplyId={clickedReplyId()}
|
||||||
|
|
|
@ -1,19 +1,16 @@
|
||||||
import type { Author, Shout, Topic } from '../../graphql/schema/core.gen'
|
|
||||||
|
|
||||||
import { getPagePath } from '@nanostores/router'
|
|
||||||
import { createPopper } from '@popperjs/core'
|
import { createPopper } from '@popperjs/core'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { install } from 'ga-gtag'
|
// import { install } from 'ga-gtag'
|
||||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
|
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
|
||||||
import { isServer } from 'solid-js/web'
|
import { isServer } from 'solid-js/web'
|
||||||
import { Link, Meta } from '../../context/meta'
|
|
||||||
|
|
||||||
|
import { Link, Meta } from '@solidjs/meta'
|
||||||
|
import { DEFAULT_HEADER_OFFSET, useUI } from '~/context/ui'
|
||||||
|
import { MediaItem } from '~/types/mediaitem'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { useReactions } from '../../context/reactions'
|
import { useReactions } from '../../context/reactions'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
import { MediaItem } from '../../pages/types'
|
import type { Author, Maybe, Shout, Topic } from '../../graphql/schema/core.gen'
|
||||||
import { DEFAULT_HEADER_OFFSET, router, useRouter } from '../../stores/router'
|
|
||||||
import { showModal } from '../../stores/ui'
|
|
||||||
import { capitalize } from '../../utils/capitalize'
|
import { capitalize } from '../../utils/capitalize'
|
||||||
import { getImageUrl, getOpenGraphImageUrl } from '../../utils/getImageUrl'
|
import { getImageUrl, getOpenGraphImageUrl } from '../../utils/getImageUrl'
|
||||||
import { getDescription, getKeywords } from '../../utils/meta'
|
import { getDescription, getKeywords } from '../../utils/meta'
|
||||||
|
@ -31,14 +28,14 @@ import { Popover } from '../_shared/Popover'
|
||||||
import { ShareModal } from '../_shared/ShareModal'
|
import { ShareModal } from '../_shared/ShareModal'
|
||||||
import { ImageSwiper } from '../_shared/SolidSwiper'
|
import { ImageSwiper } from '../_shared/SolidSwiper'
|
||||||
import { VideoPlayer } from '../_shared/VideoPlayer'
|
import { VideoPlayer } from '../_shared/VideoPlayer'
|
||||||
|
|
||||||
import { AudioHeader } from './AudioHeader'
|
import { AudioHeader } from './AudioHeader'
|
||||||
import { AudioPlayer } from './AudioPlayer'
|
import { AudioPlayer } from './AudioPlayer'
|
||||||
import { CommentsTree } from './CommentsTree'
|
import { CommentsTree } from './CommentsTree'
|
||||||
import { SharePopup, getShareUrl } from './SharePopup'
|
import { SharePopup, getShareUrl } from './SharePopup'
|
||||||
import { ShoutRatingControl } from './ShoutRatingControl'
|
import { ShoutRatingControl } from './ShoutRatingControl'
|
||||||
|
|
||||||
import { useSeen } from '../../context/seen'
|
import { A, useSearchParams } from '@solidjs/router'
|
||||||
|
import { useFeed } from '~/context/feed'
|
||||||
import stylesHeader from '../Nav/Header/Header.module.scss'
|
import stylesHeader from '../Nav/Header/Header.module.scss'
|
||||||
import styles from './Article.module.scss'
|
import styles from './Article.module.scss'
|
||||||
|
|
||||||
|
@ -60,6 +57,7 @@ export type ArticlePageSearchParams = {
|
||||||
|
|
||||||
const scrollTo = (el: HTMLElement) => {
|
const scrollTo = (el: HTMLElement) => {
|
||||||
const { top } = el.getBoundingClientRect()
|
const { top } = el.getBoundingClientRect()
|
||||||
|
if (window)
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: top + window.scrollY - DEFAULT_HEADER_OFFSET,
|
top: top + window.scrollY - DEFAULT_HEADER_OFFSET,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
@ -70,39 +68,41 @@ const scrollTo = (el: HTMLElement) => {
|
||||||
const imgSrcRegExp = /<img[^>]+src\s*=\s*["']([^"']+)["']/gi
|
const imgSrcRegExp = /<img[^>]+src\s*=\s*["']([^"']+)["']/gi
|
||||||
|
|
||||||
export const FullArticle = (props: Props) => {
|
export const FullArticle = (props: Props) => {
|
||||||
const { searchParams, changeSearchParams } = useRouter<ArticlePageSearchParams>()
|
const [searchParams, changeSearchParams] = useSearchParams<ArticlePageSearchParams>()
|
||||||
|
const { showModal } = useUI()
|
||||||
const { loadReactionsBy } = useReactions()
|
const { loadReactionsBy } = useReactions()
|
||||||
const [selectedImage, setSelectedImage] = createSignal('')
|
const [selectedImage, setSelectedImage] = createSignal('')
|
||||||
const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false)
|
const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false)
|
||||||
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
|
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
|
||||||
const { t, formatDate, lang } = useLocalize()
|
const { t, formatDate, lang } = useLocalize()
|
||||||
const { author, session, requireAuthentication } = useSession()
|
const { session, requireAuthentication } = useSession()
|
||||||
const { addSeen } = useSeen()
|
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
||||||
|
const { addSeen } = useFeed()
|
||||||
|
|
||||||
const formattedDate = createMemo(() => formatDate(new Date(props.article.published_at * 1000)))
|
const formattedDate = createMemo(() => formatDate(new Date((props.article?.published_at || 0) * 1000)))
|
||||||
|
|
||||||
const canEdit = createMemo(
|
const canEdit = createMemo(
|
||||||
() =>
|
() =>
|
||||||
Boolean(author()?.id) &&
|
Boolean(author()?.id) &&
|
||||||
(props.article?.authors?.some((a) => Boolean(a) && a?.id === author().id) ||
|
(props.article?.authors?.some((a) => Boolean(a) && a?.id === author().id) ||
|
||||||
props.article?.created_by?.id === author().id ||
|
props.article?.created_by?.id === author().id ||
|
||||||
session()?.user?.roles.includes('editor')),
|
session()?.user?.roles?.includes('editor')),
|
||||||
)
|
)
|
||||||
|
|
||||||
const mainTopic = createMemo(() => {
|
const mainTopic = createMemo(() => {
|
||||||
const mainTopicSlug = props.article.topics.length > 0 ? props.article.main_topic : null
|
const mainTopicSlug = (props.article?.topics?.length || 0) > 0 ? props.article.main_topic : null
|
||||||
const mt = props.article.topics.find((tpc: Topic) => tpc.slug === mainTopicSlug)
|
const mt = props.article.topics?.find((tpc: Maybe<Topic>) => tpc?.slug === mainTopicSlug)
|
||||||
if (mt) {
|
if (mt) {
|
||||||
mt.title = lang() === 'en' ? capitalize(mt.slug.replace(/-/, ' ')) : mt.title
|
mt.title = lang() === 'en' ? capitalize(mt.slug.replace(/-/, ' ')) : mt.title
|
||||||
return mt
|
return mt
|
||||||
}
|
}
|
||||||
return props.article.topics[0]
|
return props.article?.topics?.[0]
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleBookmarkButtonClick = (ev) => {
|
const handleBookmarkButtonClick = (ev: MouseEvent | undefined) => {
|
||||||
requireAuthentication(() => {
|
requireAuthentication(() => {
|
||||||
// TODO: implement bookmark clicked
|
// TODO: implement bookmark clicked
|
||||||
ev.preventDefault()
|
ev?.preventDefault()
|
||||||
}, 'bookmark')
|
}, 'bookmark')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -129,10 +129,11 @@ export const FullArticle = (props: Props) => {
|
||||||
|
|
||||||
if (isServer) {
|
if (isServer) {
|
||||||
const result: string[] = []
|
const result: string[] = []
|
||||||
let match: RegExpMatchArray
|
let match: RegExpMatchArray | null
|
||||||
|
|
||||||
while ((match = imgSrcRegExp.exec(body())) !== null) {
|
while ((match = imgSrcRegExp.exec(body())) !== null) {
|
||||||
result.push(match[1])
|
if (match) result.push(match[1])
|
||||||
|
else break
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
@ -150,14 +151,12 @@ export const FullArticle = (props: Props) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const commentsRef: {
|
let commentsRef: HTMLDivElement | undefined
|
||||||
current: HTMLDivElement
|
|
||||||
} = { current: null }
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (searchParams().commentId && isReactionsLoaded()) {
|
if (searchParams?.commentId && isReactionsLoaded()) {
|
||||||
const commentElement = document.querySelector<HTMLElement>(
|
const commentElement = document.querySelector<HTMLElement>(
|
||||||
`[id='comment_${searchParams().commentId}']`,
|
`[id='comment_${searchParams?.commentId}']`,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (commentElement) {
|
if (commentElement) {
|
||||||
|
@ -166,8 +165,8 @@ export const FullArticle = (props: Props) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const clickHandlers = []
|
const clickHandlers: { element: HTMLElement; handler: () => void }[] = []
|
||||||
const documentClickHandlers = []
|
const documentClickHandlers: ((e: MouseEvent) => void)[] = []
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!body()) {
|
if (!body()) {
|
||||||
|
@ -185,7 +184,7 @@ export const FullArticle = (props: Props) => {
|
||||||
tooltip.classList.add(styles.tooltip)
|
tooltip.classList.add(styles.tooltip)
|
||||||
const tooltipContent = document.createElement('div')
|
const tooltipContent = document.createElement('div')
|
||||||
tooltipContent.classList.add(styles.tooltipContent)
|
tooltipContent.classList.add(styles.tooltipContent)
|
||||||
tooltipContent.innerHTML = element.dataset.originalTitle || element.dataset.value
|
tooltipContent.innerHTML = element.dataset.originalTitle || element.dataset.value || ''
|
||||||
|
|
||||||
tooltip.append(tooltipContent)
|
tooltip.append(tooltipContent)
|
||||||
|
|
||||||
|
@ -229,7 +228,7 @@ export const FullArticle = (props: Props) => {
|
||||||
popperInstance.update()
|
popperInstance.update()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDocumentClick = (e) => {
|
const handleDocumentClick = (e: MouseEvent) => {
|
||||||
if (isTooltipVisible && e.target !== element && e.target !== tooltip) {
|
if (isTooltipVisible && e.target !== element && e.target !== tooltip) {
|
||||||
tooltip.style.visibility = 'hidden'
|
tooltip.style.visibility = 'hidden'
|
||||||
isTooltipVisible = false
|
isTooltipVisible = false
|
||||||
|
@ -253,14 +252,15 @@ export const FullArticle = (props: Props) => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const openLightbox = (image) => {
|
const openLightbox = (image: string) => {
|
||||||
setSelectedImage(image)
|
setSelectedImage(image)
|
||||||
}
|
}
|
||||||
const handleLightboxClose = () => {
|
const handleLightboxClose = () => {
|
||||||
setSelectedImage()
|
setSelectedImage('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleArticleBodyClick = (event) => {
|
// biome-ignore lint/suspicious/noExplicitAny: FIXME: typing
|
||||||
|
const handleArticleBodyClick = (event: any) => {
|
||||||
if (event.target.tagName === 'IMG' && !event.target.dataset.disableLightbox) {
|
if (event.target.tagName === 'IMG' && !event.target.dataset.disableLightbox) {
|
||||||
const src = event.target.src
|
const src = event.target.src
|
||||||
openLightbox(getImageUrl(src))
|
openLightbox(getImageUrl(src))
|
||||||
|
@ -268,12 +268,12 @@ export const FullArticle = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check iframes size
|
// Check iframes size
|
||||||
const articleContainer: { current: HTMLElement } = { current: null }
|
let articleContainer: HTMLElement | undefined
|
||||||
const updateIframeSizes = () => {
|
const updateIframeSizes = () => {
|
||||||
if (!(articleContainer?.current && props.article.body)) return
|
if (!(articleContainer && props.article.body && window)) return
|
||||||
const iframes = articleContainer?.current?.querySelectorAll('iframe')
|
const iframes = articleContainer?.querySelectorAll('iframe')
|
||||||
if (!iframes) return
|
if (!iframes) return
|
||||||
const containerWidth = articleContainer.current?.offsetWidth
|
const containerWidth = articleContainer?.offsetWidth
|
||||||
iframes.forEach((iframe) => {
|
iframes.forEach((iframe) => {
|
||||||
const style = window.getComputedStyle(iframe)
|
const style = window.getComputedStyle(iframe)
|
||||||
const originalWidth = iframe.getAttribute('width') || style.width.replace('px', '')
|
const originalWidth = iframe.getAttribute('width') || style.width.replace('px', '')
|
||||||
|
@ -302,7 +302,7 @@ export const FullArticle = (props: Props) => {
|
||||||
)
|
)
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
install('G-LQ4B87H8C2')
|
// install('G-LQ4B87H8C2')
|
||||||
await loadReactionsBy({ by: { shout: props.article.slug } })
|
await loadReactionsBy({ by: { shout: props.article.slug } })
|
||||||
addSeen(props.article.slug)
|
addSeen(props.article.slug)
|
||||||
setIsReactionsLoaded(true)
|
setIsReactionsLoaded(true)
|
||||||
|
@ -312,15 +312,15 @@ export const FullArticle = (props: Props) => {
|
||||||
onCleanup(() => window.removeEventListener('resize', updateIframeSizes))
|
onCleanup(() => window.removeEventListener('resize', updateIframeSizes))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.scrollToComments) {
|
if (props.scrollToComments && commentsRef) {
|
||||||
scrollTo(commentsRef.current)
|
scrollTo(commentsRef)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (searchParams()?.scrollTo === 'comments' && commentsRef.current) {
|
if (searchParams?.scrollTo === 'comments' && commentsRef) {
|
||||||
requestAnimationFrame(() => scrollTo(commentsRef.current))
|
requestAnimationFrame(() => commentsRef && scrollTo(commentsRef))
|
||||||
changeSearchParams({ scrollTo: null })
|
changeSearchParams({ scrollTo: undefined })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -329,7 +329,7 @@ export const FullArticle = (props: Props) => {
|
||||||
const ogImage = getOpenGraphImageUrl(cover, {
|
const ogImage = getOpenGraphImageUrl(cover, {
|
||||||
title: props.article.title,
|
title: props.article.title,
|
||||||
topic: mainTopic()?.title || '',
|
topic: mainTopic()?.title || '',
|
||||||
author: props.article?.authors[0]?.name || '',
|
author: props.article?.authors?.[0]?.name || '',
|
||||||
width: 1200,
|
width: 1200,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -338,7 +338,7 @@ export const FullArticle = (props: Props) => {
|
||||||
const keywords = getKeywords(props.article)
|
const keywords = getKeywords(props.article)
|
||||||
const shareUrl = getShareUrl({ pathname: `/${props.article.slug}` })
|
const shareUrl = getShareUrl({ pathname: `/${props.article.slug}` })
|
||||||
const getAuthorName = (a: Author) => {
|
const getAuthorName = (a: Author) => {
|
||||||
return lang() === 'en' && isCyrillic(a.name) ? capitalize(a.slug.replace(/-/, ' ')) : a.name
|
return lang() === 'en' && isCyrillic(a.name || '') ? capitalize(a.slug.replace(/-/, ' ')) : a.name
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -357,7 +357,7 @@ export const FullArticle = (props: Props) => {
|
||||||
<div class="wide-container">
|
<div class="wide-container">
|
||||||
<div class="row position-relative">
|
<div class="row position-relative">
|
||||||
<article
|
<article
|
||||||
ref={(el) => (articleContainer.current = el)}
|
ref={(el) => (articleContainer = el)}
|
||||||
class={clsx('col-md-16 col-lg-14 col-xl-12 offset-md-5', styles.articleContent)}
|
class={clsx('col-md-16 col-lg-14 col-xl-12 offset-md-5', styles.articleContent)}
|
||||||
onClick={handleArticleBodyClick}
|
onClick={handleArticleBodyClick}
|
||||||
>
|
>
|
||||||
|
@ -365,7 +365,7 @@ export const FullArticle = (props: Props) => {
|
||||||
<Show when={props.article.layout !== 'audio'}>
|
<Show when={props.article.layout !== 'audio'}>
|
||||||
<div class={styles.shoutHeader}>
|
<div class={styles.shoutHeader}>
|
||||||
<Show when={mainTopic()}>
|
<Show when={mainTopic()}>
|
||||||
<CardTopic title={mainTopic().title} slug={mainTopic().slug} />
|
<CardTopic title={mainTopic()?.title || ''} slug={mainTopic()?.slug || ''} />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<h1>{props.article.title}</h1>
|
<h1>{props.article.title}</h1>
|
||||||
|
@ -375,10 +375,10 @@ export const FullArticle = (props: Props) => {
|
||||||
|
|
||||||
<div class={styles.shoutAuthor}>
|
<div class={styles.shoutAuthor}>
|
||||||
<For each={props.article.authors}>
|
<For each={props.article.authors}>
|
||||||
{(a: Author, index) => (
|
{(a: Maybe<Author>, index: () => number) => (
|
||||||
<>
|
<>
|
||||||
<Show when={index() > 0}>, </Show>
|
<Show when={index() > 0}>, </Show>
|
||||||
<a href={getPagePath(router, 'author', { slug: a.slug })}>{getAuthorName(a)}</a>
|
<A href={`/author/${a?.slug}`}>{a && getAuthorName(a)}</A>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
@ -391,21 +391,25 @@ export const FullArticle = (props: Props) => {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<figure class="img-align-column">
|
<figure class="img-align-column">
|
||||||
<Image width={800} alt={props.article.cover_caption} src={props.article.cover} />
|
<Image
|
||||||
<figcaption innerHTML={props.article.cover_caption} />
|
width={800}
|
||||||
|
alt={props.article.cover_caption || ''}
|
||||||
|
src={props.article.cover || ''}
|
||||||
|
/>
|
||||||
|
<figcaption innerHTML={props.article.cover_caption || ''} />
|
||||||
</figure>
|
</figure>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.article.lead}>
|
<Show when={props.article.lead}>
|
||||||
<section class={styles.lead} innerHTML={props.article.lead} />
|
<section class={styles.lead} innerHTML={props.article.lead || ''} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.article.layout === 'audio'}>
|
<Show when={props.article.layout === 'audio'}>
|
||||||
<AudioHeader
|
<AudioHeader
|
||||||
title={props.article.title}
|
title={props.article.title}
|
||||||
cover={props.article.cover}
|
cover={props.article.cover || ''}
|
||||||
artistData={media()?.[0]}
|
artistData={media()?.[0]}
|
||||||
topic={mainTopic()}
|
topic={mainTopic() as Topic}
|
||||||
/>
|
/>
|
||||||
<Show when={media().length > 0}>
|
<Show when={media().length > 0}>
|
||||||
<div class="media-items">
|
<div class="media-items">
|
||||||
|
@ -467,11 +471,11 @@ export const FullArticle = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Popover content={t('Comment')} disabled={isActionPopupActive()}>
|
<Popover content={t('Comment')} disabled={isActionPopupActive()}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.shoutStatsItem)}
|
class={clsx(styles.shoutStatsItem)}
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
onClick={() => scrollTo(commentsRef.current)}
|
onClick={() => commentsRef && scrollTo(commentsRef)}
|
||||||
>
|
>
|
||||||
<Icon name="comment" class={styles.icon} />
|
<Icon name="comment" class={styles.icon} />
|
||||||
<Icon name="comment-hover" class={clsx(styles.icon, styles.iconHover)} />
|
<Icon name="comment-hover" class={clsx(styles.icon, styles.iconHover)} />
|
||||||
|
@ -487,7 +491,7 @@ export const FullArticle = (props: Props) => {
|
||||||
|
|
||||||
<Show when={props.article.stat?.viewed}>
|
<Show when={props.article.stat?.viewed}>
|
||||||
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemViews)}>
|
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemViews)}>
|
||||||
{t('some views', { count: props.article.stat?.viewed })}
|
{t('some views', { count: props.article.stat?.viewed || 0 })}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
@ -498,7 +502,7 @@ export const FullArticle = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Popover content={t('Add to bookmarks')} disabled={isActionPopupActive()}>
|
<Popover content={t('Add to bookmarks')} disabled={isActionPopupActive()}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.shoutStatsItem, styles.shoutStatsItemBookmarks)}
|
class={clsx(styles.shoutStatsItem, styles.shoutStatsItemBookmarks)}
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
|
@ -513,12 +517,12 @@ export const FullArticle = (props: Props) => {
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
<Popover content={t('Share')} disabled={isActionPopupActive()}>
|
<Popover content={t('Share')} disabled={isActionPopupActive()}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<div class={styles.shoutStatsItem} ref={triggerRef}>
|
<div class={styles.shoutStatsItem} ref={triggerRef}>
|
||||||
<SharePopup
|
<SharePopup
|
||||||
title={props.article.title}
|
title={props.article.title}
|
||||||
description={description}
|
description={description}
|
||||||
imageUrl={props.article.cover}
|
imageUrl={props.article.cover || ''}
|
||||||
shareUrl={shareUrl}
|
shareUrl={shareUrl}
|
||||||
containerCssClass={stylesHeader.control}
|
containerCssClass={stylesHeader.control}
|
||||||
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
|
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
|
||||||
|
@ -535,22 +539,19 @@ export const FullArticle = (props: Props) => {
|
||||||
|
|
||||||
<Show when={canEdit()}>
|
<Show when={canEdit()}>
|
||||||
<Popover content={t('Edit')}>
|
<Popover content={t('Edit')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<div class={styles.shoutStatsItem} ref={triggerRef}>
|
<div class={styles.shoutStatsItem} ref={triggerRef}>
|
||||||
<a
|
<A href={`/edit/${props.article?.id}`} class={styles.shoutStatsItemInner}>
|
||||||
href={getPagePath(router, 'edit', { shoutId: props.article?.id.toString() })}
|
|
||||||
class={styles.shoutStatsItemInner}
|
|
||||||
>
|
|
||||||
<Icon name="pencil-outline" class={styles.icon} />
|
<Icon name="pencil-outline" class={styles.icon} />
|
||||||
<Icon name="pencil-outline-hover" class={clsx(styles.icon, styles.iconHover)} />
|
<Icon name="pencil-outline-hover" class={clsx(styles.icon, styles.iconHover)} />
|
||||||
</a>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<FeedArticlePopup
|
<FeedArticlePopup
|
||||||
canEdit={canEdit()}
|
canEdit={Boolean(canEdit())}
|
||||||
containerCssClass={clsx(stylesHeader.control, styles.articlePopupOpener)}
|
containerCssClass={clsx(stylesHeader.control, styles.articlePopupOpener)}
|
||||||
onShareClick={() => showModal('share')}
|
onShareClick={() => showModal('share')}
|
||||||
onInviteClick={() => showModal('inviteMembers')}
|
onInviteClick={() => showModal('inviteMembers')}
|
||||||
|
@ -575,14 +576,14 @@ export const FullArticle = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.article.topics.length}>
|
<Show when={props.article.topics?.length}>
|
||||||
<div class={styles.topicsList}>
|
<div class={styles.topicsList}>
|
||||||
<For each={props.article.topics}>
|
<For each={props.article.topics}>
|
||||||
{(topic) => (
|
{(topic) => (
|
||||||
<div class={styles.shoutTopic}>
|
<div class={styles.shoutTopic}>
|
||||||
<a href={getPagePath(router, 'topic', { slug: topic.slug })}>
|
<A href={`/topic/${topic?.slug || ''}`}>
|
||||||
{lang() === 'en' ? capitalize(topic.slug) : topic.title}
|
{lang() === 'en' ? capitalize(topic?.slug || '') : topic?.title || ''}
|
||||||
</a>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
@ -590,23 +591,23 @@ export const FullArticle = (props: Props) => {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class={styles.shoutAuthorsList}>
|
<div class={styles.shoutAuthorsList}>
|
||||||
<Show when={props.article.authors.length > 1}>
|
<Show when={(props.article.authors?.length || 0) > 1}>
|
||||||
<h4>{t('Authors')}</h4>
|
<h4>{t('Authors')}</h4>
|
||||||
</Show>
|
</Show>
|
||||||
<For each={props.article.authors}>
|
<For each={props.article.authors}>
|
||||||
{(a: Author) => (
|
{(a: Maybe<Author>) => (
|
||||||
<div class="col-xl-12">
|
<div class="col-xl-12">
|
||||||
<AuthorBadge iconButtons={true} showMessageButton={true} author={a} />
|
<AuthorBadge iconButtons={true} showMessageButton={true} author={a as Author} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
<div id="comments" ref={(el) => (commentsRef.current = el)}>
|
<div id="comments" ref={(el) => (commentsRef = el)}>
|
||||||
<Show when={isReactionsLoaded()}>
|
<Show when={isReactionsLoaded()}>
|
||||||
<CommentsTree
|
<CommentsTree
|
||||||
shoutId={props.article.id}
|
shoutId={props.article.id}
|
||||||
shoutSlug={props.article.slug}
|
shoutSlug={props.article.slug}
|
||||||
articleAuthors={props.article.authors}
|
articleAuthors={props.article.authors as Author[]}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
@ -622,7 +623,7 @@ export const FullArticle = (props: Props) => {
|
||||||
<ShareModal
|
<ShareModal
|
||||||
title={props.article.title}
|
title={props.article.title}
|
||||||
description={description}
|
description={description}
|
||||||
imageUrl={props.article.cover}
|
imageUrl={props.article.cover || ''}
|
||||||
shareUrl={shareUrl}
|
shareUrl={shareUrl}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show, createMemo, createSignal } from 'solid-js'
|
import { Show, createMemo, createSignal } from 'solid-js'
|
||||||
|
import { useFeed } from '~/context/feed'
|
||||||
|
import type { Author } from '~/graphql/schema/core.gen'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { useReactions } from '../../context/reactions'
|
import { useReactions } from '../../context/reactions'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
import { ReactionKind, Shout } from '../../graphql/schema/core.gen'
|
import { ReactionKind, Shout } from '../../graphql/schema/core.gen'
|
||||||
import { loadShout } from '../../stores/zine/articles'
|
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import { Popup } from '../_shared/Popup'
|
import { Popup } from '../_shared/Popup'
|
||||||
import { VotersList } from '../_shared/VotersList'
|
import { VotersList } from '../_shared/VotersList'
|
||||||
|
@ -19,7 +19,9 @@ interface ShoutRatingControlProps {
|
||||||
|
|
||||||
export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
|
export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { author, requireAuthentication } = useSession()
|
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 { reactionEntities, createReaction, deleteReaction, loadReactionsBy } = useReactions()
|
||||||
const [isLoading, setIsLoading] = createSignal(false)
|
const [isLoading, setIsLoading] = createSignal(false)
|
||||||
|
|
||||||
|
@ -49,7 +51,7 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
|
||||||
r.shout.id === props.shout.id &&
|
r.shout.id === props.shout.id &&
|
||||||
!r.reply_to,
|
!r.reply_to,
|
||||||
)
|
)
|
||||||
return deleteReaction(reactionToDelete.id)
|
if (reactionToDelete) return deleteReaction(reactionToDelete.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRatingChange = (isUpvote: boolean) => {
|
const handleRatingChange = (isUpvote: boolean) => {
|
||||||
|
@ -61,8 +63,10 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
|
||||||
await deleteShoutReaction(ReactionKind.Dislike)
|
await deleteShoutReaction(ReactionKind.Dislike)
|
||||||
} else {
|
} else {
|
||||||
await createReaction({
|
await createReaction({
|
||||||
|
reaction: {
|
||||||
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
|
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
|
||||||
shout: props.shout.id,
|
shout: props.shout.id,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,7 +87,10 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Popup trigger={<span class={styles.ratingValue}>{props.shout.stat.rating}</span>} variant="tiny">
|
<Popup
|
||||||
|
trigger={<span class={styles.ratingValue}>{props.shout.stat?.rating || 0}</span>}
|
||||||
|
variant="tiny"
|
||||||
|
>
|
||||||
<VotersList
|
<VotersList
|
||||||
reactions={shoutRatingReactions()}
|
reactions={shoutRatingReactions()}
|
||||||
fallbackMessage={t('This post has not been rated yet')}
|
fallbackMessage={t('This post has not been rated yet')}
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
import { JSX, Show, createEffect } from 'solid-js'
|
import { useSearchParams } from '@solidjs/router'
|
||||||
|
import { JSX, Show, createEffect, createMemo, on } from 'solid-js'
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
import { RootSearchParams } from '../../pages/types'
|
|
||||||
import { useRouter } from '../../stores/router'
|
|
||||||
import { hideModal } from '../../stores/ui'
|
|
||||||
import { AuthModalSearchParams } from '../Nav/AuthModal/types'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: JSX.Element
|
children: JSX.Element
|
||||||
|
@ -12,15 +9,18 @@ type Props = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthGuard = (props: Props) => {
|
export const AuthGuard = (props: Props) => {
|
||||||
const { author, isSessionLoaded } = useSession()
|
const { session } = useSession()
|
||||||
const { changeSearchParams } = useRouter<RootSearchParams & AuthModalSearchParams>()
|
const author = createMemo<number>(() => session()?.user?.app_data?.profile?.id || 0)
|
||||||
|
const [, changeSearchParams] = useSearchParams()
|
||||||
|
const { hideModal } = useUI()
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(
|
||||||
if (props.disabled) {
|
on(
|
||||||
return
|
[() => props.disabled, author],
|
||||||
}
|
([disabled, a]) => {
|
||||||
if (isSessionLoaded()) {
|
if (disabled || !a) return
|
||||||
if (author()?.id) {
|
if (a) {
|
||||||
|
console.debug('[AuthGuard] profile is loaded')
|
||||||
hideModal()
|
hideModal()
|
||||||
} else {
|
} else {
|
||||||
changeSearchParams(
|
changeSearchParams(
|
||||||
|
@ -28,14 +28,13 @@ export const AuthGuard = (props: Props) => {
|
||||||
source: 'authguard',
|
source: 'authguard',
|
||||||
m: 'auth',
|
m: 'auth',
|
||||||
},
|
},
|
||||||
true,
|
{ replace: true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
},
|
||||||
// await loadSession()
|
{ defer: true },
|
||||||
console.warn('session is not loaded')
|
),
|
||||||
}
|
)
|
||||||
})
|
|
||||||
|
|
||||||
return <Show when={(isSessionLoaded() && author()?.id) || props.disabled}>{props.children}</Show>
|
return <Show when={author() || props.disabled}>{props.children}</Show>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,12 @@
|
||||||
import { openPage } from '@nanostores/router'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Match, Show, Switch, createEffect, createMemo, createSignal, on } from 'solid-js'
|
import { Match, Show, Switch, createEffect, createMemo, createSignal, on } from 'solid-js'
|
||||||
|
|
||||||
|
import { useNavigate, useSearchParams } from '@solidjs/router'
|
||||||
|
import { mediaMatches } from '~/utils/media-query'
|
||||||
import { useFollowing } from '../../../context/following'
|
import { useFollowing } from '../../../context/following'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { useMediaQuery } from '../../../context/mediaQuery'
|
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '../../../context/session'
|
||||||
import { Author, FollowingEntity } from '../../../graphql/schema/core.gen'
|
import { Author, FollowingEntity } from '../../../graphql/schema/core.gen'
|
||||||
import { router, useRouter } from '../../../stores/router'
|
|
||||||
import { translit } from '../../../utils/ru2en'
|
import { translit } from '../../../utils/ru2en'
|
||||||
import { isCyrillic } from '../../../utils/translate'
|
import { isCyrillic } from '../../../utils/translate'
|
||||||
import { Button } from '../../_shared/Button'
|
import { Button } from '../../_shared/Button'
|
||||||
|
@ -30,31 +29,34 @@ type Props = {
|
||||||
subscriptionsMode?: boolean
|
subscriptionsMode?: boolean
|
||||||
}
|
}
|
||||||
export const AuthorBadge = (props: Props) => {
|
export const AuthorBadge = (props: Props) => {
|
||||||
const { mediaMatches } = useMediaQuery()
|
const { session, requireAuthentication } = useSession()
|
||||||
const { author, requireAuthentication } = useSession()
|
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
||||||
const { follow, unfollow, follows, following } = useFollowing()
|
const { follow, unfollow, follows, following } = useFollowing()
|
||||||
const [isMobileView, setIsMobileView] = createSignal(false)
|
const [isMobileView, setIsMobileView] = createSignal(false)
|
||||||
const [isFollowed, setIsFollowed] = createSignal<boolean>(
|
const [isFollowed, setIsFollowed] = createSignal<boolean>(
|
||||||
follows?.authors?.some((authorEntity) => authorEntity.id === props.author?.id),
|
Boolean(follows?.authors?.some((authorEntity) => Boolean(authorEntity.id === props.author?.id))),
|
||||||
)
|
)
|
||||||
createEffect(() => setIsMobileView(!mediaMatches.sm))
|
createEffect(() => setIsMobileView(!mediaMatches.sm))
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
[() => follows?.authors, () => props.author, following],
|
[() => follows?.authors, () => props.author, following],
|
||||||
([followingAuthors, currentAuthor, _]) => {
|
([followingAuthors, currentAuthor, _]) => {
|
||||||
setIsFollowed(followingAuthors?.some((followedAuthor) => followedAuthor.id === currentAuthor?.id))
|
setIsFollowed(
|
||||||
|
Boolean(followingAuthors?.some((followedAuthor) => followedAuthor.id === currentAuthor?.id)),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const { changeSearchParams } = useRouter()
|
const [, changeSearchParams] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
const { t, formatDate, lang } = useLocalize()
|
const { t, formatDate, lang } = useLocalize()
|
||||||
|
|
||||||
const initChat = () => {
|
const initChat = () => {
|
||||||
// eslint-disable-next-line solid/reactivity
|
// eslint-disable-next-line solid/reactivity
|
||||||
requireAuthentication(() => {
|
requireAuthentication(() => {
|
||||||
openPage(router, 'inbox')
|
navigate('/inbox')
|
||||||
changeSearchParams({
|
changeSearchParams({
|
||||||
initChat: props.author?.id.toString(),
|
initChat: props.author?.id.toString(),
|
||||||
})
|
})
|
||||||
|
@ -62,12 +64,12 @@ export const AuthorBadge = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = createMemo(() => {
|
const name = createMemo(() => {
|
||||||
if (lang() !== 'ru' && isCyrillic(props.author.name)) {
|
if (lang() !== 'ru' && isCyrillic(props.author.name || '')) {
|
||||||
if (props.author.name === 'Дискурс') {
|
if (props.author.name === 'Дискурс') {
|
||||||
return 'Discours'
|
return 'Discours'
|
||||||
}
|
}
|
||||||
|
|
||||||
return translit(props.author.name)
|
return translit(props.author.name || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
return props.author.name
|
return props.author.name
|
||||||
|
@ -86,8 +88,8 @@ export const AuthorBadge = (props: Props) => {
|
||||||
<Userpic
|
<Userpic
|
||||||
hasLink={true}
|
hasLink={true}
|
||||||
size={isMobileView() ? 'M' : 'L'}
|
size={isMobileView() ? 'M' : 'L'}
|
||||||
name={name()}
|
name={name() || ''}
|
||||||
userpic={props.author.pic}
|
userpic={props.author.pic || ''}
|
||||||
slug={props.author.slug}
|
slug={props.author.slug}
|
||||||
/>
|
/>
|
||||||
<ConditionalWrapper
|
<ConditionalWrapper
|
||||||
|
@ -106,24 +108,24 @@ export const AuthorBadge = (props: Props) => {
|
||||||
fallback={
|
fallback={
|
||||||
<div class={styles.bio}>
|
<div class={styles.bio}>
|
||||||
{t('Registered since {date}', {
|
{t('Registered since {date}', {
|
||||||
date: formatDate(new Date(props.author.created_at * 1000)),
|
date: formatDate(new Date((props.author.created_at || 0) * 1000)),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Match when={props.author.bio}>
|
<Match when={props.author.bio}>
|
||||||
<div class={clsx('text-truncate', styles.bio)} innerHTML={props.author.bio} />
|
<div class={clsx('text-truncate', styles.bio)} innerHTML={props.author.bio || ''} />
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
<Show when={props.author?.stat && !props.subscriptionsMode}>
|
<Show when={props.author?.stat && !props.subscriptionsMode}>
|
||||||
<div class={styles.bio}>
|
<div class={styles.bio}>
|
||||||
<Show when={props.author?.stat.shouts > 0}>
|
<Show when={(props.author?.stat?.shouts || 0) > 0}>
|
||||||
<div>{t('some posts', { count: props.author.stat?.shouts ?? 0 })}</div>
|
<div>{t('some posts', { count: props.author.stat?.shouts ?? 0 })}</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.author?.stat.comments > 0}>
|
<Show when={(props.author?.stat?.comments || 0) > 0}>
|
||||||
<div>{t('some comments', { count: props.author.stat?.comments ?? 0 })}</div>
|
<div>{t('some comments', { count: props.author.stat?.comments ?? 0 })}</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.author?.stat.followers > 0}>
|
<Show when={(props.author?.stat?.followers || 0) > 0}>
|
||||||
<div>{t('some followers', { count: props.author.stat?.followers ?? 0 })}</div>
|
<div>{t('some followers', { count: props.author.stat?.followers ?? 0 })}</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
@ -136,7 +138,7 @@ export const AuthorBadge = (props: Props) => {
|
||||||
<FollowingButton
|
<FollowingButton
|
||||||
action={handleFollowClick}
|
action={handleFollowClick}
|
||||||
isFollowed={isFollowed()}
|
isFollowed={isFollowed()}
|
||||||
actionMessageType={following()?.slug === props.author.slug ? following().type : undefined}
|
actionMessageType={following()?.slug === props.author.slug ? following()?.type : undefined}
|
||||||
/>
|
/>
|
||||||
<Show when={props.showMessageButton}>
|
<Show when={props.showMessageButton}>
|
||||||
<Button
|
<Button
|
||||||
|
@ -152,8 +154,8 @@ export const AuthorBadge = (props: Props) => {
|
||||||
<Show when={props.inviteView}>
|
<Show when={props.inviteView}>
|
||||||
<CheckButton
|
<CheckButton
|
||||||
text={t('Invite')}
|
text={t('Invite')}
|
||||||
checked={props.selected}
|
checked={Boolean(props.selected)}
|
||||||
onClick={() => props.onInvite(props.author.id)}
|
onClick={() => props.onInvite?.(props.author.id)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -175,7 +175,7 @@
|
||||||
width: 24px;
|
width: 24px;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-default.svg);
|
background-image: url('/icons/user-link-default.svg');
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-position: 50% 50%;
|
background-position: 50% 50%;
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
|
@ -209,7 +209,7 @@
|
||||||
|
|
||||||
&[href*='facebook.com/'] {
|
&[href*='facebook.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-facebook.svg);
|
background-image: url('/icons/user-link-facebook.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -221,7 +221,7 @@
|
||||||
|
|
||||||
&[href*='twitter.com/'] {
|
&[href*='twitter.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-twitter.svg);
|
background-image: url('/icons/user-link-twitter.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -234,7 +234,7 @@
|
||||||
&[href*='telegram.com/'],
|
&[href*='telegram.com/'],
|
||||||
&[href*='t.me/'] {
|
&[href*='t.me/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-telegram.svg);
|
background-image: url('/icons/user-link-telegram.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -247,7 +247,7 @@
|
||||||
&[href*='vk.cc/'],
|
&[href*='vk.cc/'],
|
||||||
&[href*='vk.com/'] {
|
&[href*='vk.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-vk.svg);
|
background-image: url('/icons/user-link-vk.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -259,7 +259,7 @@
|
||||||
|
|
||||||
&[href*='tumblr.com/'] {
|
&[href*='tumblr.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-tumblr.svg);
|
background-image: url('/icons/user-link-tumblr.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -271,7 +271,7 @@
|
||||||
|
|
||||||
&[href*='instagram.com/'] {
|
&[href*='instagram.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-instagram.svg);
|
background-image: url('/icons/user-link-instagram.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -283,7 +283,7 @@
|
||||||
|
|
||||||
&[href*='behance.net/'] {
|
&[href*='behance.net/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-behance.svg);
|
background-image: url('/icons/user-link-behance.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -295,7 +295,7 @@
|
||||||
|
|
||||||
&[href*='dribbble.com/'] {
|
&[href*='dribbble.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-dribbble.svg);
|
background-image: url('/icons/user-link-dribbble.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -307,7 +307,7 @@
|
||||||
|
|
||||||
&[href*='github.com/'] {
|
&[href*='github.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-github.svg);
|
background-image: url('/icons/user-link-github.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -319,7 +319,7 @@
|
||||||
|
|
||||||
&[href*='linkedin.com/'] {
|
&[href*='linkedin.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-linkedin.svg);
|
background-image: url('/icons/user-link-linkedin.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -331,7 +331,7 @@
|
||||||
|
|
||||||
&[href*='medium.com/'] {
|
&[href*='medium.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-medium.svg);
|
background-image: url('/icons/user-link-medium.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -343,7 +343,7 @@
|
||||||
|
|
||||||
&[href*='ok.ru/'] {
|
&[href*='ok.ru/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-ok.svg);
|
background-image: url('/icons/user-link-ok.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -355,7 +355,7 @@
|
||||||
|
|
||||||
&[href*='pinterest.com/'] {
|
&[href*='pinterest.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-pinterest.svg);
|
background-image: url('/icons/user-link-pinterest.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -367,7 +367,7 @@
|
||||||
|
|
||||||
&[href*='reddit.com/'] {
|
&[href*='reddit.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-reddit.svg);
|
background-image: url('/icons/user-link-reddit.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -379,7 +379,7 @@
|
||||||
|
|
||||||
&[href*='tiktok.com/'] {
|
&[href*='tiktok.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-tiktok.svg);
|
background-image: url('/icons/user-link-tiktok.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -392,7 +392,7 @@
|
||||||
&[href*='youtube.com/'],
|
&[href*='youtube.com/'],
|
||||||
&[href*='youtu.be/'] {
|
&[href*='youtu.be/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-youtube.svg);
|
background-image: url('/icons/user-link-youtube.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -404,7 +404,7 @@
|
||||||
|
|
||||||
&[href*='dzen.ru/'] {
|
&[href*='dzen.ru/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-dzen.svg);
|
background-image: url('/icons/user-link-dzen.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|
|
@ -1,15 +1,11 @@
|
||||||
import type { Author, Community } from '../../../graphql/schema/core.gen'
|
import type { Author, Community } from '../../../graphql/schema/core.gen'
|
||||||
|
|
||||||
import { openPage, redirectPage } from '@nanostores/router'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { For, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js'
|
import { For, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js'
|
||||||
|
import { FollowsFilter, useFollowing } from '../../../context/following'
|
||||||
import { useFollowing } from '../../../context/following'
|
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '../../../context/session'
|
||||||
import { FollowingEntity, Topic } from '../../../graphql/schema/core.gen'
|
import { FollowingEntity, Topic } from '../../../graphql/schema/core.gen'
|
||||||
import { FollowsFilter } from '../../../pages/types'
|
|
||||||
import { router, useRouter } from '../../../stores/router'
|
|
||||||
import { isAuthor } from '../../../utils/isAuthor'
|
import { isAuthor } from '../../../utils/isAuthor'
|
||||||
import { translit } from '../../../utils/ru2en'
|
import { translit } from '../../../utils/ru2en'
|
||||||
import { isCyrillic } from '../../../utils/translate'
|
import { isCyrillic } from '../../../utils/translate'
|
||||||
|
@ -22,6 +18,7 @@ import { ShowOnlyOnClient } from '../../_shared/ShowOnlyOnClient'
|
||||||
import { AuthorBadge } from '../AuthorBadge'
|
import { AuthorBadge } from '../AuthorBadge'
|
||||||
import { Userpic } from '../Userpic'
|
import { Userpic } from '../Userpic'
|
||||||
|
|
||||||
|
import { useNavigate, useSearchParams } from '@solidjs/router'
|
||||||
import stylesButton from '../../_shared/Button/Button.module.scss'
|
import stylesButton from '../../_shared/Button/Button.module.scss'
|
||||||
import styles from './AuthorCard.module.scss'
|
import styles from './AuthorCard.module.scss'
|
||||||
|
|
||||||
|
@ -30,9 +27,12 @@ type Props = {
|
||||||
followers?: Author[]
|
followers?: Author[]
|
||||||
flatFollows?: Array<Author | Topic>
|
flatFollows?: Array<Author | Topic>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthorCard = (props: Props) => {
|
export const AuthorCard = (props: Props) => {
|
||||||
const { t, lang } = useLocalize()
|
const { t, lang } = useLocalize()
|
||||||
const { author, isSessionLoaded, requireAuthentication } = useSession()
|
const navigate = useNavigate()
|
||||||
|
const { session, isSessionLoaded, requireAuthentication } = useSession()
|
||||||
|
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
||||||
const [authorSubs, setAuthorSubs] = createSignal<Array<Author | Topic | Community>>([])
|
const [authorSubs, setAuthorSubs] = createSignal<Array<Author | Topic | Community>>([])
|
||||||
const [followsFilter, setFollowsFilter] = createSignal<FollowsFilter>('all')
|
const [followsFilter, setFollowsFilter] = createSignal<FollowsFilter>('all')
|
||||||
const [isFollowed, setIsFollowed] = createSignal<boolean>()
|
const [isFollowed, setIsFollowed] = createSignal<boolean>()
|
||||||
|
@ -40,7 +40,7 @@ export const AuthorCard = (props: Props) => {
|
||||||
const { follow, unfollow, follows, following } = useFollowing()
|
const { follow, unfollow, follows, following } = useFollowing()
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
setAuthorSubs(props.flatFollows)
|
setAuthorSubs(props.flatFollows || [])
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
@ -50,21 +50,20 @@ export const AuthorCard = (props: Props) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const name = createMemo(() => {
|
const name = createMemo(() => {
|
||||||
if (lang() !== 'ru' && isCyrillic(props.author.name)) {
|
if (lang() !== 'ru' && isCyrillic(props.author?.name || '')) {
|
||||||
if (props.author.name === 'Дискурс') {
|
if (props.author.name === 'Дискурс') {
|
||||||
return 'Discours'
|
return 'Discours'
|
||||||
}
|
}
|
||||||
return translit(props.author.name)
|
return translit(props.author?.name || '')
|
||||||
}
|
}
|
||||||
return props.author.name
|
return props.author.name
|
||||||
})
|
})
|
||||||
|
|
||||||
// TODO: reimplement AuthorCard
|
const [, changeSearchParams] = useSearchParams()
|
||||||
const { changeSearchParams } = useRouter()
|
|
||||||
const initChat = () => {
|
const initChat = () => {
|
||||||
// eslint-disable-next-line solid/reactivity
|
// eslint-disable-next-line solid/reactivity
|
||||||
requireAuthentication(() => {
|
requireAuthentication(() => {
|
||||||
openPage(router, 'inbox')
|
navigate('/inbox')
|
||||||
changeSearchParams({
|
changeSearchParams({
|
||||||
initChat: props.author?.id.toString(),
|
initChat: props.author?.id.toString(),
|
||||||
})
|
})
|
||||||
|
@ -95,7 +94,7 @@ export const AuthorCard = (props: Props) => {
|
||||||
|
|
||||||
const followButtonText = createMemo(() => {
|
const followButtonText = createMemo(() => {
|
||||||
if (following()?.slug === props.author.slug) {
|
if (following()?.slug === props.author.slug) {
|
||||||
return following().type === 'follow' ? t('Following...') : t('Unfollowing...')
|
return following()?.type === 'follow' ? t('Following...') : t('Unfollowing...')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFollowed()) {
|
if (isFollowed()) {
|
||||||
|
@ -134,7 +133,7 @@ export const AuthorCard = (props: Props) => {
|
||||||
<button type="button" onClick={() => setFollowsFilter('all')}>
|
<button type="button" onClick={() => setFollowsFilter('all')}>
|
||||||
{t('All')}
|
{t('All')}
|
||||||
</button>
|
</button>
|
||||||
<span class="view-switcher__counter">{props.flatFollows.length}</span>
|
<span class="view-switcher__counter">{props.flatFollows?.length}</span>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
class={clsx({
|
class={clsx({
|
||||||
|
@ -144,7 +143,7 @@ export const AuthorCard = (props: Props) => {
|
||||||
<button type="button" onClick={() => setFollowsFilter('authors')}>
|
<button type="button" onClick={() => setFollowsFilter('authors')}>
|
||||||
{t('Authors')}
|
{t('Authors')}
|
||||||
</button>
|
</button>
|
||||||
<span class="view-switcher__counter">{props.flatFollows.filter((s) => 'name' in s).length}</span>
|
<span class="view-switcher__counter">{props.flatFollows?.filter((s) => 'name' in s).length}</span>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
class={clsx({
|
class={clsx({
|
||||||
|
@ -154,7 +153,9 @@ export const AuthorCard = (props: Props) => {
|
||||||
<button type="button" onClick={() => setFollowsFilter('topics')}>
|
<button type="button" onClick={() => setFollowsFilter('topics')}>
|
||||||
{t('Topics')}
|
{t('Topics')}
|
||||||
</button>
|
</button>
|
||||||
<span class="view-switcher__counter">{props.flatFollows.filter((s) => 'title' in s).length}</span>
|
<span class="view-switcher__counter">
|
||||||
|
{props.flatFollows?.filter((s) => 'title' in s).length}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<br />
|
<br />
|
||||||
|
@ -181,8 +182,8 @@ export const AuthorCard = (props: Props) => {
|
||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
<Userpic
|
<Userpic
|
||||||
size={'XL'}
|
size={'XL'}
|
||||||
name={props.author.name}
|
name={props.author.name || ''}
|
||||||
userpic={props.author.pic}
|
userpic={props.author.pic || ''}
|
||||||
slug={props.author.slug}
|
slug={props.author.slug}
|
||||||
class={styles.circlewrap}
|
class={styles.circlewrap}
|
||||||
/>
|
/>
|
||||||
|
@ -191,15 +192,15 @@ export const AuthorCard = (props: Props) => {
|
||||||
<div class={styles.authorDetailsWrapper}>
|
<div class={styles.authorDetailsWrapper}>
|
||||||
<div class={styles.authorName}>{name()}</div>
|
<div class={styles.authorName}>{name()}</div>
|
||||||
<Show when={props.author.bio}>
|
<Show when={props.author.bio}>
|
||||||
<div class={styles.authorAbout} innerHTML={props.author.bio} />
|
<div class={styles.authorAbout} innerHTML={props.author.bio || ''} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.followers?.length > 0 || props.flatFollows?.length > 0}>
|
<Show when={(props.followers || [])?.length > 0 || (props.flatFollows || []).length > 0}>
|
||||||
<div class={styles.subscribersContainer}>
|
<div class={styles.subscribersContainer}>
|
||||||
<FollowingCounters
|
<FollowingCounters
|
||||||
followers={props.followers}
|
followers={props.followers}
|
||||||
followersAmount={props.author?.stat?.followers}
|
followersAmount={props.author?.stat?.followers || 0}
|
||||||
following={props.flatFollows}
|
following={props.flatFollows}
|
||||||
followingAmount={props.flatFollows.length}
|
followingAmount={props.flatFollows?.length || 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
@ -209,15 +210,15 @@ export const AuthorCard = (props: Props) => {
|
||||||
<Show when={props.author.links && props.author.links.length > 0}>
|
<Show when={props.author.links && props.author.links.length > 0}>
|
||||||
<div class={styles.authorSubscribeSocial}>
|
<div class={styles.authorSubscribeSocial}>
|
||||||
<For each={props.author.links}>
|
<For each={props.author.links}>
|
||||||
{(link) => (
|
{(link: string | null) => (
|
||||||
<a
|
<a
|
||||||
class={styles.socialLink}
|
class={styles.socialLink}
|
||||||
href={link.startsWith('http') ? link : `https://${link}`}
|
href={link?.startsWith('http') ? link : `https://${link}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="nofollow noopener noreferrer"
|
rel="nofollow noopener noreferrer"
|
||||||
>
|
>
|
||||||
<span class={styles.authorSubscribeSocialLabel}>
|
<span class={styles.authorSubscribeSocialLabel}>
|
||||||
{link.startsWith('http') ? link : `https://${link}`}
|
{link?.startsWith('http') ? link : `https://${link}`}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
@ -251,7 +252,7 @@ export const AuthorCard = (props: Props) => {
|
||||||
<div class={styles.authorActions}>
|
<div class={styles.authorActions}>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => redirectPage(router, 'profileSettings')}
|
onClick={() => navigate('/profile/settings')}
|
||||||
value={
|
value={
|
||||||
<>
|
<>
|
||||||
<span class={styles.authorActionsLabel}>{t('Edit profile')}</span>
|
<span class={styles.authorActionsLabel}>{t('Edit profile')}</span>
|
||||||
|
@ -260,9 +261,9 @@ export const AuthorCard = (props: Props) => {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SharePopup
|
<SharePopup
|
||||||
title={props.author.name}
|
title={props.author.name || ''}
|
||||||
description={props.author.bio}
|
description={props.author.bio || ''}
|
||||||
imageUrl={props.author.pic}
|
imageUrl={props.author.pic || ''}
|
||||||
shareUrl={getShareUrl({
|
shareUrl={getShareUrl({
|
||||||
pathname: `/author/${props.author.slug}`,
|
pathname: `/author/${props.author.slug}`,
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -20,18 +20,18 @@ type Props = {
|
||||||
export const AuthorLink = (props: Props) => {
|
export const AuthorLink = (props: Props) => {
|
||||||
const { lang } = useLocalize()
|
const { lang } = useLocalize()
|
||||||
const name = createMemo(() => {
|
const name = createMemo(() => {
|
||||||
return lang() === 'en' && isCyrillic(props.author.name)
|
return lang() === 'en' && isCyrillic(props.author.name || '')
|
||||||
? translit(capitalize(props.author.name))
|
? translit(capitalize(props.author.name || ''))
|
||||||
: props.author.name
|
: props.author.name
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.AuthorLink, props.class, styles[props.size ?? 'M'], {
|
class={clsx(styles.AuthorLink, props.class, styles[(props.size ?? 'M') as keyof Props['size']], {
|
||||||
[styles.authorLinkFloorImportant]: props.isFloorImportant,
|
[styles.authorLinkFloorImportant]: props.isFloorImportant,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<a class={styles.link} href={`/author/${props.author.slug}`}>
|
<a class={styles.link} href={`/author/${props.author.slug}`}>
|
||||||
<Userpic size={props.size ?? 'M'} name={name()} userpic={props.author.pic} />
|
<Userpic size={props.size ?? 'M'} name={name() || ''} userpic={props.author.pic || ''} />
|
||||||
<div class={styles.name}>{name()}</div>
|
<div class={styles.name}>{name()}</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,9 +2,8 @@ import type { Author } from '../../graphql/schema/core.gen'
|
||||||
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show, createSignal } from 'solid-js'
|
import { Show, createSignal } from 'solid-js'
|
||||||
|
import { useGraphQL } from '~/context/graphql'
|
||||||
import { apiClient } from '../../graphql/client/core'
|
import rateAuthorMutation from '~/graphql/mutation/core/author-rate'
|
||||||
|
|
||||||
import styles from './AuthorRatingControl.module.scss'
|
import styles from './AuthorRatingControl.module.scss'
|
||||||
|
|
||||||
interface AuthorRatingControlProps {
|
interface AuthorRatingControlProps {
|
||||||
|
@ -20,11 +19,14 @@ export const AuthorRatingControl = (props: AuthorRatingControlProps) => {
|
||||||
console.log('handleRatingChange', { isUpvote })
|
console.log('handleRatingChange', { isUpvote })
|
||||||
if (props.author?.slug) {
|
if (props.author?.slug) {
|
||||||
const value = isUpvote ? 1 : -1
|
const value = isUpvote ? 1 : -1
|
||||||
await apiClient.rateAuthor({ rated_slug: props.author?.slug, value })
|
const _resp = await mutation(rateAuthorMutation, {
|
||||||
setRating((r) => r + value)
|
rated_slug: props.author?.slug,
|
||||||
|
value,
|
||||||
|
}).toPromise()
|
||||||
|
setRating((r) => (r || 0) + value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const { mutation } = useGraphQL()
|
||||||
const [rating, setRating] = createSignal(props.author?.stat?.rating)
|
const [rating, setRating] = createSignal(props.author?.stat?.rating)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -11,7 +11,7 @@ interface AuthorShoutsRating {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthorShoutsRating = (props: AuthorShoutsRating) => {
|
export const AuthorShoutsRating = (props: AuthorShoutsRating) => {
|
||||||
const isUpvoted = createMemo(() => props.author?.stat?.rating_shouts > 0)
|
const isUpvoted = createMemo(() => (props.author?.stat?.rating_shouts || 0) > 0)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.rating, props.class, {
|
class={clsx(styles.rating, props.class, {
|
||||||
|
|
|
@ -54,7 +54,7 @@ export const Userpic = (props: Props) => {
|
||||||
>
|
>
|
||||||
<Show when={!props.loading} fallback={<Loading />}>
|
<Show when={!props.loading} fallback={<Loading />}>
|
||||||
<ConditionalWrapper
|
<ConditionalWrapper
|
||||||
condition={props.hasLink}
|
condition={Boolean(props.hasLink)}
|
||||||
wrapper={(children) => <a href={`/author/${props.slug}`}>{children}</a>}
|
wrapper={(children) => <a href={`/author/${props.slug}`}>{children}</a>}
|
||||||
>
|
>
|
||||||
<Show keyed={true} when={props.userpic} fallback={<div class={styles.letters}>{letters()}</div>}>
|
<Show keyed={true} when={props.userpic} fallback={<div class={styles.letters}>{letters()}</div>}>
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { For, Show, createEffect, createSignal, on } from 'solid-js'
|
import { For, Show, createEffect, createSignal, on } from 'solid-js'
|
||||||
|
import { useAuthors } from '~/context/authors'
|
||||||
|
import { useGraphQL } from '~/context/graphql'
|
||||||
|
import loadAuthorsByQuery from '~/graphql/query/core/authors-load-by'
|
||||||
|
import { Author } from '~/graphql/schema/core.gen'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { apiClient } from '../../graphql/client/core'
|
|
||||||
import { setAuthorsByFollowers, setAuthorsByShouts, useAuthorsStore } from '../../stores/zine/authors'
|
|
||||||
import { AuthorBadge } from '../Author/AuthorBadge'
|
import { AuthorBadge } from '../Author/AuthorBadge'
|
||||||
import { InlineLoader } from '../InlineLoader'
|
import { InlineLoader } from '../InlineLoader'
|
||||||
import { Button } from '../_shared/Button'
|
import { Button } from '../_shared/Button'
|
||||||
|
@ -19,27 +21,33 @@ const PAGE_SIZE = 20
|
||||||
|
|
||||||
export const AuthorsList = (props: Props) => {
|
export const AuthorsList = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { authorsByShouts, authorsByFollowers } = useAuthorsStore()
|
const { addAuthors } = useAuthors()
|
||||||
|
const [authorsByShouts, setAuthorsByShouts] = createSignal<Author[]>()
|
||||||
|
const [authorsByFollowers, setAuthorsByFollowers] = createSignal<Author[]>()
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loading, setLoading] = createSignal(false)
|
||||||
const [currentPage, setCurrentPage] = createSignal({ shouts: 0, followers: 0 })
|
const [currentPage, setCurrentPage] = createSignal({ shouts: 0, followers: 0 })
|
||||||
const [allLoaded, setAllLoaded] = createSignal(false)
|
const [allLoaded, setAllLoaded] = createSignal(false)
|
||||||
|
const { query } = useGraphQL()
|
||||||
|
|
||||||
const fetchAuthors = async (queryType: Props['query'], page: number) => {
|
const fetchAuthors = async (queryType: Props['query'], page: number) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const offset = PAGE_SIZE * page
|
const offset = PAGE_SIZE * page
|
||||||
const result = await apiClient.loadAuthorsBy({
|
const resp = await query(loadAuthorsByQuery, {
|
||||||
by: { order: queryType },
|
by: { order: queryType },
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
offset,
|
offset,
|
||||||
})
|
})
|
||||||
|
const result = resp?.data?.load_authors_by
|
||||||
|
if ((result?.length || 0) > 0) {
|
||||||
|
addAuthors([...result])
|
||||||
if (queryType === 'shouts') {
|
if (queryType === 'shouts') {
|
||||||
setAuthorsByShouts((prev) => [...prev, ...result])
|
setAuthorsByShouts((prev) => [...(prev || []), ...result])
|
||||||
} else {
|
} else if (queryType === 'followers') {
|
||||||
setAuthorsByFollowers((prev) => [...prev, ...result])
|
setAuthorsByFollowers((prev) => [...(prev || []), ...result])
|
||||||
}
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadMoreAuthors = () => {
|
const loadMoreAuthors = () => {
|
||||||
const nextPage = currentPage()[props.query] + 1
|
const nextPage = currentPage()[props.query] + 1
|
||||||
|
@ -52,8 +60,8 @@ export const AuthorsList = (props: Props) => {
|
||||||
on(
|
on(
|
||||||
() => props.query,
|
() => props.query,
|
||||||
(query) => {
|
(query) => {
|
||||||
const authorsList = query === 'shouts' ? authorsByShouts() : authorsByFollowers()
|
const al = query === 'shouts' ? authorsByShouts() : authorsByFollowers()
|
||||||
if (authorsList.length === 0 && currentPage()[query] === 0) {
|
if (al?.length === 0 && currentPage()[query] === 0) {
|
||||||
setCurrentPage((prev) => ({ ...prev, [query]: 0 }))
|
setCurrentPage((prev) => ({ ...prev, [query]: 0 }))
|
||||||
fetchAuthors(query, 0).then(() => setCurrentPage((prev) => ({ ...prev, [query]: 1 })))
|
fetchAuthors(query, 0).then(() => setCurrentPage((prev) => ({ ...prev, [query]: 1 })))
|
||||||
}
|
}
|
||||||
|
@ -88,7 +96,7 @@ export const AuthorsList = (props: Props) => {
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-20 col-xl-18">
|
<div class="col-lg-20 col-xl-18">
|
||||||
<div class={styles.action}>
|
<div class={styles.action}>
|
||||||
<Show when={!loading() && authorsList().length > 0 && !allLoaded()}>
|
<Show when={!loading() && (authorsList()?.length || 0) > 0 && !allLoaded()}>
|
||||||
<Button value={t('Load more')} onClick={loadMoreAuthors} />
|
<Button value={t('Load more')} onClick={loadMoreAuthors} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={loading() && !allLoaded()}>
|
<Show when={loading() && !allLoaded()}>
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { showModal } from '../../stores/ui'
|
|
||||||
import { Image } from '../_shared/Image'
|
import { Image } from '../_shared/Image'
|
||||||
|
|
||||||
import styles from './Banner.module.scss'
|
import styles from './Banner.module.scss'
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
|
const { showModal } = useUI()
|
||||||
return (
|
return (
|
||||||
<div class={styles.discoursBanner}>
|
<div class={styles.discoursBanner}>
|
||||||
<div class="wide-container">
|
<div class="wide-container">
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { createSignal, onMount } from 'solid-js'
|
import { createSignal, onMount } from 'solid-js'
|
||||||
|
|
||||||
|
import { useSnackbar, useUI } from '~/context/ui'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { useSnackbar } from '../../context/snackbar'
|
|
||||||
import { showModal } from '../../stores/ui'
|
|
||||||
|
|
||||||
import styles from './Donate.module.scss'
|
import styles from './Donate.module.scss'
|
||||||
|
|
||||||
|
@ -12,6 +11,7 @@ type DWindow = Window & { cp: any }
|
||||||
|
|
||||||
export const Donate = () => {
|
export const Donate = () => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
|
const { showModal } = useUI()
|
||||||
const once = ''
|
const once = ''
|
||||||
const monthly = 'Monthly'
|
const monthly = 'Monthly'
|
||||||
const cpOptions = {
|
const cpOptions = {
|
||||||
|
@ -103,13 +103,15 @@ export const Donate = () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
(opts) => {
|
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||||
|
(opts: any) => {
|
||||||
// success
|
// success
|
||||||
// действие при успешной оплате
|
// действие при успешной оплате
|
||||||
console.debug('[donate] options', opts)
|
console.debug('[donate] options', opts)
|
||||||
showModal('thank')
|
showModal('thank')
|
||||||
},
|
},
|
||||||
(reason: string, options) => {
|
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||||
|
(reason: string, options: any) => {
|
||||||
// fail
|
// fail
|
||||||
// действие при неуспешной оплате
|
// действие при неуспешной оплате
|
||||||
console.debug('[donate] options', options)
|
console.debug('[donate] options', options)
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { hideModal } from '../../stores/ui'
|
|
||||||
import { Button } from '../_shared/Button'
|
import { Button } from '../_shared/Button'
|
||||||
|
|
||||||
export const Feedback = () => {
|
export const Feedback = () => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
|
const { hideModal } = useUI()
|
||||||
const action = '/user/feedback'
|
const action = '/user/feedback'
|
||||||
const method = 'post'
|
const method = 'post'
|
||||||
let msgElement: HTMLTextAreaElement | undefined
|
let msgElement: HTMLTextAreaElement | undefined
|
||||||
|
|
|
@ -147,13 +147,16 @@ export const Footer = () => {
|
||||||
</div>
|
</div>
|
||||||
<div class={clsx(styles.footerCopyrightSocial, 'col-md-6 col-lg-4')}>
|
<div class={clsx(styles.footerCopyrightSocial, 'col-md-6 col-lg-4')}>
|
||||||
<For each={social}>
|
<For each={social}>
|
||||||
{(social) => (
|
{(social) => {
|
||||||
<div class={clsx(styles.socialItem, styles[`socialItem${social.name}`])}>
|
const styleKey = `socialItem${social.name}` as keyof typeof styles
|
||||||
|
return (
|
||||||
|
<div class={clsx(styles.socialItem, styles[styleKey])}>
|
||||||
<a href={social.href}>
|
<a href={social.href}>
|
||||||
<Icon name={`${social.name}-white`} class={styles.icon} />
|
<Icon name={`${social.name}-white`} class={styles.icon} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { useRouter } from '../../stores/router'
|
|
||||||
import { showModal } from '../../stores/ui'
|
|
||||||
import type { AuthModalSearchParams } from '../Nav/AuthModal/types'
|
|
||||||
|
|
||||||
|
import { useSearchParams } from '@solidjs/router'
|
||||||
import styles from './Hero.module.scss'
|
import styles from './Hero.module.scss'
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { changeSearchParams } = useRouter<AuthModalSearchParams>()
|
const { showModal } = useUI()
|
||||||
|
const [, changeSearchParams] = useSearchParams()
|
||||||
return (
|
return (
|
||||||
<div class={styles.aboutDiscours}>
|
<div class={styles.aboutDiscours}>
|
||||||
<div class="wide-container">
|
<div class="wide-container">
|
||||||
|
|
|
@ -1,14 +1,11 @@
|
||||||
import type { Shout } from '../../graphql/schema/core.gen'
|
|
||||||
|
|
||||||
import { getPagePath } from '@nanostores/router'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
import { useConfirm } from '../../context/confirm'
|
import { useSnackbar, useUI } from '~/context/ui'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { useSnackbar } from '../../context/snackbar'
|
import type { Shout } from '../../graphql/schema/core.gen'
|
||||||
import { router } from '../../stores/router'
|
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
|
|
||||||
|
import { A } from '@solidjs/router'
|
||||||
import styles from './Draft.module.scss'
|
import styles from './Draft.module.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -20,10 +17,10 @@ type Props = {
|
||||||
|
|
||||||
export const Draft = (props: Props) => {
|
export const Draft = (props: Props) => {
|
||||||
const { t, formatDate } = useLocalize()
|
const { t, formatDate } = useLocalize()
|
||||||
const { showConfirm } = useConfirm()
|
const { showConfirm } = useUI()
|
||||||
const { showSnackbar } = useSnackbar()
|
const { showSnackbar } = useSnackbar()
|
||||||
|
|
||||||
const handlePublishLinkClick = (e) => {
|
const handlePublishLinkClick = (e: MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (props.shout.main_topic) {
|
if (props.shout.main_topic) {
|
||||||
props.onPublish(props.shout)
|
props.onPublish(props.shout)
|
||||||
|
@ -32,7 +29,7 @@ export const Draft = (props: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteLinkClick = async (e) => {
|
const handleDeleteLinkClick = async (e: MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
const isConfirmed = await showConfirm({
|
const isConfirmed = await showConfirm({
|
||||||
|
@ -58,12 +55,9 @@ export const Draft = (props: Props) => {
|
||||||
<span class={styles.title}>{props.shout.title || t('Unnamed draft')}</span> {props.shout.subtitle}
|
<span class={styles.title}>{props.shout.title || t('Unnamed draft')}</span> {props.shout.subtitle}
|
||||||
</div>
|
</div>
|
||||||
<div class={styles.actions}>
|
<div class={styles.actions}>
|
||||||
<a
|
<A class={styles.actionItem} href={`edit/${props.shout?.id.toString()}`}>
|
||||||
class={styles.actionItem}
|
|
||||||
href={getPagePath(router, 'edit', { shoutId: props.shout?.id.toString() })}
|
|
||||||
>
|
|
||||||
{t('Edit')}
|
{t('Edit')}
|
||||||
</a>
|
</A>
|
||||||
<span onClick={handlePublishLinkClick} class={clsx(styles.actionItem, styles.publish)}>
|
<span onClick={handlePublishLinkClick} class={clsx(styles.actionItem, styles.publish)}>
|
||||||
{t('Publish')}
|
{t('Publish')}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show } from 'solid-js'
|
import { Show } from 'solid-js'
|
||||||
|
|
||||||
|
import { MediaItem } from '~/types/mediaitem'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { MediaItem } from '../../../pages/types'
|
|
||||||
import { composeMediaItems } from '../../../utils/composeMediaItems'
|
import { composeMediaItems } from '../../../utils/composeMediaItems'
|
||||||
import { AudioPlayer } from '../../Article/AudioPlayer'
|
import { AudioPlayer } from '../../Article/AudioPlayer'
|
||||||
import { DropArea } from '../../_shared/DropArea'
|
import { DropArea } from '../../_shared/DropArea'
|
||||||
|
@ -10,7 +10,7 @@ import { DropArea } from '../../_shared/DropArea'
|
||||||
// import { Buffer } from 'node:buffer'
|
// import { Buffer } from 'node:buffer'
|
||||||
import styles from './AudioUploader.module.scss'
|
import styles from './AudioUploader.module.scss'
|
||||||
|
|
||||||
window.Buffer = Buffer
|
if (window) window.Buffer = Buffer
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
class?: string
|
class?: string
|
||||||
|
@ -28,7 +28,11 @@ type Props = {
|
||||||
export const AudioUploader = (props: Props) => {
|
export const AudioUploader = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
|
|
||||||
const handleMediaItemFieldChange = (index: number, field: keyof MediaItem, value) => {
|
const handleMediaItemFieldChange = (
|
||||||
|
index: number,
|
||||||
|
field: keyof MediaItem | string | symbol | number,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
props.onAudioChange(index, { ...props.audio[index], [field]: value })
|
props.onAudioChange(index, { ...props.audio[index], [field]: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ export const BlockquoteBubbleMenu = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<div ref={props.ref} class={styles.BubbleMenu}>
|
<div ref={props.ref} class={styles.BubbleMenu}>
|
||||||
<Popover content={t('Alignment left')}>
|
<Popover content={t('Alignment left')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -30,7 +30,7 @@ export const BlockquoteBubbleMenu = (props: Props) => {
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={t('Alignment center')}>
|
<Popover content={t('Alignment center')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -42,7 +42,7 @@ export const BlockquoteBubbleMenu = (props: Props) => {
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={t('Alignment center')}>
|
<Popover content={t('Alignment center')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
import type { Editor } from '@tiptap/core'
|
import type { Editor } from '@tiptap/core'
|
||||||
|
|
||||||
|
import { UploadedFile } from '~/types/upload'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { UploadedFile } from '../../../pages/types'
|
|
||||||
import { renderUploadedImage } from '../../../utils/renderUploadedImage'
|
import { renderUploadedImage } from '../../../utils/renderUploadedImage'
|
||||||
import { Modal } from '../../Nav/Modal'
|
import { Modal } from '../../Nav/Modal'
|
||||||
import { Icon } from '../../_shared/Icon'
|
import { Icon } from '../../_shared/Icon'
|
||||||
import { Popover } from '../../_shared/Popover'
|
import { Popover } from '../../_shared/Popover'
|
||||||
import { UploadModalContent } from '../UploadModalContent'
|
import { UploadModalContent } from '../UploadModalContent'
|
||||||
|
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
import styles from './BubbleMenu.module.scss'
|
import styles from './BubbleMenu.module.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -17,15 +18,17 @@ type Props = {
|
||||||
|
|
||||||
export const FigureBubbleMenu = (props: Props) => {
|
export const FigureBubbleMenu = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
|
const { hideModal } = useUI()
|
||||||
|
|
||||||
const handleUpload = (image: UploadedFile) => {
|
const handleUpload = (image: UploadedFile) => {
|
||||||
renderUploadedImage(props.editor, image)
|
renderUploadedImage(props.editor, image)
|
||||||
|
hideModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={props.ref} class={styles.BubbleMenu}>
|
<div ref={props.ref} class={styles.BubbleMenu}>
|
||||||
<Popover content={t('Alignment left')}>
|
<Popover content={t('Alignment left')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -37,7 +40,7 @@ export const FigureBubbleMenu = (props: Props) => {
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={t('Alignment center')}>
|
<Popover content={t('Alignment center')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -49,7 +52,7 @@ export const FigureBubbleMenu = (props: Props) => {
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={t('Alignment right')}>
|
<Popover content={t('Alignment right')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -70,7 +73,7 @@ export const FigureBubbleMenu = (props: Props) => {
|
||||||
</button>
|
</button>
|
||||||
<div class={styles.delimiter} />
|
<div class={styles.delimiter} />
|
||||||
<Popover content={t('Add image')}>
|
<Popover content={t('Add image')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button type="button" ref={triggerRef} class={styles.bubbleMenuButton}>
|
<button type="button" ref={triggerRef} class={styles.bubbleMenuButton}>
|
||||||
<Icon name="editor-image-add" />
|
<Icon name="editor-image-add" />
|
||||||
</button>
|
</button>
|
||||||
|
@ -80,7 +83,7 @@ export const FigureBubbleMenu = (props: Props) => {
|
||||||
<Modal variant="narrow" name="uploadImage">
|
<Modal variant="narrow" name="uploadImage">
|
||||||
<UploadModalContent
|
<UploadModalContent
|
||||||
onClose={(value) => {
|
onClose={(value) => {
|
||||||
handleUpload(value)
|
handleUpload(value as UploadedFile)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
@ -18,7 +18,7 @@ const backgrounds = [null, 'white', 'black', 'yellow', 'pink', 'green']
|
||||||
export const IncutBubbleMenu = (props: Props) => {
|
export const IncutBubbleMenu = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const [substratBubbleOpen, setSubstratBubbleOpen] = createSignal(false)
|
const [substratBubbleOpen, setSubstratBubbleOpen] = createSignal(false)
|
||||||
const handleChangeBg = (bg) => {
|
const handleChangeBg = (bg: string | null) => {
|
||||||
props.editor.chain().focus().setArticleBg(bg).run()
|
props.editor.chain().focus().setArticleBg(bg).run()
|
||||||
setSubstratBubbleOpen(false)
|
setSubstratBubbleOpen(false)
|
||||||
}
|
}
|
||||||
|
@ -60,7 +60,12 @@ export const IncutBubbleMenu = (props: Props) => {
|
||||||
<div class={styles.dropDown}>
|
<div class={styles.dropDown}>
|
||||||
<div class={styles.actions}>
|
<div class={styles.actions}>
|
||||||
<For each={backgrounds}>
|
<For each={backgrounds}>
|
||||||
{(bg) => <div onClick={() => handleChangeBg(bg)} class={clsx(styles.color, styles[bg])} />}
|
{(bg) => (
|
||||||
|
<div
|
||||||
|
onClick={() => handleChangeBg(bg)}
|
||||||
|
class={clsx(styles.color, styles[bg as keyof typeof styles])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { HocuspocusProvider } from '@hocuspocus/provider'
|
import { HocuspocusProvider } from '@hocuspocus/provider'
|
||||||
import { isTextSelection } from '@tiptap/core'
|
import { Editor, isTextSelection } from '@tiptap/core'
|
||||||
import { Bold } from '@tiptap/extension-bold'
|
import { Bold } from '@tiptap/extension-bold'
|
||||||
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
|
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
|
||||||
import { BulletList } from '@tiptap/extension-bullet-list'
|
import { BulletList } from '@tiptap/extension-bullet-list'
|
||||||
|
@ -25,15 +25,15 @@ import { Placeholder } from '@tiptap/extension-placeholder'
|
||||||
import { Strike } from '@tiptap/extension-strike'
|
import { Strike } from '@tiptap/extension-strike'
|
||||||
import { Text } from '@tiptap/extension-text'
|
import { Text } from '@tiptap/extension-text'
|
||||||
import { Underline } from '@tiptap/extension-underline'
|
import { Underline } from '@tiptap/extension-underline'
|
||||||
import { createEffect, createSignal, onCleanup } from 'solid-js'
|
import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'solid-js'
|
||||||
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap'
|
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap'
|
||||||
import uniqolor from 'uniqolor'
|
import uniqolor from 'uniqolor'
|
||||||
import { Doc } from 'yjs'
|
import { Doc } from 'yjs'
|
||||||
|
|
||||||
|
import { useSnackbar } from '~/context/ui'
|
||||||
import { useEditorContext } from '../../context/editor'
|
import { useEditorContext } from '../../context/editor'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
import { useSnackbar } from '../../context/snackbar'
|
|
||||||
import { handleImageUpload } from '../../utils/handleImageUpload'
|
import { handleImageUpload } from '../../utils/handleImageUpload'
|
||||||
|
|
||||||
import { BlockquoteBubbleMenu, FigureBubbleMenu, IncutBubbleMenu } from './BubbleMenu'
|
import { BlockquoteBubbleMenu, FigureBubbleMenu, IncutBubbleMenu } from './BubbleMenu'
|
||||||
|
@ -50,6 +50,7 @@ import { ToggleTextWrap } from './extensions/ToggleTextWrap'
|
||||||
import { TrailingNode } from './extensions/TrailingNode'
|
import { TrailingNode } from './extensions/TrailingNode'
|
||||||
|
|
||||||
import './Prosemirror.scss'
|
import './Prosemirror.scss'
|
||||||
|
import { Author } from '~/graphql/schema/core.gen'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
shoutId: number
|
shoutId: number
|
||||||
|
@ -71,13 +72,12 @@ const allowedImageTypes = new Set([
|
||||||
const yDocs: Record<string, Doc> = {}
|
const yDocs: Record<string, Doc> = {}
|
||||||
const providers: Record<string, HocuspocusProvider> = {}
|
const providers: Record<string, HocuspocusProvider> = {}
|
||||||
|
|
||||||
export const Editor = (props: Props) => {
|
export const EditorComponent = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { author, session } = useSession()
|
const { session } = useSession()
|
||||||
|
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
||||||
const [isCommonMarkup, setIsCommonMarkup] = createSignal(false)
|
const [isCommonMarkup, setIsCommonMarkup] = createSignal(false)
|
||||||
const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false)
|
const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false)
|
||||||
|
|
||||||
const { showSnackbar } = useSnackbar()
|
const { showSnackbar } = useSnackbar()
|
||||||
|
|
||||||
const docName = `shout-${props.shoutId}`
|
const docName = `shout-${props.shoutId}`
|
||||||
|
@ -95,39 +95,12 @@ export const Editor = (props: Props) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const editorElRef: {
|
const [editorElRef, setEditorElRef] = createSignal<HTMLElement>()
|
||||||
current: HTMLDivElement
|
let textBubbleMenuRef: HTMLDivElement | undefined
|
||||||
} = {
|
let incutBubbleMenuRef: HTMLElement | undefined
|
||||||
current: null,
|
let figureBubbleMenuRef: HTMLElement | undefined
|
||||||
}
|
let blockquoteBubbleMenuRef: HTMLElement | undefined
|
||||||
|
let floatingMenuRef: HTMLDivElement | undefined
|
||||||
const textBubbleMenuRef: {
|
|
||||||
current: HTMLDivElement
|
|
||||||
} = {
|
|
||||||
current: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const incutBubbleMenuRef: {
|
|
||||||
current: HTMLElement
|
|
||||||
} = {
|
|
||||||
current: null,
|
|
||||||
}
|
|
||||||
const figureBubbleMenuRef: {
|
|
||||||
current: HTMLElement
|
|
||||||
} = {
|
|
||||||
current: null,
|
|
||||||
}
|
|
||||||
const blockquoteBubbleMenuRef: {
|
|
||||||
current: HTMLElement
|
|
||||||
} = {
|
|
||||||
current: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const floatingMenuRef: {
|
|
||||||
current: HTMLDivElement
|
|
||||||
} = {
|
|
||||||
current: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClipboardPaste = async () => {
|
const handleClipboardPaste = async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -151,10 +124,10 @@ export const Editor = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
showSnackbar({ body: t('Uploading image') })
|
showSnackbar({ body: t('Uploading image') })
|
||||||
const result = await handleImageUpload(uplFile, session()?.access_token)
|
const result = await handleImageUpload(uplFile, session()?.access_token || '')
|
||||||
|
|
||||||
editor()
|
editor()
|
||||||
.chain()
|
?.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.insertContent({
|
.insertContent({
|
||||||
type: 'figure',
|
type: 'figure',
|
||||||
|
@ -177,9 +150,12 @@ export const Editor = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { initialContent } = props
|
const { initialContent } = props
|
||||||
|
const { editor, setEditor, countWords } = useEditorContext()
|
||||||
const editor = createTiptapEditor(() => ({
|
createEffect(
|
||||||
element: editorElRef.current,
|
on(editorElRef, (ee: HTMLElement | undefined) => {
|
||||||
|
if (ee) {
|
||||||
|
const freshEditor = createTiptapEditor<HTMLElement>(() => ({
|
||||||
|
element: ee,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
class: 'articleEditor',
|
class: 'articleEditor',
|
||||||
|
@ -252,11 +228,12 @@ export const Editor = (props: Props) => {
|
||||||
CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
|
CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
|
||||||
BubbleMenu.configure({
|
BubbleMenu.configure({
|
||||||
pluginKey: 'textBubbleMenu',
|
pluginKey: 'textBubbleMenu',
|
||||||
element: textBubbleMenuRef.current,
|
element: textBubbleMenuRef,
|
||||||
shouldShow: ({ editor: e, view, state, from, to }) => {
|
shouldShow: ({ editor: e, view, state, from, to }) => {
|
||||||
const { doc, selection } = state
|
const { doc, selection } = state
|
||||||
const { empty } = selection
|
const { empty } = selection
|
||||||
const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection)
|
const isEmptyTextBlock =
|
||||||
|
doc.textBetween(from, to).length === 0 && isTextSelection(selection)
|
||||||
if (isEmptyTextBlock) {
|
if (isEmptyTextBlock) {
|
||||||
e.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
|
e.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
|
||||||
}
|
}
|
||||||
|
@ -273,81 +250,75 @@ export const Editor = (props: Props) => {
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
sticky: true,
|
onHide: () => {
|
||||||
|
const fe = freshEditor() as Editor
|
||||||
|
fe?.commands.focus()
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
BubbleMenu.configure({
|
BubbleMenu.configure({
|
||||||
pluginKey: 'blockquoteBubbleMenu',
|
pluginKey: 'blockquoteBubbleMenu',
|
||||||
element: blockquoteBubbleMenuRef.current,
|
element: blockquoteBubbleMenuRef,
|
||||||
shouldShow: ({ editor: e, state }) => {
|
shouldShow: ({ editor: e, view, state }) => {
|
||||||
const { selection } = state
|
const { empty } = state.selection
|
||||||
const { empty } = selection
|
return view.hasFocus() && !empty && e.isActive('blockquote')
|
||||||
return empty && e.isActive('blockquote')
|
|
||||||
},
|
|
||||||
tippyOptions: {
|
|
||||||
offset: [0, 0],
|
|
||||||
placement: 'top',
|
|
||||||
getReferenceClientRect: () => {
|
|
||||||
const selectedElement = editor().view.dom.querySelector('.has-focus')
|
|
||||||
if (selectedElement) {
|
|
||||||
return selectedElement.getBoundingClientRect()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
|
BubbleMenu.configure({
|
||||||
|
pluginKey: 'figureBubbleMenu',
|
||||||
|
element: figureBubbleMenuRef,
|
||||||
|
shouldShow: ({ editor: e, view, state }) => {
|
||||||
|
const { empty } = state.selection
|
||||||
|
return view.hasFocus() && !empty && e.isActive('figure')
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
BubbleMenu.configure({
|
BubbleMenu.configure({
|
||||||
pluginKey: 'incutBubbleMenu',
|
pluginKey: 'incutBubbleMenu',
|
||||||
element: incutBubbleMenuRef.current,
|
element: incutBubbleMenuRef,
|
||||||
shouldShow: ({ editor: e, state }) => {
|
shouldShow: ({ editor: e, view, state }) => {
|
||||||
const { selection } = state
|
const { empty } = state.selection
|
||||||
const { empty } = selection
|
return view.hasFocus() && !empty && e.isActive('figcaption')
|
||||||
return empty && e.isActive('article')
|
|
||||||
},
|
|
||||||
tippyOptions: {
|
|
||||||
offset: [0, -16],
|
|
||||||
placement: 'top',
|
|
||||||
getReferenceClientRect: () => {
|
|
||||||
const selectedElement = editor().view.dom.querySelector('.has-focus')
|
|
||||||
if (selectedElement) {
|
|
||||||
return selectedElement.getBoundingClientRect()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
BubbleMenu.configure({
|
|
||||||
pluginKey: 'imageBubbleMenu',
|
|
||||||
element: figureBubbleMenuRef.current,
|
|
||||||
shouldShow: ({ editor: e, view }) => {
|
|
||||||
return view.hasFocus() && e.isActive('image')
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
FloatingMenu.configure({
|
FloatingMenu.configure({
|
||||||
tippyOptions: {
|
element: floatingMenuRef,
|
||||||
placement: 'left',
|
pluginKey: 'floatingMenu',
|
||||||
|
shouldShow: ({ editor: e, state }) => {
|
||||||
|
const { $anchor, empty } = state.selection
|
||||||
|
const isRootDepth = $anchor.depth === 1
|
||||||
|
|
||||||
|
if (!(isRootDepth && empty)) return false
|
||||||
|
|
||||||
|
return !(e.isActive('codeBlock') || e.isActive('heading'))
|
||||||
},
|
},
|
||||||
element: floatingMenuRef.current,
|
|
||||||
}),
|
}),
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
Article,
|
Article,
|
||||||
],
|
],
|
||||||
enablePasteRules: [Link],
|
onTransaction: ({ transaction }) => {
|
||||||
content: initialContent ?? null,
|
if (transaction.docChanged) {
|
||||||
|
const fe = freshEditor()
|
||||||
|
if (fe) {
|
||||||
|
const changeHandle = useEditorHTML(() => fe as Editor | undefined)
|
||||||
|
props.onChange(changeHandle() || '')
|
||||||
|
countWords(fe?.storage.characterCount.words())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content: initialContent,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const { countWords, setEditor } = useEditorContext()
|
if (freshEditor) {
|
||||||
setEditor(editor)
|
editorElRef()?.addEventListener('focus', (_event) => {
|
||||||
|
if (freshEditor()?.isActive('figcaption')) {
|
||||||
const html = useEditorHTML(() => editor())
|
freshEditor()?.commands.focus()
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
props.onChange(html())
|
|
||||||
if (html()) {
|
|
||||||
countWords({
|
|
||||||
characters: editor().storage.characterCount.characters(),
|
|
||||||
words: editor().storage.characterCount.words(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
setEditor(freshEditor() as Editor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
editor()?.destroy()
|
editor()?.destroy()
|
||||||
|
@ -358,35 +329,36 @@ export const Editor = (props: Props) => {
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-5" />
|
<div class="col-md-5" />
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<div ref={(el) => (editorElRef.current = el)} id="editorBody" />
|
<div ref={setEditorElRef} id="editorBody" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={editor()}>
|
||||||
<TextBubbleMenu
|
<TextBubbleMenu
|
||||||
shouldShow={shouldShowTextBubbleMenu()}
|
shouldShow={shouldShowTextBubbleMenu()}
|
||||||
isCommonMarkup={isCommonMarkup()}
|
isCommonMarkup={isCommonMarkup()}
|
||||||
editor={editor()}
|
editor={editor() as Editor}
|
||||||
ref={(el) => (textBubbleMenuRef.current = el)}
|
ref={(el) => (textBubbleMenuRef = el)}
|
||||||
/>
|
/>
|
||||||
<BlockquoteBubbleMenu
|
<BlockquoteBubbleMenu
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
blockquoteBubbleMenuRef.current = el
|
blockquoteBubbleMenuRef = el
|
||||||
}}
|
}}
|
||||||
editor={editor()}
|
editor={editor() as Editor}
|
||||||
/>
|
/>
|
||||||
<FigureBubbleMenu
|
<FigureBubbleMenu
|
||||||
editor={editor()}
|
editor={editor() as Editor}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
figureBubbleMenuRef.current = el
|
figureBubbleMenuRef = el
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<IncutBubbleMenu
|
<IncutBubbleMenu
|
||||||
editor={editor()}
|
editor={editor() as Editor}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
incutBubbleMenuRef.current = el
|
incutBubbleMenuRef = el
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<EditorFloatingMenu editor={editor()} ref={(el) => (floatingMenuRef.current = el)} />
|
<EditorFloatingMenu editor={editor() as Editor} ref={(el) => (floatingMenuRef = el)} />
|
||||||
|
</Show>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,19 +1,17 @@
|
||||||
import type { Editor } from '@tiptap/core'
|
import type { Editor } from '@tiptap/core'
|
||||||
import type { MenuItem } from './Menu/Menu'
|
|
||||||
|
|
||||||
import { Show, createEffect, createSignal } from 'solid-js'
|
import { Show, createEffect, createSignal } from 'solid-js'
|
||||||
|
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
|
import { UploadedFile } from '~/types/upload'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { UploadedFile } from '../../../pages/types'
|
|
||||||
import { showModal } from '../../../stores/ui'
|
|
||||||
import { renderUploadedImage } from '../../../utils/renderUploadedImage'
|
import { renderUploadedImage } from '../../../utils/renderUploadedImage'
|
||||||
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
|
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
|
||||||
import { Modal } from '../../Nav/Modal'
|
import { Modal } from '../../Nav/Modal'
|
||||||
import { Icon } from '../../_shared/Icon'
|
import { Icon } from '../../_shared/Icon'
|
||||||
import { InlineForm } from '../InlineForm'
|
import { InlineForm } from '../InlineForm'
|
||||||
import { UploadModalContent } from '../UploadModalContent'
|
import { UploadModalContent } from '../UploadModalContent'
|
||||||
|
|
||||||
import { Menu } from './Menu'
|
import { Menu } from './Menu'
|
||||||
|
import type { MenuItem } from './Menu/Menu'
|
||||||
|
|
||||||
import styles from './EditorFloatingMenu.module.scss'
|
import styles from './EditorFloatingMenu.module.scss'
|
||||||
|
|
||||||
|
@ -22,16 +20,15 @@ type FloatingMenuProps = {
|
||||||
ref: (el: HTMLDivElement) => void
|
ref: (el: HTMLDivElement) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const embedData = (data) => {
|
const embedData = (data: string) => {
|
||||||
const element = document.createRange().createContextualFragment(data)
|
const element = document.createRange().createContextualFragment(data)
|
||||||
const { attributes } = element.firstChild as HTMLIFrameElement
|
const { attributes } = element.firstChild as HTMLIFrameElement
|
||||||
|
|
||||||
const result: { src: string; width?: string; height?: string } = { src: '' }
|
const result: { src: string; width?: string; height?: string } = { src: '' }
|
||||||
|
|
||||||
for (let i = 0; i < attributes.length; i++) {
|
for (let i = 0; i < attributes.length; i++) {
|
||||||
const attribute = attributes.item(i)
|
const attribute = attributes.item(i)
|
||||||
if (attribute) {
|
if (attribute?.name) {
|
||||||
result[attribute.name] = attribute.value
|
result[attribute.name as keyof typeof result] = attribute.value as string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,10 +37,11 @@ const embedData = (data) => {
|
||||||
|
|
||||||
export const EditorFloatingMenu = (props: FloatingMenuProps) => {
|
export const EditorFloatingMenu = (props: FloatingMenuProps) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
|
const { showModal, hideModal } = useUI()
|
||||||
const [selectedMenuItem, setSelectedMenuItem] = createSignal<MenuItem | undefined>()
|
const [selectedMenuItem, setSelectedMenuItem] = createSignal<MenuItem | undefined>()
|
||||||
const [menuOpen, setMenuOpen] = createSignal<boolean>(false)
|
const [menuOpen, setMenuOpen] = createSignal<boolean>(false)
|
||||||
const menuRef: { current: HTMLDivElement } = { current: null }
|
let menuRef: HTMLDivElement | undefined
|
||||||
const plusButtonRef: { current: HTMLButtonElement } = { current: null }
|
let plusButtonRef: HTMLButtonElement | undefined
|
||||||
const handleEmbedFormSubmit = async (value: string) => {
|
const handleEmbedFormSubmit = async (value: string) => {
|
||||||
// TODO: add support instagram embed (blockquote)
|
// TODO: add support instagram embed (blockquote)
|
||||||
const emb = await embedData(value)
|
const emb = await embedData(value)
|
||||||
|
@ -71,7 +69,7 @@ export const EditorFloatingMenu = (props: FloatingMenuProps) => {
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateEmbed = (value) => {
|
const validateEmbed = (value: string) => {
|
||||||
const element = document.createRange().createContextualFragment(value)
|
const element = document.createRange().createContextualFragment(value)
|
||||||
if (element.firstChild?.nodeName !== 'IFRAME') {
|
if (element.firstChild?.nodeName !== 'IFRAME') {
|
||||||
return t('Error')
|
return t('Error')
|
||||||
|
@ -101,7 +99,7 @@ export const EditorFloatingMenu = (props: FloatingMenuProps) => {
|
||||||
useOutsideClickHandler({
|
useOutsideClickHandler({
|
||||||
containerRef: menuRef,
|
containerRef: menuRef,
|
||||||
handler: (e) => {
|
handler: (e) => {
|
||||||
if (plusButtonRef.current.contains(e.target)) {
|
if (plusButtonRef?.contains(e.target)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -114,22 +112,19 @@ export const EditorFloatingMenu = (props: FloatingMenuProps) => {
|
||||||
|
|
||||||
const handleUpload = (image: UploadedFile) => {
|
const handleUpload = (image: UploadedFile) => {
|
||||||
renderUploadedImage(props.editor, image)
|
renderUploadedImage(props.editor, image)
|
||||||
|
hideModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={props.ref} class={styles.editorFloatingMenu}>
|
<div ref={props.ref} class={styles.editorFloatingMenu}>
|
||||||
<button
|
<button ref={(el) => (plusButtonRef = el)} type="button" onClick={() => setMenuOpen(!menuOpen())}>
|
||||||
ref={(el) => (plusButtonRef.current = el)}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMenuOpen(!menuOpen())}
|
|
||||||
>
|
|
||||||
<Icon name="editor-plus" />
|
<Icon name="editor-plus" />
|
||||||
</button>
|
</button>
|
||||||
<Show when={menuOpen()}>
|
<Show when={menuOpen()}>
|
||||||
<div class={styles.menuHolder} ref={(el) => (menuRef.current = el)}>
|
<div class={styles.menuHolder} ref={(el) => (menuRef = el)}>
|
||||||
<Show when={!selectedMenuItem()}>
|
<Show when={!selectedMenuItem()}>
|
||||||
<Menu selectedItem={(value: MenuItem) => setSelectedMenuItem(value)} />
|
<Menu selectedItem={(value: string) => setSelectedMenuItem(value as MenuItem)} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={selectedMenuItem() === 'embed'}>
|
<Show when={selectedMenuItem() === 'embed'}>
|
||||||
<InlineForm
|
<InlineForm
|
||||||
|
@ -137,7 +132,7 @@ export const EditorFloatingMenu = (props: FloatingMenuProps) => {
|
||||||
showInput={true}
|
showInput={true}
|
||||||
onClose={closeUploadModalHandler}
|
onClose={closeUploadModalHandler}
|
||||||
onClear={() => setSelectedMenuItem()}
|
onClear={() => setSelectedMenuItem()}
|
||||||
validate={validateEmbed}
|
validate={(val) => validateEmbed(val) || ''}
|
||||||
onSubmit={handleEmbedFormSubmit}
|
onSubmit={handleEmbedFormSubmit}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
@ -147,7 +142,7 @@ export const EditorFloatingMenu = (props: FloatingMenuProps) => {
|
||||||
<Modal variant="narrow" name="uploadImage" onClose={closeUploadModalHandler}>
|
<Modal variant="narrow" name="uploadImage" onClose={closeUploadModalHandler}>
|
||||||
<UploadModalContent
|
<UploadModalContent
|
||||||
onClose={(value) => {
|
onClose={(value) => {
|
||||||
handleUpload(value)
|
handleUpload(value as UploadedFile)
|
||||||
setSelectedMenuItem()
|
setSelectedMenuItem()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -19,21 +19,21 @@ export const Menu = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<div class={styles.Menu}>
|
<div class={styles.Menu}>
|
||||||
<Popover content={t('Add image')}>
|
<Popover content={t('Add image')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button ref={triggerRef} type="button" onClick={() => setSelectedMenuItem('image')}>
|
<button ref={triggerRef} type="button" onClick={() => setSelectedMenuItem('image')}>
|
||||||
<Icon class={styles.icon} name="editor-image" />
|
<Icon class={styles.icon} name="editor-image" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={t('Add an embed widget')}>
|
<Popover content={t('Add an embed widget')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button ref={triggerRef} type="button" onClick={() => setSelectedMenuItem('embed')}>
|
<button ref={triggerRef} type="button" onClick={() => setSelectedMenuItem('embed')}>
|
||||||
<Icon class={styles.icon} name="editor-embed" />
|
<Icon class={styles.icon} name="editor-embed" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={t('Add rule')}>
|
<Popover content={t('Add rule')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button ref={triggerRef} type="button" onClick={() => setSelectedMenuItem('horizontal-rule')}>
|
<button ref={triggerRef} type="button" onClick={() => setSelectedMenuItem('horizontal-rule')}>
|
||||||
<Icon class={styles.icon} name="editor-horizontal-rule" />
|
<Icon class={styles.icon} name="editor-horizontal-rule" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -22,9 +22,9 @@ export const InlineForm = (props: Props) => {
|
||||||
const [formValue, setFormValue] = createSignal(props.initialValue || '')
|
const [formValue, setFormValue] = createSignal(props.initialValue || '')
|
||||||
const [formValueError, setFormValueError] = createSignal<string | undefined>()
|
const [formValueError, setFormValueError] = createSignal<string | undefined>()
|
||||||
|
|
||||||
const inputRef: { current: HTMLInputElement } = { current: null }
|
let inputRef: HTMLInputElement | undefined
|
||||||
const handleFormInput = (e) => {
|
const handleFormInput = (e: { currentTarget: HTMLInputElement; target: HTMLInputElement }) => {
|
||||||
const value = e.currentTarget.value
|
const value = (e.currentTarget || e.target).value
|
||||||
setFormValueError()
|
setFormValueError()
|
||||||
setFormValue(value)
|
setFormValue(value)
|
||||||
}
|
}
|
||||||
|
@ -42,7 +42,7 @@ export const InlineForm = (props: Props) => {
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = async (e) => {
|
const handleKeyDown = async (e: KeyboardEvent) => {
|
||||||
setFormValueError('')
|
setFormValueError('')
|
||||||
|
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
|
@ -56,18 +56,18 @@ export const InlineForm = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
props.initialValue ? props.onClear() : props.onClose()
|
props.initialValue ? props.onClear?.() : props.onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
inputRef.current.focus()
|
inputRef?.focus()
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={styles.InlineForm}>
|
<div class={styles.InlineForm}>
|
||||||
<div class={styles.form}>
|
<div class={styles.form}>
|
||||||
<input
|
<input
|
||||||
ref={(el) => (inputRef.current = el)}
|
ref={(el) => (inputRef = el)}
|
||||||
type="text"
|
type="text"
|
||||||
value={props.initialValue ?? ''}
|
value={props.initialValue ?? ''}
|
||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
|
@ -75,7 +75,7 @@ export const InlineForm = (props: Props) => {
|
||||||
onInput={handleFormInput}
|
onInput={handleFormInput}
|
||||||
/>
|
/>
|
||||||
<Popover content={t('Add link')}>
|
<Popover content={t('Add link')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -87,7 +87,7 @@ export const InlineForm = (props: Props) => {
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={props.initialValue ? t('Remove link') : t('Cancel')}>
|
<Popover content={props.initialValue ? t('Remove link') : t('Cancel')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button ref={triggerRef} type="button" onClick={handleClear}>
|
<button ref={triggerRef} type="button" onClick={handleClear}>
|
||||||
{props.initialValue ? <Icon name="editor-unlink" /> : <Icon name="status-cancel" />}
|
{props.initialValue ? <Icon name="editor-unlink" /> : <Icon name="status-cancel" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -10,7 +10,7 @@ type Props = {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const checkUrl = (url) => {
|
export const checkUrl = (url: string) => {
|
||||||
try {
|
try {
|
||||||
new URL(url)
|
new URL(url)
|
||||||
return url
|
return url
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import { getPagePath } from '@nanostores/router'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show, createSignal } from 'solid-js'
|
import { Show, createSignal } from 'solid-js'
|
||||||
import { useEditorHTML } from 'solid-tiptap'
|
import { useEditorHTML } from 'solid-tiptap'
|
||||||
import Typograf from 'typograf'
|
import Typograf from 'typograf'
|
||||||
|
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
import { useEditorContext } from '../../../context/editor'
|
import { useEditorContext } from '../../../context/editor'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { router } from '../../../stores/router'
|
|
||||||
import { showModal } from '../../../stores/ui'
|
|
||||||
import { useEscKeyDownHandler } from '../../../utils/useEscKeyDownHandler'
|
import { useEscKeyDownHandler } from '../../../utils/useEscKeyDownHandler'
|
||||||
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
|
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
|
||||||
import { Button } from '../../_shared/Button'
|
import { Button } from '../../_shared/Button'
|
||||||
import { DarkModeToggle } from '../../_shared/DarkModeToggle'
|
import { DarkModeToggle } from '../../_shared/DarkModeToggle'
|
||||||
import { Icon } from '../../_shared/Icon'
|
import { Icon } from '../../_shared/Icon'
|
||||||
|
|
||||||
|
import { A } from '@solidjs/router'
|
||||||
import styles from './Panel.module.scss'
|
import styles from './Panel.module.scss'
|
||||||
|
|
||||||
const typograf = new Typograf({ locale: ['ru', 'en-US'] })
|
const typograf = new Typograf({ locale: ['ru', 'en-US'] })
|
||||||
|
@ -23,10 +23,11 @@ type Props = {
|
||||||
|
|
||||||
export const Panel = (props: Props) => {
|
export const Panel = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
|
const { showModal } = useUI()
|
||||||
const {
|
const {
|
||||||
isEditorPanelVisible,
|
isEditorPanelVisible,
|
||||||
wordCounter,
|
wordCounter,
|
||||||
editorRef,
|
editor,
|
||||||
form,
|
form,
|
||||||
toggleEditorPanel,
|
toggleEditorPanel,
|
||||||
saveShout,
|
saveShout,
|
||||||
|
@ -34,7 +35,7 @@ export const Panel = (props: Props) => {
|
||||||
publishShout,
|
publishShout,
|
||||||
} = useEditorContext()
|
} = useEditorContext()
|
||||||
|
|
||||||
const containerRef: { current: HTMLElement } = { current: null }
|
let containerRef: HTMLElement | undefined
|
||||||
const [isShortcutsVisible, setIsShortcutsVisible] = createSignal(false)
|
const [isShortcutsVisible, setIsShortcutsVisible] = createSignal(false)
|
||||||
const [isTypographyFixed, setIsTypographyFixed] = createSignal(false)
|
const [isTypographyFixed, setIsTypographyFixed] = createSignal(false)
|
||||||
|
|
||||||
|
@ -59,16 +60,16 @@ export const Panel = (props: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = useEditorHTML(() => editorRef.current())
|
const html = useEditorHTML(() => editor()) // FIXME: lost current() call
|
||||||
|
|
||||||
const handleFixTypographyClick = () => {
|
const handleFixTypographyClick = () => {
|
||||||
editorRef.current().commands.setContent(typograf.execute(html()))
|
editor()?.commands.setContent(typograf.execute(html() || '')) // here too
|
||||||
setIsTypographyFixed(true)
|
setIsTypographyFixed(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
ref={(el) => (containerRef.current = el)}
|
ref={(el) => (containerRef = el)}
|
||||||
class={clsx('col-md-6', styles.Panel, { [styles.hidden]: !isEditorPanelVisible() })}
|
class={clsx('col-md-6', styles.Panel, { [styles.hidden]: !isEditorPanelVisible() })}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
@ -98,13 +99,13 @@ export const Panel = (props: Props) => {
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a
|
<A
|
||||||
class={styles.link}
|
class={styles.link}
|
||||||
onClick={() => toggleEditorPanel()}
|
onClick={() => toggleEditorPanel()}
|
||||||
href={getPagePath(router, 'editSettings', { shoutId: props.shoutId.toString() })}
|
href={`/edit/${props.shoutId}/settings`}
|
||||||
>
|
>
|
||||||
{t('Publication settings')}
|
{t('Publication settings')}
|
||||||
</a>
|
</A>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span class={styles.link}>{t('Corrections history')}</span>
|
<span class={styles.link}>{t('Corrections history')}</span>
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { Paragraph } from '@tiptap/extension-paragraph'
|
||||||
import { Placeholder } from '@tiptap/extension-placeholder'
|
import { Placeholder } from '@tiptap/extension-placeholder'
|
||||||
import { Text } from '@tiptap/extension-text'
|
import { Text } from '@tiptap/extension-text'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show, 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 { Portal } from 'solid-js/web'
|
||||||
import {
|
import {
|
||||||
createEditorTransaction,
|
createEditorTransaction,
|
||||||
|
@ -20,23 +20,23 @@ import {
|
||||||
useEditorIsFocused,
|
useEditorIsFocused,
|
||||||
} from 'solid-tiptap'
|
} from 'solid-tiptap'
|
||||||
|
|
||||||
|
import { UploadedFile } from '~/types/upload'
|
||||||
import { useEditorContext } from '../../context/editor'
|
import { useEditorContext } from '../../context/editor'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { UploadedFile } from '../../pages/types'
|
|
||||||
import { hideModal, showModal } from '../../stores/ui'
|
|
||||||
import { Modal } from '../Nav/Modal'
|
import { Modal } from '../Nav/Modal'
|
||||||
import { Button } from '../_shared/Button'
|
import { Button } from '../_shared/Button'
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
|
import { Loading } from '../_shared/Loading'
|
||||||
import { Popover } from '../_shared/Popover'
|
import { Popover } from '../_shared/Popover'
|
||||||
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
||||||
|
|
||||||
import { LinkBubbleMenuModule } from './LinkBubbleMenu'
|
import { LinkBubbleMenuModule } from './LinkBubbleMenu'
|
||||||
import { TextBubbleMenu } from './TextBubbleMenu'
|
import { TextBubbleMenu } from './TextBubbleMenu'
|
||||||
import { UploadModalContent } from './UploadModalContent'
|
import { UploadModalContent } from './UploadModalContent'
|
||||||
import { Figcaption } from './extensions/Figcaption'
|
import { Figcaption } from './extensions/Figcaption'
|
||||||
import { Figure } from './extensions/Figure'
|
import { Figure } from './extensions/Figure'
|
||||||
|
|
||||||
import { Loading } from '../_shared/Loading'
|
import { Editor } from '@tiptap/core'
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
import styles from './SimplifiedEditor.module.scss'
|
import styles from './SimplifiedEditor.module.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -68,46 +68,30 @@ const DEFAULT_MAX_LENGTH = 400
|
||||||
|
|
||||||
const SimplifiedEditor = (props: Props) => {
|
const SimplifiedEditor = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const [counter, setCounter] = createSignal<number>()
|
const { showModal, hideModal } = useUI()
|
||||||
|
const [counter, setCounter] = createSignal<number>(0)
|
||||||
const [shouldShowLinkBubbleMenu, setShouldShowLinkBubbleMenu] = createSignal(false)
|
const [shouldShowLinkBubbleMenu, setShouldShowLinkBubbleMenu] = createSignal(false)
|
||||||
const isCancelButtonVisible = createMemo(() => props.isCancelButtonVisible !== false)
|
const isCancelButtonVisible = createMemo(() => props.isCancelButtonVisible !== false)
|
||||||
|
const [editorElement, setEditorElement] = createSignal<HTMLDivElement>()
|
||||||
|
const { editor, setEditor } = useEditorContext()
|
||||||
|
|
||||||
const maxLength = props.maxLength ?? DEFAULT_MAX_LENGTH
|
const maxLength = props.maxLength ?? DEFAULT_MAX_LENGTH
|
||||||
|
let wrapperEditorElRef: HTMLElement | undefined
|
||||||
const wrapperEditorElRef: {
|
let textBubbleMenuRef: HTMLDivElement | undefined
|
||||||
current: HTMLElement
|
let linkBubbleMenuRef: HTMLDivElement | undefined
|
||||||
} = {
|
|
||||||
current: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const editorElRef: {
|
|
||||||
current: HTMLElement
|
|
||||||
} = {
|
|
||||||
current: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const textBubbleMenuRef: {
|
|
||||||
current: HTMLDivElement
|
|
||||||
} = {
|
|
||||||
current: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const linkBubbleMenuRef: {
|
|
||||||
current: HTMLDivElement
|
|
||||||
} = {
|
|
||||||
current: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const { setEditor } = useEditorContext()
|
|
||||||
|
|
||||||
const ImageFigure = Figure.extend({
|
const ImageFigure = Figure.extend({
|
||||||
name: 'capturedImage',
|
name: 'capturedImage',
|
||||||
content: 'figcaption image',
|
content: 'figcaption image',
|
||||||
})
|
})
|
||||||
|
|
||||||
const content = props.initialContent
|
createEffect(
|
||||||
const editor = createTiptapEditor(() => ({
|
on(
|
||||||
element: editorElRef.current,
|
() => editorElement(),
|
||||||
|
(ee: HTMLDivElement | undefined) => {
|
||||||
|
if (ee && textBubbleMenuRef && linkBubbleMenuRef) {
|
||||||
|
const freshEditor = createTiptapEditor<HTMLElement>(() => ({
|
||||||
|
element: ee,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
class: styles.simplifiedEditorField,
|
class: styles.simplifiedEditorField,
|
||||||
|
@ -135,9 +119,9 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
}),
|
}),
|
||||||
BubbleMenu.configure({
|
BubbleMenu.configure({
|
||||||
pluginKey: 'textBubbleMenu',
|
pluginKey: 'textBubbleMenu',
|
||||||
element: textBubbleMenuRef.current,
|
element: textBubbleMenuRef,
|
||||||
shouldShow: ({ view, state }) => {
|
shouldShow: ({ view, state }) => {
|
||||||
if (!props.onlyBubbleControls) return
|
if (!props.onlyBubbleControls) return false
|
||||||
const { selection } = state
|
const { selection } = state
|
||||||
const { empty } = selection
|
const { empty } = selection
|
||||||
return view.hasFocus() && !empty
|
return view.hasFocus() && !empty
|
||||||
|
@ -145,7 +129,7 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
}),
|
}),
|
||||||
BubbleMenu.configure({
|
BubbleMenu.configure({
|
||||||
pluginKey: 'linkBubbleMenu',
|
pluginKey: 'linkBubbleMenu',
|
||||||
element: linkBubbleMenuRef.current,
|
element: linkBubbleMenuRef,
|
||||||
shouldShow: ({ state }) => {
|
shouldShow: ({ state }) => {
|
||||||
const { selection } = state
|
const { selection } = state
|
||||||
const { empty } = selection
|
const { empty } = selection
|
||||||
|
@ -164,10 +148,17 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
autofocus: props.autoFocus,
|
autofocus: props.autoFocus,
|
||||||
content: content ?? null,
|
content: props.initialContent || null,
|
||||||
}))
|
}))
|
||||||
|
const editorInstance = freshEditor()
|
||||||
|
if (!editorInstance) return
|
||||||
|
setEditor(editorInstance)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
setEditor(editor)
|
|
||||||
const isEmpty = useEditorIsEmpty(() => editor())
|
const isEmpty = useEditorIsEmpty(() => editor())
|
||||||
const isFocused = useEditorIsFocused(() => editor())
|
const isFocused = useEditorIsFocused(() => editor())
|
||||||
|
|
||||||
|
@ -187,7 +178,7 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
|
|
||||||
const renderImage = (image: UploadedFile) => {
|
const renderImage = (image: UploadedFile) => {
|
||||||
editor()
|
editor()
|
||||||
.chain()
|
?.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.insertContent({
|
.insertContent({
|
||||||
type: 'figure',
|
type: 'figure',
|
||||||
|
@ -211,20 +202,20 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
if (props.onCancel) {
|
if (props.onCancel) {
|
||||||
props.onCancel()
|
props.onCancel()
|
||||||
}
|
}
|
||||||
editor().commands.clearContent(true)
|
editor()?.commands.clearContent(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.setClear) {
|
if (props.setClear) {
|
||||||
editor().commands.clearContent(true)
|
editor()?.commands.clearContent(true)
|
||||||
}
|
}
|
||||||
if (props.resetToInitial) {
|
if (props.resetToInitial) {
|
||||||
editor().commands.clearContent(true)
|
editor()?.commands.clearContent(true)
|
||||||
editor().commands.setContent(props.initialContent)
|
if (props.initialContent) editor()?.commands.setContent(props.initialContent)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleKeyDown = (event) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (isEmpty() || !isFocused()) {
|
if (isEmpty() || !isFocused()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -235,7 +226,7 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
|
|
||||||
if (event.code === 'Enter' && props.submitByCtrlEnter && (event.metaKey || event.ctrlKey)) {
|
if (event.code === 'Enter' && props.submitByCtrlEnter && (event.metaKey || event.ctrlKey)) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
props.onSubmit(html())
|
props.onSubmit?.(html() || '')
|
||||||
handleClear()
|
handleClear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -256,13 +247,13 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
|
|
||||||
if (props.onChange) {
|
if (props.onChange) {
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
props.onChange(html())
|
props.onChange?.(html() || '')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (html()) {
|
if (html()) {
|
||||||
setCounter(editor().storage.characterCount.characters())
|
setCounter(editor()?.storage.characterCount.characters())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -272,19 +263,19 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleShowLinkBubble = () => {
|
const handleShowLinkBubble = () => {
|
||||||
editor().chain().focus().run()
|
editor()?.chain().focus().run()
|
||||||
setShouldShowLinkBubbleMenu(true)
|
setShouldShowLinkBubbleMenu(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleHideLinkBubble = () => {
|
const handleHideLinkBubble = () => {
|
||||||
editor().commands.focus()
|
editor()?.commands.focus()
|
||||||
setShouldShowLinkBubbleMenu(false)
|
setShouldShowLinkBubbleMenu(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ShowOnlyOnClient>
|
<ShowOnlyOnClient>
|
||||||
<div
|
<div
|
||||||
ref={(el) => (wrapperEditorElRef.current = el)}
|
ref={(el) => (wrapperEditorElRef = el)}
|
||||||
class={clsx(styles.SimplifiedEditor, {
|
class={clsx(styles.SimplifiedEditor, {
|
||||||
[styles.smallHeight]: props.smallHeight,
|
[styles.smallHeight]: props.smallHeight,
|
||||||
[styles.minimal]: props.variant === 'minimal',
|
[styles.minimal]: props.variant === 'minimal',
|
||||||
|
@ -299,17 +290,17 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
<Show when={props.label && counter() > 0}>
|
<Show when={props.label && counter() > 0}>
|
||||||
<div class={styles.label}>{props.label}</div>
|
<div class={styles.label}>{props.label}</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div style={props.maxHeight && maxHeightStyle} ref={(el) => (editorElRef.current = el)} />
|
<div style={props.maxHeight ? maxHeightStyle : undefined} ref={setEditorElement} />
|
||||||
<Show when={!props.onlyBubbleControls}>
|
<Show when={!props.onlyBubbleControls}>
|
||||||
<div class={clsx(styles.controls, { [styles.alwaysVisible]: props.controlsAlwaysVisible })}>
|
<div class={clsx(styles.controls, { [styles.alwaysVisible]: props.controlsAlwaysVisible })}>
|
||||||
<div class={styles.actions}>
|
<div class={styles.actions}>
|
||||||
<Popover content={t('Bold')}>
|
<Popover content={t('Bold')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
class={clsx(styles.actionButton, { [styles.active]: isBold() })}
|
class={clsx(styles.actionButton, { [styles.active]: isBold() })}
|
||||||
onClick={() => editor().chain().focus().toggleBold().run()}
|
onClick={() => editor()?.chain().focus().toggleBold().run()}
|
||||||
>
|
>
|
||||||
<Icon name="editor-bold" />
|
<Icon name="editor-bold" />
|
||||||
</button>
|
</button>
|
||||||
|
@ -321,7 +312,7 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
class={clsx(styles.actionButton, { [styles.active]: isItalic() })}
|
class={clsx(styles.actionButton, { [styles.active]: isItalic() })}
|
||||||
onClick={() => editor().chain().focus().toggleItalic().run()}
|
onClick={() => editor()?.chain().focus().toggleItalic().run()}
|
||||||
>
|
>
|
||||||
<Icon name="editor-italic" />
|
<Icon name="editor-italic" />
|
||||||
</button>
|
</button>
|
||||||
|
@ -345,7 +336,7 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => editor().chain().focus().toggleBlockquote().run()}
|
onClick={() => editor()?.chain().focus().toggleBlockquote().run()}
|
||||||
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
|
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
|
||||||
>
|
>
|
||||||
<Icon name="editor-quote" />
|
<Icon name="editor-quote" />
|
||||||
|
@ -378,7 +369,7 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
value={props.submitButtonText ?? t('Send')}
|
value={props.submitButtonText ?? t('Send')}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={isEmpty()}
|
disabled={isEmpty()}
|
||||||
onClick={() => props.onSubmit(html())}
|
onClick={() => props.onSubmit?.(html() || '')}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
@ -390,7 +381,7 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
<Modal variant="narrow" name="simplifiedEditorUploadImage">
|
<Modal variant="narrow" name="simplifiedEditorUploadImage">
|
||||||
<UploadModalContent
|
<UploadModalContent
|
||||||
onClose={(value) => {
|
onClose={(value) => {
|
||||||
renderImage(value)
|
renderImage(value as UploadedFile)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@ -400,13 +391,13 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
<TextBubbleMenu
|
<TextBubbleMenu
|
||||||
shouldShow={true}
|
shouldShow={true}
|
||||||
isCommonMarkup={true}
|
isCommonMarkup={true}
|
||||||
editor={editor()}
|
editor={editor() as Editor}
|
||||||
ref={(el) => (textBubbleMenuRef.current = el)}
|
ref={(el) => (textBubbleMenuRef = el)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<LinkBubbleMenuModule
|
<LinkBubbleMenuModule
|
||||||
editor={editor()}
|
editor={editor() as Editor}
|
||||||
ref={(el) => (linkBubbleMenuRef.current = el)}
|
ref={(el) => (linkBubbleMenuRef = el)}
|
||||||
onClose={handleHideLinkBubble}
|
onClose={handleHideLinkBubble}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -23,7 +23,7 @@ type BubbleMenuProps = {
|
||||||
export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
|
|
||||||
const isActive = (name: string, attributes?: unknown) =>
|
const isActive = (name: string, attributes?: Record<string, string | number>) =>
|
||||||
createEditorTransaction(
|
createEditorTransaction(
|
||||||
() => props.editor,
|
() => props.editor,
|
||||||
(editor) => editor?.isActive(name, attributes),
|
(editor) => editor?.isActive(name, attributes),
|
||||||
|
@ -71,7 +71,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
||||||
}
|
}
|
||||||
setListBubbleOpen((prev) => !prev)
|
setListBubbleOpen((prev) => !prev)
|
||||||
}
|
}
|
||||||
const handleKeyDown = (event) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.code === 'KeyK' && (event.metaKey || event.ctrlKey) && !props.editor.state.selection.empty) {
|
if (event.code === 'KeyK' && (event.metaKey || event.ctrlKey) && !props.editor.state.selection.empty) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setLinkEditorOpen(true)
|
setLinkEditorOpen(true)
|
||||||
|
@ -89,9 +89,9 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleAddFootnote = (footnote) => {
|
const handleAddFootnote = (footnote: string) => {
|
||||||
if (footNote()) {
|
if (footNote()) {
|
||||||
props.editor.chain().focus().updateFootnote(footnote).run()
|
props.editor.chain().focus().updateFootnote({ value: footnote }).run()
|
||||||
} else {
|
} else {
|
||||||
props.editor.chain().focus().setFootnote({ value: footnote }).run()
|
props.editor.chain().focus().setFootnote({ value: footnote }).run()
|
||||||
}
|
}
|
||||||
|
@ -180,7 +180,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
||||||
<header>{t('Headers')}</header>
|
<header>{t('Headers')}</header>
|
||||||
<div class={styles.actions}>
|
<div class={styles.actions}>
|
||||||
<Popover content={t('Header 1')}>
|
<Popover content={t('Header 1')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -197,7 +197,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={t('Header 2')}>
|
<Popover content={t('Header 2')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -214,7 +214,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={t('Header 3')}>
|
<Popover content={t('Header 3')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -234,7 +234,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
||||||
<header>{t('Quotes')}</header>
|
<header>{t('Quotes')}</header>
|
||||||
<div class={styles.actions}>
|
<div class={styles.actions}>
|
||||||
<Popover content={t('Quote')}>
|
<Popover content={t('Quote')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -248,7 +248,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={t('Punchline')}>
|
<Popover content={t('Punchline')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -265,7 +265,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
||||||
<header>{t('squib')}</header>
|
<header>{t('squib')}</header>
|
||||||
<div class={styles.actions}>
|
<div class={styles.actions}>
|
||||||
<Popover content={t('Incut')}>
|
<Popover content={t('Incut')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -289,7 +289,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
||||||
</>
|
</>
|
||||||
</Show>
|
</Show>
|
||||||
<Popover content={t('Bold')}>
|
<Popover content={t('Bold')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -303,7 +303,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={t('Italic')}>
|
<Popover content={t('Italic')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -319,7 +319,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
||||||
|
|
||||||
<Show when={!props.isCommonMarkup}>
|
<Show when={!props.isCommonMarkup}>
|
||||||
<Popover content={t('Highlight')}>
|
<Popover content={t('Highlight')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -335,7 +335,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
||||||
<div class={styles.delimiter} />
|
<div class={styles.delimiter} />
|
||||||
</Show>
|
</Show>
|
||||||
<Popover content={<div class={styles.noWrap}>{t('Add url')}</div>}>
|
<Popover content={<div class={styles.noWrap}>{t('Add url')}</div>}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -351,7 +351,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
||||||
<Show when={!props.isCommonMarkup}>
|
<Show when={!props.isCommonMarkup}>
|
||||||
<>
|
<>
|
||||||
<Popover content={t('Insert footnote')}>
|
<Popover content={t('Insert footnote')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -381,7 +381,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
||||||
<header>{t('Lists')}</header>
|
<header>{t('Lists')}</header>
|
||||||
<div class={styles.actions}>
|
<div class={styles.actions}>
|
||||||
<Popover content={t('Bullet list')}>
|
<Popover content={t('Bullet list')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -398,7 +398,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={t('Ordered list')}>
|
<Popover content={t('Ordered list')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
@ -21,3 +21,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.TopicSelect .solid-select-list {
|
||||||
|
background: #fff;
|
||||||
|
position: relative;
|
||||||
|
z-index: 13;
|
||||||
|
}
|
||||||
|
|
||||||
|
.TopicSelect .solid-select-option[data-disabled='true'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
|
@ -1,16 +1,7 @@
|
||||||
import type { Topic } from '../../../graphql/schema/core.gen'
|
|
||||||
|
|
||||||
import { Select, createOptions } from '@thisbeyond/solid-select'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { createSignal } from 'solid-js'
|
import { For, Show, createSignal } from 'solid-js'
|
||||||
|
import type { Topic } from '~/graphql/schema/core.gen'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { clone } from '../../../utils/clone'
|
|
||||||
import { slugify } from '../../../utils/slugify'
|
|
||||||
|
|
||||||
import '@thisbeyond/solid-select/style.css'
|
|
||||||
import './TopicSelect.scss'
|
|
||||||
|
|
||||||
import styles from './TopicSelect.module.scss'
|
import styles from './TopicSelect.module.scss'
|
||||||
|
|
||||||
type TopicSelectProps = {
|
type TopicSelectProps = {
|
||||||
|
@ -23,65 +14,80 @@ type TopicSelectProps = {
|
||||||
|
|
||||||
export const TopicSelect = (props: TopicSelectProps) => {
|
export const TopicSelect = (props: TopicSelectProps) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
|
const [isOpen, setIsOpen] = createSignal(false)
|
||||||
|
const [searchTerm, setSearchTerm] = createSignal('')
|
||||||
|
|
||||||
const [isDisabled, setIsDisabled] = createSignal(false)
|
const handleChange = (topic: Topic) => {
|
||||||
|
const isSelected = props.selectedTopics.some((selectedTopic) => selectedTopic.slug === topic.slug)
|
||||||
|
let newSelectedTopics: Topic[]
|
||||||
|
|
||||||
const createValue = (title): Topic => {
|
if (isSelected) {
|
||||||
const minId = Math.min(...props.selectedTopics.map((topic) => topic.id))
|
newSelectedTopics = props.selectedTopics.filter((selectedTopic) => selectedTopic.slug !== topic.slug)
|
||||||
const id = minId < 0 ? minId - 1 : -2
|
} else {
|
||||||
return { id, title, slug: slugify(title) }
|
newSelectedTopics = [...props.selectedTopics, topic]
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectProps = createOptions(props.topics, {
|
props.onChange(newSelectedTopics)
|
||||||
key: 'title',
|
|
||||||
disable: (topic) => {
|
|
||||||
return props.selectedTopics.some((selectedTopic) => selectedTopic.slug === topic.slug)
|
|
||||||
},
|
|
||||||
createable: createValue,
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleChange = (selectedTopics: Topic[]) => {
|
|
||||||
props.onChange(selectedTopics)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectedItemClick = (topic: Topic) => {
|
const handleMainTopicChange = (topic: Topic) => {
|
||||||
setIsDisabled(true)
|
|
||||||
props.onMainTopicChange(topic)
|
props.onMainTopicChange(topic)
|
||||||
setIsDisabled(false)
|
setIsOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const format = (item, type) => {
|
const handleSearch = (event: InputEvent) => {
|
||||||
if (type === 'option') {
|
setSearchTerm((event.currentTarget as HTMLInputElement).value)
|
||||||
// eslint-disable-next-line solid/components-return-once
|
|
||||||
return item.label
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMainTopic = item.id === props.mainTopic?.id
|
const filteredTopics = () => {
|
||||||
|
return props.topics.filter((topic: Topic) =>
|
||||||
|
topic?.title?.toLowerCase().includes(searchTerm().toLowerCase()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div class="TopicSelect">
|
||||||
|
<div class={styles.selectedTopics}>
|
||||||
|
<For each={props.selectedTopics}>
|
||||||
|
{(topic) => (
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.selectedItem, {
|
class={clsx(styles.selectedTopic, {
|
||||||
[styles.mainTopic]: isMainTopic,
|
[styles.mainTopic]: props.mainTopic?.slug === topic.slug,
|
||||||
})}
|
})}
|
||||||
onClick={() => handleSelectedItemClick(item)}
|
onClick={() => handleMainTopicChange(topic)}
|
||||||
>
|
>
|
||||||
{item.title}
|
{topic.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div class={styles.selectWrapper} onClick={() => setIsOpen(true)}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('Topics')}
|
||||||
|
class={styles.searchInput}
|
||||||
|
value={searchTerm()}
|
||||||
|
onInput={handleSearch}
|
||||||
|
/>
|
||||||
|
<Show when={isOpen()}>
|
||||||
|
<div class={styles.options}>
|
||||||
|
<For each={filteredTopics()}>
|
||||||
|
{(topic) => (
|
||||||
|
<div
|
||||||
|
class={clsx(styles.option, {
|
||||||
|
[styles.disabled]: props.selectedTopics.some(
|
||||||
|
(selectedTopic) => selectedTopic.slug === topic.slug,
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
onClick={() => handleChange(topic)}
|
||||||
|
>
|
||||||
|
{topic.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
const initialValue = clone(props.selectedTopics)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
multiple={true}
|
|
||||||
disabled={isDisabled()}
|
|
||||||
initialValue={initialValue}
|
|
||||||
{...selectProps}
|
|
||||||
format={format}
|
|
||||||
placeholder={t('Topics')}
|
|
||||||
class="TopicSelect"
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,9 +2,10 @@ import { UploadFile, createDropzone, createFileUploader } from '@solid-primitive
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show, createSignal } from 'solid-js'
|
import { Show, createSignal } from 'solid-js'
|
||||||
|
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
|
import { UploadedFile } from '~/types/upload'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { UploadedFile } from '../../../pages/types'
|
import { useSession } from '../../../context/session'
|
||||||
import { hideModal } from '../../../stores/ui'
|
|
||||||
import { handleImageUpload } from '../../../utils/handleImageUpload'
|
import { handleImageUpload } from '../../../utils/handleImageUpload'
|
||||||
import { verifyImg } from '../../../utils/verifyImg'
|
import { verifyImg } from '../../../utils/verifyImg'
|
||||||
import { Button } from '../../_shared/Button'
|
import { Button } from '../../_shared/Button'
|
||||||
|
@ -12,7 +13,6 @@ import { Icon } from '../../_shared/Icon'
|
||||||
import { Loading } from '../../_shared/Loading'
|
import { Loading } from '../../_shared/Loading'
|
||||||
import { InlineForm } from '../InlineForm'
|
import { InlineForm } from '../InlineForm'
|
||||||
|
|
||||||
import { useSession } from '../../../context/session'
|
|
||||||
import styles from './UploadModalContent.module.scss'
|
import styles from './UploadModalContent.module.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -21,6 +21,7 @@ type Props = {
|
||||||
|
|
||||||
export const UploadModalContent = (props: Props) => {
|
export const UploadModalContent = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
|
const { hideModal } = useUI()
|
||||||
const [isUploading, setIsUploading] = createSignal(false)
|
const [isUploading, setIsUploading] = createSignal(false)
|
||||||
const [uploadError, setUploadError] = createSignal<string | undefined>()
|
const [uploadError, setUploadError] = createSignal<string | undefined>()
|
||||||
const [dragActive, setDragActive] = createSignal(false)
|
const [dragActive, setDragActive] = createSignal(false)
|
||||||
|
@ -30,7 +31,7 @@ export const UploadModalContent = (props: Props) => {
|
||||||
const runUpload = async (file: UploadFile) => {
|
const runUpload = async (file: UploadFile) => {
|
||||||
try {
|
try {
|
||||||
setIsUploading(true)
|
setIsUploading(true)
|
||||||
const result = await handleImageUpload(file, session()?.access_token)
|
const result = await handleImageUpload(file, session()?.access_token || '')
|
||||||
props.onClose(result)
|
props.onClose(result)
|
||||||
setIsUploading(false)
|
setIsUploading(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -44,7 +45,9 @@ export const UploadModalContent = (props: Props) => {
|
||||||
try {
|
try {
|
||||||
const data = await fetch(value)
|
const data = await fetch(value)
|
||||||
const blob = await data.blob()
|
const blob = await data.blob()
|
||||||
const file = new File([blob], 'convertedFromUrl', { type: data.headers.get('Content-Type') })
|
const file = new File([blob], 'convertedFromUrl', {
|
||||||
|
type: data.headers.get('Content-Type') || undefined,
|
||||||
|
})
|
||||||
const fileToUpload: UploadFile = {
|
const fileToUpload: UploadFile = {
|
||||||
source: blob.toString(),
|
source: blob.toString(),
|
||||||
name: file.name,
|
name: file.name,
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
import type { MediaItem } from '../../../pages/types'
|
|
||||||
|
|
||||||
import { createDropzone } from '@solid-primitives/upload'
|
import { createDropzone } from '@solid-primitives/upload'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { For, Show, createSignal } from 'solid-js'
|
import { For, Show, createSignal } from 'solid-js'
|
||||||
|
|
||||||
|
import { useSnackbar } from '~/context/ui'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { useSnackbar } from '../../../context/snackbar'
|
|
||||||
import { composeMediaItems } from '../../../utils/composeMediaItems'
|
import { composeMediaItems } from '../../../utils/composeMediaItems'
|
||||||
import { validateUrl } from '../../../utils/validateUrl'
|
import { validateUrl } from '../../../utils/validateUrl'
|
||||||
import { VideoPlayer } from '../../_shared/VideoPlayer'
|
import { VideoPlayer } from '../../_shared/VideoPlayer'
|
||||||
|
|
||||||
|
import { MediaItem } from '~/types/mediaitem'
|
||||||
import styles from './VideoUploader.module.scss'
|
import styles from './VideoUploader.module.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -23,14 +22,8 @@ export const VideoUploader = (props: Props) => {
|
||||||
const [dragActive, setDragActive] = createSignal(false)
|
const [dragActive, setDragActive] = createSignal(false)
|
||||||
const [error, setError] = createSignal<string>()
|
const [error, setError] = createSignal<string>()
|
||||||
const [incorrectUrl, setIncorrectUrl] = createSignal<boolean>(false)
|
const [incorrectUrl, setIncorrectUrl] = createSignal<boolean>(false)
|
||||||
|
|
||||||
const { showSnackbar } = useSnackbar()
|
const { showSnackbar } = useSnackbar()
|
||||||
|
let urlInput: HTMLInputElement | undefined
|
||||||
const urlInput: {
|
|
||||||
current: HTMLInputElement
|
|
||||||
} = {
|
|
||||||
current: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const { setRef: dropzoneRef, files: droppedFiles } = createDropzone({
|
const { setRef: dropzoneRef, files: droppedFiles } = createDropzone({
|
||||||
onDrop: async () => {
|
onDrop: async () => {
|
||||||
|
@ -48,7 +41,7 @@ export const VideoUploader = (props: Props) => {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const handleDrag = (event) => {
|
const handleDrag = (event: DragEvent) => {
|
||||||
if (event.type === 'dragenter' || event.type === 'dragover') {
|
if (event.type === 'dragenter' || event.type === 'dragover') {
|
||||||
setDragActive(true)
|
setDragActive(true)
|
||||||
setError()
|
setError()
|
||||||
|
@ -100,7 +93,7 @@ export const VideoUploader = (props: Props) => {
|
||||||
<div class={styles.inputHolder}>
|
<div class={styles.inputHolder}>
|
||||||
<input
|
<input
|
||||||
class={clsx(styles.urlInput, { [styles.hasError]: incorrectUrl() })}
|
class={clsx(styles.urlInput, { [styles.hasError]: incorrectUrl() })}
|
||||||
ref={(el) => (urlInput.current = el)}
|
ref={(el) => (urlInput = el)}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('Insert video link')}
|
placeholder={t('Insert video link')}
|
||||||
onChange={(event) => handleUrlInput(event.currentTarget.value)}
|
onChange={(event) => handleUrlInput(event.currentTarget.value)}
|
||||||
|
|
|
@ -27,7 +27,7 @@ export const Figure = Node.create({
|
||||||
'data-type': { default: null },
|
'data-type': { default: null },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// @ts-ignore FIXME: why
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
|
@ -39,7 +39,7 @@ export const ToggleTextWrap = Extension.create({
|
||||||
})
|
})
|
||||||
|
|
||||||
if (changesApplied) {
|
if (changesApplied) {
|
||||||
dispatch(tr)
|
dispatch?.(tr)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|
|
@ -1,7 +1,15 @@
|
||||||
import { Extension } from '@tiptap/core'
|
import { Extension } from '@tiptap/core'
|
||||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||||
|
|
||||||
function nodeEqualsType({ types, node }) {
|
function nodeEqualsType({
|
||||||
|
types,
|
||||||
|
node,
|
||||||
|
}: {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: FIXME: any in editor extension
|
||||||
|
types: any
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: FIXME: any in editor extension
|
||||||
|
node: any
|
||||||
|
}) {
|
||||||
return (Array.isArray(types) && types.includes(node.type)) || node.type === types
|
return (Array.isArray(types) && types.includes(node.type)) || node.type === types
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export { Editor } from './Editor'
|
export { EditorComponent as Editor } from './Editor'
|
||||||
export { Panel } from './Panel'
|
export { Panel } from './Panel'
|
||||||
export { TopicSelect } from './TopicSelect'
|
export { TopicSelect } from './TopicSelect'
|
||||||
export { UploadModalContent } from './UploadModalContent'
|
export { UploadModalContent } from './UploadModalContent'
|
||||||
|
|
|
@ -1,25 +1,21 @@
|
||||||
import type { Author, Shout, Topic } from '../../../graphql/schema/core.gen'
|
import { A, useNavigate, useSearchParams } from '@solidjs/router'
|
||||||
|
|
||||||
import { getPagePath, openPage } from '@nanostores/router'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { For, Show, createMemo, createSignal } from 'solid-js'
|
import { Accessor, For, Show, createMemo, createSignal } from 'solid-js'
|
||||||
|
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '../../../context/session'
|
||||||
import { router, useRouter } from '../../../stores/router'
|
import type { Author, Maybe, Shout, Topic } from '../../../graphql/schema/core.gen'
|
||||||
import { capitalize } from '../../../utils/capitalize'
|
import { capitalize } from '../../../utils/capitalize'
|
||||||
import { getDescription } from '../../../utils/meta'
|
import { getDescription } from '../../../utils/meta'
|
||||||
import { CoverImage } from '../../Article/CoverImage'
|
import { CoverImage } from '../../Article/CoverImage'
|
||||||
import { SharePopup, getShareUrl } from '../../Article/SharePopup'
|
import { SharePopup, getShareUrl } from '../../Article/SharePopup'
|
||||||
import { ShoutRatingControl } from '../../Article/ShoutRatingControl'
|
import { ShoutRatingControl } from '../../Article/ShoutRatingControl'
|
||||||
import { AuthorLink } from '../../Author/AuthorLink'
|
import { AuthorLink } from '../../Author/AuthorLink'
|
||||||
|
import stylesHeader from '../../Nav/Header/Header.module.scss'
|
||||||
import { Icon } from '../../_shared/Icon'
|
import { Icon } from '../../_shared/Icon'
|
||||||
import { Image } from '../../_shared/Image'
|
import { Image } from '../../_shared/Image'
|
||||||
import { Popover } from '../../_shared/Popover'
|
import { Popover } from '../../_shared/Popover'
|
||||||
import { CardTopic } from '../CardTopic'
|
import { CardTopic } from '../CardTopic'
|
||||||
import { FeedArticlePopup } from '../FeedArticlePopup'
|
import { FeedArticlePopup } from '../FeedArticlePopup'
|
||||||
|
|
||||||
import stylesHeader from '../../Nav/Header/Header.module.scss'
|
|
||||||
import styles from './ArticleCard.module.scss'
|
import styles from './ArticleCard.module.scss'
|
||||||
|
|
||||||
export type ArticleCardProps = {
|
export type ArticleCardProps = {
|
||||||
|
@ -47,13 +43,13 @@ export type ArticleCardProps = {
|
||||||
noAuthorLink?: boolean
|
noAuthorLink?: boolean
|
||||||
}
|
}
|
||||||
withAspectRatio?: boolean
|
withAspectRatio?: boolean
|
||||||
desktopCoverSize?: 'XS' | 'S' | 'M' | 'L'
|
desktopCoverSize?: string // 'XS' | 'S' | 'M' | 'L'
|
||||||
article: Shout
|
article: Shout
|
||||||
onShare?: (article: Shout) => void
|
onShare?: (article: Shout) => void
|
||||||
onInvite?: () => void
|
onInvite?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const desktopCoverImageWidths: Record<ArticleCardProps['desktopCoverSize'], number> = {
|
const desktopCoverImageWidths: Record<string, number> = {
|
||||||
XS: 300,
|
XS: 300,
|
||||||
S: 400,
|
S: 400,
|
||||||
M: 600,
|
M: 600,
|
||||||
|
@ -90,14 +86,14 @@ const getTitleAndSubtitle = (
|
||||||
|
|
||||||
const getMainTopicTitle = (article: Shout, lng: string) => {
|
const getMainTopicTitle = (article: Shout, lng: string) => {
|
||||||
const mainTopicSlug = article?.main_topic || ''
|
const mainTopicSlug = article?.main_topic || ''
|
||||||
const mainTopic = article?.topics?.find((tpc: Topic) => tpc.slug === mainTopicSlug)
|
const mainTopic = (article?.topics || []).find((tpc: Maybe<Topic>) => tpc?.slug === mainTopicSlug)
|
||||||
const mainTopicTitle =
|
const mainTopicTitle =
|
||||||
mainTopicSlug && lng === 'en' ? mainTopicSlug.replace(/-/, ' ') : mainTopic?.title || ''
|
mainTopicSlug && lng === 'en' ? mainTopicSlug.replace(/-/, ' ') : mainTopic?.title || ''
|
||||||
|
|
||||||
return [mainTopicTitle, mainTopicSlug]
|
return [mainTopicTitle, mainTopicSlug]
|
||||||
}
|
}
|
||||||
|
|
||||||
const LAYOUT_ASPECT = {
|
const LAYOUT_ASPECT: { [key: string]: string } = {
|
||||||
music: styles.aspectRatio1x1,
|
music: styles.aspectRatio1x1,
|
||||||
audio: styles.aspectRatio1x1,
|
audio: styles.aspectRatio1x1,
|
||||||
literature: styles.aspectRatio16x9,
|
literature: styles.aspectRatio16x9,
|
||||||
|
@ -107,13 +103,14 @@ const LAYOUT_ASPECT = {
|
||||||
|
|
||||||
export const ArticleCard = (props: ArticleCardProps) => {
|
export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
const { t, lang, formatDate } = useLocalize()
|
const { t, lang, formatDate } = useLocalize()
|
||||||
const { author, session } = useSession()
|
const { session } = useSession()
|
||||||
const { changeSearchParams } = useRouter()
|
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
||||||
|
const [, changeSearchParams] = useSearchParams()
|
||||||
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
|
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
|
||||||
const [isCoverImageLoadError, setIsCoverImageLoadError] = createSignal(false)
|
const [isCoverImageLoadError, setIsCoverImageLoadError] = createSignal(false)
|
||||||
const [isCoverImageLoading, setIsCoverImageLoading] = createSignal(true)
|
const [isCoverImageLoading, setIsCoverImageLoading] = createSignal(true)
|
||||||
const description = getDescription(props.article?.body)
|
const description = getDescription(props.article?.body)
|
||||||
const aspectRatio = () => LAYOUT_ASPECT[props.article?.layout]
|
const aspectRatio: Accessor<string> = () => LAYOUT_ASPECT[props.article?.layout as string]
|
||||||
const [mainTopicTitle, mainTopicSlug] = getMainTopicTitle(props.article, lang())
|
const [mainTopicTitle, mainTopicSlug] = getMainTopicTitle(props.article, lang())
|
||||||
const { title, subtitle } = getTitleAndSubtitle(props.article)
|
const { title, subtitle } = getTitleAndSubtitle(props.article)
|
||||||
|
|
||||||
|
@ -126,17 +123,21 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
Boolean(author()?.id) &&
|
Boolean(author()?.id) &&
|
||||||
(props.article?.authors?.some((a) => Boolean(a) && a?.id === author().id) ||
|
(props.article?.authors?.some((a) => Boolean(a) && a?.id === author().id) ||
|
||||||
props.article?.created_by?.id === author().id ||
|
props.article?.created_by?.id === author().id ||
|
||||||
session()?.user?.roles.includes('editor')),
|
session()?.user?.roles?.includes('editor')),
|
||||||
)
|
)
|
||||||
|
const navigate = useNavigate()
|
||||||
const scrollToComments = (event) => {
|
const scrollToComments = (event: MouseEvent & { currentTarget: HTMLAnchorElement; target: Element }) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
openPage(router, 'article', { slug: props.article.slug })
|
navigate(`/article/${props.article.slug}`)
|
||||||
changeSearchParams({
|
changeSearchParams({
|
||||||
scrollTo: 'comments',
|
scrollTo: 'comments',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onInvite = () => {
|
||||||
|
if (props.onInvite) props.onInvite()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
class={clsx(styles.shoutCard, props.settings?.additionalClass, {
|
class={clsx(styles.shoutCard, props.settings?.additionalClass, {
|
||||||
|
@ -169,9 +170,9 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
fallback={<CoverImage class={styles.placeholderCoverImage} />}
|
fallback={<CoverImage class={styles.placeholderCoverImage} />}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={props.article.cover}
|
src={props.article.cover || ''}
|
||||||
alt={title}
|
alt={title}
|
||||||
width={desktopCoverImageWidths[props.desktopCoverSize]}
|
width={desktopCoverImageWidths[props.desktopCoverSize || 'M']}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
setIsCoverImageLoadError(true)
|
setIsCoverImageLoadError(true)
|
||||||
setIsCoverImageLoading(false)
|
setIsCoverImageLoading(false)
|
||||||
|
@ -209,7 +210,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
slug={mainTopicSlug}
|
slug={mainTopicSlug}
|
||||||
isFloorImportant={props.settings?.isFloorImportant}
|
isFloorImportant={props.settings?.isFloorImportant}
|
||||||
isFeedMode={true}
|
isFeedMode={true}
|
||||||
class={clsx(styles.shoutTopic, { [styles.shoutTopicTop]: props.settings.isShort })}
|
class={clsx(styles.shoutTopic, { [styles.shoutTopicTop]: props.settings?.isShort })}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
@ -219,7 +220,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
[styles.shoutCardTitlesContainerFeedMode]: props.settings?.isFeedMode,
|
[styles.shoutCardTitlesContainerFeedMode]: props.settings?.isFeedMode,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<a href={getPagePath(router, 'article', { slug: props.article.slug })}>
|
<A href={`/article${props.article.slug}`}>
|
||||||
<div class={styles.shoutCardTitle}>
|
<div class={styles.shoutCardTitle}>
|
||||||
<span class={styles.shoutCardLinkWrapper}>
|
<span class={styles.shoutCardLinkWrapper}>
|
||||||
<span class={styles.shoutCardLinkContainer} innerHTML={title} />
|
<span class={styles.shoutCardLinkContainer} innerHTML={title} />
|
||||||
|
@ -231,7 +232,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
<span class={styles.shoutCardLinkContainer} innerHTML={subtitle} />
|
<span class={styles.shoutCardLinkContainer} innerHTML={subtitle} />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</a>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details */}
|
{/* Details */}
|
||||||
|
@ -243,11 +244,13 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
<Show when={!props.settings?.noauthor}>
|
<Show when={!props.settings?.noauthor}>
|
||||||
<div class={styles.shoutAuthor}>
|
<div class={styles.shoutAuthor}>
|
||||||
<For each={props.article.authors}>
|
<For each={props.article.authors}>
|
||||||
{(a: Author) => (
|
{(a: Maybe<Author>) => (
|
||||||
<AuthorLink
|
<AuthorLink
|
||||||
size={'XS'}
|
size={'XS'}
|
||||||
author={a}
|
author={a as Author}
|
||||||
isFloorImportant={props.settings.isFloorImportant || props.settings?.isWithCover}
|
isFloorImportant={Boolean(
|
||||||
|
props.settings?.isFloorImportant || props.settings?.isWithCover,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
@ -261,11 +264,11 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<Show when={props.article.description}>
|
<Show when={props.article.description}>
|
||||||
<section class={styles.shoutCardDescription} innerHTML={props.article.description} />
|
<section class={styles.shoutCardDescription} innerHTML={props.article.description || ''} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.settings?.isFeedMode}>
|
<Show when={props.settings?.isFeedMode}>
|
||||||
<Show when={props.article.description}>
|
<Show when={props.article.description}>
|
||||||
<section class={styles.shoutCardDescription} innerHTML={props.article.description} />
|
<section class={styles.shoutCardDescription} innerHTML={props.article.description || ''} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!props.settings?.noimage && props.article.cover}>
|
<Show when={!props.settings?.noimage && props.article.cover}>
|
||||||
<div class={styles.shoutCardCoverContainer}>
|
<div class={styles.shoutCardCoverContainer}>
|
||||||
|
@ -284,7 +287,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class={styles.shoutCardCover}>
|
<div class={styles.shoutCardCover}>
|
||||||
<Image src={props.article.cover} alt={title} width={600} loading="lazy" />
|
<Image src={props.article.cover || ''} alt={title} width={600} loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
@ -327,22 +330,22 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
<div class={styles.shoutCardDetailsContent}>
|
<div class={styles.shoutCardDetailsContent}>
|
||||||
<Show when={canEdit()}>
|
<Show when={canEdit()}>
|
||||||
<Popover content={t('Edit')} disabled={isActionPopupActive()}>
|
<Popover content={t('Edit')} disabled={isActionPopupActive()}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
|
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
|
||||||
<a href={getPagePath(router, 'edit', { shoutId: props.article?.id.toString() })}>
|
<A href={`/edit/${props.article?.id}`}>
|
||||||
<Icon name="pencil-outline" class={clsx(styles.icon, styles.feedControlIcon)} />
|
<Icon name="pencil-outline" class={clsx(styles.icon, styles.feedControlIcon)} />
|
||||||
<Icon
|
<Icon
|
||||||
name="pencil-outline-hover"
|
name="pencil-outline-hover"
|
||||||
class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)}
|
class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)}
|
||||||
/>
|
/>
|
||||||
</a>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Popover content={t('Add to bookmarks')} disabled={isActionPopupActive()}>
|
<Popover content={t('Add to bookmarks')} disabled={isActionPopupActive()}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
|
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
|
||||||
<button>
|
<button>
|
||||||
<Icon name="bookmark" class={clsx(styles.icon, styles.feedControlIcon)} />
|
<Icon name="bookmark" class={clsx(styles.icon, styles.feedControlIcon)} />
|
||||||
|
@ -356,13 +359,13 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
<Popover content={t('Share')} disabled={isActionPopupActive()}>
|
<Popover content={t('Share')} disabled={isActionPopupActive()}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
|
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
|
||||||
<SharePopup
|
<SharePopup
|
||||||
containerCssClass={stylesHeader.control}
|
containerCssClass={stylesHeader.control}
|
||||||
title={title}
|
title={title}
|
||||||
description={description}
|
description={description}
|
||||||
imageUrl={props.article.cover}
|
imageUrl={props.article.cover || ''}
|
||||||
shareUrl={getShareUrl({ pathname: `/${props.article.slug}` })}
|
shareUrl={getShareUrl({ pathname: `/${props.article.slug}` })}
|
||||||
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
|
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
|
||||||
trigger={
|
trigger={
|
||||||
|
@ -381,10 +384,10 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
|
|
||||||
<div class={styles.shoutCardDetailsItem}>
|
<div class={styles.shoutCardDetailsItem}>
|
||||||
<FeedArticlePopup
|
<FeedArticlePopup
|
||||||
canEdit={canEdit()}
|
canEdit={Boolean(canEdit())}
|
||||||
containerCssClass={stylesHeader.control}
|
containerCssClass={stylesHeader.control}
|
||||||
onShareClick={() => props.onShare(props.article)}
|
onShareClick={() => props.onShare?.(props.article)}
|
||||||
onInviteClick={props.onInvite}
|
onInviteClick={onInvite}
|
||||||
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
|
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
|
||||||
trigger={
|
trigger={
|
||||||
<button>
|
<button>
|
||||||
|
|
|
@ -39,7 +39,7 @@ export const Beside = (props: Props) => {
|
||||||
class={clsx(
|
class={clsx(
|
||||||
'col-lg-8',
|
'col-lg-8',
|
||||||
styles[
|
styles[
|
||||||
`besideRatingColumn${props.wrapper.charAt(0).toUpperCase() + props.wrapper.slice(1)}`
|
`besideRatingColumn${props.wrapper?.charAt(0)?.toUpperCase() + props.wrapper.slice(1)}` as keyof typeof styles
|
||||||
],
|
],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
import { getPagePath } from '@nanostores/router'
|
import { A } from '@solidjs/router'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
import { router } from '../../stores/router'
|
|
||||||
|
|
||||||
import styles from './CardTopic.module.scss'
|
import styles from './CardTopic.module.scss'
|
||||||
|
|
||||||
type CardTopicProps = {
|
type CardTopicProps = {
|
||||||
|
@ -21,7 +18,7 @@ export const CardTopic = (props: CardTopicProps) => {
|
||||||
[styles.shoutTopicFeedMode]: props.isFeedMode,
|
[styles.shoutTopicFeedMode]: props.isFeedMode,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<a href={getPagePath(router, 'topic', { slug: props.slug })}>{props.title}</a>
|
<A href={`/topic/${props.slug}`}>{props.title}</A>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,98 +1,120 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { For, Show } from 'solid-js'
|
import { For, Show, createMemo } from 'solid-js'
|
||||||
|
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '../../../context/session'
|
||||||
import { Icon } from '../../_shared/Icon'
|
import { Icon } from '../../_shared/Icon'
|
||||||
import styles from './Placeholder.module.scss'
|
import styles from './Placeholder.module.scss'
|
||||||
|
|
||||||
|
type ProfileLink = {
|
||||||
|
href: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlaceholderData = {
|
||||||
|
[key: string]: {
|
||||||
|
image: string
|
||||||
|
header: string
|
||||||
|
text: string
|
||||||
|
buttonLabel?: string
|
||||||
|
buttonLabelAuthor?: string
|
||||||
|
buttonLabelFeed?: string
|
||||||
|
href: string
|
||||||
|
profileLinks?: ProfileLink[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type PlaceholderProps = {
|
export type PlaceholderProps = {
|
||||||
type: string
|
type: keyof PlaceholderData
|
||||||
mode: 'feed' | 'profile'
|
mode: 'feed' | 'profile'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Placeholder = (props: PlaceholderProps) => {
|
const data: PlaceholderData = {
|
||||||
const { t } = useLocalize()
|
|
||||||
const { author } = useSession()
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
feedMy: {
|
feedMy: {
|
||||||
image: 'placeholder-feed.webp',
|
image: 'placeholder-feed.webp',
|
||||||
header: t('Feed settings'),
|
header: 'Feed settings',
|
||||||
text: t('Placeholder feed'),
|
text: 'Placeholder feed',
|
||||||
buttonLabel: author() ? t('Popular authors') : t('Create own feed'),
|
buttonLabelAuthor: 'Popular authors',
|
||||||
|
buttonLabelFeed: 'Create own feed',
|
||||||
href: '/authors?by=followers',
|
href: '/authors?by=followers',
|
||||||
},
|
},
|
||||||
feedCollaborations: {
|
feedCollaborations: {
|
||||||
image: 'placeholder-experts.webp',
|
image: 'placeholder-experts.webp',
|
||||||
header: t('Find collaborators'),
|
header: 'Find collaborators',
|
||||||
text: t('Placeholder feedCollaborations'),
|
text: 'Placeholder feedCollaborations',
|
||||||
buttonLabel: t('Find co-authors'),
|
buttonLabel: 'Find co-authors',
|
||||||
href: '/authors?by=name',
|
href: '/authors?by=name',
|
||||||
},
|
},
|
||||||
feedDiscussions: {
|
feedDiscussions: {
|
||||||
image: 'placeholder-discussions.webp',
|
image: 'placeholder-discussions.webp',
|
||||||
header: t('Participate in discussions'),
|
header: 'Participate in discussions',
|
||||||
text: t('Placeholder feedDiscussions'),
|
text: 'Placeholder feedDiscussions',
|
||||||
buttonLabel: author() ? t('Current discussions') : t('Enter'),
|
buttonLabelAuthor: 'Current discussions',
|
||||||
|
buttonLabelFeed: 'Enter',
|
||||||
href: '/feed?by=last_comment',
|
href: '/feed?by=last_comment',
|
||||||
},
|
},
|
||||||
author: {
|
author: {
|
||||||
image: 'placeholder-join.webp',
|
image: 'placeholder-join.webp',
|
||||||
header: t('Join our team of authors'),
|
header: 'Join our team of authors',
|
||||||
text: t('Join our team of authors text'),
|
text: 'Join our team of authors text',
|
||||||
buttonLabel: t('Create post'),
|
buttonLabel: 'Create post',
|
||||||
href: '/create',
|
href: '/create',
|
||||||
profileLinks: [
|
profileLinks: [
|
||||||
{
|
{
|
||||||
href: '/how-to-write-a-good-article',
|
href: '/how-to-write-a-good-article',
|
||||||
label: t('How to write a good article'),
|
label: 'How to write a good article',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
authorComments: {
|
authorComments: {
|
||||||
image: 'placeholder-discussions.webp',
|
image: 'placeholder-discussions.webp',
|
||||||
header: t('Join discussions'),
|
header: 'Join discussions',
|
||||||
text: t('Placeholder feedDiscussions'),
|
text: 'Placeholder feedDiscussions',
|
||||||
buttonLabel: t('Go to discussions'),
|
buttonLabel: 'Go to discussions',
|
||||||
href: '/feed?by=last_comment',
|
href: '/feed?by=last_comment',
|
||||||
profileLinks: [
|
profileLinks: [
|
||||||
{
|
{
|
||||||
href: '/about/discussion-rules',
|
href: '/about/discussion-rules',
|
||||||
label: t('Discussion rules'),
|
label: 'Discussion rules',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/about/discussion-rules#ban',
|
href: '/about/discussion-rules#ban',
|
||||||
label: t('Block rules'),
|
label: 'Block rules',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const Placeholder = (props: PlaceholderProps) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
|
const { session } = useSession()
|
||||||
|
|
||||||
|
const placeholderData = createMemo(() => data[props.type])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={clsx(
|
class={clsx(
|
||||||
styles.placeholder,
|
styles.placeholder,
|
||||||
styles[`placeholder--${props.type}`],
|
styles[`placeholder--${props.type}` as keyof typeof styles],
|
||||||
styles[`placeholder--${props.mode}-mode`],
|
styles[`placeholder--${props.mode}-mode` as keyof typeof styles],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div class={styles.placeholderCover}>
|
<div class={styles.placeholderCover}>
|
||||||
<img src={`/${data[props.type].image}`} />
|
<img src={`/${placeholderData().image}`} alt={placeholderData().header} />
|
||||||
</div>
|
</div>
|
||||||
<div class={styles.placeholderContent}>
|
<div class={styles.placeholderContent}>
|
||||||
<div>
|
<div>
|
||||||
<h3 innerHTML={data[props.type].header} />
|
<h3 innerHTML={t(placeholderData().header)} />
|
||||||
<p innerHTML={data[props.type].text} />
|
<p innerHTML={t(placeholderData().text)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={data[props.type].profileLinks}>
|
<Show when={placeholderData().profileLinks}>
|
||||||
<div class={styles.bottomLinks}>
|
<div class={styles.bottomLinks}>
|
||||||
<For each={data[props.type].profileLinks}>
|
<For each={placeholderData().profileLinks}>
|
||||||
{(link) => (
|
{(link) => (
|
||||||
<a href={link.href}>
|
<a href={link.href}>
|
||||||
<Icon name="link-white" class={styles.icon} />
|
<Icon name="link-white" class={styles.icon} />
|
||||||
{link.label}
|
{t(link.label)}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
@ -100,15 +122,23 @@ export const Placeholder = (props: PlaceholderProps) => {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={author()}
|
when={session()?.access_token}
|
||||||
fallback={
|
fallback={
|
||||||
<a class={styles.button} href="?m=auth&mode=login">
|
<a class={styles.button} href="?m=auth&mode=login">
|
||||||
{data[props.type].buttonLabel}
|
{t(
|
||||||
|
session()?.access_token
|
||||||
|
? placeholderData()?.buttonLabelAuthor || ''
|
||||||
|
: placeholderData()?.buttonLabelFeed || '',
|
||||||
|
)}
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<a class={styles.button} href={data[props.type].href}>
|
<a class={styles.button} href={placeholderData().href}>
|
||||||
{data[props.type].buttonLabel}
|
{t(
|
||||||
|
session()?.access_token
|
||||||
|
? placeholderData()?.buttonLabelAuthor || ''
|
||||||
|
: placeholderData()?.buttonLabelFeed || '',
|
||||||
|
)}
|
||||||
<Show when={props.mode === 'profile'}>
|
<Show when={props.mode === 'profile'}>
|
||||||
<Icon name="arrow-right-2" class={styles.icon} />
|
<Icon name="arrow-right-2" class={styles.icon} />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
@ -1,80 +1,79 @@
|
||||||
import { getPagePath } from '@nanostores/router'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { For, Show, createSignal } from 'solid-js'
|
import { For, Show, createSignal } from 'solid-js'
|
||||||
|
|
||||||
|
import { A, useMatch } from '@solidjs/router'
|
||||||
|
import { useFeed } from '~/context/feed'
|
||||||
import { useFollowing } from '../../../context/following'
|
import { useFollowing } from '../../../context/following'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { useSeen } from '../../../context/seen'
|
|
||||||
import { Author } from '../../../graphql/schema/core.gen'
|
import { Author } from '../../../graphql/schema/core.gen'
|
||||||
import { router, useRouter } from '../../../stores/router'
|
|
||||||
import { useArticlesStore } from '../../../stores/zine/articles'
|
|
||||||
import { Userpic } from '../../Author/Userpic'
|
import { Userpic } from '../../Author/Userpic'
|
||||||
import { Icon } from '../../_shared/Icon'
|
import { Icon } from '../../_shared/Icon'
|
||||||
import styles from './Sidebar.module.scss'
|
import styles from './Sidebar.module.scss'
|
||||||
|
|
||||||
export const Sidebar = () => {
|
export const Sidebar = () => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { seen } = useSeen()
|
|
||||||
const { follows } = useFollowing()
|
const { follows } = useFollowing()
|
||||||
const { page } = useRouter()
|
const { feedByTopic, feedByAuthor, seen } = useFeed()
|
||||||
const { articlesByTopic, articlesByAuthor } = useArticlesStore()
|
|
||||||
const [isSubscriptionsVisible, setSubscriptionsVisible] = createSignal(true)
|
const [isSubscriptionsVisible, setSubscriptionsVisible] = createSignal(true)
|
||||||
|
const matchFeed = useMatch(() => '/feed')
|
||||||
|
const matchFeedMy = useMatch(() => '/feed/my')
|
||||||
|
const matchFeedCollabs = useMatch(() => '/feed/collabs')
|
||||||
|
const matchFeedDiscussions = useMatch(() => '/feed/discussions')
|
||||||
const checkTopicIsSeen = (topicSlug: string) => {
|
const checkTopicIsSeen = (topicSlug: string) => {
|
||||||
return articlesByTopic()[topicSlug]?.every((article) => Boolean(seen()[article.slug]))
|
return feedByTopic()[topicSlug]?.every((article) => Boolean(seen()[article.slug]))
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkAuthorIsSeen = (authorSlug: string) => {
|
const checkAuthorIsSeen = (authorSlug: string) => {
|
||||||
return articlesByAuthor()[authorSlug]?.every((article) => Boolean(seen()[article.slug]))
|
return feedByAuthor()[authorSlug]?.every((article) => Boolean(seen()[article.slug]))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={styles.sidebar}>
|
<div class={styles.sidebar}>
|
||||||
<ul class={styles.feedFilters}>
|
<ul class={styles.feedFilters}>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<A
|
||||||
href={getPagePath(router, 'feed')}
|
href={'feed'}
|
||||||
class={clsx({
|
class={clsx({
|
||||||
[styles.selected]: page().route === 'feed',
|
[styles.selected]: matchFeed(),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<span class={styles.sidebarItemName}>
|
<span class={styles.sidebarItemName}>
|
||||||
<Icon name="feed-all" class={styles.icon} />
|
<Icon name="feed-all" class={styles.icon} />
|
||||||
{t('Common feed')}
|
{t('Common feed')}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</A>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<A
|
||||||
href={getPagePath(router, 'feedMy')}
|
href={'/feed/my'}
|
||||||
class={clsx({
|
class={clsx({
|
||||||
[styles.selected]: page().route === 'feedMy',
|
[styles.selected]: matchFeedMy(),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<span class={styles.sidebarItemName}>
|
<span class={styles.sidebarItemName}>
|
||||||
<Icon name="feed-my" class={styles.icon} />
|
<Icon name="feed-my" class={styles.icon} />
|
||||||
{t('My feed')}
|
{t('My feed')}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</A>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<A
|
||||||
href={getPagePath(router, 'feedCollaborations')}
|
href={'/feed/collabs'}
|
||||||
class={clsx({
|
class={clsx({
|
||||||
[styles.selected]: page().route === 'feedCollaborations',
|
[styles.selected]: matchFeedCollabs(),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<span class={styles.sidebarItemName}>
|
<span class={styles.sidebarItemName}>
|
||||||
<Icon name="feed-collaborate" class={styles.icon} />
|
<Icon name="feed-collaborate" class={styles.icon} />
|
||||||
{t('Participation')}
|
{t('Participation')}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</A>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href={getPagePath(router, 'feedDiscussions')}
|
href={'/feed/discussions'}
|
||||||
class={clsx({
|
class={clsx({
|
||||||
[styles.selected]: page().route === 'feedDiscussions',
|
[styles.selected]: matchFeedDiscussions(),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<span class={styles.sidebarItemName}>
|
<span class={styles.sidebarItemName}>
|
||||||
|
@ -85,7 +84,7 @@ export const Sidebar = () => {
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<Show when={follows?.authors?.length > 0 || follows?.topics?.length > 0}>
|
<Show when={(follows?.authors?.length || 0) > 0 || (follows?.topics?.length || 0) > 0}>
|
||||||
<h4
|
<h4
|
||||||
classList={{ [styles.opened]: isSubscriptionsVisible() }}
|
classList={{ [styles.opened]: isSubscriptionsVisible() }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
@ -102,7 +101,7 @@ export const Sidebar = () => {
|
||||||
<li>
|
<li>
|
||||||
<a href={`/author/${a.slug}`} classList={{ [styles.unread]: checkAuthorIsSeen(a.slug) }}>
|
<a href={`/author/${a.slug}`} classList={{ [styles.unread]: checkAuthorIsSeen(a.slug) }}>
|
||||||
<div class={styles.sidebarItemName}>
|
<div class={styles.sidebarItemName}>
|
||||||
<Userpic name={a.name} userpic={a.pic} size="XS" class={styles.userpic} />
|
<Userpic name={a.name || ''} userpic={a.pic || ''} size="XS" class={styles.userpic} />
|
||||||
<div class={styles.sidebarItemNameLabel}>{a.name}</div>
|
<div class={styles.sidebarItemNameLabel}>{a.name}</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import type { Author } from '../../graphql/schema/core.gen'
|
|
||||||
|
|
||||||
import { For, createEffect, createSignal } from 'solid-js'
|
import { For, createEffect, createSignal } from 'solid-js'
|
||||||
|
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
import { useInbox } from '../../context/inbox'
|
import { useInbox } from '../../context/inbox'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { hideModal } from '../../stores/ui'
|
import type { Author } from '../../graphql/schema/core.gen'
|
||||||
|
|
||||||
import InviteUser from './InviteUser'
|
import InviteUser from './InviteUser'
|
||||||
|
|
||||||
import styles from './CreateModalContent.module.scss'
|
import styles from './CreateModalContent.module.scss'
|
||||||
|
@ -17,6 +15,7 @@ type Props = {
|
||||||
|
|
||||||
const CreateModalContent = (props: Props) => {
|
const CreateModalContent = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
|
const { hideModal } = useUI()
|
||||||
const inviteUsers: inviteUser[] = props.users.map((user) => ({ ...user, selected: false }))
|
const inviteUsers: inviteUser[] = props.users.map((user) => ({ ...user, selected: false }))
|
||||||
const [chatTitle, setChatTitle] = createSignal<string>('')
|
const [chatTitle, setChatTitle] = createSignal<string>('')
|
||||||
const [usersId, setUsersId] = createSignal<number[]>([])
|
const [usersId, setUsersId] = createSignal<number[]>([])
|
||||||
|
@ -45,10 +44,10 @@ const CreateModalContent = (props: Props) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSetTheme = () => {
|
const handleSetTheme = () => {
|
||||||
setChatTitle(textInput.value.length > 0 && textInput.value)
|
setChatTitle((_) => (textInput.value.length > 0 && textInput.value) || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClick = (user) => {
|
const handleClick = (user: inviteUser) => {
|
||||||
setCollectionToInvite((userCollection) => {
|
setCollectionToInvite((userCollection) => {
|
||||||
return userCollection.map((clickedUser) =>
|
return userCollection.map((clickedUser) =>
|
||||||
user.id === clickedUser.id ? { ...clickedUser, selected: !clickedUser.selected } : clickedUser,
|
user.id === clickedUser.id ? { ...clickedUser, selected: !clickedUser.selected } : clickedUser,
|
||||||
|
@ -72,7 +71,7 @@ const CreateModalContent = (props: Props) => {
|
||||||
<h4>{t('Create Chat')}</h4>
|
<h4>{t('Create Chat')}</h4>
|
||||||
{usersId().length > 1 && (
|
{usersId().length > 1 && (
|
||||||
<input
|
<input
|
||||||
ref={textInput}
|
ref={(el) => (textInput = el)}
|
||||||
onInput={handleSetTheme}
|
onInput={handleSetTheme}
|
||||||
type="text"
|
type="text"
|
||||||
required={true}
|
required={true}
|
||||||
|
|
|
@ -31,7 +31,11 @@ const colors = [
|
||||||
]
|
]
|
||||||
|
|
||||||
const getById = (letter: string) =>
|
const getById = (letter: string) =>
|
||||||
colors[Math.abs(Number(BigInt(letter.toLowerCase().codePointAt(0) - 97) % BigInt(colors.length)))]
|
colors[
|
||||||
|
Math.abs(
|
||||||
|
Number(BigInt(((letter || '').toLowerCase()?.codePointAt(0) || 97) - 97) % BigInt(colors.length)),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
const DialogAvatar = (props: Props) => {
|
const DialogAvatar = (props: Props) => {
|
||||||
const nameFirstLetter = createMemo(() => props.name.slice(0, 1))
|
const nameFirstLetter = createMemo(() => props.name.slice(0, 1))
|
||||||
|
@ -54,8 +58,8 @@ const DialogAvatar = (props: Props) => {
|
||||||
style={{
|
style={{
|
||||||
'background-image': `url(
|
'background-image': `url(
|
||||||
${
|
${
|
||||||
props.url.includes('discours.io')
|
props.url?.includes('discours.io')
|
||||||
? getImageUrl(props.url, { width: 40, height: 40 })
|
? getImageUrl(props.url || '', { width: 40, height: 40 })
|
||||||
: props.url
|
: props.url
|
||||||
}
|
}
|
||||||
)`,
|
)`,
|
||||||
|
|
|
@ -47,7 +47,7 @@ const DialogCard = (props: DialogProps) => {
|
||||||
when={props.isChatHeader}
|
when={props.isChatHeader}
|
||||||
fallback={
|
fallback={
|
||||||
<div class={styles.avatar}>
|
<div class={styles.avatar}>
|
||||||
<DialogAvatar name={props.members[0]?.slug} url={props.members[0]?.pic} />
|
<DialogAvatar name={props.members[0]?.slug} url={props.members[0]?.pic || ''} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
@ -78,9 +78,11 @@ const DialogCard = (props: DialogProps) => {
|
||||||
</div>
|
</div>
|
||||||
<div class={styles.activity}>
|
<div class={styles.activity}>
|
||||||
<Show when={props.lastUpdate}>
|
<Show when={props.lastUpdate}>
|
||||||
<div class={styles.time}>{formatTime(new Date(props.lastUpdate * 1000))}</div>
|
<div class={styles.time}>
|
||||||
|
{formatTime(props.lastUpdate ? new Date(props.lastUpdate * 1000) : new Date()) || ''}
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.counter > 0}>
|
<Show when={(props.counter || 0) > 0}>
|
||||||
<div class={styles.counter}>
|
<div class={styles.counter}>
|
||||||
<span>{props.counter}</span>
|
<span>{props.counter}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Chat } from '../../graphql/schema/chat.gen'
|
import type { Chat, ChatMember } from '../../graphql/schema/chat.gen'
|
||||||
|
|
||||||
import DialogCard from './DialogCard'
|
import DialogCard from './DialogCard'
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ type DialogHeader = {
|
||||||
const DialogHeader = (props: DialogHeader) => {
|
const DialogHeader = (props: DialogHeader) => {
|
||||||
return (
|
return (
|
||||||
<header class={styles.DialogHeader}>
|
<header class={styles.DialogHeader}>
|
||||||
<DialogCard isChatHeader={true} members={props.chat.members} ownId={props.ownId} />
|
<DialogCard isChatHeader={true} members={props.chat.members as ChatMember[]} ownId={props.ownId} />
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,7 +29,7 @@ const GroupDialogAvatar = (props: Props) => {
|
||||||
bordered={true}
|
bordered={true}
|
||||||
size="small"
|
size="small"
|
||||||
name={user.name}
|
name={user.name}
|
||||||
url={user.pic}
|
url={user.pic || ''}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
|
@ -15,7 +15,7 @@ type DialogProps = {
|
||||||
const InviteUser = (props: DialogProps) => {
|
const InviteUser = (props: DialogProps) => {
|
||||||
return (
|
return (
|
||||||
<div class={styles.InviteUser} onClick={props.onClick}>
|
<div class={styles.InviteUser} onClick={props.onClick}>
|
||||||
<DialogAvatar name={props.author.name} url={props.author.pic} />
|
<DialogAvatar name={props.author.name || ''} url={props.author.pic || ''} />
|
||||||
<div class={styles.name}>{props.author.name}</div>
|
<div class={styles.name}>{props.author.name}</div>
|
||||||
<div class={styles.action}>{props.selected ? <Icon name="cross" /> : <Icon name="plus" />}</div>
|
<div class={styles.action}>{props.selected ? <Icon name="cross" /> : <Icon name="plus" />}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -29,10 +29,10 @@ export const Message = (props: Props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={clsx(styles.Message, isOwn && styles.own)}>
|
<div class={clsx(styles.Message, isOwn && styles.own)}>
|
||||||
<Show when={!isOwn && user}>
|
<Show when={!isOwn && user?.name}>
|
||||||
<div class={styles.author}>
|
<div class={styles.author}>
|
||||||
<DialogAvatar size="small" name={user.name} url={user.pic} />
|
<DialogAvatar size="small" name={user?.name || ''} url={user?.pic || ''} />
|
||||||
<div class={styles.name}>{user.name}</div>
|
<div class={styles.name}>{user?.name}</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class={clsx(styles.body, { [styles.popupVisible]: isPopupVisible() })}>
|
<div class={clsx(styles.body, { [styles.popupVisible]: isPopupVisible() })}>
|
||||||
|
@ -47,7 +47,7 @@ export const Message = (props: Props) => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Show when={props.replyBody}>
|
<Show when={props.replyBody}>
|
||||||
<QuotedMessage body={props.replyBody} variant="inline" isOwn={isOwn} />
|
<QuotedMessage body={props.replyBody || ''} variant="inline" isOwn={isOwn} />
|
||||||
</Show>
|
</Show>
|
||||||
<div innerHTML={props.content.body} />
|
<div innerHTML={props.content.body} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { Popup } from '../_shared/Popup'
|
||||||
export type MessageActionType = 'reply' | 'copy' | 'pin' | 'forward' | 'select' | 'delete'
|
export type MessageActionType = 'reply' | 'copy' | 'pin' | 'forward' | 'select' | 'delete'
|
||||||
|
|
||||||
type MessageActionsPopupProps = {
|
type MessageActionsPopupProps = {
|
||||||
actionSelect?: (selectedAction) => void
|
actionSelect?: (selectedAction: MessageActionType) => void
|
||||||
} & Omit<PopupProps, 'children'>
|
} & Omit<PopupProps, 'children'>
|
||||||
|
|
||||||
export const MessageActionsPopup = (props: MessageActionsPopupProps) => {
|
export const MessageActionsPopup = (props: MessageActionsPopupProps) => {
|
||||||
|
@ -23,7 +23,7 @@ export const MessageActionsPopup = (props: MessageActionsPopupProps) => {
|
||||||
{ name: t('Delete'), action: 'delete' },
|
{ name: t('Delete'), action: 'delete' },
|
||||||
]
|
]
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.actionSelect) props.actionSelect(selectedAction())
|
if (props.actionSelect) props.actionSelect(selectedAction() || 'select')
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<Popup {...props} variant="tiny">
|
<Popup {...props} variant="tiny">
|
||||||
|
@ -31,7 +31,7 @@ export const MessageActionsPopup = (props: MessageActionsPopupProps) => {
|
||||||
<For each={actions}>
|
<For each={actions}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<li
|
<li
|
||||||
style={item.action === 'delete' && { color: 'red' }}
|
style={{ color: item.action === 'delete' ? 'red' : undefined }}
|
||||||
onClick={() => setSelectedAction(item.action)}
|
onClick={() => setSelectedAction(item.action)}
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
|
|
|
@ -10,11 +10,15 @@ type Props = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const Search = (props: Props) => {
|
const Search = (props: Props) => {
|
||||||
const [value, setValue] = createSignal<string>('')
|
// FIXME: this component does not use value, is it used?
|
||||||
const search = (event) => {
|
const [_value, setValue] = createSignal<string>('')
|
||||||
event.preventDefault()
|
const search = (event: (InputEvent | undefined) & { target: { value: string } }) => {
|
||||||
setValue(event.target.value)
|
event?.preventDefault()
|
||||||
props.onChange(value)
|
const v = event?.target?.value || ''
|
||||||
|
if (v) {
|
||||||
|
setValue(v)
|
||||||
|
props.onChange(() => v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div class={styles.Search}>
|
<div class={styles.Search}>
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
|
import { useSearchParams } from '@solidjs/router'
|
||||||
import { Show } from 'solid-js'
|
import { Show } from 'solid-js'
|
||||||
|
|
||||||
import { useLocalize } from '../../../../context/localize'
|
import { useLocalize } from '../../../../context/localize'
|
||||||
import { useRouter } from '../../../../stores/router'
|
|
||||||
import { AuthModalSearchParams } from '../types'
|
|
||||||
|
|
||||||
import styles from './AuthModalHeader.module.scss'
|
import styles from './AuthModalHeader.module.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -12,15 +9,14 @@ type Props = {
|
||||||
|
|
||||||
export const AuthModalHeader = (props: Props) => {
|
export const AuthModalHeader = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { searchParams } = useRouter<AuthModalSearchParams>()
|
const [searchParams] = useSearchParams<{ source: string }>()
|
||||||
const { source } = searchParams()
|
|
||||||
|
|
||||||
const generateModalTextsFromSource = (
|
const generateModalTextsFromSource = (
|
||||||
modalType: 'login' | 'register',
|
modalType: 'login' | 'register',
|
||||||
): { title: string; description: string } => {
|
): { title: string; description: string } => {
|
||||||
const title = modalType === 'login' ? 'Welcome to Discours' : 'Create account'
|
const title = modalType === 'login' ? 'Welcome to Discours' : 'Create account'
|
||||||
|
|
||||||
switch (source) {
|
switch (searchParams?.source) {
|
||||||
case 'create': {
|
case 'create': {
|
||||||
return {
|
return {
|
||||||
title: t(`${title} to publish articles`),
|
title: t(`${title} to publish articles`),
|
||||||
|
|
|
@ -1,15 +1,12 @@
|
||||||
import type { AuthModalSearchParams } from './types'
|
|
||||||
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { JSX, Show, createSignal } from 'solid-js'
|
import { JSX, Show, createSignal } from 'solid-js'
|
||||||
|
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '../../../context/session'
|
||||||
import { useRouter } from '../../../stores/router'
|
|
||||||
import { hideModal } from '../../../stores/ui'
|
|
||||||
|
|
||||||
import { PasswordField } from './PasswordField'
|
import { PasswordField } from './PasswordField'
|
||||||
|
|
||||||
|
import { useSearchParams } from '@solidjs/router'
|
||||||
import styles from './AuthModal.module.scss'
|
import styles from './AuthModal.module.scss'
|
||||||
|
|
||||||
type FormFields = {
|
type FormFields = {
|
||||||
|
@ -19,27 +16,27 @@ type FormFields = {
|
||||||
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
|
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
|
||||||
|
|
||||||
export const ChangePasswordForm = () => {
|
export const ChangePasswordForm = () => {
|
||||||
const { searchParams, changeSearchParams } = useRouter<AuthModalSearchParams>()
|
const [searchParams, changeSearchParams] = useSearchParams<{ token?: string }>()
|
||||||
|
const { hideModal } = useUI()
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { changePassword } = useSession()
|
const { changePassword } = useSession()
|
||||||
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
||||||
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
|
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
|
||||||
const [newPassword, setNewPassword] = createSignal<string>()
|
const [newPassword, setNewPassword] = createSignal<string>('')
|
||||||
const [passwordError, setPasswordError] = createSignal<string>()
|
const [passwordError, setPasswordError] = createSignal<string>('')
|
||||||
const [isSuccess, setIsSuccess] = createSignal(false)
|
const [isSuccess, setIsSuccess] = createSignal(false)
|
||||||
const authFormRef: { current: HTMLFormElement } = { current: null }
|
let authFormRef: HTMLFormElement | undefined
|
||||||
|
|
||||||
const handleSubmit = async (event: Event) => {
|
const handleSubmit = async (event: Event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
if (newPassword()) {
|
if (!newPassword()) return
|
||||||
changePassword(newPassword(), searchParams()?.token)
|
if (searchParams?.token) changePassword(newPassword(), searchParams.token)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
setIsSuccess(true)
|
setIsSuccess(true)
|
||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const handlePasswordInput = (value: string) => {
|
const handlePasswordInput = (value: string) => {
|
||||||
setNewPassword(value)
|
setNewPassword(value)
|
||||||
|
@ -56,7 +53,7 @@ export const ChangePasswordForm = () => {
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
class={clsx(styles.authForm, styles.authFormForgetPassword)}
|
class={clsx(styles.authForm, styles.authFormForgetPassword)}
|
||||||
ref={(el) => (authFormRef.current = el)}
|
ref={(el) => (authFormRef = el)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h4>{t('Enter a new password')}</h4>
|
<h4>{t('Enter a new password')}</h4>
|
||||||
|
|
|
@ -1,18 +1,19 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show, createEffect, createSignal } from 'solid-js'
|
import { Show, createEffect, createSignal } from 'solid-js'
|
||||||
|
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '../../../context/session'
|
||||||
import { useRouter } from '../../../stores/router'
|
|
||||||
import { hideModal } from '../../../stores/ui'
|
|
||||||
|
|
||||||
import { email, setEmail } from './sharedLogic'
|
import { email, setEmail } from './sharedLogic'
|
||||||
|
|
||||||
|
import { useSearchParams } from '@solidjs/router'
|
||||||
import styles from './AuthModal.module.scss'
|
import styles from './AuthModal.module.scss'
|
||||||
|
|
||||||
export const EmailConfirm = () => {
|
export const EmailConfirm = () => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { changeSearchParams } = useRouter()
|
const { hideModal } = useUI()
|
||||||
|
const [, changeSearchParams] = useSearchParams()
|
||||||
const { session, authError } = useSession()
|
const { session, authError } = useSession()
|
||||||
const [emailConfirmed, setEmailConfirmed] = createSignal(false)
|
const [emailConfirmed, setEmailConfirmed] = createSignal(false)
|
||||||
|
|
||||||
|
@ -24,7 +25,7 @@ export const EmailConfirm = () => {
|
||||||
setEmail(email.toLowerCase())
|
setEmail(email.toLowerCase())
|
||||||
if (isVerified) setEmailConfirmed(isVerified)
|
if (isVerified) setEmailConfirmed(isVerified)
|
||||||
if (authError()) {
|
if (authError()) {
|
||||||
changeSearchParams({}, true)
|
changeSearchParams({}, { replace: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,9 @@
|
||||||
import type { AuthModalSearchParams } from './types'
|
|
||||||
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { JSX, Show, createSignal } from 'solid-js'
|
import { JSX, Show, createSignal } from 'solid-js'
|
||||||
|
|
||||||
|
import { useSnackbar, useUI } from '~/context/ui'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '../../../context/session'
|
||||||
import { useSnackbar } from '../../../context/snackbar'
|
|
||||||
import { useRouter } from '../../../stores/router'
|
|
||||||
import { hideModal } from '../../../stores/ui'
|
|
||||||
import { validateEmail } from '../../../utils/validateEmail'
|
import { validateEmail } from '../../../utils/validateEmail'
|
||||||
|
|
||||||
import { AuthModalHeader } from './AuthModalHeader'
|
import { AuthModalHeader } from './AuthModalHeader'
|
||||||
|
@ -15,6 +11,7 @@ import { PasswordField } from './PasswordField'
|
||||||
import { SocialProviders } from './SocialProviders'
|
import { SocialProviders } from './SocialProviders'
|
||||||
import { email, setEmail } from './sharedLogic'
|
import { email, setEmail } from './sharedLogic'
|
||||||
|
|
||||||
|
import { useSearchParams } from '@solidjs/router'
|
||||||
import styles from './AuthModal.module.scss'
|
import styles from './AuthModal.module.scss'
|
||||||
|
|
||||||
type FormFields = {
|
type FormFields = {
|
||||||
|
@ -25,7 +22,8 @@ type FormFields = {
|
||||||
type ValidationErrors = Partial<Record<keyof FormFields, string>>
|
type ValidationErrors = Partial<Record<keyof FormFields, string>>
|
||||||
|
|
||||||
export const LoginForm = () => {
|
export const LoginForm = () => {
|
||||||
const { changeSearchParams } = useRouter<AuthModalSearchParams>()
|
const { hideModal } = useUI()
|
||||||
|
const [, setSearchParams] = useSearchParams()
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const [submitError, setSubmitError] = createSignal<string | JSX.Element>()
|
const [submitError, setSubmitError] = createSignal<string | JSX.Element>()
|
||||||
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
||||||
|
@ -33,9 +31,9 @@ export const LoginForm = () => {
|
||||||
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
|
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
|
||||||
// FIXME: use signal or remove
|
// FIXME: use signal or remove
|
||||||
const [_isLinkSent, setIsLinkSent] = createSignal(false)
|
const [_isLinkSent, setIsLinkSent] = createSignal(false)
|
||||||
const authFormRef: { current: HTMLFormElement } = { current: null }
|
let authFormRef: HTMLFormElement
|
||||||
const { showSnackbar } = useSnackbar()
|
const { showSnackbar } = useSnackbar()
|
||||||
const { signIn } = useSession()
|
const { signIn, authError } = useSession()
|
||||||
|
|
||||||
const handleEmailInput = (newEmail: string) => {
|
const handleEmailInput = (newEmail: string) => {
|
||||||
setValidationErrors(({ email: _notNeeded, ...rest }) => rest)
|
setValidationErrors(({ email: _notNeeded, ...rest }) => rest)
|
||||||
|
@ -52,7 +50,7 @@ export const LoginForm = () => {
|
||||||
|
|
||||||
setIsLinkSent(true)
|
setIsLinkSent(true)
|
||||||
setSubmitError()
|
setSubmitError()
|
||||||
changeSearchParams({ mode: 'send-confirm-email' })
|
setSearchParams({ mode: 'send-confirm-email' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const preSendValidate = async (value: string, type: 'email' | 'password'): Promise<boolean> => {
|
const preSendValidate = async (value: string, type: 'email' | 'password'): Promise<boolean> => {
|
||||||
|
@ -85,7 +83,7 @@ export const LoginForm = () => {
|
||||||
setSubmitError()
|
setSubmitError()
|
||||||
|
|
||||||
if (Object.keys(validationErrors()).length > 0) {
|
if (Object.keys(validationErrors()).length > 0) {
|
||||||
authFormRef.current
|
authFormRef
|
||||||
.querySelector<HTMLInputElement>(`input[name="${Object.keys(validationErrors())[0]}"]`)
|
.querySelector<HTMLInputElement>(`input[name="${Object.keys(validationErrors())[0]}"]`)
|
||||||
?.focus()
|
?.focus()
|
||||||
return
|
return
|
||||||
|
@ -94,11 +92,9 @@ export const LoginForm = () => {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { errors } = await signIn({ email: email(), password: password() })
|
const success = await signIn({ email: email(), password: password() })
|
||||||
if (errors?.length > 0) {
|
if (!success) {
|
||||||
console.warn('[signIn] errors: ', errors)
|
switch (authError()) {
|
||||||
errors.forEach((error) => {
|
|
||||||
switch (error.message) {
|
|
||||||
case 'user has not signed up email & password':
|
case 'user has not signed up email & password':
|
||||||
case 'bad user credentials': {
|
case 'bad user credentials': {
|
||||||
setValidationErrors((prev) => ({
|
setValidationErrors((prev) => ({
|
||||||
|
@ -118,7 +114,7 @@ export const LoginForm = () => {
|
||||||
default:
|
default:
|
||||||
setSubmitError(
|
setSubmitError(
|
||||||
<div class={styles.info}>
|
<div class={styles.info}>
|
||||||
{t('Error', errors[0].message)}
|
{t('Error', authError())}
|
||||||
{'. '}
|
{'. '}
|
||||||
<span class={'link'} onClick={handleSendLinkAgainClick}>
|
<span class={'link'} onClick={handleSendLinkAgainClick}>
|
||||||
{t('Send link again')}
|
{t('Send link again')}
|
||||||
|
@ -126,21 +122,19 @@ export const LoginForm = () => {
|
||||||
</div>,
|
</div>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
hideModal()
|
hideModal()
|
||||||
showSnackbar({ body: t('Welcome!') })
|
showSnackbar({ body: t('Welcome!') })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
setSubmitError(error.message)
|
setSubmitError(authError())
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef.current = el)}>
|
<form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef = el)}>
|
||||||
<div>
|
<div>
|
||||||
<AuthModalHeader modalType="login" />
|
<AuthModalHeader modalType="login" />
|
||||||
<div
|
<div
|
||||||
|
@ -182,7 +176,7 @@ export const LoginForm = () => {
|
||||||
<span
|
<span
|
||||||
class="link"
|
class="link"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
changeSearchParams({
|
setSearchParams({
|
||||||
mode: 'send-reset-link',
|
mode: 'send-reset-link',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -199,7 +193,7 @@ export const LoginForm = () => {
|
||||||
<span
|
<span
|
||||||
class={styles.authLink}
|
class={styles.authLink}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
changeSearchParams({
|
setSearchParams({
|
||||||
mode: 'register',
|
mode: 'register',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,7 +52,7 @@ export const PasswordField = (props: Props) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
props.onInput(value)
|
props.onInput?.(value)
|
||||||
if (!props.noValidate) {
|
if (!props.noValidate) {
|
||||||
const errorValue = validatePassword(value)
|
const errorValue = validatePassword(value)
|
||||||
if (errorValue) {
|
if (errorValue) {
|
||||||
|
|
|
@ -1,21 +1,17 @@
|
||||||
|
import { clsx } from 'clsx'
|
||||||
import type { JSX } from 'solid-js'
|
import type { JSX } from 'solid-js'
|
||||||
import { Show, createMemo, createSignal } from 'solid-js'
|
import { Show, createMemo, createSignal } from 'solid-js'
|
||||||
import type { AuthModalSearchParams } from './types'
|
|
||||||
|
|
||||||
import { clsx } from 'clsx'
|
|
||||||
|
|
||||||
|
import { useSearchParams } from '@solidjs/router'
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '../../../context/session'
|
||||||
import { useRouter } from '../../../stores/router'
|
|
||||||
import { hideModal } from '../../../stores/ui'
|
|
||||||
import { validateEmail } from '../../../utils/validateEmail'
|
import { validateEmail } from '../../../utils/validateEmail'
|
||||||
|
|
||||||
import { AuthModalHeader } from './AuthModalHeader'
|
import { AuthModalHeader } from './AuthModalHeader'
|
||||||
import { PasswordField } from './PasswordField'
|
import { PasswordField } from './PasswordField'
|
||||||
import { SocialProviders } from './SocialProviders'
|
import { SocialProviders } from './SocialProviders'
|
||||||
import { email, setEmail } from './sharedLogic'
|
import { email, setEmail } from './sharedLogic'
|
||||||
|
|
||||||
import { GenericResponse } from '@authorizerdev/authorizer-js'
|
|
||||||
import styles from './AuthModal.module.scss'
|
import styles from './AuthModal.module.scss'
|
||||||
|
|
||||||
type EmailStatus = 'not verified' | 'verified' | 'registered' | ''
|
type EmailStatus = 'not verified' | 'verified' | 'registered' | ''
|
||||||
|
@ -29,7 +25,8 @@ type FormFields = {
|
||||||
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
|
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
|
||||||
|
|
||||||
export const RegisterForm = () => {
|
export const RegisterForm = () => {
|
||||||
const { changeSearchParams } = useRouter<AuthModalSearchParams>()
|
const [, changeSearchParams] = useSearchParams()
|
||||||
|
const { hideModal } = useUI()
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { signUp, isRegistered, resendVerifyEmail } = useSession()
|
const { signUp, isRegistered, resendVerifyEmail } = useSession()
|
||||||
// FIXME: use submit error data or remove signal
|
// FIXME: use submit error data or remove signal
|
||||||
|
@ -42,7 +39,7 @@ export const RegisterForm = () => {
|
||||||
const [passwordError, setPasswordError] = createSignal<string>()
|
const [passwordError, setPasswordError] = createSignal<string>()
|
||||||
const [emailStatus, setEmailStatus] = createSignal<string>('')
|
const [emailStatus, setEmailStatus] = createSignal<string>('')
|
||||||
|
|
||||||
const authFormRef: { current: HTMLFormElement } = { current: null }
|
let authFormRef: HTMLFormElement
|
||||||
|
|
||||||
const handleNameInput = (newName: string) => {
|
const handleNameInput = (newName: string) => {
|
||||||
setFullName(newName)
|
setFullName(newName)
|
||||||
|
@ -81,11 +78,10 @@ export const RegisterForm = () => {
|
||||||
|
|
||||||
const isValid = createMemo(() => Object.keys(newValidationErrors).length === 0)
|
const isValid = createMemo(() => Object.keys(newValidationErrors).length === 0)
|
||||||
|
|
||||||
if (!isValid()) {
|
if (!isValid() && authFormRef) {
|
||||||
authFormRef.current
|
authFormRef
|
||||||
.querySelector<HTMLInputElement>(`input[name="${Object.keys(newValidationErrors)[0]}"]`)
|
.querySelector<HTMLInputElement>(`input[name="${Object.keys(newValidationErrors)[0]}"]`)
|
||||||
.focus()
|
?.focus()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
@ -95,11 +91,10 @@ export const RegisterForm = () => {
|
||||||
email: cleanEmail,
|
email: cleanEmail,
|
||||||
password: password(),
|
password: password(),
|
||||||
confirm_password: password(),
|
confirm_password: password(),
|
||||||
redirect_uri: window.location.origin,
|
redirect_uri: window?.location?.origin || '',
|
||||||
}
|
}
|
||||||
const { errors } = await signUp(opts)
|
const success = await signUp(opts)
|
||||||
if (errors.length > 0) return
|
setIsSuccess(success)
|
||||||
setIsSuccess(true)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -107,12 +102,13 @@ export const RegisterForm = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResendLink = async (_ev) => {
|
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||||
const response: GenericResponse = await resendVerifyEmail({
|
const handleResendLink = async (_ev: any) => {
|
||||||
|
const success: boolean = await resendVerifyEmail({
|
||||||
email: email(),
|
email: email(),
|
||||||
identifier: 'basic_signup',
|
identifier: 'basic_signup',
|
||||||
})
|
})
|
||||||
setIsSuccess(response?.message === 'Verification email has been sent. Please check your inbox')
|
setIsSuccess(success)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCheckEmailStatus = (status: EmailStatus | string) => {
|
const handleCheckEmailStatus = (status: EmailStatus | string) => {
|
||||||
|
@ -184,7 +180,7 @@ export const RegisterForm = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Show when={!isSuccess()}>
|
<Show when={!isSuccess()}>
|
||||||
<form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef.current = el)}>
|
<form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef = el)}>
|
||||||
<div>
|
<div>
|
||||||
<AuthModalHeader modalType="register" />
|
<AuthModalHeader modalType="register" />
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { hideModal } from '../../../stores/ui'
|
|
||||||
|
|
||||||
import styles from './AuthModal.module.scss'
|
import styles from './AuthModal.module.scss'
|
||||||
|
|
||||||
export const SendEmailConfirm = () => {
|
export const SendEmailConfirm = () => {
|
||||||
|
const { hideModal } = useUI()
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -1,15 +1,12 @@
|
||||||
import type { AuthModalSearchParams } from './types'
|
|
||||||
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { JSX, Show, createSignal, onMount } from 'solid-js'
|
import { JSX, Show, createSignal, onMount } from 'solid-js'
|
||||||
|
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '../../../context/session'
|
||||||
import { useRouter } from '../../../stores/router'
|
|
||||||
import { validateEmail } from '../../../utils/validateEmail'
|
import { validateEmail } from '../../../utils/validateEmail'
|
||||||
|
|
||||||
import { email, setEmail } from './sharedLogic'
|
import { email, setEmail } from './sharedLogic'
|
||||||
|
|
||||||
|
import { useSearchParams } from '@solidjs/router'
|
||||||
import styles from './AuthModal.module.scss'
|
import styles from './AuthModal.module.scss'
|
||||||
|
|
||||||
type FormFields = {
|
type FormFields = {
|
||||||
|
@ -19,7 +16,7 @@ type FormFields = {
|
||||||
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
|
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
|
||||||
|
|
||||||
export const SendResetLinkForm = () => {
|
export const SendResetLinkForm = () => {
|
||||||
const { changeSearchParams } = useRouter<AuthModalSearchParams>()
|
const [, changeSearchParams] = useSearchParams()
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const handleEmailInput = (newEmail: string) => {
|
const handleEmailInput = (newEmail: string) => {
|
||||||
setValidationErrors(({ email: _notNeeded, ...rest }) => rest)
|
setValidationErrors(({ email: _notNeeded, ...rest }) => rest)
|
||||||
|
@ -29,7 +26,7 @@ export const SendResetLinkForm = () => {
|
||||||
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
||||||
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
|
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
|
||||||
const [isUserNotFound, setIsUserNotFound] = createSignal(false)
|
const [isUserNotFound, setIsUserNotFound] = createSignal(false)
|
||||||
const authFormRef: { current: HTMLFormElement } = { current: null }
|
let authFormRef: HTMLFormElement
|
||||||
const [message, setMessage] = createSignal<string>('')
|
const [message, setMessage] = createSignal<string>('')
|
||||||
|
|
||||||
const handleSubmit = async (event: Event) => {
|
const handleSubmit = async (event: Event) => {
|
||||||
|
@ -47,29 +44,25 @@ export const SendResetLinkForm = () => {
|
||||||
const isValid = Object.keys(newValidationErrors).length === 0
|
const isValid = Object.keys(newValidationErrors).length === 0
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
authFormRef.current
|
authFormRef
|
||||||
.querySelector<HTMLInputElement>(`input[name="${Object.keys(newValidationErrors)[0]}"]`)
|
?.querySelector<HTMLInputElement>(`input[name="${Object.keys(newValidationErrors)[0]}"]`)
|
||||||
.focus()
|
?.focus()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const { data, errors } = await forgotPassword({
|
const result = await forgotPassword({
|
||||||
email: email(),
|
email: email(),
|
||||||
redirect_uri: window.location.origin,
|
redirect_uri: window?.location?.origin || '',
|
||||||
})
|
})
|
||||||
console.debug('[SendResetLinkForm] authorizer response:', data)
|
if (result) {
|
||||||
if (
|
setMessage(result || '')
|
||||||
errors?.some(
|
} else {
|
||||||
(error) =>
|
console.warn('[SendResetLinkForm] forgot password mutation failed')
|
||||||
error.message.includes('bad user credentials') || error.message.includes('user not found'),
|
setIsUserNotFound(false)
|
||||||
)
|
|
||||||
) {
|
|
||||||
setIsUserNotFound(true)
|
|
||||||
}
|
}
|
||||||
if (data.message) setMessage(data.message)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -87,7 +80,7 @@ export const SendResetLinkForm = () => {
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
class={clsx(styles.authForm, styles.authFormForgetPassword)}
|
class={clsx(styles.authForm, styles.authFormForgetPassword)}
|
||||||
ref={(el) => (authFormRef.current = el)}
|
ref={(el) => (authFormRef = el)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h4>{t('Forgot password?')}</h4>
|
<h4>{t('Forgot password?')}</h4>
|
||||||
|
|
|
@ -18,7 +18,11 @@ export const SocialProviders = () => {
|
||||||
<div class={styles.social}>
|
<div class={styles.social}>
|
||||||
<For each={PROVIDERS}>
|
<For each={PROVIDERS}>
|
||||||
{(provider) => (
|
{(provider) => (
|
||||||
<button type="button" class={styles[provider]} onClick={(_e) => oauth(provider)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
class={styles[provider as keyof typeof styles]}
|
||||||
|
onClick={(_e) => oauth(provider)}
|
||||||
|
>
|
||||||
<Icon name={provider} />
|
<Icon name={provider} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,22 +1,21 @@
|
||||||
import type { AuthModalMode, AuthModalSearchParams } from './types'
|
|
||||||
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Component, Show, createEffect, createMemo } from 'solid-js'
|
import { Component, Show, createEffect, createMemo } from 'solid-js'
|
||||||
import { Dynamic } from 'solid-js/web'
|
import { Dynamic } from 'solid-js/web'
|
||||||
|
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
|
import type { AuthModalMode } from '~/context/ui'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { useRouter } from '../../../stores/router'
|
|
||||||
import { hideModal } from '../../../stores/ui'
|
|
||||||
import { isMobile } from '../../../utils/media-query'
|
import { isMobile } from '../../../utils/media-query'
|
||||||
|
|
||||||
import { ChangePasswordForm } from './ChangePasswordForm'
|
import { ChangePasswordForm } from './ChangePasswordForm'
|
||||||
import { EmailConfirm } from './EmailConfirm'
|
import { EmailConfirm } from './EmailConfirm'
|
||||||
import { LoginForm } from './LoginForm'
|
import { LoginForm } from './LoginForm'
|
||||||
import { RegisterForm } from './RegisterForm'
|
import { RegisterForm } from './RegisterForm'
|
||||||
|
import { SendEmailConfirm } from './SendEmailConfirm'
|
||||||
import { SendResetLinkForm } from './SendResetLinkForm'
|
import { SendResetLinkForm } from './SendResetLinkForm'
|
||||||
|
|
||||||
|
import { useSearchParams } from '@solidjs/router'
|
||||||
import styles from './AuthModal.module.scss'
|
import styles from './AuthModal.module.scss'
|
||||||
import { SendEmailConfirm } from './SendEmailConfirm'
|
import { AuthModalSearchParams } from './types'
|
||||||
|
|
||||||
const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = {
|
const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = {
|
||||||
login: LoginForm,
|
login: LoginForm,
|
||||||
|
@ -28,30 +27,31 @@ const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthModal = () => {
|
export const AuthModal = () => {
|
||||||
const rootRef: { current: HTMLDivElement } = { current: null }
|
let rootRef: HTMLDivElement | null
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { searchParams } = useRouter<AuthModalSearchParams>()
|
const [searchParams] = useSearchParams<AuthModalSearchParams>()
|
||||||
const { source } = searchParams()
|
const { hideModal } = useUI()
|
||||||
|
const mode = createMemo(() => {
|
||||||
const mode = createMemo<AuthModalMode>(() => {
|
return (
|
||||||
return AUTH_MODAL_MODES[searchParams().mode] ? searchParams().mode : 'login'
|
AUTH_MODAL_MODES[searchParams?.mode as AuthModalMode] ? searchParams?.mode : 'login'
|
||||||
|
) as AuthModalMode
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect((oldMode) => {
|
createEffect((oldMode) => {
|
||||||
if (oldMode !== mode() && !isMobile()) {
|
if (oldMode !== mode() && !isMobile()) {
|
||||||
rootRef.current?.querySelector('input')?.focus()
|
rootRef?.querySelector('input')?.focus()
|
||||||
}
|
}
|
||||||
}, null)
|
}, null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={(el) => (rootRef.current = el)}
|
ref={(el) => (rootRef = el)}
|
||||||
class={clsx(styles.view, {
|
class={clsx(styles.view, {
|
||||||
row: !source,
|
row: !searchParams?.source,
|
||||||
[styles.signUp]: mode() === 'register' || mode() === 'confirm-email',
|
[styles.signUp]: mode() === 'register' || mode() === 'confirm-email',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Show when={!source}>
|
<Show when={!searchParams?.source}>
|
||||||
<div class={clsx('col-md-12 d-none d-md-flex', styles.authImage)}>
|
<div class={clsx('col-md-12 d-none d-md-flex', styles.authImage)}>
|
||||||
<div
|
<div
|
||||||
class={styles.authImageText}
|
class={styles.authImageText}
|
||||||
|
@ -84,10 +84,10 @@ export const AuthModal = () => {
|
||||||
</Show>
|
</Show>
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.auth, {
|
class={clsx(styles.auth, {
|
||||||
'col-md-12': !source,
|
'col-md-12': !searchParams?.source,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Dynamic component={AUTH_MODAL_MODES[mode()]} />
|
<Dynamic component={AUTH_MODAL_MODES[mode() as AuthModalMode]} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,18 +1,4 @@
|
||||||
export type AuthModalMode =
|
import { AuthModalMode, AuthModalSource } from '~/context/ui'
|
||||||
| 'login'
|
|
||||||
| 'register'
|
|
||||||
| 'confirm-email'
|
|
||||||
| 'send-confirm-email'
|
|
||||||
| 'send-reset-link'
|
|
||||||
| 'change-password'
|
|
||||||
export type AuthModalSource =
|
|
||||||
| 'discussions'
|
|
||||||
| 'vote'
|
|
||||||
| 'subscribe'
|
|
||||||
| 'bookmark'
|
|
||||||
| 'follow'
|
|
||||||
| 'create'
|
|
||||||
| 'authguard'
|
|
||||||
|
|
||||||
export type AuthModalSearchParams = {
|
export type AuthModalSearchParams = {
|
||||||
mode: AuthModalMode
|
mode: AuthModalMode
|
||||||
|
@ -28,3 +14,4 @@ export type ConfirmEmailSearchParams = {
|
||||||
export type CreateChatSearchParams = {
|
export type CreateChatSearchParams = {
|
||||||
id: number
|
id: number
|
||||||
}
|
}
|
||||||
|
export type { AuthModalSource }
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import { useConfirm } from '../../../context/confirm'
|
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
|
import { useUI } from '../../../context/ui'
|
||||||
import { Button } from '../../_shared/Button'
|
import { Button } from '../../_shared/Button'
|
||||||
|
|
||||||
import styles from './ConfirmModal.module.scss'
|
import styles from './ConfirmModal.module.scss'
|
||||||
|
|
||||||
export const ConfirmModal = () => {
|
export const ConfirmModal = () => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { confirmMessage, resolveConfirm } = useConfirm()
|
const { confirmMessage, resolveConfirm } = useUI()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={styles.confirmModal}>
|
<div class={styles.confirmModal}>
|
||||||
|
|
|
@ -1,13 +1,12 @@
|
||||||
import type { Topic } from '../../../graphql/schema/core.gen'
|
|
||||||
|
|
||||||
import { getPagePath, redirectPage } from '@nanostores/router'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { For, Show, createEffect, createSignal, onCleanup, onMount } from 'solid-js'
|
import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js'
|
||||||
|
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '../../../context/session'
|
||||||
import { ROUTES, router, useRouter } from '../../../stores/router'
|
import { useTopics } from '../../../context/topics'
|
||||||
import { useModalStore } from '../../../stores/ui'
|
import type { Topic } from '../../../graphql/schema/core.gen'
|
||||||
|
import { getRandomTopicsFromArray } from '../../../utils/getRandomTopicsFromArray'
|
||||||
import { getDescription } from '../../../utils/meta'
|
import { getDescription } from '../../../utils/meta'
|
||||||
import { SharePopup, getShareUrl } from '../../Article/SharePopup'
|
import { SharePopup, getShareUrl } from '../../Article/SharePopup'
|
||||||
import { Icon } from '../../_shared/Icon'
|
import { Icon } from '../../_shared/Icon'
|
||||||
|
@ -18,11 +17,9 @@ import { HeaderAuth } from '../HeaderAuth'
|
||||||
import { Modal } from '../Modal'
|
import { Modal } from '../Modal'
|
||||||
import { SearchModal } from '../SearchModal/SearchModal'
|
import { SearchModal } from '../SearchModal/SearchModal'
|
||||||
import { Snackbar } from '../Snackbar'
|
import { Snackbar } from '../Snackbar'
|
||||||
|
|
||||||
import { Link } from './Link'
|
import { Link } from './Link'
|
||||||
|
|
||||||
import { useTopics } from '../../../context/topics'
|
import { A, useLocation, useNavigate, useSearchParams } from '@solidjs/router'
|
||||||
import { getRandomTopicsFromArray } from '../../../utils/getRandomTopicsFromArray'
|
|
||||||
import styles from './Header.module.scss'
|
import styles from './Header.module.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -38,18 +35,19 @@ type HeaderSearchParams = {
|
||||||
source?: string
|
source?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSwitchLanguage = (event) => {
|
const handleSwitchLanguage = (value: string) => {
|
||||||
location.href = `${location.href}${location.href.includes('?') ? '&' : '?'}lng=${event.target.value}`
|
location.href = `${location.href}${location.href.includes('?') ? '&' : '?'}lng=${value}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Header = (props: Props) => {
|
export const Header = (props: Props) => {
|
||||||
const { t, lang } = useLocalize()
|
const { t, lang } = useLocalize()
|
||||||
const { modal } = useModalStore()
|
const { modal } = useUI()
|
||||||
const { page } = useRouter()
|
const navigate = useNavigate()
|
||||||
|
const [searchParams] = useSearchParams<HeaderSearchParams>()
|
||||||
const { requireAuthentication } = useSession()
|
const { requireAuthentication } = useSession()
|
||||||
const { searchParams } = useRouter<HeaderSearchParams>()
|
const { sortedTopics } = useTopics()
|
||||||
const { sortedTopics: topics } = useTopics()
|
const topics = createMemo<Topic[]>(() => sortedTopics())
|
||||||
const [randomTopics, setRandomTopics] = createSignal([])
|
const [randomTopics, setRandomTopics] = createSignal<Topic[]>([])
|
||||||
const [getIsScrollingBottom, setIsScrollingBottom] = createSignal(false)
|
const [getIsScrollingBottom, setIsScrollingBottom] = createSignal(false)
|
||||||
const [getIsScrolled, setIsScrolled] = createSignal(false)
|
const [getIsScrolled, setIsScrolled] = createSignal(false)
|
||||||
const [fixed, setFixed] = createSignal(false)
|
const [fixed, setFixed] = createSignal(false)
|
||||||
|
@ -70,23 +68,28 @@ export const Header = (props: Props) => {
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (topics()?.length) {
|
if (topics()?.length) {
|
||||||
setRandomTopics(getRandomTopicsFromArray(topics()))
|
const rt: Topic[] = getRandomTopicsFromArray(topics())
|
||||||
|
setRandomTopics(rt)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const mainContent = document.querySelector<HTMLDivElement>('.main-content')
|
const mainContent = document.querySelector<HTMLDivElement>('.main-content')
|
||||||
|
|
||||||
if (fixed() || modal() !== null) {
|
if ((window && fixed()) || modal() !== null) {
|
||||||
windowScrollTop = window.scrollY
|
windowScrollTop = window.scrollY
|
||||||
|
if (mainContent) {
|
||||||
mainContent.style.marginTop = `-${windowScrollTop}px`
|
mainContent.style.marginTop = `-${windowScrollTop}px`
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.body.classList.toggle('fixed', fixed() || modal() !== null)
|
document.body.classList.toggle('fixed', fixed() || modal() !== null)
|
||||||
document.body.classList.toggle(styles.fixed, fixed() && !modal())
|
document.body.classList.toggle(styles.fixed, fixed() && !modal())
|
||||||
|
|
||||||
if (!(fixed() || modal())) {
|
if (!(fixed() || modal())) {
|
||||||
|
if (mainContent) {
|
||||||
mainContent.style.marginTop = ''
|
mainContent.style.marginTop = ''
|
||||||
|
}
|
||||||
window.scrollTo(0, windowScrollTop)
|
window.scrollTo(0, windowScrollTop)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -106,27 +109,27 @@ export const Header = (props: Props) => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const scrollToComments = (event, value) => {
|
const scrollToComments = (event: MouseEvent | undefined, value: boolean) => {
|
||||||
event.preventDefault()
|
event?.preventDefault()
|
||||||
props.scrollToComments(value)
|
props.scrollToComments?.(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBookmarkButtonClick = (ev) => {
|
const handleBookmarkButtonClick = (ev: MouseEvent | undefined) => {
|
||||||
requireAuthentication(() => {
|
requireAuthentication(() => {
|
||||||
// TODO: implement bookmark clicked
|
// TODO: implement bookmark clicked
|
||||||
ev.preventDefault()
|
ev?.preventDefault()
|
||||||
}, 'bookmark')
|
}, 'bookmark')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateButtonClick = (ev) => {
|
const handleCreateButtonClick = (ev: MouseEvent | undefined) => {
|
||||||
requireAuthentication(() => {
|
requireAuthentication(() => {
|
||||||
ev.preventDefault()
|
ev?.preventDefault()
|
||||||
|
|
||||||
redirectPage(router, 'create')
|
navigate('/create')
|
||||||
}, 'create')
|
}, 'create')
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleSubnavigation = (isShow, signal?) => {
|
const toggleSubnavigation = (isShow: boolean, signal?: (v: boolean) => void) => {
|
||||||
clearTimer()
|
clearTimer()
|
||||||
setIsKnowledgeBaseVisible(false)
|
setIsKnowledgeBaseVisible(false)
|
||||||
setIsTopicsVisible(false)
|
setIsTopicsVisible(false)
|
||||||
|
@ -144,18 +147,18 @@ export const Header = (props: Props) => {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hideSubnavigation = (_event, time = 500) => {
|
const hideSubnavigation = (time = 500) => {
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
toggleSubnavigation(false)
|
toggleSubnavigation(false)
|
||||||
}, time)
|
}, time)
|
||||||
}
|
}
|
||||||
|
const loc = useLocation()
|
||||||
const handleToggleMenuByLink = (event: MouseEvent, route: keyof typeof ROUTES) => {
|
const handleToggleMenuByLink = (event: MouseEvent, route: string) => {
|
||||||
if (!fixed()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (page().route === route) {
|
console.debug(route)
|
||||||
|
console.debug(loc.pathname)
|
||||||
|
if (!fixed()) return
|
||||||
|
if (loc.pathname.startsWith(route) || loc.pathname.startsWith(`/${route}`)) {
|
||||||
toggleFixed()
|
toggleFixed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -171,9 +174,9 @@ export const Header = (props: Props) => {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal
|
<Modal
|
||||||
variant={searchParams().source ? 'narrow' : 'wide'}
|
variant={searchParams?.source ? 'narrow' : 'wide'}
|
||||||
name="auth"
|
name="auth"
|
||||||
allowClose={searchParams().source !== 'authguard'}
|
allowClose={searchParams?.source !== 'authguard'}
|
||||||
noPadding={true}
|
noPadding={true}
|
||||||
>
|
>
|
||||||
<AuthModal />
|
<AuthModal />
|
||||||
|
@ -195,9 +198,9 @@ export const Header = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class={clsx('col-md-5 col-xl-4 col-auto', styles.mainLogo)}>
|
<div class={clsx('col-md-5 col-xl-4 col-auto', styles.mainLogo)}>
|
||||||
<a href={getPagePath(router, 'home')}>
|
<A href={'/'}>
|
||||||
<img src="/logo.svg" alt={t('Discours')} />
|
<img src="/logo.svg" alt={t('Discours')} />
|
||||||
</a>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
<div class={clsx('col col-md-13 col-lg-12 offset-xl-1', styles.mainNavigationWrapper)}>
|
<div class={clsx('col col-md-13 col-lg-12 offset-xl-1', styles.mainNavigationWrapper)}>
|
||||||
<Show when={props.title}>
|
<Show when={props.title}>
|
||||||
|
@ -207,7 +210,7 @@ export const Header = (props: Props) => {
|
||||||
<ul class="view-switcher">
|
<ul class="view-switcher">
|
||||||
<Link
|
<Link
|
||||||
onMouseOver={() => toggleSubnavigation(true, setIsZineVisible)}
|
onMouseOver={() => toggleSubnavigation(true, setIsZineVisible)}
|
||||||
onMouseOut={() => hideSubnavigation}
|
onMouseOut={() => hideSubnavigation()}
|
||||||
routeName="home"
|
routeName="home"
|
||||||
active={isZineVisible()}
|
active={isZineVisible()}
|
||||||
body={t('journal')}
|
body={t('journal')}
|
||||||
|
@ -215,7 +218,7 @@ export const Header = (props: Props) => {
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
onMouseOver={() => toggleSubnavigation(true, setIsFeedVisible)}
|
onMouseOver={() => toggleSubnavigation(true, setIsFeedVisible)}
|
||||||
onMouseOut={() => hideSubnavigation}
|
onMouseOut={() => hideSubnavigation()}
|
||||||
routeName="feed"
|
routeName="feed"
|
||||||
active={isFeedVisible()}
|
active={isFeedVisible()}
|
||||||
body={t('feed')}
|
body={t('feed')}
|
||||||
|
@ -230,15 +233,15 @@ export const Header = (props: Props) => {
|
||||||
onClick={(event) => handleToggleMenuByLink(event, 'topics')}
|
onClick={(event) => handleToggleMenuByLink(event, 'topics')}
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
onMouseOver={(event) => hideSubnavigation(event, 0)}
|
onMouseOver={() => hideSubnavigation(0)}
|
||||||
onMouseOut={(event) => hideSubnavigation(event, 0)}
|
onMouseOut={() => hideSubnavigation(0)}
|
||||||
routeName="authors"
|
routeName="authors"
|
||||||
body={t('authors')}
|
body={t('authors')}
|
||||||
onClick={(event) => handleToggleMenuByLink(event, 'authors')}
|
onClick={(event) => handleToggleMenuByLink(event, 'authors')}
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
onMouseOver={() => toggleSubnavigation(true, setIsKnowledgeBaseVisible)}
|
onMouseOver={() => toggleSubnavigation(true, setIsKnowledgeBaseVisible)}
|
||||||
onMouseOut={() => hideSubnavigation}
|
onMouseOut={() => hideSubnavigation()}
|
||||||
routeName="guide"
|
routeName="guide"
|
||||||
body={t('Knowledge base')}
|
body={t('Knowledge base')}
|
||||||
active={isKnowledgeBaseVisible()}
|
active={isKnowledgeBaseVisible()}
|
||||||
|
@ -306,7 +309,7 @@ export const Header = (props: Props) => {
|
||||||
<h4>{t('Language')}</h4>
|
<h4>{t('Language')}</h4>
|
||||||
<select
|
<select
|
||||||
class={styles.languageSelectorMobile}
|
class={styles.languageSelectorMobile}
|
||||||
onChange={handleSwitchLanguage}
|
onChange={(ev) => handleSwitchLanguage(ev.target.value)}
|
||||||
value={lang()}
|
value={lang()}
|
||||||
>
|
>
|
||||||
<option value="ru">🇷🇺 Русский</option>
|
<option value="ru">🇷🇺 Русский</option>
|
||||||
|
@ -339,10 +342,10 @@ export const Header = (props: Props) => {
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<SharePopup
|
<SharePopup
|
||||||
title={props.title}
|
title={props.title || ''}
|
||||||
imageUrl={props.cover}
|
imageUrl={props.cover || ''}
|
||||||
shareUrl={getShareUrl()}
|
shareUrl={getShareUrl()}
|
||||||
description={getDescription(props.articleBody)}
|
description={getDescription(props.articleBody || '')}
|
||||||
onVisibilityChange={(isVisible) => {
|
onVisibilityChange={(isVisible) => {
|
||||||
setIsSharePopupVisible(isVisible)
|
setIsSharePopupVisible(isVisible)
|
||||||
}}
|
}}
|
||||||
|
@ -373,7 +376,7 @@ export const Header = (props: Props) => {
|
||||||
class={clsx(styles.subnavigation, 'col')}
|
class={clsx(styles.subnavigation, 'col')}
|
||||||
classList={{ hidden: !isKnowledgeBaseVisible() }}
|
classList={{ hidden: !isKnowledgeBaseVisible() }}
|
||||||
onMouseOver={clearTimer}
|
onMouseOver={clearTimer}
|
||||||
onMouseOut={hideSubnavigation}
|
onMouseOut={() => hideSubnavigation()}
|
||||||
>
|
>
|
||||||
<ul class="nodash">
|
<ul class="nodash">
|
||||||
<li>
|
<li>
|
||||||
|
@ -407,7 +410,7 @@ export const Header = (props: Props) => {
|
||||||
class={clsx(styles.subnavigation, 'col')}
|
class={clsx(styles.subnavigation, 'col')}
|
||||||
classList={{ hidden: !isZineVisible() }}
|
classList={{ hidden: !isZineVisible() }}
|
||||||
onMouseOver={clearTimer}
|
onMouseOver={clearTimer}
|
||||||
onMouseOut={hideSubnavigation}
|
onMouseOut={() => hideSubnavigation()}
|
||||||
>
|
>
|
||||||
<ul class="nodash">
|
<ul class="nodash">
|
||||||
<li class="item">
|
<li class="item">
|
||||||
|
@ -453,12 +456,12 @@ export const Header = (props: Props) => {
|
||||||
class={clsx(styles.subnavigation, 'col')}
|
class={clsx(styles.subnavigation, 'col')}
|
||||||
classList={{ hidden: !isTopicsVisible() }}
|
classList={{ hidden: !isTopicsVisible() }}
|
||||||
onMouseOver={clearTimer}
|
onMouseOver={clearTimer}
|
||||||
onMouseOut={hideSubnavigation}
|
onMouseOut={() => hideSubnavigation()}
|
||||||
>
|
>
|
||||||
<ul class="nodash">
|
<ul class="nodash">
|
||||||
<Show when={randomTopics().length > 0}>
|
<Show when={randomTopics().length > 0}>
|
||||||
<For each={randomTopics()}>
|
<For each={randomTopics()}>
|
||||||
{(topic) => (
|
{(topic: Topic) => (
|
||||||
<li class="item">
|
<li class="item">
|
||||||
<a href={`/topic/${topic.slug}`}>
|
<a href={`/topic/${topic.slug}`}>
|
||||||
<span>#{tag(topic)}</span>
|
<span>#{tag(topic)}</span>
|
||||||
|
@ -480,57 +483,57 @@ export const Header = (props: Props) => {
|
||||||
class={clsx(styles.subnavigation, styles.subnavigationFeed, 'col')}
|
class={clsx(styles.subnavigation, styles.subnavigationFeed, 'col')}
|
||||||
classList={{ hidden: !isFeedVisible() }}
|
classList={{ hidden: !isFeedVisible() }}
|
||||||
onMouseOver={clearTimer}
|
onMouseOver={clearTimer}
|
||||||
onMouseOut={hideSubnavigation}
|
onMouseOut={() => hideSubnavigation()}
|
||||||
>
|
>
|
||||||
<ul class="nodash">
|
<ul class="nodash">
|
||||||
<li>
|
<li>
|
||||||
<a href={getPagePath(router, 'feed')}>
|
<A href={'/feed'}>
|
||||||
<span class={styles.subnavigationItemName}>
|
<span class={styles.subnavigationItemName}>
|
||||||
<Icon name="feed-all" class={styles.icon} />
|
<Icon name="feed-all" class={styles.icon} />
|
||||||
{t('All')}
|
{t('All')}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</A>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<a href={getPagePath(router, 'feedMy')}>
|
<A href={'/feed/my'}>
|
||||||
<span class={styles.subnavigationItemName}>
|
<span class={styles.subnavigationItemName}>
|
||||||
<Icon name="feed-my" class={styles.icon} />
|
<Icon name="feed-my" class={styles.icon} />
|
||||||
{t('My feed')}
|
{t('My feed')}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</A>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href={getPagePath(router, 'feedCollaborations')}>
|
<A href={'/feed/collab'}>
|
||||||
<span class={styles.subnavigationItemName}>
|
<span class={styles.subnavigationItemName}>
|
||||||
<Icon name="feed-collaborate" class={styles.icon} />
|
<Icon name="feed-collaborate" class={styles.icon} />
|
||||||
{t('Participation')}
|
{t('Participation')}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</A>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href={getPagePath(router, 'feedDiscussions')}>
|
<A href={'/feed/discussions'}>
|
||||||
<span class={styles.subnavigationItemName}>
|
<span class={styles.subnavigationItemName}>
|
||||||
<Icon name="feed-discussion" class={styles.icon} />
|
<Icon name="feed-discussion" class={styles.icon} />
|
||||||
{t('Discussions')}
|
{t('Discussions')}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</A>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href={getPagePath(router, 'feedBookmarks')}>
|
<A href={'/feed/bookmark'}>
|
||||||
<span class={styles.subnavigationItemName}>
|
<span class={styles.subnavigationItemName}>
|
||||||
<Icon name="bookmark" class={styles.icon} />
|
<Icon name="bookmark" class={styles.icon} />
|
||||||
{t('Bookmarks')}
|
{t('Bookmarks')}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</A>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href={getPagePath(router, 'feedNotifications')}>
|
<A href={'/feed/notifications'}>
|
||||||
<span class={styles.subnavigationItemName}>
|
<span class={styles.subnavigationItemName}>
|
||||||
<Icon name="feed-notifications" class={styles.icon} />
|
<Icon name="feed-notifications" class={styles.icon} />
|
||||||
{t('Notifications')}
|
{t('Notifications')}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</A>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { getPagePath } from '@nanostores/router'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
import { ROUTES, router, useRouter } from '../../../stores/router'
|
import { ROUTES } from '../../../config/routes'
|
||||||
import { ConditionalWrapper } from '../../_shared/ConditionalWrapper'
|
import { ConditionalWrapper } from '../../_shared/ConditionalWrapper'
|
||||||
|
|
||||||
|
import { A, useMatch } from '@solidjs/router'
|
||||||
|
import { createMemo } from 'solid-js'
|
||||||
import styles from './Header.module.scss'
|
import styles from './Header.module.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -16,16 +17,13 @@ type Props = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Link = (props: Props) => {
|
export const Link = (props: Props) => {
|
||||||
const { page } = useRouter()
|
const matchRoute = useMatch(() => props.routeName || '')
|
||||||
const isSelected = page()?.route === props.routeName
|
const isSelected = createMemo(() => Boolean(matchRoute()))
|
||||||
return (
|
return (
|
||||||
<li
|
<li onClick={props.onClick} classList={{ 'view-switcher__item--selected': isSelected() }}>
|
||||||
onClick={props.onClick}
|
|
||||||
classList={{ 'view-switcher__item--selected': page()?.route === props.routeName }}
|
|
||||||
>
|
|
||||||
<ConditionalWrapper
|
<ConditionalWrapper
|
||||||
condition={!isSelected && Boolean(props.routeName)}
|
condition={!isSelected && Boolean(props.routeName)}
|
||||||
wrapper={(children) => <a href={getPagePath(router, props.routeName)}>{children}</a>}
|
wrapper={(children) => <A href={props.routeName || ''}>{children}</A>}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class={clsx('cursorPointer linkReplacement', { [styles.mainNavigationItemActive]: props.active })}
|
class={clsx('cursorPointer linkReplacement', { [styles.mainNavigationItemActive]: props.active })}
|
||||||
|
|
|
@ -1,19 +1,18 @@
|
||||||
import { getPagePath } from '@nanostores/router'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js'
|
import { Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js'
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
|
import type { Author } from '~/graphql/schema/core.gen'
|
||||||
import { useEditorContext } from '../../context/editor'
|
import { useEditorContext } from '../../context/editor'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { useNotifications } from '../../context/notifications'
|
import { useNotifications } from '../../context/notifications'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
import { router, useRouter } from '../../stores/router'
|
|
||||||
import { showModal } from '../../stores/ui'
|
|
||||||
import { Userpic } from '../Author/Userpic'
|
import { Userpic } from '../Author/Userpic'
|
||||||
import { Button } from '../_shared/Button'
|
import { Button } from '../_shared/Button'
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import { Popover } from '../_shared/Popover'
|
import { Popover } from '../_shared/Popover'
|
||||||
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
||||||
|
|
||||||
|
import { A, useLocation } from '@solidjs/router'
|
||||||
import { Popup } from '../_shared/Popup'
|
import { Popup } from '../_shared/Popup'
|
||||||
import styles from './Header/Header.module.scss'
|
import styles from './Header/Header.module.scss'
|
||||||
import { ProfilePopup } from './ProfilePopup'
|
import { ProfilePopup } from './ProfilePopup'
|
||||||
|
@ -31,8 +30,9 @@ type IconedButtonProps = {
|
||||||
const MD_WIDTH_BREAKPOINT = 992
|
const MD_WIDTH_BREAKPOINT = 992
|
||||||
export const HeaderAuth = (props: Props) => {
|
export const HeaderAuth = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { page } = useRouter()
|
const { showModal } = useUI()
|
||||||
const { session, author, isSessionLoaded } = useSession()
|
const { session, isSessionLoaded } = useSession()
|
||||||
|
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
||||||
const { unreadNotificationsCount, showNotificationsPanel } = useNotifications()
|
const { unreadNotificationsCount, showNotificationsPanel } = useNotifications()
|
||||||
const { form, toggleEditorPanel, publishShout } = useEditorContext()
|
const { form, toggleEditorPanel, publishShout } = useEditorContext()
|
||||||
|
|
||||||
|
@ -46,8 +46,8 @@ export const HeaderAuth = (props: Props) => {
|
||||||
|
|
||||||
showNotificationsPanel()
|
showNotificationsPanel()
|
||||||
}
|
}
|
||||||
|
const loc = useLocation()
|
||||||
const isEditorPage = createMemo(() => page().route === 'edit' || page().route === 'editSettings')
|
const isEditorPage = createMemo(() => loc?.pathname.startsWith('/edit'))
|
||||||
const isNotificationsVisible = createMemo(() => session()?.access_token && !isEditorPage())
|
const isNotificationsVisible = createMemo(() => session()?.access_token && !isEditorPage())
|
||||||
const isSaveButtonVisible = createMemo(() => session()?.access_token && isEditorPage())
|
const isSaveButtonVisible = createMemo(() => session()?.access_token && isEditorPage())
|
||||||
const isCreatePostButtonVisible = createMemo(() => !isEditorPage())
|
const isCreatePostButtonVisible = createMemo(() => !isEditorPage())
|
||||||
|
@ -96,7 +96,8 @@ export const HeaderAuth = (props: Props) => {
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const matchInbox = createMemo(() => loc.pathname.endsWith('inbox'))
|
||||||
|
const matchProfile = createMemo(() => loc.pathname.endsWith(author()?.slug))
|
||||||
return (
|
return (
|
||||||
<ShowOnlyOnClient>
|
<ShowOnlyOnClient>
|
||||||
<Show when={isSessionLoaded()} keyed={true}>
|
<Show when={isSessionLoaded()} keyed={true}>
|
||||||
|
@ -110,11 +111,11 @@ export const HeaderAuth = (props: Props) => {
|
||||||
styles.userControlItemCreate,
|
styles.userControlItemCreate,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<a href={getPagePath(router, 'create')}>
|
<A href={'/create'}>
|
||||||
<span class={styles.textLabel}>{t('Create post')}</span>
|
<span class={styles.textLabel}>{t('Create post')}</span>
|
||||||
<Icon name="pencil-outline" class={styles.icon} />
|
<Icon name="pencil-outline" class={styles.icon} />
|
||||||
<Icon name="pencil-outline-hover" class={clsx(styles.icon, styles.iconHover)} />
|
<Icon name="pencil-outline-hover" class={clsx(styles.icon, styles.iconHover)} />
|
||||||
</a>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
@ -132,12 +133,12 @@ export const HeaderAuth = (props: Props) => {
|
||||||
<div class={styles.button}>
|
<div class={styles.button}>
|
||||||
<Icon
|
<Icon
|
||||||
name="bell-white"
|
name="bell-white"
|
||||||
counter={session() ? unreadNotificationsCount() || 0 : 1}
|
counter={session() ? unreadNotificationsCount?.() || 0 : 1}
|
||||||
class={styles.icon}
|
class={styles.icon}
|
||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
name="bell-white-hover"
|
name="bell-white-hover"
|
||||||
counter={session() ? unreadNotificationsCount() || 0 : 1}
|
counter={session() ? unreadNotificationsCount?.() || 0 : 1}
|
||||||
class={clsx(styles.icon, styles.iconHover)}
|
class={clsx(styles.icon, styles.iconHover)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -223,11 +224,11 @@ export const HeaderAuth = (props: Props) => {
|
||||||
styles.userControlItemCreate,
|
styles.userControlItemCreate,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<a href={getPagePath(router, 'create')}>
|
<A href={'/create'}>
|
||||||
<span class={styles.textLabel}>{t('Create post')}</span>
|
<span class={styles.textLabel}>{t('Create post')}</span>
|
||||||
<Icon name="pencil-outline" class={styles.icon} />
|
<Icon name="pencil-outline" class={styles.icon} />
|
||||||
<Icon name="pencil-outline-hover" class={clsx(styles.icon, styles.iconHover)} />
|
<Icon name="pencil-outline-hover" class={clsx(styles.icon, styles.iconHover)} />
|
||||||
</a>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
@ -252,12 +253,12 @@ export const HeaderAuth = (props: Props) => {
|
||||||
// styles.userControlItemInbox
|
// styles.userControlItemInbox
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<a href={getPagePath(router, 'inbox')}>
|
<A href={'/inbox'}>
|
||||||
<div classList={{ entered: page().path === '/inbox' }}>
|
<div classList={{ entered: Boolean(matchInbox()) }}>
|
||||||
<Icon name="inbox-white" class={styles.icon} />
|
<Icon name="inbox-white" class={styles.icon} />
|
||||||
<Icon name="inbox-white-hover" class={clsx(styles.icon, styles.iconHover)} />
|
<Icon name="inbox-white-hover" class={clsx(styles.icon, styles.iconHover)} />
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
@ -272,11 +273,11 @@ export const HeaderAuth = (props: Props) => {
|
||||||
trigger={
|
trigger={
|
||||||
<div class={clsx(styles.userControlItem, styles.userControlItemUserpic)}>
|
<div class={clsx(styles.userControlItem, styles.userControlItemUserpic)}>
|
||||||
<button class={styles.button}>
|
<button class={styles.button}>
|
||||||
<div classList={{ entered: page().path === `/${author()?.slug}` }}>
|
<div classList={{ entered: Boolean(matchProfile()) }}>
|
||||||
<Userpic
|
<Userpic
|
||||||
size={'L'}
|
size={'L'}
|
||||||
name={author()?.name}
|
name={author()?.name || ''}
|
||||||
userpic={author()?.pic}
|
userpic={author()?.pic || ''}
|
||||||
class={styles.userpic}
|
class={styles.userpic}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,15 +1,12 @@
|
||||||
import type { JSX } from 'solid-js'
|
|
||||||
|
|
||||||
import { redirectPage } from '@nanostores/router'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
import type { JSX } from 'solid-js'
|
||||||
import { Show, createEffect, createMemo, createSignal } from 'solid-js'
|
import { Show, createEffect, createMemo, createSignal } from 'solid-js'
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
import { useMediaQuery } from '../../../context/mediaQuery'
|
|
||||||
import { router } from '../../../stores/router'
|
|
||||||
import { hideModal, useModalStore } from '../../../stores/ui'
|
|
||||||
import { useEscKeyDownHandler } from '../../../utils/useEscKeyDownHandler'
|
import { useEscKeyDownHandler } from '../../../utils/useEscKeyDownHandler'
|
||||||
import { Icon } from '../../_shared/Icon'
|
import { Icon } from '../../_shared/Icon'
|
||||||
|
|
||||||
|
import { useNavigate } from '@solidjs/router'
|
||||||
|
import { mediaMatches } from '~/utils/media-query'
|
||||||
import styles from './Modal.module.scss'
|
import styles from './Modal.module.scss'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -24,17 +21,17 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Modal = (props: Props) => {
|
export const Modal = (props: Props) => {
|
||||||
const { modal } = useModalStore()
|
const { modal, hideModal } = useUI()
|
||||||
const [visible, setVisible] = createSignal(false)
|
const [visible, setVisible] = createSignal(false)
|
||||||
const allowClose = createMemo(() => props.allowClose !== false)
|
const allowClose = createMemo(() => props.allowClose !== false)
|
||||||
const [isMobileView, setIsMobileView] = createSignal(false)
|
const [isMobileView, setIsMobileView] = createSignal(false)
|
||||||
const { mediaMatches } = useMediaQuery()
|
const navigate = useNavigate()
|
||||||
const handleHide = () => {
|
const handleHide = () => {
|
||||||
if (modal()) {
|
if (modal()) {
|
||||||
if (allowClose()) {
|
if (allowClose()) {
|
||||||
props.onClose?.()
|
props.onClose?.()
|
||||||
} else {
|
} else {
|
||||||
redirectPage(router, 'home')
|
navigate('/')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hideModal()
|
hideModal()
|
||||||
|
@ -55,7 +52,7 @@ export const Modal = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<Show when={visible()}>
|
<Show when={visible()}>
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.backdrop, [styles[`modal-${props.name}`]], {
|
class={clsx(styles.backdrop, [styles[`modal-${props.name}` as keyof typeof styles]], {
|
||||||
[styles.isMobile]: isMobileView(),
|
[styles.isMobile]: isMobileView(),
|
||||||
})}
|
})}
|
||||||
onClick={handleHide}
|
onClick={handleHide}
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import type { JSX } from 'solid-js/jsx-runtime'
|
import type { JSX } from 'solid-js/jsx-runtime'
|
||||||
import type { ModalType } from '../../../stores/ui'
|
import { type ModalType, useUI } from '~/context/ui'
|
||||||
|
|
||||||
import { showModal } from '../../../stores/ui'
|
|
||||||
|
|
||||||
export default (props: { name: ModalType; children: JSX.Element }) => {
|
export default (props: { name: ModalType; children: JSX.Element }) => {
|
||||||
|
const { showModal } = useUI()
|
||||||
return (
|
return (
|
||||||
<a href="#" onClick={() => showModal(props.name)}>
|
<a href="#" onClick={() => showModal(props.name)}>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
|
|
@ -1,54 +1,48 @@
|
||||||
import type { PopupProps } from '../_shared/Popup'
|
import { clsx } from 'clsx'
|
||||||
|
import { createMemo } from 'solid-js'
|
||||||
import { getPagePath } from '@nanostores/router'
|
import type { Author } from '~/graphql/schema/core.gen'
|
||||||
|
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
import { router } from '../../stores/router'
|
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
|
import type { PopupProps } from '../_shared/Popup'
|
||||||
import { Popup } from '../_shared/Popup'
|
import { Popup } from '../_shared/Popup'
|
||||||
|
|
||||||
import { clsx } from 'clsx'
|
import { A } from '@solidjs/router'
|
||||||
import styles from '../_shared/Popup/Popup.module.scss'
|
import styles from '../_shared/Popup/Popup.module.scss'
|
||||||
|
|
||||||
type ProfilePopupProps = Omit<PopupProps, 'children'>
|
type ProfilePopupProps = Omit<PopupProps, 'children'>
|
||||||
|
|
||||||
export const ProfilePopup = (props: ProfilePopupProps) => {
|
export const ProfilePopup = (props: ProfilePopupProps) => {
|
||||||
const { author, signOut } = useSession()
|
const { session, signOut } = useSession()
|
||||||
|
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popup {...props} horizontalAnchor="right" popupCssClass={styles.profilePopup}>
|
<Popup {...props} horizontalAnchor="right" popupCssClass={styles.profilePopup}>
|
||||||
<ul class="nodash">
|
<ul class="nodash">
|
||||||
<li>
|
<li>
|
||||||
<a class={styles.action} href={getPagePath(router, 'author', { slug: author()?.slug })}>
|
<A class={styles.action} href={`/author/${author()?.slug || 'anonymous'}`}>
|
||||||
<Icon name="profile" class={styles.icon} />
|
<Icon name="profile" class={styles.icon} />
|
||||||
{t('Profile')}
|
{t('Profile')}
|
||||||
</a>
|
</A>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class={styles.action} href={getPagePath(router, 'drafts')}>
|
<A class={styles.action} href={'/drafts'}>
|
||||||
<Icon name="pencil-outline" class={styles.icon} />
|
<Icon name="pencil-outline" class={styles.icon} />
|
||||||
{t('Drafts')}
|
{t('Drafts')}
|
||||||
</a>
|
</A>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<A class={styles.action} href={`/author/${author()?.slug}?m=following`}>
|
||||||
class={styles.action}
|
|
||||||
href={`${getPagePath(router, 'author', { slug: author()?.slug })}?m=following`}
|
|
||||||
>
|
|
||||||
<Icon name="feed-all" class={styles.icon} />
|
<Icon name="feed-all" class={styles.icon} />
|
||||||
{t('Subscriptions')}
|
{t('Subscriptions')}
|
||||||
</a>
|
</A>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<A class={styles.action} href={`/author${author()?.slug}`}>
|
||||||
class={styles.action}
|
|
||||||
href={`${getPagePath(router, 'authorComments', { slug: author()?.slug })}`}
|
|
||||||
>
|
|
||||||
<Icon name="comment" class={styles.icon} />
|
<Icon name="comment" class={styles.icon} />
|
||||||
{t('Comments')}
|
{t('Comments')}
|
||||||
</a>
|
</A>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class={styles.action} href="#">
|
<a class={styles.action} href="#">
|
||||||
|
@ -57,10 +51,10 @@ export const ProfilePopup = (props: ProfilePopupProps) => {
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class={styles.action} href={getPagePath(router, 'profileSettings')}>
|
<A class={styles.action} href={'/profile/settings'}>
|
||||||
<Icon name="settings" class={styles.icon} />
|
<Icon name="settings" class={styles.icon} />
|
||||||
{t('Settings')}
|
{t('Settings')}
|
||||||
</a>
|
</A>
|
||||||
</li>
|
</li>
|
||||||
<li class={styles.topBorderItem}>
|
<li class={styles.topBorderItem}>
|
||||||
<span class={clsx(styles.action, 'link')} onClick={() => signOut()}>
|
<span class={clsx(styles.action, 'link')} onClick={() => signOut()}>
|
||||||
|
|
|
@ -1,24 +1,24 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { useRouter } from '../../../stores/router'
|
|
||||||
|
|
||||||
|
import { useLocation } from '@solidjs/router'
|
||||||
import styles from './ProfileSettingsNavigation.module.scss'
|
import styles from './ProfileSettingsNavigation.module.scss'
|
||||||
|
|
||||||
export const ProfileSettingsNavigation = () => {
|
export const ProfileSettingsNavigation = () => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { page } = useRouter()
|
const loc = useLocation()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4 class={styles.navigationHeader}>{t('Settings')}</h4>
|
<h4 class={styles.navigationHeader}>{t('Settings')}</h4>
|
||||||
<ul class={clsx(styles.navigation, 'nodash')}>
|
<ul class={clsx(styles.navigation, 'nodash')}>
|
||||||
<li class={clsx({ [styles.active]: page().route === 'profileSettings' })}>
|
<li class={clsx({ [styles.active]: loc?.pathname === '/profile/settings' })}>
|
||||||
<a href="/profile/settings">{t('Profile')}</a>
|
<a href="/profile/settings">{t('Profile')}</a>
|
||||||
</li>
|
</li>
|
||||||
<li class={clsx({ [styles.active]: page().route === 'profileSubscriptions' })}>
|
<li class={clsx({ [styles.active]: loc?.pathname === '/profile/subscriptions' })}>
|
||||||
<a href="/profile/subscriptions">{t('Subscriptions')}</a>
|
<a href="/profile/subscriptions">{t('Subscriptions')}</a>
|
||||||
</li>
|
</li>
|
||||||
<li class={clsx({ [styles.active]: page().route === 'profileSecurity' })}>
|
<li class={clsx({ [styles.active]: loc?.pathname === '/profile/security' })}>
|
||||||
<a href="/profile/security">{t('Security')}</a>
|
<a href="/profile/security">{t('Security')}</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -3,8 +3,8 @@ import type { Shout } from '../../../graphql/schema/core.gen'
|
||||||
import { For, Show, createResource, createSignal, onCleanup } from 'solid-js'
|
import { For, Show, createResource, createSignal, onCleanup } from 'solid-js'
|
||||||
import { debounce } from 'throttle-debounce'
|
import { debounce } from 'throttle-debounce'
|
||||||
|
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useFeed } from '~/context/feed'
|
||||||
import { loadShoutsSearch } from '../../../stores/zine/articles'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { restoreScrollPosition, saveScrollPosition } from '../../../utils/scroll'
|
import { restoreScrollPosition, saveScrollPosition } from '../../../utils/scroll'
|
||||||
import { byScore } from '../../../utils/sortby'
|
import { byScore } from '../../../utils/sortby'
|
||||||
import { FEED_PAGE_SIZE } from '../../Views/Feed/Feed'
|
import { FEED_PAGE_SIZE } from '../../Views/Feed/Feed'
|
||||||
|
@ -27,7 +27,7 @@ const getSearchCoincidences = ({ str, intersection }: { str: string; intersectio
|
||||||
)}</span>`
|
)}</span>`
|
||||||
|
|
||||||
const prepareSearchResults = (list: Shout[], searchValue: string) =>
|
const prepareSearchResults = (list: Shout[], searchValue: string) =>
|
||||||
list.sort(byScore()).map((article, index) => ({
|
list.sort(byScore() as (a: Shout, b: Shout) => number).map((article, index) => ({
|
||||||
...article,
|
...article,
|
||||||
id: index,
|
id: index,
|
||||||
title: article.title
|
title: article.title
|
||||||
|
@ -46,12 +46,13 @@ const prepareSearchResults = (list: Shout[], searchValue: string) =>
|
||||||
|
|
||||||
export const SearchModal = () => {
|
export const SearchModal = () => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
|
const { loadShoutsSearch } = useFeed()
|
||||||
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
|
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
|
||||||
const [inputValue, setInputValue] = createSignal('')
|
const [inputValue, setInputValue] = createSignal('')
|
||||||
const [isLoading, setIsLoading] = createSignal(false)
|
const [isLoading, setIsLoading] = createSignal(false)
|
||||||
const [offset, setOffset] = createSignal<number>(0)
|
const [offset, setOffset] = createSignal<number>(0)
|
||||||
const [searchResultsList, { refetch: loadSearchResults, mutate: setSearchResultsList }] = createResource<
|
const [searchResultsList, { refetch: loadSearchResults, mutate: setSearchResultsList }] = createResource<
|
||||||
Shout[] | null
|
Shout[]
|
||||||
>(
|
>(
|
||||||
async () => {
|
async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
@ -68,7 +69,7 @@ export const SearchModal = () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ssrLoadFrom: 'initial',
|
ssrLoadFrom: 'initial',
|
||||||
initialValue: null,
|
initialValue: [],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -81,7 +82,7 @@ export const SearchModal = () => {
|
||||||
await debouncedLoadMore()
|
await debouncedLoadMore()
|
||||||
} else {
|
} else {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
setSearchResultsList(null)
|
setSearchResultsList([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,7 +92,7 @@ export const SearchModal = () => {
|
||||||
await debouncedLoadMore()
|
await debouncedLoadMore()
|
||||||
} else {
|
} else {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
setSearchResultsList(null)
|
setSearchResultsList([])
|
||||||
}
|
}
|
||||||
restoreScrollPosition()
|
restoreScrollPosition()
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
@ -111,7 +112,7 @@ export const SearchModal = () => {
|
||||||
class={styles.searchInput}
|
class={styles.searchInput}
|
||||||
onInput={handleQueryInput}
|
onInput={handleQueryInput}
|
||||||
onKeyDown={enterQuery}
|
onKeyDown={enterQuery}
|
||||||
ref={searchEl}
|
ref={(el: HTMLInputElement) => (searchEl = el)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { clsx } from 'clsx'
|
||||||
import { Show } from 'solid-js'
|
import { Show } from 'solid-js'
|
||||||
import { Transition } from 'solid-transition-group'
|
import { Transition } from 'solid-transition-group'
|
||||||
|
|
||||||
import { useSnackbar } from '../../context/snackbar'
|
import { useSnackbar } from '~/context/ui'
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
||||||
|
|
||||||
|
@ -24,12 +24,12 @@ export const Snackbar = () => {
|
||||||
exitToClass={styles.exitTo}
|
exitToClass={styles.exitTo}
|
||||||
onExit={(_el, done) => setTimeout(() => done(), 300)}
|
onExit={(_el, done) => setTimeout(() => done(), 300)}
|
||||||
>
|
>
|
||||||
<Show when={snackbarMessage()}>
|
<Show when={snackbarMessage()?.body}>
|
||||||
<div class={styles.content}>
|
<div class={styles.content}>
|
||||||
<Show when={snackbarMessage()?.type === 'success'}>
|
<Show when={snackbarMessage()?.type === 'success'}>
|
||||||
<Icon name="check-success" class={styles.icon} />
|
<Icon name="check-success" class={styles.icon} />
|
||||||
</Show>
|
</Show>
|
||||||
{snackbarMessage().body}
|
{snackbarMessage()?.body || ''}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
|
@ -1,57 +1,55 @@
|
||||||
import { getPagePath } from '@nanostores/router'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { router, useRouter } from '../../../stores/router'
|
|
||||||
import { Icon } from '../../_shared/Icon'
|
import { Icon } from '../../_shared/Icon'
|
||||||
|
|
||||||
|
import { A, useMatch } from '@solidjs/router'
|
||||||
import styles from './Topics.module.scss'
|
import styles from './Topics.module.scss'
|
||||||
|
|
||||||
export const Topics = () => {
|
export const Topics = () => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { page } = useRouter()
|
const matchExpo = useMatch(() => '/expo')
|
||||||
return (
|
return (
|
||||||
<nav class={clsx('wide-container text-2xl', styles.Topics)}>
|
<nav class={clsx('wide-container text-2xl', styles.Topics)}>
|
||||||
<ul class={styles.list}>
|
<ul class={styles.list}>
|
||||||
<li class={styles.item}>
|
<li class={styles.item}>
|
||||||
<a class={clsx({ [styles.selected]: page().route === 'expo' })} href="/expo">
|
<A class={clsx({ [styles.selected]: matchExpo() })} href="/expo">
|
||||||
{t('Art')}
|
{t('Art')}
|
||||||
</a>
|
</A>
|
||||||
</li>
|
</li>
|
||||||
<li class={styles.item}>
|
<li class={styles.item}>
|
||||||
<a href="/podcasts">{t('Podcasts')}</a>
|
<A href="/podcasts">{t('Podcasts')}</A>
|
||||||
</li>
|
</li>
|
||||||
<li class={styles.item}>
|
<li class={styles.item}>
|
||||||
<a href="/about/projects">{t('Special Projects')}</a>
|
<A href="/about/projects">{t('Special Projects')}</A>
|
||||||
</li>
|
</li>
|
||||||
<li class={styles.item}>
|
<li class={styles.item}>
|
||||||
<a href="/topic/interview">#{t('Interview')}</a>
|
<A href="/topic/interview">#{t('Interview')}</A>
|
||||||
</li>
|
</li>
|
||||||
<li class={styles.item}>
|
<li class={styles.item}>
|
||||||
<a href="/topic/reportage">#{t('Reports')}</a>
|
<A href="/topic/reportage">#{t('Reports')}</A>
|
||||||
</li>
|
</li>
|
||||||
<li class={styles.item}>
|
<li class={styles.item}>
|
||||||
<a href="/topic/empiric">#{t('Experience')}</a>
|
<A href="/topic/empiric">#{t('Experience')}</A>
|
||||||
</li>
|
</li>
|
||||||
<li class={styles.item}>
|
<li class={styles.item}>
|
||||||
<a href="/topic/society">#{t('Society')}</a>
|
<A href="/topic/society">#{t('Society')}</A>
|
||||||
</li>
|
</li>
|
||||||
<li class={styles.item}>
|
<li class={styles.item}>
|
||||||
<a href="/topic/culture">#{t('Culture')}</a>
|
<A href="/topic/culture">#{t('Culture')}</A>
|
||||||
</li>
|
</li>
|
||||||
<li class={styles.item}>
|
<li class={styles.item}>
|
||||||
<a href="/topic/theory">#{t('Theory')}</a>
|
<A href="/topic/theory">#{t('Theory')}</A>
|
||||||
</li>
|
</li>
|
||||||
<li class={styles.item}>
|
<li class={styles.item}>
|
||||||
<a href="/topic/poetry">#{t('Poetry')}</a>
|
<A href="/topic/poetry">#{t('Poetry')}</A>
|
||||||
</li>
|
</li>
|
||||||
<li class={clsx(styles.item, styles.right)}>
|
<li class={clsx(styles.item, styles.right)}>
|
||||||
<a href={getPagePath(router, 'topics')}>
|
<A href={'topics'}>
|
||||||
<span>
|
<span>
|
||||||
{t('All topics')}
|
{t('All topics')}
|
||||||
<Icon name="arrow-right-black" class={'icon'} />
|
<Icon name="arrow-right-black" class={'icon'} />
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</A>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
@ -1,15 +1,13 @@
|
||||||
import { getPagePath, openPage } from '@nanostores/router'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { For, Show } from 'solid-js'
|
import { For, Show } from 'solid-js'
|
||||||
|
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { useNotifications } from '../../../context/notifications'
|
import { useNotifications } from '../../../context/notifications'
|
||||||
import { NotificationGroup as Group } from '../../../graphql/schema/core.gen'
|
import { Author, NotificationGroup as Group } from '../../../graphql/schema/core.gen'
|
||||||
import { router, useRouter } from '../../../stores/router'
|
|
||||||
import { ArticlePageSearchParams } from '../../Article/FullArticle'
|
|
||||||
import { GroupAvatar } from '../../_shared/GroupAvatar'
|
import { GroupAvatar } from '../../_shared/GroupAvatar'
|
||||||
import { TimeAgo } from '../../_shared/TimeAgo'
|
import { TimeAgo } from '../../_shared/TimeAgo'
|
||||||
|
|
||||||
|
import { A, useNavigate, useSearchParams } from '@solidjs/router'
|
||||||
import styles from './NotificationView.module.scss'
|
import styles from './NotificationView.module.scss'
|
||||||
|
|
||||||
type NotificationGroupProps = {
|
type NotificationGroupProps = {
|
||||||
|
@ -44,14 +42,15 @@ const threadCaption = (threadId: string) =>
|
||||||
|
|
||||||
export const NotificationGroup = (props: NotificationGroupProps) => {
|
export const NotificationGroup = (props: NotificationGroupProps) => {
|
||||||
const { t, formatTime, formatDate } = useLocalize()
|
const { t, formatTime, formatDate } = useLocalize()
|
||||||
const { changeSearchParams } = useRouter<ArticlePageSearchParams>()
|
const navigate = useNavigate()
|
||||||
|
const [, changeSearchParams] = useSearchParams()
|
||||||
const { hideNotificationsPanel, markSeenThread } = useNotifications()
|
const { hideNotificationsPanel, markSeenThread } = useNotifications()
|
||||||
const handleClick = (threadId: string) => {
|
const handleClick = (threadId: string) => {
|
||||||
props.onClick()
|
props.onClick()
|
||||||
|
|
||||||
markSeenThread(threadId)
|
markSeenThread(threadId)
|
||||||
const [slug, commentId] = threadId.split('::')
|
const [slug, commentId] = threadId.split('::')
|
||||||
openPage(router, 'article', { slug })
|
navigate(`/article/${slug}`)
|
||||||
if (commentId) changeSearchParams({ commentId })
|
if (commentId) changeSearchParams({ commentId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,25 +64,22 @@ export const NotificationGroup = (props: NotificationGroupProps) => {
|
||||||
<For each={props.notifications}>
|
<For each={props.notifications}>
|
||||||
{(n: Group, _index) => (
|
{(n: Group, _index) => (
|
||||||
<>
|
<>
|
||||||
{t(threadCaption(n.thread), { commentsCount: n.reactions.length })}{' '}
|
{t(threadCaption(n.thread), { commentsCount: n.reactions?.length || 0 })}{' '}
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.NotificationView, props.class, { [styles.seen]: n.seen })}
|
class={clsx(styles.NotificationView, props.class, { [styles.seen]: n.seen })}
|
||||||
onClick={(_) => handleClick(n.thread)}
|
onClick={(_) => handleClick(n.thread)}
|
||||||
>
|
>
|
||||||
<div class={styles.userpic}>
|
<div class={styles.userpic}>
|
||||||
<GroupAvatar authors={n.authors} />
|
<GroupAvatar authors={n.authors as Author[]} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a href={getPagePath(router, 'article', { slug: n.shout.slug })} onClick={handleLinkClick}>
|
<A href={`/article/${n.shout?.slug || ''}`} onClick={handleLinkClick}>
|
||||||
{getTitle(n.shout.title)}
|
{getTitle(n.shout?.title || '')}
|
||||||
</a>{' '}
|
</A>{' '}
|
||||||
{t('from')}{' '}
|
{t('from')}{' '}
|
||||||
<a
|
<A href={`/author/${n.authors?.[0]?.slug || ''}`} onClick={handleLinkClick}>
|
||||||
href={getPagePath(router, 'author', { slug: n.authors[0].slug })}
|
{n.authors?.[0]?.name || ''}
|
||||||
onClick={handleLinkClick}
|
</A>{' '}
|
||||||
>
|
|
||||||
{n.authors[0].name}
|
|
||||||
</a>{' '}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class={styles.timeContainer}>
|
<div class={styles.timeContainer}>
|
||||||
|
|
|
@ -24,7 +24,7 @@ const getYesterdayStart = () => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0, 0)
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0, 0)
|
||||||
}
|
}
|
||||||
|
const hourAgo = () => Date.now() - 3600 * 1000
|
||||||
const isSameDate = (date1: Date, date2: Date) =>
|
const isSameDate = (date1: Date, date2: Date) =>
|
||||||
date1.getDate() === date2.getDate() &&
|
date1.getDate() === date2.getDate() &&
|
||||||
date1.getMonth() === date2.getMonth() &&
|
date1.getMonth() === date2.getMonth() &&
|
||||||
|
@ -46,7 +46,7 @@ const isEarlier = (date: Date) => {
|
||||||
|
|
||||||
export const NotificationsPanel = (props: Props) => {
|
export const NotificationsPanel = (props: Props) => {
|
||||||
const [isLoading, setIsLoading] = createSignal(false)
|
const [isLoading, setIsLoading] = createSignal(false)
|
||||||
const { author } = useSession()
|
const { session } = useSession()
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const {
|
const {
|
||||||
after,
|
after,
|
||||||
|
@ -61,9 +61,7 @@ export const NotificationsPanel = (props: Props) => {
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const panelRef: { current: HTMLDivElement } = {
|
let panelRef: HTMLDivElement | undefined
|
||||||
current: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
useOutsideClickHandler({
|
useOutsideClickHandler({
|
||||||
containerRef: panelRef,
|
containerRef: panelRef,
|
||||||
|
@ -76,14 +74,14 @@ export const NotificationsPanel = (props: Props) => {
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const mainContent = document.querySelector<HTMLDivElement>('.main-content')
|
const mainContent = document.querySelector<HTMLDivElement>('.main-content')
|
||||||
|
|
||||||
if (props.isOpen) {
|
if (props.isOpen && mainContent && window) {
|
||||||
windowScrollTop = window.scrollY
|
windowScrollTop = window.scrollY
|
||||||
mainContent.style.marginTop = `-${windowScrollTop}px`
|
mainContent.style.marginTop = `-${windowScrollTop}px`
|
||||||
}
|
}
|
||||||
|
|
||||||
document.body.classList.toggle('fixed', props.isOpen)
|
document.body.classList.toggle('fixed', props.isOpen)
|
||||||
|
|
||||||
if (!props.isOpen) {
|
if (!props.isOpen && mainContent && window) {
|
||||||
mainContent.style.marginTop = ''
|
mainContent.style.marginTop = ''
|
||||||
window.scrollTo(0, windowScrollTop)
|
window.scrollTo(0, windowScrollTop)
|
||||||
}
|
}
|
||||||
|
@ -111,11 +109,15 @@ export const NotificationsPanel = (props: Props) => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const scrollContainerRef: { current: HTMLDivElement } = { current: null }
|
let scrollContainerRef: HTMLDivElement | undefined
|
||||||
const loadNextPage = async () => {
|
const loadNextPage = async () => {
|
||||||
await loadNotificationsGrouped({ after: after(), limit: PAGE_SIZE, offset: loadedNotificationsCount() })
|
await loadNotificationsGrouped({
|
||||||
|
after: after() || hourAgo(),
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: loadedNotificationsCount(),
|
||||||
|
})
|
||||||
if (loadedNotificationsCount() < totalNotificationsCount()) {
|
if (loadedNotificationsCount() < totalNotificationsCount()) {
|
||||||
const hasMore = scrollContainerRef.current.scrollHeight <= scrollContainerRef.current.offsetHeight
|
const hasMore = (scrollContainerRef?.scrollHeight || 0) <= (scrollContainerRef?.offsetHeight || 0)
|
||||||
|
|
||||||
if (hasMore) {
|
if (hasMore) {
|
||||||
await loadNextPage()
|
await loadNextPage()
|
||||||
|
@ -123,7 +125,7 @@ export const NotificationsPanel = (props: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const handleScroll = async () => {
|
const handleScroll = async () => {
|
||||||
if (!scrollContainerRef.current || isLoading()) {
|
if (!scrollContainerRef || isLoading()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (totalNotificationsCount() === loadedNotificationsCount()) {
|
if (totalNotificationsCount() === loadedNotificationsCount()) {
|
||||||
|
@ -131,8 +133,8 @@ export const NotificationsPanel = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNearBottom =
|
const isNearBottom =
|
||||||
scrollContainerRef.current.scrollHeight - scrollContainerRef.current.scrollTop <=
|
scrollContainerRef.scrollHeight - scrollContainerRef.scrollTop <=
|
||||||
scrollContainerRef.current.clientHeight * 1.5
|
scrollContainerRef.clientHeight * 1.5
|
||||||
|
|
||||||
if (isNearBottom) {
|
if (isNearBottom) {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
@ -143,15 +145,15 @@ export const NotificationsPanel = (props: Props) => {
|
||||||
const handleScrollThrottled = throttle(50, handleScroll)
|
const handleScrollThrottled = throttle(50, handleScroll)
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
scrollContainerRef.current.addEventListener('scroll', handleScrollThrottled)
|
scrollContainerRef?.addEventListener('scroll', handleScrollThrottled)
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
scrollContainerRef.current.removeEventListener('scroll', handleScrollThrottled)
|
scrollContainerRef?.removeEventListener('scroll', handleScrollThrottled)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(author, async (a) => {
|
on(session, async (s) => {
|
||||||
if (a?.id) {
|
if (s?.access_token) {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
await loadNextPage()
|
await loadNextPage()
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
@ -165,12 +167,12 @@ export const NotificationsPanel = (props: Props) => {
|
||||||
[styles.isOpened]: props.isOpen,
|
[styles.isOpened]: props.isOpen,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div ref={(el) => (panelRef.current = el)} class={styles.panel}>
|
<div ref={(el) => (panelRef = el)} class={styles.panel}>
|
||||||
<div class={styles.closeButton} onClick={handleHide}>
|
<div class={styles.closeButton} onClick={handleHide}>
|
||||||
<Icon class={styles.closeIcon} name="close" />
|
<Icon class={styles.closeIcon} name="close" />
|
||||||
</div>
|
</div>
|
||||||
<div class={styles.title}>{t('Notifications')}</div>
|
<div class={styles.title}>{t('Notifications')}</div>
|
||||||
<div class={clsx('wide-container', styles.content)} ref={(el) => (scrollContainerRef.current = el)}>
|
<div class={clsx('wide-container', styles.content)} ref={(el) => (scrollContainerRef = el)}>
|
||||||
<Show
|
<Show
|
||||||
when={sortedNotifications().length > 0}
|
when={sortedNotifications().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { createFileUploader } from '@solid-primitives/upload'
|
import { UploadFile, createFileUploader } from '@solid-primitives/upload'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import deepEqual from 'fast-deep-equal'
|
import deepEqual from 'fast-deep-equal'
|
||||||
import {
|
import {
|
||||||
|
@ -15,14 +15,12 @@ import {
|
||||||
} from 'solid-js'
|
} from 'solid-js'
|
||||||
import { createStore } from 'solid-js/store'
|
import { createStore } from 'solid-js/store'
|
||||||
|
|
||||||
import { useConfirm } from '../../context/confirm'
|
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { useProfileForm } from '../../context/profile'
|
import { useProfile } from '../../context/profile'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
import { useSnackbar } from '../../context/snackbar'
|
import { useSnackbar, useUI } from '../../context/ui'
|
||||||
import { ProfileInput } from '../../graphql/schema/core.gen'
|
import { InputMaybe, ProfileInput } from '../../graphql/schema/core.gen'
|
||||||
import styles from '../../pages/profile/Settings.module.scss'
|
import styles from '../../pages/profile/Settings.module.scss'
|
||||||
import { hideModal, showModal } from '../../stores/ui'
|
|
||||||
import { clone } from '../../utils/clone'
|
import { clone } from '../../utils/clone'
|
||||||
import { getImageUrl } from '../../utils/getImageUrl'
|
import { getImageUrl } from '../../utils/getImageUrl'
|
||||||
import { handleImageUpload } from '../../utils/handleImageUpload'
|
import { handleImageUpload } from '../../utils/handleImageUpload'
|
||||||
|
@ -40,37 +38,43 @@ import { SocialNetworkInput } from '../_shared/SocialNetworkInput'
|
||||||
const SimplifiedEditor = lazy(() => import('../../components/Editor/SimplifiedEditor'))
|
const SimplifiedEditor = lazy(() => import('../../components/Editor/SimplifiedEditor'))
|
||||||
const GrowingTextarea = lazy(() => import('../../components/_shared/GrowingTextarea/GrowingTextarea'))
|
const GrowingTextarea = lazy(() => import('../../components/_shared/GrowingTextarea/GrowingTextarea'))
|
||||||
|
|
||||||
|
function filterNulls(arr: InputMaybe<string>[]): string[] {
|
||||||
|
return arr.filter((item): item is string => item !== null && item !== undefined)
|
||||||
|
}
|
||||||
|
|
||||||
export const ProfileSettings = () => {
|
export const ProfileSettings = () => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const [prevForm, setPrevForm] = createStore<ProfileInput>({})
|
const [prevForm, setPrevForm] = createStore<ProfileInput>({})
|
||||||
const [isFormInitialized, setIsFormInitialized] = createSignal(false)
|
const [isFormInitialized, setIsFormInitialized] = createSignal(false)
|
||||||
const [isSaving, setIsSaving] = createSignal(false)
|
const [isSaving, setIsSaving] = createSignal(false)
|
||||||
const [social, setSocial] = createSignal([])
|
const [social, setSocial] = createSignal<string[]>([])
|
||||||
const [addLinkForm, setAddLinkForm] = createSignal<boolean>(false)
|
const [addLinkForm, setAddLinkForm] = createSignal<boolean>(false)
|
||||||
const [incorrectUrl, setIncorrectUrl] = createSignal<boolean>(false)
|
const [incorrectUrl, setIncorrectUrl] = createSignal<boolean>(false)
|
||||||
const [isUserpicUpdating, setIsUserpicUpdating] = createSignal(false)
|
const [isUserpicUpdating, setIsUserpicUpdating] = createSignal(false)
|
||||||
const [userpicFile, setUserpicFile] = createSignal(null)
|
const [userpicFile, setUserpicFile] = createSignal<UploadFile>()
|
||||||
const [uploadError, setUploadError] = createSignal(false)
|
const [uploadError, setUploadError] = createSignal(false)
|
||||||
const [isFloatingPanelVisible, setIsFloatingPanelVisible] = createSignal(false)
|
const [isFloatingPanelVisible, setIsFloatingPanelVisible] = createSignal(false)
|
||||||
const [hostname, setHostname] = createSignal<string | null>(null)
|
const [hostname, setHostname] = createSignal<string | null>(null)
|
||||||
const [slugError, setSlugError] = createSignal<string>()
|
const [slugError, setSlugError] = createSignal<string>()
|
||||||
const [nameError, setNameError] = createSignal<string>()
|
const [nameError, setNameError] = createSignal<string>()
|
||||||
const { form, submit, updateFormField, setForm } = useProfileForm()
|
const { form, submit, updateFormField, setForm } = useProfile()
|
||||||
const { showSnackbar } = useSnackbar()
|
const { showSnackbar } = useSnackbar()
|
||||||
const { loadSession, session } = useSession()
|
const { loadSession, session } = useSession()
|
||||||
const { showConfirm } = useConfirm()
|
const { showConfirm } = useUI()
|
||||||
const [clearAbout, setClearAbout] = createSignal(false)
|
const [clearAbout, setClearAbout] = createSignal(false)
|
||||||
|
const { showModal, hideModal } = useUI()
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (Object.keys(form).length > 0 && !isFormInitialized()) {
|
if (Object.keys(form).length > 0 && !isFormInitialized()) {
|
||||||
setPrevForm(form)
|
setPrevForm(form)
|
||||||
setSocial(form.links)
|
const soc: string[] = filterNulls(form.links || [])
|
||||||
|
setSocial(soc)
|
||||||
setIsFormInitialized(true)
|
setIsFormInitialized(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const slugInputRef: { current: HTMLInputElement } = { current: null }
|
let slugInputRef: HTMLInputElement | null
|
||||||
const nameInputRef: { current: HTMLInputElement } = { current: null }
|
let nameInputRef: HTMLInputElement | null
|
||||||
|
|
||||||
const handleChangeSocial = (value: string) => {
|
const handleChangeSocial = (value: string) => {
|
||||||
if (validateUrl(value)) {
|
if (validateUrl(value)) {
|
||||||
|
@ -81,18 +85,18 @@ export const ProfileSettings = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (event: Event) => {
|
const handleSubmit = async (event: MouseEvent | undefined) => {
|
||||||
event.preventDefault()
|
event?.preventDefault()
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
if (nameInputRef.current.value.length === 0) {
|
if (nameInputRef?.value.length === 0) {
|
||||||
setNameError(t('Required'))
|
setNameError(t('Required'))
|
||||||
nameInputRef.current.focus()
|
nameInputRef?.focus()
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (slugInputRef.current.value.length === 0) {
|
if (slugInputRef?.value.length === 0) {
|
||||||
setSlugError(t('Required'))
|
setSlugError(t('Required'))
|
||||||
slugInputRef.current.focus()
|
slugInputRef?.focus()
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -102,9 +106,9 @@ export const ProfileSettings = () => {
|
||||||
setPrevForm(clone(form))
|
setPrevForm(clone(form))
|
||||||
showSnackbar({ body: t('Profile successfully saved') })
|
showSnackbar({ body: t('Profile successfully saved') })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'duplicate_slug') {
|
if (error?.toString().search('duplicate_slug')) {
|
||||||
setSlugError(t('The address is already taken'))
|
setSlugError(t('The address is already taken'))
|
||||||
slugInputRef.current.focus()
|
slugInputRef?.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
showSnackbar({ type: 'error', body: t('Error') })
|
showSnackbar({ type: 'error', body: t('Error') })
|
||||||
|
@ -132,21 +136,21 @@ export const ProfileSettings = () => {
|
||||||
const { selectFiles } = createFileUploader({ multiple: false, accept: 'image/*' })
|
const { selectFiles } = createFileUploader({ multiple: false, accept: 'image/*' })
|
||||||
|
|
||||||
selectFiles(([uploadFile]) => {
|
selectFiles(([uploadFile]) => {
|
||||||
setUserpicFile(uploadFile)
|
setUserpicFile(uploadFile as UploadFile)
|
||||||
|
|
||||||
showModal('cropImage')
|
showModal('cropImage')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUploadAvatar = async (uploadFile) => {
|
const handleUploadAvatar = async (uploadFile: UploadFile) => {
|
||||||
try {
|
try {
|
||||||
setUploadError(false)
|
setUploadError(false)
|
||||||
setIsUserpicUpdating(true)
|
setIsUserpicUpdating(true)
|
||||||
|
|
||||||
const result = await handleImageUpload(uploadFile, session()?.access_token)
|
const result = await handleImageUpload(uploadFile, session()?.access_token || '')
|
||||||
updateFormField('pic', result.url)
|
updateFormField('pic', result.url)
|
||||||
|
|
||||||
setUserpicFile(null)
|
setUserpicFile(undefined)
|
||||||
setIsUserpicUpdating(false)
|
setIsUserpicUpdating(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setUploadError(true)
|
setUploadError(true)
|
||||||
|
@ -158,7 +162,7 @@ export const ProfileSettings = () => {
|
||||||
setHostname(window?.location.host)
|
setHostname(window?.location.host)
|
||||||
|
|
||||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||||
const handleBeforeUnload = (event) => {
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
if (!deepEqual(form, prevForm)) {
|
if (!deepEqual(form, prevForm)) {
|
||||||
event.returnValue = t(
|
event.returnValue = t(
|
||||||
'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 profile settings. Are you sure you want to leave the page without saving?',
|
||||||
|
@ -181,7 +185,7 @@ export const ProfileSettings = () => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDeleteSocialLink = (link) => {
|
const handleDeleteSocialLink = (link: string) => {
|
||||||
updateFormField('links', link, true)
|
updateFormField('links', link, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -215,7 +219,7 @@ export const ProfileSettings = () => {
|
||||||
<div
|
<div
|
||||||
class={styles.userpicImage}
|
class={styles.userpicImage}
|
||||||
style={{
|
style={{
|
||||||
'background-image': `url(${getImageUrl(form.pic, {
|
'background-image': `url(${getImageUrl(form.pic || '', {
|
||||||
width: 180,
|
width: 180,
|
||||||
height: 180,
|
height: 180,
|
||||||
})})`,
|
})})`,
|
||||||
|
@ -223,7 +227,7 @@ export const ProfileSettings = () => {
|
||||||
/>
|
/>
|
||||||
<div class={styles.controls}>
|
<div class={styles.controls}>
|
||||||
<Popover content={t('Delete userpic')}>
|
<Popover content={t('Delete userpic')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
class={styles.control}
|
class={styles.control}
|
||||||
|
@ -236,7 +240,7 @@ export const ProfileSettings = () => {
|
||||||
|
|
||||||
{/* @@TODO inspect popover below. onClick causes page refreshing */}
|
{/* @@TODO inspect popover below. onClick causes page refreshing */}
|
||||||
{/* <Popover content={t('Upload userpic')}>
|
{/* <Popover content={t('Upload userpic')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
class={styles.control}
|
class={styles.control}
|
||||||
|
@ -273,8 +277,8 @@ export const ProfileSettings = () => {
|
||||||
autocomplete="one-time-code"
|
autocomplete="one-time-code"
|
||||||
placeholder={t('Name')}
|
placeholder={t('Name')}
|
||||||
onInput={(event) => updateFormField('name', event.currentTarget.value)}
|
onInput={(event) => updateFormField('name', event.currentTarget.value)}
|
||||||
value={form.name}
|
value={form.name || ''}
|
||||||
ref={(el) => (nameInputRef.current = el)}
|
ref={(el) => (nameInputRef = el)}
|
||||||
/>
|
/>
|
||||||
<label for="nameOfUser">{t('Name')}</label>
|
<label for="nameOfUser">{t('Name')}</label>
|
||||||
<Show when={nameError()}>
|
<Show when={nameError()}>
|
||||||
|
@ -299,8 +303,8 @@ export const ProfileSettings = () => {
|
||||||
data-lpignore="true"
|
data-lpignore="true"
|
||||||
autocomplete="one-time-code2"
|
autocomplete="one-time-code2"
|
||||||
onInput={(event) => updateFormField('slug', event.currentTarget.value)}
|
onInput={(event) => updateFormField('slug', event.currentTarget.value)}
|
||||||
value={form.slug}
|
value={form.slug || ''}
|
||||||
ref={(el) => (slugInputRef.current = el)}
|
ref={(el) => (slugInputRef = el)}
|
||||||
class="nolabel"
|
class="nolabel"
|
||||||
/>
|
/>
|
||||||
<Show when={slugError()}>
|
<Show when={slugError()}>
|
||||||
|
@ -359,7 +363,7 @@ export const ProfileSettings = () => {
|
||||||
network={network.name}
|
network={network.name}
|
||||||
handleInput={(value) => handleChangeSocial(value)}
|
handleInput={(value) => handleChangeSocial(value)}
|
||||||
isExist={!network.isPlaceholder}
|
isExist={!network.isPlaceholder}
|
||||||
slug={form.slug}
|
slug={form.slug || ''}
|
||||||
handleDelete={() => handleDeleteSocialLink(network.link)}
|
handleDelete={() => handleDeleteSocialLink(network.link)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -405,12 +409,12 @@ export const ProfileSettings = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Modal variant="medium" name="cropImage" onClose={() => setUserpicFile(null)}>
|
<Modal variant="medium" name="cropImage" onClose={() => setUserpicFile(undefined)}>
|
||||||
<h2>{t('Crop image')}</h2>
|
<h2>{t('Crop image')}</h2>
|
||||||
|
|
||||||
<Show when={userpicFile()}>
|
<Show when={Boolean(userpicFile())}>
|
||||||
<ImageCropper
|
<ImageCropper
|
||||||
uploadFile={userpicFile()}
|
uploadFile={userpicFile() as UploadFile}
|
||||||
onSave={(data) => {
|
onSave={(data) => {
|
||||||
handleUploadAvatar(data)
|
handleUploadAvatar(data)
|
||||||
|
|
||||||
|
|
|
@ -2,8 +2,8 @@ import { clsx } from 'clsx'
|
||||||
import { For, Show, createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
|
import { For, Show, createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
|
||||||
import { debounce, throttle } from 'throttle-debounce'
|
import { debounce, throttle } from 'throttle-debounce'
|
||||||
|
|
||||||
|
import { DEFAULT_HEADER_OFFSET } from '~/context/ui'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { DEFAULT_HEADER_OFFSET } from '../../stores/router'
|
|
||||||
import { isDesktop } from '../../utils/media-query'
|
import { isDesktop } from '../../utils/media-query'
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
|
|
||||||
|
@ -19,7 +19,8 @@ const isInViewport = (el: Element): boolean => {
|
||||||
const rect = el.getBoundingClientRect()
|
const rect = el.getBoundingClientRect()
|
||||||
return rect.top <= DEFAULT_HEADER_OFFSET + 24 // default offset + 1.5em (default header margin-top)
|
return rect.top <= DEFAULT_HEADER_OFFSET + 24 // default offset + 1.5em (default header margin-top)
|
||||||
}
|
}
|
||||||
const scrollToHeader = (element) => {
|
const scrollToHeader = (element: HTMLElement) => {
|
||||||
|
if (window)
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
top:
|
top:
|
||||||
|
@ -43,12 +44,15 @@ export const TableOfContents = (props: Props) => {
|
||||||
setIsVisible(isDesktop())
|
setIsVisible(isDesktop())
|
||||||
|
|
||||||
const updateHeadings = () => {
|
const updateHeadings = () => {
|
||||||
|
if (document) {
|
||||||
|
const parent = document.querySelector(props.parentSelector)
|
||||||
|
if (parent) {
|
||||||
setHeadings(
|
setHeadings(
|
||||||
// eslint-disable-next-line unicorn/prefer-spread
|
// eslint-disable-next-line unicorn/prefer-spread
|
||||||
Array.from(
|
Array.from(parent.querySelectorAll<HTMLElement>('h1, h2, h3, h4')),
|
||||||
document.querySelector(props.parentSelector).querySelectorAll<HTMLElement>('h1, h2, h3, h4'),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
setAreHeadingsLoaded(true)
|
setAreHeadingsLoaded(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,7 +102,7 @@ export const TableOfContents = (props: Props) => {
|
||||||
[styles.TableOfContentsHeadingsItemH4]: h.nodeName === 'H4',
|
[styles.TableOfContentsHeadingsItemH4]: h.nodeName === 'H4',
|
||||||
[styles.active]: index() === activeHeaderIndex(),
|
[styles.active]: index() === activeHeaderIndex(),
|
||||||
})}
|
})}
|
||||||
innerHTML={h.textContent}
|
innerHTML={h.textContent || ''}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
scrollToHeader(h)
|
scrollToHeader(h)
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { Show, createEffect, createMemo, createSignal, on } from 'solid-js'
|
||||||
import { useFollowing } from '../../context/following'
|
import { useFollowing } from '../../context/following'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
import { FollowingEntity, type Topic } from '../../graphql/schema/core.gen'
|
import { Author, FollowingEntity, type Topic } from '../../graphql/schema/core.gen'
|
||||||
import { capitalize } from '../../utils/capitalize'
|
import { capitalize } from '../../utils/capitalize'
|
||||||
import { CardTopic } from '../Feed/CardTopic'
|
import { CardTopic } from '../Feed/CardTopic'
|
||||||
import { CheckButton } from '../_shared/CheckButton'
|
import { CheckButton } from '../_shared/CheckButton'
|
||||||
|
@ -35,14 +35,15 @@ export const TopicCard = (props: TopicProps) => {
|
||||||
const title = createMemo(() =>
|
const title = createMemo(() =>
|
||||||
capitalize(lang() === 'en' ? props.topic.slug.replaceAll('-', ' ') : props.topic.title || ''),
|
capitalize(lang() === 'en' ? props.topic.slug.replaceAll('-', ' ') : props.topic.title || ''),
|
||||||
)
|
)
|
||||||
const { author, requireAuthentication } = useSession()
|
const { session, requireAuthentication } = useSession()
|
||||||
|
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
||||||
const { follow, unfollow, follows } = useFollowing()
|
const { follow, unfollow, follows } = useFollowing()
|
||||||
const [isFollowed, setIsFollowed] = createSignal(false)
|
const [isFollowed, setIsFollowed] = createSignal(false)
|
||||||
createEffect(
|
createEffect(
|
||||||
on([() => follows, () => props.topic], ([flws, tpc]) => {
|
on([() => follows, () => props.topic], ([flws, tpc]) => {
|
||||||
if (flws && tpc) {
|
if (flws && tpc) {
|
||||||
const followed = follows?.topics?.some((topics) => topics.id === props.topic?.id)
|
const followed = follows?.topics?.some((topics) => topics.id === props.topic?.id)
|
||||||
setIsFollowed(followed)
|
setIsFollowed(Boolean(followed))
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
@ -83,13 +84,13 @@ export const TopicCard = (props: TopicProps) => {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.isCardMode}>
|
<Show when={props.isCardMode}>
|
||||||
<CardTopic title={props.topic.title} slug={props.topic.slug} class={styles.cardMode} />
|
<CardTopic title={props.topic?.title || ''} slug={props.topic.slug} class={styles.cardMode} />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.topic.pic}>
|
<Show when={props.topic.pic}>
|
||||||
<div class={styles.topicAvatar}>
|
<div class={styles.topicAvatar}>
|
||||||
<a href={`/topic/${props.topic.slug}`}>
|
<a href={`/topic/${props.topic.slug}`}>
|
||||||
<img src={props.topic.pic} alt={title()} />
|
<img src={props.topic?.pic || ''} alt={title()} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
@ -26,13 +26,13 @@ export const FullTopic = (props: Props) => {
|
||||||
const [followed, setFollowed] = createSignal()
|
const [followed, setFollowed] = createSignal()
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (follows?.topics.length !== 0) {
|
if (follows?.topics?.length !== 0) {
|
||||||
const items = follows.topics || []
|
const items = follows.topics || []
|
||||||
setFollowed(items.some((x: Topic) => x?.slug === props.topic?.slug))
|
setFollowed(items.some((x: Topic) => x?.slug === props.topic?.slug))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleFollowClick = (_ev) => {
|
const handleFollowClick = (_ev?: MouseEvent | undefined) => {
|
||||||
const really = !followed()
|
const really = !followed()
|
||||||
setFollowed(really)
|
setFollowed(really)
|
||||||
requireAuthentication(() => {
|
requireAuthentication(() => {
|
||||||
|
@ -43,14 +43,14 @@ export const FullTopic = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<div class={clsx(styles.topicHeader, 'col-md-16 col-lg-12 offset-md-4 offset-lg-6')}>
|
<div class={clsx(styles.topicHeader, 'col-md-16 col-lg-12 offset-md-4 offset-lg-6')}>
|
||||||
<h1>#{props.topic?.title}</h1>
|
<h1>#{props.topic?.title}</h1>
|
||||||
<p class={styles.topicDescription} innerHTML={props.topic?.body} />
|
<p class={styles.topicDescription} innerHTML={props.topic?.body || ''} />
|
||||||
|
|
||||||
<div class={styles.topicDetails}>
|
<div class={styles.topicDetails}>
|
||||||
<Show when={props.topic?.stat}>
|
<Show when={props.topic?.stat}>
|
||||||
<div class={styles.topicDetailsItem}>
|
<div class={styles.topicDetailsItem}>
|
||||||
<Icon name="feed-all" class={styles.topicDetailsIcon} />
|
<Icon name="feed-all" class={styles.topicDetailsIcon} />
|
||||||
{t('some posts', {
|
{t('some posts', {
|
||||||
count: props.topic?.stat.shouts ?? 0,
|
count: props.topic?.stat?.shouts ?? 0,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
@ -75,7 +75,7 @@ export const FullTopic = (props: Props) => {
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<Show when={props.topic?.pic}>
|
<Show when={props.topic?.pic}>
|
||||||
<img src={props.topic?.pic} alt={props.topic?.title} />
|
<img src={props.topic?.pic || ''} alt={props.topic?.title || ''} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show, createEffect, createSignal, on } from 'solid-js'
|
import { Show, createEffect, createSignal, on } from 'solid-js'
|
||||||
|
|
||||||
|
import { mediaMatches } from '~/utils/media-query'
|
||||||
import { useFollowing } from '../../../context/following'
|
import { useFollowing } from '../../../context/following'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { useMediaQuery } from '../../../context/mediaQuery'
|
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '../../../context/session'
|
||||||
import { FollowingEntity, Topic } from '../../../graphql/schema/core.gen'
|
import { FollowingEntity, Topic } from '../../../graphql/schema/core.gen'
|
||||||
import { capitalize } from '../../../utils/capitalize'
|
import { capitalize } from '../../../utils/capitalize'
|
||||||
|
@ -20,7 +20,6 @@ type Props = {
|
||||||
|
|
||||||
export const TopicBadge = (props: Props) => {
|
export const TopicBadge = (props: Props) => {
|
||||||
const { t, lang } = useLocalize()
|
const { t, lang } = useLocalize()
|
||||||
const { mediaMatches } = useMediaQuery()
|
|
||||||
const [isMobileView, setIsMobileView] = createSignal(false)
|
const [isMobileView, setIsMobileView] = createSignal(false)
|
||||||
const { requireAuthentication } = useSession()
|
const { requireAuthentication } = useSession()
|
||||||
const [isFollowed, setIsFollowed] = createSignal<boolean>()
|
const [isFollowed, setIsFollowed] = createSignal<boolean>()
|
||||||
|
@ -62,8 +61,8 @@ export const TopicBadge = (props: Props) => {
|
||||||
[styles.smallSize]: isMobileView(),
|
[styles.smallSize]: isMobileView(),
|
||||||
})}
|
})}
|
||||||
style={
|
style={
|
||||||
props.topic.pic && {
|
(props.topic?.pic || '') && {
|
||||||
'background-image': `url('${getImageUrl(props.topic.pic, { width: 40, height: 40 })}')`,
|
'background-image': `url('${getImageUrl(props.topic?.pic || '', { width: 40, height: 40 })}')`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -80,15 +79,15 @@ export const TopicBadge = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div innerHTML={props.topic.body} class={clsx('text-truncate', styles.description)} />
|
<div innerHTML={props.topic?.body || ''} class={clsx('text-truncate', styles.description)} />
|
||||||
</Show>
|
</Show>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class={styles.actions}>
|
<div class={styles.actions}>
|
||||||
<FollowingButton
|
<FollowingButton
|
||||||
isFollowed={isFollowed()}
|
isFollowed={Boolean(isFollowed())}
|
||||||
action={handleFollowClick}
|
action={handleFollowClick}
|
||||||
actionMessageType={following()?.slug === props.topic.slug ? following().type : undefined}
|
actionMessageType={following()?.slug === props.topic.slug ? following()?.type : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,26 +1,21 @@
|
||||||
import type { Author } from '../../../graphql/schema/core.gen'
|
import { Meta } from '@solidjs/meta'
|
||||||
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { For, Show, createMemo, createSignal } from 'solid-js'
|
import { For, Show, createMemo, createSignal, onMount } from 'solid-js'
|
||||||
import { Meta } from '../../../context/meta'
|
|
||||||
|
|
||||||
|
import { type SortFunction, useAuthors } from '../../../context/authors'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { useRouter } from '../../../stores/router'
|
import type { Author } from '../../../graphql/schema/core.gen'
|
||||||
import { useAuthorsStore } from '../../../stores/zine/authors'
|
|
||||||
import { getImageUrl } from '../../../utils/getImageUrl'
|
import { getImageUrl } from '../../../utils/getImageUrl'
|
||||||
import { scrollHandler } from '../../../utils/scroll'
|
import { scrollHandler } from '../../../utils/scroll'
|
||||||
import { authorLetterReduce, translateAuthor } from '../../../utils/translate'
|
import { authorLetterReduce, translateAuthor } from '../../../utils/translate'
|
||||||
|
|
||||||
import { AuthorsList } from '../../AuthorsList'
|
import { AuthorsList } from '../../AuthorsList'
|
||||||
import { Loading } from '../../_shared/Loading'
|
import { Loading } from '../../_shared/Loading'
|
||||||
import { SearchField } from '../../_shared/SearchField'
|
import { SearchField } from '../../_shared/SearchField'
|
||||||
|
|
||||||
|
import { useSearchParams } from '@solidjs/router'
|
||||||
|
import { byFirstChar, byStat } from '~/utils/sortby'
|
||||||
import styles from './AllAuthors.module.scss'
|
import styles from './AllAuthors.module.scss'
|
||||||
|
|
||||||
type AllAuthorsPageSearchParams = {
|
|
||||||
by: '' | 'name' | 'shouts' | 'followers'
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
authors: Author[]
|
authors: Author[]
|
||||||
topFollowedAuthors?: Author[]
|
topFollowedAuthors?: Author[]
|
||||||
|
@ -33,17 +28,22 @@ export const AllAuthors = (props: Props) => {
|
||||||
const [searchQuery, setSearchQuery] = createSignal('')
|
const [searchQuery, setSearchQuery] = createSignal('')
|
||||||
const ALPHABET =
|
const ALPHABET =
|
||||||
lang() === 'ru' ? [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ@'] : [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ@']
|
lang() === 'ru' ? [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ@'] : [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ@']
|
||||||
const { searchParams } = useRouter<AllAuthorsPageSearchParams>()
|
const [searchParams] = useSearchParams<{ by?: string }>()
|
||||||
const { sortedAuthors } = useAuthorsStore({
|
const { authorsSorted, addAuthors, setSortBy } = useAuthors()
|
||||||
authors: props.authors,
|
|
||||||
sortBy: searchParams().by || 'name',
|
|
||||||
})
|
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
addAuthors([...props.authors])
|
||||||
|
const sortStat: string = searchParams?.by || 'name'
|
||||||
|
const sortfn = sortStat
|
||||||
|
? (byStat(sortStat) as SortFunction<Author>)
|
||||||
|
: (byFirstChar as SortFunction<Author>)
|
||||||
|
setSortBy(sortfn)
|
||||||
|
})
|
||||||
const filteredAuthors = createMemo(() => {
|
const filteredAuthors = createMemo(() => {
|
||||||
const query = searchQuery().toLowerCase()
|
const query = searchQuery().toLowerCase()
|
||||||
return sortedAuthors().filter((author) => {
|
return authorsSorted().filter((author: Author) => {
|
||||||
// Предполагаем, что у автора есть свойство name
|
// Предполагаем, что у автора есть свойство name
|
||||||
return author.name.toLowerCase().includes(query)
|
return author?.name?.toLowerCase().includes(query)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -57,7 +57,8 @@ export const AllAuthors = (props: Props) => {
|
||||||
const sortedKeys = createMemo<string[]>(() => {
|
const sortedKeys = createMemo<string[]>(() => {
|
||||||
const keys = Object.keys(byLetterFiltered())
|
const keys = Object.keys(byLetterFiltered())
|
||||||
keys.sort()
|
keys.sort()
|
||||||
keys.push(keys.shift())
|
const fk = keys.shift() || ''
|
||||||
|
fk && keys.push(fk)
|
||||||
return keys
|
return keys
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -86,26 +87,26 @@ export const AllAuthors = (props: Props) => {
|
||||||
<ul class={clsx(styles.viewSwitcher, 'view-switcher')}>
|
<ul class={clsx(styles.viewSwitcher, 'view-switcher')}>
|
||||||
<li
|
<li
|
||||||
class={clsx({
|
class={clsx({
|
||||||
['view-switcher__item--selected']: !searchParams().by || searchParams().by === 'shouts',
|
['view-switcher__item--selected']: !searchParams?.by || searchParams?.by === 'shouts',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<a href="/authors?by=shouts">{t('By shouts')}</a>
|
<a href="/authors?by=shouts">{t('By shouts')}</a>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
class={clsx({
|
class={clsx({
|
||||||
['view-switcher__item--selected']: searchParams().by === 'followers',
|
['view-switcher__item--selected']: searchParams?.by === 'followers',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<a href="/authors?by=followers">{t('By popularity')}</a>
|
<a href="/authors?by=followers">{t('By popularity')}</a>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
class={clsx({
|
class={clsx({
|
||||||
['view-switcher__item--selected']: searchParams().by === 'name',
|
['view-switcher__item--selected']: searchParams?.by === 'name',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<a href="/authors?by=name">{t('By name')}</a>
|
<a href="/authors?by=name">{t('By name')}</a>
|
||||||
</li>
|
</li>
|
||||||
<Show when={searchParams().by === 'name'}>
|
<Show when={searchParams?.by === 'name'}>
|
||||||
<li class="view-switcher__search">
|
<li class="view-switcher__search">
|
||||||
<SearchField onChange={(value) => setSearchQuery(value)} />
|
<SearchField onChange={(value) => setSearchQuery(value)} />
|
||||||
</li>
|
</li>
|
||||||
|
@ -114,7 +115,7 @@ export const AllAuthors = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={searchParams().by === 'name'}>
|
<Show when={searchParams?.by === 'name'}>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-20 col-xl-18">
|
<div class="col-lg-20 col-xl-18">
|
||||||
<ul class={clsx('nodash', styles.alphabet)}>
|
<ul class={clsx('nodash', styles.alphabet)}>
|
||||||
|
@ -151,8 +152,8 @@ export const AllAuthors = (props: Props) => {
|
||||||
<div class={clsx(styles.topic, 'topic col-sm-12 col-md-8')}>
|
<div class={clsx(styles.topic, 'topic col-sm-12 col-md-8')}>
|
||||||
<div class="topic-title">
|
<div class="topic-title">
|
||||||
<a href={`/author/${author.slug}`}>{translateAuthor(author, lang())}</a>
|
<a href={`/author/${author.slug}`}>{translateAuthor(author, lang())}</a>
|
||||||
<Show when={author.stat}>
|
<Show when={author.stat?.shouts || 0}>
|
||||||
<span class={styles.articlesCounter}>{author.stat.shouts}</span>
|
<span class={styles.articlesCounter}>{author.stat?.shouts || 0}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -166,11 +167,11 @@ export const AllAuthors = (props: Props) => {
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={searchParams().by !== 'name' && props.isLoaded}>
|
<Show when={searchParams?.by !== 'name' && props.isLoaded}>
|
||||||
<AuthorsList
|
<AuthorsList
|
||||||
allAuthorsLength={sortedAuthors()?.length}
|
allAuthorsLength={authorsSorted()?.length || 0}
|
||||||
searchQuery={searchQuery()}
|
searchQuery={searchQuery()}
|
||||||
query={searchParams().by === 'followers' ? 'followers' : 'shouts'}
|
query={searchParams?.by === 'followers' ? 'followers' : 'shouts'}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,56 +1,41 @@
|
||||||
import type { Topic } from '../../../graphql/schema/core.gen'
|
|
||||||
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { For, Show, createEffect, createMemo, createSignal } from 'solid-js'
|
import { For, Show, createMemo, createSignal } from 'solid-js'
|
||||||
|
|
||||||
|
import { Meta } from '@solidjs/meta'
|
||||||
|
import { useSearchParams } from '@solidjs/router'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { Meta } from '../../../context/meta'
|
import type { Topic } from '../../../graphql/schema/core.gen'
|
||||||
import { useTopics } from '../../../context/topics'
|
|
||||||
import { useRouter } from '../../../stores/router'
|
|
||||||
import { capitalize } from '../../../utils/capitalize'
|
import { capitalize } from '../../../utils/capitalize'
|
||||||
import { dummyFilter } from '../../../utils/dummyFilter'
|
import { dummyFilter } from '../../../utils/dummyFilter'
|
||||||
import { getImageUrl } from '../../../utils/getImageUrl'
|
import { getImageUrl } from '../../../utils/getImageUrl'
|
||||||
import { scrollHandler } from '../../../utils/scroll'
|
import { scrollHandler } from '../../../utils/scroll'
|
||||||
|
import { TopicBadge } from '../../Topic/TopicBadge'
|
||||||
import { Loading } from '../../_shared/Loading'
|
import { Loading } from '../../_shared/Loading'
|
||||||
import { SearchField } from '../../_shared/SearchField'
|
import { SearchField } from '../../_shared/SearchField'
|
||||||
|
|
||||||
import { TopicBadge } from '../../Topic/TopicBadge'
|
|
||||||
import styles from './AllTopics.module.scss'
|
import styles from './AllTopics.module.scss'
|
||||||
|
|
||||||
type AllTopicsPageSearchParams = {
|
|
||||||
by: 'shouts' | 'authors' | 'title' | ''
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
topics: Topic[]
|
topics: Topic[]
|
||||||
isLoaded: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PAGE_SIZE = 20
|
export const TOPICS_PER_PAGE = 20
|
||||||
|
export const ABC = {
|
||||||
|
ru: 'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ#',
|
||||||
|
en: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#',
|
||||||
|
}
|
||||||
|
|
||||||
export const AllTopics = (props: Props) => {
|
export const AllTopics = (props: Props) => {
|
||||||
const { t, lang } = useLocalize()
|
const { t, lang } = useLocalize()
|
||||||
const { searchParams, changeSearchParams } = useRouter<AllTopicsPageSearchParams>()
|
const alphabet = createMemo(() => ABC[lang()])
|
||||||
const [limit, setLimit] = createSignal(PAGE_SIZE)
|
const [searchParams] = useSearchParams<{ by?: string }>()
|
||||||
const ALPHABET =
|
const sortedTopics = createMemo(() => props.topics)
|
||||||
lang() === 'ru' ? [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ#'] : [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ#']
|
|
||||||
const { sortedTopics, setTopicsSort } = useTopics()
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!searchParams().by) {
|
|
||||||
changeSearchParams({
|
|
||||||
by: 'shouts',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
setTopicsSort(searchParams().by || 'shouts')
|
|
||||||
})
|
|
||||||
|
|
||||||
|
// sorted derivative
|
||||||
const byLetter = createMemo<{ [letter: string]: Topic[] }>(() => {
|
const byLetter = createMemo<{ [letter: string]: Topic[] }>(() => {
|
||||||
return sortedTopics().reduce(
|
return sortedTopics().reduce(
|
||||||
(acc, topic) => {
|
(acc, topic) => {
|
||||||
let letter = lang() === 'en' ? topic.slug[0].toUpperCase() : topic.title[0].toUpperCase()
|
let letter = lang() === 'en' ? topic.slug[0].toUpperCase() : (topic?.title?.[0] || '').toUpperCase()
|
||||||
if (/[^ËА-яё]/.test(letter) && lang() === 'ru') letter = '#'
|
if (/[^ËА-яё]/.test(letter) && lang() === 'ru') letter = '#'
|
||||||
if (/[^A-z]/.test(letter) && lang() === 'en') letter = '#'
|
if (/[^A-z]/.test(letter) && lang() === 'en') letter = '#'
|
||||||
if (!acc[letter]) acc[letter] = []
|
if (!acc[letter]) acc[letter] = []
|
||||||
|
@ -61,19 +46,28 @@ export const AllTopics = (props: Props) => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// helper memo
|
||||||
const sortedKeys = createMemo<string[]>(() => {
|
const sortedKeys = createMemo<string[]>(() => {
|
||||||
const keys = Object.keys(byLetter())
|
const keys = Object.keys(byLetter())
|
||||||
|
if (keys) {
|
||||||
keys.sort()
|
keys.sort()
|
||||||
keys.push(keys.shift())
|
const firstKey: string = keys.shift() || ''
|
||||||
|
keys.push(firstKey)
|
||||||
|
}
|
||||||
return keys
|
return keys
|
||||||
})
|
})
|
||||||
|
|
||||||
const showMore = () => setLimit((oldLimit) => oldLimit + PAGE_SIZE)
|
// limit/offset based pagination aka 'show more' logic
|
||||||
|
const [limit, setLimit] = createSignal(TOPICS_PER_PAGE)
|
||||||
|
const showMore = () => setLimit((oldLimit) => oldLimit + TOPICS_PER_PAGE)
|
||||||
|
|
||||||
|
// filter
|
||||||
const [searchQuery, setSearchQuery] = createSignal('')
|
const [searchQuery, setSearchQuery] = createSignal('')
|
||||||
const filteredResults = createMemo(() => {
|
const filteredResults = createMemo(() => {
|
||||||
return dummyFilter(sortedTopics(), searchQuery(), lang())
|
return dummyFilter(sortedTopics(), searchQuery(), lang())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// subcomponent
|
||||||
const AllTopicsHead = () => (
|
const AllTopicsHead = () => (
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-18 col-xl-15">
|
<div class="col-lg-18 col-xl-15">
|
||||||
|
@ -81,16 +75,16 @@ export const AllTopics = (props: Props) => {
|
||||||
<p>{t('Subscribe what you like to tune your personal feed')}</p>
|
<p>{t('Subscribe what you like to tune your personal feed')}</p>
|
||||||
|
|
||||||
<ul class="view-switcher">
|
<ul class="view-switcher">
|
||||||
<li classList={{ 'view-switcher__item--selected': searchParams().by === 'shouts' }}>
|
<li classList={{ 'view-switcher__item--selected': searchParams?.by === 'shouts' }}>
|
||||||
<a href="/topics?by=shouts">{t('By shouts')}</a>
|
<a href="/topics?by=shouts">{t('By shouts')}</a>
|
||||||
</li>
|
</li>
|
||||||
<li classList={{ 'view-switcher__item--selected': searchParams().by === 'authors' }}>
|
<li classList={{ 'view-switcher__item--selected': searchParams?.by === 'authors' }}>
|
||||||
<a href="/topics?by=authors">{t('By authors')}</a>
|
<a href="/topics?by=authors">{t('By authors')}</a>
|
||||||
</li>
|
</li>
|
||||||
<li classList={{ 'view-switcher__item--selected': searchParams().by === 'title' }}>
|
<li classList={{ 'view-switcher__item--selected': searchParams?.by === 'title' }}>
|
||||||
<a href="/topics?by=title">{t('By title')}</a>
|
<a href="/topics?by=title">{t('By title')}</a>
|
||||||
</li>
|
</li>
|
||||||
<Show when={searchParams().by !== 'title'}>
|
<Show when={searchParams?.by !== 'title'}>
|
||||||
<li class="view-switcher__search">
|
<li class="view-switcher__search">
|
||||||
<SearchField onChange={(value) => setSearchQuery(value)} />
|
<SearchField onChange={(value) => setSearchQuery(value)} />
|
||||||
</li>
|
</li>
|
||||||
|
@ -100,6 +94,7 @@ export const AllTopics = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// meta
|
||||||
const ogImage = getImageUrl('production/image/logo_image.png')
|
const ogImage = getImageUrl('production/image/logo_image.png')
|
||||||
const ogTitle = t('Themes and plots')
|
const ogTitle = t('Themes and plots')
|
||||||
const description = t(
|
const description = t(
|
||||||
|
@ -118,16 +113,16 @@ export const AllTopics = (props: Props) => {
|
||||||
<Meta name="twitter:card" content="summary_large_image" />
|
<Meta name="twitter:card" content="summary_large_image" />
|
||||||
<Meta name="twitter:title" content={ogTitle} />
|
<Meta name="twitter:title" content={ogTitle} />
|
||||||
<Meta name="twitter:description" content={description} />
|
<Meta name="twitter:description" content={description} />
|
||||||
<Show when={props.isLoaded} fallback={<Loading />}>
|
<Show when={Boolean(props.topics)} fallback={<Loading />}>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-19 offset-md-5">
|
<div class="col-md-19 offset-md-5">
|
||||||
<AllTopicsHead />
|
<AllTopicsHead />
|
||||||
|
|
||||||
<Show when={filteredResults().length > 0}>
|
<Show when={filteredResults().length > 0}>
|
||||||
<Show when={searchParams().by === 'title'}>
|
<Show when={searchParams?.by === 'title'}>
|
||||||
<div class="col-lg-18 col-xl-15">
|
<div class="col-lg-18 col-xl-15">
|
||||||
<ul class={clsx('nodash', styles.alphabet)}>
|
<ul class={clsx('nodash', styles.alphabet)}>
|
||||||
<For each={ALPHABET}>
|
<For each={Array.from(alphabet())}>
|
||||||
{(letter, index) => (
|
{(letter, index) => (
|
||||||
<li>
|
<li>
|
||||||
<Show when={letter in byLetter()} fallback={letter}>
|
<Show when={letter in byLetter()} fallback={letter}>
|
||||||
|
@ -150,7 +145,7 @@ export const AllTopics = (props: Props) => {
|
||||||
<For each={sortedKeys()}>
|
<For each={sortedKeys()}>
|
||||||
{(letter) => (
|
{(letter) => (
|
||||||
<div class={clsx(styles.group, 'group')}>
|
<div class={clsx(styles.group, 'group')}>
|
||||||
<h2 id={`letter-${ALPHABET.indexOf(letter)}`}>{letter}</h2>
|
<h2 id={`letter-${alphabet().indexOf(letter)}`}>{letter}</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-20">
|
<div class="col-lg-20">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
@ -162,7 +157,7 @@ export const AllTopics = (props: Props) => {
|
||||||
? capitalize(topic.slug.replaceAll('-', ' '))
|
? capitalize(topic.slug.replaceAll('-', ' '))
|
||||||
: topic.title}
|
: topic.title}
|
||||||
</a>
|
</a>
|
||||||
<span class={styles.articlesCounter}>{topic.stat.shouts}</span>
|
<span class={styles.articlesCounter}>{topic.stat?.shouts || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
@ -174,7 +169,7 @@ export const AllTopics = (props: Props) => {
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={searchParams().by && searchParams().by !== 'title'}>
|
<Show when={searchParams?.by && searchParams?.by !== 'title'}>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-18 col-xl-15 py-4">
|
<div class="col-lg-18 col-xl-15 py-4">
|
||||||
<For each={filteredResults().slice(0, limit())}>
|
<For each={filteredResults().slice(0, limit())}>
|
||||||
|
@ -188,7 +183,7 @@ export const AllTopics = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={filteredResults().length > limit() && searchParams().by !== 'title'}>
|
<Show when={filteredResults().length > limit() && searchParams?.by !== 'title'}>
|
||||||
<div class={clsx(styles.loadMoreContainer, 'col-24 col-md-20 col-lg-14 offset-md-2')}>
|
<div class={clsx(styles.loadMoreContainer, 'col-24 col-md-20 col-lg-14 offset-md-2')}>
|
||||||
<button class={clsx('button', styles.loadMoreButton)} onClick={showMore}>
|
<button class={clsx('button', styles.loadMoreButton)} onClick={showMore}>
|
||||||
{t('Load more')}
|
{t('Load more')}
|
||||||
|
|
|
@ -1,16 +1,19 @@
|
||||||
import { getPagePath } from '@nanostores/router'
|
import { Meta, Title } from '@solidjs/meta'
|
||||||
|
import { A, useLocation, useMatch } from '@solidjs/router'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'
|
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'
|
||||||
|
import { useAuthors } from '~/context/authors'
|
||||||
|
import { useGraphQL } from '~/context/graphql'
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
|
import { useFeed } from '../../../context/feed'
|
||||||
import { useFollowing } from '../../../context/following'
|
import { useFollowing } from '../../../context/following'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { Meta, Title } from '../../../context/meta'
|
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '../../../context/session'
|
||||||
import { apiClient } from '../../../graphql/client/core'
|
import loadShoutsQuery from '../../../graphql/query/core/articles-load-by'
|
||||||
|
import getAuthorFollowersQuery from '../../../graphql/query/core/author-followers'
|
||||||
|
import getAuthorFollowsQuery from '../../../graphql/query/core/author-follows'
|
||||||
|
import loadReactionsBy from '../../../graphql/query/core/reactions-load-by'
|
||||||
import type { Author, Reaction, Shout, Topic } from '../../../graphql/schema/core.gen'
|
import type { Author, Reaction, Shout, Topic } from '../../../graphql/schema/core.gen'
|
||||||
import { router, useRouter } from '../../../stores/router'
|
|
||||||
import { MODALS, hideModal } from '../../../stores/ui'
|
|
||||||
import { loadShouts, useArticlesStore } from '../../../stores/zine/articles'
|
|
||||||
import { loadAuthor } from '../../../stores/zine/authors'
|
|
||||||
import { getImageUrl } from '../../../utils/getImageUrl'
|
import { getImageUrl } from '../../../utils/getImageUrl'
|
||||||
import { getDescription } from '../../../utils/meta'
|
import { getDescription } from '../../../utils/meta'
|
||||||
import { restoreScrollPosition, saveScrollPosition } from '../../../utils/scroll'
|
import { restoreScrollPosition, saveScrollPosition } from '../../../utils/scroll'
|
||||||
|
@ -39,117 +42,137 @@ const LOAD_MORE_PAGE_SIZE = 9
|
||||||
export const AuthorView = (props: Props) => {
|
export const AuthorView = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { followers: myFollowers, follows: myFollows } = useFollowing()
|
const { followers: myFollowers, follows: myFollows } = useFollowing()
|
||||||
const { author: me } = useSession()
|
const { session } = useSession()
|
||||||
const { sortedArticles } = useArticlesStore({ shouts: props.shouts })
|
const me = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
||||||
const { page: getPage, searchParams } = useRouter()
|
const [slug, setSlug] = createSignal(props.authorSlug)
|
||||||
|
const { sortedFeed } = useFeed()
|
||||||
|
const { modal, hideModal } = useUI()
|
||||||
|
const loc = useLocation()
|
||||||
|
const matchAuthor = useMatch(() => '/author')
|
||||||
|
const matchComments = useMatch(() => '/author/:authorId/comments')
|
||||||
|
const matchAbout = useMatch(() => '/author/:authorId/about')
|
||||||
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
|
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
|
||||||
const [isBioExpanded, setIsBioExpanded] = createSignal(false)
|
const [isBioExpanded, setIsBioExpanded] = createSignal(false)
|
||||||
const [author, setAuthor] = createSignal<Author>(props.author)
|
const { loadAuthor, authorsEntities } = useAuthors()
|
||||||
const [followers, setFollowers] = createSignal([])
|
const [author, setAuthor] = createSignal<Author>()
|
||||||
const [following, changeFollowing] = createSignal<Array<Author | Topic>>([]) // flat AuthorFollowsResult
|
const [followers, setFollowers] = createSignal<Author[]>([] as Author[])
|
||||||
|
const [following, changeFollowing] = createSignal<Array<Author | Topic>>([] as Array<Author | Topic>) // flat AuthorFollowsResult
|
||||||
const [showExpandBioControl, setShowExpandBioControl] = createSignal(false)
|
const [showExpandBioControl, setShowExpandBioControl] = createSignal(false)
|
||||||
const [commented, setCommented] = createSignal<Reaction[]>()
|
const [commented, setCommented] = createSignal<Reaction[]>()
|
||||||
const modal = MODALS[searchParams().m]
|
const { query } = useGraphQL()
|
||||||
|
|
||||||
// пагинация загрузки ленты постов
|
// пагинация загрузки ленты постов
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
saveScrollPosition()
|
saveScrollPosition()
|
||||||
const { hasMore } = await loadShouts({
|
const resp = await query(loadShoutsQuery, {
|
||||||
filters: { author: props.authorSlug },
|
filters: { author: props.authorSlug },
|
||||||
limit: LOAD_MORE_PAGE_SIZE,
|
limit: LOAD_MORE_PAGE_SIZE,
|
||||||
offset: sortedArticles().length,
|
offset: sortedFeed().length,
|
||||||
})
|
})
|
||||||
|
const hasMore = resp?.data?.load_shouts_by?.hasMore
|
||||||
setIsLoadMoreButtonVisible(hasMore)
|
setIsLoadMoreButtonVisible(hasMore)
|
||||||
restoreScrollPosition()
|
restoreScrollPosition()
|
||||||
}
|
}
|
||||||
|
|
||||||
// загружает профиль и подписки
|
// 1 // проверяет не собственный ли это профиль, иначе - загружает
|
||||||
const [isFetching, setIsFetching] = createSignal(false)
|
const [isFetching, setIsFetching] = createSignal(false)
|
||||||
const fetchData = async (slug) => {
|
|
||||||
setIsFetching(true)
|
|
||||||
const authorResult = await loadAuthor({ slug })
|
|
||||||
setAuthor(authorResult)
|
|
||||||
console.info(`[Author] profile for @${slug} fetched`)
|
|
||||||
|
|
||||||
const followsResult = await apiClient.getAuthorFollows({ slug })
|
|
||||||
const { authors, topics } = followsResult
|
|
||||||
changeFollowing([...(authors || []), ...(topics || [])])
|
|
||||||
console.info(`[Author] follows for @${slug} fetched`)
|
|
||||||
|
|
||||||
const followersResult = await apiClient.getAuthorFollowers({ slug })
|
|
||||||
setFollowers(followersResult || [])
|
|
||||||
console.info(`[Author] followers for @${slug} fetched`)
|
|
||||||
setIsFetching(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// проверяет не собственный ли это профиль, иначе - загружает
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on([() => me(), () => props.authorSlug], ([myProfile, slug]) => {
|
on([() => session()?.user?.app_data?.profile, () => props.authorSlug || ''], async ([me, s]) => {
|
||||||
const my = slug && myProfile?.slug === slug
|
const my = s && me?.slug === s
|
||||||
if (my) {
|
if (my) {
|
||||||
console.debug('[Author] my profile precached')
|
console.debug('[Author] my profile precached')
|
||||||
myProfile && setAuthor(myProfile)
|
if (me) {
|
||||||
setFollowers(myFollowers() || [])
|
setAuthor(me)
|
||||||
changeFollowing([...(myFollows?.authors || []), ...(myFollows?.topics || [])])
|
if (myFollowers()) setFollowers((myFollowers() || []) as Author[])
|
||||||
} else if (slug && !isFetching()) {
|
changeFollowing([...(myFollows?.topics || []), ...(myFollows?.authors || [])])
|
||||||
fetchData(slug)
|
}
|
||||||
|
} else if (s && !isFetching()) {
|
||||||
|
setIsFetching(true)
|
||||||
|
setSlug(s)
|
||||||
|
await loadAuthor(s)
|
||||||
|
setIsFetching(false) // Сброс состояния загрузки после завершения
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
)
|
||||||
|
// 3 // after fetch loading following data
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
[followers, () => authorsEntities()[slug()]],
|
||||||
|
async ([current, found]) => {
|
||||||
|
if (current) return
|
||||||
|
if (!found) return
|
||||||
|
setAuthor(found)
|
||||||
|
console.info(`[Author] profile for @${slug()} fetched`)
|
||||||
|
const followsResp = await query(getAuthorFollowsQuery, { slug: slug() }).toPromise()
|
||||||
|
const follows = followsResp?.data?.get_author_followers || {}
|
||||||
|
changeFollowing([...(follows?.authors || []), ...(follows?.topics || [])])
|
||||||
|
console.info(`[Author] follows for @${slug()} fetched`)
|
||||||
|
const followersResp = await query(getAuthorFollowersQuery, { slug: slug() }).toPromise()
|
||||||
|
setFollowers(followersResp?.data?.get_author_followers || [])
|
||||||
|
console.info(`[Author] followers for @${slug()} fetched`)
|
||||||
|
setIsFetching(false)
|
||||||
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// догружает ленту и комментарии
|
// догружает ленту и комментарии
|
||||||
createEffect(
|
createEffect(
|
||||||
on(author, async (profile) => {
|
on(
|
||||||
|
() => author() as Author,
|
||||||
|
async (profile: Author) => {
|
||||||
if (!commented() && profile) {
|
if (!commented() && profile) {
|
||||||
await loadMore()
|
await loadMore()
|
||||||
|
|
||||||
const ccc = await apiClient.getReactionsBy({
|
const resp = await query(loadReactionsBy, {
|
||||||
by: { comment: true, created_by: profile.id },
|
by: { comment: true, created_by: profile.id },
|
||||||
})
|
}).toPromise()
|
||||||
setCommented(ccc)
|
const ccc = resp?.data?.load_reactions_by
|
||||||
|
if (ccc) setCommented(ccc)
|
||||||
}
|
}
|
||||||
}),
|
},
|
||||||
|
// { defer: true },
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const bioContainerRef: { current: HTMLDivElement } = { current: null }
|
let bioContainerRef: HTMLDivElement
|
||||||
const bioWrapperRef: { current: HTMLDivElement } = { current: null }
|
let bioWrapperRef: HTMLDivElement
|
||||||
const checkBioHeight = () => {
|
const checkBioHeight = () => {
|
||||||
if (bioContainerRef.current) {
|
if (bioContainerRef) {
|
||||||
setShowExpandBioControl(bioContainerRef.current.offsetHeight > bioWrapperRef.current.offsetHeight)
|
setShowExpandBioControl(bioContainerRef.offsetHeight > bioWrapperRef.offsetHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!modal) hideModal()
|
if (!modal()) hideModal()
|
||||||
checkBioHeight()
|
checkBioHeight()
|
||||||
})
|
})
|
||||||
|
|
||||||
const pages = createMemo<Shout[][]>(() =>
|
const pages = createMemo<Shout[][]>(() =>
|
||||||
splitToPages(sortedArticles(), PRERENDERED_ARTICLES_COUNT, LOAD_MORE_PAGE_SIZE),
|
splitToPages(sortedFeed(), PRERENDERED_ARTICLES_COUNT, LOAD_MORE_PAGE_SIZE),
|
||||||
)
|
)
|
||||||
|
|
||||||
const ogImage = createMemo(() =>
|
const ogImage = createMemo(() =>
|
||||||
author()?.pic
|
author()?.pic
|
||||||
? getImageUrl(author()?.pic, { width: 1200 })
|
? getImageUrl(author()?.pic || '', { width: 1200 })
|
||||||
: getImageUrl('production/image/logo_image.png'),
|
: getImageUrl('production/image/logo_image.png'),
|
||||||
)
|
)
|
||||||
const description = createMemo(() => getDescription(author()?.bio))
|
const description = createMemo(() => getDescription(author()?.bio || ''))
|
||||||
const handleDeleteComment = (id: number) => {
|
const handleDeleteComment = (id: number) => {
|
||||||
setCommented((prev) => prev.filter((comment) => comment.id !== id))
|
setCommented((prev) => (prev || []).filter((comment) => comment.id !== id))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={styles.authorPage}>
|
<div class={styles.authorPage}>
|
||||||
<Show when={author()}>
|
<Show when={author()}>
|
||||||
<Title>{author().name}</Title>
|
<Title>{author()?.name}</Title>
|
||||||
<Meta name="descprition" content={description()} />
|
<Meta name="descprition" content={description()} />
|
||||||
<Meta name="og:type" content="profile" />
|
<Meta name="og:type" content="profile" />
|
||||||
<Meta name="og:title" content={author().name} />
|
<Meta name="og:title" content={author()?.name || ''} />
|
||||||
<Meta name="og:image" content={ogImage()} />
|
<Meta name="og:image" content={ogImage()} />
|
||||||
<Meta name="og:description" content={description()} />
|
<Meta name="og:description" content={description()} />
|
||||||
<Meta name="twitter:card" content="summary_large_image" />
|
<Meta name="twitter:card" content="summary_large_image" />
|
||||||
<Meta name="twitter:title" content={author().name} />
|
<Meta name="twitter:title" content={author()?.name || ''} />
|
||||||
<Meta name="twitter:description" content={description()} />
|
<Meta name="twitter:description" content={description()} />
|
||||||
<Meta name="twitter:image" content={ogImage()} />
|
<Meta name="twitter:image" content={ogImage()} />
|
||||||
</Show>
|
</Show>
|
||||||
|
@ -157,44 +180,31 @@ export const AuthorView = (props: Props) => {
|
||||||
<Show when={author()} fallback={<Loading />}>
|
<Show when={author()} fallback={<Loading />}>
|
||||||
<>
|
<>
|
||||||
<div class={styles.authorHeader}>
|
<div class={styles.authorHeader}>
|
||||||
<AuthorCard author={author()} followers={followers() || []} flatFollows={following() || []} />
|
<AuthorCard
|
||||||
|
author={author() as Author}
|
||||||
|
followers={followers() || []}
|
||||||
|
flatFollows={following() || []}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class={clsx(styles.groupControls, 'row')}>
|
<div class={clsx(styles.groupControls, 'row')}>
|
||||||
<div class="col-md-16">
|
<div class="col-md-16">
|
||||||
<ul class="view-switcher">
|
<ul class="view-switcher">
|
||||||
<li classList={{ 'view-switcher__item--selected': getPage().route === 'author' }}>
|
<li classList={{ 'view-switcher__item--selected': !!matchAuthor() }}>
|
||||||
<a
|
<A href={`/author/${props.authorSlug}`}>{t('Publications')}</A>
|
||||||
href={getPagePath(router, 'author', {
|
<Show when={author()?.stat}>
|
||||||
slug: props.authorSlug,
|
<span class="view-switcher__counter">{author()?.stat?.shouts || 0}</span>
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t('Publications')}
|
|
||||||
</a>
|
|
||||||
<Show when={author().stat}>
|
|
||||||
<span class="view-switcher__counter">{author().stat.shouts}</span>
|
|
||||||
</Show>
|
</Show>
|
||||||
</li>
|
</li>
|
||||||
<li classList={{ 'view-switcher__item--selected': getPage().route === 'authorComments' }}>
|
<li classList={{ 'view-switcher__item--selected': !!matchComments() }}>
|
||||||
<a
|
<A href={`/author/${props.authorSlug}/comments`}>{t('Comments')}</A>
|
||||||
href={getPagePath(router, 'authorComments', {
|
<Show when={author()?.stat}>
|
||||||
slug: props.authorSlug,
|
<span class="view-switcher__counter">{author()?.stat?.comments || 0}</span>
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t('Comments')}
|
|
||||||
</a>
|
|
||||||
<Show when={author().stat}>
|
|
||||||
<span class="view-switcher__counter">{author().stat.comments}</span>
|
|
||||||
</Show>
|
</Show>
|
||||||
</li>
|
</li>
|
||||||
<li classList={{ 'view-switcher__item--selected': getPage().route === 'authorAbout' }}>
|
<li classList={{ 'view-switcher__item--selected': !!matchAbout() }}>
|
||||||
<a
|
<A onClick={() => checkBioHeight()} href={`/author/${props.authorSlug}`}>
|
||||||
onClick={() => checkBioHeight()}
|
|
||||||
href={getPagePath(router, 'authorAbout', {
|
|
||||||
slug: props.authorSlug,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t('About')}
|
{t('About')}
|
||||||
</a>
|
</A>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -202,7 +212,7 @@ export const AuthorView = (props: Props) => {
|
||||||
<Show when={author()?.stat?.rating || author()?.stat?.rating === 0}>
|
<Show when={author()?.stat?.rating || author()?.stat?.rating === 0}>
|
||||||
<div class={styles.ratingContainer}>
|
<div class={styles.ratingContainer}>
|
||||||
{t('All posts rating')}
|
{t('All posts rating')}
|
||||||
<AuthorShoutsRating author={author()} class={styles.ratingControl} />
|
<AuthorShoutsRating author={author() as Author} class={styles.ratingControl} />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
@ -212,16 +222,16 @@ export const AuthorView = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={getPage().route === 'authorAbout'}>
|
<Match when={matchAbout()}>
|
||||||
<div class="wide-container">
|
<div class="wide-container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-20 col-lg-18">
|
<div class="col-md-20 col-lg-18">
|
||||||
<div
|
<div
|
||||||
ref={(el) => (bioWrapperRef.current = el)}
|
ref={(el) => (bioWrapperRef = el)}
|
||||||
class={styles.longBio}
|
class={styles.longBio}
|
||||||
classList={{ [styles.longBioExpanded]: isBioExpanded() }}
|
classList={{ [styles.longBioExpanded]: isBioExpanded() }}
|
||||||
>
|
>
|
||||||
<div ref={(el) => (bioContainerRef.current = el)} innerHTML={author()?.about || ''} />
|
<div ref={(el) => (bioContainerRef = el)} innerHTML={author()?.about || ''} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={showExpandBioControl()}>
|
<Show when={showExpandBioControl()}>
|
||||||
|
@ -236,10 +246,10 @@ export const AuthorView = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={getPage().route === 'authorComments'}>
|
<Match when={matchComments()}>
|
||||||
<Show when={me()?.slug === props.authorSlug && !me().stat?.comments}>
|
<Show when={me()?.slug === props.authorSlug && !me().stat?.comments}>
|
||||||
<div class="wide-container">
|
<div class="wide-container">
|
||||||
<Placeholder type={getPage().route} mode="profile" />
|
<Placeholder type={loc?.pathname} mode="profile" />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
@ -262,25 +272,25 @@ export const AuthorView = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={getPage().route === 'author'}>
|
<Match when={matchAuthor()}>
|
||||||
<Show when={me()?.slug === props.authorSlug && !me().stat?.shouts}>
|
<Show when={me()?.slug === props.authorSlug && !me().stat?.shouts}>
|
||||||
<div class="wide-container">
|
<div class="wide-container">
|
||||||
<Placeholder type={getPage().route} mode="profile" />
|
<Placeholder type={loc?.pathname} mode="profile" />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={sortedArticles().length > 0}>
|
<Show when={sortedFeed().length > 0}>
|
||||||
<Row1 article={sortedArticles()[0]} noauthor={true} nodate={true} />
|
<Row1 article={sortedFeed()[0]} noauthor={true} nodate={true} />
|
||||||
|
|
||||||
<Show when={sortedArticles().length > 1}>
|
<Show when={sortedFeed().length > 1}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={sortedArticles().length === 2}>
|
<Match when={sortedFeed().length === 2}>
|
||||||
<Row2 articles={sortedArticles()} isEqual={true} noauthor={true} nodate={true} />
|
<Row2 articles={sortedFeed()} isEqual={true} noauthor={true} nodate={true} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={sortedArticles().length === 3}>
|
<Match when={sortedFeed().length === 3}>
|
||||||
<Row3 articles={sortedArticles()} noauthor={true} nodate={true} />
|
<Row3 articles={sortedFeed()} noauthor={true} nodate={true} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={sortedArticles().length > 3}>
|
<Match when={sortedFeed().length > 3}>
|
||||||
<For each={pages()}>
|
<For each={pages()}>
|
||||||
{(page) => (
|
{(page) => (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -1,34 +1,37 @@
|
||||||
import { openPage } from '@nanostores/router'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { For, Show, createEffect, createSignal, on } from 'solid-js'
|
import { For, Show, createEffect, createMemo, createSignal, on } from 'solid-js'
|
||||||
|
|
||||||
|
import { useNavigate } from '@solidjs/router'
|
||||||
|
import { useGraphQL } from '~/context/graphql'
|
||||||
|
import getDraftsQuery from '~/graphql/query/core/articles-load-drafts'
|
||||||
import { useEditorContext } from '../../../context/editor'
|
import { useEditorContext } from '../../../context/editor'
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '../../../context/session'
|
||||||
import { apiClient } from '../../../graphql/client/core'
|
|
||||||
import { Shout } from '../../../graphql/schema/core.gen'
|
import { Shout } from '../../../graphql/schema/core.gen'
|
||||||
import { router } from '../../../stores/router'
|
|
||||||
import { Draft } from '../../Draft'
|
import { Draft } from '../../Draft'
|
||||||
|
|
||||||
import { Loading } from '../../_shared/Loading'
|
import { Loading } from '../../_shared/Loading'
|
||||||
import styles from './DraftsView.module.scss'
|
import styles from './DraftsView.module.scss'
|
||||||
|
|
||||||
export const DraftsView = () => {
|
export const DraftsView = () => {
|
||||||
const { author, loadSession } = useSession()
|
const { session } = useSession()
|
||||||
|
const authorized = createMemo<boolean>(() => Boolean(session()?.access_token))
|
||||||
|
const navigate = useNavigate()
|
||||||
const [drafts, setDrafts] = createSignal<Shout[]>([])
|
const [drafts, setDrafts] = createSignal<Shout[]>([])
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
const { query } = useGraphQL()
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => author(),
|
() => Boolean(session()?.access_token),
|
||||||
async (a) => {
|
async (s) => {
|
||||||
if (a) {
|
if (s) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const { shouts: loadedDrafts, error } = await apiClient.getDrafts()
|
const resp = await query(getDraftsQuery, {}).toPromise()
|
||||||
if (error) {
|
const result = resp?.data?.get_shouts_drafts
|
||||||
console.warn(error)
|
if (result) {
|
||||||
await loadSession()
|
const { error, drafts: loadedDrafts } = result
|
||||||
|
if (error) console.warn(error)
|
||||||
|
if (loadedDrafts) setDrafts(loadedDrafts)
|
||||||
}
|
}
|
||||||
setDrafts(loadedDrafts || [])
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -39,22 +42,20 @@ export const DraftsView = () => {
|
||||||
const { publishShoutById, deleteShout } = useEditorContext()
|
const { publishShoutById, deleteShout } = useEditorContext()
|
||||||
|
|
||||||
const handleDraftDelete = async (shout: Shout) => {
|
const handleDraftDelete = async (shout: Shout) => {
|
||||||
const result = deleteShout(shout.id)
|
const success = await deleteShout(shout.id)
|
||||||
if (result) {
|
if (success) {
|
||||||
setDrafts((ddd) => ddd.filter((d) => d.id !== shout.id))
|
setDrafts((ddd) => ddd.filter((d) => d.id !== shout.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDraftPublish = (shout: Shout) => {
|
const handleDraftPublish = (shout: Shout) => {
|
||||||
const result = publishShoutById(shout.id)
|
publishShoutById(shout.id)
|
||||||
if (result) {
|
setTimeout(() => navigate('/feed'), 2000)
|
||||||
openPage(router, 'feed')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={clsx(styles.DraftsView)}>
|
<div class={clsx(styles.DraftsView)}>
|
||||||
<Show when={!loading() && author()?.id} fallback={<Loading />}>
|
<Show when={!loading() && authorized()} fallback={<Loading />}>
|
||||||
<div class="wide-container">
|
<div class="wide-container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-19 col-lg-18 col-xl-16 offset-md-5">
|
<div class="col-md-19 col-lg-18 col-xl-16 offset-md-5">
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user