Merge branch 'dev' of https://dev.discours.io/discours.io/webapp into feature/email-templates

This commit is contained in:
Untone 2024-02-01 12:20:40 +03:00
commit efb09f89df
96 changed files with 1762 additions and 1844 deletions

View File

@ -1,6 +1,7 @@
node_modules node_modules
public public
*.cjs *.cjs
src/graphql/schema/*.gen.ts
dist/ dist/
.vercel/ .vercel/
src/graphql/client/*
src/graphql/schema/*

View File

@ -1,106 +1,108 @@
module.exports = { module.exports = {
plugins: ["@typescript-eslint", "import", "sonarjs", "unicorn", "promise", "solid", "jest"], plugins: ['@typescript-eslint', 'import', 'sonarjs', 'unicorn', 'promise', 'solid', 'jest'],
extends: [ extends: [
"eslint:recommended", 'eslint:recommended',
"plugin:import/recommended", 'plugin:import/recommended',
"plugin:import/typescript", 'plugin:import/typescript',
"prettier", 'prettier',
"plugin:sonarjs/recommended", 'plugin:sonarjs/recommended',
"plugin:unicorn/recommended", 'plugin:unicorn/recommended',
"plugin:promise/recommended", 'plugin:promise/recommended',
"plugin:solid/recommended", 'plugin:solid/recommended',
"plugin:jest/recommended" 'plugin:jest/recommended',
], ],
overrides: [ overrides: [
{ {
files: ["**/*.ts", "**/*.tsx"], files: ['**/*.ts', '**/*.tsx'],
parser: "@typescript-eslint/parser", parser: '@typescript-eslint/parser',
parserOptions: { parserOptions: {
ecmaVersion: 2021, ecmaVersion: 2021,
ecmaFeatures: { jsx: true }, ecmaFeatures: { jsx: true },
sourceType: "module", sourceType: 'module',
project: "./tsconfig.json" project: './tsconfig.json',
}, },
extends: [ extends: [
"plugin:@typescript-eslint/recommended" 'plugin:@typescript-eslint/recommended',
// Maybe one day... // 'plugin:@typescript-eslint/recommended-requiring-type-checking', // 23-01-2024 681 problems
// 'plugin:@typescript-eslint/recommended-requiring-type-checking'
], ],
rules: { rules: {
"@typescript-eslint/no-unused-vars": [ '@typescript-eslint/no-unused-vars': [
"warn", 'warn',
{ {
argsIgnorePattern: "^_" argsIgnorePattern: '^_',
} },
], ],
"@typescript-eslint/no-non-null-assertion": "error", '@typescript-eslint/no-non-null-assertion': 'error',
// TODO: Remove any usage and enable '@typescript-eslint/no-explicit-any': 'warn',
"@typescript-eslint/no-explicit-any": "off" },
} },
}
], ],
env: { env: {
browser: true, browser: true,
node: true, node: true,
mocha: true mocha: true,
}, },
globals: {}, globals: {},
rules: { rules: {
// Solid // Solid
"solid/reactivity": "off", // FIXME 'solid/reactivity': 'off', // too many 'should be used within JSX'
"solid/no-innerhtml": "off", 'solid/no-innerhtml': 'off',
/** Unicorn **/ /** Unicorn **/
"unicorn/no-null": "off", 'unicorn/no-null': 'off',
"unicorn/filename-case": "off", 'unicorn/filename-case': 'off',
"unicorn/no-array-for-each": "off", 'unicorn/no-array-for-each': 'off',
"unicorn/no-array-reduce": "off", 'unicorn/no-array-reduce': 'off',
"unicorn/prefer-string-replace-all": "warn", 'unicorn/prefer-string-replace-all': 'warn',
"unicorn/prevent-abbreviations": "off", 'unicorn/prevent-abbreviations': 'off',
"unicorn/prefer-module": "off", 'unicorn/prefer-module': 'off',
"unicorn/import-style": "off", 'unicorn/import-style': 'off',
"unicorn/numeric-separators-style": "off", 'unicorn/numeric-separators-style': 'off',
"unicorn/prefer-node-protocol": "off", 'unicorn/prefer-node-protocol': 'off',
"unicorn/prefer-dom-node-append": "off", // FIXME 'unicorn/prefer-dom-node-append': 'warn',
"unicorn/prefer-top-level-await": "warn", 'unicorn/prefer-top-level-await': 'warn',
"unicorn/consistent-function-scoping": "warn", 'unicorn/consistent-function-scoping': 'warn',
"unicorn/no-array-callback-reference": "warn", 'unicorn/no-array-callback-reference': 'warn',
"unicorn/no-array-method-this-argument": "warn", 'unicorn/no-array-method-this-argument': 'warn',
"unicorn/no-for-loop": "off", 'unicorn/no-for-loop': 'off',
"sonarjs/no-duplicate-string": ["warn", { threshold: 5 }], 'sonarjs/no-duplicate-string': ['warn', { threshold: 5 }],
'sonarjs/prefer-immediate-return': 'warn',
// Promise // Promise
// 'promise/catch-or-return': 'off', // Should be enabled 'promise/catch-or-return': 'off',
"promise/always-return": "off", 'promise/always-return': 'off',
eqeqeq: "error", eqeqeq: 'error',
"no-param-reassign": "error", 'no-param-reassign': 'error',
"no-nested-ternary": "error", 'no-nested-ternary': 'error',
"no-shadow": "error", 'no-shadow': 'error',
"import/order": ["warn", { 'import/order': [
groups: ["type", "builtin", "external", "internal", "parent", "sibling", "index"], 'warn',
distinctGroup: false, {
pathGroups: [ groups: ['type', 'builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
{ distinctGroup: false,
pattern: "*.scss", pathGroups: [
patternOptions: { matchBase: true }, {
group: "index", pattern: '*.scss',
position: "after" patternOptions: { matchBase: true },
} group: 'index',
], position: 'after',
"newlines-between": "always", },
alphabetize: { ],
order: "asc", 'newlines-between': 'always',
caseInsensitive: true alphabetize: {
} order: 'asc',
}] caseInsensitive: true,
},
},
],
}, },
settings: { settings: {
"import/resolver": { 'import/resolver': {
typescript: true, typescript: true,
node: true node: true,
} },
} },
}; }

View File

@ -1,9 +1,11 @@
name: 'deploy' name: 'deploy'
on: on:
push: push:
branches: branches:
- main - main
- feature/sse-connect - dev
- feature/email-templates
jobs: jobs:
test: test:
@ -26,24 +28,40 @@ jobs:
update_mailgun_template: update_mailgun_template:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Update templates on Mailgun name: Update templates on Mailgun
if: github.event_name == 'push' && github.ref == 'refs/heads/feature/email-templates'
continue-on-error: true continue-on-error: true
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: update authorizer_password_reset
uses: jlepocher/mailgun-create-template-version-action@v1.3 - name: "Email confirmation template"
uses: gyto/mailgun-template-action@v2
with: with:
mailgun-host: 'api.eu.mailgun.net' html-file: "./templates/authorizer_email_confirmation.html"
mailgun-api-key: ${{ secrets.MAILGUN_API_KEY }} mailgun-api-key: ${{ secrets.MAILGUN_API_KEY }}
mailgun-domain-name: 'discours.io' mailgun-domain: "discours.io"
mailgun-template-name: 'authorizer_password_reset' mailgun-template: "authorizer_email_confirmation"
html-file-path: './templates/authorizer_password_reset.html'
- name: "Password reset template"
- name: update authorizer_password_reset uses: gyto/mailgun-template-action@v2
uses: jlepocher/mailgun-create-template-version-action@v1.3
with: with:
mailgun-host: 'api.eu.mailgun.net' html-file: "./templates/authorizer_password_reset.html"
mailgun-api-key: ${{ secrets.MAILGUN_API_KEY }} mailgun-api-key: ${{ secrets.MAILGUN_API_KEY }}
mailgun-domain-name: 'discours.io' mailgun-domain: "discours.io"
mailgun-template-name: 'authorizer_email_confirm' mailgun-template: "authorizer_password_reset"
html-file-path: './templates/authorizer_email_confirm.html'
- name: "First publication notification"
uses: gyto/mailgun-template-action@v2
with:
html-file: "./templates/first_publication_notification.html"
mailgun-api-key: ${{ secrets.MAILGUN_API_KEY }}
mailgun-domain: "discours.io"
mailgun-template: "first_publication_notification"
- name: "New comment notification template"
uses: gyto/mailgun-template-action@v2
with:
html-file: "./templates/new_comment_notification.html"
mailgun-api-key: ${{ secrets.MAILGUN_API_KEY }}
mailgun-domain: "discours.io"
mailgun-template: "new_comment_notification"

View File

@ -7,6 +7,8 @@
"stylelint-scss" "stylelint-scss"
], ],
"rules": { "rules": {
"keyframes-name-pattern": null,
"declaration-block-no-redundant-longhand-properties": null,
"selector-class-pattern": null, "selector-class-pattern": null,
"no-descending-specificity": null, "no-descending-specificity": null,
"scss/function-no-unknown": null, "scss/function-no-unknown": null,

1584
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -31,12 +31,14 @@
}, },
"dependencies": { "dependencies": {
"@authorizerdev/authorizer-js": "1.2.11", "@authorizerdev/authorizer-js": "1.2.11",
"ackee-tracker": "5.1.0", "@solid-primitives/pagination": "0.2.10",
"cropperjs": "1.6.1",
"form-data": "4.0.0", "form-data": "4.0.0",
"i18next": "22.4.15", "i18next": "22.4.15",
"i18next-icu": "2.3.0", "i18next-icu": "2.3.0",
"idb": "7.1.1", "idb": "7.1.1",
"intl-messageformat": "10.5.3", "intl-messageformat": "10.5.3",
"just-throttle": "4.2.0",
"mailgun.js": "8.2.1" "mailgun.js": "8.2.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -105,6 +105,7 @@
"Create gallery": "Create gallery", "Create gallery": "Create gallery",
"Create post": "Create post", "Create post": "Create post",
"Create video": "Create video", "Create video": "Create video",
"Crop image": "Crop image",
"Culture": "Culture", "Culture": "Culture",
"Date of Birth": "Date of Birth", "Date of Birth": "Date of Birth",
"Decline": "Decline", "Decline": "Decline",
@ -203,6 +204,7 @@
"Invalid email": "Check if your email is correct", "Invalid email": "Check if your email is correct",
"Invalid image URL": "Invalid image URL", "Invalid image URL": "Invalid image URL",
"Invalid url format": "Invalid url format", "Invalid url format": "Invalid url format",
"Invite": "Invite",
"Invite co-authors": "Invite co-authors", "Invite co-authors": "Invite co-authors",
"Invite collaborators": "Invite collaborators", "Invite collaborators": "Invite collaborators",
"Invite to collab": "Invite to Collab", "Invite to collab": "Invite to Collab",
@ -343,6 +345,7 @@
"Special projects": "Special projects", "Special projects": "Special projects",
"Specify the source and the name of the author": "Specify the source and the name of the author", "Specify the source and the name of the author": "Specify the source and the name of the author",
"Start conversation": "Start a conversation", "Start conversation": "Start a conversation",
"Start dialog": "Start dialog",
"Subsccriptions": "Subscriptions", "Subsccriptions": "Subscriptions",
"Subscribe": "Subscribe", "Subscribe": "Subscribe",
"Subscribe to the best publications newsletter": "Subscribe to the best publications newsletter", "Subscribe to the best publications newsletter": "Subscribe to the best publications newsletter",
@ -381,6 +384,7 @@
"This way you ll be able to subscribe to authors, interesting topics and customize your feed": "This way you ll be able to subscribe to authors, interesting topics and customize your feed", "This way you ll be able to subscribe to authors, interesting topics and customize your feed": "This way you ll be able to subscribe to authors, interesting topics and customize your feed",
"This week": "This week", "This week": "This week",
"This year": "This year", "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",
"To leave a comment please": "To leave a comment please", "To leave a comment please": "To leave a comment please",
"To write a comment, you must": "To write a comment, you must", "To write a comment, you must": "To write a comment, you must",
"Top authors": "Authors rating", "Top authors": "Authors rating",
@ -403,6 +407,7 @@
"Upload userpic": "Upload userpic", "Upload userpic": "Upload userpic",
"Upload video": "Upload video", "Upload video": "Upload video",
"Uploading image": "Uploading image", "Uploading image": "Uploading image",
"User with this email already exists": "User with this email already exists",
"Username": "Username", "Username": "Username",
"Userpic": "Userpic", "Userpic": "Userpic",
"Users": "Users", "Users": "Users",
@ -411,6 +416,7 @@
"Views": "Views", "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 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 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",
"We know you, please try to login": "This email address is already registered, please try to login", "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.", "We've sent you a message with a link to enter our website.": "We've sent you an email with a link to your email. Follow the link in the email to enter our website.",
"Welcome to Discours": "Welcome to Discours", "Welcome to Discours": "Welcome to Discours",

View File

@ -110,6 +110,7 @@
"Create gallery": "Создать галерею", "Create gallery": "Создать галерею",
"Create post": "Создать публикацию", "Create post": "Создать публикацию",
"Create video": "Создать видео", "Create video": "Создать видео",
"Crop image": "Кадрировать изображение",
"Culture": "Культура", "Culture": "Культура",
"Date of Birth": "Дата рождения", "Date of Birth": "Дата рождения",
"Decline": "Отмена", "Decline": "Отмена",
@ -213,6 +214,7 @@
"Invalid email": "Проверьте правильность ввода почты", "Invalid email": "Проверьте правильность ввода почты",
"Invalid image URL": "Некорректная ссылка на изображение", "Invalid image URL": "Некорректная ссылка на изображение",
"Invalid url format": "Неверный формат ссылки", "Invalid url format": "Неверный формат ссылки",
"Invite": "Пригласить",
"Invite co-authors": "Пригласить соавторов", "Invite co-authors": "Пригласить соавторов",
"Invite collaborators": "Пригласить соавторов", "Invite collaborators": "Пригласить соавторов",
"Invite experts": "Пригласить экспертов", "Invite experts": "Пригласить экспертов",
@ -364,6 +366,7 @@
"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 conversation": "Начать беседу",
"Start dialog": "Начать диалог",
"Subheader": "Подзаголовок", "Subheader": "Подзаголовок",
"Subscribe": "Подписаться", "Subscribe": "Подписаться",
"Subscribe to comments": "Подписаться на комментарии", "Subscribe to comments": "Подписаться на комментарии",
@ -403,6 +406,7 @@
"This way you ll be able to subscribe to authors, interesting topics and customize your feed": "Так вы сможете подписаться на авторов, интересные темы и настроить свою ленту", "This way you ll be able to subscribe to authors, interesting topics and 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 leave a comment please": "Чтобы оставить комментарий, необходимо", "To leave a comment please": "Чтобы оставить комментарий, необходимо",
"To write a comment, you must": "Чтобы написать комментарий, необходимо", "To write a comment, you must": "Чтобы написать комментарий, необходимо",
"Top authors": "Рейтинг авторов", "Top authors": "Рейтинг авторов",
@ -425,6 +429,7 @@
"Upload userpic": "Загрузить аватар", "Upload userpic": "Загрузить аватар",
"Upload video": "Загрузить видео", "Upload video": "Загрузить видео",
"Uploading image": "Загружаем изображение", "Uploading image": "Загружаем изображение",
"User with this email already exists": "Пользователь с таким email уже существует",
"Username": "Имя пользователя", "Username": "Имя пользователя",
"Userpic": "Аватар", "Userpic": "Аватар",
"Users": "Пользователи", "Users": "Пользователи",
@ -433,6 +438,7 @@
"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 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": "Добро пожаловать в Дискурс",

View File

@ -7,6 +7,7 @@ import { Dynamic } from 'solid-js/web'
import { ConfirmProvider } from '../context/confirm' import { ConfirmProvider } from '../context/confirm'
import { ConnectProvider } from '../context/connect' import { ConnectProvider } from '../context/connect'
import { EditorProvider } from '../context/editor' import { EditorProvider } from '../context/editor'
import { InboxProvider } from '../context/inbox'
import { LocalizeProvider } from '../context/localize' import { LocalizeProvider } from '../context/localize'
import { MediaQueryProvider } from '../context/mediaQuery' import { MediaQueryProvider } from '../context/mediaQuery'
import { NotificationsProvider } from '../context/notifications' import { NotificationsProvider } from '../context/notifications'
@ -92,11 +93,11 @@ export const App = (props: Props) => {
let is404 = props.is404 let is404 = props.is404
createEffect(() => { createEffect(() => {
if (!searchParams().modal) { if (!searchParams().m) {
hideModal() hideModal()
} }
const modal = MODALS[searchParams().modal] const modal = MODALS[searchParams().m]
if (modal) { if (modal) {
showModal(modal) showModal(modal)
} }
@ -124,7 +125,9 @@ export const App = (props: Props) => {
<ConnectProvider> <ConnectProvider>
<NotificationsProvider> <NotificationsProvider>
<EditorProvider> <EditorProvider>
<Dynamic component={pageComponent()} {...props} /> <InboxProvider>
<Dynamic component={pageComponent()} {...props} />
</InboxProvider>
</EditorProvider> </EditorProvider>
</NotificationsProvider> </NotificationsProvider>
</ConnectProvider> </ConnectProvider>

View File

@ -172,11 +172,11 @@ export const CommentsTree = (props: Props) => {
fallback={ fallback={
<div class={styles.signInMessage}> <div class={styles.signInMessage}>
{t('To write a comment, you must')}{' '} {t('To write a comment, you must')}{' '}
<a href="?modal=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')}&nbsp; {t('or')}&nbsp;
<a href="?modal=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>

View File

@ -4,7 +4,7 @@ import { getPagePath } from '@nanostores/router'
import { createPopper } from '@popperjs/core' import { createPopper } from '@popperjs/core'
import { Link, Meta } from '@solidjs/meta' import { Link, Meta } from '@solidjs/meta'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createEffect, For, createMemo, onMount, Show, createSignal, onCleanup } from 'solid-js' import { createEffect, For, createMemo, onMount, Show, createSignal, onCleanup, on } from 'solid-js'
import { isServer } from 'solid-js/web' import { isServer } from 'solid-js/web'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
@ -19,7 +19,7 @@ import { getImageUrl, getOpenGraphImageUrl } from '../../utils/getImageUrl'
import { getDescription, getKeywords } from '../../utils/meta' import { getDescription, getKeywords } from '../../utils/meta'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import { Image } from '../_shared/Image' import { Image } from '../_shared/Image'
import { InviteCoAuthorsModal } from '../_shared/InviteCoAuthorsModal' import { InviteMembers } from '../_shared/InviteMembers'
import { Lightbox } from '../_shared/Lightbox' import { Lightbox } from '../_shared/Lightbox'
import { Popover } from '../_shared/Popover' import { Popover } from '../_shared/Popover'
import { ShareModal } from '../_shared/ShareModal' import { ShareModal } from '../_shared/ShareModal'
@ -44,6 +44,11 @@ type Props = {
scrollToComments?: boolean scrollToComments?: boolean
} }
type IframeSize = {
width: number
height: number
}
export type ArticlePageSearchParams = { export type ArticlePageSearchParams = {
scrollTo: 'comments' scrollTo: 'comments'
commentId: string commentId: string
@ -182,18 +187,6 @@ export const FullArticle = (props: Props) => {
actions: { loadReactionsBy }, actions: { loadReactionsBy },
} = useReactions() } = useReactions()
onMount(async () => {
await loadReactionsBy({
by: { shout: props.article.slug },
})
setIsReactionsLoaded(true)
})
onMount(() => {
document.title = props.article.title
})
const clickHandlers = [] const clickHandlers = []
const documentClickHandlers = [] const documentClickHandlers = []
@ -215,9 +208,9 @@ export const FullArticle = (props: Props) => {
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.appendChild(tooltipContent) tooltip.append(tooltipContent)
document.body.appendChild(tooltip) document.body.append(tooltip)
if (element.hasAttribute('href')) { if (element.hasAttribute('href')) {
element.setAttribute('href', 'javascript: void(0)') element.setAttribute('href', 'javascript: void(0)')
@ -295,8 +288,50 @@ export const FullArticle = (props: Props) => {
} }
} }
const cover = props.article.cover ?? 'production/image/logo_image.png' // Check iframes size
const articleContainer: { current: HTMLElement } = { current: null }
const updateIframeSizes = () => {
if (!articleContainer?.current || !props.article.body) return
const iframes = articleContainer?.current?.querySelectorAll('iframe')
if (!iframes) return
const containerWidth = articleContainer.current?.offsetWidth
iframes.forEach((iframe) => {
const style = window.getComputedStyle(iframe)
const originalWidth = iframe.getAttribute('width') || style.width.replace('px', '')
const originalHeight = iframe.getAttribute('height') || style.height.replace('px', '')
const width: IframeSize['width'] = Number(originalWidth)
const height: IframeSize['height'] = Number(originalHeight)
if (containerWidth < width) {
const aspectRatio = width / height
iframe.style.width = `${containerWidth}px`
iframe.style.height = `${Math.round(containerWidth / aspectRatio) + 40}px`
}
})
}
createEffect(
on(
() => props.article,
() => {
updateIframeSizes()
},
),
)
onMount(async () => {
await loadReactionsBy({
by: { shout: props.article.slug },
})
setIsReactionsLoaded(true)
document.title = props.article.title
window?.addEventListener('resize', updateIframeSizes)
onCleanup(() => window.removeEventListener('resize', updateIframeSizes))
})
const cover = props.article.cover ?? 'production/image/logo_image.png'
const ogImage = getOpenGraphImageUrl(cover, { const ogImage = getOpenGraphImageUrl(cover, {
title: props.article.title, title: props.article.title,
topic: mainTopic().title, topic: mainTopic().title,
@ -328,6 +363,7 @@ export const FullArticle = (props: Props) => {
<div class="wide-container"> <div class="wide-container">
<div class="row position-relative"> <div class="row position-relative">
<article <article
ref={(el) => (articleContainer.current = el)}
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}
> >
@ -519,7 +555,7 @@ export const FullArticle = (props: Props) => {
isOwner={canEdit()} isOwner={canEdit()}
containerCssClass={clsx(stylesHeader.control, styles.articlePopupOpener)} containerCssClass={clsx(stylesHeader.control, styles.articlePopupOpener)}
onShareClick={() => showModal('share')} onShareClick={() => showModal('share')}
onInviteClick={() => showModal('inviteCoAuthors')} onInviteClick={() => showModal('inviteMembers')}
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)} onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
trigger={ trigger={
<button> <button>
@ -582,7 +618,7 @@ export const FullArticle = (props: Props) => {
<Show when={selectedImage()}> <Show when={selectedImage()}>
<Lightbox image={selectedImage()} onClose={handleLightboxClose} /> <Lightbox image={selectedImage()} onClose={handleLightboxClose} />
</Show> </Show>
<InviteCoAuthorsModal title={t('Invite experts')} /> <InviteMembers variant={'coauthors'} title={t('Invite experts')} />
<ShareModal <ShareModal
title={props.article.title} title={props.article.title}
description={description} description={description}

View File

@ -1,5 +1,5 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createMemo, Show } from 'solid-js' import { createMemo, createSignal, Show } from 'solid-js'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
import { useReactions } from '../../context/reactions' import { useReactions } from '../../context/reactions'
@ -29,25 +29,23 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
actions: { createReaction, deleteReaction, loadReactionsBy }, actions: { createReaction, deleteReaction, loadReactionsBy },
} = useReactions() } = useReactions()
const [isLoading, setIsLoading] = createSignal(false)
const checkReaction = (reactionKind: ReactionKind) => const checkReaction = (reactionKind: ReactionKind) =>
Object.values(reactionEntities).some( Object.values(reactionEntities).some(
(r) => (r) =>
r.kind === reactionKind && r.kind === reactionKind &&
r.created_by.slug === author()?.slug && r.created_by.id === author()?.id &&
r.shout.id === props.shout.id && r.shout.id === props.shout.id &&
!r.reply_to, !r.reply_to,
) )
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like)) const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike)) const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
const shoutRatingReactions = createMemo(() => const shoutRatingReactions = createMemo(() =>
Object.values(reactionEntities).filter( Object.values(reactionEntities).filter(
(r) => (r) => ['LIKE', 'DISLIKE'].includes(r.kind) && r.shout.id === props.shout.id && !r.reply_to,
[ReactionKind.Like, ReactionKind.Dislike].includes(r.kind) &&
r.shout.id === props.shout.id &&
!r.reply_to,
), ),
) )
@ -55,7 +53,7 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
const reactionToDelete = Object.values(reactionEntities).find( const reactionToDelete = Object.values(reactionEntities).find(
(r) => (r) =>
r.kind === reactionKind && r.kind === reactionKind &&
r.created_by.slug === author()?.slug && r.created_by.id === author()?.id &&
r.shout.id === props.shout.id && r.shout.id === props.shout.id &&
!r.reply_to, !r.reply_to,
) )
@ -64,6 +62,7 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
const handleRatingChange = async (isUpvote: boolean) => { const handleRatingChange = async (isUpvote: boolean) => {
requireAuthentication(async () => { requireAuthentication(async () => {
setIsLoading(true)
if (isUpvoted()) { if (isUpvoted()) {
await deleteShoutReaction(ReactionKind.Like) await deleteShoutReaction(ReactionKind.Like)
} else if (isDownvoted()) { } else if (isDownvoted()) {
@ -79,18 +78,17 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
loadReactionsBy({ loadReactionsBy({
by: { shout: props.shout.slug }, by: { shout: props.shout.slug },
}) })
setIsLoading(false)
}, 'vote') }, 'vote')
} }
return ( return (
<div class={clsx(styles.rating, props.class)}> <div class={clsx(styles.rating, props.class)}>
<button onClick={() => handleRatingChange(false)}> <button onClick={() => handleRatingChange(false)} disabled={isLoading()}>
<Show when={!isDownvoted()}> <Show when={!isDownvoted()} fallback={<Icon name="rating-control-checked" />}>
<Icon name="rating-control-less" /> <Icon name="rating-control-less" />
</Show> </Show>
<Show when={isDownvoted()}>
<Icon name="rating-control-checked" />
</Show>
</button> </button>
<Popup trigger={<span class={styles.ratingValue}>{props.shout.stat.rating}</span>} variant="tiny"> <Popup trigger={<span class={styles.ratingValue}>{props.shout.stat.rating}</span>} variant="tiny">
@ -100,13 +98,10 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
/> />
</Popup> </Popup>
<button onClick={() => handleRatingChange(true)}> <button onClick={() => handleRatingChange(true)} disabled={isLoading()}>
<Show when={!isUpvoted()}> <Show when={!isUpvoted()} fallback={<Icon name="rating-control-checked" />}>
<Icon name="rating-control-more" /> <Icon name="rating-control-more" />
</Show> </Show>
<Show when={isUpvoted()}>
<Icon name="rating-control-checked" />
</Show>
</button> </button>
</div> </div>
) )

View File

@ -12,11 +12,7 @@ type Props = {
} }
export const AuthGuard = (props: Props) => { export const AuthGuard = (props: Props) => {
const { const { isAuthenticated, isSessionLoaded } = useSession()
isAuthenticated,
isSessionLoaded,
actions: { loadSession },
} = useSession()
const { changeSearchParams } = useRouter<RootSearchParams & AuthModalSearchParams>() const { changeSearchParams } = useRouter<RootSearchParams & AuthModalSearchParams>()
createEffect(async () => { createEffect(async () => {
@ -30,13 +26,14 @@ export const AuthGuard = (props: Props) => {
changeSearchParams( changeSearchParams(
{ {
source: 'authguard', source: 'authguard',
modal: 'auth', m: 'auth',
}, },
true, true,
) )
} }
} else { } else {
await loadSession() // await loadSession()
console.warn('session is not loaded')
} }
}) })

View File

@ -43,21 +43,23 @@
&:hover { &:hover {
background: unset; background: unset;
} }
}
.name { .name {
color: var(--default-color); @include font-size(1.4rem);
font-weight: 500;
& span:hover { color: var(--default-color);
color: var(--default-color-invert); font-weight: 500;
background: var(--background-color-invert);
} & span:hover {
color: var(--default-color-invert);
background: var(--background-color-invert);
} }
}
.bio { .bio {
color: var(--black-400); color: var(--black-400);
font-weight: 500; font-weight: 500;
}
} }
.actions { .actions {

View File

@ -13,6 +13,7 @@ import { isCyrillic } from '../../../utils/cyrillic'
import { translit } from '../../../utils/ru2en' import { translit } from '../../../utils/ru2en'
import { Button } from '../../_shared/Button' import { Button } from '../../_shared/Button'
import { CheckButton } from '../../_shared/CheckButton' import { CheckButton } from '../../_shared/CheckButton'
import { ConditionalWrapper } from '../../_shared/ConditionalWrapper'
import { Icon } from '../../_shared/Icon' import { Icon } from '../../_shared/Icon'
import { Userpic } from '../Userpic' import { Userpic } from '../Userpic'
@ -25,6 +26,9 @@ type Props = {
showMessageButton?: boolean showMessageButton?: boolean
iconButtons?: boolean iconButtons?: boolean
nameOnly?: boolean nameOnly?: boolean
inviteView?: boolean
onInvite?: (id: number) => void
selected?: boolean
} }
export const AuthorBadge = (props: Props) => { export const AuthorBadge = (props: Props) => {
const { mediaMatches } = useMediaQuery() const { mediaMatches } = useMediaQuery()
@ -94,7 +98,14 @@ export const AuthorBadge = (props: Props) => {
userpic={props.author.pic} userpic={props.author.pic}
slug={props.author.slug} slug={props.author.slug}
/> />
<a href={`/author/${props.author.slug}`} class={styles.info}> <ConditionalWrapper
condition={!props.inviteView}
wrapper={(children) => (
<a href={`/author/${props.author.slug}`} class={styles.info}>
{children}
</a>
)}
>
<div class={styles.name}> <div class={styles.name}>
<span>{name()}</span> <span>{name()}</span>
</div> </div>
@ -118,7 +129,7 @@ export const AuthorBadge = (props: Props) => {
</Match> </Match>
</Switch> </Switch>
</Show> </Show>
</a> </ConditionalWrapper>
</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}>
@ -195,6 +206,13 @@ export const AuthorBadge = (props: Props) => {
</Show> </Show>
</div> </div>
</Show> </Show>
<Show when={props.inviteView}>
<CheckButton
text={t('Invite')}
checked={props.selected}
onClick={() => props.onInvite(props.author.id)}
/>
</Show>
</div> </div>
) )
} }

View File

@ -142,7 +142,7 @@ export const AuthorCard = (props: Props) => {
> >
<div class={styles.subscribersContainer}> <div class={styles.subscribersContainer}>
<Show when={props.followers && props.followers.length > 0}> <Show when={props.followers && props.followers.length > 0}>
<a href="?modal=followers" class={styles.subscribers}> <a href="?m=followers" class={styles.subscribers}>
<For each={props.followers.slice(0, 3)}> <For each={props.followers.slice(0, 3)}>
{(f) => ( {(f) => (
<Userpic size={'XS'} name={f.name} userpic={f.pic} class={styles.subscribersItem} /> <Userpic size={'XS'} name={f.name} userpic={f.pic} class={styles.subscribersItem} />
@ -155,7 +155,7 @@ export const AuthorCard = (props: Props) => {
</Show> </Show>
<Show when={props.following && props.following.length > 0}> <Show when={props.following && props.following.length > 0}>
<a href="?modal=following" class={styles.subscribers}> <a href="?m=following" class={styles.subscribers}>
<For each={props.following.slice(0, 3)}> <For each={props.following.slice(0, 3)}>
{(f) => { {(f) => {
if ('name' in f) { if ('name' in f) {

View File

@ -30,9 +30,7 @@ export const Donate = () => {
const initiated = () => { const initiated = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const { const CloudPayments = window['cp'] // Checkout(cpOptions)
cp: { CloudPayments },
} = window as any // Checkout(cpOptions)
setWidget(new CloudPayments()) setWidget(new CloudPayments())
console.log('[donate] payments initiated') console.log('[donate] payments initiated')
setCustomerReciept({ setCustomerReciept({
@ -68,7 +66,7 @@ export const Donate = () => {
script.src = 'https://widget.cloudpayments.ru/bundles/cloudpayments.js' script.src = 'https://widget.cloudpayments.ru/bundles/cloudpayments.js'
script.async = true script.async = true
script.addEventListener('load', initiated) script.addEventListener('load', initiated)
document.head.appendChild(script) document.head.append(script)
}) })
const show = () => { const show = () => {

View File

@ -46,6 +46,8 @@ import { Figcaption } from './extensions/Figcaption'
import { Figure } from './extensions/Figure' import { Figure } from './extensions/Figure'
import { Footnote } from './extensions/Footnote' import { Footnote } from './extensions/Footnote'
import { Iframe } from './extensions/Iframe' import { Iframe } from './extensions/Iframe'
import { Span } from './extensions/Span'
import { ToggleTextWrap } from './extensions/ToggleTextWrap'
import { TrailingNode } from './extensions/TrailingNode' import { TrailingNode } from './extensions/TrailingNode'
import { TextBubbleMenu } from './TextBubbleMenu' import { TextBubbleMenu } from './TextBubbleMenu'
@ -201,6 +203,8 @@ export const Editor = (props: Props) => {
CustomBlockquote, CustomBlockquote,
Bold, Bold,
Italic, Italic,
Span,
ToggleTextWrap,
Strike, Strike,
HorizontalRule.configure({ HorizontalRule.configure({
HTMLAttributes: { HTMLAttributes: {
@ -208,7 +212,10 @@ export const Editor = (props: Props) => {
}, },
}), }),
Underline, Underline,
Link.configure({ Link.extend({
inclusive: false,
}).configure({
autolink: true,
openOnClick: false, openOnClick: false,
}), }),
Heading.configure({ Heading.configure({
@ -244,6 +251,7 @@ export const Editor = (props: Props) => {
Figure, Figure,
Figcaption, Figcaption,
Footnote, Footnote,
ToggleTextWrap,
CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689 CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
BubbleMenu.configure({ BubbleMenu.configure({
pluginKey: 'textBubbleMenu', pluginKey: 'textBubbleMenu',
@ -252,6 +260,9 @@ export const Editor = (props: Props) => {
const { doc, selection } = state const { doc, selection } = state
const { empty } = selection const { empty } = selection
const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection) const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection)
if (isEmptyTextBlock) {
e.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
}
setIsCommonMarkup(e.isActive('figcaption')) setIsCommonMarkup(e.isActive('figcaption'))
const result = const result =
(view.hasFocus() && (view.hasFocus() &&
@ -345,7 +356,7 @@ export const Editor = (props: Props) => {
}) })
onCleanup(() => { onCleanup(() => {
editor().destroy() editor()?.destroy()
}) })
return ( return (

View File

@ -92,7 +92,7 @@ export const Panel = (props: Props) => {
<section> <section>
<p> <p>
<span class={styles.link} onClick={() => showModal('inviteCoAuthors')}> <span class={styles.link} onClick={() => showModal('inviteMembers')}>
{t('Invite co-authors')} {t('Invite co-authors')}
</span> </span>
</p> </p>

View File

@ -311,3 +311,10 @@ footnote {
background-color: unset; background-color: unset;
} }
} }
.highlight-fake-selection {
background: var(--selection-background);
color: var(--selection-color);
border: solid var(--selection-background);
border-width: 0;
}

View File

@ -117,7 +117,10 @@ const SimplifiedEditor = (props: Props) => {
Paragraph, Paragraph,
Bold, Bold,
Italic, Italic,
Link.configure({ Link.extend({
inclusive: false,
}).configure({
autolink: true,
openOnClick: false, openOnClick: false,
}), }),
CharacterCount.configure({ CharacterCount.configure({

View File

@ -129,11 +129,21 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
}) })
}) })
const handleOpenLinkForm = () => {
props.editor.chain().focus().addTextWrap({ class: 'highlight-fake-selection' }).run()
setLinkEditorOpen(true)
}
const handleCloseLinkForm = () => {
setLinkEditorOpen(false)
props.editor.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
}
return ( return (
<div ref={props.ref} class={clsx(styles.TextBubbleMenu, { [styles.growWidth]: footnoteEditorOpen() })}> <div ref={props.ref} class={clsx(styles.TextBubbleMenu, { [styles.growWidth]: footnoteEditorOpen() })}>
<Switch> <Switch>
<Match when={linkEditorOpen()}> <Match when={linkEditorOpen()}>
<InsertLinkForm editor={props.editor} onClose={() => setLinkEditorOpen(false)} /> <InsertLinkForm editor={props.editor} onClose={handleCloseLinkForm} />
</Match> </Match>
<Match when={footnoteEditorOpen()}> <Match when={footnoteEditorOpen()}>
<SimplifiedEditor <SimplifiedEditor
@ -329,7 +339,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
<button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
onClick={() => setLinkEditorOpen(true)} onClick={handleOpenLinkForm}
class={clsx(styles.bubbleMenuButton, { class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isLink(), [styles.bubbleMenuButtonActive]: isLink(),
})} })}

View File

@ -56,7 +56,7 @@ export const TopicSelect = (props: TopicSelectProps) => {
return item.label return item.label
} }
const isMainTopic = item.id === props.mainTopic.id const isMainTopic = item.id === props.mainTopic?.id
return ( return (
<div <div

View File

@ -3,7 +3,7 @@ import { Node } from '@tiptap/core'
export interface IframeOptions { export interface IframeOptions {
allowFullscreen: boolean allowFullscreen: boolean
HTMLAttributes: { HTMLAttributes: {
[key: string]: any [key: string]: string | number
} }
} }
@ -41,6 +41,8 @@ export const Iframe = Node.create<IframeOptions>({
default: this.options.allowFullscreen, default: this.options.allowFullscreen,
parseHTML: () => this.options.allowFullscreen, parseHTML: () => this.options.allowFullscreen,
}, },
width: { default: null },
height: { default: null },
} }
}, },

View File

@ -0,0 +1,31 @@
import { Mark, mergeAttributes } from '@tiptap/core'
export const Span = Mark.create({
name: 'span',
parseHTML() {
return [
{
tag: 'span[class]',
getAttrs: (dom) => {
if (dom instanceof HTMLElement) {
return { class: dom.getAttribute('class') }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes), 0]
},
addAttributes() {
return {
class: {
default: null,
},
}
},
})

View File

@ -0,0 +1,50 @@
import { Extension } from '@tiptap/core'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
toggleSpanWrap: {
addTextWrap: (attributes: { class: string }) => ReturnType
removeTextWrap: (attributes: { class: string }) => ReturnType
}
}
}
export const ToggleTextWrap = Extension.create({
name: 'toggleTextWrap',
addCommands() {
return {
addTextWrap:
(attributes) =>
({ commands, state: _s }) => {
return commands.setMark('span', attributes)
},
removeTextWrap:
(attributes) =>
({ state, dispatch }) => {
let tr = state.tr
let changesApplied = false
state.doc.descendants((node, pos) => {
if (node.isInline) {
node.marks.forEach((mark) => {
if (mark.type.name === 'span' && mark.attrs.class === attributes.class) {
const end = pos + node.nodeSize
tr = tr.removeMark(pos, end, mark.type)
changesApplied = true
}
})
}
})
if (changesApplied) {
dispatch(tr)
return true
} else {
return false
}
},
}
},
})

View File

@ -440,7 +440,6 @@
@include media-breakpoint-down(xl) { @include media-breakpoint-down(xl) {
aspect-ratio: auto; aspect-ratio: auto;
height: 100%; height: 100%;
padding-top: 30%;
} }
swiper-slide & { swiper-slide & {
@ -502,7 +501,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: end; justify-content: end;
padding: 2.4rem; padding: 30% 2.4rem 2.4rem;
z-index: 1; z-index: 1;
@include media-breakpoint-down(xl) { @include media-breakpoint-down(xl) {

View File

@ -7,12 +7,10 @@ import { createMemo, createSignal, For, Show } from 'solid-js'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session' import { useSession } from '../../../context/session'
import { router, useRouter } from '../../../stores/router' import { router, useRouter } from '../../../stores/router'
import { showModal } from '../../../stores/ui'
import { capitalize } from '../../../utils/capitalize' import { capitalize } from '../../../utils/capitalize'
import { getDescription } from '../../../utils/meta' import { getDescription } from '../../../utils/meta'
import { Icon } from '../../_shared/Icon' import { Icon } from '../../_shared/Icon'
import { Image } from '../../_shared/Image' import { Image } from '../../_shared/Image'
import { InviteCoAuthorsModal } from '../../_shared/InviteCoAuthorsModal'
import { Popover } from '../../_shared/Popover' import { Popover } from '../../_shared/Popover'
import { CoverImage } from '../../Article/CoverImage' import { CoverImage } from '../../Article/CoverImage'
import { getShareUrl, SharePopup } from '../../Article/SharePopup' import { getShareUrl, SharePopup } from '../../Article/SharePopup'
@ -216,13 +214,13 @@ export const ArticleCard = (props: ArticleCardProps) => {
<a href={getPagePath(router, 'article', { slug: props.article.slug })}> <a href={getPagePath(router, 'article', { slug: props.article.slug })}>
<div class={styles.shoutCardTitle}> <div class={styles.shoutCardTitle}>
<span class={styles.shoutCardLinkWrapper}> <span class={styles.shoutCardLinkWrapper}>
<span class={styles.shoutCardLinkContainer}>{title}</span> <span class={styles.shoutCardLinkContainer} innerHTML={title} />
</span> </span>
</div> </div>
<Show when={!props.settings?.nosubtitle && subtitle}> <Show when={!props.settings?.nosubtitle && subtitle}>
<div class={styles.shoutCardSubtitle}> <div class={styles.shoutCardSubtitle}>
<span class={styles.shoutCardLinkContainer}>{subtitle}</span> <span class={styles.shoutCardLinkContainer} innerHTML={subtitle} />
</div> </div>
</Show> </Show>
</a> </a>
@ -251,6 +249,9 @@ export const ArticleCard = (props: ArticleCardProps) => {
</Show> </Show>
</div> </div>
</Show> </Show>
<Show when={props.article.description}>
<section class={styles.shoutCardDescription} innerHTML={props.article.description} />
</Show>
<Show when={props.settings?.isFeedMode}> <Show when={props.settings?.isFeedMode}>
<Show when={props.article.description}> <Show when={props.article.description}>
<section class={styles.shoutCardDescription} innerHTML={props.article.description} /> <section class={styles.shoutCardDescription} innerHTML={props.article.description} />

View File

@ -1,7 +1,7 @@
import type { PopupProps } from '../../_shared/Popup' import type { PopupProps } from '../../_shared/Popup'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createEffect, createSignal, onMount, Show } from 'solid-js' import { createSignal, Show } from 'solid-js'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { Popup } from '../../_shared/Popup' import { Popup } from '../../_shared/Popup'

View File

@ -33,10 +33,6 @@
margin-right: 1.2rem; margin-right: 1.2rem;
} }
.userpic {
margin-right: 1.2rem;
}
.selected { .selected {
font-weight: 700; font-weight: 700;
} }

View File

@ -33,7 +33,7 @@ const DialogCard = (props: DialogProps) => {
const names = createMemo<string>(() => (companions() || []).map((companion) => companion.name).join(', ')) const names = createMemo<string>(() => (companions() || []).map((companion) => companion.name).join(', '))
return ( return (
<Show when={props.members}> <Show when={props.members.length > 0} fallback={<div>'No chat members'</div>}>
<div <div
class={clsx(styles.DialogCard, { class={clsx(styles.DialogCard, {
[styles.opened]: props.isOpened, [styles.opened]: props.isOpened,
@ -47,7 +47,7 @@ const DialogCard = (props: DialogProps) => {
when={props.isChatHeader} when={props.isChatHeader}
fallback={ fallback={
<div class={styles.avatar}> <div class={styles.avatar}>
<DialogAvatar name={props.members[0].slug} url={props.members[0].pic} /> <DialogAvatar name={props.members[0]?.slug} url={props.members[0]?.pic} />
</div> </div>
} }
> >

View File

@ -66,7 +66,7 @@ export const LoginForm = () => {
setIsEmailNotConfirmed(false) setIsEmailNotConfirmed(false)
setSubmitError('') setSubmitError('')
changeSearchParams({ mode: 'forgot-password' }) changeSearchParams({ mode: 'forgot-password' })
// NOTE: temporary solition, needs logix in authorizer // NOTE: temporary solution, needs logic in authorizer
/* FIXME: /* FIXME:
const { actions: { authorizer } } = useSession() const { actions: { authorizer } } = useSession()
const result = await authorizer().verifyEmail({ token }) const result = await authorizer().verifyEmail({ token })
@ -140,9 +140,9 @@ export const LoginForm = () => {
<div class={styles.authInfo}> <div class={styles.authInfo}>
<div class={styles.warn}>{submitError()}</div> <div class={styles.warn}>{submitError()}</div>
<Show when={isEmailNotConfirmed()}> <Show when={isEmailNotConfirmed()}>
<a href="#" onClick={handleSendLinkAgainClick}> <span class={'link'} onClick={handleSendLinkAgainClick}>
{t('Send link again')} {t('Send link again')}
</a> </span>
</Show> </Show>
</div> </div>
</Show> </Show>
@ -169,7 +169,7 @@ export const LoginForm = () => {
</Show> </Show>
</div> </div>
<PasswordField onInput={(value) => handlePasswordInput(value)} /> <PasswordField variant={'login'} onInput={(value) => handlePasswordInput(value)} />
<div> <div>
<button class={clsx('button', styles.submitButton)} disabled={isSubmitting()} type="submit"> <button class={clsx('button', styles.submitButton)} disabled={isSubmitting()} type="submit">

View File

@ -10,6 +10,7 @@ type Props = {
class?: string class?: string
errorMessage?: (error: string) => void errorMessage?: (error: string) => void
onInput: (value: string) => void onInput: (value: string) => void
variant?: 'login' | 'registration'
} }
export const PasswordField = (props: Props) => { export const PasswordField = (props: Props) => {
@ -49,7 +50,7 @@ export const PasswordField = (props: Props) => {
on( on(
() => error(), () => error(),
() => { () => {
props.errorMessage ?? props.errorMessage(error()) props.errorMessage && props.errorMessage(error())
}, },
{ defer: true }, { defer: true },
), ),
@ -59,7 +60,7 @@ export const PasswordField = (props: Props) => {
<div class={clsx(styles.PassportField, props.class)}> <div class={clsx(styles.PassportField, props.class)}>
<div <div
class={clsx('pretty-form__item', { class={clsx('pretty-form__item', {
'pretty-form__item--error': error(), 'pretty-form__item--error': error() && props.variant !== 'login',
})} })}
> >
<input <input
@ -78,7 +79,7 @@ export const PasswordField = (props: Props) => {
> >
<Icon class={styles.passwordToggleIcon} name={showPassword() ? 'eye-off' : 'eye'} /> <Icon class={styles.passwordToggleIcon} name={showPassword() ? 'eye-off' : 'eye'} />
</button> </button>
<Show when={error()}> <Show when={error() && props.variant !== 'login'}>
<div class={clsx(styles.registerPassword, styles.validationError)}>{error()}</div> <div class={clsx(styles.registerPassword, styles.validationError)}>{error()}</div>
</Show> </Show>
</div> </div>

View File

@ -118,13 +118,29 @@ export const RegisterForm = () => {
setIsSuccess(true) setIsSuccess(true)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
if (error) {
// TODO: move to context/session if (error.message.includes('has already signed up')) {
if (error?.code === 'user_already_exists') { setValidationErrors((errors) => ({
return ...errors,
email: (
<>
{t('User with this email already exists')},{' '}
<span
class={'link'}
onClick={() =>
changeSearchParams({
mode: 'login',
})
}
>
{t('sign in')}
</span>
</>
),
}))
}
console.error(error)
} }
setSubmitError(error.message)
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
@ -138,9 +154,7 @@ export const RegisterForm = () => {
<AuthModalHeader modalType="register" /> <AuthModalHeader modalType="register" />
<Show when={submitError()}> <Show when={submitError()}>
<div class={styles.authInfo}> <div class={styles.authInfo}>
<ul> <div class={styles.warn}>{submitError()}</div>
<li class={styles.warn}>{submitError()}</li>
</ul>
</div> </div>
</Show> </Show>
<div <div

View File

@ -38,12 +38,14 @@
a { a {
border: none !important; border: none !important;
} }
.facebook, .facebook,
.google, .google,
.vk, .vk,
.telegram { .telegram {
border: none; border: none;
} }
.github:hover { .github:hover {
img { img {
filter: invert(1); filter: invert(1);

View File

@ -128,10 +128,10 @@ export const HeaderAuth = (props: Props) => {
<Show when={!isSaveButtonVisible()}> <Show when={!isSaveButtonVisible()}>
<div class={styles.userControlItem}> <div class={styles.userControlItem}>
<button onClick={() => showModal('search')}> <a href="?m=search">
<Icon name="search" class={styles.icon} /> <Icon name="search" class={styles.icon} />
<Icon name="search" class={clsx(styles.icon, styles.iconHover)} /> <Icon name="search" class={clsx(styles.icon, styles.iconHover)} />
</button> </a>
</div> </div>
</Show> </Show>
@ -187,7 +187,7 @@ export const HeaderAuth = (props: Props) => {
when={isAuthenticatedControlsVisible()} when={isAuthenticatedControlsVisible()}
fallback={ fallback={
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose, 'loginbtn')}> <div class={clsx(styles.userControlItem, styles.userControlItemVerbose, 'loginbtn')}>
<a href="?modal=auth&mode=login"> <a href="?m=auth&mode=login">
<span class={styles.textLabel}>{t('Enter')}</span> <span class={styles.textLabel}>{t('Enter')}</span>
<Icon name="key" class={styles.icon} /> <Icon name="key" class={styles.icon} />
{/*<Icon name="user-default" class={clsx(styles.icon, styles.iconHover)} />*/} {/*<Icon name="user-default" class={clsx(styles.icon, styles.iconHover)} />*/}

View File

@ -89,6 +89,13 @@
position: relative; position: relative;
text-align: left; text-align: left;
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
@include media-breakpoint-up(sm) { @include media-breakpoint-up(sm) {
padding: 5rem; padding: 5rem;
} }
@ -116,28 +123,6 @@
height: 90vh; height: 90vh;
} }
.backdrop.isMobile {
z-index: 10002;
top: 56px;
height: calc(100% - 58px);
bottom: 0;
.maxHeight {
height: 100%;
}
.container {
padding: 0;
height: 100%;
min-height: 100%;
}
.modalInner {
padding: 1rem 1rem 0;
height: 100%;
}
}
.modal-search { .modal-search {
background: #000; background: #000;
@ -163,3 +148,25 @@
width: 3.2rem; width: 3.2rem;
} }
} }
.backdrop.isMobile {
z-index: 10002;
top: 56px;
height: calc(100% - 58px);
bottom: 0;
.maxHeight {
height: 100%;
}
.container {
padding: 0;
height: 100%;
min-height: 100%;
}
.modalInner {
padding: 1rem 1rem 0;
height: 100%;
}
}

View File

@ -55,7 +55,7 @@ export const Modal = (props: Props) => {
return ( return (
<Show when={visible()}> <Show when={visible()}>
<div <div
class={clsx(styles.backdrop, { class={clsx(styles.backdrop, [styles[`modal-${props.name}`]], {
[styles.isMobile]: isMobileView(), [styles.isMobile]: isMobileView(),
})} })}
onClick={handleHide} onClick={handleHide}

View File

@ -29,7 +29,7 @@ export const ProfilePopup = (props: ProfilePopupProps) => {
<a href={getPagePath(router, 'drafts')}>{t('Drafts')}</a> <a href={getPagePath(router, 'drafts')}>{t('Drafts')}</a>
</li> </li>
<li> <li>
<a href={`${getPagePath(router, 'author', { slug: author().slug })}?modal=following`}> <a href={`${getPagePath(router, 'author', { slug: author().slug })}?m=following`}>
{t('Subscriptions')} {t('Subscriptions')}
</a> </a>
</li> </li>

View File

@ -1,13 +1,14 @@
@mixin searchFilterControl { @mixin searchFilterControl {
background: rgb(64 64 64 / 50%);
border-radius: 10rem;
color: #fff;
@include font-size(1.4rem); @include font-size(1.4rem);
font-weight: 500;
height: 4rem; height: 4rem;
padding: 0 2rem; padding: 0 2rem;
background: rgb(64 64 64 / 0.5);
border-radius: 10rem;
color: #fff;
font-weight: 500;
white-space: nowrap; white-space: nowrap;
&:hover { &:hover {
@ -15,49 +16,60 @@
} }
&:active { &:active {
color: rgb(255 255 255 / 40%); color: rgb(255 255 255 / 0.4);
} }
} }
.searchForm { .searchContainer {
position: relative; position: relative;
}
.searchField { .searchInput {
background: none; @include font-size(4.8rem);
border: none;
border-bottom: 2px solid #fff;
color: #fff;
@include font-size(4.8rem); width: 100%;
font-weight: bold; padding: 0 0 0.5rem;
outline: none;
padding: 0 0 0.5rem;
&::placeholder { background: none;
color: rgb(255 255 255 / 32%); border: none;
} border-bottom: 2px solid #fff;
color: #fff;
font-weight: bold;
outline: none;
&:not(:placeholder-shown) + .submitControl { &::placeholder {
display: block; color: rgb(255 255 255 / 0.32);
} }
&:not(:placeholder-shown) + .searchButton img {
filter: invert(1);
}
&::-moz-selection,
&::selection {
color: #2638d9;
} }
} }
.submitControl { .searchButton {
display: none;
filter: invert(1);
height: 3.2rem;
position: absolute; position: absolute;
right: 0; right: 0;
top: 2rem; top: 2rem;
width: 3.2rem; width: 3.2rem;
height: 3.2rem;
& img {
filter: invert(0.4);
}
} }
.searchDescription { .searchDescription {
color: rgb(255 255 255 / 64%); margin-bottom: 44px;
@include font-size(1.6rem); @include font-size(1.6rem);
color: rgb(255 255 255 / 0.64);
} }
.topicsList { .topicsList {
@ -65,6 +77,7 @@
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
gap: 1rem; gap: 1rem;
margin-top: 9.6rem !important; margin-top: 9.6rem !important;
} }
@ -95,9 +108,31 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 1rem; gap: 1rem;
margin: 6.4rem 0; margin: 6.4rem 0;
} }
.filterResultsControl { .filterResultsControl {
@include searchFilterControl; @include searchFilterControl;
} }
.searchLoader {
width: 28px;
height: 28px;
border: 5px solid #fff;
border-bottom-color: transparent;
border-radius: 50%;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -1,140 +1,201 @@
import { openPage } from '@nanostores/router' import type { Shout } from '../../../graphql/schema/core.gen'
import { clsx } from 'clsx'
import { createResource, createSignal, For, onCleanup, Show } from 'solid-js'
import { debounce } from 'throttle-debounce'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { router, useRouter } from '../../../stores/router' import { loadShoutsSearch } from '../../../stores/zine/articles'
import { hideModal } from '../../../stores/ui' import { restoreScrollPosition, saveScrollPosition } from '../../../utils/scroll'
import { byScore } from '../../../utils/sortby'
import { Button } from '../../_shared/Button'
import { Icon } from '../../_shared/Icon' import { Icon } from '../../_shared/Icon'
import { FEED_PAGE_SIZE } from '../../Views/Feed/Feed'
import { SearchResultItem } from './SearchResultItem'
import styles from './SearchModal.module.scss' import styles from './SearchModal.module.scss'
// @@TODO handle empty article options after backend support (subtitle, cover, etc.)
// @@TODO implement load more
// @@TODO implement FILTERS & TOPICS
// @@TODO use save/restoreScrollPosition if needed
const getSearchCoincidences = ({ str, intersection }: { str: string; intersection: string }) =>
`<span>${str.replaceAll(
new RegExp(intersection, 'gi'),
(casePreservedMatch) => `<span class="blackModeIntersection">${casePreservedMatch}</span>`,
)}</span>`
const prepareSearchResults = (list: Shout[], searchValue: string) =>
list.sort(byScore()).map((article, index) => ({
...article,
body: article.body,
cover: article.cover,
created_at: article.created_at,
id: index,
slug: article.slug,
authors: article.authors,
topics: article.topics,
title: article.title
? getSearchCoincidences({
str: article.title,
intersection: searchValue,
})
: '',
subtitle: article.subtitle
? getSearchCoincidences({
str: article.subtitle,
intersection: searchValue,
})
: '',
}))
export const SearchModal = () => { export const SearchModal = () => {
const { t } = useLocalize() const { t } = useLocalize()
const { changeSearchParams } = useRouter() const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
let qElement: HTMLInputElement | undefined const [inputValue, setInputValue] = createSignal('')
const [isLoading, setIsLoading] = createSignal(false)
const [offset, setOffset] = createSignal<number>(0)
const [searchResultsList, { refetch: loadSearchResults, mutate: setSearchResultsList }] = createResource<
Shout[] | null
>(
async () => {
setIsLoading(true)
const { hasMore, newShouts } = await loadShoutsSearch({
limit: FEED_PAGE_SIZE,
text: inputValue(),
offset: offset(),
})
setIsLoading(false)
setOffset(newShouts.length)
setIsLoadMoreButtonVisible(hasMore)
return newShouts
},
{
ssrLoadFrom: 'initial',
initialValue: null,
},
)
const submitQuery = async (ev) => { let searchEl: HTMLInputElement
ev.preventDefault() const debouncedLoadMore = debounce(500, loadSearchResults)
changeSearchParams({}, true)
hideModal() const handleQueryInput = async () => {
openPage(router, 'search', { q: qElement.value }) setInputValue(searchEl.value)
if (searchEl.value?.length > 2) {
await debouncedLoadMore()
} else {
setIsLoading(false)
setSearchResultsList(null)
}
} }
const enterQuery = async (ev: KeyboardEvent) => {
setIsLoading(true)
if (ev.key === 'Enter' && inputValue().length > 2) {
await debouncedLoadMore()
} else {
setIsLoading(false)
setSearchResultsList(null)
}
restoreScrollPosition()
setIsLoading(false)
}
// Cleanup the debounce timer when the component unmounts
onCleanup(() => {
debouncedLoadMore.cancel()
// console.debug('[SearchModal] cleanup debouncing search')
})
return ( return (
<form onSubmit={submitQuery} class={styles.searchForm}> <div class={styles.searchContainer}>
<input <input
type="text" type="search"
name="q"
placeholder={t('Site search')} placeholder={t('Site search')}
ref={qElement} class={styles.searchInput}
class={styles.searchField} onInput={handleQueryInput}
onKeyDown={enterQuery}
ref={searchEl}
/> />
<button type="submit" class={styles.submitControl}>
<Icon name="search" />
</button>
<p class={styles.searchDescription}>
Для поиска публикаций, искусства, комментариев, интересных вам авторов и&nbsp;тем, просто начните
вводить ваш запрос
</p>
<ul class={clsx('view-switcher', styles.filterSwitcher)}> <Button
<li class="view-switcher__item view-switcher__item--selected"> class={styles.searchButton}
<button type="button">{t('All')}</button> onClick={debouncedLoadMore}
</li> value={isLoading() ? <div class={styles.searchLoader} /> : <Icon name="search" />}
<li class="view-switcher__item"> />
<button type="button">{t('Publications')}</button>
</li>
<li class="view-switcher__item">
<button type="button">{t('Topics')}</button>
</li>
</ul>
<div class={styles.filterResults}> <p
<button type="button" class={styles.filterResultsControl}> class={styles.searchDescription}
Период времени innerHTML={t(
</button> 'To find publications, art, comments, authors and topics of interest to you, just start typing your query',
<button type="button" class={styles.filterResultsControl}> )}
Рейтинг />
</button>
<button type="button" class={styles.filterResultsControl}>
Тип постов
</button>
<button type="button" class={styles.filterResultsControl}>
Темы
</button>
<button type="button" class={styles.filterResultsControl}>
Авторы
</button>
<button type="button" class={styles.filterResultsControl}>
Сообщества
</button>
</div>
<div class="container-xl"> <Show when={!isLoading()}>
<div class="row"> <Show when={searchResultsList()}>
<div class={clsx('col-md-18 offset-md-2', styles.topicsList)}> <For each={prepareSearchResults(searchResultsList(), inputValue())}>
<button type="button" class={styles.topTopic}> {(article: Shout) => (
За месяц <div>
</button> <SearchResultItem
<button type="button" class={styles.topTopic}> article={article}
#репортажи settings={{
</button> isFloorImportant: true,
<button type="button" class={styles.topTopic}> isSingle: true,
#интервью nodate: true,
</button> }}
<button type="button" class={styles.topTopic}> />
#культура </div>
</button> )}
<button type="button" class={styles.topTopic}> </For>
#поэзия
</button> <Show when={isLoadMoreButtonVisible()}>
<button type="button" class={styles.topTopic}> <p class="load-more-container">
#теории <button class="button" onClick={loadSearchResults}>
</button> {t('Load more')}
<button type="button" class={styles.topTopic}> </button>
#война в украине </p>
</button> </Show>
<button type="button" class={styles.topTopic}> </Show>
#общество
</button> <Show when={Array.isArray(searchResultsList()) && searchResultsList().length === 0}>
<button type="button" class={styles.topTopic}> <p class={styles.searchDescription} innerHTML={t("We couldn't find anything for your request")} />
#Экспериментальная Музыка </Show>
</button> </Show>
<button type="button" class={styles.topTopic}>
Рейтинг 300+ {/* @@TODO handle filter */}
</button> {/* <Show when={FILTERS.length}>
<button type="button" class={styles.topTopic}> <div class={styles.filterResults}>
#Протесты <For each={FILTERS}>
</button> {(filter) => (
<button type="button" class={styles.topTopic}> <button
Музыка type="button"
</button> class={styles.filterResultsControl}
<button type="button" class={styles.topTopic}> onClick={() => setActiveFilter(filter)}
#За линией Маннергейма >
</button> {filter.name}
<button type="button" class={styles.topTopic}> </button>
Тесты )}
</button> </For>
<button type="button" class={styles.topTopic}> </div>
Коллективные истории </Show> */}
</button>
<button type="button" class={styles.topTopic}> {/* @@TODO handle topics */}
#личный опыт {/* <Show when={TOPICS.length}>
</button> <div class="container-xl">
<button type="button" class={styles.topTopic}> <div class="row">
Тоня Самсонова <div class={clsx('col-md-18 offset-md-2', styles.topicsList)}>
</button> <For each={TOPICS}>
<button type="button" class={styles.topTopic}> {(topic) => (
#личный опыт <button type="button" class={styles.topTopic} onClick={() => setActiveTopic(topic)}>
</button> {topic.name}
<button type="button" class={styles.topTopic}> </button>
#Секс )}
</button> </For>
<button type="button" class={styles.topTopic}> </div>
Молоко Plus
</button>
</div> </div>
</div> </div>
</div> </Show> */}
</form> </div>
) )
} }

View File

@ -0,0 +1,33 @@
import type { Shout } from '../../../graphql/schema/core.gen'
import { ArticleCard } from '../../Feed/ArticleCard'
interface SearchCardProps {
settings?: {
noicon?: boolean
noimage?: boolean
nosubtitle?: boolean
noauthor?: boolean
nodate?: boolean
isGroup?: boolean
photoBottom?: boolean
additionalClass?: string
isFeedMode?: boolean
isFloorImportant?: boolean
isWithCover?: boolean
isBigTitle?: boolean
isVertical?: boolean
isShort?: boolean
withBorder?: boolean
isCompact?: boolean
isSingle?: boolean
isBeside?: boolean
withViewed?: boolean
noAuthorLink?: boolean
}
article: Shout
}
export const SearchResultItem = (props: SearchCardProps) => {
return <ArticleCard article={props.article} settings={props.settings} />
}

View File

@ -6,10 +6,8 @@
overflow: hidden; overflow: hidden;
position: relative; position: relative;
transform: translateY(-2px); transform: translateY(-2px);
width: 100%;
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
overflow: auto;
padding: 0 divide($container-padding-x, 2); padding: 0 divide($container-padding-x, 2);
} }

View File

@ -9,6 +9,7 @@ import { useLocalize } from '../../context/localize'
import { useProfileForm } from '../../context/profile' import { useProfileForm } from '../../context/profile'
import { useSession } from '../../context/session' import { useSession } from '../../context/session'
import { useSnackbar } from '../../context/snackbar' import { useSnackbar } from '../../context/snackbar'
import { showModal, hideModal } from '../../stores/ui'
import { clone } from '../../utils/clone' import { clone } from '../../utils/clone'
import { getImageUrl } from '../../utils/getImageUrl' import { getImageUrl } from '../../utils/getImageUrl'
import { handleImageUpload } from '../../utils/handleImageUpload' import { handleImageUpload } from '../../utils/handleImageUpload'
@ -16,9 +17,11 @@ import { profileSocialLinks } from '../../utils/profileSocialLinks'
import { validateUrl } from '../../utils/validateUrl' import { validateUrl } from '../../utils/validateUrl'
import { Button } from '../_shared/Button' import { Button } from '../_shared/Button'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import { ImageCropper } from '../_shared/ImageCropper'
import { Loading } from '../_shared/Loading' import { Loading } from '../_shared/Loading'
import { Popover } from '../_shared/Popover' import { Popover } from '../_shared/Popover'
import { SocialNetworkInput } from '../_shared/SocialNetworkInput' import { SocialNetworkInput } from '../_shared/SocialNetworkInput'
import { Modal } from '../Nav/Modal'
import { ProfileSettingsNavigation } from '../Nav/ProfileSettingsNavigation' import { ProfileSettingsNavigation } from '../Nav/ProfileSettingsNavigation'
import styles from '../../pages/profile/Settings.module.scss' import styles from '../../pages/profile/Settings.module.scss'
@ -28,12 +31,14 @@ const GrowingTextarea = lazy(() => import('../../components/_shared/GrowingTexta
export const ProfileSettings = () => { export const ProfileSettings = () => {
const { t } = useLocalize() const { t } = useLocalize()
const [prevForm, setPrevForm] = createStore({}) const [prevForm, setPrevForm] = createStore({})
const [isFormInitialized, setIsFormInitialized] = createSignal(false) const [isFormInitialized, setIsFormInitialized] = createSignal(false)
const [social, setSocial] = createSignal([]) const [social, setSocial] = createSignal([])
const [addLinkForm, setAddLinkForm] = createSignal<boolean>(false) const [addLinkForm, setAddLinkForm] = createSignal<boolean>(false)
const [incorrectUrl, setIncorrectUrl] = createSignal<boolean>(false) const [incorrectUrl, setIncorrectUrl] = createSignal<boolean>(false)
const [isUserpicUpdating, setIsUserpicUpdating] = createSignal(false) const [isUserpicUpdating, setIsUserpicUpdating] = createSignal(false)
const [userpicFile, setUserpicFile] = createSignal<any | null>(null)
const [uploadError, setUploadError] = createSignal(false) const [uploadError, setUploadError] = createSignal(false)
const [isFloatingPanelVisible, setIsFloatingPanelVisible] = createSignal(false) const [isFloatingPanelVisible, setIsFloatingPanelVisible] = createSignal(false)
const [hostname, setHostname] = createSignal<string | null>(null) const [hostname, setHostname] = createSignal<string | null>(null)
@ -114,23 +119,32 @@ export const ProfileSettings = () => {
} }
} }
const { selectFiles } = createFileUploader({ multiple: false, accept: 'image/*' }) const handleCropAvatar = () => {
const { selectFiles } = createFileUploader({ multiple: false, accept: 'image/*' })
const handleUploadAvatar = async () => { selectFiles(([uploadFile]) => {
selectFiles(async ([uploadFile]) => { setUserpicFile(uploadFile)
try {
setUploadError(false) showModal('cropImage')
setIsUserpicUpdating(true)
const result = await handleImageUpload(uploadFile)
updateFormField('userpic', result.url)
setIsUserpicUpdating(false)
} catch (error) {
setUploadError(true)
console.error('[upload avatar] error', error)
}
}) })
} }
const handleUploadAvatar = async (uploadFile) => {
try {
setUploadError(false)
setIsUserpicUpdating(true)
const result = await handleImageUpload(uploadFile)
updateFormField('userpic', result.url)
setUserpicFile(null)
setIsUserpicUpdating(false)
} catch (error) {
setUploadError(true)
console.error('[upload avatar] error', error)
}
}
onMount(() => { onMount(() => {
setHostname(window?.location.host) setHostname(window?.location.host)
@ -177,7 +191,7 @@ export const ProfileSettings = () => {
<div class="pretty-form__item"> <div class="pretty-form__item">
<div <div
class={clsx(styles.userpic, { [styles.hasControls]: form.pic })} class={clsx(styles.userpic, { [styles.hasControls]: form.pic })}
onClick={!form.pic && handleUploadAvatar} onClick={handleCropAvatar}
> >
<Switch> <Switch>
<Match when={isUserpicUpdating()}> <Match when={isUserpicUpdating()}>
@ -205,17 +219,19 @@ export const ProfileSettings = () => {
</button> </button>
)} )}
</Popover> </Popover>
<Popover content={t('Upload userpic')}>
{/* @@TODO inspect popover below. onClick causes page refreshing */}
{/* <Popover content={t('Upload userpic')}>
{(triggerRef: (el) => void) => ( {(triggerRef: (el) => void) => (
<button <button
ref={triggerRef} ref={triggerRef}
class={styles.control} class={styles.control}
onClick={handleUploadAvatar} onClick={() => handleCropAvatar()}
> >
<Icon name="user-image-black" /> <Icon name="user-image-black" />
</button> </button>
)} )}
</Popover> </Popover> */}
</div> </div>
</Match> </Match>
<Match when={!form.pic}> <Match when={!form.pic}>
@ -364,6 +380,21 @@ export const ProfileSettings = () => {
</div> </div>
</div> </div>
</Show> </Show>
<Modal variant="medium" name="cropImage" onClose={() => setUserpicFile(null)}>
<h2>{t('Crop image')}</h2>
<Show when={userpicFile()}>
<ImageCropper
uploadFile={userpicFile()}
onSave={(data) => {
handleUploadAvatar(data)
hideModal()
}}
onDecline={() => hideModal()}
/>
</Show>
</Modal>
</> </>
</Show> </Show>
) )

View File

@ -1,6 +1,6 @@
import { openPage } from '@nanostores/router' import { openPage } from '@nanostores/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createSignal, For, onMount, Show } from 'solid-js' import { createSignal, createEffect, For, Show } from 'solid-js'
import { useEditorContext } from '../../../context/editor' import { useEditorContext } from '../../../context/editor'
import { useSession } from '../../../context/session' import { useSession } from '../../../context/session'
@ -13,28 +13,26 @@ import styles from './DraftsView.module.scss'
export const DraftsView = () => { export const DraftsView = () => {
const { isAuthenticated, isSessionLoaded } = useSession() const { isAuthenticated, isSessionLoaded } = useSession()
const [drafts, setDrafts] = createSignal<Shout[]>([]) const [drafts, setDrafts] = createSignal<Shout[]>([])
const loadDrafts = async () => { const loadDrafts = async () => {
const loadedDrafts = await apiClient.getDrafts() if (apiClient.private) {
if (loadedDrafts) setDrafts(loadedDrafts.reverse()) const loadedDrafts = await apiClient.getDrafts()
else setDrafts([]) setDrafts(loadedDrafts || [])
}
} }
onMount(() => { createEffect(async () => {
loadDrafts() if (isSessionLoaded()) await loadDrafts()
}) })
const { const {
actions: { publishShoutById, deleteShout }, actions: { publishShoutById, deleteShout },
} = useEditorContext() } = useEditorContext()
const handleDraftDelete = (shout: Shout) => { const handleDraftDelete = async (shout: Shout) => {
const result = deleteShout(shout.id) const result = deleteShout(shout.id)
if (result) { if (result) await loadDrafts()
loadDrafts()
}
} }
const handleDraftPublish = (shout: Shout) => { const handleDraftPublish = (shout: Shout) => {

View File

@ -14,7 +14,7 @@ import { isDesktop } from '../../utils/media-query'
import { slugify } from '../../utils/slugify' import { slugify } from '../../utils/slugify'
import { DropArea } from '../_shared/DropArea' import { DropArea } from '../_shared/DropArea'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import { InviteCoAuthorsModal } from '../_shared/InviteCoAuthorsModal' import { InviteMembers } from '../_shared/InviteMembers'
import { Popover } from '../_shared/Popover' import { Popover } from '../_shared/Popover'
import { EditorSwiper } from '../_shared/SolidSwiper' import { EditorSwiper } from '../_shared/SolidSwiper'
import { Editor, Panel } from '../Editor' import { Editor, Panel } from '../Editor'
@ -182,7 +182,7 @@ export const EditView = (props: Props) => {
const hasChanges = !deepEqual(form, prevForm) const hasChanges = !deepEqual(form, prevForm)
if (hasChanges) { if (hasChanges) {
setSaving(true) setSaving(true)
if (props.shout.visibility === ShoutVisibility.Authors) { if (props.shout?.visibility === ShoutVisibility.Authors) {
await saveDraft(form) await saveDraft(form)
} else { } else {
saveDraftToLocalStorage(form) saveDraftToLocalStorage(form)
@ -413,7 +413,7 @@ export const EditView = (props: Props) => {
<PublishSettings shoutId={props.shout.id} form={form} /> <PublishSettings shoutId={props.shout.id} form={form} />
</Show> </Show>
<Panel shoutId={props.shout.id} /> <Panel shoutId={props.shout.id} />
<InviteCoAuthorsModal /> <InviteMembers variant={'coauthors'} title={t('Invite experts')} />
</> </>
) )
} }

View File

@ -36,8 +36,12 @@ export const Expo = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
// const { sortedArticles } = useArticlesStore({
// shouts: isLoaded() ? props.shouts : [],
// })
const { sortedArticles } = useArticlesStore({ const { sortedArticles } = useArticlesStore({
shouts: isLoaded() ? props.shouts : [], shouts: props.shouts || [],
layout: props.layout,
}) })
const getLoadShoutsFilters = (additionalFilters: LoadShoutsFilters = {}): LoadShoutsFilters => { const getLoadShoutsFilters = (additionalFilters: LoadShoutsFilters = {}): LoadShoutsFilters => {

View File

@ -17,7 +17,7 @@ import { useTopicsStore } from '../../../stores/zine/topics'
import { getImageUrl } from '../../../utils/getImageUrl' import { getImageUrl } from '../../../utils/getImageUrl'
import { DropDown } from '../../_shared/DropDown' import { DropDown } from '../../_shared/DropDown'
import { Icon } from '../../_shared/Icon' import { Icon } from '../../_shared/Icon'
import { InviteCoAuthorsModal } from '../../_shared/InviteCoAuthorsModal' import { InviteMembers } from '../../_shared/InviteMembers'
import { Loading } from '../../_shared/Loading' import { Loading } from '../../_shared/Loading'
import { ShareModal } from '../../_shared/ShareModal' import { ShareModal } from '../../_shared/ShareModal'
import { CommentDate } from '../../Article/CommentDate' import { CommentDate } from '../../Article/CommentDate'
@ -48,14 +48,14 @@ type VisibilityItem = {
} }
type FeedSearchParams = { type FeedSearchParams = {
by: 'publish_date' | 'rating' | 'last_comment' by: 'publish_date' | 'likes_stat' | 'rating' | 'last_comment'
period: FeedPeriod period: FeedPeriod
visibility: VisibilityMode visibility: VisibilityMode
} }
const getOrderBy = (by: FeedSearchParams['by']) => { const getOrderBy = (by: FeedSearchParams['by']) => {
if (by === 'rating') { if (by === 'likes_stat' || by === 'rating') {
return 'rating_stat' return 'likes_stat'
} }
if (by === 'last_comment') { if (by === 'last_comment') {
@ -305,7 +305,7 @@ export const FeedView = (props: Props) => {
{(article) => ( {(article) => (
<ArticleCard <ArticleCard
onShare={(shared) => handleShare(shared)} onShare={(shared) => handleShare(shared)}
onInvite={() => showModal('inviteCoAuthors')} onInvite={() => showModal('inviteMembers')}
article={article} article={article}
settings={{ isFeedMode: true }} settings={{ isFeedMode: true }}
desktopCoverSize="M" desktopCoverSize="M"
@ -432,7 +432,7 @@ export const FeedView = (props: Props) => {
shareUrl={getShareUrl({ pathname: `/${shareData().slug}` })} shareUrl={getShareUrl({ pathname: `/${shareData().slug}` })}
/> />
</Show> </Show>
<InviteCoAuthorsModal title={t('Invite experts')} /> <InviteMembers title={t('Invite experts')} variant={'coauthors'} />
</div> </div>
) )
} }

View File

@ -3,7 +3,7 @@ import { useLocalize } from '../../context/localize'
import styles from '../../styles/FeedSettings.module.scss' import styles from '../../styles/FeedSettings.module.scss'
// type FeedSettingsSearchParams = { // type FeedSettingsSearchParams = {
// by: '' | 'topics' | 'authors' | 'reacted' // by: '' | 'topics' | 'authors' | 'shouts'
// } // }
export const FeedSettingsView = (_props) => { export const FeedSettingsView = (_props) => {
@ -25,7 +25,7 @@ export const FeedSettingsView = (_props) => {
<a href="?by=authors">{t('authors')}</a> <a href="?by=authors">{t('authors')}</a>
</li> </li>
<li> <li>
<a href="?by=reacted">{t('reactions')}</a> <a href="?by=shouts">{t('publications')}</a>
</li> </li>
</ul> </ul>

View File

@ -36,7 +36,6 @@ main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 10px; padding: 10px;
height: calc(100% - 10px);
$fadeHeight: 10px; $fadeHeight: 10px;
@ -52,26 +51,6 @@ main {
position: relative; position: relative;
padding: $fadeHeight 0; padding: $fadeHeight 0;
&::before,
&::after {
content: '';
position: absolute;
width: 100%;
right: 0;
z-index: 1;
height: $fadeHeight;
}
&::before {
top: 0;
background: linear-gradient(white, transparent $fadeHeight);
}
&::after {
bottom: 0;
background: linear-gradient(transparent, white $fadeHeight);
}
.dialogs { .dialogs {
scroll-behavior: smooth; scroll-behavior: smooth;
display: flex; display: flex;

View File

@ -1,27 +1,26 @@
import type { Chat, Message as MessageType } from '../../graphql/schema/chat.gen' import type { Chat, Message as MessageType } from '../../../graphql/schema/chat.gen'
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 { For, createSignal, Show, onMount, createEffect, createMemo, on } from 'solid-js' import { For, createSignal, Show, onMount, createEffect, createMemo, on } from 'solid-js'
import { useInbox } from '../../context/inbox' import { useInbox } from '../../../context/inbox'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../../context/localize'
import { useSession } from '../../context/session' import { useSession } from '../../../context/session'
import { useRouter } from '../../stores/router' import { useRouter } from '../../../stores/router'
import { showModal } from '../../stores/ui' import { showModal } from '../../../stores/ui'
// import { AuthorsSortBy, useAuthorsStore } from '../../stores/zine/authors' import { useAuthorsStore } from '../../../stores/zine/authors'
import { Icon } from '../_shared/Icon' import { Icon } from '../../_shared/Icon'
import { Popover } from '../_shared/Popover' import { InviteMembers } from '../../_shared/InviteMembers'
import SimplifiedEditor from '../Editor/SimplifiedEditor' import { Popover } from '../../_shared/Popover'
import CreateModalContent from '../Inbox/CreateModalContent' import SimplifiedEditor from '../../Editor/SimplifiedEditor'
import DialogCard from '../Inbox/DialogCard' import DialogCard from '../../Inbox/DialogCard'
import DialogHeader from '../Inbox/DialogHeader' import DialogHeader from '../../Inbox/DialogHeader'
import { Message } from '../Inbox/Message' import { Message } from '../../Inbox/Message'
import MessagesFallback from '../Inbox/MessagesFallback' import MessagesFallback from '../../Inbox/MessagesFallback'
import Search from '../Inbox/Search' import Search from '../../Inbox/Search'
import { Modal } from '../Nav/Modal'
import styles from '../../styles/Inbox.module.scss' import styles from './Inbox.module.scss'
type InboxSearchParams = { type InboxSearchParams = {
by?: string by?: string
@ -34,7 +33,7 @@ const userSearch = (array: Author[], keyword: string) => {
} }
const handleOpenInviteModal = () => { const handleOpenInviteModal = () => {
showModal('inviteToChat') showModal('inviteMembers')
} }
type Props = { type Props = {
@ -64,16 +63,12 @@ export const InboxView = (props: Props) => {
current: null, current: null,
} }
// Поиск по диалогам
const getQuery = (query) => { const getQuery = (query) => {
if (query().length >= 2) { if (query().length >= 2) {
const match = userSearch(recipients(), query()) const match = userSearch(recipients(), query())
setRecipients(match) setRecipients(match)
} else {
// setRecipients(cashedRecipients())
} }
} }
const handleOpenChat = async (chat: Chat) => { const handleOpenChat = async (chat: Chat) => {
setCurrentDialog(chat) setCurrentDialog(chat)
changeSearchParams({ changeSearchParams({
@ -91,8 +86,6 @@ export const InboxView = (props: Props) => {
} }
} }
onMount(loadChats)
const handleSubmit = async (message: string) => { const handleSubmit = async (message: string) => {
sendMessage({ sendMessage({
body: message, body: message,
@ -129,6 +122,7 @@ export const InboxView = (props: Props) => {
}) })
const chatsToShow = () => { const chatsToShow = () => {
if (!chats()) return
const sorted = chats().sort((a, b) => { const sorted = chats().sort((a, b) => {
return b.updated_at - a.updated_at return b.updated_at - a.updated_at
}) })
@ -181,11 +175,14 @@ export const InboxView = (props: Props) => {
setIsScrollToNewVisible(false) setIsScrollToNewVisible(false)
} }
onMount(async () => {
await loadChats()
})
return ( return (
<div class={clsx('container', styles.Inbox)}> <div class={clsx('container', styles.Inbox)}>
<Modal variant="narrow" name="inviteToChat"> <InviteMembers title={t('Create Chat')} variant={'recipients'} />
<CreateModalContent users={recipients()} /> {/*<CreateModalContent users={recipients()} />*/}
</Modal>
<div class={clsx('row', styles.row)}> <div class={clsx('row', styles.row)}>
<div class={clsx(styles.chatList, 'col-md-8')}> <div class={clsx(styles.chatList, 'col-md-8')}>
<div class={styles.sidebarHeader}> <div class={styles.sidebarHeader}>
@ -195,7 +192,7 @@ export const InboxView = (props: Props) => {
</button> </button>
</div> </div>
<Show when={chatsToShow}> <Show when={chatsToShow()}>
<ul class="view-switcher"> <ul class="view-switcher">
<li class={clsx({ 'view-switcher__item--selected': !sortByPerToPer() && !sortByGroup() })}> <li class={clsx({ 'view-switcher__item--selected': !sortByPerToPer() && !sortByGroup() })}>
<button <button

View File

@ -1,15 +1,16 @@
import { redirectPage } from '@nanostores/router' import { redirectPage } from '@nanostores/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { lazy, Show } from 'solid-js' import { createEffect, createSignal, lazy, onMount, Show } from 'solid-js'
import { createStore } from 'solid-js/store' import { createStore } from 'solid-js/store'
import { ShoutForm, useEditorContext } from '../../../context/editor' import { ShoutForm, useEditorContext } from '../../../context/editor'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session' import { useSession } from '../../../context/session'
import { Topic } from '../../../graphql/schema/core.gen'
import { UploadedFile } from '../../../pages/types' import { UploadedFile } from '../../../pages/types'
import { router } from '../../../stores/router' import { router } from '../../../stores/router'
import { hideModal, showModal } from '../../../stores/ui' import { hideModal, showModal } from '../../../stores/ui'
import { useTopicsStore } from '../../../stores/zine/topics' import { loadAllTopics, useTopicsStore } from '../../../stores/zine/topics'
import { Button } from '../../_shared/Button' import { Button } from '../../_shared/Button'
import { Icon } from '../../_shared/Icon' import { Icon } from '../../_shared/Icon'
import { Image } from '../../_shared/Image' import { Image } from '../../_shared/Image'
@ -40,6 +41,16 @@ export const PublishSettings = (props: Props) => {
const { author } = useSession() const { author } = useSession()
const { sortedTopics } = useTopicsStore() const { sortedTopics } = useTopicsStore()
const [topics, setTopics] = createSignal<Topic[]>(sortedTopics())
onMount(async () => {
await loadAllTopics()
})
createEffect(() => {
setTopics(sortedTopics())
})
const composeDescription = () => { const composeDescription = () => {
if (!props.form.description) { if (!props.form.description) {
const cleanFootnotes = props.form.body.replaceAll(/<footnote data-value=".*?">.*?<\/footnote>/g, '') const cleanFootnotes = props.form.body.replaceAll(/<footnote data-value=".*?">.*?<\/footnote>/g, '')
@ -56,6 +67,7 @@ export const PublishSettings = (props: Props) => {
title: props.form.title, title: props.form.title,
subtitle: props.form.subtitle, subtitle: props.form.subtitle,
description: composeDescription(), description: composeDescription(),
selectedTopics: [],
} }
const { const {
@ -205,9 +217,9 @@ export const PublishSettings = (props: Props) => {
</p> </p>
<div class={styles.inputContainer}> <div class={styles.inputContainer}>
<div class={clsx('pretty-form__item', styles.topicSelectContainer)}> <div class={clsx('pretty-form__item', styles.topicSelectContainer)}>
<Show when={sortedTopics()}> <Show when={topics().length > 0}>
<TopicSelect <TopicSelect
topics={sortedTopics()} topics={topics()}
onChange={handleTopicSelectChange} onChange={handleTopicSelectChange}
selectedTopics={props.form.selectedTopics} selectedTopics={props.form.selectedTopics}
onMainTopicChange={(mainTopic) => setForm('mainTopic', mainTopic)} onMainTopicChange={(mainTopic) => setForm('mainTopic', mainTopic)}
@ -222,7 +234,7 @@ export const PublishSettings = (props: Props) => {
<h4>{t('Collaborators')}</h4> <h4>{t('Collaborators')}</h4>
<Button <Button
variant="primary" variant="primary"
onClick={() => showModal('inviteCoAuthors')} onClick={() => showModal('inviteMembers')}
value={t('Invite collaborators')} value={t('Invite collaborators')}
/> />
</div> </div>

View File

@ -0,0 +1,10 @@
.cropperContainer {
max-height: 55vh;
}
.cropperControls {
display: flex;
justify-content: space-between;
margin-top: 2rem;
}

View File

@ -0,0 +1,78 @@
import 'cropperjs/dist/cropper.css'
import { UploadFile } from '@solid-primitives/upload'
import Cropper from 'cropperjs'
import { createSignal, onMount, Show } from 'solid-js'
import { useLocalize } from '../../../context/localize'
import { Button } from '../Button'
import styles from './ImageCropper.module.scss'
interface CropperProps {
uploadFile: UploadFile
onSave: (any) => void
onDecline?: () => void
}
export const ImageCropper = (props: CropperProps) => {
const { t } = useLocalize()
const imageTagRef: { current: HTMLImageElement } = {
current: null,
}
const [cropper, setCropper] = createSignal(null)
onMount(() => {
if (imageTagRef.current) {
setCropper(
new Cropper(imageTagRef.current, {
viewMode: 1,
aspectRatio: 1,
guides: false,
background: false,
rotatable: false,
autoCropArea: 1,
modal: true,
}),
)
}
})
return (
<div>
<div class={styles.cropperContainer}>
<img
ref={(el) => (imageTagRef.current = el)}
src={props.uploadFile.source}
alt="image crop panel"
/>
</div>
<div class={styles.cropperControls}>
<Show when={props.onDecline}>
<Button variant="secondary" onClick={props.onDecline} value={t('Decline')} />
</Show>
<Button
variant="primary"
onClick={() => {
cropper()
.getCroppedCanvas()
.toBlob((blob) => {
const formData = new FormData()
formData.append('media', blob, props.uploadFile.file.name)
props.onSave({
...props.uploadFile,
file: formData.get('media'),
})
})
}}
value={t('Save')}
/>
</div>
</div>
)
}

View File

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

View File

@ -1,17 +0,0 @@
import { useLocalize } from '../../../context/localize'
import { Modal } from '../../Nav/Modal'
import { UserSearch } from '../UserSearch'
type Props = {
title?: string
}
export const InviteCoAuthorsModal = (props: Props) => {
const { t } = useLocalize()
return (
<Modal variant="medium" name="inviteCoAuthors">
<h2>{props.title || t('Invite collaborators')}</h2>
<UserSearch placeholder={t('Write your colleagues name or email')} onChange={() => {}} />
</Modal>
)
}

View File

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

View File

@ -1,4 +1,4 @@
.UserSearch { .InviteMembers {
.searchHeader { .searchHeader {
display: flex; display: flex;
flex-flow: row nowrap; flex-flow: row nowrap;
@ -32,10 +32,40 @@
} }
} }
.searchButton {
margin: 0;
}
.authors { .authors {
height: 400px; height: 300px;
overflow: auto; overflow: auto;
padding: 1rem 0; margin-top: 1rem;
.author {
cursor: pointer;
&:hover {
background: var(--black-100);
}
}
}
.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;
}
} }
.teaser { .teaser {
@ -46,4 +76,11 @@
justify-content: center; justify-content: center;
text-align: center; text-align: center;
} }
.actions {
display: flex;
margin-top: 1rem;
flex-direction: row;
justify-content: space-between;
}
} }

View File

@ -0,0 +1,187 @@
import { createInfiniteScroll } from '@solid-primitives/pagination'
import { clsx } from 'clsx'
import { createEffect, createSignal, For, on, Show } from 'solid-js'
import { useInbox } from '../../../context/inbox'
import { useLocalize } from '../../../context/localize'
import { Author } from '../../../graphql/schema/core.gen'
import { hideModal } from '../../../stores/ui'
import { useAuthorsStore } from '../../../stores/zine/authors'
import { AuthorBadge } from '../../Author/AuthorBadge'
import { Modal } from '../../Nav/Modal'
import { Button } from '../Button'
import { DropdownSelect } from '../DropdownSelect'
import { Loading } from '../Loading'
import styles from './InviteMembers.module.scss'
type InviteAuthor = Author & { selected: boolean }
type Props = {
title?: string
variant?: 'coauthors' | 'recipients'
}
const PAGE_SIZE = 50
export const InviteMembers = (props: Props) => {
const { t } = useLocalize()
const roles = [
{
title: t('Editor'),
description: t('Can write and edit text directly, and accept or reject suggestions from others'),
},
{
title: t('Co-author'),
description: t('Can make any changes, accept or reject suggestions, and share access with others'),
},
{
title: t('Commentator'),
description: t('Can offer edits and comments, but cannot edit the post or share access with others'),
},
]
const { sortedAuthors } = useAuthorsStore({ sortBy: 'name' })
const {
actions: { loadChats, createChat },
} = useInbox()
const [authorsToInvite, setAuthorsToInvite] = createSignal<InviteAuthor[]>()
const [searchResultAuthors, setSearchResultAuthors] = createSignal<Author[]>()
const [collectionToInvite, setCollectionToInvite] = createSignal<number[]>([])
const fetcher = async (page: number) => {
await new Promise((resolve, reject) => {
const checkDataLoaded = () => {
if (sortedAuthors().length > 0) {
resolve(true)
} else {
setTimeout(checkDataLoaded, 100)
}
}
setTimeout(() => reject(new Error('Timeout waiting for sortedAuthors')), 10000)
checkDataLoaded()
})
const start = page * PAGE_SIZE
const end = start + PAGE_SIZE
const authors = authorsToInvite()?.map((author) => ({ ...author, selected: false }))
return authors?.slice(start, end)
}
const [pages, infiniteScrollLoader, { end }] = createInfiniteScroll(fetcher)
createEffect(
on(
() => sortedAuthors(),
(currentAuthors) => {
setAuthorsToInvite(currentAuthors.map((author) => ({ ...author, selected: false })))
},
{ defer: true },
),
)
const handleInputChange = async (value: string) => {
if (value.length > 1) {
const match = authorsToInvite().filter((author) =>
author.name.toLowerCase().includes(value.toLowerCase()),
)
setSearchResultAuthors(match)
} else {
setSearchResultAuthors()
}
}
const handleInvite = (id) => {
setCollectionToInvite((prev) => [...prev, id])
}
const handleCloseModal = () => {
setSearchResultAuthors()
setCollectionToInvite()
hideModal()
}
const handleCreate = async () => {
try {
const initChat = await createChat(collectionToInvite(), 'chat Title')
console.debug('[components.Inbox] create chat result:', initChat)
hideModal()
await loadChats()
} catch (error) {
console.error('handleCreate chat', error)
}
}
return (
<Modal variant="medium" name="inviteMembers">
<h2>{props.title || t('Invite collaborators')}</h2>
<div class={clsx(styles.InviteMembers)}>
<div class={styles.searchHeader}>
<div class={styles.field}>
<input
class={styles.input}
type="text"
placeholder={t('Write your colleagues name or email')}
onChange={(e) => {
if (props.variant === 'recipients') return
handleInputChange(e.target.value)
}}
onInput={(e) => {
if (props.variant === 'coauthors') return
handleInputChange(e.target.value)
}}
/>
<Show when={props.variant === 'coauthors'}>
<DropdownSelect selectItems={roles} />
</Show>
</div>
<Show when={props.variant === 'coauthors'}>
<Button class={styles.searchButton} variant={'bordered'} size={'M'} value={t('Search')} />
</Show>
</div>
<Show when={props.variant === 'coauthors'}>
<div class={styles.teaser}>
<h3>{t('Coming soon')}</h3>
<p>
{t(
'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',
)}
</p>
</div>
</Show>
<Show when={props.variant === 'recipients'}>
<div class={styles.authors}>
<For each={searchResultAuthors() ?? pages()}>
{(author) => (
<div class={styles.author}>
<AuthorBadge
author={author}
nameOnly={true}
inviteView={true}
onInvite={(id) => handleInvite(id)}
/>
</div>
)}
</For>
<Show when={!end()}>
<div use:infiniteScrollLoader class={styles.loading}>
<div class={styles.icon}>
<Loading size="tiny" />
</div>
<div>{t('Loading')}</div>
</div>
</Show>
</div>
</Show>
<div class={styles.actions}>
<Button variant={'bordered'} size={'M'} value={t('Cancel')} onClick={handleCloseModal} />
<Button
variant={'primary'}
size={'M'}
disabled={collectionToInvite().length === 0}
value={t('Start dialog')}
onClick={handleCreate}
/>
</div>
</div>
</Modal>
)
}

View File

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

View File

@ -8,7 +8,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 10000; z-index: 99999;
animation: 300ms fadeIn; animation: 300ms fadeIn;
animation-fill-mode: forwards; animation-fill-mode: forwards;
@ -23,20 +23,20 @@
border-radius: 100%; border-radius: 100%;
position: fixed; position: fixed;
z-index: 1001; z-index: 1001;
top: 20px; top: -40px;
right: 40px; right: -40px;
font-size: 30px; font-size: 30px;
color: white; color: white;
cursor: pointer; cursor: pointer;
width: 36px; width: 80px;
height: 36px; height: 80px;
.icon { .icon {
height: 20px; bottom: 16px;
left: 50%; height: 15px;
top: 50%; left: 16px;
transform: translate(-50%, -50%); position: absolute;
width: 20px; width: 15px;
} }
} }
@ -93,12 +93,10 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 10001; z-index: 10001;
font-size: 1.2rem; font-size: 1.2rem;
border-radius: 6px; border-radius: 6px;
background-color: rgb(0 0 0 / 80%); background-color: rgb(0 0 0 / 80%);
color: #fff; color: #fff;
pointer-events: none; pointer-events: none;
} }

View File

@ -30,6 +30,12 @@ export const Lightbox = (props: Props) => {
current: null, current: null,
} }
const handleSmoothAction = (action: () => void) => {
setTransitionEnabled(true)
action()
setTimeout(() => setTransitionEnabled(false), TRANSITION_SPEED)
}
const closeLightbox = () => { const closeLightbox = () => {
lightboxRef.current?.classList.add(styles.fadeOut) lightboxRef.current?.classList.add(styles.fadeOut)
@ -40,34 +46,45 @@ export const Lightbox = (props: Props) => {
const zoomIn = (event) => { const zoomIn = (event) => {
event.stopPropagation() event.stopPropagation()
setTransitionEnabled(true)
setZoomLevel(zoomLevel() * ZOOM_STEP) handleSmoothAction(() => {
setTimeout(() => setTransitionEnabled(false), TRANSITION_SPEED) setZoomLevel(zoomLevel() * ZOOM_STEP)
})
} }
const zoomOut = (event) => { const zoomOut = (event) => {
event.stopPropagation() event.stopPropagation()
setTransitionEnabled(true)
setZoomLevel(zoomLevel() / ZOOM_STEP) handleSmoothAction(() => {
setTimeout(() => setTransitionEnabled(false), TRANSITION_SPEED) setZoomLevel(zoomLevel() / ZOOM_STEP)
})
}
const positionReset = () => {
setTranslateX(0)
setTranslateY(0)
} }
const zoomReset = (event) => { const zoomReset = (event) => {
event.stopPropagation() event.stopPropagation()
setZoomLevel(1)
handleSmoothAction(() => {
setZoomLevel(1)
positionReset()
})
} }
const handleWheelZoom = (event) => { const handleMouseWheelZoom = (event) => {
event.preventDefault() event.preventDefault()
event.stopPropagation()
let scale = zoomLevel() let scale = zoomLevel()
scale += event.deltaY * -0.01 scale += event.deltaY * -0.01
scale = Math.min(Math.max(0.125, scale), 4) scale = Math.min(Math.max(0.125, scale), 4)
setTransitionEnabled(true) handleSmoothAction(() => {
setZoomLevel(scale * ZOOM_STEP) setZoomLevel(scale * ZOOM_STEP)
})
} }
useEscKeyDownHandler(closeLightbox) useEscKeyDownHandler(closeLightbox)
@ -130,14 +147,15 @@ export const Lightbox = (props: Props) => {
<div <div
class={clsx(styles.Lightbox, props.class)} class={clsx(styles.Lightbox, props.class)}
onClick={closeLightbox} onClick={closeLightbox}
onWheel={(e) => e.preventDefault()}
ref={(el) => (lightboxRef.current = el)} ref={(el) => (lightboxRef.current = el)}
> >
<Show when={pictureScalePercentage()}> <Show when={pictureScalePercentage()}>
<div class={styles.scalePercentage}>{`${pictureScalePercentage()}%`}</div> <div class={styles.scalePercentage}>{`${pictureScalePercentage()}%`}</div>
</Show> </Show>
<span class={styles.close} onClick={closeLightbox}> <div class={styles.close} onClick={closeLightbox}>
<Icon name="close-white" class={styles.icon} /> <Icon name="close-white" class={styles.icon} />
</span> </div>
<div class={styles.zoomControls}> <div class={styles.zoomControls}>
<button class={styles.control} onClick={(event) => zoomOut(event)}> <button class={styles.control} onClick={(event) => zoomOut(event)}>
&minus; &minus;
@ -154,7 +172,7 @@ export const Lightbox = (props: Props) => {
src={getImageUrl(props.image, { noSizeUrlPart: true })} src={getImageUrl(props.image, { noSizeUrlPart: true })}
alt={props.imageAlt || ''} alt={props.imageAlt || ''}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onWheel={handleWheelZoom} onWheel={handleMouseWheelZoom}
style={lightboxStyle()} style={lightboxStyle()}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
/> />

View File

@ -14,7 +14,7 @@
opacity: 1; opacity: 1;
position: absolute; position: absolute;
text-align: left; text-align: left;
top: calc(100% + 8px); top: calc(100% + 11px);
z-index: 101; z-index: 101;
ul { ul {

View File

@ -52,7 +52,7 @@
} }
.thumbs { .thumbs {
//overflow: hidden; // overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
position: relative; position: relative;
@ -87,6 +87,7 @@
&.mobileView { &.mobileView {
.container { .container {
padding: 0; padding: 0;
.thumbs { .thumbs {
& swiper-slide { & swiper-slide {
// bind to html element <swiper-slide/> // bind to html element <swiper-slide/>
@ -130,7 +131,6 @@
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
max-height: var(--slide-height);
.counter { .counter {
@include font-size(1.2rem); @include font-size(1.2rem);
@ -229,7 +229,7 @@
margin-top: 24px; margin-top: 24px;
* { * {
color: var(--default-color-invert) !important; //Force fix migration errors with inline styles color: var(--default-color-invert) !important; // Force fix migration errors with inline styles
} }
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {

View File

@ -6,7 +6,7 @@ import { useLocalize } from '../../../context/localize'
import styles from './TimeAgo.module.scss' import styles from './TimeAgo.module.scss'
type Props = { type Props = {
date: any date: string | number | Date
class?: string class?: string
} }

View File

@ -1,61 +0,0 @@
import { clsx } from 'clsx'
import { useLocalize } from '../../../context/localize'
import { Button } from '../Button'
import { DropdownSelect } from '../DropdownSelect'
import styles from './UserSearch.module.scss'
type Props = {
class?: string
placeholder: string
onChange: (value: string) => void
}
export const UserSearch = (props: Props) => {
const { t } = useLocalize()
const roles = [
{
title: t('Editor'),
description: t('Can write and edit text directly, and accept or reject suggestions from others'),
},
{
title: t('Co-author'),
description: t('Can make any changes, accept or reject suggestions, and share access with others'),
},
{
title: t('Commentator'),
description: t('Can offer edits and comments, but cannot edit the post or share access with others'),
},
]
const handleInputChange = (value: string) => {
props.onChange(value)
}
return (
<div class={clsx(styles.UserSearch, props.class)}>
<div class={styles.searchHeader}>
<div class={styles.field}>
<input
class={styles.input}
type="text"
placeholder={props.placeholder ?? t('Search')}
onChange={(e) => handleInputChange(e.target.value)}
/>
<DropdownSelect selectItems={roles} />
</div>
<Button variant={'bordered'} size={'M'} value={t('Add')} />
</div>
<div class={styles.teaser}>
<h3>{t('Coming soon')}</h3>
<p>
{t(
'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',
)}
</p>
</div>
</div>
)
}

View File

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

View File

@ -11,7 +11,8 @@ export interface SSEMessage {
id: string id: string
entity: string // follower | shout | reaction entity: string // follower | shout | reaction
action: string // create | delete | update | join | follow | seen action: string // create | delete | update | join | follow | seen
payload: any // Author | Shout | Reaction | Message // eslint-disable-next-line @typescript-eslint/no-explicit-any
payload: any // Author Shout Message Reaction Chat
created_at?: number // unixtime x1000 created_at?: number // unixtime x1000
seen?: boolean seen?: boolean
} }

View File

@ -23,7 +23,7 @@ export type ShoutForm = {
shoutId: number shoutId: number
slug: string slug: string
title: string title: string
subtitle: string subtitle?: string
lead?: string lead?: string
description?: string description?: string
selectedTopics: Topic[] selectedTopics: Topic[]

View File

@ -47,7 +47,11 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => {
const loadNotificationsGrouped = async (options: { after: number; limit?: number; offset?: number }) => { const loadNotificationsGrouped = async (options: { after: number; limit?: number; offset?: number }) => {
if (isAuthenticated() && notifierClient?.private) { if (isAuthenticated() && notifierClient?.private) {
const { notifications: groups, total, unread } = await notifierClient.getNotifications(options) const notificationsResult = await notifierClient.getNotifications(options)
const groups = notificationsResult?.notifications || []
const total = notificationsResult?.total || 0
const unread = notificationsResult?.unread || 0
const newGroupsEntries = groups.reduce((acc, group: NotificationGroup) => { const newGroupsEntries = groups.reduce((acc, group: NotificationGroup) => {
acc[group.id] = group acc[group.id] = group
return acc return acc

View File

@ -43,10 +43,13 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
offset?: number offset?: number
}): Promise<Reaction[]> => { }): Promise<Reaction[]> => {
const reactions = await apiClient.getReactionsBy({ by, limit, offset }) const reactions = await apiClient.getReactionsBy({ by, limit, offset })
const newReactionEntities = reactions.reduce((acc, reaction) => { const newReactionEntities = reactions.reduce(
acc[reaction.id] = reaction (acc: { [reaction_id: number]: Reaction }, reaction: Reaction) => {
return acc acc[reaction.id] = reaction
}, {}) return acc
},
{},
)
setReactionEntities(newReactionEntities) setReactionEntities(newReactionEntities)
return reactions return reactions
} }
@ -78,11 +81,13 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
setReactionEntities(changes) setReactionEntities(changes)
} }
const deleteReaction = async (id: number): Promise<void> => { const deleteReaction = async (reaction_id: number): Promise<void> => {
const reaction = await apiClient.destroyReaction(id) if (reaction_id) {
setReactionEntities({ await apiClient.destroyReaction(reaction_id)
[reaction.id]: undefined, setReactionEntities({
}) [reaction_id]: undefined,
})
}
} }
const updateReaction = async (id: number, input: ReactionInput): Promise<void> => { const updateReaction = async (id: number, input: ReactionInput): Promise<void> => {

View File

@ -47,7 +47,6 @@ export type SessionContextType = {
authError: Accessor<string> authError: Accessor<string>
isSessionLoaded: Accessor<boolean> isSessionLoaded: Accessor<boolean>
subscriptions: Accessor<Result> subscriptions: Accessor<Result>
isAuthWithCallback: Accessor<() => void>
isAuthenticated: Accessor<boolean> isAuthenticated: Accessor<boolean>
actions: { actions: {
loadSession: () => AuthToken | Promise<AuthToken> loadSession: () => AuthToken | Promise<AuthToken>
@ -70,6 +69,8 @@ export type SessionContextType = {
} }
} }
const noop = () => {}
const SessionContext = createContext<SessionContextType>() const SessionContext = createContext<SessionContextType>()
export function useSession() { export function useSession() {
@ -82,7 +83,7 @@ const EMPTY_SUBSCRIPTIONS = {
} }
export const SessionProvider = (props: { export const SessionProvider = (props: {
onStateChangeCallback(state: any): unknown onStateChangeCallback(state: AuthToken): unknown
children: JSX.Element children: JSX.Element
}) => { }) => {
const { t } = useLocalize() const { t } = useLocalize()
@ -250,18 +251,25 @@ export const SessionProvider = (props: {
), ),
) )
// require auth wrapper const [authCallback, setAuthCallback] = createSignal<() => void>(() => {})
const [isAuthWithCallback, setIsAuthWithCallback] = createSignal<() => void>()
const requireAuthentication = async (callback: () => void, modalSource: AuthModalSource) => { const requireAuthentication = async (callback: () => void, modalSource: AuthModalSource) => {
setIsAuthWithCallback(() => callback) setAuthCallback((_cb) => callback)
await loadSession()
if (!session()) { if (!session()) {
showModal('auth', modalSource) await loadSession()
if (!session()) {
showModal('auth', modalSource)
}
} }
} }
createEffect(() => {
const handler = authCallback()
if (handler !== noop) {
handler()
setAuthCallback((_cb) => noop)
}
})
// authorizer api proxy methods // authorizer api proxy methods
const signUp = async (params: SignupInput) => { const signUp = async (params: SignupInput) => {
const authResult: void | AuthToken = await authorizer().signup(params) const authResult: void | AuthToken = await authorizer().signup(params)
@ -337,7 +345,6 @@ export const SessionProvider = (props: {
isSessionLoaded, isSessionLoaded,
author, author,
actions, actions,
isAuthWithCallback,
isAuthenticated, isAuthenticated,
} }

View File

@ -28,6 +28,7 @@ export const inboxClient = {
loadChats: async (options: QueryLoad_ChatsArgs): Promise<Chat[]> => { loadChats: async (options: QueryLoad_ChatsArgs): Promise<Chat[]> => {
const resp = await inboxClient.private.query(myChats, options).toPromise() const resp = await inboxClient.private.query(myChats, options).toPromise()
console.log('!!! resp:', resp)
return resp.data.load_chats.chats return resp.data.load_chats.chats
}, },

View File

@ -1,8 +1,8 @@
import { gql } from '@urql/core' import { gql } from '@urql/core'
export default gql` export default gql`
mutation DeleteReactionMutation($id: Int!) { mutation DeleteReactionMutation($reaction_id: Int!) {
delete_reaction(id: $id) { delete_reaction(reaction_id: $reaction_id) {
error error
reaction { reaction {
id id

View File

@ -47,7 +47,7 @@ export default gql`
published_at published_at
stat { stat {
viewed viewed
reacted
rating rating
commented commented
} }

View File

@ -36,7 +36,7 @@ export default gql`
published_at published_at
stat { stat {
viewed viewed
reacted
rating rating
commented commented
} }

View File

@ -34,7 +34,7 @@ export default gql`
published_at published_at
stat { stat {
viewed viewed
reacted
rating rating
commented commented
} }

View File

@ -28,7 +28,7 @@ export default gql`
published_at published_at
stat { stat {
viewed viewed
reacted
rating rating
} }
} }

View File

@ -31,7 +31,7 @@ export default gql`
published_at published_at
stat { stat {
viewed viewed
reacted
rating rating
} }
} }

View File

@ -36,7 +36,7 @@ export default gql`
published_at published_at
stat { stat {
viewed viewed
reacted
rating rating
commented commented
} }

View File

@ -52,7 +52,7 @@ export default gql`
published_at published_at
stat { stat {
viewed viewed
reacted
rating rating
commented commented
} }

View File

@ -37,7 +37,7 @@ export default gql`
published_at published_at
stat { stat {
viewed viewed
reacted
rating rating
commented commented
} }

View File

@ -37,13 +37,9 @@ export const ArticlePage = (props: PageProps) => {
}) })
onMount(() => { onMount(() => {
const script = document.createElement('script')
script.async = true
script.src = 'https://ackee.discours.io/increment.js'
script.dataset.ackeeServer = 'https://ackee.discours.io'
script.dataset.ackeeDomainId = '2a6df3a8-53ac-4383-8cc6-73d38cea4524'
try { try {
document.body.appendChild(script) // document.body.appendChild(script)
console.debug('TODO: connect ga')
} catch (error) { } catch (error) {
console.warn(error) console.warn(error)
} }

View File

@ -4,8 +4,7 @@ import { createSignal, onMount } from 'solid-js'
import { PageLayout } from '../components/_shared/PageLayout' import { PageLayout } from '../components/_shared/PageLayout'
import { ShowOnlyOnClient } from '../components/_shared/ShowOnlyOnClient' import { ShowOnlyOnClient } from '../components/_shared/ShowOnlyOnClient'
import { InboxView } from '../components/Views/Inbox' import { InboxView } from '../components/Views/Inbox/Inbox'
import { InboxProvider } from '../context/inbox'
import { useLocalize } from '../context/localize' import { useLocalize } from '../context/localize'
import { loadAllAuthors } from '../stores/zine/authors' import { loadAllAuthors } from '../stores/zine/authors'
@ -24,9 +23,7 @@ export const InboxPage = (props: PageProps) => {
return ( return (
<PageLayout hideFooter={true} title={t('Inbox')}> <PageLayout hideFooter={true} title={t('Inbox')}>
<ShowOnlyOnClient> <ShowOnlyOnClient>
<InboxProvider> <InboxView isLoaded={isLoaded()} authors={props.allAuthors} />
<InboxView isLoaded={isLoaded()} authors={props.allAuthors} />
</InboxProvider>
</ShowOnlyOnClient> </ShowOnlyOnClient>
</PageLayout> </PageLayout>
) )

View File

@ -23,7 +23,7 @@ export type PageProps = {
} }
export type RootSearchParams = { export type RootSearchParams = {
modal: string m: string // modal
lang: string lang: string
} }

View File

@ -1,7 +1,7 @@
import type { PageContext } from './types' import type { PageContext } from './types'
import type { PageContextBuiltInClientWithClientRouting } from 'vike/types' import type { PageContextBuiltInClientWithClientRouting } from 'vike/types'
import * as Sentry from '@sentry/browser' // import * as Sentry from '@sentry/browser'
import i18next from 'i18next' import i18next from 'i18next'
import HttpApi from 'i18next-http-backend' import HttpApi from 'i18next-http-backend'
import ICU from 'i18next-icu' import ICU from 'i18next-icu'
@ -9,7 +9,7 @@ import { hydrate } from 'solid-js/web'
import { App } from '../components/App' import { App } from '../components/App'
import { initRouter } from '../stores/router' import { initRouter } from '../stores/router'
import { SENTRY_DSN } from '../utils/config' // import { SENTRY_DSN } from '../utils/config'
import { resolveHydrationPromise } from '../utils/hydrationPromise' import { resolveHydrationPromise } from '../utils/hydrationPromise'
let layoutReady = false let layoutReady = false
@ -20,13 +20,13 @@ export const render = async (pageContext: PageContextBuiltInClientWithClientRout
const { pathname, search } = window.location const { pathname, search } = window.location
const searchParams = Object.fromEntries(new URLSearchParams(search)) const searchParams = Object.fromEntries(new URLSearchParams(search))
initRouter(pathname, searchParams) initRouter(pathname, searchParams)
/*
if (SENTRY_DSN) { if (SENTRY_DSN) {
Sentry.init({ Sentry.init({
dsn: SENTRY_DSN, dsn: SENTRY_DSN,
}) })
} }
*/
// eslint-disable-next-line import/no-named-as-default-member // eslint-disable-next-line import/no-named-as-default-member
await i18next await i18next
.use(HttpApi) .use(HttpApi)

View File

@ -7,8 +7,7 @@ export type PageContext = PageContextBuiltInClientWithClientRouting & {
Page: (pageProps: PageProps) => Component Page: (pageProps: PageProps) => Component
pageProps: PageProps pageProps: PageProps
lng: string lng: string
// FIXME typing cookies: { [key: string]: string | number | undefined } | null
cookies: any
documentProps?: { documentProps?: {
title?: string title?: string
description?: string description?: string

View File

@ -152,9 +152,14 @@ export const useRouter = <TSearchParams extends Record<string, string> = Record<
searchParamsStore.open(newSearchParams, replace) searchParamsStore.open(newSearchParams, replace)
} }
const clearSearchParams = (replace = false) => {
searchParamsStore.open({}, replace)
}
return { return {
page, page,
searchParams, searchParams,
changeSearchParams, changeSearchParams,
clearSearchParams,
} }
} }

View File

@ -16,7 +16,6 @@ export type ModalType =
| 'thank' | 'thank'
| 'confirm' | 'confirm'
| 'donate' | 'donate'
| 'inviteToChat'
| 'uploadImage' | 'uploadImage'
| 'simplifiedEditorUploadImage' | 'simplifiedEditorUploadImage'
| 'uploadCoverImage' | 'uploadCoverImage'
@ -24,8 +23,9 @@ export type ModalType =
| 'followers' | 'followers'
| 'following' | 'following'
| 'search' | 'search'
| 'inviteCoAuthors' | 'inviteMembers'
| 'share' | 'share'
| 'cropImage'
export const MODALS: Record<ModalType, ModalType> = { export const MODALS: Record<ModalType, ModalType> = {
auth: 'auth', auth: 'auth',
@ -34,21 +34,21 @@ export const MODALS: Record<ModalType, ModalType> = {
thank: 'thank', thank: 'thank',
confirm: 'confirm', confirm: 'confirm',
donate: 'donate', donate: 'donate',
inviteToChat: 'inviteToChat', inviteMembers: 'inviteMembers',
uploadImage: 'uploadImage', uploadImage: 'uploadImage',
simplifiedEditorUploadImage: 'simplifiedEditorUploadImage', simplifiedEditorUploadImage: 'simplifiedEditorUploadImage',
uploadCoverImage: 'uploadCoverImage', uploadCoverImage: 'uploadCoverImage',
editorInsertLink: 'editorInsertLink', editorInsertLink: 'editorInsertLink',
followers: 'followers', followers: 'followers',
following: 'following', following: 'following',
inviteCoAuthors: 'inviteCoAuthors',
search: 'search', search: 'search',
share: 'share', share: 'share',
cropImage: 'cropImage',
} }
const [modal, setModal] = createSignal<ModalType>(null) const [modal, setModal] = createSignal<ModalType>(null)
const { changeSearchParams } = useRouter< const { changeSearchParams, clearSearchParams } = useRouter<
AuthModalSearchParams & ConfirmEmailSearchParams & RootSearchParams AuthModalSearchParams & ConfirmEmailSearchParams & RootSearchParams
>() >()
@ -62,7 +62,10 @@ export const showModal = (modalType: ModalType, modalSource?: AuthModalSource) =
setModal(modalType) setModal(modalType)
} }
export const hideModal = () => setModal(null) export const hideModal = () => {
clearSearchParams()
setModal(null)
}
export const useModalStore = () => { export const useModalStore = () => {
return { return {

View File

@ -22,7 +22,7 @@ const [topMonthArticles, setTopMonthArticles] = createSignal<Shout[]>([])
const articlesByAuthor = createLazyMemo(() => { const articlesByAuthor = createLazyMemo(() => {
return Object.values(articleEntities()).reduce( return Object.values(articleEntities()).reduce(
(acc, article) => { (acc, article) => {
article.authors.forEach((author) => { article.authors?.forEach((author) => {
if (!acc[author.slug]) { if (!acc[author.slug]) {
acc[author.slug] = [] acc[author.slug] = []
} }
@ -183,6 +183,7 @@ export const resetSortedArticles = () => {
type InitialState = { type InitialState = {
shouts?: Shout[] shouts?: Shout[]
layout?: string
} }
const TOP_MONTH_ARTICLES_COUNT = 10 const TOP_MONTH_ARTICLES_COUNT = 10
@ -195,7 +196,7 @@ export const loadTopMonthArticles = async (): Promise<void> => {
published: true, published: true,
after, after,
}, },
order_by: 'rating_stat', order_by: 'likes_stat',
limit: TOP_MONTH_ARTICLES_COUNT, limit: TOP_MONTH_ARTICLES_COUNT,
} }
const articles = await apiClient.getShouts(options) const articles = await apiClient.getShouts(options)
@ -208,7 +209,7 @@ const TOP_ARTICLES_COUNT = 10
export const loadTopArticles = async (): Promise<void> => { export const loadTopArticles = async (): Promise<void> => {
const options: LoadShoutsOptions = { const options: LoadShoutsOptions = {
filters: { published: true }, filters: { published: true },
order_by: 'rating_stat', order_by: 'likes_stat',
limit: TOP_ARTICLES_COUNT, limit: TOP_ARTICLES_COUNT,
} }
const articles = await apiClient.getShouts(options) const articles = await apiClient.getShouts(options)
@ -219,7 +220,14 @@ export const loadTopArticles = async (): Promise<void> => {
export const useArticlesStore = (initialState: InitialState = {}) => { export const useArticlesStore = (initialState: InitialState = {}) => {
addArticles([...(initialState.shouts || [])]) addArticles([...(initialState.shouts || [])])
if (initialState.shouts) { if (initialState.layout) {
// eslint-disable-next-line promise/catch-or-return
loadShouts({ filters: { layouts: [initialState.layout] }, limit: 10 }).then(({ newShouts }) => {
addArticles(newShouts)
setSortedArticles(newShouts)
})
} else if (initialState.shouts) {
addArticles([...initialState.shouts])
setSortedArticles([...initialState.shouts]) setSortedArticles([...initialState.shouts])
} }

View File

@ -18,16 +18,13 @@ const sortedAuthors = createLazyMemo(() => {
const authors = Object.values(authorEntities()) const authors = Object.values(authorEntities())
switch (sortAllBy()) { switch (sortAllBy()) {
case 'followers': { case 'followers': {
authors.sort(byStat('followers')) return authors.sort(byStat('followers'))
break
} }
case 'shouts': { case 'shouts': {
authors.sort(byStat('shouts')) return authors.sort(byStat('shouts'))
break
} }
case 'name': { case 'name': {
authors.sort((a, b) => a.name.localeCompare(b.name)) return authors.sort((a, b) => a.name.localeCompare(b.name))
break
} }
} }
return authors return authors

View File

@ -213,7 +213,6 @@ a:visited,
a:link, a:link,
.link { .link {
color: var(--link-color); color: var(--link-color);
padding-bottom: 0.1em;
transition: transition:
color 0.2s, color 0.2s,
background-color 0.2s; background-color 0.2s;
@ -596,7 +595,9 @@ figure {
figure { figure {
figcaption { figcaption {
color: rgb(0 0 0 / 60%); color: rgb(0 0 0 / 60%);
@include font-size(1.2rem); @include font-size(1.2rem);
line-height: 1.5; line-height: 1.5;
} }
} }
@ -1067,6 +1068,39 @@ iframe {
cursor: pointer; cursor: pointer;
} }
.blackModeIntersection {
color: var(--default-color);
background: #fef2f2;
}
.img-align-column { .img-align-column {
clear: both; clear: both;
} }
.cropper-modal {
background-color: #fff !important;
}
.cropper-canvas {
filter: blur(2px);
}
.cropper-view-box,
.cropper-crop-box,
.cropper-line,
.cropper-point {
box-shadow: none !important;
outline: none !important;
border: none !important;
background-color: transparent !important;
}
.cropper-crop-box {
border: 2px solid #000 !important;
border-radius: 8px;
}
.cropper-view-box,
.cropper-face {
border-radius: 50%;
}

View File

@ -1,6 +1,10 @@
export const isDev = import.meta.env.MODE === 'development' export const isDev = import.meta.env.MODE === 'development'
const defaultThumborUrl = 'https://images.discours.io' const defaultThumborUrl = 'https://images.discours.io'
export const cdnUrl = 'https://cdn.discours.io'
export const thumborUrl = import.meta.env.PUBLIC_THUMBOR_URL || defaultThumborUrl export const thumborUrl = import.meta.env.PUBLIC_THUMBOR_URL || defaultThumborUrl
export const SENTRY_DSN = import.meta.env.PUBLIC_SENTRY_DSN || '' export const SENTRY_DSN = import.meta.env.PUBLIC_SENTRY_DSN || ''
const defaultSearchUrl = 'https://search.discours.io'
export const searchUrl = import.meta.env.PUBLIC_SEARCH_URL || defaultSearchUrl

View File

@ -1,12 +1,10 @@
import { thumborUrl } from './config' import { thumborUrl, cdnUrl } from './config'
const thumborPrefix = `${thumborUrl}/unsafe/` const getSizeUrlPart = (options: { width?: number; height?: number; noSizeUrlPart?: boolean } = {}) => {
const getSizeUrlPart = (options: { width?: number; height?: number } = {}) => {
const widthString = options.width ? options.width.toString() : '' const widthString = options.width ? options.width.toString() : ''
const heightString = options.height ? options.height.toString() : '' const heightString = options.height ? options.height.toString() : ''
if (!widthString && !heightString) { if ((!widthString && !heightString) || options.noSizeUrlPart) {
return '' return ''
} }
@ -17,21 +15,12 @@ export const getImageUrl = (
src: string, src: string,
options: { width?: number; height?: number; noSizeUrlPart?: boolean } = {}, options: { width?: number; height?: number; noSizeUrlPart?: boolean } = {},
) => { ) => {
const sizeUrlPart = getSizeUrlPart(options) const filename = src.split('/').pop()
const isAudio = src.toLowerCase().split('.').pop() in ['wav', 'mp3', 'ogg', 'aif', 'flac']
const base = isAudio ? cdnUrl : `${thumborUrl}/unsafe/`
const sizeUrlPart = isAudio ? '' : getSizeUrlPart(options)
let modifiedSrc = src // Используйте новую переменную вместо переназначения параметра return `${base}${sizeUrlPart}production/${isAudio ? 'audio' : 'image'}/${filename}`
if (options.noSizeUrlPart) {
modifiedSrc = modifiedSrc.replace(/\d+x.*?\//, '')
}
if (src.startsWith(thumborPrefix)) {
const thumborKey = modifiedSrc.replace(thumborPrefix, '')
return `${thumborUrl}/unsafe/${sizeUrlPart}${thumborKey}`
}
return `${thumborUrl}/unsafe/${sizeUrlPart}${modifiedSrc}`
} }
export const getOpenGraphImageUrl = ( export const getOpenGraphImageUrl = (
@ -50,8 +39,8 @@ export const getOpenGraphImageUrl = (
options.author, options.author,
)}','${encodeURIComponent(options.title)}')/` )}','${encodeURIComponent(options.title)}')/`
if (src.startsWith(thumborPrefix)) { if (src.startsWith(thumborUrl)) {
const thumborKey = src.replace(thumborPrefix, '') const thumborKey = src.replace(thumborUrl + '/unsafe', '')
return `${thumborUrl}/unsafe/${sizeUrlPart}${filtersPart}${thumborKey}` return `${thumborUrl}/unsafe/${sizeUrlPart}${filtersPart}${thumborKey}`
} }

View File

@ -1,10 +1,10 @@
const pageLoadManager: { const pageLoadManager: {
promise: Promise<any> promise: Promise<void>
} = { promise: Promise.resolve() } } = { promise: Promise.resolve() }
export const getPageLoadManagerPromise = () => { export const getPageLoadManagerPromise = () => {
return pageLoadManager.promise return pageLoadManager.promise
} }
export const setPageLoadManagerPromise = (promise: Promise<any>) => { export const setPageLoadManagerPromise = (promise: Promise<void>) => {
pageLoadManager.promise = promise pageLoadManager.promise = promise
} }

View File

@ -40,6 +40,16 @@ export const byTopicStatDesc = (metric: keyof TopicStat) => {
} }
} }
export const byScore = () => {
return (a, b) => {
const x = a?.score || 0
const y = b?.score || 0
if (x > y) return -1
if (x < y) return 1
return 0
}
}
export const sortBy = (data, metric) => { export const sortBy = (data, metric) => {
const x = [...data] const x = [...data]
x.sort(typeof metric === 'function' ? metric : byStat(metric)) x.sort(typeof metric === 'function' ? metric : byStat(metric))