gigantic-wip

This commit is contained in:
Untone 2024-06-24 20:50:27 +03:00
parent 3e214d0352
commit 8d39c74242
214 changed files with 5600 additions and 16770 deletions

4
.gitignore vendored
View File

@ -22,4 +22,6 @@ bun.lockb
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/plawright-report/ /plawright-report/
target
.output
.vinxi

View File

@ -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
``` ```

View File

@ -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
View 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)

View File

@ -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,
@ -74,4 +98,4 @@
} }
} }
} }
} }

12285
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

Before

Width:  |  Height:  |  Size: 245 B

After

Width:  |  Height:  |  Size: 245 B

45
src/app.tsx Normal file
View 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

View File

@ -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>

View File

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

View File

@ -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"

View File

@ -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>

View File

@ -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({
kind: ReactionKind.Comment, reaction: {
reply_to: props.comment.id, kind: ReactionKind.Comment,
body: value, reply_to: props.comment.id,
shout: props.comment.shout.id, body: value,
}) 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: {
kind: ReactionKind.Comment, id: props.comment.id || 0,
body: value, kind: ReactionKind.Comment,
shout: props.comment.shout.id, body: value,
}) 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}

View File

@ -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({
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike, reaction: {
shout: props.comment.shout.id, kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
reply_to: props.comment.id, shout: props.comment.shout.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(),

View File

@ -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({
kind: ReactionKind.Comment, reaction: {
body: value, kind: ReactionKind.Comment,
shout: props.shoutId, body: value,
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()}

View File

@ -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,49 +57,52 @@ export type ArticlePageSearchParams = {
const scrollTo = (el: HTMLElement) => { const scrollTo = (el: HTMLElement) => {
const { top } = el.getBoundingClientRect() const { top } = el.getBoundingClientRect()
window.scrollTo({ if (window)
top: top + window.scrollY - DEFAULT_HEADER_OFFSET, window.scrollTo({
left: 0, top: top + window.scrollY - DEFAULT_HEADER_OFFSET,
behavior: 'smooth', left: 0,
}) behavior: 'smooth',
})
} }
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}
/> />
</> </>

View File

@ -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({
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike, reaction: {
shout: props.shout.id, kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
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')}

View File

@ -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,30 +9,32 @@ 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) {
hideModal() console.debug('[AuthGuard] profile is loaded')
} else { hideModal()
changeSearchParams( } else {
{ changeSearchParams(
source: 'authguard', {
m: 'auth', source: 'authguard',
}, m: 'auth',
true, },
) { replace: true },
} )
} else { }
// await loadSession() },
console.warn('session is not loaded') { defer: true },
} ),
}) )
return <Show when={(isSessionLoaded() && author()?.id) || props.disabled}>{props.children}</Show> return <Show when={author() || props.disabled}>{props.children}</Show>
} }

View File

@ -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>

View File

@ -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 {

View File

@ -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}`,
})} })}

View File

@ -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>

View File

@ -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

View File

@ -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, {

View File

@ -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>}>

View File

@ -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,26 +21,32 @@ 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 (queryType === 'shouts') { if ((result?.length || 0) > 0) {
setAuthorsByShouts((prev) => [...prev, ...result]) addAuthors([...result])
} else { if (queryType === 'shouts') {
setAuthorsByFollowers((prev) => [...prev, ...result]) setAuthorsByShouts((prev) => [...(prev || []), ...result])
} else if (queryType === 'followers') {
setAuthorsByFollowers((prev) => [...(prev || []), ...result])
}
setLoading(false)
} }
setLoading(false)
} }
const loadMoreAuthors = () => { const loadMoreAuthors = () => {
@ -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()}>

View File

@ -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">

View File

@ -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)

View File

@ -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

View File

@ -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
<a href={social.href}> return (
<Icon name={`${social.name}-white`} class={styles.icon} /> <div class={clsx(styles.socialItem, styles[styleKey])}>
</a> <a href={social.href}>
</div> <Icon name={`${social.name}-white`} class={styles.icon} />
)} </a>
</div>
)
}}
</For> </For>
</div> </div>
</div> </div>

View File

@ -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">

View File

@ -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>

View File

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

View File

@ -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"

View File

@ -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>

View File

@ -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>

View File

@ -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,177 +150,175 @@ export const Editor = (props: Props) => {
} }
const { initialContent } = props const { initialContent } = props
const { editor, setEditor, countWords } = useEditorContext()
createEffect(
on(editorElRef, (ee: HTMLElement | undefined) => {
if (ee) {
const freshEditor = createTiptapEditor<HTMLElement>(() => ({
element: ee,
editorProps: {
attributes: {
class: 'articleEditor',
},
transformPastedHTML(html) {
return html.replaceAll(/<img.*?>/g, '')
},
handlePaste: () => {
handleClipboardPaste()
return false
},
},
extensions: [
Document,
Text,
Paragraph,
Dropcursor,
CustomBlockquote,
Bold,
Italic,
Span,
ToggleTextWrap,
Strike,
HorizontalRule.configure({
HTMLAttributes: {
class: 'horizontalRule',
},
}),
Underline,
Link.extend({
inclusive: false,
}).configure({
autolink: true,
openOnClick: false,
}),
Heading.configure({
levels: [2, 3, 4],
}),
BulletList,
OrderedList,
ListItem,
Collaboration.configure({
document: yDocs[docName],
}),
CollaborationCursor.configure({
provider: providers[docName],
user: {
name: author().name,
color: uniqolor(author().slug).color,
},
}),
Placeholder.configure({
placeholder: t('Add a link or click plus to embed media'),
}),
Focus,
Gapcursor,
HardBreak,
Highlight.configure({
multicolor: true,
HTMLAttributes: {
class: 'highlight',
},
}),
Image,
Iframe,
Figure,
Figcaption,
Footnote,
ToggleTextWrap,
CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef,
shouldShow: ({ editor: e, view, state, from, to }) => {
const { doc, selection } = state
const { empty } = selection
const isEmptyTextBlock =
doc.textBetween(from, to).length === 0 && isTextSelection(selection)
if (isEmptyTextBlock) {
e.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
}
setIsCommonMarkup(e.isActive('figcaption'))
const result =
(view.hasFocus() &&
!empty &&
!isEmptyTextBlock &&
!e.isActive('image') &&
!e.isActive('figure')) ||
e.isActive('footnote') ||
(e.isActive('figcaption') && !empty)
setShouldShowTextBubbleMenu(result)
return result
},
tippyOptions: {
onHide: () => {
const fe = freshEditor() as Editor
fe?.commands.focus()
},
},
}),
BubbleMenu.configure({
pluginKey: 'blockquoteBubbleMenu',
element: blockquoteBubbleMenuRef,
shouldShow: ({ editor: e, view, state }) => {
const { empty } = state.selection
return view.hasFocus() && !empty && e.isActive('blockquote')
},
}),
BubbleMenu.configure({
pluginKey: 'figureBubbleMenu',
element: figureBubbleMenuRef,
shouldShow: ({ editor: e, view, state }) => {
const { empty } = state.selection
return view.hasFocus() && !empty && e.isActive('figure')
},
}),
BubbleMenu.configure({
pluginKey: 'incutBubbleMenu',
element: incutBubbleMenuRef,
shouldShow: ({ editor: e, view, state }) => {
const { empty } = state.selection
return view.hasFocus() && !empty && e.isActive('figcaption')
},
}),
FloatingMenu.configure({
element: floatingMenuRef,
pluginKey: 'floatingMenu',
shouldShow: ({ editor: e, state }) => {
const { $anchor, empty } = state.selection
const isRootDepth = $anchor.depth === 1
const editor = createTiptapEditor(() => ({ if (!(isRootDepth && empty)) return false
element: editorElRef.current,
editorProps: { return !(e.isActive('codeBlock') || e.isActive('heading'))
attributes: { },
class: 'articleEditor', }),
}, TrailingNode,
transformPastedHTML(html) { Article,
return html.replaceAll(/<img.*?>/g, '') ],
}, onTransaction: ({ transaction }) => {
handlePaste: () => { if (transaction.docChanged) {
handleClipboardPaste() const fe = freshEditor()
return false if (fe) {
}, const changeHandle = useEditorHTML(() => fe as Editor | undefined)
}, props.onChange(changeHandle() || '')
extensions: [ countWords(fe?.storage.characterCount.words())
Document, }
Text,
Paragraph,
Dropcursor,
CustomBlockquote,
Bold,
Italic,
Span,
ToggleTextWrap,
Strike,
HorizontalRule.configure({
HTMLAttributes: {
class: 'horizontalRule',
},
}),
Underline,
Link.extend({
inclusive: false,
}).configure({
autolink: true,
openOnClick: false,
}),
Heading.configure({
levels: [2, 3, 4],
}),
BulletList,
OrderedList,
ListItem,
Collaboration.configure({
document: yDocs[docName],
}),
CollaborationCursor.configure({
provider: providers[docName],
user: {
name: author().name,
color: uniqolor(author().slug).color,
},
}),
Placeholder.configure({
placeholder: t('Add a link or click plus to embed media'),
}),
Focus,
Gapcursor,
HardBreak,
Highlight.configure({
multicolor: true,
HTMLAttributes: {
class: 'highlight',
},
}),
Image,
Iframe,
Figure,
Figcaption,
Footnote,
ToggleTextWrap,
CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef.current,
shouldShow: ({ editor: e, view, state, from, to }) => {
const { doc, selection } = state
const { empty } = selection
const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection)
if (isEmptyTextBlock) {
e.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
}
setIsCommonMarkup(e.isActive('figcaption'))
const result =
(view.hasFocus() &&
!empty &&
!isEmptyTextBlock &&
!e.isActive('image') &&
!e.isActive('figure')) ||
e.isActive('footnote') ||
(e.isActive('figcaption') && !empty)
setShouldShowTextBubbleMenu(result)
return result
},
tippyOptions: {
sticky: true,
},
}),
BubbleMenu.configure({
pluginKey: 'blockquoteBubbleMenu',
element: blockquoteBubbleMenuRef.current,
shouldShow: ({ editor: e, state }) => {
const { selection } = state
const { empty } = selection
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()
} }
}, },
}, content: initialContent,
}), }))
BubbleMenu.configure({
pluginKey: 'incutBubbleMenu', if (freshEditor) {
element: incutBubbleMenuRef.current, editorElRef()?.addEventListener('focus', (_event) => {
shouldShow: ({ editor: e, state }) => { if (freshEditor()?.isActive('figcaption')) {
const { selection } = state freshEditor()?.commands.focus()
const { empty } = selection
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()
} }
}, })
}, setEditor(freshEditor() as Editor)
}), }
BubbleMenu.configure({ }
pluginKey: 'imageBubbleMenu', }),
element: figureBubbleMenuRef.current, )
shouldShow: ({ editor: e, view }) => {
return view.hasFocus() && e.isActive('image')
},
}),
FloatingMenu.configure({
tippyOptions: {
placement: 'left',
},
element: floatingMenuRef.current,
}),
TrailingNode,
Article,
],
enablePasteRules: [Link],
content: initialContent ?? null,
}))
const { countWords, setEditor } = useEditorContext()
setEditor(editor)
const html = useEditorHTML(() => editor())
createEffect(() => {
props.onChange(html())
if (html()) {
countWords({
characters: editor().storage.characterCount.characters(),
words: editor().storage.characterCount.words(),
})
}
})
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>
</> </>
) )
} }

View File

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

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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,106 +68,97 @@ 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(),
editorProps: { (ee: HTMLDivElement | undefined) => {
attributes: { if (ee && textBubbleMenuRef && linkBubbleMenuRef) {
class: styles.simplifiedEditorField, const freshEditor = createTiptapEditor<HTMLElement>(() => ({
element: ee,
editorProps: {
attributes: {
class: styles.simplifiedEditorField,
},
},
extensions: [
Document,
Text,
Paragraph,
Bold,
Italic,
Link.extend({
inclusive: false,
}).configure({
autolink: true,
openOnClick: false,
}),
CharacterCount.configure({
limit: props.noLimits ? null : maxLength,
}),
Blockquote.configure({
HTMLAttributes: {
class: styles.blockQuote,
},
}),
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef,
shouldShow: ({ view, state }) => {
if (!props.onlyBubbleControls) return false
const { selection } = state
const { empty } = selection
return view.hasFocus() && !empty
},
}),
BubbleMenu.configure({
pluginKey: 'linkBubbleMenu',
element: linkBubbleMenuRef,
shouldShow: ({ state }) => {
const { selection } = state
const { empty } = selection
return !empty && shouldShowLinkBubbleMenu()
},
tippyOptions: {
placement: 'bottom',
},
}),
ImageFigure,
Image,
Figcaption,
Placeholder.configure({
emptyNodeClass: styles.emptyNode,
placeholder: props.placeholder,
}),
],
autofocus: props.autoFocus,
content: props.initialContent || null,
}))
const editorInstance = freshEditor()
if (!editorInstance) return
setEditor(editorInstance)
}
}, },
}, { defer: true },
extensions: [ ),
Document, )
Text,
Paragraph,
Bold,
Italic,
Link.extend({
inclusive: false,
}).configure({
autolink: true,
openOnClick: false,
}),
CharacterCount.configure({
limit: props.noLimits ? null : maxLength,
}),
Blockquote.configure({
HTMLAttributes: {
class: styles.blockQuote,
},
}),
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef.current,
shouldShow: ({ view, state }) => {
if (!props.onlyBubbleControls) return
const { selection } = state
const { empty } = selection
return view.hasFocus() && !empty
},
}),
BubbleMenu.configure({
pluginKey: 'linkBubbleMenu',
element: linkBubbleMenuRef.current,
shouldShow: ({ state }) => {
const { selection } = state
const { empty } = selection
return !empty && shouldShowLinkBubbleMenu()
},
tippyOptions: {
placement: 'bottom',
},
}),
ImageFigure,
Image,
Figcaption,
Placeholder.configure({
emptyNodeClass: styles.emptyNode,
placeholder: props.placeholder,
}),
],
autofocus: props.autoFocus,
content: content ?? null,
}))
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>

View File

@ -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"

View File

@ -21,3 +21,13 @@
} }
} }
} }
.TopicSelect .solid-select-list {
background: #fff;
position: relative;
z-index: 13;
}
.TopicSelect .solid-select-option[data-disabled='true'] {
display: none;
}

View File

@ -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, {
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) => {
setIsDisabled(true)
props.onMainTopicChange(topic)
setIsDisabled(false)
}
const format = (item, type) => {
if (type === 'option') {
// eslint-disable-next-line solid/components-return-once
return item.label
} }
const isMainTopic = item.id === props.mainTopic?.id props.onChange(newSelectedTopics)
}
return ( const handleMainTopicChange = (topic: Topic) => {
<div props.onMainTopicChange(topic)
class={clsx(styles.selectedItem, { setIsOpen(false)
[styles.mainTopic]: isMainTopic, }
})}
onClick={() => handleSelectedItemClick(item)} const handleSearch = (event: InputEvent) => {
> setSearchTerm((event.currentTarget as HTMLInputElement).value)
{item.title} }
</div>
const filteredTopics = () => {
return props.topics.filter((topic: Topic) =>
topic?.title?.toLowerCase().includes(searchTerm().toLowerCase()),
) )
} }
const initialValue = clone(props.selectedTopics)
return ( return (
<Select <div class="TopicSelect">
multiple={true} <div class={styles.selectedTopics}>
disabled={isDisabled()} <For each={props.selectedTopics}>
initialValue={initialValue} {(topic) => (
{...selectProps} <div
format={format} class={clsx(styles.selectedTopic, {
placeholder={t('Topics')} [styles.mainTopic]: props.mainTopic?.slug === topic.slug,
class="TopicSelect" })}
onChange={handleChange} onClick={() => handleMainTopicChange(topic)}
/> >
{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>
) )
} }

View File

@ -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,

View File

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

View File

@ -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 [
{ {

View File

@ -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

View File

@ -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
} }

View File

@ -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'

View File

@ -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>

View File

@ -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
], ],
)} )}
> >

View File

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

View File

@ -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'
} }
const data: PlaceholderData = {
feedMy: {
image: 'placeholder-feed.webp',
header: 'Feed settings',
text: 'Placeholder feed',
buttonLabelAuthor: 'Popular authors',
buttonLabelFeed: 'Create own feed',
href: '/authors?by=followers',
},
feedCollaborations: {
image: 'placeholder-experts.webp',
header: 'Find collaborators',
text: 'Placeholder feedCollaborations',
buttonLabel: 'Find co-authors',
href: '/authors?by=name',
},
feedDiscussions: {
image: 'placeholder-discussions.webp',
header: 'Participate in discussions',
text: 'Placeholder feedDiscussions',
buttonLabelAuthor: 'Current discussions',
buttonLabelFeed: 'Enter',
href: '/feed?by=last_comment',
},
author: {
image: 'placeholder-join.webp',
header: 'Join our team of authors',
text: 'Join our team of authors text',
buttonLabel: 'Create post',
href: '/create',
profileLinks: [
{
href: '/how-to-write-a-good-article',
label: 'How to write a good article',
},
],
},
authorComments: {
image: 'placeholder-discussions.webp',
header: 'Join discussions',
text: 'Placeholder feedDiscussions',
buttonLabel: 'Go to discussions',
href: '/feed?by=last_comment',
profileLinks: [
{
href: '/about/discussion-rules',
label: 'Discussion rules',
},
{
href: '/about/discussion-rules#ban',
label: 'Block rules',
},
],
},
}
export const Placeholder = (props: PlaceholderProps) => { export const Placeholder = (props: PlaceholderProps) => {
const { t } = useLocalize() const { t } = useLocalize()
const { author } = useSession() const { session } = useSession()
const data = { const placeholderData = createMemo(() => data[props.type])
feedMy: {
image: 'placeholder-feed.webp',
header: t('Feed settings'),
text: t('Placeholder feed'),
buttonLabel: author() ? t('Popular authors') : t('Create own feed'),
href: '/authors?by=followers',
},
feedCollaborations: {
image: 'placeholder-experts.webp',
header: t('Find collaborators'),
text: t('Placeholder feedCollaborations'),
buttonLabel: t('Find co-authors'),
href: '/authors?by=name',
},
feedDiscussions: {
image: 'placeholder-discussions.webp',
header: t('Participate in discussions'),
text: t('Placeholder feedDiscussions'),
buttonLabel: author() ? t('Current discussions') : t('Enter'),
href: '/feed?by=last_comment',
},
author: {
image: 'placeholder-join.webp',
header: t('Join our team of authors'),
text: t('Join our team of authors text'),
buttonLabel: t('Create post'),
href: '/create',
profileLinks: [
{
href: '/how-to-write-a-good-article',
label: t('How to write a good article'),
},
],
},
authorComments: {
image: 'placeholder-discussions.webp',
header: t('Join discussions'),
text: t('Placeholder feedDiscussions'),
buttonLabel: t('Go to discussions'),
href: '/feed?by=last_comment',
profileLinks: [
{
href: '/about/discussion-rules',
label: t('Discussion rules'),
},
{
href: '/about/discussion-rules#ban',
label: t('Block rules'),
},
],
},
}
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>

View File

@ -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>

View File

@ -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}

View File

@ -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
} }
)`, )`,

View File

@ -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>

View File

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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -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}>

View File

@ -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`),

View File

@ -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,26 +16,26 @@ 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) => {
@ -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>

View File

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

View File

@ -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,53 +92,49 @@ 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) => { case 'user has not signed up email & password':
switch (error.message) { case 'bad user credentials': {
case 'user has not signed up email & password': setValidationErrors((prev) => ({
case 'bad user credentials': { ...prev,
setValidationErrors((prev) => ({ password: t('Something went wrong, check email and password'),
...prev, }))
password: t('Something went wrong, check email and password'), break
}))
break
}
case 'user not found': {
setValidationErrors((prev) => ({ ...prev, email: t('User was not found') }))
break
}
case 'email not verified': {
setValidationErrors((prev) => ({ ...prev, email: t('This email is not verified') }))
break
}
default:
setSubmitError(
<div class={styles.info}>
{t('Error', errors[0].message)}
{'. '}
<span class={'link'} onClick={handleSendLinkAgainClick}>
{t('Send link again')}
</span>
</div>,
)
} }
}) case 'user not found': {
return setValidationErrors((prev) => ({ ...prev, email: t('User was not found') }))
break
}
case 'email not verified': {
setValidationErrors((prev) => ({ ...prev, email: t('This email is not verified') }))
break
}
default:
setSubmitError(
<div class={styles.info}>
{t('Error', authError())}
{'. '}
<span class={'link'} onClick={handleSendLinkAgainClick}>
{t('Send link again')}
</span>
</div>,
)
}
} }
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',
}) })
} }

View File

@ -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) {

View File

@ -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

View File

@ -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

View File

@ -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>

View File

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

View File

@ -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>
) )

View File

@ -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 }

View File

@ -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}>

View File

@ -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
mainContent.style.marginTop = `-${windowScrollTop}px` if (mainContent) {
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())) {
mainContent.style.marginTop = '' if (mainContent) {
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>

View File

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

View File

@ -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>

View File

@ -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}

View File

@ -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}

View File

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

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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}>

View File

@ -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={

View File

@ -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)

View File

@ -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,14 +19,15 @@ 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) => {
window.scrollTo({ if (window)
behavior: 'smooth', window.scrollTo({
top: behavior: 'smooth',
element.getBoundingClientRect().top - top:
document.body.getBoundingClientRect().top - element.getBoundingClientRect().top -
DEFAULT_HEADER_OFFSET, document.body.getBoundingClientRect().top -
}) DEFAULT_HEADER_OFFSET,
})
} }
export const TableOfContents = (props: Props) => { export const TableOfContents = (props: Props) => {
@ -43,12 +44,15 @@ export const TableOfContents = (props: Props) => {
setIsVisible(isDesktop()) setIsVisible(isDesktop())
const updateHeadings = () => { const updateHeadings = () => {
setHeadings( if (document) {
// eslint-disable-next-line unicorn/prefer-spread const parent = document.querySelector(props.parentSelector)
Array.from( if (parent) {
document.querySelector(props.parentSelector).querySelectorAll<HTMLElement>('h1, h2, h3, h4'), setHeadings(
), // eslint-disable-next-line unicorn/prefer-spread
) Array.from(parent.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)

View File

@ -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>

View File

@ -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>
) )

View File

@ -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>

View File

@ -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>

View File

@ -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())
keys.sort() if (keys) {
keys.push(keys.shift()) keys.sort()
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')}

View File

@ -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) // Сброс состояния загрузки после завершения
} }
}), }),
{ defer: true }, )
// 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 },
),
) )
// догружает ленту и комментарии // догружает ленту и комментарии
createEffect( createEffect(
on(author, async (profile) => { on(
if (!commented() && profile) { () => author() as Author,
await loadMore() async (profile: Author) => {
if (!commented() && profile) {
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) => (
<> <>

View File

@ -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