Merge branch 'hotfix/posting' of github.com:Discours/discoursio-webapp into feature/sse-connect
Some checks failed
deploy / test (push) Successful in 1m15s
deploy / Update templates on Mailgun (push) Failing after 4s

This commit is contained in:
Untone 2024-02-01 12:52:14 +03:00
commit 43b3361e0e
118 changed files with 2305 additions and 2265 deletions

View File

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

View File

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

1
.gitignore vendored
View File

@ -16,3 +16,4 @@ stats.html
*.scss.d.ts *.scss.d.ts
pnpm-lock.yaml pnpm-lock.yaml
bun.lockb bun.lockb
.jj

View File

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

1598
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -30,13 +30,16 @@
"typecheck:watch": "tsc --noEmit --watch" "typecheck:watch": "tsc --noEmit --watch"
}, },
"dependencies": { "dependencies": {
"@authorizerdev/authorizer-js": "1.2.11", "@authorizerdev/authorizer-js": "2.0.0",
"ackee-tracker": "5.1.0", "@solid-primitives/pagination": "0.2.10",
"cropperjs": "1.6.1",
"form-data": "4.0.0", "form-data": "4.0.0",
"ga-gtag": "1.2.0",
"i18next": "22.4.15", "i18next": "22.4.15",
"i18next-icu": "2.3.0", "i18next-icu": "2.3.0",
"idb": "7.1.1", "idb": "7.1.1",
"intl-messageformat": "10.5.3", "intl-messageformat": "10.5.3",
"just-throttle": "4.2.0",
"mailgun.js": "8.2.1" "mailgun.js": "8.2.1"
}, },
"devDependencies": { "devDependencies": {
@ -147,7 +150,7 @@
"typograf": "7.1.0", "typograf": "7.1.0",
"uniqolor": "1.1.0", "uniqolor": "1.1.0",
"vike": "0.4.148", "vike": "0.4.148",
"vite": "4.5.1", "vite": "4.5.2",
"vite-plugin-mkcert": "1.16.0", "vite-plugin-mkcert": "1.16.0",
"vite-plugin-sass-dts": "1.3.11", "vite-plugin-sass-dts": "1.3.11",
"vite-plugin-solid": "2.7.2", "vite-plugin-solid": "2.7.2",

View File

@ -105,6 +105,7 @@
"Create gallery": "Create gallery", "Create gallery": "Create gallery",
"Create post": "Create post", "Create post": "Create post",
"Create video": "Create video", "Create video": "Create video",
"Crop image": "Crop image",
"Culture": "Culture", "Culture": "Culture",
"Date of Birth": "Date of Birth", "Date of Birth": "Date of Birth",
"Decline": "Decline", "Decline": "Decline",
@ -203,6 +204,7 @@
"Invalid email": "Check if your email is correct", "Invalid email": "Check if your email is correct",
"Invalid image URL": "Invalid image URL", "Invalid image URL": "Invalid image URL",
"Invalid url format": "Invalid url format", "Invalid url format": "Invalid url format",
"Invite": "Invite",
"Invite co-authors": "Invite co-authors", "Invite co-authors": "Invite co-authors",
"Invite collaborators": "Invite collaborators", "Invite collaborators": "Invite collaborators",
"Invite to collab": "Invite to Collab", "Invite to collab": "Invite to Collab",
@ -343,6 +345,7 @@
"Special projects": "Special projects", "Special projects": "Special projects",
"Specify the source and the name of the author": "Specify the source and the name of the author", "Specify the source and the name of the author": "Specify the source and the name of the author",
"Start conversation": "Start a conversation", "Start conversation": "Start a conversation",
"Start dialog": "Start dialog",
"Subsccriptions": "Subscriptions", "Subsccriptions": "Subscriptions",
"Subscribe": "Subscribe", "Subscribe": "Subscribe",
"Subscribe to the best publications newsletter": "Subscribe to the best publications newsletter", "Subscribe to the best publications newsletter": "Subscribe to the best publications newsletter",
@ -381,6 +384,7 @@
"This way you ll be able to subscribe to authors, interesting topics and customize your feed": "This way you ll be able to subscribe to authors, interesting topics and customize your feed", "This way you ll be able to subscribe to authors, interesting topics and customize your feed": "This way you ll be able to subscribe to authors, interesting topics and customize your feed",
"This week": "This week", "This week": "This week",
"This year": "This year", "This year": "This year",
"To find publications, art, comments, authors and topics of interest to you, just start typing your query": "To find publications, art, comments, authors and topics of interest to you, just start typing your query",
"To leave a comment please": "To leave a comment please", "To leave a comment please": "To leave a comment please",
"To write a comment, you must": "To write a comment, you must", "To write a comment, you must": "To write a comment, you must",
"Top authors": "Authors rating", "Top authors": "Authors rating",
@ -403,6 +407,7 @@
"Upload userpic": "Upload userpic", "Upload userpic": "Upload userpic",
"Upload video": "Upload video", "Upload video": "Upload video",
"Uploading image": "Uploading image", "Uploading image": "Uploading image",
"User with this email already exists": "User with this email already exists",
"Username": "Username", "Username": "Username",
"Userpic": "Userpic", "Userpic": "Userpic",
"Users": "Users", "Users": "Users",
@ -411,6 +416,7 @@
"Views": "Views", "Views": "Views",
"We are working on collaborative editing of articles and in the near future you will have an amazing opportunity - to create together with your colleagues": "We are working on collaborative editing of articles and in the near future you will have an amazing opportunity - to create together with your colleagues", "We are working on collaborative editing of articles and in the near future you will have an amazing opportunity - to create together with your colleagues": "We are working on collaborative editing of articles and in the near future you will have an amazing opportunity - to create together with your colleagues",
"We can't find you, check email or": "We can't find you, check email or", "We can't find you, check email or": "We can't find you, check email or",
"We couldn't find anything for your request": "We couldn’t find anything for your request",
"We know you, please try to login": "This email address is already registered, please try to login", "We know you, please try to login": "This email address is already registered, please try to login",
"We've sent you a message with a link to enter our website.": "We've sent you an email with a link to your email. Follow the link in the email to enter our website.", "We've sent you a message with a link to enter our website.": "We've sent you an email with a link to your email. Follow the link in the email to enter our website.",
"Welcome to Discours": "Welcome to Discours", "Welcome to Discours": "Welcome to Discours",

View File

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

View File

@ -7,6 +7,8 @@ import { Dynamic } from 'solid-js/web'
import { ConfirmProvider } from '../context/confirm' import { ConfirmProvider } from '../context/confirm'
import { ConnectProvider } from '../context/connect' import { ConnectProvider } from '../context/connect'
import { EditorProvider } from '../context/editor' import { EditorProvider } from '../context/editor'
import { FollowingProvider } from '../context/following'
import { InboxProvider } from '../context/inbox'
import { LocalizeProvider } from '../context/localize' import { LocalizeProvider } from '../context/localize'
import { MediaQueryProvider } from '../context/mediaQuery' import { MediaQueryProvider } from '../context/mediaQuery'
import { NotificationsProvider } from '../context/notifications' import { NotificationsProvider } from '../context/notifications'
@ -89,14 +91,14 @@ type Props = PageProps & { is404: boolean }
export const App = (props: Props) => { export const App = (props: Props) => {
const { page, searchParams } = useRouter<RootSearchParams>() const { page, searchParams } = useRouter<RootSearchParams>()
let is404 = props.is404 const is404 = createMemo(() => props.is404)
createEffect(() => { createEffect(() => {
if (!searchParams().modal) { if (!searchParams().m) {
hideModal() hideModal()
} }
const modal = MODALS[searchParams().modal] const modal = MODALS[searchParams().m]
if (modal) { if (modal) {
showModal(modal) showModal(modal)
} }
@ -105,8 +107,7 @@ export const App = (props: Props) => {
const pageComponent = createMemo(() => { const pageComponent = createMemo(() => {
const result = pagesMap[page()?.route || 'home'] const result = pagesMap[page()?.route || 'home']
if (is404 || !result || page()?.path === '/404') { if (is404() || !result || page()?.path === '/404') {
is404 = false
return FourOuFourPage return FourOuFourPage
} }
@ -121,13 +122,17 @@ export const App = (props: Props) => {
<SnackbarProvider> <SnackbarProvider>
<ConfirmProvider> <ConfirmProvider>
<SessionProvider onStateChangeCallback={console.log}> <SessionProvider onStateChangeCallback={console.log}>
<ConnectProvider> <FollowingProvider>
<NotificationsProvider> <ConnectProvider>
<EditorProvider> <NotificationsProvider>
<Dynamic component={pageComponent()} {...props} /> <EditorProvider>
</EditorProvider> <InboxProvider>
</NotificationsProvider> <Dynamic component={pageComponent()} {...props} />
</ConnectProvider> </InboxProvider>
</EditorProvider>
</NotificationsProvider>
</ConnectProvider>
</FollowingProvider>
</SessionProvider> </SessionProvider>
</ConfirmProvider> </ConfirmProvider>
</SnackbarProvider> </SnackbarProvider>

View File

@ -1,3 +1,4 @@
import { gtag } from 'ga-gtag'
import { createSignal, For, lazy, Show } from 'solid-js' import { createSignal, For, lazy, Show } from 'solid-js'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
@ -24,6 +25,8 @@ type Props = {
onChangeMediaIndex?: (direction: 'up' | 'down', index) => void onChangeMediaIndex?: (direction: 'up' | 'down', index) => void
} }
const getMediaTitle = (itm: MediaItem, idx: number) => `${idx}. ${itm.artist} - ${itm.title}`
export const PlayerPlaylist = (props: Props) => { export const PlayerPlaylist = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const [activeEditIndex, setActiveEditIndex] = createSignal(-1) const [activeEditIndex, setActiveEditIndex] = createSignal(-1)
@ -34,6 +37,15 @@ export const PlayerPlaylist = (props: Props) => {
const handleMediaItemFieldChange = (field: keyof MediaItem, value: string) => { const handleMediaItemFieldChange = (field: keyof MediaItem, value: string) => {
props.onMediaItemFieldChange(activeEditIndex(), field, value) props.onMediaItemFieldChange(activeEditIndex(), field, value)
} }
const play = (index: number) => {
const mi = props.media[index]
gtag('event', 'select_item', {
item_list_id: props.articleSlug,
item_list_name: getMediaTitle(mi, index),
items: props.media.map((it, ix) => getMediaTitle(it, ix)),
})
}
return ( return (
<ul class={styles.playlist}> <ul class={styles.playlist}>
<For each={props.media}> <For each={props.media}>
@ -42,7 +54,7 @@ export const PlayerPlaylist = (props: Props) => {
<div class={styles.playlistItem}> <div class={styles.playlistItem}>
<button <button
class={styles.playlistItemPlayButton} class={styles.playlistItemPlayButton}
onClick={() => props.onPlayMedia(index())} onClick={() => play(index())}
type="button" type="button"
aria-label="Play" aria-label="Play"
> >

View File

@ -172,11 +172,11 @@ export const CommentsTree = (props: Props) => {
fallback={ fallback={
<div class={styles.signInMessage}> <div class={styles.signInMessage}>
{t('To write a comment, you must')}{' '} {t('To write a comment, you must')}{' '}
<a href="?modal=auth&mode=register" class={styles.link}> <a href="?m=auth&mode=register" class={styles.link}>
{t('sign up')} {t('sign up')}
</a>{' '} </a>{' '}
{t('or')}&nbsp; {t('or')}&nbsp;
<a href="?modal=auth&mode=login" class={styles.link}> <a href="?m=auth&mode=login" class={styles.link}>
{t('sign in')} {t('sign in')}
</a> </a>
</div> </div>

View File

@ -4,7 +4,8 @@ import { getPagePath } from '@nanostores/router'
import { createPopper } from '@popperjs/core' import { createPopper } from '@popperjs/core'
import { Link, Meta } from '@solidjs/meta' import { Link, Meta } from '@solidjs/meta'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createEffect, For, createMemo, onMount, Show, createSignal, onCleanup } from 'solid-js' import { install } from 'ga-gtag'
import { createEffect, For, createMemo, onMount, Show, createSignal, onCleanup, on } from 'solid-js'
import { isServer } from 'solid-js/web' import { isServer } from 'solid-js/web'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
@ -19,7 +20,7 @@ import { getImageUrl, getOpenGraphImageUrl } from '../../utils/getImageUrl'
import { getDescription, getKeywords } from '../../utils/meta' import { getDescription, getKeywords } from '../../utils/meta'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import { Image } from '../_shared/Image' import { Image } from '../_shared/Image'
import { InviteCoAuthorsModal } from '../_shared/InviteCoAuthorsModal' import { InviteMembers } from '../_shared/InviteMembers'
import { Lightbox } from '../_shared/Lightbox' import { Lightbox } from '../_shared/Lightbox'
import { Popover } from '../_shared/Popover' import { Popover } from '../_shared/Popover'
import { ShareModal } from '../_shared/ShareModal' import { ShareModal } from '../_shared/ShareModal'
@ -44,6 +45,11 @@ type Props = {
scrollToComments?: boolean scrollToComments?: boolean
} }
type IframeSize = {
width: number
height: number
}
export type ArticlePageSearchParams = { export type ArticlePageSearchParams = {
scrollTo: 'comments' scrollTo: 'comments'
commentId: string commentId: string
@ -182,18 +188,6 @@ export const FullArticle = (props: Props) => {
actions: { loadReactionsBy }, actions: { loadReactionsBy },
} = useReactions() } = useReactions()
onMount(async () => {
await loadReactionsBy({
by: { shout: props.article.slug },
})
setIsReactionsLoaded(true)
})
onMount(() => {
document.title = props.article.title
})
const clickHandlers = [] const clickHandlers = []
const documentClickHandlers = [] const documentClickHandlers = []
@ -215,9 +209,9 @@ export const FullArticle = (props: Props) => {
tooltipContent.classList.add(styles.tooltipContent) tooltipContent.classList.add(styles.tooltipContent)
tooltipContent.innerHTML = element.dataset.originalTitle || element.dataset.value tooltipContent.innerHTML = element.dataset.originalTitle || element.dataset.value
tooltip.appendChild(tooltipContent) tooltip.append(tooltipContent)
document.body.appendChild(tooltip) document.body.append(tooltip)
if (element.hasAttribute('href')) { if (element.hasAttribute('href')) {
element.setAttribute('href', 'javascript: void(0)') element.setAttribute('href', 'javascript: void(0)')
@ -295,8 +289,49 @@ 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 () => {
install('G-LQ4B87H8C2')
await loadReactionsBy({ by: { shout: props.article.slug } })
setIsReactionsLoaded(true)
document.title = props.article.title
window?.addEventListener('resize', updateIframeSizes)
onCleanup(() => window.removeEventListener('resize', updateIframeSizes))
})
const cover = props.article.cover ?? 'production/image/logo_image.png'
const ogImage = getOpenGraphImageUrl(cover, { const ogImage = getOpenGraphImageUrl(cover, {
title: props.article.title, title: props.article.title,
topic: mainTopic().title, topic: mainTopic().title,
@ -328,6 +363,7 @@ export const FullArticle = (props: Props) => {
<div class="wide-container"> <div class="wide-container">
<div class="row position-relative"> <div class="row position-relative">
<article <article
ref={(el) => (articleContainer.current = el)}
class={clsx('col-md-16 col-lg-14 col-xl-12 offset-md-5', styles.articleContent)} class={clsx('col-md-16 col-lg-14 col-xl-12 offset-md-5', styles.articleContent)}
onClick={handleArticleBodyClick} onClick={handleArticleBodyClick}
> >
@ -519,7 +555,7 @@ export const FullArticle = (props: Props) => {
isOwner={canEdit()} isOwner={canEdit()}
containerCssClass={clsx(stylesHeader.control, styles.articlePopupOpener)} containerCssClass={clsx(stylesHeader.control, styles.articlePopupOpener)}
onShareClick={() => showModal('share')} onShareClick={() => showModal('share')}
onInviteClick={() => showModal('inviteCoAuthors')} onInviteClick={() => showModal('inviteMembers')}
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)} onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
trigger={ trigger={
<button> <button>
@ -582,7 +618,7 @@ export const FullArticle = (props: Props) => {
<Show when={selectedImage()}> <Show when={selectedImage()}>
<Lightbox image={selectedImage()} onClose={handleLightboxClose} /> <Lightbox image={selectedImage()} onClose={handleLightboxClose} />
</Show> </Show>
<InviteCoAuthorsModal title={t('Invite experts')} /> <InviteMembers variant={'coauthors'} title={t('Invite experts')} />
<ShareModal <ShareModal
title={props.article.title} title={props.article.title}
description={description} description={description}

View File

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

View File

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

View File

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

View File

@ -2,17 +2,17 @@ import { openPage } from '@nanostores/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createEffect, createMemo, createSignal, Match, Show, Switch } from 'solid-js' import { createEffect, createMemo, createSignal, Match, Show, Switch } from 'solid-js'
import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { useMediaQuery } from '../../../context/mediaQuery' import { useMediaQuery } from '../../../context/mediaQuery'
import { useSession } from '../../../context/session' import { useSession } from '../../../context/session'
import { Author, FollowingEntity } from '../../../graphql/schema/core.gen' import { Author, FollowingEntity } from '../../../graphql/schema/core.gen'
import { router, useRouter } from '../../../stores/router' import { router, useRouter } from '../../../stores/router'
import { follow, unfollow } from '../../../stores/zine/common'
// import { capitalize } from '../../../utils/capitalize'
import { isCyrillic } from '../../../utils/cyrillic' import { isCyrillic } from '../../../utils/cyrillic'
import { translit } from '../../../utils/ru2en' import { translit } from '../../../utils/ru2en'
import { Button } from '../../_shared/Button' import { Button } from '../../_shared/Button'
import { CheckButton } from '../../_shared/CheckButton' import { CheckButton } from '../../_shared/CheckButton'
import { ConditionalWrapper } from '../../_shared/ConditionalWrapper'
import { Icon } from '../../_shared/Icon' import { Icon } from '../../_shared/Icon'
import { Userpic } from '../Userpic' import { Userpic } from '../Userpic'
@ -25,11 +25,14 @@ type Props = {
showMessageButton?: boolean showMessageButton?: boolean
iconButtons?: boolean iconButtons?: boolean
nameOnly?: boolean nameOnly?: boolean
inviteView?: boolean
onInvite?: (id: number) => void
selected?: boolean
} }
export const AuthorBadge = (props: Props) => { export const AuthorBadge = (props: Props) => {
const { mediaMatches } = useMediaQuery() const { mediaMatches } = useMediaQuery()
const [isMobileView, setIsMobileView] = createSignal(false) const [isMobileView, setIsMobileView] = createSignal(false)
const [isSubscribing, setIsSubscribing] = createSignal(false) const [followed, setFollowed] = createSignal(false)
createEffect(() => { createEffect(() => {
setIsMobileView(!mediaMatches.sm) setIsMobileView(!mediaMatches.sm)
@ -37,33 +40,14 @@ export const AuthorBadge = (props: Props) => {
const { const {
author, author,
subscriptions, actions: { requireAuthentication },
actions: { loadSubscriptions, requireAuthentication },
} = useSession() } = useSession()
const { setFollowing } = useFollowing()
const { changeSearchParams } = useRouter() const { changeSearchParams } = useRouter()
const { t, formatDate, lang } = useLocalize() const { t, formatDate, lang } = useLocalize()
const subscribed = createMemo(() => {
const sss = subscriptions()
return sss?.authors.some((a: Author) => a?.slug === props.author.slug)
})
const subscribe = async (really = true) => {
setIsSubscribing(true)
await (really
? follow({ what: FollowingEntity.Author, slug: props.author.slug })
: unfollow({ what: FollowingEntity.Author, slug: props.author.slug }))
await loadSubscriptions()
setIsSubscribing(false)
}
const handleSubscribe = (really: boolean) => {
requireAuthentication(() => {
subscribe(really)
}, 'subscribe')
}
const initChat = () => { const initChat = () => {
// eslint-disable-next-line solid/reactivity
requireAuthentication(() => { requireAuthentication(() => {
openPage(router, `inbox`) openPage(router, `inbox`)
changeSearchParams({ changeSearchParams({
@ -84,6 +68,14 @@ export const AuthorBadge = (props: Props) => {
return props.author.name return props.author.name
}) })
const handleFollowClick = () => {
const value = !followed()
requireAuthentication(() => {
setFollowed(value)
setFollowing(FollowingEntity.Author, props.author.slug, value)
}, 'subscribe')
}
return ( return (
<div class={clsx(styles.AuthorBadge, { [styles.nameOnly]: props.nameOnly })}> <div class={clsx(styles.AuthorBadge, { [styles.nameOnly]: props.nameOnly })}>
<div class={styles.basicInfo}> <div class={styles.basicInfo}>
@ -94,7 +86,14 @@ export const AuthorBadge = (props: Props) => {
userpic={props.author.pic} userpic={props.author.pic}
slug={props.author.slug} slug={props.author.slug}
/> />
<a href={`/author/${props.author.slug}`} class={styles.info}> <ConditionalWrapper
condition={!props.inviteView}
wrapper={(children) => (
<a href={`/author/${props.author.slug}`} class={styles.info}>
{children}
</a>
)}
>
<div class={styles.name}> <div class={styles.name}>
<span>{name()}</span> <span>{name()}</span>
</div> </div>
@ -118,43 +117,30 @@ export const AuthorBadge = (props: Props) => {
</Match> </Match>
</Switch> </Switch>
</Show> </Show>
</a> </ConditionalWrapper>
</div> </div>
<Show when={props.author.slug !== author()?.slug && !props.nameOnly}> <Show when={props.author.slug !== author()?.slug && !props.nameOnly}>
<div class={styles.actions}> <div class={styles.actions}>
<Show <Show
when={!props.minimizeSubscribeButton} when={!props.minimizeSubscribeButton}
fallback={ fallback={<CheckButton text={t('Follow')} checked={followed()} onClick={handleFollowClick} />}
<CheckButton
text={t('Follow')}
checked={subscribed()}
onClick={() => handleSubscribe(!subscribed())}
/>
}
> >
<Show <Show
when={subscribed()} when={followed()}
fallback={ fallback={
<Button <Button
variant={props.iconButtons ? 'secondary' : 'bordered'} variant={props.iconButtons ? 'secondary' : 'bordered'}
size="S" size="S"
value={ value={
<Show <Show when={props.iconButtons} fallback={t('Subscribe')}>
when={props.iconButtons}
fallback={
<Show when={isSubscribing()} fallback={t('Subscribe')}>
{t('subscribing...')}
</Show>
}
>
<Icon name="author-subscribe" class={stylesButton.icon} /> <Icon name="author-subscribe" class={stylesButton.icon} />
</Show> </Show>
} }
onClick={() => handleSubscribe(true)} onClick={handleFollowClick}
isSubscribeButton={true} isSubscribeButton={true}
class={clsx(styles.actionButton, { class={clsx(styles.actionButton, {
[styles.iconed]: props.iconButtons, [styles.iconed]: props.iconButtons,
[stylesButton.subscribed]: subscribed(), [stylesButton.subscribed]: followed(),
})} })}
/> />
} }
@ -175,11 +161,11 @@ export const AuthorBadge = (props: Props) => {
<Icon name="author-unsubscribe" class={stylesButton.icon} /> <Icon name="author-unsubscribe" class={stylesButton.icon} />
</Show> </Show>
} }
onClick={() => handleSubscribe(false)} onClick={handleFollowClick}
isSubscribeButton={true} isSubscribeButton={true}
class={clsx(styles.actionButton, { class={clsx(styles.actionButton, {
[styles.iconed]: props.iconButtons, [styles.iconed]: props.iconButtons,
[stylesButton.subscribed]: subscribed(), [stylesButton.subscribed]: followed(),
})} })}
/> />
</Show> </Show>
@ -195,6 +181,13 @@ export const AuthorBadge = (props: Props) => {
</Show> </Show>
</div> </div>
</Show> </Show>
<Show when={props.inviteView}>
<CheckButton
text={t('Invite')}
checked={props.selected}
onClick={() => props.onInvite(props.author.id)}
/>
</Show>
</div> </div>
) )
} }

View File

@ -1,15 +1,15 @@
import type { Author } from '../../../graphql/schema/core.gen' import type { Author, Community } from '../../../graphql/schema/core.gen'
import { openPage, redirectPage } from '@nanostores/router' import { openPage, redirectPage } from '@nanostores/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createEffect, createMemo, createSignal, For, Show } from 'solid-js' import { createEffect, createMemo, createSignal, For, onMount, Show } from 'solid-js'
import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session' import { useSession } from '../../../context/session'
import { FollowingEntity, Topic } from '../../../graphql/schema/core.gen' import { FollowingEntity, Topic } from '../../../graphql/schema/core.gen'
import { SubscriptionFilter } from '../../../pages/types' import { SubscriptionFilter } from '../../../pages/types'
import { router, useRouter } from '../../../stores/router' import { router, useRouter } from '../../../stores/router'
import { follow, unfollow } from '../../../stores/zine/common'
import { isCyrillic } from '../../../utils/cyrillic' import { isCyrillic } from '../../../utils/cyrillic'
import { isAuthor } from '../../../utils/isAuthor' import { isAuthor } from '../../../utils/isAuthor'
import { translit } from '../../../utils/ru2en' import { translit } from '../../../utils/ru2en'
@ -33,32 +33,14 @@ export const AuthorCard = (props: Props) => {
const { t, lang } = useLocalize() const { t, lang } = useLocalize()
const { const {
author, author,
subscriptions,
isSessionLoaded, isSessionLoaded,
actions: { loadSubscriptions, requireAuthentication }, actions: { requireAuthentication },
} = useSession() } = useSession()
const [authorSubs, setAuthorSubs] = createSignal<Array<Author | Topic | Community>>([])
const [isSubscribing, setIsSubscribing] = createSignal(false)
const [following, setFollowing] = createSignal<Array<Author | Topic>>(props.following)
const [subscriptionFilter, setSubscriptionFilter] = createSignal<SubscriptionFilter>('all') const [subscriptionFilter, setSubscriptionFilter] = createSignal<SubscriptionFilter>('all')
const subscribed = createMemo<boolean>(() =>
subscriptions().authors.some((a: Author) => a?.slug === props.author.slug),
)
const subscribe = async (really = true) => {
setIsSubscribing(true)
await (really
? follow({ what: FollowingEntity.Author, slug: props.author.slug })
: unfollow({ what: FollowingEntity.Author, slug: props.author.slug }))
await loadSubscriptions()
setIsSubscribing(false)
}
const isProfileOwner = createMemo(() => author()?.slug === props.author.slug) const isProfileOwner = createMemo(() => author()?.slug === props.author.slug)
const [followed, setFollowed] = createSignal()
const { setFollowing } = useFollowing()
const name = createMemo(() => { const name = createMemo(() => {
if (lang() !== 'ru' && isCyrillic(props.author.name)) { if (lang() !== 'ru' && isCyrillic(props.author.name)) {
if (props.author.name === 'Дискурс') { if (props.author.name === 'Дискурс') {
@ -71,9 +53,12 @@ export const AuthorCard = (props: Props) => {
return props.author.name return props.author.name
}) })
onMount(() => setAuthorSubs(props.following))
// TODO: reimplement AuthorCard // TODO: reimplement AuthorCard
const { changeSearchParams } = useRouter() const { changeSearchParams } = useRouter()
const initChat = () => { const initChat = () => {
// eslint-disable-next-line solid/reactivity
requireAuthentication(() => { requireAuthentication(() => {
openPage(router, `inbox`) openPage(router, `inbox`)
changeSearchParams({ changeSearchParams({
@ -82,30 +67,30 @@ export const AuthorCard = (props: Props) => {
}, 'discussions') }, 'discussions')
} }
const handleSubscribe = () => {
requireAuthentication(() => {
subscribe(!subscribed())
}, 'subscribe')
}
createEffect(() => { createEffect(() => {
if (props.following) { if (props.following) {
if (subscriptionFilter() === 'users') { if (subscriptionFilter() === 'authors') {
setFollowing(props.following.filter((s) => 'name' in s)) setAuthorSubs(props.following.filter((s) => 'name' in s))
} else if (subscriptionFilter() === 'topics') { } else if (subscriptionFilter() === 'topics') {
setFollowing(props.following.filter((s) => 'title' in s)) setAuthorSubs(props.following.filter((s) => 'title' in s))
} else if (subscriptionFilter() === 'communities') {
setAuthorSubs(props.following.filter((s) => 'title' in s))
} else { } else {
setFollowing(props.following) setAuthorSubs(props.following)
} }
} }
}) })
const followButtonText = createMemo(() => { const handleFollowClick = () => {
if (isSubscribing()) { const value = !followed()
return t('subscribing...') requireAuthentication(() => {
} setFollowed(value)
setFollowing(FollowingEntity.Author, props.author.slug, value)
}, 'subscribe')
}
if (subscribed()) { const followButtonText = createMemo(() => {
if (followed()) {
return ( return (
<> <>
<span class={stylesButton.buttonSubscribeLabel}>{t('Following')}</span> <span class={stylesButton.buttonSubscribeLabel}>{t('Following')}</span>
@ -142,7 +127,7 @@ export const AuthorCard = (props: Props) => {
> >
<div class={styles.subscribersContainer}> <div class={styles.subscribersContainer}>
<Show when={props.followers && props.followers.length > 0}> <Show when={props.followers && props.followers.length > 0}>
<a href="?modal=followers" class={styles.subscribers}> <a href="?m=followers" class={styles.subscribers}>
<For each={props.followers.slice(0, 3)}> <For each={props.followers.slice(0, 3)}>
{(f) => ( {(f) => (
<Userpic size={'XS'} name={f.name} userpic={f.pic} class={styles.subscribersItem} /> <Userpic size={'XS'} name={f.name} userpic={f.pic} class={styles.subscribersItem} />
@ -155,7 +140,7 @@ export const AuthorCard = (props: Props) => {
</Show> </Show>
<Show when={props.following && props.following.length > 0}> <Show when={props.following && props.following.length > 0}>
<a href="?modal=following" class={styles.subscribers}> <a href="?m=following" class={styles.subscribers}>
<For each={props.following.slice(0, 3)}> <For each={props.following.slice(0, 3)}>
{(f) => { {(f) => {
if ('name' in f) { if ('name' in f) {
@ -214,11 +199,11 @@ export const AuthorCard = (props: Props) => {
fallback={ fallback={
<div class={styles.authorActions}> <div class={styles.authorActions}>
<Button <Button
onClick={handleSubscribe} onClick={handleFollowClick}
value={followButtonText()} value={followButtonText()}
isSubscribeButton={true} isSubscribeButton={true}
class={clsx({ class={clsx({
[stylesButton.subscribed]: subscribed(), [stylesButton.subscribed]: followed(),
})} })}
/> />
<Button <Button
@ -279,8 +264,8 @@ export const AuthorCard = (props: Props) => {
</button> </button>
<span class="view-switcher__counter">{props.following.length}</span> <span class="view-switcher__counter">{props.following.length}</span>
</li> </li>
<li class={clsx({ 'view-switcher__item--selected': subscriptionFilter() === 'users' })}> <li class={clsx({ 'view-switcher__item--selected': subscriptionFilter() === 'authors' })}>
<button type="button" onClick={() => setSubscriptionFilter('users')}> <button type="button" onClick={() => setSubscriptionFilter('authors')}>
{t('Authors')} {t('Authors')}
</button> </button>
<span class="view-switcher__counter"> <span class="view-switcher__counter">
@ -300,7 +285,7 @@ export const AuthorCard = (props: Props) => {
<div class={styles.listWrapper}> <div class={styles.listWrapper}>
<div class="row"> <div class="row">
<div class="col-24"> <div class="col-24">
<For each={following()}> <For each={authorSubs()}>
{(subscription) => {(subscription) =>
isAuthor(subscription) ? ( isAuthor(subscription) ? (
<AuthorBadge author={subscription} /> <AuthorBadge author={subscription} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,10 @@
import type { Shout } from '../../graphql/schema/core.gen' import type { Shout } from '../../graphql/schema/core.gen'
import { createComputed, createSignal, Show, For } from 'solid-js' import { createSignal, createEffect, For, Show } from 'solid-js'
import { ArticleCard } from './ArticleCard' import { ArticleCard } from './ArticleCard'
import { ArticleCardProps } from './ArticleCard/ArticleCard'
const x = [ const columnSizes = ['col-md-12', 'col-md-8', 'col-md-16']
['12', '12'],
['8', '16'],
['16', '8'],
]
export const Row2 = (props: { export const Row2 = (props: {
articles: Shout[] articles: Shout[]
@ -18,10 +13,10 @@ export const Row2 = (props: {
noAuthorLink?: boolean noAuthorLink?: boolean
noauthor?: boolean noauthor?: boolean
}) => { }) => {
const [y, setY] = createSignal(0) const [columnIndex, setColumnIndex] = createSignal(0)
// FIXME: random can break hydration // Update column index on component mount
createComputed(() => setY(Math.floor(Math.random() * x.length))) createEffect(() => setColumnIndex(Math.floor(Math.random() * columnSizes.length)))
return ( return (
<Show when={props.articles && props.articles.length > 0}> <Show when={props.articles && props.articles.length > 0}>
@ -29,31 +24,16 @@ export const Row2 = (props: {
<div class="wide-container"> <div class="wide-container">
<div class="row"> <div class="row">
<For each={props.articles}> <For each={props.articles}>
{(a, i) => { {(article, _idx) => {
// FIXME: refactor this, too ugly now const className = columnSizes[props.isEqual ? 0 : columnIndex() % columnSizes.length]
const className = `col-md-${props.isEqual ? '12' : x[y()][i()]}` const big = className === 'col-md-12' ? 'M' : 'L'
let desktopCoverSize: ArticleCardProps['desktopCoverSize'] const desktopCoverSize = className === 'col-md-8' ? 'S' : big
switch (className) {
case 'col-md-8': {
desktopCoverSize = 'S'
break
}
case 'col-md-12': {
desktopCoverSize = 'M'
break
}
default: {
desktopCoverSize = 'L'
}
}
return ( return (
<div class={className}> <div class={className}>
<ArticleCard <ArticleCard
article={a} article={article}
settings={{ settings={{
isWithCover: props.isEqual || x[y()][i()] === '16', isWithCover: props.isEqual || className === 'col-md-16',
nodate: props.isEqual || props.nodate, nodate: props.isEqual || props.nodate,
noAuthorLink: props.noAuthorLink, noAuthorLink: props.noAuthorLink,
noauthor: props.noauthor, noauthor: props.noauthor,

View File

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

View File

@ -2,8 +2,9 @@ import { getPagePath } from '@nanostores/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createSignal, For, Show } from 'solid-js' import { createSignal, For, Show } from 'solid-js'
import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session' import { Author } from '../../../graphql/schema/core.gen'
import { router, useRouter } from '../../../stores/router' import { router, useRouter } from '../../../stores/router'
import { useArticlesStore } from '../../../stores/zine/articles' import { useArticlesStore } from '../../../stores/zine/articles'
import { useSeenStore } from '../../../stores/zine/seen' import { useSeenStore } from '../../../stores/zine/seen'
@ -15,7 +16,7 @@ import styles from './Sidebar.module.scss'
export const Sidebar = () => { export const Sidebar = () => {
const { t } = useLocalize() const { t } = useLocalize()
const { seen } = useSeenStore() const { seen } = useSeenStore()
const { subscriptions } = useSession() const { subscriptions } = useFollowing()
const { page } = useRouter() const { page } = useRouter()
const { articlesByTopic } = useArticlesStore() const { articlesByTopic } = useArticlesStore()
const [isSubscriptionsVisible, setSubscriptionsVisible] = createSignal(true) const [isSubscriptionsVisible, setSubscriptionsVisible] = createSignal(true)
@ -27,7 +28,6 @@ export const Sidebar = () => {
const checkAuthorIsSeen = (authorSlug: string) => { const checkAuthorIsSeen = (authorSlug: string) => {
return Boolean(seen()[authorSlug]) return Boolean(seen()[authorSlug])
} }
return ( return (
<div class={styles.sidebar}> <div class={styles.sidebar}>
<ul class={styles.feedFilters}> <ul class={styles.feedFilters}>
@ -111,7 +111,7 @@ export const Sidebar = () => {
</li> </li>
</ul> </ul>
<Show when={subscriptions().authors.length > 0 || subscriptions().topics.length > 0}> <Show when={subscriptions.authors.length > 0 || subscriptions.topics.length > 0}>
<h4 <h4
classList={{ [styles.opened]: isSubscriptionsVisible() }} classList={{ [styles.opened]: isSubscriptionsVisible() }}
onClick={() => { onClick={() => {
@ -122,22 +122,19 @@ export const Sidebar = () => {
</h4> </h4>
<ul class={clsx(styles.subscriptions, { [styles.hidden]: !isSubscriptionsVisible() })}> <ul class={clsx(styles.subscriptions, { [styles.hidden]: !isSubscriptionsVisible() })}>
<For each={subscriptions().authors}> <For each={subscriptions.authors}>
{(author) => ( {(a: Author) => (
<li> <li>
<a <a href={`/author/${a.slug}`} classList={{ [styles.unread]: checkAuthorIsSeen(a.slug) }}>
href={`/author/${author.slug}`}
classList={{ [styles.unread]: checkAuthorIsSeen(author.slug) }}
>
<div class={styles.sidebarItemName}> <div class={styles.sidebarItemName}>
<Userpic name={author.name} userpic={author.pic} size="XS" class={styles.userpic} /> <Userpic name={a.name} userpic={a.pic} size="XS" class={styles.userpic} />
<div class={styles.sidebarItemNameLabel}>{author.name}</div> <div class={styles.sidebarItemNameLabel}>{a.name}</div>
</div> </div>
</a> </a>
</li> </li>
)} )}
</For> </For>
<For each={subscriptions().topics}> <For each={subscriptions.topics}>
{(topic) => ( {(topic) => (
<li> <li>
<a <a

View File

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

View File

@ -27,7 +27,7 @@ export const ForgotPasswordForm = () => {
setEmail(newEmail.toLowerCase()) setEmail(newEmail.toLowerCase())
} }
const { const {
actions: { authorizer }, actions: { forgotPassword },
} = useSession() } = useSession()
const [submitError, setSubmitError] = createSignal('') const [submitError, setSubmitError] = createSignal('')
const [isSubmitting, setIsSubmitting] = createSignal(false) const [isSubmitting, setIsSubmitting] = createSignal(false)
@ -61,19 +61,23 @@ export const ForgotPasswordForm = () => {
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const response = await authorizer().forgotPassword({ const { data, errors } = await forgotPassword({
email: email(), email: email(),
redirect_uri: window.location.origin, redirect_uri: window.location.origin,
}) })
console.debug('[ForgotPasswordForm] authorizer response:', response) console.debug('[ForgotPasswordForm] authorizer response:', data)
if (response && response.message) setMessage(response.message) setMessage(data.message)
} catch (error) {
console.error(error) console.warn(errors)
if (error?.code === 'user_not_found') { if (errors.some((e) => e.cause === 'user_not_found')) {
setIsUserNotFound(true) setIsUserNotFound(true)
return return
} else {
const errorText = errors.map((e) => e.message).join(' ') // FIXME
setSubmitError(errorText)
} }
setSubmitError(error?.message) } catch (error) {
console.error(error)
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -148,8 +148,10 @@ export const Header = (props: Props) => {
} }
onMount(async () => { onMount(async () => {
const topics = await apiClient.getRandomTopics({ amount: RANDOM_TOPICS_COUNT }) if (window.location.pathname === '/' || window.location.pathname === '') {
setRandomTopics(topics) const topics = await apiClient.getRandomTopics({ amount: RANDOM_TOPICS_COUNT })
setRandomTopics(topics)
}
}) })
const handleToggleMenuByLink = (event: MouseEvent, route: keyof typeof ROUTES) => { const handleToggleMenuByLink = (event: MouseEvent, route: keyof typeof ROUTES) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,10 @@
import type { Topic } from '../../graphql/schema/core.gen'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createMemo, createSignal, Show } from 'solid-js' import { createMemo, createSignal, Show } from 'solid-js'
import { useFollowing } from '../../context/following'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
import { useSession } from '../../context/session' import { useSession } from '../../context/session'
import { FollowingEntity } from '../../graphql/schema/core.gen' import { FollowingEntity, type Topic } from '../../graphql/schema/core.gen'
import { follow, unfollow } from '../../stores/zine/common'
import { capitalize } from '../../utils/capitalize' import { capitalize } from '../../utils/capitalize'
import { Button } from '../_shared/Button' import { Button } from '../_shared/Button'
import { CheckButton } from '../_shared/CheckButton' import { CheckButton } from '../_shared/CheckButton'
@ -36,32 +34,21 @@ interface TopicProps {
export const TopicCard = (props: TopicProps) => { export const TopicCard = (props: TopicProps) => {
const { t, lang } = useLocalize() const { t, lang } = useLocalize()
const title = createMemo(() =>
capitalize(lang() === 'en' ? props.topic.slug.replaceAll('-', ' ') : props.topic.title || ''),
)
const { const {
subscriptions, author,
isSessionLoaded, actions: { requireAuthentication },
actions: { loadSubscriptions, requireAuthentication },
} = useSession() } = useSession()
const { setFollowing, loading: subLoading } = useFollowing()
const [followed, setFollowed] = createSignal()
const [isSubscribing, setIsSubscribing] = createSignal(false) const handleFollowClick = () => {
const value = !followed()
const subscribed = createMemo(() => {
return subscriptions().topics.some((topic) => topic.slug === props.topic.slug)
})
const subscribe = async (really = true) => {
setIsSubscribing(true)
await (really
? follow({ what: FollowingEntity.Topic, slug: props.topic.slug })
: unfollow({ what: FollowingEntity.Topic, slug: props.topic.slug }))
await loadSubscriptions()
setIsSubscribing(false)
}
const handleSubscribe = () => {
requireAuthentication(() => { requireAuthentication(() => {
subscribe(!subscribed()) setFollowed(value)
setFollowing(FollowingEntity.Topic, props.topic.slug, value)
}, 'subscribe') }, 'subscribe')
} }
@ -69,12 +56,12 @@ export const TopicCard = (props: TopicProps) => {
return ( return (
<> <>
<Show when={props.iconButton}> <Show when={props.iconButton}>
<Show when={subscribed()} fallback="+"> <Show when={followed()} fallback="+">
<Icon name="check-subscribed" /> <Icon name="check-subscribed" />
</Show> </Show>
</Show> </Show>
<Show when={!props.iconButton}> <Show when={!props.iconButton}>
<Show when={subscribed()} fallback={t('Follow')}> <Show when={followed()} fallback={t('Follow')}>
<span class={stylesButton.buttonSubscribeLabelHovered}>{t('Unfollow')}</span> <span class={stylesButton.buttonSubscribeLabelHovered}>{t('Unfollow')}</span>
<span class={stylesButton.buttonSubscribeLabel}>{t('Following')}</span> <span class={stylesButton.buttonSubscribeLabel}>{t('Following')}</span>
</Show> </Show>
@ -83,10 +70,6 @@ export const TopicCard = (props: TopicProps) => {
) )
} }
const title = createMemo(() =>
capitalize(lang() === 'en' ? props.topic.slug.replaceAll('-', ' ') : props.topic.title || ''),
)
return ( return (
<div class={styles.topicContainer}> <div class={styles.topicContainer}>
<div <div
@ -141,24 +124,28 @@ export const TopicCard = (props: TopicProps) => {
}} }}
> >
<ShowOnlyOnClient> <ShowOnlyOnClient>
<Show when={isSessionLoaded()}> <Show when={author()}>
<Show <Show
when={!props.minimizeSubscribeButton} when={!props.minimizeSubscribeButton}
fallback={ fallback={
<CheckButton text={t('Follow')} checked={subscribed()} onClick={handleSubscribe} /> <CheckButton
text={t('Follow')}
checked={Boolean(followed())}
onClick={handleFollowClick}
/>
} }
> >
<Button <Button
variant="bordered" variant="bordered"
size="M" size="M"
value={subscribeValue()} value={subscribeValue()}
onClick={handleSubscribe} onClick={handleFollowClick}
isSubscribeButton={true} isSubscribeButton={true}
class={clsx(styles.actionButton, { class={clsx(styles.actionButton, {
[styles.isSubscribing]: isSubscribing(), [styles.isSubscribing]: subLoading(),
[stylesButton.subscribed]: subscribed(), [stylesButton.subscribed]: followed(),
})} })}
disabled={isSubscribing()} // disabled={subLoading()}
/> />
</Show> </Show>
</Show> </Show>

View File

@ -1,12 +1,12 @@
import type { Topic } from '../../graphql/schema/core.gen' import type { Topic } from '../../graphql/schema/core.gen'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createMemo, Show } from 'solid-js' import { createEffect, createSignal, Show } from 'solid-js'
import { useFollowing } from '../../context/following'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
import { useSession } from '../../context/session' import { useSession } from '../../context/session'
import { FollowingEntity } from '../../graphql/schema/core.gen' import { FollowingEntity } from '../../graphql/schema/core.gen'
import { follow, unfollow } from '../../stores/zine/common'
import { Button } from '../_shared/Button' import { Button } from '../_shared/Button'
import styles from './Full.module.scss' import styles from './Full.module.scss'
@ -16,23 +16,26 @@ type Props = {
} }
export const FullTopic = (props: Props) => { export const FullTopic = (props: Props) => {
const {
subscriptions,
actions: { requireAuthentication, loadSubscriptions },
} = useSession()
const { t } = useLocalize() const { t } = useLocalize()
const { subscriptions, setFollowing } = useFollowing()
const {
actions: { requireAuthentication },
} = useSession()
const [followed, setFollowed] = createSignal()
const subscribed = createMemo(() => createEffect(() => {
subscriptions().topics.some((topic) => topic.slug === props.topic?.slug), const subs = subscriptions
) if (subs?.topics.length !== 0) {
const items = subs.topics || []
setFollowed(items.some((x: Topic) => x?.slug === props.topic?.slug))
}
})
const handleSubscribe = (really: boolean) => { const handleFollowClick = (_ev) => {
requireAuthentication(async () => { const really = !followed()
await (really setFollowed(really)
? follow({ what: FollowingEntity.Topic, slug: props.topic.slug }) requireAuthentication(() => {
: unfollow({ what: FollowingEntity.Topic, slug: props.topic.slug })) setFollowing(FollowingEntity.Topic, props.topic.slug, really)
loadSubscriptions()
}, 'follow') }, 'follow')
} }
@ -41,16 +44,11 @@ export const FullTopic = (props: Props) => {
<h1>#{props.topic?.title}</h1> <h1>#{props.topic?.title}</h1>
<p>{props.topic?.body}</p> <p>{props.topic?.body}</p>
<div class={clsx(styles.topicActions)}> <div class={clsx(styles.topicActions)}>
<Show when={!subscribed()}> <Button
<Button variant="primary" onClick={() => handleSubscribe(true)} value={t('Follow the topic')} /> variant="primary"
</Show> onClick={handleFollowClick}
<Show when={subscribed()}> value={followed() ? t('Unfollow the topic') : t('Follow the topic')}
<Button />
variant="primary"
onClick={() => handleSubscribe(false)}
value={t('Unfollow the topic')}
/>
</Show>
<a class={styles.write} href={`/create/?topicId=${props.topic?.id}`}> <a class={styles.write} href={`/create/?topicId=${props.topic?.id}`}>
{t('Write about the topic')} {t('Write about the topic')}
</a> </a>

View File

@ -1,17 +1,18 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createEffect, createMemo, createSignal, Show } from 'solid-js' import { createEffect, createSignal, Show } from 'solid-js'
import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { useMediaQuery } from '../../../context/mediaQuery' import { useMediaQuery } from '../../../context/mediaQuery'
import { useSession } from '../../../context/session' import { useSession } from '../../../context/session'
import { FollowingEntity, Topic } from '../../../graphql/schema/core.gen' import { FollowingEntity, Topic } from '../../../graphql/schema/core.gen'
import { follow, unfollow } from '../../../stores/zine/common'
import { capitalize } from '../../../utils/capitalize' import { capitalize } from '../../../utils/capitalize'
import { getImageUrl } from '../../../utils/getImageUrl' import { getImageUrl } from '../../../utils/getImageUrl'
import { Button } from '../../_shared/Button' import { Button } from '../../_shared/Button'
import { CheckButton } from '../../_shared/CheckButton' import { CheckButton } from '../../_shared/CheckButton'
import styles from './TopicBadge.module.scss' import styles from './TopicBadge.module.scss'
type Props = { type Props = {
topic: Topic topic: Topic
minimizeSubscribeButton?: boolean minimizeSubscribeButton?: boolean
@ -21,29 +22,23 @@ export const TopicBadge = (props: Props) => {
const { t, lang } = useLocalize() const { t, lang } = useLocalize()
const { mediaMatches } = useMediaQuery() const { mediaMatches } = useMediaQuery()
const [isMobileView, setIsMobileView] = createSignal(false) const [isMobileView, setIsMobileView] = createSignal(false)
const [isSubscribing, setIsSubscribing] = createSignal(false) const {
actions: { requireAuthentication },
} = useSession()
const { setFollowing, loading: subLoading } = useFollowing()
const [followed, setFollowed] = createSignal()
const handleFollowClick = () => {
const value = !followed()
requireAuthentication(() => {
setFollowed(value)
setFollowing(FollowingEntity.Topic, props.topic.slug, value)
}, 'subscribe')
}
createEffect(() => { createEffect(() => {
setIsMobileView(!mediaMatches.sm) setIsMobileView(!mediaMatches.sm)
}) })
const {
subscriptions,
actions: { loadSubscriptions },
} = useSession()
const subscribed = createMemo(() =>
subscriptions().topics.some((topic) => topic.slug === props.topic.slug),
)
const subscribe = async (really = true) => {
setIsSubscribing(true)
await (really
? follow({ what: FollowingEntity.Topic, slug: props.topic.slug })
: unfollow({ what: FollowingEntity.Topic, slug: props.topic.slug }))
await loadSubscriptions()
setIsSubscribing(false)
}
const title = () => const title = () =>
lang() === 'en' ? capitalize(props.topic.slug.replaceAll('-', ' ')) : props.topic.title lang() === 'en' ? capitalize(props.topic.slug.replaceAll('-', ' ')) : props.topic.title
@ -82,23 +77,23 @@ export const TopicBadge = (props: Props) => {
<Show <Show
when={!props.minimizeSubscribeButton} when={!props.minimizeSubscribeButton}
fallback={ fallback={
<CheckButton text={t('Follow')} checked={subscribed()} onClick={() => subscribe(!subscribed)} /> <CheckButton text={t('Follow')} checked={Boolean(followed())} onClick={handleFollowClick} />
} }
> >
<Show <Show
when={subscribed()} when={followed()}
fallback={ fallback={
<Button <Button
variant="primary" variant="primary"
size="S" size="S"
value={isSubscribing() ? t('subscribing...') : t('Subscribe')} value={subLoading() ? t('subscribing...') : t('Subscribe')}
onClick={() => subscribe(true)} onClick={handleFollowClick}
class={styles.subscribeButton} class={styles.subscribeButton}
/> />
} }
> >
<Button <Button
onClick={() => subscribe(false)} onClick={handleFollowClick}
variant="bordered" variant="bordered"
size="S" size="S"
value={t('Following')} value={t('Following')}

View File

@ -4,8 +4,8 @@ import { Meta } from '@solidjs/meta'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createEffect, createMemo, createSignal, For, Show } from 'solid-js' import { createEffect, createMemo, createSignal, For, Show } from 'solid-js'
import { useFollowing } from '../../context/following'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
import { useSession } from '../../context/session'
import { useRouter } from '../../stores/router' import { useRouter } from '../../stores/router'
import { setTopicsSort, useTopicsStore } from '../../stores/zine/topics' import { setTopicsSort, useTopicsStore } from '../../stores/zine/topics'
import { capitalize } from '../../utils/capitalize' import { capitalize } from '../../utils/capitalize'
@ -41,7 +41,7 @@ export const AllTopicsView = (props: Props) => {
sortBy: searchParams().by || 'shouts', sortBy: searchParams().by || 'shouts',
}) })
const { subscriptions } = useSession() const { subscriptions } = useFollowing()
createEffect(() => { createEffect(() => {
if (!searchParams().by) { if (!searchParams().by) {
@ -76,7 +76,7 @@ export const AllTopicsView = (props: Props) => {
return keys return keys
}) })
const subscribed = (topicSlug: string) => subscriptions().topics.some((topic) => topic.slug === topicSlug) const subscribed = (topicSlug: string) => subscriptions.topics.some((topic) => topic.slug === topicSlug)
const showMore = () => setLimit((oldLimit) => oldLimit + PAGE_SIZE) const showMore = () => setLimit((oldLimit) => oldLimit + PAGE_SIZE)
const [searchQuery, setSearchQuery] = createSignal('') const [searchQuery, setSearchQuery] = createSignal('')
@ -191,7 +191,7 @@ export const AllTopicsView = (props: Props) => {
{(topic) => ( {(topic) => (
<> <>
<TopicCard <TopicCard
topic={topic as Topic} topic={topic}
compact={false} compact={false}
subscribed={subscribed(topic.slug)} subscribed={subscribed(topic.slug)}
showPublications={true} showPublications={true}

View File

@ -1,10 +1,11 @@
import type { Author, Shout, Topic } from '../../../graphql/schema/core.gen' import type { Author, Reaction, Shout, Topic } from '../../../graphql/schema/core.gen'
import { getPagePath } from '@nanostores/router' import { getPagePath } from '@nanostores/router'
import { Meta } from '@solidjs/meta' import { Meta } from '@solidjs/meta'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Show, createMemo, createSignal, Switch, onMount, For, Match, createEffect } from 'solid-js' import { Show, createMemo, createSignal, Switch, onMount, For, Match, createEffect, on } from 'solid-js'
import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { apiClient } from '../../../graphql/client/core' import { apiClient } from '../../../graphql/client/core'
import { router, useRouter } from '../../../stores/router' import { router, useRouter } from '../../../stores/router'
@ -13,6 +14,7 @@ import { loadAuthor, useAuthorsStore } from '../../../stores/zine/authors'
import { getImageUrl } from '../../../utils/getImageUrl' import { getImageUrl } from '../../../utils/getImageUrl'
import { getDescription } from '../../../utils/meta' import { getDescription } from '../../../utils/meta'
import { restoreScrollPosition, saveScrollPosition } from '../../../utils/scroll' import { restoreScrollPosition, saveScrollPosition } from '../../../utils/scroll'
import { byCreated } from '../../../utils/sortby'
import { splitToPages } from '../../../utils/splitToPages' import { splitToPages } from '../../../utils/splitToPages'
import { Loading } from '../../_shared/Loading' import { Loading } from '../../_shared/Loading'
import { Comment } from '../../Article/Comment' import { Comment } from '../../Article/Comment'
@ -36,6 +38,7 @@ const LOAD_MORE_PAGE_SIZE = 9
export const AuthorView = (props: Props) => { export const AuthorView = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const { loadSubscriptions } = useFollowing()
const { sortedArticles } = useArticlesStore({ shouts: props.shouts }) const { sortedArticles } = useArticlesStore({ shouts: props.shouts })
const { authorEntities } = useAuthorsStore({ authors: [props.author] }) const { authorEntities } = useAuthorsStore({ authors: [props.author] })
const { page: getPage } = useRouter() const { page: getPage } = useRouter()
@ -44,21 +47,32 @@ export const AuthorView = (props: Props) => {
const [followers, setFollowers] = createSignal<Author[]>([]) const [followers, setFollowers] = createSignal<Author[]>([])
const [following, setFollowing] = createSignal<Array<Author | Topic>>([]) const [following, setFollowing] = createSignal<Array<Author | Topic>>([])
const [showExpandBioControl, setShowExpandBioControl] = createSignal(false) const [showExpandBioControl, setShowExpandBioControl] = createSignal(false)
const author = createMemo(() => authorEntities()[props.authorSlug])
createEffect(async () => { // current author
const [author, setAuthor] = createSignal<Author>()
createEffect(() => {
try {
const a = authorEntities()[props.authorSlug]
setAuthor(a)
} catch (error) {
console.debug(error)
}
})
createEffect(() => {
if (author() && author().id && !author().stat) { if (author() && author().id && !author().stat) {
const a = await loadAuthor({ slug: '', author_id: author().id }) const a = loadAuthor({ slug: '', author_id: author().id })
console.debug(`[AuthorView] loaded author:`, a) console.debug(`[AuthorView] loaded author:`, a)
} }
}) })
const bioContainerRef: { current: HTMLDivElement } = { current: null } const bioContainerRef: { current: HTMLDivElement } = { current: null }
const bioWrapperRef: { current: HTMLDivElement } = { current: null } const bioWrapperRef: { current: HTMLDivElement } = { current: null }
const fetchSubscriptions = async (): Promise<{ authors: Author[]; topics: Topic[] }> => { const fetchSubscriptions = async (): Promise<{ authors: Author[]; topics: Topic[] }> => {
try { try {
const [getAuthors, getTopics] = await Promise.all([ const [getAuthors, getTopics] = await Promise.all([
apiClient.getAuthorFollowingUsers({ slug: props.authorSlug }), apiClient.getAuthorFollowingAuthors({ slug: props.authorSlug }),
apiClient.getAuthorFollowingTopics({ slug: props.authorSlug }), apiClient.getAuthorFollowingTopics({ slug: props.authorSlug }),
]) ])
const authors = getAuthors const authors = getAuthors
@ -76,32 +90,27 @@ export const AuthorView = (props: Props) => {
} }
} }
onMount(async () => { const fetchData = async () => {
checkBioHeight() const slug = author()?.slug || props.authorSlug
if (slug && getPage().route === 'authorComments' && author()) {
// pagination
if (sortedArticles().length === PRERENDERED_ARTICLES_COUNT) {
await loadMore()
}
})
createEffect(async () => {
const slug = author()?.slug
if (slug) {
console.debug('[AuthorView] load subscriptions')
try { try {
const { authors, topics } = await fetchSubscriptions() const { authors, topics } = await fetchSubscriptions()
setFollowing([...(authors || []), ...(topics || [])]) setFollowing([...(authors || []), ...(topics || [])])
const userSubscribers = await apiClient.getAuthorFollowers({ slug }) const flwrs = await apiClient.getAuthorFollowers({ slug })
setFollowers(userSubscribers || []) setFollowers(flwrs || [])
console.info('[components.Author] following data loaded')
} catch (error) { } catch (error) {
console.error('[AuthorView] error:', error) console.error('[components.Author] fetch error', error)
} }
} }
}) }
createEffect(() => { createEffect(() => {
document.title = author()?.name if (author()) {
console.info('[components.Author] profile data loaded')
document.title = author().name
fetchData()
}
}) })
const loadMore = async () => { const loadMore = async () => {
@ -115,42 +124,56 @@ export const AuthorView = (props: Props) => {
restoreScrollPosition() restoreScrollPosition()
} }
onMount(() => {
checkBioHeight()
// pagination
if (sortedArticles().length === PRERENDERED_ARTICLES_COUNT) {
loadMore()
loadSubscriptions()
}
})
const pages = createMemo<Shout[][]>(() => const pages = createMemo<Shout[][]>(() =>
splitToPages(sortedArticles(), PRERENDERED_ARTICLES_COUNT, LOAD_MORE_PAGE_SIZE), splitToPages(sortedArticles(), PRERENDERED_ARTICLES_COUNT, LOAD_MORE_PAGE_SIZE),
) )
const [commented, setCommented] = createSignal([]) const fetchComments = async (commenter: Author) => {
const data = await apiClient.getReactionsBy({
by: { comment: true, created_by: commenter.id },
})
console.debug(`[components.Author] fetched ${data.length} comments`)
setCommented(data)
}
createEffect(async () => { const [commented, setCommented] = createSignal<Reaction[]>([])
if (getPage().route === 'authorComments' && props.author) { createEffect(() => {
try { const a = author()
const data = await apiClient.getReactionsBy({ if (a) {
by: { comment: true, created_by: props.author.id }, fetchComments(a)
})
setCommented(data)
} catch (error) {
console.error('[getReactionsBy comment]', error)
}
} }
}) })
const ogImage = props.author?.pic const ogImage = createMemo(() =>
? getImageUrl(props.author.pic, { width: 1200 }) author()?.pic
: getImageUrl('production/image/logo_image.png') ? getImageUrl(author()?.pic, { width: 1200 })
const description = getDescription(props.author?.bio) : getImageUrl('production/image/logo_image.png'),
const ogTitle = props.author?.name )
const description = createMemo(() => getDescription(author()?.bio))
return ( return (
<div class={styles.authorPage}> <div class={styles.authorPage}>
<Meta name="descprition" content={description} /> <Show when={author()}>
<Meta name="og:type" content="profile" /> <Meta name="descprition" content={description()} />
<Meta name="og:title" content={ogTitle} /> <Meta name="og:type" content="profile" />
<Meta name="og:image" content={ogImage} /> <Meta name="og:title" content={author().name} />
<Meta name="og:description" content={description} /> <Meta name="og:image" content={ogImage()} />
<Meta name="twitter:card" content="summary_large_image" /> <Meta name="og:description" content={description()} />
<Meta name="twitter:title" content={ogTitle} /> <Meta name="twitter:card" content="summary_large_image" />
<Meta name="twitter:description" content={description} /> <Meta name="twitter:title" content={author().name} />
<Meta name="twitter:image" content={ogImage} /> <Meta name="twitter:description" content={description()} />
<Meta name="twitter:image" content={ogImage()} />
</Show>
<div class="wide-container"> <div class="wide-container">
<Show when={author()} fallback={<Loading />}> <Show when={author()} fallback={<Loading />}>
<> <>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,9 +28,10 @@ export const ProfileSubscriptions = () => {
const fetchSubscriptions = async () => { const fetchSubscriptions = async () => {
try { try {
const slug = author()?.slug
const [getAuthors, getTopics] = await Promise.all([ const [getAuthors, getTopics] = await Promise.all([
apiClient.getAuthorFollowingUsers({ slug: author()?.slug }), apiClient.getAuthorFollowingAuthors({ slug }),
apiClient.getAuthorFollowingTopics({ slug: author()?.slug }), apiClient.getAuthorFollowingTopics({ slug }),
]) ])
setFollowing([...getAuthors, ...getTopics]) setFollowing([...getAuthors, ...getTopics])
setFiltered([...getAuthors, ...getTopics]) setFiltered([...getAuthors, ...getTopics])
@ -42,7 +43,7 @@ export const ProfileSubscriptions = () => {
createEffect(() => { createEffect(() => {
if (following()) { if (following()) {
if (subscriptionFilter() === 'users') { if (subscriptionFilter() === 'authors') {
setFiltered(following().filter((s) => 'name' in s)) setFiltered(following().filter((s) => 'name' in s))
} else if (subscriptionFilter() === 'topics') { } else if (subscriptionFilter() === 'topics') {
setFiltered(following().filter((s) => 'title' in s)) setFiltered(following().filter((s) => 'title' in s))
@ -80,8 +81,8 @@ export const ProfileSubscriptions = () => {
{t('All')} {t('All')}
</button> </button>
</li> </li>
<li class={clsx({ 'view-switcher__item--selected': subscriptionFilter() === 'users' })}> <li class={clsx({ 'view-switcher__item--selected': subscriptionFilter() === 'authors' })}>
<button type="button" onClick={() => setSubscriptionFilter('users')}> <button type="button" onClick={() => setSubscriptionFilter('authors')}>
{t('Authors')} {t('Authors')}
</button> </button>
</li> </li>

View File

@ -1,21 +1,21 @@
import { redirectPage } from '@nanostores/router' import { redirectPage } from '@nanostores/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { lazy, Show } from 'solid-js' import { createEffect, createMemo, createSignal, lazy, onMount, Show } from 'solid-js'
import { createStore } from 'solid-js/store' import { createStore } from 'solid-js/store'
import { ShoutForm, useEditorContext } from '../../../context/editor' import { ShoutForm, useEditorContext } from '../../../context/editor'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session' import { useSession } from '../../../context/session'
import { Topic } from '../../../graphql/schema/core.gen'
import { UploadedFile } from '../../../pages/types' import { UploadedFile } from '../../../pages/types'
import { router } from '../../../stores/router' import { router } from '../../../stores/router'
import { hideModal, showModal } from '../../../stores/ui' import { hideModal, showModal } from '../../../stores/ui'
import { useTopicsStore } from '../../../stores/zine/topics' import { loadAllTopics, useTopicsStore } from '../../../stores/zine/topics'
import { Button } from '../../_shared/Button' import { Button } from '../../_shared/Button'
import { Icon } from '../../_shared/Icon' import { Icon } from '../../_shared/Icon'
import { Image } from '../../_shared/Image' import { Image } from '../../_shared/Image'
import { TopicSelect, UploadModalContent } from '../../Editor' import { TopicSelect, UploadModalContent } from '../../Editor'
import { Modal } from '../../Nav/Modal' import { Modal } from '../../Nav/Modal'
import { EMPTY_TOPIC } from '../Edit'
import styles from './PublishSettings.module.scss' import styles from './PublishSettings.module.scss'
import stylesBeside from '../../Feed/Beside.module.scss' import stylesBeside from '../../Feed/Beside.module.scss'
@ -35,10 +35,25 @@ const shorten = (str: string, maxLen: number) => {
return `${result}...` return `${result}...`
} }
const EMPTY_TOPIC: Topic = {
id: -1,
slug: '',
}
const emptyConfig = {
coverImageUrl: '',
mainTopic: EMPTY_TOPIC,
slug: '',
title: '',
subtitle: '',
description: '',
selectedTopics: [],
}
export const PublishSettings = (props: Props) => { export const PublishSettings = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const { author } = useSession() const { author } = useSession()
const { sortedTopics } = useTopicsStore() const { sortedTopics } = useTopicsStore()
const [topics, setTopics] = createSignal<Topic[]>(sortedTopics())
const composeDescription = () => { const composeDescription = () => {
if (!props.form.description) { if (!props.form.description) {
@ -49,22 +64,32 @@ export const PublishSettings = (props: Props) => {
return props.form.description return props.form.description
} }
const initialData: Partial<ShoutForm> = { const initialData = createMemo(() => {
coverImageUrl: props.form.coverImageUrl, return {
mainTopic: props.form.mainTopic || EMPTY_TOPIC, coverImageUrl: props.form?.coverImageUrl,
slug: props.form.slug, mainTopic: props.form?.mainTopic || EMPTY_TOPIC,
title: props.form.title, slug: props.form?.slug,
subtitle: props.form.subtitle, title: props.form?.title,
description: composeDescription(), subtitle: props.form?.subtitle,
} description: composeDescription(),
selectedTopics: [],
}
})
const [settingsForm, setSettingsForm] = createStore(emptyConfig)
onMount(() => {
setSettingsForm(initialData())
loadAllTopics()
})
createEffect(() => setTopics(sortedTopics()))
const { const {
formErrors, formErrors,
actions: { setForm, setFormErrors, saveShout, publishShout }, actions: { setForm, setFormErrors, saveShout, publishShout },
} = useEditorContext() } = useEditorContext()
const [settingsForm, setSettingsForm] = createStore(initialData)
const handleUploadModalContentCloseSetCover = (image: UploadedFile) => { const handleUploadModalContentCloseSetCover = (image: UploadedFile) => {
hideModal() hideModal()
setSettingsForm('coverImageUrl', image.url) setSettingsForm('coverImageUrl', image.url)
@ -98,7 +123,7 @@ export const PublishSettings = (props: Props) => {
}) })
} }
const handleCancelClick = () => { const handleCancelClick = () => {
setSettingsForm(initialData) setSettingsForm(initialData())
handleBackClick() handleBackClick()
} }
const handlePublishSubmit = () => { const handlePublishSubmit = () => {
@ -137,9 +162,9 @@ export const PublishSettings = (props: Props) => {
[styles.hasImage]: settingsForm.coverImageUrl, [styles.hasImage]: settingsForm.coverImageUrl,
})} })}
> >
<Show when={settingsForm.coverImageUrl ?? initialData.coverImageUrl}> <Show when={settingsForm.coverImageUrl ?? initialData().coverImageUrl}>
<div class={styles.shoutCardCover}> <div class={styles.shoutCardCover}>
<Image src={settingsForm.coverImageUrl} alt={initialData.title} width={800} /> <Image src={settingsForm.coverImageUrl} alt={initialData().title} width={800} />
</div> </div>
</Show> </Show>
<div class={styles.text}> <div class={styles.text}>
@ -205,9 +230,9 @@ export const PublishSettings = (props: Props) => {
</p> </p>
<div class={styles.inputContainer}> <div class={styles.inputContainer}>
<div class={clsx('pretty-form__item', styles.topicSelectContainer)}> <div class={clsx('pretty-form__item', styles.topicSelectContainer)}>
<Show when={sortedTopics()}> <Show when={topics().length > 0}>
<TopicSelect <TopicSelect
topics={sortedTopics()} topics={topics()}
onChange={handleTopicSelectChange} onChange={handleTopicSelectChange}
selectedTopics={props.form.selectedTopics} selectedTopics={props.form.selectedTopics}
onMainTopicChange={(mainTopic) => setForm('mainTopic', mainTopic)} onMainTopicChange={(mainTopic) => setForm('mainTopic', mainTopic)}
@ -222,7 +247,7 @@ export const PublishSettings = (props: Props) => {
<h4>{t('Collaborators')}</h4> <h4>{t('Collaborators')}</h4>
<Button <Button
variant="primary" variant="primary"
onClick={() => showModal('inviteCoAuthors')} onClick={() => showModal('inviteMembers')}
value={t('Invite collaborators')} value={t('Invite collaborators')}
/> />
</div> </div>

View File

@ -2,7 +2,7 @@ import type { Shout, Topic } from '../../graphql/schema/core.gen'
import { Meta } from '@solidjs/meta' import { Meta } from '@solidjs/meta'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { For, Show, createMemo, onMount, createSignal } from 'solid-js' import { For, Show, createMemo, onMount, createSignal, createEffect } from 'solid-js'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
import { useRouter } from '../../stores/router' import { useRouter } from '../../stores/router'
@ -39,23 +39,27 @@ const LOAD_MORE_PAGE_SIZE = 9 // Row3 + Row3 + Row3
export const TopicView = (props: Props) => { export const TopicView = (props: Props) => {
const { t, lang } = useLocalize() const { t, lang } = useLocalize()
const { searchParams, changeSearchParams } = useRouter<TopicsPageSearchParams>() const { searchParams, changeSearchParams } = useRouter<TopicsPageSearchParams>()
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false) const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const { sortedArticles } = useArticlesStore({ shouts: props.shouts }) const { sortedArticles } = useArticlesStore({ shouts: props.shouts })
const { topicEntities } = useTopicsStore({ topics: [props.topic] }) const { topicEntities } = useTopicsStore({ topics: [props.topic] })
const { authorsByTopic } = useAuthorsStore() const { authorsByTopic } = useAuthorsStore()
const topic = createMemo(() => const [topic, setTopic] = createSignal<Topic>()
props.topic?.slug in topicEntities() ? topicEntities()[props.topic.slug] : props.topic, createEffect(() => {
const topics = topicEntities()
if (props.topicSlug && !topic() && topics) {
setTopic(topics[props.topicSlug])
}
})
const title = createMemo(
() =>
`#${capitalize(
lang() === 'en'
? topic()?.slug.replace(/-/, ' ')
: topic()?.title || topic()?.slug.replace(/-/, ' '),
true,
)}`,
) )
const title = () =>
`#${capitalize(
lang() === 'en' ? topic()?.slug.replace(/-/, ' ') : topic()?.title || topic()?.slug.replace(/-/, ' '),
true,
)}`
onMount(() => (document.title = title()))
const loadMore = async () => { const loadMore = async () => {
saveScrollPosition() saveScrollPosition()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
.UserSearch { .InviteMembers {
.searchHeader { .searchHeader {
display: flex; display: flex;
flex-flow: row nowrap; flex-flow: row nowrap;
@ -32,10 +32,40 @@
} }
} }
.searchButton {
margin: 0;
}
.authors { .authors {
height: 400px; height: 300px;
overflow: auto; overflow: auto;
padding: 1rem 0; margin-top: 1rem;
.author {
cursor: pointer;
&:hover {
background: var(--black-100);
}
}
}
.loading {
@include font-size(1.4rem);
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
width: 100%;
flex-direction: row;
opacity: 0.5;
.icon {
position: relative;
width: 18px;
height: 18px;
}
} }
.teaser { .teaser {
@ -46,4 +76,11 @@
justify-content: center; justify-content: center;
text-align: center; text-align: center;
} }
.actions {
display: flex;
margin-top: 1rem;
flex-direction: row;
justify-content: space-between;
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,61 +0,0 @@
import { clsx } from 'clsx'
import { useLocalize } from '../../../context/localize'
import { Button } from '../Button'
import { DropdownSelect } from '../DropdownSelect'
import styles from './UserSearch.module.scss'
type Props = {
class?: string
placeholder: string
onChange: (value: string) => void
}
export const UserSearch = (props: Props) => {
const { t } = useLocalize()
const roles = [
{
title: t('Editor'),
description: t('Can write and edit text directly, and accept or reject suggestions from others'),
},
{
title: t('Co-author'),
description: t('Can make any changes, accept or reject suggestions, and share access with others'),
},
{
title: t('Commentator'),
description: t('Can offer edits and comments, but cannot edit the post or share access with others'),
},
]
const handleInputChange = (value: string) => {
props.onChange(value)
}
return (
<div class={clsx(styles.UserSearch, props.class)}>
<div class={styles.searchHeader}>
<div class={styles.field}>
<input
class={styles.input}
type="text"
placeholder={props.placeholder ?? t('Search')}
onChange={(e) => handleInputChange(e.target.value)}
/>
<DropdownSelect selectItems={roles} />
</div>
<Button variant={'bordered'} size={'M'} value={t('Add')} />
</div>
<div class={styles.teaser}>
<h3>{t('Coming soon')}</h3>
<p>
{t(
'We are working on collaborative editing of articles and in the near future you will have an amazing opportunity - to create together with your colleagues',
)}
</p>
</div>
</div>
)
}

View File

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

View File

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

View File

@ -23,7 +23,7 @@ export type ShoutForm = {
shoutId: number shoutId: number
slug: string slug: string
title: string title: string
subtitle: string subtitle?: string
lead?: string lead?: string
description?: string description?: string
selectedTopics: Topic[] selectedTopics: Topic[]
@ -218,10 +218,10 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
} }
} }
const deleteShout = async (shoutId: number) => { const deleteShout = async (shout_id: number) => {
try { try {
await apiClient.deleteShout({ await apiClient.deleteShout({
shoutId, shout_id,
}) })
return true return true
} catch { } catch {

122
src/context/following.tsx Normal file
View File

@ -0,0 +1,122 @@
import { createEffect, createSignal, createContext, Accessor, useContext, JSX, onMount } from 'solid-js'
import { createStore } from 'solid-js/store'
import { apiClient } from '../graphql/client/core'
import { Author, Community, FollowingEntity, Topic } from '../graphql/schema/core.gen'
import { useSession } from './session'
type SubscriptionsData = {
topics?: Topic[]
authors?: Author[]
communities?: Community[]
}
interface FollowingContextType {
loading: Accessor<boolean>
subscriptions: SubscriptionsData
setSubscriptions: (subscriptions: SubscriptionsData) => void
setFollowing: (what: FollowingEntity, slug: string, value: boolean) => void
loadSubscriptions: () => void
follow: (what: FollowingEntity, slug: string) => Promise<void>
unfollow: (what: FollowingEntity, slug: string) => Promise<void>
}
const FollowingContext = createContext<FollowingContextType>()
export function useFollowing() {
return useContext(FollowingContext)
}
const EMPTY_SUBSCRIPTIONS = {
topics: [],
authors: [],
communities: [],
}
export const FollowingProvider = (props: { children: JSX.Element }) => {
const [loading, setLoading] = createSignal<boolean>(false)
const [subscriptions, setSubscriptions] = createStore<SubscriptionsData>(EMPTY_SUBSCRIPTIONS)
const { author } = useSession()
const fetchData = async () => {
setLoading(true)
try {
if (apiClient.private) {
console.debug('[context.following] fetching subs data...')
const result = await apiClient.getMySubscriptions()
setSubscriptions(result || EMPTY_SUBSCRIPTIONS)
console.info('[context.following] subs:', subscriptions)
}
} catch (error) {
console.info('[context.following] cannot get subs', error)
} finally {
setLoading(false)
}
}
const follow = async (what: FollowingEntity, slug: string) => {
if (!author()) return
try {
await apiClient.follow({ what, slug })
setSubscriptions((prevSubscriptions) => {
const updatedSubs = { ...prevSubscriptions }
if (!updatedSubs[what]) updatedSubs[what] = []
const exists = updatedSubs[what]?.some((entity) => entity.slug === slug)
if (!exists) updatedSubs[what].push(slug)
return updatedSubs
})
} catch (error) {
console.error(error)
}
}
const unfollow = async (what: FollowingEntity, slug: string) => {
if (!author()) return
try {
await apiClient.unfollow({ what, slug })
} catch (error) {
console.error(error)
}
}
createEffect(() => {
if (author()) {
console.debug('[context.following] author update detect')
fetchData()
}
})
const setFollowing = (what: FollowingEntity, slug: string, value = true) => {
setSubscriptions((prevSubscriptions) => {
const updatedSubs = { ...prevSubscriptions }
if (!updatedSubs[what]) updatedSubs[what] = []
if (value) {
const exists = updatedSubs[what]?.some((entity) => entity.slug === slug)
if (!exists) updatedSubs[what].push(slug)
} else {
updatedSubs[what] = (updatedSubs[what] || []).filter((x) => x !== slug)
}
return updatedSubs
})
try {
;(value ? follow : unfollow)(what, slug)
} catch (error) {
console.error(error)
} finally {
fetchData()
}
}
const value: FollowingContextType = {
loading,
subscriptions,
setSubscriptions,
setFollowing,
loadSubscriptions: fetchData,
follow,
unfollow,
}
return <FollowingContext.Provider value={value}>{props.children}</FollowingContext.Provider>
}

View File

@ -34,13 +34,13 @@ export const InboxProvider = (props: { children: JSX.Element }) => {
const { sortedAuthors } = useAuthorsStore() const { sortedAuthors } = useAuthorsStore()
const handleMessage = (sseMessage: SSEMessage) => { const handleMessage = (sseMessage: SSEMessage) => {
console.log('[context.inbox]:', sseMessage)
// handling all action types: create update delete join left seen // handling all action types: create update delete join left seen
if (sseMessage.entity === 'message') { if (sseMessage.entity === 'message') {
console.debug('[context.inbox]:', sseMessage.payload)
const relivedMessage = sseMessage.payload const relivedMessage = sseMessage.payload
setMessages((prev) => [...prev, relivedMessage]) setMessages((prev) => [...prev, relivedMessage])
} else if (sseMessage.entity === 'chat') { } else if (sseMessage.entity === 'chat') {
console.debug('[context.inbox]:', sseMessage.payload)
const relivedChat = sseMessage.payload const relivedChat = sseMessage.payload
setChats((prev) => [...prev, relivedChat]) setChats((prev) => [...prev, relivedChat])
} }

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import type { AuthModalSource } from '../components/Nav/AuthModal/types' import type { AuthModalSource } from '../components/Nav/AuthModal/types'
import type { Author, Result } from '../graphql/schema/core.gen' import type { Author } from '../graphql/schema/core.gen'
import type { Accessor, JSX, Resource } from 'solid-js' import type { Accessor, JSX, Resource } from 'solid-js'
import { import {
@ -10,7 +10,10 @@ import {
ConfigType, ConfigType,
SignupInput, SignupInput,
AuthorizeResponse, AuthorizeResponse,
// GraphqlQueryInput, ApiResponse,
GenericResponse,
ForgotPasswordResponse,
ForgotPasswordInput,
} from '@authorizerdev/authorizer-js' } from '@authorizerdev/authorizer-js'
import { import {
createContext, createContext,
@ -41,20 +44,17 @@ const defaultConfig: ConfigType = {
} }
export type SessionContextType = { export type SessionContextType = {
config: ConfigType config: Accessor<ConfigType>
session: Resource<AuthToken> session: Resource<AuthToken>
author: Resource<Author | null> author: Resource<Author | null>
authError: Accessor<string> authError: Accessor<string>
isSessionLoaded: Accessor<boolean> isSessionLoaded: Accessor<boolean>
subscriptions: Accessor<Result>
isAuthWithCallback: Accessor<() => void>
isAuthenticated: Accessor<boolean> isAuthenticated: Accessor<boolean>
actions: { actions: {
loadSession: () => AuthToken | Promise<AuthToken> loadSession: () => AuthToken | Promise<AuthToken>
setSession: (token: AuthToken | null) => void // setSession setSession: (token: AuthToken | null) => void // setSession
loadAuthor: (info?: unknown) => Author | Promise<Author> loadAuthor: (info?: unknown) => Author | Promise<Author>
setAuthor: (a: Author) => void setAuthor: (a: Author) => void
loadSubscriptions: () => Promise<void>
requireAuthentication: ( requireAuthentication: (
callback: (() => Promise<void>) | (() => void), callback: (() => Promise<void>) | (() => void),
modalSource: AuthModalSource, modalSource: AuthModalSource,
@ -63,6 +63,9 @@ export type SessionContextType = {
signIn: (params: LoginInput) => Promise<void> signIn: (params: LoginInput) => Promise<void>
signOut: () => Promise<void> signOut: () => Promise<void>
oauth: (provider: string) => Promise<void> oauth: (provider: string) => Promise<void>
forgotPassword: (
params: ForgotPasswordInput,
) => Promise<{ data: ForgotPasswordResponse; errors: Error[] }>
changePassword: (password: string, token: string) => void changePassword: (password: string, token: string) => void
confirmEmail: (input: VerifyEmailInput) => Promise<AuthToken | void> // email confirm callback is in auth.discours.io confirmEmail: (input: VerifyEmailInput) => Promise<AuthToken | void> // email confirm callback is in auth.discours.io
setIsSessionLoaded: (loaded: boolean) => void setIsSessionLoaded: (loaded: boolean) => void
@ -70,19 +73,16 @@ export type SessionContextType = {
} }
} }
const noop = () => {}
const SessionContext = createContext<SessionContextType>() const SessionContext = createContext<SessionContextType>()
export function useSession() { export function useSession() {
return useContext(SessionContext) return useContext(SessionContext)
} }
const EMPTY_SUBSCRIPTIONS = {
topics: [],
authors: [],
}
export const SessionProvider = (props: { export const SessionProvider = (props: {
onStateChangeCallback(state: any): unknown onStateChangeCallback(state: AuthToken): unknown
children: JSX.Element children: JSX.Element
}) => { }) => {
const { t } = useLocalize() const { t } = useLocalize()
@ -90,12 +90,12 @@ export const SessionProvider = (props: {
actions: { showSnackbar }, actions: { showSnackbar },
} = useSnackbar() } = useSnackbar()
const { searchParams, changeSearchParams } = useRouter() const { searchParams, changeSearchParams } = useRouter()
const [configuration, setConfig] = createSignal<ConfigType>(defaultConfig) const [config, setConfig] = createSignal<ConfigType>(defaultConfig)
const authorizer = createMemo(() => new Authorizer(configuration())) const authorizer = createMemo(() => new Authorizer(config()))
const [oauthState, setOauthState] = createSignal<string>() const [oauthState, setOauthState] = createSignal<string>()
// handle callback's redirect_uri // handle callback's redirect_uri
createEffect(async () => { createEffect(() => {
// oauth // oauth
const state = searchParams()?.state const state = searchParams()?.state
if (state) { if (state) {
@ -119,43 +119,54 @@ export const SessionProvider = (props: {
}) })
// load // load
let minuteLater let minuteLater: NodeJS.Timeout | null
const [isSessionLoaded, setIsSessionLoaded] = createSignal(false) const [isSessionLoaded, setIsSessionLoaded] = createSignal(false)
const [authError, setAuthError] = createSignal('') const [authError, setAuthError] = createSignal('')
const [session, { refetch: loadSession, mutate: setSession }] = createResource<AuthToken>(
async () => { // Function to load session data
try { const sessionData = async () => {
const s = await authorizer().getSession() try {
const s: ApiResponse<AuthToken> = await authorizer().getSession()
if (s?.data) {
console.info('[context.session] loading session', s) console.info('[context.session] loading session', s)
// Set session expiration time in local storage // Set session expiration time in local storage
const expires_at = new Date(Date.now() + s.expires_in * 1000) const expires_at = new Date(Date.now() + s.data.expires_in * 1000)
localStorage.setItem('expires_at', `${expires_at.getTime()}`) localStorage.setItem('expires_at', `${expires_at.getTime()}`)
// Set up session expiration check timer // Set up session expiration check timer
minuteLater = setTimeout(checkSessionIsExpired, 60 * 1000) minuteLater = setTimeout(checkSessionIsExpired, 60 * 1000)
console.info(`[context.session] will refresh in ${s.expires_in / 60} mins`) console.info(`[context.session] will refresh in ${s.data.expires_in / 60} mins`)
// Set the session loaded flag // Set the session loaded flag
setIsSessionLoaded(true) setIsSessionLoaded(true)
return s return s.data
} catch (error) { } else {
console.info('[context.session] cannot refresh session', error) console.info('[context.session] cannot refresh session', s.errors)
setAuthError(error) setAuthError(s.errors.pop().message)
// Set the session loaded flag even if there's an error // Set the session loaded flag even if there's an error
setIsSessionLoaded(true) setIsSessionLoaded(true)
return null return null
} }
}, } catch (error) {
{ console.info('[context.session] cannot refresh session', error)
ssrLoadFrom: 'initial', setAuthError(error)
initialValue: null,
}, // Set the session loaded flag even if there's an error
) setIsSessionLoaded(true)
return null
}
}
const [session, { refetch: loadSession, mutate: setSession }] = createResource<AuthToken>(sessionData, {
ssrLoadFrom: 'initial',
initialValue: null,
})
const checkSessionIsExpired = () => { const checkSessionIsExpired = () => {
const expires_at_data = localStorage.getItem('expires_at') const expires_at_data = localStorage.getItem('expires_at')
@ -176,26 +187,17 @@ export const SessionProvider = (props: {
} }
onCleanup(() => clearTimeout(minuteLater)) onCleanup(() => clearTimeout(minuteLater))
const authorData = async () => {
const [author, { refetch: loadAuthor, mutate: setAuthor }] = createResource<Author | null>( const u = session()?.user
async () => { return u ? (await apiClient.getAuthorId({ user: u.id.trim() })) || null : null
const u = session()?.user
return u ? (await apiClient.getAuthorId({ user: u.id.trim() })) || null : null
},
{
ssrLoadFrom: 'initial',
initialValue: null,
},
)
const [subscriptions, setSubscriptions] = createSignal<Result>(EMPTY_SUBSCRIPTIONS)
const loadSubscriptions = async (): Promise<void> => {
const result = await apiClient.getMySubscriptions()
setSubscriptions(result || EMPTY_SUBSCRIPTIONS)
} }
const [author, { refetch: loadAuthor, mutate: setAuthor }] = createResource<Author | null>(authorData, {
ssrLoadFrom: 'initial',
initialValue: null,
})
// when session is loaded // when session is loaded
createEffect(async () => { createEffect(() => {
if (session()) { if (session()) {
const token = session()?.access_token const token = session()?.access_token
if (token) { if (token) {
@ -205,23 +207,24 @@ export const SessionProvider = (props: {
notifierClient.connect(token) notifierClient.connect(token)
inboxClient.connect(token) inboxClient.connect(token)
} }
if (!author()) { if (!author()) loadAuthor()
const a = await loadAuthor()
if (a) {
await loadSubscriptions()
addAuthors([a])
} else {
reset()
}
}
setIsSessionLoaded(true) setIsSessionLoaded(true)
} }
} }
}) })
// when author is loaded
createEffect(() => {
if (author()) {
addAuthors([author()])
} else {
reset()
}
})
const reset = () => { const reset = () => {
setIsSessionLoaded(true) setIsSessionLoaded(true)
setSubscriptions(EMPTY_SUBSCRIPTIONS)
setSession(null) setSession(null)
setAuthor(null) setAuthor(null)
} }
@ -250,31 +253,41 @@ export const SessionProvider = (props: {
), ),
) )
// require auth wrapper const [authCallback, setAuthCallback] = createSignal<() => void>(() => {})
const [isAuthWithCallback, setIsAuthWithCallback] = createSignal<() => void>() const requireAuthentication = (callback: () => void, modalSource: AuthModalSource) => {
const requireAuthentication = async (callback: () => void, modalSource: AuthModalSource) => { setAuthCallback((_cb) => callback)
setIsAuthWithCallback(() => callback)
await loadSession()
if (!session()) { if (!session()) {
showModal('auth', modalSource) loadSession()
if (!session()) {
showModal('auth', modalSource)
}
} }
} }
createEffect(() => {
const handler = authCallback()
if (handler !== noop) {
handler()
setAuthCallback((_cb) => noop)
}
})
// authorizer api proxy methods // authorizer api proxy methods
const signUp = async (params: SignupInput) => { const signUp = async (params: SignupInput) => {
const authResult: void | AuthToken = await authorizer().signup(params) const authResult: ApiResponse<AuthToken> = await authorizer().signup(params)
if (authResult) setSession(authResult) if (authResult?.data) setSession(authResult.data)
if (authResult?.errors) console.error(authResult.errors)
} }
const signIn = async (params: LoginInput) => { const signIn = async (params: LoginInput) => {
const authResult: AuthToken | void = await authorizer().login(params) const authResult: ApiResponse<AuthToken> = await authorizer().login(params)
if (authResult) setSession(authResult) if (authResult?.data) setSession(authResult.data)
if (authResult?.errors) console.error(authResult.errors)
} }
const signOut = async () => { const signOut = async () => {
await authorizer().logout() const authResult: ApiResponse<GenericResponse> = await authorizer().logout()
console.debug(authResult)
reset() reset()
showSnackbar({ body: t("You've successfully logged out") }) showSnackbar({ body: t("You've successfully logged out") })
} }
@ -284,12 +297,22 @@ export const SessionProvider = (props: {
console.debug('[context.session] change password response:', resp) console.debug('[context.session] change password response:', resp)
} }
const forgotPassword = async (params: ForgotPasswordInput) => {
const resp = await authorizer().forgotPassword(params)
console.debug('[context.session] change password response:', resp)
return { data: resp?.data, errors: resp.errors }
}
const confirmEmail = async (input: VerifyEmailInput) => { const confirmEmail = async (input: VerifyEmailInput) => {
console.debug(`[context.session] calling authorizer's verify email with`, input) console.debug(`[context.session] calling authorizer's verify email with`, input)
try { try {
const at: void | AuthToken = await authorizer().verifyEmail(input) const at: ApiResponse<AuthToken> = await authorizer().verifyEmail(input)
if (at) setSession(at) if (at?.data) {
return at setSession(at.data)
return at.data
} else {
console.warn(at?.errors)
}
} catch (error) { } catch (error) {
console.warn(error) console.warn(error)
} }
@ -315,7 +338,6 @@ export const SessionProvider = (props: {
const isAuthenticated = createMemo(() => Boolean(author())) const isAuthenticated = createMemo(() => Boolean(author()))
const actions = { const actions = {
loadSession, loadSession,
loadSubscriptions,
requireAuthentication, requireAuthentication,
signUp, signUp,
signIn, signIn,
@ -326,18 +348,17 @@ export const SessionProvider = (props: {
setAuthor, setAuthor,
authorizer, authorizer,
loadAuthor, loadAuthor,
forgotPassword,
changePassword, changePassword,
oauth, oauth,
} }
const value: SessionContextType = { const value: SessionContextType = {
authError, authError,
config: configuration(), config,
session, session,
subscriptions,
isSessionLoaded, isSessionLoaded,
author, author,
actions, actions,
isAuthWithCallback,
isAuthenticated, isAuthenticated,
} }

View File

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

View File

@ -12,6 +12,8 @@ import type {
QueryLoad_Authors_ByArgs, QueryLoad_Authors_ByArgs,
QueryLoad_Shouts_SearchArgs, QueryLoad_Shouts_SearchArgs,
QueryLoad_Shouts_Random_TopArgs, QueryLoad_Shouts_Random_TopArgs,
Community,
MutationDelete_ShoutArgs,
} from '../schema/core.gen' } from '../schema/core.gen'
import { createGraphQLClient } from '../createGraphQLClient' import { createGraphQLClient } from '../createGraphQLClient'
@ -37,20 +39,24 @@ import authorBy from '../query/core/author-by'
import authorFollowers from '../query/core/author-followers' import authorFollowers from '../query/core/author-followers'
import authorId from '../query/core/author-id' import authorId from '../query/core/author-id'
import authorsAll from '../query/core/authors-all' import authorsAll from '../query/core/authors-all'
import authorFollowed from '../query/core/authors-followed-by'
import authorsLoadBy from '../query/core/authors-load-by' import authorsLoadBy from '../query/core/authors-load-by'
import mySubscriptions from '../query/core/my-followed' import mySubscriptions from '../query/core/my-followed'
import reactionsLoadBy from '../query/core/reactions-load-by' import reactionsLoadBy from '../query/core/reactions-load-by'
import topicBySlug from '../query/core/topic-by-slug' import topicBySlug from '../query/core/topic-by-slug'
import topicsAll from '../query/core/topics-all' import topicsAll from '../query/core/topics-all'
import userFollowedTopics from '../query/core/topics-by-author' import authorFollowedAuthors from '../query/core/authors-followed-by'
import authorFollowedTopics from '../query/core/topics-followed-by'
import authorFollowedCommunities from '../query/core/communities-followed-by'
import topicsRandomQuery from '../query/core/topics-random' import topicsRandomQuery from '../query/core/topics-random'
const publicGraphQLClient = createGraphQLClient('core') const publicGraphQLClient = createGraphQLClient('core')
export const apiClient = { export const apiClient = {
private: null, private: null,
connect: (token: string) => (apiClient.private = createGraphQLClient('core', token)), // NOTE: use it after token appears connect: (token: string) => {
// NOTE: use it after token appears
apiClient.private = createGraphQLClient('core', token)
},
getRandomTopShouts: async (params: QueryLoad_Shouts_Random_TopArgs) => { getRandomTopShouts: async (params: QueryLoad_Shouts_Random_TopArgs) => {
const response = await publicGraphQLClient.query(loadShoutsTopRandom, params).toPromise() const response = await publicGraphQLClient.query(loadShoutsTopRandom, params).toPromise()
@ -119,14 +125,18 @@ export const apiClient = {
const response = await publicGraphQLClient.query(authorFollowers, { slug }).toPromise() const response = await publicGraphQLClient.query(authorFollowers, { slug }).toPromise()
return response.data.get_author_followers return response.data.get_author_followers
}, },
getAuthorFollowingUsers: async ({ slug }: { slug: string }): Promise<Author[]> => { getAuthorFollowingAuthors: async ({ slug }: { slug: string }): Promise<Author[]> => {
const response = await publicGraphQLClient.query(authorFollowed, { slug }).toPromise() const response = await publicGraphQLClient.query(authorFollowedAuthors, { slug }).toPromise()
return response.data.get_author_followed return response.data.get_author_followed
}, },
getAuthorFollowingTopics: async ({ slug }: { slug: string }): Promise<Topic[]> => { getAuthorFollowingTopics: async ({ slug }: { slug: string }): Promise<Topic[]> => {
const response = await publicGraphQLClient.query(userFollowedTopics, { slug }).toPromise() const response = await publicGraphQLClient.query(authorFollowedTopics, { slug }).toPromise()
return response.data.get_topics_by_author return response.data.get_topics_by_author
}, },
getAuthorFollowingCommunities: async ({ slug }: { slug: string }): Promise<Community[]> => {
const response = await publicGraphQLClient.query(authorFollowedCommunities, { slug }).toPromise()
return response.data.get_communities_by_author
},
updateProfile: async (input: ProfileInput) => { updateProfile: async (input: ProfileInput) => {
const response = await apiClient.private.mutation(updateProfile, { profile: input }).toPromise() const response = await apiClient.private.mutation(updateProfile, { profile: input }).toPromise()
return response.data.update_profile return response.data.update_profile
@ -154,8 +164,8 @@ export const apiClient = {
console.debug('[graphql.client.core] updateArticle:', response.data) console.debug('[graphql.client.core] updateArticle:', response.data)
return response.data.update_shout.shout return response.data.update_shout.shout
}, },
deleteShout: async ({ shoutId }: { shoutId: number }): Promise<void> => { deleteShout: async (params: MutationDelete_ShoutArgs): Promise<void> => {
const response = await apiClient.private.mutation(deleteShout, { shout_id: shoutId }).toPromise() const response = await apiClient.private.mutation(deleteShout, params).toPromise()
console.debug('[graphql.client.core] deleteShout:', response) console.debug('[graphql.client.core] deleteShout:', response)
}, },
getDrafts: async (): Promise<Shout[]> => { getDrafts: async (): Promise<Shout[]> => {
@ -200,7 +210,7 @@ export const apiClient = {
const resp = await publicGraphQLClient.query(shoutsLoadBy, { options }).toPromise() const resp = await publicGraphQLClient.query(shoutsLoadBy, { options }).toPromise()
if (resp.error) console.error(resp) if (resp.error) console.error(resp)
return resp.data.load_shouts_by return resp.data?.load_shouts_by
}, },
getShoutsSearch: async ({ text, limit, offset }: QueryLoad_Shouts_SearchArgs) => { getShoutsSearch: async ({ text, limit, offset }: QueryLoad_Shouts_SearchArgs) => {

View File

@ -1,8 +1,8 @@
import { gql } from '@urql/core' import { gql } from '@urql/core'
export default gql` export default gql`
mutation DeleteShoutMutation($shoutId: Int!) { mutation DeleteShoutMutation($shout_id: Int!) {
delete_shout(shout_id: $shoutId) { delete_shout(shout_id: $shout_id) {
error error
} }
} }

View File

@ -8,7 +8,6 @@ export default gql`
id id
body body
kind kind
range
created_at created_at
reply_to reply_to
stat { stat {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { gql } from '@urql/core' import { gql } from '@urql/core'
export default gql` export default gql`
query AuthorsAllQuery($by: AuthorsBy, $limit: Int, $offset: Int) { query AuthorsAllQuery($by: AuthorsBy!, $limit: Int, $offset: Int) {
load_authors_by(by: $by, limit: $limit, offset: $offset) { load_authors_by(by: $by, limit: $limit, offset: $offset) {
id id
slug slug

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