postmerge-fixes

This commit is contained in:
Untone 2023-12-22 20:45:01 +03:00
commit 0adeba1407
61 changed files with 1273 additions and 441 deletions

View File

@ -2,6 +2,7 @@
"A guide to horizontal editorial: how an open journal works": "A guide to horizontal editorial: how an open journal works", "A guide to horizontal editorial: how an open journal works": "A guide to horizontal editorial: how an open journal works",
"About": "About", "About": "About",
"About the project": "About the project", "About the project": "About the project",
"Add": "Add",
"Add a few topics so that the reader knows what your content is about and can find it on pages of topics that interest them. Topics can be swapped, the first topic becomes the title": "Add a few topics so that the reader knows what your content is about and can find it on pages of topics that interest them. Topics can be swapped, the first topic becomes the title", "Add a few topics so that the reader knows what your content is about and can find it on pages of topics that interest them. Topics can be swapped, the first topic becomes the title": "Add a few topics so that the reader knows what your content is about and can find it on pages of topics that interest them. Topics can be swapped, the first topic becomes the title",
"Add a link or click plus to embed media": "Add a link or click plus to embed media", "Add a link or click plus to embed media": "Add a link or click plus to embed media",
"Add an embed widget": "Add an embed widget", "Add an embed widget": "Add an embed widget",
@ -24,6 +25,7 @@
"Alignment left": "Alignment left", "Alignment left": "Alignment left",
"Alignment right": "Alignment right", "Alignment right": "Alignment right",
"All": "All", "All": "All",
"All articles": "All articles",
"All authors": "All authors", "All authors": "All authors",
"All posts": "All posts", "All posts": "All posts",
"All topics": "All topics", "All topics": "All topics",
@ -59,18 +61,26 @@
"By title": "By title", "By title": "By title",
"By updates": "By updates", "By updates": "By updates",
"By views": "By views", "By views": "By views",
"Can make any changes, accept or reject suggestions, and share access with others": "Can make any changes, accept or reject suggestions, and share access with others",
"Can offer edits and comments, but cannot edit the post or share access with others": "Can offer edits and comments, but cannot edit the post or share access with others",
"Can write and edit text directly, and accept or reject suggestions from others": "Can write and edit text directly, and accept or reject suggestions from others",
"Cancel": "Cancel", "Cancel": "Cancel",
"Cancel changes": "Cancel changes", "Cancel changes": "Cancel changes",
"Change password": "Change password",
"Characters": "Знаков", "Characters": "Знаков",
"Chat Title": "Chat Title", "Chat Title": "Chat Title",
"Choose a post type": "Choose a post type", "Choose a post type": "Choose a post type",
"Choose a title image for the article. You can immediately see how the publication card will look like.": "Choose a title image for the article. You can immediately see how the publication card will look like.", "Choose a title image for the article. You can immediately see how the publication card will look like.": "Choose a title image for the article. You can immediately see how the publication card will look like.",
"Choose who you want to write to": "Choose who you want to write to", "Choose who you want to write to": "Choose who you want to write to",
"Co-author": "Co-author",
"Collaborate": "Help Edit", "Collaborate": "Help Edit",
"Collaborators": "Collaborators",
"Collections": "Collections", "Collections": "Collections",
"Come up with a subtitle for your story": "Come up with a subtitle for your story", "Come up with a subtitle for your story": "Come up with a subtitle for your story",
"Come up with a title for your story": "Come up with a title for your story", "Come up with a title for your story": "Come up with a title for your story",
"Coming soon": "Coming soon",
"Comment successfully deleted": "Comment successfully deleted", "Comment successfully deleted": "Comment successfully deleted",
"Commentator": "Commentator",
"Comments": "Comments", "Comments": "Comments",
"Communities": "Communities", "Communities": "Communities",
"Community Discussion Rules": "Community Discussion Rules", "Community Discussion Rules": "Community Discussion Rules",
@ -119,9 +129,11 @@
"Edit": "Edit", "Edit": "Edit",
"Edit profile": "Edit profile", "Edit profile": "Edit profile",
"Editing": "Editing", "Editing": "Editing",
"Editor": "Editor",
"Email": "Mail", "Email": "Mail",
"Enter": "Enter", "Enter": "Enter",
"Enter URL address": "Enter URL address", "Enter URL address": "Enter URL address",
"Enter a new password": "Enter a new password",
"Enter footnote text": "Enter footnote text", "Enter footnote text": "Enter footnote text",
"Enter image description": "Enter image description", "Enter image description": "Enter image description",
"Enter image title": "Enter image title", "Enter image title": "Enter image title",
@ -191,6 +203,7 @@
"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 co-authors": "Invite co-authors", "Invite co-authors": "Invite co-authors",
"Invite collaborators": "Invite collaborators",
"Invite to collab": "Invite to Collab", "Invite to collab": "Invite to Collab",
"It does not look like url": "It doesn't look like a link", "It does not look like url": "It doesn't look like a link",
"Italic": "Italic", "Italic": "Italic",
@ -237,6 +250,7 @@
"Nothing here yet": "There's nothing here yet", "Nothing here yet": "There's nothing here yet",
"Nothing is here": "There is nothing here", "Nothing is here": "There is nothing here",
"Notifications": "Notifications", "Notifications": "Notifications",
"Now you can enter a new password, it must contain at least 8 characters and not be the same as the previous password": "Now you can enter a new password, it must contain at least 8 characters and not be the same as the previous password",
"Or paste a link to an image": "Or paste a link to an image", "Or paste a link to an image": "Or paste a link to an image",
"Ordered list": "Ordered list", "Ordered list": "Ordered list",
"Our regular contributor": "Our regular contributor", "Our regular contributor": "Our regular contributor",
@ -250,6 +264,7 @@
"Password should be at least 8 characters": "Password should be at least 8 characters", "Password should be at least 8 characters": "Password should be at least 8 characters",
"Password should contain at least one number": "Password should contain at least one number", "Password should contain at least one number": "Password should contain at least one number",
"Password should contain at least one special character: !@#$%^&*": "Password should contain at least one special character: !@#$%^&*", "Password should contain at least one special character: !@#$%^&*": "Password should contain at least one special character: !@#$%^&*",
"Password updated!": "Password updated!",
"Passwords are not equal": "Passwords are not equal", "Passwords are not equal": "Passwords are not equal",
"Paste Embed code": "Paste Embed code", "Paste Embed code": "Paste Embed code",
"Personal": "Personal", "Personal": "Personal",
@ -354,9 +369,12 @@
"This comment has not yet been rated": "This comment has not yet been rated", "This comment has not yet been rated": "This comment has not yet been rated",
"This email is already taken. If it's you": "This email is already taken. If it's you", "This email is already taken. If it's you": "This email is already taken. If it's you",
"This functionality is currently not available, we would like to work on this issue. Use the download link.": "This functionality is currently not available, we would like to work on this issue. Use the download link.", "This functionality is currently not available, we would like to work on this issue. Use the download link.": "This functionality is currently not available, we would like to work on this issue. Use the download link.",
"This month": "This month",
"This post has not been rated yet": "This post has not been rated yet", "This post has not been rated yet": "This post has not been rated yet",
"This way we ll realize that you re a real person and ll take your vote into account. And you ll see how others voted": "This way we ll realize that you re a real person and ll take your vote into account. And you ll see how others voted", "This way we ll realize that you re a real person and ll take your vote into account. And you ll see how others voted": "This way we ll realize that you re a real person and ll take your vote into account. And you ll see how others voted",
"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 year": "This year",
"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",
@ -385,6 +403,7 @@
"Video": "Video", "Video": "Video",
"Video format not supported": "Video format not supported", "Video format not supported": "Video format not supported",
"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 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 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.",
@ -407,7 +426,9 @@
"Write good articles, comment\nand it won't be so empty here": "Write good articles, comment\nand it won't be so empty here", "Write good articles, comment\nand it won't be so empty here": "Write good articles, comment\nand it won't be so empty here",
"Write message": "Write a message", "Write message": "Write a message",
"Write to us": "Write to us", "Write to us": "Write to us",
"Write your colleagues name or email": "Write your colleague's name or email",
"You can download multiple tracks at once in .mp3, .wav or .flac formats": "You can download multiple tracks at once in .mp3, .wav or .flac formats", "You can download multiple tracks at once in .mp3, .wav or .flac formats": "You can download multiple tracks at once in .mp3, .wav or .flac formats",
"You can now login using your new password": "Теперь вы можете входить с помощью нового пароля",
"You were successfully authorized": "You were successfully authorized", "You were successfully authorized": "You were successfully authorized",
"You ll be able to participate in discussions, rate others' comments and learn about new responses": "You ll be able to participate in discussions, rate others' comments and learn about new responses", "You ll be able to participate in discussions, rate others' comments and learn about new responses": "You ll be able to participate in discussions, rate others' comments and learn about new responses",
"You've confirmed email": "You've confirmed email", "You've confirmed email": "You've confirmed email",

View File

@ -3,6 +3,7 @@
"A short introduction to keep the reader interested": "Добавьте вступление, чтобы заинтересовать читателя", "A short introduction to keep the reader interested": "Добавьте вступление, чтобы заинтересовать читателя",
"About": "О себе", "About": "О себе",
"About the project": "О проекте", "About the project": "О проекте",
"Add": "Добавить",
"Add a few topics so that the reader knows what your content is about and can find it on pages of topics that interest them. Topics can be swapped, the first topic becomes the title": "Добавьте несколько тем, чтобы читатель знал, о чем ваш материал, и мог найти его на страницах интересных ему тем. Темы можно менять местами, первая тема становится заглавной", "Add a few topics so that the reader knows what your content is about and can find it on pages of topics that interest them. Topics can be swapped, the first topic becomes the title": "Добавьте несколько тем, чтобы читатель знал, о чем ваш материал, и мог найти его на страницах интересных ему тем. Темы можно менять местами, первая тема становится заглавной",
"Add a link or click plus to embed media": "Добавьте ссылку или нажмите плюс для вставки медиа", "Add a link or click plus to embed media": "Добавьте ссылку или нажмите плюс для вставки медиа",
"Add an embed widget": "Добавить embed-виджет", "Add an embed widget": "Добавить embed-виджет",
@ -26,6 +27,7 @@
"Alignment left": "По левому краю", "Alignment left": "По левому краю",
"Alignment right": "По правому краю", "Alignment right": "По правому краю",
"All": "Все", "All": "Все",
"All articles": "Все материалы",
"All authors": "Все авторы", "All authors": "Все авторы",
"All posts": "Все публикации", "All posts": "Все публикации",
"All topics": "Все темы", "All topics": "Все темы",
@ -62,19 +64,27 @@
"By title": "По названию", "By title": "По названию",
"By updates": "По обновлениям", "By updates": "По обновлениям",
"By views": "По просмотрам", "By views": "По просмотрам",
"Can make any changes, accept or reject suggestions, and share access with others": "Может вносить любые изменения, принимать и отклонять предложения, а также делиться доступом с другими",
"Can offer edits and comments, but cannot edit the post or share access with others": "Может предлагать правки и комментарии, но не может изменять пост и делиться доступом с другими",
"Can write and edit text directly, and accept or reject suggestions from others": "Может писать и редактировать текст напрямую, а также принимать или отклонять предложения других",
"Cancel": "Отмена", "Cancel": "Отмена",
"Cancel changes": "Отменить изменения", "Cancel changes": "Отменить изменения",
"Change password": "Сменить пароль",
"Characters": "Знаков", "Characters": "Знаков",
"Chat Title": "Тема дискурса", "Chat Title": "Тема дискурса",
"Choose a post type": "Выберите тип публикации", "Choose a post type": "Выберите тип публикации",
"Choose a title image for the article. You can immediately see how the publication card will look like.": "Выберите заглавное изображение для статьи. Тут же сразу можно увидеть как будет выглядеть карточка публикации.", "Choose a title image for the article. You can immediately see how the publication card will look like.": "Выберите заглавное изображение для статьи. Тут же сразу можно увидеть как будет выглядеть карточка публикации.",
"Choose who you want to write to": "Выберите кому хотите написать", "Choose who you want to write to": "Выберите кому хотите написать",
"Co-author": "Соавтор",
"Collaborate": "Помочь редактировать", "Collaborate": "Помочь редактировать",
"Collaborators": "Соавторы",
"Collections": "Коллекции", "Collections": "Коллекции",
"Come up with a subtitle for your story": "Придумайте подзаголовок вашей истории", "Come up with a subtitle for your story": "Придумайте подзаголовок вашей истории",
"Come up with a title for your story": "Придумайте заголовок вашей истории", "Come up with a title for your story": "Придумайте заголовок вашей истории",
"Coming soon": "Уже скоро",
"Comment": "Комментировать", "Comment": "Комментировать",
"Comment successfully deleted": "Комментарий успешно удален", "Comment successfully deleted": "Комментарий успешно удален",
"Commentator": "Комментатор",
"Comments": "Комментарии", "Comments": "Комментарии",
"Communities": "Сообщества", "Communities": "Сообщества",
"Community Discussion Rules": "Правила дискуссий в сообществе", "Community Discussion Rules": "Правила дискуссий в сообществе",
@ -125,9 +135,11 @@
"Edit profile": "Редактировать профиль", "Edit profile": "Редактировать профиль",
"Edited": "Отредактирован", "Edited": "Отредактирован",
"Editing": "Редактирование", "Editing": "Редактирование",
"Editor": "Редактор",
"Email": "Почта", "Email": "Почта",
"Enter": "Войти", "Enter": "Войти",
"Enter URL address": "Введите адрес ссылки", "Enter URL address": "Введите адрес ссылки",
"Enter a new password": "Введите новый пароль",
"Enter footnote text": "Введите текст сноски", "Enter footnote text": "Введите текст сноски",
"Enter image description": "Введите описание изображения", "Enter image description": "Введите описание изображения",
"Enter image title": "Введите название изображения", "Enter image title": "Введите название изображения",
@ -200,6 +212,7 @@
"Invalid image URL": "Некорректная ссылка на изображение", "Invalid image URL": "Некорректная ссылка на изображение",
"Invalid url format": "Неверный формат ссылки", "Invalid url format": "Неверный формат ссылки",
"Invite co-authors": "Пригласить соавторов", "Invite co-authors": "Пригласить соавторов",
"Invite collaborators": "Пригласить соавторов",
"Invite experts": "Пригласить экспертов", "Invite experts": "Пригласить экспертов",
"Invite to collab": "Пригласить к участию", "Invite to collab": "Пригласить к участию",
"It does not look like url": "Это не похоже на ссылку", "It does not look like url": "Это не похоже на ссылку",
@ -249,6 +262,7 @@
"Nothing here yet": "Здесь пока ничего нет", "Nothing here yet": "Здесь пока ничего нет",
"Nothing is here": "Здесь ничего нет", "Nothing is here": "Здесь ничего нет",
"Notifications": "Уведомления", "Notifications": "Уведомления",
"Now you can enter a new password, it must contain at least 8 characters and not be the same as the previous password": "Теперь можете ввести новый пароль, он должен содержать минимум 8 символов и не совпадать с предыдущим паролем",
"Or paste a link to an image": "Или вставьте ссылку на изображение", "Or paste a link to an image": "Или вставьте ссылку на изображение",
"Ordered list": "Нумерованный список", "Ordered list": "Нумерованный список",
"Our regular contributor": "Наш постоянный автор", "Our regular contributor": "Наш постоянный автор",
@ -262,6 +276,7 @@
"Password should be at least 8 characters": "Пароль должен быть не менее 8 символов", "Password should be at least 8 characters": "Пароль должен быть не менее 8 символов",
"Password should contain at least one number": "Пароль должен содержать хотя бы одну цифру", "Password should contain at least one number": "Пароль должен содержать хотя бы одну цифру",
"Password should contain at least one special character: !@#$%^&*": "Пароль должен содержать хотя бы один спецсимвол: !@#$%^&*", "Password should contain at least one special character: !@#$%^&*": "Пароль должен содержать хотя бы один спецсимвол: !@#$%^&*",
"Password updated!": "Пароль обновлен!",
"Passwords are not equal": "Пароли не совпадают", "Passwords are not equal": "Пароли не совпадают",
"Paste Embed code": "Вставьте embed код", "Paste Embed code": "Вставьте embed код",
"Personal": "Личные", "Personal": "Личные",
@ -375,9 +390,12 @@
"This comment has not yet been rated": "Этот комментарий еще пока никто не оценил", "This comment has not yet been rated": "Этот комментарий еще пока никто не оценил",
"This email is already taken. If it's you": "Такой email уже зарегистрирован. Если это вы", "This email is already taken. If it's you": "Такой email уже зарегистрирован. Если это вы",
"This functionality is currently not available, we would like to work on this issue. Use the download link.": "В данный момент этот функционал не доступен, бы работаем над этой проблемой. Воспользуйтесь загрузкой по ссылке.", "This functionality is currently not available, we would like to work on this issue. Use the download link.": "В данный момент этот функционал не доступен, бы работаем над этой проблемой. Воспользуйтесь загрузкой по ссылке.",
"This month": "За месяц",
"This post has not been rated yet": "Эту публикацию еще пока никто не оценил", "This post has not been rated yet": "Эту публикацию еще пока никто не оценил",
"This way we ll realize that you re a real person and ll take your vote into account. And you ll see how others voted": "Так мы поймем, что вы реальный человек, и учтем ваш голос. А вы увидите, как проголосовали другие", "This way we ll realize that you re a real person and ll take your vote into account. And you ll see how others voted": "Так мы поймем, что вы реальный человек, и учтем ваш голос. А вы увидите, как проголосовали другие",
"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 year": "За год",
"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": "Рейтинг авторов",
@ -406,6 +424,7 @@
"Video": "Видео", "Video": "Видео",
"Video format not supported": "Тип видео не поддерживается", "Video format not supported": "Тип видео не поддерживается",
"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 can't find you, check email or": "Не можем вас найти, проверьте адрес электронной почты или", "We can't find you, check email or": "Не можем вас найти, проверьте адрес электронной почты или",
"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.": "Мы выслали вам письмо с ссылкой на почту. Перейдите по ссылке в письме, чтобы войти на сайт.",
@ -429,7 +448,9 @@
"Write good articles, comment\nand it won't be so empty here": "Пишите хорошие статьи, комментируйте,\nи здесь станет не так пусто", "Write good articles, comment\nand it won't be so empty here": "Пишите хорошие статьи, комментируйте,\nи здесь станет не так пусто",
"Write message": "Написать сообщение", "Write message": "Написать сообщение",
"Write to us": "Напишите нам", "Write to us": "Напишите нам",
"Write your colleagues name or email": "Напишите имя или e-mail коллеги",
"You can download multiple tracks at once in .mp3, .wav or .flac formats": "Можно загрузить сразу несколько треков в форматах .mp3, .wav или .flac", "You can download multiple tracks at once in .mp3, .wav or .flac formats": "Можно загрузить сразу несколько треков в форматах .mp3, .wav или .flac",
"You can now login using your new password": "Теперь вы можете входить с помощью нового пароля",
"You was successfully authorized": "Вы были успешно авторизованы", "You was successfully authorized": "Вы были успешно авторизованы",
"You ll be able to participate in discussions, rate others' comments and learn about new responses": "Вы сможете участвовать в обсуждениях, оценивать комментарии других и узнавать о новых ответах", "You ll be able to participate in discussions, rate others' comments and learn about new responses": "Вы сможете участвовать в обсуждениях, оценивать комментарии других и узнавать о новых ответах",
"You've confirmed email": "Вы подтвердили почту", "You've confirmed email": "Вы подтвердили почту",

View File

@ -144,7 +144,7 @@ export const FullArticle = (props: Props) => {
scrollTo(commentsRef.current) scrollTo(commentsRef.current)
} }
const { searchParams, changeSearchParam } = useRouter<ArticlePageSearchParams>() const { searchParams, changeSearchParams } = useRouter<ArticlePageSearchParams>()
createEffect(() => { createEffect(() => {
if (props.scrollToComments) { if (props.scrollToComments) {
@ -155,7 +155,7 @@ export const FullArticle = (props: Props) => {
createEffect(() => { createEffect(() => {
if (searchParams()?.scrollTo === 'comments' && commentsRef.current) { if (searchParams()?.scrollTo === 'comments' && commentsRef.current) {
scrollToComments() scrollToComments()
changeSearchParam({ changeSearchParams({
scrollTo: null, scrollTo: null,
}) })
} }
@ -167,7 +167,7 @@ export const FullArticle = (props: Props) => {
`[id='comment_${searchParams().commentId}']`, `[id='comment_${searchParams().commentId}']`,
) )
changeSearchParam({ commentId: null }) changeSearchParams({ commentId: null })
if (commentElement) { if (commentElement) {
scrollTo(commentElement) scrollTo(commentElement)

View File

@ -13,7 +13,7 @@ type Props = {
export const AuthGuard = (props: Props) => { export const AuthGuard = (props: Props) => {
const { isAuthenticated, isSessionLoaded } = useSession() const { isAuthenticated, isSessionLoaded } = useSession()
const { changeSearchParam } = useRouter<RootSearchParams & AuthModalSearchParams>() const { changeSearchParams } = useRouter<RootSearchParams & AuthModalSearchParams>()
createEffect(() => { createEffect(() => {
if (props.disabled) { if (props.disabled) {
@ -23,7 +23,7 @@ export const AuthGuard = (props: Props) => {
if (isAuthenticated()) { if (isAuthenticated()) {
hideModal() hideModal()
} else { } else {
changeSearchParam( changeSearchParams(
{ {
source: 'authguard', source: 'authguard',
modal: 'auth', modal: 'auth',

View File

@ -29,7 +29,7 @@ export const AuthorBadge = (props: Props) => {
subscriptions, subscriptions,
actions: { loadSubscriptions, requireAuthentication }, actions: { loadSubscriptions, requireAuthentication },
} = useSession() } = useSession()
const { changeSearchParam } = useRouter() const { changeSearchParams } = useRouter()
const { t, formatDate } = useLocalize() const { t, formatDate } = useLocalize()
const subscribed = createMemo(() => const subscribed = createMemo(() =>
subscriptions().authors.some((a: Author) => a.slug === props.author.slug), subscriptions().authors.some((a: Author) => a.slug === props.author.slug),
@ -54,7 +54,7 @@ export const AuthorBadge = (props: Props) => {
const initChat = () => { const initChat = () => {
requireAuthentication(() => { requireAuthentication(() => {
openPage(router, `inbox`) openPage(router, `inbox`)
changeSearchParam({ changeSearchParams({
initChat: props.author.id.toString(), initChat: props.author.id.toString(),
}) })
}, 'discussions') }, 'discussions')

View File

@ -72,11 +72,11 @@ export const AuthorCard = (props: Props) => {
}) })
// TODO: reimplement AuthorCard // TODO: reimplement AuthorCard
const { changeSearchParam } = useRouter() const { changeSearchParams } = useRouter()
const initChat = () => { const initChat = () => {
requireAuthentication(() => { requireAuthentication(() => {
openPage(router, `inbox`) openPage(router, `inbox`)
changeSearchParam({ changeSearchParams({
initChat: props.author.id.toString(), initChat: props.author.id.toString(),
}) })
}, 'discussions') }, 'discussions')

View File

@ -7,7 +7,7 @@ import styles from './Hero.module.scss'
export default () => { export default () => {
const { t } = useLocalize() const { t } = useLocalize()
const { changeSearchParam } = useRouter<AuthModalSearchParams>() const { changeSearchParams } = useRouter<AuthModalSearchParams>()
return ( return (
<div class={styles.aboutDiscours}> <div class={styles.aboutDiscours}>
@ -28,7 +28,7 @@ export default () => {
class="button" class="button"
onClick={() => { onClick={() => {
showModal('auth') showModal('auth')
changeSearchParam({ changeSearchParams({
mode: 'register', mode: 'register',
}) })
}} }}

View File

@ -7,6 +7,7 @@ import Typograf from 'typograf'
import { useEditorContext } from '../../../context/editor' import { useEditorContext } from '../../../context/editor'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { router } from '../../../stores/router' import { router } from '../../../stores/router'
import { showModal } from '../../../stores/ui'
import { useEscKeyDownHandler } from '../../../utils/useEscKeyDownHandler' import { useEscKeyDownHandler } from '../../../utils/useEscKeyDownHandler'
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler' import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
import { Button } from '../../_shared/Button' import { Button } from '../../_shared/Button'
@ -91,7 +92,9 @@ export const Panel = (props: Props) => {
<section> <section>
<p> <p>
<a class={styles.link}>{t('Invite co-authors')}</a> <span class={styles.link} onClick={() => showModal('inviteCoAuthors')}>
{t('Invite co-authors')}
</span>
</p> </p>
<p> <p>
<a <a

View File

@ -101,11 +101,11 @@ export const ArticleCard = (props: ArticleCardProps) => {
props.article.authors?.some((a) => a.slug === author()?.slug) || props.article.authors?.some((a) => a.slug === author()?.slug) ||
props.article.created_by?.id === author()?.id props.article.created_by?.id === author()?.id
const { changeSearchParam } = useRouter() const { changeSearchParams } = useRouter()
const scrollToComments = (event) => { const scrollToComments = (event) => {
event.preventDefault() event.preventDefault()
openPage(router, 'article', { slug: props.article.slug }) openPage(router, 'article', { slug: props.article.slug })
changeSearchParam({ changeSearchParams({
scrollTo: 'comments', scrollTo: 'comments',
}) })
} }

View File

@ -12,7 +12,6 @@ interface GroupProps {
} }
export default (props: GroupProps) => { export default (props: GroupProps) => {
if (!props.articles) props.articles = []
return ( return (
<div class="floor floor--group"> <div class="floor floor--group">
<Show when={props.articles.length > 4}> <Show when={props.articles.length > 4}>

View File

@ -2,6 +2,7 @@
background: #fff; background: #fff;
min-height: 550px; min-height: 550px;
position: relative; position: relative;
justify-content: center;
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {
min-height: 600px; min-height: 600px;
@ -106,6 +107,10 @@
margin-top: 1.6rem; margin-top: 1.6rem;
text-align: center; text-align: center;
display: flex;
flex-direction: row;
justify-content: center;
gap: 1rem;
a { a {
color: #9fa1a7; color: #9fa1a7;
@ -125,7 +130,7 @@
.submitButton { .submitButton {
display: block; display: block;
font-weight: 700; font-weight: 700;
margin-top: 32px; margin-top: 36px;
padding: 1.6rem !important; padding: 1.6rem !important;
width: 100%; width: 100%;
} }
@ -183,10 +188,6 @@
line-height: 16px; line-height: 16px;
margin-top: 0.3em; margin-top: 0.3em;
&.registerPassword {
margin-bottom: -32px;
}
/* Red/500 */ /* Red/500 */
color: #d00820; color: #d00820;
@ -201,26 +202,6 @@
} }
} }
.passwordToggle {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
}
.passwordToggleIcon {
height: 1.6em;
display: inline-block;
margin-right: 0.2em;
max-width: 1.4em;
max-height: 1.4em;
transition: filter 0.2s;
vertical-align: middle;
}
.title { .title {
font-size: 26px; font-size: 26px;
line-height: 32px; line-height: 32px;

View File

@ -0,0 +1,103 @@
import type { AuthModalSearchParams } from './types'
import { clsx } from 'clsx'
import { createSignal, JSX, Show } from 'solid-js'
import { useLocalize } from '../../../context/localize'
import { useRouter } from '../../../stores/router'
import { hideModal } from '../../../stores/ui'
import { PasswordField } from './PasswordField'
import styles from './AuthModal.module.scss'
type FormFields = {
password: string
}
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
export const ChangePasswordForm = () => {
const { changeSearchParams } = useRouter<AuthModalSearchParams>()
const { t } = useLocalize()
const [isSubmitting, setIsSubmitting] = createSignal(false)
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
const [newPassword, setNewPassword] = createSignal<string>()
const [passwordError, setPasswordError] = createSignal<string>()
const [isSuccess, setIsSuccess] = createSignal(false)
const authFormRef: { current: HTMLFormElement } = { current: null }
const handleSubmit = async (event: Event) => {
event.preventDefault()
setIsSubmitting(true)
// Fake change password logic
console.log('!!! sent new password:', newPassword)
setTimeout(() => {
setIsSubmitting(false)
setIsSuccess(true)
}, 1000)
}
const handlePasswordInput = (value) => {
setNewPassword(value)
if (passwordError()) {
setValidationErrors((errors) => ({ ...errors, password: passwordError() }))
} else {
setValidationErrors(({ password: _notNeeded, ...rest }) => rest)
}
}
return (
<>
<Show when={!isSuccess()}>
<form
onSubmit={handleSubmit}
class={clsx(styles.authForm, styles.authFormForgetPassword)}
ref={(el) => (authFormRef.current = el)}
>
<div>
<h4>{t('Enter a new password')}</h4>
<div class={styles.authSubtitle}>
{t(
'Now you can enter a new password, it must contain at least 8 characters and not be the same as the previous password',
)}
</div>
<PasswordField
errorMessage={(err) => setPasswordError(err)}
onInput={(value) => handlePasswordInput(value)}
/>
<div>
<button class={clsx('button', styles.submitButton)} disabled={isSubmitting()} type="submit">
{isSubmitting() ? '...' : t('Change password')}
</button>
</div>
<div class={styles.authControl}>
<span
class={styles.authLink}
onClick={() =>
changeSearchParams({
mode: 'login',
})
}
>
{t('Cancel')}
</span>
</div>
</div>
</form>
</Show>
<Show when={isSuccess()}>
<div class={styles.title}>{t('Password updated!')}</div>
<div class={styles.text}>{t('You can now login using your new password')}</div>
<div>
<button class={clsx('button', styles.submitButton)} onClick={() => hideModal()}>
{t('Back to main page')}
</button>
</div>
</Show>
</>
)
}

View File

@ -20,8 +20,8 @@ type FormFields = {
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>> type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
export const ForgotPasswordForm = () => { export const ForgotPasswordForm = () => {
const { changeSearchParam } = useRouter<AuthModalSearchParams>() const { changeSearchParams } = useRouter<AuthModalSearchParams>()
const { t } = useLocalize() const { t, lang } = useLocalize()
const handleEmailInput = (newEmail: string) => { const handleEmailInput = (newEmail: string) => {
setValidationErrors(({ email: _notNeeded, ...rest }) => rest) setValidationErrors(({ email: _notNeeded, ...rest }) => rest)
setEmail(newEmail) setEmail(newEmail)
@ -130,7 +130,7 @@ export const ForgotPasswordForm = () => {
href="#" href="#"
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
changeSearchParam({ changeSearchParams({
mode: 'register', mode: 'register',
}) })
}} }}
@ -152,7 +152,7 @@ export const ForgotPasswordForm = () => {
<span <span
class={styles.authLink} class={styles.authLink}
onClick={() => onClick={() =>
changeSearchParam({ changeSearchParams({
mode: 'login', mode: 'login',
}) })
} }

View File

@ -10,9 +10,9 @@ import { ApiError } from '../../../graphql/error'
import { useRouter } from '../../../stores/router' import { useRouter } from '../../../stores/router'
import { hideModal } from '../../../stores/ui' import { hideModal } from '../../../stores/ui'
import { validateEmail } from '../../../utils/validateEmail' import { validateEmail } from '../../../utils/validateEmail'
import { Icon } from '../../_shared/Icon'
import { AuthModalHeader } from './AuthModalHeader' import { AuthModalHeader } from './AuthModalHeader'
import { PasswordField } from './PasswordField'
import { email, setEmail } from './sharedLogic' import { email, setEmail } from './sharedLogic'
import { SocialProviders } from './SocialProviders' import { SocialProviders } from './SocialProviders'
@ -34,7 +34,6 @@ export const LoginForm = () => {
// TODO: better solution for interactive error messages // TODO: better solution for interactive error messages
const [isEmailNotConfirmed, setIsEmailNotConfirmed] = createSignal(false) const [isEmailNotConfirmed, setIsEmailNotConfirmed] = createSignal(false)
const [isLinkSent, setIsLinkSent] = createSignal(false) const [isLinkSent, setIsLinkSent] = createSignal(false)
const [showPassword, setShowPassword] = createSignal(false)
const authFormRef: { current: HTMLFormElement } = { current: null } const authFormRef: { current: HTMLFormElement } = { current: null }
@ -46,7 +45,7 @@ export const LoginForm = () => {
actions: { signIn }, actions: { signIn },
} = useSession() } = useSession()
const { changeSearchParam } = useRouter<AuthModalSearchParams>() const { changeSearchParams } = useRouter<AuthModalSearchParams>()
const [password, setPassword] = createSignal('') const [password, setPassword] = createSignal('')
@ -167,31 +166,7 @@ export const LoginForm = () => {
</Show> </Show>
</div> </div>
<div <PasswordField onInput={(value) => handlePasswordInput(value)} />
class={clsx('pretty-form__item', {
'pretty-form__item--error': validationErrors().password,
})}
>
<input
id="password"
name="password"
autocomplete="password"
type={showPassword() ? 'text' : 'password'}
placeholder={t('Password')}
onInput={(event) => handlePasswordInput(event.currentTarget.value)}
/>
<label for="password">{t('Password')}</label>
<button
type="button"
class={styles.passwordToggle}
onClick={() => setShowPassword(!showPassword())}
>
<Icon class={styles.passwordToggleIcon} name={showPassword() ? 'eye-off' : 'eye'} />
</button>
<Show when={validationErrors().password}>
<div class={styles.validationError}>{validationErrors().password}</div>
</Show>
</div>
<div> <div>
<button class={clsx('button', styles.submitButton)} disabled={isSubmitting()} type="submit"> <button class={clsx('button', styles.submitButton)} disabled={isSubmitting()} type="submit">
@ -202,13 +177,23 @@ export const LoginForm = () => {
<span <span
class="link" class="link"
onClick={() => onClick={() =>
changeSearchParam({ changeSearchParams({
mode: 'forgot-password', mode: 'forgot-password',
}) })
} }
> >
{t('Forgot password?')} {t('Forgot password?')}
</span> </span>
<span
class="link"
onClick={() =>
changeSearchParams({
mode: 'change-password',
})
}
>
{t('Change password')}
</span>
</div> </div>
</div> </div>
@ -219,7 +204,7 @@ export const LoginForm = () => {
<span <span
class={styles.authLink} class={styles.authLink}
onClick={() => onClick={() =>
changeSearchParam({ changeSearchParams({
mode: 'register', mode: 'register',
}) })
} }

View File

@ -0,0 +1,46 @@
.PassportField {
.passwordToggle {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
}
.passwordToggleIcon {
height: 1.6em;
display: inline-block;
margin-right: 0.2em;
max-width: 1.4em;
max-height: 1.4em;
transition: filter 0.2s;
vertical-align: middle;
}
.validationError {
position: absolute;
top: 100%;
font-size: 12px;
line-height: 16px;
margin-top: 0.3em;
&.registerPassword {
margin-bottom: -32px;
}
/* Red/500 */
color: #d00820;
a {
color: #d00820;
border-color: #d00820;
&:hover {
color: var(--default-color-invert);
border-color: var(--background-color-invert);
}
}
}
}

View File

@ -0,0 +1,88 @@
import { clsx } from 'clsx'
import { createEffect, createSignal, JSX, on, Show } from 'solid-js'
import { useLocalize } from '../../../../context/localize'
import { resetSortedArticles } from '../../../../stores/zine/articles'
import { Icon } from '../../../_shared/Icon'
import styles from './PasswordField.module.scss'
type Props = {
class?: string
errorMessage?: (error: string) => void
onInput: (value: string) => void
}
export const PasswordField = (props: Props) => {
const { t } = useLocalize()
const [showPassword, setShowPassword] = createSignal(false)
const [error, setError] = createSignal<string>()
const validatePassword = (passwordToCheck) => {
const minLength = 8
const hasNumber = /\d/
const hasSpecial = /[!#$%&*@^]/
if (passwordToCheck.length < minLength) {
return t('Password should be at least 8 characters')
}
if (!hasNumber.test(passwordToCheck)) {
return t('Password should contain at least one number')
}
if (!hasSpecial.test(passwordToCheck)) {
return t('Password should contain at least one special character: !@#$%^&*')
}
return null
}
const handleInputChange = (value) => {
props.onInput(value)
const errorValue = validatePassword(value)
if (errorValue) {
setError(errorValue)
} else {
setError()
}
}
createEffect(
on(
() => error(),
() => {
props.errorMessage(error())
},
{ defer: true },
),
)
return (
<div class={clsx(styles.PassportField, props.class)}>
<div
class={clsx('pretty-form__item', {
'pretty-form__item--error': error(),
})}
>
<input
id="password"
name="password"
autocomplete="current-password"
type={showPassword() ? 'text' : 'password'}
placeholder={t('Password')}
onInput={(event) => handleInputChange(event.currentTarget.value)}
/>
<label for="password">{t('Password')}</label>
<button
type="button"
class={styles.passwordToggle}
onClick={() => setShowPassword(!showPassword())}
>
<Icon class={styles.passwordToggleIcon} name={showPassword() ? 'eye-off' : 'eye'} />
</button>
<Show when={error()}>
<div class={clsx(styles.registerPassword, styles.validationError)}>{error()}</div>
</Show>
</div>
</div>
)
}

View File

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

View File

@ -11,9 +11,9 @@ import { checkEmail, useEmailChecks } from '../../../stores/emailChecks'
import { useRouter } from '../../../stores/router' import { useRouter } from '../../../stores/router'
import { hideModal } from '../../../stores/ui' import { hideModal } from '../../../stores/ui'
import { validateEmail } from '../../../utils/validateEmail' import { validateEmail } from '../../../utils/validateEmail'
import { Icon } from '../../_shared/Icon'
import { AuthModalHeader } from './AuthModalHeader' import { AuthModalHeader } from './AuthModalHeader'
import { PasswordField } from './PasswordField'
import { email, setEmail } from './sharedLogic' import { email, setEmail } from './sharedLogic'
import { SocialProviders } from './SocialProviders' import { SocialProviders } from './SocialProviders'
@ -32,7 +32,7 @@ const handleEmailInput = (newEmail: string) => {
} }
export const RegisterForm = () => { export const RegisterForm = () => {
const { changeSearchParam } = useRouter<AuthModalSearchParams>() const { changeSearchParams } = useRouter<AuthModalSearchParams>()
const { t } = useLocalize() const { t } = useLocalize()
const { emailChecks } = useEmailChecks() const { emailChecks } = useEmailChecks()
const { const {
@ -42,9 +42,9 @@ export const RegisterForm = () => {
const [fullName, setFullName] = createSignal('') const [fullName, setFullName] = createSignal('')
const [password, setPassword] = createSignal('') const [password, setPassword] = createSignal('')
const [isSubmitting, setIsSubmitting] = createSignal(false) const [isSubmitting, setIsSubmitting] = createSignal(false)
const [showPassword, setShowPassword] = createSignal(false)
const [isSuccess, setIsSuccess] = createSignal(false) const [isSuccess, setIsSuccess] = createSignal(false)
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({}) const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
const [passwordError, setPasswordError] = createSignal<string>()
const authFormRef: { current: HTMLFormElement } = { current: null } const authFormRef: { current: HTMLFormElement } = { current: null }
@ -54,37 +54,15 @@ export const RegisterForm = () => {
} }
} }
function isValidPassword(passwordToCheck) { const handleNameInput = (newName: string) => {
const minLength = 8 setFullName(newName)
const hasNumber = /\d/
const hasSpecial = /[!#$%&*@^]/
if (passwordToCheck.length < minLength) {
return t('Password should be at least 8 characters')
}
if (!hasNumber.test(passwordToCheck)) {
return t('Password should contain at least one number')
}
if (!hasSpecial.test(passwordToCheck)) {
return t('Password should contain at least one special character: !@#$%^&*')
}
return null
}
const handlePasswordInput = (newPassword: string) => {
setPassword(newPassword)
}
const handleNameInput = (newPasswordCopy: string) => {
setFullName(newPasswordCopy)
} }
const handleSubmit = async (event: Event) => { const handleSubmit = async (event: Event) => {
event.preventDefault() event.preventDefault()
const passwordError = isValidPassword(password()) if (passwordError()) {
if (passwordError) { setValidationErrors((errors) => ({ ...errors, password: passwordError() }))
setValidationErrors((errors) => ({ ...errors, password: passwordError }))
} else { } else {
setValidationErrors(({ password: _notNeeded, ...rest }) => rest) setValidationErrors(({ password: _notNeeded, ...rest }) => rest)
} }
@ -202,48 +180,17 @@ export const RegisterForm = () => {
<Show when={emailChecks()[email()]}> <Show when={emailChecks()[email()]}>
<div class={styles.validationError}> <div class={styles.validationError}>
{t("This email is already taken. If it's you")},{' '} {t("This email is already taken. If it's you")},{' '}
<a <span class="link" onClick={() => changeSearchParams({ mode: 'login' })}>
href="#"
onClick={(event) => {
event.preventDefault()
changeSearchParam({
mode: 'login',
})
}}
>
{t('enter')} {t('enter')}
</a> </span>
</div> </div>
</Show> </Show>
</div> </div>
<div <PasswordField
class={clsx('pretty-form__item', { errorMessage={(err) => setPasswordError(err)}
'pretty-form__item--error': validationErrors().password, onInput={(value) => setPassword(value)}
})} />
>
<input
id="password"
name="password"
autocomplete="current-password"
type={showPassword() ? 'text' : 'password'}
placeholder={t('Password')}
onInput={(event) => handlePasswordInput(event.currentTarget.value)}
/>
<label for="password">{t('Password')}</label>
<button
type="button"
class={styles.passwordToggle}
onClick={() => setShowPassword(!showPassword())}
>
<Icon class={styles.passwordToggleIcon} name={showPassword() ? 'eye-off' : 'eye'} />
</button>
<Show when={validationErrors().password}>
<div class={clsx(styles.registerPassword, styles.validationError)}>
{validationErrors().password}
</div>
</Show>
</div>
<div> <div>
<button class={clsx('button', styles.submitButton)} disabled={isSubmitting()} type="submit"> <button class={clsx('button', styles.submitButton)} disabled={isSubmitting()} type="submit">
@ -259,7 +206,7 @@ export const RegisterForm = () => {
<span <span
class={styles.authLink} class={styles.authLink}
onClick={() => onClick={() =>
changeSearchParam({ changeSearchParams({
mode: 'login', mode: 'login',
}) })
} }

View File

@ -9,6 +9,7 @@ import { useRouter } from '../../../stores/router'
import { hideModal } from '../../../stores/ui' import { hideModal } from '../../../stores/ui'
import { isMobile } from '../../../utils/media-query' import { isMobile } from '../../../utils/media-query'
import { ChangePasswordForm } from './ChangePasswordForm'
import { EmailConfirm } from './EmailConfirm' import { EmailConfirm } from './EmailConfirm'
import { ForgotPasswordForm } from './ForgotPasswordForm' import { ForgotPasswordForm } from './ForgotPasswordForm'
import { LoginForm } from './LoginForm' import { LoginForm } from './LoginForm'
@ -21,10 +22,11 @@ const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = {
register: RegisterForm, register: RegisterForm,
'forgot-password': ForgotPasswordForm, 'forgot-password': ForgotPasswordForm,
'confirm-email': EmailConfirm, 'confirm-email': EmailConfirm,
'change-password': ChangePasswordForm,
} }
export const AuthModal = () => { export const AuthModal = () => {
let rootRef: HTMLDivElement const rootRef: { current: HTMLDivElement } = { current: null }
const { t } = useLocalize() const { t } = useLocalize()
const { searchParams } = useRouter<AuthModalSearchParams>() const { searchParams } = useRouter<AuthModalSearchParams>()
@ -36,17 +38,17 @@ export const AuthModal = () => {
createEffect((oldMode) => { createEffect((oldMode) => {
if (oldMode !== mode() && !isMobile()) { if (oldMode !== mode() && !isMobile()) {
rootRef?.querySelector('input')?.focus() rootRef.current?.querySelector('input')?.focus()
} }
}, null) }, null)
return ( return (
<div <div
ref={rootRef} ref={(el) => (rootRef.current = el)}
class={clsx(styles.view, { class={clsx(styles.view, {
row: !source, row: !source,
[styles.signUp]: mode() === 'register' || mode() === 'confirm-email',
})} })}
classList={{ [styles.signUp]: mode() === 'register' || mode() === 'confirm-email' }}
> >
<Show when={!source}> <Show when={!source}>
<div class={clsx('col-md-12 d-none d-md-flex', styles.authImage)}> <div class={clsx('col-md-12 d-none d-md-flex', styles.authImage)}>

View File

@ -1,4 +1,4 @@
export type AuthModalMode = 'login' | 'register' | 'confirm-email' | 'forgot-password' export type AuthModalMode = 'login' | 'register' | 'confirm-email' | 'forgot-password' | 'change-password'
export type AuthModalSource = export type AuthModalSource =
| 'discussions' | 'discussions'
| 'vote' | 'vote'

View File

@ -44,7 +44,7 @@ const reactionsCaption = (threadId: string) =>
export const NotificationGroup = (props: NotificationGroupProps) => { export const NotificationGroup = (props: NotificationGroupProps) => {
const { t, formatTime, formatDate } = useLocalize() const { t, formatTime, formatDate } = useLocalize()
const { changeSearchParam } = useRouter<ArticlePageSearchParams>() const { changeSearchParams } = useRouter<ArticlePageSearchParams>()
const { const {
actions: { hideNotificationsPanel, markSeenThread }, actions: { hideNotificationsPanel, markSeenThread },
} = useNotifications() } = useNotifications()
@ -54,7 +54,7 @@ export const NotificationGroup = (props: NotificationGroupProps) => {
markSeenThread(threadId) markSeenThread(threadId)
const [slug, commentId] = threadId.split('::') const [slug, commentId] = threadId.split('::')
openPage(router, 'article', { slug }) openPage(router, 'article', { slug })
if (commentId) changeSearchParam({ commentId }) if (commentId) changeSearchParams({ commentId })
} }
const handleLinkClick = (event: MouseEvent | TouchEvent) => { const handleLinkClick = (event: MouseEvent | TouchEvent) => {

View File

@ -31,7 +31,7 @@ const ALPHABET = [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫ
export const AllAuthorsView = (props: Props) => { export const AllAuthorsView = (props: Props) => {
const { t, lang } = useLocalize() const { t, lang } = useLocalize()
const [limit, setLimit] = createSignal(PAGE_SIZE) const [limit, setLimit] = createSignal(PAGE_SIZE)
const { searchParams, changeSearchParam } = useRouter<AllAuthorsPageSearchParams>() const { searchParams, changeSearchParams } = useRouter<AllAuthorsPageSearchParams>()
const { sortedAuthors } = useAuthorsStore({ const { sortedAuthors } = useAuthorsStore({
authors: props.authors, authors: props.authors,
sortBy: searchParams().by || 'shouts', sortBy: searchParams().by || 'shouts',
@ -41,7 +41,7 @@ export const AllAuthorsView = (props: Props) => {
createEffect(() => { createEffect(() => {
if (!searchParams().by) { if (!searchParams().by) {
changeSearchParam({ changeSearchParams({
by: 'shouts', by: 'shouts',
}) })
} }

View File

@ -31,7 +31,7 @@ const PAGE_SIZE = 20
export const AllTopicsView = (props: Props) => { export const AllTopicsView = (props: Props) => {
const { t, lang } = useLocalize() const { t, lang } = useLocalize()
const { searchParams, changeSearchParam } = useRouter<AllTopicsPageSearchParams>() const { searchParams, changeSearchParams } = useRouter<AllTopicsPageSearchParams>()
const [limit, setLimit] = createSignal(PAGE_SIZE) const [limit, setLimit] = createSignal(PAGE_SIZE)
const ALPHABET = const ALPHABET =
lang() === 'ru' ? [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ#'] : [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ#'] lang() === 'ru' ? [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ#'] : [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ#']
@ -45,7 +45,7 @@ export const AllTopicsView = (props: Props) => {
createEffect(() => { createEffect(() => {
if (!searchParams().by) { if (!searchParams().by) {
changeSearchParam({ changeSearchParams({
by: 'shouts', by: 'shouts',
}) })
} }

View File

@ -14,6 +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 { 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'
@ -412,6 +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 />
</> </>
) )
} }

View File

@ -41,7 +41,7 @@ export const Expo = (props: Props) => {
}) })
const getLoadShoutsFilters = (additionalFilters: LoadShoutsFilters = {}): LoadShoutsFilters => { const getLoadShoutsFilters = (additionalFilters: LoadShoutsFilters = {}): LoadShoutsFilters => {
const filters = { ...additionalFilters } const filters = { visibility: 'public', ...additionalFilters }
filters.layouts = [] filters.layouts = []
if (props.layout) { if (props.layout) {

View File

@ -1,7 +1,4 @@
.feedFilter { .feedFilter {
margin-bottom: 4.8rem;
margin-top: 0.2em;
@include media-breakpoint-down(md) { @include media-breakpoint-down(md) {
margin-right: 4rem !important; margin-right: 4rem !important;
} }
@ -192,3 +189,25 @@
font-weight: 500; font-weight: 500;
} }
.filtersContainer {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4rem;
.feedFilter {
margin-top: 0;
margin-bottom: 0;
& > li {
margin-bottom: 0;
}
}
}
.periodSwitcher {
font-size: 14px;
font-weight: 700;
line-height: 18px;
}

View File

@ -3,7 +3,7 @@ import type { Author, LoadShoutsOptions, Reaction, Shout } from '../../../graphq
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 { createEffect, createSignal, For, on, onMount, Show } from 'solid-js' import { createEffect, createMemo, createSignal, For, on, onMount, Show } from 'solid-js'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { useReactions } from '../../../context/reactions' import { useReactions } from '../../../context/reactions'
@ -13,6 +13,8 @@ import { useArticlesStore, resetSortedArticles } from '../../../stores/zine/arti
import { useTopAuthorsStore } from '../../../stores/zine/topAuthors' import { useTopAuthorsStore } from '../../../stores/zine/topAuthors'
import { useTopicsStore } from '../../../stores/zine/topics' import { useTopicsStore } from '../../../stores/zine/topics'
import { getImageUrl } from '../../../utils/getImageUrl' import { getImageUrl } from '../../../utils/getImageUrl'
import { getServerDate } from '../../../utils/getServerDate'
import { DropDown } from '../../_shared/DropDown'
import { Icon } from '../../_shared/Icon' import { Icon } from '../../_shared/Icon'
import { Loading } from '../../_shared/Loading' import { Loading } from '../../_shared/Loading'
import { CommentDate } from '../../Article/CommentDate' import { CommentDate } from '../../Article/CommentDate'
@ -28,8 +30,16 @@ import stylesTopic from '../../Feed/CardTopic.module.scss'
export const FEED_PAGE_SIZE = 20 export const FEED_PAGE_SIZE = 20
const UNRATED_ARTICLES_COUNT = 5 const UNRATED_ARTICLES_COUNT = 5
type FeedPeriod = 'week' | 'month' | 'year'
type PeriodItem = {
value: FeedPeriod
title: string
}
type FeedSearchParams = { type FeedSearchParams = {
by: 'publish_date' | 'rating' | 'last_comment' by: 'publish_date' | 'rating' | 'last_comment'
period: FeedPeriod
} }
const getOrderBy = (by: FeedSearchParams['by']) => { const getOrderBy = (by: FeedSearchParams['by']) => {
@ -44,6 +54,21 @@ const getOrderBy = (by: FeedSearchParams['by']) => {
return '' return ''
} }
const getFromDate = (period: FeedPeriod): Date => {
const now = new Date()
switch (period) {
case 'week': {
return new Date(now.setDate(now.getDate() - 7))
}
case 'month': {
return new Date(now.setMonth(now.getMonth() - 1))
}
case 'year': {
return new Date(now.setFullYear(now.getFullYear() - 1))
}
}
}
type Props = { type Props = {
loadShouts: (options: LoadShoutsOptions) => Promise<{ loadShouts: (options: LoadShoutsOptions) => Promise<{
hasMore: boolean hasMore: boolean
@ -53,7 +78,16 @@ type Props = {
export const FeedView = (props: Props) => { export const FeedView = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const { page, searchParams } = useRouter<FeedSearchParams>()
const monthPeriod: PeriodItem = { value: 'month', title: t('This month') }
const periods: PeriodItem[] = [
{ value: 'week', title: t('This week') },
monthPeriod,
{ value: 'year', title: t('This year') },
]
const { page, searchParams, changeSearchParams } = useRouter<FeedSearchParams>()
const [isLoading, setIsLoading] = createSignal(false) const [isLoading, setIsLoading] = createSignal(false)
const [isRightColumnLoaded, setIsRightColumnLoaded] = createSignal(false) const [isRightColumnLoaded, setIsRightColumnLoaded] = createSignal(false)
@ -64,6 +98,16 @@ export const FeedView = (props: Props) => {
const [topComments, setTopComments] = createSignal<Reaction[]>([]) const [topComments, setTopComments] = createSignal<Reaction[]>([])
const [unratedArticles, setUnratedArticles] = createSignal<Shout[]>([]) const [unratedArticles, setUnratedArticles] = createSignal<Shout[]>([])
const currentPeriod = createMemo(() => {
const period = periods.find((p) => p.value === searchParams().period)
if (!period) {
return monthPeriod
}
return period
})
const { const {
actions: { loadReactionsBy }, actions: { loadReactionsBy },
} = useReactions() } = useReactions()
@ -86,7 +130,7 @@ export const FeedView = (props: Props) => {
createEffect( createEffect(
on( on(
() => page().route + searchParams().by, () => page().route + searchParams().by + searchParams().period,
() => { () => {
resetSortedArticles() resetSortedArticles()
loadMore() loadMore()
@ -94,6 +138,7 @@ export const FeedView = (props: Props) => {
{ defer: true }, { defer: true },
), ),
) )
const loadFeedShouts = () => { const loadFeedShouts = () => {
const options: LoadShoutsOptions = { const options: LoadShoutsOptions = {
limit: FEED_PAGE_SIZE, limit: FEED_PAGE_SIZE,
@ -106,6 +151,12 @@ export const FeedView = (props: Props) => {
options.order_by = orderBy options.order_by = orderBy
} }
if (searchParams().by && searchParams().by !== 'publish_date') {
const period = searchParams().period || 'month'
const fromDate = getFromDate(period)
options.filters = { fromDate: getServerDate(fromDate) }
}
return props.loadShouts(options) return props.loadShouts(options)
} }
@ -148,32 +199,49 @@ export const FeedView = (props: Props) => {
</div> </div>
<div class="col-md-12 offset-xl-1"> <div class="col-md-12 offset-xl-1">
<ul class={clsx(styles.feedFilter, 'view-switcher')}> <div class={styles.filtersContainer}>
<li <ul class={clsx('view-switcher', styles.feedFilter)}>
class={clsx({ <li
'view-switcher__item--selected': searchParams().by === 'publish_date' || !searchParams().by, class={clsx({
})} 'view-switcher__item--selected':
> searchParams().by === 'publish_date' || !searchParams().by,
<a href={getPagePath(router, page().route)}>{t('Recent')}</a> })}
</li> >
{/*<li>*/} <a href={getPagePath(router, page().route)}>{t('Recent')}</a>
{/* <a href="/feed/?by=views">{t('Most read')}</a>*/} </li>
{/*</li>*/} {/*<li>*/}
<li {/* <a href="/feed/?by=views">{t('Most read')}</a>*/}
class={clsx({ {/*</li>*/}
'view-switcher__item--selected': searchParams().by === 'rating', <li
})} class={clsx({
> 'view-switcher__item--selected': searchParams().by === 'rating',
<a href={`${getPagePath(router, page().route)}?by=rating`}>{t('Top rated')}</a> })}
</li> >
<li <span class="link" onClick={() => changeSearchParams({ by: 'rating' })}>
class={clsx({ {t('Top rated')}
'view-switcher__item--selected': searchParams().by === 'last_comment', </span>
})} </li>
> <li
<a href={`${getPagePath(router, page().route)}?by=last_comment`}>{t('Most commented')}</a> class={clsx({
</li> 'view-switcher__item--selected': searchParams().by === 'last_comment',
</ul> })}
>
<span class="link" onClick={() => changeSearchParams({ by: 'last_comment' })}>
{t('Most commented')}
</span>
</li>
</ul>
<Show when={searchParams().by && searchParams().by !== 'publish_date'}>
<div>
<DropDown
options={periods}
currentOption={currentPeriod()}
triggerCssClass={styles.periodSwitcher}
onChange={(period) => changeSearchParams({ period: period.value })}
/>
</div>
</Show>
</div>
<Show when={!isLoading()} fallback={<Loading />}> <Show when={!isLoading()} fallback={<Loading />}>
<Show when={sortedArticles().length > 0}> <Show when={sortedArticles().length > 0}>

View File

@ -0,0 +1,36 @@
.randomTopicHeaderContainer {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
}
.randomTopicHeader {
font-size: 40px;
font-weight: 700;
line-height: 44px;
text-transform: capitalize;
}
.randomTopicHeaderLink {
border: none !important;
display: inline-block;
font-size: 20px;
font-weight: 500;
line-height: 24px;
.icon {
vertical-align: top;
display: inline-block;
width: 24px;
height: 24px;
}
&:hover {
border: none !important;
.icon {
filter: invert(1);
}
}
}

View File

@ -1,7 +1,12 @@
import { createMemo, createSignal, For, onMount, Show } from 'solid-js' import { createMemo, createSignal, For, onMount, Show } from 'solid-js'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
import { Shout } from '../../graphql/schema/core.gen' import { Shout, Topic } from '../../graphql/schema/core.gen'
import { getPagePath } from '@nanostores/router'
import { batch, createMemo, createSignal, For, onMount, Show } from 'solid-js'
import { useLocalize } from '../../context/localize'
import { router } from '../../stores/router'
import { import {
loadShouts, loadShouts,
loadTopArticles, loadTopArticles,
@ -10,8 +15,10 @@ import {
} from '../../stores/zine/articles' } from '../../stores/zine/articles'
import { useTopAuthorsStore } from '../../stores/zine/topAuthors' import { useTopAuthorsStore } from '../../stores/zine/topAuthors'
import { useTopicsStore } from '../../stores/zine/topics' import { useTopicsStore } from '../../stores/zine/topics'
import { apiClient } from '../../utils/apiClient'
import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll' import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll'
import { splitToPages } from '../../utils/splitToPages' import { splitToPages } from '../../utils/splitToPages'
import { Icon } from '../_shared/Icon'
import { ArticleCardSwiper } from '../_shared/SolidSwiper/ArticleCardSwiper' import { ArticleCardSwiper } from '../_shared/SolidSwiper/ArticleCardSwiper'
import Banner from '../Discours/Banner' import Banner from '../Discours/Banner'
import Hero from '../Discours/Hero' import Hero from '../Discours/Hero'
@ -24,32 +31,32 @@ import { Row5 } from '../Feed/Row5'
import RowShort from '../Feed/RowShort' import RowShort from '../Feed/RowShort'
import { Topics } from '../Nav/Topics' import { Topics } from '../Nav/Topics'
import styles from './Home.module.scss'
type Props = { type Props = {
shouts: Shout[] shouts: Shout[]
} }
export const PRERENDERED_ARTICLES_COUNT = 5 export const PRERENDERED_ARTICLES_COUNT = 5
export const RANDOM_TOPICS_COUNT = 12 export const RANDOM_TOPICS_COUNT = 12
export const RANDOM_TOPIC_SHOUTS_COUNT = 7
const CLIENT_LOAD_ARTICLES_COUNT = 29 const CLIENT_LOAD_ARTICLES_COUNT = 29
const LOAD_MORE_PAGE_SIZE = 16 // Row1 + Row3 + Row2 + Beside (3 + 1) + Row1 + Row 2 + Row3 const LOAD_MORE_PAGE_SIZE = 16 // Row1 + Row3 + Row2 + Beside (3 + 1) + Row1 + Row 2 + Row3
export const HomeView = (props: Props) => { export const HomeView = (props: Props) => {
const { const { sortedArticles, topArticles, topCommentedArticles, topMonthArticles, topViewedArticles } =
sortedArticles, useArticlesStore({
articlesByLayout, shouts: props.shouts,
topArticles, })
topCommentedArticles,
topMonthArticles,
topViewedArticles,
} = useArticlesStore({
shouts: props.shouts,
})
const { topTopics } = useTopicsStore() const { topTopics } = useTopicsStore()
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false) const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const { topAuthors } = useTopAuthorsStore() const { topAuthors } = useTopAuthorsStore()
const { t } = useLocalize() const { t } = useLocalize()
const [randomTopic, setRandomTopic] = createSignal<Topic>(null)
const [randomTopicArticles, setRandomTopicArticles] = createSignal<Shout[]>([])
onMount(async () => { onMount(async () => {
loadTopArticles() loadTopArticles()
loadTopMonthArticles() loadTopMonthArticles()
@ -62,22 +69,12 @@ export const HomeView = (props: Props) => {
setIsLoadMoreButtonVisible(hasMore) setIsLoadMoreButtonVisible(hasMore)
} }
})
const randomLayout = createMemo(() => { const { topic, shouts } = await apiClient.getRandomTopicShouts(RANDOM_TOPIC_SHOUTS_COUNT)
const filledLayouts = Object.keys(articlesByLayout()).filter( batch(() => {
// FIXME: is 7 ok? or more complex logic needed? setRandomTopic(topic)
(layout) => articlesByLayout()[layout].length > 7, setRandomTopicArticles(shouts)
) })
const selectedRandomLayout =
filledLayouts.length > 0 ? filledLayouts[Math.floor(Math.random() * filledLayouts.length)] : ''
return (
<Show when={Boolean(selectedRandomLayout)}>
<Group articles={articlesByLayout()[selectedRandomLayout]} header={''} />
</Show>
)
}) })
const loadMore = async () => { const loadMore = async () => {
@ -114,9 +111,7 @@ export const HomeView = (props: Props) => {
wrapper={'top-article'} wrapper={'top-article'}
nodate={true} nodate={true}
/> />
<Row3 articles={sortedArticles().slice(6, 9)} nodate={true} /> <Row3 articles={sortedArticles().slice(6, 9)} nodate={true} />
<Beside <Beside
beside={sortedArticles()[9]} beside={sortedArticles()[9]}
title={t('Top authors')} title={t('Top authors')}
@ -124,15 +119,11 @@ export const HomeView = (props: Props) => {
wrapper={'author'} wrapper={'author'}
nodate={true} nodate={true}
/> />
<Show when={topMonthArticles()}> <Show when={topMonthArticles()}>
<ArticleCardSwiper title={t('Top month articles')} slides={topMonthArticles()} /> <ArticleCardSwiper title={t('Top month articles')} slides={topMonthArticles()} />
</Show> </Show>
<Row2 articles={sortedArticles().slice(10, 12)} nodate={true} /> <Row2 articles={sortedArticles().slice(10, 12)} nodate={true} />
<RowShort articles={sortedArticles().slice(12, 16)} /> <RowShort articles={sortedArticles().slice(12, 16)} />
<Row1 article={sortedArticles()[16]} nodate={true} /> <Row1 article={sortedArticles()[16]} nodate={true} />
<Row3 articles={sortedArticles().slice(17, 20)} nodate={true} /> <Row3 articles={sortedArticles().slice(17, 20)} nodate={true} />
<Row3 <Row3
@ -140,13 +131,27 @@ export const HomeView = (props: Props) => {
header={<h2>{t('Top commented')}</h2>} header={<h2>{t('Top commented')}</h2>}
nodate={true} nodate={true}
/> />
<Show when={randomTopic()}>
{randomLayout()} <Group
articles={randomTopicArticles()}
header={
<div class={styles.randomTopicHeaderContainer}>
<div class={styles.randomTopicHeader}>{randomTopic().title}</div>
<div>
<a
class={styles.randomTopicHeaderLink}
href={getPagePath(router, 'topic', { slug: randomTopic().slug })}
>
{t('All articles')} <Icon class={styles.icon} name="arrow-right" />
</a>
</div>
</div>
}
/>
</Show>
<Show when={topArticles()}> <Show when={topArticles()}>
<ArticleCardSwiper title={t('Favorite')} slides={topArticles()} /> <ArticleCardSwiper title={t('Favorite')} slides={topArticles()} />
</Show> </Show>
<Beside <Beside
beside={sortedArticles()[20]} beside={sortedArticles()[20]}
title={t('Top topics')} title={t('Top topics')}
@ -155,11 +160,8 @@ export const HomeView = (props: Props) => {
isTopicCompact={true} isTopicCompact={true}
nodate={true} nodate={true}
/> />
<Row3 articles={sortedArticles().slice(21, 24)} nodate={true} /> <Row3 articles={sortedArticles().slice(21, 24)} nodate={true} />
<Banner /> <Banner />
<Row2 articles={sortedArticles().slice(24, 26)} nodate={true} /> <Row2 articles={sortedArticles().slice(24, 26)} nodate={true} />
<Row3 articles={sortedArticles().slice(26, 29)} nodate={true} /> <Row3 articles={sortedArticles().slice(26, 29)} nodate={true} />
<Row2 articles={sortedArticles().slice(29, 31)} nodate={true} /> <Row2 articles={sortedArticles().slice(29, 31)} nodate={true} />

View File

@ -70,7 +70,7 @@ export const InboxView = () => {
const handleOpenChat = async (chat: Chat) => { const handleOpenChat = async (chat: Chat) => {
setCurrentDialog(chat) setCurrentDialog(chat)
changeSearchParam({ changeSearchParams({
chat: chat.id, chat: chat.id,
}) })
try { try {
@ -118,7 +118,7 @@ export const InboxView = () => {
try { try {
const newChat = await createChat([Number(searchParams().initChat)], '') const newChat = await createChat([Number(searchParams().initChat)], '')
await loadChats() await loadChats()
changeSearchParam({ changeSearchParams({
initChat: null, initChat: null,
chat: newChat.chat.id, chat: newChat.chat.id,
}) })

View File

@ -219,23 +219,12 @@ export const PublishSettings = (props: Props) => {
<div class={styles.validationError}>{formErrors.selectedTopics}</div> <div class={styles.validationError}>{formErrors.selectedTopics}</div>
</Show> </Show>
</div> </div>
<h4>{t('Collaborators')}</h4>
{/*<h4>Соавторы</h4>*/} <Button
{/*<p class="description">У каждого соавтора можно добавить роль</p>*/} variant="primary"
{/*<div class="pretty-form__item--with-button">*/} onClick={() => showModal('inviteCoAuthors')}
{/* <div class="pretty-form__item">*/} value={t('Invite collaborators')}
{/* <input type="text" name="authors" id="authors" placeholder="Введите имя или e-mail" />*/} />
{/* <label for="authors">Введите имя или e-mail</label>*/}
{/* </div>*/}
{/* <button class="button button--submit">Добавить</button>*/}
{/*</div>*/}
{/*<div class="row">*/}
{/* <div class="col-md-6">Михаил Драбкин</div>*/}
{/* <div class="col-md-6">*/}
{/* <input type="text" name="coauthor" id="coauthor1" class="nolabel" />*/}
{/* </div>*/}
{/*</div>*/}
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@ import type { Shout, Topic } from '../../graphql/schema/core.gen'
import { Meta } from '@solidjs/meta' import { Meta } from '@solidjs/meta'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { For, Show, createMemo, onMount, createSignal, createEffect } from 'solid-js' import { For, Show, createMemo, onMount, createSignal } from 'solid-js'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
import { useRouter } from '../../stores/router' import { useRouter } from '../../stores/router'
@ -14,7 +14,6 @@ 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 { splitToPages } from '../../utils/splitToPages' import { splitToPages } from '../../utils/splitToPages'
import { Loading } from '../_shared/Loading'
import { ArticleCardSwiper } from '../_shared/SolidSwiper/ArticleCardSwiper' import { ArticleCardSwiper } from '../_shared/SolidSwiper/ArticleCardSwiper'
import { Beside } from '../Feed/Beside' import { Beside } from '../Feed/Beside'
import { Row1 } from '../Feed/Row1' import { Row1 } from '../Feed/Row1'
@ -32,8 +31,6 @@ interface Props {
topic: Topic topic: Topic
shouts: Shout[] shouts: Shout[]
topicSlug: string topicSlug: string
isLoaded: boolean
title: (val: string) => string
} }
export const PRERENDERED_ARTICLES_COUNT = 28 export const PRERENDERED_ARTICLES_COUNT = 28
@ -113,102 +110,98 @@ export const TopicView = (props: Props) => {
<Meta name="twitter:card" content="summary_large_image" /> <Meta name="twitter:card" content="summary_large_image" />
<Meta name="twitter:title" content={title()} /> <Meta name="twitter:title" content={title()} />
<Meta name="twitter:description" content={description()} /> <Meta name="twitter:description" content={description()} />
<Show when={props.isLoaded} fallback={<Loading />}> <FullTopic topic={topic()} />
<Show when={topic()}> <div class="wide-container">
<FullTopic topic={topic()} /> <div class={clsx(styles.groupControls, 'row group__controls')}>
<div class="wide-container"> <div class="col-md-16">
<div class={clsx(styles.groupControls, 'row group__controls')}> <ul class="view-switcher">
<div class="col-md-16"> <li
<ul class="view-switcher"> classList={{
<li 'view-switcher__item--selected': searchParams().by === 'recent' || !searchParams().by,
classList={{ }}
'view-switcher__item--selected': searchParams().by === 'recent' || !searchParams().by, >
}} <button
> type="button"
<button onClick={() =>
type="button" changeSearchParams({
onClick={() => by: 'recent',
changeSearchParam({ })
by: 'recent', }
}) >
} {t('Recent')}
> </button>
{t('Recent')} </li>
</button> {/*TODO: server sort*/}
</li> {/*<li classList={{ 'view-switcher__item--selected': getSearchParams().by === 'rating' }}>*/}
{/*TODO: server sort*/} {/* <button type="button" onClick={() => changeSearchParams('by', 'rating')}>*/}
{/*<li classList={{ 'view-switcher__item--selected': getSearchParams().by === 'rating' }}>*/} {/* {t('Popular')}*/}
{/* <button type="button" onClick={() => changeSearchParam('by', 'rating')}>*/} {/* </button>*/}
{/* {t('Popular')}*/} {/*</li>*/}
{/* </button>*/} {/*<li classList={{ 'view-switcher__item--selected': getSearchParams().by === 'viewed' }}>*/}
{/*</li>*/} {/* <button type="button" onClick={() => changeSearchParams('by', 'viewed')}>*/}
{/*<li classList={{ 'view-switcher__item--selected': getSearchParams().by === 'viewed' }}>*/} {/* {t('Views')}*/}
{/* <button type="button" onClick={() => changeSearchParam('by', 'viewed')}>*/} {/* </button>*/}
{/* {t('Views')}*/} {/*</li>*/}
{/* </button>*/} {/*<li classList={{ 'view-switcher__item--selected': getSearchParams().by === 'commented' }}>*/}
{/*</li>*/} {/* <button type="button" onClick={() => changeSearchParams('by', 'commented')}>*/}
{/*<li classList={{ 'view-switcher__item--selected': getSearchParams().by === 'commented' }}>*/} {/* {t('Discussing')}*/}
{/* <button type="button" onClick={() => changeSearchParam('by', 'commented')}>*/} {/* </button>*/}
{/* {t('Discussing')}*/} {/*</li>*/}
{/* </button>*/} </ul>
{/*</li>*/} </div>
</ul> <div class="col-md-8">
</div> <div class="mode-switcher">
<div class="col-md-8"> {`${t('Show')} `}
<div class="mode-switcher"> <span class="mode-switcher__control">{t('All posts')}</span>
{`${t('Show')} `}
<span class="mode-switcher__control">{t('All posts')}</span>
</div>
</div>
</div> </div>
</div> </div>
</div>
</div>
<Row1 article={sortedArticles()[0]} /> <Row1 article={sortedArticles()[0]} />
<Row2 articles={sortedArticles().slice(1, 3)} isEqual={true} /> <Row2 articles={sortedArticles().slice(1, 3)} isEqual={true} />
<Beside <Beside
title={t('Topic is supported by')} title={t('Topic is supported by')}
values={authorsByTopic()[topic().slug].slice(0, 6)} values={authorsByTopic()[topic().slug].slice(0, 6)}
beside={sortedArticles()[4]} beside={sortedArticles()[4]}
wrapper={'author'} wrapper={'author'}
/> />
<ArticleCardSwiper title={selectionTitle()} slides={sortedArticles().slice(5, 11)} /> <ArticleCardSwiper title={title()} slides={sortedArticles().slice(5, 11)} />
<Beside <Beside
beside={sortedArticles()[12]} beside={sortedArticles()[12]}
title={t('Top viewed')} title={t('Top viewed')}
values={sortedArticles().slice(0, 5)} values={sortedArticles().slice(0, 5)}
wrapper={'top-article'} wrapper={'top-article'}
/> />
<Row2 articles={sortedArticles().slice(13, 15)} isEqual={true} /> <Row2 articles={sortedArticles().slice(13, 15)} isEqual={true} />
<Row1 article={sortedArticles()[15]} /> <Row1 article={sortedArticles()[15]} />
<Show when={sortedArticles().length > 15}> <Show when={sortedArticles().length > 15}>
<ArticleCardSwiper slides={sortedArticles().slice(16, 22)} /> <ArticleCardSwiper slides={sortedArticles().slice(16, 22)} />
<Row3 articles={sortedArticles().slice(23, 26)} /> <Row3 articles={sortedArticles().slice(23, 26)} />
<Row2 articles={sortedArticles().slice(26, 28)} /> <Row2 articles={sortedArticles().slice(26, 28)} />
</Show> </Show>
<For each={pages()}> <For each={pages()}>
{(page) => ( {(page) => (
<> <>
<Row3 articles={page.slice(0, 3)} /> <Row3 articles={page.slice(0, 3)} />
<Row3 articles={page.slice(3, 6)} /> <Row3 articles={page.slice(3, 6)} />
<Row3 articles={page.slice(6, 9)} /> <Row3 articles={page.slice(6, 9)} />
</> </>
)} )}
</For> </For>
<Show when={isLoadMoreButtonVisible()}> <Show when={isLoadMoreButtonVisible()}>
<p class="load-more-container"> <p class="load-more-container">
<button class="button" onClick={loadMore}> <button class="button" onClick={loadMore}>
{t('Load more')} {t('Load more')}
</button> </button>
</p> </p>
</Show>
</Show>
</Show> </Show>
</div> </div>
) )

View File

@ -0,0 +1,7 @@
.chevron {
vertical-align: top;
&.rotate {
transform: rotate(180deg);
}
}

View File

@ -0,0 +1,69 @@
import type { PopupProps } from '../Popup'
import { clsx } from 'clsx'
import { createSignal, For, Show } from 'solid-js'
import { Popup } from '../Popup'
import styles from './DropDown.module.scss'
export type Option = {
value: string | number
title: string
}
type Props<TOption> = {
class?: string
popupProps?: PopupProps
options: TOption[]
currentOption: TOption
triggerCssClass?: string
onChange: (option: TOption) => void
}
const Chevron = (props: { class?: string }) => {
return (
<svg
class={props.class}
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
>
<path d="M13.5 6L9 12L4.5 6H13.5Z" fill="#141414" />
</svg>
)
}
export const DropDown = <TOption extends Option = Option>(props: Props<TOption>) => {
const [isPopupVisible, setIsPopupVisible] = createSignal(false)
return (
<Show when={props.currentOption} keyed={true}>
<Popup
trigger={
<div class={props.triggerCssClass}>
{props.currentOption.title}{' '}
<Chevron
class={clsx(styles.chevron, {
[styles.rotate]: isPopupVisible(),
})}
/>
</div>
}
variant="tiny"
onVisibilityChange={(isVisible) => setIsPopupVisible(isVisible)}
{...props.popupProps}
>
<For each={props.options.filter((p) => p.value !== props.currentOption.value)}>
{(option) => (
<div class="link" onClick={() => props.onChange(option)}>
{option.title}
</div>
)}
</For>
</Popup>
</Show>
)
}

View File

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

View File

@ -0,0 +1,71 @@
.DropdownSelect {
position: relative;
.toggler {
@include font-size(1.3rem);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
&::after {
content: '';
display: block;
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid var(--background-color-invert);
}
&.isOpen::after {
transform: rotate(-180deg);
}
}
.listItems {
position: absolute;
right: 0;
padding: 8px 10px;
min-width: 30vw;
top: calc(100% + 2px);
border: 2px solid var(--background-color-invert);
background: var(--background-color);
margin: 0;
li {
padding: 6px;
cursor: pointer;
list-style: none;
margin: 0;
transition: 0.3s ease-in-out;
.title {
@include font-size(1.6rem);
font-weight: 700 !important;
margin-bottom: 0;
}
.description {
@include font-size(1.2rem);
color: var(--default-color);
opacity: 0.5;
margin: 6px 0;
}
&:hover {
background: var(--background-color-invert);
color: var(--default-color-invert);
.description {
opacity: 1;
color: #9fa1a7;
}
}
}
}
}

View File

@ -0,0 +1,61 @@
import { clsx } from 'clsx'
import { createSignal, For, Show } from 'solid-js'
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
import styles from './DropdownSelect.module.scss'
type FilterItem = {
title: string
description?: string
}
type Props = {
class?: string
selectItems: FilterItem[]
}
export const DropdownSelect = (props: Props) => {
const [selected, setSelected] = createSignal<FilterItem>(props.selectItems[0])
const [isDropDownVisible, setIsDropDownVisible] = createSignal(false)
const containerRef: { current: HTMLElement } = {
current: null,
}
const handleShowDropdown = () => {
setIsDropDownVisible(!isDropDownVisible())
}
useOutsideClickHandler({
containerRef,
predicate: () => isDropDownVisible(),
handler: () => setIsDropDownVisible(false),
})
return (
<div class={clsx(styles.DropdownSelect, props.class)}>
<div
class={clsx(styles.toggler, { [styles.isOpen]: isDropDownVisible() })}
onClick={handleShowDropdown}
>
<div>{selected().title}</div>
</div>
<Show when={isDropDownVisible()}>
<ul class={styles.listItems} ref={(el) => (containerRef.current = el)}>
<For each={props.selectItems}>
{(item) => (
<li>
<h3 class={styles.title}>{item.title}</h3>
<Show when={item.description}>
<p class={styles.description}>{item.description}</p>
</Show>
</li>
)}
</For>
</ul>
</Show>
</div>
)
}

View File

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

View File

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

View File

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

View File

@ -10,6 +10,9 @@
justify-content: center; justify-content: center;
z-index: 10000; z-index: 10000;
animation: 300ms fadeIn;
animation-fill-mode: forwards;
.image { .image {
max-width: 90%; max-width: 90%;
max-height: 80%; max-height: 80%;
@ -72,3 +75,28 @@
} }
} }
} }
.fadeOut {
animation: 300ms fadeOut;
animation-fill-mode: backwards;
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fadeOut {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}

View File

@ -1,5 +1,5 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createSignal } from 'solid-js' import { createMemo, createSignal, onCleanup } from 'solid-js'
import { useEscKeyDownHandler } from '../../../utils/useEscKeyDownHandler' import { useEscKeyDownHandler } from '../../../utils/useEscKeyDownHandler'
import { Icon } from '../Icon' import { Icon } from '../Icon'
@ -8,40 +8,109 @@ import styles from './Lightbox.module.scss'
type Props = { type Props = {
class?: string class?: string
imageAlt?: string
image: string image: string
onClose: () => void onClose: () => void
} }
const ZOOM_STEP = 1.08 const ZOOM_STEP = 1.08
const TRANSITION_SPEED = 300
export const Lightbox = (props: Props) => { export const Lightbox = (props: Props) => {
const [zoomLevel, setZoomLevel] = createSignal(1) const [zoomLevel, setZoomLevel] = createSignal(1)
const [translateX, setTranslateX] = createSignal(0)
const [translateY, setTranslateY] = createSignal(0)
const [transitionEnabled, setTransitionEnabled] = createSignal(false)
const lightboxRef: {
current: HTMLElement
} = {
current: null,
}
const closeLightbox = () => { const closeLightbox = () => {
props.onClose() lightboxRef.current?.classList.add(styles.fadeOut)
setTimeout(() => {
props.onClose()
}, 300)
} }
const zoomIn = (event) => { const zoomIn = (event) => {
event.stopPropagation() event.stopPropagation()
setTransitionEnabled(true)
setZoomLevel(zoomLevel() * ZOOM_STEP) setZoomLevel(zoomLevel() * ZOOM_STEP)
setTimeout(() => setTransitionEnabled(false), TRANSITION_SPEED)
} }
const zoomOut = (event) => { const zoomOut = (event) => {
event.stopPropagation() event.stopPropagation()
setTransitionEnabled(true)
setZoomLevel(zoomLevel() / ZOOM_STEP) setZoomLevel(zoomLevel() / ZOOM_STEP)
setTimeout(() => setTransitionEnabled(false), TRANSITION_SPEED)
} }
const zoomReset = (event) => { const zoomReset = (event) => {
event.stopPropagation() event.stopPropagation()
setZoomLevel(1) setZoomLevel(1)
} }
const lightboxStyle = () => ({ const handleWheelZoom = (event) => {
transform: `scale(${zoomLevel()})`, event.preventDefault()
transition: 'transform 0.3s ease',
}) let scale = zoomLevel()
scale += event.deltaY * -0.01
scale = Math.min(Math.max(0.125, scale), 4)
setZoomLevel(scale * ZOOM_STEP)
}
useEscKeyDownHandler(closeLightbox) useEscKeyDownHandler(closeLightbox)
let startX: number = 0
let startY: number = 0
let isDragging: boolean = false
const onMouseDown: (event: MouseEvent) => void = (event) => {
startX = event.clientX - translateX()
startY = event.clientY - translateY()
isDragging = true
event.preventDefault()
}
const onMouseMove: (event: MouseEvent) => void = (event) => {
if (isDragging) {
setTranslateX(event.clientX - startX)
setTranslateY(event.clientY - startY)
}
}
const onMouseUp: () => void = () => {
isDragging = false
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
onCleanup(() => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
})
const lightboxStyle = createMemo(() => ({
transform: `translate(${translateX()}px, ${translateY()}px) scale(${zoomLevel()})`,
transition: transitionEnabled() ? `transform ${TRANSITION_SPEED}ms ease-in-out` : '',
cursor: 'grab',
}))
return ( return (
<div class={clsx(styles.Lightbox, props.class)} onClick={closeLightbox}> <div
class={clsx(styles.Lightbox, props.class)}
onClick={closeLightbox}
ref={(el) => (lightboxRef.current = el)}
>
<span class={styles.close} onClick={closeLightbox}> <span class={styles.close} onClick={closeLightbox}>
<Icon name="close-white" class={styles.icon} /> <Icon name="close-white" class={styles.icon} />
</span> </span>
@ -59,9 +128,11 @@ export const Lightbox = (props: Props) => {
<img <img
class={styles.image} class={styles.image}
src={props.image} src={props.image}
style={lightboxStyle()} alt={props.imageAlt || ''}
alt={''}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onWheel={handleWheelZoom}
style={lightboxStyle()}
onMouseDown={onMouseDown}
/> />
</div> </div>
) )

View File

@ -32,7 +32,9 @@ export const Popup = (props: PopupProps) => {
useOutsideClickHandler({ useOutsideClickHandler({
containerRef, containerRef,
predicate: () => isVisible(), predicate: () => isVisible(),
handler: () => setIsVisible(false), handler: () => {
setIsVisible(false)
},
}) })
const toggle = () => setIsVisible((oldVisible) => !oldVisible) const toggle = () => setIsVisible((oldVisible) => !oldVisible)

View File

@ -5,6 +5,7 @@ import SwiperCore, { Manipulation, Navigation, Pagination } from 'swiper'
import { Shout } from '../../../graphql/schema/core.gen' import { Shout } from '../../../graphql/schema/core.gen'
import { ArticleCard } from '../../Feed/ArticleCard' import { ArticleCard } from '../../Feed/ArticleCard'
import { Icon } from '../Icon' import { Icon } from '../Icon'
import { ShowOnlyOnClient } from '../ShowOnlyOnClient'
import { SwiperRef } from './swiper' import { SwiperRef } from './swiper'
@ -25,65 +26,67 @@ export const ArticleCardSwiper = (props: Props) => {
}) })
return ( return (
<div class={clsx(styles.Swiper, styles.articleMode, styles.ArticleCardSwiper)}> <ShowOnlyOnClient>
<Show when={props.title}> <div class={clsx(styles.Swiper, styles.articleMode, styles.ArticleCardSwiper)}>
<h2 class={styles.sliderTitle}>{props.title}</h2> <Show when={props.title}>
</Show> <h2 class={styles.sliderTitle}>{props.title}</h2>
<div class={styles.container}>
<Show when={props.slides.length > 0}>
<div class={styles.holder}>
<swiper-container
ref={(el) => (mainSwipeRef.current = el)}
centered-slides={true}
observer={true}
space-between={10}
breakpoints={{
576: { spaceBetween: 20, slidesPerView: 1.5 },
992: { spaceBetween: 52, slidesPerView: 1.5 },
}}
round-lengths={true}
loop={true}
speed={800}
autoplay={{
disableOnInteraction: false,
delay: 6000,
pauseOnMouseEnter: true,
}}
>
<For each={props.slides}>
{(slide, index) => (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<swiper-slide virtual-index={index()}>
<ArticleCard
article={slide}
settings={{
additionalClass: 'swiper-slide',
isFloorImportant: true,
isWithCover: true,
nodate: true,
}}
desktopCoverSize="L"
/>
</swiper-slide>
)}
</For>
</swiper-container>
<div
class={clsx(styles.navigation, styles.prev)}
onClick={() => mainSwipeRef.current.swiper.slidePrev()}
>
<Icon name="swiper-l-arr" class={styles.icon} />
</div>
<div
class={clsx(styles.navigation, styles.next)}
onClick={() => mainSwipeRef.current.swiper.slideNext()}
>
<Icon name="swiper-r-arr" class={styles.icon} />
</div>
</div>
</Show> </Show>
<div class={styles.container}>
<Show when={props.slides.length > 0}>
<div class={styles.holder}>
<swiper-container
ref={(el) => (mainSwipeRef.current = el)}
centered-slides={true}
observer={true}
space-between={10}
breakpoints={{
576: { spaceBetween: 20, slidesPerView: 1.5 },
992: { spaceBetween: 52, slidesPerView: 1.5 },
}}
round-lengths={true}
loop={true}
speed={800}
autoplay={{
disableOnInteraction: false,
delay: 6000,
pauseOnMouseEnter: true,
}}
>
<For each={props.slides}>
{(slide, index) => (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<swiper-slide virtual-index={index()}>
<ArticleCard
article={slide}
settings={{
additionalClass: 'swiper-slide',
isFloorImportant: true,
isWithCover: true,
nodate: true,
}}
desktopCoverSize="L"
/>
</swiper-slide>
)}
</For>
</swiper-container>
<div
class={clsx(styles.navigation, styles.prev)}
onClick={() => mainSwipeRef.current.swiper.slidePrev()}
>
<Icon name="swiper-l-arr" class={styles.icon} />
</div>
<div
class={clsx(styles.navigation, styles.next)}
onClick={() => mainSwipeRef.current.swiper.slideNext()}
>
<Icon name="swiper-r-arr" class={styles.icon} />
</div>
</div>
</Show>
</div>
</div> </div>
</div> </ShowOnlyOnClient>
) )
} }

View File

@ -7,6 +7,7 @@ import { MediaItem } from '../../../pages/types'
import { getImageUrl } from '../../../utils/getImageUrl' import { getImageUrl } from '../../../utils/getImageUrl'
import { Icon } from '../Icon' import { Icon } from '../Icon'
import { Image } from '../Image' import { Image } from '../Image'
import { Lightbox } from '../Lightbox'
import { SwiperRef } from './swiper' import { SwiperRef } from './swiper'
@ -23,12 +24,14 @@ type Props = {
const MIN_WIDTH = 540 const MIN_WIDTH = 540
export const ImageSwiper = (props: Props) => { export const ImageSwiper = (props: Props) => {
const [slideIndex, setSlideIndex] = createSignal(0)
const [isMobileView, setIsMobileView] = createSignal(false)
const mainSwipeRef: { current: SwiperRef } = { current: null } const mainSwipeRef: { current: SwiperRef } = { current: null }
const thumbSwipeRef: { current: SwiperRef } = { current: null } const thumbSwipeRef: { current: SwiperRef } = { current: null }
const swiperMainContainer: { current: HTMLDivElement } = { current: null } const swiperMainContainer: { current: HTMLDivElement } = { current: null }
const [slideIndex, setSlideIndex] = createSignal(0)
const [isMobileView, setIsMobileView] = createSignal(false)
const [selectedImage, setSelectedImage] = createSignal('')
const handleSlideChange = () => { const handleSlideChange = () => {
thumbSwipeRef.current.swiper.slideTo(mainSwipeRef.current.swiper.activeIndex) thumbSwipeRef.current.swiper.slideTo(mainSwipeRef.current.swiper.activeIndex)
setSlideIndex(mainSwipeRef.current.swiper.activeIndex) setSlideIndex(mainSwipeRef.current.swiper.activeIndex)
@ -76,6 +79,19 @@ export const ImageSwiper = (props: Props) => {
}) })
}) })
const openLightbox = (image) => {
setSelectedImage(image)
}
const handleLightboxClose = () => {
setSelectedImage()
}
const handleImageClick = (event) => {
const src = event.target.src
openLightbox(getImageUrl(src))
}
return ( return (
<div class={clsx(styles.Swiper, styles.articleMode, { [styles.mobileView]: isMobileView() })}> <div class={clsx(styles.Swiper, styles.articleMode, { [styles.mobileView]: isMobileView() })}>
<div class={styles.container} ref={(el) => (swiperMainContainer.current = el)}> <div class={styles.container} ref={(el) => (swiperMainContainer.current = el)}>
@ -94,7 +110,7 @@ export const ImageSwiper = (props: Props) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
<swiper-slide lazy="true" virtual-index={index()}> <swiper-slide lazy="true" virtual-index={index()}>
<div class={styles.image}> <div class={styles.image} onClick={handleImageClick}>
<Image src={slide.url} alt={slide.title} width={800} /> <Image src={slide.url} alt={slide.title} width={800} />
</div> </div>
</swiper-slide> </swiper-slide>
@ -178,6 +194,10 @@ export const ImageSwiper = (props: Props) => {
<div class={styles.body} innerHTML={props.images[slideIndex()].body} /> <div class={styles.body} innerHTML={props.images[slideIndex()].body} />
</Show> </Show>
</div> </div>
<Show when={selectedImage()}>
<Lightbox image={selectedImage()} onClose={handleLightboxClose} />
</Show>
</div> </div>
) )
} }

View File

@ -188,6 +188,7 @@
img { img {
max-height: 100%; max-height: 100%;
width: auto; width: auto;
cursor: zoom-in;
} }
} }

View File

@ -0,0 +1,50 @@
.UserSearch {
.searchHeader {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
width: 100%;
gap: 1rem;
}
.field {
border-bottom: 2px solid var(--background-color-invert);
display: flex;
flex-direction: row;
flex-wrap: nowrap;
padding: 4px 0;
align-items: center;
width: 100%;
}
.input {
@include font-size(1.5rem);
border: none;
padding: 0;
margin: 0;
flex: 1;
&::placeholder {
color: #404040;
}
&:focus {
outline: none;
}
}
.authors {
height: 400px;
overflow: auto;
padding: 1rem 0;
}
.teaser {
min-height: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
}

View File

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

View File

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

View File

@ -32,7 +32,7 @@ export function useLocalize() {
export const LocalizeProvider = (props: { children: JSX.Element }) => { export const LocalizeProvider = (props: { children: JSX.Element }) => {
const [lang, setLang] = createSignal<Language>(i18next.language === 'en' ? 'en' : 'ru') const [lang, setLang] = createSignal<Language>(i18next.language === 'en' ? 'en' : 'ru')
const { searchParams, changeSearchParam } = useRouter<{ const { searchParams, changeSearchParams } = useRouter<{
lng: string lng: string
}>() }>()
@ -46,7 +46,7 @@ export const LocalizeProvider = (props: { children: JSX.Element }) => {
changeLanguage(lng) changeLanguage(lng)
setLang(lng) setLang(lng)
Cookie.set('lng', lng) Cookie.set('lng', lng)
changeSearchParam({ lng: null }, true) changeSearchParams({ lng: null }, true)
}) })
const formatTime = (date: Date, options: Intl.DateTimeFormatOptions = {}) => { const formatTime = (date: Date, options: Intl.DateTimeFormatOptions = {}) => {

View File

@ -0,0 +1,62 @@
import { gql } from '@urql/core'
export default gql`
query LoadRandomTopicShoutsQuery($limit: Int!) {
loadRandomTopicShouts(limit: $limit) {
topic {
id
title
body
slug
pic
# community
stat {
shouts
authors
followers
# viewed
}
}
shouts {
id
title
lead
description
subtitle
slug
layout
cover
lead
# community
mainTopic
topics {
id
title
body
slug
stat {
shouts
authors
followers
}
}
authors {
id
name
slug
userpic
createdAt
bio
}
createdAt
publishedAt
stat {
viewed
reacted
rating
commented
}
}
}
}
`

0
src/graphql/types.gen.ts Normal file
View File

View File

@ -12,7 +12,10 @@ import { loadMyFeed, loadShouts, resetSortedArticles } from '../stores/zine/arti
const handleFeedLoadShouts = (options: LoadShoutsOptions) => { const handleFeedLoadShouts = (options: LoadShoutsOptions) => {
return loadShouts({ return loadShouts({
...options, ...options,
filters: { published: false }, filters: {
published: false,
...options.filters,
},
}) })
} }

View File

@ -4,6 +4,7 @@ import type { PageContext } from '../renderer/types'
import { render } from 'vike/abort' import { render } from 'vike/abort'
import { apiClient } from '../graphql/client/core' import { apiClient } from '../graphql/client/core'
import { PRERENDERED_ARTICLES_COUNT } from '../components/Views/Topic'
export const onBeforeRender = async (pageContext: PageContext) => { export const onBeforeRender = async (pageContext: PageContext) => {
const { slug } = pageContext.routeParams const { slug } = pageContext.routeParams
@ -14,7 +15,12 @@ export const onBeforeRender = async (pageContext: PageContext) => {
throw render(404) throw render(404)
} }
const pageProps: PageProps = { topic, seo: { title: topic.title } } const topicShouts = await apiClient.getShouts({
filters: { topic: topic.slug },
limit: PRERENDERED_ARTICLES_COUNT,
})
const pageProps: PageProps = { topic, topicShouts, seo: { title: topic.title } }
return { return {
pageContext: { pageContext: {

View File

@ -1,7 +1,8 @@
import type { PageProps } from './types' import type { PageProps } from './types'
import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js' import { createEffect, createMemo, createSignal, on, onCleanup, onMount, Show } from 'solid-js'
import { Loading } from '../components/_shared/Loading'
import { PageLayout } from '../components/_shared/PageLayout' import { PageLayout } from '../components/_shared/PageLayout'
import { PRERENDERED_ARTICLES_COUNT, TopicView } from '../components/Views/Topic' import { PRERENDERED_ARTICLES_COUNT, TopicView } from '../components/Views/Topic'
import { ReactionsProvider } from '../context/reactions' import { ReactionsProvider } from '../context/reactions'
@ -16,7 +17,7 @@ export const TopicPage = (props: PageProps) => {
const [isLoaded, setIsLoaded] = createSignal( const [isLoaded, setIsLoaded] = createSignal(
Boolean(props.topicShouts) && Boolean(props.topic) && props.topic.slug === slug(), Boolean(props.topicShouts) && Boolean(props.topic) && props.topic.slug === slug(),
) )
const [pageTitle, setPageTitle] = createSignal<string>()
const preload = () => const preload = () =>
Promise.all([ Promise.all([
loadShouts({ filters: { topic: slug() }, limit: PRERENDERED_ARTICLES_COUNT, offset: 0 }), loadShouts({ filters: { topic: slug() }, limit: PRERENDERED_ARTICLES_COUNT, offset: 0 }),
@ -51,15 +52,15 @@ export const TopicPage = (props: PageProps) => {
const usePrerenderedData = props.topic?.slug === slug() const usePrerenderedData = props.topic?.slug === slug()
return ( return (
<PageLayout title={pageTitle()}> <PageLayout title={props.seo.title}>
<ReactionsProvider> <ReactionsProvider>
<TopicView <Show when={isLoaded()} fallback={<Loading />}>
title={(title) => setPageTitle(title)} <TopicView
isLoaded={isLoaded()} topic={usePrerenderedData ? props.topic : null}
topic={usePrerenderedData ? props.topic : null} shouts={usePrerenderedData ? props.topicShouts : null}
shouts={usePrerenderedData ? props.topicShouts : null} topicSlug={slug()}
topicSlug={slug()} />
/> </Show>
</ReactionsProvider> </ReactionsProvider>
</PageLayout> </PageLayout>
) )

View File

@ -138,7 +138,7 @@ export const useRouter = <TSearchParams extends Record<string, string> = Record<
const page = useStore(routerStore) const page = useStore(routerStore)
const searchParams = useStore(searchParamsStore) as unknown as Accessor<TSearchParams> const searchParams = useStore(searchParamsStore) as unknown as Accessor<TSearchParams>
const changeSearchParam = (newValues: Partial<TSearchParams>, replace = false) => { const changeSearchParams = (newValues: Partial<TSearchParams>, replace = false) => {
const newSearchParams = { ...searchParamsStore.get() } const newSearchParams = { ...searchParamsStore.get() }
Object.keys(newValues).forEach((key) => { Object.keys(newValues).forEach((key) => {
@ -155,6 +155,6 @@ export const useRouter = <TSearchParams extends Record<string, string> = Record<
return { return {
page, page,
searchParams, searchParams,
changeSearchParam, changeSearchParams,
} }
} }

View File

@ -24,6 +24,7 @@ export type ModalType =
| 'followers' | 'followers'
| 'following' | 'following'
| 'search' | 'search'
| 'inviteCoAuthors'
export const MODALS: Record<ModalType, ModalType> = { export const MODALS: Record<ModalType, ModalType> = {
auth: 'auth', auth: 'auth',
@ -39,18 +40,19 @@ export const MODALS: Record<ModalType, ModalType> = {
editorInsertLink: 'editorInsertLink', editorInsertLink: 'editorInsertLink',
followers: 'followers', followers: 'followers',
following: 'following', following: 'following',
inviteCoAuthors: 'inviteCoAuthors',
search: 'search', search: 'search',
} }
const [modal, setModal] = createSignal<ModalType>(null) const [modal, setModal] = createSignal<ModalType>(null)
const { searchParams, changeSearchParam } = useRouter< const { searchParams, changeSearchParams } = useRouter<
AuthModalSearchParams & ConfirmEmailSearchParams & RootSearchParams AuthModalSearchParams & ConfirmEmailSearchParams & RootSearchParams
>() >()
export const showModal = (modalType: ModalType, modalSource?: AuthModalSource) => { export const showModal = (modalType: ModalType, modalSource?: AuthModalSource) => {
if (modalSource) { if (modalSource) {
changeSearchParam({ changeSearchParams({
source: modalSource, source: modalSource,
}) })
} }
@ -72,7 +74,7 @@ export const hideModal = () => {
newSearchParams.mode = null newSearchParams.mode = null
} }
changeSearchParam(newSearchParams, true) changeSearchParams(newSearchParams, true)
setModal(null) setModal(null)
} }

View File

@ -51,21 +51,6 @@ const articlesByTopic = createLazyMemo(() => {
) )
}) })
const articlesByLayout = createLazyMemo(() => {
return Object.values(articleEntities()).reduce(
(acc, article) => {
if (!acc[article.layout]) {
acc[article.layout] = []
}
acc[article.layout].push(article)
return acc
},
{} as { [layout: string]: Shout[] },
)
})
const topViewedArticles = createLazyMemo(() => { const topViewedArticles = createLazyMemo(() => {
const result = Object.values(articleEntities()) const result = Object.values(articleEntities())
result.sort(byStat('viewed')) result.sort(byStat('viewed'))
@ -240,7 +225,6 @@ export const useArticlesStore = (initialState: InitialState = {}) => {
articleEntities, articleEntities,
sortedArticles, sortedArticles,
articlesByAuthor, articlesByAuthor,
articlesByLayout,
articlesByTopic, articlesByTopic,
topMonthArticles, topMonthArticles,
topArticles, topArticles,

View File

@ -204,7 +204,7 @@ a:hover,
a:visited, a:visited,
a:link, a:link,
.link { .link {
border-bottom: 1px solid rgb(0 0 0 / 30%); border-bottom: 2px solid rgb(0 0 0 / 30%);
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
} }
@ -624,6 +624,10 @@ figure {
margin-bottom: 0.6em; margin-bottom: 0.6em;
white-space: nowrap; white-space: nowrap;
.link {
border-bottom: none;
}
&:last-child { &:last-child {
margin-right: 0; margin-right: 0;
} }
@ -645,9 +649,10 @@ figure {
} }
a, a,
.link,
.linkReplacement, .linkReplacement,
button { button {
border-bottom: 2px solid transparent; border-bottom: 1px solid transparent;
color: var(--link-color); color: var(--link-color);
cursor: pointer; cursor: pointer;
font-weight: inherit; font-weight: inherit;
@ -662,6 +667,7 @@ figure {
font-weight: bold; font-weight: bold;
a, a,
.link,
.linkReplacement, .linkReplacement,
button { button {
border-bottom: 2px solid #000; border-bottom: 2px solid #000;

0
src/utils/apiClient.ts Normal file
View File