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/
/playwright/.cache/
/plawright-report/
target
.output
.vinxi

View File

@ -1,20 +1,17 @@
## How to start
Use Bun to manage packages.
```
npm install
npm start
bun i
```
## Useful commands
run checks
```
npm run check
```
type checking with watch
```
npm run typecheck:watch
bun run typecheck
```
fix styles, imports, formatting and autofixable linting errors:
```
npm run fix
npm run format
bun run fix
```

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": {
"include": ["*.tsx", "*.ts", "*.js", "*.json"],
"ignore": ["./dist", "./node_modules", ".husky", "docs", "gen", "*.gen.ts", "*.d.ts"]
"include": [
"*.tsx",
"*.ts",
"*.js",
"*.json"
],
"ignore": [
"./dist",
"./node_modules",
".husky",
"docs",
"gen",
"*.gen.ts",
"*.d.ts"
]
},
"vcs": {
"defaultBranch": "dev",
@ -10,26 +23,37 @@
},
"organizeImports": {
"enabled": true,
"ignore": ["./api", "./gen"]
"ignore": [
"./api",
"./gen"
]
},
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 108,
"ignore": ["./src/graphql/schema", "./gen"]
"ignore": [
"./src/graphql/schema",
"./gen"
]
},
"javascript": {
"formatter": {
"semicolons": "asNeeded",
"quoteStyle": "single",
"trailingComma": "all",
"enabled": true,
"jsxQuoteStyle": "double",
"arrowParentheses": "always"
}
},
"linter": {
"ignore": ["*.scss", "*.md", ".DS_Store", "*.svg", "*.d.ts"],
"ignore": [
"*.scss",
"*.md",
".DS_Store",
"*.svg",
"*.d.ts"
],
"enabled": true,
"rules": {
"all": true,

12285
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,140 +1,123 @@
{
"name": "discoursio-webapp",
"version": "0.9.2",
"private": true,
"license": "MIT",
"version": "0.9.5",
"contributors": [],
"type": "module",
"scripts": {
"build": "vite build",
"check": "npm run lint && npm run typecheck",
"dev": "vinxi dev",
"build": "vinxi build",
"start": "vinxi start",
"codegen": "graphql-codegen",
"deploy": "graphql-codegen && npm run typecheck && vite build && vercel",
"dev": "vite",
"e2e": "npx playwright test --project=webkit",
"fix": "npm run check:code:fix && stylelint **/*.{scss,css} --fix",
"format": "npx @biomejs/biome format src/. --write",
"postinstall": "npm run codegen && npx patch-package",
"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"
"deploy": "graphql-codegen && bun run typecheck && bun run build && vercel",
"e2e": "bunx playwright test --project=webkit",
"fix": "bunx @biomejs/biome check src/. --write && stylelint **/*.{scss,css} --fix",
"format": "bunx @biomejs/biome format src/. --write",
"postinstall": "bun run codegen && bunx patch-package",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@authorizerdev/authorizer-js": "^2.0.0",
"@babel/core": "^7.24.5",
"@biomejs/biome": "^1.7.2",
"@graphql-codegen/cli": "^5.0.0",
"@graphql-codegen/typescript": "^4.0.1",
"@graphql-codegen/typescript-operations": "^4.0.1",
"@authorizerdev/authorizer-js": "^2.0.3",
"@biomejs/biome": "^1.8.2",
"@graphql-codegen/cli": "^5.0.2",
"@graphql-codegen/typescript": "^4.0.7",
"@graphql-codegen/typescript-operations": "^4.2.1",
"@graphql-codegen/typescript-urql": "^4.0.0",
"@graphql-tools/url-loader": "8.0.1",
"@hocuspocus/provider": "2.11.0",
"@microsoft/fetch-event-source": "^2.0.1",
"@nanostores/router": "0.13.0",
"@nanostores/solid": "0.4.2",
"@playwright/test": "^1.44.0",
"@popperjs/core": "2.11.8",
"@sentry/browser": "^7.113.0",
"@solid-primitives/media": "2.2.3",
"@solid-primitives/memo": "1.2.4",
"@solid-primitives/pagination": "0.2.10",
"@solid-primitives/share": "2.0.4",
"@solid-primitives/storage": "^3.5.0",
"@solid-primitives/upload": "0.0.115",
"@thisbeyond/solid-select": "0.14.0",
"@tiptap/core": "2.4.0",
"@tiptap/extension-blockquote": "2.4.0",
"@tiptap/extension-bold": "2.4.0",
"@tiptap/extension-bubble-menu": "2.4.0",
"@tiptap/extension-bullet-list": "2.4.0",
"@tiptap/extension-character-count": "2.4.0",
"@tiptap/extension-collaboration": "2.4.0",
"@tiptap/extension-collaboration-cursor": "2.4.0",
"@tiptap/extension-document": "2.4.0",
"@tiptap/extension-dropcursor": "2.4.0",
"@tiptap/extension-floating-menu": "2.4.0",
"@tiptap/extension-focus": "2.4.0",
"@tiptap/extension-gapcursor": "2.4.0",
"@tiptap/extension-hard-break": "2.4.0",
"@tiptap/extension-heading": "2.4.0",
"@tiptap/extension-highlight": "2.4.0",
"@tiptap/extension-history": "2.4.0",
"@tiptap/extension-horizontal-rule": "2.4.0",
"@tiptap/extension-image": "2.4.0",
"@tiptap/extension-italic": "2.4.0",
"@tiptap/extension-link": "2.4.0",
"@tiptap/extension-list-item": "2.4.0",
"@tiptap/extension-ordered-list": "2.4.0",
"@tiptap/extension-paragraph": "2.4.0",
"@tiptap/extension-placeholder": "2.4.0",
"@tiptap/extension-strike": "2.4.0",
"@tiptap/extension-text": "2.4.0",
"@tiptap/extension-underline": "2.4.0",
"@tiptap/extension-youtube": "2.4.0",
"@types/js-cookie": "^3.0.6",
"@types/node": "^20.11.0",
"@urql/core": "4.2.3",
"@urql/devtools": "^2.0.3",
"babel-preset-solid": "1.8.17",
"bootstrap": "5.3.2",
"clsx": "2.0.0",
"cropperjs": "1.6.1",
"fast-deep-equal": "3.1.3",
"ga-gtag": "1.2.0",
"graphql": "16.8.1",
"graphql-tag": "^2.12.6",
"i18next": "22.4.15",
"i18next-http-backend": "2.2.0",
"i18next-icu": "2.3.0",
"@hocuspocus/provider": "^2.13.2",
"@playwright/test": "^1.44.1",
"@popperjs/core": "^2.11.8",
"@solid-devtools/transform": "^0.10.4",
"@solid-primitives/media": "^2.2.9",
"@solid-primitives/memo": "^1.3.8",
"@solid-primitives/pagination": "^0.3.0",
"@solid-primitives/share": "^2.0.6",
"@solid-primitives/storage": "^3.7.1",
"@solid-primitives/upload": "^0.0.117",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.13.6",
"@solidjs/start": "^1.0.2",
"@tiptap/core": "^2.4.0",
"@tiptap/extension-blockquote": "^2.4.0",
"@tiptap/extension-bold": "^2.4.0",
"@tiptap/extension-bubble-menu": "^2.4.0",
"@tiptap/extension-bullet-list": "^2.4.0",
"@tiptap/extension-character-count": "^2.4.0",
"@tiptap/extension-collaboration": "^2.4.0",
"@tiptap/extension-collaboration-cursor": "^2.4.0",
"@tiptap/extension-document": "^2.4.0",
"@tiptap/extension-dropcursor": "^2.4.0",
"@tiptap/extension-floating-menu": "^2.4.0",
"@tiptap/extension-focus": "^2.4.0",
"@tiptap/extension-gapcursor": "^2.4.0",
"@tiptap/extension-hard-break": "^2.4.0",
"@tiptap/extension-heading": "^2.4.0",
"@tiptap/extension-highlight": "^2.4.0",
"@tiptap/extension-history": "^2.4.0",
"@tiptap/extension-horizontal-rule": "^2.4.0",
"@tiptap/extension-image": "^2.4.0",
"@tiptap/extension-italic": "^2.4.0",
"@tiptap/extension-link": "^2.4.0",
"@tiptap/extension-list-item": "^2.4.0",
"@tiptap/extension-ordered-list": "^2.4.0",
"@tiptap/extension-paragraph": "^2.4.0",
"@tiptap/extension-placeholder": "^2.4.0",
"@tiptap/extension-strike": "^2.4.0",
"@tiptap/extension-text": "^2.4.0",
"@tiptap/extension-underline": "^2.4.0",
"@tiptap/extension-youtube": "^2.4.0",
"@types/cookie": "^0.6.0",
"@types/cookie-signature": "^1.1.2",
"@types/node": "^20.14.8",
"@types/throttle-debounce": "^5.0.2",
"@urql/core": "^5.0.4",
"bootstrap": "^5.3.3",
"clsx": "^2.1.1",
"cookie": "^0.6.0",
"cookie-signature": "^1.2.1",
"cropperjs": "^1.6.2",
"extended-eventsource": "^1.4.9",
"fast-deep-equal": "^3.1.3",
"graphql": "^16.9.0",
"i18next": "^23.11.5",
"i18next-http-backend": "^2.5.2",
"i18next-icu": "^2.3.0",
"intl-messageformat": "^10.5.14",
"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",
"prosemirror-history": "1.3.2",
"prosemirror-trailing-node": "2.0.7",
"prosemirror-view": "1.32.7",
"rollup": "4.17.2",
"sass": "1.77.2",
"prosemirror-history": "^1.4.0",
"prosemirror-trailing-node": "^2.0.8",
"prosemirror-view": "^1.33.8",
"sass": "^1.77.6",
"solid-js": "1.8.17",
"solid-popper": "0.3.0",
"solid-popper": "^0.3.0",
"solid-tiptap": "0.7.0",
"solid-transition-group": "0.2.3",
"stylelint": "^16.5.0",
"solid-transition-group": "^0.2.3",
"stylelint": "^16.6.1",
"stylelint-config-standard-scss": "^13.1.0",
"stylelint-order": "^6.0.3",
"stylelint-scss": "^6.1.0",
"swiper": "11.0.5",
"throttle-debounce": "5.0.0",
"typescript": "5.4.5",
"typograf": "7.3.0",
"uniqolor": "1.1.0",
"vike": "0.4.148",
"vite": "5.2.11",
"vite-plugin-mkcert": "^1.17.5",
"stylelint-order": "^6.0.4",
"stylelint-scss": "^6.3.2",
"swiper": "^11.1.4",
"throttle-debounce": "^5.0.2",
"tslib": "^2.6.3",
"typescript": "^5.5.2",
"typograf": "^7.4.1",
"uniqolor": "^1.1.1",
"vinxi": "^0.3.12",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-sass-dts": "^1.3.22",
"vite-plugin-solid": "^2.10.2",
"y-prosemirror": "1.2.5",
"yjs": "13.6.15"
"y-prosemirror": "1.2.9",
"yjs": "13.6.18"
},
"overrides": {
"y-prosemirror": "1.2.5",
"yjs": "13.6.15"
"yjs": "13.6.18",
"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 { Show, createSignal } from 'solid-js'
import { MediaItem } from '~/types/mediaitem'
import { Topic } from '../../../graphql/schema/core.gen'
import { MediaItem } from '../../../pages/types'
import { CardTopic } from '../../Feed/CardTopic'
import { Icon } from '../../_shared/Icon'
import { Image } from '../../_shared/Image'
@ -30,19 +30,19 @@ export const AudioHeader = (props: Props) => {
</div>
<div class={styles.albumInfo}>
<Show when={props.topic}>
<CardTopic title={props.topic.title} slug={props.topic.slug} />
<CardTopic title={props.topic.title || ''} slug={props.topic.slug} />
</Show>
<h1>{props.title}</h1>
<Show when={props.artistData}>
<div class={styles.artistData}>
<Show when={props.artistData?.artist}>
<div class={styles.item}>{props.artistData.artist}</div>
<div class={styles.item}>{props.artistData?.artist || ''}</div>
</Show>
<Show when={props.artistData?.date}>
<div class={styles.item}>{props.artistData.date}</div>
<div class={styles.item}>{props.artistData?.date || ''}</div>
</Show>
<Show when={props.artistData?.genre}>
<div class={styles.item}>{props.artistData.genre}</div>
<div class={styles.item}>{props.artistData?.genre || ''}</div>
</Show>
</div>
</Show>

View File

@ -1,6 +1,6 @@
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 { PlayerPlaylist } from './PlayerPlaylist'
@ -12,18 +12,22 @@ type Props = {
articleSlug?: string
body?: string
editorMode?: boolean
onMediaItemFieldChange?: (index: number, field: keyof MediaItem, value: string) => void
onChangeMediaIndex?: (direction: 'up' | 'down', index) => void
onMediaItemFieldChange?: (
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)
export const AudioPlayer = (props: Props) => {
const audioRef: { current: HTMLAudioElement } = { current: null }
const gainNodeRef: { current: GainNode } = { current: null }
const progressRef: { current: HTMLDivElement } = { current: null }
const audioContextRef: { current: AudioContext } = { current: null }
const mouseDownRef: { current: boolean } = { current: false }
let audioRef: HTMLAudioElement | undefined
let gainNodeRef: GainNode | undefined
let progressRef: HTMLDivElement | undefined
let audioContextRef: AudioContext | undefined
let mouseDownRef: boolean | undefined
const [currentTrackDuration, setCurrentTrackDuration] = createSignal(0)
const [currentTime, setCurrentTime] = createSignal(0)
@ -37,19 +41,19 @@ export const AudioPlayer = (props: Props) => {
setIsPlaying(!isPlaying() || trackIndex !== currentTrackIndex())
setCurrentTrackIndex(trackIndex)
if (audioContextRef.current.state === 'suspended') {
await audioContextRef.current.resume()
if (audioContextRef?.state === 'suspended') {
await audioContextRef?.resume()
}
if (isPlaying()) {
await audioRef.current.play()
await audioRef?.play()
} else {
audioRef.current.pause()
audioRef?.pause()
}
}
const handleVolumeChange = (volume: number) => {
gainNodeRef.current.gain.value = volume
if (gainNodeRef) gainNodeRef.gain.value = volume
}
const handleAudioEnd = () => {
@ -58,21 +62,22 @@ export const AudioPlayer = (props: Props) => {
return
}
audioRef.current.currentTime = 0
if (audioRef) audioRef.currentTime = 0
setIsPlaying(false)
setCurrentTrackIndex(0)
}
const handleAudioTimeUpdate = () => {
setCurrentTime(audioRef.current.currentTime)
setCurrentTime(audioRef?.currentTime || 0)
}
onMount(() => {
audioContextRef.current = new AudioContext()
gainNodeRef.current = audioContextRef.current.createGain()
const track = audioContextRef.current.createMediaElementSource(audioRef.current)
track.connect(gainNodeRef.current).connect(audioContextRef.current.destination)
audioContextRef = new AudioContext()
gainNodeRef = audioContextRef.createGain()
if (audioRef) {
const track = audioContextRef?.createMediaElementSource(audioRef)
track.connect(gainNodeRef).connect(audioContextRef?.destination)
}
})
const playPrevTrack = () => {
@ -93,13 +98,18 @@ export const AudioPlayer = (props: Props) => {
setCurrentTrackIndex(newCurrentTrackIndex)
}
const handleMediaItemFieldChange = (index: number, field: keyof MediaItem, value) => {
props.onMediaItemFieldChange(index, field, value)
const handleMediaItemFieldChange = (
index: number,
field: keyof MediaItem | string | number | symbol,
value: string,
) => {
props.onMediaItemFieldChange?.(index, field, value)
}
const scrub = (event) => {
audioRef.current.currentTime =
(event.offsetX / progressRef.current.offsetWidth) * currentTrackDuration()
const scrub = (event: MouseEvent | undefined) => {
if (progressRef && audioRef) {
audioRef.currentTime = (event?.offsetX || 0 / progressRef.offsetWidth) * currentTrackDuration()
}
}
return (
@ -116,11 +126,11 @@ export const AudioPlayer = (props: Props) => {
<div class={styles.timeline}>
<div
class={styles.progress}
ref={(el) => (progressRef.current = el)}
onClick={(e) => scrub(e)}
onMouseMove={(e) => mouseDownRef.current && scrub(e)}
onMouseDown={() => (mouseDownRef.current = true)}
onMouseUp={() => (mouseDownRef.current = false)}
ref={(el) => (progressRef = el)}
onClick={scrub}
onMouseMove={(e) => mouseDownRef && scrub(e)}
onMouseDown={() => (mouseDownRef = true)}
onMouseUp={() => (mouseDownRef = false)}
>
<div
class={styles.progressFilled}
@ -136,13 +146,13 @@ export const AudioPlayer = (props: Props) => {
</Show>
</div>
<audio
ref={(el) => (audioRef.current = el)}
ref={(el) => (audioRef = el)}
onTimeUpdate={handleAudioTimeUpdate}
src={currentTack().url.replace('images.discours.io', 'cdn.discours.io')}
onCanPlay={() => {
// start to play the next track on src change
if (isPlaying()) {
audioRef.current.play()
if (isPlaying() && audioRef) {
audioRef.play()
}
}}
onLoadedMetadata={({ currentTarget }) => setCurrentTrackDuration(currentTarget.duration)}
@ -153,7 +163,7 @@ export const AudioPlayer = (props: Props) => {
<PlayerPlaylist
editorMode={props.editorMode}
onPlayMedia={handlePlayMedia}
onChangeMediaIndex={(direction, index) => props.onChangeMediaIndex(direction, index)}
onChangeMediaIndex={(direction, index) => props.onChangeMediaIndex?.(direction, index)}
isPlaying={isPlaying()}
media={props.media}
currentTrackIndex={currentTrackIndex()}

View File

@ -1,10 +1,9 @@
import { clsx } from 'clsx'
import { Show, createSignal } from 'solid-js'
import { MediaItem } from '../../../pages/types'
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
import { Icon } from '../../_shared/Icon'
import { MediaItem } from '~/types/mediaitem'
import styles from './AudioPlayer.module.scss'
type Props = {
@ -17,10 +16,7 @@ type Props = {
}
export const PlayerHeader = (props: Props) => {
const volumeContainerRef: { current: HTMLDivElement } = {
current: null,
}
let volumeContainerRef: HTMLDivElement | undefined
const [isVolumeBarOpened, setIsVolumeBarOpened] = createSignal(false)
const toggleVolumeBar = () => {
@ -65,7 +61,7 @@ export const PlayerHeader = (props: Props) => {
>
<Icon name="player-arrow" />
</button>
<div ref={(el) => (volumeContainerRef.current = el)} class={styles.volumeContainer}>
<div ref={(el) => (volumeContainerRef = el)} class={styles.volumeContainer}>
<Show when={isVolumeBarOpened()}>
<input
type="range"

View File

@ -1,8 +1,7 @@
import { gtag } from 'ga-gtag'
import { For, Show, createSignal, lazy } from 'solid-js'
import { MediaItem } from '~/types/mediaitem'
import { useLocalize } from '../../../context/localize'
import { MediaItem } from '../../../pages/types'
import { getDescription } from '../../../utils/meta'
import { Icon } from '../../_shared/Icon'
import { Popover } from '../../_shared/Popover'
@ -22,30 +21,30 @@ type Props = {
body?: string
editorMode?: boolean
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) => {
const { t } = useLocalize()
const [activeEditIndex, setActiveEditIndex] = createSignal(-1)
const toggleDropDown = (index) => {
const toggleDropDown = (index: number) => {
setActiveEditIndex(activeEditIndex() === index ? -1 : index)
}
const handleMediaItemFieldChange = (field: keyof MediaItem, value: string) => {
props.onMediaItemFieldChange(activeEditIndex(), field, value)
props.onMediaItemFieldChange?.(activeEditIndex(), field, value)
}
const play = (index: number) => {
props.onPlayMedia(index)
const mi = props.media[index]
gtag('event', 'select_item', {
item_list_id: props.articleSlug,
item_list_name: getMediaTitle(mi, index),
items: props.media.map((it, ix) => getMediaTitle(it, ix)),
})
//const mi = props.media[index]
//gtag('event', 'select_item', {
//item_list_id: props.articleSlug,
//item_list_name: getMediaTitle(mi, index),
//items: props.media.map((it, ix) => getMediaTitle(it, ix)),
//})
}
return (
<ul class={styles.playlist}>
@ -90,26 +89,26 @@ export const PlayerPlaylist = (props: Props) => {
<div class={styles.actions}>
<Show when={props.editorMode}>
<Popover content={t('Move up')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
type="button"
ref={triggerRef}
class={styles.action}
disabled={index() === 0}
onClick={() => props.onChangeMediaIndex('up', index())}
onClick={() => props.onChangeMediaIndex?.('up', index())}
>
<Icon name="up-button" />
</button>
)}
</Popover>
<Popover content={t('Move down')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
type="button"
ref={triggerRef}
class={styles.action}
disabled={index() === props.media.length - 1}
onClick={() => props.onChangeMediaIndex('down', index())}
onClick={() => props.onChangeMediaIndex?.('down', index())}
>
<Icon name="up-button" class={styles.moveIconDown} />
</button>
@ -118,7 +117,7 @@ export const PlayerPlaylist = (props: Props) => {
</Show>
<Show when={(mi.lyrics || mi.body) && !props.editorMode}>
<Popover content={t('Show lyrics')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button ref={triggerRef} type="button" onClick={() => toggleDropDown(index())}>
<Icon name="list" />
</button>
@ -126,7 +125,7 @@ export const PlayerPlaylist = (props: Props) => {
</Popover>
</Show>
<Popover content={props.editorMode ? t('Edit') : t('Share')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<div ref={triggerRef}>
<Show
when={!props.editorMode}
@ -138,8 +137,8 @@ export const PlayerPlaylist = (props: Props) => {
>
<SharePopup
title={mi.title}
description={getDescription(props.body)}
imageUrl={mi.pic}
description={getDescription(props.body || '')}
imageUrl={mi.pic || ''}
shareUrl={getShareUrl({ pathname: `/${props.articleSlug}` })}
trigger={
<div>

View File

@ -1,21 +1,25 @@
import { getPagePath } from '@nanostores/router'
import { A } from '@solidjs/router'
import { clsx } from 'clsx'
import { For, Show, Suspense, createMemo, createSignal, lazy } from 'solid-js'
import { useConfirm } from '../../../context/confirm'
import { useGraphQL } from '~/context/graphql'
import { useSnackbar, useUI } from '~/context/ui'
import deleteReactionMutation from '~/graphql/mutation/core/reaction-destroy'
import { useLocalize } from '../../../context/localize'
import { useReactions } from '../../../context/reactions'
import { useSession } from '../../../context/session'
import { useSnackbar } from '../../../context/snackbar'
import { Author, Reaction, ReactionKind } from '../../../graphql/schema/core.gen'
import { router } from '../../../stores/router'
import {
Author,
MutationCreate_ReactionArgs,
MutationUpdate_ReactionArgs,
Reaction,
ReactionKind,
} from '../../../graphql/schema/core.gen'
import { AuthorLink } from '../../Author/AuthorLink'
import { Userpic } from '../../Author/Userpic'
import { Icon } from '../../_shared/Icon'
import { ShowIfAuthenticated } from '../../_shared/ShowIfAuthenticated'
import { CommentDate } from '../CommentDate'
import { CommentRatingControl } from '../CommentRatingControl'
import styles from './Comment.module.scss'
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))
@ -40,18 +44,20 @@ export const Comment = (props: Props) => {
const [editMode, setEditMode] = createSignal(false)
const [clearEditor, setClearEditor] = createSignal(false)
const [editedBody, setEditedBody] = createSignal<string>()
const { author, session } = useSession()
const { createReaction, deleteReaction, updateReaction } = useReactions()
const { showConfirm } = useConfirm()
const { session } = useSession()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
const { createReaction, updateReaction } = useReactions()
const { showConfirm } = useUI()
const { showSnackbar } = useSnackbar()
const { mutation } = useGraphQL()
const canEdit = createMemo(
() =>
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 () => {
if (props.comment?.id) {
@ -64,12 +70,18 @@ export const Comment = (props: Props) => {
})
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 notificationMessage = error
? t('Failed to delete comment')
: t('Comment successfully deleted')
await showSnackbar({ type: notificationType, body: notificationMessage })
await showSnackbar({
type: notificationType,
body: notificationMessage,
duration: 3,
})
if (!error && props.onDelete) {
props.onDelete(props.comment.id)
@ -82,15 +94,17 @@ export const Comment = (props: Props) => {
}
}
const handleCreate = async (value) => {
const handleCreate = async (value: string) => {
try {
setLoading(true)
await createReaction({
reaction: {
kind: ReactionKind.Comment,
reply_to: props.comment.id,
body: value,
shout: props.comment.shout.id,
})
},
} as MutationCreate_ReactionArgs)
setClearEditor(true)
setIsReplyVisible(false)
setLoading(false)
@ -104,15 +118,17 @@ export const Comment = (props: Props) => {
setEditMode((oldEditMode) => !oldEditMode)
}
const handleUpdate = async (value) => {
const handleUpdate = async (value: string) => {
setLoading(true)
try {
const reaction = await updateReaction({
id: props.comment.id,
reaction: {
id: props.comment.id || 0,
kind: ReactionKind.Comment,
body: value,
shout: props.comment.shout.id,
})
},
} as MutationUpdate_ReactionArgs)
if (reaction) {
setEditedBody(value)
}
@ -127,7 +143,8 @@ export const Comment = (props: Props) => {
<li
id={`comment_${props.comment.id}`}
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()}>
@ -137,8 +154,8 @@ export const Comment = (props: Props) => {
fallback={
<div>
<Userpic
name={props.comment.created_by.name}
userpic={props.comment.created_by.pic}
name={props.comment.created_by.name || ''}
userpic={props.comment.created_by.pic || ''}
class={clsx({
[styles.compactUserpic]: props.compact,
})}
@ -161,13 +178,9 @@ export const Comment = (props: Props) => {
<Show when={props.showArticleLink}>
<div class={styles.articleLink}>
<Icon name="arrow-right" class={styles.articleLinkIcon} />
<a
href={`${getPagePath(router, 'article', {
slug: props.comment.shout.slug,
})}?commentId=${props.comment.id}`}
>
<A href={`${props.comment.shout.slug}?commentId=${props.comment.id}`}>
{props.comment.shout.title}
</a>
</A>
</div>
</Show>
<CommentDate showOnHover={true} comment={props.comment} isShort={true} />
@ -178,7 +191,7 @@ export const Comment = (props: Props) => {
<Show when={editMode()} fallback={<div innerHTML={body()} />}>
<Suspense fallback={<p>{t('Loading')}</p>}>
<SimplifiedEditor
initialContent={editedBody() || props.comment.body}
initialContent={editedBody() || props.comment.body || ''}
submitButtonText={t('Save')}
quoteEnabled={true}
imageEnabled={true}
@ -199,7 +212,7 @@ export const Comment = (props: Props) => {
disabled={loading()}
onClick={() => {
setIsReplyVisible(!isReplyVisible())
props.clickedReply(props.comment.id)
props.clickedReply?.(props.comment.id)
}}
class={clsx(styles.commentControl, styles.commentControlReply)}
>
@ -260,7 +273,7 @@ export const Comment = (props: Props) => {
</Show>
<Show when={props.sortedComments}>
<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) => (
<Comment
sortedComments={props.sortedComments}

View File

@ -1,12 +1,12 @@
import { clsx } from 'clsx'
import { createMemo } from 'solid-js'
import { useFeed } from '~/context/feed'
import { useSnackbar } from '~/context/ui'
import { useLocalize } from '../../context/localize'
import { useReactions } from '../../context/reactions'
import { useSession } from '../../context/session'
import { useSnackbar } from '../../context/snackbar'
import { Reaction, ReactionKind } from '../../graphql/schema/core.gen'
import { loadShout } from '../../stores/zine/articles'
import { Popup } from '../_shared/Popup'
import { VotersList } from '../_shared/VotersList'
@ -18,7 +18,9 @@ type Props = {
export const CommentRatingControl = (props: Props) => {
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 { reactionEntities, createReaction, deleteReaction, loadReactionsBy } = useReactions()
@ -26,13 +28,13 @@ export const CommentRatingControl = (props: Props) => {
Object.values(reactionEntities).some(
(r) =>
r.kind === reactionKind &&
r.created_by.slug === author()?.slug &&
r.created_by.id === uid() &&
r.shout.id === props.comment.shout.id &&
r.reply_to === props.comment.id,
)
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
const canVote = createMemo(() => author()?.slug !== props.comment.created_by.slug)
const canVote = createMemo(() => uid() !== props.comment.created_by.id)
const commentRatingReactions = createMemo(() =>
Object.values(reactionEntities).filter(
@ -47,11 +49,11 @@ export const CommentRatingControl = (props: Props) => {
const reactionToDelete = Object.values(reactionEntities).find(
(r) =>
r.kind === reactionKind &&
r.created_by.slug === author()?.slug &&
r.created_by.id === uid() &&
r.shout.id === props.comment.shout.id &&
r.reply_to === props.comment.id,
)
return deleteReaction(reactionToDelete.id)
if (reactionToDelete) return deleteReaction(reactionToDelete.id)
}
const handleRatingChange = async (isUpvote: boolean) => {
@ -62,9 +64,11 @@ export const CommentRatingControl = (props: Props) => {
await deleteCommentReaction(ReactionKind.Dislike)
} else {
await createReaction({
reaction: {
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
shout: props.comment.shout.id,
reply_to: props.comment.id,
},
})
}
} catch {
@ -81,7 +85,7 @@ export const CommentRatingControl = (props: Props) => {
<div class={styles.commentRating}>
<button
role="button"
disabled={!(canVote() && author())}
disabled={!(canVote() && uid())}
onClick={() => handleRatingChange(true)}
class={clsx(styles.commentRatingControl, styles.commentRatingControlUp, {
[styles.voted]: isUpvoted(),
@ -91,11 +95,11 @@ export const CommentRatingControl = (props: Props) => {
trigger={
<div
class={clsx(styles.commentRatingValue, {
[styles.commentRatingPositive]: props.comment.stat.rating > 0,
[styles.commentRatingNegative]: props.comment.stat.rating < 0,
[styles.commentRatingPositive]: (props.comment?.stat?.rating || 0) > 0,
[styles.commentRatingNegative]: (props.comment?.stat?.rating || 0) < 0,
})}
>
{props.comment.stat.rating || 0}
{props.comment?.stat?.rating || 0}
</div>
}
variant="tiny"
@ -107,7 +111,7 @@ export const CommentRatingControl = (props: Props) => {
</Popup>
<button
role="button"
disabled={!(canVote() && author())}
disabled={!(canVote() && uid())}
onClick={() => handleRatingChange(false)}
class={clsx(styles.commentRatingControl, styles.commentRatingControlDown, {
[styles.voted]: isDownvoted(),

View File

@ -11,7 +11,8 @@ import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated'
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'
const SimplifiedEditor = lazy(() => import('../Editor/SimplifiedEditor'))
@ -23,7 +24,7 @@ type Props = {
}
export const CommentsTree = (props: Props) => {
const { author } = useSession()
const { session } = useSession()
const { t } = useLocalize()
const [commentsOrder, setCommentsOrder] = createSignal<ReactionSort>(ReactionSort.Newest)
const [onlyNew, setOnlyNew] = createSignal(false)
@ -45,11 +46,11 @@ export const CommentsTree = (props: Props) => {
}
if (commentsOrder() === ReactionSort.Like) {
newSortedComments = newSortedComments.sort(byStat('rating'))
newSortedComments = newSortedComments.sort(byStat('rating') as SortFunction<Reaction>)
}
return newSortedComments
})
const { seen } = useSeen()
const { seen } = useFeed()
const shoutLastSeen = createMemo(() => seen()[props.shoutSlug] ?? 0)
const currentDate = new Date()
const setCookie = () => localStorage.setItem(`${props.shoutSlug}`, `${currentDate}`)
@ -59,7 +60,10 @@ export const CommentsTree = (props: Props) => {
setCookie()
} else if (currentDate.getTime() > shoutLastSeen()) {
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 (c.updated_at || c.created_at) > shoutLastSeen()
@ -73,9 +77,11 @@ export const CommentsTree = (props: Props) => {
setPosting(true)
try {
await createReaction({
reaction: {
kind: ReactionKind.Comment,
body: value,
shout: props.shoutId,
},
})
setClearEditor(true)
await loadReactionsBy({ by: { shout: props.shoutSlug } })
@ -128,9 +134,7 @@ export const CommentsTree = (props: Props) => {
{(reaction) => (
<Comment
sortedComments={sortedComments()}
isArticleAuthor={Boolean(
props.articleAuthors.some((a) => a?.slug === reaction.created_by.slug),
)}
isArticleAuthor={Boolean(props.articleAuthors.some((a) => a?.id === reaction.created_by.id))}
comment={reaction}
clickedReply={(id) => setClickedReplyId(id)}
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 { 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 { 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 { useReactions } from '../../context/reactions'
import { useSession } from '../../context/session'
import { MediaItem } from '../../pages/types'
import { DEFAULT_HEADER_OFFSET, router, useRouter } from '../../stores/router'
import { showModal } from '../../stores/ui'
import type { Author, Maybe, Shout, Topic } from '../../graphql/schema/core.gen'
import { capitalize } from '../../utils/capitalize'
import { getImageUrl, getOpenGraphImageUrl } from '../../utils/getImageUrl'
import { getDescription, getKeywords } from '../../utils/meta'
@ -31,14 +28,14 @@ import { Popover } from '../_shared/Popover'
import { ShareModal } from '../_shared/ShareModal'
import { ImageSwiper } from '../_shared/SolidSwiper'
import { VideoPlayer } from '../_shared/VideoPlayer'
import { AudioHeader } from './AudioHeader'
import { AudioPlayer } from './AudioPlayer'
import { CommentsTree } from './CommentsTree'
import { SharePopup, getShareUrl } from './SharePopup'
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 styles from './Article.module.scss'
@ -60,6 +57,7 @@ export type ArticlePageSearchParams = {
const scrollTo = (el: HTMLElement) => {
const { top } = el.getBoundingClientRect()
if (window)
window.scrollTo({
top: top + window.scrollY - DEFAULT_HEADER_OFFSET,
left: 0,
@ -70,39 +68,41 @@ const scrollTo = (el: HTMLElement) => {
const imgSrcRegExp = /<img[^>]+src\s*=\s*["']([^"']+)["']/gi
export const FullArticle = (props: Props) => {
const { searchParams, changeSearchParams } = useRouter<ArticlePageSearchParams>()
const [searchParams, changeSearchParams] = useSearchParams<ArticlePageSearchParams>()
const { showModal } = useUI()
const { loadReactionsBy } = useReactions()
const [selectedImage, setSelectedImage] = createSignal('')
const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false)
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
const { t, formatDate, lang } = useLocalize()
const { author, session, requireAuthentication } = useSession()
const { addSeen } = useSeen()
const { session, requireAuthentication } = useSession()
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(
() =>
Boolean(author()?.id) &&
(props.article?.authors?.some((a) => Boolean(a) && a?.id === author().id) ||
props.article?.created_by?.id === author().id ||
session()?.user?.roles.includes('editor')),
session()?.user?.roles?.includes('editor')),
)
const mainTopic = createMemo(() => {
const mainTopicSlug = props.article.topics.length > 0 ? props.article.main_topic : null
const mt = props.article.topics.find((tpc: Topic) => tpc.slug === mainTopicSlug)
const mainTopicSlug = (props.article?.topics?.length || 0) > 0 ? props.article.main_topic : null
const mt = props.article.topics?.find((tpc: Maybe<Topic>) => tpc?.slug === mainTopicSlug)
if (mt) {
mt.title = lang() === 'en' ? capitalize(mt.slug.replace(/-/, ' ')) : mt.title
return mt
}
return props.article.topics[0]
return props.article?.topics?.[0]
})
const handleBookmarkButtonClick = (ev) => {
const handleBookmarkButtonClick = (ev: MouseEvent | undefined) => {
requireAuthentication(() => {
// TODO: implement bookmark clicked
ev.preventDefault()
ev?.preventDefault()
}, 'bookmark')
}
@ -129,10 +129,11 @@ export const FullArticle = (props: Props) => {
if (isServer) {
const result: string[] = []
let match: RegExpMatchArray
let match: RegExpMatchArray | null
while ((match = imgSrcRegExp.exec(body())) !== null) {
result.push(match[1])
if (match) result.push(match[1])
else break
}
return result
}
@ -150,14 +151,12 @@ export const FullArticle = (props: Props) => {
}
})
const commentsRef: {
current: HTMLDivElement
} = { current: null }
let commentsRef: HTMLDivElement | undefined
createEffect(() => {
if (searchParams().commentId && isReactionsLoaded()) {
if (searchParams?.commentId && isReactionsLoaded()) {
const commentElement = document.querySelector<HTMLElement>(
`[id='comment_${searchParams().commentId}']`,
`[id='comment_${searchParams?.commentId}']`,
)
if (commentElement) {
@ -166,8 +165,8 @@ export const FullArticle = (props: Props) => {
}
})
const clickHandlers = []
const documentClickHandlers = []
const clickHandlers: { element: HTMLElement; handler: () => void }[] = []
const documentClickHandlers: ((e: MouseEvent) => void)[] = []
createEffect(() => {
if (!body()) {
@ -185,7 +184,7 @@ export const FullArticle = (props: Props) => {
tooltip.classList.add(styles.tooltip)
const tooltipContent = document.createElement('div')
tooltipContent.classList.add(styles.tooltipContent)
tooltipContent.innerHTML = element.dataset.originalTitle || element.dataset.value
tooltipContent.innerHTML = element.dataset.originalTitle || element.dataset.value || ''
tooltip.append(tooltipContent)
@ -229,7 +228,7 @@ export const FullArticle = (props: Props) => {
popperInstance.update()
}
const handleDocumentClick = (e) => {
const handleDocumentClick = (e: MouseEvent) => {
if (isTooltipVisible && e.target !== element && e.target !== tooltip) {
tooltip.style.visibility = 'hidden'
isTooltipVisible = false
@ -253,14 +252,15 @@ export const FullArticle = (props: Props) => {
})
})
const openLightbox = (image) => {
const openLightbox = (image: string) => {
setSelectedImage(image)
}
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) {
const src = event.target.src
openLightbox(getImageUrl(src))
@ -268,12 +268,12 @@ export const FullArticle = (props: Props) => {
}
// Check iframes size
const articleContainer: { current: HTMLElement } = { current: null }
let articleContainer: HTMLElement | undefined
const updateIframeSizes = () => {
if (!(articleContainer?.current && props.article.body)) return
const iframes = articleContainer?.current?.querySelectorAll('iframe')
if (!(articleContainer && props.article.body && window)) return
const iframes = articleContainer?.querySelectorAll('iframe')
if (!iframes) return
const containerWidth = articleContainer.current?.offsetWidth
const containerWidth = articleContainer?.offsetWidth
iframes.forEach((iframe) => {
const style = window.getComputedStyle(iframe)
const originalWidth = iframe.getAttribute('width') || style.width.replace('px', '')
@ -302,7 +302,7 @@ export const FullArticle = (props: Props) => {
)
onMount(async () => {
install('G-LQ4B87H8C2')
// install('G-LQ4B87H8C2')
await loadReactionsBy({ by: { shout: props.article.slug } })
addSeen(props.article.slug)
setIsReactionsLoaded(true)
@ -312,15 +312,15 @@ export const FullArticle = (props: Props) => {
onCleanup(() => window.removeEventListener('resize', updateIframeSizes))
createEffect(() => {
if (props.scrollToComments) {
scrollTo(commentsRef.current)
if (props.scrollToComments && commentsRef) {
scrollTo(commentsRef)
}
})
createEffect(() => {
if (searchParams()?.scrollTo === 'comments' && commentsRef.current) {
requestAnimationFrame(() => scrollTo(commentsRef.current))
changeSearchParams({ scrollTo: null })
if (searchParams?.scrollTo === 'comments' && commentsRef) {
requestAnimationFrame(() => commentsRef && scrollTo(commentsRef))
changeSearchParams({ scrollTo: undefined })
}
})
})
@ -329,7 +329,7 @@ export const FullArticle = (props: Props) => {
const ogImage = getOpenGraphImageUrl(cover, {
title: props.article.title,
topic: mainTopic()?.title || '',
author: props.article?.authors[0]?.name || '',
author: props.article?.authors?.[0]?.name || '',
width: 1200,
})
@ -338,7 +338,7 @@ export const FullArticle = (props: Props) => {
const keywords = getKeywords(props.article)
const shareUrl = getShareUrl({ pathname: `/${props.article.slug}` })
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 (
<>
@ -357,7 +357,7 @@ export const FullArticle = (props: Props) => {
<div class="wide-container">
<div class="row position-relative">
<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)}
onClick={handleArticleBodyClick}
>
@ -365,7 +365,7 @@ export const FullArticle = (props: Props) => {
<Show when={props.article.layout !== 'audio'}>
<div class={styles.shoutHeader}>
<Show when={mainTopic()}>
<CardTopic title={mainTopic().title} slug={mainTopic().slug} />
<CardTopic title={mainTopic()?.title || ''} slug={mainTopic()?.slug || ''} />
</Show>
<h1>{props.article.title}</h1>
@ -375,10 +375,10 @@ export const FullArticle = (props: Props) => {
<div class={styles.shoutAuthor}>
<For each={props.article.authors}>
{(a: Author, index) => (
{(a: Maybe<Author>, index: () => number) => (
<>
<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>
@ -391,21 +391,25 @@ export const FullArticle = (props: Props) => {
}
>
<figure class="img-align-column">
<Image width={800} alt={props.article.cover_caption} src={props.article.cover} />
<figcaption innerHTML={props.article.cover_caption} />
<Image
width={800}
alt={props.article.cover_caption || ''}
src={props.article.cover || ''}
/>
<figcaption innerHTML={props.article.cover_caption || ''} />
</figure>
</Show>
</div>
</Show>
<Show when={props.article.lead}>
<section class={styles.lead} innerHTML={props.article.lead} />
<section class={styles.lead} innerHTML={props.article.lead || ''} />
</Show>
<Show when={props.article.layout === 'audio'}>
<AudioHeader
title={props.article.title}
cover={props.article.cover}
cover={props.article.cover || ''}
artistData={media()?.[0]}
topic={mainTopic()}
topic={mainTopic() as Topic}
/>
<Show when={media().length > 0}>
<div class="media-items">
@ -467,11 +471,11 @@ export const FullArticle = (props: Props) => {
</div>
<Popover content={t('Comment')} disabled={isActionPopupActive()}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<div
class={clsx(styles.shoutStatsItem)}
ref={triggerRef}
onClick={() => scrollTo(commentsRef.current)}
onClick={() => commentsRef && scrollTo(commentsRef)}
>
<Icon name="comment" class={styles.icon} />
<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}>
<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>
</Show>
@ -498,7 +502,7 @@ export const FullArticle = (props: Props) => {
</div>
<Popover content={t('Add to bookmarks')} disabled={isActionPopupActive()}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<div
class={clsx(styles.shoutStatsItem, styles.shoutStatsItemBookmarks)}
ref={triggerRef}
@ -513,12 +517,12 @@ export const FullArticle = (props: Props) => {
</Popover>
<Popover content={t('Share')} disabled={isActionPopupActive()}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<div class={styles.shoutStatsItem} ref={triggerRef}>
<SharePopup
title={props.article.title}
description={description}
imageUrl={props.article.cover}
imageUrl={props.article.cover || ''}
shareUrl={shareUrl}
containerCssClass={stylesHeader.control}
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
@ -535,22 +539,19 @@ export const FullArticle = (props: Props) => {
<Show when={canEdit()}>
<Popover content={t('Edit')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<div class={styles.shoutStatsItem} ref={triggerRef}>
<a
href={getPagePath(router, 'edit', { shoutId: props.article?.id.toString() })}
class={styles.shoutStatsItemInner}
>
<A href={`/edit/${props.article?.id}`} class={styles.shoutStatsItemInner}>
<Icon name="pencil-outline" class={styles.icon} />
<Icon name="pencil-outline-hover" class={clsx(styles.icon, styles.iconHover)} />
</a>
</A>
</div>
)}
</Popover>
</Show>
<FeedArticlePopup
canEdit={canEdit()}
canEdit={Boolean(canEdit())}
containerCssClass={clsx(stylesHeader.control, styles.articlePopupOpener)}
onShareClick={() => showModal('share')}
onInviteClick={() => showModal('inviteMembers')}
@ -575,14 +576,14 @@ export const FullArticle = (props: Props) => {
</div>
</Show>
<Show when={props.article.topics.length}>
<Show when={props.article.topics?.length}>
<div class={styles.topicsList}>
<For each={props.article.topics}>
{(topic) => (
<div class={styles.shoutTopic}>
<a href={getPagePath(router, 'topic', { slug: topic.slug })}>
{lang() === 'en' ? capitalize(topic.slug) : topic.title}
</a>
<A href={`/topic/${topic?.slug || ''}`}>
{lang() === 'en' ? capitalize(topic?.slug || '') : topic?.title || ''}
</A>
</div>
)}
</For>
@ -590,23 +591,23 @@ export const FullArticle = (props: Props) => {
</Show>
<div class={styles.shoutAuthorsList}>
<Show when={props.article.authors.length > 1}>
<Show when={(props.article.authors?.length || 0) > 1}>
<h4>{t('Authors')}</h4>
</Show>
<For each={props.article.authors}>
{(a: Author) => (
{(a: Maybe<Author>) => (
<div class="col-xl-12">
<AuthorBadge iconButtons={true} showMessageButton={true} author={a} />
<AuthorBadge iconButtons={true} showMessageButton={true} author={a as Author} />
</div>
)}
</For>
</div>
<div id="comments" ref={(el) => (commentsRef.current = el)}>
<div id="comments" ref={(el) => (commentsRef = el)}>
<Show when={isReactionsLoaded()}>
<CommentsTree
shoutId={props.article.id}
shoutSlug={props.article.slug}
articleAuthors={props.article.authors}
articleAuthors={props.article.authors as Author[]}
/>
</Show>
</div>
@ -622,7 +623,7 @@ export const FullArticle = (props: Props) => {
<ShareModal
title={props.article.title}
description={description}
imageUrl={props.article.cover}
imageUrl={props.article.cover || ''}
shareUrl={shareUrl}
/>
</>

View File

@ -1,11 +1,11 @@
import { clsx } from 'clsx'
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 { useReactions } from '../../context/reactions'
import { useSession } from '../../context/session'
import { ReactionKind, Shout } from '../../graphql/schema/core.gen'
import { loadShout } from '../../stores/zine/articles'
import { Icon } from '../_shared/Icon'
import { Popup } from '../_shared/Popup'
import { VotersList } from '../_shared/VotersList'
@ -19,7 +19,9 @@ interface ShoutRatingControlProps {
export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
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 [isLoading, setIsLoading] = createSignal(false)
@ -49,7 +51,7 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
r.shout.id === props.shout.id &&
!r.reply_to,
)
return deleteReaction(reactionToDelete.id)
if (reactionToDelete) return deleteReaction(reactionToDelete.id)
}
const handleRatingChange = (isUpvote: boolean) => {
@ -61,8 +63,10 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
await deleteShoutReaction(ReactionKind.Dislike)
} else {
await createReaction({
reaction: {
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
shout: props.shout.id,
},
})
}
@ -83,7 +87,10 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
</Show>
</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
reactions={shoutRatingReactions()}
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 { RootSearchParams } from '../../pages/types'
import { useRouter } from '../../stores/router'
import { hideModal } from '../../stores/ui'
import { AuthModalSearchParams } from '../Nav/AuthModal/types'
type Props = {
children: JSX.Element
@ -12,15 +9,18 @@ type Props = {
}
export const AuthGuard = (props: Props) => {
const { author, isSessionLoaded } = useSession()
const { changeSearchParams } = useRouter<RootSearchParams & AuthModalSearchParams>()
const { session } = useSession()
const author = createMemo<number>(() => session()?.user?.app_data?.profile?.id || 0)
const [, changeSearchParams] = useSearchParams()
const { hideModal } = useUI()
createEffect(() => {
if (props.disabled) {
return
}
if (isSessionLoaded()) {
if (author()?.id) {
createEffect(
on(
[() => props.disabled, author],
([disabled, a]) => {
if (disabled || !a) return
if (a) {
console.debug('[AuthGuard] profile is loaded')
hideModal()
} else {
changeSearchParams(
@ -28,14 +28,13 @@ export const AuthGuard = (props: Props) => {
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 { 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 { useLocalize } from '../../../context/localize'
import { useMediaQuery } from '../../../context/mediaQuery'
import { useSession } from '../../../context/session'
import { Author, FollowingEntity } from '../../../graphql/schema/core.gen'
import { router, useRouter } from '../../../stores/router'
import { translit } from '../../../utils/ru2en'
import { isCyrillic } from '../../../utils/translate'
import { Button } from '../../_shared/Button'
@ -30,31 +29,34 @@ type Props = {
subscriptionsMode?: boolean
}
export const AuthorBadge = (props: Props) => {
const { mediaMatches } = useMediaQuery()
const { author, requireAuthentication } = useSession()
const { session, requireAuthentication } = useSession()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
const { follow, unfollow, follows, following } = useFollowing()
const [isMobileView, setIsMobileView] = createSignal(false)
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(
on(
[() => follows?.authors, () => props.author, following],
([followingAuthors, currentAuthor, _]) => {
setIsFollowed(followingAuthors?.some((followedAuthor) => followedAuthor.id === currentAuthor?.id))
setIsFollowed(
Boolean(followingAuthors?.some((followedAuthor) => followedAuthor.id === currentAuthor?.id)),
)
},
{ defer: true },
),
)
const { changeSearchParams } = useRouter()
const [, changeSearchParams] = useSearchParams()
const navigate = useNavigate()
const { t, formatDate, lang } = useLocalize()
const initChat = () => {
// eslint-disable-next-line solid/reactivity
requireAuthentication(() => {
openPage(router, 'inbox')
navigate('/inbox')
changeSearchParams({
initChat: props.author?.id.toString(),
})
@ -62,12 +64,12 @@ export const AuthorBadge = (props: Props) => {
}
const name = createMemo(() => {
if (lang() !== 'ru' && isCyrillic(props.author.name)) {
if (lang() !== 'ru' && isCyrillic(props.author.name || '')) {
if (props.author.name === 'Дискурс') {
return 'Discours'
}
return translit(props.author.name)
return translit(props.author.name || '')
}
return props.author.name
@ -86,8 +88,8 @@ export const AuthorBadge = (props: Props) => {
<Userpic
hasLink={true}
size={isMobileView() ? 'M' : 'L'}
name={name()}
userpic={props.author.pic}
name={name() || ''}
userpic={props.author.pic || ''}
slug={props.author.slug}
/>
<ConditionalWrapper
@ -106,24 +108,24 @@ export const AuthorBadge = (props: Props) => {
fallback={
<div class={styles.bio}>
{t('Registered since {date}', {
date: formatDate(new Date(props.author.created_at * 1000)),
date: formatDate(new Date((props.author.created_at || 0) * 1000)),
})}
</div>
}
>
<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>
</Switch>
<Show when={props.author?.stat && !props.subscriptionsMode}>
<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>
</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>
</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>
</Show>
</div>
@ -136,7 +138,7 @@ export const AuthorBadge = (props: Props) => {
<FollowingButton
action={handleFollowClick}
isFollowed={isFollowed()}
actionMessageType={following()?.slug === props.author.slug ? following().type : undefined}
actionMessageType={following()?.slug === props.author.slug ? following()?.type : undefined}
/>
<Show when={props.showMessageButton}>
<Button
@ -152,8 +154,8 @@ export const AuthorBadge = (props: Props) => {
<Show when={props.inviteView}>
<CheckButton
text={t('Invite')}
checked={props.selected}
onClick={() => props.onInvite(props.author.id)}
checked={Boolean(props.selected)}
onClick={() => props.onInvite?.(props.author.id)}
/>
</Show>
</div>

View File

@ -175,7 +175,7 @@
width: 24px;
&::before {
background-image: url(/icons/user-link-default.svg);
background-image: url('/icons/user-link-default.svg');
background-repeat: no-repeat;
background-position: 50% 50%;
background-size: contain;
@ -209,7 +209,7 @@
&[href*='facebook.com/'] {
&::before {
background-image: url(/icons/user-link-facebook.svg);
background-image: url('/icons/user-link-facebook.svg');
}
&:hover {
@ -221,7 +221,7 @@
&[href*='twitter.com/'] {
&::before {
background-image: url(/icons/user-link-twitter.svg);
background-image: url('/icons/user-link-twitter.svg');
}
&:hover {
@ -234,7 +234,7 @@
&[href*='telegram.com/'],
&[href*='t.me/'] {
&::before {
background-image: url(/icons/user-link-telegram.svg);
background-image: url('/icons/user-link-telegram.svg');
}
&:hover {
@ -247,7 +247,7 @@
&[href*='vk.cc/'],
&[href*='vk.com/'] {
&::before {
background-image: url(/icons/user-link-vk.svg);
background-image: url('/icons/user-link-vk.svg');
}
&:hover {
@ -259,7 +259,7 @@
&[href*='tumblr.com/'] {
&::before {
background-image: url(/icons/user-link-tumblr.svg);
background-image: url('/icons/user-link-tumblr.svg');
}
&:hover {
@ -271,7 +271,7 @@
&[href*='instagram.com/'] {
&::before {
background-image: url(/icons/user-link-instagram.svg);
background-image: url('/icons/user-link-instagram.svg');
}
&:hover {
@ -283,7 +283,7 @@
&[href*='behance.net/'] {
&::before {
background-image: url(/icons/user-link-behance.svg);
background-image: url('/icons/user-link-behance.svg');
}
&:hover {
@ -295,7 +295,7 @@
&[href*='dribbble.com/'] {
&::before {
background-image: url(/icons/user-link-dribbble.svg);
background-image: url('/icons/user-link-dribbble.svg');
}
&:hover {
@ -307,7 +307,7 @@
&[href*='github.com/'] {
&::before {
background-image: url(/icons/user-link-github.svg);
background-image: url('/icons/user-link-github.svg');
}
&:hover {
@ -319,7 +319,7 @@
&[href*='linkedin.com/'] {
&::before {
background-image: url(/icons/user-link-linkedin.svg);
background-image: url('/icons/user-link-linkedin.svg');
}
&:hover {
@ -331,7 +331,7 @@
&[href*='medium.com/'] {
&::before {
background-image: url(/icons/user-link-medium.svg);
background-image: url('/icons/user-link-medium.svg');
}
&:hover {
@ -343,7 +343,7 @@
&[href*='ok.ru/'] {
&::before {
background-image: url(/icons/user-link-ok.svg);
background-image: url('/icons/user-link-ok.svg');
}
&:hover {
@ -355,7 +355,7 @@
&[href*='pinterest.com/'] {
&::before {
background-image: url(/icons/user-link-pinterest.svg);
background-image: url('/icons/user-link-pinterest.svg');
}
&:hover {
@ -367,7 +367,7 @@
&[href*='reddit.com/'] {
&::before {
background-image: url(/icons/user-link-reddit.svg);
background-image: url('/icons/user-link-reddit.svg');
}
&:hover {
@ -379,7 +379,7 @@
&[href*='tiktok.com/'] {
&::before {
background-image: url(/icons/user-link-tiktok.svg);
background-image: url('/icons/user-link-tiktok.svg');
}
&:hover {
@ -392,7 +392,7 @@
&[href*='youtube.com/'],
&[href*='youtu.be/'] {
&::before {
background-image: url(/icons/user-link-youtube.svg);
background-image: url('/icons/user-link-youtube.svg');
}
&:hover {
@ -404,7 +404,7 @@
&[href*='dzen.ru/'] {
&::before {
background-image: url(/icons/user-link-dzen.svg);
background-image: url('/icons/user-link-dzen.svg');
}
&:hover {

View File

@ -1,15 +1,11 @@
import type { Author, Community } from '../../../graphql/schema/core.gen'
import { openPage, redirectPage } from '@nanostores/router'
import { clsx } from 'clsx'
import { For, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js'
import { useFollowing } from '../../../context/following'
import { FollowsFilter, useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session'
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 { translit } from '../../../utils/ru2en'
import { isCyrillic } from '../../../utils/translate'
@ -22,6 +18,7 @@ import { ShowOnlyOnClient } from '../../_shared/ShowOnlyOnClient'
import { AuthorBadge } from '../AuthorBadge'
import { Userpic } from '../Userpic'
import { useNavigate, useSearchParams } from '@solidjs/router'
import stylesButton from '../../_shared/Button/Button.module.scss'
import styles from './AuthorCard.module.scss'
@ -30,9 +27,12 @@ type Props = {
followers?: Author[]
flatFollows?: Array<Author | Topic>
}
export const AuthorCard = (props: Props) => {
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 [followsFilter, setFollowsFilter] = createSignal<FollowsFilter>('all')
const [isFollowed, setIsFollowed] = createSignal<boolean>()
@ -40,7 +40,7 @@ export const AuthorCard = (props: Props) => {
const { follow, unfollow, follows, following } = useFollowing()
onMount(() => {
setAuthorSubs(props.flatFollows)
setAuthorSubs(props.flatFollows || [])
})
createEffect(() => {
@ -50,21 +50,20 @@ export const AuthorCard = (props: Props) => {
})
const name = createMemo(() => {
if (lang() !== 'ru' && isCyrillic(props.author.name)) {
if (lang() !== 'ru' && isCyrillic(props.author?.name || '')) {
if (props.author.name === 'Дискурс') {
return 'Discours'
}
return translit(props.author.name)
return translit(props.author?.name || '')
}
return props.author.name
})
// TODO: reimplement AuthorCard
const { changeSearchParams } = useRouter()
const [, changeSearchParams] = useSearchParams()
const initChat = () => {
// eslint-disable-next-line solid/reactivity
requireAuthentication(() => {
openPage(router, 'inbox')
navigate('/inbox')
changeSearchParams({
initChat: props.author?.id.toString(),
})
@ -95,7 +94,7 @@ export const AuthorCard = (props: Props) => {
const followButtonText = createMemo(() => {
if (following()?.slug === props.author.slug) {
return following().type === 'follow' ? t('Following...') : t('Unfollowing...')
return following()?.type === 'follow' ? t('Following...') : t('Unfollowing...')
}
if (isFollowed()) {
@ -134,7 +133,7 @@ export const AuthorCard = (props: Props) => {
<button type="button" onClick={() => setFollowsFilter('all')}>
{t('All')}
</button>
<span class="view-switcher__counter">{props.flatFollows.length}</span>
<span class="view-switcher__counter">{props.flatFollows?.length}</span>
</li>
<li
class={clsx({
@ -144,7 +143,7 @@ export const AuthorCard = (props: Props) => {
<button type="button" onClick={() => setFollowsFilter('authors')}>
{t('Authors')}
</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
class={clsx({
@ -154,7 +153,9 @@ export const AuthorCard = (props: Props) => {
<button type="button" onClick={() => setFollowsFilter('topics')}>
{t('Topics')}
</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>
</ul>
<br />
@ -181,8 +182,8 @@ export const AuthorCard = (props: Props) => {
<div class="col-md-5">
<Userpic
size={'XL'}
name={props.author.name}
userpic={props.author.pic}
name={props.author.name || ''}
userpic={props.author.pic || ''}
slug={props.author.slug}
class={styles.circlewrap}
/>
@ -191,15 +192,15 @@ export const AuthorCard = (props: Props) => {
<div class={styles.authorDetailsWrapper}>
<div class={styles.authorName}>{name()}</div>
<Show when={props.author.bio}>
<div class={styles.authorAbout} innerHTML={props.author.bio} />
<div class={styles.authorAbout} innerHTML={props.author.bio || ''} />
</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}>
<FollowingCounters
followers={props.followers}
followersAmount={props.author?.stat?.followers}
followersAmount={props.author?.stat?.followers || 0}
following={props.flatFollows}
followingAmount={props.flatFollows.length}
followingAmount={props.flatFollows?.length || 0}
/>
</div>
</Show>
@ -209,15 +210,15 @@ export const AuthorCard = (props: Props) => {
<Show when={props.author.links && props.author.links.length > 0}>
<div class={styles.authorSubscribeSocial}>
<For each={props.author.links}>
{(link) => (
{(link: string | null) => (
<a
class={styles.socialLink}
href={link.startsWith('http') ? link : `https://${link}`}
href={link?.startsWith('http') ? link : `https://${link}`}
target="_blank"
rel="nofollow noopener noreferrer"
>
<span class={styles.authorSubscribeSocialLabel}>
{link.startsWith('http') ? link : `https://${link}`}
{link?.startsWith('http') ? link : `https://${link}`}
</span>
</a>
)}
@ -251,7 +252,7 @@ export const AuthorCard = (props: Props) => {
<div class={styles.authorActions}>
<Button
variant="secondary"
onClick={() => redirectPage(router, 'profileSettings')}
onClick={() => navigate('/profile/settings')}
value={
<>
<span class={styles.authorActionsLabel}>{t('Edit profile')}</span>
@ -260,9 +261,9 @@ export const AuthorCard = (props: Props) => {
}
/>
<SharePopup
title={props.author.name}
description={props.author.bio}
imageUrl={props.author.pic}
title={props.author.name || ''}
description={props.author.bio || ''}
imageUrl={props.author.pic || ''}
shareUrl={getShareUrl({
pathname: `/author/${props.author.slug}`,
})}

View File

@ -20,18 +20,18 @@ type Props = {
export const AuthorLink = (props: Props) => {
const { lang } = useLocalize()
const name = createMemo(() => {
return lang() === 'en' && isCyrillic(props.author.name)
? translit(capitalize(props.author.name))
return lang() === 'en' && isCyrillic(props.author.name || '')
? translit(capitalize(props.author.name || ''))
: props.author.name
})
return (
<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,
})}
>
<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>
</a>
</div>

View File

@ -2,9 +2,8 @@ import type { Author } from '../../graphql/schema/core.gen'
import { clsx } from 'clsx'
import { Show, createSignal } from 'solid-js'
import { apiClient } from '../../graphql/client/core'
import { useGraphQL } from '~/context/graphql'
import rateAuthorMutation from '~/graphql/mutation/core/author-rate'
import styles from './AuthorRatingControl.module.scss'
interface AuthorRatingControlProps {
@ -20,11 +19,14 @@ export const AuthorRatingControl = (props: AuthorRatingControlProps) => {
console.log('handleRatingChange', { isUpvote })
if (props.author?.slug) {
const value = isUpvote ? 1 : -1
await apiClient.rateAuthor({ rated_slug: props.author?.slug, value })
setRating((r) => r + value)
const _resp = await mutation(rateAuthorMutation, {
rated_slug: props.author?.slug,
value,
}).toPromise()
setRating((r) => (r || 0) + value)
}
}
const { mutation } = useGraphQL()
const [rating, setRating] = createSignal(props.author?.stat?.rating)
return (
<div

View File

@ -11,7 +11,7 @@ interface 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 (
<div
class={clsx(styles.rating, props.class, {

View File

@ -54,7 +54,7 @@ export const Userpic = (props: Props) => {
>
<Show when={!props.loading} fallback={<Loading />}>
<ConditionalWrapper
condition={props.hasLink}
condition={Boolean(props.hasLink)}
wrapper={(children) => <a href={`/author/${props.slug}`}>{children}</a>}
>
<Show keyed={true} when={props.userpic} fallback={<div class={styles.letters}>{letters()}</div>}>

View File

@ -1,8 +1,10 @@
import { clsx } from 'clsx'
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 { apiClient } from '../../graphql/client/core'
import { setAuthorsByFollowers, setAuthorsByShouts, useAuthorsStore } from '../../stores/zine/authors'
import { AuthorBadge } from '../Author/AuthorBadge'
import { InlineLoader } from '../InlineLoader'
import { Button } from '../_shared/Button'
@ -19,27 +21,33 @@ const PAGE_SIZE = 20
export const AuthorsList = (props: Props) => {
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 [currentPage, setCurrentPage] = createSignal({ shouts: 0, followers: 0 })
const [allLoaded, setAllLoaded] = createSignal(false)
const { query } = useGraphQL()
const fetchAuthors = async (queryType: Props['query'], page: number) => {
setLoading(true)
const offset = PAGE_SIZE * page
const result = await apiClient.loadAuthorsBy({
const resp = await query(loadAuthorsByQuery, {
by: { order: queryType },
limit: PAGE_SIZE,
offset,
})
const result = resp?.data?.load_authors_by
if ((result?.length || 0) > 0) {
addAuthors([...result])
if (queryType === 'shouts') {
setAuthorsByShouts((prev) => [...prev, ...result])
} else {
setAuthorsByFollowers((prev) => [...prev, ...result])
setAuthorsByShouts((prev) => [...(prev || []), ...result])
} else if (queryType === 'followers') {
setAuthorsByFollowers((prev) => [...(prev || []), ...result])
}
setLoading(false)
}
}
const loadMoreAuthors = () => {
const nextPage = currentPage()[props.query] + 1
@ -52,8 +60,8 @@ export const AuthorsList = (props: Props) => {
on(
() => props.query,
(query) => {
const authorsList = query === 'shouts' ? authorsByShouts() : authorsByFollowers()
if (authorsList.length === 0 && currentPage()[query] === 0) {
const al = query === 'shouts' ? authorsByShouts() : authorsByFollowers()
if (al?.length === 0 && currentPage()[query] === 0) {
setCurrentPage((prev) => ({ ...prev, [query]: 0 }))
fetchAuthors(query, 0).then(() => setCurrentPage((prev) => ({ ...prev, [query]: 1 })))
}
@ -88,7 +96,7 @@ export const AuthorsList = (props: Props) => {
<div class="row">
<div class="col-lg-20 col-xl-18">
<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} />
</Show>
<Show when={loading() && !allLoaded()}>

View File

@ -1,13 +1,14 @@
import { clsx } from 'clsx'
import { useUI } from '~/context/ui'
import { useLocalize } from '../../context/localize'
import { showModal } from '../../stores/ui'
import { Image } from '../_shared/Image'
import styles from './Banner.module.scss'
export default () => {
const { t } = useLocalize()
const { showModal } = useUI()
return (
<div class={styles.discoursBanner}>
<div class="wide-container">

View File

@ -1,9 +1,8 @@
import { clsx } from 'clsx'
import { createSignal, onMount } from 'solid-js'
import { useSnackbar, useUI } from '~/context/ui'
import { useLocalize } from '../../context/localize'
import { useSnackbar } from '../../context/snackbar'
import { showModal } from '../../stores/ui'
import styles from './Donate.module.scss'
@ -12,6 +11,7 @@ type DWindow = Window & { cp: any }
export const Donate = () => {
const { t } = useLocalize()
const { showModal } = useUI()
const once = ''
const monthly = 'Monthly'
const cpOptions = {
@ -103,13 +103,15 @@ export const Donate = () => {
},
},
},
(opts) => {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
(opts: any) => {
// success
// действие при успешной оплате
console.debug('[donate] options', opts)
showModal('thank')
},
(reason: string, options) => {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
(reason: string, options: any) => {
// fail
// действие при неуспешной оплате
console.debug('[donate] options', options)

View File

@ -1,10 +1,10 @@
import { useUI } from '~/context/ui'
import { useLocalize } from '../../context/localize'
import { hideModal } from '../../stores/ui'
import { Button } from '../_shared/Button'
export const Feedback = () => {
const { t } = useLocalize()
const { hideModal } = useUI()
const action = '/user/feedback'
const method = 'post'
let msgElement: HTMLTextAreaElement | undefined

View File

@ -147,13 +147,16 @@ export const Footer = () => {
</div>
<div class={clsx(styles.footerCopyrightSocial, 'col-md-6 col-lg-4')}>
<For each={social}>
{(social) => (
<div class={clsx(styles.socialItem, styles[`socialItem${social.name}`])}>
{(social) => {
const styleKey = `socialItem${social.name}` as keyof typeof styles
return (
<div class={clsx(styles.socialItem, styles[styleKey])}>
<a href={social.href}>
<Icon name={`${social.name}-white`} class={styles.icon} />
</a>
</div>
)}
)
}}
</For>
</div>
</div>

View File

@ -1,14 +1,13 @@
import { useUI } from '~/context/ui'
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'
export default () => {
const { t } = useLocalize()
const { changeSearchParams } = useRouter<AuthModalSearchParams>()
const { showModal } = useUI()
const [, changeSearchParams] = useSearchParams()
return (
<div class={styles.aboutDiscours}>
<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 { useConfirm } from '../../context/confirm'
import { useSnackbar, useUI } from '~/context/ui'
import { useLocalize } from '../../context/localize'
import { useSnackbar } from '../../context/snackbar'
import { router } from '../../stores/router'
import type { Shout } from '../../graphql/schema/core.gen'
import { Icon } from '../_shared/Icon'
import { A } from '@solidjs/router'
import styles from './Draft.module.scss'
type Props = {
@ -20,10 +17,10 @@ type Props = {
export const Draft = (props: Props) => {
const { t, formatDate } = useLocalize()
const { showConfirm } = useConfirm()
const { showConfirm } = useUI()
const { showSnackbar } = useSnackbar()
const handlePublishLinkClick = (e) => {
const handlePublishLinkClick = (e: MouseEvent) => {
e.preventDefault()
if (props.shout.main_topic) {
props.onPublish(props.shout)
@ -32,7 +29,7 @@ export const Draft = (props: Props) => {
}
}
const handleDeleteLinkClick = async (e) => {
const handleDeleteLinkClick = async (e: MouseEvent) => {
e.preventDefault()
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}
</div>
<div class={styles.actions}>
<a
class={styles.actionItem}
href={getPagePath(router, 'edit', { shoutId: props.shout?.id.toString() })}
>
<A class={styles.actionItem} href={`edit/${props.shout?.id.toString()}`}>
{t('Edit')}
</a>
</A>
<span onClick={handlePublishLinkClick} class={clsx(styles.actionItem, styles.publish)}>
{t('Publish')}
</span>

View File

@ -1,8 +1,8 @@
import { clsx } from 'clsx'
import { Show } from 'solid-js'
import { MediaItem } from '~/types/mediaitem'
import { useLocalize } from '../../../context/localize'
import { MediaItem } from '../../../pages/types'
import { composeMediaItems } from '../../../utils/composeMediaItems'
import { AudioPlayer } from '../../Article/AudioPlayer'
import { DropArea } from '../../_shared/DropArea'
@ -10,7 +10,7 @@ import { DropArea } from '../../_shared/DropArea'
// import { Buffer } from 'node:buffer'
import styles from './AudioUploader.module.scss'
window.Buffer = Buffer
if (window) window.Buffer = Buffer
type Props = {
class?: string
@ -28,7 +28,11 @@ type Props = {
export const AudioUploader = (props: Props) => {
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 })
}

View File

@ -16,7 +16,7 @@ export const BlockquoteBubbleMenu = (props: Props) => {
return (
<div ref={props.ref} class={styles.BubbleMenu}>
<Popover content={t('Alignment left')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
@ -30,7 +30,7 @@ export const BlockquoteBubbleMenu = (props: Props) => {
)}
</Popover>
<Popover content={t('Alignment center')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
@ -42,7 +42,7 @@ export const BlockquoteBubbleMenu = (props: Props) => {
)}
</Popover>
<Popover content={t('Alignment center')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"

View File

@ -1,13 +1,14 @@
import type { Editor } from '@tiptap/core'
import { UploadedFile } from '~/types/upload'
import { useLocalize } from '../../../context/localize'
import { UploadedFile } from '../../../pages/types'
import { renderUploadedImage } from '../../../utils/renderUploadedImage'
import { Modal } from '../../Nav/Modal'
import { Icon } from '../../_shared/Icon'
import { Popover } from '../../_shared/Popover'
import { UploadModalContent } from '../UploadModalContent'
import { useUI } from '~/context/ui'
import styles from './BubbleMenu.module.scss'
type Props = {
@ -17,15 +18,17 @@ type Props = {
export const FigureBubbleMenu = (props: Props) => {
const { t } = useLocalize()
const { hideModal } = useUI()
const handleUpload = (image: UploadedFile) => {
renderUploadedImage(props.editor, image)
hideModal()
}
return (
<div ref={props.ref} class={styles.BubbleMenu}>
<Popover content={t('Alignment left')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
@ -37,7 +40,7 @@ export const FigureBubbleMenu = (props: Props) => {
)}
</Popover>
<Popover content={t('Alignment center')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
@ -49,7 +52,7 @@ export const FigureBubbleMenu = (props: Props) => {
)}
</Popover>
<Popover content={t('Alignment right')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
@ -70,7 +73,7 @@ export const FigureBubbleMenu = (props: Props) => {
</button>
<div class={styles.delimiter} />
<Popover content={t('Add image')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button type="button" ref={triggerRef} class={styles.bubbleMenuButton}>
<Icon name="editor-image-add" />
</button>
@ -80,7 +83,7 @@ export const FigureBubbleMenu = (props: Props) => {
<Modal variant="narrow" name="uploadImage">
<UploadModalContent
onClose={(value) => {
handleUpload(value)
handleUpload(value as UploadedFile)
}}
/>
</Modal>

View File

@ -18,7 +18,7 @@ const backgrounds = [null, 'white', 'black', 'yellow', 'pink', 'green']
export const IncutBubbleMenu = (props: Props) => {
const { t } = useLocalize()
const [substratBubbleOpen, setSubstratBubbleOpen] = createSignal(false)
const handleChangeBg = (bg) => {
const handleChangeBg = (bg: string | null) => {
props.editor.chain().focus().setArticleBg(bg).run()
setSubstratBubbleOpen(false)
}
@ -60,7 +60,12 @@ export const IncutBubbleMenu = (props: Props) => {
<div class={styles.dropDown}>
<div class={styles.actions}>
<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>
</div>
</div>

View File

@ -1,5 +1,5 @@
import { HocuspocusProvider } from '@hocuspocus/provider'
import { isTextSelection } from '@tiptap/core'
import { Editor, isTextSelection } from '@tiptap/core'
import { Bold } from '@tiptap/extension-bold'
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
import { BulletList } from '@tiptap/extension-bullet-list'
@ -25,15 +25,15 @@ import { Placeholder } from '@tiptap/extension-placeholder'
import { Strike } from '@tiptap/extension-strike'
import { Text } from '@tiptap/extension-text'
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 uniqolor from 'uniqolor'
import { Doc } from 'yjs'
import { useSnackbar } from '~/context/ui'
import { useEditorContext } from '../../context/editor'
import { useLocalize } from '../../context/localize'
import { useSession } from '../../context/session'
import { useSnackbar } from '../../context/snackbar'
import { handleImageUpload } from '../../utils/handleImageUpload'
import { BlockquoteBubbleMenu, FigureBubbleMenu, IncutBubbleMenu } from './BubbleMenu'
@ -50,6 +50,7 @@ import { ToggleTextWrap } from './extensions/ToggleTextWrap'
import { TrailingNode } from './extensions/TrailingNode'
import './Prosemirror.scss'
import { Author } from '~/graphql/schema/core.gen'
type Props = {
shoutId: number
@ -71,13 +72,12 @@ const allowedImageTypes = new Set([
const yDocs: Record<string, Doc> = {}
const providers: Record<string, HocuspocusProvider> = {}
export const Editor = (props: Props) => {
export const EditorComponent = (props: Props) => {
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 [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false)
const { showSnackbar } = useSnackbar()
const docName = `shout-${props.shoutId}`
@ -95,39 +95,12 @@ export const Editor = (props: Props) => {
})
}
const editorElRef: {
current: HTMLDivElement
} = {
current: null,
}
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 [editorElRef, setEditorElRef] = createSignal<HTMLElement>()
let textBubbleMenuRef: HTMLDivElement | undefined
let incutBubbleMenuRef: HTMLElement | undefined
let figureBubbleMenuRef: HTMLElement | undefined
let blockquoteBubbleMenuRef: HTMLElement | undefined
let floatingMenuRef: HTMLDivElement | undefined
const handleClipboardPaste = async () => {
try {
@ -151,10 +124,10 @@ export const Editor = (props: Props) => {
}
showSnackbar({ body: t('Uploading image') })
const result = await handleImageUpload(uplFile, session()?.access_token)
const result = await handleImageUpload(uplFile, session()?.access_token || '')
editor()
.chain()
?.chain()
.focus()
.insertContent({
type: 'figure',
@ -177,9 +150,12 @@ export const Editor = (props: Props) => {
}
const { initialContent } = props
const editor = createTiptapEditor(() => ({
element: editorElRef.current,
const { editor, setEditor, countWords } = useEditorContext()
createEffect(
on(editorElRef, (ee: HTMLElement | undefined) => {
if (ee) {
const freshEditor = createTiptapEditor<HTMLElement>(() => ({
element: ee,
editorProps: {
attributes: {
class: 'articleEditor',
@ -252,11 +228,12 @@ export const Editor = (props: Props) => {
CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef.current,
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)
const isEmptyTextBlock =
doc.textBetween(from, to).length === 0 && isTextSelection(selection)
if (isEmptyTextBlock) {
e.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
}
@ -273,81 +250,75 @@ export const Editor = (props: Props) => {
return result
},
tippyOptions: {
sticky: true,
onHide: () => {
const fe = freshEditor() as Editor
fe?.commands.focus()
},
},
}),
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()
}
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.current,
shouldShow: ({ editor: e, state }) => {
const { selection } = state
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()
}
},
},
}),
BubbleMenu.configure({
pluginKey: 'imageBubbleMenu',
element: figureBubbleMenuRef.current,
shouldShow: ({ editor: e, view }) => {
return view.hasFocus() && e.isActive('image')
element: incutBubbleMenuRef,
shouldShow: ({ editor: e, view, state }) => {
const { empty } = state.selection
return view.hasFocus() && !empty && e.isActive('figcaption')
},
}),
FloatingMenu.configure({
tippyOptions: {
placement: 'left',
element: floatingMenuRef,
pluginKey: 'floatingMenu',
shouldShow: ({ editor: e, state }) => {
const { $anchor, empty } = state.selection
const isRootDepth = $anchor.depth === 1
if (!(isRootDepth && empty)) return false
return !(e.isActive('codeBlock') || e.isActive('heading'))
},
element: floatingMenuRef.current,
}),
TrailingNode,
Article,
],
enablePasteRules: [Link],
content: initialContent ?? null,
onTransaction: ({ transaction }) => {
if (transaction.docChanged) {
const fe = freshEditor()
if (fe) {
const changeHandle = useEditorHTML(() => fe as Editor | undefined)
props.onChange(changeHandle() || '')
countWords(fe?.storage.characterCount.words())
}
}
},
content: initialContent,
}))
const { countWords, setEditor } = useEditorContext()
setEditor(editor)
const html = useEditorHTML(() => editor())
createEffect(() => {
props.onChange(html())
if (html()) {
countWords({
characters: editor().storage.characterCount.characters(),
words: editor().storage.characterCount.words(),
})
if (freshEditor) {
editorElRef()?.addEventListener('focus', (_event) => {
if (freshEditor()?.isActive('figcaption')) {
freshEditor()?.commands.focus()
}
})
setEditor(freshEditor() as Editor)
}
}
}),
)
onCleanup(() => {
editor()?.destroy()
@ -358,35 +329,36 @@ export const Editor = (props: Props) => {
<div class="row">
<div class="col-md-5" />
<div class="col-md-12">
<div ref={(el) => (editorElRef.current = el)} id="editorBody" />
<div ref={setEditorElRef} id="editorBody" />
</div>
</div>
<Show when={editor()}>
<TextBubbleMenu
shouldShow={shouldShowTextBubbleMenu()}
isCommonMarkup={isCommonMarkup()}
editor={editor()}
ref={(el) => (textBubbleMenuRef.current = el)}
editor={editor() as Editor}
ref={(el) => (textBubbleMenuRef = el)}
/>
<BlockquoteBubbleMenu
ref={(el) => {
blockquoteBubbleMenuRef.current = el
blockquoteBubbleMenuRef = el
}}
editor={editor()}
editor={editor() as Editor}
/>
<FigureBubbleMenu
editor={editor()}
editor={editor() as Editor}
ref={(el) => {
figureBubbleMenuRef.current = el
figureBubbleMenuRef = el
}}
/>
<IncutBubbleMenu
editor={editor()}
editor={editor() as Editor}
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 { MenuItem } from './Menu/Menu'
import { Show, createEffect, createSignal } from 'solid-js'
import { useUI } from '~/context/ui'
import { UploadedFile } from '~/types/upload'
import { useLocalize } from '../../../context/localize'
import { UploadedFile } from '../../../pages/types'
import { showModal } from '../../../stores/ui'
import { renderUploadedImage } from '../../../utils/renderUploadedImage'
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
import { Modal } from '../../Nav/Modal'
import { Icon } from '../../_shared/Icon'
import { InlineForm } from '../InlineForm'
import { UploadModalContent } from '../UploadModalContent'
import { Menu } from './Menu'
import type { MenuItem } from './Menu/Menu'
import styles from './EditorFloatingMenu.module.scss'
@ -22,16 +20,15 @@ type FloatingMenuProps = {
ref: (el: HTMLDivElement) => void
}
const embedData = (data) => {
const embedData = (data: string) => {
const element = document.createRange().createContextualFragment(data)
const { attributes } = element.firstChild as HTMLIFrameElement
const result: { src: string; width?: string; height?: string } = { src: '' }
for (let i = 0; i < attributes.length; i++) {
const attribute = attributes.item(i)
if (attribute) {
result[attribute.name] = attribute.value
if (attribute?.name) {
result[attribute.name as keyof typeof result] = attribute.value as string
}
}
@ -40,10 +37,11 @@ const embedData = (data) => {
export const EditorFloatingMenu = (props: FloatingMenuProps) => {
const { t } = useLocalize()
const { showModal, hideModal } = useUI()
const [selectedMenuItem, setSelectedMenuItem] = createSignal<MenuItem | undefined>()
const [menuOpen, setMenuOpen] = createSignal<boolean>(false)
const menuRef: { current: HTMLDivElement } = { current: null }
const plusButtonRef: { current: HTMLButtonElement } = { current: null }
let menuRef: HTMLDivElement | undefined
let plusButtonRef: HTMLButtonElement | undefined
const handleEmbedFormSubmit = async (value: string) => {
// TODO: add support instagram embed (blockquote)
const emb = await embedData(value)
@ -71,7 +69,7 @@ export const EditorFloatingMenu = (props: FloatingMenuProps) => {
.run()
}
const validateEmbed = (value) => {
const validateEmbed = (value: string) => {
const element = document.createRange().createContextualFragment(value)
if (element.firstChild?.nodeName !== 'IFRAME') {
return t('Error')
@ -101,7 +99,7 @@ export const EditorFloatingMenu = (props: FloatingMenuProps) => {
useOutsideClickHandler({
containerRef: menuRef,
handler: (e) => {
if (plusButtonRef.current.contains(e.target)) {
if (plusButtonRef?.contains(e.target)) {
return
}
@ -114,22 +112,19 @@ export const EditorFloatingMenu = (props: FloatingMenuProps) => {
const handleUpload = (image: UploadedFile) => {
renderUploadedImage(props.editor, image)
hideModal()
}
return (
<>
<div ref={props.ref} class={styles.editorFloatingMenu}>
<button
ref={(el) => (plusButtonRef.current = el)}
type="button"
onClick={() => setMenuOpen(!menuOpen())}
>
<button ref={(el) => (plusButtonRef = el)} type="button" onClick={() => setMenuOpen(!menuOpen())}>
<Icon name="editor-plus" />
</button>
<Show when={menuOpen()}>
<div class={styles.menuHolder} ref={(el) => (menuRef.current = el)}>
<div class={styles.menuHolder} ref={(el) => (menuRef = el)}>
<Show when={!selectedMenuItem()}>
<Menu selectedItem={(value: MenuItem) => setSelectedMenuItem(value)} />
<Menu selectedItem={(value: string) => setSelectedMenuItem(value as MenuItem)} />
</Show>
<Show when={selectedMenuItem() === 'embed'}>
<InlineForm
@ -137,7 +132,7 @@ export const EditorFloatingMenu = (props: FloatingMenuProps) => {
showInput={true}
onClose={closeUploadModalHandler}
onClear={() => setSelectedMenuItem()}
validate={validateEmbed}
validate={(val) => validateEmbed(val) || ''}
onSubmit={handleEmbedFormSubmit}
/>
</Show>
@ -147,7 +142,7 @@ export const EditorFloatingMenu = (props: FloatingMenuProps) => {
<Modal variant="narrow" name="uploadImage" onClose={closeUploadModalHandler}>
<UploadModalContent
onClose={(value) => {
handleUpload(value)
handleUpload(value as UploadedFile)
setSelectedMenuItem()
}}
/>

View File

@ -19,21 +19,21 @@ export const Menu = (props: Props) => {
return (
<div class={styles.Menu}>
<Popover content={t('Add image')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button ref={triggerRef} type="button" onClick={() => setSelectedMenuItem('image')}>
<Icon class={styles.icon} name="editor-image" />
</button>
)}
</Popover>
<Popover content={t('Add an embed widget')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button ref={triggerRef} type="button" onClick={() => setSelectedMenuItem('embed')}>
<Icon class={styles.icon} name="editor-embed" />
</button>
)}
</Popover>
<Popover content={t('Add rule')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button ref={triggerRef} type="button" onClick={() => setSelectedMenuItem('horizontal-rule')}>
<Icon class={styles.icon} name="editor-horizontal-rule" />
</button>

View File

@ -22,9 +22,9 @@ export const InlineForm = (props: Props) => {
const [formValue, setFormValue] = createSignal(props.initialValue || '')
const [formValueError, setFormValueError] = createSignal<string | undefined>()
const inputRef: { current: HTMLInputElement } = { current: null }
const handleFormInput = (e) => {
const value = e.currentTarget.value
let inputRef: HTMLInputElement | undefined
const handleFormInput = (e: { currentTarget: HTMLInputElement; target: HTMLInputElement }) => {
const value = (e.currentTarget || e.target).value
setFormValueError()
setFormValue(value)
}
@ -42,7 +42,7 @@ export const InlineForm = (props: Props) => {
props.onClose()
}
const handleKeyDown = async (e) => {
const handleKeyDown = async (e: KeyboardEvent) => {
setFormValueError('')
if (e.key === 'Enter') {
@ -56,18 +56,18 @@ export const InlineForm = (props: Props) => {
}
const handleClear = () => {
props.initialValue ? props.onClear() : props.onClose()
props.initialValue ? props.onClear?.() : props.onClose()
}
onMount(() => {
inputRef.current.focus()
inputRef?.focus()
})
return (
<div class={styles.InlineForm}>
<div class={styles.form}>
<input
ref={(el) => (inputRef.current = el)}
ref={(el) => (inputRef = el)}
type="text"
value={props.initialValue ?? ''}
placeholder={props.placeholder}
@ -75,7 +75,7 @@ export const InlineForm = (props: Props) => {
onInput={handleFormInput}
/>
<Popover content={t('Add link')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
@ -87,7 +87,7 @@ export const InlineForm = (props: Props) => {
)}
</Popover>
<Popover content={props.initialValue ? t('Remove link') : t('Cancel')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button ref={triggerRef} type="button" onClick={handleClear}>
{props.initialValue ? <Icon name="editor-unlink" /> : <Icon name="status-cancel" />}
</button>

View File

@ -10,7 +10,7 @@ type Props = {
onClose: () => void
}
export const checkUrl = (url) => {
export const checkUrl = (url: string) => {
try {
new URL(url)
return url

View File

@ -1,18 +1,18 @@
import { getPagePath } from '@nanostores/router'
import { clsx } from 'clsx'
import { Show, createSignal } from 'solid-js'
import { useEditorHTML } from 'solid-tiptap'
import Typograf from 'typograf'
import { useUI } from '~/context/ui'
import { useEditorContext } from '../../../context/editor'
import { useLocalize } from '../../../context/localize'
import { router } from '../../../stores/router'
import { showModal } from '../../../stores/ui'
import { useEscKeyDownHandler } from '../../../utils/useEscKeyDownHandler'
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
import { Button } from '../../_shared/Button'
import { DarkModeToggle } from '../../_shared/DarkModeToggle'
import { Icon } from '../../_shared/Icon'
import { A } from '@solidjs/router'
import styles from './Panel.module.scss'
const typograf = new Typograf({ locale: ['ru', 'en-US'] })
@ -23,10 +23,11 @@ type Props = {
export const Panel = (props: Props) => {
const { t } = useLocalize()
const { showModal } = useUI()
const {
isEditorPanelVisible,
wordCounter,
editorRef,
editor,
form,
toggleEditorPanel,
saveShout,
@ -34,7 +35,7 @@ export const Panel = (props: Props) => {
publishShout,
} = useEditorContext()
const containerRef: { current: HTMLElement } = { current: null }
let containerRef: HTMLElement | undefined
const [isShortcutsVisible, setIsShortcutsVisible] = 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 = () => {
editorRef.current().commands.setContent(typograf.execute(html()))
editor()?.commands.setContent(typograf.execute(html() || '')) // here too
setIsTypographyFixed(true)
}
return (
<aside
ref={(el) => (containerRef.current = el)}
ref={(el) => (containerRef = el)}
class={clsx('col-md-6', styles.Panel, { [styles.hidden]: !isEditorPanelVisible() })}
>
<Button
@ -98,13 +99,13 @@ export const Panel = (props: Props) => {
</span>
</p>
<p>
<a
<A
class={styles.link}
onClick={() => toggleEditorPanel()}
href={getPagePath(router, 'editSettings', { shoutId: props.shoutId.toString() })}
href={`/edit/${props.shoutId}/settings`}
>
{t('Publication settings')}
</a>
</A>
</p>
<p>
<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 { Text } from '@tiptap/extension-text'
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 {
createEditorTransaction,
@ -20,23 +20,23 @@ import {
useEditorIsFocused,
} from 'solid-tiptap'
import { UploadedFile } from '~/types/upload'
import { useEditorContext } from '../../context/editor'
import { useLocalize } from '../../context/localize'
import { UploadedFile } from '../../pages/types'
import { hideModal, showModal } from '../../stores/ui'
import { Modal } from '../Nav/Modal'
import { Button } from '../_shared/Button'
import { Icon } from '../_shared/Icon'
import { Loading } from '../_shared/Loading'
import { Popover } from '../_shared/Popover'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
import { LinkBubbleMenuModule } from './LinkBubbleMenu'
import { TextBubbleMenu } from './TextBubbleMenu'
import { UploadModalContent } from './UploadModalContent'
import { Figcaption } from './extensions/Figcaption'
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'
type Props = {
@ -68,46 +68,30 @@ const DEFAULT_MAX_LENGTH = 400
const SimplifiedEditor = (props: Props) => {
const { t } = useLocalize()
const [counter, setCounter] = createSignal<number>()
const { showModal, hideModal } = useUI()
const [counter, setCounter] = createSignal<number>(0)
const [shouldShowLinkBubbleMenu, setShouldShowLinkBubbleMenu] = createSignal(false)
const isCancelButtonVisible = createMemo(() => props.isCancelButtonVisible !== false)
const [editorElement, setEditorElement] = createSignal<HTMLDivElement>()
const { editor, setEditor } = useEditorContext()
const maxLength = props.maxLength ?? DEFAULT_MAX_LENGTH
const wrapperEditorElRef: {
current: HTMLElement
} = {
current: null,
}
const editorElRef: {
current: HTMLElement
} = {
current: null,
}
const textBubbleMenuRef: {
current: HTMLDivElement
} = {
current: null,
}
const linkBubbleMenuRef: {
current: HTMLDivElement
} = {
current: null,
}
const { setEditor } = useEditorContext()
let wrapperEditorElRef: HTMLElement | undefined
let textBubbleMenuRef: HTMLDivElement | undefined
let linkBubbleMenuRef: HTMLDivElement | undefined
const ImageFigure = Figure.extend({
name: 'capturedImage',
content: 'figcaption image',
})
const content = props.initialContent
const editor = createTiptapEditor(() => ({
element: editorElRef.current,
createEffect(
on(
() => editorElement(),
(ee: HTMLDivElement | undefined) => {
if (ee && textBubbleMenuRef && linkBubbleMenuRef) {
const freshEditor = createTiptapEditor<HTMLElement>(() => ({
element: ee,
editorProps: {
attributes: {
class: styles.simplifiedEditorField,
@ -135,9 +119,9 @@ const SimplifiedEditor = (props: Props) => {
}),
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef.current,
element: textBubbleMenuRef,
shouldShow: ({ view, state }) => {
if (!props.onlyBubbleControls) return
if (!props.onlyBubbleControls) return false
const { selection } = state
const { empty } = selection
return view.hasFocus() && !empty
@ -145,7 +129,7 @@ const SimplifiedEditor = (props: Props) => {
}),
BubbleMenu.configure({
pluginKey: 'linkBubbleMenu',
element: linkBubbleMenuRef.current,
element: linkBubbleMenuRef,
shouldShow: ({ state }) => {
const { selection } = state
const { empty } = selection
@ -164,10 +148,17 @@ const SimplifiedEditor = (props: Props) => {
}),
],
autofocus: props.autoFocus,
content: content ?? null,
content: props.initialContent || null,
}))
const editorInstance = freshEditor()
if (!editorInstance) return
setEditor(editorInstance)
}
},
{ defer: true },
),
)
setEditor(editor)
const isEmpty = useEditorIsEmpty(() => editor())
const isFocused = useEditorIsFocused(() => editor())
@ -187,7 +178,7 @@ const SimplifiedEditor = (props: Props) => {
const renderImage = (image: UploadedFile) => {
editor()
.chain()
?.chain()
.focus()
.insertContent({
type: 'figure',
@ -211,20 +202,20 @@ const SimplifiedEditor = (props: Props) => {
if (props.onCancel) {
props.onCancel()
}
editor().commands.clearContent(true)
editor()?.commands.clearContent(true)
}
createEffect(() => {
if (props.setClear) {
editor().commands.clearContent(true)
editor()?.commands.clearContent(true)
}
if (props.resetToInitial) {
editor().commands.clearContent(true)
editor().commands.setContent(props.initialContent)
editor()?.commands.clearContent(true)
if (props.initialContent) editor()?.commands.setContent(props.initialContent)
}
})
const handleKeyDown = (event) => {
const handleKeyDown = (event: KeyboardEvent) => {
if (isEmpty() || !isFocused()) {
return
}
@ -235,7 +226,7 @@ const SimplifiedEditor = (props: Props) => {
if (event.code === 'Enter' && props.submitByCtrlEnter && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
props.onSubmit(html())
props.onSubmit?.(html() || '')
handleClear()
}
@ -256,13 +247,13 @@ const SimplifiedEditor = (props: Props) => {
if (props.onChange) {
createEffect(() => {
props.onChange(html())
props.onChange?.(html() || '')
})
}
createEffect(() => {
if (html()) {
setCounter(editor().storage.characterCount.characters())
setCounter(editor()?.storage.characterCount.characters())
}
})
@ -272,19 +263,19 @@ const SimplifiedEditor = (props: Props) => {
}
const handleShowLinkBubble = () => {
editor().chain().focus().run()
editor()?.chain().focus().run()
setShouldShowLinkBubbleMenu(true)
}
const handleHideLinkBubble = () => {
editor().commands.focus()
editor()?.commands.focus()
setShouldShowLinkBubbleMenu(false)
}
return (
<ShowOnlyOnClient>
<div
ref={(el) => (wrapperEditorElRef.current = el)}
ref={(el) => (wrapperEditorElRef = el)}
class={clsx(styles.SimplifiedEditor, {
[styles.smallHeight]: props.smallHeight,
[styles.minimal]: props.variant === 'minimal',
@ -299,17 +290,17 @@ const SimplifiedEditor = (props: Props) => {
<Show when={props.label && counter() > 0}>
<div class={styles.label}>{props.label}</div>
</Show>
<div style={props.maxHeight && maxHeightStyle} ref={(el) => (editorElRef.current = el)} />
<div style={props.maxHeight ? maxHeightStyle : undefined} ref={setEditorElement} />
<Show when={!props.onlyBubbleControls}>
<div class={clsx(styles.controls, { [styles.alwaysVisible]: props.controlsAlwaysVisible })}>
<div class={styles.actions}>
<Popover content={t('Bold')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.actionButton, { [styles.active]: isBold() })}
onClick={() => editor().chain().focus().toggleBold().run()}
onClick={() => editor()?.chain().focus().toggleBold().run()}
>
<Icon name="editor-bold" />
</button>
@ -321,7 +312,7 @@ const SimplifiedEditor = (props: Props) => {
ref={triggerRef}
type="button"
class={clsx(styles.actionButton, { [styles.active]: isItalic() })}
onClick={() => editor().chain().focus().toggleItalic().run()}
onClick={() => editor()?.chain().focus().toggleItalic().run()}
>
<Icon name="editor-italic" />
</button>
@ -345,7 +336,7 @@ const SimplifiedEditor = (props: Props) => {
<button
ref={triggerRef}
type="button"
onClick={() => editor().chain().focus().toggleBlockquote().run()}
onClick={() => editor()?.chain().focus().toggleBlockquote().run()}
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
>
<Icon name="editor-quote" />
@ -378,7 +369,7 @@ const SimplifiedEditor = (props: Props) => {
value={props.submitButtonText ?? t('Send')}
variant="primary"
disabled={isEmpty()}
onClick={() => props.onSubmit(html())}
onClick={() => props.onSubmit?.(html() || '')}
/>
</Show>
</div>
@ -390,7 +381,7 @@ const SimplifiedEditor = (props: Props) => {
<Modal variant="narrow" name="simplifiedEditorUploadImage">
<UploadModalContent
onClose={(value) => {
renderImage(value)
renderImage(value as UploadedFile)
}}
/>
</Modal>
@ -400,13 +391,13 @@ const SimplifiedEditor = (props: Props) => {
<TextBubbleMenu
shouldShow={true}
isCommonMarkup={true}
editor={editor()}
ref={(el) => (textBubbleMenuRef.current = el)}
editor={editor() as Editor}
ref={(el) => (textBubbleMenuRef = el)}
/>
</Show>
<LinkBubbleMenuModule
editor={editor()}
ref={(el) => (linkBubbleMenuRef.current = el)}
editor={editor() as Editor}
ref={(el) => (linkBubbleMenuRef = el)}
onClose={handleHideLinkBubble}
/>
</div>

View File

@ -23,7 +23,7 @@ type BubbleMenuProps = {
export const TextBubbleMenu = (props: BubbleMenuProps) => {
const { t } = useLocalize()
const isActive = (name: string, attributes?: unknown) =>
const isActive = (name: string, attributes?: Record<string, string | number>) =>
createEditorTransaction(
() => props.editor,
(editor) => editor?.isActive(name, attributes),
@ -71,7 +71,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
}
setListBubbleOpen((prev) => !prev)
}
const handleKeyDown = (event) => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code === 'KeyK' && (event.metaKey || event.ctrlKey) && !props.editor.state.selection.empty) {
event.preventDefault()
setLinkEditorOpen(true)
@ -89,9 +89,9 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
},
)
const handleAddFootnote = (footnote) => {
const handleAddFootnote = (footnote: string) => {
if (footNote()) {
props.editor.chain().focus().updateFootnote(footnote).run()
props.editor.chain().focus().updateFootnote({ value: footnote }).run()
} else {
props.editor.chain().focus().setFootnote({ value: footnote }).run()
}
@ -180,7 +180,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
<header>{t('Headers')}</header>
<div class={styles.actions}>
<Popover content={t('Header 1')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
@ -197,7 +197,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
)}
</Popover>
<Popover content={t('Header 2')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
@ -214,7 +214,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
)}
</Popover>
<Popover content={t('Header 3')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
@ -234,7 +234,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
<header>{t('Quotes')}</header>
<div class={styles.actions}>
<Popover content={t('Quote')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
@ -248,7 +248,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
)}
</Popover>
<Popover content={t('Punchline')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
@ -265,7 +265,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
<header>{t('squib')}</header>
<div class={styles.actions}>
<Popover content={t('Incut')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
@ -289,7 +289,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
</>
</Show>
<Popover content={t('Bold')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
@ -303,7 +303,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
)}
</Popover>
<Popover content={t('Italic')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
@ -319,7 +319,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
<Show when={!props.isCommonMarkup}>
<Popover content={t('Highlight')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
@ -335,7 +335,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
<div class={styles.delimiter} />
</Show>
<Popover content={<div class={styles.noWrap}>{t('Add url')}</div>}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
@ -351,7 +351,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
<Show when={!props.isCommonMarkup}>
<>
<Popover content={t('Insert footnote')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
@ -381,7 +381,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
<header>{t('Lists')}</header>
<div class={styles.actions}>
<Popover content={t('Bullet list')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
@ -398,7 +398,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
)}
</Popover>
<Popover content={t('Ordered list')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
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 { 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 { clone } from '../../../utils/clone'
import { slugify } from '../../../utils/slugify'
import '@thisbeyond/solid-select/style.css'
import './TopicSelect.scss'
import styles from './TopicSelect.module.scss'
type TopicSelectProps = {
@ -23,65 +14,80 @@ type TopicSelectProps = {
export const TopicSelect = (props: TopicSelectProps) => {
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 => {
const minId = Math.min(...props.selectedTopics.map((topic) => topic.id))
const id = minId < 0 ? minId - 1 : -2
return { id, title, slug: slugify(title) }
if (isSelected) {
newSelectedTopics = props.selectedTopics.filter((selectedTopic) => selectedTopic.slug !== topic.slug)
} else {
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)
props.onChange(newSelectedTopics)
}
const handleSelectedItemClick = (topic: Topic) => {
setIsDisabled(true)
const handleMainTopicChange = (topic: Topic) => {
props.onMainTopicChange(topic)
setIsDisabled(false)
setIsOpen(false)
}
const format = (item, type) => {
if (type === 'option') {
// eslint-disable-next-line solid/components-return-once
return item.label
const handleSearch = (event: InputEvent) => {
setSearchTerm((event.currentTarget as HTMLInputElement).value)
}
const isMainTopic = item.id === props.mainTopic?.id
const filteredTopics = () => {
return props.topics.filter((topic: Topic) =>
topic?.title?.toLowerCase().includes(searchTerm().toLowerCase()),
)
}
return (
<div class="TopicSelect">
<div class={styles.selectedTopics}>
<For each={props.selectedTopics}>
{(topic) => (
<div
class={clsx(styles.selectedItem, {
[styles.mainTopic]: isMainTopic,
class={clsx(styles.selectedTopic, {
[styles.mainTopic]: props.mainTopic?.slug === topic.slug,
})}
onClick={() => handleSelectedItemClick(item)}
onClick={() => handleMainTopicChange(topic)}
>
{item.title}
{topic.title}
</div>
)}
</For>
</div>
<div class={styles.selectWrapper} onClick={() => setIsOpen(true)}>
<input
type="text"
placeholder={t('Topics')}
class={styles.searchInput}
value={searchTerm()}
onInput={handleSearch}
/>
<Show when={isOpen()}>
<div class={styles.options}>
<For each={filteredTopics()}>
{(topic) => (
<div
class={clsx(styles.option, {
[styles.disabled]: props.selectedTopics.some(
(selectedTopic) => selectedTopic.slug === topic.slug,
),
})}
onClick={() => handleChange(topic)}
>
{topic.title}
</div>
)}
</For>
</div>
</Show>
</div>
</div>
)
}
const initialValue = clone(props.selectedTopics)
return (
<Select
multiple={true}
disabled={isDisabled()}
initialValue={initialValue}
{...selectProps}
format={format}
placeholder={t('Topics')}
class="TopicSelect"
onChange={handleChange}
/>
)
}

View File

@ -2,9 +2,10 @@ import { UploadFile, createDropzone, createFileUploader } from '@solid-primitive
import { clsx } from 'clsx'
import { Show, createSignal } from 'solid-js'
import { useUI } from '~/context/ui'
import { UploadedFile } from '~/types/upload'
import { useLocalize } from '../../../context/localize'
import { UploadedFile } from '../../../pages/types'
import { hideModal } from '../../../stores/ui'
import { useSession } from '../../../context/session'
import { handleImageUpload } from '../../../utils/handleImageUpload'
import { verifyImg } from '../../../utils/verifyImg'
import { Button } from '../../_shared/Button'
@ -12,7 +13,6 @@ import { Icon } from '../../_shared/Icon'
import { Loading } from '../../_shared/Loading'
import { InlineForm } from '../InlineForm'
import { useSession } from '../../../context/session'
import styles from './UploadModalContent.module.scss'
type Props = {
@ -21,6 +21,7 @@ type Props = {
export const UploadModalContent = (props: Props) => {
const { t } = useLocalize()
const { hideModal } = useUI()
const [isUploading, setIsUploading] = createSignal(false)
const [uploadError, setUploadError] = createSignal<string | undefined>()
const [dragActive, setDragActive] = createSignal(false)
@ -30,7 +31,7 @@ export const UploadModalContent = (props: Props) => {
const runUpload = async (file: UploadFile) => {
try {
setIsUploading(true)
const result = await handleImageUpload(file, session()?.access_token)
const result = await handleImageUpload(file, session()?.access_token || '')
props.onClose(result)
setIsUploading(false)
} catch (error) {
@ -44,7 +45,9 @@ export const UploadModalContent = (props: Props) => {
try {
const data = await fetch(value)
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 = {
source: blob.toString(),
name: file.name,

View File

@ -1,15 +1,14 @@
import type { MediaItem } from '../../../pages/types'
import { createDropzone } from '@solid-primitives/upload'
import { clsx } from 'clsx'
import { For, Show, createSignal } from 'solid-js'
import { useSnackbar } from '~/context/ui'
import { useLocalize } from '../../../context/localize'
import { useSnackbar } from '../../../context/snackbar'
import { composeMediaItems } from '../../../utils/composeMediaItems'
import { validateUrl } from '../../../utils/validateUrl'
import { VideoPlayer } from '../../_shared/VideoPlayer'
import { MediaItem } from '~/types/mediaitem'
import styles from './VideoUploader.module.scss'
type Props = {
@ -23,14 +22,8 @@ export const VideoUploader = (props: Props) => {
const [dragActive, setDragActive] = createSignal(false)
const [error, setError] = createSignal<string>()
const [incorrectUrl, setIncorrectUrl] = createSignal<boolean>(false)
const { showSnackbar } = useSnackbar()
const urlInput: {
current: HTMLInputElement
} = {
current: null,
}
let urlInput: HTMLInputElement | undefined
const { setRef: dropzoneRef, files: droppedFiles } = createDropzone({
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') {
setDragActive(true)
setError()
@ -100,7 +93,7 @@ export const VideoUploader = (props: Props) => {
<div class={styles.inputHolder}>
<input
class={clsx(styles.urlInput, { [styles.hasError]: incorrectUrl() })}
ref={(el) => (urlInput.current = el)}
ref={(el) => (urlInput = el)}
type="text"
placeholder={t('Insert video link')}
onChange={(event) => handleUrlInput(event.currentTarget.value)}

View File

@ -27,7 +27,7 @@ export const Figure = Node.create({
'data-type': { default: null },
}
},
// @ts-ignore FIXME: why
parseHTML() {
return [
{

View File

@ -39,7 +39,7 @@ export const ToggleTextWrap = Extension.create({
})
if (changesApplied) {
dispatch(tr)
dispatch?.(tr)
return true
}
return false

View File

@ -1,7 +1,15 @@
import { Extension } from '@tiptap/core'
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
}

View File

@ -1,4 +1,4 @@
export { Editor } from './Editor'
export { EditorComponent as Editor } from './Editor'
export { Panel } from './Panel'
export { TopicSelect } from './TopicSelect'
export { UploadModalContent } from './UploadModalContent'

View File

@ -1,25 +1,21 @@
import type { Author, Shout, Topic } from '../../../graphql/schema/core.gen'
import { getPagePath, openPage } from '@nanostores/router'
import { A, useNavigate, useSearchParams } from '@solidjs/router'
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 { 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 { getDescription } from '../../../utils/meta'
import { CoverImage } from '../../Article/CoverImage'
import { SharePopup, getShareUrl } from '../../Article/SharePopup'
import { ShoutRatingControl } from '../../Article/ShoutRatingControl'
import { AuthorLink } from '../../Author/AuthorLink'
import stylesHeader from '../../Nav/Header/Header.module.scss'
import { Icon } from '../../_shared/Icon'
import { Image } from '../../_shared/Image'
import { Popover } from '../../_shared/Popover'
import { CardTopic } from '../CardTopic'
import { FeedArticlePopup } from '../FeedArticlePopup'
import stylesHeader from '../../Nav/Header/Header.module.scss'
import styles from './ArticleCard.module.scss'
export type ArticleCardProps = {
@ -47,13 +43,13 @@ export type ArticleCardProps = {
noAuthorLink?: boolean
}
withAspectRatio?: boolean
desktopCoverSize?: 'XS' | 'S' | 'M' | 'L'
desktopCoverSize?: string // 'XS' | 'S' | 'M' | 'L'
article: Shout
onShare?: (article: Shout) => void
onInvite?: () => void
}
const desktopCoverImageWidths: Record<ArticleCardProps['desktopCoverSize'], number> = {
const desktopCoverImageWidths: Record<string, number> = {
XS: 300,
S: 400,
M: 600,
@ -90,14 +86,14 @@ const getTitleAndSubtitle = (
const getMainTopicTitle = (article: Shout, lng: string) => {
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 =
mainTopicSlug && lng === 'en' ? mainTopicSlug.replace(/-/, ' ') : mainTopic?.title || ''
return [mainTopicTitle, mainTopicSlug]
}
const LAYOUT_ASPECT = {
const LAYOUT_ASPECT: { [key: string]: string } = {
music: styles.aspectRatio1x1,
audio: styles.aspectRatio1x1,
literature: styles.aspectRatio16x9,
@ -107,13 +103,14 @@ const LAYOUT_ASPECT = {
export const ArticleCard = (props: ArticleCardProps) => {
const { t, lang, formatDate } = useLocalize()
const { author, session } = useSession()
const { changeSearchParams } = useRouter()
const { session } = useSession()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
const [, changeSearchParams] = useSearchParams()
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
const [isCoverImageLoadError, setIsCoverImageLoadError] = createSignal(false)
const [isCoverImageLoading, setIsCoverImageLoading] = createSignal(true)
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 { title, subtitle } = getTitleAndSubtitle(props.article)
@ -126,17 +123,21 @@ export const ArticleCard = (props: ArticleCardProps) => {
Boolean(author()?.id) &&
(props.article?.authors?.some((a) => Boolean(a) && a?.id === author().id) ||
props.article?.created_by?.id === author().id ||
session()?.user?.roles.includes('editor')),
session()?.user?.roles?.includes('editor')),
)
const scrollToComments = (event) => {
const navigate = useNavigate()
const scrollToComments = (event: MouseEvent & { currentTarget: HTMLAnchorElement; target: Element }) => {
event.preventDefault()
openPage(router, 'article', { slug: props.article.slug })
navigate(`/article/${props.article.slug}`)
changeSearchParams({
scrollTo: 'comments',
})
}
const onInvite = () => {
if (props.onInvite) props.onInvite()
}
return (
<section
class={clsx(styles.shoutCard, props.settings?.additionalClass, {
@ -169,9 +170,9 @@ export const ArticleCard = (props: ArticleCardProps) => {
fallback={<CoverImage class={styles.placeholderCoverImage} />}
>
<Image
src={props.article.cover}
src={props.article.cover || ''}
alt={title}
width={desktopCoverImageWidths[props.desktopCoverSize]}
width={desktopCoverImageWidths[props.desktopCoverSize || 'M']}
onError={() => {
setIsCoverImageLoadError(true)
setIsCoverImageLoading(false)
@ -209,7 +210,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
slug={mainTopicSlug}
isFloorImportant={props.settings?.isFloorImportant}
isFeedMode={true}
class={clsx(styles.shoutTopic, { [styles.shoutTopicTop]: props.settings.isShort })}
class={clsx(styles.shoutTopic, { [styles.shoutTopicTop]: props.settings?.isShort })}
/>
</Show>
@ -219,7 +220,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
[styles.shoutCardTitlesContainerFeedMode]: props.settings?.isFeedMode,
})}
>
<a href={getPagePath(router, 'article', { slug: props.article.slug })}>
<A href={`/article${props.article.slug}`}>
<div class={styles.shoutCardTitle}>
<span class={styles.shoutCardLinkWrapper}>
<span class={styles.shoutCardLinkContainer} innerHTML={title} />
@ -231,7 +232,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
<span class={styles.shoutCardLinkContainer} innerHTML={subtitle} />
</div>
</Show>
</a>
</A>
</div>
{/* Details */}
@ -243,11 +244,13 @@ export const ArticleCard = (props: ArticleCardProps) => {
<Show when={!props.settings?.noauthor}>
<div class={styles.shoutAuthor}>
<For each={props.article.authors}>
{(a: Author) => (
{(a: Maybe<Author>) => (
<AuthorLink
size={'XS'}
author={a}
isFloorImportant={props.settings.isFloorImportant || props.settings?.isWithCover}
author={a as Author}
isFloorImportant={Boolean(
props.settings?.isFloorImportant || props.settings?.isWithCover,
)}
/>
)}
</For>
@ -261,11 +264,11 @@ export const ArticleCard = (props: ArticleCardProps) => {
{/* Description */}
<Show when={props.article.description}>
<section class={styles.shoutCardDescription} innerHTML={props.article.description} />
<section class={styles.shoutCardDescription} innerHTML={props.article.description || ''} />
</Show>
<Show when={props.settings?.isFeedMode}>
<Show when={props.article.description}>
<section class={styles.shoutCardDescription} innerHTML={props.article.description} />
<section class={styles.shoutCardDescription} innerHTML={props.article.description || ''} />
</Show>
<Show when={!props.settings?.noimage && props.article.cover}>
<div class={styles.shoutCardCoverContainer}>
@ -284,7 +287,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
</div>
</Show>
<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>
</Show>
@ -327,22 +330,22 @@ export const ArticleCard = (props: ArticleCardProps) => {
<div class={styles.shoutCardDetailsContent}>
<Show when={canEdit()}>
<Popover content={t('Edit')} disabled={isActionPopupActive()}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<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-hover"
class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)}
/>
</a>
</A>
</div>
)}
</Popover>
</Show>
<Popover content={t('Add to bookmarks')} disabled={isActionPopupActive()}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
<button>
<Icon name="bookmark" class={clsx(styles.icon, styles.feedControlIcon)} />
@ -356,13 +359,13 @@ export const ArticleCard = (props: ArticleCardProps) => {
</Popover>
<Popover content={t('Share')} disabled={isActionPopupActive()}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
<SharePopup
containerCssClass={stylesHeader.control}
title={title}
description={description}
imageUrl={props.article.cover}
imageUrl={props.article.cover || ''}
shareUrl={getShareUrl({ pathname: `/${props.article.slug}` })}
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
trigger={
@ -381,10 +384,10 @@ export const ArticleCard = (props: ArticleCardProps) => {
<div class={styles.shoutCardDetailsItem}>
<FeedArticlePopup
canEdit={canEdit()}
canEdit={Boolean(canEdit())}
containerCssClass={stylesHeader.control}
onShareClick={() => props.onShare(props.article)}
onInviteClick={props.onInvite}
onShareClick={() => props.onShare?.(props.article)}
onInviteClick={onInvite}
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
trigger={
<button>

View File

@ -39,7 +39,7 @@ export const Beside = (props: Props) => {
class={clsx(
'col-lg-8',
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 { router } from '../../stores/router'
import styles from './CardTopic.module.scss'
type CardTopicProps = {
@ -21,7 +18,7 @@ export const CardTopic = (props: CardTopicProps) => {
[styles.shoutTopicFeedMode]: props.isFeedMode,
})}
>
<a href={getPagePath(router, 'topic', { slug: props.slug })}>{props.title}</a>
<A href={`/topic/${props.slug}`}>{props.title}</A>
</div>
)
}

View File

@ -1,98 +1,120 @@
import { clsx } from 'clsx'
import { For, Show } from 'solid-js'
import { For, Show, createMemo } from 'solid-js'
import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session'
import { Icon } from '../../_shared/Icon'
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 = {
type: string
type: keyof PlaceholderData
mode: 'feed' | 'profile'
}
export const Placeholder = (props: PlaceholderProps) => {
const { t } = useLocalize()
const { author } = useSession()
const data = {
const data: PlaceholderData = {
feedMy: {
image: 'placeholder-feed.webp',
header: t('Feed settings'),
text: t('Placeholder feed'),
buttonLabel: author() ? t('Popular authors') : t('Create own feed'),
header: 'Feed settings',
text: 'Placeholder feed',
buttonLabelAuthor: 'Popular authors',
buttonLabelFeed: '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'),
header: 'Find collaborators',
text: 'Placeholder feedCollaborations',
buttonLabel: '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'),
header: 'Participate in discussions',
text: 'Placeholder feedDiscussions',
buttonLabelAuthor: 'Current discussions',
buttonLabelFeed: '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'),
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: t('How to write a good article'),
label: 'How to write a good article',
},
],
},
authorComments: {
image: 'placeholder-discussions.webp',
header: t('Join discussions'),
text: t('Placeholder feedDiscussions'),
buttonLabel: t('Go to discussions'),
header: 'Join discussions',
text: 'Placeholder feedDiscussions',
buttonLabel: 'Go to discussions',
href: '/feed?by=last_comment',
profileLinks: [
{
href: '/about/discussion-rules',
label: t('Discussion rules'),
label: 'Discussion rules',
},
{
href: '/about/discussion-rules#ban',
label: t('Block rules'),
label: 'Block rules',
},
],
},
}
export const Placeholder = (props: PlaceholderProps) => {
const { t } = useLocalize()
const { session } = useSession()
const placeholderData = createMemo(() => data[props.type])
return (
<div
class={clsx(
styles.placeholder,
styles[`placeholder--${props.type}`],
styles[`placeholder--${props.mode}-mode`],
styles[`placeholder--${props.type}` as keyof typeof styles],
styles[`placeholder--${props.mode}-mode` as keyof typeof styles],
)}
>
<div class={styles.placeholderCover}>
<img src={`/${data[props.type].image}`} />
<img src={`/${placeholderData().image}`} alt={placeholderData().header} />
</div>
<div class={styles.placeholderContent}>
<div>
<h3 innerHTML={data[props.type].header} />
<p innerHTML={data[props.type].text} />
<h3 innerHTML={t(placeholderData().header)} />
<p innerHTML={t(placeholderData().text)} />
</div>
<Show when={data[props.type].profileLinks}>
<Show when={placeholderData().profileLinks}>
<div class={styles.bottomLinks}>
<For each={data[props.type].profileLinks}>
<For each={placeholderData().profileLinks}>
{(link) => (
<a href={link.href}>
<Icon name="link-white" class={styles.icon} />
{link.label}
{t(link.label)}
</a>
)}
</For>
@ -100,15 +122,23 @@ export const Placeholder = (props: PlaceholderProps) => {
</Show>
<Show
when={author()}
when={session()?.access_token}
fallback={
<a class={styles.button} href="?m=auth&mode=login">
{data[props.type].buttonLabel}
{t(
session()?.access_token
? placeholderData()?.buttonLabelAuthor || ''
: placeholderData()?.buttonLabelFeed || '',
)}
</a>
}
>
<a class={styles.button} href={data[props.type].href}>
{data[props.type].buttonLabel}
<a class={styles.button} href={placeholderData().href}>
{t(
session()?.access_token
? placeholderData()?.buttonLabelAuthor || ''
: placeholderData()?.buttonLabelFeed || '',
)}
<Show when={props.mode === 'profile'}>
<Icon name="arrow-right-2" class={styles.icon} />
</Show>

View File

@ -1,80 +1,79 @@
import { getPagePath } from '@nanostores/router'
import { clsx } from 'clsx'
import { For, Show, createSignal } from 'solid-js'
import { A, useMatch } from '@solidjs/router'
import { useFeed } from '~/context/feed'
import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize'
import { useSeen } from '../../../context/seen'
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 { Icon } from '../../_shared/Icon'
import styles from './Sidebar.module.scss'
export const Sidebar = () => {
const { t } = useLocalize()
const { seen } = useSeen()
const { follows } = useFollowing()
const { page } = useRouter()
const { articlesByTopic, articlesByAuthor } = useArticlesStore()
const { feedByTopic, feedByAuthor, seen } = useFeed()
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) => {
return articlesByTopic()[topicSlug]?.every((article) => Boolean(seen()[article.slug]))
return feedByTopic()[topicSlug]?.every((article) => Boolean(seen()[article.slug]))
}
const checkAuthorIsSeen = (authorSlug: string) => {
return articlesByAuthor()[authorSlug]?.every((article) => Boolean(seen()[article.slug]))
return feedByAuthor()[authorSlug]?.every((article) => Boolean(seen()[article.slug]))
}
return (
<div class={styles.sidebar}>
<ul class={styles.feedFilters}>
<li>
<a
href={getPagePath(router, 'feed')}
<A
href={'feed'}
class={clsx({
[styles.selected]: page().route === 'feed',
[styles.selected]: matchFeed(),
})}
>
<span class={styles.sidebarItemName}>
<Icon name="feed-all" class={styles.icon} />
{t('Common feed')}
</span>
</a>
</A>
</li>
<li>
<a
href={getPagePath(router, 'feedMy')}
<A
href={'/feed/my'}
class={clsx({
[styles.selected]: page().route === 'feedMy',
[styles.selected]: matchFeedMy(),
})}
>
<span class={styles.sidebarItemName}>
<Icon name="feed-my" class={styles.icon} />
{t('My feed')}
</span>
</a>
</A>
</li>
<li>
<a
href={getPagePath(router, 'feedCollaborations')}
<A
href={'/feed/collabs'}
class={clsx({
[styles.selected]: page().route === 'feedCollaborations',
[styles.selected]: matchFeedCollabs(),
})}
>
<span class={styles.sidebarItemName}>
<Icon name="feed-collaborate" class={styles.icon} />
{t('Participation')}
</span>
</a>
</A>
</li>
<li>
<a
href={getPagePath(router, 'feedDiscussions')}
href={'/feed/discussions'}
class={clsx({
[styles.selected]: page().route === 'feedDiscussions',
[styles.selected]: matchFeedDiscussions(),
})}
>
<span class={styles.sidebarItemName}>
@ -85,7 +84,7 @@ export const Sidebar = () => {
</li>
</ul>
<Show when={follows?.authors?.length > 0 || follows?.topics?.length > 0}>
<Show when={(follows?.authors?.length || 0) > 0 || (follows?.topics?.length || 0) > 0}>
<h4
classList={{ [styles.opened]: isSubscriptionsVisible() }}
onClick={() => {
@ -102,7 +101,7 @@ export const Sidebar = () => {
<li>
<a href={`/author/${a.slug}`} classList={{ [styles.unread]: checkAuthorIsSeen(a.slug) }}>
<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>
</a>

View File

@ -1,11 +1,9 @@
import type { Author } from '../../graphql/schema/core.gen'
import { For, createEffect, createSignal } from 'solid-js'
import { useUI } from '~/context/ui'
import { useInbox } from '../../context/inbox'
import { useLocalize } from '../../context/localize'
import { hideModal } from '../../stores/ui'
import type { Author } from '../../graphql/schema/core.gen'
import InviteUser from './InviteUser'
import styles from './CreateModalContent.module.scss'
@ -17,6 +15,7 @@ type Props = {
const CreateModalContent = (props: Props) => {
const { t } = useLocalize()
const { hideModal } = useUI()
const inviteUsers: inviteUser[] = props.users.map((user) => ({ ...user, selected: false }))
const [chatTitle, setChatTitle] = createSignal<string>('')
const [usersId, setUsersId] = createSignal<number[]>([])
@ -45,10 +44,10 @@ const CreateModalContent = (props: Props) => {
})
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) => {
return userCollection.map((clickedUser) =>
user.id === clickedUser.id ? { ...clickedUser, selected: !clickedUser.selected } : clickedUser,
@ -72,7 +71,7 @@ const CreateModalContent = (props: Props) => {
<h4>{t('Create Chat')}</h4>
{usersId().length > 1 && (
<input
ref={textInput}
ref={(el) => (textInput = el)}
onInput={handleSetTheme}
type="text"
required={true}

View File

@ -31,7 +31,11 @@ const colors = [
]
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 nameFirstLetter = createMemo(() => props.name.slice(0, 1))
@ -54,8 +58,8 @@ const DialogAvatar = (props: Props) => {
style={{
'background-image': `url(
${
props.url.includes('discours.io')
? getImageUrl(props.url, { width: 40, height: 40 })
props.url?.includes('discours.io')
? getImageUrl(props.url || '', { width: 40, height: 40 })
: props.url
}
)`,

View File

@ -47,7 +47,7 @@ const DialogCard = (props: DialogProps) => {
when={props.isChatHeader}
fallback={
<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>
}
>
@ -78,9 +78,11 @@ const DialogCard = (props: DialogProps) => {
</div>
<div class={styles.activity}>
<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 when={props.counter > 0}>
<Show when={(props.counter || 0) > 0}>
<div class={styles.counter}>
<span>{props.counter}</span>
</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'
@ -11,7 +11,7 @@ type DialogHeader = {
const DialogHeader = (props: DialogHeader) => {
return (
<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>
)
}

View File

@ -29,7 +29,7 @@ const GroupDialogAvatar = (props: Props) => {
bordered={true}
size="small"
name={user.name}
url={user.pic}
url={user.pic || ''}
/>
)}
</For>

View File

@ -15,7 +15,7 @@ type DialogProps = {
const InviteUser = (props: DialogProps) => {
return (
<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.action}>{props.selected ? <Icon name="cross" /> : <Icon name="plus" />}</div>
</div>

View File

@ -29,10 +29,10 @@ export const Message = (props: Props) => {
return (
<div class={clsx(styles.Message, isOwn && styles.own)}>
<Show when={!isOwn && user}>
<Show when={!isOwn && user?.name}>
<div class={styles.author}>
<DialogAvatar size="small" name={user.name} url={user.pic} />
<div class={styles.name}>{user.name}</div>
<DialogAvatar size="small" name={user?.name || ''} url={user?.pic || ''} />
<div class={styles.name}>{user?.name}</div>
</div>
</Show>
<div class={clsx(styles.body, { [styles.popupVisible]: isPopupVisible() })}>
@ -47,7 +47,7 @@ export const Message = (props: Props) => {
/>
</div>
<Show when={props.replyBody}>
<QuotedMessage body={props.replyBody} variant="inline" isOwn={isOwn} />
<QuotedMessage body={props.replyBody || ''} variant="inline" isOwn={isOwn} />
</Show>
<div innerHTML={props.content.body} />
</div>

View File

@ -8,7 +8,7 @@ import { Popup } from '../_shared/Popup'
export type MessageActionType = 'reply' | 'copy' | 'pin' | 'forward' | 'select' | 'delete'
type MessageActionsPopupProps = {
actionSelect?: (selectedAction) => void
actionSelect?: (selectedAction: MessageActionType) => void
} & Omit<PopupProps, 'children'>
export const MessageActionsPopup = (props: MessageActionsPopupProps) => {
@ -23,7 +23,7 @@ export const MessageActionsPopup = (props: MessageActionsPopupProps) => {
{ name: t('Delete'), action: 'delete' },
]
createEffect(() => {
if (props.actionSelect) props.actionSelect(selectedAction())
if (props.actionSelect) props.actionSelect(selectedAction() || 'select')
})
return (
<Popup {...props} variant="tiny">
@ -31,7 +31,7 @@ export const MessageActionsPopup = (props: MessageActionsPopupProps) => {
<For each={actions}>
{(item) => (
<li
style={item.action === 'delete' && { color: 'red' }}
style={{ color: item.action === 'delete' ? 'red' : undefined }}
onClick={() => setSelectedAction(item.action)}
>
{item.name}

View File

@ -10,11 +10,15 @@ type Props = {
}
const Search = (props: Props) => {
const [value, setValue] = createSignal<string>('')
const search = (event) => {
event.preventDefault()
setValue(event.target.value)
props.onChange(value)
// FIXME: this component does not use value, is it used?
const [_value, setValue] = createSignal<string>('')
const search = (event: (InputEvent | undefined) & { target: { value: string } }) => {
event?.preventDefault()
const v = event?.target?.value || ''
if (v) {
setValue(v)
props.onChange(() => v)
}
}
return (
<div class={styles.Search}>

View File

@ -1,9 +1,6 @@
import { useSearchParams } from '@solidjs/router'
import { Show } from 'solid-js'
import { useLocalize } from '../../../../context/localize'
import { useRouter } from '../../../../stores/router'
import { AuthModalSearchParams } from '../types'
import styles from './AuthModalHeader.module.scss'
type Props = {
@ -12,15 +9,14 @@ type Props = {
export const AuthModalHeader = (props: Props) => {
const { t } = useLocalize()
const { searchParams } = useRouter<AuthModalSearchParams>()
const { source } = searchParams()
const [searchParams] = useSearchParams<{ source: string }>()
const generateModalTextsFromSource = (
modalType: 'login' | 'register',
): { title: string; description: string } => {
const title = modalType === 'login' ? 'Welcome to Discours' : 'Create account'
switch (source) {
switch (searchParams?.source) {
case 'create': {
return {
title: t(`${title} to publish articles`),

View File

@ -1,15 +1,12 @@
import type { AuthModalSearchParams } from './types'
import { clsx } from 'clsx'
import { JSX, Show, createSignal } from 'solid-js'
import { useUI } from '~/context/ui'
import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session'
import { useRouter } from '../../../stores/router'
import { hideModal } from '../../../stores/ui'
import { PasswordField } from './PasswordField'
import { useSearchParams } from '@solidjs/router'
import styles from './AuthModal.module.scss'
type FormFields = {
@ -19,27 +16,27 @@ type FormFields = {
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
export const ChangePasswordForm = () => {
const { searchParams, changeSearchParams } = useRouter<AuthModalSearchParams>()
const [searchParams, changeSearchParams] = useSearchParams<{ token?: string }>()
const { hideModal } = useUI()
const { t } = useLocalize()
const { changePassword } = useSession()
const [isSubmitting, setIsSubmitting] = createSignal(false)
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
const [newPassword, setNewPassword] = createSignal<string>()
const [passwordError, setPasswordError] = createSignal<string>()
const [newPassword, setNewPassword] = createSignal<string>('')
const [passwordError, setPasswordError] = createSignal<string>('')
const [isSuccess, setIsSuccess] = createSignal(false)
const authFormRef: { current: HTMLFormElement } = { current: null }
let authFormRef: HTMLFormElement | undefined
const handleSubmit = async (event: Event) => {
event.preventDefault()
setIsSubmitting(true)
if (newPassword()) {
changePassword(newPassword(), searchParams()?.token)
if (!newPassword()) return
if (searchParams?.token) changePassword(newPassword(), searchParams.token)
setTimeout(() => {
setIsSubmitting(false)
setIsSuccess(true)
}, 1000)
}
}
const handlePasswordInput = (value: string) => {
setNewPassword(value)
@ -56,7 +53,7 @@ export const ChangePasswordForm = () => {
<form
onSubmit={handleSubmit}
class={clsx(styles.authForm, styles.authFormForgetPassword)}
ref={(el) => (authFormRef.current = el)}
ref={(el) => (authFormRef = el)}
>
<div>
<h4>{t('Enter a new password')}</h4>

View File

@ -1,18 +1,19 @@
import { clsx } from 'clsx'
import { Show, createEffect, createSignal } from 'solid-js'
import { useUI } from '~/context/ui'
import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session'
import { useRouter } from '../../../stores/router'
import { hideModal } from '../../../stores/ui'
import { email, setEmail } from './sharedLogic'
import { useSearchParams } from '@solidjs/router'
import styles from './AuthModal.module.scss'
export const EmailConfirm = () => {
const { t } = useLocalize()
const { changeSearchParams } = useRouter()
const { hideModal } = useUI()
const [, changeSearchParams] = useSearchParams()
const { session, authError } = useSession()
const [emailConfirmed, setEmailConfirmed] = createSignal(false)
@ -24,7 +25,7 @@ export const EmailConfirm = () => {
setEmail(email.toLowerCase())
if (isVerified) setEmailConfirmed(isVerified)
if (authError()) {
changeSearchParams({}, true)
changeSearchParams({}, { replace: true })
}
}

View File

@ -1,13 +1,9 @@
import type { AuthModalSearchParams } from './types'
import { clsx } from 'clsx'
import { JSX, Show, createSignal } from 'solid-js'
import { useSnackbar, useUI } from '~/context/ui'
import { useLocalize } from '../../../context/localize'
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 { AuthModalHeader } from './AuthModalHeader'
@ -15,6 +11,7 @@ import { PasswordField } from './PasswordField'
import { SocialProviders } from './SocialProviders'
import { email, setEmail } from './sharedLogic'
import { useSearchParams } from '@solidjs/router'
import styles from './AuthModal.module.scss'
type FormFields = {
@ -25,7 +22,8 @@ type FormFields = {
type ValidationErrors = Partial<Record<keyof FormFields, string>>
export const LoginForm = () => {
const { changeSearchParams } = useRouter<AuthModalSearchParams>()
const { hideModal } = useUI()
const [, setSearchParams] = useSearchParams()
const { t } = useLocalize()
const [submitError, setSubmitError] = createSignal<string | JSX.Element>()
const [isSubmitting, setIsSubmitting] = createSignal(false)
@ -33,9 +31,9 @@ export const LoginForm = () => {
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
// FIXME: use signal or remove
const [_isLinkSent, setIsLinkSent] = createSignal(false)
const authFormRef: { current: HTMLFormElement } = { current: null }
let authFormRef: HTMLFormElement
const { showSnackbar } = useSnackbar()
const { signIn } = useSession()
const { signIn, authError } = useSession()
const handleEmailInput = (newEmail: string) => {
setValidationErrors(({ email: _notNeeded, ...rest }) => rest)
@ -52,7 +50,7 @@ export const LoginForm = () => {
setIsLinkSent(true)
setSubmitError()
changeSearchParams({ mode: 'send-confirm-email' })
setSearchParams({ mode: 'send-confirm-email' })
}
const preSendValidate = async (value: string, type: 'email' | 'password'): Promise<boolean> => {
@ -85,7 +83,7 @@ export const LoginForm = () => {
setSubmitError()
if (Object.keys(validationErrors()).length > 0) {
authFormRef.current
authFormRef
.querySelector<HTMLInputElement>(`input[name="${Object.keys(validationErrors())[0]}"]`)
?.focus()
return
@ -94,11 +92,9 @@ export const LoginForm = () => {
setIsSubmitting(true)
try {
const { errors } = await signIn({ email: email(), password: password() })
if (errors?.length > 0) {
console.warn('[signIn] errors: ', errors)
errors.forEach((error) => {
switch (error.message) {
const success = await signIn({ email: email(), password: password() })
if (!success) {
switch (authError()) {
case 'user has not signed up email & password':
case 'bad user credentials': {
setValidationErrors((prev) => ({
@ -118,7 +114,7 @@ export const LoginForm = () => {
default:
setSubmitError(
<div class={styles.info}>
{t('Error', errors[0].message)}
{t('Error', authError())}
{'. '}
<span class={'link'} onClick={handleSendLinkAgainClick}>
{t('Send link again')}
@ -126,21 +122,19 @@ export const LoginForm = () => {
</div>,
)
}
})
return
}
hideModal()
showSnackbar({ body: t('Welcome!') })
} catch (error) {
console.error(error)
setSubmitError(error.message)
setSubmitError(authError())
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef.current = el)}>
<form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef = el)}>
<div>
<AuthModalHeader modalType="login" />
<div
@ -182,7 +176,7 @@ export const LoginForm = () => {
<span
class="link"
onClick={() =>
changeSearchParams({
setSearchParams({
mode: 'send-reset-link',
})
}
@ -199,7 +193,7 @@ export const LoginForm = () => {
<span
class={styles.authLink}
onClick={() =>
changeSearchParams({
setSearchParams({
mode: 'register',
})
}

View File

@ -52,7 +52,7 @@ export const PasswordField = (props: Props) => {
return
}
props.onInput(value)
props.onInput?.(value)
if (!props.noValidate) {
const errorValue = validatePassword(value)
if (errorValue) {

View File

@ -1,21 +1,17 @@
import { clsx } from 'clsx'
import type { JSX } 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 { useSession } from '../../../context/session'
import { useRouter } from '../../../stores/router'
import { hideModal } from '../../../stores/ui'
import { validateEmail } from '../../../utils/validateEmail'
import { AuthModalHeader } from './AuthModalHeader'
import { PasswordField } from './PasswordField'
import { SocialProviders } from './SocialProviders'
import { email, setEmail } from './sharedLogic'
import { GenericResponse } from '@authorizerdev/authorizer-js'
import styles from './AuthModal.module.scss'
type EmailStatus = 'not verified' | 'verified' | 'registered' | ''
@ -29,7 +25,8 @@ type FormFields = {
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
export const RegisterForm = () => {
const { changeSearchParams } = useRouter<AuthModalSearchParams>()
const [, changeSearchParams] = useSearchParams()
const { hideModal } = useUI()
const { t } = useLocalize()
const { signUp, isRegistered, resendVerifyEmail } = useSession()
// FIXME: use submit error data or remove signal
@ -42,7 +39,7 @@ export const RegisterForm = () => {
const [passwordError, setPasswordError] = createSignal<string>()
const [emailStatus, setEmailStatus] = createSignal<string>('')
const authFormRef: { current: HTMLFormElement } = { current: null }
let authFormRef: HTMLFormElement
const handleNameInput = (newName: string) => {
setFullName(newName)
@ -81,11 +78,10 @@ export const RegisterForm = () => {
const isValid = createMemo(() => Object.keys(newValidationErrors).length === 0)
if (!isValid()) {
authFormRef.current
if (!isValid() && authFormRef) {
authFormRef
.querySelector<HTMLInputElement>(`input[name="${Object.keys(newValidationErrors)[0]}"]`)
.focus()
?.focus()
return
}
setIsSubmitting(true)
@ -95,11 +91,10 @@ export const RegisterForm = () => {
email: cleanEmail,
password: password(),
confirm_password: password(),
redirect_uri: window.location.origin,
redirect_uri: window?.location?.origin || '',
}
const { errors } = await signUp(opts)
if (errors.length > 0) return
setIsSuccess(true)
const success = await signUp(opts)
setIsSuccess(success)
} catch (error) {
console.error(error)
} finally {
@ -107,12 +102,13 @@ export const RegisterForm = () => {
}
}
const handleResendLink = async (_ev) => {
const response: GenericResponse = await resendVerifyEmail({
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const handleResendLink = async (_ev: any) => {
const success: boolean = await resendVerifyEmail({
email: email(),
identifier: 'basic_signup',
})
setIsSuccess(response?.message === 'Verification email has been sent. Please check your inbox')
setIsSuccess(success)
}
const handleCheckEmailStatus = (status: EmailStatus | string) => {
@ -184,7 +180,7 @@ export const RegisterForm = () => {
return (
<>
<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>
<AuthModalHeader modalType="register" />
<div

View File

@ -1,10 +1,12 @@
import { clsx } from 'clsx'
import { useUI } from '~/context/ui'
import { useLocalize } from '../../../context/localize'
import { hideModal } from '../../../stores/ui'
import styles from './AuthModal.module.scss'
export const SendEmailConfirm = () => {
const { hideModal } = useUI()
const { t } = useLocalize()
return (
<div

View File

@ -1,15 +1,12 @@
import type { AuthModalSearchParams } from './types'
import { clsx } from 'clsx'
import { JSX, Show, createSignal, onMount } from 'solid-js'
import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session'
import { useRouter } from '../../../stores/router'
import { validateEmail } from '../../../utils/validateEmail'
import { email, setEmail } from './sharedLogic'
import { useSearchParams } from '@solidjs/router'
import styles from './AuthModal.module.scss'
type FormFields = {
@ -19,7 +16,7 @@ type FormFields = {
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
export const SendResetLinkForm = () => {
const { changeSearchParams } = useRouter<AuthModalSearchParams>()
const [, changeSearchParams] = useSearchParams()
const { t } = useLocalize()
const handleEmailInput = (newEmail: string) => {
setValidationErrors(({ email: _notNeeded, ...rest }) => rest)
@ -29,7 +26,7 @@ export const SendResetLinkForm = () => {
const [isSubmitting, setIsSubmitting] = createSignal(false)
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
const [isUserNotFound, setIsUserNotFound] = createSignal(false)
const authFormRef: { current: HTMLFormElement } = { current: null }
let authFormRef: HTMLFormElement
const [message, setMessage] = createSignal<string>('')
const handleSubmit = async (event: Event) => {
@ -47,29 +44,25 @@ export const SendResetLinkForm = () => {
const isValid = Object.keys(newValidationErrors).length === 0
if (!isValid) {
authFormRef.current
.querySelector<HTMLInputElement>(`input[name="${Object.keys(newValidationErrors)[0]}"]`)
.focus()
authFormRef
?.querySelector<HTMLInputElement>(`input[name="${Object.keys(newValidationErrors)[0]}"]`)
?.focus()
return
}
setIsSubmitting(true)
try {
const { data, errors } = await forgotPassword({
const result = await forgotPassword({
email: email(),
redirect_uri: window.location.origin,
redirect_uri: window?.location?.origin || '',
})
console.debug('[SendResetLinkForm] authorizer response:', data)
if (
errors?.some(
(error) =>
error.message.includes('bad user credentials') || error.message.includes('user not found'),
)
) {
setIsUserNotFound(true)
if (result) {
setMessage(result || '')
} else {
console.warn('[SendResetLinkForm] forgot password mutation failed')
setIsUserNotFound(false)
}
if (data.message) setMessage(data.message)
} catch (error) {
console.error(error)
} finally {
@ -87,7 +80,7 @@ export const SendResetLinkForm = () => {
<form
onSubmit={handleSubmit}
class={clsx(styles.authForm, styles.authFormForgetPassword)}
ref={(el) => (authFormRef.current = el)}
ref={(el) => (authFormRef = el)}
>
<div>
<h4>{t('Forgot password?')}</h4>

View File

@ -18,7 +18,11 @@ export const SocialProviders = () => {
<div class={styles.social}>
<For each={PROVIDERS}>
{(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} />
</button>
)}

View File

@ -1,22 +1,21 @@
import type { AuthModalMode, AuthModalSearchParams } from './types'
import { clsx } from 'clsx'
import { Component, Show, createEffect, createMemo } from 'solid-js'
import { Dynamic } from 'solid-js/web'
import { useUI } from '~/context/ui'
import type { AuthModalMode } from '~/context/ui'
import { useLocalize } from '../../../context/localize'
import { useRouter } from '../../../stores/router'
import { hideModal } from '../../../stores/ui'
import { isMobile } from '../../../utils/media-query'
import { ChangePasswordForm } from './ChangePasswordForm'
import { EmailConfirm } from './EmailConfirm'
import { LoginForm } from './LoginForm'
import { RegisterForm } from './RegisterForm'
import { SendEmailConfirm } from './SendEmailConfirm'
import { SendResetLinkForm } from './SendResetLinkForm'
import { useSearchParams } from '@solidjs/router'
import styles from './AuthModal.module.scss'
import { SendEmailConfirm } from './SendEmailConfirm'
import { AuthModalSearchParams } from './types'
const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = {
login: LoginForm,
@ -28,30 +27,31 @@ const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = {
}
export const AuthModal = () => {
const rootRef: { current: HTMLDivElement } = { current: null }
let rootRef: HTMLDivElement | null
const { t } = useLocalize()
const { searchParams } = useRouter<AuthModalSearchParams>()
const { source } = searchParams()
const mode = createMemo<AuthModalMode>(() => {
return AUTH_MODAL_MODES[searchParams().mode] ? searchParams().mode : 'login'
const [searchParams] = useSearchParams<AuthModalSearchParams>()
const { hideModal } = useUI()
const mode = createMemo(() => {
return (
AUTH_MODAL_MODES[searchParams?.mode as AuthModalMode] ? searchParams?.mode : 'login'
) as AuthModalMode
})
createEffect((oldMode) => {
if (oldMode !== mode() && !isMobile()) {
rootRef.current?.querySelector('input')?.focus()
rootRef?.querySelector('input')?.focus()
}
}, null)
return (
<div
ref={(el) => (rootRef.current = el)}
ref={(el) => (rootRef = el)}
class={clsx(styles.view, {
row: !source,
row: !searchParams?.source,
[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={styles.authImageText}
@ -84,10 +84,10 @@ export const AuthModal = () => {
</Show>
<div
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>
)

View File

@ -1,18 +1,4 @@
export type AuthModalMode =
| 'login'
| 'register'
| 'confirm-email'
| 'send-confirm-email'
| 'send-reset-link'
| 'change-password'
export type AuthModalSource =
| 'discussions'
| 'vote'
| 'subscribe'
| 'bookmark'
| 'follow'
| 'create'
| 'authguard'
import { AuthModalMode, AuthModalSource } from '~/context/ui'
export type AuthModalSearchParams = {
mode: AuthModalMode
@ -28,3 +14,4 @@ export type ConfirmEmailSearchParams = {
export type CreateChatSearchParams = {
id: number
}
export type { AuthModalSource }

View File

@ -1,12 +1,12 @@
import { useConfirm } from '../../../context/confirm'
import { useLocalize } from '../../../context/localize'
import { useUI } from '../../../context/ui'
import { Button } from '../../_shared/Button'
import styles from './ConfirmModal.module.scss'
export const ConfirmModal = () => {
const { t } = useLocalize()
const { confirmMessage, resolveConfirm } = useConfirm()
const { confirmMessage, resolveConfirm } = useUI()
return (
<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 { 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 { useSession } from '../../../context/session'
import { ROUTES, router, useRouter } from '../../../stores/router'
import { useModalStore } from '../../../stores/ui'
import { useTopics } from '../../../context/topics'
import type { Topic } from '../../../graphql/schema/core.gen'
import { getRandomTopicsFromArray } from '../../../utils/getRandomTopicsFromArray'
import { getDescription } from '../../../utils/meta'
import { SharePopup, getShareUrl } from '../../Article/SharePopup'
import { Icon } from '../../_shared/Icon'
@ -18,11 +17,9 @@ import { HeaderAuth } from '../HeaderAuth'
import { Modal } from '../Modal'
import { SearchModal } from '../SearchModal/SearchModal'
import { Snackbar } from '../Snackbar'
import { Link } from './Link'
import { useTopics } from '../../../context/topics'
import { getRandomTopicsFromArray } from '../../../utils/getRandomTopicsFromArray'
import { A, useLocation, useNavigate, useSearchParams } from '@solidjs/router'
import styles from './Header.module.scss'
type Props = {
@ -38,18 +35,19 @@ type HeaderSearchParams = {
source?: string
}
const handleSwitchLanguage = (event) => {
location.href = `${location.href}${location.href.includes('?') ? '&' : '?'}lng=${event.target.value}`
const handleSwitchLanguage = (value: string) => {
location.href = `${location.href}${location.href.includes('?') ? '&' : '?'}lng=${value}`
}
export const Header = (props: Props) => {
const { t, lang } = useLocalize()
const { modal } = useModalStore()
const { page } = useRouter()
const { modal } = useUI()
const navigate = useNavigate()
const [searchParams] = useSearchParams<HeaderSearchParams>()
const { requireAuthentication } = useSession()
const { searchParams } = useRouter<HeaderSearchParams>()
const { sortedTopics: topics } = useTopics()
const [randomTopics, setRandomTopics] = createSignal([])
const { sortedTopics } = useTopics()
const topics = createMemo<Topic[]>(() => sortedTopics())
const [randomTopics, setRandomTopics] = createSignal<Topic[]>([])
const [getIsScrollingBottom, setIsScrollingBottom] = createSignal(false)
const [getIsScrolled, setIsScrolled] = createSignal(false)
const [fixed, setFixed] = createSignal(false)
@ -70,23 +68,28 @@ export const Header = (props: Props) => {
createEffect(() => {
if (topics()?.length) {
setRandomTopics(getRandomTopicsFromArray(topics()))
const rt: Topic[] = getRandomTopicsFromArray(topics())
setRandomTopics(rt)
}
})
createEffect(() => {
const mainContent = document.querySelector<HTMLDivElement>('.main-content')
if (fixed() || modal() !== null) {
if ((window && fixed()) || modal() !== null) {
windowScrollTop = window.scrollY
if (mainContent) {
mainContent.style.marginTop = `-${windowScrollTop}px`
}
}
document.body.classList.toggle('fixed', fixed() || modal() !== null)
document.body.classList.toggle(styles.fixed, fixed() && !modal())
if (!(fixed() || modal())) {
if (mainContent) {
mainContent.style.marginTop = ''
}
window.scrollTo(0, windowScrollTop)
}
})
@ -106,27 +109,27 @@ export const Header = (props: Props) => {
})
})
const scrollToComments = (event, value) => {
event.preventDefault()
props.scrollToComments(value)
const scrollToComments = (event: MouseEvent | undefined, value: boolean) => {
event?.preventDefault()
props.scrollToComments?.(value)
}
const handleBookmarkButtonClick = (ev) => {
const handleBookmarkButtonClick = (ev: MouseEvent | undefined) => {
requireAuthentication(() => {
// TODO: implement bookmark clicked
ev.preventDefault()
ev?.preventDefault()
}, 'bookmark')
}
const handleCreateButtonClick = (ev) => {
const handleCreateButtonClick = (ev: MouseEvent | undefined) => {
requireAuthentication(() => {
ev.preventDefault()
ev?.preventDefault()
redirectPage(router, 'create')
navigate('/create')
}, 'create')
}
const toggleSubnavigation = (isShow, signal?) => {
const toggleSubnavigation = (isShow: boolean, signal?: (v: boolean) => void) => {
clearTimer()
setIsKnowledgeBaseVisible(false)
setIsTopicsVisible(false)
@ -144,18 +147,18 @@ export const Header = (props: Props) => {
clearTimeout(timer)
}
const hideSubnavigation = (_event, time = 500) => {
const hideSubnavigation = (time = 500) => {
timer = setTimeout(() => {
toggleSubnavigation(false)
}, time)
}
const handleToggleMenuByLink = (event: MouseEvent, route: keyof typeof ROUTES) => {
if (!fixed()) {
return
}
const loc = useLocation()
const handleToggleMenuByLink = (event: MouseEvent, route: string) => {
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()
}
}
@ -171,9 +174,9 @@ export const Header = (props: Props) => {
}}
>
<Modal
variant={searchParams().source ? 'narrow' : 'wide'}
variant={searchParams?.source ? 'narrow' : 'wide'}
name="auth"
allowClose={searchParams().source !== 'authguard'}
allowClose={searchParams?.source !== 'authguard'}
noPadding={true}
>
<AuthModal />
@ -195,9 +198,9 @@ export const Header = (props: Props) => {
</div>
</div>
<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')} />
</a>
</A>
</div>
<div class={clsx('col col-md-13 col-lg-12 offset-xl-1', styles.mainNavigationWrapper)}>
<Show when={props.title}>
@ -207,7 +210,7 @@ export const Header = (props: Props) => {
<ul class="view-switcher">
<Link
onMouseOver={() => toggleSubnavigation(true, setIsZineVisible)}
onMouseOut={() => hideSubnavigation}
onMouseOut={() => hideSubnavigation()}
routeName="home"
active={isZineVisible()}
body={t('journal')}
@ -215,7 +218,7 @@ export const Header = (props: Props) => {
/>
<Link
onMouseOver={() => toggleSubnavigation(true, setIsFeedVisible)}
onMouseOut={() => hideSubnavigation}
onMouseOut={() => hideSubnavigation()}
routeName="feed"
active={isFeedVisible()}
body={t('feed')}
@ -230,15 +233,15 @@ export const Header = (props: Props) => {
onClick={(event) => handleToggleMenuByLink(event, 'topics')}
/>
<Link
onMouseOver={(event) => hideSubnavigation(event, 0)}
onMouseOut={(event) => hideSubnavigation(event, 0)}
onMouseOver={() => hideSubnavigation(0)}
onMouseOut={() => hideSubnavigation(0)}
routeName="authors"
body={t('authors')}
onClick={(event) => handleToggleMenuByLink(event, 'authors')}
/>
<Link
onMouseOver={() => toggleSubnavigation(true, setIsKnowledgeBaseVisible)}
onMouseOut={() => hideSubnavigation}
onMouseOut={() => hideSubnavigation()}
routeName="guide"
body={t('Knowledge base')}
active={isKnowledgeBaseVisible()}
@ -306,7 +309,7 @@ export const Header = (props: Props) => {
<h4>{t('Language')}</h4>
<select
class={styles.languageSelectorMobile}
onChange={handleSwitchLanguage}
onChange={(ev) => handleSwitchLanguage(ev.target.value)}
value={lang()}
>
<option value="ru">🇷🇺 Русский</option>
@ -339,10 +342,10 @@ export const Header = (props: Props) => {
})}
>
<SharePopup
title={props.title}
imageUrl={props.cover}
title={props.title || ''}
imageUrl={props.cover || ''}
shareUrl={getShareUrl()}
description={getDescription(props.articleBody)}
description={getDescription(props.articleBody || '')}
onVisibilityChange={(isVisible) => {
setIsSharePopupVisible(isVisible)
}}
@ -373,7 +376,7 @@ export const Header = (props: Props) => {
class={clsx(styles.subnavigation, 'col')}
classList={{ hidden: !isKnowledgeBaseVisible() }}
onMouseOver={clearTimer}
onMouseOut={hideSubnavigation}
onMouseOut={() => hideSubnavigation()}
>
<ul class="nodash">
<li>
@ -407,7 +410,7 @@ export const Header = (props: Props) => {
class={clsx(styles.subnavigation, 'col')}
classList={{ hidden: !isZineVisible() }}
onMouseOver={clearTimer}
onMouseOut={hideSubnavigation}
onMouseOut={() => hideSubnavigation()}
>
<ul class="nodash">
<li class="item">
@ -453,12 +456,12 @@ export const Header = (props: Props) => {
class={clsx(styles.subnavigation, 'col')}
classList={{ hidden: !isTopicsVisible() }}
onMouseOver={clearTimer}
onMouseOut={hideSubnavigation}
onMouseOut={() => hideSubnavigation()}
>
<ul class="nodash">
<Show when={randomTopics().length > 0}>
<For each={randomTopics()}>
{(topic) => (
{(topic: Topic) => (
<li class="item">
<a href={`/topic/${topic.slug}`}>
<span>#{tag(topic)}</span>
@ -480,57 +483,57 @@ export const Header = (props: Props) => {
class={clsx(styles.subnavigation, styles.subnavigationFeed, 'col')}
classList={{ hidden: !isFeedVisible() }}
onMouseOver={clearTimer}
onMouseOut={hideSubnavigation}
onMouseOut={() => hideSubnavigation()}
>
<ul class="nodash">
<li>
<a href={getPagePath(router, 'feed')}>
<A href={'/feed'}>
<span class={styles.subnavigationItemName}>
<Icon name="feed-all" class={styles.icon} />
{t('All')}
</span>
</a>
</A>
</li>
<li>
<a href={getPagePath(router, 'feedMy')}>
<A href={'/feed/my'}>
<span class={styles.subnavigationItemName}>
<Icon name="feed-my" class={styles.icon} />
{t('My feed')}
</span>
</a>
</A>
</li>
<li>
<a href={getPagePath(router, 'feedCollaborations')}>
<A href={'/feed/collab'}>
<span class={styles.subnavigationItemName}>
<Icon name="feed-collaborate" class={styles.icon} />
{t('Participation')}
</span>
</a>
</A>
</li>
<li>
<a href={getPagePath(router, 'feedDiscussions')}>
<A href={'/feed/discussions'}>
<span class={styles.subnavigationItemName}>
<Icon name="feed-discussion" class={styles.icon} />
{t('Discussions')}
</span>
</a>
</A>
</li>
<li>
<a href={getPagePath(router, 'feedBookmarks')}>
<A href={'/feed/bookmark'}>
<span class={styles.subnavigationItemName}>
<Icon name="bookmark" class={styles.icon} />
{t('Bookmarks')}
</span>
</a>
</A>
</li>
<li>
<a href={getPagePath(router, 'feedNotifications')}>
<A href={'/feed/notifications'}>
<span class={styles.subnavigationItemName}>
<Icon name="feed-notifications" class={styles.icon} />
{t('Notifications')}
</span>
</a>
</A>
</li>
</ul>
</div>

View File

@ -1,9 +1,10 @@
import { getPagePath } from '@nanostores/router'
import { clsx } from 'clsx'
import { ROUTES, router, useRouter } from '../../../stores/router'
import { ROUTES } from '../../../config/routes'
import { ConditionalWrapper } from '../../_shared/ConditionalWrapper'
import { A, useMatch } from '@solidjs/router'
import { createMemo } from 'solid-js'
import styles from './Header.module.scss'
type Props = {
@ -16,16 +17,13 @@ type Props = {
}
export const Link = (props: Props) => {
const { page } = useRouter()
const isSelected = page()?.route === props.routeName
const matchRoute = useMatch(() => props.routeName || '')
const isSelected = createMemo(() => Boolean(matchRoute()))
return (
<li
onClick={props.onClick}
classList={{ 'view-switcher__item--selected': page()?.route === props.routeName }}
>
<li onClick={props.onClick} classList={{ 'view-switcher__item--selected': isSelected() }}>
<ConditionalWrapper
condition={!isSelected && Boolean(props.routeName)}
wrapper={(children) => <a href={getPagePath(router, props.routeName)}>{children}</a>}
wrapper={(children) => <A href={props.routeName || ''}>{children}</A>}
>
<span
class={clsx('cursorPointer linkReplacement', { [styles.mainNavigationItemActive]: props.active })}

View File

@ -1,19 +1,18 @@
import { getPagePath } from '@nanostores/router'
import { clsx } from 'clsx'
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 { useLocalize } from '../../context/localize'
import { useNotifications } from '../../context/notifications'
import { useSession } from '../../context/session'
import { router, useRouter } from '../../stores/router'
import { showModal } from '../../stores/ui'
import { Userpic } from '../Author/Userpic'
import { Button } from '../_shared/Button'
import { Icon } from '../_shared/Icon'
import { Popover } from '../_shared/Popover'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
import { A, useLocation } from '@solidjs/router'
import { Popup } from '../_shared/Popup'
import styles from './Header/Header.module.scss'
import { ProfilePopup } from './ProfilePopup'
@ -31,8 +30,9 @@ type IconedButtonProps = {
const MD_WIDTH_BREAKPOINT = 992
export const HeaderAuth = (props: Props) => {
const { t } = useLocalize()
const { page } = useRouter()
const { session, author, isSessionLoaded } = useSession()
const { showModal } = useUI()
const { session, isSessionLoaded } = useSession()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
const { unreadNotificationsCount, showNotificationsPanel } = useNotifications()
const { form, toggleEditorPanel, publishShout } = useEditorContext()
@ -46,8 +46,8 @@ export const HeaderAuth = (props: Props) => {
showNotificationsPanel()
}
const isEditorPage = createMemo(() => page().route === 'edit' || page().route === 'editSettings')
const loc = useLocation()
const isEditorPage = createMemo(() => loc?.pathname.startsWith('/edit'))
const isNotificationsVisible = createMemo(() => session()?.access_token && !isEditorPage())
const isSaveButtonVisible = createMemo(() => session()?.access_token && isEditorPage())
const isCreatePostButtonVisible = createMemo(() => !isEditorPage())
@ -96,7 +96,8 @@ export const HeaderAuth = (props: Props) => {
</Show>
)
}
const matchInbox = createMemo(() => loc.pathname.endsWith('inbox'))
const matchProfile = createMemo(() => loc.pathname.endsWith(author()?.slug))
return (
<ShowOnlyOnClient>
<Show when={isSessionLoaded()} keyed={true}>
@ -110,11 +111,11 @@ export const HeaderAuth = (props: Props) => {
styles.userControlItemCreate,
)}
>
<a href={getPagePath(router, 'create')}>
<A href={'/create'}>
<span class={styles.textLabel}>{t('Create post')}</span>
<Icon name="pencil-outline" class={styles.icon} />
<Icon name="pencil-outline-hover" class={clsx(styles.icon, styles.iconHover)} />
</a>
</A>
</div>
</Show>
@ -132,12 +133,12 @@ export const HeaderAuth = (props: Props) => {
<div class={styles.button}>
<Icon
name="bell-white"
counter={session() ? unreadNotificationsCount() || 0 : 1}
counter={session() ? unreadNotificationsCount?.() || 0 : 1}
class={styles.icon}
/>
<Icon
name="bell-white-hover"
counter={session() ? unreadNotificationsCount() || 0 : 1}
counter={session() ? unreadNotificationsCount?.() || 0 : 1}
class={clsx(styles.icon, styles.iconHover)}
/>
</div>
@ -223,11 +224,11 @@ export const HeaderAuth = (props: Props) => {
styles.userControlItemCreate,
)}
>
<a href={getPagePath(router, 'create')}>
<A href={'/create'}>
<span class={styles.textLabel}>{t('Create post')}</span>
<Icon name="pencil-outline" class={styles.icon} />
<Icon name="pencil-outline-hover" class={clsx(styles.icon, styles.iconHover)} />
</a>
</A>
</div>
</Show>
@ -252,12 +253,12 @@ export const HeaderAuth = (props: Props) => {
// styles.userControlItemInbox
)}
>
<a href={getPagePath(router, 'inbox')}>
<div classList={{ entered: page().path === '/inbox' }}>
<A href={'/inbox'}>
<div classList={{ entered: Boolean(matchInbox()) }}>
<Icon name="inbox-white" class={styles.icon} />
<Icon name="inbox-white-hover" class={clsx(styles.icon, styles.iconHover)} />
</div>
</a>
</A>
</div>
</Show>
</Show>
@ -272,11 +273,11 @@ export const HeaderAuth = (props: Props) => {
trigger={
<div class={clsx(styles.userControlItem, styles.userControlItemUserpic)}>
<button class={styles.button}>
<div classList={{ entered: page().path === `/${author()?.slug}` }}>
<div classList={{ entered: Boolean(matchProfile()) }}>
<Userpic
size={'L'}
name={author()?.name}
userpic={author()?.pic}
name={author()?.name || ''}
userpic={author()?.pic || ''}
class={styles.userpic}
/>
</div>

View File

@ -1,15 +1,12 @@
import type { JSX } from 'solid-js'
import { redirectPage } from '@nanostores/router'
import { clsx } from 'clsx'
import type { JSX } from 'solid-js'
import { Show, createEffect, createMemo, createSignal } from 'solid-js'
import { useMediaQuery } from '../../../context/mediaQuery'
import { router } from '../../../stores/router'
import { hideModal, useModalStore } from '../../../stores/ui'
import { useUI } from '~/context/ui'
import { useEscKeyDownHandler } from '../../../utils/useEscKeyDownHandler'
import { Icon } from '../../_shared/Icon'
import { useNavigate } from '@solidjs/router'
import { mediaMatches } from '~/utils/media-query'
import styles from './Modal.module.scss'
interface Props {
@ -24,17 +21,17 @@ interface Props {
}
export const Modal = (props: Props) => {
const { modal } = useModalStore()
const { modal, hideModal } = useUI()
const [visible, setVisible] = createSignal(false)
const allowClose = createMemo(() => props.allowClose !== false)
const [isMobileView, setIsMobileView] = createSignal(false)
const { mediaMatches } = useMediaQuery()
const navigate = useNavigate()
const handleHide = () => {
if (modal()) {
if (allowClose()) {
props.onClose?.()
} else {
redirectPage(router, 'home')
navigate('/')
}
}
hideModal()
@ -55,7 +52,7 @@ export const Modal = (props: Props) => {
return (
<Show when={visible()}>
<div
class={clsx(styles.backdrop, [styles[`modal-${props.name}`]], {
class={clsx(styles.backdrop, [styles[`modal-${props.name}` as keyof typeof styles]], {
[styles.isMobile]: isMobileView(),
})}
onClick={handleHide}

View File

@ -1,9 +1,8 @@
import type { JSX } from 'solid-js/jsx-runtime'
import type { ModalType } from '../../../stores/ui'
import { showModal } from '../../../stores/ui'
import { type ModalType, useUI } from '~/context/ui'
export default (props: { name: ModalType; children: JSX.Element }) => {
const { showModal } = useUI()
return (
<a href="#" onClick={() => showModal(props.name)}>
{props.children}

View File

@ -1,54 +1,48 @@
import type { PopupProps } from '../_shared/Popup'
import { getPagePath } from '@nanostores/router'
import { clsx } from 'clsx'
import { createMemo } from 'solid-js'
import type { Author } from '~/graphql/schema/core.gen'
import { useLocalize } from '../../context/localize'
import { useSession } from '../../context/session'
import { router } from '../../stores/router'
import { Icon } from '../_shared/Icon'
import type { PopupProps } 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'
type ProfilePopupProps = Omit<PopupProps, 'children'>
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()
return (
<Popup {...props} horizontalAnchor="right" popupCssClass={styles.profilePopup}>
<ul class="nodash">
<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} />
{t('Profile')}
</a>
</A>
</li>
<li>
<a class={styles.action} href={getPagePath(router, 'drafts')}>
<A class={styles.action} href={'/drafts'}>
<Icon name="pencil-outline" class={styles.icon} />
{t('Drafts')}
</a>
</A>
</li>
<li>
<a
class={styles.action}
href={`${getPagePath(router, 'author', { slug: author()?.slug })}?m=following`}
>
<A class={styles.action} href={`/author/${author()?.slug}?m=following`}>
<Icon name="feed-all" class={styles.icon} />
{t('Subscriptions')}
</a>
</A>
</li>
<li>
<a
class={styles.action}
href={`${getPagePath(router, 'authorComments', { slug: author()?.slug })}`}
>
<A class={styles.action} href={`/author${author()?.slug}`}>
<Icon name="comment" class={styles.icon} />
{t('Comments')}
</a>
</A>
</li>
<li>
<a class={styles.action} href="#">
@ -57,10 +51,10 @@ export const ProfilePopup = (props: ProfilePopupProps) => {
</a>
</li>
<li>
<a class={styles.action} href={getPagePath(router, 'profileSettings')}>
<A class={styles.action} href={'/profile/settings'}>
<Icon name="settings" class={styles.icon} />
{t('Settings')}
</a>
</A>
</li>
<li class={styles.topBorderItem}>
<span class={clsx(styles.action, 'link')} onClick={() => signOut()}>

View File

@ -1,24 +1,24 @@
import { clsx } from 'clsx'
import { useLocalize } from '../../../context/localize'
import { useRouter } from '../../../stores/router'
import { useLocation } from '@solidjs/router'
import styles from './ProfileSettingsNavigation.module.scss'
export const ProfileSettingsNavigation = () => {
const { t } = useLocalize()
const { page } = useRouter()
const loc = useLocation()
return (
<>
<h4 class={styles.navigationHeader}>{t('Settings')}</h4>
<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>
</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>
</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>
</li>
</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 { debounce } from 'throttle-debounce'
import { useLocalize } from '../../../context/localize'
import { loadShoutsSearch } from '../../../stores/zine/articles'
import { useFeed } from '~/context/feed'
import { useLocalize } from '~/context/localize'
import { restoreScrollPosition, saveScrollPosition } from '../../../utils/scroll'
import { byScore } from '../../../utils/sortby'
import { FEED_PAGE_SIZE } from '../../Views/Feed/Feed'
@ -27,7 +27,7 @@ const getSearchCoincidences = ({ str, intersection }: { str: string; intersectio
)}</span>`
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,
id: index,
title: article.title
@ -46,12 +46,13 @@ const prepareSearchResults = (list: Shout[], searchValue: string) =>
export const SearchModal = () => {
const { t } = useLocalize()
const { loadShoutsSearch } = useFeed()
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const [inputValue, setInputValue] = createSignal('')
const [isLoading, setIsLoading] = createSignal(false)
const [offset, setOffset] = createSignal<number>(0)
const [searchResultsList, { refetch: loadSearchResults, mutate: setSearchResultsList }] = createResource<
Shout[] | null
Shout[]
>(
async () => {
setIsLoading(true)
@ -68,7 +69,7 @@ export const SearchModal = () => {
},
{
ssrLoadFrom: 'initial',
initialValue: null,
initialValue: [],
},
)
@ -81,7 +82,7 @@ export const SearchModal = () => {
await debouncedLoadMore()
} else {
setIsLoading(false)
setSearchResultsList(null)
setSearchResultsList([])
}
}
@ -91,7 +92,7 @@ export const SearchModal = () => {
await debouncedLoadMore()
} else {
setIsLoading(false)
setSearchResultsList(null)
setSearchResultsList([])
}
restoreScrollPosition()
setIsLoading(false)
@ -111,7 +112,7 @@ export const SearchModal = () => {
class={styles.searchInput}
onInput={handleQueryInput}
onKeyDown={enterQuery}
ref={searchEl}
ref={(el: HTMLInputElement) => (searchEl = el)}
/>
<Button

View File

@ -2,7 +2,7 @@ import { clsx } from 'clsx'
import { Show } from 'solid-js'
import { Transition } from 'solid-transition-group'
import { useSnackbar } from '../../context/snackbar'
import { useSnackbar } from '~/context/ui'
import { Icon } from '../_shared/Icon'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
@ -24,12 +24,12 @@ export const Snackbar = () => {
exitToClass={styles.exitTo}
onExit={(_el, done) => setTimeout(() => done(), 300)}
>
<Show when={snackbarMessage()}>
<Show when={snackbarMessage()?.body}>
<div class={styles.content}>
<Show when={snackbarMessage()?.type === 'success'}>
<Icon name="check-success" class={styles.icon} />
</Show>
{snackbarMessage().body}
{snackbarMessage()?.body || ''}
</div>
</Show>
</Transition>

View File

@ -1,57 +1,55 @@
import { getPagePath } from '@nanostores/router'
import { clsx } from 'clsx'
import { useLocalize } from '../../../context/localize'
import { router, useRouter } from '../../../stores/router'
import { Icon } from '../../_shared/Icon'
import { A, useMatch } from '@solidjs/router'
import styles from './Topics.module.scss'
export const Topics = () => {
const { t } = useLocalize()
const { page } = useRouter()
const matchExpo = useMatch(() => '/expo')
return (
<nav class={clsx('wide-container text-2xl', styles.Topics)}>
<ul class={styles.list}>
<li class={styles.item}>
<a class={clsx({ [styles.selected]: page().route === 'expo' })} href="/expo">
<A class={clsx({ [styles.selected]: matchExpo() })} href="/expo">
{t('Art')}
</a>
</A>
</li>
<li class={styles.item}>
<a href="/podcasts">{t('Podcasts')}</a>
<A href="/podcasts">{t('Podcasts')}</A>
</li>
<li class={styles.item}>
<a href="/about/projects">{t('Special Projects')}</a>
<A href="/about/projects">{t('Special Projects')}</A>
</li>
<li class={styles.item}>
<a href="/topic/interview">#{t('Interview')}</a>
<A href="/topic/interview">#{t('Interview')}</A>
</li>
<li class={styles.item}>
<a href="/topic/reportage">#{t('Reports')}</a>
<A href="/topic/reportage">#{t('Reports')}</A>
</li>
<li class={styles.item}>
<a href="/topic/empiric">#{t('Experience')}</a>
<A href="/topic/empiric">#{t('Experience')}</A>
</li>
<li class={styles.item}>
<a href="/topic/society">#{t('Society')}</a>
<A href="/topic/society">#{t('Society')}</A>
</li>
<li class={styles.item}>
<a href="/topic/culture">#{t('Culture')}</a>
<A href="/topic/culture">#{t('Culture')}</A>
</li>
<li class={styles.item}>
<a href="/topic/theory">#{t('Theory')}</a>
<A href="/topic/theory">#{t('Theory')}</A>
</li>
<li class={styles.item}>
<a href="/topic/poetry">#{t('Poetry')}</a>
<A href="/topic/poetry">#{t('Poetry')}</A>
</li>
<li class={clsx(styles.item, styles.right)}>
<a href={getPagePath(router, 'topics')}>
<A href={'topics'}>
<span>
{t('All topics')}
<Icon name="arrow-right-black" class={'icon'} />
</span>
</a>
</A>
</li>
</ul>
</nav>

View File

@ -1,15 +1,13 @@
import { getPagePath, openPage } from '@nanostores/router'
import { clsx } from 'clsx'
import { For, Show } from 'solid-js'
import { useLocalize } from '../../../context/localize'
import { useNotifications } from '../../../context/notifications'
import { NotificationGroup as Group } from '../../../graphql/schema/core.gen'
import { router, useRouter } from '../../../stores/router'
import { ArticlePageSearchParams } from '../../Article/FullArticle'
import { Author, NotificationGroup as Group } from '../../../graphql/schema/core.gen'
import { GroupAvatar } from '../../_shared/GroupAvatar'
import { TimeAgo } from '../../_shared/TimeAgo'
import { A, useNavigate, useSearchParams } from '@solidjs/router'
import styles from './NotificationView.module.scss'
type NotificationGroupProps = {
@ -44,14 +42,15 @@ const threadCaption = (threadId: string) =>
export const NotificationGroup = (props: NotificationGroupProps) => {
const { t, formatTime, formatDate } = useLocalize()
const { changeSearchParams } = useRouter<ArticlePageSearchParams>()
const navigate = useNavigate()
const [, changeSearchParams] = useSearchParams()
const { hideNotificationsPanel, markSeenThread } = useNotifications()
const handleClick = (threadId: string) => {
props.onClick()
markSeenThread(threadId)
const [slug, commentId] = threadId.split('::')
openPage(router, 'article', { slug })
navigate(`/article/${slug}`)
if (commentId) changeSearchParams({ commentId })
}
@ -65,25 +64,22 @@ export const NotificationGroup = (props: NotificationGroupProps) => {
<For each={props.notifications}>
{(n: Group, _index) => (
<>
{t(threadCaption(n.thread), { commentsCount: n.reactions.length })}{' '}
{t(threadCaption(n.thread), { commentsCount: n.reactions?.length || 0 })}{' '}
<div
class={clsx(styles.NotificationView, props.class, { [styles.seen]: n.seen })}
onClick={(_) => handleClick(n.thread)}
>
<div class={styles.userpic}>
<GroupAvatar authors={n.authors} />
<GroupAvatar authors={n.authors as Author[]} />
</div>
<div>
<a href={getPagePath(router, 'article', { slug: n.shout.slug })} onClick={handleLinkClick}>
{getTitle(n.shout.title)}
</a>{' '}
<A href={`/article/${n.shout?.slug || ''}`} onClick={handleLinkClick}>
{getTitle(n.shout?.title || '')}
</A>{' '}
{t('from')}{' '}
<a
href={getPagePath(router, 'author', { slug: n.authors[0].slug })}
onClick={handleLinkClick}
>
{n.authors[0].name}
</a>{' '}
<A href={`/author/${n.authors?.[0]?.slug || ''}`} onClick={handleLinkClick}>
{n.authors?.[0]?.name || ''}
</A>{' '}
</div>
<div class={styles.timeContainer}>

View File

@ -24,7 +24,7 @@ const getYesterdayStart = () => {
const now = new Date()
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) =>
date1.getDate() === date2.getDate() &&
date1.getMonth() === date2.getMonth() &&
@ -46,7 +46,7 @@ const isEarlier = (date: Date) => {
export const NotificationsPanel = (props: Props) => {
const [isLoading, setIsLoading] = createSignal(false)
const { author } = useSession()
const { session } = useSession()
const { t } = useLocalize()
const {
after,
@ -61,9 +61,7 @@ export const NotificationsPanel = (props: Props) => {
props.onClose()
}
const panelRef: { current: HTMLDivElement } = {
current: null,
}
let panelRef: HTMLDivElement | undefined
useOutsideClickHandler({
containerRef: panelRef,
@ -76,14 +74,14 @@ export const NotificationsPanel = (props: Props) => {
createEffect(() => {
const mainContent = document.querySelector<HTMLDivElement>('.main-content')
if (props.isOpen) {
if (props.isOpen && mainContent && window) {
windowScrollTop = window.scrollY
mainContent.style.marginTop = `-${windowScrollTop}px`
}
document.body.classList.toggle('fixed', props.isOpen)
if (!props.isOpen) {
if (!props.isOpen && mainContent && window) {
mainContent.style.marginTop = ''
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 () => {
await loadNotificationsGrouped({ after: after(), limit: PAGE_SIZE, offset: loadedNotificationsCount() })
await loadNotificationsGrouped({
after: after() || hourAgo(),
limit: PAGE_SIZE,
offset: loadedNotificationsCount(),
})
if (loadedNotificationsCount() < totalNotificationsCount()) {
const hasMore = scrollContainerRef.current.scrollHeight <= scrollContainerRef.current.offsetHeight
const hasMore = (scrollContainerRef?.scrollHeight || 0) <= (scrollContainerRef?.offsetHeight || 0)
if (hasMore) {
await loadNextPage()
@ -123,7 +125,7 @@ export const NotificationsPanel = (props: Props) => {
}
}
const handleScroll = async () => {
if (!scrollContainerRef.current || isLoading()) {
if (!scrollContainerRef || isLoading()) {
return
}
if (totalNotificationsCount() === loadedNotificationsCount()) {
@ -131,8 +133,8 @@ export const NotificationsPanel = (props: Props) => {
}
const isNearBottom =
scrollContainerRef.current.scrollHeight - scrollContainerRef.current.scrollTop <=
scrollContainerRef.current.clientHeight * 1.5
scrollContainerRef.scrollHeight - scrollContainerRef.scrollTop <=
scrollContainerRef.clientHeight * 1.5
if (isNearBottom) {
setIsLoading(true)
@ -143,15 +145,15 @@ export const NotificationsPanel = (props: Props) => {
const handleScrollThrottled = throttle(50, handleScroll)
onMount(() => {
scrollContainerRef.current.addEventListener('scroll', handleScrollThrottled)
scrollContainerRef?.addEventListener('scroll', handleScrollThrottled)
onCleanup(() => {
scrollContainerRef.current.removeEventListener('scroll', handleScrollThrottled)
scrollContainerRef?.removeEventListener('scroll', handleScrollThrottled)
})
})
createEffect(
on(author, async (a) => {
if (a?.id) {
on(session, async (s) => {
if (s?.access_token) {
setIsLoading(true)
await loadNextPage()
setIsLoading(false)
@ -165,12 +167,12 @@ export const NotificationsPanel = (props: Props) => {
[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}>
<Icon class={styles.closeIcon} name="close" />
</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
when={sortedNotifications().length > 0}
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 deepEqual from 'fast-deep-equal'
import {
@ -15,14 +15,12 @@ import {
} from 'solid-js'
import { createStore } from 'solid-js/store'
import { useConfirm } from '../../context/confirm'
import { useLocalize } from '../../context/localize'
import { useProfileForm } from '../../context/profile'
import { useProfile } from '../../context/profile'
import { useSession } from '../../context/session'
import { useSnackbar } from '../../context/snackbar'
import { ProfileInput } from '../../graphql/schema/core.gen'
import { useSnackbar, useUI } from '../../context/ui'
import { InputMaybe, ProfileInput } from '../../graphql/schema/core.gen'
import styles from '../../pages/profile/Settings.module.scss'
import { hideModal, showModal } from '../../stores/ui'
import { clone } from '../../utils/clone'
import { getImageUrl } from '../../utils/getImageUrl'
import { handleImageUpload } from '../../utils/handleImageUpload'
@ -40,37 +38,43 @@ import { SocialNetworkInput } from '../_shared/SocialNetworkInput'
const SimplifiedEditor = lazy(() => import('../../components/Editor/SimplifiedEditor'))
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 = () => {
const { t } = useLocalize()
const [prevForm, setPrevForm] = createStore<ProfileInput>({})
const [isFormInitialized, setIsFormInitialized] = createSignal(false)
const [isSaving, setIsSaving] = createSignal(false)
const [social, setSocial] = createSignal([])
const [social, setSocial] = createSignal<string[]>([])
const [addLinkForm, setAddLinkForm] = createSignal<boolean>(false)
const [incorrectUrl, setIncorrectUrl] = createSignal<boolean>(false)
const [isUserpicUpdating, setIsUserpicUpdating] = createSignal(false)
const [userpicFile, setUserpicFile] = createSignal(null)
const [userpicFile, setUserpicFile] = createSignal<UploadFile>()
const [uploadError, setUploadError] = createSignal(false)
const [isFloatingPanelVisible, setIsFloatingPanelVisible] = createSignal(false)
const [hostname, setHostname] = createSignal<string | null>(null)
const [slugError, setSlugError] = createSignal<string>()
const [nameError, setNameError] = createSignal<string>()
const { form, submit, updateFormField, setForm } = useProfileForm()
const { form, submit, updateFormField, setForm } = useProfile()
const { showSnackbar } = useSnackbar()
const { loadSession, session } = useSession()
const { showConfirm } = useConfirm()
const { showConfirm } = useUI()
const [clearAbout, setClearAbout] = createSignal(false)
const { showModal, hideModal } = useUI()
createEffect(() => {
if (Object.keys(form).length > 0 && !isFormInitialized()) {
setPrevForm(form)
setSocial(form.links)
const soc: string[] = filterNulls(form.links || [])
setSocial(soc)
setIsFormInitialized(true)
}
})
const slugInputRef: { current: HTMLInputElement } = { current: null }
const nameInputRef: { current: HTMLInputElement } = { current: null }
let slugInputRef: HTMLInputElement | null
let nameInputRef: HTMLInputElement | null
const handleChangeSocial = (value: string) => {
if (validateUrl(value)) {
@ -81,18 +85,18 @@ export const ProfileSettings = () => {
}
}
const handleSubmit = async (event: Event) => {
event.preventDefault()
const handleSubmit = async (event: MouseEvent | undefined) => {
event?.preventDefault()
setIsSaving(true)
if (nameInputRef.current.value.length === 0) {
if (nameInputRef?.value.length === 0) {
setNameError(t('Required'))
nameInputRef.current.focus()
nameInputRef?.focus()
setIsSaving(false)
return
}
if (slugInputRef.current.value.length === 0) {
if (slugInputRef?.value.length === 0) {
setSlugError(t('Required'))
slugInputRef.current.focus()
slugInputRef?.focus()
setIsSaving(false)
return
}
@ -102,9 +106,9 @@ export const ProfileSettings = () => {
setPrevForm(clone(form))
showSnackbar({ body: t('Profile successfully saved') })
} catch (error) {
if (error.code === 'duplicate_slug') {
if (error?.toString().search('duplicate_slug')) {
setSlugError(t('The address is already taken'))
slugInputRef.current.focus()
slugInputRef?.focus()
return
}
showSnackbar({ type: 'error', body: t('Error') })
@ -132,21 +136,21 @@ export const ProfileSettings = () => {
const { selectFiles } = createFileUploader({ multiple: false, accept: 'image/*' })
selectFiles(([uploadFile]) => {
setUserpicFile(uploadFile)
setUserpicFile(uploadFile as UploadFile)
showModal('cropImage')
})
}
const handleUploadAvatar = async (uploadFile) => {
const handleUploadAvatar = async (uploadFile: UploadFile) => {
try {
setUploadError(false)
setIsUserpicUpdating(true)
const result = await handleImageUpload(uploadFile, session()?.access_token)
const result = await handleImageUpload(uploadFile, session()?.access_token || '')
updateFormField('pic', result.url)
setUserpicFile(null)
setUserpicFile(undefined)
setIsUserpicUpdating(false)
} catch (error) {
setUploadError(true)
@ -158,7 +162,7 @@ export const ProfileSettings = () => {
setHostname(window?.location.host)
// eslint-disable-next-line unicorn/consistent-function-scoping
const handleBeforeUnload = (event) => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!deepEqual(form, prevForm)) {
event.returnValue = t(
'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)
}
@ -215,7 +219,7 @@ export const ProfileSettings = () => {
<div
class={styles.userpicImage}
style={{
'background-image': `url(${getImageUrl(form.pic, {
'background-image': `url(${getImageUrl(form.pic || '', {
width: 180,
height: 180,
})})`,
@ -223,7 +227,7 @@ export const ProfileSettings = () => {
/>
<div class={styles.controls}>
<Popover content={t('Delete userpic')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
class={styles.control}
@ -236,7 +240,7 @@ export const ProfileSettings = () => {
{/* @@TODO inspect popover below. onClick causes page refreshing */}
{/* <Popover content={t('Upload userpic')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
class={styles.control}
@ -273,8 +277,8 @@ export const ProfileSettings = () => {
autocomplete="one-time-code"
placeholder={t('Name')}
onInput={(event) => updateFormField('name', event.currentTarget.value)}
value={form.name}
ref={(el) => (nameInputRef.current = el)}
value={form.name || ''}
ref={(el) => (nameInputRef = el)}
/>
<label for="nameOfUser">{t('Name')}</label>
<Show when={nameError()}>
@ -299,8 +303,8 @@ export const ProfileSettings = () => {
data-lpignore="true"
autocomplete="one-time-code2"
onInput={(event) => updateFormField('slug', event.currentTarget.value)}
value={form.slug}
ref={(el) => (slugInputRef.current = el)}
value={form.slug || ''}
ref={(el) => (slugInputRef = el)}
class="nolabel"
/>
<Show when={slugError()}>
@ -359,7 +363,7 @@ export const ProfileSettings = () => {
network={network.name}
handleInput={(value) => handleChangeSocial(value)}
isExist={!network.isPlaceholder}
slug={form.slug}
slug={form.slug || ''}
handleDelete={() => handleDeleteSocialLink(network.link)}
/>
)}
@ -405,12 +409,12 @@ export const ProfileSettings = () => {
</div>
</div>
</Show>
<Modal variant="medium" name="cropImage" onClose={() => setUserpicFile(null)}>
<Modal variant="medium" name="cropImage" onClose={() => setUserpicFile(undefined)}>
<h2>{t('Crop image')}</h2>
<Show when={userpicFile()}>
<Show when={Boolean(userpicFile())}>
<ImageCropper
uploadFile={userpicFile()}
uploadFile={userpicFile() as UploadFile}
onSave={(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 { debounce, throttle } from 'throttle-debounce'
import { DEFAULT_HEADER_OFFSET } from '~/context/ui'
import { useLocalize } from '../../context/localize'
import { DEFAULT_HEADER_OFFSET } from '../../stores/router'
import { isDesktop } from '../../utils/media-query'
import { Icon } from '../_shared/Icon'
@ -19,7 +19,8 @@ const isInViewport = (el: Element): boolean => {
const rect = el.getBoundingClientRect()
return rect.top <= DEFAULT_HEADER_OFFSET + 24 // default offset + 1.5em (default header margin-top)
}
const scrollToHeader = (element) => {
const scrollToHeader = (element: HTMLElement) => {
if (window)
window.scrollTo({
behavior: 'smooth',
top:
@ -43,12 +44,15 @@ export const TableOfContents = (props: Props) => {
setIsVisible(isDesktop())
const updateHeadings = () => {
if (document) {
const parent = document.querySelector(props.parentSelector)
if (parent) {
setHeadings(
// eslint-disable-next-line unicorn/prefer-spread
Array.from(
document.querySelector(props.parentSelector).querySelectorAll<HTMLElement>('h1, h2, h3, h4'),
),
Array.from(parent.querySelectorAll<HTMLElement>('h1, h2, h3, h4')),
)
}
}
setAreHeadingsLoaded(true)
}
@ -98,7 +102,7 @@ export const TableOfContents = (props: Props) => {
[styles.TableOfContentsHeadingsItemH4]: h.nodeName === 'H4',
[styles.active]: index() === activeHeaderIndex(),
})}
innerHTML={h.textContent}
innerHTML={h.textContent || ''}
onClick={(e) => {
e.preventDefault()
scrollToHeader(h)

View File

@ -4,7 +4,7 @@ import { Show, createEffect, createMemo, createSignal, on } from 'solid-js'
import { useFollowing } from '../../context/following'
import { useLocalize } from '../../context/localize'
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 { CardTopic } from '../Feed/CardTopic'
import { CheckButton } from '../_shared/CheckButton'
@ -35,14 +35,15 @@ export const TopicCard = (props: TopicProps) => {
const title = createMemo(() =>
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 [isFollowed, setIsFollowed] = createSignal(false)
createEffect(
on([() => follows, () => props.topic], ([flws, tpc]) => {
if (flws && tpc) {
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 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 when={props.topic.pic}>
<div class={styles.topicAvatar}>
<a href={`/topic/${props.topic.slug}`}>
<img src={props.topic.pic} alt={title()} />
<img src={props.topic?.pic || ''} alt={title()} />
</a>
</div>
</Show>

View File

@ -26,13 +26,13 @@ export const FullTopic = (props: Props) => {
const [followed, setFollowed] = createSignal()
createEffect(() => {
if (follows?.topics.length !== 0) {
if (follows?.topics?.length !== 0) {
const items = follows.topics || []
setFollowed(items.some((x: Topic) => x?.slug === props.topic?.slug))
}
})
const handleFollowClick = (_ev) => {
const handleFollowClick = (_ev?: MouseEvent | undefined) => {
const really = !followed()
setFollowed(really)
requireAuthentication(() => {
@ -43,14 +43,14 @@ export const FullTopic = (props: Props) => {
return (
<div class={clsx(styles.topicHeader, 'col-md-16 col-lg-12 offset-md-4 offset-lg-6')}>
<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}>
<Show when={props.topic?.stat}>
<div class={styles.topicDetailsItem}>
<Icon name="feed-all" class={styles.topicDetailsIcon} />
{t('some posts', {
count: props.topic?.stat.shouts ?? 0,
count: props.topic?.stat?.shouts ?? 0,
})}
</div>
</Show>
@ -75,7 +75,7 @@ export const FullTopic = (props: Props) => {
</a>
</div>
<Show when={props.topic?.pic}>
<img src={props.topic?.pic} alt={props.topic?.title} />
<img src={props.topic?.pic || ''} alt={props.topic?.title || ''} />
</Show>
</div>
)

View File

@ -1,9 +1,9 @@
import { clsx } from 'clsx'
import { Show, createEffect, createSignal, on } from 'solid-js'
import { mediaMatches } from '~/utils/media-query'
import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize'
import { useMediaQuery } from '../../../context/mediaQuery'
import { useSession } from '../../../context/session'
import { FollowingEntity, Topic } from '../../../graphql/schema/core.gen'
import { capitalize } from '../../../utils/capitalize'
@ -20,7 +20,6 @@ type Props = {
export const TopicBadge = (props: Props) => {
const { t, lang } = useLocalize()
const { mediaMatches } = useMediaQuery()
const [isMobileView, setIsMobileView] = createSignal(false)
const { requireAuthentication } = useSession()
const [isFollowed, setIsFollowed] = createSignal<boolean>()
@ -62,8 +61,8 @@ export const TopicBadge = (props: Props) => {
[styles.smallSize]: isMobileView(),
})}
style={
props.topic.pic && {
'background-image': `url('${getImageUrl(props.topic.pic, { width: 40, height: 40 })}')`,
(props.topic?.pic || '') && {
'background-image': `url('${getImageUrl(props.topic?.pic || '', { width: 40, height: 40 })}')`,
}
}
/>
@ -80,15 +79,15 @@ export const TopicBadge = (props: Props) => {
</div>
}
>
<div innerHTML={props.topic.body} class={clsx('text-truncate', styles.description)} />
<div innerHTML={props.topic?.body || ''} class={clsx('text-truncate', styles.description)} />
</Show>
</a>
</div>
<div class={styles.actions}>
<FollowingButton
isFollowed={isFollowed()}
isFollowed={Boolean(isFollowed())}
action={handleFollowClick}
actionMessageType={following()?.slug === props.topic.slug ? following().type : undefined}
actionMessageType={following()?.slug === props.topic.slug ? following()?.type : undefined}
/>
</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 { For, Show, createMemo, createSignal } from 'solid-js'
import { Meta } from '../../../context/meta'
import { For, Show, createMemo, createSignal, onMount } from 'solid-js'
import { type SortFunction, useAuthors } from '../../../context/authors'
import { useLocalize } from '../../../context/localize'
import { useRouter } from '../../../stores/router'
import { useAuthorsStore } from '../../../stores/zine/authors'
import type { Author } from '../../../graphql/schema/core.gen'
import { getImageUrl } from '../../../utils/getImageUrl'
import { scrollHandler } from '../../../utils/scroll'
import { authorLetterReduce, translateAuthor } from '../../../utils/translate'
import { AuthorsList } from '../../AuthorsList'
import { Loading } from '../../_shared/Loading'
import { SearchField } from '../../_shared/SearchField'
import { useSearchParams } from '@solidjs/router'
import { byFirstChar, byStat } from '~/utils/sortby'
import styles from './AllAuthors.module.scss'
type AllAuthorsPageSearchParams = {
by: '' | 'name' | 'shouts' | 'followers'
}
type Props = {
authors: Author[]
topFollowedAuthors?: Author[]
@ -33,17 +28,22 @@ export const AllAuthors = (props: Props) => {
const [searchQuery, setSearchQuery] = createSignal('')
const ALPHABET =
lang() === 'ru' ? [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ@'] : [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ@']
const { searchParams } = useRouter<AllAuthorsPageSearchParams>()
const { sortedAuthors } = useAuthorsStore({
authors: props.authors,
sortBy: searchParams().by || 'name',
})
const [searchParams] = useSearchParams<{ by?: string }>()
const { authorsSorted, addAuthors, setSortBy } = useAuthors()
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 query = searchQuery().toLowerCase()
return sortedAuthors().filter((author) => {
return authorsSorted().filter((author: Author) => {
// Предполагаем, что у автора есть свойство 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 keys = Object.keys(byLetterFiltered())
keys.sort()
keys.push(keys.shift())
const fk = keys.shift() || ''
fk && keys.push(fk)
return keys
})
@ -86,26 +87,26 @@ export const AllAuthors = (props: Props) => {
<ul class={clsx(styles.viewSwitcher, 'view-switcher')}>
<li
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>
</li>
<li
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>
</li>
<li
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>
</li>
<Show when={searchParams().by === 'name'}>
<Show when={searchParams?.by === 'name'}>
<li class="view-switcher__search">
<SearchField onChange={(value) => setSearchQuery(value)} />
</li>
@ -114,7 +115,7 @@ export const AllAuthors = (props: Props) => {
</div>
</div>
<Show when={searchParams().by === 'name'}>
<Show when={searchParams?.by === 'name'}>
<div class="row">
<div class="col-lg-20 col-xl-18">
<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="topic-title">
<a href={`/author/${author.slug}`}>{translateAuthor(author, lang())}</a>
<Show when={author.stat}>
<span class={styles.articlesCounter}>{author.stat.shouts}</span>
<Show when={author.stat?.shouts || 0}>
<span class={styles.articlesCounter}>{author.stat?.shouts || 0}</span>
</Show>
</div>
</div>
@ -166,11 +167,11 @@ export const AllAuthors = (props: Props) => {
)}
</For>
</Show>
<Show when={searchParams().by !== 'name' && props.isLoaded}>
<Show when={searchParams?.by !== 'name' && props.isLoaded}>
<AuthorsList
allAuthorsLength={sortedAuthors()?.length}
allAuthorsLength={authorsSorted()?.length || 0}
searchQuery={searchQuery()}
query={searchParams().by === 'followers' ? 'followers' : 'shouts'}
query={searchParams?.by === 'followers' ? 'followers' : 'shouts'}
/>
</Show>
</div>

View File

@ -1,56 +1,41 @@
import type { Topic } from '../../../graphql/schema/core.gen'
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 { Meta } from '../../../context/meta'
import { useTopics } from '../../../context/topics'
import { useRouter } from '../../../stores/router'
import type { Topic } from '../../../graphql/schema/core.gen'
import { capitalize } from '../../../utils/capitalize'
import { dummyFilter } from '../../../utils/dummyFilter'
import { getImageUrl } from '../../../utils/getImageUrl'
import { scrollHandler } from '../../../utils/scroll'
import { TopicBadge } from '../../Topic/TopicBadge'
import { Loading } from '../../_shared/Loading'
import { SearchField } from '../../_shared/SearchField'
import { TopicBadge } from '../../Topic/TopicBadge'
import styles from './AllTopics.module.scss'
type AllTopicsPageSearchParams = {
by: 'shouts' | 'authors' | 'title' | ''
}
type Props = {
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) => {
const { t, lang } = useLocalize()
const { searchParams, changeSearchParams } = useRouter<AllTopicsPageSearchParams>()
const [limit, setLimit] = createSignal(PAGE_SIZE)
const ALPHABET =
lang() === 'ru' ? [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ#'] : [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ#']
const { sortedTopics, setTopicsSort } = useTopics()
createEffect(() => {
if (!searchParams().by) {
changeSearchParams({
by: 'shouts',
})
}
})
createEffect(() => {
setTopicsSort(searchParams().by || 'shouts')
})
const alphabet = createMemo(() => ABC[lang()])
const [searchParams] = useSearchParams<{ by?: string }>()
const sortedTopics = createMemo(() => props.topics)
// sorted derivative
const byLetter = createMemo<{ [letter: string]: Topic[] }>(() => {
return sortedTopics().reduce(
(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 (/[^A-z]/.test(letter) && lang() === 'en') letter = '#'
if (!acc[letter]) acc[letter] = []
@ -61,19 +46,28 @@ export const AllTopics = (props: Props) => {
)
})
// helper memo
const sortedKeys = createMemo<string[]>(() => {
const keys = Object.keys(byLetter())
if (keys) {
keys.sort()
keys.push(keys.shift())
const firstKey: string = keys.shift() || ''
keys.push(firstKey)
}
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 filteredResults = createMemo(() => {
return dummyFilter(sortedTopics(), searchQuery(), lang())
})
// subcomponent
const AllTopicsHead = () => (
<div class="row">
<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>
<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>
</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>
</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>
</li>
<Show when={searchParams().by !== 'title'}>
<Show when={searchParams?.by !== 'title'}>
<li class="view-switcher__search">
<SearchField onChange={(value) => setSearchQuery(value)} />
</li>
@ -100,6 +94,7 @@ export const AllTopics = (props: Props) => {
</div>
)
// meta
const ogImage = getImageUrl('production/image/logo_image.png')
const ogTitle = t('Themes and plots')
const description = t(
@ -118,16 +113,16 @@ export const AllTopics = (props: Props) => {
<Meta name="twitter:card" content="summary_large_image" />
<Meta name="twitter:title" content={ogTitle} />
<Meta name="twitter:description" content={description} />
<Show when={props.isLoaded} fallback={<Loading />}>
<Show when={Boolean(props.topics)} fallback={<Loading />}>
<div class="row">
<div class="col-md-19 offset-md-5">
<AllTopicsHead />
<Show when={filteredResults().length > 0}>
<Show when={searchParams().by === 'title'}>
<Show when={searchParams?.by === 'title'}>
<div class="col-lg-18 col-xl-15">
<ul class={clsx('nodash', styles.alphabet)}>
<For each={ALPHABET}>
<For each={Array.from(alphabet())}>
{(letter, index) => (
<li>
<Show when={letter in byLetter()} fallback={letter}>
@ -150,7 +145,7 @@ export const AllTopics = (props: Props) => {
<For each={sortedKeys()}>
{(letter) => (
<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="col-lg-20">
<div class="row">
@ -162,7 +157,7 @@ export const AllTopics = (props: Props) => {
? capitalize(topic.slug.replaceAll('-', ' '))
: topic.title}
</a>
<span class={styles.articlesCounter}>{topic.stat.shouts}</span>
<span class={styles.articlesCounter}>{topic.stat?.shouts || 0}</span>
</div>
)}
</For>
@ -174,7 +169,7 @@ export const AllTopics = (props: Props) => {
</For>
</Show>
<Show when={searchParams().by && searchParams().by !== 'title'}>
<Show when={searchParams?.by && searchParams?.by !== 'title'}>
<div class="row">
<div class="col-lg-18 col-xl-15 py-4">
<For each={filteredResults().slice(0, limit())}>
@ -188,7 +183,7 @@ export const AllTopics = (props: Props) => {
</div>
</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')}>
<button class={clsx('button', styles.loadMoreButton)} onClick={showMore}>
{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 { 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 { useLocalize } from '../../../context/localize'
import { Meta, Title } from '../../../context/meta'
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 { 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 { getDescription } from '../../../utils/meta'
import { restoreScrollPosition, saveScrollPosition } from '../../../utils/scroll'
@ -39,117 +42,137 @@ const LOAD_MORE_PAGE_SIZE = 9
export const AuthorView = (props: Props) => {
const { t } = useLocalize()
const { followers: myFollowers, follows: myFollows } = useFollowing()
const { author: me } = useSession()
const { sortedArticles } = useArticlesStore({ shouts: props.shouts })
const { page: getPage, searchParams } = useRouter()
const { session } = useSession()
const me = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
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 [isBioExpanded, setIsBioExpanded] = createSignal(false)
const [author, setAuthor] = createSignal<Author>(props.author)
const [followers, setFollowers] = createSignal([])
const [following, changeFollowing] = createSignal<Array<Author | Topic>>([]) // flat AuthorFollowsResult
const { loadAuthor, authorsEntities } = useAuthors()
const [author, setAuthor] = createSignal<Author>()
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 [commented, setCommented] = createSignal<Reaction[]>()
const modal = MODALS[searchParams().m]
const { query } = useGraphQL()
// пагинация загрузки ленты постов
const loadMore = async () => {
saveScrollPosition()
const { hasMore } = await loadShouts({
const resp = await query(loadShoutsQuery, {
filters: { author: props.authorSlug },
limit: LOAD_MORE_PAGE_SIZE,
offset: sortedArticles().length,
offset: sortedFeed().length,
})
const hasMore = resp?.data?.load_shouts_by?.hasMore
setIsLoadMoreButtonVisible(hasMore)
restoreScrollPosition()
}
// загружает профиль и подписки
// 1 // проверяет не собственный ли это профиль, иначе - загружает
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(
on([() => me(), () => props.authorSlug], ([myProfile, slug]) => {
const my = slug && myProfile?.slug === slug
on([() => session()?.user?.app_data?.profile, () => props.authorSlug || ''], async ([me, s]) => {
const my = s && me?.slug === s
if (my) {
console.debug('[Author] my profile precached')
myProfile && setAuthor(myProfile)
setFollowers(myFollowers() || [])
changeFollowing([...(myFollows?.authors || []), ...(myFollows?.topics || [])])
} else if (slug && !isFetching()) {
fetchData(slug)
if (me) {
setAuthor(me)
if (myFollowers()) setFollowers((myFollowers() || []) as Author[])
changeFollowing([...(myFollows?.topics || []), ...(myFollows?.authors || [])])
}
} else if (s && !isFetching()) {
setIsFetching(true)
setSlug(s)
await loadAuthor(s)
setIsFetching(false) // Сброс состояния загрузки после завершения
}
}),
)
// 3 // after fetch loading following data
createEffect(
on(
[followers, () => authorsEntities()[slug()]],
async ([current, found]) => {
if (current) return
if (!found) return
setAuthor(found)
console.info(`[Author] profile for @${slug()} fetched`)
const followsResp = await query(getAuthorFollowsQuery, { slug: slug() }).toPromise()
const follows = followsResp?.data?.get_author_followers || {}
changeFollowing([...(follows?.authors || []), ...(follows?.topics || [])])
console.info(`[Author] follows for @${slug()} fetched`)
const followersResp = await query(getAuthorFollowersQuery, { slug: slug() }).toPromise()
setFollowers(followersResp?.data?.get_author_followers || [])
console.info(`[Author] followers for @${slug()} fetched`)
setIsFetching(false)
},
{ defer: true },
),
)
// догружает ленту и комментарии
createEffect(
on(author, async (profile) => {
on(
() => author() as Author,
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 },
})
setCommented(ccc)
}).toPromise()
const ccc = resp?.data?.load_reactions_by
if (ccc) setCommented(ccc)
}
}),
},
// { defer: true },
),
)
const bioContainerRef: { current: HTMLDivElement } = { current: null }
const bioWrapperRef: { current: HTMLDivElement } = { current: null }
let bioContainerRef: HTMLDivElement
let bioWrapperRef: HTMLDivElement
const checkBioHeight = () => {
if (bioContainerRef.current) {
setShowExpandBioControl(bioContainerRef.current.offsetHeight > bioWrapperRef.current.offsetHeight)
if (bioContainerRef) {
setShowExpandBioControl(bioContainerRef.offsetHeight > bioWrapperRef.offsetHeight)
}
}
onMount(() => {
if (!modal) hideModal()
if (!modal()) hideModal()
checkBioHeight()
})
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(() =>
author()?.pic
? getImageUrl(author()?.pic, { width: 1200 })
? getImageUrl(author()?.pic || '', { width: 1200 })
: getImageUrl('production/image/logo_image.png'),
)
const description = createMemo(() => getDescription(author()?.bio))
const description = createMemo(() => getDescription(author()?.bio || ''))
const handleDeleteComment = (id: number) => {
setCommented((prev) => prev.filter((comment) => comment.id !== id))
setCommented((prev) => (prev || []).filter((comment) => comment.id !== id))
}
return (
<div class={styles.authorPage}>
<Show when={author()}>
<Title>{author().name}</Title>
<Title>{author()?.name}</Title>
<Meta name="descprition" content={description()} />
<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:description" content={description()} />
<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:image" content={ogImage()} />
</Show>
@ -157,44 +180,31 @@ export const AuthorView = (props: Props) => {
<Show when={author()} fallback={<Loading />}>
<>
<div class={styles.authorHeader}>
<AuthorCard author={author()} followers={followers() || []} flatFollows={following() || []} />
<AuthorCard
author={author() as Author}
followers={followers() || []}
flatFollows={following() || []}
/>
</div>
<div class={clsx(styles.groupControls, 'row')}>
<div class="col-md-16">
<ul class="view-switcher">
<li classList={{ 'view-switcher__item--selected': getPage().route === 'author' }}>
<a
href={getPagePath(router, 'author', {
slug: props.authorSlug,
})}
>
{t('Publications')}
</a>
<Show when={author().stat}>
<span class="view-switcher__counter">{author().stat.shouts}</span>
<li classList={{ 'view-switcher__item--selected': !!matchAuthor() }}>
<A href={`/author/${props.authorSlug}`}>{t('Publications')}</A>
<Show when={author()?.stat}>
<span class="view-switcher__counter">{author()?.stat?.shouts || 0}</span>
</Show>
</li>
<li classList={{ 'view-switcher__item--selected': getPage().route === 'authorComments' }}>
<a
href={getPagePath(router, 'authorComments', {
slug: props.authorSlug,
})}
>
{t('Comments')}
</a>
<Show when={author().stat}>
<span class="view-switcher__counter">{author().stat.comments}</span>
<li classList={{ 'view-switcher__item--selected': !!matchComments() }}>
<A href={`/author/${props.authorSlug}/comments`}>{t('Comments')}</A>
<Show when={author()?.stat}>
<span class="view-switcher__counter">{author()?.stat?.comments || 0}</span>
</Show>
</li>
<li classList={{ 'view-switcher__item--selected': getPage().route === 'authorAbout' }}>
<a
onClick={() => checkBioHeight()}
href={getPagePath(router, 'authorAbout', {
slug: props.authorSlug,
})}
>
<li classList={{ 'view-switcher__item--selected': !!matchAbout() }}>
<A onClick={() => checkBioHeight()} href={`/author/${props.authorSlug}`}>
{t('About')}
</a>
</A>
</li>
</ul>
</div>
@ -202,7 +212,7 @@ export const AuthorView = (props: Props) => {
<Show when={author()?.stat?.rating || author()?.stat?.rating === 0}>
<div class={styles.ratingContainer}>
{t('All posts rating')}
<AuthorShoutsRating author={author()} class={styles.ratingControl} />
<AuthorShoutsRating author={author() as Author} class={styles.ratingControl} />
</div>
</Show>
</div>
@ -212,16 +222,16 @@ export const AuthorView = (props: Props) => {
</div>
<Switch>
<Match when={getPage().route === 'authorAbout'}>
<Match when={matchAbout()}>
<div class="wide-container">
<div class="row">
<div class="col-md-20 col-lg-18">
<div
ref={(el) => (bioWrapperRef.current = el)}
ref={(el) => (bioWrapperRef = el)}
class={styles.longBio}
classList={{ [styles.longBioExpanded]: isBioExpanded() }}
>
<div ref={(el) => (bioContainerRef.current = el)} innerHTML={author()?.about || ''} />
<div ref={(el) => (bioContainerRef = el)} innerHTML={author()?.about || ''} />
</div>
<Show when={showExpandBioControl()}>
@ -236,10 +246,10 @@ export const AuthorView = (props: Props) => {
</div>
</div>
</Match>
<Match when={getPage().route === 'authorComments'}>
<Match when={matchComments()}>
<Show when={me()?.slug === props.authorSlug && !me().stat?.comments}>
<div class="wide-container">
<Placeholder type={getPage().route} mode="profile" />
<Placeholder type={loc?.pathname} mode="profile" />
</div>
</Show>
@ -262,25 +272,25 @@ export const AuthorView = (props: Props) => {
</div>
</div>
</Match>
<Match when={getPage().route === 'author'}>
<Match when={matchAuthor()}>
<Show when={me()?.slug === props.authorSlug && !me().stat?.shouts}>
<div class="wide-container">
<Placeholder type={getPage().route} mode="profile" />
<Placeholder type={loc?.pathname} mode="profile" />
</div>
</Show>
<Show when={sortedArticles().length > 0}>
<Row1 article={sortedArticles()[0]} noauthor={true} nodate={true} />
<Show when={sortedFeed().length > 0}>
<Row1 article={sortedFeed()[0]} noauthor={true} nodate={true} />
<Show when={sortedArticles().length > 1}>
<Show when={sortedFeed().length > 1}>
<Switch>
<Match when={sortedArticles().length === 2}>
<Row2 articles={sortedArticles()} isEqual={true} noauthor={true} nodate={true} />
<Match when={sortedFeed().length === 2}>
<Row2 articles={sortedFeed()} isEqual={true} noauthor={true} nodate={true} />
</Match>
<Match when={sortedArticles().length === 3}>
<Row3 articles={sortedArticles()} noauthor={true} nodate={true} />
<Match when={sortedFeed().length === 3}>
<Row3 articles={sortedFeed()} noauthor={true} nodate={true} />
</Match>
<Match when={sortedArticles().length > 3}>
<Match when={sortedFeed().length > 3}>
<For each={pages()}>
{(page) => (
<>

View File

@ -1,34 +1,37 @@
import { openPage } from '@nanostores/router'
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 { useSession } from '../../../context/session'
import { apiClient } from '../../../graphql/client/core'
import { Shout } from '../../../graphql/schema/core.gen'
import { router } from '../../../stores/router'
import { Draft } from '../../Draft'
import { Loading } from '../../_shared/Loading'
import styles from './DraftsView.module.scss'
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 [loading, setLoading] = createSignal(false)
const { query } = useGraphQL()
createEffect(
on(
() => author(),
async (a) => {
if (a) {
() => Boolean(session()?.access_token),
async (s) => {
if (s) {
setLoading(true)
const { shouts: loadedDrafts, error } = await apiClient.getDrafts()
if (error) {
console.warn(error)
await loadSession()
const resp = await query(getDraftsQuery, {}).toPromise()
const result = resp?.data?.get_shouts_drafts
if (result) {
const { error, drafts: loadedDrafts } = result
if (error) console.warn(error)
if (loadedDrafts) setDrafts(loadedDrafts)
}
setDrafts(loadedDrafts || [])
setLoading(false)
}
},
@ -39,22 +42,20 @@ export const DraftsView = () => {
const { publishShoutById, deleteShout } = useEditorContext()
const handleDraftDelete = async (shout: Shout) => {
const result = deleteShout(shout.id)
if (result) {
const success = await deleteShout(shout.id)
if (success) {
setDrafts((ddd) => ddd.filter((d) => d.id !== shout.id))
}
}
const handleDraftPublish = (shout: Shout) => {
const result = publishShoutById(shout.id)
if (result) {
openPage(router, 'feed')
}
publishShoutById(shout.id)
setTimeout(() => navigate('/feed'), 2000)
}
return (
<div class={clsx(styles.DraftsView)}>
<Show when={!loading() && author()?.id} fallback={<Loading />}>
<Show when={!loading() && authorized()} fallback={<Loading />}>
<div class="wide-container">
<div class="row">
<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