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
public
*.cjs
src/graphql/schema/*.gen.ts
dist/
.vercel/
src/graphql/client/*
src/graphql/schema/*

View File

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

View File

@ -1,9 +1,11 @@
name: 'deploy'
on:
push:
branches:
- main
- feature/sse-connect
- dev
- feature/email-templates
jobs:
test:
@ -26,24 +28,40 @@ jobs:
update_mailgun_template:
runs-on: ubuntu-latest
name: Update templates on Mailgun
if: github.event_name == 'push' && github.ref == 'refs/heads/feature/email-templates'
continue-on-error: true
steps:
- name: Checkout
uses: actions/checkout@v2
- name: update authorizer_password_reset
uses: jlepocher/mailgun-create-template-version-action@v1.3
with:
mailgun-host: 'api.eu.mailgun.net'
mailgun-api-key: ${{ secrets.MAILGUN_API_KEY }}
mailgun-domain-name: 'discours.io'
mailgun-template-name: 'authorizer_password_reset'
html-file-path: './templates/authorizer_password_reset.html'
- 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:
mailgun-host: 'api.eu.mailgun.net'
html-file: "./templates/authorizer_email_confirmation.html"
mailgun-api-key: ${{ secrets.MAILGUN_API_KEY }}
mailgun-domain-name: 'discours.io'
mailgun-template-name: 'authorizer_email_confirm'
html-file-path: './templates/authorizer_email_confirm.html'
mailgun-domain: "discours.io"
mailgun-template: "authorizer_email_confirmation"
- name: "Password reset template"
uses: gyto/mailgun-template-action@v2
with:
html-file: "./templates/authorizer_password_reset.html"
mailgun-api-key: ${{ secrets.MAILGUN_API_KEY }}
mailgun-domain: "discours.io"
mailgun-template: "authorizer_password_reset"
- 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"
],
"rules": {
"keyframes-name-pattern": null,
"declaration-block-no-redundant-longhand-properties": null,
"selector-class-pattern": null,
"no-descending-specificity": 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": {
"@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",
"i18next": "22.4.15",
"i18next-icu": "2.3.0",
"idb": "7.1.1",
"intl-messageformat": "10.5.3",
"just-throttle": "4.2.0",
"mailgun.js": "8.2.1"
},
"devDependencies": {

View File

@ -105,6 +105,7 @@
"Create gallery": "Create gallery",
"Create post": "Create post",
"Create video": "Create video",
"Crop image": "Crop image",
"Culture": "Culture",
"Date of Birth": "Date of Birth",
"Decline": "Decline",
@ -203,6 +204,7 @@
"Invalid email": "Check if your email is correct",
"Invalid image URL": "Invalid image URL",
"Invalid url format": "Invalid url format",
"Invite": "Invite",
"Invite co-authors": "Invite co-authors",
"Invite collaborators": "Invite collaborators",
"Invite to collab": "Invite to Collab",
@ -343,6 +345,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 a conversation",
"Start dialog": "Start dialog",
"Subsccriptions": "Subscriptions",
"Subscribe": "Subscribe",
"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 week": "This week",
"This year": "This year",
"To find publications, art, comments, authors and topics of interest to you, just start typing your query": "To find publications, art, comments, authors and topics of interest to you, just start typing your query",
"To leave a comment please": "To leave a comment please",
"To write a comment, you must": "To write a comment, you must",
"Top authors": "Authors rating",
@ -403,6 +407,7 @@
"Upload userpic": "Upload userpic",
"Upload video": "Upload video",
"Uploading image": "Uploading image",
"User with this email already exists": "User with this email already exists",
"Username": "Username",
"Userpic": "Userpic",
"Users": "Users",
@ -411,6 +416,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 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'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",

View File

@ -110,6 +110,7 @@
"Create gallery": "Создать галерею",
"Create post": "Создать публикацию",
"Create video": "Создать видео",
"Crop image": "Кадрировать изображение",
"Culture": "Культура",
"Date of Birth": "Дата рождения",
"Decline": "Отмена",
@ -213,6 +214,7 @@
"Invalid email": "Проверьте правильность ввода почты",
"Invalid image URL": "Некорректная ссылка на изображение",
"Invalid url format": "Неверный формат ссылки",
"Invite": "Пригласить",
"Invite co-authors": "Пригласить соавторов",
"Invite collaborators": "Пригласить соавторов",
"Invite experts": "Пригласить экспертов",
@ -364,6 +366,7 @@
"Special projects": "Спецпроекты",
"Specify the source and the name of the author": "Укажите источник и имя автора",
"Start conversation": "Начать беседу",
"Start dialog": "Начать диалог",
"Subheader": "Подзаголовок",
"Subscribe": "Подписаться",
"Subscribe to comments": "Подписаться на комментарии",
@ -403,6 +406,7 @@
"This way you ll be able to subscribe to authors, interesting topics and customize your feed": "Так вы сможете подписаться на авторов, интересные темы и настроить свою ленту",
"This week": "За неделю",
"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 write a comment, you must": "Чтобы написать комментарий, необходимо",
"Top authors": "Рейтинг авторов",
@ -425,6 +429,7 @@
"Upload userpic": "Загрузить аватар",
"Upload video": "Загрузить видео",
"Uploading image": "Загружаем изображение",
"User with this email already exists": "Пользователь с таким email уже существует",
"Username": "Имя пользователя",
"Userpic": "Аватар",
"Users": "Пользователи",
@ -433,6 +438,7 @@
"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 can't find you, check email or": "Не можем вас найти, проверьте адрес электронной почты или",
"We couldn't find anything for your request": "Мы не смогли ничего найти по вашему запросу",
"We know you, please try to login": "Такой адрес почты уже зарегистрирован, попробуйте залогиниться",
"We've sent you a message with a link to enter our website.": "Мы выслали вам письмо с ссылкой на почту. Перейдите по ссылке в письме, чтобы войти на сайт.",
"Welcome to Discours": "Добро пожаловать в Дискурс",

View File

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

View File

@ -172,11 +172,11 @@ export const CommentsTree = (props: Props) => {
fallback={
<div class={styles.signInMessage}>
{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')}
</a>{' '}
{t('or')}&nbsp;
<a href="?modal=auth&mode=login" class={styles.link}>
<a href="?m=auth&mode=login" class={styles.link}>
{t('sign in')}
</a>
</div>

View File

@ -4,7 +4,7 @@ import { getPagePath } from '@nanostores/router'
import { createPopper } from '@popperjs/core'
import { Link, Meta } from '@solidjs/meta'
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 { useLocalize } from '../../context/localize'
@ -19,7 +19,7 @@ import { getImageUrl, getOpenGraphImageUrl } from '../../utils/getImageUrl'
import { getDescription, getKeywords } from '../../utils/meta'
import { Icon } from '../_shared/Icon'
import { Image } from '../_shared/Image'
import { InviteCoAuthorsModal } from '../_shared/InviteCoAuthorsModal'
import { InviteMembers } from '../_shared/InviteMembers'
import { Lightbox } from '../_shared/Lightbox'
import { Popover } from '../_shared/Popover'
import { ShareModal } from '../_shared/ShareModal'
@ -44,6 +44,11 @@ type Props = {
scrollToComments?: boolean
}
type IframeSize = {
width: number
height: number
}
export type ArticlePageSearchParams = {
scrollTo: 'comments'
commentId: string
@ -182,18 +187,6 @@ export const FullArticle = (props: Props) => {
actions: { loadReactionsBy },
} = useReactions()
onMount(async () => {
await loadReactionsBy({
by: { shout: props.article.slug },
})
setIsReactionsLoaded(true)
})
onMount(() => {
document.title = props.article.title
})
const clickHandlers = []
const documentClickHandlers = []
@ -215,9 +208,9 @@ export const FullArticle = (props: Props) => {
tooltipContent.classList.add(styles.tooltipContent)
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')) {
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, {
title: props.article.title,
topic: mainTopic().title,
@ -328,6 +363,7 @@ export const FullArticle = (props: Props) => {
<div class="wide-container">
<div class="row position-relative">
<article
ref={(el) => (articleContainer.current = el)}
class={clsx('col-md-16 col-lg-14 col-xl-12 offset-md-5', styles.articleContent)}
onClick={handleArticleBodyClick}
>
@ -519,7 +555,7 @@ export const FullArticle = (props: Props) => {
isOwner={canEdit()}
containerCssClass={clsx(stylesHeader.control, styles.articlePopupOpener)}
onShareClick={() => showModal('share')}
onInviteClick={() => showModal('inviteCoAuthors')}
onInviteClick={() => showModal('inviteMembers')}
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
trigger={
<button>
@ -582,7 +618,7 @@ export const FullArticle = (props: Props) => {
<Show when={selectedImage()}>
<Lightbox image={selectedImage()} onClose={handleLightboxClose} />
</Show>
<InviteCoAuthorsModal title={t('Invite experts')} />
<InviteMembers variant={'coauthors'} title={t('Invite experts')} />
<ShareModal
title={props.article.title}
description={description}

View File

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

View File

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

View File

@ -43,8 +43,11 @@
&:hover {
background: unset;
}
}
.name {
@include font-size(1.4rem);
color: var(--default-color);
font-weight: 500;
@ -58,7 +61,6 @@
color: var(--black-400);
font-weight: 500;
}
}
.actions {
flex: 0 20%;

View File

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

View File

@ -142,7 +142,7 @@ export const AuthorCard = (props: Props) => {
>
<div class={styles.subscribersContainer}>
<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)}>
{(f) => (
<Userpic size={'XS'} name={f.name} userpic={f.pic} class={styles.subscribersItem} />
@ -155,7 +155,7 @@ export const AuthorCard = (props: Props) => {
</Show>
<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)}>
{(f) => {
if ('name' in f) {

View File

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

View File

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

View File

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

View File

@ -311,3 +311,10 @@ footnote {
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,
Bold,
Italic,
Link.configure({
Link.extend({
inclusive: false,
}).configure({
autolink: true,
openOnClick: false,
}),
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 (
<div ref={props.ref} class={clsx(styles.TextBubbleMenu, { [styles.growWidth]: footnoteEditorOpen() })}>
<Switch>
<Match when={linkEditorOpen()}>
<InsertLinkForm editor={props.editor} onClose={() => setLinkEditorOpen(false)} />
<InsertLinkForm editor={props.editor} onClose={handleCloseLinkForm} />
</Match>
<Match when={footnoteEditorOpen()}>
<SimplifiedEditor
@ -329,7 +339,7 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
<button
ref={triggerRef}
type="button"
onClick={() => setLinkEditorOpen(true)}
onClick={handleOpenLinkForm}
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isLink(),
})}

View File

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

View File

@ -3,7 +3,7 @@ import { Node } from '@tiptap/core'
export interface IframeOptions {
allowFullscreen: boolean
HTMLAttributes: {
[key: string]: any
[key: string]: string | number
}
}
@ -41,6 +41,8 @@ export const Iframe = Node.create<IframeOptions>({
default: 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) {
aspect-ratio: auto;
height: 100%;
padding-top: 30%;
}
swiper-slide & {
@ -502,7 +501,7 @@
display: flex;
flex-direction: column;
justify-content: end;
padding: 2.4rem;
padding: 30% 2.4rem 2.4rem;
z-index: 1;
@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 { useSession } from '../../../context/session'
import { router, useRouter } from '../../../stores/router'
import { showModal } from '../../../stores/ui'
import { capitalize } from '../../../utils/capitalize'
import { getDescription } from '../../../utils/meta'
import { Icon } from '../../_shared/Icon'
import { Image } from '../../_shared/Image'
import { InviteCoAuthorsModal } from '../../_shared/InviteCoAuthorsModal'
import { Popover } from '../../_shared/Popover'
import { CoverImage } from '../../Article/CoverImage'
import { getShareUrl, SharePopup } from '../../Article/SharePopup'
@ -216,13 +214,13 @@ export const ArticleCard = (props: ArticleCardProps) => {
<a href={getPagePath(router, 'article', { slug: props.article.slug })}>
<div class={styles.shoutCardTitle}>
<span class={styles.shoutCardLinkWrapper}>
<span class={styles.shoutCardLinkContainer}>{title}</span>
<span class={styles.shoutCardLinkContainer} innerHTML={title} />
</span>
</div>
<Show when={!props.settings?.nosubtitle && subtitle}>
<div class={styles.shoutCardSubtitle}>
<span class={styles.shoutCardLinkContainer}>{subtitle}</span>
<span class={styles.shoutCardLinkContainer} innerHTML={subtitle} />
</div>
</Show>
</a>
@ -251,6 +249,9 @@ export const ArticleCard = (props: ArticleCardProps) => {
</Show>
</div>
</Show>
<Show when={props.article.description}>
<section class={styles.shoutCardDescription} innerHTML={props.article.description} />
</Show>
<Show when={props.settings?.isFeedMode}>
<Show when={props.article.description}>
<section class={styles.shoutCardDescription} innerHTML={props.article.description} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -118,13 +118,29 @@ export const RegisterForm = () => {
setIsSuccess(true)
} catch (error) {
console.error(error)
// TODO: move to context/session
if (error?.code === 'user_already_exists') {
return
if (error) {
if (error.message.includes('has already signed up')) {
setValidationErrors((errors) => ({
...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 {
setIsSubmitting(false)
}
@ -138,9 +154,7 @@ export const RegisterForm = () => {
<AuthModalHeader modalType="register" />
<Show when={submitError()}>
<div class={styles.authInfo}>
<ul>
<li class={styles.warn}>{submitError()}</li>
</ul>
<div class={styles.warn}>{submitError()}</div>
</div>
</Show>
<div

View File

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

View File

@ -128,10 +128,10 @@ export const HeaderAuth = (props: Props) => {
<Show when={!isSaveButtonVisible()}>
<div class={styles.userControlItem}>
<button onClick={() => showModal('search')}>
<a href="?m=search">
<Icon name="search" class={styles.icon} />
<Icon name="search" class={clsx(styles.icon, styles.iconHover)} />
</button>
</a>
</div>
</Show>
@ -187,7 +187,7 @@ export const HeaderAuth = (props: Props) => {
when={isAuthenticatedControlsVisible()}
fallback={
<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>
<Icon name="key" class={styles.icon} />
{/*<Icon name="user-default" class={clsx(styles.icon, styles.iconHover)} />*/}

View File

@ -89,6 +89,13 @@
position: relative;
text-align: left;
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
@include media-breakpoint-up(sm) {
padding: 5rem;
}
@ -116,28 +123,6 @@
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 {
background: #000;
@ -163,3 +148,25 @@
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 (
<Show when={visible()}>
<div
class={clsx(styles.backdrop, {
class={clsx(styles.backdrop, [styles[`modal-${props.name}`]], {
[styles.isMobile]: isMobileView(),
})}
onClick={handleHide}

View File

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

View File

@ -1,13 +1,14 @@
@mixin searchFilterControl {
background: rgb(64 64 64 / 50%);
border-radius: 10rem;
color: #fff;
@include font-size(1.4rem);
font-weight: 500;
height: 4rem;
padding: 0 2rem;
background: rgb(64 64 64 / 0.5);
border-radius: 10rem;
color: #fff;
font-weight: 500;
white-space: nowrap;
&:hover {
@ -15,49 +16,60 @@
}
&:active {
color: rgb(255 255 255 / 40%);
color: rgb(255 255 255 / 0.4);
}
}
.searchForm {
.searchContainer {
position: relative;
}
.searchInput {
@include font-size(4.8rem);
width: 100%;
padding: 0 0 0.5rem;
.searchField {
background: none;
border: none;
border-bottom: 2px solid #fff;
color: #fff;
@include font-size(4.8rem);
font-weight: bold;
outline: none;
padding: 0 0 0.5rem;
&::placeholder {
color: rgb(255 255 255 / 32%);
color: rgb(255 255 255 / 0.32);
}
&:not(:placeholder-shown) + .submitControl {
display: block;
}
}
}
.submitControl {
display: none;
&:not(:placeholder-shown) + .searchButton img {
filter: invert(1);
height: 3.2rem;
}
&::-moz-selection,
&::selection {
color: #2638d9;
}
}
.searchButton {
position: absolute;
right: 0;
top: 2rem;
width: 3.2rem;
height: 3.2rem;
& img {
filter: invert(0.4);
}
}
.searchDescription {
color: rgb(255 255 255 / 64%);
margin-bottom: 44px;
@include font-size(1.6rem);
color: rgb(255 255 255 / 0.64);
}
.topicsList {
@ -65,6 +77,7 @@
flex-wrap: wrap;
justify-content: center;
gap: 1rem;
margin-top: 9.6rem !important;
}
@ -95,9 +108,31 @@
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin: 6.4rem 0;
}
.filterResultsControl {
@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 { clsx } from 'clsx'
import type { Shout } from '../../../graphql/schema/core.gen'
import { createResource, createSignal, For, onCleanup, Show } from 'solid-js'
import { debounce } from 'throttle-debounce'
import { useLocalize } from '../../../context/localize'
import { router, useRouter } from '../../../stores/router'
import { hideModal } from '../../../stores/ui'
import { loadShoutsSearch } from '../../../stores/zine/articles'
import { restoreScrollPosition, saveScrollPosition } from '../../../utils/scroll'
import { byScore } from '../../../utils/sortby'
import { Button } from '../../_shared/Button'
import { Icon } from '../../_shared/Icon'
import { FEED_PAGE_SIZE } from '../../Views/Feed/Feed'
import { SearchResultItem } from './SearchResultItem'
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 = () => {
const { t } = useLocalize()
const { changeSearchParams } = useRouter()
let qElement: HTMLInputElement | undefined
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const [inputValue, setInputValue] = createSignal('')
const [isLoading, setIsLoading] = createSignal(false)
const [offset, setOffset] = createSignal<number>(0)
const [searchResultsList, { refetch: loadSearchResults, mutate: setSearchResultsList }] = createResource<
Shout[] | null
>(
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) => {
ev.preventDefault()
changeSearchParams({}, true)
hideModal()
openPage(router, 'search', { q: qElement.value })
let searchEl: HTMLInputElement
const debouncedLoadMore = debounce(500, loadSearchResults)
const handleQueryInput = async () => {
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 (
<form onSubmit={submitQuery} class={styles.searchForm}>
<div class={styles.searchContainer}>
<input
type="text"
name="q"
type="search"
placeholder={t('Site search')}
ref={qElement}
class={styles.searchField}
class={styles.searchInput}
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)}>
<li class="view-switcher__item view-switcher__item--selected">
<button type="button">{t('All')}</button>
</li>
<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>
<Button
class={styles.searchButton}
onClick={debouncedLoadMore}
value={isLoading() ? <div class={styles.searchLoader} /> : <Icon name="search" />}
/>
<div class={styles.filterResults}>
<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>
<button type="button" class={styles.filterResultsControl}>
Сообщества
</button>
<p
class={styles.searchDescription}
innerHTML={t(
'To find publications, art, comments, authors and topics of interest to you, just start typing your query',
)}
/>
<Show when={!isLoading()}>
<Show when={searchResultsList()}>
<For each={prepareSearchResults(searchResultsList(), inputValue())}>
{(article: Shout) => (
<div>
<SearchResultItem
article={article}
settings={{
isFloorImportant: true,
isSingle: true,
nodate: true,
}}
/>
</div>
)}
</For>
<Show when={isLoadMoreButtonVisible()}>
<p class="load-more-container">
<button class="button" onClick={loadSearchResults}>
{t('Load more')}
</button>
</p>
</Show>
</Show>
<Show when={Array.isArray(searchResultsList()) && searchResultsList().length === 0}>
<p class={styles.searchDescription} innerHTML={t("We couldn't find anything for your request")} />
</Show>
</Show>
{/* @@TODO handle filter */}
{/* <Show when={FILTERS.length}>
<div class={styles.filterResults}>
<For each={FILTERS}>
{(filter) => (
<button
type="button"
class={styles.filterResultsControl}
onClick={() => setActiveFilter(filter)}
>
{filter.name}
</button>
)}
</For>
</div>
</Show> */}
{/* @@TODO handle topics */}
{/* <Show when={TOPICS.length}>
<div class="container-xl">
<div class="row">
<div class={clsx('col-md-18 offset-md-2', styles.topicsList)}>
<button type="button" class={styles.topTopic}>
За месяц
</button>
<button type="button" class={styles.topTopic}>
#репортажи
</button>
<button type="button" class={styles.topTopic}>
#интервью
</button>
<button type="button" class={styles.topTopic}>
#культура
</button>
<button type="button" class={styles.topTopic}>
#поэзия
</button>
<button type="button" class={styles.topTopic}>
#теории
</button>
<button type="button" class={styles.topTopic}>
#война в украине
</button>
<button type="button" class={styles.topTopic}>
#общество
</button>
<button type="button" class={styles.topTopic}>
#Экспериментальная Музыка
</button>
<button type="button" class={styles.topTopic}>
Рейтинг 300+
</button>
<button type="button" class={styles.topTopic}>
#Протесты
</button>
<button type="button" class={styles.topTopic}>
Музыка
</button>
<button type="button" class={styles.topTopic}>
#За линией Маннергейма
</button>
<button type="button" class={styles.topTopic}>
Тесты
</button>
<button type="button" class={styles.topTopic}>
Коллективные истории
</button>
<button type="button" class={styles.topTopic}>
#личный опыт
</button>
<button type="button" class={styles.topTopic}>
Тоня Самсонова
</button>
<button type="button" class={styles.topTopic}>
#личный опыт
</button>
<button type="button" class={styles.topTopic}>
#Секс
</button>
<button type="button" class={styles.topTopic}>
Молоко Plus
<For each={TOPICS}>
{(topic) => (
<button type="button" class={styles.topTopic} onClick={() => setActiveTopic(topic)}>
{topic.name}
</button>
)}
</For>
</div>
</div>
</div>
</form>
</Show> */}
</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;
position: relative;
transform: translateY(-2px);
width: 100%;
@include media-breakpoint-down(sm) {
overflow: auto;
padding: 0 divide($container-padding-x, 2);
}

View File

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

View File

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

View File

@ -14,7 +14,7 @@ import { isDesktop } from '../../utils/media-query'
import { slugify } from '../../utils/slugify'
import { DropArea } from '../_shared/DropArea'
import { Icon } from '../_shared/Icon'
import { InviteCoAuthorsModal } from '../_shared/InviteCoAuthorsModal'
import { InviteMembers } from '../_shared/InviteMembers'
import { Popover } from '../_shared/Popover'
import { EditorSwiper } from '../_shared/SolidSwiper'
import { Editor, Panel } from '../Editor'
@ -182,7 +182,7 @@ export const EditView = (props: Props) => {
const hasChanges = !deepEqual(form, prevForm)
if (hasChanges) {
setSaving(true)
if (props.shout.visibility === ShoutVisibility.Authors) {
if (props.shout?.visibility === ShoutVisibility.Authors) {
await saveDraft(form)
} else {
saveDraftToLocalStorage(form)
@ -413,7 +413,7 @@ export const EditView = (props: Props) => {
<PublishSettings shoutId={props.shout.id} form={form} />
</Show>
<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 { sortedArticles } = useArticlesStore({
// shouts: isLoaded() ? props.shouts : [],
// })
const { sortedArticles } = useArticlesStore({
shouts: isLoaded() ? props.shouts : [],
shouts: props.shouts || [],
layout: props.layout,
})
const getLoadShoutsFilters = (additionalFilters: LoadShoutsFilters = {}): LoadShoutsFilters => {

View File

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

View File

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

View File

@ -36,7 +36,6 @@ main {
display: flex;
flex-direction: column;
padding: 10px;
height: calc(100% - 10px);
$fadeHeight: 10px;
@ -52,26 +51,6 @@ main {
position: relative;
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 {
scroll-behavior: smooth;
display: flex;

View File

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

View File

@ -1,15 +1,16 @@
import { redirectPage } from '@nanostores/router'
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 { ShoutForm, useEditorContext } from '../../../context/editor'
import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session'
import { Topic } from '../../../graphql/schema/core.gen'
import { UploadedFile } from '../../../pages/types'
import { router } from '../../../stores/router'
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 { Icon } from '../../_shared/Icon'
import { Image } from '../../_shared/Image'
@ -40,6 +41,16 @@ export const PublishSettings = (props: Props) => {
const { author } = useSession()
const { sortedTopics } = useTopicsStore()
const [topics, setTopics] = createSignal<Topic[]>(sortedTopics())
onMount(async () => {
await loadAllTopics()
})
createEffect(() => {
setTopics(sortedTopics())
})
const composeDescription = () => {
if (!props.form.description) {
const cleanFootnotes = props.form.body.replaceAll(/<footnote data-value=".*?">.*?<\/footnote>/g, '')
@ -56,6 +67,7 @@ export const PublishSettings = (props: Props) => {
title: props.form.title,
subtitle: props.form.subtitle,
description: composeDescription(),
selectedTopics: [],
}
const {
@ -205,9 +217,9 @@ export const PublishSettings = (props: Props) => {
</p>
<div class={styles.inputContainer}>
<div class={clsx('pretty-form__item', styles.topicSelectContainer)}>
<Show when={sortedTopics()}>
<Show when={topics().length > 0}>
<TopicSelect
topics={sortedTopics()}
topics={topics()}
onChange={handleTopicSelectChange}
selectedTopics={props.form.selectedTopics}
onMainTopicChange={(mainTopic) => setForm('mainTopic', mainTopic)}
@ -222,7 +234,7 @@ export const PublishSettings = (props: Props) => {
<h4>{t('Collaborators')}</h4>
<Button
variant="primary"
onClick={() => showModal('inviteCoAuthors')}
onClick={() => showModal('inviteMembers')}
value={t('Invite collaborators')}
/>
</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 {
display: flex;
flex-flow: row nowrap;
@ -32,10 +32,40 @@
}
}
.searchButton {
margin: 0;
}
.authors {
height: 400px;
height: 300px;
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 {
@ -46,4 +76,11 @@
justify-content: 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;
align-items: center;
justify-content: center;
z-index: 10000;
z-index: 99999;
animation: 300ms fadeIn;
animation-fill-mode: forwards;
@ -23,20 +23,20 @@
border-radius: 100%;
position: fixed;
z-index: 1001;
top: 20px;
right: 40px;
top: -40px;
right: -40px;
font-size: 30px;
color: white;
cursor: pointer;
width: 36px;
height: 36px;
width: 80px;
height: 80px;
.icon {
height: 20px;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 20px;
bottom: 16px;
height: 15px;
left: 16px;
position: absolute;
width: 15px;
}
}
@ -93,12 +93,10 @@
align-items: center;
justify-content: center;
z-index: 10001;
font-size: 1.2rem;
border-radius: 6px;
background-color: rgb(0 0 0 / 80%);
color: #fff;
pointer-events: none;
}

View File

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

View File

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

View File

@ -87,6 +87,7 @@
&.mobileView {
.container {
padding: 0;
.thumbs {
& swiper-slide {
// bind to html element <swiper-slide/>
@ -130,7 +131,6 @@
box-sizing: border-box;
overflow: hidden;
width: 100%;
max-height: var(--slide-height);
.counter {
@include font-size(1.2rem);

View File

@ -6,7 +6,7 @@ import { useLocalize } from '../../../context/localize'
import styles from './TimeAgo.module.scss'
type Props = {
date: any
date: string | number | Date
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
entity: string // follower | shout | reaction
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
seen?: boolean
}

View File

@ -23,7 +23,7 @@ export type ShoutForm = {
shoutId: number
slug: string
title: string
subtitle: string
subtitle?: string
lead?: string
description?: string
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 }) => {
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) => {
acc[group.id] = group
return acc

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -37,13 +37,9 @@ export const ArticlePage = (props: PageProps) => {
})
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 {
document.body.appendChild(script)
// document.body.appendChild(script)
console.debug('TODO: connect ga')
} catch (error) {
console.warn(error)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -213,7 +213,6 @@ a:visited,
a:link,
.link {
color: var(--link-color);
padding-bottom: 0.1em;
transition:
color 0.2s,
background-color 0.2s;
@ -596,7 +595,9 @@ figure {
figure {
figcaption {
color: rgb(0 0 0 / 60%);
@include font-size(1.2rem);
line-height: 1.5;
}
}
@ -1067,6 +1068,39 @@ iframe {
cursor: pointer;
}
.blackModeIntersection {
color: var(--default-color);
background: #fef2f2;
}
.img-align-column {
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'
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 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 } = {}) => {
const getSizeUrlPart = (options: { width?: number; height?: number; noSizeUrlPart?: boolean } = {}) => {
const widthString = options.width ? options.width.toString() : ''
const heightString = options.height ? options.height.toString() : ''
if (!widthString && !heightString) {
if ((!widthString && !heightString) || options.noSizeUrlPart) {
return ''
}
@ -17,21 +15,12 @@ export const getImageUrl = (
src: string,
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 // Используйте новую переменную вместо переназначения параметра
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}`
return `${base}${sizeUrlPart}production/${isAudio ? 'audio' : 'image'}/${filename}`
}
export const getOpenGraphImageUrl = (
@ -50,8 +39,8 @@ export const getOpenGraphImageUrl = (
options.author,
)}','${encodeURIComponent(options.title)}')/`
if (src.startsWith(thumborPrefix)) {
const thumborKey = src.replace(thumborPrefix, '')
if (src.startsWith(thumborUrl)) {
const thumborKey = src.replace(thumborUrl + '/unsafe', '')
return `${thumborUrl}/unsafe/${sizeUrlPart}${filtersPart}${thumborKey}`
}

View File

@ -1,10 +1,10 @@
const pageLoadManager: {
promise: Promise<any>
promise: Promise<void>
} = { promise: Promise.resolve() }
export const getPageLoadManagerPromise = () => {
return pageLoadManager.promise
}
export const setPageLoadManagerPromise = (promise: Promise<any>) => {
export const setPageLoadManagerPromise = (promise: Promise<void>) => {
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) => {
const x = [...data]
x.sort(typeof metric === 'function' ? metric : byStat(metric))