Compare commits

..

No commits in common. "dev" and "feature/rating" have entirely different histories.

589 changed files with 18474 additions and 32746 deletions

View File

@ -18,10 +18,10 @@ jobs:
run: npm install --global --save-exact @biomejs/biome run: npm install --global --save-exact @biomejs/biome
- name: Lint with Biome - name: Lint with Biome
run: npx @biomejs/biome ci run: npx biome ci .
- name: Lint styles - name: Lint styles
run: npx stylelint **/*.{scss,css} run: npm run lint:styles
- name: Check types - name: Check types
run: npm run typecheck run: npm run typecheck
@ -29,15 +29,6 @@ jobs:
- name: Test production build - name: Test production build
run: npm run 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 }}
DEBUG: pw:api
email-templates: email-templates:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Update templates on Mailgun name: Update templates on Mailgun

View File

@ -1,58 +1,42 @@
name: "CI and E2E Tests" name: "deploy"
on: on: [push]
push:
deployment_status:
types: [success]
jobs: jobs:
ci: test:
if: github.event_name == 'push'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
- name: Install dependencies - name: Install dependencies
run: npm i
- name: Install CI checks
run: npm ci run: npm ci
- name: Check types - name: Check types
run: npm run typecheck run: npm run typecheck
- name: Lint with Biome - name: Lint with Biome
run: npx @biomejs/biome check src/. run: npm run check:code
- name: Lint styles - name: Lint styles
run: npx stylelint **/*.{scss,css} run: npm run lint:styles
- name: Test production build - name: Test production build
run: npm run build run: npm run build
e2e_tests: e2e:
needs: ci timeout-minutes: 60
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event.deployment_status.state == 'success'
steps: steps:
- name: Debug event info
run: |
echo "Event Name: ${{ github.event_name }}"
echo "Deployment Status: ${{ github.event.deployment_status.state }}"
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install dependencies - name: Install dependencies
run: npm install run: npm ci
- name: Wait for deployment to be live - name: Install Playwright
run: | run: npx playwright install --with-deps
echo "Waiting for Vercel deployment to be live..."
until curl -sSf https://testing.discours.io > /dev/null; do
printf '.'
sleep 10
done
- name: Install Playwright and dependencies
run: npm run e2e:install
- name: Run Playwright tests - name: Run Playwright tests
run: npm run e2e:tests:ci run: npx playwright test
env: env:
BASE_URL: https://testing.discours.io BASE_URL: ${{ github.event.deployment_status.target_url }}
continue-on-error: true
- name: Report test result if failed
if: failure()
run: echo "E2E tests failed. Please review the logs."

11
.gitignore vendored
View File

@ -1,9 +1,8 @@
.devcontainer
.pnpm-store
dist/ dist/
node_modules/ node_modules/
npm-debug.log* npm-debug.log*
pnpm-debug.log* pnpm-debug.log*
.vscode
.env .env
.env.production .env.production
.DS_Store .DS_Store
@ -24,10 +23,4 @@ bun.lockb
/playwright/.cache/ /playwright/.cache/
/plawright-report/ /plawright-report/
target target
.github/dependabot.yml .venv
.output
.vinxi
*.pem
edge.*
.vscode/settings.json
storybook-static

5
.lintstagedrc Normal file
View File

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

View File

@ -1,49 +0,0 @@
import type { FrameworkOptions, StorybookConfig } from 'storybook-solidjs-vite'
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y',
'@storybook/addon-themes',
'storybook-addon-sass-postcss'
],
framework: {
name: 'storybook-solidjs-vite',
options: {
builder: {
viteConfigPath: './vite.config.ts'
}
} as FrameworkOptions
},
docs: {
autodocs: 'tag'
},
viteFinal: (config) => {
if (config.build) {
config.build.sourcemap = true
config.build.minify = process.env.NODE_ENV === 'production'
}
if (config.css) {
config.css.preprocessorOptions = {
scss: {
silenceDeprecations: ['mixed-decls'],
additionalData: '@import "~/styles/imports";\n',
includePaths: ['./public', './src/styles', './node_modules']
}
}
}
return config
},
previewHead: (head) => `
${head}
<style>
body {
transition: none !important;
}
</style>
`
}
export default config

View File

@ -1,34 +0,0 @@
import { withThemeByClassName } from '@storybook/addon-themes'
import '../src/styles/app.scss'
const preview = {
parameters: {
themes: {
default: 'light',
list: [
{ name: 'light', class: '', color: '#f8fafc' },
{ name: 'dark', class: 'dark', color: '#0f172a' }
]
},
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/
}
}
}
}
export default preview
export const decorators = [
withThemeByClassName({
themes: {
light: '',
dark: 'dark'
},
defaultTheme: 'light',
parentSelector: 'body'
})
]

View File

@ -1,23 +0,0 @@
import type { Page } from '@playwright/test'
import type { TestRunnerConfig } from '@storybook/test-runner'
import { checkA11y, injectAxe } from 'axe-playwright'
/*
* See https://storybook.js.org/docs/react/writing-tests/test-runner#test-hook-api-experimental
* to learn more about the test-runner hooks API.
*/
const a11yConfig = {
async preRender(page: Page) {
await injectAxe(page)
},
async postRender(page: Page) {
await checkA11y(page, '#storybook-root', {
detailedReport: true,
detailedReportOptions: {
html: true
}
})
}
} as TestRunnerConfig
module.exports = a11yConfig

View File

@ -1,6 +1,2 @@
node_modules .vercel/
dist/ dist/
storybook-static
.output
.vinxi
.vercel

View File

@ -1,73 +1,34 @@
{ {
"defaultSeverity": "warning", "extends": ["stylelint-config-standard-scss"],
"extends": ["stylelint-config-standard-scss", "stylelint-config-recommended"],
"plugins": ["stylelint-order", "stylelint-scss"], "plugins": ["stylelint-order", "stylelint-scss"],
"rules": { "rules": {
"annotation-no-unknown": [
true,
{
"ignoreAnnotations": ["default"]
}
],
"at-rule-no-unknown": null,
"declaration-block-no-redundant-longhand-properties": null,
"font-family-no-missing-generic-family-keyword": null,
"function-no-unknown": [
true,
{
"ignoreFunctions": ["divide", "transparentize"]
}
],
"function-url-quotes": null,
"keyframes-name-pattern": null, "keyframes-name-pattern": null,
"declaration-block-no-redundant-longhand-properties": null,
"selector-class-pattern": null,
"no-descending-specificity": null, "no-descending-specificity": null,
"order/order": [ "scss/function-no-unknown": null,
{ "scss/no-global-function-names": null,
"type": "at-rule", "function-url-quotes": null,
"name": "include" "font-family-no-missing-generic-family-keyword": null,
}, "order/order": ["custom-properties", "declarations"],
"custom-properties",
"declarations",
"rules"
],
"property-no-vendor-prefix": [
true,
{
"ignoreProperties": ["box-decoration-break"]
}
],
"scss/at-function-pattern": null,
"scss/at-mixin-pattern": null,
"scss/dollar-variable-colon-space-after": "always-single-line",
"scss/dollar-variable-colon-space-before": "never",
"scss/dollar-variable-pattern": [ "scss/dollar-variable-pattern": [
"^[a-z][a-zA-Z]+$", "^[a-z][a-zA-Z]+$",
{ {
"ignore": "global" "ignore": "global"
} }
], ],
"scss/double-slash-comment-empty-line-before": [
"always",
{
"except": ["first-nested"],
"ignore": ["between-comments", "stylelint-commands"]
}
],
"scss/double-slash-comment-whitespace-inside": "always",
"scss/function-no-unknown": null,
"scss/no-duplicate-dollar-variables": null,
"scss/no-duplicate-mixins": null,
"scss/no-global-function-names": null,
"scss/operator-no-newline-after": null,
"scss/operator-no-newline-before": null,
"scss/operator-no-unspaced": null,
"scss/percent-placeholder-pattern": null,
"selector-class-pattern": null,
"selector-pseudo-class-no-unknown": [ "selector-pseudo-class-no-unknown": [
true, true,
{ {
"ignorePseudoClasses": ["global", "export"] "ignorePseudoClasses": ["global", "export"]
} }
],
"property-no-vendor-prefix": [
true,
{
"ignoreProperties": ["box-decoration-break"]
}
] ]
} },
"defaultSeverity": "warning"
} }

View File

@ -1,3 +0,0 @@
{
"recommendations": ["biomejs.biome", "stylelint.vscode-stylelint", "wayou.vscode-todo-highlight"]
}

View File

@ -1,5 +0,0 @@
{
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "always"
}
}

View File

@ -1,57 +0,0 @@
## Development setup recommendations
### How to start
Use `bun i`, `npm i`, `pnpm i` or `yarn` to install packages.
### Config of variables
- Use `.env` file to setup your own development environment
- Env vars with prefix `PUBLIC_` are widely used in `/src/utils/config.ts`
### Useful commands
run checks, fix styles, imports, formatting and autofixable linting errors:
```
bun run typecheck
bun run fix
```
## End-to-End (E2E) Tests
This directory contains end-to-end tests. These tests are written using [Playwright](https://playwright.dev/)
### Structure
- `/tests/*`: This directory contains the test files.
- `/playwright.config.ts`: This is the configuration file for Playwright.
### Getting Started
Follow these steps:
1. **Install dependencies**: Run `npm run e2e:install` to install the necessary dependencies for running the tests.
2. **Run the tests**: After using `npm run e2e:tests`.
### Additional Information
If workers is no needed use:
- `npx playwright test --project=webkit --workers 4`
For more information on how to write tests using Playwright - [Playwright documentation](https://playwright.dev/docs/intro).
### 🚀 Tests in CI Mode
Tests are executed within a GitHub workflow. We organize our tests into two main directories:
- `tests`: Contains tests that do not require authentication.
- `tests-with-auth`: Houses tests that interact with authenticated parts of the application.
🔧 **Configuration:**
Playwright is configured to utilize the `BASE_URL` environment variable. Ensure this is properly set in your CI configuration to point to the correct environment.
📝 **Note:**
After pages have been adjusted to work with authentication, all tests should be moved to the `tests` directory to streamline the testing process.

View File

@ -1,57 +1,31 @@
[English](README.en.md) ## How to start
## Рекомендации по настройке разработки
### Как начать
Используйте `bun i`, `npm i`, `pnpm i` или `yarn`, чтобы установить пакеты.
### Настройка переменных
- Используйте файл `.env` для настройки переменных собственной среды разработки.
- Переменные окружения с префиксом `PUBLIC_` широко используются в `/src/utils/config.ts`.
### Полезные команды
Запуск проверки соответствия типов и автоматически исправить ошибки стилей, порядок импорта, форматирование:
``` ```
bun run typecheck npm install
bun run fix npm start
``` ```
## End-to-End (E2E) тесты ## Useful commands
End-to-end тесты написаны с использованием [Playwright](https://playwright.dev/). run checks with your favorite package manager: npm, yarn, pnpm or bun
### Структура ```
npm run check
```
- `/tests/*`: содержит файлы тестов fix styles, imports, formatting and autofixable linting errors:
- `/playwright.config.ts`: конфиг для Playwright
### Начало работы ```
npm run fix
```
Следуйте этим шагам: ## Code generation
1. **Установите зависимости**: Запустите `npm run e2e:install`, чтобы установить необходимые зависимости для выполнения тестов. generate new SolidJS component:
```
npm run hygen component new NewComponentName
```
2. **Запустите тесты**: После установки зависимостей используйте `npm run e2e:tests`. generate new SolidJS context:
```
### Дополнительная информация npm run hygen context new NewContextName
```
Для параллельного исполнения:
- `npx playwright test --project=webkit --workers 4`
Для получения дополнительной информации о написании тестов с использованием Playwright - [Документация Playwright](https://playwright.dev/docs/intro).
### 🚀 Тесты в режиме CI
Тесты выполняются в рамках GitHub workflow из папки `tests`
🔧 **Конфигурация:**
Playwright настроен на использование переменной окружения `BASE_URL`. Убедитесь, что она правильно установлена в вашей конфигурации CI для указания на правильную среду.
📝 **Примечание:**
После того как страницы были настроены для работы с аутентификацией, все тесты должны быть перемещены в директорию `tests` для упрощения процесса тестирования.

32
api/edge-ssr.js Normal file
View File

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

View File

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

View File

@ -1,23 +0,0 @@
import { SolidStartInlineConfig, defineConfig } from '@solidjs/start/config'
import viteConfig, { isDev } from './vite.config'
const isVercel = Boolean(process.env.VERCEL)
const isNetlify = Boolean(process.env.NETLIFY)
const isBun = Boolean(process.env.BUN)
const preset = isNetlify ? 'netlify' : isVercel ? 'vercel_edge' : isBun ? 'bun' : 'node'
console.info(`[app.config] solid-start preset {> ${preset} <}`)
export default defineConfig({
nitro: {
timing: true
},
ssr: true,
server: {
preset,
port: 3000,
https: true
},
devOverlay: isDev,
vite: viteConfig
} as SolidStartInlineConfig)

View File

@ -1,18 +1,16 @@
{ {
"$schema": "https://biomejs.dev/schemas/1.9.3/schema.json", "$schema": "https://biomejs.dev/schemas/1.5.3/schema.json",
"files": { "files": {
"include": ["*.tsx", "*.ts", "*.js", "*.json"], "include": ["*.tsx", "*.ts", "*.js", "*.json"],
"ignore": ["./dist", "./node_modules", ".husky", "docs", "gen", "*.gen.ts", "*.d.ts"] "ignore": ["./dist", "./node_modules", ".husky", "docs", "gen", "*.gen.ts", "*.d.ts"]
}, },
"vcs": { "vcs": {
"defaultBranch": "dev", "defaultBranch": "dev",
"useIgnoreFile": true, "useIgnoreFile": true
"enabled": true,
"clientKind": "git"
}, },
"organizeImports": { "organizeImports": {
"enabled": true, "enabled": true,
"ignore": ["./gen"] "ignore": ["./api", "./gen"]
}, },
"formatter": { "formatter": {
"indentStyle": "space", "indentStyle": "space",
@ -24,10 +22,10 @@
"formatter": { "formatter": {
"semicolons": "asNeeded", "semicolons": "asNeeded",
"quoteStyle": "single", "quoteStyle": "single",
"trailingComma": "all",
"enabled": true, "enabled": true,
"jsxQuoteStyle": "double", "jsxQuoteStyle": "double",
"arrowParentheses": "always", "arrowParentheses": "always"
"trailingCommas": "none"
} }
}, },
"linter": { "linter": {
@ -42,9 +40,7 @@
"noExcessiveCognitiveComplexity": "off" "noExcessiveCognitiveComplexity": "off"
}, },
"correctness": { "correctness": {
"useHookAtTopLevel": "off", "useHookAtTopLevel": "off"
"useImportExtensions": "off",
"noUndeclaredDependencies": "off"
}, },
"a11y": { "a11y": {
"useHeadingContent": "off", "useHeadingContent": "off",
@ -56,8 +52,7 @@
"useAltText": "off", "useAltText": "off",
"useButtonType": "off", "useButtonType": "off",
"noRedundantAlt": "off", "noRedundantAlt": "off",
"noSvgWithoutTitle": "off", "noSvgWithoutTitle": "off"
"noLabelWithoutControl": "off"
}, },
"nursery": { "nursery": {
"useImportRestrictions": "off" "useImportRestrictions": "off"
@ -66,18 +61,14 @@
"noBarrelFile": "off" "noBarrelFile": "off"
}, },
"style": { "style": {
"noNonNullAssertion": "off",
"noNamespaceImport": "warn",
"useBlockStatements": "off", "useBlockStatements": "off",
"noImplicitBoolean": "off", "noImplicitBoolean": "off",
"useNamingConvention": "off", "useNamingConvention": "off",
"useImportType": "off", "useImportType": "off",
"noDefaultExport": "off", "noDefaultExport": "off",
"useFilenamingConvention": "off", "useFilenamingConvention": "off"
"useExplicitLengthCheck": "off"
}, },
"suspicious": { "suspicious": {
"noConsole": "off",
"noConsoleLog": "off", "noConsoleLog": "off",
"noAssignInExpressions": "off" "noAssignInExpressions": "off"
} }

View File

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

66
docs/article.puml Normal file
View File

@ -0,0 +1,66 @@
@startuml
actor User
participant Browser
participant Vercel
participant article.page.server.ts
participant Solid
participant Store
User -> Browser: discours.io
activate Browser
Browser -> Vercel: GET <slug>
activate Vercel
Vercel -> article.page.server.ts: render
activate article.page.server.ts
article.page.server.ts -> apiClient: getArticle({ slug })
activate apiClient
apiClient -> DB: query: articleBySlug
activate DB
DB --> apiClient: response
deactivate DB
apiClient --> article.page.server.ts: article data
deactivate apiClient
article.page.server.ts -> Solid: render <ArticlePage article={article} />
activate Solid
Solid -> Store: useCurrentArticleStore(article)
activate Store
Store -> Store: create store with initial data (server)
Store --> Solid: currentArticle
deactivate Store
Solid -> Solid: render component
Solid --> article.page.server.ts: rendered component
deactivate Solid
article.page.server.ts --> Vercel: rendered page
Vercel -> Vercel: save rendered page to CDN
deactivate article.page.server.ts
Vercel --> Browser: rendered page
deactivate Vercel
Browser --> User: rendered page
deactivate Browser
Browser -> Browser: load client scripts
Browser -> Solid: render <ArticlePage article={article} />
Solid -> Store: useCurrentArticleStore(article)
activate Store
Store -> Store: create store with initial data (client)
Store --> Solid: currentArticle
deactivate Store
Solid -> Solid: render component (no changes)
Solid -> Solid: onMount
Solid -> Store: loadArticleComments
activate Store
Store -> apiClient: getArticleComments
activate apiClient
apiClient -> DB: query: getReactions
activate DB
DB --> apiClient: response
deactivate DB
apiClient --> Store: comments data
deactivate apiClient
Store -> Store: update store
Store --> Solid: store updated
deactivate Store
Solid -> Solid: render comments
Solid --> Browser: rendered comments
Browser --> User: comments
@enduml

40
docs/i18n.puml Normal file
View File

@ -0,0 +1,40 @@
@startuml
actor User
participant Browser
participant Server
User -> Browser: discours.io
activate Browser
Browser -> Server: GET\nquery { lng }\ncookies { lng }
opt lng in query
Server -> Server: lng = lng from query
else no lng in query
opt lng in cookies
Server -> Server: lng = lng from cookies
else no lng in cookies
Server -> Server: lng = 'ru'
end opt
end opt
note right
_dafault.page.server.ts render
end note
opt i18next is not initialized
Server -> Server: initialize i18next with lng
else i18next not initialized
Server -> Server: change i18next language to lng
end opt
note right
all resources loaded synchronously
end note
Server --> Browser: pageContext { lng }
Browser -> Browser: init client side i18next with http backend
activate Browser
Browser -> Server: get translations for current language
Server --> Browser: translations JSON
deactivate Browser
Browser -> Browser: render page
Browser --> User: rendered page
deactivate Browser
@enduml

24
docs/routing.puml Normal file
View File

@ -0,0 +1,24 @@
@startuml
actor User
participant Browser
participant Server
User -> Browser: discours.io
activate Browser
Browser -> Server: GET
activate Server
Server -> Server: resolve route
note right
based on routes from
*.page.route.ts files
end note
Server -> Server: some.page.server.ts onBeforeRender
Server -> Server: _default.page.server.tsx render
Server --> Browser: pageContent
deactivate Server
Browser -> Browser: _default.page.client.tsx render(pageContext)
Browser --> User: rendered page
deactivate Browser
@enduml

View File

@ -0,0 +1,18 @@
---
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

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

View File

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

View File

@ -0,0 +1,24 @@
---
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

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

View File

@ -0,0 +1,16 @@
---
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

@ -0,0 +1,16 @@
---
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

@ -0,0 +1,14 @@
---
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

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

20704
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,151 +1,144 @@
{ {
"name": "discoursio-webapp", "name": "discoursio-webapp",
"version": "0.9.2",
"private": true, "private": true,
"version": "0.9.6", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vinxi dev", "build": "vite build",
"build": "vinxi build", "check": "npm run lint && npm run typecheck",
"start": "vinxi start",
"codegen": "graphql-codegen", "codegen": "graphql-codegen",
"e2e": "E2E=1 npm run e2e:tests", "deploy": "graphql-codegen && npm run typecheck && vite build && vercel",
"e2e:tests": "npx playwright test --project=webkit", "dev": "vite",
"e2e:tests:ci": "CI=true npx playwright test --project=webkit", "e2e": "npx playwright test --project=chromium",
"e2e:install": "npx playwright install webkit && npx playwright install-deps ", "fix": "npm run check:code:fix && stylelint **/*.{scss,css} --fix",
"fix": "npx @biomejs/biome check . --fix && stylelint **/*.{scss,css} --fix",
"format": "npx @biomejs/biome format src/. --write", "format": "npx @biomejs/biome format src/. --write",
"hygen": "HYGEN_TMPLS=gen hygen",
"postinstall": "npm run codegen && npx patch-package", "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": "tsc --noEmit",
"storybook": "storybook dev -p 6006", "typecheck:watch": "tsc --noEmit --watch"
"storybook:test": "test-storybook", },
"build-storybook": "storybook build" "dependencies": {
"form-data": "4.0.0",
"idb": "8.0.0",
"mailgun.js": "10.1.0"
}, },
"devDependencies": { "devDependencies": {
"@authorizerdev/authorizer-js": "^2.0.3", "@authorizerdev/authorizer-js": "2.0.0",
"@biomejs/biome": "^1.9.3", "@babel/core": "7.23.3",
"@graphql-codegen/cli": "^5.0.2", "@biomejs/biome": "^1.7.2",
"@graphql-codegen/typescript": "^4.0.9", "@graphql-codegen/cli": "^5.0.0",
"@graphql-codegen/typescript-operations": "^4.2.3", "@graphql-codegen/typescript": "^4.0.1",
"@graphql-codegen/typescript-operations": "^4.0.1",
"@graphql-codegen/typescript-urql": "^4.0.0", "@graphql-codegen/typescript-urql": "^4.0.0",
"@hocuspocus/provider": "^2.13.6", "@graphql-tools/url-loader": "8.0.1",
"@playwright/test": "^1.47.2", "@hocuspocus/provider": "2.11.0",
"@popperjs/core": "^2.11.8", "@microsoft/fetch-event-source": "^2.0.1",
"@solid-primitives/media": "^2.2.9", "@nanostores/router": "0.13.0",
"@solid-primitives/memo": "^1.3.9", "@nanostores/solid": "0.4.2",
"@solid-primitives/pagination": "^0.3.0", "@playwright/test": "1.41.2",
"@solid-primitives/script-loader": "^2.2.0", "@popperjs/core": "2.11.8",
"@solid-primitives/share": "^2.0.6", "@sentry/browser": "^7.113.0",
"@solid-primitives/storage": "^4.2.1", "@solid-primitives/media": "2.2.3",
"@solid-primitives/upload": "^0.0.117", "@solid-primitives/memo": "1.2.4",
"@solidjs/meta": "^0.29.4", "@solid-primitives/pagination": "0.2.10",
"@solidjs/router": "^0.14.7", "@solid-primitives/share": "2.0.4",
"@solidjs/start": "^1.0.8", "@solid-primitives/storage": "^3.5.0",
"@storybook/addon-a11y": "^8.3.4", "@solid-primitives/upload": "0.0.115",
"@storybook/addon-actions": "^8.3.4", "@thisbeyond/solid-select": "0.14.0",
"@storybook/addon-controls": "^8.3.4", "@tiptap/core": "2.2.3",
"@storybook/addon-essentials": "^8.3.4", "@tiptap/extension-blockquote": "2.2.3",
"@storybook/addon-interactions": "^8.3.4", "@tiptap/extension-bold": "2.2.3",
"@storybook/addon-links": "^8.3.4", "@tiptap/extension-bubble-menu": "2.2.3",
"@storybook/addon-themes": "^8.3.4", "@tiptap/extension-bullet-list": "2.2.3",
"@storybook/addon-viewport": "^8.3.4", "@tiptap/extension-character-count": "2.2.3",
"@storybook/builder-vite": "^8.3.4", "@tiptap/extension-collaboration": "2.2.3",
"@storybook/docs-tools": "^8.3.4", "@tiptap/extension-collaboration-cursor": "2.2.3",
"@storybook/test": "^8.3.4", "@tiptap/extension-document": "2.2.3",
"@storybook/test-runner": "^0.19.1", "@tiptap/extension-dropcursor": "2.2.3",
"@tiptap/core": "^2.8.0", "@tiptap/extension-floating-menu": "2.2.3",
"@tiptap/extension-blockquote": "^2.8.0", "@tiptap/extension-focus": "2.2.3",
"@tiptap/extension-bold": "^2.8.0", "@tiptap/extension-gapcursor": "2.2.3",
"@tiptap/extension-bubble-menu": "^2.8.0", "@tiptap/extension-hard-break": "2.2.3",
"@tiptap/extension-bullet-list": "^2.8.0", "@tiptap/extension-heading": "2.2.3",
"@tiptap/extension-character-count": "^2.8.0", "@tiptap/extension-highlight": "2.2.3",
"@tiptap/extension-collaboration": "^2.8.0", "@tiptap/extension-history": "2.2.3",
"@tiptap/extension-collaboration-cursor": "^2.8.0", "@tiptap/extension-horizontal-rule": "2.2.3",
"@tiptap/extension-document": "^2.8.0", "@tiptap/extension-image": "2.2.3",
"@tiptap/extension-dropcursor": "^2.8.0", "@tiptap/extension-italic": "2.2.3",
"@tiptap/extension-floating-menu": "^2.8.0", "@tiptap/extension-link": "2.2.3",
"@tiptap/extension-focus": "^2.8.0", "@tiptap/extension-list-item": "2.2.3",
"@tiptap/extension-gapcursor": "^2.8.0", "@tiptap/extension-ordered-list": "2.2.3",
"@tiptap/extension-hard-break": "^2.8.0", "@tiptap/extension-paragraph": "2.2.3",
"@tiptap/extension-heading": "^2.8.0", "@tiptap/extension-placeholder": "2.2.3",
"@tiptap/extension-highlight": "^2.8.0", "@tiptap/extension-strike": "2.2.3",
"@tiptap/extension-history": "^2.8.0", "@tiptap/extension-text": "2.2.3",
"@tiptap/extension-horizontal-rule": "^2.8.0", "@tiptap/extension-underline": "2.2.3",
"@tiptap/extension-image": "^2.8.0", "@tiptap/extension-youtube": "2.2.3",
"@tiptap/extension-italic": "^2.8.0", "@types/js-cookie": "3.0.6",
"@tiptap/extension-link": "^2.8.0", "@types/node": "^20.11.0",
"@tiptap/extension-list-item": "^2.8.0", "@urql/core": "4.2.3",
"@tiptap/extension-ordered-list": "^2.8.0", "@urql/devtools": "^2.0.3",
"@tiptap/extension-paragraph": "^2.8.0", "babel-preset-solid": "1.8.4",
"@tiptap/extension-placeholder": "^2.8.0", "bootstrap": "5.3.2",
"@tiptap/extension-strike": "^2.8.0", "clsx": "2.0.0",
"@tiptap/extension-text": "^2.8.0", "cropperjs": "1.6.1",
"@tiptap/extension-underline": "^2.8.0", "cross-env": "7.0.3",
"@tiptap/extension-youtube": "^2.8.0", "fast-deep-equal": "3.1.3",
"@tiptap/starter-kit": "^2.8.0", "ga-gtag": "1.2.0",
"@types/cookie": "^0.6.0", "graphql": "16.8.1",
"@types/cookie-signature": "^1.1.2", "graphql-tag": "2.12.6",
"@types/node": "^22.7.4", "hygen": "6.2.11",
"@types/throttle-debounce": "^5.0.2", "i18next": "22.4.15",
"@urql/core": "^5.0.6", "i18next-http-backend": "2.2.0",
"axe-playwright": "^2.0.3", "i18next-icu": "2.3.0",
"bootstrap": "^5.3.3", "intl-messageformat": "10.5.3",
"clsx": "^2.1.1", "javascript-time-ago": "2.5.9",
"cookie": "^0.6.0", "js-cookie": "3.0.5",
"cookie-signature": "^1.2.1", "lint-staged": "15.1.0",
"cropperjs": "^1.6.2", "loglevel": "1.8.1",
"extended-eventsource": "^1.6.4", "loglevel-plugin-prefix": "0.8.4",
"fast-deep-equal": "^3.1.3", "nanostores": "0.9.5",
"graphql": "^16.9.0",
"i18next": "^23.15.1",
"i18next-http-backend": "^2.6.1",
"i18next-icu": "^2.3.0",
"intl-messageformat": "^10.5.14",
"javascript-time-ago": "^2.5.11",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"prosemirror-history": "^1.4.1", "prosemirror-history": "1.3.2",
"prosemirror-trailing-node": "^2.0.9", "prosemirror-trailing-node": "2.0.7",
"prosemirror-view": "^1.34.3", "prosemirror-view": "1.32.7",
"rollup-plugin-visualizer": "^5.12.0", "rollup": "4.17.2",
"sass": "1.77.6", "sass": "1.69.5",
"solid-js": "^1.9.1", "solid-js": "1.8.17",
"solid-popper": "^0.3.0", "solid-popper": "0.3.0",
"solid-tiptap": "0.7.0", "solid-tiptap": "0.7.0",
"solid-transition-group": "^0.2.3", "solid-transition-group": "0.2.3",
"storybook": "^8.3.4", "stylelint": "^16.0.0",
"storybook-addon-sass-postcss": "^0.3.2", "stylelint-config-standard-scss": "^13.0.0",
"storybook-solidjs": "^1.0.0-beta.2", "stylelint-order": "^6.0.3",
"storybook-solidjs-vite": "^1.0.0-beta.2", "stylelint-scss": "^6.1.0",
"stylelint": "^16.9.0", "swiper": "11.0.5",
"stylelint-config-recommended": "^14.0.1", "throttle-debounce": "5.0.0",
"stylelint-config-standard-scss": "^13.1.0", "typescript": "5.2.2",
"stylelint-order": "^6.0.4", "typograf": "7.3.0",
"stylelint-scss": "^6.7.0", "uniqolor": "1.1.0",
"swiper": "^11.1.14", "vike": "0.4.148",
"throttle-debounce": "^5.0.2", "vite": "5.2.11",
"tslib": "^2.7.0", "vite-plugin-mkcert": "^1.17.3",
"typescript": "^5.6.2", "vite-plugin-node-polyfills": "0.21.0",
"typograf": "^7.4.1", "vite-plugin-sass-dts": "^1.3.17",
"uniqolor": "^1.1.1", "vite-plugin-solid": "2.10.1",
"vinxi": "^0.4.3", "y-prosemirror": "1.2.2",
"vite-plugin-mkcert": "^1.17.6", "yjs": "13.6.12"
"vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-sass-dts": "^1.3.29",
"y-prosemirror": "1.2.12",
"yjs": "13.6.19"
}, },
"overrides": { "overrides": {
"sass": "1.77.6", "y-prosemirror": "1.2.2",
"vite": "5.3.5", "yjs": "13.6.12"
"yjs": "13.6.19",
"y-prosemirror": "1.2.12"
}, },
"engines": { "trustedDependencies": ["@biomejs/biome"]
"node": ">= 20"
},
"trustedDependencies": ["@biomejs/biome", "@swc/core", "esbuild", "protobufjs"],
"dependencies": {
"form-data": "^4.0.0",
"idb": "^8.0.0",
"mailgun.js": "^10.2.3"
}
} }

View File

@ -10,40 +10,44 @@ import { defineConfig, devices } from '@playwright/test'
* See https://playwright.dev/docs/test-configuration. * See https://playwright.dev/docs/test-configuration.
*/ */
export default defineConfig({ export default defineConfig({
/* Directory to search for tests */
testDir: './tests', testDir: './tests',
/* Run tests in files in parallel */ /* Run tests in files in parallel */
fullyParallel: false, fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
/* Retry on CI only */ /* Retry on CI only */
retries: 0, retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'list', reporter: 'html',
/* Timeout for each test */
timeout: 40000,
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.BASE_URL || 'https://localhost:3000', // baseURL: 'http://127.0.0.1:3000',
/* Headless */
headless: true,
/* Ignode SSL certificates */
ignoreHTTPSErrors: true,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* 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 */ /* Configure projects for major browsers */
projects: [ projects: [
{ {
name: 'webkit', name: 'chromium',
use: { ...devices['Desktop Safari'] } use: { ...devices['Desktop Chrome'] },
} },
/* Test against many viewports. {
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// { // {
// name: 'Mobile Chrome', // name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] }, // use: { ...devices['Pixel 5'] },
@ -64,15 +68,10 @@ export default defineConfig({
// }, // },
], ],
/* Run local dev server before starting the tests */ /* Run your local dev server before starting the tests */
/* If process env CI is set to false */ // webServer: {
webServer: process.env.CI // command: 'npm run start',
? undefined // url: 'http://127.0.0.1:3000',
: { // reuseExistingServer: !process.env.CI,
command: 'npm run dev', // },
url: 'http://localhost:3000',
ignoreHTTPSErrors: true,
reuseExistingServer: !process.env.CI,
timeout: 5 * 60 * 1000
}
}) })

View File

@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 713 B

View File

Before

Width:  |  Height:  |  Size: 290 B

After

Width:  |  Height:  |  Size: 290 B

View File

Before

Width:  |  Height:  |  Size: 350 B

After

Width:  |  Height:  |  Size: 350 B

View File

Before

Width:  |  Height:  |  Size: 714 B

After

Width:  |  Height:  |  Size: 714 B

View File

@ -0,0 +1,11 @@
<svg
width="13" height="16"
viewBox="0 0 13 16"
fill="none"
version="1.1"
xmlns="http://www.w3.org/2000/svg">
<path
d="M 10.1573,7.43667 C 11.2197,6.70286 11.9645,5.49809 11.9645,4.38095 11.9645,1.90571 10.0478,0 7.58352,0 H 0.738281 V 15.3333 H 8.44876 c 2.28904,0 4.06334,-1.8619 4.06334,-4.1509 0,-1.66478 -0.9419,-3.08859 -2.3548,-3.74573 z M 4.02344,2.73828 h 3.28571 c 0.90905,0 1.64286,0.73381 1.64286,1.64286 0,0.90905 -0.73381,1.64286 -1.64286,1.64286 H 4.02344 Z M 4.01629,9.3405869 h 3.87946 c 0.9090501,0 1.6428601,0.7338101 1.6428601,1.6428601 0,0.90905 -0.73381,1.64286 -1.6428601,1.64286 H 4.01629 Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 677 B

View File

@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,544 @@
{
"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",
"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",
"Add another image": "Add another image",
"Add audio": "Add audio",
"Add blockquote": "Add blockquote",
"Add comment": "Comment",
"Add cover": "Add cover",
"Add image": "Add image",
"Add images": "Add images",
"Add intro": "Add intro",
"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 articles": "All articles",
"All authors": "All authors",
"All posts": "All posts",
"All topics": "All topics",
"All": "All",
"Almost done! Check your email.": "Almost done! Just checking your email.",
"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",
"Artist": "Artist",
"Artworks": "Artworks",
"Audio": "Audio",
"Author": "Author",
"Authors": "Authors",
"Autotypograph": "Autotypograph",
"Back to editor": "Back to editor",
"Back to main page": "Back to main page",
"Back": "Back",
"Be the first to rate": "Be the first to rate",
"Become an author": "Become an author",
"Bold": "Bold",
"Bookmarked": "Saved",
"Bookmarks": "Bookmarks",
"Bullet list": "Bullet list",
"By alphabet": "By alphabet",
"By authors": "By authors",
"By name": "By name",
"By popularity": "By popularity",
"By rating": "By popularity",
"By relevance": "By relevance",
"By shouts": "By publications",
"By signing up you agree with our": "By signing up you agree with our",
"By time": "By time",
"By title": "By title",
"By updates": "By updates",
"By views": "By views",
"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",
"Change password": "Change password",
"Characters": "Знаков",
"Chat Title": "Chat Title",
"Choose a post type": "Choose a post type",
"Choose a title image for the article. You can immediately see how the publication card will look like.": "Choose a title image for the article. You can immediately see how the publication card will look like.",
"Choose who you want to write to": "Choose who you want to write to",
"Close": "Close",
"Co-author": "Co-author",
"Collaborate": "Help Edit",
"Collaborators": "Collaborators",
"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",
"Coming soon": "Coming soon",
"Comment successfully deleted": "Comment successfully deleted",
"Commentator": "Commentator",
"Commenting": "Commenting",
"Comments": "Comments",
"CommentsWithCount": "{count, plural, =0 {{count} comments} one {{count} comment} few {{count} comments} other {{count} comments}}",
"Communities": "Communities",
"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",
"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",
"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 vote": "Create an account to vote",
"Create gallery": "Create gallery",
"Create post": "Create post",
"Create video": "Create video",
"Crop image": "Crop image",
"Culture": "Culture",
"Date of Birth": "Date of Birth",
"Decline": "Decline",
"Delete cover": "Delete cover",
"Delete userpic": "Delete userpic",
"Delete": "Delete",
"Description": "Description",
"Discours Manifest": "Discours Manifest",
"Discours Partners": "Discours Partners",
"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 an open magazine about culture, science and society": "Discours an open magazine about culture, science and society",
"Discours": "Discours",
"Discussing": "Discussing",
"Discussion rules": "Discussion rules",
"Discussions": "Discussions",
"Do you really want to reset all changes?": "Do you really want to reset all changes?",
"Dogma": "Dogma",
"Draft successfully deleted": "Draft successfully deleted",
"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",
"Edit": "Edit",
"Editing": "Editing",
"Editor": "Editor",
"Email": "Mail",
"Enter URL address": "Enter URL address",
"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 your new password": "Enter your new password",
"Enter": "Enter",
"Error": "Error",
"Experience": "Experience",
"FAQ": "Tips and suggestions",
"Favorite topics": "Favorite topics",
"Favorite": "Favorites",
"Feed settings": "Feed settings",
"Feed": "Feed",
"Feedback": "Feedback",
"Fill email": "Fill email",
"Fixed": "Fixed",
"Follow the topic": "Follow the topic",
"Follow": "Follow",
"Followers": "Followers",
"Following": "Following",
"Forward": "Forward",
"Full name": "First and last name",
"Gallery name": "Gallery name",
"Gallery": "Gallery",
"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 2": "Header 2",
"Header 3": "Header 3",
"Headers": "Headers",
"Help to edit": "Help to edit",
"Help": "Помощь",
"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",
"Hide table of contents": "Hide table of contents",
"Highlight": "Highlight",
"Hooray! Welcome!": "Hooray! Welcome!",
"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 it works": "How it works",
"How to help": "How to help?",
"How to write a good article": "Как написать хорошую статью",
"How to write an article": "How to write an article",
"Hundreds of people from different countries and cities share their knowledge and art on the Discours. Join us!": "Hundreds of people from different countries and cities share their knowledge and art on the Discours. Join us!",
"I have an account": "I have an account!",
"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",
"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",
"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",
"Insert footnote": "Insert footnote",
"Insert video link": "Insert video link",
"Interview": "Interview",
"Introduce": "Introduction",
"Invalid email": "Check if your email is correct",
"Invalid image URL": "Invalid image URL",
"Invalid url format": "Invalid url format",
"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",
"Italic": "Italic",
"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",
"Just start typing...": "Just start typing...",
"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 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",
"Load more": "Show more",
"Loading": "Loading",
"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",
"Material card": "Material card",
"Message": "Message",
"More": "More",
"Most commented": "Commented",
"Most read": "Readable",
"Move down": "Move down",
"Move up": "Move up",
"Music": "Music",
"My feed": "My feed",
"My subscriptions": "Subscriptions",
"Name": "Name",
"New literary work": "New literary work",
"New only": "New only",
"New password": "New password",
"New stories every day and even more!": "New stories and more are waiting for you every day!",
"Newsletter": "Newsletter",
"Night mode": "Night mode",
"No notifications yet": "No notifications yet",
"Nothing here yet": "There's nothing here yet",
"Nothing is here": "There is nothing here",
"Notifications": "Notifications",
"Or paste a link to an image": "Or paste a link to an image",
"Ordered list": "Ordered list",
"Our regular contributor": "Our regular contributor",
"Paragraphs": "Абзацев",
"Participate in the Discours: share information, join the editorial team": "Участвуйте в Дискурсе: делитесь информацией, присоединяйтесь к редакции",
"Participating": "Participating",
"Participation": "Participation",
"Partners": "Partners",
"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",
"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, confirm email": "Please confirm email",
"Please, set the main topic first": "Please, set the main topic first",
"Podcasts": "Podcasts",
"Poetry": "Poetry",
"Popular authors": "Popular authors",
"Popular": "Popular",
"Principles": "Community principles",
"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",
"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",
"Punchline": "Punchline",
"Quit": "Quit",
"Quote": "Quote",
"Quotes": "Quotes",
"Reason uknown": "Reason unknown",
"Recent": "Fresh",
"Registered since {date}": "Registered since {date}",
"Remove link": "Remove link",
"Reply": "Reply",
"Report": "Complain",
"Reports": "Reports",
"Required": "Required",
"Resend code": "Send confirmation",
"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 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?",
"Settings": "Settings",
"Share publication": "Share publication",
"Share": "Share",
"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",
"Site search": "Site search",
"Slug": "Slug",
"Social networks": "Social networks",
"Society": "Society",
"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",
"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",
"Specify the source and the name of the author": "Specify the source and the name of the author",
"Start conversation": "Start a conversation",
"Start dialog": "Start dialog",
"Subsccriptions": "Subscriptions",
"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}}",
"Subscription": "Subscription",
"SubscriptionWithCount": "{count, plural, =0 {no subscriptions} one {{count} subscription} other {{count} subscriptions}}",
"Subscriptions": "Subscriptions",
"Substrate": "Substrate",
"Success": "Success",
"Successfully authorized": "Authorization successful",
"Suggest an idea": "Suggest an idea",
"Support Discours": "Support Discours",
"Support the project": "Support the project",
"Support us": "Support us",
"Terms of use": "Site rules",
"Text checking": "Text checking",
"Thank you!": "Thank you!",
"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?",
"This comment has not yet been rated": "This comment has not yet been rated",
"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 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",
"This year": "This year",
"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",
"Top authors": "Authors rating",
"Top commented": "Most commented",
"Top discussed": "Top discussed",
"Top month": "Top of the month",
"Top rated": "Popular",
"Top recent": "Most recent",
"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",
"Topics": "Topics",
"Try to find another way": "Try to find another way",
"Unfollow the topic": "Unfollow the topic",
"Unfollow": "Unfollow",
"Unnamed draft": "Unnamed draft",
"Upload error": "Upload error",
"Upload userpic": "Upload userpic",
"Upload video": "Upload video",
"Upload": "Upload",
"Uploading image": "Uploading image",
"Username": "Username",
"Userpic": "Userpic",
"Users": "Users",
"Video format not supported": "Video format not supported",
"Video": "Video",
"Views": "Views",
"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 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 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": "Слов",
"Work with us": "Cooperate with Discours",
"Write a comment...": "Write a comment...",
"Write a short introduction": "Write a short introduction",
"Write about the topic": "Write about the topic",
"Write an article": "Write an article",
"Write comment": "Write comment",
"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",
"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",
"You were successfully authorized": "You were successfully authorized",
"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 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"
}

View File

@ -1,10 +1,8 @@
{ {
"A guide to horizontal editorial: how an open journal works": "Гид по горизонтальной редакции: как работает открытый журнал", "A guide to horizontal editorial: how an open journal works": "Гид по горизонтальной редакции: как работает открытый журнал",
"A short introduction to keep the reader interested": "Добавьте вступление, чтобы заинтересовать читателя", "A short introduction to keep the reader interested": "Добавьте вступление, чтобы заинтересовать читателя",
"About": "О себе",
"About the project": "О проекте", "About the project": "О проекте",
"actions": "действия", "About": "О себе",
"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": "Добавить embed-виджет", "Add an embed widget": "Добавить embed-виджет",
@ -22,36 +20,33 @@
"Add subtitle": "Добавить подзаголовок", "Add subtitle": "Добавить подзаголовок",
"Add to bookmarks": "Добавить в закладки", "Add to bookmarks": "Добавить в закладки",
"Add url": "Добавить ссылку", "Add url": "Добавить ссылку",
"Add": "Добавить",
"Address on Discours": "Адрес на Дискурсе", "Address on Discours": "Адрес на Дискурсе",
"Album name": "Название альбома", "Album name": "Название альбома",
"Alignment center": "По центру", "Alignment center": "По центру",
"Alignment left": "По левому краю", "Alignment left": "По левому краю",
"Alignment right": "По правому краю", "Alignment right": "По правому краю",
"All": "Все",
"All articles": "Все материалы", "All articles": "Все материалы",
"All authors": "Все авторы", "All authors": "Все авторы",
"All posts": "Все публикации",
"All posts rating": "Рейтинг всех постов", "All posts rating": "Рейтинг всех постов",
"All posts": "Все публикации",
"All topics": "Все темы", "All topics": "Все темы",
"All": "Общая лента",
"Almost done! Check your email.": "Почти готово! Осталось подтвердить вашу почту.", "Almost done! Check your email.": "Почти готово! Осталось подтвердить вашу почту.",
"and some more authors": "{restUsersCount, plural, =0 {} one { и ещё 1 пользователя} few { и ещё {restUsersCount} пользователей} other { и ещё {restUsersCount} пользователей}}",
"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": "Статья",
"Artist": "Исполнитель", "Artist": "Исполнитель",
"Artist...": "Исполнитель...", "Artist...": "Исполнитель...",
"Artworks": "Артворки", "Artworks": "Артворки",
"Audio": "Аудио", "Audio": "Аудио",
"Author": "Автор", "Author": "Автор",
"author profile was not found": "не удалось найти профиль автора",
"Authors": "Авторы", "Authors": "Авторы",
"Autotypograph": "Автотипограф", "Autotypograph": "Автотипограф",
"Back": "Назад",
"Back to editor": "Вернуться в редактор", "Back to editor": "Вернуться в редактор",
"Back to main page": "Вернуться на главную", "Back to main page": "Вернуться на главную",
"back to menu": "назад в меню", "Back": "Назад",
"Be the first to rate": "Оцените первым", "Be the first to rate": "Оцените первым",
"Become an author": "Стать автором", "Become an author": "Стать автором",
"Bold": "Жирный", "Bold": "Жирный",
@ -73,8 +68,8 @@
"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": "Отмена",
"Cancel changes": "Отменить изменения", "Cancel changes": "Отменить изменения",
"Cancel": "Отмена",
"Change password": "Сменить пароль", "Change password": "Сменить пароль",
"Characters": "Знаков", "Characters": "Знаков",
"Chat Title": "Тема дискурса", "Chat Title": "Тема дискурса",
@ -89,117 +84,104 @@
"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": "Придумайте заголовок вашей истории",
"Coming soon": "Уже скоро", "Coming soon": "Уже скоро",
"Comment": "Комментировать",
"Comment successfully deleted": "Комментарий успешно удален", "Comment successfully deleted": "Комментарий успешно удален",
"Comment": "Комментировать",
"Commentator": "Комментатор", "Commentator": "Комментатор",
"Commented": "Комментируемое",
"Commenting": "Комментирование", "Commenting": "Комментирование",
"Comments": "Комментарии", "Comments": "Комментарии",
"CommentsWithCount": "{count, plural, =0 {{count} комментариев} one {{count} комментарий} few {{count} комментария} other {{count} комментариев}}",
"Communities": "Сообщества", "Communities": "Сообщества",
"community": "сообщество",
"Community Discussion Rules": "Правила дискуссий в сообществе", "Community Discussion Rules": "Правила дискуссий в сообществе",
"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 email and the action will complete": "Подтвердите почту и действие совершится",
"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": "Внесите вклад в свободный самиздат. Поддержите Дискурс — независимое некоммерческое издание, которое работает только для вас. Станьте опорой открытой редакции", "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": "Соучаствовать",
"Cooperate with Discours": "Сотрудничать с Дискурсом",
"Copy": "Скопировать",
"Copy link": "Скопировать ссылку", "Copy link": "Скопировать ссылку",
"Copy": "Скопировать",
"Corrections history": "История правок", "Corrections history": "История правок",
"Create Chat": "Создать чат",
"Create Group": "Создать группу",
"Create 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": "Создайте аккаунт, чтобы подписаться",
"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 vote": "Создайте аккаунт, чтобы голосовать", "Create an account to vote": "Создайте аккаунт, чтобы голосовать",
"Create chat": "Создать чат",
"Create gallery": "Создать галерею", "Create gallery": "Создать галерею",
"Create group": "Создать группу",
"Create post": "Создать публикацию", "Create post": "Создать публикацию",
"Create video": "Создать видео", "Create video": "Создать видео",
"Crop image": "Кадрировать изображение", "Crop image": "Кадрировать изображение",
"Culture": "Культура", "Culture": "Культура",
"Current password": "Текущий пароль",
"Date of Birth": "Дата рождения", "Date of Birth": "Дата рождения",
"Decline": "Отмена", "Decline": "Отмена",
"Delete": "Удалить",
"Delete cover": "Удалить обложку", "Delete cover": "Удалить обложку",
"Delete userpic": "Удалить аватар", "Delete userpic": "Удалить аватар",
"delimiter": "разделитель", "Delete": "Удалить",
"Description": "Описание", "Description": "Описание",
"Discours": "Дискурс",
"Discours an open magazine about culture, science and society": "Дискурс открытый журнал о культуре, науке и обществе",
"Discours exists because of our common effort": "Дискурс существует благодаря нашему общему вкладу",
"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 Manifest": "Манифест Дискурса", "Discours Manifest": "Манифест Дискурса",
"Discours Partners": "Партнеры Дискурса", "Discours Partners": "Партнеры Дискурса",
"Discours theme": "Тема дискурса", "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 an open magazine about culture, science and society": "Дискурс открытый журнал о культуре, науке и обществе",
"Discours": "Дискурс",
"Discours_theme": "Тема дискурса",
"Discussing": "Обсуждаемое", "Discussing": "Обсуждаемое",
"discussion": "дискурс",
"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, догма, редакционные принципы, этический кодекс, журналистика, сообщество",
"Draft successfully deleted": "Черновик успешно удален", "Draft successfully deleted": "Черновик успешно удален",
"Drafts": "Черновики", "Drafts": "Черновики",
"Drag the image to this area": "Перетащите изображение в эту область", "Drag the image to this area": "Перетащите изображение в эту область",
"Each image must be no larger than 5 MB.": "Каждое изображение должно быть размером не больше 5 мб.", "Each image must be no larger than 5 MB.": "Каждое изображение должно быть размером не больше 5 мб.",
"earlier": "ранее",
"Edit": "Редактировать",
"Edit profile": "Редактировать профиль", "Edit profile": "Редактировать профиль",
"Edit": "Редактировать",
"Editing": "Редактирование", "Editing": "Редактирование",
"Editor": "Редактор", "Editor": "Редактор",
"Email": "Почта", "Email": "Почта",
"email not confirmed": "email не подтвержден", "Enter URL address": "Введите адрес ссылки",
"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 or click the link from email to confirm": "Введите код из письма или пройдите по ссылке в письме для подтверждения регистрации",
"Enter URL address": "Введите адрес ссылки",
"Enter your new password": "Введите новый пароль", "Enter your new password": "Введите новый пароль",
"Enter": "Войти",
"This content is not published yet": "Содержимое ещё не опубликовано",
"Error": "Ошибка", "Error": "Ошибка",
"Experience": "Личный опыт", "Experience": "Личный опыт",
"Failed to delete comment": "Не удалось удалить комментарий",
"FAQ": "Советы и предложения", "FAQ": "Советы и предложения",
"Favorite": "Избранное",
"Favorite topics": "Избранные темы", "Favorite topics": "Избранные темы",
"Feed": "Лента", "Favorite": "Избранное",
"Feed settings": "Настроить ленту", "Feed settings": "Настроить ленту",
"Feed": "Лента",
"Feedback": "Обратная связь", "Feedback": "Обратная связь",
"Fill email": "Введите почту", "Fill email": "Введите почту",
"Fixed": "Все поправлено", "Fixed": "Все поправлено",
"Follow": "Подписаться",
"Follow the topic": "Подписаться на тему", "Follow the topic": "Подписаться на тему",
"follower": "подписчик", "Follow": "Подписаться",
"Followers": "Подписчики", "Followers": "Подписчики",
"Following": "Вы подписаны", "Following": "Вы подписаны",
"Forgot password?": "Забыли пароль?",
"Forward": "Переслать", "Forward": "Переслать",
"from": "от",
"Full name": "Имя и фамилия", "Full name": "Имя и фамилия",
"Gallery": "Галерея",
"Gallery name": "Название галереи", "Gallery name": "Название галереи",
"Gallery": "Галерея",
"Genre...": "Жанр...", "Genre...": "Жанр...",
"Get notifications": "Получать уведомления", "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": "Познакомитесь с выдающимися людьми нашего времени, участвуйте в редактировании и обсуждении статей, выступайте экспертом, оценивайте материалы других авторов со всего мира и определяйте, какие статьи будут опубликованы в журнале", "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": "Заголовок",
"Header 1": "Заголовок 1", "Header 1": "Заголовок 1",
"Header 2": "Заголовок 2", "Header 2": "Заголовок 2",
"Header 3": "Заголовок 3", "Header 3": "Заголовок 3",
"Header": "Заголовок",
"Headers": "Заголовки", "Headers": "Заголовки",
"Help": "Помощь",
"Help to edit": "Помочь редактировать", "Help to edit": "Помочь редактировать",
"Help": "Помощь",
"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": "Здесь вы можете загрузить свою фотографию",
@ -209,8 +191,8 @@
"Horizontal collaborative journalistic platform": "Открытая платформа<br/>для независимой журналистики", "Horizontal collaborative journalistic platform": "Открытая платформа<br/>для независимой журналистики",
"Hot topics": "Горячие темы", "Hot topics": "Горячие темы",
"Hotkeys": "Горячие клавиши", "Hotkeys": "Горячие клавиши",
"How can I help/skills": "Чем могу помочь/навыки",
"How Discours works": "Как устроен Дискурс", "How Discours works": "Как устроен Дискурс",
"How can I help/skills": "Чем могу помочь/навыки",
"How it works": "Как это работает", "How it works": "Как это работает",
"How to help": "Как помочь?", "How to help": "Как помочь?",
"How to write a good article": "Как написать хорошую статью", "How to write a good article": "Как написать хорошую статью",
@ -220,11 +202,8 @@
"I have no account yet": "У меня еще нет аккаунта", "I have no account yet": "У меня еще нет аккаунта",
"I know the password": "Я знаю пароль!", "I know the password": "Я знаю пароль!",
"Image format not supported": "Тип изображения не поддерживается", "Image format not supported": "Тип изображения не поддерживается",
"Image": "Изображение", "In&nbsp;bookmarks, you can save favorite discussions and&nbsp;materials that you want to return to": "В&nbsp;закладках можно сохранять избранные дискуссии и&nbsp;материалы, к&nbsp;которым хочется вернуться",
"In bookmarks, you can save favorite discussions and materials that you want to return to": "В закладках можно сохранять избранные дискуссии и материалы, к которым хочется вернуться",
"Inbox": "Входящие", "Inbox": "Входящие",
"Incorrect new password confirm": "Неверное подтверждение нового пароля",
"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": "Независимый медиапроект о культуре, науке, искусстве и обществе с горизонтальной редакцией",
@ -234,52 +213,42 @@
"Introduce": "Представление", "Introduce": "Представление",
"Invalid email": "Проверьте правильность ввода почты", "Invalid email": "Проверьте правильность ввода почты",
"Invalid image URL": "Некорректная ссылка на изображение", "Invalid image URL": "Некорректная ссылка на изображение",
"invalid password": "некорректный пароль",
"Invalid url format": "Неверный формат ссылки", "Invalid url format": "Неверный формат ссылки",
"Invite": "Пригласить",
"Invite co-authors": "Пригласить соавторов", "Invite co-authors": "Пригласить соавторов",
"Invite collaborators": "Пригласить соавторов", "Invite collaborators": "Пригласить соавторов",
"Invite experts": "Пригласить экспертов", "Invite experts": "Пригласить экспертов",
"Invite to collab": "Пригласить к участию", "Invite to collab": "Пригласить к участию",
"Invite": "Пригласить",
"It does not look like url": "Это не похоже на ссылку", "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 our maillist": "Чтобы получать рассылку лучших публикаций, просто укажите свою почту",
"Join the community": "Присоединиться к сообществу", "Join the community": "Присоединиться к сообществу",
"Join the global community of authors!": "Присоединятесь к глобальному сообществу авторов со всего мира!", "Join the global community of authors!": "Присоединятесь к глобальному сообществу авторов со всего мира!",
"Journal": "Журнал", "Join": "Присоединиться",
"jpg, .png, max. 10 mb.": "jpg, .png, макс. 10 мб.",
"Just start typing...": "Просто начните печатать...", "Just start typing...": "Просто начните печатать...",
"Karma": "Карма", "Karma": "Карма",
"keywords": "Discours.io, журнал Дискурс, Дискурс, культура, наука, искусство, общество, независимая журналистика, литература, музыка, кино, видео, фотографии",
"Knowledge base": "База знаний", "Knowledge base": "База знаний",
"Language": "Язык", "Language": "Язык",
"Last rev.": "Посл. изм.", "Last rev.": "Посл. изм.",
"Let's log in": "Давайте авторизуемся", "Let's log in": "Давайте авторизуемся",
"Liked": "Популярное",
"Link copied": "Ссылка скопирована",
"Link copied to clipboard": "Ссылка скопирована в буфер обмена", "Link copied to clipboard": "Ссылка скопирована в буфер обмена",
"Link copied": "Ссылка скопирована",
"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": "Литература",
"Load more": "Показать ещё", "Load more": "Показать ещё",
"loaded": "загружено",
"Loading": "Загрузка", "Loading": "Загрузка",
"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": "маркир. список",
"Material card": "Карточка материала", "Material card": "Карточка материала",
"Message": "Написать", "Message": "Написать",
"Message text": "Текст сообщения",
"min. 1400×1400 pix": "мин. 1400×1400 пикс.",
"More": "Ещё", "More": "Ещё",
"Most commented": "Комментируемое",
"Most read": "Читаемое", "Most read": "Читаемое",
"Move down": "Переместить вниз", "Move down": "Переместить вниз",
"Move up": "Переместить вверх", "Move up": "Переместить вверх",
@ -287,38 +256,31 @@
"My feed": "Моя лента", "My feed": "Моя лента",
"My subscriptions": "Подписки", "My subscriptions": "Подписки",
"Name": "Имя", "Name": "Имя",
"New group": "Новая группа",
"New literary work": "Новое произведение", "New literary work": "Новое произведение",
"New message": "Новое сообщение",
"New only": "Только новые", "New only": "Только новые",
"New password": "Новый пароль", "New password": "Новый пароль",
"New stories and more are waiting for you every day!": "Каждый день вас ждут новые истории и ещё много всего интересного!", "New stories every day and even more!": "Каждый день вас ждут новые истории и ещё много всего интересного!",
"Newsletter": "Рассылка", "Newsletter": "Рассылка",
"Night mode": "Ночная тема", "Night mode": "Ночная тема",
"No drafts": "Нет черновиков",
"No notifications yet": "Уведомлений пока нет", "No notifications yet": "Уведомлений пока нет",
"No such account, please try to register": "Такой адрес не найден, попробуйте зарегистрироваться", "No such account, please try to register": "Такой адрес не найден, попробуйте зарегистрироваться",
"not verified": "ещё не подтверждён", "Nothing here yet": "Здесь пока ничего нет",
"Nothing is here": "Здесь ничего нет", "Nothing is here": "Здесь ничего нет",
"Notifications": "Уведомления", "Notifications": "Уведомления",
"number list": "нумер. список",
"or": "или",
"Or paste a link to an image": "Или вставьте ссылку на изображение", "Or paste a link to an image": "Или вставьте ссылку на изображение",
"or sign in with social networks": "или войдите через соцсеть",
"Ordered list": "Нумерованный список", "Ordered list": "Нумерованный список",
"Our principles": "Принципы сообщества",
"Our regular contributor": "Наш постоянный автор", "Our regular contributor": "Наш постоянный автор",
"Paragraphs": "Абзацев", "Paragraphs": "Абзацев",
"Participate in the Discours: share information, join the editorial team": "Participate in the Discours: share information, join the editorial team", "Participate in the Discours: share information, join the editorial team": "Participate in the Discours: share information, join the editorial team",
"Participating": "Участвовать", "Participating": "Участвовать",
"Participation": "Соучастие", "Participation": "Соучастие",
"Partners": "Партнёры", "Partners": "Партнёры",
"Password": "Пароль",
"Password again": "Пароль ещё раз", "Password again": "Пароль ещё раз",
"Password should be at least 8 characters": "Пароль должен быть не менее 8 символов", "Password should be at least 8 characters": "Пароль должен быть не менее 8 символов",
"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": "Пароль",
"Passwords are not equal": "Пароли не совпадают", "Passwords are not equal": "Пароли не совпадают",
"Paste Embed code": "Вставьте embed код", "Paste Embed code": "Вставьте embed код",
"Personal": "Личные", "Personal": "Личные",
@ -326,26 +288,28 @@
"Platform Guide": "Гид по дискурсу", "Platform Guide": "Гид по дискурсу",
"Please check your email address": "Пожалуйста, проверьте введенный адрес почты", "Please check your email address": "Пожалуйста, проверьте введенный адрес почты",
"Please check your inbox! We have sent a password reset link.": "Пожалуйста, проверьте свою почту, мы отправили вам письмо со ссылкой для сброса пароля", "Please check your inbox! We have sent a password reset link.": "Пожалуйста, проверьте свою почту, мы отправили вам письмо со ссылкой для сброса пароля",
"Please confirm email": "Пожалуйста, подтвердите электронную почту", "Please confirm your email to finish": "Подтвердите почту и действие совершится",
"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 email": "Пожалуйста, введите почту",
"Please enter password": "Пожалуйста, введите пароль",
"Please enter password again": "Пожалуйста, введите пароль ещё рез", "Please enter password again": "Пожалуйста, введите пароль ещё рез",
"Please, set the article title": "Пожалуйста, задайте заголовок статьи", "Please enter password": "Пожалуйста, введите пароль",
"Please, confirm email": "Пожалуйста, подтвердите электронную почту",
"Please, set the main topic first": "Пожалуйста, сначала выберите главную тему", "Please, set the main topic first": "Пожалуйста, сначала выберите главную тему",
"Please, set the article title": "Пожалуйста, задайте заголовок статьи",
"Podcasts": "Подкасты", "Podcasts": "Подкасты",
"Poetry": "Поэзия", "Poetry": "Поэзия",
"Popular": "Популярное",
"Popular authors": "Популярные авторы", "Popular authors": "Популярные авторы",
"post": "пост", "Popular": "Популярное",
"Preview": "Предпросмотр", "Preview": "Предпросмотр",
"principles keywords": "Discours.io, сообщества, ценности, правила редакции, многоголосие, созидание", "Principles": "Принципы сообщества",
"Professional principles that the open editorial team follows in its work": "Профессиональные принципы, которым открытая редакция следует в работе", "Professional principles that the open editorial team follows in its work": "Профессиональные принципы, которым открытая редакция следует в работе",
"Profile": "Профиль",
"Profile settings": "Настройки профиля", "Profile settings": "Настройки профиля",
"Profile successfully saved": "Профиль успешно сохранён", "Profile successfully saved": "Профиль успешно сохранён",
"Profile": "Профиль",
"Publication settings": "Настройки публикации", "Publication settings": "Настройки публикации",
"Publications": "Публикации", "Publications": "Публикации",
"PublicationsWithCount": "{count, plural, =0 {нет публикаций} one {{count} публикация} few {{count} публикации} other {{count} публикаций}}",
"FollowersWithCount": "{count, plural, =0 {нет подписчиков} one {{count} подписчик} few {{count} подписчика} other {{count} подписчиков}}",
"Publish": "Опубликовать", "Publish": "Опубликовать",
"Publish Album": "Опубликовать альбом", "Publish Album": "Опубликовать альбом",
"Publish Settings": "Настройки публикации", "Publish Settings": "Настройки публикации",
@ -354,88 +318,68 @@
"Quit": "Выйти", "Quit": "Выйти",
"Quote": "Цитата", "Quote": "Цитата",
"Quotes": "Цитаты", "Quotes": "Цитаты",
"Reason unknown": "Причина неизвестна", "Reason uknown": "Причина неизвестна",
"Recent": "Свежее", "Recent": "Свежее",
"register": "зарегистрируйтесь",
"registered": "уже зарегистрирован",
"Registered since {date}": "На сайте c {date}", "Registered since {date}": "На сайте c {date}",
"Release date...": "Дата выхода...", "Release date...": "Дата выхода...",
"Remove link": "Убрать ссылку", "Remove link": "Убрать ссылку",
"Repeat": "Повторить",
"Repeat new password": "Повторите новый пароль",
"Reply": "Ответить", "Reply": "Ответить",
"Report": "Пожаловаться", "Report": "Пожаловаться",
"Reports": "Репортажи", "Reports": "Репортажи",
"Required": "Поле обязательно для заполнения", "Required": "Поле обязательно для заполнения",
"Resend code": "Выслать подтверждение", "Resend code": "Выслать подтверждение",
"resend confirmation link": "отправить ссылку ещё раз", "Forgot password?": "Забыли пароль?",
"Restore password": "Восстановить пароль",
"Rules of the journal Discours": "Правила журнала Дискурс", "Rules of the journal Discours": "Правила журнала Дискурс",
"Save": "Сохранить",
"Save draft": "Сохранить черновик", "Save draft": "Сохранить черновик",
"Save settings": "Сохранить настройки", "Save settings": "Сохранить настройки",
"Save": "Сохранить",
"Saving...": "Сохраняем...", "Saving...": "Сохраняем...",
"Scroll up": "Наверх", "Scroll up": "Наверх",
"Search": "Поиск",
"Search author": "Поиск автора", "Search author": "Поиск автора",
"Search topic": "Поиск темы", "Search topic": "Поиск темы",
"Search": "Поиск",
"Sections": "Разделы", "Sections": "Разделы",
"Security": "Безопасность", "Security": "Безопасность",
"Select": "Выбрать", "Select": "Выбрать",
"Self-publishing exists thanks to the help of wonderful people from all over the world. Thank you!": "Самиздат существуют благодаря помощи замечательных людей со всего мира. Спасибо Вам!", "Self-publishing exists thanks to the help of wonderful people from all over the world. Thank you!": "Самиздат существуют благодаря помощи замечательных людей со всего мира. Спасибо Вам!",
"Send": "Отправить",
"Send link again": "Прислать ссылку ещё раз", "Send link again": "Прислать ссылку ещё раз",
"Send": "Отправить",
"Settings": "Настройки", "Settings": "Настройки",
"Settings for account, email, password and login methods.": "Настройки аккаунта, почты, пароля и способов входа.",
"Share": "Поделиться",
"Share publication": "Поделиться публикацией", "Share publication": "Поделиться публикацией",
"Share": "Поделиться",
"Short opening": "Расскажите вашу историю...", "Short opening": "Расскажите вашу историю...",
"shout": "пост",
"shout not found": "публикация не найдена",
"Show": "Показать",
"Show lyrics": "Текст песни", "Show lyrics": "Текст песни",
"Show more": "Читать дальше", "Show more": "Читать дальше",
"Show table of contents": "Показать главление", "Show table of contents": "Показать главление",
"sign in": "войти", "Show": "Показать",
"sign up": "зарегистрироваться", "to see the voters": "чтобы видеть кто голосует",
"Sign up": "Создать аккаунт",
"sign up or sign in": "зарегистрироваться или войти",
"Site search": "Поиск по сайту", "Site search": "Поиск по сайту",
"Slug": "Постоянная ссылка", "Slug": "Постоянная ссылка",
"slug is used by another user": "Имя уже занято другим пользователем",
"Social networks": "Социальные сети", "Social networks": "Социальные сети",
"Society": "Общество", "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 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 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, check email and password": "Что-то пошло не так. Проверьте адрес электронной почты и пароль",
"Something went wrong, please try again": "Что-то пошло не так, попробуйте еще раз", "Something went wrong, please try again": "Что-то пошло не так, попробуйте еще раз",
"Song lyrics": "Текст песни...", "Song lyrics": "Текст песни...",
"Song title": "Название песни", "Song title": "Название песни",
"Soon": "Скоро", "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": "Спецпроекты",
"Specify the source and the name of the author": "Укажите источник и имя автора", "Specify the source and the name of the author": "Укажите источник и имя автора",
"squib": "Подверстка",
"Start conversation": "Начать беседу", "Start conversation": "Начать беседу",
"Start dialog": "Начать диалог", "Start dialog": "Начать диалог",
"Subheader": "Подзаголовок", "Subheader": "Подзаголовок",
"Subscribe": "Подписаться",
"Subscribe to comments": "Подписаться на комментарии", "Subscribe to comments": "Подписаться на комментарии",
"Subscribe to the best publications newsletter": "Подпишитесь на рассылку лучших публикаций", "Subscribe to the best publications newsletter": "Подпишитесь на рассылку лучших публикаций",
"Subscribe us": "Подпишитесь на нас", "Subscribe us": "Подпишитесь на&nbsp;нас",
"Subscribe what you like to tune your personal feed": "Подпишитесь на интересующие вас темы, чтобы настроить вашу персональную ленту и моментально узнавать о новых публикациях и обсуждениях", "Subscribe what you like to tune your personal feed": "Подпишитесь на интересующие вас темы, чтобы настроить вашу персональную ленту и моментально узнавать о новых публикациях и обсуждениях",
"Subscribe who you like to tune your personal feed": "Подпишитесь на интересующих вас авторов, чтобы настроить вашу персональную ленту и моментально узнавать о новых публикациях и обсуждениях", "Subscribe who you like to tune your personal feed": "Подпишитесь на интересующих вас авторов, чтобы настроить вашу персональную ленту и моментально узнавать о новых публикациях и обсуждениях",
"subscriber": "подписчик", "Subscribe": "Подписаться",
"subscribers": "подписчиков", "SubscriberWithCount": "{count, plural, =0 {нет подписчиков} one {{count} подписчик} few {{count} подписчика} other {{count} подписчиков}}",
"Subscribing...": "Подписываем...",
"Subscription": "Подписка", "Subscription": "Подписка",
"SubscriptionWithCount": "{count, plural, =0 {нет подписок} one {{count} подписка} few {{count} подписки} other {{count} подписок}}",
"Subscriptions": "Подписки", "Subscriptions": "Подписки",
"Substrate": "Подложка", "Substrate": "Подложка",
"Success": "Успешно", "Success": "Успешно",
@ -444,11 +388,10 @@
"Support Discours": "Поддержите Дискурс", "Support Discours": "Поддержите Дискурс",
"Support the project": "Поддержать проект", "Support the project": "Поддержать проект",
"Support us": "Помочь журналу", "Support us": "Помочь журналу",
"terms of use": "правилами пользования сайтом",
"Terms of use": "Правила сайта", "Terms of use": "Правила сайта",
"Text checking": "Проверка текста", "Text checking": "Проверка текста",
"Thank you": "Благодарности",
"Thank you!": "Спасибо Вам!", "Thank you!": "Спасибо Вам!",
"Thank you": "Благодарности",
"The address is already taken": "Адрес уже занят", "The address is already taken": "Адрес уже занят",
"The most interesting publications on the topic": "Самые интересные публикации по теме {topicName}", "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.": "Тематическое оглавление журнала. Здесь можно найти все темы, о которых писали авторы сообщества.",
@ -458,23 +401,21 @@
"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?": "В настройках публикации есть несохраненные изменения. Уверены, что хотите покинуть страницу без сохранения?",
"This comment has not yet been rated": "Этот комментарий еще пока никто не оценил", "This comment has not yet been rated": "Этот комментарий еще пока никто не оценил",
"This content is not published yet": "Содержимое ещё не опубликовано",
"This email is": "Этот email", "This email is": "Этот email",
"This email is not verified": "Этот email не подтвержден", "This email is not verified": "Этот email не подтвержден",
"This email is registered": "Этот email уже зарегистрирован",
"This email is verified": "Этот email подтвержден", "This email is verified": "Этот email подтвержден",
"try": "попробуйте",
"This email is registered": "Этот email уже зарегистрирован",
"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": "За месяц",
"This post has not been rated yet": "Эту публикацию еще пока никто не оценил", "No one rated yet": "Ещё никто не оценивал",
"This way we ll realize that you re a real person and ll take your vote into account. And you 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": "Так мы&nbsp;поймем, что вы&nbsp;реальный человек, и&nbsp;учтем ваш голос. А&nbsp;вы&nbsp;увидите, как проголосовали другие",
"This way you ll be able to subscribe to authors, interesting topics and customize your feed": "Так вы сможете подписаться на авторов, интересные темы и настроить свою ленту", "This way you&nbsp;ll be able to subscribe to&nbsp;authors, interesting topics and&nbsp;customize your feed": "Так вы&nbsp;сможете подписаться на&nbsp;авторов, интересные темы и&nbsp;настроить свою ленту",
"This week": "За неделю", "This week": "За неделю",
"This year": "За год", "This year": "За год",
"To find publications, art, comments, authors and topics of interest to you, just start typing your query": "Для поиска публикаций, искусства, комментариев, интересных вам авторов и тем, просто начните вводить ваш запрос", "To find publications, art, comments, authors and topics of interest to you, just start typing your query": "Для поиска публикаций, искусства, комментариев, интересных вам авторов и&nbsp;тем, просто начните вводить ваш запрос",
"To leave a comment please": "Чтобы оставить комментарий, необходимо", "To leave a comment please": "Чтобы оставить комментарий, необходимо",
"to process personal data and receive email notifications": "на обработку персональных данных и на получение почтовых уведомлений",
"To write a comment, you must": "Чтобы написать комментарий, необходимо", "To write a comment, you must": "Чтобы написать комментарий, необходимо",
"today": "сегодня",
"Top authors": "Рейтинг авторов", "Top authors": "Рейтинг авторов",
"Top commented": "Самое комментируемое", "Top commented": "Самое комментируемое",
"Top discussed": "Обсуждаемое", "Top discussed": "Обсуждаемое",
@ -484,65 +425,147 @@
"Top topics": "Интересные темы", "Top topics": "Интересные темы",
"Top viewed": "Самое читаемое", "Top viewed": "Самое читаемое",
"Topic is supported by": "Тему поддерживают", "Topic is supported by": "Тему поддерживают",
"topicKeywords": "{topic}, Discours.io, статьи, журналистика, исследования",
"Topics": "Темы",
"Topics which supported by author": "Автор поддерживает темы", "Topics which supported by author": "Автор поддерживает темы",
"try": "попробуйте", "Topics": "Темы",
"Try to find another way": "Попробуйте найти по-другому", "Try to find another way": "Попробуйте найти по-другому",
"Unfollow": "Отписаться",
"Unfollow the topic": "Отписаться от темы", "Unfollow the topic": "Отписаться от темы",
"Unfollow": "Отписаться",
"Unnamed draft": "Черновик без названия", "Unnamed draft": "Черновик без названия",
"UnSubscribing...": "Отписываем...",
"Upload": "Загрузить",
"Upload error": "Ошибка загрузки", "Upload error": "Ошибка загрузки",
"Upload userpic": "Загрузить аватар", "Upload userpic": "Загрузить аватар",
"Upload video": "Загрузить видео", "Upload video": "Загрузить видео",
"Upload": "Загрузить",
"Uploading image": "Загружаем изображение", "Uploading image": "Загружаем изображение",
"user already exist": "пользователь уже существует",
"User was not found": "Пользователь не найден",
"Username": "Имя пользователя", "Username": "Имя пользователя",
"Userpic": "Аватар", "Userpic": "Аватар",
"Users": "Пользователи", "Users": "Пользователи",
"verified": "уже подтверждён",
"Video": "Видео",
"Video format not supported": "Тип видео не поддерживается", "Video format not supported": "Тип видео не поддерживается",
"view": "просмотр", "Video": "Видео",
"Views": "Просмотры", "Views": "Просмотры",
"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 couldn't find anything for your request": "Мы&nbsp;не&nbsp;смогли ничего найти по&nbsp;вашему запросу",
"We know you, please try to login": "Такой адрес почты уже зарегистрирован, попробуйте залогиниться", "We know you, please try to login": "Такой адрес почты уже зарегистрирован, попробуйте залогиниться",
"We've sent you a message with a link to enter our website.": "Мы выслали вам письмо с ссылкой на почту. Перейдите по ссылке в письме, чтобы войти на сайт.", "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 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": "Войдите в Дискурс для подписки на новые публикации",
"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 vote": "Войдите в Дискурс, чтобы голосовать", "Welcome to Discours to vote": "Войдите в Дискурс, чтобы голосовать",
"Welcome to Discours": "Добро пожаловать в Дискурс",
"Welcome!": "Добро пожаловать!", "Welcome!": "Добро пожаловать!",
"Where": "Откуда", "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": "За что можно заслужить дырку в карме и как получить лучи благодарности за вклад в дискуссии в сообществах самиздата", "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": "Слов", "Words": "Слов",
"Work with us": "Сотрудничать с Дискурсом",
"Write a comment...": "Написать комментарий...", "Write a comment...": "Написать комментарий...",
"Write a short introduction": "Напишите краткое вступление", "Write a short introduction": "Напишите краткое вступление",
"Write about the topic": "Написать в тему", "Write about the topic": "Написать в тему",
"Write an article": "Написать статью", "Write an article": "Написать статью",
"Write comment": "Написать комментарий", "Write comment": "Написать комментарий",
"Write good articles, comment\nand it won't be so empty here": "Пишите хорошие статьи, комментируйте,\nи здесь станет не так пусто", "Write good articles, comment\nand it won't be so empty here": "Пишите хорошие статьи, комментируйте,\nи здесь станет не так пусто",
"Write message": "Написать сообщение",
"Write to us": "Напишите нам", "Write to us": "Напишите нам",
"Write your colleagues name or email": "Напишите имя или e-mail коллеги", "Write your colleagues name or email": "Напишите имя или e-mail коллеги",
"yesterday": "вчера",
"You can": "Вы можете", "You can": "Вы можете",
"You can download multiple tracks at once in .mp3, .wav or .flac formats": "Можно загрузить сразу несколько треков в форматах .mp3, .wav или .flac", "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 can now login using your new password": "Теперь вы можете входить с помощью нового пароля",
"You can't edit this post": "Вы не можете редактировать этот материал",
"You ll be able to participate in discussions, rate others' comments and learn about new responses": "Вы сможете участвовать в обсуждениях, оценивать комментарии других и узнавать о новых ответах",
"You was successfully authorized": "Вы были успешно авторизованы", "You was successfully authorized": "Вы были успешно авторизованы",
"You can't edit this post": "Вы не можете редактировать этот материал",
"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 confirmed email": "Вы подтвердили почту",
"You've reached a non-existed page": "Вы попали на несуществующую страницу", "You've reached a non-existed page": "Вы попали на несуществующую страницу",
"You've successfully logged out": "Вы успешно вышли из аккаунта", "You've successfully logged out": "Вы успешно вышли из аккаунта",
"Your contact for answer": "Ваш контакт для ответа",
"Your email": "Ваш email", "Your email": "Ваш 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": "действия",
"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": "Неверное подтверждение нового пароля"
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

View File

@ -1,51 +0,0 @@
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 { AuthToken } from '@authorizerdev/authorizer-js'
import { Loading } from './components/_shared/Loading'
import { AuthorsProvider } from './context/authors'
import { EditorProvider } from './context/editor'
import { FeedProvider } from './context/feed'
import { LocalizeProvider } from './context/localize'
import { SessionProvider } from './context/session'
import { TopicsProvider } from './context/topics'
import { UIProvider } from './context/ui'
import '~/styles/app.scss'
export const Providers = (props: { children?: JSX.Element }) => {
const sessionStateChanged = (payload: AuthToken) => {
console.debug(payload)
// TODO: maybe load subs here
}
return (
<LocalizeProvider>
<SessionProvider onStateChangeCallback={sessionStateChanged}>
<TopicsProvider>
<FeedProvider>
<MetaProvider>
<Meta name="viewport" content="width=device-width, initial-scale=1" />
<UIProvider>
<EditorProvider>
<AuthorsProvider>
<Suspense fallback={<Loading />}>{props.children}</Suspense>
</AuthorsProvider>
</EditorProvider>
</UIProvider>
</MetaProvider>
</FeedProvider>
</TopicsProvider>
</SessionProvider>
</LocalizeProvider>
)
}
export const App = () => (
<Router root={Providers}>
<FileRoutes />
</Router>
)
export default App

View File

Before

Width:  |  Height:  |  Size: 245 B

After

Width:  |  Height:  |  Size: 245 B

142
src/components/App.tsx Normal file
View File

@ -0,0 +1,142 @@
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

@ -27,16 +27,6 @@ img {
} }
.shoutBody { .shoutBody {
@include media-breakpoint-up(sm) {
:global(.width-30) {
width: 30%;
}
:global(.width-50) {
width: 50%;
}
}
font-size: 1.6rem; font-size: 1.6rem;
line-height: 1.6; line-height: 1.6;
@ -75,16 +65,6 @@ img {
blockquote[data-type='quote'], blockquote[data-type='quote'],
ta-quotation { ta-quotation {
@include media-breakpoint-up(sm) {
&[data-float='left'] {
margin-right: 1.5em;
}
&[data-float='right'] {
margin-left: 1.5em;
}
}
border: solid #000; border: solid #000;
border-width: 0 0 0 2px; border-width: 0 0 0 2px;
clear: both; clear: both;
@ -98,11 +78,21 @@ img {
&[data-float='right'] { &[data-float='right'] {
@include font-size(2.2rem); @include font-size(2.2rem);
line-height: 1.4;
@include media-breakpoint-up(sm) { @include media-breakpoint-up(sm) {
clear: none; clear: none;
} }
}
line-height: 1.4; @include media-breakpoint-up(sm) {
&[data-float='left'] {
margin-right: 1.5em;
}
&[data-float='right'] {
margin-left: 1.5em;
}
} }
&::before { &::before {
@ -116,17 +106,17 @@ img {
ta-border-sub { ta-border-sub {
@include font-size(1.4rem); @include font-size(1.4rem);
@include media-breakpoint-up(md) {
margin: 3.2rem -8.3333%;
padding: 3.2rem 8.3333%;
}
background: #f1f2f3; background: #f1f2f3;
clear: both; clear: both;
display: block; display: block;
margin: 3.2rem 0; margin: 3.2rem 0;
padding: 3.2rem; padding: 3.2rem;
@include media-breakpoint-up(md) {
margin: 3.2rem -8.3333%;
padding: 3.2rem 8.3333%;
}
p:last-child { p:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
@ -204,6 +194,16 @@ img {
margin: 0 8.3333% 1.5em 0; margin: 0 8.3333% 1.5em 0;
} }
@include media-breakpoint-up(sm) {
:global(.width-30) {
width: 30%;
}
:global(.width-50) {
width: 50%;
}
}
:global(.img-align-left.width-50) { :global(.img-align-left.width-50) {
@include media-breakpoint-up(xl) { @include media-breakpoint-up(xl) {
margin-left: -16.6666%; margin-left: -16.6666%;
@ -313,24 +313,20 @@ img {
} }
.shoutStats { .shoutStats {
@include media-breakpoint-down(lg) {
flex-wrap: wrap;
}
border-top: 4px solid #000; border-top: 4px solid #000;
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
padding: 3rem 0 0; padding: 3rem 0 0;
position: relative; position: relative;
@include media-breakpoint-down(lg) {
flex-wrap: wrap;
}
} }
.shoutStatsItem { .shoutStatsItem {
@include font-size(1.5rem); @include font-size(1.5rem);
@include media-breakpoint-up(xl) {
margin-right: 3.2rem;
}
align-items: center; align-items: center;
font-weight: 500; font-weight: 500;
display: flex; display: flex;
@ -338,6 +334,10 @@ img {
vertical-align: baseline; vertical-align: baseline;
cursor: pointer; cursor: pointer;
@include media-breakpoint-up(xl) {
margin-right: 3.2rem;
}
.icon { .icon {
display: inline-block; display: inline-block;
margin-right: 0.2em; margin-right: 0.2em;
@ -379,11 +379,11 @@ img {
} }
.shoutStatsItemBookmarks { .shoutStatsItemBookmarks {
margin-left: auto;
@include media-breakpoint-up(lg) { @include media-breakpoint-up(lg) {
margin-left: 0; margin-left: 0;
} }
margin-left: auto;
} }
.shoutStatsItemInner { .shoutStatsItemInner {
@ -408,15 +408,6 @@ img {
} }
.shoutStatsItemAdditionalData { .shoutStatsItemAdditionalData {
@include media-breakpoint-down(lg) {
flex: 1 100%;
order: 9;
.shoutStatsItemAdditionalDataItem {
margin-left: 0;
}
}
color: rgb(0 0 0 / 40%); color: rgb(0 0 0 / 40%);
cursor: default; cursor: default;
font-weight: normal; font-weight: normal;
@ -427,9 +418,24 @@ img {
opacity: 0.4; opacity: 0.4;
height: 2rem; height: 2rem;
} }
@include media-breakpoint-down(lg) {
flex: 1 100%;
order: 9;
.shoutStatsItemAdditionalDataItem {
margin-left: 0;
}
}
} }
.shoutStatsItemViews { .shoutStatsItemViews {
color: rgb(0 0 0 / 40%);
cursor: default;
font-weight: normal;
margin-left: auto;
white-space: nowrap;
@include media-breakpoint-down(lg) { @include media-breakpoint-down(lg) {
bottom: 0; bottom: 0;
flex: 1 40%; flex: 1 40%;
@ -443,12 +449,6 @@ img {
display: none !important; display: none !important;
} }
} }
color: rgb(0 0 0 / 40%);
cursor: default;
font-weight: normal;
margin-left: auto;
white-space: nowrap;
} }
.shoutStatsItemLabel { .shoutStatsItemLabel {
@ -457,11 +457,11 @@ img {
} }
.commentsTextLabel { .commentsTextLabel {
display: none;
@include media-breakpoint-up(sm) { @include media-breakpoint-up(sm) {
display: block; display: block;
} }
display: none;
} }
.shoutStatsItemCount { .shoutStatsItemCount {
@ -471,12 +471,6 @@ img {
} }
.shoutStatsItemAdditionalDataItem { .shoutStatsItemAdditionalDataItem {
@include media-breakpoint-down(sm) {
&:first-child {
margin-left: 0;
}
}
font-weight: normal; font-weight: normal;
display: inline-block; display: inline-block;
@ -484,6 +478,12 @@ img {
margin-right: 0; margin-right: 0;
margin-bottom: 0; margin-bottom: 0;
cursor: default; cursor: default;
@include media-breakpoint-down(sm) {
&:first-child {
margin-left: 0;
}
}
} }
.topicsList { .topicsList {

View File

@ -36,7 +36,7 @@
width: 200px; width: 200px;
height: 200px; height: 200px;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
background: var(--placeholder-color-semi) url('/icons/create-audio.svg') no-repeat 50% 50%; background: var(--placeholder-color-semi) url('/icons/create-music.svg') no-repeat 50% 50%;
.image { .image {
object-fit: cover; object-fit: cover;

View File

@ -1,11 +1,11 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Show, createSignal } from 'solid-js' import { Show, createSignal } from 'solid-js'
import { Icon } from '~/components/_shared/Icon' import { Topic } from '../../../graphql/schema/core.gen'
import { Image } from '~/components/_shared/Image' import { MediaItem } from '../../../pages/types'
import { Topic } from '~/graphql/schema/core.gen'
import { MediaItem } from '~/types/mediaitem'
import { CardTopic } from '../../Feed/CardTopic' import { CardTopic } from '../../Feed/CardTopic'
import { Icon } from '../../_shared/Icon'
import { Image } from '../../_shared/Image'
import styles from './AudioHeader.module.scss' import styles from './AudioHeader.module.scss'
@ -30,19 +30,19 @@ export const AudioHeader = (props: Props) => {
</div> </div>
<div class={styles.albumInfo}> <div class={styles.albumInfo}>
<Show when={props.topic}> <Show when={props.topic}>
<CardTopic title={props.topic.title || ''} slug={props.topic.slug} /> <CardTopic title={props.topic.title} slug={props.topic.slug} />
</Show> </Show>
<h1>{props.title}</h1> <h1>{props.title}</h1>
<Show when={props.artistData}> <Show when={props.artistData}>
<div class={styles.artistData}> <div class={styles.artistData}>
<Show when={props.artistData?.artist}> <Show when={props.artistData?.artist}>
<div class={styles.item}>{props.artistData?.artist || ''}</div> <div class={styles.item}>{props.artistData.artist}</div>
</Show> </Show>
<Show when={props.artistData?.date}> <Show when={props.artistData?.date}>
<div class={styles.item}>{props.artistData?.date || ''}</div> <div class={styles.item}>{props.artistData.date}</div>
</Show> </Show>
<Show when={props.artistData?.genre}> <Show when={props.artistData?.genre}>
<div class={styles.item}>{props.artistData?.genre || ''}</div> <div class={styles.item}>{props.artistData.genre}</div>
</Show> </Show>
</div> </div>
</Show> </Show>

View File

@ -3,32 +3,27 @@
} }
.playerHeader { .playerHeader {
@include media-breakpoint-down(sm) {
flex-direction: column;
}
width: 100%; width: 100%;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@include media-breakpoint-down(sm) {
flex-direction: column;
}
} }
.playerTitle { .playerTitle {
@include media-breakpoint-down(sm) {
max-width: 100%;
}
max-width: 50%; max-width: 50%;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@include media-breakpoint-down(sm) {
max-width: 100%;
}
} }
.playerControls { .playerControls {
@include media-breakpoint-down(sm) {
margin-top: 20px;
margin-left: 0;
}
display: flex; display: flex;
min-width: 160px; min-width: 160px;
align-items: center; align-items: center;
@ -47,6 +42,11 @@
} }
} }
@include media-breakpoint-down(sm) {
margin-top: 20px;
margin-left: 0;
}
.playButton { .playButton {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -1,6 +1,6 @@
import { Show, createEffect, createMemo, createSignal, on, onMount } from 'solid-js' import { Show, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'
import { MediaItem } from '~/types/mediaitem' import { MediaItem } from '../../../pages/types'
import { PlayerHeader } from './PlayerHeader' import { PlayerHeader } from './PlayerHeader'
import { PlayerPlaylist } from './PlayerPlaylist' import { PlayerPlaylist } from './PlayerPlaylist'
@ -12,22 +12,18 @@ type Props = {
articleSlug?: string articleSlug?: string
body?: string body?: string
editorMode?: boolean editorMode?: boolean
onMediaItemFieldChange?: ( onMediaItemFieldChange?: (index: number, field: keyof MediaItem, value: string) => void
index: number, onChangeMediaIndex?: (direction: 'up' | 'down', index) => void
field: keyof MediaItem | string | number | symbol,
value: string
) => void
onChangeMediaIndex?: (direction: 'up' | 'down', index: number) => void
} }
const getFormattedTime = (point: number) => new Date(point * 1000).toISOString().slice(14, -5) const getFormattedTime = (point: number) => new Date(point * 1000).toISOString().slice(14, -5)
export const AudioPlayer = (props: Props) => { export const AudioPlayer = (props: Props) => {
let audioRef: HTMLAudioElement | undefined const audioRef: { current: HTMLAudioElement } = { current: null }
let gainNodeRef: GainNode | undefined const gainNodeRef: { current: GainNode } = { current: null }
let progressRef: HTMLDivElement | undefined const progressRef: { current: HTMLDivElement } = { current: null }
let audioContextRef: AudioContext | undefined const audioContextRef: { current: AudioContext } = { current: null }
let mouseDownRef: boolean | undefined const mouseDownRef: { current: boolean } = { current: false }
const [currentTrackDuration, setCurrentTrackDuration] = createSignal(0) const [currentTrackDuration, setCurrentTrackDuration] = createSignal(0)
const [currentTime, setCurrentTime] = createSignal(0) const [currentTime, setCurrentTime] = createSignal(0)
@ -35,25 +31,34 @@ export const AudioPlayer = (props: Props) => {
const [isPlaying, setIsPlaying] = createSignal(false) const [isPlaying, setIsPlaying] = createSignal(false)
const currentTack = createMemo(() => props.media[currentTrackIndex()]) 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) => { const handlePlayMedia = async (trackIndex: number) => {
setIsPlaying(!isPlaying() || trackIndex !== currentTrackIndex()) setIsPlaying(!isPlaying() || trackIndex !== currentTrackIndex())
setCurrentTrackIndex(trackIndex) setCurrentTrackIndex(trackIndex)
if (audioContextRef?.state === 'suspended') { if (audioContextRef.current.state === 'suspended') {
await audioContextRef?.resume() await audioContextRef.current.resume()
} }
if (isPlaying()) { if (isPlaying()) {
await audioRef?.play() await audioRef.current.play()
} else { } else {
audioRef?.pause() audioRef.current.pause()
} }
} }
const handleVolumeChange = (volume: number) => { const handleVolumeChange = (volume: number) => {
if (gainNodeRef) gainNodeRef.gain.value = volume gainNodeRef.current.gain.value = volume
} }
const handleAudioEnd = () => { const handleAudioEnd = () => {
@ -62,22 +67,21 @@ export const AudioPlayer = (props: Props) => {
return return
} }
if (audioRef) audioRef.currentTime = 0 audioRef.current.currentTime = 0
setIsPlaying(false) setIsPlaying(false)
setCurrentTrackIndex(0) setCurrentTrackIndex(0)
} }
const handleAudioTimeUpdate = () => { const handleAudioTimeUpdate = () => {
setCurrentTime(audioRef?.currentTime || 0) setCurrentTime(audioRef.current.currentTime)
} }
onMount(() => { onMount(() => {
audioContextRef = new AudioContext() audioContextRef.current = new AudioContext()
gainNodeRef = audioContextRef.createGain() gainNodeRef.current = audioContextRef.current.createGain()
if (audioRef) {
const track = audioContextRef?.createMediaElementSource(audioRef) const track = audioContextRef.current.createMediaElementSource(audioRef.current)
track.connect(gainNodeRef).connect(audioContextRef?.destination) track.connect(gainNodeRef.current).connect(audioContextRef.current.destination)
}
}) })
const playPrevTrack = () => { const playPrevTrack = () => {
@ -98,18 +102,13 @@ export const AudioPlayer = (props: Props) => {
setCurrentTrackIndex(newCurrentTrackIndex) setCurrentTrackIndex(newCurrentTrackIndex)
} }
const handleMediaItemFieldChange = ( const handleMediaItemFieldChange = (index: number, field: keyof MediaItem, value) => {
index: number, props.onMediaItemFieldChange(index, field, value)
field: keyof MediaItem | string | number | symbol,
value: string
) => {
props.onMediaItemFieldChange?.(index, field, value)
} }
const scrub = (event: MouseEvent | undefined) => { const scrub = (event) => {
if (progressRef && audioRef) { audioRef.current.currentTime =
audioRef.currentTime = (event?.offsetX || 0 / progressRef.offsetWidth) * currentTrackDuration() (event.offsetX / progressRef.current.offsetWidth) * currentTrackDuration()
}
} }
return ( return (
@ -126,16 +125,16 @@ export const AudioPlayer = (props: Props) => {
<div class={styles.timeline}> <div class={styles.timeline}>
<div <div
class={styles.progress} class={styles.progress}
ref={(el) => (progressRef = el)} ref={(el) => (progressRef.current = el)}
onClick={scrub} onClick={(e) => scrub(e)}
onMouseMove={(e) => mouseDownRef && scrub(e)} onMouseMove={(e) => mouseDownRef.current && scrub(e)}
onMouseDown={() => (mouseDownRef = true)} onMouseDown={() => (mouseDownRef.current = true)}
onMouseUp={() => (mouseDownRef = false)} onMouseUp={() => (mouseDownRef.current = false)}
> >
<div <div
class={styles.progressFilled} class={styles.progressFilled}
style={{ style={{
width: `${(currentTime() / currentTrackDuration()) * 100 || 0}%` width: `${(currentTime() / currentTrackDuration()) * 100 || 0}%`,
}} }}
/> />
</div> </div>
@ -146,13 +145,13 @@ export const AudioPlayer = (props: Props) => {
</Show> </Show>
</div> </div>
<audio <audio
ref={(el) => (audioRef = el)} ref={(el) => (audioRef.current = el)}
onTimeUpdate={handleAudioTimeUpdate} onTimeUpdate={handleAudioTimeUpdate}
src={currentTack().url.replace('images.discours.io', 'cdn.discours.io')} src={currentTack().url.replace('images.discours.io', 'cdn.discours.io')}
onCanPlay={() => { onCanPlay={() => {
// start to play the next track on src change // start to play the next track on src change
if (isPlaying() && audioRef) { if (isPlaying()) {
audioRef.play() audioRef.current.play()
} }
}} }}
onLoadedMetadata={({ currentTarget }) => setCurrentTrackDuration(currentTarget.duration)} onLoadedMetadata={({ currentTarget }) => setCurrentTrackDuration(currentTarget.duration)}
@ -163,7 +162,7 @@ export const AudioPlayer = (props: Props) => {
<PlayerPlaylist <PlayerPlaylist
editorMode={props.editorMode} editorMode={props.editorMode}
onPlayMedia={handlePlayMedia} onPlayMedia={handlePlayMedia}
onChangeMediaIndex={(direction, index) => props.onChangeMediaIndex?.(direction, index)} onChangeMediaIndex={(direction, index) => props.onChangeMediaIndex(direction, index)}
isPlaying={isPlaying()} isPlaying={isPlaying()}
media={props.media} media={props.media}
currentTrackIndex={currentTrackIndex()} currentTrackIndex={currentTrackIndex()}

View File

@ -1,9 +1,10 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Show, createSignal } from 'solid-js' import { Show, createSignal } from 'solid-js'
import { Icon } from '~/components/_shared/Icon'
import { useOutsideClickHandler } from '~/lib/useOutsideClickHandler'
import { MediaItem } from '~/types/mediaitem' import { MediaItem } from '../../../pages/types'
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
import { Icon } from '../../_shared/Icon'
import styles from './AudioPlayer.module.scss' import styles from './AudioPlayer.module.scss'
type Props = { type Props = {
@ -16,7 +17,10 @@ type Props = {
} }
export const PlayerHeader = (props: Props) => { export const PlayerHeader = (props: Props) => {
let volumeContainerRef: HTMLDivElement | undefined const volumeContainerRef: { current: HTMLDivElement } = {
current: null,
}
const [isVolumeBarOpened, setIsVolumeBarOpened] = createSignal(false) const [isVolumeBarOpened, setIsVolumeBarOpened] = createSignal(false)
const toggleVolumeBar = () => { const toggleVolumeBar = () => {
@ -26,7 +30,7 @@ export const PlayerHeader = (props: Props) => {
useOutsideClickHandler({ useOutsideClickHandler({
containerRef: volumeContainerRef, containerRef: volumeContainerRef,
predicate: () => isVolumeBarOpened(), predicate: () => isVolumeBarOpened(),
handler: () => toggleVolumeBar() handler: () => toggleVolumeBar(),
}) })
return ( return (
@ -38,7 +42,7 @@ export const PlayerHeader = (props: Props) => {
onClick={props.onPlayMedia} onClick={props.onPlayMedia}
class={clsx( class={clsx(
styles.playButton, styles.playButton,
props.isPlaying ? styles.playButtonInvertPause : styles.playButtonInvertPlay props.isPlaying ? styles.playButtonInvertPause : styles.playButtonInvertPlay,
)} )}
aria-label="Play" aria-label="Play"
data-playing="false" data-playing="false"
@ -61,7 +65,7 @@ export const PlayerHeader = (props: Props) => {
> >
<Icon name="player-arrow" /> <Icon name="player-arrow" />
</button> </button>
<div ref={(el) => (volumeContainerRef = el)} class={styles.volumeContainer}> <div ref={(el) => (volumeContainerRef.current = el)} class={styles.volumeContainer}>
<Show when={isVolumeBarOpened()}> <Show when={isVolumeBarOpened()}>
<input <input
type="range" type="range"
@ -74,7 +78,7 @@ export const PlayerHeader = (props: Props) => {
onChange={({ target }) => props.onVolumeChange(Number(target.value))} onChange={({ target }) => props.onVolumeChange(Number(target.value))}
/> />
</Show> </Show>
<button onClick={toggleVolumeBar} class={styles.volumeButton} aria-label="Volume"> <button onClick={toggleVolumeBar} class={styles.volumeButton} role="button" aria-label="Volume">
<Icon name="volume" /> <Icon name="volume" />
</button> </button>
</div> </div>

View File

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

View File

@ -1,14 +1,14 @@
.comment { .comment {
@include media-breakpoint-down(sm) {
padding-right: 0;
}
margin: 0 0 0.5em; margin: 0 0 0.5em;
padding: 0 1rem; padding: 0 1rem;
transition: background-color 0.3s; transition: background-color 0.3s;
position: relative; position: relative;
list-style: none; list-style: none;
@include media-breakpoint-down(sm) {
padding-right: 0;
}
&.isNew { &.isNew {
border-radius: 6px; border-radius: 6px;
background: rgb(38 56 217 / 5%); background: rgb(38 56 217 / 5%);
@ -193,6 +193,9 @@
.articleLink { .articleLink {
@include font-size(1.2rem); @include font-size(1.2rem);
flex: 0 0 50%;
margin-right: 2em;
@include media-breakpoint-down(md) { @include media-breakpoint-down(md) {
margin: 0.3em 0 0.5em; margin: 0.3em 0 0.5em;
} }
@ -205,25 +208,20 @@
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
} }
flex: 0 0 50%;
margin-right: 2em;
} }
.articleLinkIcon { .articleLinkIcon {
@include media-breakpoint-up(md) {
margin-left: 1em;
}
display: inline-block; display: inline-block;
margin-right: 1em; margin-right: 1em;
vertical-align: middle; vertical-align: middle;
width: 1em; width: 1em;
@include media-breakpoint-up(md) {
margin-left: 1em;
}
} }
.commentDates { .commentDates {
@include font-size(1.2rem);
flex: 1; flex: 1;
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@ -233,6 +231,8 @@
margin: 0 1em 4px 0; margin: 0 1em 4px 0;
color: rgb(0 0 0 / 30%); color: rgb(0 0 0 / 30%);
@include font-size(1.2rem);
.date { .date {
.icon { .icon {
line-height: 1; line-height: 1;
@ -246,13 +246,13 @@
} }
.commentDetails { .commentDetails {
padding: 1rem 0.2rem 0;
margin-bottom: 1.2rem;
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {
align-items: center; align-items: center;
display: flex; display: flex;
} }
padding: 1rem 0.2rem 0;
margin-bottom: 1.2rem;
} }
.compactUserpic { .compactUserpic {

View File

@ -1,27 +1,24 @@
import { A } from '@solidjs/router' import { getPagePath } from '@nanostores/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { For, Show, Suspense, createMemo, createSignal, lazy } from 'solid-js' import { For, Show, Suspense, createMemo, createSignal, lazy } from 'solid-js'
import { Icon } from '~/components/_shared/Icon'
import { ShowIfAuthenticated } from '~/components/_shared/ShowIfAuthenticated' import { useConfirm } from '../../../context/confirm'
import { useLocalize } from '~/context/localize' import { useLocalize } from '../../../context/localize'
import { useReactions } from '~/context/reactions' import { useReactions } from '../../../context/reactions'
import { useSession } from '~/context/session' import { useSession } from '../../../context/session'
import { useSnackbar, useUI } from '~/context/ui' import { useSnackbar } from '../../../context/snackbar'
import deleteReactionMutation from '~/graphql/mutation/core/reaction-destroy' import { Author, Reaction, ReactionKind } from '../../../graphql/schema/core.gen'
import { import { router } from '../../../stores/router'
Author,
MutationCreate_ReactionArgs,
MutationUpdate_ReactionArgs,
Reaction,
ReactionKind
} from '~/graphql/schema/core.gen'
import { AuthorLink } from '../../Author/AuthorLink' import { AuthorLink } from '../../Author/AuthorLink'
import { Userpic } from '../../Author/Userpic' import { Userpic } from '../../Author/Userpic'
import { Icon } from '../../_shared/Icon'
import { ShowIfAuthenticated } from '../../_shared/ShowIfAuthenticated'
import { CommentDate } from '../CommentDate' import { CommentDate } from '../CommentDate'
import { CommentRatingControl } from '../CommentRatingControl' import { RatingControl as CommentRatingControl } from '../RatingControl'
import styles from './Comment.module.scss' import styles from './Comment.module.scss'
const MiniEditor = lazy(() => import('../../Editor/MiniEditor')) const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))
type Props = { type Props = {
comment: Reaction comment: Reaction
@ -41,19 +38,20 @@ export const Comment = (props: Props) => {
const [isReplyVisible, setIsReplyVisible] = createSignal(false) const [isReplyVisible, setIsReplyVisible] = createSignal(false)
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const [editMode, setEditMode] = createSignal(false) const [editMode, setEditMode] = createSignal(false)
const [clearEditor, setClearEditor] = createSignal(false)
const [editedBody, setEditedBody] = createSignal<string>() const [editedBody, setEditedBody] = createSignal<string>()
const { session, client } = useSession() const { author, session } = useSession()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author) const { createReaction, deleteReaction, updateReaction } = useReactions()
const { createShoutReaction, updateShoutReaction } = useReactions() const { showConfirm } = useConfirm()
const { showConfirm } = useUI()
const { showSnackbar } = useSnackbar() const { showSnackbar } = useSnackbar()
const canEdit = createMemo( const canEdit = createMemo(
() => () =>
Boolean(author()?.id) && Boolean(author()?.id) &&
(props.comment?.created_by?.slug === author()?.slug || session()?.user?.roles?.includes('editor')) (props.comment?.created_by?.slug === author().slug || session()?.user?.roles.includes('editor')),
) )
const body = createMemo(() => (editedBody() ? editedBody()?.trim() : props.comment.body?.trim() || '')) const body = createMemo(() => (editedBody() ? editedBody().trim() : props.comment.body.trim() || ''))
const remove = async () => { const remove = async () => {
if (props.comment?.id) { if (props.comment?.id) {
@ -62,24 +60,16 @@ export const Comment = (props: Props) => {
confirmBody: t('Are you sure you want to delete this comment?'), confirmBody: t('Are you sure you want to delete this comment?'),
confirmButtonLabel: t('Delete'), confirmButtonLabel: t('Delete'),
confirmButtonVariant: 'danger', confirmButtonVariant: 'danger',
declineButtonVariant: 'primary' declineButtonVariant: 'primary',
}) })
if (isConfirmed) { if (isConfirmed) {
const resp = await client() const { error } = await deleteReaction(props.comment.id)
?.mutation(deleteReactionMutation, { id: props.comment.id })
.toPromise()
const result = resp?.data?.delete_reaction
const { error } = result
const notificationType = error ? 'error' : 'success' const notificationType = error ? 'error' : 'success'
const notificationMessage = error const notificationMessage = error
? t('Failed to delete comment') ? t('Failed to delete comment')
: t('Comment successfully deleted') : t('Comment successfully deleted')
await showSnackbar({ await showSnackbar({ type: notificationType, body: notificationMessage })
type: notificationType,
body: notificationMessage,
duration: 3
})
if (!error && props.onDelete) { if (!error && props.onDelete) {
props.onDelete(props.comment.id) props.onDelete(props.comment.id)
@ -92,39 +82,37 @@ export const Comment = (props: Props) => {
} }
} }
const handleCreate = async (value: string) => { const handleCreate = async (value) => {
try { try {
setLoading(true) setLoading(true)
await createShoutReaction({ await createReaction({
reaction: { kind: ReactionKind.Comment,
kind: ReactionKind.Comment, reply_to: props.comment.id,
reply_to: props.comment.id, body: value,
body: value, shout: props.comment.shout.id,
shout: props.comment.shout.id })
} setClearEditor(true)
} as MutationCreate_ReactionArgs)
setIsReplyVisible(false) setIsReplyVisible(false)
setLoading(false) setLoading(false)
} catch (error) { } catch (error) {
console.error('[handleCreate reaction]:', error) console.error('[handleCreate reaction]:', error)
} }
setClearEditor(false)
} }
const toggleEditMode = () => { const toggleEditMode = () => {
setEditMode((oldEditMode) => !oldEditMode) setEditMode((oldEditMode) => !oldEditMode)
} }
const handleUpdate = async (value: string) => { const handleUpdate = async (value) => {
setLoading(true) setLoading(true)
try { try {
const reaction = await updateShoutReaction({ const reaction = await updateReaction({
reaction: { id: props.comment.id,
id: props.comment.id || 0, kind: ReactionKind.Comment,
kind: ReactionKind.Comment, body: value,
body: value, shout: props.comment.shout.id,
shout: props.comment.shout.id })
}
} as MutationUpdate_ReactionArgs)
if (reaction) { if (reaction) {
setEditedBody(value) setEditedBody(value)
} }
@ -139,8 +127,7 @@ export const Comment = (props: Props) => {
<li <li
id={`comment_${props.comment.id}`} id={`comment_${props.comment.id}`}
class={clsx(styles.comment, props.class, { class={clsx(styles.comment, props.class, {
[styles.isNew]: [styles.isNew]: props.lastSeen > (props.comment.updated_at || props.comment.created_at),
(props.lastSeen || Date.now()) > (props.comment.updated_at || props.comment.created_at)
})} })}
> >
<Show when={!!body()}> <Show when={!!body()}>
@ -150,10 +137,10 @@ export const Comment = (props: Props) => {
fallback={ fallback={
<div> <div>
<Userpic <Userpic
name={props.comment.created_by.name || ''} name={props.comment.created_by.name}
userpic={props.comment.created_by.pic || ''} userpic={props.comment.created_by.pic}
class={clsx({ class={clsx({
[styles.compactUserpic]: props.compact [styles.compactUserpic]: props.compact,
})} })}
/> />
<small> <small>
@ -174,9 +161,13 @@ export const Comment = (props: Props) => {
<Show when={props.showArticleLink}> <Show when={props.showArticleLink}>
<div class={styles.articleLink}> <div class={styles.articleLink}>
<Icon name="arrow-right" class={styles.articleLinkIcon} /> <Icon name="arrow-right" class={styles.articleLinkIcon} />
<A href={`${props.comment.shout.slug}?commentId=${props.comment.id}`}> <a
href={`${getPagePath(router, 'article', {
slug: props.comment.shout.slug,
})}?commentId=${props.comment.id}`}
>
{props.comment.shout.title} {props.comment.shout.title}
</A> </a>
</div> </div>
</Show> </Show>
<CommentDate showOnHover={true} comment={props.comment} isShort={true} /> <CommentDate showOnHover={true} comment={props.comment} isShort={true} />
@ -186,11 +177,16 @@ export const Comment = (props: Props) => {
<div class={styles.commentBody}> <div class={styles.commentBody}>
<Show when={editMode()} fallback={<div innerHTML={body()} />}> <Show when={editMode()} fallback={<div innerHTML={body()} />}>
<Suspense fallback={<p>{t('Loading')}</p>}> <Suspense fallback={<p>{t('Loading')}</p>}>
<MiniEditor <SimplifiedEditor
content={editedBody() || props.comment.body || ''} initialContent={editedBody() || props.comment.body}
submitButtonText={t('Save')}
quoteEnabled={true}
imageEnabled={true}
placeholder={t('Write a comment...')} placeholder={t('Write a comment...')}
onSubmit={(value) => handleUpdate(value)} onSubmit={(value) => handleUpdate(value)}
submitByCtrlEnter={true}
onCancel={() => setEditMode(false)} onCancel={() => setEditMode(false)}
setClear={clearEditor()}
/> />
</Suspense> </Suspense>
</Show> </Show>
@ -203,7 +199,7 @@ export const Comment = (props: Props) => {
disabled={loading()} disabled={loading()}
onClick={() => { onClick={() => {
setIsReplyVisible(!isReplyVisible()) setIsReplyVisible(!isReplyVisible())
props.clickedReply?.(props.comment.id) props.clickedReply(props.comment.id)
}} }}
class={clsx(styles.commentControl, styles.commentControlReply)} class={clsx(styles.commentControl, styles.commentControlReply)}
> >
@ -244,15 +240,18 @@ export const Comment = (props: Props) => {
{/* class={clsx(styles.commentControl, styles.commentControlComplain)}*/} {/* class={clsx(styles.commentControl, styles.commentControlComplain)}*/}
{/* onClick={() => showModal('reportComment')}*/} {/* onClick={() => showModal('reportComment')}*/}
{/*>*/} {/*>*/}
{/* {t('Complain')}*/} {/* {t('Report')}*/}
{/*</button>*/} {/*</button>*/}
</div> </div>
<Show when={isReplyVisible() && props.clickedReplyId === props.comment.id}> <Show when={isReplyVisible() && props.clickedReplyId === props.comment.id}>
<Suspense fallback={<p>{t('Loading')}</p>}> <Suspense fallback={<p>{t('Loading')}</p>}>
<MiniEditor <SimplifiedEditor
quoteEnabled={true}
imageEnabled={true}
placeholder={t('Write a comment...')} placeholder={t('Write a comment...')}
onSubmit={(value) => handleCreate(value)} onSubmit={(value) => handleCreate(value)}
submitByCtrlEnter={true}
/> />
</Suspense> </Suspense>
</Show> </Show>
@ -261,7 +260,7 @@ export const Comment = (props: Props) => {
</Show> </Show>
<Show when={props.sortedComments}> <Show when={props.sortedComments}>
<ul> <ul>
<For each={props.sortedComments?.filter((r) => r.reply_to === props.comment.id)}> <For each={props.sortedComments.filter((r) => r.reply_to === props.comment.id)}>
{(c) => ( {(c) => (
<Comment <Comment
sortedComments={props.sortedComments} sortedComments={props.sortedComments}

View File

@ -1,8 +1,8 @@
import type { Reaction } from '~/graphql/schema/core.gen' import type { Reaction } from '../../../graphql/schema/core.gen'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { useLocalize } from '~/context/localize' import { useLocalize } from '../../../context/localize'
import styles from './CommentDate.module.scss' import styles from './CommentDate.module.scss'
@ -28,7 +28,7 @@ export const CommentDate = (props: Props) => {
<div <div
class={clsx(styles.commentDates, { class={clsx(styles.commentDates, {
[styles.commentDatesLastInRow]: props.isLastInRow, [styles.commentDatesLastInRow]: props.isLastInRow,
[styles.showOnHover]: props.showOnHover [styles.showOnHover]: props.showOnHover,
})} })}
> >
<time class={styles.date}>{formattedDate(props.comment.created_at * 1000)}</time> <time class={styles.date}>{formattedDate(props.comment.created_at * 1000)}</time>

View File

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

View File

@ -1,20 +1,20 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { For, Show, createMemo, createSignal, lazy, onMount } from 'solid-js' import { For, Show, createMemo, createSignal, lazy, onMount } from 'solid-js'
import { useFeed } from '~/context/feed' import { useLocalize } from '../../context/localize'
import { useLocalize } from '~/context/localize' import { useReactions } from '../../context/reactions'
import { useReactions } from '~/context/reactions' import { useSession } from '../../context/session'
import { useSession } from '~/context/session' import { Author, Reaction, ReactionKind, ReactionSort } from '../../graphql/schema/core.gen'
import { Author, Reaction, ReactionKind, ReactionSort } from '~/graphql/schema/core.gen' import { byCreated, byStat } from '../../utils/sortby'
import { SortFunction } from '~/types/common'
import { byCreated, byStat } from '~/utils/sort'
import { Button } from '../_shared/Button' import { Button } from '../_shared/Button'
import { Loading } from '../_shared/Loading'
import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated' import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated'
import styles from './Article.module.scss'
import { Comment } from './Comment' import { Comment } from './Comment'
const MiniEditor = lazy(() => import('../Editor/MiniEditor')) import { useSeen } from '../../context/seen'
import styles from './Article.module.scss'
const SimplifiedEditor = lazy(() => import('../Editor/SimplifiedEditor'))
type Props = { type Props = {
articleAuthors: Author[] articleAuthors: Author[]
@ -23,16 +23,17 @@ type Props = {
} }
export const CommentsTree = (props: Props) => { export const CommentsTree = (props: Props) => {
const { session } = useSession() const { author } = useSession()
const { t } = useLocalize() const { t } = useLocalize()
const [commentsOrder, setCommentsOrder] = createSignal<ReactionSort>(ReactionSort.Newest) const [commentsOrder, setCommentsOrder] = createSignal<ReactionSort>(ReactionSort.Newest)
const [onlyNew, setOnlyNew] = createSignal(false) const [onlyNew, setOnlyNew] = createSignal(false)
const [newReactions, setNewReactions] = createSignal<Reaction[]>([]) const [newReactions, setNewReactions] = createSignal<Reaction[]>([])
const [clearEditor, setClearEditor] = createSignal(false)
const [clickedReplyId, setClickedReplyId] = createSignal<number>() const [clickedReplyId, setClickedReplyId] = createSignal<number>()
const { reactionEntities, createShoutReaction, loadReactionsBy } = useReactions() const { reactionEntities, createReaction, loadReactionsBy } = useReactions()
const comments = createMemo(() => const comments = createMemo(() =>
Object.values(reactionEntities()).filter((reaction) => reaction.kind === 'COMMENT') Object.values(reactionEntities).filter((reaction) => reaction.kind === 'COMMENT'),
) )
const sortedComments = createMemo(() => { const sortedComments = createMemo(() => {
@ -44,24 +45,21 @@ export const CommentsTree = (props: Props) => {
} }
if (commentsOrder() === ReactionSort.Like) { if (commentsOrder() === ReactionSort.Like) {
newSortedComments = newSortedComments.sort(byStat('rating') as SortFunction<Reaction>) newSortedComments = newSortedComments.sort(byStat('rating'))
} }
return newSortedComments return newSortedComments
}) })
const { seen } = useFeed() const { seen } = useSeen()
const shoutLastSeen = createMemo(() => seen()[props.shoutSlug] ?? 0) const shoutLastSeen = createMemo(() => seen()[props.shoutSlug] ?? 0)
const currentDate = new Date()
const setCookie = () => localStorage.setItem(`${props.shoutSlug}`, `${currentDate}`)
onMount(() => { onMount(() => {
const currentDate = new Date()
const setCookie = () => localStorage?.setItem(`${props.shoutSlug}`, `${currentDate}`)
if (!shoutLastSeen()) { if (!shoutLastSeen()) {
setCookie() setCookie()
} else if (currentDate.getTime() > shoutLastSeen()) { } else if (currentDate.getTime() > shoutLastSeen()) {
const newComments = comments().filter((c) => { const newComments = comments().filter((c) => {
if ( if (c.reply_to || c.created_by.slug === author()?.slug) {
(session()?.user?.app_data?.profile?.id && c.reply_to) ||
c.created_by.id === session()?.user?.app_data?.profile?.id
) {
return return
} }
return (c.updated_at || c.created_at) > shoutLastSeen() return (c.updated_at || c.created_at) > shoutLastSeen()
@ -70,22 +68,21 @@ export const CommentsTree = (props: Props) => {
setCookie() setCookie()
} }
}) })
const [posting, setPosting] = createSignal(false) const [posting, setPosting] = createSignal(false)
const handleSubmitComment = async (value: string) => { const handleSubmitComment = async (value: string) => {
setPosting(true) setPosting(true)
try { try {
await createShoutReaction({ await createReaction({
reaction: { kind: ReactionKind.Comment,
kind: ReactionKind.Comment, body: value,
body: value, shout: props.shoutId,
shout: props.shoutId
}
}) })
setClearEditor(true)
await loadReactionsBy({ by: { shout: props.shoutSlug } }) await loadReactionsBy({ by: { shout: props.shoutSlug } })
} catch (error) { } catch (error) {
console.error('[handleCreate reaction]:', error) console.error('[handleCreate reaction]:', error)
} }
setClearEditor(false)
setPosting(false) setPosting(false)
} }
@ -95,7 +92,7 @@ export const CommentsTree = (props: Props) => {
<h2 class={styles.commentsHeader}> <h2 class={styles.commentsHeader}>
{t('Comments')} {comments().length.toString() || ''} {t('Comments')} {comments().length.toString() || ''}
<Show when={newReactions().length > 0}> <Show when={newReactions().length > 0}>
<span class={styles.newReactions}>{` +${newReactions().length}`}</span> <span class={styles.newReactions}>&nbsp;+{newReactions().length}</span>
</Show> </Show>
</h2> </h2>
<Show when={comments().length > 0}> <Show when={comments().length > 0}>
@ -131,7 +128,9 @@ export const CommentsTree = (props: Props) => {
{(reaction) => ( {(reaction) => (
<Comment <Comment
sortedComments={sortedComments()} sortedComments={sortedComments()}
isArticleAuthor={Boolean(props.articleAuthors.some((a) => a?.id === reaction.created_by.id))} isArticleAuthor={Boolean(
props.articleAuthors.some((a) => a?.slug === reaction.created_by.slug),
)}
comment={reaction} comment={reaction}
clickedReply={(id) => setClickedReplyId(id)} clickedReply={(id) => setClickedReplyId(id)}
clickedReplyId={clickedReplyId()} clickedReplyId={clickedReplyId()}
@ -147,17 +146,23 @@ export const CommentsTree = (props: Props) => {
<a href="?m=auth&mode=register" class={styles.link}> <a href="?m=auth&mode=register" class={styles.link}>
{t('sign up')} {t('sign up')}
</a>{' '} </a>{' '}
{t('or')}{' '} {t('or')}&nbsp;
<a href="?m=auth&mode=login" class={styles.link}> <a href="?m=auth&mode=login" class={styles.link}>
{t('sign in')} {t('sign in')}
</a> </a>
</div> </div>
} }
> >
<MiniEditor placeholder={t('Write a comment...')} onSubmit={handleSubmitComment} /> <SimplifiedEditor
<Show when={posting()}> quoteEnabled={true}
<Loading /> imageEnabled={true}
</Show> autoFocus={false}
submitByCtrlEnter={true}
placeholder={t('Write a comment...')}
onSubmit={(value) => handleSubmitComment(value)}
setClear={clearEditor()}
isPosting={posting()}
/>
</ShowIfAuthenticated> </ShowIfAuthenticated>
</> </>
) )

View File

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

View File

@ -1,44 +1,50 @@
import { AuthToken } from '@authorizerdev/authorizer-js' import type { Author, Reaction, Shout, Topic } from '../../graphql/schema/core.gen'
import { getPagePath } from '@nanostores/router'
import { createPopper } from '@popperjs/core' import { createPopper } from '@popperjs/core'
import { Link } from '@solidjs/meta'
import { A, useSearchParams } from '@solidjs/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { install } from 'ga-gtag'
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js' import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
import { isServer } from 'solid-js/web' import { isServer } from 'solid-js/web'
import { useFeed } from '~/context/feed' import { Link, Meta } from '../../context/meta'
import { useLocalize } from '~/context/localize'
import { useReactions } from '~/context/reactions' import { useLocalize } from '../../context/localize'
import { useSession } from '~/context/session' import { useReactions } from '../../context/reactions'
import { DEFAULT_HEADER_OFFSET, useUI } from '~/context/ui' import { useSession } from '../../context/session'
import type { Author, Maybe, Shout, Topic } from '~/graphql/schema/core.gen' import { MediaItem } from '../../pages/types'
import { processPrepositions } from '~/intl/prepositions' import { DEFAULT_HEADER_OFFSET, router, useRouter } from '../../stores/router'
import { isCyrillic } from '~/intl/translate' import { showModal } from '../../stores/ui'
import { getImageUrl } from '~/lib/getThumbUrl' import { capitalize } from '../../utils/capitalize'
import { MediaItem } from '~/types/mediaitem' import { getImageUrl, getOpenGraphImageUrl } from '../../utils/getImageUrl'
import { capitalize } from '~/utils/capitalize' import { getDescription, getKeywords } from '../../utils/meta'
import { isCyrillic } from '../../utils/translate'
import { AuthorBadge } from '../Author/AuthorBadge' import { AuthorBadge } from '../Author/AuthorBadge'
import { CardTopic } from '../Feed/CardTopic' import { CardTopic } from '../Feed/CardTopic'
import { FeedArticlePopup } from '../Feed/FeedArticlePopup' import { FeedArticlePopup } from '../Feed/FeedArticlePopup'
import stylesHeader from '../HeaderNav/Header.module.scss' import { Modal } from '../Nav/Modal'
import { TableOfContents } from '../TableOfContents'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import { Image } from '../_shared/Image' import { Image } from '../_shared/Image'
import { InviteMembers } from '../_shared/InviteMembers' import { InviteMembers } from '../_shared/InviteMembers'
import { Lightbox } from '../_shared/Lightbox' import { Lightbox } from '../_shared/Lightbox'
import { Modal } from '../_shared/Modal'
import { Popover } from '../_shared/Popover' import { Popover } from '../_shared/Popover'
import { ShareModal } from '../_shared/ShareModal' import { ShareModal } from '../_shared/ShareModal'
import { ImageSwiper } from '../_shared/SolidSwiper' import { ImageSwiper } from '../_shared/SolidSwiper'
import { TableOfContents } from '../_shared/TableOfContents'
import { VideoPlayer } from '../_shared/VideoPlayer' import { VideoPlayer } from '../_shared/VideoPlayer'
import styles from './Article.module.scss'
import { AudioHeader } from './AudioHeader' import { AudioHeader } from './AudioHeader'
import { AudioPlayer } from './AudioPlayer' import { AudioPlayer } from './AudioPlayer'
import { CommentsTree } from './CommentsTree' import { CommentsTree } from './CommentsTree'
import { RatingControl as ShoutRatingControl } from './RatingControl'
import { SharePopup, getShareUrl } from './SharePopup' import { SharePopup, getShareUrl } from './SharePopup'
import { ShoutRatingControl } from './ShoutRatingControl'
import { useSeen } from '../../context/seen'
import stylesHeader from '../Nav/Header/Header.module.scss'
import styles from './Article.module.scss'
type Props = { type Props = {
article: Shout article: Shout
scrollToComments?: boolean
} }
type IframeSize = { type IframeSize = {
@ -47,105 +53,73 @@ type IframeSize = {
} }
export type ArticlePageSearchParams = { export type ArticlePageSearchParams = {
commentId?: string scrollTo: 'comments'
commentId: string
slide?: string slide?: string
} }
const scrollTo = (el: HTMLElement) => { const scrollTo = (el: HTMLElement) => {
const { top } = el.getBoundingClientRect() const { top } = el.getBoundingClientRect()
window.scrollTo({
window?.scrollTo({
top: top + window.scrollY - DEFAULT_HEADER_OFFSET, top: top + window.scrollY - DEFAULT_HEADER_OFFSET,
left: 0, left: 0,
behavior: 'smooth' behavior: 'smooth',
}) })
} }
const imgSrcRegExp = /<img[^>]+src\s*=\s*["']([^"']+)["']/gi const imgSrcRegExp = /<img[^>]+src\s*=\s*["']([^"']+)["']/gi
export const COMMENTS_PER_PAGE = 30
const VOTES_PER_PAGE = 50
export const FullArticle = (props: Props) => { export const FullArticle = (props: Props) => {
const [searchParams] = useSearchParams<ArticlePageSearchParams>() const { searchParams, changeSearchParams } = useRouter<ArticlePageSearchParams>()
const { showModal } = useUI()
const { loadReactionsBy } = useReactions() const { loadReactionsBy } = useReactions()
const [selectedImage, setSelectedImage] = createSignal('') const [selectedImage, setSelectedImage] = createSignal('')
const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false) const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false)
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false) const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
const { t, formatDate, lang } = useLocalize() const { t, formatDate, lang } = useLocalize()
const { session, requireAuthentication } = useSession() const { author, session, requireAuthentication } = useSession()
const { addSeen } = useFeed() const { addSeen } = useSeen()
const formattedDate = createMemo(() => formatDate(new Date((props.article.published_at || 0) * 1000)))
const [pages, setPages] = createSignal<Record<string, number>>({}) const formattedDate = createMemo(() => formatDate(new Date(props.article.published_at * 1000)))
createEffect(
on(
pages,
(p: Record<string, number>) => {
console.debug('content paginated')
loadReactionsBy({
by: { shout: props.article.slug, comment: true },
limit: COMMENTS_PER_PAGE,
offset: COMMENTS_PER_PAGE * p.comments || 0
})
loadReactionsBy({
by: { shout: props.article.slug, rating: true },
limit: VOTES_PER_PAGE,
offset: VOTES_PER_PAGE * p.rating || 0
})
setIsReactionsLoaded(true)
console.debug('reactions paginated')
},
{ defer: true }
)
)
const [canEdit, setCanEdit] = createSignal<boolean>(false) const canEdit = createMemo(
createEffect( () =>
on( Boolean(author()?.id) &&
() => session(), (props.article?.authors?.some((a) => Boolean(a) && a?.id === author().id) ||
(s?: AuthToken) => { props.article?.created_by?.id === author().id ||
const profile = s?.user?.app_data?.profile session()?.user?.roles.includes('editor')),
if (!profile) return
const isEditor = s?.user?.roles?.includes('editor')
const isCreator = props.article.created_by?.id === profile.id
const fit = (a: Maybe<Author>) => a?.id === profile.id || isCreator || isEditor
setCanEdit((_: boolean) => Boolean(props.article.authors?.some(fit)))
}
)
) )
const mainTopic = createMemo(() => { const mainTopic = createMemo(() => {
const mainTopicSlug = (props.article.topics?.length || 0) > 0 ? props.article.main_topic : null const mainTopicSlug = props.article.topics.length > 0 ? props.article.main_topic : null
const mt = props.article.topics?.find((tpc: Maybe<Topic>) => tpc?.slug === mainTopicSlug) const mt = props.article.topics.find((tpc: Topic) => tpc.slug === mainTopicSlug)
if (mt) { if (mt) {
mt.title = lang() === 'en' ? capitalize(mt.slug.replaceAll('-', ' ')) : mt.title mt.title = lang() === 'en' ? capitalize(mt.slug.replace(/-/, ' ')) : mt.title
return mt return mt
} }
return props.article.topics?.[0] return props.article.topics[0]
}) })
const handleBookmarkButtonClick = (ev: MouseEvent | undefined) => { const handleBookmarkButtonClick = (ev) => {
requireAuthentication(() => { requireAuthentication(() => {
// TODO: implement bookmark clicked // TODO: implement bookmark clicked
ev?.preventDefault() ev.preventDefault()
}, 'bookmark') }, 'bookmark')
} }
const body = createMemo(() => { const body = createMemo(() => {
if (props.article.layout === 'literature') { if (props.article.layout === 'literature') {
try { try {
if (props.article.media) { if (props.article?.media) {
const media = JSON.parse(props.article.media) const media = JSON.parse(props.article.media)
if (media.length > 0) { if (media.length > 0) {
return processPrepositions(media[0].body) return media[0].body
} }
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
} }
return processPrepositions(props.article.body) || '' return props.article.body
}) })
const imageUrls = createMemo(() => { const imageUrls = createMemo(() => {
@ -155,11 +129,10 @@ export const FullArticle = (props: Props) => {
if (isServer) { if (isServer) {
const result: string[] = [] const result: string[] = []
let match: RegExpMatchArray | null let match: RegExpMatchArray
while ((match = imgSrcRegExp.exec(body())) !== null) { while ((match = imgSrcRegExp.exec(body())) !== null) {
if (match) result.push(match[1]) result.push(match[1])
else break
} }
return result return result
} }
@ -169,25 +142,32 @@ export const FullArticle = (props: Props) => {
return Array.from(imageElements).map((img) => img.src) return Array.from(imageElements).map((img) => img.src)
}) })
const media = createMemo<MediaItem[]>(() => JSON.parse(props.article.media || '[]')) const media = createMemo<MediaItem[]>(() => {
try {
return JSON.parse(props.article?.media || '[]')
} catch {
return []
}
})
const commentsRef: {
current: HTMLDivElement
} = { current: null }
let commentsRef: HTMLDivElement | undefined
createEffect(() => { createEffect(() => {
if (searchParams?.commentId && isReactionsLoaded()) { if (searchParams().commentId && isReactionsLoaded()) {
console.debug('comment id is in link, scroll to') const commentElement = document.querySelector<HTMLElement>(
const scrollToElement = `[id='comment_${searchParams().commentId}']`,
document.querySelector<HTMLElement>(`[id='comment_${searchParams?.commentId}']`) || )
commentsRef ||
document.body
if (scrollToElement) { if (commentElement) {
requestAnimationFrame(() => scrollTo(scrollToElement)) requestAnimationFrame(() => scrollTo(commentElement))
} }
} }
}) })
const clickHandlers: { element: HTMLElement; handler: () => void }[] = [] const clickHandlers = []
const documentClickHandlers: ((e: MouseEvent) => void)[] = [] const documentClickHandlers = []
createEffect(() => { createEffect(() => {
if (!body()) { if (!body()) {
@ -195,7 +175,7 @@ export const FullArticle = (props: Props) => {
} }
const tooltipElements: NodeListOf<HTMLElement> = document.querySelectorAll( const tooltipElements: NodeListOf<HTMLElement> = document.querySelectorAll(
'[data-toggle="tooltip"], footnote' '[data-toggle="tooltip"], footnote',
) )
if (!tooltipElements) { if (!tooltipElements) {
return return
@ -205,7 +185,7 @@ export const FullArticle = (props: Props) => {
tooltip.classList.add(styles.tooltip) tooltip.classList.add(styles.tooltip)
const tooltipContent = document.createElement('div') const tooltipContent = document.createElement('div')
tooltipContent.classList.add(styles.tooltipContent) tooltipContent.classList.add(styles.tooltipContent)
tooltipContent.innerHTML = element.dataset.originalTitle || element.dataset.value || '' tooltipContent.innerHTML = element.dataset.originalTitle || element.dataset.value
tooltip.append(tooltipContent) tooltip.append(tooltipContent)
@ -220,19 +200,19 @@ export const FullArticle = (props: Props) => {
modifiers: [ modifiers: [
{ {
name: 'eventListeners', name: 'eventListeners',
options: { scroll: false } options: { scroll: false },
}, },
{ {
name: 'offset', name: 'offset',
options: { options: {
offset: [0, 8] offset: [0, 8],
} },
}, },
{ {
name: 'flip', name: 'flip',
options: { fallbackPlacements: ['top'] } options: { fallbackPlacements: ['top'] },
} },
] ],
}) })
tooltip.style.visibility = 'hidden' tooltip.style.visibility = 'hidden'
@ -249,7 +229,7 @@ export const FullArticle = (props: Props) => {
popperInstance.update() popperInstance.update()
} }
const handleDocumentClick = (e: MouseEvent) => { const handleDocumentClick = (e) => {
if (isTooltipVisible && e.target !== element && e.target !== tooltip) { if (isTooltipVisible && e.target !== element && e.target !== tooltip) {
tooltip.style.visibility = 'hidden' tooltip.style.visibility = 'hidden'
isTooltipVisible = false isTooltipVisible = false
@ -273,15 +253,14 @@ export const FullArticle = (props: Props) => {
}) })
}) })
const openLightbox = (image: string) => { const openLightbox = (image) => {
setSelectedImage(image) setSelectedImage(image)
} }
const handleLightboxClose = () => { const handleLightboxClose = () => {
setSelectedImage('') setSelectedImage()
} }
// biome-ignore lint/suspicious/noExplicitAny: FIXME: typing const handleArticleBodyClick = (event) => {
const handleArticleBodyClick = (event: any) => {
if (event.target.tagName === 'IMG' && !event.target.dataset.disableLightbox) { if (event.target.tagName === 'IMG' && !event.target.dataset.disableLightbox) {
const src = event.target.src const src = event.target.src
openLightbox(getImageUrl(src)) openLightbox(getImageUrl(src))
@ -289,13 +268,12 @@ export const FullArticle = (props: Props) => {
} }
// Check iframes size // Check iframes size
let articleContainer: HTMLElement | undefined const articleContainer: { current: HTMLElement } = { current: null }
const updateIframeSizes = () => { const updateIframeSizes = () => {
if (!window) return if (!(articleContainer?.current && props.article.body)) return
if (!(articleContainer && props.article.body)) return const iframes = articleContainer?.current?.querySelectorAll('iframe')
const iframes = articleContainer?.querySelectorAll('iframe')
if (!iframes) return if (!iframes) return
const containerWidth = articleContainer?.offsetWidth const containerWidth = articleContainer.current?.offsetWidth
iframes.forEach((iframe) => { iframes.forEach((iframe) => {
const style = window.getComputedStyle(iframe) const style = window.getComputedStyle(iframe)
const originalWidth = iframe.getAttribute('width') || style.width.replace('px', '') const originalWidth = iframe.getAttribute('width') || style.width.replace('px', '')
@ -314,26 +292,86 @@ export const FullArticle = (props: Props) => {
}) })
} }
onMount(() => { createEffect(
console.debug(props.article) on(
setPages((_) => ({ comments: 0, rating: 0 })) () => props.article,
() => {
updateIframeSizes()
},
),
)
const [ratings, setRatings] = createSignal<Reaction[]>([])
onMount(async () => {
install('G-LQ4B87H8C2')
await loadReactionsBy({ by: { shout: props.article.slug } })
addSeen(props.article.slug) addSeen(props.article.slug)
setIsReactionsLoaded(true)
document.title = props.article.title document.title = props.article.title
updateIframeSizes()
window?.addEventListener('resize', updateIframeSizes) window?.addEventListener('resize', updateIframeSizes)
onCleanup(() => window.removeEventListener('resize', updateIframeSizes)) onCleanup(() => window.removeEventListener('resize', updateIframeSizes))
createEffect(() => {
if (props.scrollToComments) {
scrollTo(commentsRef.current)
}
})
createEffect(() => {
if (searchParams()?.scrollTo === 'comments' && commentsRef.current) {
requestAnimationFrame(() => scrollTo(commentsRef.current))
changeSearchParams({ scrollTo: null })
}
})
}) })
const shareUrl = createMemo(() => getShareUrl({ pathname: `/${props.article.slug || ''}` })) createEffect(
const getAuthorName = (a: Author) => on(
lang() === 'en' && isCyrillic(a.name || '') ? capitalize(a.slug.replaceAll('-', ' ')) : a.name () => props.article,
async (shout: Shout) => {
setIsReactionsLoaded(false)
const rrr = await loadReactionsBy({ by: { shout: shout?.slug } })
setRatings((_) => rrr.filter((r) => ['LIKE', 'DISLIKE'].includes(r.kind)))
setIsReactionsLoaded(true)
},
{ defer: true },
),
)
const cover = props.article.cover ?? 'production/image/logo_image.png'
const ogImage = getOpenGraphImageUrl(cover, {
title: props.article.title,
topic: mainTopic()?.title || '',
author: props.article?.authors[0]?.name || '',
width: 1200,
})
const description = getDescription(props.article.description || body() || media()[0]?.body)
const ogTitle = props.article.title
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 ( return (
<> <>
<Meta name="descprition" content={description} />
<Meta name="keywords" content={keywords} />
<Meta name="og:type" content="article" />
<Meta name="og:title" content={ogTitle} />
<Meta name="og:image" content={ogImage} />
<Meta name="og:description" content={description} />
<Meta name="twitter:card" content="summary_large_image" />
<Meta name="twitter:title" content={ogTitle} />
<Meta name="twitter:description" content={description} />
<Meta name="twitter:image" content={ogImage} />
<For each={imageUrls()}>{(imageUrl) => <Link rel="preload" as="image" href={imageUrl} />}</For> <For each={imageUrls()}>{(imageUrl) => <Link rel="preload" as="image" href={imageUrl} />}</For>
<div class="wide-container"> <div class="wide-container">
<div class="row position-relative"> <div class="row position-relative">
<article <article
ref={(el) => (articleContainer = el)} ref={(el) => (articleContainer.current = el)}
class={clsx('col-md-16 col-lg-14 col-xl-12 offset-md-5', styles.articleContent)} class={clsx('col-md-16 col-lg-14 col-xl-12 offset-md-5', styles.articleContent)}
onClick={handleArticleBodyClick} onClick={handleArticleBodyClick}
> >
@ -341,20 +379,20 @@ export const FullArticle = (props: Props) => {
<Show when={props.article.layout !== 'audio'}> <Show when={props.article.layout !== 'audio'}>
<div class={styles.shoutHeader}> <div class={styles.shoutHeader}>
<Show when={mainTopic()}> <Show when={mainTopic()}>
<CardTopic title={mainTopic()?.title || ''} slug={mainTopic()?.slug || ''} /> <CardTopic title={mainTopic().title} slug={mainTopic().slug} />
</Show> </Show>
<h1>{props.article.title || ''}</h1> <h1>{props.article.title}</h1>
<Show when={props.article.subtitle}> <Show when={props.article.subtitle}>
<h4>{processPrepositions(props.article.subtitle || '')}</h4> <h4>{props.article.subtitle}</h4>
</Show> </Show>
<div class={styles.shoutAuthor}> <div class={styles.shoutAuthor}>
<For each={props.article.authors}> <For each={props.article.authors}>
{(a: Maybe<Author>, index: () => number) => ( {(a: Author, index) => (
<> <>
<Show when={index() > 0}>, </Show> <Show when={index() > 0}>, </Show>
<A href={`/@${a?.slug}`}>{a && getAuthorName(a)}</A> <a href={getPagePath(router, 'author', { slug: a.slug })}>{getAuthorName(a)}</a>
</> </>
)} )}
</For> </For>
@ -367,29 +405,25 @@ export const FullArticle = (props: Props) => {
} }
> >
<figure class="img-align-column"> <figure class="img-align-column">
<Image <Image width={800} alt={props.article.cover_caption} src={props.article.cover} />
width={800} <figcaption innerHTML={props.article.cover_caption} />
alt={props.article.cover_caption || ''}
src={props.article.cover || ''}
/>
<figcaption innerHTML={props.article.cover_caption || ''} />
</figure> </figure>
</Show> </Show>
</div> </div>
</Show> </Show>
<Show when={props.article.lead}> <Show when={props.article.lead}>
<section class={styles.lead} innerHTML={processPrepositions(props.article.lead || '')} /> <section class={styles.lead} innerHTML={props.article.lead} />
</Show> </Show>
<Show when={props.article.layout === 'audio'}> <Show when={props.article.layout === 'audio'}>
<AudioHeader <AudioHeader
title={props.article.title || ''} title={props.article.title}
cover={props.article.cover || ''} cover={props.article.cover}
artistData={media()?.[0]} artistData={media()?.[0]}
topic={mainTopic() as Topic} topic={mainTopic()}
/> />
<Show when={media().length > 0}> <Show when={media().length > 0}>
<div class="media-items"> <div class="media-items">
<AudioPlayer media={media()} articleSlug={props.article.slug || ''} body={body()} /> <AudioPlayer media={media()} articleSlug={props.article.slug} body={body()} />
</div> </div>
</Show> </Show>
</Show> </Show>
@ -443,15 +477,19 @@ export const FullArticle = (props: Props) => {
<div class="col-md-16 offset-md-5"> <div class="col-md-16 offset-md-5">
<div class={styles.shoutStats}> <div class={styles.shoutStats}>
<div class={styles.shoutStatsItem}> <div class={styles.shoutStatsItem}>
<ShoutRatingControl shout={props.article} class={styles.ratingControl} /> <ShoutRatingControl
shout={props.article}
class={styles.ratingControl}
ratings={ratings()}
/>
</div> </div>
<Popover content={t('Comment')} disabled={isActionPopupActive()}> <Popover content={t('Comment')} disabled={isActionPopupActive()}>
{(triggerRef: (el: HTMLElement) => void) => ( {(triggerRef: (el) => void) => (
<div <div
class={clsx(styles.shoutStatsItem)} class={clsx(styles.shoutStatsItem)}
ref={triggerRef} ref={triggerRef}
onClick={() => commentsRef && scrollTo(commentsRef)} onClick={() => scrollTo(commentsRef.current)}
> >
<Icon name="comment" class={styles.icon} /> <Icon name="comment" class={styles.icon} />
<Icon name="comment-hover" class={clsx(styles.icon, styles.iconHover)} /> <Icon name="comment-hover" class={clsx(styles.icon, styles.iconHover)} />
@ -467,7 +505,7 @@ export const FullArticle = (props: Props) => {
<Show when={props.article.stat?.viewed}> <Show when={props.article.stat?.viewed}>
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemViews)}> <div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemViews)}>
{t('some views', { count: props.article.stat?.viewed || 0 })} {t('viewsWithCount', { count: props.article.stat?.viewed })}
</div> </div>
</Show> </Show>
@ -478,7 +516,7 @@ export const FullArticle = (props: Props) => {
</div> </div>
<Popover content={t('Add to bookmarks')} disabled={isActionPopupActive()}> <Popover content={t('Add to bookmarks')} disabled={isActionPopupActive()}>
{(triggerRef: (el: HTMLElement) => void) => ( {(triggerRef: (el) => void) => (
<div <div
class={clsx(styles.shoutStatsItem, styles.shoutStatsItemBookmarks)} class={clsx(styles.shoutStatsItem, styles.shoutStatsItemBookmarks)}
ref={triggerRef} ref={triggerRef}
@ -493,13 +531,13 @@ export const FullArticle = (props: Props) => {
</Popover> </Popover>
<Popover content={t('Share')} disabled={isActionPopupActive()}> <Popover content={t('Share')} disabled={isActionPopupActive()}>
{(triggerRef: (el: HTMLElement) => void) => ( {(triggerRef: (el) => void) => (
<div class={styles.shoutStatsItem} ref={triggerRef}> <div class={styles.shoutStatsItem} ref={triggerRef}>
<SharePopup <SharePopup
title={props.article.title} title={props.article.title}
description={props.article.description || body() || media()[0]?.body} description={description}
imageUrl={props.article.cover || ''} imageUrl={props.article.cover}
shareUrl={shareUrl()} shareUrl={shareUrl}
containerCssClass={stylesHeader.control} containerCssClass={stylesHeader.control}
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)} onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
trigger={ trigger={
@ -515,19 +553,22 @@ export const FullArticle = (props: Props) => {
<Show when={canEdit()}> <Show when={canEdit()}>
<Popover content={t('Edit')}> <Popover content={t('Edit')}>
{(triggerRef: (el: HTMLElement) => void) => ( {(triggerRef: (el) => void) => (
<div class={styles.shoutStatsItem} ref={triggerRef}> <div class={styles.shoutStatsItem} ref={triggerRef}>
<A href={`/edit/${props.article.id}`} class={styles.shoutStatsItemInner}> <a
href={getPagePath(router, 'edit', { shoutId: props.article?.id.toString() })}
class={styles.shoutStatsItemInner}
>
<Icon name="pencil-outline" class={styles.icon} /> <Icon name="pencil-outline" class={styles.icon} />
<Icon name="pencil-outline-hover" class={clsx(styles.icon, styles.iconHover)} /> <Icon name="pencil-outline-hover" class={clsx(styles.icon, styles.iconHover)} />
</A> </a>
</div> </div>
)} )}
</Popover> </Popover>
</Show> </Show>
<FeedArticlePopup <FeedArticlePopup
canEdit={Boolean(canEdit())} canEdit={canEdit()}
containerCssClass={clsx(stylesHeader.control, styles.articlePopupOpener)} containerCssClass={clsx(stylesHeader.control, styles.articlePopupOpener)}
onShareClick={() => showModal('share')} onShareClick={() => showModal('share')}
onInviteClick={() => showModal('inviteMembers')} onInviteClick={() => showModal('inviteMembers')}
@ -541,7 +582,7 @@ export const FullArticle = (props: Props) => {
/> />
</div> </div>
<Show when={session()?.access_token && !canEdit()}> <Show when={author()?.id && !canEdit()}>
<div class={styles.help}> <div class={styles.help}>
<button class="button">{t('Cooperate')}</button> <button class="button">{t('Cooperate')}</button>
</div> </div>
@ -552,14 +593,14 @@ export const FullArticle = (props: Props) => {
</div> </div>
</Show> </Show>
<Show when={props.article.topics?.length}> <Show when={props.article.topics.length}>
<div class={styles.topicsList}> <div class={styles.topicsList}>
<For each={props.article.topics || []}> <For each={props.article.topics}>
{(topic) => ( {(topic) => (
<div class={styles.shoutTopic}> <div class={styles.shoutTopic}>
<A href={`/topic/${topic?.slug || ''}`}> <a href={getPagePath(router, 'topic', { slug: topic.slug })}>
{lang() === 'en' ? capitalize(topic?.slug || '') : topic?.title || ''} {lang() === 'en' ? capitalize(topic.slug) : topic.title}
</A> </a>
</div> </div>
)} )}
</For> </For>
@ -567,23 +608,23 @@ export const FullArticle = (props: Props) => {
</Show> </Show>
<div class={styles.shoutAuthorsList}> <div class={styles.shoutAuthorsList}>
<Show when={(props.article.authors?.length || 0) > 1}> <Show when={props.article.authors.length > 1}>
<h4>{t('Authors')}</h4> <h4>{t('Authors')}</h4>
</Show> </Show>
<For each={props.article.authors}> <For each={props.article.authors}>
{(a: Maybe<Author>) => ( {(a: Author) => (
<div class="col-xl-12"> <div class="col-xl-12">
<AuthorBadge iconButtons={true} showMessageButton={true} author={a as Author} /> <AuthorBadge iconButtons={true} showMessageButton={true} author={a} />
</div> </div>
)} )}
</For> </For>
</div> </div>
<div id="comments" ref={(el) => (commentsRef = el)}> <div id="comments" ref={(el) => (commentsRef.current = el)}>
<Show when={isReactionsLoaded()}> <Show when={isReactionsLoaded()}>
<CommentsTree <CommentsTree
shoutId={props.article.id} shoutId={props.article.id}
shoutSlug={props.article.slug} shoutSlug={props.article.slug}
articleAuthors={props.article.authors as Author[]} articleAuthors={props.article.authors}
/> />
</Show> </Show>
</div> </div>
@ -598,9 +639,9 @@ export const FullArticle = (props: Props) => {
</Modal> </Modal>
<ShareModal <ShareModal
title={props.article.title} title={props.article.title}
description={props.article.description || body() || media()[0]?.body} description={description}
imageUrl={props.article.cover || ''} imageUrl={props.article.cover}
shareUrl={shareUrl()} shareUrl={shareUrl}
/> />
</> </>
) )

View File

@ -0,0 +1,255 @@
import { clsx } from 'clsx'
import { Show, createEffect, createMemo, createSignal, on } from 'solid-js'
import { useLocalize } from '../../context/localize'
import { useReactions } from '../../context/reactions'
import { useSession } from '../../context/session'
import { useSnackbar } from '../../context/snackbar'
import { Reaction, ReactionKind, Shout } from '../../graphql/schema/core.gen'
import { useRouter } from '../../stores/router'
import { loadShout } from '../../stores/zine/articles'
import { byCreated } from '../../utils/sortby'
import { Icon } from '../_shared/Icon'
import { Popup } from '../_shared/Popup'
import { VotersList } from '../_shared/VotersList'
import stylesComment from './CommentRatingControl.module.scss'
import stylesShout from './ShoutRatingControl.module.scss'
interface RatingControlProps {
shout?: Shout
comment?: Reaction
ratings?: Reaction[]
class?: string
}
export const RatingControl = (props: RatingControlProps) => {
const { t, lang } = useLocalize()
const { changeSearchParams } = useRouter()
const snackbar = useSnackbar()
const { author } = useSession()
const { reactionEntities, createReaction, deleteReaction, loadReactionsBy } = useReactions()
const checkReaction = (reactionKind: ReactionKind) =>
Object.values(reactionEntities).some(
(r) =>
r.kind === reactionKind &&
r.created_by.slug === author()?.slug &&
r.shout.id === props.comment.shout.id &&
r.reply_to === props.comment.id,
)
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
const [myRate, setMyRate] = createSignal<Reaction | undefined>()
const [total, setTotal] = createSignal(props.comment?.stat?.rating || props.shout?.stat?.rating || 0)
const [ratingReactions, setRatingReactions] = createSignal<Reaction[]>([])
createEffect(() => {
const shout = props.comment.shout.id || props.shout.id
if (shout && !ratingReactions()) {
let result = Object.values(reactionEntities).filter(
(r) => [ReactionKind.Like, ReactionKind.Dislike].includes(r.kind) && r.shout.id === shout,
)
if (props.comment?.id) result = result.filter((r) => r.reply_to === props.comment.id)
setRatingReactions(result)
}
})
const deleteRating = async (reactionKind: ReactionKind) => {
const reactionToDelete = Object.values(reactionEntities).find(
(r) =>
r.kind === reactionKind &&
r.created_by.slug === author()?.slug &&
r.shout.id === props.comment.shout.id &&
r.reply_to === props.comment.id,
)
return deleteReaction(reactionToDelete.id)
}
const [isLoading, setIsLoading] = createSignal(false)
const handleRatingChange = async (isUpvote: boolean) => {
setIsLoading(true)
try {
if (isUpvoted()) {
await deleteRating(ReactionKind.Like)
} else if (isDownvoted()) {
await deleteRating(ReactionKind.Dislike)
} else {
await createReaction({
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
shout: props.comment.shout.id,
reply_to: props.comment.id,
})
}
} catch {
snackbar?.showSnackbar({ type: 'error', body: t('Error') })
}
await loadShout(props.comment.shout.slug)
await loadReactionsBy({
by: { shout: props.comment.shout.slug },
})
setIsLoading(false)
}
createEffect(
on(
() => props.comment,
(comment) => {
if (comment) {
setTotal(comment?.stat?.rating)
}
},
{ defer: true },
),
)
createEffect(
on(
() => props.shout,
(shout) => {
if (shout) {
setTotal(shout.stat?.rating)
}
},
{ defer: true },
),
)
createEffect(
on(
() => reactionEntities,
(reactions) => {
const ratings = Object.values(reactions).filter((r) => !r?.reply_to)
const likes = ratings.filter((rating) => rating.kind === 'LIKE').length
const dislikes = ratings.filter((rating) => rating.kind === 'DISLIKE').length
const total = likes - dislikes
setTotal(total)
},
{ defer: true },
),
)
createEffect(
on(
[ratingReactions, author],
([reactions, me]) => {
console.debug('[RatingControl] on reactions update')
const ratingVotes = Object.values(reactions).filter((r) => !r.reply_to)
setRatingReactions((_) => ratingVotes.sort(byCreated))
const myReaction = reactions.find((r) => r.created_by.id === me?.id)
setMyRate((_) => myReaction)
},
{ defer: true },
),
)
const getTrigger = createMemo(() => {
return (
<div
class={clsx(stylesComment.commentRatingValue, {
[stylesComment.commentRatingPositive]: total() > 0 && Boolean(props.comment?.id),
[stylesComment.commentRatingNegative]: total() < 0 && Boolean(props.comment?.id),
[stylesShout.ratingValue]: !props.comment?.id,
})}
>
{total()}
</div>
)
})
return props.comment?.id ? (
<div class={stylesComment.commentRating}>
<button
role="button"
disabled={!author()}
onClick={() => handleRatingChange(true)}
class={clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlUp, {
[stylesComment.voted]: isUpvoted(),
})}
/>
<Popup
trigger={
<div
class={clsx(stylesComment.commentRatingValue, {
[stylesComment.commentRatingPositive]: props.comment.stat.rating > 0,
[stylesComment.commentRatingNegative]: props.comment.stat.rating < 0,
})}
>
{props.comment.stat.rating || 0}
</div>
}
variant="tiny"
>
<VotersList
reactions={ratingReactions()}
fallbackMessage={t('This comment has not yet been rated')}
/>
</Popup>
<button
role="button"
disabled={!author()}
onClick={() => handleRatingChange(false)}
class={clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlDown, {
[stylesComment.voted]: isDownvoted(),
})}
/>
</div>
) : (
<div class={clsx(props.comment ? stylesComment.commentRating : stylesShout.rating, props.class)}>
<button
onClick={() => handleRatingChange(false)}
disabled={isLoading()}
class={
props.comment
? clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlUp, {
[stylesComment.voted]: myRate()?.kind === 'LIKE',
})
: ''
}
>
<Show when={!props.comment}>
<Icon
name={isDownvoted() ? 'rating-control-checked' : 'rating-control-less'}
class={isLoading() ? 'rotating' : ''}
/>
</Show>
</button>
<Popup trigger={getTrigger()} variant="tiny">
<Show
when={author()}
fallback={
<>
<span class="link" onClick={() => changeSearchParams({ mode: 'login', modal: 'auth' })}>
{t('Enter')}
</span>
{lang() === 'ru' ? ', ' : ' '}
{t('to see the voters')}
</>
}
>
<VotersList
reactions={ratingReactions()}
fallbackMessage={isLoading() ? t('Loading') : t('No one rated yet')}
/>
</Show>
</Popup>
<button
onClick={() => handleRatingChange(true)}
disabled={isLoading()}
class={
props.comment
? clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlDown, {
[stylesComment.voted]: myRate()?.kind === 'DISLIKE',
})
: ''
}
>
<Show when={!props.comment}>
<Icon
name={isUpvoted() ? 'rating-control-checked' : 'rating-control-more'}
class={isLoading() ? 'rotating' : ''}
/>
</Show>
</button>
</div>
)
}

View File

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

View File

@ -1,7 +1,10 @@
import { useSearchParams } from '@solidjs/router' import { JSX, Show, createEffect } from 'solid-js'
import { JSX, Show, createEffect, createMemo, on } from 'solid-js'
import { useSession } from '~/context/session' import { useSession } from '../../context/session'
import { useUI } from '~/context/ui' import { RootSearchParams } from '../../pages/types'
import { useRouter } from '../../stores/router'
import { hideModal } from '../../stores/ui'
import { AuthModalSearchParams } from '../Nav/AuthModal/types'
type Props = { type Props = {
children: JSX.Element children: JSX.Element
@ -9,32 +12,30 @@ type Props = {
} }
export const AuthGuard = (props: Props) => { export const AuthGuard = (props: Props) => {
const { session } = useSession() const { author, isSessionLoaded } = useSession()
const author = createMemo<number>(() => session()?.user?.app_data?.profile?.id || 0) const { changeSearchParams } = useRouter<RootSearchParams & AuthModalSearchParams>()
const [, changeSearchParams] = useSearchParams()
const { hideModal } = useUI()
createEffect( createEffect(() => {
on( if (props.disabled) {
[() => props.disabled, author], return
([disabled, a]) => { }
if (disabled || !a) return if (isSessionLoaded()) {
if (a) { if (author()?.id) {
console.debug('[AuthGuard] profile is loaded') hideModal()
hideModal() } else {
} else { changeSearchParams(
changeSearchParams( {
{ source: 'authguard',
source: 'authguard', m: 'auth',
m: 'auth' },
}, true,
{ replace: true } )
) }
} } else {
}, // await loadSession()
{ defer: true } console.warn('session is not loaded')
) }
) })
return <Show when={author() || props.disabled}>{props.children}</Show> return <Show when={(isSessionLoaded() && author()?.id) || props.disabled}>{props.children}</Show>
} }

View File

@ -1,8 +1,4 @@
.AuthorBadge { .AuthorBadge {
@include media-breakpoint-down(md) {
text-align: left;
}
align-items: flex-start; align-items: flex-start;
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@ -16,30 +12,34 @@
} }
} }
.basicInfo { @include media-breakpoint-down(md) {
@include media-breakpoint-down(sm) { text-align: left;
flex: 0 100%; }
}
.basicInfo {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
flex: 0 calc(100% - 5.2rem); flex: 0 calc(100% - 5.2rem);
gap: 1rem; gap: 1rem;
@include media-breakpoint-down(sm) {
flex: 0 100%;
}
} }
.info { .info {
@include font-size(1.4rem); @include font-size(1.4rem);
@include media-breakpoint-up(sm) {
flex: 1 100%;
}
border: none; border: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
line-height: 1.3; line-height: 1.3;
@include media-breakpoint-up(sm) {
flex: 1 100%;
}
&:hover { &:hover {
background: unset; background: unset;
} }
@ -70,6 +70,12 @@
} }
.actions { .actions {
flex: 0 20%;
display: flex;
flex-direction: row;
margin-left: 5.2rem;
gap: 1rem;
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
margin-left: 0; margin-left: 0;
} }
@ -84,12 +90,6 @@
padding-left: 1rem; padding-left: 1rem;
text-align: right; text-align: right;
} }
flex: 0 20%;
display: flex;
flex-direction: row;
margin-left: 5.2rem;
gap: 1rem;
} }
.actionButton { .actionButton {

View File

@ -1,80 +1,81 @@
import { useNavigate } from '@solidjs/router' import { openPage } from '@nanostores/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Match, Show, Switch, createEffect, createMemo, createSignal, on } from 'solid-js' import { Match, Show, Switch, createEffect, createMemo, createSignal } from 'solid-js'
import { Button } from '~/components/_shared/Button'
import { CheckButton } from '~/components/_shared/CheckButton' import { useFollowing } from '../../../context/following'
import { ConditionalWrapper } from '~/components/_shared/ConditionalWrapper' import { useLocalize } from '../../../context/localize'
import { FollowingButton } from '~/components/_shared/FollowingButton' import { useMediaQuery } from '../../../context/mediaQuery'
import { Icon } from '~/components/_shared/Icon' import { useSession } from '../../../context/session'
import { useFollowing } from '~/context/following' import { Author, FollowingEntity } from '../../../graphql/schema/core.gen'
import { useLocalize } from '~/context/localize' import { router, useRouter } from '../../../stores/router'
import { useSession } from '~/context/session' import { translit } from '../../../utils/ru2en'
import { Author, FollowingEntity } from '~/graphql/schema/core.gen' import { isCyrillic } from '../../../utils/translate'
import { isCyrillic } from '~/intl/translate' import { BadgeSubscribeButton } from '../../_shared/BadgeSubscribeButton'
import { translit } from '~/intl/translit' import { Button } from '../../_shared/Button'
import { mediaMatches } from '~/lib/mediaQuery' import { CheckButton } from '../../_shared/CheckButton'
import { ConditionalWrapper } from '../../_shared/ConditionalWrapper'
import { Icon } from '../../_shared/Icon'
import { Userpic } from '../Userpic' import { Userpic } from '../Userpic'
import styles from './AuthorBadge.module.scss' import styles from './AuthorBadge.module.scss'
type Props = { type Props = {
author: Author author: Author
minimize?: boolean minimizeSubscribeButton?: boolean
showMessageButton?: boolean showMessageButton?: boolean
iconButtons?: boolean iconButtons?: boolean
nameOnly?: boolean nameOnly?: boolean
inviteView?: boolean inviteView?: boolean
onInvite?: (id: number) => void onInvite?: (id: number) => void
selected?: boolean selected?: boolean
subscriptionsMode?: boolean
} }
export const AuthorBadge = (props: Props) => { export const AuthorBadge = (props: Props) => {
const { session, requireAuthentication } = useSession() const { mediaMatches } = useMediaQuery()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author) const { author, requireAuthentication } = useSession()
const { follow, unfollow, follows, following } = useFollowing() const { follow, unfollow, subscriptions, subscribeInAction } = useFollowing()
const [isMobileView, setIsMobileView] = createSignal(false) const [isMobileView, setIsMobileView] = createSignal(false)
const [isFollowed, setIsFollowed] = createSignal<boolean>( const [isSubscribed, setIsSubscribed] = 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 }
)
)
const navigate = useNavigate() 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 { t, formatDate, lang } = useLocalize() const { t, formatDate, lang } = useLocalize()
const initChat = () => { const initChat = () => {
// eslint-disable-next-line solid/reactivity // eslint-disable-next-line solid/reactivity
requireAuthentication(() => { requireAuthentication(() => {
props.author?.id && navigate(`/inbox/${props.author?.id}`, { replace: true }) openPage(router, 'inbox')
changeSearchParams({
initChat: props.author?.id.toString(),
})
}, 'discussions') }, 'discussions')
} }
const name = createMemo(() => { const name = createMemo(() => {
if (lang() !== 'ru' && isCyrillic(props.author.name || '')) { if (lang() !== 'ru' && isCyrillic(props.author.name)) {
if (props.author.name === 'Дискурс') { if (props.author.name === 'Дискурс') {
return 'Discours' return 'Discours'
} }
return translit(props.author.name || '') return translit(props.author.name)
} }
return props.author.name return props.author.name
}) })
const handleFollowClick = () => { const handleFollowClick = () => {
requireAuthentication(async () => { requireAuthentication(() => {
const handle = isFollowed() ? unfollow : follow isSubscribed()
await handle(FollowingEntity.Author, props.author.slug) ? unfollow(FollowingEntity.Author, props.author.slug)
}, 'follow') : follow(FollowingEntity.Author, props.author.slug)
}, 'subscribe')
} }
return ( return (
@ -83,14 +84,14 @@ export const AuthorBadge = (props: Props) => {
<Userpic <Userpic
hasLink={true} hasLink={true}
size={isMobileView() ? 'M' : 'L'} size={isMobileView() ? 'M' : 'L'}
name={name() || ''} name={name()}
userpic={props.author.pic || ''} userpic={props.author.pic}
slug={props.author.slug} slug={props.author.slug}
/> />
<ConditionalWrapper <ConditionalWrapper
condition={!props.inviteView} condition={!props.inviteView}
wrapper={(children) => ( wrapper={(children) => (
<a href={`/@${props.author.slug}`} class={styles.info}> <a href={`/author/${props.author.slug}`} class={styles.info}>
{children} {children}
</a> </a>
)} )}
@ -103,25 +104,25 @@ export const AuthorBadge = (props: Props) => {
fallback={ fallback={
<div class={styles.bio}> <div class={styles.bio}>
{t('Registered since {date}', { {t('Registered since {date}', {
date: formatDate(new Date((props.author.created_at || 0) * 1000)) date: formatDate(new Date(props.author.created_at * 1000)),
})} })}
</div> </div>
} }
> >
<Match when={props.author.bio}> <Match when={props.author.bio}>
<div class={clsx('text-truncate', styles.bio)} innerHTML={props.author.bio || ''} /> <div class={clsx('text-truncate', styles.bio)} innerHTML={props.author.bio} />
</Match> </Match>
</Switch> </Switch>
<Show when={props.author?.stat && !props.subscriptionsMode}> <Show when={props.author?.stat}>
<div class={styles.bio}> <div class={styles.bio}>
<Show when={(props.author?.stat?.shouts || 0) > 0}> <Show when={props.author?.stat.shouts > 0}>
<div>{t('some posts', { count: props.author.stat?.shouts ?? 0 })}</div> <div>{t('PublicationsWithCount', { count: props.author.stat?.shouts ?? 0 })}</div>
</Show> </Show>
<Show when={(props.author?.stat?.comments || 0) > 0}> <Show when={props.author?.stat.comments > 0}>
<div>{t('some comments', { count: props.author.stat?.comments ?? 0 })}</div> <div>{t('CommentsWithCount', { count: props.author.stat?.comments ?? 0 })}</div>
</Show> </Show>
<Show when={(props.author?.stat?.followers || 0) > 0}> <Show when={props.author?.stat.followers > 0}>
<div>{t('some followers', { count: props.author.stat?.followers ?? 0 })}</div> <div>{t('FollowersWithCount', { count: props.author.stat?.followers ?? 0 })}</div>
</Show> </Show>
</div> </div>
</Show> </Show>
@ -130,10 +131,12 @@ export const AuthorBadge = (props: Props) => {
</div> </div>
<Show when={props.author.slug !== author()?.slug && !props.nameOnly}> <Show when={props.author.slug !== author()?.slug && !props.nameOnly}>
<div class={styles.actions}> <div class={styles.actions}>
<FollowingButton <BadgeSubscribeButton
action={handleFollowClick} action={() => handleFollowClick()}
isFollowed={isFollowed()} isSubscribed={isSubscribed()}
actionMessageType={following()?.slug === props.author.slug ? following()?.type : undefined} actionMessageType={
subscribeInAction()?.slug === props.author.slug ? subscribeInAction().type : undefined
}
/> />
<Show when={props.showMessageButton}> <Show when={props.showMessageButton}>
<Button <Button
@ -149,8 +152,8 @@ export const AuthorBadge = (props: Props) => {
<Show when={props.inviteView}> <Show when={props.inviteView}>
<CheckButton <CheckButton
text={t('Invite')} text={t('Invite')}
checked={Boolean(props.selected)} checked={props.selected}
onClick={() => props.onInvite?.(props.author.id)} onClick={() => props.onInvite(props.author.id)}
/> />
</Show> </Show>
</div> </div>

View File

@ -1,16 +1,4 @@
.author { .author {
@include media-breakpoint-down(md) {
justify-content: center;
}
@include media-breakpoint-up(md) {
margin-bottom: 2.4rem;
}
@include media-breakpoint-down(lg) {
flex-wrap: wrap;
}
display: flex; display: flex;
align-items: center; align-items: center;
flex-flow: row nowrap; flex-flow: row nowrap;
@ -20,11 +8,19 @@
margin-bottom: 0; margin-bottom: 0;
} }
@include media-breakpoint-down(md) {
justify-content: center;
}
@include media-breakpoint-up(md) {
margin-bottom: 2.4rem;
}
.authorName { .authorName {
@include font-size(4rem); @include font-size(4rem);
font-weight: 700; font-weight: 700;
margin-bottom: 1.2rem; margin-bottom: 0.2em;
} }
.authorAbout { .authorAbout {
@ -36,15 +32,15 @@
} }
.authorActions { .authorActions {
@include media-breakpoint-down(md) {
justify-content: center;
}
margin: 2rem -0.8rem 0 0; margin: 2rem -0.8rem 0 0;
padding-left: 0; padding-left: 0;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 1rem; gap: 1rem;
@include media-breakpoint-down(md) {
justify-content: center;
}
} }
.authorActionsLabel { .authorActionsLabel {
@ -54,24 +50,27 @@
} }
.authorActionsLabelMobile { .authorActionsLabelMobile {
display: none;
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
display: block; display: block;
} }
display: none;
} }
.authorDetails { .authorDetails {
display: block;
margin-bottom: 0;
@include media-breakpoint-down(md) { @include media-breakpoint-down(md) {
flex: 1 100%; flex: 1 100%;
text-align: center; text-align: center;
} }
display: block;
margin-bottom: 0;
} }
.listWrapper & { .listWrapper & {
align-items: flex-start;
margin-bottom: 2rem;
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
margin-bottom: 3rem; margin-bottom: 3rem;
} }
@ -80,9 +79,6 @@
margin-bottom: 3rem; margin-bottom: 3rem;
} }
align-items: flex-start;
margin-bottom: 2rem;
.circlewrap { .circlewrap {
margin-top: 1rem; margin-top: 1rem;
} }
@ -92,6 +88,10 @@
} }
} }
@include media-breakpoint-down(lg) {
flex-wrap: wrap;
}
.buttonWriteMessage { .buttonWriteMessage {
border-radius: 0.8rem; border-radius: 0.8rem;
padding-bottom: 0.6rem; padding-bottom: 0.6rem;
@ -100,6 +100,8 @@
} }
.authorDetails { .authorDetails {
flex: 0 0 auto;
@include media-breakpoint-up(sm) { @include media-breakpoint-up(sm) {
align-items: center; align-items: center;
display: flex; display: flex;
@ -116,11 +118,12 @@
flex-wrap: nowrap; flex-wrap: nowrap;
} }
} }
flex: 0 0 auto;
} }
.authorDetailsWrapper { .authorDetailsWrapper {
flex: 1 0;
position: relative;
@include media-breakpoint-up(sm) { @include media-breakpoint-up(sm) {
flex: 1; flex: 1;
} }
@ -136,9 +139,6 @@
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {
padding-right: 1.2rem; padding-right: 1.2rem;
} }
flex: 1 0;
position: relative;
} }
.authorName { .authorName {
@ -160,15 +160,6 @@
} }
.authorSubscribeSocial { .authorSubscribeSocial {
@include media-breakpoint-down(sm) {
flex: 1 100%;
justify-content: center;
}
@include media-breakpoint-down(md) {
justify-content: center;
}
align-items: center; align-items: center;
display: flex; display: flex;
margin: 0.5rem 0 2rem -0.4rem; margin: 0.5rem 0 2rem -0.4rem;
@ -184,7 +175,7 @@
width: 24px; width: 24px;
&::before { &::before {
background-image: url('/icons/user-link-default.svg'); background-image: url(/icons/user-link-default.svg);
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: 50% 50%; background-position: 50% 50%;
background-size: contain; background-size: contain;
@ -218,7 +209,7 @@
&[href*='facebook.com/'] { &[href*='facebook.com/'] {
&::before { &::before {
background-image: url('/icons/user-link-facebook.svg'); background-image: url(/icons/user-link-facebook.svg);
} }
&:hover { &:hover {
@ -230,7 +221,7 @@
&[href*='twitter.com/'] { &[href*='twitter.com/'] {
&::before { &::before {
background-image: url('/icons/user-link-twitter.svg'); background-image: url(/icons/user-link-twitter.svg);
} }
&:hover { &:hover {
@ -243,7 +234,7 @@
&[href*='telegram.com/'], &[href*='telegram.com/'],
&[href*='t.me/'] { &[href*='t.me/'] {
&::before { &::before {
background-image: url('/icons/user-link-telegram.svg'); background-image: url(/icons/user-link-telegram.svg);
} }
&:hover { &:hover {
@ -256,7 +247,7 @@
&[href*='vk.cc/'], &[href*='vk.cc/'],
&[href*='vk.com/'] { &[href*='vk.com/'] {
&::before { &::before {
background-image: url('/icons/user-link-vk.svg'); background-image: url(/icons/user-link-vk.svg);
} }
&:hover { &:hover {
@ -268,7 +259,7 @@
&[href*='tumblr.com/'] { &[href*='tumblr.com/'] {
&::before { &::before {
background-image: url('/icons/user-link-tumblr.svg'); background-image: url(/icons/user-link-tumblr.svg);
} }
&:hover { &:hover {
@ -280,7 +271,7 @@
&[href*='instagram.com/'] { &[href*='instagram.com/'] {
&::before { &::before {
background-image: url('/icons/user-link-instagram.svg'); background-image: url(/icons/user-link-instagram.svg);
} }
&:hover { &:hover {
@ -292,7 +283,7 @@
&[href*='behance.net/'] { &[href*='behance.net/'] {
&::before { &::before {
background-image: url('/icons/user-link-behance.svg'); background-image: url(/icons/user-link-behance.svg);
} }
&:hover { &:hover {
@ -304,7 +295,7 @@
&[href*='dribbble.com/'] { &[href*='dribbble.com/'] {
&::before { &::before {
background-image: url('/icons/user-link-dribbble.svg'); background-image: url(/icons/user-link-dribbble.svg);
} }
&:hover { &:hover {
@ -316,7 +307,7 @@
&[href*='github.com/'] { &[href*='github.com/'] {
&::before { &::before {
background-image: url('/icons/user-link-github.svg'); background-image: url(/icons/user-link-github.svg);
} }
&:hover { &:hover {
@ -328,7 +319,7 @@
&[href*='linkedin.com/'] { &[href*='linkedin.com/'] {
&::before { &::before {
background-image: url('/icons/user-link-linkedin.svg'); background-image: url(/icons/user-link-linkedin.svg);
} }
&:hover { &:hover {
@ -340,7 +331,7 @@
&[href*='medium.com/'] { &[href*='medium.com/'] {
&::before { &::before {
background-image: url('/icons/user-link-medium.svg'); background-image: url(/icons/user-link-medium.svg);
} }
&:hover { &:hover {
@ -352,7 +343,7 @@
&[href*='ok.ru/'] { &[href*='ok.ru/'] {
&::before { &::before {
background-image: url('/icons/user-link-ok.svg'); background-image: url(/icons/user-link-ok.svg);
} }
&:hover { &:hover {
@ -364,7 +355,7 @@
&[href*='pinterest.com/'] { &[href*='pinterest.com/'] {
&::before { &::before {
background-image: url('/icons/user-link-pinterest.svg'); background-image: url(/icons/user-link-pinterest.svg);
} }
&:hover { &:hover {
@ -376,7 +367,7 @@
&[href*='reddit.com/'] { &[href*='reddit.com/'] {
&::before { &::before {
background-image: url('/icons/user-link-reddit.svg'); background-image: url(/icons/user-link-reddit.svg);
} }
&:hover { &:hover {
@ -388,7 +379,7 @@
&[href*='tiktok.com/'] { &[href*='tiktok.com/'] {
&::before { &::before {
background-image: url('/icons/user-link-tiktok.svg'); background-image: url(/icons/user-link-tiktok.svg);
} }
&:hover { &:hover {
@ -401,7 +392,7 @@
&[href*='youtube.com/'], &[href*='youtube.com/'],
&[href*='youtu.be/'] { &[href*='youtu.be/'] {
&::before { &::before {
background-image: url('/icons/user-link-youtube.svg'); background-image: url(/icons/user-link-youtube.svg);
} }
&:hover { &:hover {
@ -413,7 +404,7 @@
&[href*='dzen.ru/'] { &[href*='dzen.ru/'] {
&::before { &::before {
background-image: url('/icons/user-link-dzen.svg'); background-image: url(/icons/user-link-dzen.svg);
} }
&:hover { &:hover {
@ -424,24 +415,78 @@
} }
} }
@include media-breakpoint-down(sm) {
flex: 1 100%;
justify-content: center;
}
@include media-breakpoint-down(md) {
justify-content: center;
}
a:link { a:link {
border: none; border: none;
} }
} }
.subscribersContainer {
display: flex;
flex-wrap: wrap;
font-size: 1.4rem;
margin-top: 1.5rem;
@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 { .listWrapper {
max-height: 70vh; max-height: 70vh;
} }
.subscribersContainer {
@include media-breakpoint-down(md) {
justify-content: center;
}
display: flex;
flex-wrap: wrap;
font-size: 1.4rem;
gap: 1rem;
margin-top: 0;
white-space: nowrap;
}

View File

@ -1,88 +1,103 @@
import { redirect, useNavigate } from '@solidjs/router' import type { Author, Community } from '../../../graphql/schema/core.gen'
import { openPage, redirectPage } from '@nanostores/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { For, Show, createEffect, createMemo, createSignal, on } from 'solid-js' import { For, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js'
import { Button } from '~/components/_shared/Button'
import stylesButton from '~/components/_shared/Button/Button.module.scss' import { useFollowing } from '../../../context/following'
import { FollowingCounters } from '~/components/_shared/FollowingCounters/FollowingCounters' import { useLocalize } from '../../../context/localize'
import { ShowOnlyOnClient } from '~/components/_shared/ShowOnlyOnClient' import { useSession } from '../../../context/session'
import { FollowsFilter, useFollowing } from '~/context/following' import { FollowingEntity, Topic } from '../../../graphql/schema/core.gen'
import { useLocalize } from '~/context/localize' import { SubscriptionFilter } from '../../../pages/types'
import { useSession } from '~/context/session' import { router, useRouter } from '../../../stores/router'
import type { Author, Community } from '~/graphql/schema/core.gen' import { isAuthor } from '../../../utils/isAuthor'
import { FollowingEntity, Topic } from '~/graphql/schema/core.gen' import { translit } from '../../../utils/ru2en'
import { isCyrillic } from '~/intl/translate' import { isCyrillic } from '../../../utils/translate'
import { translit } from '~/intl/translit'
import { SharePopup, getShareUrl } from '../../Article/SharePopup' import { SharePopup, getShareUrl } from '../../Article/SharePopup'
import { Modal } from '../../Nav/Modal'
import { TopicBadge } from '../../Topic/TopicBadge' import { TopicBadge } from '../../Topic/TopicBadge'
import { Modal } from '../../_shared/Modal' import { Button } from '../../_shared/Button'
import { ShowOnlyOnClient } from '../../_shared/ShowOnlyOnClient'
import { AuthorBadge } from '../AuthorBadge' import { AuthorBadge } from '../AuthorBadge'
import { Userpic } from '../Userpic' import { Userpic } from '../Userpic'
import stylesButton from '../../_shared/Button/Button.module.scss'
import styles from './AuthorCard.module.scss' import styles from './AuthorCard.module.scss'
type Props = { type Props = {
author: Author author: Author
followers?: Author[] followers?: Author[]
flatFollows?: Array<Author | Topic> following?: Array<Author | Topic>
} }
export const AuthorCard = (props: Props) => { export const AuthorCard = (props: Props) => {
const { t, lang } = useLocalize() const { t, lang } = useLocalize()
const navigate = useNavigate() const { author, isSessionLoaded, requireAuthentication } = useSession()
const { session, isSessionLoaded, requireAuthentication } = useSession()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
const [authorSubs, setAuthorSubs] = createSignal<Array<Author | Topic | Community>>([]) const [authorSubs, setAuthorSubs] = createSignal<Array<Author | Topic | Community>>([])
const [followsFilter, setFollowsFilter] = createSignal<FollowsFilter>('all') const [subscriptionFilter, setSubscriptionFilter] = createSignal<SubscriptionFilter>('all')
const [isFollowed, setIsFollowed] = createSignal<boolean>() const [isSubscribed, setIsSubscribed] = createSignal<boolean>()
const isProfileOwner = createMemo(() => author()?.slug === props.author.slug) const isProfileOwner = createMemo(() => author()?.slug === props.author.slug)
const { follow, unfollow, follows, following } = useFollowing() // viewer's followings const { follow, unfollow, subscriptions, subscribeInAction } = useFollowing()
onMount(() => {
setAuthorSubs(props.following)
})
createEffect(() => { createEffect(() => {
if (!(follows && props.author)) return if (!(subscriptions && props.author)) return
const followed = follows?.authors?.some((authorEntity) => authorEntity.id === props.author?.id) const subscribed = subscriptions.authors?.some((authorEntity) => authorEntity.id === props.author?.id)
setIsFollowed(followed) setIsSubscribed(subscribed)
}) })
const name = createMemo(() => { const name = createMemo(() => {
if (lang() !== 'ru' && isCyrillic(props.author?.name || '')) { if (lang() !== 'ru' && isCyrillic(props.author.name)) {
if (props.author.name === 'Дискурс') { if (props.author.name === 'Дискурс') {
return 'Discours' return 'Discours'
} }
return translit(props.author?.name || '') return translit(props.author.name)
} }
return props.author.name return props.author.name
}) })
// TODO: reimplement AuthorCard
const { changeSearchParams } = useRouter()
const initChat = () => { const initChat = () => {
// eslint-disable-next-line solid/reactivity // eslint-disable-next-line solid/reactivity
requireAuthentication(() => { requireAuthentication(() => {
props.author?.id && navigate(`/inbox/${props.author?.id}`, { replace: true }) openPage(router, 'inbox')
changeSearchParams({
initChat: props.author?.id.toString(),
})
}, 'discussions') }, 'discussions')
} }
createEffect( createEffect(() => {
on(followsFilter, (f = 'all') => { if (props.following) {
const subs = if (subscriptionFilter() === 'authors') {
f !== 'all' setAuthorSubs(props.following.filter((s) => 'name' in s))
? follows[f as keyof typeof follows] } else if (subscriptionFilter() === 'topics') {
: [...(follows.topics || []), ...(follows.authors || [])] setAuthorSubs(props.following.filter((s) => 'title' in s))
setAuthorSubs(subs || []) } else if (subscriptionFilter() === 'communities') {
}) setAuthorSubs(props.following.filter((s) => 'title' in s))
) } else {
setAuthorSubs(props.following)
}
}
})
const handleFollowClick = () => { const handleFollowClick = () => {
requireAuthentication(() => { requireAuthentication(() => {
isFollowed() isSubscribed()
? unfollow(FollowingEntity.Author, props.author.slug) ? unfollow(FollowingEntity.Author, props.author.slug)
: follow(FollowingEntity.Author, props.author.slug) : follow(FollowingEntity.Author, props.author.slug)
}, 'follow') }, 'subscribe')
} }
const followButtonText = createMemo(() => { const followButtonText = createMemo(() => {
if (following()?.slug === props.author.slug) { if (subscribeInAction()?.slug === props.author.slug) {
return following()?.type === 'follow' ? t('Following...') : t('Unfollowing...') return subscribeInAction().type === 'subscribe' ? t('Subscribing...') : t('Unsubscribing...')
} }
if (isFollowed()) { if (isSubscribed()) {
return ( return (
<> <>
<span class={stylesButton.buttonSubscribeLabel}>{t('Following')}</span> <span class={stylesButton.buttonSubscribeLabel}>{t('Following')}</span>
@ -93,82 +108,13 @@ export const AuthorCard = (props: Props) => {
return t('Follow') 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) =>
'name' in subscription ? (
<AuthorBadge author={subscription as Author} subscriptionsMode={true} />
) : (
<TopicBadge topic={subscription as Topic} subscriptionsMode={true} />
)
}
</For>
</div>
</div>
</div>
</>
)
return ( return (
<div class={clsx(styles.author, 'row')}> <div class={clsx(styles.author, 'row')}>
<div class="col-md-5"> <div class="col-md-5">
<Userpic <Userpic
size={'XL'} size={'XL'}
name={props.author.name || ''} name={props.author.name}
userpic={props.author.pic || ''} userpic={props.author.pic}
slug={props.author.slug} slug={props.author.slug}
class={styles.circlewrap} class={styles.circlewrap}
/> />
@ -177,16 +123,61 @@ export const AuthorCard = (props: Props) => {
<div class={styles.authorDetailsWrapper}> <div class={styles.authorDetailsWrapper}>
<div class={styles.authorName}>{name()}</div> <div class={styles.authorName}>{name()}</div>
<Show when={props.author.bio}> <Show when={props.author.bio}>
<div class={styles.authorAbout} innerHTML={props.author.bio || ''} /> <div class={styles.authorAbout} innerHTML={props.author.bio} />
</Show> </Show>
<Show when={(props.followers || [])?.length > 0 || (props.flatFollows || []).length > 0}> <Show when={props.followers?.length > 0 || props.following?.length > 0}>
<div class={styles.subscribersContainer}> <div class={styles.subscribersContainer}>
<FollowingCounters <Show when={props.followers && props.followers.length > 0}>
followers={props.followers} <a href="?m=followers" class={styles.subscribers}>
followersAmount={props.author?.stat?.followers || 0} <For each={props.followers.slice(0, 3)}>
following={props.flatFollows} {(f) => (
followingAmount={props.flatFollows?.length || 0} <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>
</div> </div>
</Show> </Show>
</div> </div>
@ -195,15 +186,15 @@ export const AuthorCard = (props: Props) => {
<Show when={props.author.links && props.author.links.length > 0}> <Show when={props.author.links && props.author.links.length > 0}>
<div class={styles.authorSubscribeSocial}> <div class={styles.authorSubscribeSocial}>
<For each={props.author.links}> <For each={props.author.links}>
{(link: string | null) => ( {(link) => (
<a <a
class={styles.socialLink} class={styles.socialLink}
href={link?.startsWith('http') ? link : `https://${link}`} href={link.startsWith('http') ? link : `https://${link}`}
target="_blank" target="_blank"
rel="nofollow noopener noreferrer" rel="nofollow noopener noreferrer"
> >
<span class={styles.authorSubscribeSocialLabel}> <span class={styles.authorSubscribeSocialLabel}>
{link?.startsWith('http') ? link : `https://${link}`} {link.startsWith('http') ? link : `https://${link}`}
</span> </span>
</a> </a>
)} )}
@ -217,11 +208,11 @@ export const AuthorCard = (props: Props) => {
<Show when={authorSubs()?.length}> <Show when={authorSubs()?.length}>
<Button <Button
onClick={handleFollowClick} onClick={handleFollowClick}
disabled={Boolean(following())} disabled={Boolean(subscribeInAction())}
value={followButtonText()} value={followButtonText()}
isSubscribeButton={true} isSubscribeButton={true}
class={clsx({ class={clsx({
[stylesButton.followed]: isFollowed() [stylesButton.subscribed]: isSubscribed(),
})} })}
/> />
</Show> </Show>
@ -237,7 +228,7 @@ export const AuthorCard = (props: Props) => {
<div class={styles.authorActions}> <div class={styles.authorActions}>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => redirect('/settings')} onClick={() => redirectPage(router, 'profileSettings')}
value={ value={
<> <>
<span class={styles.authorActionsLabel}>{t('Edit profile')}</span> <span class={styles.authorActionsLabel}>{t('Edit profile')}</span>
@ -246,11 +237,11 @@ export const AuthorCard = (props: Props) => {
} }
/> />
<SharePopup <SharePopup
title={props.author.name || ''} title={props.author.name}
description={props.author.bio || ''} description={props.author.bio}
imageUrl={props.author.pic || ''} imageUrl={props.author.pic}
shareUrl={getShareUrl({ shareUrl={getShareUrl({
pathname: `/@${props.author.slug}` pathname: `/author/${props.author.slug}`,
})} })}
trigger={<Button variant="secondary" value={t('Share')} />} trigger={<Button variant="secondary" value={t('Share')} />}
/> />
@ -260,12 +251,77 @@ export const AuthorCard = (props: Props) => {
</ShowOnlyOnClient> </ShowOnlyOnClient>
<Show when={props.followers}> <Show when={props.followers}>
<Modal variant="medium" isResponsive={true} name="followers" maxHeight> <Modal variant="medium" isResponsive={true} name="followers" maxHeight>
<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>
</>
</Modal> </Modal>
</Show> </Show>
<Show when={props.flatFollows}> <Show when={props.following}>
<Modal variant="medium" isResponsive={true} name="following" maxHeight> <Modal variant="medium" isResponsive={true} name="following" maxHeight>
<FollowingModalView /> <>
<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>
</>
</Modal> </Modal>
</Show> </Show>
</div> </div>

View File

@ -1,14 +1,14 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createMemo } from 'solid-js' import { createMemo } from 'solid-js'
import { useLocalize } from '~/context/localize' import { useLocalize } from '../../../context/localize'
import { Author } from '~/graphql/schema/core.gen' import { Author } from '../../../graphql/schema/core.gen'
import { isCyrillic } from '~/intl/translate' import { capitalize } from '../../../utils/capitalize'
import { translit } from '~/intl/translit' import { translit } from '../../../utils/ru2en'
import { capitalize } from '~/utils/capitalize' import { isCyrillic } from '../../../utils/translate'
import { Userpic } from '../Userpic' import { Userpic } from '../Userpic'
import styles from './AuthorLink.module.scss' import styles from './AhtorLink.module.scss'
type Props = { type Props = {
author: Author author: Author
@ -20,18 +20,18 @@ type Props = {
export const AuthorLink = (props: Props) => { export const AuthorLink = (props: Props) => {
const { lang } = useLocalize() const { lang } = useLocalize()
const name = createMemo(() => { const name = createMemo(() => {
return lang() === 'en' && isCyrillic(props.author.name || '') return lang() === 'en' && isCyrillic(props.author.name)
? translit(capitalize(props.author.name || '')) ? translit(capitalize(props.author.name))
: props.author.name : props.author.name
}) })
return ( return (
<div <div
class={clsx(styles.AuthorLink, props.class, styles[(props.size ?? 'M') as keyof Props['size']], { class={clsx(styles.AuthorLink, props.class, styles[props.size ?? 'M'], {
[styles.authorLinkFloorImportant]: props.isFloorImportant [styles.authorLinkFloorImportant]: props.isFloorImportant,
})} })}
> >
<a class={styles.link} href={`/@${props.author.slug}`}> <a class={styles.link} href={`/author/${props.author.slug}`}>
<Userpic size={props.size ?? 'M'} name={name() || ''} userpic={props.author.pic || ''} /> <Userpic size={props.size ?? 'M'} name={name()} userpic={props.author.pic} />
<div class={styles.name}>{name()}</div> <div class={styles.name}>{name()}</div>
</a> </a>
</div> </div>

View File

@ -1,9 +1,10 @@
import type { Author } from '~/graphql/schema/core.gen' import type { Author } from '../../graphql/schema/core.gen'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Show, createSignal } from 'solid-js' import { Show, createSignal } from 'solid-js'
import { useSession } from '~/context/session'
import rateAuthorMutation from '~/graphql/mutation/core/author-rate' import { apiClient } from '../../graphql/client/core'
import styles from './AuthorRatingControl.module.scss' import styles from './AuthorRatingControl.module.scss'
interface AuthorRatingControlProps { interface AuthorRatingControlProps {
@ -14,29 +15,22 @@ interface AuthorRatingControlProps {
export const AuthorRatingControl = (props: AuthorRatingControlProps) => { export const AuthorRatingControl = (props: AuthorRatingControlProps) => {
const isUpvoted = false const isUpvoted = false
const isDownvoted = false const isDownvoted = false
const { client } = useSession()
// eslint-disable-next-line unicorn/consistent-function-scoping // eslint-disable-next-line unicorn/consistent-function-scoping
const handleRatingChange = async (isUpvote: boolean) => { const handleRatingChange = async (isUpvote: boolean) => {
console.log('handleRatingChange', { isUpvote }) console.log('handleRatingChange', { isUpvote })
if (props.author?.slug) { if (props.author?.slug) {
const value = isUpvote ? 1 : -1 const value = isUpvote ? 1 : -1
const _resp = await client() await apiClient.rateAuthor({ rated_slug: props.author?.slug, value })
?.mutation(rateAuthorMutation, { setRating((r) => r + value)
rated_slug: props.author?.slug,
value
})
.toPromise()
setRating((r) => (r || 0) + value)
} }
} }
const [rating, setRating] = createSignal(props.author?.stat?.rating) const [rating, setRating] = createSignal(props.author?.stat?.rating)
return ( return (
<div <div
class={clsx(styles.rating, props.class, { class={clsx(styles.rating, props.class, {
[styles.isUpvoted]: isUpvoted, [styles.isUpvoted]: isUpvoted,
[styles.isDownvoted]: isDownvoted [styles.isDownvoted]: isDownvoted,
})} })}
> >
<button <button

View File

@ -1,4 +1,4 @@
import type { Author } from '~/graphql/schema/core.gen' import type { Author } from '../../graphql/schema/core.gen'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createMemo } from 'solid-js' import { createMemo } from 'solid-js'
@ -11,12 +11,12 @@ interface AuthorShoutsRating {
} }
export const AuthorShoutsRating = (props: AuthorShoutsRating) => { export const AuthorShoutsRating = (props: AuthorShoutsRating) => {
const isUpvoted = createMemo(() => (props.author?.stat?.rating_shouts || 0) > 0) const isUpvoted = createMemo(() => props.author?.stat?.rating_shouts > 0)
return ( return (
<div <div
class={clsx(styles.rating, props.class, { class={clsx(styles.rating, props.class, {
[styles.isUpvoted]: isUpvoted(), [styles.isUpvoted]: isUpvoted(),
[styles.isDownvoted]: !isUpvoted() [styles.isDownvoted]: !isUpvoted(),
})} })}
> >
<span class={styles.ratingValue}>{props.author?.stat?.rating_shouts}</span> <span class={styles.ratingValue}>{props.author?.stat?.rating_shouts}</span>

View File

@ -86,17 +86,17 @@
} }
&.XL { &.XL {
@include media-breakpoint-up(md) {
margin: 0;
max-width: 100%;
}
aspect-ratio: 1/1; aspect-ratio: 1/1;
margin: 0 auto 1rem; margin: 0 auto 1rem;
max-width: 168px; max-width: 168px;
height: auto; height: auto;
width: 100%; width: 100%;
@include media-breakpoint-up(md) {
margin: 0;
max-width: 100%;
}
.letters { .letters {
align-items: center; align-items: center;
display: flex; display: flex;

View File

@ -1,9 +1,9 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Show, createMemo } from 'solid-js' import { Show, createMemo } from 'solid-js'
import { ConditionalWrapper } from '~/components/_shared/ConditionalWrapper' import { ConditionalWrapper } from '../../_shared/ConditionalWrapper'
import { Image } from '~/components/_shared/Image' import { Image } from '../../_shared/Image'
import { Loading } from '~/components/_shared/Loading' import { Loading } from '../../_shared/Loading'
import styles from './Userpic.module.scss' import styles from './Userpic.module.scss'
@ -22,7 +22,7 @@ export const Userpic = (props: Props) => {
const letters = () => { const letters = () => {
if (!props.name) return if (!props.name) return
const names = props.name ? props.name.split(' ') : [] const names = props.name ? props.name.split(' ') : []
return `${names[0][0] ? names[0][0] : ''}.${names.length > 1 ? `${names[1][0]}.` : ''}` return `${names[0][0 ?? names[0][0]]}.${names.length > 1 ? `${names[1][0]}.` : ''}`
} }
const avatarSize = createMemo(() => { const avatarSize = createMemo(() => {
@ -48,14 +48,14 @@ export const Userpic = (props: Props) => {
return ( return (
<div <div
class={clsx(styles.Userpic, props.class, styles[props.size ?? 'M'], { class={clsx(styles.Userpic, props.class, styles[props.size ?? 'M'], {
cursorPointer: props.onClick cursorPointer: props.onClick,
})} })}
onClick={props.onClick} onClick={props.onClick}
> >
<Show when={!props.loading} fallback={<Loading />}> <Show when={!props.loading} fallback={<Loading />}>
<ConditionalWrapper <ConditionalWrapper
condition={Boolean(props.hasLink)} condition={props.hasLink}
wrapper={(children) => <a href={`/@${props.slug}`}>{children}</a>} wrapper={(children) => <a href={`/author/${props.slug}`}>{children}</a>}
> >
<Show keyed={true} when={props.userpic} fallback={<div class={styles.letters}>{letters()}</div>}> <Show keyed={true} when={props.userpic} fallback={<div class={styles.letters}>{letters()}</div>}>
<Image src={props.userpic} width={avatarSize()} height={avatarSize()} alt={props.name} /> <Image src={props.userpic} width={avatarSize()} height={avatarSize()} alt={props.name} />

View File

@ -0,0 +1,26 @@
.AuthorsList {
.action {
display: flex;
align-items: center;
justify-content: center;
min-height: 8rem;
}
.loading {
@include font-size(1.4rem);
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
width: 100%;
flex-direction: row;
opacity: 0.5;
.icon {
position: relative;
width: 18px;
height: 18px;
}
}
}

View File

@ -0,0 +1,102 @@
import { clsx } from 'clsx'
import { For, Show, createEffect, createSignal, on } from 'solid-js'
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'
import styles from './AuthorsList.module.scss'
type Props = {
class?: string
query: 'followers' | 'shouts'
searchQuery?: string
allAuthorsLength?: number
}
const PAGE_SIZE = 20
export const AuthorsList = (props: Props) => {
const { t } = useLocalize()
const { authorsByShouts, authorsByFollowers } = useAuthorsStore()
const [loading, setLoading] = createSignal(false)
const [currentPage, setCurrentPage] = createSignal({ shouts: 0, followers: 0 })
const [allLoaded, setAllLoaded] = createSignal(false)
const fetchAuthors = async (queryType: Props['query'], page: number) => {
setLoading(true)
const offset = PAGE_SIZE * page
const result = await apiClient.loadAuthorsBy({
by: { order: queryType },
limit: PAGE_SIZE,
offset,
})
if (queryType === 'shouts') {
setAuthorsByShouts((prev) => [...prev, ...result])
} else {
setAuthorsByFollowers((prev) => [...prev, ...result])
}
setLoading(false)
}
const loadMoreAuthors = () => {
const nextPage = currentPage()[props.query] + 1
fetchAuthors(props.query, nextPage).then(() =>
setCurrentPage({ ...currentPage(), [props.query]: nextPage }),
)
}
createEffect(
on(
() => props.query,
(query) => {
const authorsList = query === 'shouts' ? authorsByShouts() : authorsByFollowers()
if (authorsList.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())
// TODO: do it with backend
// createEffect(() => {
// if (props.searchQuery) {
// // search logic
// }
// })
createEffect(() => {
setAllLoaded(props.allAuthorsLength === authorsList.length)
})
return (
<div class={clsx(styles.AuthorsList, props.class)}>
<For each={authorsList()}>
{(author) => (
<div class="row">
<div class="col-lg-20 col-xl-18">
<AuthorBadge author={author} />
</div>
</div>
)}
</For>
<div class="row">
<div class="col-lg-20 col-xl-18">
<div class={styles.action}>
<Show when={!loading() && authorsList().length > 0 && !allLoaded()}>
<Button value={t('Load more')} onClick={loadMoreAuthors} />
</Show>
<Show when={loading() && !allLoaded()}>
<InlineLoader />
</Show>
</div>
</div>
</div>
</div>
)
}

View File

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

View File

@ -1,12 +1,12 @@
.discoursBanner { .discoursBanner {
@include media-breakpoint-down(sm) {
font-size: 80%;
}
background: #f8f8f8; background: #f8f8f8;
margin-bottom: 6.4rem; margin-bottom: 6.4rem;
padding: 0.8rem 0 0; padding: 0.8rem 0 0;
@include media-breakpoint-down(sm) {
font-size: 80%;
}
h3 { h3 {
font-size: 3.2rem; font-size: 3.2rem;
font-weight: 800; font-weight: 800;

View File

@ -1,23 +1,22 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { useLocalize } from '~/context/localize' import { useLocalize } from '../../context/localize'
import { useUI } from '~/context/ui' import { showModal } from '../../stores/ui'
import { Image } from '../_shared/Image' import { Image } from '../_shared/Image'
import styles from './Banner.module.scss' import styles from './Banner.module.scss'
export default () => { export default () => {
const { t } = useLocalize() const { t } = useLocalize()
const { showModal } = useUI()
return ( return (
<div class={styles.discoursBanner}> <div class={styles.discoursBanner}>
<div class="wide-container"> <div class="wide-container">
<div class="row"> <div class="row">
<div class={clsx(styles.discoursBannerContent, 'col-lg-10')}> <div class={clsx(styles.discoursBannerContent, 'col-lg-10')}>
<h3>{t('Discours exists because of our common effort')}</h3> <h3>{t('Discours is created with our common effort')}</h3>
<p> <p>
<a href="/support">{t('Support us')}</a> <a href="/about/help">{t('Support us')}</a>
<a href="/edit/new">{t('Become an author')}</a> <a href="/create">{t('Become an author')}</a>
<a href={''} onClick={() => showModal('auth')}> <a href={''} onClick={() => showModal('auth')}>
{t('Join the community')} {t('Join the community')}
</a> </a>

View File

@ -24,12 +24,12 @@
} }
&:focus { &:focus {
box-shadow: inset 0 0 0 3px #000;
&::placeholder { &::placeholder {
opacity: 0; opacity: 0;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
} }
box-shadow: inset 0 0 0 3px #000;
} }
&:valid, &:valid,
@ -49,18 +49,18 @@
} }
.donateForm .btn { .donateForm .btn {
@include media-breakpoint-down(sm) {
&:last-of-type {
margin-right: 0 !important;
}
}
cursor: pointer; cursor: pointer;
flex: 1; flex: 1;
padding: 5px 10px; padding: 5px 10px;
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
transform: none !important; transform: none !important;
@include media-breakpoint-down(sm) {
&:last-of-type {
margin-right: 0 !important;
}
}
} }
.btnGroup { .btnGroup {
@ -82,22 +82,22 @@
} }
.donateButtonsContainer { .donateButtonsContainer {
@include media-breakpoint-down(sm) {
flex-wrap: wrap;
}
align-items: center; align-items: center;
display: flex; display: flex;
flex: 1; flex: 1;
justify-content: space-between; justify-content: space-between;
@include media-breakpoint-down(sm) {
flex-wrap: wrap;
}
input, input,
label { label {
margin: 0 8px;
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
margin-bottom: 1em; margin-bottom: 1em;
} }
margin: 0 8px;
} }
input { input {

View File

@ -1,8 +1,9 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createSignal, onMount } from 'solid-js' import { createSignal, onMount } from 'solid-js'
import { useLocalize } from '~/context/localize' import { useLocalize } from '../../context/localize'
import { useSnackbar, useUI } from '~/context/ui' import { useSnackbar } from '../../context/snackbar'
import { showModal } from '../../stores/ui'
import styles from './Donate.module.scss' import styles from './Donate.module.scss'
@ -11,13 +12,12 @@ type DWindow = Window & { cp: any }
export const Donate = () => { export const Donate = () => {
const { t } = useLocalize() const { t } = useLocalize()
const { showModal } = useUI()
const once = '' const once = ''
const monthly = 'Monthly' const monthly = 'Monthly'
const cpOptions = { const cpOptions = {
publicId: 'pk_0a37bab30ffc6b77b2f93d65f2aed', publicId: 'pk_0a37bab30ffc6b77b2f93d65f2aed',
description: t('Help discours to grow'), description: t('Help discours to grow'),
currency: 'RUB' currency: 'RUB',
} }
let amountSwitchElement: HTMLDivElement | undefined let amountSwitchElement: HTMLDivElement | undefined
@ -45,8 +45,8 @@ export const Donate = () => {
amount: amount() || 0, //сумма amount: amount() || 0, //сумма
vat: 20, //ставка НДС vat: 20, //ставка НДС
method: 0, // тег-1214 признак способа расчета - признак способа расчета method: 0, // тег-1214 признак способа расчета - признак способа расчета
object: 0 // тег-1212 признак предмета расчета - признак предмета товара, работы, услуги, платежа, выплаты, иного предмета расчета object: 0, // тег-1212 признак предмета расчета - признак предмета товара, работы, услуги, платежа, выплаты, иного предмета расчета
} },
], ],
// taxationSystem: 0, //система налогообложения; необязательный, если у вас одна система налогообложения // taxationSystem: 0, //система налогообложения; необязательный, если у вас одна система налогообложения
// email: 'user@example.com', //e-mail покупателя, если нужно отправить письмо с чеком // email: 'user@example.com', //e-mail покупателя, если нужно отправить письмо с чеком
@ -56,8 +56,8 @@ export const Donate = () => {
electronic: amount(), // Сумма оплаты электронными деньгами electronic: amount(), // Сумма оплаты электронными деньгами
advancePayment: 0, // Сумма из предоплаты (зачетом аванса) (2 знака после запятой) advancePayment: 0, // Сумма из предоплаты (зачетом аванса) (2 знака после запятой)
credit: 0, // Сумма постоплатой(в кредит) (2 знака после запятой) credit: 0, // Сумма постоплатой(в кредит) (2 знака после запятой)
provision: 0 // Сумма оплаты встречным предоставлением (сертификаты, др. мат.ценности) (2 знака после запятой) provision: 0, // Сумма оплаты встречным предоставлением (сертификаты, др. мат.ценности) (2 знака после запятой)
} },
}) })
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@ -98,29 +98,27 @@ export const Donate = () => {
recurrent: { recurrent: {
interval: period(), // local solid's signal interval: period(), // local solid's signal
period: 1, // internal widget's period: 1, // internal widget's
CustomerReciept: customerReciept() // чек для регулярных платежей CustomerReciept: customerReciept(), // чек для регулярных платежей
} },
} },
} },
}, },
// biome-ignore lint/suspicious/noExplicitAny: <explanation> (opts) => {
(opts: any) => {
// success // success
// действие при успешной оплате // действие при успешной оплате
console.debug('[donate] options', opts) console.debug('[donate] options', opts)
showModal('thank') showModal('thank')
}, },
// biome-ignore lint/suspicious/noExplicitAny: <explanation> (reason: string, options) => {
(reason: string, options: any) => {
// fail // fail
// действие при неуспешной оплате // действие при неуспешной оплате
console.debug('[donate] options', options) console.debug('[donate] options', options)
showSnackbar({ showSnackbar({
type: 'error', type: 'error',
body: reason body: reason,
}) })
} },
) )
} }

View File

@ -1,10 +1,10 @@
import { useLocalize } from '~/context/localize' import { useLocalize } from '../../context/localize'
import { useUI } from '~/context/ui' import { hideModal } from '../../stores/ui'
import { Button } from '../_shared/Button' import { Button } from '../_shared/Button'
export const Feedback = () => { export const Feedback = () => {
const { t } = useLocalize() const { t } = useLocalize()
const { hideModal } = useUI()
const action = '/user/feedback' const action = '/user/feedback'
const method = 'post' const method = 'post'
let msgElement: HTMLTextAreaElement | undefined let msgElement: HTMLTextAreaElement | undefined
@ -14,9 +14,9 @@ export const Feedback = () => {
method, method,
headers: { headers: {
accept: 'application/json', 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() hideModal()
} }

View File

@ -88,16 +88,16 @@
} }
.socialItem { .socialItem {
margin-top: 1em;
text-align: center;
width: 25%;
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {
margin-top: 0; margin-top: 0;
margin-left: 0.3em; margin-left: 0.3em;
text-align: right; text-align: right;
} }
margin-top: 1em;
text-align: center;
width: 25%;
a:link { a:link {
border: none; border: none;
padding-bottom: 0; padding-bottom: 0;

View File

@ -1,81 +1,127 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { For, createSignal, onMount } from 'solid-js' import { For, createMemo } from 'solid-js'
import { useLocalize } from '~/context/localize'
import { useLocalize } from '../../context/localize'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import { Newsletter } from '../_shared/Newsletter' import { Subscribe } from '../_shared/Subscribe'
import styles from './Footer.module.scss' import styles from './Footer.module.scss'
const social = [ export const Footer = () => {
{ 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 { t, lang } = useLocalize()
const [footerLinks, setFooterLinks] = createSignal<Array<{ header: string; items: FooterItem[] }>>([])
onMount(() => { const changeLangTitle = createMemo(() => (lang() === 'ru' ? 'English' : 'Русский'))
setFooterLinks([ const changeLangLink = createMemo(() => `?lng=${lang() === 'ru' ? 'en' : 'ru'}`)
{ const links = createMemo(() => [
header: t('About the project'), {
items: [ header: 'About the project',
{ title: t('Discours Manifest'), slug: '/manifest' }, items: [
{ title: t('How it works'), slug: '/guide' }, {
{ title: t('Dogma'), slug: '/dogma' }, title: 'Discours Manifest',
{ title: t('Our principles'), slug: '/principles' }, slug: '/about/manifest',
{ title: t('How to write an article'), slug: '/how-to-write-a-good-article' } },
] {
}, title: 'How it works',
{ slug: '/about/guide',
header: t('Participating'), },
items: [ {
{ title: t('Suggest an idea'), slug: '/connect' }, title: 'Dogma',
{ title: t('Become an author'), slug: '/edit/new' }, slug: '/about/dogma',
{ title: t('Support Discours'), slug: '/support' }, },
{ {
title: t('Cooperate with Discours'), title: 'Principles',
slug: 'https://docs.google.com/forms/d/e/1FAIpQLSeNNvIzKlXElJtkPkYiXl-jQjlvsL9u4-kpnoRjz1O8Wo40xQ/viewform' slug: '/about/principles',
} },
] {
}, title: 'How to write an article',
{ slug: '/how-to-write-a-good-article',
header: t('Sections'), },
items: [ ],
{ title: t('Authors'), slug: '/author' }, },
{ title: t('Communities'), slug: '/community' },
{ title: t('Partners'), slug: '/partners' },
{ title: t('Special projects'), slug: '/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 ( return (
<footer class={styles.discoursFooter}> <footer class={styles.discoursFooter}>
<div class="wide-container"> <div class="wide-container">
<div class="row"> <div class="row">
<For each={footerLinks()}> <For each={links()}>
{({ header, items }) => ( {({ header, items }) => (
<div class="col-sm-8 col-md-6"> <div class="col-sm-8 col-md-6">
<h5>{t(header)}</h5> <h5>{t(header)}</h5>
<ul> <ul>
<For each={items}> <For each={items}>
{({ slug, title, rel }: FooterItem) => ( {({ slug, title, ...rest }) => (
<li> <li>
{' '} {' '}
<a href={slug} rel={rel}> <a href={slug} {...rest}>
{rel ? title : t(title)} {slug.startsWith('?') ? title : t(title)}
</a>{' '} </a>{' '}
</li> </li>
)} )}
@ -87,29 +133,24 @@ export const FooterView = () => {
<div class="col-md-6"> <div class="col-md-6">
<h5>{t('Subscription')}</h5> <h5>{t('Subscription')}</h5>
<p>{t('Join our maillist')}</p> <p>{t('Join our maillist')}</p>
<Newsletter /> <Subscribe />
</div> </div>
</div> </div>
<div class={clsx(styles.footerCopyright, 'row')}> <div class={clsx(styles.footerCopyright, 'row')}>
<div class="col-md-18 col-lg-20"> <div class="col-md-18 col-lg-20">
{t( {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()}{' '} . {t('Discours')} &copy; 2015&ndash;{new Date().getFullYear()}{' '}
<a href="/terms">{t('Terms of use')}</a> <a href="/about/terms-of-use">{t('Terms of use')}</a>
</div> </div>
<div class={clsx(styles.footerCopyrightSocial, 'col-md-6 col-lg-4')}> <div class={clsx(styles.footerCopyrightSocial, 'col-md-6 col-lg-4')}>
<For each={social}> <For each={social}>
{(provider) => ( {(social) => (
<div <div class={clsx(styles.socialItem, styles[`socialItem${social.name}`])}>
class={clsx( <a href={social.href}>
styles.socialItem, <Icon name={`${social.name}-white`} class={styles.icon} />
styles[`socialItem${provider.name}` as keyof typeof styles]
)}
>
<a href={provider.href}>
<Icon name={`${provider.name}-white`} class={styles.icon} />
</a> </a>
</div> </div>
)} )}

View File

@ -1,13 +1,14 @@
import { useLocalize } from '~/context/localize' import { useLocalize } from '../../context/localize'
import { useUI } from '~/context/ui' import { useRouter } from '../../stores/router'
import { showModal } from '../../stores/ui'
import type { AuthModalSearchParams } from '../Nav/AuthModal/types'
import { useSearchParams } from '@solidjs/router'
import styles from './Hero.module.scss' import styles from './Hero.module.scss'
export default () => { export default () => {
const { t } = useLocalize() const { t } = useLocalize()
const { showModal } = useUI() const { changeSearchParams } = useRouter<AuthModalSearchParams>()
const [, changeSearchParams] = useSearchParams()
return ( return (
<div class={styles.aboutDiscours}> <div class={styles.aboutDiscours}>
<div class="wide-container"> <div class="wide-container">
@ -16,11 +17,11 @@ export default () => {
<h4 innerHTML={t('Horizontal collaborative journalistic platform')} /> <h4 innerHTML={t('Horizontal collaborative journalistic platform')} />
<p <p
innerHTML={t( 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}> <div class={styles.aboutDiscoursActions}>
<a class="button" href="/edit/new"> <a class="button" href="/create">
{t('Create post')} {t('Create post')}
</a> </a>
<a <a
@ -28,13 +29,13 @@ export default () => {
onClick={() => { onClick={() => {
showModal('auth') showModal('auth')
changeSearchParams({ changeSearchParams({
mode: 'register' mode: 'register',
}) })
}} }}
> >
{t('Join the community')} {t('Join the community')}
</a> </a>
<a class="button" href="/support"> <a class="button" href="/about/help">
{t('Support us')} {t('Support us')}
</a> </a>
</div> </div>

View File

@ -1,7 +1,3 @@
.draft {
margin-bottom: 56px;
}
.created { .created {
@include font-size(1.2rem); @include font-size(1.2rem);

View File

@ -1,13 +1,18 @@
import { A } from '@solidjs/router' import type { Shout } from '../../graphql/schema/core.gen'
import { getPagePath } from '@nanostores/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { useLocalize } from '~/context/localize'
import { useSnackbar, useUI } from '~/context/ui' import { useConfirm } from '../../context/confirm'
import type { Shout } from '~/graphql/schema/core.gen' import { useLocalize } from '../../context/localize'
import { useSnackbar } from '../../context/snackbar'
import { router } from '../../stores/router'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import styles from './Draft.module.scss' import styles from './Draft.module.scss'
type Props = { type Props = {
class?: string
shout: Shout shout: Shout
onPublish: (shout: Shout) => void onPublish: (shout: Shout) => void
onDelete: (shout: Shout) => void onDelete: (shout: Shout) => void
@ -15,10 +20,10 @@ type Props = {
export const Draft = (props: Props) => { export const Draft = (props: Props) => {
const { t, formatDate } = useLocalize() const { t, formatDate } = useLocalize()
const { showConfirm } = useUI() const { showConfirm } = useConfirm()
const { showSnackbar } = useSnackbar() const { showSnackbar } = useSnackbar()
const handlePublishLinkClick = (e: MouseEvent) => { const handlePublishLinkClick = (e) => {
e.preventDefault() e.preventDefault()
if (props.shout.main_topic) { if (props.shout.main_topic) {
props.onPublish(props.shout) props.onPublish(props.shout)
@ -27,14 +32,14 @@ export const Draft = (props: Props) => {
} }
} }
const handleDeleteLinkClick = async (e: MouseEvent) => { const handleDeleteLinkClick = async (e) => {
e.preventDefault() e.preventDefault()
const isConfirmed = await showConfirm({ const isConfirmed = await showConfirm({
confirmBody: t('Are you sure you want to delete this draft?'), confirmBody: t('Are you sure you want to delete this draft?'),
confirmButtonLabel: t('Delete'), confirmButtonLabel: t('Delete'),
confirmButtonVariant: 'danger', confirmButtonVariant: 'danger',
declineButtonVariant: 'primary' declineButtonVariant: 'primary',
}) })
if (isConfirmed) { if (isConfirmed) {
props.onDelete(props.shout) props.onDelete(props.shout)
@ -44,7 +49,7 @@ export const Draft = (props: Props) => {
} }
return ( return (
<div class={styles.draft}> <div class={clsx(props.class)}>
<div class={styles.created}> <div class={styles.created}>
<Icon name="pencil-outline" class={styles.icon} />{' '} <Icon name="pencil-outline" class={styles.icon} />{' '}
{formatDate(new Date(props.shout.created_at * 1000), { hour: '2-digit', minute: '2-digit' })} {formatDate(new Date(props.shout.created_at * 1000), { hour: '2-digit', minute: '2-digit' })}
@ -53,9 +58,12 @@ export const Draft = (props: Props) => {
<span class={styles.title}>{props.shout.title || t('Unnamed draft')}</span> {props.shout.subtitle} <span class={styles.title}>{props.shout.title || t('Unnamed draft')}</span> {props.shout.subtitle}
</div> </div>
<div class={styles.actions}> <div class={styles.actions}>
<A class={styles.actionItem} href={`edit/${props.shout?.id.toString()}`}> <a
class={styles.actionItem}
href={getPagePath(router, 'edit', { shoutId: props.shout?.id.toString() })}
>
{t('Edit')} {t('Edit')}
</A> </a>
<span onClick={handlePublishLinkClick} class={clsx(styles.actionItem, styles.publish)}> <span onClick={handlePublishLinkClick} class={clsx(styles.actionItem, styles.publish)}>
{t('Publish')} {t('Publish')}
</span> </span>

View File

@ -1,67 +0,0 @@
import { useNavigate } from '@solidjs/router'
import clsx from 'clsx'
import { For } from 'solid-js'
import { useEditorContext } from '~/context/editor'
import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session'
import { useSnackbar } from '~/context/ui'
import createShoutMutation from '~/graphql/mutation/core/article-create'
import { LayoutType } from '~/types/common'
import { Button } from '../_shared/Button'
import { Icon } from '../_shared/Icon'
import styles from './LayoutSelector.module.scss'
export const LayoutSelector = () => {
const { t } = useLocalize()
const { client } = useSession()
const { saveDraftToLocalStorage } = useEditorContext()
const { showSnackbar } = useSnackbar()
const navigate = useNavigate()
const handleCreate = async (layout: LayoutType) => {
console.debug('[routes : edit/new] handling create click...')
const result = await client()
?.mutation(createShoutMutation, { shout: { layout: layout } })
.toPromise()
if (result) {
console.debug(result)
const { shout, error } = result.data.create_shout
if (error) {
showSnackbar({
body: `${t('Error')}: ${t(error)}`,
type: 'error'
})
return
}
if (shout?.id) {
saveDraftToLocalStorage({
shoutId: shout.id,
selectedTopics: shout.topics,
slug: shout.slug,
title: '',
body: ''
})
navigate(`/edit/${shout.id}`)
}
}
}
return (
<article class={clsx('wide-container', 'container--static-page', styles.Create)}>
<h1>{t('Choose a post type')}</h1>
<ul class={clsx('nodash', styles.list)}>
<For each={['Article', 'Literature', 'Image', 'Audio', 'Video']}>
{(layout: string) => (
<li onClick={() => handleCreate(layout.toLowerCase() as LayoutType)}>
<div class={styles.link}>
<Icon name={`create-${layout.toLowerCase()}`} class={styles.icon} />
<div>{t(layout)}</div>
</div>
</li>
)}
</For>
</ul>
<Button value={t('Back')} onClick={() => window?.history.back()} />
</article>
)
}

View File

@ -1,15 +1,16 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Show } from 'solid-js' import { Show } from 'solid-js'
import { isServer } from 'solid-js/web'
import { DropArea } from '~/components/_shared/DropArea' import { useLocalize } from '../../../context/localize'
import { useLocalize } from '~/context/localize' import { MediaItem } from '../../../pages/types'
import { composeMediaItems } from '~/lib/composeMediaItems' import { composeMediaItems } from '../../../utils/composeMediaItems'
import { MediaItem } from '~/types/mediaitem'
import { AudioPlayer } from '../../Article/AudioPlayer' import { AudioPlayer } from '../../Article/AudioPlayer'
import { DropArea } from '../../_shared/DropArea'
// import { Buffer } from 'node:buffer'
import styles from './AudioUploader.module.scss' import styles from './AudioUploader.module.scss'
if (!isServer && window) window.Buffer = Buffer window.Buffer = Buffer
// console.debug('buffer patch passed')
type Props = { type Props = {
class?: string class?: string
@ -27,24 +28,18 @@ type Props = {
export const AudioUploader = (props: Props) => { export const AudioUploader = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const handleMediaItemFieldChange = ( const handleMediaItemFieldChange = (index: number, field: keyof MediaItem, value) => {
index: number,
field: keyof MediaItem | string | symbol | number,
value: string
) => {
props.onAudioChange(index, { ...props.audio[index], [field]: value }) props.onAudioChange(index, { ...props.audio[index], [field]: value })
} }
const handleChangeIndex = (direction: 'up' | 'down', index: number) => { const handleChangeIndex = (direction: 'up' | 'down', index: number) => {
const media = [...props.audio] const media = [...props.audio]
if (media?.length > 0) { if (direction === 'up' && index > 0) {
if (direction === 'up' && index > 0) { const copy = media.splice(index, 1)[0]
const copy = media.splice(index, 1)[0] media.splice(index - 1, 0, copy)
media.splice(index - 1, 0, copy) } else if (direction === 'down' && index < media.length - 1) {
} else if (direction === 'down' && index < media.length - 1) { const copy = media.splice(index, 1)[0]
const copy = media.splice(index, 1)[0] media.splice(index + 1, 0, copy)
media.splice(index + 1, 0, copy)
}
} }
props.onAudioSorted(media) props.onAudioSorted(media)
} }

View File

@ -1,6 +1,8 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Loading } from '~/components/_shared/Loading'
import { useLocalize } from '~/context/localize' import { useLocalize } from '../../../context/localize'
import { Loading } from '../../_shared/Loading'
import styles from './AutoSaveNotice.module.scss' import styles from './AutoSaveNotice.module.scss'
type Props = { type Props = {

View File

@ -1,8 +1,8 @@
import type { Editor } from '@tiptap/core' import type { Editor } from '@tiptap/core'
import { Icon } from '~/components/_shared/Icon' import { useLocalize } from '../../../context/localize'
import { Popover } from '~/components/_shared/Popover' import { Icon } from '../../_shared/Icon'
import { useLocalize } from '~/context/localize' import { Popover } from '../../_shared/Popover'
import styles from './BubbleMenu.module.scss' import styles from './BubbleMenu.module.scss'
@ -16,13 +16,13 @@ export const BlockquoteBubbleMenu = (props: Props) => {
return ( return (
<div ref={props.ref} class={styles.BubbleMenu}> <div ref={props.ref} class={styles.BubbleMenu}>
<Popover content={t('Alignment left')}> <Popover content={t('Alignment left')}>
{(triggerRef: (el: HTMLElement) => void) => ( {(triggerRef: (el) => void) => (
<button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
class={styles.bubbleMenuButton} class={styles.bubbleMenuButton}
onClick={() => { onClick={() => {
props.editor?.chain().focus().setBlockQuoteFloat('left').run() props.editor.chain().focus().setBlockQuoteFloat('left').run()
}} }}
> >
<Icon name="editor-image-align-left" /> <Icon name="editor-image-align-left" />
@ -30,24 +30,24 @@ export const BlockquoteBubbleMenu = (props: Props) => {
)} )}
</Popover> </Popover>
<Popover content={t('Alignment center')}> <Popover content={t('Alignment center')}>
{(triggerRef: (el: HTMLElement) => void) => ( {(triggerRef: (el) => void) => (
<button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
class={styles.bubbleMenuButton} class={styles.bubbleMenuButton}
onClick={() => props.editor?.chain().focus().setBlockQuoteFloat(null).run()} onClick={() => props.editor.chain().focus().setBlockQuoteFloat(null).run()}
> >
<Icon name="editor-image-align-center" /> <Icon name="editor-image-align-center" />
</button> </button>
)} )}
</Popover> </Popover>
<Popover content={t('Alignment center')}> <Popover content={t('Alignment center')}>
{(triggerRef: (el: HTMLElement) => void) => ( {(triggerRef: (el) => void) => (
<button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
class={styles.bubbleMenuButton} class={styles.bubbleMenuButton}
onClick={() => props.editor?.chain().focus().setBlockQuoteFloat('right').run()} onClick={() => props.editor.chain().focus().setBlockQuoteFloat('right').run()}
> >
<Icon name="editor-image-align-right" /> <Icon name="editor-image-align-right" />
</button> </button>

View File

@ -1,12 +1,12 @@
import type { Editor } from '@tiptap/core' import type { Editor } from '@tiptap/core'
import { renderUploadedImage } from '~/components/Upload/renderUploadedImage'
import { Icon } from '~/components/_shared/Icon' import { useLocalize } from '../../../context/localize'
import { Popover } from '~/components/_shared/Popover' import { UploadedFile } from '../../../pages/types'
import { useLocalize } from '~/context/localize' import { renderUploadedImage } from '../../../utils/renderUploadedImage'
import { useUI } from '~/context/ui' import { Modal } from '../../Nav/Modal'
import { UploadedFile } from '~/types/upload' import { Icon } from '../../_shared/Icon'
import { UploadModalContent } from '../../Upload/UploadModalContent' import { Popover } from '../../_shared/Popover'
import { Modal } from '../../_shared/Modal' import { UploadModalContent } from '../UploadModalContent'
import styles from './BubbleMenu.module.scss' import styles from './BubbleMenu.module.scss'
@ -17,46 +17,44 @@ type Props = {
export const FigureBubbleMenu = (props: Props) => { export const FigureBubbleMenu = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const { hideModal } = useUI()
const handleUpload = (image?: UploadedFile) => { const handleUpload = (image: UploadedFile) => {
image && renderUploadedImage(props.editor, image) renderUploadedImage(props.editor, image)
hideModal()
} }
return ( return (
<div ref={props.ref} class={styles.BubbleMenu}> <div ref={props.ref} class={styles.BubbleMenu}>
<Popover content={t('Alignment left')}> <Popover content={t('Alignment left')}>
{(triggerRef: (el: HTMLElement) => void) => ( {(triggerRef: (el) => void) => (
<button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
class={styles.bubbleMenuButton} class={styles.bubbleMenuButton}
onClick={() => props.editor?.chain().focus().setFigureFloat('left').run()} onClick={() => props.editor.chain().focus().setFigureFloat('left').run()}
> >
<Icon name="editor-image-align-left" /> <Icon name="editor-image-align-left" />
</button> </button>
)} )}
</Popover> </Popover>
<Popover content={t('Alignment center')}> <Popover content={t('Alignment center')}>
{(triggerRef: (el: HTMLElement) => void) => ( {(triggerRef: (el) => void) => (
<button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
class={styles.bubbleMenuButton} class={styles.bubbleMenuButton}
onClick={() => props.editor?.chain().focus().setFigureFloat(null).run()} onClick={() => props.editor.chain().focus().setFigureFloat(null).run()}
> >
<Icon name="editor-image-align-center" /> <Icon name="editor-image-align-center" />
</button> </button>
)} )}
</Popover> </Popover>
<Popover content={t('Alignment right')}> <Popover content={t('Alignment right')}>
{(triggerRef: (el: HTMLElement) => void) => ( {(triggerRef: (el) => void) => (
<button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
class={styles.bubbleMenuButton} class={styles.bubbleMenuButton}
onClick={() => props.editor?.chain().focus().setFigureFloat('right').run()} onClick={() => props.editor.chain().focus().setFigureFloat('right').run()}
> >
<Icon name="editor-image-align-right" /> <Icon name="editor-image-align-right" />
</button> </button>
@ -66,13 +64,13 @@ export const FigureBubbleMenu = (props: Props) => {
<button <button
type="button" type="button"
class={styles.bubbleMenuButton} class={styles.bubbleMenuButton}
onClick={() => props.editor?.chain().focus().setFigcaptionFocus(true).run()} onClick={() => props.editor.chain().focus().setFigcaptionFocus(true).run()}
> >
<span style={{ color: 'white' }}>{t('Add signature')}</span> <span style={{ color: 'white' }}>{t('Add signature')}</span>
</button> </button>
<div class={styles.delimiter} /> <div class={styles.delimiter} />
<Popover content={t('Add image')}> <Popover content={t('Add image')}>
{(triggerRef: (el: HTMLElement) => void) => ( {(triggerRef: (el) => void) => (
<button type="button" ref={triggerRef} class={styles.bubbleMenuButton}> <button type="button" ref={triggerRef} class={styles.bubbleMenuButton}>
<Icon name="editor-image-add" /> <Icon name="editor-image-add" />
</button> </button>
@ -80,7 +78,11 @@ export const FigureBubbleMenu = (props: Props) => {
</Popover> </Popover>
<Modal variant="narrow" name="uploadImage"> <Modal variant="narrow" name="uploadImage">
<UploadModalContent onClose={handleUpload} /> <UploadModalContent
onClose={(value) => {
handleUpload(value)
}}
/>
</Modal> </Modal>
</div> </div>
) )

View File

@ -3,8 +3,8 @@ import type { Editor } from '@tiptap/core'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { For, Show, createSignal } from 'solid-js' import { For, Show, createSignal } from 'solid-js'
import { Icon } from '~/components/_shared/Icon' import { useLocalize } from '../../../context/localize'
import { useLocalize } from '~/context/localize' import { Icon } from '../../_shared/Icon'
import styles from './BubbleMenu.module.scss' import styles from './BubbleMenu.module.scss'
@ -18,8 +18,8 @@ const backgrounds = [null, 'white', 'black', 'yellow', 'pink', 'green']
export const IncutBubbleMenu = (props: Props) => { export const IncutBubbleMenu = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const [substratBubbleOpen, setSubstratBubbleOpen] = createSignal(false) const [substratBubbleOpen, setSubstratBubbleOpen] = createSignal(false)
const handleChangeBg = (bg: string | null) => { const handleChangeBg = (bg) => {
props.editor?.chain().focus().setArticleBg(bg).run() props.editor.chain().focus().setArticleBg(bg).run()
setSubstratBubbleOpen(false) setSubstratBubbleOpen(false)
} }
return ( return (
@ -27,14 +27,14 @@ export const IncutBubbleMenu = (props: Props) => {
<button <button
type="button" type="button"
class={styles.bubbleMenuButton} class={styles.bubbleMenuButton}
onClick={() => props.editor?.chain().focus().setArticleFloat('half-left').run()} onClick={() => props.editor.chain().focus().setArticleFloat('half-left').run()}
> >
<Icon name="editor-image-half-align-left" /> <Icon name="editor-image-half-align-left" />
</button> </button>
<button <button
type="button" type="button"
class={styles.bubbleMenuButton} class={styles.bubbleMenuButton}
onClick={() => props.editor?.chain().focus().setArticleFloat(null).run()} onClick={() => props.editor.chain().focus().setArticleFloat(null).run()}
> >
<Icon name="editor-image-align-center" /> <Icon name="editor-image-align-center" />
</button> </button>
@ -42,7 +42,7 @@ export const IncutBubbleMenu = (props: Props) => {
<button <button
type="button" type="button"
class={styles.bubbleMenuButton} class={styles.bubbleMenuButton}
onClick={() => props.editor?.chain().focus().setArticleFloat('half-right').run()} onClick={() => props.editor.chain().focus().setArticleFloat('half-right').run()}
> >
<Icon name="editor-image-half-align-right" /> <Icon name="editor-image-half-align-right" />
</button> </button>
@ -60,12 +60,7 @@ export const IncutBubbleMenu = (props: Props) => {
<div class={styles.dropDown}> <div class={styles.dropDown}>
<div class={styles.actions}> <div class={styles.actions}>
<For each={backgrounds}> <For each={backgrounds}>
{(bg) => ( {(bg) => <div onClick={() => handleChangeBg(bg)} class={clsx(styles.color, styles[bg])} />}
<div
onClick={() => handleChangeBg(bg)}
class={clsx(styles.color, styles[bg as keyof typeof styles])}
/>
)}
</For> </For>
</div> </div>
</div> </div>

View File

@ -0,0 +1,3 @@
export { FigureBubbleMenu } from './FigureBubbleMenu'
export { BlockquoteBubbleMenu } from './BlockquoteBubbleMenu'
export { IncutBubbleMenu } from './IncutBubbleMenu'

View File

@ -1,64 +0,0 @@
import { Meta, StoryObj } from 'storybook-solidjs'
import { EditorComponent } from './Editor'
const meta: Meta<typeof EditorComponent> = {
title: 'Components/Editor',
component: EditorComponent,
argTypes: {
content: {
control: 'text',
description: 'Initial content for the editor',
defaultValue: ''
},
limit: {
control: 'number',
description: 'Character limit for the editor',
defaultValue: 500
},
placeholder: {
control: 'text',
description: 'Placeholder text when the editor is empty',
defaultValue: 'Start typing here...'
},
onChange: {
action: 'changed',
description: 'Callback when the content changes'
}
}
}
export default meta
type Story = StoryObj<typeof EditorComponent>
export const Default: Story = {
args: {
content: '',
limit: 500,
placeholder: 'Start typing here...'
}
}
export const WithInitialContent: Story = {
args: {
content: 'This is some initial content',
limit: 500,
placeholder: 'Start typing here...'
}
}
export const WithCharacterLimit: Story = {
args: {
content: '',
limit: 50,
placeholder: 'You have a 50 character limit...'
}
}
export const WithCustomPlaceholder: Story = {
args: {
content: '',
limit: 500,
placeholder: 'Custom placeholder here...'
}
}

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