Merge branch 'router-upgrade' into feature/rating

This commit is contained in:
Untone 2024-07-01 16:46:49 +03:00
commit c1d6b4498a
321 changed files with 16884 additions and 10726 deletions

View File

@ -18,10 +18,10 @@ jobs:
run: npm install --global --save-exact @biomejs/biome
- name: Lint with Biome
run: npx biome ci .
run: npx @biomejs/biome ci
- name: Lint styles
run: npm run lint:styles
run: npx stylelint **/*.{scss,css}
- name: Check types
run: npm run typecheck
@ -29,6 +29,14 @@ jobs:
- name: Test production build
run: npm run build
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npm run e2e
env:
BASE_URL: ${{ github.event.deployment_status.target_url }}
email-templates:
runs-on: ubuntu-latest
name: Update templates on Mailgun

View File

@ -10,33 +10,19 @@ jobs:
- uses: actions/setup-node@v4
- name: Install dependencies
run: npm i
- name: Install CI checks
run: npm ci
- name: Check types
run: npm run typecheck
- name: Lint with Biome
run: npm run check:code
run: npx @biomejs/biome check src/.
- name: Lint styles
run: npm run lint:styles
run: npx stylelint **/*.{scss,css}
- name: Test production build
run: npm run build
e2e:
timeout-minutes: 60
runs-on: ubuntu-latest
if: github.event.deployment_status.state == 'success'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
env:
BASE_URL: ${{ github.event.deployment_status.target_url }}

5
.gitignore vendored
View File

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

View File

@ -1,5 +0,0 @@
{
"*.{js,ts,cjs,mjs,d.mts,jsx,tsx,json,jsonc}": [
"npx @biomejs/biome check ./src && tsc"
]
}

View File

@ -1,7 +1,9 @@
## How to start
Use Bun to manage packages.
```
npm install
npm start
bun i
```
## Useful commands
@ -9,23 +11,9 @@ npm start
run checks with your favorite package manager: npm, yarn, pnpm or bun
```
npm run check
bun run typecheck
```
fix styles, imports, formatting and autofixable linting errors:
```
npm run fix
```
## Code generation
generate new SolidJS component:
```
npm run hygen component new NewComponentName
```
generate new SolidJS context:
```
npm run hygen context new NewContextName
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, _response) {
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 })
}

View File

@ -1,10 +1,8 @@
const formData = require('form-data')
const Mailgun = require('mailgun.js')
import FormData from 'form-data'
import Mailgun from 'mailgun.js'
const mailgun = new Mailgun(formData)
const { MAILGUN_API_KEY, MAILGUN_DOMAIN } = process.env
const mg = mailgun.client({ username: 'discoursio', key: MAILGUN_API_KEY })
const mailgun = new Mailgun(FormData)
const mg = mailgun.client({ username: 'discoursio', key: process.env.MAILGUN_API_KEY })
export default async function handler(req, res) {
const { contact, subject, message } = req.body
@ -15,11 +13,11 @@ export default async function handler(req, res) {
from: 'Discours Feedback Robot <robot@discours.io>',
to: 'welcome@discours.io',
subject,
text,
text
}
try {
const response = await mg.messages.create(MAILGUN_DOMAIN, data)
const response = await mg.messages.create('discours.io', data)
console.log('Email sent successfully!', response)
res.status(200).json({ result: 'great success' })
} catch (error) {

View File

@ -1,10 +1,8 @@
const formData = require('form-data')
const Mailgun = require('mailgun.js')
import FormData from 'form-data'
import Mailgun from 'mailgun.js'
const mailgun = new Mailgun(formData)
const { MAILGUN_API_KEY } = process.env
const mg = mailgun.client({ username: 'discoursio', key: MAILGUN_API_KEY })
const mailgun = new Mailgun(FormData)
const mg = mailgun.client({ username: 'discoursio', key: process.env.MAILGUN_API_KEY })
export default async (req, res) => {
const { email } = req.body
@ -13,18 +11,18 @@ export default async (req, res) => {
const response = await mg.lists.members.createMember('newsletter@discours.io', {
address: email,
subscribed: true,
upsert: 'yes',
upsert: 'yes'
})
return res.status(200).json({
success: true,
message: 'Email was added to newsletter list',
response: JSON.stringify(response),
response: JSON.stringify(response)
})
} catch (error) {
return res.status(400).json({
success: false,
message: error.message,
message: error.message
})
}
}

48
app.config.ts Normal file
View File

@ -0,0 +1,48 @@
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)
const isBun = Boolean(process.env.BUN)
export default defineConfig({
ssr: true,
server: {
preset: isVercel ? 'vercel_edge' : isBun ? 'bun' : 'node',
port: 3000
},
devOverlay: true,
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,16 +1,18 @@
{
"$schema": "https://biomejs.dev/schemas/1.5.3/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"]
},
"vcs": {
"defaultBranch": "dev",
"useIgnoreFile": true
"useIgnoreFile": true,
"enabled": true,
"clientKind": "git"
},
"organizeImports": {
"enabled": true,
"ignore": ["./api", "./gen"]
"ignore": ["./gen"]
},
"formatter": {
"indentStyle": "space",
@ -22,10 +24,10 @@
"formatter": {
"semicolons": "asNeeded",
"quoteStyle": "single",
"trailingComma": "all",
"enabled": true,
"jsxQuoteStyle": "double",
"arrowParentheses": "always"
"arrowParentheses": "always",
"trailingCommas": "none"
}
},
"linter": {
@ -61,6 +63,7 @@
"noBarrelFile": "off"
},
"style": {
"noNamespaceImport": "warn",
"useBlockStatements": "off",
"noImplicitBoolean": "off",
"useNamingConvention": "off",

View File

@ -1,18 +0,0 @@
---
to: src/components/<%= h.changeCase.pascal(name) %>/<%= h.changeCase.pascal(name) %>.tsx
---
import { clsx } from 'clsx'
import styles from './<%= h.changeCase.pascal(name) %>.module.scss'
type Props = {
class?: string
}
export const <%= h.changeCase.pascal(name) %> = (props: Props) => {
return (
<div class={clsx(styles.<%= h.changeCase.pascal(name) %>, props.class)}>
<%= h.changeCase.pascal(name) %>
</div>
)
}

View File

@ -1,4 +0,0 @@
---
to: src/components/<%= h.changeCase.pascal(name) %>/index.ts
---
export { <%= h.changeCase.pascal(name) %> } from './<%= h.changeCase.pascal(name) %>'

View File

@ -1,7 +0,0 @@
---
to: src/components/<%= h.changeCase.pascal(name) %>/<%= h.changeCase.pascal(name) %>.module.scss
---
.<%= h.changeCase.pascal(name) %> {
display: block;
}

View File

@ -1,24 +0,0 @@
---
to: src/context/<%= h.changeCase.camel(name) %>.tsx
---
import type { Accessor, JSX } from 'solid-js'
import { createContext, createSignal, useContext } from 'solid-js'
type <%= h.changeCase.pascal(name) %>ContextType = {
}
const <%= h.changeCase.pascal(name) %>Context = createContext<<%= h.changeCase.pascal(name) %>ContextType>()
export function use<%= h.changeCase.pascal(name) %>() {
return useContext(<%= h.changeCase.pascal(name) %>Context)
}
export const <%= h.changeCase.pascal(name) %>Provider = (props: { children: JSX.Element }) => {
const actions = {
}
const value: <%= h.changeCase.pascal(name) %>ContextType = { ...actions }
return <<%= h.changeCase.pascal(name) %>Context.Provider value={value}>{props.children}</<%= h.changeCase.pascal(name) %>Context.Provider>
}

View File

@ -1,5 +0,0 @@
---
message: |
hygen {bold generator new} --name [NAME] --action [ACTION]
hygen {bold generator with-prompt} --name [NAME] --action [ACTION]
---

View File

@ -1,16 +0,0 @@
---
to: gen/<%= name %>/<%= action || 'new' %>/hello.ejs.t
---
---
to: app/hello.js
---
const hello = ```
Hello!
This is your first hygen template.
Learn what it can do here:
https://github.com/jondot/hygen
```
console.log(hello)

View File

@ -1,16 +0,0 @@
---
to: gen/<%= name %>/<%= action || 'new' %>/hello.ejs.t
---
---
to: app/hello.js
---
const hello = ```
Hello!
This is your first prompt based hygen template.
Learn what it can do here:
https://github.com/jondot/hygen
```
console.log(hello)

View File

@ -1,14 +0,0 @@
---
to: gen/<%= name %>/<%= action || 'new' %>/prompt.js
---
// see types of prompts:
// https://github.com/enquirer/enquirer/tree/master/examples
//
module.exports = [
{
type: 'input',
name: 'message',
message: "What's your message?"
}
]

View File

@ -1,4 +0,0 @@
---
setup: <%= name %>
force: true # this is because mostly, people init into existing folders is safe
---

12299
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,144 +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=chromium",
"fix": "npm run check:code:fix && stylelint **/*.{scss,css} --fix",
"e2e": "npx playwright test --project=webkit",
"fix": "npx @biomejs/biome check src/. --write && stylelint **/*.{scss,css} --fix",
"format": "npx @biomejs/biome format src/. --write",
"hygen": "HYGEN_TMPLS=gen hygen",
"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"
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@authorizerdev/authorizer-js": "2.0.0",
"@babel/core": "7.23.3",
"@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.41.2",
"@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.2.3",
"@tiptap/extension-blockquote": "2.2.3",
"@tiptap/extension-bold": "2.2.3",
"@tiptap/extension-bubble-menu": "2.2.3",
"@tiptap/extension-bullet-list": "2.2.3",
"@tiptap/extension-character-count": "2.2.3",
"@tiptap/extension-collaboration": "2.2.3",
"@tiptap/extension-collaboration-cursor": "2.2.3",
"@tiptap/extension-document": "2.2.3",
"@tiptap/extension-dropcursor": "2.2.3",
"@tiptap/extension-floating-menu": "2.2.3",
"@tiptap/extension-focus": "2.2.3",
"@tiptap/extension-gapcursor": "2.2.3",
"@tiptap/extension-hard-break": "2.2.3",
"@tiptap/extension-heading": "2.2.3",
"@tiptap/extension-highlight": "2.2.3",
"@tiptap/extension-history": "2.2.3",
"@tiptap/extension-horizontal-rule": "2.2.3",
"@tiptap/extension-image": "2.2.3",
"@tiptap/extension-italic": "2.2.3",
"@tiptap/extension-link": "2.2.3",
"@tiptap/extension-list-item": "2.2.3",
"@tiptap/extension-ordered-list": "2.2.3",
"@tiptap/extension-paragraph": "2.2.3",
"@tiptap/extension-placeholder": "2.2.3",
"@tiptap/extension-strike": "2.2.3",
"@tiptap/extension-text": "2.2.3",
"@tiptap/extension-underline": "2.2.3",
"@tiptap/extension-youtube": "2.2.3",
"@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.4",
"bootstrap": "5.3.2",
"clsx": "2.0.0",
"cropperjs": "1.6.1",
"cross-env": "7.0.3",
"fast-deep-equal": "3.1.3",
"ga-gtag": "1.2.0",
"graphql": "16.8.1",
"graphql-tag": "2.12.6",
"hygen": "6.2.11",
"i18next": "22.4.15",
"i18next-http-backend": "2.2.0",
"i18next-icu": "2.3.0",
"intl-messageformat": "10.5.3",
"javascript-time-ago": "2.5.9",
"js-cookie": "3.0.5",
"lint-staged": "15.1.0",
"loglevel": "1.8.1",
"loglevel-plugin-prefix": "0.8.4",
"nanostores": "0.9.5",
"@hocuspocus/provider": "^2.13.2",
"@playwright/test": "^1.44.1",
"@popperjs/core": "^2.11.8",
"@solid-primitives/media": "^2.2.9",
"@solid-primitives/memo": "^1.3.8",
"@solid-primitives/pagination": "^0.3.0",
"@solid-primitives/script-loader": "^2.2.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",
"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.69.5",
"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.0.0",
"stylelint-config-standard-scss": "^13.0.0",
"stylelint-order": "^6.0.3",
"stylelint-scss": "^6.1.0",
"swiper": "11.0.5",
"throttle-debounce": "5.0.0",
"typescript": "5.2.2",
"typograf": "7.3.0",
"uniqolor": "1.1.0",
"vike": "0.4.148",
"vite": "5.2.11",
"vite-plugin-mkcert": "^1.17.3",
"vite-plugin-node-polyfills": "0.21.0",
"vite-plugin-sass-dts": "^1.3.17",
"vite-plugin-solid": "2.10.1",
"y-prosemirror": "1.2.2",
"yjs": "13.6.12"
"solid-transition-group": "^0.2.3",
"stylelint": "^16.6.1",
"stylelint-config-standard-scss": "^13.1.0",
"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",
"y-prosemirror": "1.2.9",
"yjs": "13.6.18"
},
"overrides": {
"y-prosemirror": "1.2.2",
"yjs": "13.6.12"
"yjs": "13.6.18",
"y-prosemirror": "1.2.9"
},
"trustedDependencies": ["@biomejs/biome"]
"trustedDependencies": ["@biomejs/biome", "esbuild", "protobufjs"],
"dependencies": {
"form-data": "^4.0.0",
"idb": "^8.0.0",
"mailgun.js": "^10.2.1"
},
"engines": {
"node": "20.x"
}
}

View File

@ -27,27 +27,27 @@ export default defineConfig({
// baseURL: 'http://127.0.0.1:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
trace: 'on-first-retry'
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
use: { ...devices['Desktop Chrome'] }
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
use: { ...devices['Desktop Firefox'] }
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
use: { ...devices['Desktop Safari'] }
}
/* Test against mobile viewports. */
/* Test against many viewports.
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
@ -66,12 +66,12 @@ export default defineConfig({
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
]
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://127.0.0.1:3000',
// reuseExistingServer: !process.env.CI,
// },
/* Run local dev server before starting the tests */
//webServer: {
// command: 'npm run dev',
// url: 'https://localhost:3000',
// reuseExistingServer: !process.env.CI,
//},
})

View File

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.125 12.75H4.5C4.08854 12.75 3.75 12.4115 3.75 12C3.75 11.5885 4.08854 11.25 4.5 11.25H19.125C19.5365 11.25 19.875 11.5885 19.875 12C19.875 12.4115 19.5365 12.75 19.125 12.75Z" fill="currentColor"/>
<path
d="M14.0678 18.3593C13.8803 18.3593 13.6928 18.2916 13.547 18.151C13.2501 17.8593 13.2397 17.3853 13.5314 17.0885L18.4584 11.9999L13.5314 6.91137C13.2397 6.6145 13.2501 6.14054 13.547 5.84887C13.8439 5.56241 14.3178 5.57283 14.6043 5.8697L20.0366 11.4791C20.3178 11.7707 20.3178 12.2291 20.0366 12.5207L14.6043 18.1301C14.4584 18.2864 14.2657 18.3593 14.0678 18.3593Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 713 B

4
public/icons/expert.svg Normal file
View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11.9967 4.51318C11.5931 4.51318 11.1868 4.59652 10.8118 4.75798L7.9056 6.01058L9.83268 6.81266L11.4056 6.13558C11.5931 6.05225 11.7962 6.01058 11.9993 6.01058C12.2025 6.01058 12.4056 6.05225 12.5931 6.13558L20.5801 9.57829C20.6504 9.60693 20.6947 9.67464 20.6947 9.75016C20.6947 9.82568 20.6504 9.89339 20.5801 9.92204L12.5931 13.3647C12.2181 13.5262 11.7806 13.5262 11.4056 13.3647L3.41862 9.92204C3.34831 9.89339 3.30404 9.82568 3.30404 9.75016C3.30404 9.67464 3.34831 9.60693 3.41862 9.57829L6.47591 8.26058L11.7103 10.4429C11.804 10.4819 11.903 10.5002 11.9993 10.5002C12.291 10.5002 12.5723 10.3283 12.6921 10.0392C12.8509 9.65641 12.6712 9.21631 12.2884 9.05746L8.39258 7.43506L8.39518 7.43246L6.4681 6.63037L2.42643 8.37516C1.87435 8.60954 1.51758 9.1512 1.51758 9.75016C1.51758 10.3491 1.87435 10.8908 2.42643 11.1252L4.87435 12.1825V18.5679C4.64779 18.7371 4.49935 19.008 4.49935 19.3127V20.8127C4.49935 21.3309 4.91862 21.7502 5.43685 21.7502H5.81185C6.33008 21.7502 6.74935 21.3309 6.74935 20.8127V19.3127C6.74935 19.008 6.60091 18.7371 6.37435 18.5679V17.1512C7.42904 17.909 9.2181 18.7502 11.9993 18.7502C15.5384 18.7502 17.4889 17.3856 18.3353 16.5705C18.8379 16.0887 19.1243 15.4064 19.1243 14.6955V12.1825L21.5723 11.1252C22.1243 10.8908 22.4811 10.3491 22.4811 9.75016C22.4811 9.1512 22.1243 8.60954 21.5723 8.37516L13.1868 4.75798C12.8092 4.59652 12.403 4.51318 11.9967 4.51318ZM6.37435 12.8283L10.8118 14.7424C11.1895 14.9064 11.5931 14.9845 11.9993 14.9845C12.4056 14.9845 12.8092 14.9064 13.1868 14.7424L17.6243 12.8283V14.6955C17.6243 15.0002 17.5046 15.2892 17.2962 15.4897C16.6113 16.146 15.015 17.2502 11.9993 17.2502C8.98372 17.2502 7.38737 16.146 6.70247 15.4897C6.49414 15.2892 6.37435 15.0002 6.37435 14.6955V12.8283Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 245 B

After

Width:  |  Height:  |  Size: 245 B

View File

@ -1,7 +1,9 @@
{
"A guide to horizontal editorial: how an open journal works": "A guide to horizontal editorial: how an open journal works",
"About the project": "About the project",
"About": "About",
"About the project": "About the project",
"actions": "actions",
"Add": "Add",
"Add a few topics so that the reader knows what your content is about and can find it on pages of topics that interest them. Topics can be swapped, the first topic becomes the title": "Add a few topics so that the reader knows what your content is about and can find it on pages of topics that interest them. Topics can be swapped, the first topic becomes the title",
"Add a link or click plus to embed media": "Add a link or click plus to embed media",
"Add an embed widget": "Add an embed widget",
@ -13,41 +15,49 @@
"Add image": "Add image",
"Add images": "Add images",
"Add intro": "Add intro",
"add link": "add link",
"Add link": "Add link",
"Add rule": "Add rule",
"Add signature": "Add signature",
"Add subtitle": "Add subtitle",
"Add url": "Add url",
"try": "попробуйте",
"Add": "Add",
"Address on Discours": "Address on Discours",
"Album name": "Название aльбома",
"Alignment center": "Alignment center",
"Alignment left": "Alignment left",
"Alignment right": "Alignment right",
"All": "All",
"All articles": "All articles",
"All authors": "All authors",
"All posts": "All posts",
"all topics": "all topics",
"All topics": "All topics",
"All": "All",
"Almost done! Check your email.": "Almost done! Just checking your email.",
"and some more authors": "{restUsersCount, plural, =0 {} one { and one more user} other { and more {restUsersCount} users}}",
"Anything else": "Anything else",
"Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?",
"Are you sure you want to delete this draft?": "Are you sure you want to delete this draft?",
"Are you sure you want to to proceed the action?": "Are you sure you want to to proceed the action?",
"Art": "Art",
"article": "article",
"Artist": "Artist",
"Artworks": "Artworks",
"Audio": "Audio",
"author": "author",
"Author": "Author",
"authors": "authors",
"Authors": "Authors",
"Autotypograph": "Autotypograph",
"Back": "Back",
"Back to editor": "Back to editor",
"Back to main page": "Back to main page",
"Back": "Back",
"back to menu": "back to menu",
"Be the first to rate": "Be the first to rate",
"Become an author": "Become an author",
"bold": "bold",
"Bold": "Bold",
"Bookmarked": "Saved",
"bookmarks": "bookmarks",
"Bookmarks": "Bookmarks",
"Bullet list": "Bullet list",
"By alphabet": "By alphabet",
@ -65,8 +75,9 @@
"Can make any changes, accept or reject suggestions, and share access with others": "Can make any changes, accept or reject suggestions, and share access with others",
"Can offer edits and comments, but cannot edit the post or share access with others": "Can offer edits and comments, but cannot edit the post or share access with others",
"Can write and edit text directly, and accept or reject suggestions from others": "Can write and edit text directly, and accept or reject suggestions from others",
"Cancel changes": "Cancel changes",
"cancel": "cancel",
"Cancel": "Cancel",
"Cancel changes": "Cancel changes",
"Change password": "Change password",
"Characters": "Знаков",
"Chat Title": "Chat Title",
@ -77,6 +88,7 @@
"Co-author": "Co-author",
"Collaborate": "Help Edit",
"Collaborators": "Collaborators",
"collections": "collections",
"Collections": "Collections",
"Come up with a subtitle for your story": "Come up with a subtitle for your story",
"Come up with a title for your story": "Come up with a title for your story",
@ -85,94 +97,114 @@
"Commentator": "Commentator",
"Commenting": "Commenting",
"Comments": "Comments",
"CommentsWithCount": "{count, plural, =0 {{count} comments} one {{count} comment} few {{count} comments} other {{count} comments}}",
"Common feed": "All",
"Communities": "Communities",
"community": "community",
"Community Discussion Rules": "Community Discussion Rules",
"Community Principles": "Community Principles",
"Community values and rules of engagement for the open editorial team": "Community values and rules of engagement for the open editorial team",
"Confirm": "Confirm",
"Confirm your new password": "Confirm your new password",
"Connect": "Connect",
"Contents": "Contents",
"Contribute to free samizdat. Support Discours - an independent non-profit publication that works only for you. Become a pillar of the open newsroom": "Contribute to free samizdat. Support Discours - an independent non-profit publication that works only for you. Become a pillar of the open newsroom",
"Cooperate": "Cooperate",
"Copy link": "Copy link",
"Copy": "Copy",
"Copy link": "Copy link",
"Corrections history": "Corrections history",
"Create Chat": "Create Chat",
"Create Group": "Create a group",
"Create account": "Create an account",
"Create an account to add to your bookmarks": "Create an account to add to your bookmarks",
"Create an account to participate in discussions": "Create an account to participate in discussions",
"Create an account to publish articles": "Create an account to publish articles",
"Create an account to subscribe to new publications": "Create an account to subscribe to new publications",
"Create an account to subscribe": "Create an account to subscribe",
"Create an account to subscribe to new publications": "Create an account to subscribe to new publications",
"Create an account to vote": "Create an account to vote",
"Create Chat": "Create Chat",
"Create gallery": "Create gallery",
"Create Group": "Create a group",
"Create post": "Create post",
"Create video": "Create video",
"Crop image": "Crop image",
"Culture": "Culture",
"Current password": "Current password",
"Date of Birth": "Date of Birth",
"Decline": "Decline",
"Delete": "Delete",
"Delete cover": "Delete cover",
"Delete userpic": "Delete userpic",
"Delete": "Delete",
"delimiter": "delimiter",
"Description": "Description",
"Discours Manifest": "Discours Manifest",
"Discours Partners": "Discours Partners",
"Discours": "Discours",
"Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects": "Discours is&#160;an&#160;intellectual environment, a&#160;web space and tools that allows authors to&#160;collaborate with readers and come together to&#160;co-create publications and media projects.<br/><em>We&#160;are convinced that one voice is&#160;good, but many is&#160;better. We&#160;create the most amazing stories together</em>",
"Discours is created with our common effort": "Discours exists because of our common effort",
"Discours Manifest": "Discours Manifest",
"Discours Partners": "Discours Partners",
"Discours an open magazine about culture, science and society": "Discours an open magazine about culture, science and society",
"Discours": "Discours",
"Discussing": "Discussing",
"discussion": "Discours",
"Discussion rules": "Discussion rules",
"Discussions": "Discussions",
"Do you really want to reset all changes?": "Do you really want to reset all changes?",
"Dogma": "Dogma",
"dogma keywords": "Discours.io, dogma, editorial principles, code of ethics, journalism, community",
"Draft successfully deleted": "Draft successfully deleted",
"drafts": "drafts",
"Drafts": "Drafts",
"Drag the image to this area": "Drag the image to this area",
"Each image must be no larger than 5 MB.": "Each image must be no larger than 5 MB.",
"Edit profile": "Edit profile",
"earlier": "earlier",
"Edit": "Edit",
"Edit profile": "Edit profile",
"Editing": "Editing",
"Editor": "Editor",
"Email": "Mail",
"Enter URL address": "Enter URL address",
"email not confirmed": "email not confirmed",
"enter": "enter",
"Enter": "Enter",
"Enter a new password": "Enter a new password",
"Enter footnote text": "Enter footnote text",
"Enter image description": "Enter image description",
"Enter image title": "Enter image title",
"Enter text": "Enter text",
"Enter the code or click the link from email to confirm": "Enter the code from the email or follow the link in the email to confirm registration",
"Enter URL address": "Enter URL address",
"Enter your new password": "Enter your new password",
"Enter": "Enter",
"Error": "Error",
"Experience": "Experience",
"Failed to delete comment": "Failed to delete comment",
"FAQ": "Tips and suggestions",
"Favorite topics": "Favorite topics",
"Favorite": "Favorites",
"Feed settings": "Feed settings",
"Favorite topics": "Favorite topics",
"feed": "feed",
"Feed": "Feed",
"Feed settings": "Feed settings",
"Feedback": "Feedback",
"Fill email": "Fill email",
"Fixed": "Fixed",
"Follow the topic": "Follow the topic",
"Follow": "Follow",
"Follow the topic": "Follow the topic",
"follower": "follower",
"Followers": "Followers",
"Following": "Following",
"Forgot password?": "Forgot password?",
"Forward": "Forward",
"from": "from",
"Full name": "First and last name",
"Gallery name": "Gallery name",
"Gallery": "Gallery",
"Gallery name": "Gallery name",
"Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine": "Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine",
"Go to main page": "Go to main page",
"Group Chat": "Group Chat",
"Groups": "Groups",
"header 1": "header 1",
"Header 1": "Header 1",
"header 2": "header 2",
"Header 2": "Header 2",
"header 3": "header 3",
"Header 3": "Header 3",
"Headers": "Headers",
"Help to edit": "Help to edit",
"Help": "Помощь",
"Help to edit": "Help to edit",
"Here you can customize your profile the way you want.": "Here you can customize your profile the way you want.",
"Here you can manage all your Discours subscriptions": "Here you can manage all your Discours subscriptions",
"Here you can upload your photo": "Here you can upload your photo",
@ -182,8 +214,8 @@
"Horizontal collaborative journalistic platform": "Horizontal collaborative journalism platform",
"Hot topics": "Hot topics",
"Hotkeys": "Горячие клавиши",
"How Discours works": "How Discours works",
"How can I help/skills": "How can I help/skills",
"How Discours works": "How Discours works",
"How it works": "How it works",
"How to help": "How to help?",
"How to write a good article": "Как написать хорошую статью",
@ -193,8 +225,11 @@
"I have no account yet": "I don't have an account yet",
"I know the password": "I know the password",
"Image format not supported": "Image format not supported",
"images": "images",
"In&nbsp;bookmarks, you can save favorite discussions and&nbsp;materials that you want to return to": "In&nbsp;bookmarks, you can save favorite discussions and&nbsp;materials that you want to return to",
"Inbox": "Inbox",
"Incorrect new password confirm": "Incorrect new password confirm",
"Incorrect old password": "Incorrect old password",
"Incut": "Incut",
"Independant magazine with an open horizontal cooperation about culture, science and society": "Independant magazine with an open horizontal cooperation about culture, science and society",
"Independent media project about culture, science, art and society with horizontal editing": "Independent media project about culture, science, art and society with horizontal editing",
@ -204,44 +239,57 @@
"Introduce": "Introduction",
"Invalid email": "Check if your email is correct",
"Invalid image URL": "Invalid image URL",
"invalid password": "invalid password",
"Invalid url format": "Invalid url format",
"Invite": "Invite",
"Invite co-authors": "Invite co-authors",
"Invite collaborators": "Invite collaborators",
"Invite to collab": "Invite to Collab",
"Invite": "Invite",
"It does not look like url": "It doesn't look like a link",
"It's OK. Just enter your email to receive a link to change your password": "It's OK. Just enter your email to receive a link to change your password",
"italic": "italic",
"Italic": "Italic",
"Join": "Join",
"Join our maillist": "To receive the best postings, just enter your email",
"Join the community": "Join the community",
"Join the global community of authors!": "Join the global community of authors from all over the world!",
"Join": "Join",
"journal": "journal",
"jpg, .png, max. 10 mb.": "jpg, .png, макс. 10 мб.",
"Just start typing...": "Just start typing...",
"keywords": "Discours.io, Discours magazine, Discours, culture, science, art, society, independent journalism, literature, music, cinema, video, photography",
"Knowledge base": "Knowledge base",
"Language": "Language",
"Last rev.": "Посл. изм.",
"Let's log in": "Let's log in",
"Link copied to clipboard": "Link copied to clipboard",
"Link copied": "Link copied",
"Link copied to clipboard": "Link copied to clipboard",
"Link sent, check your email": "Link sent, check your email",
"List of authors of the open editorial community": "List of authors of the open editorial community",
"Lists": "Lists",
"literature": "literature",
"Literature": "Literature",
"Load more": "Show more",
"Loading": "Loading",
"Login and security": "Login and security",
"Logout": "Logout",
"Looks like you forgot to upload the video": "Looks like you forgot to upload the video",
"Manifest of samizdat: principles and mission of an open magazine with a horizontal editorial board": "Manifest of samizdat: principles and mission of an open magazine with a horizontal editorial board",
"Manifesto": "Manifesto",
"Many files, choose only one": "Many files, choose only one",
"Mark as read": "Mark as read",
"marker list": "marker list",
"Material card": "Material card",
"Message": "Message",
"Message text": "Message text",
"min. 1400×1400 pix": "мин. 1400×1400 пикс.",
"More": "More",
"Most commented": "Commented",
"Most read": "Readable",
"Move down": "Move down",
"Move up": "Move up",
"music": "music",
"Music": "Music",
"my feed": "my ribbon",
"My feed": "My feed",
"My subscriptions": "Subscriptions",
"Name": "Name",
@ -252,10 +300,13 @@
"Newsletter": "Newsletter",
"Night mode": "Night mode",
"No notifications yet": "No notifications yet",
"not verified": "not verified",
"Nothing here yet": "There's nothing here yet",
"Nothing is here": "There is nothing here",
"Notifications": "Notifications",
"number list": "number list",
"Or paste a link to an image": "Or paste a link to an image",
"or sign in with social networks": "or sign in with social networks",
"Ordered list": "Ordered list",
"Our regular contributor": "Our regular contributor",
"Paragraphs": "Абзацев",
@ -263,36 +314,38 @@
"Participating": "Participating",
"Participation": "Participation",
"Partners": "Partners",
"Password": "Password",
"Password again": "Password again",
"Password should be at least 8 characters": "Password should be at least 8 characters",
"Password should contain at least one number": "Password should contain at least one number",
"Password should contain at least one special character: !@#$%^&*": "Password should contain at least one special character: !@#$%^&*",
"Password updated!": "Password updated!",
"Password": "Password",
"Passwords are not equal": "Passwords are not equal",
"Paste Embed code": "Paste Embed code",
"Personal": "Personal",
"personal data usage and email notifications": "to process personal data and receive email notifications",
"Pin": "Pin",
"Platform Guide": "Platform Guide",
"Please check your email address": "Please check your email address",
"Please confirm your email to finish": "Confirm your email and the action will complete",
"Please enter a name to sign your comments and publication": "Please enter a name to sign your comments and publication",
"Please enter email": "Please enter your email",
"Please enter password again": "Please enter password again",
"Please enter password": "Please enter a password",
"Please enter password again": "Please enter password again",
"Please, confirm email": "Please confirm email",
"Please, set the article title": "Please, set the article title",
"Please, set the main topic first": "Please, set the main topic first",
"Podcasts": "Podcasts",
"Poetry": "Poetry",
"Popular authors": "Popular authors",
"Popular": "Popular",
"Popular authors": "Popular authors",
"post": "post",
"Principles": "Community principles",
"principles keywords": "Discours.io, communities, values, editorial rules, polyphony, creation",
"Professional principles that the open editorial team follows in its work": "Professional principles that the open editorial team follows in its work",
"Profile settings": "Profile settings",
"Profile": "Profile",
"Profile settings": "Profile settings",
"Publications": "Publications",
"PublicationsWithCount": "{count, plural, =0 {no publications} one {{count} publication} other {{count} publications}}",
"FollowersWithCount": "{count, plural, =0 {no followers} one {{count} follower} other {{count} followers}}",
"Publish Album": "Publish Album",
"Publish Settings": "Publish Settings",
"Published": "Published",
@ -302,62 +355,86 @@
"Quotes": "Quotes",
"Reason uknown": "Reason unknown",
"Recent": "Fresh",
"Recommend some new topic": "Recommend some new topic",
"register": "register",
"registered": "registered",
"Registered since {date}": "Registered since {date}",
"Remove link": "Remove link",
"repeat": "repeat",
"Repeat new password": "Repeat new password",
"Reply": "Reply",
"Report": "Complain",
"Report an error": "Report an error",
"Reports": "Reports",
"Required": "Required",
"Resend code": "Send confirmation",
"resend confirmation link": "resend confirmation link",
"Restore password": "Restore password",
"Rules of the journal Discours": "Rules of the journal Discours",
"Save draft": "Save draft",
"Save settings": "Save settings",
"Saving...": "Saving...",
"Scroll up": "Scroll up",
"Search": "Search",
"Search author": "Search author",
"Search topic": "Search topic",
"Search": "Search",
"Sections": "Sections",
"Security": "Security",
"Select": "Select",
"Self-publishing exists thanks to the help of wonderful people from all over the world. Thank you!": "Samizdat exists thanks to the help of wonderful people from all over the world. Thank you!",
"Send link again": "Send link again",
"Send": "Send",
"Forgot password?": "Forgot password?",
"Send link again": "Send link again",
"Settings": "Settings",
"Share publication": "Share publication",
"Settings for account, email, password and login methods.": "Settings for account, email, password and login methods.",
"Share": "Share",
"Share publication": "Share publication",
"shout": "post",
"Show": "Show",
"Show lyrics": "Show lyrics",
"Show more": "Show more",
"Show table of contents": "Show table of contents",
"Show": "Show",
"to see the voters": "to see the voters",
"sign up or sign in": "sign up or sign in",
"Site search": "Site search",
"Slug": "Slug",
"slug is used by another user": "Slug is already taken by another user",
"Social networks": "Social networks",
"Society": "Society",
"some authors": "{count} {count, plural, one {author} other {authors}}",
"some comments": "{count, plural, =0 {{count} comments} one {{count} comment} few {{count} comments} other {{count} comments}}",
"some followers": "{count} {count, plural, one {follower} other {followers}}",
"some followings": "{count, plural, =0 {no subscriptions} one {{count} subscription} other {{count} subscriptions}}",
"Some new comments to your publication": "{commentsCount, plural, one {New comment} other {{commentsCount} comments}} to your publication",
"Some new replies to your comment": "{commentsCount, plural, one {New reply} other {{commentsCount} replays}} to your publication",
"some posts": "{count, plural, =0 {no publications} one {{count} publication} other {{count} publications}}",
"some shouts": "{count} {count, plural, one {post} other {posts}}",
"some views": "{count} {count, plural, one {view} other {views}}",
"Something went wrong, check email and password": "Something went wrong. Check your email and password",
"Something went wrong, please try again": "Something went wrong, please try again",
"Song lyrics": "Song lyrics...",
"Song title": "Song title",
"Soon": "Скоро",
"Sorry, this address is already taken, please choose another one.": "Sorry, this address is already taken, please choose another one",
"Special Projects": "Special Projects",
"Special projects": "Special projects",
"Special Projects": "Special Projects",
"Specify the source and the name of the author": "Specify the source and the name of the author",
"Specify your e-mail and we will reply.": "Specify your e-mail and we will reply.",
"Start conversation": "Start a conversation",
"Start dialog": "Start dialog",
"Subsccriptions": "Subscriptions",
"Subscribe": "Subscribe",
"Subscribe to the best publications newsletter": "Subscribe to the best publications newsletter",
"Subscribe us": "Subscribe us",
"Subscribe what you like to tune your personal feed": "Subscribe to topics that interest you to customize your personal feed and get instant updates on new posts and discussions",
"Subscribe who you like to tune your personal feed": "Subscribe to authors you're interested in to customize your personal feed and get instant updates on new posts and discussions",
"Subscribe": "Subscribe",
"SubscriberWithCount": "{count, plural, =0 {no followers} one {{count} follower} other {{count} followers}}",
"subscriber": "subscriber",
"subscriber_rp": "subscriber",
"subscribers": "subscribers",
"Subscribing...": "Subscribing...",
"subscribing...": "subscribing...",
"subscription": "subscription",
"Subscription": "Subscription",
"SubscriptionWithCount": "{count, plural, =0 {no subscriptions} one {{count} subscription} other {{count} subscriptions}}",
"subscription_rp": "subscription",
"subscriptions": "subscriptions",
"Subscriptions": "Subscriptions",
"Substrate": "Substrate",
"Success": "Success",
@ -366,16 +443,18 @@
"Support Discours": "Support Discours",
"Support the project": "Support the project",
"Support us": "Support us",
"terms of use": "terms of use",
"Terms of use": "Site rules",
"terms of use keywords": "Discours.io, site rules, terms of use",
"Text checking": "Text checking",
"Thank you!": "Thank you!",
"Thank you": "Thank you",
"Thank you for reaching us": "Thank you for reaching us",
"Thank you!": "Thank you!",
"The address is already taken": "The address is already taken",
"The most interesting publications on the topic": "The most interesting publications on the topic {topicName}",
"Thematic table of contents of the magazine. Here you can find all the topics that community authors have written about.": "Thematic table of contents of the magazine. Here you can find all the topics that community authors have written about.",
"Thematic table of contents of the magazine. Here you can find all the topics that the community authors wrote about": "Thematic table of contents of the magazine. Here you can find all the topics that the community authors wrote about",
"Themes and plots": "Themes and plots",
"Please, set the article title": "Please, set the article title",
"Theory": "Theory",
"There are unsaved changes in your profile settings. Are you sure you want to leave the page without saving?": "There are unsaved changes in your profile settings. Are you sure you want to leave the page without saving?",
"There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?": "There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?",
@ -383,11 +462,10 @@
"This content is not published yet": "This content is not published yet",
"This email is": "This email is",
"This email is not verified": "This email is not verified",
"This email is verified": "This email is verified",
"This email is registered": "This email is registered",
"This email is verified": "This email is verified",
"This functionality is currently not available, we would like to work on this issue. Use the download link.": "This functionality is currently not available, we would like to work on this issue. Use the download link.",
"This month": "This month",
"No one rated yet": "No one rated yet",
"This way we&nbsp;ll realize that you&nbsp;re a real person and&nbsp;ll take your vote into account. And&nbsp;you&nbsp;ll see how others voted": "This way we&nbsp;ll realize that you&nbsp;re a real person and&nbsp;ll take your vote into account. And&nbsp;you&nbsp;ll see how others voted",
"This way you&nbsp;ll be able to subscribe to&nbsp;authors, interesting topics and&nbsp;customize your feed": "This way you&nbsp;ll be able to subscribe to&nbsp;authors, interesting topics and&nbsp;customize your feed",
"This week": "This week",
@ -395,6 +473,7 @@
"To find publications, art, comments, authors and topics of interest to you, just start typing your query": "To&nbsp;find publications, art, comments, authors and topics of&nbsp;interest to&nbsp;you, just start typing your query",
"To leave a comment please": "To leave a comment please",
"To write a comment, you must": "To write a comment, you must",
"today": "today",
"Top authors": "Authors rating",
"Top commented": "Most commented",
"Top discussed": "Top discussed",
@ -404,35 +483,46 @@
"Top topics": "Interesting topics",
"Top viewed": "Most viewed",
"Topic is supported by": "Topic is supported by",
"Topics which supported by author": "Topics which supported by author",
"topicKeywords": "{topic}, Discours.io, articles, journalism, research",
"topics": "topics",
"Topics": "Topics",
"Topics which supported by author": "Topics which supported by author",
"try": "попробуйте",
"Try to find another way": "Try to find another way",
"Unfollow the topic": "Unfollow the topic",
"Unfollow": "Unfollow",
"Unfollow the topic": "Unfollow the topic",
"Unnamed draft": "Unnamed draft",
"Unsubscribing...": "Unsubscribing...",
"Upload": "Upload",
"Upload error": "Upload error",
"Upload userpic": "Upload userpic",
"Upload video": "Upload video",
"Upload": "Upload",
"Uploading image": "Uploading image",
"user already exist": "user already exists",
"User was not found": "User was not found",
"Username": "Username",
"Userpic": "Userpic",
"Users": "Users",
"Video format not supported": "Video format not supported",
"verified": "verified",
"video": "video",
"Video": "Video",
"Video format not supported": "Video format not supported",
"view": "view",
"Views": "Views",
"Volounteering": "Volounteering",
"Want to suggest, discuss or advise something? Share a topic or an idea? Please send us a message!": "Want to suggest, discuss or advise something? Share a topic or an idea? Please send us a message!",
"We are working on collaborative editing of articles and in the near future you will have an amazing opportunity - to create together with your colleagues": "We are working on collaborative editing of articles and in the near future you will have an amazing opportunity - to create together with your colleagues",
"We can't find you, check email or": "We can't find you, check email or",
"We couldn't find anything for your request": "We&nbsp;couldn&rsquo;t find anything for your request",
"We know you, please try to login": "This email address is already registered, please try to login",
"We've sent you a message with a link to enter our website.": "We've sent you an email with a link to your email. Follow the link in the email to enter our website.",
"Welcome to Discours": "Welcome to Discours",
"Welcome to Discours to add to your bookmarks": "Welcome to Discours to add to your bookmarks",
"Welcome to Discours to participate in discussions": "Welcome to Discours to participate in discussions",
"Welcome to Discours to publish articles": "Welcome to Discours to publish articles",
"Welcome to Discours to subscribe to new publications": "Welcome to Discours to subscribe to new publications",
"Welcome to Discours to subscribe": "Welcome to Discours to subscribe",
"Welcome to Discours to subscribe to new publications": "Welcome to Discours to subscribe to new publications",
"Welcome to Discours to vote": "Welcome to Discours to vote",
"Welcome to Discours": "Welcome to Discours",
"Where": "From",
"Why you can earn a hole in your karma and how to receive rays of gratitude for your contribution to discussions in samizdat communities": "Why you can earn a hole in your karma and how to receive rays of gratitude for your contribution to discussions in samizdat communities",
"Words": "Слов",
@ -445,8 +535,9 @@
"Write good articles, comment\nand it won't be so empty here": "Write good articles, comment\nand it won't be so empty here",
"Write message": "Write a message",
"Write to us": "Write to us",
"You can": "You can",
"Write your colleagues name or email": "Write your colleague's name or email",
"yesterday": "yesterday",
"You can": "You can",
"You can download multiple tracks at once in .mp3, .wav or .flac formats": "You can download multiple tracks at once in .mp3, .wav or .flac formats",
"You can now login using your new password": "Теперь вы можете входить с помощью нового пароля",
"You can't edit this post": "You can't edit this post",
@ -454,91 +545,7 @@
"You&nbsp;ll be able to participate in&nbsp;discussions, rate others' comments and&nbsp;learn about&nbsp;new responses": "You&nbsp;ll be able to participate in&nbsp;discussions, rate others' comments and&nbsp;learn about&nbsp;new responses",
"You've confirmed email": "You've confirmed email",
"You've reached a non-existed page": "You've reached a non-existed page",
"Your contact for answer": "Your contact for answer",
"Your email": "Your email",
"Your name will appear on your profile page and as your signature in publications, comments and responses.": "Your name will appear on your profile page and as your signature in publications, comments and responses",
"actions": "actions",
"add link": "add link",
"all topics": "all topics",
"and some more authors": "{restUsersCount, plural, =0 {} one { and one more user} other { and more {restUsersCount} users}}",
"article": "article",
"author": "author",
"authors": "authors",
"authorsWithCount": "{count} {count, plural, one {author} other {authors}}",
"back to menu": "back to menu",
"bold": "bold",
"bookmarks": "bookmarks",
"cancel": "cancel",
"collections": "collections",
"community": "community",
"contents": "contents",
"delimiter": "delimiter",
"discussion": "Discours",
"dogma keywords": "Discours.io, dogma, editorial principles, code of ethics, journalism, community",
"drafts": "drafts",
"earlier": "earlier",
"email not confirmed": "email not confirmed",
"enter": "enter",
"feed": "feed",
"follower": "follower",
"followersWithCount": "{count} {count, plural, one {follower} other {followers}}",
"from": "from",
"header 1": "header 1",
"header 2": "header 2",
"header 3": "header 3",
"images": "images",
"invalid password": "invalid password",
"italic": "italic",
"journal": "journal",
"jpg, .png, max. 10 mb.": "jpg, .png, макс. 10 мб.",
"keywords": "Discours.io, Discours magazine, Discours, culture, science, art, society, independent journalism, literature, music, cinema, video, photography",
"literature": "literature",
"marker list": "marker list",
"min. 1400×1400 pix": "мин. 1400×1400 пикс.",
"music": "music",
"my feed": "my ribbon",
"not verified": "not verified",
"number list": "number list",
"or sign in with social networks": "or sign in with social networks",
"personal data usage and email notifications": "to process personal data and receive email notifications",
"post": "post",
"principles keywords": "Discours.io, communities, values, editorial rules, polyphony, creation",
"register": "register",
"registered": "registered",
"repeat": "repeat",
"resend confirmation link": "resend confirmation link",
"shout": "post",
"shoutsWithCount": "{count} {count, plural, one {post} other {posts}}",
"sign up or sign in": "sign up or sign in",
"slug is used by another user": "Slug is already taken by another user",
"subscriber": "subscriber",
"subscriber_rp": "subscriber",
"subscribers": "subscribers",
"subscribing...": "subscribing...",
"subscription": "subscription",
"subscription_rp": "subscription",
"subscriptions": "subscriptions",
"terms of use keywords": "Discours.io, site rules, terms of use",
"terms of use": "terms of use",
"today": "today",
"topicKeywords": "{topic}, Discours.io, articles, journalism, research",
"topics": "topics",
"user already exist": "user already exists",
"verified": "verified",
"video": "video",
"view": "view",
"viewsWithCount": "{count} {count, plural, one {view} other {views}}",
"yesterday": "yesterday",
"Failed to delete comment": "Failed to delete comment",
"It's OK. Just enter your email to receive a link to change your password": "It's OK. Just enter your email to receive a link to change your password",
"Restore password": "Restore password",
"Subscribing...": "Subscribing...",
"Unsubscribing...": "Unsubscribing...",
"Login and security": "Login and security",
"Settings for account, email, password and login methods.": "Settings for account, email, password and login methods.",
"Current password": "Current password",
"Confirm your new password": "Confirm your new password",
"Connect": "Connect",
"Incorrect old password": "Incorrect old password",
"Repeat new password": "Repeat new password",
"Incorrect new password confirm": "Incorrect new password confirm"
"Your name will appear on your profile page and as your signature in publications, comments and responses.": "Your name will appear on your profile page and as your signature in publications, comments and responses"
}

View File

@ -1,8 +1,10 @@
{
"A guide to horizontal editorial: how an open journal works": "Гид по горизонтальной редакции: как работает открытый журнал",
"A short introduction to keep the reader interested": "Добавьте вступление, чтобы заинтересовать читателя",
"About the project": "О проекте",
"About": "О себе",
"About the project": "О проекте",
"actions": "действия",
"Add": "Добавить",
"Add a few topics so that the reader knows what your content is about and can find it on pages of topics that interest them. Topics can be swapped, the first topic becomes the title": "Добавьте несколько тем, чтобы читатель знал, о чем ваш материал, и мог найти его на страницах интересных ему тем. Темы можно менять местами, первая тема становится заглавной",
"Add a link or click plus to embed media": "Добавьте ссылку или нажмите плюс для вставки медиа",
"Add an embed widget": "Добавить embed-виджет",
@ -15,43 +17,52 @@
"Add images": "Добавить изображения",
"Add intro": "Добавить вступление",
"Add link": "Добавить ссылку",
"add link": "добавить ссылку",
"Add rule": "Добавить разделитель",
"Add signature": "Добавить подпись",
"Add subtitle": "Добавить подзаголовок",
"Add to bookmarks": "Добавить в закладки",
"Add url": "Добавить ссылку",
"Add": "Добавить",
"Address on Discours": "Адрес на Дискурсе",
"Album name": "Название альбома",
"Alignment center": "По центру",
"Alignment left": "По левому краю",
"Alignment right": "По правому краю",
"All": "Все",
"All articles": "Все материалы",
"All authors": "Все авторы",
"All posts rating": "Рейтинг всех постов",
"All posts": "Все публикации",
"All posts rating": "Рейтинг всех постов",
"All topics": "Все темы",
"All": "Общая лента",
"all topics": "все темы",
"Almost done! Check your email.": "Почти готово! Осталось подтвердить вашу почту.",
"and some more authors": "{restUsersCount, plural, =0 {} one { и ещё 1 пользователя} few { и ещё {restUsersCount} пользователей} other { и ещё {restUsersCount} пользователей}}",
"Anything else": "Что-либо ещё",
"Are you sure you want to delete this comment?": "Уверены, что хотите удалить этот комментарий?",
"Are you sure you want to delete this draft?": "Уверены, что хотите удалить этот черновик?",
"Are you sure you want to to proceed the action?": "Вы уверены, что хотите продолжить?",
"Art": "Искусство",
"article": "статья",
"Artist": "Исполнитель",
"Artist...": "Исполнитель...",
"Artworks": "Артворки",
"Audio": "Аудио",
"Author": "Автор",
"author": "автор",
"Authors": "Авторы",
"authors": "авторы",
"Autotypograph": "Автотипограф",
"Back": "Назад",
"Back to editor": "Вернуться в редактор",
"Back to main page": "Вернуться на главную",
"Back": "Назад",
"back to menu": "назад в меню",
"Be the first to rate": "Оцените первым",
"Become an author": "Стать автором",
"Bold": "Жирный",
"bold": "жирный",
"Bookmarked": "Сохранено",
"Bookmarks": "Закладки",
"bookmarks": "закладки",
"Bullet list": "Маркированный список",
"By alphabet": "По алфавиту",
"By authors": "По авторам",
@ -68,8 +79,9 @@
"Can make any changes, accept or reject suggestions, and share access with others": "Может вносить любые изменения, принимать и отклонять предложения, а также делиться доступом с другими",
"Can offer edits and comments, but cannot edit the post or share access with others": "Может предлагать правки и комментарии, но не может изменять пост и делиться доступом с другими",
"Can write and edit text directly, and accept or reject suggestions from others": "Может писать и редактировать текст напрямую, а также принимать или отклонять предложения других",
"Cancel changes": "Отменить изменения",
"Cancel": "Отмена",
"cancel": "отменить",
"Cancel changes": "Отменить изменения",
"Change password": "Сменить пароль",
"Characters": "Знаков",
"Chat Title": "Тема дискурса",
@ -81,107 +93,129 @@
"Collaborate": "Помочь редактировать",
"Collaborators": "Соавторы",
"Collections": "Коллекции",
"collections": "коллекции",
"Come up with a subtitle for your story": "Придумайте подзаголовок вашей истории",
"Come up with a title for your story": "Придумайте заголовок вашей истории",
"Coming soon": "Уже скоро",
"Comment successfully deleted": "Комментарий успешно удален",
"Comment": "Комментировать",
"Comment successfully deleted": "Комментарий успешно удален",
"Commentator": "Комментатор",
"Commenting": "Комментирование",
"Comments": "Комментарии",
"CommentsWithCount": "{count, plural, =0 {{count} комментариев} one {{count} комментарий} few {{count} комментария} other {{count} комментариев}}",
"Common feed": "Общая лента",
"Communities": "Сообщества",
"community": "сообщество",
"Community Discussion Rules": "Правила дискуссий в сообществе",
"Community Principles": "Принципы сообщества",
"Community values and rules of engagement for the open editorial team": "Ценности сообщества и правила взаимодействия открытой редакции",
"Confirm": "Подтвердить",
"Confirm your new password": "Подтвердите новый пароль",
"Connect": "Привязать",
"Contents": "Оглавление",
"Contribute to free samizdat. Support Discours - an independent non-profit publication that works only for you. Become a pillar of the open newsroom": "Внесите вклад в свободный самиздат. Поддержите Дискурс — независимое некоммерческое издание, которое работает только для вас. Станьте опорой открытой редакции",
"Cooperate": "Соучаствовать",
"Copy link": "Скопировать ссылку",
"Copy": "Скопировать",
"Copy link": "Скопировать ссылку",
"Corrections history": "История правок",
"Create Chat": "Создать чат",
"Create Group": "Создать группу",
"Create account": "Создать аккаунт",
"Create an account to add to your bookmarks": "Создайте аккаунт, чтобы добавить в закладки",
"Create an account to participate in discussions": "Создайте аккаунт для участия в дискуссиях",
"Create an account to publish articles": "Создайте аккаунт, чтобы публиковать статьи",
"Create an account to subscribe to new publications": "Создайте аккаунт для подписки на новые публикации",
"Create an account to subscribe": "Создайте аккаунт, чтобы подписаться",
"Create an account to subscribe to new publications": "Создайте аккаунт для подписки на новые публикации",
"Create an account to vote": "Создайте аккаунт, чтобы голосовать",
"Create Chat": "Создать чат",
"Create gallery": "Создать галерею",
"Create Group": "Создать группу",
"Create post": "Создать публикацию",
"Create video": "Создать видео",
"create_chat": "Создать чат",
"create_group": "Создать группу",
"Crop image": "Кадрировать изображение",
"Culture": "Культура",
"Current password": "Текущий пароль",
"Date of Birth": "Дата рождения",
"Decline": "Отмена",
"Delete": "Удалить",
"Delete cover": "Удалить обложку",
"Delete userpic": "Удалить аватар",
"Delete": "Удалить",
"delimiter": "разделитель",
"Description": "Описание",
"Discours Manifest": "Манифест Дискурса",
"Discours Partners": "Партнеры Дискурса",
"Discours": "Дискурс",
"Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects": "Дискурс&#160;&#8212; это интеллектуальная среда, веб-пространство и&#160;инструменты, которые позволяют авторам сотрудничать&#160;с&#160;читателями и&#160;объединяться для совместного создания публикаций и&#160;медиапроектов.<br/>Мы&#160;убеждены, один голос хорошо, а&#160;много&#160;&#8212; лучше. Самые потрясающиe истории мы создаём вместе.",
"Discours is created with our common effort": "Дискурс существует благодаря нашему общему вкладу",
"Discours Manifest": "Манифест Дискурса",
"Discours Partners": "Партнеры Дискурса",
"Discours an open magazine about culture, science and society": "Дискурс открытый журнал о культуре, науке и обществе",
"Discours": "Дискурс",
"Discours_theme": "Тема дискурса",
"Discussing": "Обсуждаемое",
"discussion": "дискурс",
"Discussion rules": "Правила дискуссий",
"Discussions": "Дискуссии",
"Do you really want to reset all changes?": "Вы действительно хотите сбросить все изменения?",
"Dogma": "Догма",
"dogma keywords": "Discours.io, догма, редакционные принципы, этический кодекс, журналистика, сообщество",
"Draft successfully deleted": "Черновик успешно удален",
"Drafts": "Черновики",
"drafts": "черновики",
"Drag the image to this area": "Перетащите изображение в эту область",
"Each image must be no larger than 5 MB.": "Каждое изображение должно быть размером не больше 5 мб.",
"Edit profile": "Редактировать профиль",
"earlier": "ранее",
"Edit": "Редактировать",
"Edit profile": "Редактировать профиль",
"Editing": "Редактирование",
"Editor": "Редактор",
"Email": "Почта",
"Enter URL address": "Введите адрес ссылки",
"email not confirmed": "email не подтвержден",
"Enter": "Войти",
"enter": "войти",
"Enter a new password": "Введите новый пароль",
"Enter footnote text": "Введите текст сноски",
"Enter image description": "Введите описание изображения",
"Enter image title": "Введите название изображения",
"Enter text": "Введите текст",
"Enter the code or click the link from email to confirm": "Введите код из письма или пройдите по ссылке в письме для подтверждения регистрации",
"Enter URL address": "Введите адрес ссылки",
"Enter your new password": "Введите новый пароль",
"Enter": "Войти",
"This content is not published yet": "Содержимое ещё не опубликовано",
"Error": "Ошибка",
"Experience": "Личный опыт",
"Failed to delete comment": "Не удалось удалить комментарий",
"FAQ": "Советы и предложения",
"Favorite topics": "Избранные темы",
"Favorite": "Избранное",
"Feed settings": "Настроить ленту",
"Favorite topics": "Избранные темы",
"Feed": "Лента",
"feed": "лента",
"Feed settings": "Настроить ленту",
"Feedback": "Обратная связь",
"Fill email": "Введите почту",
"Fixed": "Все поправлено",
"Follow the topic": "Подписаться на тему",
"Follow": "Подписаться",
"Follow the topic": "Подписаться на тему",
"follower": "подписчик",
"Followers": "Подписчики",
"Following": "Вы подписаны",
"Forgot password?": "Забыли пароль?",
"Forward": "Переслать",
"from": "от",
"Full name": "Имя и фамилия",
"Gallery name": "Название галереи",
"Gallery": "Галерея",
"Gallery name": "Название галереи",
"Genre...": "Жанр...",
"Get notifications": "Получать уведомления",
"Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine": "Познакомитесь с выдающимися людьми нашего времени, участвуйте в редактировании и обсуждении статей, выступайте экспертом, оценивайте материалы других авторов со всего мира и определяйте, какие статьи будут опубликованы в журнале",
"Go to main page": "Перейти на главную",
"Group Chat": "Общий чат",
"Groups": "Группы",
"Header 1": "Заголовок 1",
"Header 2": "Заголовок 2",
"Header 3": "Заголовок 3",
"Header": "Заголовок",
"Header 1": "Заголовок 1",
"header 1": "заголовок 1",
"Header 2": "Заголовок 2",
"header 2": "заголовок 2",
"Header 3": "Заголовок 3",
"header 3": "заголовок 3",
"Headers": "Заголовки",
"Help to edit": "Помочь редактировать",
"Help": "Помощь",
"Help to edit": "Помочь редактировать",
"Here you can customize your profile the way you want.": "Здесь можно настроить свой профиль так, как вы хотите.",
"Here you can manage all your Discours subscriptions": "Здесь можно управлять всеми своими подписками на Дискурсе",
"Here you can upload your photo": "Здесь вы можете загрузить свою фотографию",
@ -191,8 +225,8 @@
"Horizontal collaborative journalistic platform": "Открытая платформа<br/>для независимой журналистики",
"Hot topics": "Горячие темы",
"Hotkeys": "Горячие клавиши",
"How Discours works": "Как устроен Дискурс",
"How can I help/skills": "Чем могу помочь/навыки",
"How Discours works": "Как устроен Дискурс",
"How it works": "Как это работает",
"How to help": "Как помочь?",
"How to write a good article": "Как написать хорошую статью",
@ -202,8 +236,11 @@
"I have no account yet": "У меня еще нет аккаунта",
"I know the password": "Я знаю пароль!",
"Image format not supported": "Тип изображения не поддерживается",
"images": "изображения",
"In&nbsp;bookmarks, you can save favorite discussions and&nbsp;materials that you want to return to": "В&nbsp;закладках можно сохранять избранные дискуссии и&nbsp;материалы, к&nbsp;которым хочется вернуться",
"Inbox": "Входящие",
"Incorrect new password confirm": "Неверное подтверждение нового пароля",
"Incorrect old password": "Старый пароль не верен",
"Incut": "Подверстка",
"Independant magazine with an open horizontal cooperation about culture, science and society": "Независимый журнал с открытой горизонтальной редакцией о культуре, науке и обществе",
"Independent media project about culture, science, art and society with horizontal editing": "Независимый медиапроект о культуре, науке, искусстве и обществе с горизонтальной редакцией",
@ -213,47 +250,60 @@
"Introduce": "Представление",
"Invalid email": "Проверьте правильность ввода почты",
"Invalid image URL": "Некорректная ссылка на изображение",
"invalid password": "некорректный пароль",
"Invalid url format": "Неверный формат ссылки",
"Invite": "Пригласить",
"Invite co-authors": "Пригласить соавторов",
"Invite collaborators": "Пригласить соавторов",
"Invite experts": "Пригласить экспертов",
"Invite to collab": "Пригласить к участию",
"Invite": "Пригласить",
"It does not look like url": "Это не похоже на ссылку",
"It's OK. Just enter your email to receive a link to change your password": "Ничего страшного. Просто укажите свою почту, чтобы получить ссылку для смены пароля",
"Italic": "Курсив",
"italic": "курсив",
"Join": "Присоединиться",
"Join our maillist": "Чтобы получать рассылку лучших публикаций, просто укажите свою почту",
"Join the community": "Присоединиться к сообществу",
"Join the global community of authors!": "Присоединятесь к глобальному сообществу авторов со всего мира!",
"Join": "Присоединиться",
"journal": "журнал",
"jpg, .png, max. 10 mb.": "jpg, .png, макс. 10 мб.",
"Just start typing...": "Просто начните печатать...",
"Karma": "Карма",
"keywords": "Discours.io, журнал Дискурс, Дискурс, культура, наука, искусство, общество, независимая журналистика, литература, музыка, кино, видео, фотографии",
"Knowledge base": "База знаний",
"Language": "Язык",
"Last rev.": "Посл. изм.",
"Let's log in": "Давайте авторизуемся",
"Link copied to clipboard": "Ссылка скопирована в буфер обмена",
"Link copied": "Ссылка скопирована",
"Link copied to clipboard": "Ссылка скопирована в буфер обмена",
"Link sent, check your email": "Ссылка отправлена, проверьте почту",
"List of authors of the open editorial community": "Список авторов сообщества открытой редакции",
"Lists": "Списки",
"Literature": "Литература",
"literature": "литература",
"Load more": "Показать ещё",
"Loading": "Загрузка",
"Login and security": "Вход и безопасность",
"Logout": "Выход",
"Looks like you forgot to upload the video": "Похоже, что вы забыли загрузить видео",
"Manifest of samizdat: principles and mission of an open magazine with a horizontal editorial board": "Манифест самиздата: принципы и миссия открытого журнала с горизонтальной редакцией",
"Manifesto": "Манифест",
"Many files, choose only one": "Много файлов, выберете один",
"Mark as read": "Отметить прочитанным",
"marker list": "маркир. список",
"Material card": "Карточка материала",
"Message": "Написать",
"Message text": "Текст сообщения",
"min. 1400×1400 pix": "мин. 1400×1400 пикс.",
"More": "Ещё",
"Most commented": "Комментируемое",
"Most read": "Читаемое",
"Move down": "Переместить вниз",
"Move up": "Переместить вверх",
"Music": "Музыка",
"music": "музыка",
"My feed": "Моя лента",
"my feed": "моя лента",
"My subscriptions": "Подписки",
"Name": "Имя",
"New literary work": "Новое произведение",
@ -264,10 +314,14 @@
"Night mode": "Ночная тема",
"No notifications yet": "Уведомлений пока нет",
"No such account, please try to register": "Такой адрес не найден, попробуйте зарегистрироваться",
"not verified": "ещё не подтверждён",
"Nothing here yet": "Здесь пока ничего нет",
"Nothing is here": "Здесь ничего нет",
"Notifications": "Уведомления",
"number list": "нумер. список",
"or": "или",
"Or paste a link to an image": "Или вставьте ссылку на изображение",
"or sign in with social networks": "или войдите через соцсеть",
"Ordered list": "Нумерованный список",
"Our regular contributor": "Наш постоянный автор",
"Paragraphs": "Абзацев",
@ -275,15 +329,16 @@
"Participating": "Участвовать",
"Participation": "Соучастие",
"Partners": "Партнёры",
"Password": "Пароль",
"Password again": "Пароль ещё раз",
"Password should be at least 8 characters": "Пароль должен быть не менее 8 символов",
"Password should contain at least one number": "Пароль должен содержать хотя бы одну цифру",
"Password should contain at least one special character: !@#$%^&*": "Пароль должен содержать хотя бы один спецсимвол: !@#$%^&*",
"Password updated!": "Пароль обновлен!",
"Password": "Пароль",
"Passwords are not equal": "Пароли не совпадают",
"Paste Embed code": "Вставьте embed код",
"Personal": "Личные",
"personal data usage and email notifications": "на обработку персональных данных и на получение почтовых уведомлений",
"Pin": "Закрепить",
"Platform Guide": "Гид по дискурсу",
"Please check your email address": "Пожалуйста, проверьте введенный адрес почты",
@ -291,25 +346,25 @@
"Please confirm your email to finish": "Подтвердите почту и действие совершится",
"Please enter a name to sign your comments and publication": "Пожалуйста, введите имя, которое будет отображаться на сайте",
"Please enter email": "Пожалуйста, введите почту",
"Please enter password again": "Пожалуйста, введите пароль ещё рез",
"Please enter password": "Пожалуйста, введите пароль",
"Please enter password again": "Пожалуйста, введите пароль ещё рез",
"Please, confirm email": "Пожалуйста, подтвердите электронную почту",
"Please, set the main topic first": "Пожалуйста, сначала выберите главную тему",
"Please, set the article title": "Пожалуйста, задайте заголовок статьи",
"Please, set the main topic first": "Пожалуйста, сначала выберите главную тему",
"Podcasts": "Подкасты",
"Poetry": "Поэзия",
"Popular authors": "Популярные авторы",
"Popular": "Популярное",
"Popular authors": "Популярные авторы",
"post": "пост",
"Preview": "Предпросмотр",
"Principles": "Принципы сообщества",
"principles keywords": "Discours.io, сообщества, ценности, правила редакции, многоголосие, созидание",
"Professional principles that the open editorial team follows in its work": "Профессиональные принципы, которым открытая редакция следует в работе",
"Profile": "Профиль",
"Profile settings": "Настройки профиля",
"Profile successfully saved": "Профиль успешно сохранён",
"Profile": "Профиль",
"Publication settings": "Настройки публикации",
"Publications": "Публикации",
"PublicationsWithCount": "{count, plural, =0 {нет публикаций} one {{count} публикация} few {{count} публикации} other {{count} публикаций}}",
"FollowersWithCount": "{count, plural, =0 {нет подписчиков} one {{count} подписчик} few {{count} подписчика} other {{count} подписчиков}}",
"Publish": "Опубликовать",
"Publish Album": "Опубликовать альбом",
"Publish Settings": "Настройки публикации",
@ -320,45 +375,65 @@
"Quotes": "Цитаты",
"Reason uknown": "Причина неизвестна",
"Recent": "Свежее",
"Recommend some new topic": "Предложить тему",
"register": "зарегистрируйтесь",
"registered": "уже зарегистрирован",
"Registered since {date}": "На сайте c {date}",
"Release date...": "Дата выхода...",
"Remove link": "Убрать ссылку",
"repeat": "повторить",
"Repeat new password": "Повторите новый пароль",
"Reply": "Ответить",
"Report": "Пожаловаться",
"Report an error": "Сообщить об ошибке",
"Reports": "Репортажи",
"Required": "Поле обязательно для заполнения",
"Resend code": "Выслать подтверждение",
"Forgot password?": "Забыли пароль?",
"resend confirmation link": "отправить ссылку ещё раз",
"Restore password": "Восстановить пароль",
"Rules of the journal Discours": "Правила журнала Дискурс",
"Save": "Сохранить",
"Save draft": "Сохранить черновик",
"Save settings": "Сохранить настройки",
"Save": "Сохранить",
"Saving...": "Сохраняем...",
"Scroll up": "Наверх",
"Search": "Поиск",
"Search author": "Поиск автора",
"Search topic": "Поиск темы",
"Search": "Поиск",
"Sections": "Разделы",
"Security": "Безопасность",
"Select": "Выбрать",
"Self-publishing exists thanks to the help of wonderful people from all over the world. Thank you!": "Самиздат существуют благодаря помощи замечательных людей со всего мира. Спасибо Вам!",
"Send link again": "Прислать ссылку ещё раз",
"Send": "Отправить",
"Send link again": "Прислать ссылку ещё раз",
"Settings": "Настройки",
"Share publication": "Поделиться публикацией",
"Settings for account, email, password and login methods.": "Настройки аккаунта, почты, пароля и способов входа.",
"Share": "Поделиться",
"Share publication": "Поделиться публикацией",
"Short opening": "Расскажите вашу историю...",
"shout": "пост",
"shout not found": "публикация не найдена",
"Show": "Показать",
"Show lyrics": "Текст песни",
"Show more": "Читать дальше",
"Show table of contents": "Показать главление",
"Show": "Показать",
"to see the voters": "чтобы видеть кто голосует",
"sign in": "войти",
"sign up": "зарегистрироваться",
"sign up or sign in": "зарегистрироваться или войти",
"Site search": "Поиск по сайту",
"Slug": "Постоянная ссылка",
"slug is used by another user": "Имя уже занято другим пользователем",
"Social networks": "Социальные сети",
"Society": "Общество",
"some authors": "{count} {count, plural, one {автор} few {автора} other {авторов}}",
"some comments": "{count, plural, =0 {{count} комментариев} one {{count} комментарий} few {{count} комментария} other {{count} комментариев}}",
"some followers": "{count} {count, plural, one {подписчик} few {подписчика} other {подписчиков}}",
"some followings": "{count, plural, =0 {нет подписок} one {{count} подписка} few {{count} подписки} other {{count} подписок}}",
"Some new comments to your publication": "{commentsCount, plural, one {Новый комментарий} few {{commentsCount} новых комментария} other {{commentsCount} новых комментариев}} к вашей публикации",
"Some new replies to your comment": "{commentsCount, plural, one {Новый ответ} few {{commentsCount} новых ответа} other {{commentsCount} новых ответов}} на ваш комментарий к публикации",
"some posts": "{count, plural, =0 {нет публикаций} one {{count} публикация} few {{count} публикации} other {{count} публикаций}}",
"some shouts": "{count} {count, plural, one {публикация} few {публикации} other {публикаций}}",
"some views": "{count} {count, plural, one {просмотр} few {просмотрa} other {просмотров}}",
"Something went wrong, check email and password": "Что-то пошло не так. Проверьте адрес электронной почты и пароль",
"Something went wrong, please try again": "Что-то пошло не так, попробуйте еще раз",
"Song lyrics": "Текст песни...",
@ -368,18 +443,23 @@
"Special Projects": "Спецпроекты",
"Special projects": "Спецпроекты",
"Specify the source and the name of the author": "Укажите источник и имя автора",
"Specify your e-mail and we will reply.": "Укажите ваш e-mail и мы обязательно ответим.",
"squib": "Подверстка",
"Start conversation": "Начать беседу",
"Start dialog": "Начать диалог",
"Subheader": "Подзаголовок",
"Subscribe": "Подписаться",
"Subscribe to comments": "Подписаться на комментарии",
"Subscribe to the best publications newsletter": "Подпишитесь на рассылку лучших публикаций",
"Subscribe us": "Подпишитесь на&nbsp;нас",
"Subscribe what you like to tune your personal feed": "Подпишитесь на интересующие вас темы, чтобы настроить вашу персональную ленту и моментально узнавать о новых публикациях и обсуждениях",
"Subscribe who you like to tune your personal feed": "Подпишитесь на интересующих вас авторов, чтобы настроить вашу персональную ленту и моментально узнавать о новых публикациях и обсуждениях",
"Subscribe": "Подписаться",
"SubscriberWithCount": "{count, plural, =0 {нет подписчиков} one {{count} подписчик} few {{count} подписчика} other {{count} подписчиков}}",
"subscriber": "подписчик",
"subscriber_rp": "подписчика",
"subscribers": "подписчиков",
"subscribing...": "Подписка...",
"Subscribing...": "Подписываем...",
"Subscription": "Подписка",
"SubscriptionWithCount": "{count, plural, =0 {нет подписок} one {{count} подписка} few {{count} подписки} other {{count} подписок}}",
"Subscriptions": "Подписки",
"Substrate": "Подложка",
"Success": "Успешно",
@ -389,9 +469,12 @@
"Support the project": "Поддержать проект",
"Support us": "Помочь журналу",
"Terms of use": "Правила сайта",
"terms of use": "правилами пользования сайтом",
"terms of use keywords": "Discours.io, правила сайта, terms of use",
"Text checking": "Проверка текста",
"Thank you!": "Спасибо Вам!",
"Thank you": "Благодарности",
"Thank you for reaching us": "Спасибо, что связались с нами",
"Thank you!": "Спасибо Вам!",
"The address is already taken": "Адрес уже занят",
"The most interesting publications on the topic": "Самые интересные публикации по теме {topicName}",
"Thematic table of contents of the magazine. Here you can find all the topics that community authors have written about.": "Тематическое оглавление журнала. Здесь можно найти все темы, о которых писали авторы сообщества.",
@ -401,14 +484,14 @@
"There are unsaved changes in your profile settings. Are you sure you want to leave the page without saving?": "В настройках вашего профиля есть несохраненные изменения. Уверены, что хотите покинуть страницу без сохранения?",
"There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?": "В настройках публикации есть несохраненные изменения. Уверены, что хотите покинуть страницу без сохранения?",
"This comment has not yet been rated": "Этот комментарий еще пока никто не оценил",
"This content is not published yet": "Содержимое ещё не опубликовано",
"This email is": "Этот email",
"This email is not verified": "Этот email не подтвержден",
"This email is verified": "Этот email подтвержден",
"try": "попробуйте",
"This email is registered": "Этот email уже зарегистрирован",
"This email is verified": "Этот email подтвержден",
"This functionality is currently not available, we would like to work on this issue. Use the download link.": "В данный момент этот функционал не доступен, бы работаем над этой проблемой. Воспользуйтесь загрузкой по ссылке.",
"This month": "За месяц",
"No one rated yet": "Ещё никто не оценивал",
"This post has not been rated yet": "Эту публикацию еще пока никто не оценил",
"This way we&nbsp;ll realize that you&nbsp;re a real person and&nbsp;ll take your vote into account. And&nbsp;you&nbsp;ll see how others voted": "Так мы&nbsp;поймем, что вы&nbsp;реальный человек, и&nbsp;учтем ваш голос. А&nbsp;вы&nbsp;увидите, как проголосовали другие",
"This way you&nbsp;ll be able to subscribe to&nbsp;authors, interesting topics and&nbsp;customize your feed": "Так вы&nbsp;сможете подписаться на&nbsp;авторов, интересные темы и&nbsp;настроить свою ленту",
"This week": "За неделю",
@ -416,6 +499,7 @@
"To find publications, art, comments, authors and topics of interest to you, just start typing your query": "Для поиска публикаций, искусства, комментариев, интересных вам авторов и&nbsp;тем, просто начните вводить ваш запрос",
"To leave a comment please": "Чтобы оставить комментарий, необходимо",
"To write a comment, you must": "Чтобы написать комментарий, необходимо",
"today": "сегодня",
"Top authors": "Рейтинг авторов",
"Top commented": "Самое комментируемое",
"Top discussed": "Обсуждаемое",
@ -425,35 +509,46 @@
"Top topics": "Интересные темы",
"Top viewed": "Самое читаемое",
"Topic is supported by": "Тему поддерживают",
"Topics which supported by author": "Автор поддерживает темы",
"topicKeywords": "{topic}, Discours.io, статьи, журналистика, исследования",
"Topics": "Темы",
"topics": "темы",
"Topics which supported by author": "Автор поддерживает темы",
"try": "попробуйте",
"Try to find another way": "Попробуйте найти по-другому",
"Unfollow the topic": "Отписаться от темы",
"Unfollow": "Отписаться",
"Unfollow the topic": "Отписаться от темы",
"Unnamed draft": "Черновик без названия",
"Unsubscribing...": "Отписываем...",
"Upload": "Загрузить",
"Upload error": "Ошибка загрузки",
"Upload userpic": "Загрузить аватар",
"Upload video": "Загрузить видео",
"Upload": "Загрузить",
"Uploading image": "Загружаем изображение",
"user already exist": "пользователь уже существует",
"User was not found": "Пользователь не найден",
"Username": "Имя пользователя",
"Userpic": "Аватар",
"Users": "Пользователи",
"Video format not supported": "Тип видео не поддерживается",
"verified": "уже подтверждён",
"Video": "Видео",
"video": "видео",
"Video format not supported": "Тип видео не поддерживается",
"view": "просмотр",
"Views": "Просмотры",
"Volounteering": "Волонтёрство",
"Want to suggest, discuss or advise something? Share a topic or an idea? Please send us a message!": "Хотите что-то предложить, обсудить или посоветовать? Поделиться темой или идеей? Напишите нам скорее!",
"We are working on collaborative editing of articles and in the near future you will have an amazing opportunity - to create together with your colleagues": "Мы работаем над коллаборативным редактированием статей и в ближайшем времени у вас появиться удивительная возможность - творить вместе с коллегами",
"We can't find you, check email or": "Не можем вас найти, проверьте адрес электронной почты или",
"We couldn't find anything for your request": "Мы&nbsp;не&nbsp;смогли ничего найти по&nbsp;вашему запросу",
"We know you, please try to login": "Такой адрес почты уже зарегистрирован, попробуйте залогиниться",
"We've sent you a message with a link to enter our website.": "Мы выслали вам письмо с ссылкой на почту. Перейдите по ссылке в письме, чтобы войти на сайт.",
"Welcome to Discours": "Добро пожаловать в Дискурс",
"Welcome to Discours to add to your bookmarks": "Войдите в Дискурс, чтобы добавить в закладки",
"Welcome to Discours to participate in discussions": "Войдите в Дискурс для участия в дискуссиях",
"Welcome to Discours to publish articles": "Войдите в Дискурс, чтобы публиковать статьи",
"Welcome to Discours to subscribe to new publications": "Войдите в Дискурс, чтобы подписаться",
"Welcome to Discours to subscribe": "Войдите в Дискурс для подписки на новые публикации",
"Welcome to Discours to subscribe to new publications": "Войдите в Дискурс, чтобы подписаться",
"Welcome to Discours to vote": "Войдите в Дискурс, чтобы голосовать",
"Welcome to Discours": "Добро пожаловать в Дискурс",
"Welcome!": "Добро пожаловать!",
"Where": "Откуда",
"Why you can earn a hole in your karma and how to receive rays of gratitude for your contribution to discussions in samizdat communities": "За что можно заслужить дырку в карме и как получить лучи благодарности за вклад в дискуссии в сообществах самиздата",
@ -468,104 +563,17 @@
"Write message": "Написать сообщение",
"Write to us": "Напишите нам",
"Write your colleagues name or email": "Напишите имя или e-mail коллеги",
"yesterday": "вчера",
"You can": "Вы можете",
"You can download multiple tracks at once in .mp3, .wav or .flac formats": "Можно загрузить сразу несколько треков в форматах .mp3, .wav или .flac",
"You can now login using your new password": "Теперь вы можете входить с помощью нового пароля",
"You was successfully authorized": "Вы были успешно авторизованы",
"You can't edit this post": "Вы не можете редактировать этот материал",
"You was successfully authorized": "Вы были успешно авторизованы",
"You&nbsp;ll be able to participate in&nbsp;discussions, rate others' comments and&nbsp;learn about&nbsp;new responses": "Вы&nbsp;сможете участвовать в&nbsp;обсуждениях, оценивать комментарии других и&nbsp;узнавать о&nbsp;новых ответах",
"You've confirmed email": "Вы подтвердили почту",
"You've reached a non-existed page": "Вы попали на несуществующую страницу",
"You've successfully logged out": "Вы успешно вышли из аккаунта",
"Your contact for answer": "Ваш контакт для обратной связи",
"Your email": "Ваш email",
"Your name will appear on your profile page and as your signature in publications, comments and responses.": "Ваше имя появится на странице вашего профиля и как ваша подпись в публикациях, комментариях и откликах",
"actions": "действия",
"add link": "добавить ссылку",
"all topics": "все темы",
"and some more authors": "{restUsersCount, plural, =0 {} one { и ещё 1 пользователя} few { и ещё {restUsersCount} пользователей} other { и ещё {restUsersCount} пользователей}}",
"article": "статья",
"author": "автор",
"authors": "авторы",
"authorsWithCount": "{count} {count, plural, one {автор} few {автора} other {авторов}}",
"back to menu": "назад в меню",
"bold": "жирный",
"bookmarks": "закладки",
"cancel": "отменить",
"collections": "коллекции",
"community": "сообщество",
"contents": "оглавление",
"create_chat": "Создать чат",
"create_group": "Создать группу",
"delimiter": "разделитель",
"discussion": "дискурс",
"dogma keywords": "Discours.io, догма, редакционные принципы, этический кодекс, журналистика, сообщество",
"drafts": "черновики",
"earlier": "ранее",
"email not confirmed": "email не подтвержден",
"enter": "войти",
"feed": "лента",
"follower": "подписчик",
"followersWithCount": "{count} {count, plural, one {подписчик} few {подписчика} other {подписчиков}}",
"from": "от",
"header 1": "заголовок 1",
"header 2": "заголовок 2",
"header 3": "заголовок 3",
"images": "изображения",
"invalid password": "некорректный пароль",
"italic": "курсив",
"journal": "журнал",
"jpg, .png, max. 10 mb.": "jpg, .png, макс. 10 мб.",
"keywords": "Discours.io, журнал Дискурс, Дискурс, культура, наука, искусство, общество, независимая журналистика, литература, музыка, кино, видео, фотографии",
"literature": "литература",
"marker list": "маркир. список",
"min. 1400×1400 pix": "мин. 1400×1400 пикс.",
"music": "музыка",
"my feed": "моя лента",
"not verified": "ещё не подтверждён",
"number list": "нумер. список",
"or sign in with social networks": "или войдите через соцсеть",
"or": "или",
"personal data usage and email notifications": "на обработку персональных данных и на получение почтовых уведомлений",
"post": "пост",
"principles keywords": "Discours.io, сообщества, ценности, правила редакции, многоголосие, созидание",
"register": "зарегистрируйтесь",
"registered": "уже зарегистрирован",
"repeat": "повторить",
"resend confirmation link": "отправить ссылку ещё раз",
"shout": "пост",
"shout not found": "публикация не найдена",
"shoutsWithCount": "{count} {count, plural, one {публикация} few {публикации} other {публикаций}}",
"sign in": "войти",
"sign up or sign in": "зарегистрироваться или войти",
"sign up": "зарегистрироваться",
"slug is used by another user": "Имя уже занято другим пользователем",
"squib": "Подверстка",
"subscriber": "подписчик",
"subscriber_rp": "подписчика",
"subscribers": "подписчиков",
"subscribing...": "Подписка...",
"terms of use keywords": "Discours.io, правила сайта, terms of use",
"terms of use": "правилами пользования сайтом",
"today": "сегодня",
"topicKeywords": "{topic}, Discours.io, статьи, журналистика, исследования",
"topics": "темы",
"user already exist": "пользователь уже существует",
"verified": "уже подтверждён",
"video": "видео",
"view": "просмотр",
"viewsWithCount": "{count} {count, plural, one {просмотр} few {просмотрa} other {просмотров}}",
"yesterday": "вчера",
"Failed to delete comment": "Не удалось удалить комментарий",
"It's OK. Just enter your email to receive a link to change your password": "Ничего страшного. Просто укажите свою почту, чтобы получить ссылку для смены пароля",
"Restore password": "Восстановить пароль",
"Subscribing...": "Подписываем...",
"Unsubscribing...": "Отписываем...",
"Login and security": "Вход и безопасность",
"Settings for account, email, password and login methods.": "Настройки аккаунта, почты, пароля и способов входа.",
"Current password": "Текущий пароль",
"Confirm your new password": "Подтвердите новый пароль",
"Connect": "Привязать",
"Incorrect old password": "Старый пароль не верен",
"Repeat new password": "Повторите новый пароль",
"Incorrect new password confirm": "Неверное подтверждение нового пароля"
"Your name will appear on your profile page and as your signature in publications, comments and responses.": "Ваше имя появится на странице вашего профиля и как ваша подпись в публикациях, комментариях и откликах"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

45
src/app.tsx Normal file
View File

@ -0,0 +1,45 @@
import { Meta, 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 { EditorProvider } from './context/editor'
import { FeedProvider } from './context/feed'
import { GraphQLClientProvider } from './context/graphql'
import { LocalizeProvider } 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 }) => {
return (
<LocalizeProvider>
<SessionProvider onStateChangeCallback={console.info}>
<GraphQLClientProvider>
<TopicsProvider>
<FeedProvider>
<MetaProvider>
<Meta name="viewport" content="width=device-width, initial-scale=1" />
<UIProvider>
<EditorProvider>
<Suspense fallback={<Loading />}>{props.children}</Suspense>
</EditorProvider>
</UIProvider>
</MetaProvider>
</FeedProvider>
</TopicsProvider>
</GraphQLClientProvider>
</SessionProvider>
</LocalizeProvider>
)
}
export const App = () => (
<Router root={Providers}>
<FileRoutes />
</Router>
)
export default App

View File

@ -1,142 +0,0 @@
import type { PageProps, RootSearchParams } from '../pages/types'
import { Component, createEffect, createMemo } from 'solid-js'
import { Dynamic } from 'solid-js/web'
import { Meta, MetaProvider } from '../context/meta'
import { ConfirmProvider } from '../context/confirm'
import { ConnectProvider } from '../context/connect'
import { EditorProvider } from '../context/editor'
import { FollowingProvider } from '../context/following'
import { InboxProvider } from '../context/inbox'
import { LocalizeProvider } from '../context/localize'
import { MediaQueryProvider } from '../context/mediaQuery'
import { NotificationsProvider } from '../context/notifications'
import { SeenProvider } from '../context/seen'
import { SessionProvider } from '../context/session'
import { SnackbarProvider } from '../context/snackbar'
import { TopicsProvider } from '../context/topics'
import { DiscussionRulesPage } from '../pages/about/discussionRules.page'
import { DogmaPage } from '../pages/about/dogma.page'
import { GuidePage } from '../pages/about/guide.page'
import { HelpPage } from '../pages/about/help.page'
import { ManifestPage } from '../pages/about/manifest.page'
import { PartnersPage } from '../pages/about/partners.page'
import { PrinciplesPage } from '../pages/about/principles.page'
import { ProjectsPage } from '../pages/about/projects.page'
import { TermsOfUsePage } from '../pages/about/termsOfUse.page'
import { ThanksPage } from '../pages/about/thanks.page'
import { AllAuthorsPage } from '../pages/allAuthors.page'
import { AllTopicsPage } from '../pages/allTopics.page'
import { ArticlePage } from '../pages/article.page'
import { AuthorPage } from '../pages/author.page'
import { ConnectPage } from '../pages/connect.page'
import { CreatePage } from '../pages/create.page'
import { DraftsPage } from '../pages/drafts.page'
import { EditPage } from '../pages/edit.page'
import { ExpoPage } from '../pages/expo/expo.page'
import { FeedPage } from '../pages/feed.page'
import { FourOuFourPage } from '../pages/fourOuFour.page'
import { InboxPage } from '../pages/inbox.page'
import { HomePage } from '../pages/index.page'
import { ProfileSecurityPage } from '../pages/profile/profileSecurity.page'
import { ProfileSettingsPage } from '../pages/profile/profileSettings.page'
import { ProfileSubscriptionsPage } from '../pages/profile/profileSubscriptions.page'
import { SearchPage } from '../pages/search.page'
import { TopicPage } from '../pages/topic.page'
import { ROUTES, useRouter } from '../stores/router'
import { MODALS, showModal } from '../stores/ui'
const pagesMap: Record<keyof typeof ROUTES, Component<PageProps>> = {
author: AuthorPage,
authorComments: AuthorPage,
authorAbout: AuthorPage,
inbox: InboxPage,
expo: ExpoPage,
connect: ConnectPage,
create: CreatePage,
edit: EditPage,
editSettings: EditPage,
drafts: DraftsPage,
home: HomePage,
topics: AllTopicsPage,
topic: TopicPage,
authors: AllAuthorsPage,
feed: FeedPage,
feedMy: FeedPage,
feedNotifications: FeedPage,
feedBookmarks: FeedPage,
feedCollaborations: FeedPage,
feedDiscussions: FeedPage,
article: ArticlePage,
search: SearchPage,
discussionRules: DiscussionRulesPage,
dogma: DogmaPage,
guide: GuidePage,
help: HelpPage,
manifest: ManifestPage,
projects: ProjectsPage,
partners: PartnersPage,
principles: PrinciplesPage,
termsOfUse: TermsOfUsePage,
thanks: ThanksPage,
profileSettings: ProfileSettingsPage,
profileSecurity: ProfileSecurityPage,
profileSubscriptions: ProfileSubscriptionsPage,
fourOuFour: FourOuFourPage,
}
type Props = PageProps & { is404: boolean }
export const App = (props: Props) => {
const { page, searchParams } = useRouter<RootSearchParams>()
const is404 = createMemo(() => props.is404)
createEffect(() => {
const modal = MODALS[searchParams().m]
if (modal) {
showModal(modal)
}
})
const pageComponent = createMemo(() => {
const result = pagesMap[page()?.route || 'home']
if (is404() || !result || page()?.path === '/404') {
return FourOuFourPage
}
return result
})
return (
<MetaProvider>
<Meta name="viewport" content="width=device-width, initial-scale=1" />
<LocalizeProvider>
<MediaQueryProvider>
<SnackbarProvider>
<TopicsProvider>
<SeenProvider>
<ConfirmProvider>
<SessionProvider onStateChangeCallback={console.log}>
<FollowingProvider>
<ConnectProvider>
<NotificationsProvider>
<EditorProvider>
<InboxProvider>
<Dynamic component={pageComponent()} {...props} />
</InboxProvider>
</EditorProvider>
</NotificationsProvider>
</ConnectProvider>
</FollowingProvider>
</SessionProvider>
</ConfirmProvider>
</SeenProvider>
</TopicsProvider>
</SnackbarProvider>
</MediaQueryProvider>
</LocalizeProvider>
</MetaProvider>
)
}

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)
@ -31,34 +35,25 @@ export const AudioPlayer = (props: Props) => {
const [isPlaying, setIsPlaying] = createSignal(false)
const currentTack = createMemo(() => props.media[currentTrackIndex()])
createEffect(
on(
() => currentTrackIndex(),
() => {
setCurrentTrackDuration(0)
},
{ defer: true },
),
)
createEffect(on(currentTrackIndex, () => setCurrentTrackDuration(0), { defer: true }))
const handlePlayMedia = async (trackIndex: number) => {
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 = () => {
@ -67,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 = () => {
@ -102,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 (
@ -125,16 +126,16 @@ 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}
style={{
width: `${(currentTime() / currentTrackDuration()) * 100 || 0}%`,
width: `${(currentTime() / currentTrackDuration()) * 100 || 0}%`
}}
/>
</div>
@ -145,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)}
@ -162,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 = () => {
@ -30,7 +26,7 @@ export const PlayerHeader = (props: Props) => {
useOutsideClickHandler({
containerRef: volumeContainerRef,
predicate: () => isVolumeBarOpened(),
handler: () => toggleVolumeBar(),
handler: () => toggleVolumeBar()
})
return (
@ -42,7 +38,7 @@ export const PlayerHeader = (props: Props) => {
onClick={props.onPlayMedia}
class={clsx(
styles.playButton,
props.isPlaying ? styles.playButtonInvertPause : styles.playButtonInvertPlay,
props.isPlaying ? styles.playButtonInvertPause : styles.playButtonInvertPlay
)}
aria-label="Play"
data-playing="false"
@ -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,14 +1,19 @@
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'
@ -40,18 +45,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) {
@ -60,16 +67,22 @@ export const Comment = (props: Props) => {
confirmBody: t('Are you sure you want to delete this comment?'),
confirmButtonLabel: t('Delete'),
confirmButtonVariant: 'danger',
declineButtonVariant: 'primary',
declineButtonVariant: 'primary'
})
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 +95,17 @@ export const Comment = (props: Props) => {
}
}
const handleCreate = async (value) => {
const handleCreate = async (value: string) => {
try {
setLoading(true)
await createReaction({
kind: ReactionKind.Comment,
reply_to: props.comment.id,
body: value,
shout: props.comment.shout.id,
})
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 +119,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,
kind: ReactionKind.Comment,
body: value,
shout: props.comment.shout.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 +144,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,10 +155,10 @@ 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,
[styles.compactUserpic]: props.compact
})}
/>
<small>
@ -161,13 +179,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 +192,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 +213,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 +274,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

@ -28,7 +28,7 @@ export const CommentDate = (props: Props) => {
<div
class={clsx(styles.commentDates, {
[styles.commentDatesLastInRow]: props.isLastInRow,
[styles.showOnHover]: props.showOnHover,
[styles.showOnHover]: props.showOnHover
})}
>
<time class={styles.date}>{formattedDate(props.comment.created_at * 1000)}</time>

View File

@ -0,0 +1 @@

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)
@ -33,7 +34,7 @@ export const CommentsTree = (props: Props) => {
const { reactionEntities, createReaction, loadReactionsBy } = useReactions()
const comments = createMemo(() =>
Object.values(reactionEntities).filter((reaction) => reaction.kind === 'COMMENT'),
Object.values(reactionEntities).filter((reaction) => reaction.kind === 'COMMENT')
)
const sortedComments = createMemo(() => {
@ -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({
kind: ReactionKind.Comment,
body: value,
shout: props.shoutId,
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

@ -26,7 +26,7 @@ const coverImages = [
CoverImage9,
CoverImage10,
CoverImage11,
CoverImage12,
CoverImage12
]
let counter = 0

View File

@ -1,19 +1,16 @@
import type { Author, Reaction, 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 { RatingControl as ShoutRatingControl } from './RatingControl'
import { SharePopup, getShareUrl } from './SharePopup'
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,49 +57,52 @@ export type ArticlePageSearchParams = {
const scrollTo = (el: HTMLElement) => {
const { top } = el.getBoundingClientRect()
window.scrollTo({
top: top + window.scrollY - DEFAULT_HEADER_OFFSET,
left: 0,
behavior: 'smooth',
})
if (window)
window.scrollTo({
top: top + window.scrollY - DEFAULT_HEADER_OFFSET,
left: 0,
behavior: 'smooth'
})
}
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()) {
@ -175,7 +174,7 @@ export const FullArticle = (props: Props) => {
}
const tooltipElements: NodeListOf<HTMLElement> = document.querySelectorAll(
'[data-toggle="tooltip"], footnote',
'[data-toggle="tooltip"], footnote'
)
if (!tooltipElements) {
return
@ -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)
@ -200,19 +199,19 @@ export const FullArticle = (props: Props) => {
modifiers: [
{
name: 'eventListeners',
options: { scroll: false },
options: { scroll: false }
},
{
name: 'offset',
options: {
offset: [0, 8],
},
offset: [0, 8]
}
},
{
name: 'flip',
options: { fallbackPlacements: ['top'] },
},
],
options: { fallbackPlacements: ['top'] }
}
]
})
tooltip.style.visibility = 'hidden'
@ -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', '')
@ -297,13 +297,13 @@ export const FullArticle = (props: Props) => {
() => props.article,
() => {
updateIframeSizes()
},
),
}
)
)
const [ratings, setRatings] = createSignal<Reaction[]>([])
onMount(async () => {
install('G-LQ4B87H8C2')
// install('G-LQ4B87H8C2')
await loadReactionsBy({ by: { shout: props.article.slug } })
addSeen(props.article.slug)
setIsReactionsLoaded(true)
@ -313,15 +313,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 })
}
})
})
@ -343,8 +343,8 @@ export const FullArticle = (props: Props) => {
const ogImage = getOpenGraphImageUrl(cover, {
title: props.article.title,
topic: mainTopic()?.title || '',
author: props.article?.authors[0]?.name || '',
width: 1200,
author: props.article?.authors?.[0]?.name || '',
width: 1200
})
const description = getDescription(props.article.description || body() || media()[0]?.body)
@ -352,7 +352,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 (
<>
@ -371,7 +371,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}
>
@ -379,7 +379,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>
@ -389,10 +389,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>
@ -405,21 +405,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">
@ -485,11 +489,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)} />
@ -505,7 +509,7 @@ export const FullArticle = (props: Props) => {
<Show when={props.article.stat?.viewed}>
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemViews)}>
{t('viewsWithCount', { count: props.article.stat?.viewed })}
{t('some views', { count: props.article.stat?.viewed || 0 })}
</div>
</Show>
@ -516,7 +520,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}
@ -531,12 +535,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)}
@ -553,22 +557,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')}
@ -593,14 +594,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>
@ -608,23 +609,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>
@ -640,7 +641,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,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,30 +9,32 @@ 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) {
hideModal()
} else {
changeSearchParams(
{
source: 'authguard',
m: 'auth',
},
true,
)
}
} else {
// await loadSession()
console.warn('session is not loaded')
}
})
createEffect(
on(
[() => props.disabled, author],
([disabled, a]) => {
if (disabled || !a) return
if (a) {
console.debug('[AuthGuard] profile is loaded')
hideModal()
} else {
changeSearchParams(
{
source: 'authguard',
m: 'auth'
},
{ replace: true }
)
}
},
{ 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,26 +1,25 @@
import { openPage } from '@nanostores/router'
import { clsx } from 'clsx'
import { Match, Show, Switch, createEffect, createMemo, createSignal } from 'solid-js'
import { Match, Show, Switch, createEffect, createMemo, createSignal, on } from 'solid-js'
import { useNavigate, useSearchParams } from '@solidjs/router'
import { mediaMatches } from '~/utils/media-query'
import { useFollowing } from '../../../context/following'
import { 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 { BadgeSubscribeButton } from '../../_shared/BadgeSubscribeButton'
import { Button } from '../../_shared/Button'
import { CheckButton } from '../../_shared/CheckButton'
import { ConditionalWrapper } from '../../_shared/ConditionalWrapper'
import { FollowingButton } from '../../_shared/FollowingButton'
import { Icon } from '../../_shared/Icon'
import { Userpic } from '../Userpic'
import styles from './AuthorBadge.module.scss'
type Props = {
author: Author
minimizeSubscribeButton?: boolean
minimize?: boolean
showMessageButton?: boolean
iconButtons?: boolean
nameOnly?: boolean
@ -29,53 +28,57 @@ type Props = {
selected?: boolean
}
export const AuthorBadge = (props: Props) => {
const { mediaMatches } = useMediaQuery()
const { author, requireAuthentication } = useSession()
const { follow, unfollow, subscriptions, subscribeInAction } = useFollowing()
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 [isSubscribed, setIsSubscribed] = createSignal<boolean>()
const [isFollowed, setIsFollowed] = createSignal<boolean>(
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(
Boolean(followingAuthors?.some((followedAuthor) => followedAuthor.id === currentAuthor?.id))
)
},
{ defer: true }
)
)
createEffect(() => {
if (!(subscriptions && props.author)) return
const subscribed = subscriptions.authors?.some((authorEntity) => authorEntity.id === props.author?.id)
setIsSubscribed(subscribed)
})
createEffect(() => {
setIsMobileView(!mediaMatches.sm)
})
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(),
initChat: props.author?.id.toString()
})
}, 'discussions')
}
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
})
const handleFollowClick = () => {
requireAuthentication(() => {
isSubscribed()
? unfollow(FollowingEntity.Author, props.author.slug)
: follow(FollowingEntity.Author, props.author.slug)
}, 'subscribe')
requireAuthentication(async () => {
const handle = isFollowed() ? unfollow : follow
await handle(FollowingEntity.Author, props.author.slug)
}, 'follow')
}
return (
@ -84,8 +87,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
@ -104,25 +107,25 @@ 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}>
<div class={styles.bio}>
<Show when={props.author?.stat.shouts > 0}>
<div>{t('PublicationsWithCount', { count: props.author.stat?.shouts ?? 0 })}</div>
<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}>
<div>{t('CommentsWithCount', { count: props.author.stat?.comments ?? 0 })}</div>
<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}>
<div>{t('FollowersWithCount', { count: props.author.stat?.followers ?? 0 })}</div>
<Show when={(props.author?.stat?.followers || 0) > 0}>
<div>{t('some followers', { count: props.author.stat?.followers ?? 0 })}</div>
</Show>
</div>
</Show>
@ -131,12 +134,10 @@ export const AuthorBadge = (props: Props) => {
</div>
<Show when={props.author.slug !== author()?.slug && !props.nameOnly}>
<div class={styles.actions}>
<BadgeSubscribeButton
action={() => handleFollowClick()}
isSubscribed={isSubscribed()}
actionMessageType={
subscribeInAction()?.slug === props.author.slug ? subscribeInAction().type : undefined
}
<FollowingButton
action={handleFollowClick}
isFollowed={isFollowed()}
actionMessageType={following()?.slug === props.author.slug ? following()?.type : undefined}
/>
<Show when={props.showMessageButton}>
<Button
@ -152,8 +153,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

@ -20,7 +20,7 @@
@include font-size(4rem);
font-weight: 700;
margin-bottom: 0.2em;
margin-bottom: 1.2rem;
}
.authorAbout {
@ -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 {
@ -429,64 +429,19 @@
}
}
.listWrapper {
max-height: 70vh;
}
.subscribersContainer {
display: flex;
flex-wrap: wrap;
font-size: 1.4rem;
margin-top: 1.5rem;
gap: 1rem;
margin-top: 0;
white-space: nowrap;
@include media-breakpoint-down(md) {
justify-content: center;
}
}
.subscribers {
align-items: center;
cursor: pointer;
display: inline-flex;
margin: 0 2% 1rem;
vertical-align: top;
border-bottom: unset !important;
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
.subscribersItem {
position: relative;
&:nth-child(1) {
z-index: 2;
}
&:nth-child(2) {
z-index: 1;
}
&:not(:last-child) {
margin-right: -4px;
box-shadow: 0 0 0 1px var(--background-color);
}
}
.subscribersCounter {
font-weight: 500;
margin-left: 1rem;
}
&:hover {
background: none !important;
.subscribersCounter {
background: var(--background-color-invert);
}
}
}
.listWrapper {
max-height: 70vh;
}

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 { SubscriptionFilter } from '../../../pages/types'
import { router, useRouter } from '../../../stores/router'
import { isAuthor } from '../../../utils/isAuthor'
import { translit } from '../../../utils/ru2en'
import { isCyrillic } from '../../../utils/translate'
@ -17,87 +13,91 @@ import { SharePopup, getShareUrl } from '../../Article/SharePopup'
import { Modal } from '../../Nav/Modal'
import { TopicBadge } from '../../Topic/TopicBadge'
import { Button } from '../../_shared/Button'
import { FollowingCounters } from '../../_shared/FollowingCounters/FollowingCounters'
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'
type Props = {
author: Author
followers?: Author[]
following?: Array<Author | Topic>
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 [subscriptionFilter, setSubscriptionFilter] = createSignal<SubscriptionFilter>('all')
const [isSubscribed, setIsSubscribed] = createSignal<boolean>()
const [followsFilter, setFollowsFilter] = createSignal<FollowsFilter>('all')
const [isFollowed, setIsFollowed] = createSignal<boolean>()
const isProfileOwner = createMemo(() => author()?.slug === props.author.slug)
const { follow, unfollow, subscriptions, subscribeInAction } = useFollowing()
const { follow, unfollow, follows, following } = useFollowing()
onMount(() => {
setAuthorSubs(props.following)
setAuthorSubs(props.flatFollows || [])
})
createEffect(() => {
if (!(subscriptions && props.author)) return
const subscribed = subscriptions.authors?.some((authorEntity) => authorEntity.id === props.author?.id)
setIsSubscribed(subscribed)
if (!(follows && props.author)) return
const followed = follows?.authors?.some((authorEntity) => authorEntity.id === props.author?.id)
setIsFollowed(followed)
})
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(),
initChat: props.author?.id.toString()
})
}, 'discussions')
}
createEffect(() => {
if (props.following) {
if (subscriptionFilter() === 'authors') {
setAuthorSubs(props.following.filter((s) => 'name' in s))
} else if (subscriptionFilter() === 'topics') {
setAuthorSubs(props.following.filter((s) => 'title' in s))
} else if (subscriptionFilter() === 'communities') {
setAuthorSubs(props.following.filter((s) => 'title' in s))
if (props.flatFollows) {
if (followsFilter() === 'authors') {
setAuthorSubs(props.flatFollows.filter((s) => 'name' in s))
} else if (followsFilter() === 'topics') {
setAuthorSubs(props.flatFollows.filter((s) => 'title' in s))
} else if (followsFilter() === 'communities') {
setAuthorSubs(props.flatFollows.filter((s) => 'title' in s))
} else {
setAuthorSubs(props.following)
setAuthorSubs(props.flatFollows)
}
}
})
const handleFollowClick = () => {
requireAuthentication(() => {
isSubscribed()
isFollowed()
? unfollow(FollowingEntity.Author, props.author.slug)
: follow(FollowingEntity.Author, props.author.slug)
}, 'subscribe')
}, 'follow')
}
const followButtonText = createMemo(() => {
if (subscribeInAction()?.slug === props.author.slug) {
return subscribeInAction().type === 'subscribe' ? t('Subscribing...') : t('Unsubscribing...')
if (following()?.slug === props.author.slug) {
return following()?.type === 'follow' ? t('Following...') : t('Unfollowing...')
}
if (isSubscribed()) {
if (isFollowed()) {
return (
<>
<span class={stylesButton.buttonSubscribeLabel}>{t('Following')}</span>
@ -108,13 +108,82 @@ export const AuthorCard = (props: Props) => {
return t('Follow')
})
const FollowersModalView = () => (
<>
<h2>{t('Followers')}</h2>
<div class={styles.listWrapper}>
<div class="row">
<div class="col-24">
<For each={props.followers}>{(follower: Author) => <AuthorBadge author={follower} />}</For>
</div>
</div>
</div>
</>
)
const FollowingModalView = () => (
<>
<h2>{t('Subscriptions')}</h2>
<ul class="view-switcher">
<li
class={clsx({
'view-switcher__item--selected': followsFilter() === 'all'
})}
>
<button type="button" onClick={() => setFollowsFilter('all')}>
{t('All')}
</button>
<span class="view-switcher__counter">{props.flatFollows?.length}</span>
</li>
<li
class={clsx({
'view-switcher__item--selected': followsFilter() === 'authors'
})}
>
<button type="button" onClick={() => setFollowsFilter('authors')}>
{t('Authors')}
</button>
<span class="view-switcher__counter">{props.flatFollows?.filter((s) => 'name' in s).length}</span>
</li>
<li
class={clsx({
'view-switcher__item--selected': followsFilter() === 'topics'
})}
>
<button type="button" onClick={() => setFollowsFilter('topics')}>
{t('Topics')}
</button>
<span class="view-switcher__counter">
{props.flatFollows?.filter((s) => 'title' in s).length}
</span>
</li>
</ul>
<br />
<div class={styles.listWrapper}>
<div class="row">
<div class="col-24">
<For each={authorSubs()}>
{(subscription) =>
isAuthor(subscription) ? (
<AuthorBadge author={subscription} subscriptionsMode={true} />
) : (
<TopicBadge topic={subscription} subscriptionsMode={true} />
)
}
</For>
</div>
</div>
</div>
</>
)
return (
<div class={clsx(styles.author, 'row')}>
<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}
/>
@ -123,61 +192,16 @@ 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.following?.length > 0}>
<Show when={(props.followers || [])?.length > 0 || (props.flatFollows || []).length > 0}>
<div class={styles.subscribersContainer}>
<Show when={props.followers && props.followers.length > 0}>
<a href="?m=followers" class={styles.subscribers}>
<For each={props.followers.slice(0, 3)}>
{(f) => (
<Userpic size={'XS'} name={f.name} userpic={f.pic} class={styles.subscribersItem} />
)}
</For>
<div class={styles.subscribersCounter}>
{t('SubscriberWithCount', {
count: props.followers.length ?? 0,
})}
</div>
</a>
</Show>
<Show when={props.following && props.following.length > 0}>
<a href="?m=following" class={styles.subscribers}>
<For each={props.following.slice(0, 3)}>
{(f) => {
if ('name' in f) {
return (
<Userpic
size={'XS'}
name={f.name}
userpic={f.pic}
class={styles.subscribersItem}
/>
)
}
if ('title' in f) {
return (
<Userpic
size={'XS'}
name={f.title}
userpic={f.pic}
class={styles.subscribersItem}
/>
)
}
return null
}}
</For>
<div class={styles.subscribersCounter}>
{t('SubscriptionWithCount', {
count: props?.following.length ?? 0,
})}
</div>
</a>
</Show>
<FollowingCounters
followers={props.followers}
followersAmount={props.author?.stat?.followers || 0}
following={props.flatFollows}
followingAmount={props.flatFollows?.length || 0}
/>
</div>
</Show>
</div>
@ -186,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>
)}
@ -208,11 +232,11 @@ export const AuthorCard = (props: Props) => {
<Show when={authorSubs()?.length}>
<Button
onClick={handleFollowClick}
disabled={Boolean(subscribeInAction())}
disabled={Boolean(following())}
value={followButtonText()}
isSubscribeButton={true}
class={clsx({
[stylesButton.subscribed]: isSubscribed(),
[stylesButton.followed]: isFollowed()
})}
/>
</Show>
@ -228,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>
@ -237,11 +261,11 @@ 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}`,
pathname: `/author/${props.author.slug}`
})}
trigger={<Button variant="secondary" value={t('Share')} />}
/>
@ -251,77 +275,12 @@ export const AuthorCard = (props: Props) => {
</ShowOnlyOnClient>
<Show when={props.followers}>
<Modal variant="medium" isResponsive={true} name="followers" maxHeight>
<>
<h2>{t('Followers')}</h2>
<div class={styles.listWrapper}>
<div class="row">
<div class="col-24">
<For each={props.followers}>
{(follower: Author) => <AuthorBadge author={follower} />}
</For>
</div>
</div>
</div>
</>
<FollowersModalView />
</Modal>
</Show>
<Show when={props.following}>
<Show when={props.flatFollows}>
<Modal variant="medium" isResponsive={true} name="following" maxHeight>
<>
<h2>{t('Subscriptions')}</h2>
<ul class="view-switcher">
<li
class={clsx({
'view-switcher__item--selected': subscriptionFilter() === 'all',
})}
>
<button type="button" onClick={() => setSubscriptionFilter('all')}>
{t('All')}
</button>
<span class="view-switcher__counter">{props.following.length}</span>
</li>
<li
class={clsx({
'view-switcher__item--selected': subscriptionFilter() === 'authors',
})}
>
<button type="button" onClick={() => setSubscriptionFilter('authors')}>
{t('Authors')}
</button>
<span class="view-switcher__counter">
{props.following.filter((s) => 'name' in s).length}
</span>
</li>
<li
class={clsx({
'view-switcher__item--selected': subscriptionFilter() === 'topics',
})}
>
<button type="button" onClick={() => setSubscriptionFilter('topics')}>
{t('Topics')}
</button>
<span class="view-switcher__counter">
{props.following.filter((s) => 'title' in s).length}
</span>
</li>
</ul>
<br />
<div class={styles.listWrapper}>
<div class="row">
<div class="col-24">
<For each={authorSubs()}>
{(subscription) =>
isAuthor(subscription) ? (
<AuthorBadge author={subscription} />
) : (
<TopicBadge topic={subscription} subscriptionsMode={true} />
)
}
</For>
</div>
</div>
</div>
</>
<FollowingModalView />
</Modal>
</Show>
</div>

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'], {
[styles.authorLinkFloorImportant]: props.isFloorImportant,
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,17 +19,20 @@ 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
class={clsx(styles.rating, props.class, {
[styles.isUpvoted]: isUpvoted,
[styles.isDownvoted]: isDownvoted,
[styles.isDownvoted]: isDownvoted
})}
>
<button

View File

@ -11,12 +11,12 @@ 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, {
[styles.isUpvoted]: isUpvoted(),
[styles.isDownvoted]: !isUpvoted(),
[styles.isDownvoted]: !isUpvoted()
})}
>
<span class={styles.ratingValue}>{props.author?.stat?.rating_shouts}</span>

View File

@ -48,13 +48,13 @@ export const Userpic = (props: Props) => {
return (
<div
class={clsx(styles.Userpic, props.class, styles[props.size ?? 'M'], {
cursorPointer: props.onClick,
cursorPointer: props.onClick
})}
onClick={props.onClick}
>
<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,32 +21,38 @@ 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,
offset
})
if (queryType === 'shouts') {
setAuthorsByShouts((prev) => [...prev, ...result])
} else {
setAuthorsByFollowers((prev) => [...prev, ...result])
const result = resp?.data?.load_authors_by
if ((result?.length || 0) > 0) {
addAuthors([...result])
if (queryType === 'shouts') {
setAuthorsByShouts((prev) => [...(prev || []), ...result])
} else if (queryType === 'followers') {
setAuthorsByFollowers((prev) => [...(prev || []), ...result])
}
setLoading(false)
}
setLoading(false)
}
const loadMoreAuthors = () => {
const nextPage = currentPage()[props.query] + 1
fetchAuthors(props.query, nextPage).then(() =>
setCurrentPage({ ...currentPage(), [props.query]: nextPage }),
setCurrentPage({ ...currentPage(), [props.query]: nextPage })
)
}
@ -52,13 +60,13 @@ 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 })))
}
},
),
}
)
)
const authorsList = () => (props.query === 'shouts' ? authorsByShouts() : authorsByFollowers())
@ -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,12 +11,13 @@ type DWindow = Window & { cp: any }
export const Donate = () => {
const { t } = useLocalize()
const { showModal } = useUI()
const once = ''
const monthly = 'Monthly'
const cpOptions = {
publicId: 'pk_0a37bab30ffc6b77b2f93d65f2aed',
description: t('Help discours to grow'),
currency: 'RUB',
currency: 'RUB'
}
let amountSwitchElement: HTMLDivElement | undefined
@ -45,8 +45,8 @@ export const Donate = () => {
amount: amount() || 0, //сумма
vat: 20, //ставка НДС
method: 0, // тег-1214 признак способа расчета - признак способа расчета
object: 0, // тег-1212 признак предмета расчета - признак предмета товара, работы, услуги, платежа, выплаты, иного предмета расчета
},
object: 0 // тег-1212 признак предмета расчета - признак предмета товара, работы, услуги, платежа, выплаты, иного предмета расчета
}
],
// taxationSystem: 0, //система налогообложения; необязательный, если у вас одна система налогообложения
// email: 'user@example.com', //e-mail покупателя, если нужно отправить письмо с чеком
@ -56,8 +56,8 @@ export const Donate = () => {
electronic: amount(), // Сумма оплаты электронными деньгами
advancePayment: 0, // Сумма из предоплаты (зачетом аванса) (2 знака после запятой)
credit: 0, // Сумма постоплатой(в кредит) (2 знака после запятой)
provision: 0, // Сумма оплаты встречным предоставлением (сертификаты, др. мат.ценности) (2 знака после запятой)
},
provision: 0 // Сумма оплаты встречным предоставлением (сертификаты, др. мат.ценности) (2 знака после запятой)
}
})
} catch (error) {
console.error(error)
@ -98,27 +98,29 @@ export const Donate = () => {
recurrent: {
interval: period(), // local solid's signal
period: 1, // internal widget's
CustomerReciept: customerReciept(), // чек для регулярных платежей
},
},
},
CustomerReciept: customerReciept() // чек для регулярных платежей
}
}
}
},
(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)
showSnackbar({
type: 'error',
body: reason,
body: reason
})
},
}
)
}

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
@ -14,9 +14,9 @@ export const Feedback = () => {
method,
headers: {
accept: 'application/json',
'content-type': 'application/json; charset=utf-8',
'content-type': 'application/json; charset=utf-8'
},
body: JSON.stringify({ contact: contactElement?.value, message: msgElement?.textContent }),
body: JSON.stringify({ contact: contactElement?.value, message: msgElement?.textContent })
})
hideModal()
}

View File

@ -1,127 +1,81 @@
import { clsx } from 'clsx'
import { For, createMemo } from 'solid-js'
import { For, createSignal, onMount } from 'solid-js'
import { useLocalize } from '../../context/localize'
import { Icon } from '../_shared/Icon'
import { Subscribe } from '../_shared/Subscribe'
import { Newsletter } from '../_shared/Newsletter'
import styles from './Footer.module.scss'
export const Footer = () => {
const social = [
{ name: 'facebook', href: 'https://facebook.com/discoursio' },
{ name: 'vk', href: 'https://vk.com/discoursio' },
{ name: 'twitter', href: 'https://twitter.com/discours_io' },
{ name: 'telegram', href: 'https://t.me/discoursio' }
]
type FooterItem = {
title: string
slug: string
rel?: string
}
export const FooterView = () => {
const { t, lang } = useLocalize()
const [footerLinks, setFooterLinks] = createSignal<Array<{ header: string; items: FooterItem[] }>>([])
const changeLangTitle = createMemo(() => (lang() === 'ru' ? 'English' : 'Русский'))
const changeLangLink = createMemo(() => `?lng=${lang() === 'ru' ? 'en' : 'ru'}`)
const links = createMemo(() => [
{
header: 'About the project',
items: [
{
title: 'Discours Manifest',
slug: '/about/manifest',
},
{
title: 'How it works',
slug: '/about/guide',
},
{
title: 'Dogma',
slug: '/about/dogma',
},
{
title: 'Principles',
slug: '/about/principles',
},
{
title: 'How to write an article',
slug: '/how-to-write-a-good-article',
},
],
},
onMount(() => {
setFooterLinks([
{
header: t('About the project'),
items: [
{ title: t('Discours Manifest'), slug: '/about/manifest' },
{ title: t('How it works'), slug: '/about/guide' },
{ title: t('Dogma'), slug: '/about/dogma' },
{ title: t('Principles'), slug: '/about/principles' },
{ title: t('How to write an article'), slug: '/how-to-write-a-good-article' }
]
},
{
header: t('Participating'),
items: [
{ title: t('Suggest an idea'), slug: '/connect' },
{ title: t('Become an author'), slug: '/create' },
{ title: t('Support Discours'), slug: '/about/help' },
{
title: t('Work with us'),
slug: 'https://docs.google.com/forms/d/e/1FAIpQLSeNNvIzKlXElJtkPkYiXl-jQjlvsL9u4-kpnoRjz1O8Wo40xQ/viewform'
}
]
},
{
header: t('Sections'),
items: [
{ title: t('Authors'), slug: '/authors' },
{ title: t('Communities'), slug: '/community' },
{ title: t('Partners'), slug: '/about/partners' },
{ title: t('Special projects'), slug: '/about/projects' },
{
title: lang() === 'ru' ? 'English' : 'Русский',
slug: `?lng=${lang() === 'ru' ? 'en' : 'ru'}`,
rel: 'external'
}
]
}
])
})
{
header: 'Participating',
items: [
{
title: 'Suggest an idea',
slug: '/connect',
},
{
title: 'Become an author',
slug: '/create',
},
{
title: 'Support Discours',
slug: '/about/help',
},
{
title: 'Work with us',
slug: 'https://docs.google.com/forms/d/e/1FAIpQLSeNNvIzKlXElJtkPkYiXl-jQjlvsL9u4-kpnoRjz1O8Wo40xQ/viewform',
},
],
},
{
header: 'Sections',
items: [
{
title: 'Authors',
slug: '/authors',
},
{
title: 'Communities',
slug: '/community',
},
{
title: 'Partners',
slug: '/about/partners',
},
{
title: 'Special projects',
slug: '/about/projects',
},
{
title: changeLangTitle(),
slug: changeLangLink(),
rel: 'external',
},
],
},
])
const social = [
{
name: 'facebook',
href: 'https://facebook.com/discoursio',
},
{
name: 'vk',
href: 'https://vk.com/discoursio',
},
{
name: 'twitter',
href: 'https://twitter.com/discours_io',
},
{
name: 'telegram',
href: 'https://t.me/discoursio',
},
]
return (
<footer class={styles.discoursFooter}>
<div class="wide-container">
<div class="row">
<For each={links()}>
<For each={footerLinks()}>
{({ header, items }) => (
<div class="col-sm-8 col-md-6">
<h5>{t(header)}</h5>
<ul>
<For each={items}>
{({ slug, title, ...rest }) => (
{({ slug, title, rel }: FooterItem) => (
<li>
{' '}
<a href={slug} {...rest}>
{slug.startsWith('?') ? title : t(title)}
<a href={slug} rel={rel}>
{rel ? title : t(title)}
</a>{' '}
</li>
)}
@ -133,24 +87,29 @@ export const Footer = () => {
<div class="col-md-6">
<h5>{t('Subscription')}</h5>
<p>{t('Join our maillist')}</p>
<Subscribe />
<Newsletter />
</div>
</div>
<div class={clsx(styles.footerCopyright, 'row')}>
<div class="col-md-18 col-lg-20">
{t(
'Independant magazine with an open horizontal cooperation about culture, science and society',
'Independant magazine with an open horizontal cooperation about culture, science and society'
)}
. {t('Discours')} &copy; 2015&ndash;{new Date().getFullYear()}{' '}
<a href="/about/terms-of-use">{t('Terms of use')}</a>
</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}`])}>
<a href={social.href}>
<Icon name={`${social.name}-white`} class={styles.icon} />
{(provider) => (
<div
class={clsx(
styles.socialItem,
styles[`socialItem${provider.name}` as keyof typeof styles]
)}
>
<a href={provider.href}>
<Icon name={`${provider.name}-white`} class={styles.icon} />
</a>
</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">
@ -17,7 +16,7 @@ export default () => {
<h4 innerHTML={t('Horizontal collaborative journalistic platform')} />
<p
innerHTML={t(
'Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects',
'Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects'
)}
/>
<div class={styles.aboutDiscoursActions}>
@ -29,7 +28,7 @@ export default () => {
onClick={() => {
showModal('auth')
changeSearchParams({
mode: 'register',
mode: 'register'
})
}}
>

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,14 +29,14 @@ export const Draft = (props: Props) => {
}
}
const handleDeleteLinkClick = async (e) => {
const handleDeleteLinkClick = async (e: MouseEvent) => {
e.preventDefault()
const isConfirmed = await showConfirm({
confirmBody: t('Are you sure you want to delete this draft?'),
confirmButtonLabel: t('Delete'),
confirmButtonVariant: 'danger',
declineButtonVariant: 'primary',
declineButtonVariant: 'primary'
})
if (isConfirmed) {
props.onDelete(props.shout)
@ -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,18 +28,24 @@ 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 })
}
const handleChangeIndex = (direction: 'up' | 'down', index: number) => {
const media = [...props.audio]
if (direction === 'up' && index > 0) {
const copy = media.splice(index, 1)[0]
media.splice(index - 1, 0, copy)
} else if (direction === 'down' && index < media.length - 1) {
const copy = media.splice(index, 1)[0]
media.splice(index + 1, 0, copy)
if (media?.length > 0) {
if (direction === 'up' && index > 0) {
const copy = media.splice(index, 1)[0]
media.splice(index - 1, 0, copy)
} else if (direction === 'down' && index < media.length - 1) {
const copy = media.splice(index, 1)[0]
media.splice(index + 1, 0, copy)
}
}
props.onAudioSorted(media)
}

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
@ -65,19 +66,18 @@ const allowedImageTypes = new Set([
'image/png',
'image/tiff',
'image/webp',
'image/x-icon',
'image/x-icon'
])
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}`
@ -91,43 +91,16 @@ export const Editor = (props: Props) => {
url: 'wss://hocuspocus.discours.io',
name: docName,
document: yDocs[docName],
token: session()?.access_token || '',
token: session()?.access_token || ''
})
}
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 {
@ -147,14 +120,14 @@ export const Editor = (props: Props) => {
source: blob.toString(),
name: file.name,
size: file.size,
file,
file
}
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',
@ -162,13 +135,13 @@ export const Editor = (props: Props) => {
content: [
{
type: 'image',
attrs: { src: result.url },
attrs: { src: result.url }
},
{
type: 'figcaption',
content: [{ type: 'text', text: result.originalFilename }],
},
],
content: [{ type: 'text', text: result.originalFilename }]
}
]
})
.run()
} catch (error) {
@ -177,177 +150,175 @@ export const Editor = (props: Props) => {
}
const { initialContent } = props
const editor = createTiptapEditor(() => ({
element: editorElRef.current,
editorProps: {
attributes: {
class: 'articleEditor',
},
transformPastedHTML(html) {
return html.replaceAll(/<img.*?>/g, '')
},
handlePaste: () => {
handleClipboardPaste()
return false
},
},
extensions: [
Document,
Text,
Paragraph,
Dropcursor,
CustomBlockquote,
Bold,
Italic,
Span,
ToggleTextWrap,
Strike,
HorizontalRule.configure({
HTMLAttributes: {
class: 'horizontalRule',
},
}),
Underline,
Link.extend({
inclusive: false,
}).configure({
autolink: true,
openOnClick: false,
}),
Heading.configure({
levels: [2, 3, 4],
}),
BulletList,
OrderedList,
ListItem,
Collaboration.configure({
document: yDocs[docName],
}),
CollaborationCursor.configure({
provider: providers[docName],
user: {
name: author().name,
color: uniqolor(author().slug).color,
},
}),
Placeholder.configure({
placeholder: t('Add a link or click plus to embed media'),
}),
Focus,
Gapcursor,
HardBreak,
Highlight.configure({
multicolor: true,
HTMLAttributes: {
class: 'highlight',
},
}),
Image,
Iframe,
Figure,
Figcaption,
Footnote,
ToggleTextWrap,
CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef.current,
shouldShow: ({ editor: e, view, state, from, to }) => {
const { doc, selection } = state
const { empty } = selection
const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection)
if (isEmptyTextBlock) {
e.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
}
setIsCommonMarkup(e.isActive('figcaption'))
const result =
(view.hasFocus() &&
!empty &&
!isEmptyTextBlock &&
!e.isActive('image') &&
!e.isActive('figure')) ||
e.isActive('footnote') ||
(e.isActive('figcaption') && !empty)
setShouldShowTextBubbleMenu(result)
return result
},
tippyOptions: {
sticky: true,
},
}),
BubbleMenu.configure({
pluginKey: 'blockquoteBubbleMenu',
element: blockquoteBubbleMenuRef.current,
shouldShow: ({ editor: e, state }) => {
const { selection } = state
const { empty } = selection
return empty && e.isActive('blockquote')
},
tippyOptions: {
offset: [0, 0],
placement: 'top',
getReferenceClientRect: () => {
const selectedElement = editor().view.dom.querySelector('.has-focus')
if (selectedElement) {
return selectedElement.getBoundingClientRect()
const { editor, setEditor, countWords } = useEditorContext()
createEffect(
on(editorElRef, (ee: HTMLElement | undefined) => {
if (ee) {
const freshEditor = createTiptapEditor<HTMLElement>(() => ({
element: ee,
editorProps: {
attributes: {
class: 'articleEditor'
},
transformPastedHTML(html) {
return html.replaceAll(/<img.*?>/g, '')
},
handlePaste: () => {
handleClipboardPaste()
return false
}
},
},
}),
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()
extensions: [
Document,
Text,
Paragraph,
Dropcursor,
CustomBlockquote,
Bold,
Italic,
Span,
ToggleTextWrap,
Strike,
HorizontalRule.configure({
HTMLAttributes: {
class: 'horizontalRule'
}
}),
Underline,
Link.extend({
inclusive: false
}).configure({
autolink: true,
openOnClick: false
}),
Heading.configure({
levels: [2, 3, 4]
}),
BulletList,
OrderedList,
ListItem,
Collaboration.configure({
document: yDocs[docName]
}),
CollaborationCursor.configure({
provider: providers[docName],
user: {
name: author().name,
color: uniqolor(author().slug).color
}
}),
Placeholder.configure({
placeholder: t('Add a link or click plus to embed media')
}),
Focus,
Gapcursor,
HardBreak,
Highlight.configure({
multicolor: true,
HTMLAttributes: {
class: 'highlight'
}
}),
Image,
Iframe,
Figure,
Figcaption,
Footnote,
ToggleTextWrap,
CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef,
shouldShow: ({ editor: e, view, state, from, to }) => {
const { doc, selection } = state
const { empty } = selection
const isEmptyTextBlock =
doc.textBetween(from, to).length === 0 && isTextSelection(selection)
if (isEmptyTextBlock) {
e.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
}
setIsCommonMarkup(e.isActive('figcaption'))
const result =
(view.hasFocus() &&
!empty &&
!isEmptyTextBlock &&
!e.isActive('image') &&
!e.isActive('figure')) ||
e.isActive('footnote') ||
(e.isActive('figcaption') && !empty)
setShouldShowTextBubbleMenu(result)
return result
},
tippyOptions: {
onHide: () => {
const fe = freshEditor() as Editor
fe?.commands.focus()
}
}
}),
BubbleMenu.configure({
pluginKey: 'blockquoteBubbleMenu',
element: blockquoteBubbleMenuRef,
shouldShow: ({ editor: e, view, state }) => {
const { empty } = state.selection
return view.hasFocus() && !empty && e.isActive('blockquote')
}
}),
BubbleMenu.configure({
pluginKey: 'figureBubbleMenu',
element: figureBubbleMenuRef,
shouldShow: ({ editor: e, view, state }) => {
const { empty } = state.selection
return view.hasFocus() && !empty && e.isActive('figure')
}
}),
BubbleMenu.configure({
pluginKey: 'incutBubbleMenu',
element: incutBubbleMenuRef,
shouldShow: ({ editor: e, view, state }) => {
const { empty } = state.selection
return view.hasFocus() && !empty && e.isActive('figcaption')
}
}),
FloatingMenu.configure({
element: floatingMenuRef,
pluginKey: 'floatingMenu',
shouldShow: ({ editor: e, state }) => {
const { $anchor, empty } = state.selection
const isRootDepth = $anchor.depth === 1
if (!(isRootDepth && empty)) return false
return !(e.isActive('codeBlock') || e.isActive('heading'))
}
}),
TrailingNode,
Article
],
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())
}
}
},
},
}),
BubbleMenu.configure({
pluginKey: 'imageBubbleMenu',
element: figureBubbleMenuRef.current,
shouldShow: ({ editor: e, view }) => {
return view.hasFocus() && e.isActive('image')
},
}),
FloatingMenu.configure({
tippyOptions: {
placement: 'left',
},
element: floatingMenuRef.current,
}),
TrailingNode,
Article,
],
enablePasteRules: [Link],
content: initialContent ?? null,
}))
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>
<TextBubbleMenu
shouldShow={shouldShowTextBubbleMenu()}
isCommonMarkup={isCommonMarkup()}
editor={editor()}
ref={(el) => (textBubbleMenuRef.current = el)}
/>
<BlockquoteBubbleMenu
ref={(el) => {
blockquoteBubbleMenuRef.current = el
}}
editor={editor()}
/>
<FigureBubbleMenu
editor={editor()}
ref={(el) => {
figureBubbleMenuRef.current = el
}}
/>
<IncutBubbleMenu
editor={editor()}
ref={(el) => {
incutBubbleMenuRef.current = el
}}
/>
<EditorFloatingMenu editor={editor()} ref={(el) => (floatingMenuRef.current = el)} />
<Show when={editor()}>
<TextBubbleMenu
shouldShow={shouldShowTextBubbleMenu()}
isCommonMarkup={isCommonMarkup()}
editor={editor() as Editor}
ref={(el) => (textBubbleMenuRef = el)}
/>
<BlockquoteBubbleMenu
ref={(el) => {
blockquoteBubbleMenuRef = el
}}
editor={editor() as Editor}
/>
<FigureBubbleMenu
editor={editor() as Editor}
ref={(el) => {
figureBubbleMenuRef = el
}}
/>
<IncutBubbleMenu
editor={editor() as Editor}
ref={(el) => {
incutBubbleMenuRef = 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)
@ -59,19 +57,19 @@ export const EditorFloatingMenu = (props: FloatingMenuProps) => {
attrs: {
src: emb.src,
width: emb.width,
height: emb.height,
},
height: emb.height
}
},
{
type: 'figcaption',
content: [{ type: 'text', text: t('Description') }],
},
],
content: [{ type: 'text', text: t('Description') }]
}
]
})
.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
}
@ -109,27 +107,24 @@ export const EditorFloatingMenu = (props: FloatingMenuProps) => {
setMenuOpen(false)
setSelectedMenuItem()
}
},
}
})
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
@ -25,7 +25,7 @@ export const InsertLinkForm = (props: Props) => {
() => props.editor,
(ed) => {
return ed?.getAttributes('link').href || ''
},
}
)
const handleClearLinkForm = () => {
if (currentUrl()) {

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,25 +23,26 @@ type Props = {
export const Panel = (props: Props) => {
const { t } = useLocalize()
const { showModal } = useUI()
const {
isEditorPanelVisible,
wordCounter,
editorRef,
editor,
form,
toggleEditorPanel,
saveShout,
saveDraft,
publishShout,
publishShout
} = useEditorContext()
const containerRef: { current: HTMLElement } = { current: null }
let containerRef: HTMLElement | undefined
const [isShortcutsVisible, setIsShortcutsVisible] = createSignal(false)
const [isTypographyFixed, setIsTypographyFixed] = createSignal(false)
useOutsideClickHandler({
containerRef,
predicate: () => isEditorPanelVisible(),
handler: () => toggleEditorPanel(),
handler: () => toggleEditorPanel()
})
useEscKeyDownHandler(() => {
@ -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,33 +10,33 @@ 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,
createTiptapEditor,
useEditorHTML,
useEditorIsEmpty,
useEditorIsFocused,
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,106 +68,97 @@ 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',
content: 'figcaption image'
})
const content = props.initialContent
const editor = createTiptapEditor(() => ({
element: editorElRef.current,
editorProps: {
attributes: {
class: styles.simplifiedEditorField,
createEffect(
on(
() => editorElement(),
(ee: HTMLDivElement | undefined) => {
if (ee && textBubbleMenuRef && linkBubbleMenuRef) {
const freshEditor = createTiptapEditor<HTMLElement>(() => ({
element: ee,
editorProps: {
attributes: {
class: styles.simplifiedEditorField
}
},
extensions: [
Document,
Text,
Paragraph,
Bold,
Italic,
Link.extend({
inclusive: false
}).configure({
autolink: true,
openOnClick: false
}),
CharacterCount.configure({
limit: props.noLimits ? null : maxLength
}),
Blockquote.configure({
HTMLAttributes: {
class: styles.blockQuote
}
}),
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef,
shouldShow: ({ view, state }) => {
if (!props.onlyBubbleControls) return false
const { selection } = state
const { empty } = selection
return view.hasFocus() && !empty
}
}),
BubbleMenu.configure({
pluginKey: 'linkBubbleMenu',
element: linkBubbleMenuRef,
shouldShow: ({ state }) => {
const { selection } = state
const { empty } = selection
return !empty && shouldShowLinkBubbleMenu()
},
tippyOptions: {
placement: 'bottom'
}
}),
ImageFigure,
Image,
Figcaption,
Placeholder.configure({
emptyNodeClass: styles.emptyNode,
placeholder: props.placeholder
})
],
autofocus: props.autoFocus,
content: props.initialContent || null
}))
const editorInstance = freshEditor()
if (!editorInstance) return
setEditor(editorInstance)
}
},
},
extensions: [
Document,
Text,
Paragraph,
Bold,
Italic,
Link.extend({
inclusive: false,
}).configure({
autolink: true,
openOnClick: false,
}),
CharacterCount.configure({
limit: props.noLimits ? null : maxLength,
}),
Blockquote.configure({
HTMLAttributes: {
class: styles.blockQuote,
},
}),
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef.current,
shouldShow: ({ view, state }) => {
if (!props.onlyBubbleControls) return
const { selection } = state
const { empty } = selection
return view.hasFocus() && !empty
},
}),
BubbleMenu.configure({
pluginKey: 'linkBubbleMenu',
element: linkBubbleMenuRef.current,
shouldShow: ({ state }) => {
const { selection } = state
const { empty } = selection
return !empty && shouldShowLinkBubbleMenu()
},
tippyOptions: {
placement: 'bottom',
},
}),
ImageFigure,
Image,
Figcaption,
Placeholder.configure({
emptyNodeClass: styles.emptyNode,
placeholder: props.placeholder,
}),
],
autofocus: props.autoFocus,
content: content ?? null,
}))
{ defer: true }
)
)
setEditor(editor)
const isEmpty = useEditorIsEmpty(() => editor())
const isFocused = useEditorIsFocused(() => editor())
@ -176,7 +167,7 @@ const SimplifiedEditor = (props: Props) => {
() => editor(),
(ed) => {
return ed?.isActive(name)
},
}
)
const html = useEditorHTML(() => editor())
@ -187,7 +178,7 @@ const SimplifiedEditor = (props: Props) => {
const renderImage = (image: UploadedFile) => {
editor()
.chain()
?.chain()
.focus()
.insertContent({
type: 'figure',
@ -195,13 +186,13 @@ const SimplifiedEditor = (props: Props) => {
content: [
{
type: 'image',
attrs: { src: image.url },
attrs: { src: image.url }
},
{
type: 'figcaption',
content: [{ type: 'text', text: image.originalFilename }],
},
],
content: [{ type: 'text', text: image.originalFilename }]
}
]
})
.run()
hideModal()
@ -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,41 +247,41 @@ 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())
}
})
const maxHeightStyle = {
overflow: 'auto',
'max-height': `${props.maxHeight}px`,
'max-height': `${props.maxHeight}px`
}
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',
[styles.bordered]: props.variant === 'bordered',
[styles.isFocused]: isFocused() || !isEmpty(),
[styles.labelVisible]: props.label && counter() > 0,
[styles.labelVisible]: props.label && counter() > 0
})}
>
<Show when={props.maxLength && editor()}>
@ -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,10 +23,10 @@ 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),
(editor) => editor?.isActive(name, attributes)
)
const [textSizeBubbleOpen, setTextSizeBubbleOpen] = createSignal(false)
@ -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)
@ -86,12 +86,12 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
}
const value = ed.getAttributes('footnote').value
setFootNote(value)
},
}
)
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()
}
@ -168,7 +168,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: textSizeBubbleOpen(),
[styles.bubbleMenuButtonActive]: textSizeBubbleOpen()
})}
onClick={toggleTextSizePopup}
>
@ -180,12 +180,12 @@ 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"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isH1(),
[styles.bubbleMenuButtonActive]: isH1()
})}
onClick={() => {
props.editor.chain().focus().toggleHeading({ level: 2 }).run()
@ -197,12 +197,12 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
)}
</Popover>
<Popover content={t('Header 2')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isH2(),
[styles.bubbleMenuButtonActive]: isH2()
})}
onClick={() => {
props.editor.chain().focus().toggleHeading({ level: 3 }).run()
@ -214,12 +214,12 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
)}
</Popover>
<Popover content={t('Header 3')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isH3(),
[styles.bubbleMenuButtonActive]: isH3()
})}
onClick={() => {
props.editor.chain().focus().toggleHeading({ level: 4 }).run()
@ -234,12 +234,12 @@ 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"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isQuote(),
[styles.bubbleMenuButtonActive]: isQuote()
})}
onClick={handleSetPunchline}
>
@ -248,12 +248,12 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
)}
</Popover>
<Popover content={t('Punchline')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isPunchLine(),
[styles.bubbleMenuButtonActive]: isPunchLine()
})}
onClick={handleSetQuote}
>
@ -265,12 +265,12 @@ 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"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isIncut(),
[styles.bubbleMenuButtonActive]: isIncut()
})}
onClick={() => {
props.editor.chain().focus().toggleArticle().run()
@ -289,12 +289,12 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
</>
</Show>
<Popover content={t('Bold')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isBold(),
[styles.bubbleMenuButtonActive]: isBold()
})}
onClick={() => props.editor.chain().focus().toggleBold().run()}
>
@ -303,12 +303,12 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
)}
</Popover>
<Popover content={t('Italic')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isItalic(),
[styles.bubbleMenuButtonActive]: isItalic()
})}
onClick={() => props.editor.chain().focus().toggleItalic().run()}
>
@ -319,12 +319,12 @@ 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"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isHighlight(),
[styles.bubbleMenuButtonActive]: isHighlight()
})}
onClick={() => props.editor.chain().focus().toggleHighlight({ color: '#f6e3a1' }).run()}
>
@ -335,13 +335,13 @@ 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"
onClick={handleOpenLinkForm}
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isLink(),
[styles.bubbleMenuButtonActive]: isLink()
})}
>
<Icon name="editor-link" />
@ -351,12 +351,12 @@ 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"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isFootnote(),
[styles.bubbleMenuButtonActive]: isFootnote()
})}
onClick={handleOpenFootnoteEditor}
>
@ -369,7 +369,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: listBubbleOpen(),
[styles.bubbleMenuButtonActive]: listBubbleOpen()
})}
onClick={toggleListPopup}
>
@ -381,12 +381,12 @@ 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"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isBulletList(),
[styles.bubbleMenuButtonActive]: isBulletList()
})}
onClick={() => {
props.editor.chain().focus().toggleBulletList().run()
@ -398,12 +398,12 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
)}
</Popover>
<Popover content={t('Ordered list')}>
{(triggerRef: (el) => void) => (
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isOrderedList(),
[styles.bubbleMenuButtonActive]: isOrderedList()
})}
onClick={() => {
props.editor.chain().focus().toggleOrderedList().run()

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

View File

@ -2,9 +2,10 @@ import { UploadFile, createDropzone, createFileUploader } from '@solid-primitive
import { clsx } from 'clsx'
import { 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,12 +45,14 @@ 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,
size: file.size,
file: file,
file: file
}
await runUpload(fileToUpload)
} catch (error) {
@ -73,7 +76,7 @@ export const UploadModalContent = (props: Props) => {
} else {
setDragError(t('Image format not supported'))
}
},
}
})
const handleDrag = (event: MouseEvent) => {
if (event.type === 'dragenter' || event.type === 'dragover') {

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 () => {
@ -40,15 +33,15 @@ export const VideoUploader = (props: Props) => {
} else if (droppedFiles()[0].file.type.startsWith('video/')) {
await showSnackbar({
body: t(
'This functionality is currently not available, we would like to work on this issue. Use the download link.',
),
'This functionality is currently not available, we would like to work on this issue. Use the download link.'
)
})
} else {
setError(t('Video format not supported'))
}
},
}
})
const handleDrag = (event) => {
const handleDrag = (event: DragEvent) => {
if (event.type === 'dragenter' || event.type === 'dragover') {
setDragActive(true)
setError()
@ -85,8 +78,8 @@ export const VideoUploader = (props: Props) => {
onClick={() =>
showSnackbar({
body: t(
'This functionality is currently not available, we would like to work on this issue. Use the download link.',
),
'This functionality is currently not available, we would like to work on this issue. Use the download link.'
)
})
}
ref={dropzoneRef}
@ -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

@ -12,19 +12,14 @@ declare module '@tiptap/core' {
export default Node.create({
name: 'article',
defaultOptions: {
HTMLAttributes: {
'data-type': 'incut',
},
},
group: 'block',
content: 'block+',
parseHTML() {
return [
{
tag: 'article',
},
tag: 'article'
}
]
},
@ -32,14 +27,20 @@ export default Node.create({
return ['article', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
addOptions() {
return {
'data-type': 'incut'
}
},
addAttributes() {
return {
'data-float': {
default: null,
default: null
},
'data-bg': {
default: null,
},
default: null
}
}
},
@ -60,7 +61,7 @@ export default Node.create({
(value) =>
({ commands }) => {
return commands.updateAttributes(this.name, { 'data-bg': value })
},
}
}
},
}
})

View File

@ -1,4 +1,4 @@
import { Blockquote } from '@tiptap/extension-blockquote'
import { Blockquote, BlockquoteOptions } from '@tiptap/extension-blockquote'
export type QuoteTypes = 'quote' | 'punchline'
@ -13,19 +13,21 @@ declare module '@tiptap/core' {
export const CustomBlockquote = Blockquote.extend({
name: 'blockquote',
defaultOptions: {
HTMLAttributes: {},
},
group: 'block',
content: 'block+',
addOptions(): BlockquoteOptions {
return {} as BlockquoteOptions
},
addAttributes() {
return {
'data-float': {
default: null,
default: null
},
'data-type': {
default: null,
},
default: null
}
}
},
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@ -34,14 +36,12 @@ export const CustomBlockquote = Blockquote.extend({
return {
toggleBlockquote:
(type) =>
({ commands }) => {
return commands.toggleWrap(this.name, { 'data-type': type })
},
({ commands }) =>
commands.toggleWrap(this.name, { 'data-type': type }),
setBlockQuoteFloat:
(value) =>
({ commands }) => {
return commands.updateAttributes(this.name, { 'data-float': value })
},
({ commands }) =>
commands.updateAttributes(this.name, { 'data-float': value })
}
},
}
})

View File

@ -16,20 +16,20 @@ export const CustomImage = Image.extend({
addAttributes() {
return {
src: {
default: null,
default: null
},
alt: {
default: null,
default: null
},
width: {
default: null,
default: null
},
height: {
default: null,
default: null
},
'data-float': {
default: null,
},
default: null
}
}
},
addCommands() {
@ -39,14 +39,14 @@ export const CustomImage = Image.extend({
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
attrs: options
})
},
setImageFloat:
(value) =>
({ commands }) => {
return commands.updateAttributes(this.name, { 'data-float': value })
},
}
}
},
}
})

View File

@ -12,7 +12,7 @@ export const Figcaption = Node.create({
addOptions() {
return {
HTMLAttributes: {},
HTMLAttributes: {}
}
},
@ -25,8 +25,8 @@ export const Figcaption = Node.create({
parseHTML() {
return [
{
tag: 'figcaption',
},
tag: 'figcaption'
}
]
},
@ -39,7 +39,7 @@ export const Figcaption = Node.create({
(value) =>
({ commands }) => {
return commands.focus(value)
},
}
}
},
}
})

View File

@ -12,7 +12,7 @@ export const Figure = Node.create({
name: 'figure',
addOptions() {
return {
HTMLAttributes: {},
HTMLAttributes: {}
}
},
group: 'block',
@ -24,10 +24,10 @@ export const Figure = Node.create({
addAttributes() {
return {
'data-float': null,
'data-type': { default: null },
'data-type': { default: null }
}
},
// @ts-ignore FIXME: why
parseHTML() {
return [
{
@ -45,8 +45,8 @@ export const Figure = Node.create({
dataType = 'iframe'
}
return { 'data-type': dataType }
},
},
}
}
]
},
renderHTML({ HTMLAttributes }) {
@ -69,10 +69,10 @@ export const Figure = Node.create({
event.preventDefault()
}
return false
},
},
},
}),
}
}
}
})
]
},
@ -82,7 +82,7 @@ export const Figure = Node.create({
(value) =>
({ commands }) => {
return commands.updateAttributes(this.name, { 'data-float': value })
},
}
}
},
}
})

View File

@ -14,7 +14,7 @@ export const Footnote = Node.create({
name: 'footnote',
addOptions() {
return {
HTMLAttributes: {},
HTMLAttributes: {}
}
},
group: 'inline',
@ -29,18 +29,18 @@ export const Footnote = Node.create({
parseHTML: (element) => element.dataset.value || null,
renderHTML: (attributes) => {
return {
'data-value': attributes.value,
'data-value': attributes.value
}
},
},
}
}
}
},
parseHTML() {
return [
{
tag: 'footnote',
},
tag: 'footnote'
}
]
},
@ -92,7 +92,7 @@ export const Footnote = Node.create({
}
return false
},
}
}
},
}
})

View File

@ -24,33 +24,33 @@ export const Iframe = Node.create<IframeOptions>({
return {
allowFullscreen: true,
HTMLAttributes: {
class: 'iframe-wrapper',
},
class: 'iframe-wrapper'
}
}
},
addAttributes() {
return {
src: {
default: null,
default: null
},
frameborder: {
default: 0,
default: 0
},
allowfullscreen: {
default: this.options.allowFullscreen,
parseHTML: () => this.options.allowFullscreen,
parseHTML: () => this.options.allowFullscreen
},
width: { default: null },
height: { default: null },
height: { default: null }
}
},
parseHTML() {
return [
{
tag: 'iframe',
},
tag: 'iframe'
}
]
},
@ -69,7 +69,7 @@ export const Iframe = Node.create<IframeOptions>({
tr.replaceRangeWith(selection.from, selection.to, node)
}
return true
},
}
}
},
}
})

View File

@ -12,8 +12,8 @@ export const Span = Mark.create({
return { class: dom.getAttribute('class') }
}
return false
},
},
}
}
]
},
@ -24,8 +24,8 @@ export const Span = Mark.create({
addAttributes() {
return {
class: {
default: null,
},
default: null
}
}
},
}
})

View File

@ -39,11 +39,11 @@ 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
}
@ -22,7 +30,7 @@ export const TrailingNode = Extension.create<TrailingNodeOptions>({
addOptions() {
return {
node: 'paragraph',
notAfter: ['paragraph'],
notAfter: ['paragraph']
}
},
@ -61,9 +69,9 @@ export const TrailingNode = Extension.create<TrailingNodeOptions>({
const lastNode = tr.doc.lastChild
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
},
},
}),
}
}
})
]
},
}
})

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

@ -13,7 +13,10 @@
&:hover {
.shoutCardCover {
transform: scale(1.05);
img,
.placeholderCoverImage {
transform: scale(1.1);
}
}
}
@ -110,7 +113,6 @@
padding-bottom: 56.2%; // 16:9
position: relative;
transform-origin: 50% 50%;
transition: transform 1s ease-in-out;
&.loading {
background: rgb(0 0 0 / 20%);
@ -123,9 +125,10 @@
width: 100%;
}
&:hover img {
/* TODO: small zoom on hover */
transform: scaleZ(1.1);
img,
.placeholderCoverImage {
transform: scale(1);
transition: transform 1s ease-in-out;
}
}

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 { RatingControl as ShoutRatingControl } from '../../Article/RatingControl'
import { SharePopup, getShareUrl } from '../../Article/SharePopup'
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,27 +43,27 @@ 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,
L: 800,
L: 800
}
const getTitleAndSubtitle = (
article: Shout,
article: Shout
): {
title: string
subtitle: string
} => {
let title = article.title
let subtitle: string = article.subtitle || ''
let title = article?.title || ''
let subtitle: string = article?.subtitle || ''
if (!subtitle) {
let titleParts = article.title?.split('. ') || []
@ -90,34 +86,36 @@ 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,
video: styles.aspectRatio16x9,
image: styles.aspectRatio4x3,
image: styles.aspectRatio4x3
}
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)
const formattedDate = createMemo<string>(() =>
props.article?.published_at ? formatDate(new Date(props.article.published_at * 1000)) : '',
props.article?.published_at ? formatDate(new Date(props.article.published_at * 1000)) : ''
)
const canEdit = createMemo(
@ -125,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',
scrollTo: 'comments'
})
}
const onInvite = () => {
if (props.onInvite) props.onInvite()
}
return (
<section
class={clsx(styles.shoutCard, props.settings?.additionalClass, {
@ -151,7 +153,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
[styles.shoutCardSingle]: props.settings?.isSingle,
[styles.shoutCardBeside]: props.settings?.isBeside,
[styles.shoutCardNoImage]: !props.article.cover,
[aspectRatio()]: props.withAspectRatio,
[aspectRatio()]: props.withAspectRatio
})}
>
{/* Cover Image */}
@ -160,7 +162,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
<div class={styles.shoutCardCoverContainer}>
<div
class={clsx(styles.shoutCardCover, {
[styles.loading]: props.article.cover && isCoverImageLoading(),
[styles.loading]: props.article.cover && isCoverImageLoading()
})}
>
<Show
@ -168,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)
@ -208,17 +210,17 @@ 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>
{/* Title and Subtitle */}
<div
class={clsx(styles.shoutCardTitlesContainer, {
[styles.shoutCardTitlesContainerFeedMode]: props.settings?.isFeedMode,
[styles.shoutCardTitlesContainerFeedMode]: props.settings?.isFeedMode
})}
>
<a href={getPagePath(router, 'article', { slug: props.article.slug })}>
<A href={`/article${props.article?.slug || ''}`}>
<div class={styles.shoutCardTitle}>
<span class={styles.shoutCardLinkWrapper}>
<span class={styles.shoutCardLinkContainer} innerHTML={title} />
@ -227,10 +229,10 @@ export const ArticleCard = (props: ArticleCardProps) => {
<Show when={!props.settings?.nosubtitle && subtitle}>
<div class={styles.shoutCardSubtitle}>
<span class={styles.shoutCardLinkContainer} innerHTML={subtitle} />
<span class={styles.shoutCardLinkContainer} innerHTML={subtitle || ''} />
</div>
</Show>
</a>
</A>
</div>
{/* Details */}
@ -242,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>
@ -260,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}>
@ -283,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>
@ -326,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)} />
@ -355,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={
@ -380,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,8 +39,8 @@ 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
]
)}
>
<Show when={!!props.title}>
@ -64,7 +64,7 @@ export const Beside = (props: Props) => {
</Show>
<ul
class={clsx(styles.besideColumn, {
[styles.besideColumnTopViewed]: props.wrapper === 'top-article',
[styles.besideColumnTopViewed]: props.wrapper === 'top-article'
})}
>
<For each={[...props.values]}>

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 = {
@ -18,10 +15,10 @@ export const CardTopic = (props: CardTopicProps) => {
<div
class={clsx(styles.shoutTopic, props.class, {
[styles.shoutTopicFloorImportant]: props.isFloorImportant,
[styles.shoutTopicFeedMode]: props.isFeedMode,
[styles.shoutTopicFeedMode]: props.isFeedMode
})}
>
<a href={getPagePath(router, 'topic', { slug: props.slug })}>{props.title}</a>
<A href={`/topic/${props.slug}`}>{props.title}</A>
</div>
)
}

View File

@ -16,14 +16,15 @@
}
.action {
display: flex;
align-items: center;
width: 100%;
box-sizing: border-box;
padding: 8px 16px;
display: flex;
font-size: inherit;
font-weight: 500;
gap: 0.8rem;
padding: 8px 16px;
text-align: left;
width: 100%;
white-space: nowrap;
&.soon {
@ -32,11 +33,29 @@
gap: 0.6rem;
width: 100%;
justify-content: space-between;
.icon {
opacity: 0.4;
}
}
&:hover {
background: var(--black-500);
color: var(--black-50) !important;
.icon {
filter: invert(1);
opacity: 1 !important;
}
}
.icon {
flex: 0 2.4rem;
min-width: 2.4rem;
}
.title {
flex: 1;
}
}

View File

@ -4,6 +4,7 @@ import { clsx } from 'clsx'
import { Show, createSignal } from 'solid-js'
import { useLocalize } from '../../../context/localize'
import { Icon } from '../../_shared/Icon'
import { Popup } from '../../_shared/Popup'
import { SoonChip } from '../../_shared/SoonChip'
@ -38,7 +39,8 @@ export const FeedArticlePopup = (props: Props) => {
setHidePopup(true)
}}
>
{t('Share')}
<Icon name="share-outline" class={styles.icon} />
<div class={styles.title}>{t('Share')}</div>
</button>
</li>
<Show when={!props.canEdit}>
@ -51,7 +53,8 @@ export const FeedArticlePopup = (props: Props) => {
setHidePopup(true)
}}
>
{t('Help to edit')}
<Icon name="pencil-outline" class={styles.icon} />
<div class={styles.title}>{t('Help to edit')}</div>
</button>
</li>
</Show>
@ -64,19 +67,24 @@ export const FeedArticlePopup = (props: Props) => {
setHidePopup(false)
}}
>
{t('Invite experts')}
<Icon name="expert" class={styles.icon} />
<div class={styles.title}>{t('Invite experts')}</div>
</button>
</li>
<Show when={!props.canEdit}>
<li>
<button class={clsx(styles.action, styles.soon)} role="button">
{t('Subscribe to comments')} <SoonChip />
<Icon name="bell-white" class={styles.icon} />
<div class={styles.title}>{t('Subscribe to comments')}</div>
<SoonChip />
</button>
</li>
</Show>
<li>
<button class={clsx(styles.action, styles.soon)} role="button">
{t('Add to bookmarks')} <SoonChip />
<Icon name="bookmark" class={styles.icon} />
<div class={styles.title}>{t('Add to bookmarks')}</div>
<SoonChip />
</button>
</li>
{/*<Show when={!props.canEdit}>*/}

View File

@ -26,7 +26,7 @@ export default (props: GroupProps) => {
nosubtitle: false,
noicon: true,
isBigTitle: true,
nodate: true,
nodate: true
}}
desktopCoverSize="M"
/>
@ -60,7 +60,7 @@ export default (props: GroupProps) => {
noimage: true,
isBigTitle: true,
isCompact: true,
nodate: true,
nodate: true
}}
desktopCoverSize="XS"
/>
@ -77,7 +77,7 @@ export default (props: GroupProps) => {
noimage: true,
isBigTitle: true,
isCompact: true,
nodate: true,
nodate: true
}}
desktopCoverSize="XS"
/>

View File

@ -0,0 +1,268 @@
.placeholder {
border-radius: 2.2rem;
display: flex;
font-size: 1.4rem;
font-weight: 500;
overflow: hidden;
position: relative;
h3 {
font-size: 2.4rem;
}
button,
.button {
align-items: center;
border-radius: 1.2rem;
display: flex;
@include font-size(1.5rem);
gap: 0.6rem;
justify-content: center;
margin-top: 3rem;
padding: 1rem 2rem;
width: 100%;
.icon {
height: 2.4rem;
width: 2.4rem;
}
}
}
.placeholder--feed-mode {
flex-direction: column;
min-height: 40rem;
text-align: center;
@include media-breakpoint-up(lg) {
aspect-ratio: 1 / 0.8;
}
.placeholderCover {
flex: 1 100%;
position: relative;
&::after {
bottom: 0;
content: '';
height: 20%;
left: 0;
position: absolute;
width: 100%;
}
img {
position: absolute;
}
}
&.placeholder--feedMy .placeholderCover::after {
background: linear-gradient(to top, #171032, rgb(23 16 50 / 0%));
}
&.placeholder--feedCollaborations .placeholderCover::after {
background: linear-gradient(to top, #070709, rgb(7 7 9 / 0%));
}
}
.placeholder--profile-mode {
min-height: 40rem;
@include media-breakpoint-down(lg) {
display: block;
}
@include media-breakpoint-up(lg) {
max-height: 30rem;
min-height: auto;
}
.placeholderCover {
flex: 1;
padding: 1.6rem;
@include media-breakpoint-up(lg) {
order: 2;
position: static;
}
img {
aspect-ratio: 16/10;
min-width: 40rem;
object-fit: contain;
width: 100%;
@include media-breakpoint-up(lg) {
object-position: right;
}
}
}
.placeholderContent {
display: flex;
flex-direction: column;
justify-content: space-between;
font-size: 1.4rem;
line-height: 1.2;
min-width: 60%;
padding: 0 2rem 2rem;
@include media-breakpoint-up(md) {
font-size: 1.6rem;
padding: 3rem;
}
@include media-breakpoint-up(lg) {
font-size: 2rem;
}
}
h3 {
@include font-size(3.8rem);
}
.button {
background: var(--background-color-invert);
bottom: 2rem;
color: var(--default-color-invert);
font-size: 1.6rem;
left: 2rem;
right: 2rem;
width: 100%;
@include media-breakpoint-up(lg) {
left: auto;
position: absolute;
width: auto;
}
.icon {
filter: invert(1);
}
}
}
.placeholderCover {
position: relative;
img {
left: 0;
height: 100%;
object-fit: cover;
width: 100%;
}
}
.placeholderContent {
padding: 1.6rem;
@include media-breakpoint-down(lg) {
br {
display: none;
}
}
}
.placeholder--feedMy,
.placeholder--feedCollaborations {
color: var(--default-color-invert);
button,
.button {
background: var(--background-color);
color: var(--default-color);
}
}
.placeholder--feedMy {
background: #171032;
.placeholderCover {
img {
object-position: top;
}
}
}
.placeholder--feedCollaborations {
background: #070709;
.placeholderCover {
img {
object-position: bottom;
}
}
}
.placeholder--feedDiscussions {
background: #E9E9EE;
.placeholderCover {
padding: 2rem;
text-align: center;
img {
height: 90%;
mix-blend-mode: multiply;
object-fit: contain;
top: 10%;
}
}
button,
.button {
background: var(--background-color-invert);
color: var(--default-color-invert);
}
}
.placeholder--author {
background: #E58B72;
}
.placeholder--authorComments {
background: #E9E9EE;
.placeholderCover {
img {
mix-blend-mode: multiply;
}
}
}
.bottomLinks {
display: flex;
@include font-size(1.6rem);
gap: 4rem;
@include media-breakpoint-down(sm) {
flex-direction: column;
gap: 1.4rem;
}
a {
border: none !important;
padding-left: 2.6rem;
position: relative;
&:hover {
.icon {
filter: invert(0);
}
}
}
.icon {
filter: invert(1);
height: 1.8rem;
left: 0;
position: absolute;
transition: filter 0.2s;
width: 1.8rem;
}
}

View File

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

View File

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

View File

@ -21,7 +21,7 @@ export const Row1 = (props: {
isSingle: true,
nodate: props.nodate,
noAuthorLink: props.noAuthorLink,
noauthor: props.noauthor,
noauthor: props.noauthor
}}
desktopCoverSize="L"
/>

View File

@ -36,7 +36,7 @@ export const Row2 = (props: {
isWithCover: props.isEqual || className === 'col-md-16',
nodate: props.isEqual || props.nodate,
noAuthorLink: props.noAuthorLink,
noauthor: props.noauthor,
noauthor: props.noauthor
}}
desktopCoverSize={desktopCoverSize}
/>

View File

@ -28,7 +28,7 @@ export const Row3 = (props: {
settings={{
nodate: props.nodate,
noAuthorLink: props.noAuthorLink,
noauthor: props.noauthor,
noauthor: props.noauthor
}}
desktopCoverSize="S"
/>

Some files were not shown because too many files have changed in this diff Show More