Merge branch 'hotfix/posting' of github.com:Discours/discoursio-webapp into feature/sse-connect
This commit is contained in:
commit
43b3361e0e
|
@ -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/*
|
||||||
|
|
151
.eslintrc.cjs
151
.eslintrc.cjs
|
@ -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
1
.gitignore
vendored
|
@ -16,3 +16,4 @@ stats.html
|
||||||
*.scss.d.ts
|
*.scss.d.ts
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
bun.lockb
|
bun.lockb
|
||||||
|
.jj
|
||||||
|
|
|
@ -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
1598
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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": "Добро пожаловать в Дискурс",
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
@ -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')}
|
{t('or')}
|
||||||
<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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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 = () => {
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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(),
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
31
src/components/Editor/extensions/Span.ts
Normal file
31
src/components/Editor/extensions/Span.ts
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
50
src/components/Editor/extensions/ToggleTextWrap.ts
Normal file
50
src/components/Editor/extensions/ToggleTextWrap.ts
Normal 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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
|
@ -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) {
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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)} />*/}
|
||||||
|
|
|
@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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}>
|
|
||||||
Для поиска публикаций, искусства, комментариев, интересных вам авторов и тем, просто начните
|
|
||||||
вводить ваш запрос
|
|
||||||
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
33
src/components/Nav/SearchModal/SearchResultItem.tsx
Normal file
33
src/components/Nav/SearchModal/SearchResultItem.tsx
Normal 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} />
|
||||||
|
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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')}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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 />}>
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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')} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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()
|
||||||
|
|
10
src/components/_shared/ImageCropper/ImageCropper.module.scss
Normal file
10
src/components/_shared/ImageCropper/ImageCropper.module.scss
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
.cropperContainer {
|
||||||
|
max-height: 55vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropperControls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
78
src/components/_shared/ImageCropper/ImageCropper.tsx
Normal file
78
src/components/_shared/ImageCropper/ImageCropper.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
1
src/components/_shared/ImageCropper/index.tsx
Normal file
1
src/components/_shared/ImageCropper/index.tsx
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { ImageCropper } from './ImageCropper'
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1 +0,0 @@
|
||||||
export { InviteCoAuthorsModal } from './InviteCoAuthorsModal'
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
187
src/components/_shared/InviteMembers/InviteMembers.tsx
Normal file
187
src/components/_shared/InviteMembers/InviteMembers.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
1
src/components/_shared/InviteMembers/index.ts
Normal file
1
src/components/_shared/InviteMembers/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { InviteMembers } from './InviteMembers'
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)}>
|
||||||
−
|
−
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1 +0,0 @@
|
||||||
export { UserSearch } from './UserSearch'
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
122
src/context/following.tsx
Normal 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>
|
||||||
|
}
|
|
@ -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])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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> => {
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,6 @@ export default gql`
|
||||||
id
|
id
|
||||||
body
|
body
|
||||||
kind
|
kind
|
||||||
range
|
|
||||||
created_at
|
created_at
|
||||||
reply_to
|
reply_to
|
||||||
stat {
|
stat {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -47,7 +47,7 @@ export default gql`
|
||||||
published_at
|
published_at
|
||||||
stat {
|
stat {
|
||||||
viewed
|
viewed
|
||||||
reacted
|
|
||||||
rating
|
rating
|
||||||
commented
|
commented
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,7 +36,7 @@ export default gql`
|
||||||
published_at
|
published_at
|
||||||
stat {
|
stat {
|
||||||
viewed
|
viewed
|
||||||
reacted
|
|
||||||
rating
|
rating
|
||||||
commented
|
commented
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,7 +34,7 @@ export default gql`
|
||||||
published_at
|
published_at
|
||||||
stat {
|
stat {
|
||||||
viewed
|
viewed
|
||||||
reacted
|
|
||||||
rating
|
rating
|
||||||
commented
|
commented
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,7 @@ export default gql`
|
||||||
published_at
|
published_at
|
||||||
stat {
|
stat {
|
||||||
viewed
|
viewed
|
||||||
reacted
|
|
||||||
rating
|
rating
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ export default gql`
|
||||||
published_at
|
published_at
|
||||||
stat {
|
stat {
|
||||||
viewed
|
viewed
|
||||||
reacted
|
|
||||||
rating
|
rating
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,7 +36,7 @@ export default gql`
|
||||||
published_at
|
published_at
|
||||||
stat {
|
stat {
|
||||||
viewed
|
viewed
|
||||||
reacted
|
|
||||||
rating
|
rating
|
||||||
commented
|
commented
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,7 +52,7 @@ export default gql`
|
||||||
published_at
|
published_at
|
||||||
stat {
|
stat {
|
||||||
viewed
|
viewed
|
||||||
reacted
|
|
||||||
rating
|
rating
|
||||||
commented
|
commented
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,7 +37,7 @@ export default gql`
|
||||||
published_at
|
published_at
|
||||||
stat {
|
stat {
|
||||||
viewed
|
viewed
|
||||||
reacted
|
|
||||||
rating
|
rating
|
||||||
commented
|
commented
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
Loading…
Reference in New Issue
Block a user